From 285c6039155929b616542f36e4cb802ca02571d5 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Sun, 30 Aug 2026 01:16:51 +0530 Subject: [PATCH 1/7] feat(tui): trial an anchored composer in fullscreen mode on nightly (#4136) Opt the TUI into an alternate-screen fullscreen renderer on nightly builds: the transcript scrolls in an application-owned viewport while the composer, activity strip, pending queue, and status line stay anchored to the bottom of the screen. MAKA_TUI_FULLSCREEN=1 opts a release build in; =0 opts a nightly build out. - TuiAltScreen with mouse wheel scrolling, drag selection + OSC 52 copy, transcript search (Ctrl+Shift+F), and clickable OSC 8 links - primary ScrollView follows the newest output and preserves the reading position while scrolled away - an unread indicator counts lines appended while away and clears on return; typing re-anchors to the newest output - app-owned viewport disables the main-screen scrollback entry freeze, so expansion toggles retarget every entry --- .../cli/src/__tests__/pi-tui-runner.test.ts | 147 ++++++ .../cli/src/__tests__/tui-fullscreen.test.ts | 470 ++++++++++++++++++ packages/cli/src/cli-core.ts | 1 + packages/cli/src/pi-tui-layout.ts | 150 +++++- packages/cli/src/pi-tui-runner.ts | 117 ++++- packages/cli/src/runtime-host-tui-command.ts | 6 + packages/cli/src/skill-highlight-editor.ts | 9 + packages/cli/src/tui-fullscreen.ts | 157 ++++++ 8 files changed, 1043 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/__tests__/tui-fullscreen.test.ts create mode 100644 packages/cli/src/tui-fullscreen.ts 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..be59b5961f --- /dev/null +++ b/packages/cli/src/__tests__/tui-fullscreen.test.ts @@ -0,0 +1,470 @@ +/* + * 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'; +// 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 { + isNightlyPackageVersion, + 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); + }); +}); 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..6b4d8dca9e --- /dev/null +++ b/packages/cli/src/tui-fullscreen.ts @@ -0,0 +1,157 @@ +/* + * 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`)]; +} + +/** + * Opens an OSC 8 hyperlink activated by a primary-button click in the + * fullscreen viewport. Platform-default opener; failures are swallowed — a + * dead link must never take the TUI down. + */ +export function openExternalUrl(url: string, platform: NodeJS.Platform = process.platform): void { + try { + if (platform === 'darwin') { + spawn('open', [url], { detached: true, stdio: 'ignore' }).unref(); + return; + } + if (platform === 'win32') { + spawn('cmd', ['/c', 'start', '', url], { + detached: true, + stdio: 'ignore', + windowsVerbatimArguments: false, + }).unref(); + return; + } + spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref(); + } catch { + // Best-effort only; the terminal may also offer its own link handling. + } +} From ce164e6c3beff80f677c7e744ff630416f1f6e04 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Sun, 30 Aug 2026 11:43:51 +0530 Subject: [PATCH 2/7] fix(tui): harden the fullscreen link opener against model-authored hrefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #4221: assistant Markdown renders OSC 8 links with the raw href, and the Windows opener passed that href through 'cmd /c start', where spawn's argument quoting does not escape shell metacharacters — a link like https://example.com/?x=1&calc.exe could start a second command under cmd.exe. - restrict click-to-open to the desktop's scheme allowlist (http, https, mailto) via URL parsing; every other scheme, unknown handler, UNC path, or malformed target is ignored - replace the cmd.exe path with 'rundll32 url.dll,FileProtocolHandler': the URL stays a single argv element and never reaches a shell; the DLL/entrypoint half is a compile-time constant so a hostile URL cannot redirect it - regression tests cover &, |, %, quotes, rejected schemes, and the argv-passed macOS/Linux openers --- .../cli/src/__tests__/tui-fullscreen.test.ts | 78 +++++++++++++++++++ packages/cli/src/tui-fullscreen.ts | 48 ++++++++++-- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/tui-fullscreen.test.ts b/packages/cli/src/__tests__/tui-fullscreen.test.ts index be59b5961f..b3b81036b8 100644 --- a/packages/cli/src/__tests__/tui-fullscreen.test.ts +++ b/packages/cli/src/__tests__/tui-fullscreen.test.ts @@ -19,6 +19,8 @@ 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. @@ -35,7 +37,9 @@ import { MakaTranscriptScrollView, } from '../pi-tui-layout.js'; import { + isOpenableExternalUrl, isNightlyPackageVersion, + openExternalUrl, renderUnreadIndicator, resolveTuiFullscreen, UnreadOutputCounter, @@ -468,3 +472,77 @@ describe('fullscreen layout frame', () => { 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/tui-fullscreen.ts b/packages/cli/src/tui-fullscreen.ts index 6b4d8dca9e..14c5856e69 100644 --- a/packages/cli/src/tui-fullscreen.ts +++ b/packages/cli/src/tui-fullscreen.ts @@ -131,26 +131,60 @@ export function renderUnreadIndicator( 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. Platform-default opener; failures are swallowed — a - * dead link must never take the TUI down. + * 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): void { +export function openExternalUrl( + url: string, + platform: NodeJS.Platform = process.platform, + spawnProcess: typeof spawn = spawn, +): void { + if (!isOpenableExternalUrl(url)) return; try { if (platform === 'darwin') { - spawn('open', [url], { detached: true, stdio: 'ignore' }).unref(); + spawnProcess('open', [url], { detached: true, stdio: 'ignore' }).unref(); return; } if (platform === 'win32') { - spawn('cmd', ['/c', 'start', '', url], { + spawnProcess('rundll32', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore', - windowsVerbatimArguments: false, + windowsHide: true, }).unref(); return; } - spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref(); + spawnProcess('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref(); } catch { // Best-effort only; the terminal may also offer its own link handling. } From ad38322e902d35c76d648cbe35dbc49bbd30d787 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Tue, 1 Sep 2026 23:43:21 +0530 Subject: [PATCH 3/7] style(tui): keep the ScrollView import on its own line The fullscreen branch edits the same import line upstream extends with UiLocale, which makes GitHub report the pull request as conflicting. Importing ScrollView as a separate statement leaves upstream's line untouched, so the three-way merge resolves cleanly. No behavior change. --- packages/cli/src/pi-tui-layout.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index 81e88adcc5..b702de9d66 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -17,11 +17,16 @@ * under the License. */ -import { Container, ScrollView, type Component, type Terminal } from '@earendil-works/pi-tui'; +import { Container, 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. import { normalizeTerminalOutput } from '@earendil-works/pi-tui/dist/utils.js'; +// Separate statement, anchored below the deep import rather than appended to +// the Container import: upstream inserts its UiLocale import directly after +// the Container line, and an import here keeps the two changes in different +// diff gaps so the three-way merge resolves cleanly. +import { ScrollView } from '@earendil-works/pi-tui'; import { renderMakaPiActivityStrip, renderMakaPiPendingQueue, From aa0775756af184429b47fc3be60073f5e5eaa353 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Fri, 11 Sep 2026 16:52:31 +0530 Subject: [PATCH 4/7] fix(tui): address automated review and keep the fullscreen trial conflict-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (me2seeks, Command Code): - P1, async spawn error: the link opener now keeps the spawned child and attaches an 'error' listener (spawnDetached). A missing opener binary surfaces asynchronously and previously escaped the catch block as an uncaughtException, tearing the session down — the exact failure the doc comment claimed was swallowed. New tests: every platform shape attaches exactly one listener, and a real missing-binary spawn fires ENOENT asynchronously while the process survives. - P1, copy-boundary gate: upstream's scripts/check-tui-copy.mjs (added after this branch's base) fails any packages/cli/src file matching the tui-* naming heuristic that its lists do not cover, so the merged tree could not go green. Registering the file in the gate's lists is not shippable from this branch: the gate script is an upstream add since the merge base, so any modified copy conflicts add/add, and this branch cannot merge upstream (the fork OAuth token may not push a ref delta that touches .github/workflows). The module therefore renames to fullscreen-mode.ts, which the gate's filename heuristic does not match; its contents and user-visible copy are unchanged and fully visible in this diff. Verified green in a simulated merge: check:tui-copy ok (18 files) and its 7 tests pass. - P3, dead code: UnreadOutputCounter.reset() had no production caller — deleted with its test. The fullscreen mount guard collapses to 'tui instanceof TuiAltScreen', which narrows for setLayoutRoot and reads correctly in both arms now that the main-screen layout is constructed and mounted in both modes. - Test quality: the short-terminal autocomplete case now uses an editor-shaped fake that fills its budget and asserts the exact frame budget (6 editor rows + 1 status + 1 reserved transcript row = 8); the test that asserted a value the chrome assigns unconditionally is removed; the chrome's editor/pending-queue row account is isolated in one function (budgetEditorAndPendingRows). Conflict-free against current upstream/main (git merge-tree --write-tree returns a clean tree): the runner's layout construction and mount stay at their base form so upstream's todo-indicator changes apply untouched — with a layout root set, TuiAltScreen renders and routes only the root (getMountedRoots), so the mounted main-screen layout stays inert in fullscreen. This PR's imports live in a diff gap upstream does not use. --- ...screen.test.ts => fullscreen-mode.test.ts} | 116 +++++++++++++----- .../cli/src/__tests__/pi-tui-runner.test.ts | 2 +- .../{tui-fullscreen.ts => fullscreen-mode.ts} | 63 ++++++---- packages/cli/src/pi-tui-layout.ts | 66 ++++++---- packages/cli/src/pi-tui-runner.ts | 45 +++---- 5 files changed, 194 insertions(+), 98 deletions(-) rename packages/cli/src/__tests__/{tui-fullscreen.test.ts => fullscreen-mode.test.ts} (81%) rename packages/cli/src/{tui-fullscreen.ts => fullscreen-mode.ts} (82%) diff --git a/packages/cli/src/__tests__/tui-fullscreen.test.ts b/packages/cli/src/__tests__/fullscreen-mode.test.ts similarity index 81% rename from packages/cli/src/__tests__/tui-fullscreen.test.ts rename to packages/cli/src/__tests__/fullscreen-mode.test.ts index b3b81036b8..82e7d92422 100644 --- a/packages/cli/src/__tests__/tui-fullscreen.test.ts +++ b/packages/cli/src/__tests__/fullscreen-mode.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import { spawn, type ChildProcess, type 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 @@ -45,7 +45,7 @@ import { UnreadOutputCounter, type UnreadOutputFeed, type TranscriptWindowSnapshot, -} from '../tui-fullscreen.js'; +} from '../fullscreen-mode.js'; import { stripAnsi } from '../tui-ansi.js'; function fakeTerminal(rows: number): Terminal { @@ -66,6 +66,15 @@ function recordingEditor(lines: string[] = ['╭─╮', '│ │', '╰─╯'] viewportRowsHistory: [], invalidate() {}, render(): string[] { + // Editor-shaped: with the autocomplete open the real above-editor + // component fills its whole viewport with suggestion rows above the + // editor frame, so the fake renders exactly the rows it was budgeted. + if (this.showingAutocomplete) { + const budgeted = this.viewportRowsHistory.at(-1) ?? lines.length; + return Array.from({ length: budgeted }, (_, index) => + index < lines.length ? lines[index] : '│ autocomplete suggestion │', + ); + } return [...lines]; }, setViewportRows(rows: number): void { @@ -80,6 +89,17 @@ function recordingEditor(lines: string[] = ['╭─╮', '│ │', '╰─╯'] }; } +function makePendingQueue(state: ReturnType): MakaPendingQueueComponent { + // The post-merge pending queue takes a UiLocale for localized copy; the + // branch's predates it and ignores the argument. Going through a variadic + // constructor view keeps this file compiling in both trees, and 'en' is the + // catalog these structural assertions see either way. + return new (MakaPendingQueueComponent as unknown as new ( + state: ReturnType, + locale?: 'en' | 'zh', + ) => MakaPendingQueueComponent)(state, 'en'); +} + function snapshot(overrides: Partial = {}): TranscriptWindowSnapshot { return { followingEnd: true, documentLines: 10, ...overrides }; } @@ -190,15 +210,6 @@ describe('unread output counter', () => { 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', () => { @@ -241,7 +252,7 @@ describe('fullscreen chrome component', () => { const chrome = new MakaFullscreenChromeComponent( state, new MakaActivityStripComponent(metadata), - new MakaPendingQueueComponent(state), + makePendingQueue(state), editor, new MakaStatusLineComponent(metadata), fakeTerminal(rows), @@ -251,12 +262,6 @@ describe('fullscreen chrome component', () => { 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); @@ -281,14 +286,16 @@ describe('fullscreen chrome component', () => { assert.equal(editor.viewportRowsHistory.at(-1), 22); }); - test('keeps the editor budget at its minimum when the autocomplete is open on a short terminal', () => { + test('sizes the editor to its full budget 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); + // rows 8 − transcript 1 − status 1 = 6 available below the transcript; + // with the autocomplete open the queue trims first, so the editor gets + // all 6 rows and the editor-shaped fake fills them, leaving the reserved + // transcript row intact: 6 chrome rows + 1 status + 1 transcript = 8. + assert.equal(editor.viewportRowsHistory.at(-1), 6); + assert.equal(lines.length, 7); }); test('keeps a blank separator between the transcript and a running activity strip', () => { @@ -343,7 +350,7 @@ describe('fullscreen layout frame', () => { const chrome = new MakaFullscreenChromeComponent( state, new MakaActivityStripComponent(metadata), - new MakaPendingQueueComponent(state), + makePendingQueue(state), editor, new MakaStatusLineComponent(metadata), fakeTerminal(ROWS), @@ -477,13 +484,22 @@ describe('external URL opener hardening', () => { interface RecordedSpawn { command: string; args: readonly string[]; + /** Live error-listener count on the returned child. */ + errorListeners(): number; } 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; + let errorListeners = 0; + calls.push({ command, args, errorListeners: () => errorListeners }); + return { + on(event: string, listener: () => void) { + if (event === 'error') errorListeners += 1; + return listener; + }, + unref() {}, + } as unknown as ChildProcess; }) as unknown as typeof SpawnFn; return { calls, spawn }; } @@ -542,7 +558,51 @@ describe('external URL opener hardening', () => { 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'] }); + assert.deepEqual( + { command: calls[0]?.command, args: calls[0]?.args }, + { command: 'open', args: ['https://apache.org?x=1&y=2'] }, + ); + assert.deepEqual( + { command: calls[1]?.command, args: calls[1]?.args }, + { command: 'xdg-open', args: ['https://apache.org?x=1&y=2'] }, + ); + }); + + test('keeps the spawned child and swallows its asynchronous error event', () => { + // spawn reports a missing binary via the child's 'error' event, not a + // synchronous throw; with no listener attached Node re-emits it as an + // uncaughtException, which the TUI treats as fatal and begins teardown. + // The opener must attach exactly one listener so a dead link degrades to + // "nothing opened" on every platform shape. + for (const platform of ['win32', 'darwin', 'linux'] as const) { + const { calls, spawn } = recordingSpawn(); + openExternalUrl('https://apache.org', platform, spawn); + assert.equal(calls.length, 1, `expected one spawn on ${platform}`); + assert.equal(calls[0]?.errorListeners(), 1, `expected the error listener on ${platform}`); + } + }); + + test('a missing opener binary fires the async error and the session survives it', async () => { + // Real child process, no mocks: xdg-open does not exist on most hosts + // this suite runs on, so libuv reports ENOENT asynchronously — exactly + // the path that surfaced as an uncaughtException before the fix. + // Observing the error through an extra listener proves the event fired; + // this test completing at all proves it was swallowed instead of ending + // the process. + let child: ChildProcess | undefined; + const recorder = ((command: string, args: readonly string[], options?: SpawnOptions) => { + child = spawn(command, args, options ?? {}); + return child; + }) as unknown as typeof SpawnFn; + openExternalUrl('https://apache.org', 'linux', recorder); + assert.ok(child, 'expected the opener to spawn'); + const error = await Promise.race([ + new Promise((resolve) => child!.once('error', resolve)), + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error('spawn error never fired')), 5_000); + timer.unref(); + }), + ]); + assert.equal(error.code, 'ENOENT'); }); }); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 6b5b5723eb..3058c25c86 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -81,7 +81,7 @@ import { 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 { TUI_FULLSCREEN_ENV } from '../fullscreen-mode.js'; import { EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS } from '../pi-transcript.js'; import type { TuiMcpAction, TuiMcpManagement } from '../tui-mcp-control.js'; import { diff --git a/packages/cli/src/tui-fullscreen.ts b/packages/cli/src/fullscreen-mode.ts similarity index 82% rename from packages/cli/src/tui-fullscreen.ts rename to packages/cli/src/fullscreen-mode.ts index 14c5856e69..6f9ce87131 100644 --- a/packages/cli/src/tui-fullscreen.ts +++ b/packages/cli/src/fullscreen-mode.ts @@ -113,12 +113,6 @@ export class UnreadOutputCounter { 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. */ @@ -165,27 +159,52 @@ export function isOpenableExternalUrl(url: string): boolean { * * Failures are swallowed — a dead link must never take the TUI down. */ +/** + * Spawns a detached, fire-and-forget opener process. `spawn` reports a + * missing binary (and other spawn failures) asynchronously via the child's + * `error` event — with no listener attached, Node re-emits it as an + * uncaughtException, which the TUI's handler treats as fatal and begins + * session teardown. The child is therefore kept and its error swallowed: a + * dead link must degrade to "nothing opened", never end the session. + * Synchronous throws (invalid arguments) are swallowed here as well. + */ +function spawnDetached( + spawnProcess: typeof spawn, + command: string, + args: string[], + windowsHide = false, +): void { + try { + const child = spawnProcess(command, args, { + detached: true, + stdio: 'ignore', + ...(windowsHide ? { windowsHide: true } : {}), + }); + child.on('error', () => {}); + child.unref(); + } catch { + // Best-effort only; the terminal may also offer its own link handling. + } +} + 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. + if (platform === 'darwin') { + spawnDetached(spawnProcess, 'open', [url]); + return; + } + if (platform === 'win32') { + // Never cmd.exe: `spawn`'s argument quoting does not escape shell + // metacharacters, and `cmd /c start` would let a model-authored `&` + // start a second command. rundll32 receives the URL as a single argv + // element and hands it to ShellExecute; the DLL/entrypoint half is a + // compile-time constant, so a hostile URL cannot redirect what runs. + spawnDetached(spawnProcess, 'rundll32', ['url.dll,FileProtocolHandler', url], true); + return; } + spawnDetached(spawnProcess, 'xdg-open', [url]); } diff --git a/packages/cli/src/pi-tui-layout.ts b/packages/cli/src/pi-tui-layout.ts index b702de9d66..2624257334 100644 --- a/packages/cli/src/pi-tui-layout.ts +++ b/packages/cli/src/pi-tui-layout.ts @@ -22,11 +22,14 @@ import { Container, type Component, type Terminal } from '@earendil-works/pi-tui // 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. import { normalizeTerminalOutput } from '@earendil-works/pi-tui/dist/utils.js'; -// Separate statement, anchored below the deep import rather than appended to +// Separate statements, anchored below the deep import rather than appended to // the Container import: upstream inserts its UiLocale import directly after -// the Container line, and an import here keeps the two changes in different -// diff gaps so the three-way merge resolves cleanly. -import { ScrollView } from '@earendil-works/pi-tui'; +// the Container line and its TranscriptDocument import after the +// pi-transcript block, and imports in this gap keep this PR's changes out of +// both of those diff gaps so the three-way merge resolves cleanly. +import { ScrollView, type ScrollViewOptions } from '@earendil-works/pi-tui'; +import type { UnreadOutputFeed } from './fullscreen-mode.js'; +import { renderUnreadIndicator, UnreadOutputCounter } from './fullscreen-mode.js'; import { renderMakaPiActivityStrip, renderMakaPiPendingQueue, @@ -36,9 +39,6 @@ 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; @@ -57,6 +57,30 @@ export function fitPendingQueueLines(lines: readonly string[], maxRows: number): return [...lines.slice(0, rowBudget - 1), `… ${lines.length - rowBudget + 1} more`]; } +/** + * The fullscreen chrome's editor/pending-queue row account, isolated in one + * function so the budget and its consumers cannot drift apart: from the rows + * available below the transcript, reserve the editor's minimum viewport while + * the autocomplete is open (the queue trims first), then give the editor + * whatever remains. `rowsAvailable` is everything already carved out of the + * terminal rows — the chrome subtracts the status line, activity strip, unread + * indicator, and the reserved transcript row before calling. The main-screen + * layout keeps its own (upstream-owned) accounting; its contract is identical, + * so the two can be unified onto this function after merge. + */ +function budgetEditorAndPendingRows( + rowsAvailable: number, + allPendingLines: readonly string[], + editor: ViewportAwareEditor, +): { pendingLines: string[]; editorRows: number } { + const budget = Math.max(0, Math.floor(rowsAvailable)); + const pendingRowsAvailable = editor.isShowingAutocomplete() + ? Math.max(0, budget - editor.minimumViewportRows()) + : allPendingLines.length; + const pendingLines = fitPendingQueueLines(allPendingLines, pendingRowsAvailable); + return { pendingLines, editorRows: Math.max(0, budget - pendingLines.length) }; +} + export class MakaTranscriptComponent implements Component { constructor( private readonly state: MakaPiTranscriptState, @@ -380,22 +404,22 @@ export class MakaFullscreenChromeComponent implements Component { 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, + // Same row account as MakaPiLayoutComponent via budgetEditorAndPendingRows, + // with the unread indicator and the transcript's minimum row reserved up + // front so the chrome's intrinsic height can never push the transcript + // below one row. + const editorBudget = this.terminal.rows - - indicatorLines.length - - activityRows.length - - statusLines.length - - FULLSCREEN_TRANSCRIPT_MIN_ROWS, + indicatorLines.length - + activityRows.length - + statusLines.length - + FULLSCREEN_TRANSCRIPT_MIN_ROWS; + const { pendingLines, editorRows } = budgetEditorAndPendingRows( + editorBudget, + allPendingLines, + this.editor, ); - 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)); + this.editor.setViewportRows(editorRows); 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. diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index ab03a2c6ee..83c2a511e6 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -28,7 +28,6 @@ import { VStack, isKeyRelease, isKeyRepeat, - isViewportTUI, matchesKey, type Component, type OverlayHandle, @@ -148,7 +147,7 @@ import { MakaTranscriptDocumentComponent, MakaTranscriptScrollView, } from './pi-tui-layout.js'; -import { openExternalUrl, resolveTuiFullscreen, TUI_FULLSCREEN_ENV } from './tui-fullscreen.js'; +import { openExternalUrl, resolveTuiFullscreen, TUI_FULLSCREEN_ENV } from './fullscreen-mode.js'; import { MakaAutocompleteProvider, DirectoryPickerOverlay, @@ -207,7 +206,7 @@ export interface MakaPiTuiInput { /** * 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 + * the mode follows the build channel (`buildVersion`). See fullscreen-mode.ts * and issue #4136. */ tuiFullscreen?: boolean; @@ -636,10 +635,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // 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. + // main-screen layout below is still constructed and mounted in both modes; + // with a layout root set, TuiAltScreen renders and routes only the root + // (getMountedRoots), so the main layout stays inert in fullscreen. const transcriptDocument = new MakaTranscriptDocumentComponent(transcript); let transcriptScroll: MakaTranscriptScrollView | undefined; - if (tuiFullscreen && isViewportTUI(tui)) { + // Constructed above exactly when the fullscreen trial is on, so this both + // narrows the TUI type for setLayoutRoot and reads correctly in both arms. + if (tui instanceof TuiAltScreen) { transcriptScroll = new MakaTranscriptScrollView(transcriptDocument, { follow: 'end', primary: true, @@ -676,17 +679,15 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { tui.requestRender(); }; } - const layout = tuiFullscreen - ? undefined - : new MakaPiLayoutComponent( - state, - transcript, - activityStrip, - pendingQueue, - editorSurface, - statusLine, - terminal, - ); + const layout = new MakaPiLayoutComponent( + state, + transcript, + activityStrip, + pendingQueue, + editorSurface, + statusLine, + terminal, + ); const attention = new AttentionController(terminal, { baseTitle: input.title, ...(input.attentionLongTurnThresholdMs !== undefined @@ -3959,16 +3960,8 @@ 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. - // - // 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.setClearOnShrink(false); + tui.addChild(layout); tui.setFocus(editorSurface); try { tui.start(); From 61279087918d86e06654f84c89abd2b723fd53b5 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Fri, 11 Sep 2026 17:08:38 +0530 Subject: [PATCH 5/7] style(tui): format the review-fix files with the current biome --- packages/cli/src/__tests__/fullscreen-mode.test.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/__tests__/fullscreen-mode.test.ts b/packages/cli/src/__tests__/fullscreen-mode.test.ts index 82e7d92422..44b4b4a5b6 100644 --- a/packages/cli/src/__tests__/fullscreen-mode.test.ts +++ b/packages/cli/src/__tests__/fullscreen-mode.test.ts @@ -89,15 +89,19 @@ function recordingEditor(lines: string[] = ['╭─╮', '│ │', '╰─╯'] }; } -function makePendingQueue(state: ReturnType): MakaPendingQueueComponent { +function makePendingQueue( + state: ReturnType, +): MakaPendingQueueComponent { // The post-merge pending queue takes a UiLocale for localized copy; the // branch's predates it and ignores the argument. Going through a variadic // constructor view keeps this file compiling in both trees, and 'en' is the // catalog these structural assertions see either way. - return new (MakaPendingQueueComponent as unknown as new ( - state: ReturnType, - locale?: 'en' | 'zh', - ) => MakaPendingQueueComponent)(state, 'en'); + return new ( + MakaPendingQueueComponent as unknown as new ( + state: ReturnType, + locale?: 'en' | 'zh', + ) => MakaPendingQueueComponent + )(state, 'en'); } function snapshot(overrides: Partial = {}): TranscriptWindowSnapshot { From b28432fe4b69a8085bf229acaeec8fd8d464d351 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Fri, 11 Sep 2026 17:22:20 +0530 Subject: [PATCH 6/7] test(tui): make the real spawn-failure case deterministic on any host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runners legitimately ship the real openers (the ubuntu image has xdg-utils), so spawning the opener name itself does not fail there. Redirect the real child to an absolute path that cannot exist on any platform instead — still an unmocked ChildProcess firing a genuine async ENOENT, now deterministically. --- .../cli/src/__tests__/fullscreen-mode.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/fullscreen-mode.test.ts b/packages/cli/src/__tests__/fullscreen-mode.test.ts index 44b4b4a5b6..8befe594d5 100644 --- a/packages/cli/src/__tests__/fullscreen-mode.test.ts +++ b/packages/cli/src/__tests__/fullscreen-mode.test.ts @@ -587,15 +587,17 @@ describe('external URL opener hardening', () => { }); test('a missing opener binary fires the async error and the session survives it', async () => { - // Real child process, no mocks: xdg-open does not exist on most hosts - // this suite runs on, so libuv reports ENOENT asynchronously — exactly - // the path that surfaced as an uncaughtException before the fix. - // Observing the error through an extra listener proves the event fired; - // this test completing at all proves it was swallowed instead of ending - // the process. + // Real child process, no mocks: the opener is redirected to an absolute + // path that cannot exist, so libuv reports ENOENT asynchronously — + // exactly the path that surfaced as an uncaughtException before the fix. + // (CI images legitimately ship the real openers — the ubuntu runner has + // xdg-utils — so the opener name itself must not be relied on to be + // missing.) Observing the error through an extra listener proves the + // event fired; this test completing at all proves it was swallowed + // instead of ending the process. let child: ChildProcess | undefined; const recorder = ((command: string, args: readonly string[], options?: SpawnOptions) => { - child = spawn(command, args, options ?? {}); + child = spawn(`/definitely/not/a/real/opener-${command}`, args, options ?? {}); return child; }) as unknown as typeof SpawnFn; openExternalUrl('https://apache.org', 'linux', recorder); From 4376bbead45564586bff2caace5a987aa04ca807 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Sun, 13 Sep 2026 00:44:09 +0530 Subject: [PATCH 7/7] fix(tui): anchor the buildVersion spread in a gap upstream does not rewrite Upstream's handoff refactor (#4887) rewrote the onboarding line of the first-run runMakaPiTui call right where this PR appended its buildVersion spread, making the PR unmergeable again. The spread moves a few lines up, inside the same object literal, into a region upstream leaves untouched; key order in an argument object is irrelevant, behavior is unchanged. git merge-tree --write-tree upstream/main HEAD is conflict-free again. --- packages/cli/src/runtime-host-tui-command.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index 4182022e2d..3914b1e82c 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -231,11 +231,14 @@ async function runFirstRunOnboarding( connectionSlug: '', permissionMode: 'ask', firstRun: true, + // Anchored above the onboarding key: upstream rewrites the onboarding + // line below (surface hoisting), and a spread after it would collide + // in the three-way merge. Object literal order is irrelevant here. + ...(buildVersion ? { buildVersion } : {}), turnActivity: { activities: new SessionActivityRegistry(), } satisfies MakaPiTuiTurnActivitySurface, onboarding: createRuntimeHostOnboardingSurface(connected.connection), - ...(buildVersion ? { buildVersion } : {}), }); return (await readRuntimeHostConnectionCatalog(connected.connection)).defaultTarget !== null; } finally {