diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f6db8e7598..6b5b5723eb 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -80,6 +80,8 @@ import { } from '../pi-tui-runner.js'; import { AUTO_RECAP_IDLE_MS } from '../session-recap.js'; import { BUSY_SPINNER_FRAMES } from '../tui-attention.js'; +import { stripAnsi } from '../tui-ansi.js'; +import { TUI_FULLSCREEN_ENV } from '../tui-fullscreen.js'; import { EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS } from '../pi-transcript.js'; import type { TuiMcpAction, TuiMcpManagement } from '../tui-mcp-control.js'; import { @@ -9959,3 +9961,148 @@ async function runFatalExitProbe( clearTimeout(killTimer); return { code, signal, stdout, stderr }; } + +describe('fullscreen TUI trial (#4136)', () => { + const ALT_SCREEN_ENTER = '\x1b[?1049h'; + + /** A history tall enough to overflow a 24-row terminal many times over. */ + function tallHistory(): StoredMessage[] { + const messages: StoredMessage[] = []; + for (let index = 0; index < 24; index += 1) { + messages.push( + storedUserMessage( + `u${index}`, + `turn-${index}`, + `HISTORY-QUESTION-${index}: ${'detail '.repeat(8)}`, + ), + storedAssistantMessage( + `a${index}`, + `turn-${index}`, + `HISTORY-ANSWER-${index}: ${'result '.repeat(14)}`, + ), + ); + } + return messages; + } + + function screenLines(terminal: FakeTerminal): string[] { + return terminal + .screenOutput() + .split(/\r?\n/) + .map((line) => stripAnsi(line)); + } + + test('wheel scrolling keeps the composer anchored and typing re-anchors the transcript', async () => { + const terminal = new FakeTerminal(80, 24); + const driver = new SlashCommandDriver( + [fakeSessionSummary('session-2', '/repo')], + new Map([['session-2', tallHistory()]]), + ); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + tuiFullscreen: true, + resumeSessionId: 'session-2', + }); + + await waitFor(() => screenLines(terminal).join('\n').includes('HISTORY-ANSWER-23')); + // The composer is anchored to the screen bottom, status line last. + let lines = screenLines(terminal); + assert.match(lines.at(-1) ?? '', /claude-sonnet-4-5/); + assert.match(stripAnsi(lines.at(-2) ?? ''), /^─+$/); + // The transcript follows the newest output; the top of history is + // windowed out of the viewport instead of pushed into scrollback. + assert.equal(lines.join('\n').includes('HISTORY-QUESTION-0'), false); + + // The mouse wheel scrolls the application-owned viewport up. + for (let index = 0; index < 150; index += 1) { + terminal.input('\x1b[<64;40;12M'); + } + await waitFor(() => screenLines(terminal).join('\n').includes('HISTORY-QUESTION-0')); + lines = screenLines(terminal); + // The reading position moved up; the composer and status line did not. + assert.match(lines.at(-1) ?? '', /claude-sonnet-4-5/); + assert.match(stripAnsi(lines.at(-2) ?? ''), /^─+$/); + + // Typing re-anchors to the newest output (the trial's chosen answer to + // issue #4136's "what happens when the user types while reading older + // content?"): the composer is never blind at the bottom of the screen. + terminal.input('x'); + await waitFor(() => !screenLines(terminal).join('\n').includes('HISTORY-QUESTION-0')); + lines = screenLines(terminal); + assert.match(lines.join('\n'), /HISTORY-ANSWER-23/); + assert.match(lines.at(-1) ?? '', /claude-sonnet-4-5/); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('the trial follows the build channel and the MAKA_TUI_FULLSCREEN override', async () => { + const runsFullscreen = async (input: { + buildVersion?: string; + override?: string; + }): Promise => { + const terminal = new FakeTerminal(80, 24); + const driver = new SlashCommandDriver(); + const previousOverride = process.env[TUI_FULLSCREEN_ENV]; + if (input.override === undefined) delete process.env[TUI_FULLSCREEN_ENV]; + else process.env[TUI_FULLSCREEN_ENV] = input.override; + try { + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + ...(input.buildVersion !== undefined ? { buildVersion: input.buildVersion } : {}), + }); + await waitForTuiPaint(terminal); + const fullscreen = terminal.output().includes(ALT_SCREEN_ENTER); + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + return fullscreen; + } finally { + if (previousOverride === undefined) delete process.env[TUI_FULLSCREEN_ENV]; + else process.env[TUI_FULLSCREEN_ENV] = previousOverride; + } + }; + + assert.equal( + await runsFullscreen({ buildVersion: '0.2.0' }), + false, + 'release builds stay on the main screen', + ); + assert.equal( + await runsFullscreen({ buildVersion: '0.2.0-dev.42.20260829' }), + true, + 'nightly builds opt into the fullscreen trial', + ); + assert.equal( + await runsFullscreen({ buildVersion: '0.2.0', override: '1' }), + true, + 'the override opts a release build in', + ); + assert.equal( + await runsFullscreen({ buildVersion: '0.2.0-dev.42.20260829', override: '0' }), + false, + 'the override opts a nightly build out', + ); + }); +}); diff --git a/packages/cli/src/__tests__/tui-fullscreen.test.ts b/packages/cli/src/__tests__/tui-fullscreen.test.ts new file mode 100644 index 0000000000..b3b81036b8 --- /dev/null +++ b/packages/cli/src/__tests__/tui-fullscreen.test.ts @@ -0,0 +1,548 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import type { spawn as SpawnFn } from 'node:child_process'; +// Deep import (pi-tui does not re-export it): the layout frame is what +// TuiAltScreen's doRender builds every frame, so rendering one here exercises +// the exact composition path the fullscreen trial uses. +import { renderLayoutFrame } from '@earendil-works/pi-tui/dist/layout.js'; +import { VStack, type Component, type Terminal } from '@earendil-works/pi-tui'; +import { createMakaPiTranscriptState } from '../pi-transcript.js'; +import { + MakaActivityStripComponent, + MakaFullscreenChromeComponent, + MakaPendingQueueComponent, + MakaStatusLineComponent, + MakaTranscriptComponent, + MakaTranscriptDocumentComponent, + MakaTranscriptScrollView, +} from '../pi-tui-layout.js'; +import { + isOpenableExternalUrl, + isNightlyPackageVersion, + openExternalUrl, + renderUnreadIndicator, + resolveTuiFullscreen, + UnreadOutputCounter, + type UnreadOutputFeed, + type TranscriptWindowSnapshot, +} from '../tui-fullscreen.js'; +import { stripAnsi } from '../tui-ansi.js'; + +function fakeTerminal(rows: number): Terminal { + return { rows, columns: 80 } as Terminal; +} + +interface RecordingEditor extends Component { + readonly viewportRowsHistory: number[]; + showingAutocomplete: boolean; + setViewportRows(rows: number): void; + isShowingAutocomplete(): boolean; + minimumViewportRows(): number; +} + +function recordingEditor(lines: string[] = ['╭─╮', '│ │', '╰─╯']): RecordingEditor { + return { + showingAutocomplete: false, + viewportRowsHistory: [], + invalidate() {}, + render(): string[] { + return [...lines]; + }, + setViewportRows(rows: number): void { + this.viewportRowsHistory.push(rows); + }, + isShowingAutocomplete(): boolean { + return this.showingAutocomplete; + }, + minimumViewportRows(): number { + return 4; + }, + }; +} + +function snapshot(overrides: Partial = {}): TranscriptWindowSnapshot { + return { followingEnd: true, documentLines: 10, ...overrides }; +} + +/** An in-memory UnreadOutputFeed, mirroring the runner's scroll-view wiring. */ +function memoryFeed(initial = 0): UnreadOutputFeed & { set(value: number): void } { + let value = initial; + return { + current: () => value, + present: (unreadLines) => { + value = unreadLines; + }, + set: (next) => { + value = next; + }, + }; +} + +describe('fullscreen TUI trial switch', () => { + test('an explicit setting wins over everything else', () => { + assert.equal( + resolveTuiFullscreen({ + setting: false, + override: '1', + packageVersion: '0.2.0-dev.42.20260829', + }), + false, + ); + assert.equal( + resolveTuiFullscreen({ setting: true, override: '0', packageVersion: '0.2.0' }), + true, + ); + }); + + test('the environment override opts a release build in and a nightly out', () => { + assert.equal(resolveTuiFullscreen({ override: '1', packageVersion: '0.2.0' }), true); + assert.equal(resolveTuiFullscreen({ override: 'true', packageVersion: '0.2.0' }), true); + assert.equal( + resolveTuiFullscreen({ override: '0', packageVersion: '0.2.0-dev.42.20260829' }), + false, + ); + assert.equal( + resolveTuiFullscreen({ override: 'false', packageVersion: '0.2.0-dev.42.20260829' }), + false, + ); + }); + + test('a malformed override falls through to the channel default', () => { + assert.equal(resolveTuiFullscreen({ override: 'yes', packageVersion: '0.2.0' }), false); + assert.equal( + resolveTuiFullscreen({ override: 'yes', packageVersion: '0.2.0-dev.42.20260829' }), + true, + ); + assert.equal(resolveTuiFullscreen({ override: '' }), false); + }); + + test('the default follows the build channel', () => { + assert.equal(resolveTuiFullscreen({}), false); + assert.equal(resolveTuiFullscreen({ packageVersion: '0.2.0' }), false); + assert.equal(resolveTuiFullscreen({ packageVersion: '0.2.0-dev.42.20260829' }), true); + }); + + test('nightly identity detection matches the Product Nightly version format', () => { + assert.equal(isNightlyPackageVersion('0.2.0-dev.1.20260101'), true); + assert.equal(isNightlyPackageVersion('0.2.0-dev.999.20261231'), true); + assert.equal(isNightlyPackageVersion('0.2.0'), false); + assert.equal(isNightlyPackageVersion('0.2.0-dev.0.20260101'), false); + assert.equal(isNightlyPackageVersion('0.2.0-dev.42.2026010'), false); + assert.equal(isNightlyPackageVersion(undefined), false); + assert.equal(isNightlyPackageVersion(''), false); + }); +}); + +describe('unread output counter', () => { + test('stays at zero while following the end', () => { + const counter = new UnreadOutputCounter(); + assert.equal(counter.update(snapshot({ documentLines: 10 })), 0); + assert.equal(counter.update(snapshot({ documentLines: 25 })), 0); + }); + + test('accumulates growth while the user is scrolled away', () => { + const counter = new UnreadOutputCounter(); + assert.equal(counter.update(snapshot({ followingEnd: true, documentLines: 40 })), 0); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 40 })), 0); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 45 })), 5); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 52 })), 12); + }); + + test('returning to the bottom clears the count', () => { + const counter = new UnreadOutputCounter(); + counter.update(snapshot({ followingEnd: true, documentLines: 40 })); + counter.update(snapshot({ followingEnd: false, documentLines: 40 })); + counter.update(snapshot({ followingEnd: false, documentLines: 50 })); + assert.equal(counter.update(snapshot({ followingEnd: true, documentLines: 50 })), 0); + // And new growth afterwards is counted from the new baseline. + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 53 })), 3); + }); + + test('a shrinking document never manufactures unread lines', () => { + const counter = new UnreadOutputCounter(); + counter.update(snapshot({ followingEnd: true, documentLines: 40 })); + counter.update(snapshot({ followingEnd: false, documentLines: 40 })); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 30 })), 0); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 45 })), 15); + }); + + test('the first frame away from the bottom has no baseline to count from', () => { + const counter = new UnreadOutputCounter(); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 40 })), 0); + }); + + test('reset discards the accumulated count and baseline', () => { + const counter = new UnreadOutputCounter(); + counter.update(snapshot({ followingEnd: true, documentLines: 40 })); + counter.update(snapshot({ followingEnd: false, documentLines: 40 })); + counter.update(snapshot({ followingEnd: false, documentLines: 50 })); + counter.reset(); + assert.equal(counter.update(snapshot({ followingEnd: false, documentLines: 55 })), 0); + }); +}); + +describe('unread indicator rendering', () => { + test('renders nothing while there is no unread output', () => { + assert.deepEqual( + renderUnreadIndicator(0, (text) => text), + [], + ); + }); + + test('renders a singular hint for one new line', () => { + assert.deepEqual( + renderUnreadIndicator(1, (text) => text), + ['↓ 1 new line — End to jump to latest'], + ); + }); + + test('renders a plural hint and the jump key for more', () => { + const [line] = renderUnreadIndicator(14, (text) => text); + assert.equal(line, '↓ 14 new lines — End to jump to latest'); + }); +}); + +describe('fullscreen chrome component', () => { + function buildChrome( + rows: number, + feed: UnreadOutputFeed, + metadataOverrides: Record = {}, + ) { + const state = createMakaPiTranscriptState(); + const metadata = () => ({ + title: 'Maka', + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + ...metadataOverrides, + }); + const editor = recordingEditor(); + const chrome = new MakaFullscreenChromeComponent( + state, + new MakaActivityStripComponent(metadata), + new MakaPendingQueueComponent(state), + editor, + new MakaStatusLineComponent(metadata), + fakeTerminal(rows), + feed, + (text) => text, + ); + return { state, chrome, editor }; + } + + test('pins the transcript geometry to the app-owned viewport', () => { + const { state, chrome } = buildChrome(24, memoryFeed()); + chrome.render(80); + assert.equal(state.renderGeometry.viewportTop, 0); + }); + + test('renders the unread indicator only while lines accumulated away from the bottom', () => { + const feed = memoryFeed(); + const { chrome } = buildChrome(24, feed); + assert.deepEqual( + chrome.render(80).filter((line) => line.includes('new lines')), + [], + ); + feed.set(10); + const lines = chrome.render(80); + assert.equal( + lines.some((line) => line.includes('↓ 10 new lines — End to jump to latest')), + true, + ); + }); + + test('reserves a transcript row and the chrome rows when sizing the editor', () => { + const { chrome, editor } = buildChrome(24, memoryFeed()); + chrome.render(80); + // Editor budget = rows − status (1) − transcript minimum (1). The editor + // renders 3 border/content rows, activity strip 0, pending 0, indicator 0 — + // so the transcript keeps its row and nothing overflows. + assert.equal(editor.viewportRowsHistory.at(-1), 22); + }); + + test('keeps the editor budget at its minimum when the autocomplete is open on a short terminal', () => { + const { chrome, editor } = buildChrome(8, memoryFeed()); + editor.showingAutocomplete = true; + const lines = chrome.render(80); + // rows 8 − transcript 1 − status 1 = 6 for indicator+activity+pending+editor; + // the autocomplete trims so the editor never needs more than its minimum. + assert.ok(editor.viewportRowsHistory.at(-1)! >= editor.minimumViewportRows()); + assert.ok(lines.length <= 8); + }); + + test('keeps a blank separator between the transcript and a running activity strip', () => { + const feed = memoryFeed(); + const { chrome } = buildChrome(24, feed, { turnElapsedMs: 5_000 }); + const lines = chrome.render(80); + const stripIndex = lines.findIndex((line) => stripAnsi(line).startsWith('Working…')); + assert.ok(stripIndex >= 1, 'expected the activity strip in the chrome output'); + assert.equal(lines[stripIndex - 1], ''); + }); +}); + +describe('fullscreen layout frame', () => { + const ROWS = 10; + + interface FrameHarness { + state: ReturnType; + scrollView: MakaTranscriptScrollView; + document: MakaTranscriptDocumentComponent; + /** Renders one frame exactly the way TuiAltScreen.doRender does. */ + render(): ReturnType; + /** Plain-text lines of a freshly rendered frame. */ + plainLines(): string[]; + /** + * Catch-up requests raised during the most recent frame render. Scroll + * gestures between frames also fire pi-tui's persisted request callback; + * only in-frame requests come from the unread convergence. + */ + frameCatchUps: number; + addEntries(count: number): void; + documentLines(): number; + } + + function build(): FrameHarness { + const state = createMakaPiTranscriptState(); + const metadata = () => ({ + title: 'Maka', + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + }); + const transcript = new MakaTranscriptComponent(state, metadata); + const document = new MakaTranscriptDocumentComponent(transcript); + const scrollView = new MakaTranscriptScrollView(document, { + follow: 'end', + primary: true, + overscroll: 'chain', + scrollbar: 'hidden', + }); + const editor = recordingEditor(); + const chrome = new MakaFullscreenChromeComponent( + state, + new MakaActivityStripComponent(metadata), + new MakaPendingQueueComponent(state), + editor, + new MakaStatusLineComponent(metadata), + fakeTerminal(ROWS), + { + current: () => scrollView.computedUnread, + present: (unreadLines) => { + scrollView.presentedUnread = unreadLines; + }, + }, + (text) => text, + ); + const root = new VStack([ + { component: scrollView, basis: 0, grow: 1, minSize: 1 }, + { component: chrome, basis: 'auto', shrink: 1, minSize: 1 }, + ]); + const harness: FrameHarness = { + state, + scrollView, + document, + frameCatchUps: 0, + render: () => { + // Count only requests raised inside this frame's layout walk — the + // unread convergence. Scroll gestures between frames also fire + // pi-tui's persisted request callback; those are not catch-ups. + let inFrame = 0; + const frame = renderLayoutFrame(root, 80, ROWS, () => { + inFrame += 1; + }); + harness.frameCatchUps = inFrame; + return frame; + }, + plainLines: () => harness.render().lines.map((line) => stripAnsi(line)), + addEntries: (count) => { + for (let index = 0; index < count; index += 1) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: `HISTORY-ENTRY-${state.entries.length}-${'x'.repeat(40)}`, + }); + } + }, + documentLines: () => document.documentLines, + }; + return harness; + } + + test('keeps the composer anchored at the bottom of the viewport', () => { + const harness = build(); + harness.addEntries(6); + const lines = harness.plainLines(); + assert.equal(lines.length, ROWS); + assert.match(lines.at(-1) ?? '', /claude-sonnet-4-5/); + // The editor's bottom border sits directly above the status line — the + // composer is anchored to the screen bottom regardless of transcript size. + assert.match(lines.at(-2) ?? '', /╰/); + }); + + test('follows the newest output while at the bottom', () => { + const harness = build(); + harness.addEntries(30); + harness.render(); + const firstFrameDocument = harness.documentLines(); + harness.addEntries(3); + harness.render(); + assert.ok(harness.documentLines() > firstFrameDocument); + assert.equal(harness.scrollView.isFollowingEnd, true); + // The transcript window above the chrome shows the newest entry. + const lines = harness.plainLines(); + assert.match(lines.join('\n'), /HISTORY-ENTRY-32/); + }); + + test('preserves the reading position when content grows while scrolled away', () => { + const harness = build(); + harness.addEntries(40); + harness.render(); + harness.scrollView.scrollBy(-6); + harness.render(); + const scrollTop = harness.scrollView.scrollTop; + const topLineBefore = harness.plainLines()[0]; + harness.addEntries(5); + harness.render(); + assert.equal(harness.scrollView.scrollTop, scrollTop); + assert.equal(harness.scrollView.isFollowingEnd, false); + assert.equal(harness.plainLines()[0], topLineBefore); + assert.ok(scrollTop > 0); + }); + + test('counts lines appended while scrolled away, settling on the catch-up frame', () => { + const harness = build(); + harness.addEntries(40); + harness.plainLines(); + harness.scrollView.scrollBy(-6); + harness.plainLines(); + const baseline = harness.documentLines(); + harness.addEntries(5); + // First frame after growth: the chrome was measured before the scroll view + // laid out, so it still renders the previous count — and the scroll view + // schedules the catch-up frame. + assert.doesNotMatch(harness.plainLines().join('\n'), /new lines — End to jump to latest/); + assert.equal(harness.frameCatchUps, 1); + const expected = harness.documentLines() - baseline; + // Catch-up frame: the indicator appears with the exact appended-line count. + assert.match( + harness.plainLines().join('\n'), + new RegExp(`↓ ${expected} new lines — End to jump to latest`), + ); + assert.equal(harness.frameCatchUps, 0, 'a settled count must not request further frames'); + }); + + test('jumping back to the bottom clears the indicator on the catch-up frame', () => { + const harness = build(); + harness.addEntries(40); + harness.plainLines(); + harness.scrollView.scrollBy(-6); + harness.addEntries(5); + harness.plainLines(); + harness.plainLines(); + assert.match(harness.plainLines().join('\n'), /new lines — End to jump to latest/); + // The End-key path: scroll view returns to follow-end… + harness.scrollView.scrollToEnd(); + // …the still-rendered count lags one frame… + assert.match(harness.plainLines().join('\n'), /new lines — End to jump to latest/); + assert.equal(harness.frameCatchUps, 1); + // …and the catch-up frame clears it. + assert.doesNotMatch(harness.plainLines().join('\n'), /new lines — End to jump to latest/); + assert.equal(harness.frameCatchUps, 0); + }); +}); + +describe('external URL opener hardening', () => { + interface RecordedSpawn { + command: string; + args: readonly string[]; + } + + function recordingSpawn(): { calls: RecordedSpawn[]; spawn: typeof SpawnFn } { + const calls: RecordedSpawn[] = []; + const spawn = ((command: string, args: readonly string[], _options?: SpawnOptions) => { + calls.push({ command, args }); + return { unref() {} } as unknown as ChildProcess; + }) as unknown as typeof SpawnFn; + return { calls, spawn }; + } + + test('opens web and mail targets with the platform opener', () => { + assert.equal(isOpenableExternalUrl('https://apache.org'), true); + assert.equal(isOpenableExternalUrl('http://localhost:8080/?x=1'), true); + assert.equal(isOpenableExternalUrl('mailto:someone@example.com'), true); + // `URL` normalizes the scheme to lowercase, so a hostile casing cannot + // smuggle a scheme past the allowlist either. + assert.equal(isOpenableExternalUrl('HTTPS://APACHE.ORG'), true); + }); + + test('rejects every non-allowlisted scheme without spawning an opener', () => { + const rejected = [ + 'file:///C:/Windows/System32/calc.exe', + 'javascript:alert(1)', + 'ftp://example.com/pub', + 'calc://payload', + 'ms-msdt:id', + '\\\\server\\share\\payload', + 'not a url', + '', + 'https://example.com trailing text', + ]; + for (const platform of ['win32', 'darwin', 'linux'] as const) { + for (const url of rejected) { + const { calls, spawn } = recordingSpawn(); + openExternalUrl(url, platform, spawn); + assert.deepEqual(calls, [], `expected no opener for ${JSON.stringify(url)} on ${platform}`); + } + } + assert.equal(isOpenableExternalUrl('file:///etc/passwd'), false); + }); + + test('never routes a URL through cmd.exe, whatever metacharacters it carries', () => { + const hostile = [ + 'https://example.com/?x=1&calc.exe', + 'https://example.com/?x=1|calc.exe', + 'https://example.com/?x=%PATH%', + 'https://example.com/?q="quoted"&x=1', + ]; + for (const url of hostile) { + const { calls, spawn } = recordingSpawn(); + openExternalUrl(url, 'win32', spawn); + assert.deepEqual( + calls.map((call) => call.command), + ['rundll32'], + `expected the shell-free rundll32 opener for ${JSON.stringify(url)}`, + ); + assert.deepEqual(calls[0]?.args, ['url.dll,FileProtocolHandler', url]); + } + }); + + test('passes macOS and Linux targets as plain argv elements', () => { + const { calls, spawn } = recordingSpawn(); + openExternalUrl('https://apache.org?x=1&y=2', 'darwin', spawn); + openExternalUrl('https://apache.org?x=1&y=2', 'linux', spawn); + assert.deepEqual(calls[0], { command: 'open', args: ['https://apache.org?x=1&y=2'] }); + assert.deepEqual(calls[1], { command: 'xdg-open', args: ['https://apache.org?x=1&y=2'] }); + }); +}); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index cedb82178b..9dc949da16 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -767,6 +767,7 @@ export async function runMakaCli( locale: locale.locale, cwd: process.cwd(), onProcessExit: handleMakaCliProcessExit, + buildVersion: version, ...(command.resumeSessionId ? { resumeSessionId: command.resumeSessionId } : {}), ...(command.resumeCwd ? { resumeCwd: command.resumeCwd } : {}), ...(command.hostProfileId ? { hostProfileId: command.hostProfileId } : {}), diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index 3d31fbe689..81e88adcc5 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Container, type Component, type Terminal } from '@earendil-works/pi-tui'; +import { Container, ScrollView, type Component, type Terminal } from '@earendil-works/pi-tui'; // Deep import (pi-tui does not re-export it): the viewport shadow diff must // compare the same canonical lines pi-tui diffs, and pi-tui normalizes Thai/Lao // AM sequences before its diff. Pinned to pi-tui 0.80.3. @@ -31,6 +31,9 @@ import { type MakaPiTranscriptMetadata, type MakaPiTranscriptState, } from './pi-transcript.js'; +import type { ScrollViewOptions } from '@earendil-works/pi-tui'; +import type { UnreadOutputFeed } from './tui-fullscreen.js'; +import { renderUnreadIndicator, UnreadOutputCounter } from './tui-fullscreen.js'; interface ViewportAwareEditor extends Component { setViewportRows(rows: number): void; @@ -38,6 +41,9 @@ interface ViewportAwareEditor extends Component { minimumViewportRows(): number; } +/** Rows the transcript keeps in the fullscreen layout even on tiny terminals. */ +const FULLSCREEN_TRANSCRIPT_MIN_ROWS = 1; + export function fitPendingQueueLines(lines: readonly string[], maxRows: number): string[] { const rowBudget = Math.max(0, Math.floor(maxRows)); if (lines.length <= rowBudget) return [...lines]; @@ -256,3 +262,145 @@ export class MakaPiLayoutComponent extends Container { return Math.max(current, tailTop); } } + +/** + * The ScrollView child of the fullscreen layout: renders the complete + * transcript document (the scroll view windows it) and exposes the rendered + * line count so the scroll view can count lines appended while the user is + * scrolled away. Lives in pi-tui-layout.ts alongside the other transcript + * adapters. + */ +export class MakaTranscriptDocumentComponent implements Component { + /** Rendered transcript document lines from the most recent frame. */ + documentLines = 0; + + constructor(private readonly transcript: MakaTranscriptComponent) {} + + invalidate(): void { + this.transcript.invalidate(); + } + + render(width: number): string[] { + const lines = this.transcript.render(width); + this.documentLines = lines.length; + return lines; + } +} + +/** + * The fullscreen layout's transcript scroll view. `ScrollView.updateLayout` + * runs at the layout pass with this frame's content height and scroll state — + * the one point in the frame where the window is fresh — so this subclass + * computes the unread count there and compares it with what the anchored + * chrome actually rendered (`presentedUnread`, written back by the chrome via + * its `UnreadOutputFeed`). The chrome is measured before the scroll view is + * laid out, so its view lags one frame; when the rendered count falls behind, + * a catch-up render is requested and the indicator settles deterministically. + */ +export class MakaTranscriptScrollView extends ScrollView { + /** Unread count as of the most recent layout pass. */ + computedUnread = 0; + /** Unread count the chrome last rendered. */ + presentedUnread = 0; + + private readonly counter = new UnreadOutputCounter(); + + constructor( + private readonly document: MakaTranscriptDocumentComponent, + options: ScrollViewOptions, + ) { + super(document, options); + } + + override updateLayout( + contentHeight: number, + viewportHeight: number, + requestRender: () => void, + ): void { + super.updateLayout(contentHeight, viewportHeight, requestRender); + this.computedUnread = this.counter.update({ + followingEnd: this.isFollowingEnd, + documentLines: this.document.documentLines, + }); + if (this.computedUnread !== this.presentedUnread) requestRender(); + } +} + +/** + * The anchored bottom chrome of the fullscreen layout (issue #4136): unread + * indicator, activity strip, pending queue, editor, and status line — stacked + * below the scrolling transcript and pinned to the screen bottom by the + * VStack. The transcript region above owns its own scrolling, so unlike + * `MakaPiLayoutComponent` this component emits only the chrome rows and never + * pads or windows the transcript. + * + * The unread count comes from the scroll view's `UnreadOutputFeed` (see + * `MakaTranscriptScrollView`): the layout engine measures this component + * before the scroll view is laid out, so the count it reads lags one frame and + * the scroll view requests a catch-up render whenever the rendered count falls + * behind. + * + * `renderGeometry.viewportTop` is pinned to 0: the app owns the whole screen, + * no rendered line sits in untouchable terminal scrollback, so the + * entry-freeze and viewport-restricted expansion toggles that main-screen mode + * needs (#1097, #1134, #4011) must not engage — every entry stays + * re-renderable and globally toggleable. + */ +export class MakaFullscreenChromeComponent implements Component { + constructor( + private readonly state: MakaPiTranscriptState, + private readonly activityStrip: MakaActivityStripComponent, + private readonly pendingQueue: MakaPendingQueueComponent, + private readonly editor: ViewportAwareEditor, + private readonly statusLine: Component, + private readonly terminal: Terminal, + private readonly unreadFeed: UnreadOutputFeed, + private readonly accent: (text: string) => string, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const unreadLines = this.unreadFeed.current(); + const indicatorLines = renderUnreadIndicator(unreadLines, this.accent); + this.unreadFeed.present(unreadLines); + // App-owned viewport: no terminal scrollback exists, so expansion toggles + // may retarget any entry and no entry is ever frozen off-screen. + this.state.renderGeometry.viewportTop = 0; + + const allActivityLines = this.activityStrip.render(width); + // The activity strip renders one row even when idle (an empty string); + // an all-empty strip would burn a permanent chrome row between the + // transcript and the editor, so it collapses to nothing when idle. + const activityRows = allActivityLines.some((line) => line.length > 0) ? allActivityLines : []; + const allPendingLines = this.pendingQueue.render(width); + const statusLines = this.statusLine.render(width); + // Same editor/autocomplete fixed-point as MakaPiLayoutComponent, with the + // transcript's minimum row and the indicator reserved up front so the + // chrome's intrinsic height can never push the transcript below one row. + const editorBudget = Math.max( + 0, + this.terminal.rows - + indicatorLines.length - + activityRows.length - + statusLines.length - + FULLSCREEN_TRANSCRIPT_MIN_ROWS, + ); + const pendingRowsAvailable = this.editor.isShowingAutocomplete() + ? Math.max(0, editorBudget - this.editor.minimumViewportRows()) + : allPendingLines.length; + const pendingLines = fitPendingQueueLines(allPendingLines, pendingRowsAvailable); + this.editor.setViewportRows(Math.max(0, editorBudget - pendingLines.length)); + const editorLines = this.editor.render(width); + // #1064's separator, fullscreen edition: keep "Working… Ns" from touching + // the last visible transcript line when a turn is running. + return [ + ...indicatorLines, + ...(activityRows.length > 0 ? [''] : []), + ...activityRows, + ...pendingLines, + ...editorLines, + ...statusLines, + ]; + } +} diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 45f0842fd8..ab03a2c6ee 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -23,14 +23,18 @@ import { Key, ProcessTerminal, SelectList, + TuiAltScreen, TuiMainScreen, + VStack, isKeyRelease, isKeyRepeat, + isViewportTUI, matchesKey, type Component, type OverlayHandle, type SelectItem, type Terminal, + type TUI, } from '@earendil-works/pi-tui'; import type { PermissionMode } from '@maka/core/permission'; import { @@ -119,7 +123,7 @@ import { type MakaPiTranscriptMetadata, } from './pi-transcript.js'; import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; -import { editorTheme, selectListTheme } from './tui-ansi.js'; +import { ansi, editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; import { McpManagementOverlay } from './pi-tui-mcp-status.js'; @@ -136,11 +140,15 @@ import { } from './tui-attention.js'; import { MakaActivityStripComponent, + MakaFullscreenChromeComponent, MakaPendingQueueComponent, MakaPiLayoutComponent, MakaStatusLineComponent, MakaTranscriptComponent, + MakaTranscriptDocumentComponent, + MakaTranscriptScrollView, } from './pi-tui-layout.js'; +import { openExternalUrl, resolveTuiFullscreen, TUI_FULLSCREEN_ENV } from './tui-fullscreen.js'; import { MakaAutocompleteProvider, DirectoryPickerOverlay, @@ -196,6 +204,19 @@ export interface MakaPiTuiInput { /** Maximum context tokens for the active model, for the statusline ctx segment. */ modelContextWindow?: number; terminal?: Terminal; + /** + * Explicit fullscreen-TUI decision for embeddings and tests. When omitted, + * the nightly trial switch decides: `MAKA_TUI_FULLSCREEN` overrides, else + * the mode follows the build channel (`buildVersion`). See tui-fullscreen.ts + * and issue #4136. + */ + tuiFullscreen?: boolean; + /** + * CLI package version, used to resolve the nightly-channel default of the + * fullscreen TUI trial. Embeddings that omit it (and `tuiFullscreen`) can + * only enable the mode through `MAKA_TUI_FULLSCREEN`. + */ + buildVersion?: string; /** * Whether turns and control actions publish terminal taskbar progress. * Defaults off on native Windows and Windows Terminal sessions because its @@ -367,7 +388,23 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const setTaskbarProgress = (active: boolean): void => { if (taskbarProgress) terminal.setProgress(active); }; - const tui = new TuiMainScreen(terminal); + // Nightly trial (issue #4136): fullscreen swaps the terminal-scrollback + // renderer for an alternate-screen viewport whose transcript scrolls + // independently under an anchored composer. Opt out with + // MAKA_TUI_FULLSCREEN=0, opt in on release builds with =1. + const tuiFullscreen = resolveTuiFullscreen({ + ...(input.tuiFullscreen !== undefined ? { setting: input.tuiFullscreen } : {}), + override: process.env[TUI_FULLSCREEN_ENV], + packageVersion: input.buildVersion, + }); + const tui: TUI = tuiFullscreen + ? new TuiAltScreen(terminal, undefined, undefined, { + // App-owned mouse: wheel scrolls the transcript, drag selects, click + // opens OSC 8 links. Copy keeps pi-tui's OSC 52 write. + mouse: true, + openUrl: openExternalUrl, + }) + : new TuiMainScreen(terminal); const state = createMakaPiTranscriptState(); // A pending confirmation is meaningful only for the exact transcript whose // geometry produced it; reconnect/session replacement starts fresh. @@ -595,15 +632,61 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); let refreshEditorCwd: ((cwd: string) => void) | undefined; const editorSurface = new MakaAutocompleteAboveEditorComponent(editor); - const layout = new MakaPiLayoutComponent( - state, - transcript, - activityStrip, - pendingQueue, - editorSurface, - statusLine, - terminal, - ); + // Fullscreen trial (#4136): the transcript document lives inside a primary + // ScrollView (follow-end, app-owned wheel/keyboard scrolling, chaining + // overscroll), the chrome is an intrinsic-height VStack entry below it, so + // the composer and status line stay anchored while history scrolls. The + // main-screen layout keeps owning the regular mode. + const transcriptDocument = new MakaTranscriptDocumentComponent(transcript); + let transcriptScroll: MakaTranscriptScrollView | undefined; + if (tuiFullscreen && isViewportTUI(tui)) { + transcriptScroll = new MakaTranscriptScrollView(transcriptDocument, { + follow: 'end', + primary: true, + overscroll: 'chain', + scrollbar: 'auto', + }); + const fullscreenChrome = new MakaFullscreenChromeComponent( + state, + activityStrip, + pendingQueue, + editorSurface, + statusLine, + terminal, + { + current: () => transcriptScroll!.computedUnread, + present: (unreadLines) => { + transcriptScroll!.presentedUnread = unreadLines; + }, + }, + ansi.accent, + ); + tui.setLayoutRoot( + new VStack([ + { component: transcriptScroll, basis: 0, grow: 1, minSize: 1 }, + { component: fullscreenChrome, basis: 'auto', shrink: 1, minSize: 1 }, + ]), + ); + // Typing while reading older content re-anchors to the newest output: the + // composer and its autocomplete live at the bottom, so composing from the + // middle of history would be blind. One of the trial's explicit evaluation + // questions (#4136) — revisit with nightly evidence. + editor.onUserTextChanged = () => { + transcriptScroll!.scrollToEnd(); + tui.requestRender(); + }; + } + const layout = tuiFullscreen + ? undefined + : new MakaPiLayoutComponent( + state, + transcript, + activityStrip, + pendingQueue, + editorSurface, + statusLine, + terminal, + ); const attention = new AttentionController(terminal, { baseTitle: input.title, ...(input.attentionLongTurnThresholdMs !== undefined @@ -3876,8 +3959,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // again within EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS applies the collapsed // default to those blocks too and pays one scrollback-clearing full redraw // (requestRender(true)), re-anchoring the viewport at the tail. - tui.setClearOnShrink(false); - tui.addChild(layout); + // + // Fullscreen mode (#4136) mounts differently: the layout root set at + // construction owns the screen (the transcript scroll view preserves the + // user's position and the chrome re-renders freely — no untouchable + // scrollback), so the main-screen layout component and its + // clear-on-shrink protection do not apply. + if (!tuiFullscreen && layout) { + tui.setClearOnShrink(false); + tui.addChild(layout); + } tui.setFocus(editorSurface); try { tui.start(); diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index 7cb312e18f..4182022e2d 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -48,6 +48,8 @@ export interface RunRuntimeHostTuiInput { readonly resumeCwd?: string; readonly hostProfileId?: string; readonly projectId?: string; + /** CLI package version, threaded to the fullscreen TUI trial's channel default. */ + readonly buildVersion?: string; readonly onProcessExit: (exitCode: number, error?: Error) => void; } @@ -73,6 +75,7 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< input.cwd, input.locale, input.hostProfileId, + input.buildVersion, ); if (!configured) throw error; context = await createRuntimeHostTuiContext(contextInput); @@ -116,6 +119,7 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< listShellRunUpdates: (sessionId) => context.driver.listShellRunUpdates(sessionId), onProcessExit: input.onProcessExit, cliCommand: input.cliCommand, + buildVersion: input.buildVersion, resumeSessionId: input.resumeSessionId, resumeCwd: input.resumeCwd, ...(runtimeHostProfileUsesHostWorkspace(context.profile.kind) && input.resumeSessionId @@ -209,6 +213,7 @@ async function runFirstRunOnboarding( cwd: string, locale: UiLocale, hostProfileId?: string, + buildVersion?: string, ): Promise { const connected = await connectRuntimeHostCli({ clientDataRoot, @@ -230,6 +235,7 @@ async function runFirstRunOnboarding( activities: new SessionActivityRegistry(), } satisfies MakaPiTuiTurnActivitySurface, onboarding: createRuntimeHostOnboardingSurface(connected.connection), + ...(buildVersion ? { buildVersion } : {}), }); return (await readRuntimeHostConnectionCatalog(connected.connection)).defaultTarget !== null; } finally { diff --git a/packages/cli/src/skill-highlight-editor.ts b/packages/cli/src/skill-highlight-editor.ts index e27f6dfafe..e78ac19093 100644 --- a/packages/cli/src/skill-highlight-editor.ts +++ b/packages/cli/src/skill-highlight-editor.ts @@ -45,6 +45,14 @@ const MID_MESSAGE_SLASH_TOKEN = /(?:\s)\/\S*$/; export class MakaSkillHighlightEditor extends Editor { private isInvocable: (name: string) => boolean = () => false; + /** + * Invoked after any input that changed the editor text (typing, paste, + * autocomplete insertion). The fullscreen TUI uses it to re-anchor the + * transcript to the newest output — typing while reading older content + * jumps back to the bottom (an explicit evaluation point of issue #4136). + */ + onUserTextChanged?: () => void; + /** * Swap the validator used by the render pass. Must be synchronous and * cheap (called per token per render) — the runner feeds it a snapshot of @@ -74,6 +82,7 @@ export class MakaSkillHighlightEditor extends Editor { // here because super.handleInput performs the insertion. const textBefore = this.getText(); super.handleInput(data); + if (this.getText() !== textBefore) this.onUserTextChanged?.(); if (this.isShowingAutocomplete()) return; if (this.getText() === textBefore) return; // pi-tui auto-triggers slash completion only at line start (its diff --git a/packages/cli/src/tui-fullscreen.ts b/packages/cli/src/tui-fullscreen.ts new file mode 100644 index 0000000000..14c5856e69 --- /dev/null +++ b/packages/cli/src/tui-fullscreen.ts @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; + +/** + * Nightly trial switch for the fullscreen (alternate-screen) TUI, issue #4136. + * + * The fullscreen path swaps `TuiMainScreen` for `TuiAltScreen`: the composer + * and status line stay anchored to the bottom of the screen while the + * transcript scrolls in an application-owned viewport. This is a scoped + * product experiment, not a stable mode: it defaults on only for nightly + * builds and must not grow a permanent user-facing toggle until the nightly + * evidence is in (issue non-goals). + * + * Precedence, highest first: + * 1. `setting` — an explicit caller decision (`MakaPiTuiInput.tuiFullscreen`), + * used by embeddings and tests. + * 2. `override` — the `MAKA_TUI_FULLSCREEN` environment variable. `1`/`true` + * opts a release build in; `0`/`false` opts a nightly build out. + * 3. `packageVersion` — nightly default: on for `-dev.` versions (the + * Product Nightly identity in scripts/product-nightly.mjs), off otherwise. + */ +export const TUI_FULLSCREEN_ENV = 'MAKA_TUI_FULLSCREEN'; + +export interface TuiFullscreenResolution { + readonly setting?: boolean; + readonly override?: string; + readonly packageVersion?: string; +} + +export function isNightlyPackageVersion(version: string | undefined): boolean { + if (!version) return false; + // Product Nightly versions look like `0.2.0-dev..`; + // formal releases are always a stable product version. + return /-dev\.[1-9]\d*\.\d{8}$/u.test(version); +} + +export function resolveTuiFullscreen(resolution: TuiFullscreenResolution = {}): boolean { + if (typeof resolution.setting === 'boolean') return resolution.setting; + const override = resolution.override?.trim().toLowerCase(); + if (override === '1' || override === 'true') return true; + if (override === '0' || override === 'false') return false; + return isNightlyPackageVersion(resolution.packageVersion); +} + +/** + * Per-frame snapshot of the transcript scroll viewport. The chrome reads it + * once per render to drive the unread indicator; the document line count comes + * from the transcript document wrapper that renders inside the scroll view. + */ +export interface TranscriptWindowSnapshot { + /** True while the scroll view is pinned to the newest content. */ + readonly followingEnd: boolean; + /** Total rendered transcript document lines this frame. */ + readonly documentLines: number; +} + +/** + * Bridges the scroll view (which sees fresh scroll state at each frame's + * layout pass) and the anchored chrome (which the layout engine measures + * before the scroll view is laid out, so its view of scroll state lags one + * frame). The scroll view computes the unread count and compares it against + * what the chrome actually rendered, requesting one catch-up frame after any + * change so the indicator settles deterministically. + */ +export interface UnreadOutputFeed { + /** Lines appended since the user left the bottom, as of the latest layout. */ + readonly current: () => number; + /** Called by the chrome each frame with the count it rendered. */ + readonly present: (unreadLines: number) => void; +} + +/** + * Counts transcript lines appended while the user is scrolled away from the + * bottom — the "unread / new output" signal for the anchored-composer trial. + * + * Updated once per frame with the current window snapshot: growth accumulates + * while the user is away, arriving at the bottom clears the count. Shrinks + * (collapsing tool output, re-wraps) never manufacture unread lines; the + * counter is approximate by design — it is an attention hint, not an exact + * diff. + */ +export class UnreadOutputCounter { + private lastDocumentLines: number | undefined; + private unreadLines = 0; + + update(window: TranscriptWindowSnapshot): number { + if ( + !window.followingEnd && + this.lastDocumentLines !== undefined && + window.documentLines > this.lastDocumentLines + ) { + this.unreadLines += window.documentLines - this.lastDocumentLines; + } + if (window.followingEnd) this.unreadLines = 0; + this.lastDocumentLines = window.documentLines; + return this.unreadLines; + } + + /** Discards the accumulated count (e.g. after the user jumps to the bottom). */ + reset(): void { + this.unreadLines = 0; + this.lastDocumentLines = undefined; + } +} + +/** The rendered unread line: accent-colored, one row, empty when nothing is new. */ +export function renderUnreadIndicator( + unreadLines: number, + accent: (text: string) => string, +): string[] { + if (unreadLines <= 0) return []; + const noun = unreadLines === 1 ? 'line' : 'lines'; + return [accent(`↓ ${unreadLines} new ${noun} — End to jump to latest`)]; +} + +/** + * URL schemes a model-authored OSC 8 link may be opened with, mirroring the + * desktop's external-link guard (apps/desktop/src/main/external-link-guard.ts): + * web and mail only. Assistant Markdown is rendered with the raw href, so + * everything else — `file:`, `javascript:`, unknown handlers, UNC paths — + * must never reach an OS opener from a click. + */ +const OPENABLE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); + +export function isOpenableExternalUrl(url: string): boolean { + try { + return OPENABLE_URL_PROTOCOLS.has(new URL(url).protocol); + } catch { + return false; + } +} + +/** + * Opens an OSC 8 hyperlink activated by a primary-button click in the + * fullscreen viewport. Model-authored hrefs are untrusted input, so the + * opener is deliberately narrow: + * + * - Only `http:`, `https:`, and `mailto:` targets are handed off at all. + * - Windows never routes the URL through cmd.exe — `spawn`'s argument + * quoting does not escape shell metacharacters (`&` would start a second + * command under `cmd /c start`), so the opener is `rundll32 + * url.dll,FileProtocolHandler`, which receives the URL as a single argv + * element and hands it to ShellExecute. The DLL/entrypoint half of the + * command line is a compile-time constant, so a hostile URL cannot + * redirect it. + * - macOS/Linux openers take the URL as a plain argv element (no shell). + * + * Failures are swallowed — a dead link must never take the TUI down. + */ +export function openExternalUrl( + url: string, + platform: NodeJS.Platform = process.platform, + spawnProcess: typeof spawn = spawn, +): void { + if (!isOpenableExternalUrl(url)) return; + try { + if (platform === 'darwin') { + spawnProcess('open', [url], { detached: true, stdio: 'ignore' }).unref(); + return; + } + if (platform === 'win32') { + spawnProcess('rundll32', ['url.dll,FileProtocolHandler', url], { + detached: true, + stdio: 'ignore', + windowsHide: true, + }).unref(); + return; + } + spawnProcess('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref(); + } catch { + // Best-effort only; the terminal may also offer its own link handling. + } +}