Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
614 changes: 614 additions & 0 deletions packages/cli/src/__tests__/fullscreen-mode.test.ts

Large diffs are not rendered by default.

147 changes: 147 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '../fullscreen-mode.js';
import { EXPANSION_COLLAPSE_CONFIRM_WINDOW_MS } from '../pi-transcript.js';
import type { TuiMcpAction, TuiMcpManagement } from '../tui-mcp-control.js';
import {
Expand Down Expand Up @@ -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<boolean> => {
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',
);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
210 changes: 210 additions & 0 deletions packages/cli/src/fullscreen-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* 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.<runNumber>.<YYYYMMDD>`;
// 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;
}
}

/** 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.
*/
/**
* 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;
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]);
}
Loading
Loading