diff --git a/bun.lock b/bun.lock index 800b44d..68edc00 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "zcode-app-cli", "dependencies": { - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-tui": "^0.84.3", "playwright-core": "1.59.1", }, "devDependencies": { @@ -20,7 +20,7 @@ }, }, "packages": { - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.6", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-bSuzS4EVSqEPj/Qr/p9eqCESfKsGuDNbl77EGci8Iaqqt/C/XCBZL1MjXaxSWW1NsT5afjp/Cb0NTPzOLv/aPA=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.84.3", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-fS6OEQKEEALnKa6Uw8LcgZZ+9CWck7f3MQSCETQp6leUgIFwMEDtKmOUnL9nsYm+RIPmy7OmplVxYRbV6hiaFg=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], diff --git a/config.example.json b/config.example.json index 9d3d2b2..58b2550 100644 --- a/config.example.json +++ b/config.example.json @@ -104,6 +104,7 @@ "ui": { "locale": "auto", "theme": "auto", + "tuiMode": "regular", "notifications": { "method": "auto", "condition": "unfocused" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9e956f3..f514415 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -336,6 +336,31 @@ diagnostics in an isolated environment: ZCODE_TUI_RUNTIME_LOG=/tmp/zcode-tui-runtime.log zcode ``` +## TUI display mode + +The interactive TUI uses regular scrollback output by default. Set +`ui.tuiMode` to `"fullscreen"` to use the terminal's alternate screen with an +independently scrollable transcript, a fixed composer, and mouse-wheel/ +scrollbar navigation. The composer remains available while older transcript +content is being reviewed. + +```json +{ + "ui": { + "tuiMode": "fullscreen" + } +} +``` + +The same setting can be changed from `/settings` (or `/config`) under **Display +mode**. `ZCODE_TUI_MODE=fullscreen` or `ZCODE_TUI_MODE=regular` temporarily +overrides the saved value for the current shell; the settings picker labels +this override and does not remove it. + +Fullscreen mode is restored on normal exit and on handled `SIGINT`, `SIGTERM`, +or `SIGHUP` shutdowns. A hard `SIGKILL` cannot be intercepted by any terminal +application. + ## Theme Set `ui.theme` to `"auto"` (terminal detection), `"dark"`, or `"light"` in the diff --git a/package.json b/package.json index e34deb3..c08cf4b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "sync:local": "bun run build && bun scripts/sync-runtime.ts --app /Applications/ZCode.app", "check": "bun run build && bun scripts/check-runtime.ts", "check:oauth-callback": "bun scripts/smoke-oauth-callback.ts", - "check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-clear.ts && bun scripts/smoke-tui-session-title.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts", + "check:tui": "bun scripts/smoke-tui.ts && bun scripts/smoke-tui-features.ts && bun scripts/smoke-tui-clear.ts && bun scripts/smoke-tui-session-title.ts && bun scripts/smoke-tui-pressure.ts && bun scripts/smoke-tui-widths.ts && bun scripts/smoke-tui-fullscreen.ts && bun scripts/smoke-tui-fullscreen-switch.ts && bun scripts/smoke-tui-fullscreen-layout.ts", "test": "bun test", "typecheck": "tsc --noEmit", "verify:tui-perf": "bun scripts/verify-tui-perf.ts", @@ -68,7 +68,7 @@ "provenance": true }, "dependencies": { - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-tui": "^0.84.3", "playwright-core": "1.59.1" }, "devDependencies": { diff --git a/packages/zcode-tui/package.json b/packages/zcode-tui/package.json index a1c1d8f..e168c40 100644 --- a/packages/zcode-tui/package.json +++ b/packages/zcode-tui/package.json @@ -7,7 +7,7 @@ ".": "./dist/index.js" }, "dependencies": { - "@earendil-works/pi-tui": "^0.80.6" + "@earendil-works/pi-tui": "^0.84.3" }, "license": "MIT", "private": true diff --git a/packages/zcode-tui/src/choice-dialog.ts b/packages/zcode-tui/src/choice-dialog.ts index db24ae7..ba187b7 100644 --- a/packages/zcode-tui/src/choice-dialog.ts +++ b/packages/zcode-tui/src/choice-dialog.ts @@ -8,6 +8,7 @@ import { truncateToWidth, type Component, type Container, + type OverlayHandle, type SelectItem, type TUI } from "@earendil-works/pi-tui"; @@ -26,6 +27,63 @@ export interface ChoiceItem extends SelectItem { preview?: Component; } +const fullscreenDialogContentMaxWidth = 100; + +class FullscreenDialogSurface implements Component { + focused = false; + + constructor( + private readonly dialog: Component, + private readonly theme: ZCodeTheme + ) {} + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + if ("focused" in this.dialog) { + (this.dialog as Component & { focused: boolean }).focused = this.focused; + } + const inset = safeWidth >= 12 ? 2 : 0; + const contentWidth = Math.max( + 1, + Math.min(fullscreenDialogContentMaxWidth, safeWidth - inset * 2) + ); + const prefix = " ".repeat(inset); + const rule = this.theme.muted("─".repeat(safeWidth)); + return [ + rule, + ...this.dialog.render(contentWidth).map((line) => ( + truncateToWidth(`${prefix}${line}`, safeWidth, "", true) + )), + rule + ]; + } + + handleInput(data: string): void { + this.dialog.handleInput?.(data); + } + + invalidate(): void { + this.dialog.invalidate(); + } +} + +function showFullscreenDialog( + ui: TUI, + theme: ZCodeTheme, + dialog: Component +): { focus: Component; handle: OverlayHandle } | undefined { + if (ui.mode !== "fullscreen" || typeof ui.showOverlay !== "function") return undefined; + const surface = new FullscreenDialogSurface(dialog, theme); + return { + focus: surface, + handle: ui.showOverlay(surface, { + anchor: "bottom-left", + maxHeight: "100%", + width: "100%" + }) + }; +} + class ChoiceItemDetails implements Component { constructor( private readonly item: ChoiceItem, @@ -329,11 +387,13 @@ export function choose( }; dialog.setSelectionPreview(previewFor(list.getSelectedItem())); let settled = false; + let overlayHandle: OverlayHandle | undefined; const finish = (item: ChoiceItem | null) => { if (settled) return; settled = true; options.signal?.removeEventListener("abort", onAbort); - host.removeChild(dialog); + if (overlayHandle) overlayHandle.hide(); + else host.removeChild(dialog); ui.requestRender(); resolve(item); }; @@ -341,8 +401,16 @@ export function choose( list.onSelect = (item) => finish(choicesByValue.get(item.value) ?? null); list.onSelectionChange = (item) => dialog.setSelectionPreview(previewFor(item)); list.onCancel = () => finish(null); - host.addChild(dialog); - ui.setFocus(dialog); + // Mount as an overlay in fullscreen mode so TuiAltScreen defers viewport + // input (PageUp/PageDown/Home/End) to the focused dialog. In regular mode + // keep the inline host layout to preserve the existing visual placement. + const fullscreenDialog = showFullscreenDialog(ui, theme, dialog); + if (fullscreenDialog) { + overlayHandle = fullscreenDialog.handle; + } else { + host.addChild(dialog); + } + ui.setFocus(fullscreenDialog?.focus ?? dialog); ui.requestRender(); options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.signal?.aborted) finish(null); @@ -350,6 +418,8 @@ export function choose( } class TextPromptDialog implements Component { + focused = false; + constructor( private readonly title: string, private readonly prompt: string, @@ -360,6 +430,9 @@ class TextPromptDialog implements Component { render(width: number): string[] { const safeWidth = Math.max(1, width); + if ("focused" in this.input) { + (this.input as Component & { focused: boolean }).focused = this.focused; + } return [ ...wrapTerminalText(this.theme.bold(this.title), safeWidth), ...wrapTerminalText(this.theme.muted(this.prompt), safeWidth), @@ -370,6 +443,10 @@ class TextPromptDialog implements Component { ]; } + handleInput(data: string): void { + (this.input as Component & { handleInput?: (input: string) => void }).handleInput?.(data); + } + invalidate(): void { this.input.invalidate(); } @@ -475,19 +552,28 @@ export function promptText( sanitizeTerminalText(options.help ?? "Enter confirm · Esc cancel", { preserveSgr: false }) ); let settled = false; + let overlayHandle: OverlayHandle | undefined; const finish = (value: string | null): void => { if (settled) return; settled = true; options.signal?.removeEventListener("abort", onAbort); - host.removeChild(dialog); + if (overlayHandle) overlayHandle.hide(); + else host.removeChild(dialog); ui.requestRender(); resolve(value); }; const onAbort = () => finish(null); input.onSubmit = (value) => finish(value); input.onEscape = () => finish(null); - host.addChild(dialog); - ui.setFocus(input); + const fullscreenDialog = showFullscreenDialog(ui, theme, dialog); + if (fullscreenDialog) { + overlayHandle = fullscreenDialog.handle; + } else { + host.addChild(dialog); + } + // Keep focus on the overlay root. TuiAltScreen uses the root focus state + // to defer viewport keys; TextPromptDialog forwards input to its child. + ui.setFocus(fullscreenDialog?.focus ?? dialog); ui.requestRender(); options.signal?.addEventListener("abort", onAbort, { once: true }); if (options.signal?.aborted) finish(null); diff --git a/packages/zcode-tui/src/events.ts b/packages/zcode-tui/src/events.ts index 93a71ff..572694e 100644 --- a/packages/zcode-tui/src/events.ts +++ b/packages/zcode-tui/src/events.ts @@ -222,6 +222,32 @@ export function isModelCancellationEvent(event: StreamEvent): boolean { ); } +const toolCancellationValues = new Set([ + "aborterror", + "cancelled", + "canceled", + "tool_cancelled", + "tool_canceled" +]); + +export function isToolCancellation(value: unknown): boolean { + if (value instanceof Error) { + return toolCancellationValues.has(value.name.toLowerCase()) + || /\b(?:tool|command|process|bash)\b.{0,40}\b(?:cancelled|canceled)\b/iu.test(value.message); + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return toolCancellationValues.has(normalized) + || /\b(?:tool|command|process|bash)\b.{0,40}\b(?:cancelled|canceled)\b/iu.test(value); + } + if (!isRecord(value)) return false; + const markers = [value.type, value.code, value.name, value.status, value.reason]; + if (markers.some((marker) => ( + typeof marker === "string" && toolCancellationValues.has(marker.trim().toLowerCase()) + ))) return true; + return value.error !== value && isToolCancellation(value.error); +} + export function responseText(value: unknown): string | undefined { if (!isRecord(value)) return undefined; return asString(value.response) ?? asString(value.message) ?? asString(value.text); diff --git a/packages/zcode-tui/src/fullscreen-header.ts b/packages/zcode-tui/src/fullscreen-header.ts new file mode 100644 index 0000000..357f9f6 --- /dev/null +++ b/packages/zcode-tui/src/fullscreen-header.ts @@ -0,0 +1,241 @@ +import { homedir } from "node:os"; + +import { + truncateToWidth, + visibleWidth, + type Component +} from "@earendil-works/pi-tui"; + +import { sanitizeTerminalText } from "./terminal-text.ts"; +import type { ZCodeTheme } from "./theme.ts"; + +export type FullscreenHeaderPhase = "welcome" | "transition" | "rail"; + +export interface FullscreenHeaderOptions { + branch?: string; + distributionVersion?: string; + runtimeVersion: string; + workspace: string; + homeDirectory?: string; +} + +function clean(value: string | undefined): string | undefined { + const text = value + ? sanitizeTerminalText(value, { preserveSgr: false }).replace(/\s+/gu, " ").trim() + : ""; + return text || undefined; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/"); +} + +export function displayWorkspacePath(workspace: string, homeDirectory = homedir()): string { + const path = normalizePath(clean(workspace) ?? ""); + const home = normalizePath(clean(homeDirectory) ?? "").replace(/\/+$/u, ""); + if (!path || !home) return path; + if (path === home) return "~"; + if (path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`; + return path; +} + +function truncateFromStart(value: string, width: number): string { + if (width <= 0) return ""; + if (visibleWidth(value) <= width) return value; + if (width === 1) return "…"; + const suffix: string[] = []; + for (const character of Array.from(value).reverse()) { + const candidate = `${character}${suffix.join("")}`; + if (visibleWidth(`…${candidate}`) > width) break; + suffix.unshift(character); + } + return `…${suffix.join("")}`; +} + +function locationText( + workspace: string, + branch: string | undefined, + width: number, + homeDirectory: string +): string { + const path = displayWorkspacePath(workspace, homeDirectory); + if (!branch) return truncateFromStart(path, width); + const separator = " · "; + const branchWidth = Math.min(visibleWidth(branch), Math.max(8, Math.floor(width * 0.36))); + const branchText = truncateToWidth(branch, branchWidth, "…"); + const pathWidth = width - visibleWidth(separator) - visibleWidth(branchText); + if (pathWidth <= 0) return truncateToWidth(branchText, width, "…"); + return `${truncateFromStart(path, pathWidth)}${separator}${branchText}`; +} + +function fit(value: string, width: number): string { + return truncateToWidth(value, Math.max(0, width), "…"); +} + +function horizontalRail(content: string, width: number, theme: ZCodeTheme): string { + const safeWidth = Math.max(1, width); + if (safeWidth < 5) return fit(content, safeWidth); + const left = "── "; + const contentBudget = Math.max(1, safeWidth - visibleWidth(left) - 2); + const body = fit(content, contentBudget); + const fill = "─".repeat(Math.max(1, safeWidth - visibleWidth(left) - visibleWidth(body) - 1)); + return fit(`${theme.muted(left)}${body}${theme.muted(` ${fill}`)}`, safeWidth); +} + +function frameRule( + edge: "top" | "bottom", + title: string, + width: number, + theme: ZCodeTheme +): string { + const [left, right] = edge === "top" ? ["╭─ ", "╮"] : ["╰─ ", "╯"]; + const fixedWidth = visibleWidth(left) + visibleWidth(right) + 2; + if (width < fixedWidth) { + return fit(theme.muted("─".repeat(Math.max(1, width))), width); + } + const titleBudget = Math.max(1, width - fixedWidth); + const body = fit(title, titleBudget); + const fill = "─".repeat(Math.max( + 1, + width - visibleWidth(left) - visibleWidth(body) - visibleWidth(right) - 1 + )); + return fit(`${theme.muted(left)}${body}${theme.muted(` ${fill}${right}`)}`, width); +} + +function frameContent(content: string, width: number, theme: ZCodeTheme): string { + if (width < 4) return fit(content, width); + const innerWidth = width - 4; + const body = fit(content, innerWidth); + const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(body))); + return `${theme.muted("│ ")}${body}${padding}${theme.muted(" │")}`; +} + +export class FullscreenHeader implements Component { + private phase: FullscreenHeaderPhase = "welcome"; + private readonly branch?: string; + private readonly distributionVersion?: string; + private readonly runtimeVersion: string; + private readonly workspace: string; + private readonly homeDirectory: string; + + constructor( + private readonly theme: ZCodeTheme, + options: FullscreenHeaderOptions + ) { + this.branch = clean(options.branch); + this.distributionVersion = clean(options.distributionVersion); + this.runtimeVersion = clean(options.runtimeVersion) ?? "unknown"; + this.workspace = clean(options.workspace) ?? ""; + this.homeDirectory = options.homeDirectory ?? homedir(); + } + + setPhase(phase: FullscreenHeaderPhase): void { + this.phase = phase; + } + + getPhase(): FullscreenHeaderPhase { + return this.phase; + } + + location(width: number): string { + return locationText( + this.workspace, + this.branch, + Math.max(0, width), + this.homeDirectory + ); + } + + identity(width: number, includeVersion = true): string { + const brand = this.theme.bold(this.theme.accent("◆ ZCODE")); + if (!includeVersion) return fit(brand, Math.max(0, width)); + const version = this.distributionVersion ?? this.runtimeVersion; + return fit(`${brand} ${this.theme.muted(`v${version}`)}`, Math.max(0, width)); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + if (this.phase === "welcome") { + return [horizontalRail(this.identity(safeWidth, true), safeWidth, this.theme)]; + } + if (this.phase === "transition") { + return [horizontalRail(this.identity(safeWidth, false), safeWidth, this.theme)]; + } + + const identity = this.identity(safeWidth, false); + const separator = " "; + const location = this.location(Math.max(0, safeWidth - visibleWidth(identity) - visibleWidth(separator) - 6)); + return [horizontalRail(`${identity}${separator}${this.theme.muted(location)}`, safeWidth, this.theme)]; + } + + invalidate(): void {} +} + +export interface SessionWelcomeOptions { + loginRequired?: boolean; + includeIdentity?: boolean; +} + +/** One-time startup content that belongs to the transcript, not the chrome. */ +export class SessionWelcome implements Component { + private loginRequired: boolean; + private includeIdentity: boolean; + private transitioning = false; + + constructor( + private readonly theme: ZCodeTheme, + private readonly location: (width: number) => string, + private readonly identity: (width: number) => string, + options: SessionWelcomeOptions = {} + ) { + this.loginRequired = options.loginRequired === true; + this.includeIdentity = options.includeIdentity !== false; + } + + setLoginRequired(required: boolean): void { + this.loginRequired = required; + } + + setIncludeIdentity(include: boolean): void { + this.includeIdentity = include; + } + + setTransitioning(transitioning: boolean): void { + this.transitioning = transitioning; + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const frameWidth = Math.max(1, Math.min(safeWidth, 76)); + const innerWidth = Math.max(1, frameWidth - 4); + const title = this.includeIdentity + ? this.identity(Math.max(1, frameWidth - 8)) + : this.theme.bold("Workspace"); + const content = [ + frameRule("top", title, frameWidth, this.theme), + frameContent(this.theme.muted(this.location(innerWidth)), frameWidth, this.theme) + ]; + if (this.loginRequired) { + content.push(frameContent( + this.theme.warning("Model access is not configured · Run /login."), + frameWidth, + this.theme + )); + } + content.push( + frameContent(this.theme.bold("Ask a task about this workspace"), frameWidth, this.theme), + frameRule( + "bottom", + this.theme.muted("/help commands · /status details"), + frameWidth, + this.theme + ) + ); + const lines = content.map((line) => truncateToWidth(line, safeWidth, "…")); + return this.transitioning + ? lines.map((line) => this.theme.muted(sanitizeTerminalText(line, { preserveSgr: false }))) + : lines; + } + + invalidate(): void {} +} diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 1b2e160..3e4d949 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; +import { constants as osConstants } from "node:os"; import { basename } from "node:path"; import { @@ -26,12 +27,18 @@ import { Container, Editor, isKeyRelease, + isViewportTUI, Markdown, matchesKey, ProcessTerminal, + ScrollView, Spacer, Text, - TUI, + TuiAltScreen, + TuiMainScreen, + VStack, + type TUI, + type TuiMode, type Component, type SlashCommand } from "@earendil-works/pi-tui"; @@ -57,6 +64,7 @@ import { import { historyText, isModelCancellationEvent, + isToolCancellation, modelLabel, normalizeEvent, responseText, @@ -126,6 +134,11 @@ import { type NotificationSettings, type TurnNotificationKind } from "./notifications.ts"; +import { + readTuiMode, + resolveTuiMode, + writeTuiMode +} from "./tui-mode.ts"; import { effortPicker, explicitModelRequest, @@ -233,7 +246,10 @@ import { TurnPresentationRegistry } from "./turn-presentation-registry.ts"; import { TurnWorkTracker } from "./turn-work-tracker.ts"; import { asString, isRecord, type PromptCallOptions, type TuiOptions } from "./types.ts"; import { UpdateAvailableView, updateCommand } from "./update-available-view.ts"; -import { Divider, WelcomeBanner } from "./welcome-banner.ts"; +import { + FullscreenHeader, + SessionWelcome +} from "./fullscreen-header.ts"; import { WorkspaceAutocompleteProvider } from "./workspace-autocomplete.ts"; import { readWorkspaceDiff } from "./workspace-diff.ts"; import { workedDurationLabel, WorkDurationView } from "./work-duration-view.ts"; @@ -284,6 +300,7 @@ const runtimeCommandSummaries = new Map([ const terminalThemeQueryTimeoutMs = 100; const exitUsageQueryTimeoutMs = 250; +const fullscreenWelcomeTransitionMs = 180; const updateAvailableBlockId = "update_available"; const modelRetryBlockIdPrefix = "model_retry_status"; const questionBackValue = "__back__"; @@ -431,22 +448,103 @@ class ConditionalContainer extends Container { } } +/** + * AI SDK emits its warning banner through console.info when no handler is + * installed. In the interactive TUI that stdout write is terminal content, + * so suppress only the default banner while preserving a runtime-provided + * structured warning handler. + */ +export function suppressTuiAiSdkWarnings(): void { + const global = globalThis as typeof globalThis & { + AI_SDK_LOG_WARNINGS?: unknown; + }; + if (typeof global.AI_SDK_LOG_WARNINGS === "function") return; + try { + global.AI_SDK_LOG_WARNINGS = false; + } catch { + // A host may expose a read-only global; warning suppression is optional. + } +} + +// The runtime loads this module before it initializes the interactive app. +// Install the TUI-safe default early enough to cover startup model discovery. +suppressTuiAiSdkWarnings(); + +/** + * The upstream alternate-screen renderer intentionally reserves Home/End and + * PageUp/PageDown for viewport navigation. ZCode's composer is a real editor, + * so it needs the same priority as a modal overlay while it has focus. + */ +class ZCodeAltScreen extends TuiAltScreen { + private viewportInputDeferral?: (data: string) => boolean; + private currentInput?: string; + + setViewportInputDeferral(deferral: (data: string) => boolean): void { + this.viewportInputDeferral = deferral; + } + + setCurrentInput(data: string | undefined): void { + this.currentInput = data; + } + + protected override isOverlayFocused(): boolean { + if (super.isOverlayFocused()) return true; + return this.viewportInputDeferral?.(this.currentInput ?? "") === true; + } +} + +/** Feed focus reports to the notifier before the TUI's own input listeners. */ +class NotifyingProcessTerminal extends ProcessTerminal { + constructor( + private readonly beforeInput: (data: string) => void, + private readonly afterInput?: () => void + ) { + super(); + } + + override start(onInput: (data: string) => void, onResize: () => void): void { + super.start((data) => { + this.beforeInput(data); + try { + onInput(data); + } finally { + this.afterInput?.(); + } + }, onResize); + } +} + +function signalExitCode(signal: NodeJS.Signals): number { + const number = (osConstants.signals as Record)[signal]; + return typeof number === "number" ? 128 + number : 1; +} + class ZCodeTui { private readonly animateTurnTimer: boolean; private readonly colorsEnabled: boolean; private readonly distributionVersion?: string; private readonly themePreference: ZCodeThemePreference; private readonly theme: ZCodeTheme; - private readonly ui: TUI; + private ui: TUI; private readonly transcript: Transcript; private readonly choiceHost = new Container(); private readonly composerHost = new ConditionalContainer(() => this.choiceDepth === 0); + private readonly headerHost = new Container(); + private readonly transcriptHost = new Container(); + private readonly sessionWelcomeHost = new ConditionalContainer( + () => this.tuiMode === "fullscreen" && this.fullscreenWelcomeVisible + ); + private readonly editorHost = new Container(); + private readonly fullscreenHeader: FullscreenHeader; + private readonly sessionWelcome: SessionWelcome; + private fullscreenTranscript?: ScrollView; + private fullscreenLayout?: VStack; private readonly runtimeActivity: RuntimeActivityView; private readonly status: StatusLine; private readonly turnStatus: FooterBar; private readonly queuedInputView: QueuedInputView; private readonly attachmentBar: AttachmentBar; - private readonly editor: Editor; + private editor: Editor; private readonly assistantStream: AssistantStream; private readonly notifications: TurnNotifier; private readonly skillCatalog: SkillCatalog; @@ -483,8 +581,10 @@ class ZCodeTui { private currentToolGroupBlockId?: string; private currentToolGroupMessageId?: string; private pendingAttachments: PromptImageAttachment[] = []; + private readonly editorHistory: string[] = []; private mode: Mode; private model: string; + private tuiMode: TuiMode; private thoughtLevel?: string; private modelOptions: unknown[]; private effortOptions: unknown[]; @@ -500,6 +600,9 @@ class ZCodeTui { private workflowRefreshInFlight = false; private choiceDepth = 0; private settingSwitchInFlight = false; + private fullscreenWelcomeVisible = true; + private fullscreenWelcomeTransitionTimer?: ReturnType; + private sessionHasContent = false; private rewindEscapePending = false; private rewindEscapeTimer?: ReturnType; private rewindFlowActive = false; @@ -531,8 +634,6 @@ class ZCodeTui { private backgroundHandoffInterruptInFlight = false; private updateCheckAbortController?: AbortController; private loginRequired: boolean; - private readonly loginWarning = new Text("", 1, 0); - private readonly loginHelp = new Text("", 1, 0); constructor(private readonly options: TuiOptions) { this.animateTurnTimer = turnTimerAnimationEnabled(); @@ -544,6 +645,20 @@ class ZCodeTui { ) || undefined; this.theme = createTheme(this.colorsEnabled, initialColorScheme(this.themePreference)); this.transcript = new Transcript(this.theme.searchMatch); + const workspace = options.workspaceDirectory ?? process.cwd(); + const runtimeVersion = sanitizeTerminalText(options.version ?? "unknown", { preserveSgr: false }); + this.fullscreenHeader = new FullscreenHeader(this.theme, { + branch: options.workspaceGitBranch, + distributionVersion: this.distributionVersion, + runtimeVersion, + workspace + }); + this.sessionWelcome = new SessionWelcome( + this.theme, + (width) => this.fullscreenHeader.location(width), + (width) => this.fullscreenHeader.identity(width), + { loginRequired: options.loginRequired === true, includeIdentity: true } + ); this.mode = normalizedMode(options.initialMode); this.model = modelLabel(options.initialModel); this.thoughtLevel = options.initialThoughtLevel; @@ -551,7 +666,8 @@ class ZCodeTui { this.effortOptions = [...(options.effortOptions ?? [])]; this.loginRequired = options.loginRequired === true; this.skillCatalog = new SkillCatalog(options.listSkills); - this.ui = new TUI(new ProcessTerminal(), true); + this.tuiMode = resolveTuiMode(process.env, { ui: { tuiMode: options.initialTuiMode } }); + this.ui = this.createTui(this.tuiMode); this.notifications = new TurnNotifier({ writeTerminal: (data) => this.ui.terminal.write(data) }); @@ -587,10 +703,10 @@ class ZCodeTui { onRender: () => this.ui.requestRender() }); this.runtimeActivity = new RuntimeActivityView(this.theme); - this.editor = new Editor(this.ui, this.theme.editor, { paddingX: 1, autocompleteMaxVisible: 7 }); + this.editor = this.createEditor(this.ui); this.assistantStream = new AssistantStream( this.theme, - (component, blockOptions) => this.transcript.addBlock(component, blockOptions) + (component, blockOptions) => this.addTranscriptBlock(component, blockOptions) ); this.done = new Promise((resolve) => { this.resolveDone = resolve; @@ -607,52 +723,105 @@ class ZCodeTui { } catch (error) { notificationConfigError = error instanceof Error ? error.message : String(error); } + // Resolve the effective TUI mode from env > options > config. The vendor + // runtime does not forward initialTuiMode, so read the persisted config + // here and rebuild the TUI instance before start() when it differs from + // the constructor's default. + try { + const configEnv = { ...process.env }; + delete configEnv.ZCODE_TUI_MODE; + const fromConfig = await readTuiMode(configEnv); + const effective = resolveTuiMode(process.env, { + ui: { tuiMode: this.options.initialTuiMode ?? fromConfig } + }); + if (effective !== this.tuiMode) { + this.ui = this.createTui(effective); + this.tuiMode = effective; + this.editor = this.createEditor(this.ui); + } + } catch { + // Config unreadable — keep the constructor's resolved mode. + } const updateCheck = this.distributionVersion ? await readStartupUpdate({ currentVersion: this.distributionVersion }).catch(() => undefined) : undefined; - this.ui.start(); - await this.resolveTerminalColorScheme(); - this.buildLayout(); - if (notificationConfigError) { - this.addNotice(`Unable to load notification settings: ${notificationConfigError}`, "warning"); - } - await this.restoreInitialTranscript(); - if (updateCheck?.availableVersion && this.distributionVersion) { - this.addUpdateAvailable(this.distributionVersion, updateCheck.availableVersion); - } - this.bindInput(); - this.notifications.start(); - this.ui.setFocus(this.editor); - this.updateMetadata(); - this.updateTurnStatus(); - this.ui.requestRender(true); - this.startUpdateRefresh(updateCheck); - if (!this.loginRequired) void this.refreshGoal(); - if (!this.loginRequired) void this.refreshSessionUsage(); - if (await readSetupPending().catch(() => false)) { - if (await readConfiguredModelAccess().catch(() => null)) { - // The user already configured model access outside the wizard (for - // example via `zcode login` or a hand-edited config.json); honor that - // as completed setup instead of showing the wizard again. - await clearSetupPending().catch(() => {}); - } else { - void this.runFirstRunSetup(); + // Ensure the TUI cleans up terminal state (alt screen, mouse, cursor) + // even when the process is killed by an external signal. + const onSigint = () => this.handleSignal("SIGINT"); + const onSigterm = () => this.handleSignal("SIGTERM"); + const onSighup = () => this.handleSignal("SIGHUP"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + if (process.platform !== "win32") process.once("SIGHUP", onSighup); + let startAttempted = false; + try { + if (this.stopped) { + await this.done; + return; + } + startAttempted = true; + this.ui.start(); + if (this.stopped) { + await this.done; + return; + } + await this.resolveTerminalColorScheme(); + this.buildLayout(); + if (notificationConfigError) { + this.addNotice(`Unable to load notification settings: ${notificationConfigError}`, "warning"); + } + await this.restoreInitialTranscript(); + if (this.transcript.blockCount > 0) this.enterSessionRail(true); + if (updateCheck?.availableVersion && this.distributionVersion) { + this.addUpdateAvailable(this.distributionVersion, updateCheck.availableVersion); + } + this.bindInput(); + this.notifications.start(); + this.focusEditor(); + this.updateMetadata(); + this.updateTurnStatus(); + this.ui.requestRender(true); + this.startUpdateRefresh(updateCheck); + if (!this.loginRequired) void this.refreshGoal(); + if (!this.loginRequired) void this.refreshSessionUsage(); + if (await readSetupPending().catch(() => false)) { + if (await readConfiguredModelAccess().catch(() => null)) { + // The user already configured model access outside the wizard (for + // example via `zcode login` or a hand-edited config.json); honor that + // as completed setup instead of showing the wizard again. + await clearSetupPending().catch(() => {}); + } else { + void this.runFirstRunSetup(); + } + } + this.scheduleRuntimePoll(0); + void this.loadHistory(); + if (this.options.subscribeSessionEvents) { + this.unsubscribeSession = this.options.subscribeSessionEvents((event) => { + this.onSessionEvent(event); + }) ?? undefined; + } + if (this.options.subscribeWorkflowEvents) { + this.unsubscribeWorkflow = this.options.subscribeWorkflowEvents((event) => { + this.debugEvent("workflow", event); + void this.refreshWorkflowFromEvent(); + }) ?? undefined; + } + await this.done; + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); + if (startAttempted && !this.stopped) { + this.stop(); + await this.done; } } - this.scheduleRuntimePoll(0); - void this.loadHistory(); - if (this.options.subscribeSessionEvents) { - this.unsubscribeSession = this.options.subscribeSessionEvents((event) => { - this.onSessionEvent(event); - }) ?? undefined; - } - if (this.options.subscribeWorkflowEvents) { - this.unsubscribeWorkflow = this.options.subscribeWorkflowEvents((event) => { - this.debugEvent("workflow", event); - void this.refreshWorkflowFromEvent(); - }) ?? undefined; - } - await this.done; + } + + private handleSignal(signal: NodeJS.Signals): void { + process.exitCode = signalExitCode(signal); + if (!this.stopped) this.stop(); } private async resolveTerminalColorScheme(): Promise { @@ -669,33 +838,171 @@ class ZCodeTui { } } + private createTui(mode: TuiMode): TUI { + let fullscreenTui: ZCodeAltScreen | undefined; + const terminal = new NotifyingProcessTerminal((data) => { + fullscreenTui?.setCurrentInput(data); + this.notifications?.handleInput(data); + }, () => fullscreenTui?.setCurrentInput(undefined)); + if (mode !== "fullscreen") return new TuiMainScreen(terminal, true); + + const tui = new ZCodeAltScreen(terminal, true, undefined, { + copySelection: async (text) => { + const writeClipboardText = this.options.writeClipboardText; + if (!writeClipboardText) return false; + try { + await writeClipboardText(text); + return true; + } catch { + return false; + } + }, + mouse: true, + wheelScrollLines: 3 + }); + fullscreenTui = tui; + tui.setViewportInputDeferral((data) => { + const focused = tui.getFocusedComponent(); + if (focused === this.attachmentBar) { + return matchesKey(data, "home") || matchesKey(data, "end"); + } + if (focused !== this.editor) return false; + if (matchesKey(data, "home") || matchesKey(data, "end") + || matchesKey(data, "ctrl+home") || matchesKey(data, "ctrl+end")) return true; + return matchesKey(data, "pageUp") || matchesKey(data, "pageDown") + || matchesKey(data, "ctrl+pageUp") || matchesKey(data, "ctrl+pageDown"); + }); + return tui; + } + private buildLayout(): void { - const workspace = this.options.workspaceDirectory ?? process.cwd(); - const runtimeVersion = sanitizeTerminalText(this.options.version ?? "unknown", { preserveSgr: false }); - this.ui.addChild(new WelcomeBanner(this.theme, { - branch: this.options.workspaceGitBranch, - distributionVersion: this.distributionVersion, - runtimeVersion, - workspace - })); - this.ui.addChild(new Divider("─", this.theme.muted)); - this.ui.addChild(this.loginWarning); - this.ui.addChild(this.loginHelp); + const fullscreen = isViewportTUI(this.ui); + this.fullscreenHeader.setPhase( + fullscreen && this.fullscreenWelcomeVisible ? "welcome" : "rail" + ); + this.sessionWelcome.setLoginRequired(this.loginRequired); + this.sessionWelcome.setIncludeIdentity(!fullscreen); + this.headerHost.clear(); this.updateLoginWarning(); - this.ui.addChild(new Spacer(1)); - this.ui.addChild(this.transcript); - this.ui.addChild(this.runtimeActivity); - this.ui.addChild(this.choiceHost); + if (!fullscreen) { + // Regular mode owns terminal scrollback. Keep a single session intro at + // the top so it naturally scrolls away as the conversation grows. + if (!this.sessionHasContent) this.mountRegularSessionWelcome(); + } + + this.transcriptHost.clear(); + this.sessionWelcomeHost.clear(); + if (fullscreen) this.sessionWelcome.setIncludeIdentity(false); + this.sessionWelcomeHost.addChild(this.sessionWelcome); + if (fullscreen) this.transcriptHost.addChild(this.sessionWelcomeHost); + this.transcriptHost.addChild(this.transcript); + this.transcriptHost.addChild(this.runtimeActivity); + this.transcriptHost.addChild(this.choiceHost); + + this.editorHost.clear(); + this.editorHost.addChild(this.editor); + this.composerHost.clear(); this.composerHost.addChild(this.turnStatus); this.composerHost.addChild(this.queuedInputView); this.composerHost.addChild(this.attachmentBar); - this.composerHost.addChild(this.editor); + this.composerHost.addChild(this.editorHost); this.composerHost.addChild(this.status); + + this.mountLayout(); + } + + private enterSessionRail(immediate = false): void { + this.sessionHasContent = true; + if (this.tuiMode !== "fullscreen") return; + const phase = this.fullscreenHeader.getPhase(); + if (phase === "rail") { + this.fullscreenWelcomeVisible = false; + this.sessionWelcome.setTransitioning(false); + return; + } + if (phase === "transition" && !immediate) return; + if (this.fullscreenWelcomeTransitionTimer) { + clearTimeout(this.fullscreenWelcomeTransitionTimer); + this.fullscreenWelcomeTransitionTimer = undefined; + } + if (immediate || !this.animateTurnTimer) { + this.fullscreenWelcomeVisible = false; + this.sessionWelcome.setTransitioning(false); + this.fullscreenHeader.setPhase("rail"); + this.ui.requestRender(true); + return; + } + + this.sessionWelcome.setTransitioning(true); + this.fullscreenHeader.setPhase("transition"); + this.ui.requestRender(); + this.fullscreenWelcomeTransitionTimer = setTimeout(() => { + this.fullscreenWelcomeTransitionTimer = undefined; + if (this.stopped || this.tuiMode !== "fullscreen") return; + this.fullscreenWelcomeVisible = false; + this.sessionWelcome.setTransitioning(false); + this.fullscreenHeader.setPhase("rail"); + this.ui.requestRender(); + }, fullscreenWelcomeTransitionMs); + this.fullscreenWelcomeTransitionTimer.unref?.(); + } + + private resetSessionPresentation(): void { + if (this.fullscreenWelcomeTransitionTimer) { + clearTimeout(this.fullscreenWelcomeTransitionTimer); + this.fullscreenWelcomeTransitionTimer = undefined; + } + this.sessionHasContent = false; + this.fullscreenWelcomeVisible = true; + this.fullscreenHeader.setPhase("welcome"); + this.sessionWelcome.setTransitioning(false); + if (this.tuiMode === "regular") this.mountRegularSessionWelcome(); + this.ui.requestRender(); + } + + private mountRegularSessionWelcome(): void { + this.headerHost.clear(); + this.sessionWelcome.setIncludeIdentity(true); + this.headerHost.addChild(this.sessionWelcome); + this.headerHost.addChild(new Spacer(1)); + } + + private addTranscriptBlock( + component: Component, + options: Parameters[1] = {} + ): string { + this.enterSessionRail(); + return this.transcript.addBlock(component, options); + } + + private mountLayout(): void { + if (isViewportTUI(this.ui)) { + this.fullscreenTranscript ??= new ScrollView(this.transcriptHost, { + follow: "end", + primary: true, + overscroll: "chain", + scrollbar: "always" + }); + this.fullscreenLayout ??= new VStack([ + { component: this.fullscreenHeader, basis: 1, shrink: 0, minSize: 1 }, + { component: this.fullscreenTranscript, basis: 0, grow: 1, minSize: 1 }, + { component: this.composerHost, basis: "auto", shrink: 1, minSize: 1 } + ]); + this.ui.setLayoutRoot(this.fullscreenLayout); + return; + } + this.ui.clear(); + this.ui.addChild(this.headerHost); + this.ui.addChild(this.transcriptHost); this.ui.addChild(this.composerHost); + } + private createEditor(tui: TUI): Editor { + const editor = new Editor(tui, this.theme.editor, { paddingX: 1, autocompleteMaxVisible: 7 }); + for (const input of [...this.editorHistory].reverse()) editor.addToHistory(input); const commands = this.autocompleteCommands(); const workspaceDirectory = this.options.workspaceDirectory ?? process.cwd(); - this.editor.setAutocompleteProvider( + editor.setAutocompleteProvider( new WorkspaceAutocompleteProvider( commands, workspaceDirectory, @@ -704,25 +1011,104 @@ class ZCodeTui { this.options.listPluginReferences ?? createRuntimePluginReferenceLister(workspaceDirectory) ) ); - this.editor.onSubmit = (text) => void this.submit(text); + editor.onSubmit = (text) => void this.submit(text); + return editor; + } + + private focusEditor(): void { + this.ui.setFocus(this.editor); + } + + private rememberEditorHistory(input: string): void { + const trimmed = input.trim(); + if (!trimmed || this.editorHistory[0] === trimmed) return; + this.editorHistory.unshift(trimmed); + if (this.editorHistory.length > 100) this.editorHistory.pop(); + } + + private async switchTuiMode(next: TuiMode): Promise { + if (this.settingSwitchInFlight) return; + if (next === this.tuiMode) return; + // Background tasks do not block a display switch, but any active + // foreground or suspended submission does. + if (this.activeSubmissions > 0 || this.turnAbortController) { + this.addNotice("Wait for the active turn before switching display mode.", "warning"); + return; + } + this.settingSwitchInFlight = true; + const savedDraft = this.editor.getText(); + const hadSessionContent = this.sessionHasContent || this.transcript.blockCount > 0; + const previousUi = this.ui; + const previousMode = this.tuiMode; + let previousStopped = false; + try { + this.notifications.stop(); + previousStopped = true; + this.ui.stop({ preserveScreen: true }); + this.ui = this.createTui(next); + this.tuiMode = next; + this.sessionHasContent = hadSessionContent; + this.fullscreenWelcomeVisible = !hadSessionContent; + this.fullscreenHeader.setPhase(hadSessionContent ? "rail" : "welcome"); + this.editor = this.createEditor(this.ui); + this.fullscreenTranscript = undefined; + this.fullscreenLayout = undefined; + this.buildLayout(); + this.ui.start(); + this.bindInput(); + this.focusEditor(); + if (savedDraft) this.editor.setText(savedDraft); + this.ui.requestRender(true); + this.notifications.start(); + try { + await writeTuiMode(next); + } catch (error) { + this.addNotice(error instanceof Error ? error.message : String(error), "error"); + } + } catch (error) { + if (this.ui !== previousUi || previousStopped) { + if (this.ui !== previousUi) { + try { + this.ui.stop({ preserveScreen: true }); + } catch { + // Continue with the best-effort rollback below. + } + } + this.ui = previousUi; + this.tuiMode = previousMode; + this.sessionHasContent = hadSessionContent; + this.fullscreenWelcomeVisible = previousMode === "fullscreen" ? !hadSessionContent : true; + this.fullscreenHeader.setPhase(hadSessionContent ? "rail" : "welcome"); + this.fullscreenTranscript = undefined; + this.fullscreenLayout = undefined; + this.editor = this.createEditor(this.ui); + if (savedDraft) this.editor.setText(savedDraft); + this.buildLayout(); + try { + this.ui.start(); + this.bindInput(); + this.focusEditor(); + this.notifications.start(); + this.ui.requestRender(true); + } catch { + // Preserve the original switch failure in the user-facing notice. + } + } + this.addNotice(`Display mode switch failed: ${error instanceof Error ? error.message : String(error)}`, "error"); + } finally { + this.settingSwitchInFlight = false; + } } private updateLoginWarning(): void { - const configPath = userConfigPathHint(); - this.loginWarning.setText( - this.loginRequired ? this.theme.warning("Model access is not configured.") : "" - ); - this.loginHelp.setText( - this.loginRequired - ? this.theme.warning(`Run /login, or configure a custom provider in ${configPath}.`) - : "" - ); + this.sessionWelcome.setLoginRequired(this.loginRequired); } private setLoginRequired(required: boolean): void { const changed = this.loginRequired !== required; this.loginRequired = required; this.updateLoginWarning(); + this.ui.requestRender(); if (changed && !required) { void this.refreshGoal(); void this.refreshSessionUsage(); @@ -785,10 +1171,12 @@ class ZCodeTui { } catch (error) { failure = error instanceof Error ? error.message : String(error); } finally { - this.ui.start(); - this.notifications.start(); - this.ui.setFocus(this.editor); - this.ui.requestRender(true); + if (!this.stopped) { + this.ui.start(); + this.notifications.start(); + this.focusEditor(); + this.ui.requestRender(true); + } } const access = code === 0 ? await readConfiguredModelAccess() : null; @@ -863,6 +1251,22 @@ class ZCodeTui { this.clearRewindEscape(); this.recentSteerCommit = undefined; } + const focusedComponent = (this.ui as TUI & { + getFocusedComponent?: () => Component | null; + }).getFocusedComponent?.(); + if (this.tuiMode === "fullscreen" + && this.fullscreenTranscript + && focusedComponent === this.editor + && !this.editor.getText() + && (matchesKey(data, "pageUp") || matchesKey(data, "pageDown"))) { + if (matchesKey(data, "pageUp")) { + this.fullscreenTranscript.scrollBy(-Math.max(1, this.fullscreenTranscript.viewportHeight - 4)); + } else { + this.fullscreenTranscript.scrollBy(Math.max(1, this.fullscreenTranscript.viewportHeight - 4)); + } + this.ui.requestRender(); + return { consume: true }; + } if (matchesKey(data, "up") && this.canEnterAttachmentSelection()) { this.enterAttachmentSelection(); return { consume: true }; @@ -1001,7 +1405,10 @@ class ZCodeTui { const input = (queuedSubmission?.input ?? rawInput).trim(); if (!input || this.stopped) return; const submission = queuedSubmission ?? protectSubmission(input); - if (submission.recordHistory) this.editor.addToHistory(input); + if (submission.recordHistory) { + this.rememberEditorHistory(input); + this.editor.addToHistory(input); + } if (input === "/exit" || input === "/quit") { this.stop(); @@ -1388,11 +1795,9 @@ class ZCodeTui { pendingInputIds: this.inputQueue.admittedPendingInputIds(), reason: "TUI interrupted the active model step to submit steer instructions.", reservationId: request.reservationId - }).then((outcome) => { + }).then(() => { if (!this.isPendingSteerInterrupt(turnEpoch, abortController)) return; - if (!isRecord(outcome) || asString(outcome.kind) !== "stopped") { - abortController.abort(); - } + abortController.abort(); }).catch(() => { if (this.isPendingSteerInterrupt(turnEpoch, abortController)) { abortController.abort(); @@ -1412,9 +1817,9 @@ class ZCodeTui { abortController.abort(); return; } - void interruptTurn({ reason: "TUI interrupted the active foreground turn." }).then((outcome) => { + void interruptTurn({ reason: "TUI interrupted the active foreground turn." }).then(() => { if (this.turnAbortController !== abortController || abortController.signal.aborted) return; - if (!isRecord(outcome) || asString(outcome.kind) !== "stopped") abortController.abort(); + abortController.abort(); }).catch(() => { if (this.turnAbortController === abortController && !abortController.signal.aborted) { abortController.abort(); @@ -1485,6 +1890,7 @@ class ZCodeTui { emitSessionTerminalTitle(this.options.stdout ?? process.stdout, ""); this.sessionMetrics = {}; this.restoreTranscript(restoredMessages(result.restoredMessages)); + if (this.transcript.blockCount > 0) this.enterSessionRail(true); } const response = responseText(result); @@ -1625,10 +2031,23 @@ class ZCodeTui { this.updateToolView(tool, "running", event.result, undefined, event.progress); } else if (event.kind === "result") { const tool = this.ensureToolView(event.toolCallId, event.toolName, event.partId, event.messageId); - this.updateToolView(tool, toolSucceeded(event.result) ? "complete" : "failed", event.result, undefined, event.progress); + const cancelled = isToolCancellation(event.result); + this.updateToolView( + tool, + cancelled ? "cancelled" : toolSucceeded(event.result) ? "complete" : "failed", + event.result, + undefined, + event.progress + ); } else if (event.kind === "error" && (event.toolCallId || event.toolName)) { const tool = this.ensureToolView(event.toolCallId, event.toolName, event.partId, event.messageId); - this.updateToolView(tool, "failed", event.result, event.error, event.progress); + this.updateToolView( + tool, + isToolCancellation(event.error ?? event.result) ? "cancelled" : "failed", + event.result, + event.error, + event.progress + ); } else if (event.kind === "closed" && (event.toolCallId || event.toolName)) { const tool = this.ensureToolView(event.toolCallId, event.toolName, event.partId, event.messageId); if (!tool.view.isTerminal()) this.updateToolView(tool, "complete", event.result, event.error, event.progress); @@ -1885,7 +2304,9 @@ class ZCodeTui { ? { output: part.output, display: part.resultDisplay } : part.output; if (part.output !== undefined || part.resultDisplay !== undefined) tool.outputText = undefined; - this.updateToolView(tool, restoredToolState(part.status), result, part.error, { + this.updateToolView(tool, isToolCancellation(part.error ?? result) + ? "cancelled" + : restoredToolState(part.status), result, part.error, { parentToolCallId: part.parentToolCallId, childToolCallId: part.childToolCallId, agentId: part.agentId, @@ -2020,6 +2441,7 @@ class ZCodeTui { private clearTranscriptProjection(): void { this.transcript.clear(); + this.resetSessionPresentation(); this.assistantStream.clear(); this.currentThinking = undefined; this.currentThinkingPartId = undefined; @@ -2032,6 +2454,7 @@ class ZCodeTui { } private appendThinking(delta: string, partId?: string, messageId?: string): void { + if (delta.trim()) this.enterSessionRail(); if (partId) { let view = this.thinkingParts.get(partId); if (!view) { @@ -2063,6 +2486,7 @@ class ZCodeTui { private addUserMessage(text: string, attachmentCount = 0, messageId?: string): void { const safeText = sanitizeTerminalText(text, { preserveSgr: false }); + this.enterSessionRail(); const suffix = attachmentCount > 0 ? ` [${attachmentCount} image${attachmentCount === 1 ? "" : "s"}]` : ""; this.currentToolGroup = undefined; this.transcript.addBlock( @@ -2073,6 +2497,7 @@ class ZCodeTui { } private addAssistantMessage(text: string, partId?: string, messageId?: string): void { + this.enterSessionRail(); this.currentToolGroup = undefined; this.transcript.addBlock(new RichMarkdown(text, 1, this.theme), { id: partId, @@ -2152,6 +2577,7 @@ class ZCodeTui { partId?: string, messageId?: string ): ToolViewState { + this.enterSessionRail(); const anonymous = !toolCallId ? Array.from(this.toolViews.values()).findLast((tool) => tool.name === (toolName ?? "tool") && !tool.view.isTerminal()) : undefined; @@ -2537,7 +2963,7 @@ class ZCodeTui { const submission = this.inputQueue.editLatestFollowUp(); if (!submission) return; this.editor.setText(submission.input); - this.ui.setFocus(this.editor); + this.focusEditor(); } private async attachClipboardImage(): Promise { @@ -2589,7 +3015,7 @@ class ZCodeTui { private leaveAttachmentSelection(): void { this.attachmentBar.deactivate(); - this.ui.setFocus(this.editor); + this.focusEditor(); this.ui.requestRender(); } @@ -2608,7 +3034,7 @@ class ZCodeTui { private syncAttachmentBar(): void { const wasActive = this.attachmentBar.isActive(); this.attachmentBar.setAttachments(this.pendingAttachments); - if (wasActive && !this.attachmentBar.isActive()) this.ui.setFocus(this.editor); + if (wasActive && !this.attachmentBar.isActive()) this.focusEditor(); this.ui.requestRender(); } @@ -3247,6 +3673,7 @@ class ZCodeTui { const backend = notificationDeliveryLabel(effective.method, diagnostics.backend); const methodOverride = Boolean(process.env.ZCODE_TUI_NOTIFICATION_METHOD?.trim()); const conditionOverride = Boolean(process.env.ZCODE_TUI_NOTIFICATION_CONDITION?.trim()); + const tuiModeOverride = process.env.ZCODE_TUI_MODE?.trim().toLowerCase(); const savedConfig = await readUserConfig() .then((config) => (isRecord(config.model) ? config.model as Record : undefined)) .catch(() => undefined); @@ -3278,6 +3705,13 @@ class ZCodeTui { description: conditionOverride ? `Current: ${effective.condition} · Saved: ${stored.condition} (environment override)` : `Current: ${stored.condition}` + }, + { + value: "tui-mode", + label: "Display mode", + description: tuiModeOverride === "fullscreen" || tuiModeOverride === "regular" + ? `Current: ${this.tuiMode === "fullscreen" ? "Fullscreen" : "Regular"} (environment override)` + : `Current: ${this.tuiMode === "fullscreen" ? "Fullscreen" : "Regular"}` } ], selectedIndex: selectedSettingIndex @@ -3291,6 +3725,43 @@ class ZCodeTui { continue; } + if (setting.value === "tui-mode") { + selectedSettingIndex = 3; + const selected = await this.showChoice({ + title: "Display mode", + prompt: "Switch between regular and fullscreen TUI.", + help: "Up/Down choose · Enter apply · Esc back", + items: [ + { + value: "regular", + label: "Regular", + description: "Scrollback-style output (default)" + }, + { + value: "fullscreen", + label: "Fullscreen", + description: "Alternate screen with scrollable transcript and scrollbars" + } + ], + selectedIndex: this.tuiMode === "fullscreen" ? 1 : 0 + }); + if (!selected) { + feedback = "No changes · Esc closes settings"; + continue; + } + const next = selected.value as TuiMode; + if (next === this.tuiMode) { + feedback = "Display mode unchanged"; + continue; + } + await this.switchTuiMode(next); + feedback = this.tuiMode === next + ? `Display mode: ${next} · applied` + : "Could not switch display mode"; + selectedSettingIndex = 3; + continue; + } + selectedSettingIndex = setting.value === "notification-condition" ? 2 : setting.value === "notification-method" ? 1 : 0; let next = stored; let changedLabel: string; @@ -3663,6 +4134,7 @@ class ZCodeTui { this.lastAssistantText = ""; this.turnAssistantText = ""; this.restoreTranscript(restored); + if (this.transcript.blockCount > 0) this.enterSessionRail(true); this.editor.setText(target.text); } @@ -4353,7 +4825,7 @@ class ZCodeTui { return await choose(this.ui, this.choiceHost, this.theme, options); } finally { this.choiceDepth = Math.max(0, this.choiceDepth - 1); - this.ui.setFocus(this.editor); + this.focusEditor(); this.ui.requestRender(); } } @@ -4364,7 +4836,7 @@ class ZCodeTui { return await promptText(this.ui, this.choiceHost, this.theme, options); } finally { this.choiceDepth = Math.max(0, this.choiceDepth - 1); - this.ui.setFocus(this.editor); + this.focusEditor(); this.ui.requestRender(); } } @@ -4400,7 +4872,10 @@ class ZCodeTui { break; } } - for (const input of history.reverse()) this.editor.addToHistory(input); + for (const input of history.reverse()) { + this.rememberEditorHistory(input); + this.editor.addToHistory(input); + } } private async restoreInitialTranscript(): Promise { @@ -4758,7 +5233,10 @@ class ZCodeTui { this.finalizeUnresolvedTools(unfinishedToolState); this.turnDiffs.finishTurn(); this.currentToolGroup = undefined; - if (!this.turnWork.finishForeground(Boolean(this.options.readRuntimeProjection))) { + if (unfinishedToolState === "cancelled") { + this.turnWork.cancel(); + this.settleTurnTiming(); + } else if (!this.turnWork.finishForeground(Boolean(this.options.readRuntimeProjection))) { this.settleTurnTiming(); } this.activity = undefined; @@ -4819,6 +5297,7 @@ class ZCodeTui { this.updateCheckAbortController?.abort(); if (this.turnTimer) clearInterval(this.turnTimer); if (this.rewindEscapeTimer) clearTimeout(this.rewindEscapeTimer); + if (this.fullscreenWelcomeTransitionTimer) clearTimeout(this.fullscreenWelcomeTransitionTimer); if (this.runtimeRefreshTimer) clearTimeout(this.runtimeRefreshTimer); if (this.runtimePollTimer) clearTimeout(this.runtimePollTimer); this.unsubscribeSession?.(); @@ -4826,37 +5305,51 @@ class ZCodeTui { const elapsedMilliseconds = this.turnStartedAt === undefined ? this.turnElapsedMilliseconds : Math.max(0, performance.now() - this.turnStartedAt); - this.notifications.stop(); - this.ui.stop(); + try { + this.notifications.stop(); + } catch { + // Notification cleanup must not prevent terminal restoration. + } + try { + this.ui.stop(); + } catch { + // Keep resolving the run even if a terminal implementation fails. + } void this.finishStop(elapsedMilliseconds); } private async finishStop(elapsedMilliseconds: number): Promise { - await this.refreshExitUsage(); - const summary = buildExitSummary({ - elapsedMilliseconds, - metrics: this.sessionMetrics, - sessionId: this.sessionId ?? this.runtimeProjection?.sessionId, - width: this.ui.terminal.columns - }); - const lines = [ - summary.divider && this.theme.muted(summary.divider), - summary.tokenUsage, - summary.resumeCommand - ? `To continue this session, run ${this.theme.accent(summary.resumeCommand)}` - : undefined - ].filter((line): line is string => Boolean(line)); - if (lines.length > 0) { - try { - (this.options.stdout ?? process.stdout).write(`${lines.join("\n")}\n`); - } catch { - // Exit diagnostics must not prevent terminal cleanup. + try { + await this.refreshExitUsage(); + const summary = buildExitSummary({ + elapsedMilliseconds, + metrics: this.sessionMetrics, + sessionId: this.sessionId ?? this.runtimeProjection?.sessionId, + width: this.ui.terminal.columns + }); + const lines = [ + summary.divider && this.theme.muted(summary.divider), + summary.tokenUsage, + summary.resumeCommand + ? `To continue this session, run ${this.theme.accent(summary.resumeCommand)}` + : undefined + ].filter((line): line is string => Boolean(line)); + if (lines.length > 0) { + try { + (this.options.stdout ?? process.stdout).write(`${lines.join("\n")}\n`); + } catch { + // Exit diagnostics must not prevent terminal cleanup. + } } + } catch { + // Exit diagnostics are supplementary; terminal cleanup is authoritative. + } finally { + this.resolveDone(); } - this.resolveDone(); } } export async function runTui(options: TuiOptions): Promise { + suppressTuiAiSdkWarnings(); await new ZCodeTui(options).run(); } diff --git a/packages/zcode-tui/src/tool-view.ts b/packages/zcode-tui/src/tool-view.ts index 0f77a82..5d94845 100644 --- a/packages/zcode-tui/src/tool-view.ts +++ b/packages/zcode-tui/src/tool-view.ts @@ -264,7 +264,16 @@ function resultContent( function errorText(value: unknown): string | undefined { if (value instanceof Error) return sanitizeTerminalText(value.message, { preserveSgr: false }); const direct = asString(value); - return direct ? sanitizeTerminalText(direct, { preserveSgr: false }) : stringify(value); + if (direct) return sanitizeTerminalText(direct, { preserveSgr: false }); + if (!isRecord(value)) return stringify(value); + const message = asString(value.message) ?? asString(value.detail) ?? asString(value.description); + if (message) return sanitizeTerminalText(message, { preserveSgr: false }); + if (value.error !== value) { + const nested = errorText(value.error); + if (nested) return nested; + } + const code = asString(value.code) ?? asString(value.type) ?? asString(value.name); + return code ? sanitizeTerminalText(code, { preserveSgr: false }) : undefined; } function statePresentation(state: string, theme: ZCodeTheme): { icon: string; suffix?: string } { @@ -288,7 +297,7 @@ function statePresentation(state: string, theme: ZCodeTheme): { icon: string; su function stateBackground(state: string, theme: ZCodeTheme): ((text: string) => string) | undefined { const normalized = state.toLowerCase(); - if (normalized === "failed" || normalized === "error" || normalized === "cancelled") { + if (normalized === "failed" || normalized === "error") { return theme.toolErrorBackground; } return normalized === "waiting_permission" ? theme.toolPendingBackground : undefined; @@ -335,6 +344,10 @@ function toolText( presentation.suffix && theme.muted(`· ${presentation.suffix}`) ].filter(Boolean).join(" "); + if (options.state.toLowerCase() === "cancelled") { + return { header, images: [], truncated: false }; + } + const sections: string[] = []; let truncated = false; const mutation = mutationInput(options.name, input); diff --git a/packages/zcode-tui/src/tui-mode.ts b/packages/zcode-tui/src/tui-mode.ts new file mode 100644 index 0000000..3f88319 --- /dev/null +++ b/packages/zcode-tui/src/tui-mode.ts @@ -0,0 +1,48 @@ +import { readUserConfig, updateUserConfig } from "../../../src/model-access.ts"; + +export type TuiMode = "regular" | "fullscreen"; + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function configuredTuiMode(config: unknown): string | undefined { + const value = record(record(config)?.ui)?.tuiMode; + return typeof value === "string" ? value : undefined; +} + +function tuiMode(value: unknown): TuiMode | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + return normalized === "regular" || normalized === "fullscreen" + ? normalized + : undefined; +} + +export function resolveTuiMode( + env: NodeJS.ProcessEnv = process.env, + config?: unknown +): TuiMode { + return tuiMode(env.ZCODE_TUI_MODE) + ?? tuiMode(configuredTuiMode(config)) + ?? "regular"; +} + +export async function readTuiMode( + env: NodeJS.ProcessEnv = process.env +): Promise { + return resolveTuiMode(env, await readUserConfig(env)); +} + +export async function writeTuiMode( + mode: TuiMode, + env: NodeJS.ProcessEnv = process.env +): Promise { + return await updateUserConfig((config) => { + const ui = record(config.ui) ?? {}; + ui.tuiMode = mode; + config.ui = ui; + }, env); +} diff --git a/packages/zcode-tui/src/turn-work-tracker.ts b/packages/zcode-tui/src/turn-work-tracker.ts index 1d4ddd6..99da13b 100644 --- a/packages/zcode-tui/src/turn-work-tracker.ts +++ b/packages/zcode-tui/src/turn-work-tracker.ts @@ -68,6 +68,15 @@ export class TurnWorkTracker { return this.isActive(); } + cancel(): void { + this.foregroundActive = false; + this.awaitingProjection = false; + this.projectionToolActive = false; + this.projectionTurnActive = false; + this.turnId = undefined; + this.taskIds.clear(); + } + reconcile(projection: RuntimeProjectionSnapshot): boolean { const jobs = projection.backgroundJobs; for (const job of jobs) { diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 2283c88..1562775 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -109,6 +109,7 @@ export interface TuiOptions extends RuntimeAdapter { initialMode?: string; initialModel?: unknown; initialThoughtLevel?: string; + initialTuiMode?: "regular" | "fullscreen"; loginRequired?: boolean; locale?: string; theme?: string; diff --git a/scripts/smoke-tui-fullscreen-layout.ts b/scripts/smoke-tui-fullscreen-layout.ts new file mode 100644 index 0000000..170a1c2 --- /dev/null +++ b/scripts/smoke-tui-fullscreen-layout.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env bun + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = join(import.meta.dir, ".."); +const fixture = join(root, "test", "fixtures", "tui-fullscreen-layout.ts"); +const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-layout-")); +const clipboardPath = join(temporaryHome, "clipboard.txt"); +const decoder = new TextDecoder(); +let output = ""; +const terminal = new Bun.Terminal({ + cols: 80, + rows: 24, + name: "xterm-256color", + data(_terminal, data) { + output += decoder.decode(data, { stream: true }); + } +}); + +const child = Bun.spawn([process.execPath, fixture], { + cwd: root, + env: { + ...process.env, + CI: "1", + HOME: temporaryHome, + USERPROFILE: temporaryHome, + TERM: "xterm-256color", + ZCODE_TUI_MODE: "fullscreen", + ZCODE_TUI_NOTIFICATION_METHOD: "off", + ZCODE_TUI_TEST_CLIPBOARD_PATH: clipboardPath + }, + terminal +}); + +function plain(value: string): string { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b_p[^\x07]*\x07/g, "") + .replace(/\r/g, ""); +} + +async function waitFor(pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { + const startedAt = Date.now(); + while (!pattern.test(plain(output.slice(start))) && child.exitCode === null && Date.now() - startedAt < timeoutMs) { + await Bun.sleep(20); + } + if (!pattern.test(plain(output.slice(start)))) { + throw new Error(`Timed out waiting for ${pattern}.\n${plain(output).slice(-4_000)}`); + } +} + +function screenRows(): string[] { + const rows: string[] = []; + const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); + for (const match of writes) rows[Number(match[1]) - 1] = plain(match[2] ?? "").trimEnd(); + return rows; +} + +const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); +let failure: unknown; +try { + await waitFor(/alpha\/model/i); + const startupRows = screenRows(); + if (startupRows.some((row) => row.includes("SYSTEM INITIATED"))) { + throw new Error(`Fullscreen header used the wide banner unexpectedly.\n${startupRows.join("\n")}`); + } + if (!startupRows.some((row) => /^── ◆ ZCODE/u.test(row))) { + throw new Error(`Fullscreen header rail did not render its separator.\n${startupRows.join("\n")}`); + } + if (!startupRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { + throw new Error(`Fullscreen welcome card did not render its frame.\n${startupRows.join("\n")}`); + } + if (!startupRows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Fullscreen welcome surface was not rendered.\n${startupRows.join("\n")}`); + } + const copyRow = startupRows.findIndex((row) => row.includes("Ask a task about this workspace")); + const copyColumn = startupRows[copyRow]?.indexOf("Ask") ?? -1; + if (copyRow < 0 || copyColumn < 0) { + throw new Error(`Could not locate fullscreen text for mouse-copy verification.\n${startupRows.join("\n")}`); + } + terminal.write(`\x1b[<0;${copyColumn + 1};${copyRow + 1}M`); + terminal.write(`\x1b[<32;${copyColumn + 4};${copyRow + 1}M`); + terminal.write(`\x1b[<0;${copyColumn + 4};${copyRow + 1}m`); + await waitFor(/Copied!/i); + const copiedText = await Bun.file(clipboardPath).text(); + if (!copiedText.startsWith("Ask")) { + throw new Error(`Fullscreen selection did not reach the system clipboard writer: ${JSON.stringify(copiedText)}`); + } + const turnStart = output.length; + terminal.write("long transcript\r"); + await waitFor(/transcript line 80/i, turnStart); + const beforeRows = screenRows(); + terminal.write("\x1b[5~"); + await Bun.sleep(100); + const rows = screenRows(); + const firstTranscript = (lines: string[]): number => { + const line = lines.find((value) => /^ transcript line \d+$/u.test(value)); + return line ? Number(line.match(/\d+/u)?.[0] ?? 0) : 0; + }; + const firstTranscriptRow = rows.findIndex((row) => /^ transcript line \d+$/u.test(row)); + if (firstTranscriptRow < 1 || firstTranscriptRow > 3) { + throw new Error(`Fullscreen header consumed too much space. transcriptRow=${firstTranscriptRow}\n${rows.join("\n")}`); + } + if (!rows.some((row) => row.includes("◆ ZCODE"))) { + throw new Error(`Fullscreen context rail was not rendered after the first turn.\n${rows.join("\n")}`); + } + if (rows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Fullscreen welcome surface did not collapse after the first turn.\n${rows.join("\n")}`); + } + if (firstTranscript(rows) >= firstTranscript(beforeRows)) { + throw new Error(`Fullscreen transcript did not scroll independently. before=${firstTranscript(beforeRows)} after=${firstTranscript(rows)}`); + } + const statusRow = rows.findIndex((row) => row.includes("◈ alpha/model")); + if (statusRow < 0 || statusRow < 18) { + throw new Error(`Fullscreen composer was not fixed near the bottom. statusRow=${statusRow}\n${rows.join("\n")}`); + } + const resetStart = output.length; + terminal.write("/cls\r"); + await waitFor(/Ask a task about this workspace/i, resetStart); + const resetRows = screenRows(); + if (!resetRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { + throw new Error(`Fullscreen /cls did not restore the welcome frame.\n${resetRows.join("\n")}`); + } + if (!resetRows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Fullscreen /cls did not restore the welcome content.\n${resetRows.join("\n")}`); + } + terminal.write("\x03"); + await Bun.sleep(40); + terminal.write("\x03"); +} catch (error) { + failure = error; + child.kill("SIGKILL"); +} + +const code = await child.exited; +clearTimeout(timeout); +if (!terminal.closed) terminal.close(); +await rm(temporaryHome, { recursive: true, force: true }); +if (failure) throw failure; +if (code !== 0) throw new Error(`Fullscreen layout smoke exited with ${code}.`); +console.log("Fullscreen fixed-composer layout smoke passed."); diff --git a/scripts/smoke-tui-fullscreen-switch.ts b/scripts/smoke-tui-fullscreen-switch.ts new file mode 100644 index 0000000..277fe91 --- /dev/null +++ b/scripts/smoke-tui-fullscreen-switch.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env bun + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = join(import.meta.dir, ".."); +const fixture = join(root, "test", "fixtures", "tui-features.ts"); +const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-switch-")); +const decoder = new TextDecoder(); +const terminalRows = 40; +let output = ""; +const terminal = new Bun.Terminal({ + cols: 110, + rows: terminalRows, + name: "xterm-256color", + data(_terminal, data) { + output += decoder.decode(data, { stream: true }); + } +}); + +const child = Bun.spawn([process.execPath, fixture], { + cwd: root, + env: { + ...process.env, + CI: "1", + HOME: temporaryHome, + USERPROFILE: temporaryHome, + TERM: "xterm-256color", + TERM_PROGRAM: "iTerm.app", + ZCODE_APP_CLI_EXECUTABLE: process.execPath, + ZCODE_APP_CLI_ENTRY: fixture, + ZCODE_TUI_NOTIFICATION_METHOD: "osc9", + ZCODE_TUI_NOTIFICATION_CONDITION: "unfocused" + }, + terminal +}); + +function plainText(value: string): string { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\r/g, ""); +} + +function screenRows(): string[] { + const rows: string[] = []; + const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); + for (const match of writes) rows[Number(match[1]) - 1] = plainText(match[2] ?? "").trimEnd(); + return rows; +} + +async function waitFor( + label: string, + pattern: RegExp, + start = 0, + timeoutMs = 8_000, + raw = false +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const text = raw ? output.slice(start) : plainText(output.slice(start)); + if (pattern.test(text)) return; + if (child.exitCode !== null) break; + await Bun.sleep(25); + } + throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-6_000)}`); +} + +async function sendAndWait(input: string, label: string, pattern: RegExp, timeoutMs?: number): Promise { + const start = output.length; + terminal.write(input); + await waitFor(label, pattern, start, timeoutMs); + await Bun.sleep(25); + return start; +} + +const timeout = setTimeout(() => child.kill("SIGKILL"), 60_000); + +let interactionError: unknown; +try { + await waitFor("welcome screen", /ZCode/i); + await waitFor("interactive editor", /alpha\/model/i); + await sendAndWait("/settings\r", "settings picker", /ZCode settings/i); + // Navigate to Display mode (4th item). + await sendAndWait("\x1b[B\x1b[B\x1b[B\r", "display mode picker", /Switch between regular and fullscreen/i); + // Select Fullscreen (second item, move down from Regular). + await sendAndWait("\x1b[B\r", "display mode applied", /Switched to fullscreen mode|Display mode: fullscreen/i, 10_000); + // The switch must enter the alternate screen. + await waitFor("alternate screen enter on switch", /\x1b\[\?1049h/, 0, 8_000, true); + await Bun.sleep(75); + const settingsRows = screenRows(); + const settingsTitleRow = settingsRows.findIndex((row) => row.includes("ZCode settings")); + const settingsRuleRow = settingsRows.findLastIndex( + (row, index) => index < settingsTitleRow && /^─{20,}$/u.test(row) + ); + if (settingsTitleRow < Math.floor(terminalRows / 2) || settingsRuleRow !== settingsTitleRow - 1) { + throw new Error(`Fullscreen settings did not render as a separated bottom pane.\n${settingsRows.join("\n")}`); + } + if (settingsRows.slice(settingsRuleRow).some((row) => /Restored (?:startup|later)/u.test(row))) { + throw new Error(`Fullscreen settings mixed with transcript content.\n${settingsRows.join("\n")}`); + } + await sendAndWait("\x1b", "close fullscreen settings", /alpha\/model/i); + // The settings loop redraws its root menu once after the mode switch. A + // second escape closes that menu before exercising the rebuilt editor. + terminal.write("\x1b"); + await Bun.sleep(75); + const restoredRows = screenRows(); + if (!restoredRows.some((row) => row.includes("◆ ZCODE"))) { + throw new Error(`Restored fullscreen session did not render the context rail.\n${restoredRows.join("\n")}`); + } + if (restoredRows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Restored fullscreen session rendered the empty-session welcome unexpectedly.\n${restoredRows.join("\n")}`); + } + // The rebuilt editor must own the new TUI and preserve ordinary navigation. + await sendAndWait("abc", "fullscreen editor input", /abc/); + terminal.write("\x1b[H"); + await Bun.sleep(50); + await sendAndWait("X", "fullscreen editor Home insertion", /Xabc/); + terminal.write("\x15"); + await Bun.sleep(50); + // Async completion must repaint without requiring a follow-up key. + const completionStart = output.length; + terminal.write("inspect @ind"); + await waitFor("fullscreen async completion", /src\/index\.ts/i, completionStart, 4_000); + // Ctrl+C first clears the draft; the second exits an idle TUI. + terminal.write("\x03"); + await Bun.sleep(50); + terminal.write("\x15"); + await Bun.sleep(50); + await sendAndWait("/settings\r", "settings after fullscreen switch", /ZCode settings/i); + await sendAndWait("\x1b[B\x1b[B\x1b[B\r", "display mode picker after fullscreen", /Switch between regular and fullscreen/i); + const regularStart = await sendAndWait("\x1b[A\r", "display mode returned to regular", /Display mode: regular/i, 10_000); + await waitFor("alternate screen exit on switch back", /\x1b\[\?1049l/, regularStart, 8_000, true); + await sendAndWait("\x1b", "close regular settings", /alpha\/model/i); + await sendAndWait("return", "regular editor after switch back", /return/); + terminal.write("\x03"); + await Bun.sleep(50); + const resetStart = await sendAndWait( + "/cls\r", + "regular welcome after transcript reset", + /Ask a task about this workspace/i + ); + const resetOutput = plainText(output.slice(resetStart)); + if (!resetOutput.includes("╭─ ◆ ZCODE") || !resetOutput.includes("╰─ /help commands · /status details")) { + throw new Error(`Regular /cls did not remount the framed session intro.\n${resetOutput.slice(-4_000)}`); + } + terminal.write("\x03"); +} catch (error) { + interactionError = error; + child.kill("SIGKILL"); +} + +const code = await child.exited; +clearTimeout(timeout); +if (!terminal.closed) terminal.close(); +await rm(temporaryHome, { recursive: true, force: true }); +output += decoder.decode(); + +if (interactionError) throw interactionError; +if (!/\x1b\[\?1049h/.test(output)) { + throw new Error(`Runtime switch to fullscreen did not enter the alternate screen.\n${plainText(output).slice(-6_000)}`); +} +if (!/\x1b\[\?1049l/.test(output)) { + throw new Error(`Runtime switch smoke did not restore the main screen on exit.\n${plainText(output).slice(-6_000)}`); +} +if (code !== 0) { + throw new Error(`Runtime switch smoke exited with status ${code}.\n${plainText(output).slice(-6_000)}`); +} + +console.log("Fullscreen runtime-switch smoke test passed."); diff --git a/scripts/smoke-tui-fullscreen.ts b/scripts/smoke-tui-fullscreen.ts new file mode 100644 index 0000000..2e41d6d --- /dev/null +++ b/scripts/smoke-tui-fullscreen.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env bun + +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const root = join(import.meta.dir, ".."); +const fixture = join(root, "test", "fixtures", "tui-fullscreen.ts"); +const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-fullscreen-")); +const configDir = join(temporaryHome, ".zcode", "cli"); +const configPath = join(configDir, "config.json"); +const decoder = new TextDecoder(); +let output = ""; +const terminal = new Bun.Terminal({ + cols: 100, + rows: 32, + name: "xterm-256color", + data(_terminal, data) { + output += decoder.decode(data, { stream: true }); + } +}); + +// Pre-write config.json with ui.tuiMode=fullscreen to verify P1-1: the TUI +// reads the persisted mode on startup even when the vendor runtime does not +// forward an initialTuiMode option. +await mkdir(configDir, { recursive: true }); +await writeFile(configPath, JSON.stringify({ ui: { tuiMode: "fullscreen" } }, null, 2) + "\n"); + +const child = Bun.spawn([process.execPath, fixture], { + cwd: root, + env: { + ...process.env, + CI: "1", + HOME: temporaryHome, + USERPROFILE: temporaryHome, + TERM: "xterm-256color", + TERM_PROGRAM: "iTerm.app", + ZCODE_APP_CLI_EXECUTABLE: process.execPath, + ZCODE_APP_CLI_ENTRY: fixture + }, + terminal +}); + +function plainText(value: string): string { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\r/g, ""); +} + +async function waitFor(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (pattern.test(output.slice(start))) return; + if (child.exitCode !== null) break; + await Bun.sleep(25); + } + throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-4_000)}`); +} + +async function waitForPlain(label: string, pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (pattern.test(plainText(output.slice(start)))) return; + if (child.exitCode !== null) break; + await Bun.sleep(25); + } + throw new Error(`Timed out waiting for ${label}.\n${plainText(output).slice(-4_000)}`); +} + +const timeout = setTimeout(() => child.kill("SIGKILL"), 30_000); + +let interactionError: unknown; +try { + // P1-1: config-persisted fullscreen mode takes effect on startup. + await waitFor("alternate screen enter (config-driven)", /\x1b\[\?1049h/); + await waitForPlain("welcome banner", /ZCode/i); + await waitForPlain("interactive editor", /alpha\/model/i); + // The editor must be constructed against the config-selected fullscreen TUI, + // so delayed autocomplete repaints without a follow-up key. + const completionStart = output.length; + terminal.write("inspect @ind"); + await waitForPlain("config-driven async completion", /src\/index\.ts/i, completionStart, 4_000); + // P1-2: SIGTERM must trigger clean TUI teardown (exit alt screen, show cursor). + child.kill("SIGTERM"); + await waitFor("alternate screen exit on SIGTERM", /\x1b\[\?1049l/); + await waitFor("cursor restored on SIGTERM", /\x1b\[\?25h/); +} catch (error) { + interactionError = error; + child.kill("SIGKILL"); +} + +const code = await child.exited; +clearTimeout(timeout); +if (!terminal.closed) terminal.close(); +await rm(temporaryHome, { recursive: true, force: true }); +output += decoder.decode(); + +if (interactionError) throw interactionError; +// SIGTERM should result in a non-zero exit, but the TUI must have cleaned up. +if (!/\x1b\[\?1049h/.test(output)) { + throw new Error(`Fullscreen TUI did not enter the alternate screen.\n${plainText(output).slice(-4_000)}`); +} +if (!/\x1b\[\?1049l/.test(output)) { + throw new Error(`Fullscreen TUI did not exit the alternate screen on SIGTERM.\n${plainText(output).slice(-4_000)}`); +} +if (!/\x1b\[\?25h/.test(output)) { + throw new Error(`Fullscreen TUI did not restore the cursor on SIGTERM.\n${plainText(output).slice(-4_000)}`); +} +if (code !== 143) { + throw new Error(`Fullscreen TUI SIGTERM exit code was ${code}, expected 143.\n${plainText(output).slice(-4_000)}`); +} + +console.log("Fullscreen TUI smoke test passed."); diff --git a/scripts/smoke-tui-pressure.ts b/scripts/smoke-tui-pressure.ts index d26a90c..93706d4 100644 --- a/scripts/smoke-tui-pressure.ts +++ b/scripts/smoke-tui-pressure.ts @@ -131,6 +131,11 @@ try { await waitFor("foreground Esc cancellation turn", /Bash cancel-pressure/i, foregroundCancelStart); terminal.write("\x1b"); await waitFor("foreground Esc cancellation", /Turn cancelled\./i, foregroundCancelStart, 2_000); + const cancellationSettled = output.length; + await Bun.sleep(1_200); + if (/[🕐-🕛] [1-9]\d*s/u.test(plainText(output.slice(cancellationSettled)))) { + throw new Error("Turn timer kept advancing after foreground Esc cancellation."); + } if (child.exitCode !== null) throw new Error("Esc exited ZCode while cancelling a foreground turn."); terminal.write("\x03"); } catch (error) { @@ -161,9 +166,12 @@ if (/Model request failed/i.test(plain.slice(cancelTurnStart))) { for (const [label, pattern] of [ ["bounded active tool input", /input characters omitted from active tool stream/i], ["bounded active tool output", /output characters omitted from active tool stream/i], - ["bounded cancelled tool result", /completed tool payload retained as a bounded preview/i] + ["quiet cancelled tool", /■ Bash cancel-pressure · cancelled/i] ] as const) { if (!pattern.test(plain)) throw new Error(`Missing ${label}.\n${plain.slice(-5_000)}`); } +if (/TOOL_CANCELLED|internal vendor stack|Bash cancel-pressure · failed/i.test(plain)) { + throw new Error(`Cancelled tool leaked failure internals.\n${plain.slice(-5_000)}`); +} console.log("TUI output-pressure steering, Esc recovery, and cancellation smoke test passed."); diff --git a/scripts/smoke-tui.ts b/scripts/smoke-tui.ts index 96d35a2..81a8197 100755 --- a/scripts/smoke-tui.ts +++ b/scripts/smoke-tui.ts @@ -157,6 +157,12 @@ const timeout = setTimeout(() => { let interactionError: unknown; try { await waitFor("welcome screen", /ZCode/i); + if (!plainText(output).includes("Ask a task about this workspace")) { + throw new Error("Regular TUI did not render the session welcome intro."); + } + if (!plainText(output).includes("╭─ ◆ ZCODE")) { + throw new Error("Regular TUI did not render the framed session header."); + } if (!await Bun.file(configPath).exists()) { throw new Error("The launcher did not create config.json before starting the TUI."); } @@ -217,6 +223,9 @@ try { /(?:Configured BigModel Coding Plan|已配置 BigModel Coding Plan)[\s\S]*◈ bigmodel\/glm-5\.2/i, bigmodelSetupStart ); + await sendAndWait("/status\r", "status details", /Runtime version\s+\d+/i); + terminal.write("\r"); + await Bun.sleep(50); await sendAndWait("/help\r", "help output", /Slash commands:|Usage:/i); await sendAndWait("/mode plan\r", "plan mode", /mode switched to plan|current mode: plan|◈ default ─ ◉ plan/i); terminal.write("/exit\r"); @@ -261,8 +270,11 @@ if (process.env.ZCODE_TUI_SMOKE_DEBUG === "1") console.log(plain); if (code !== 0) throw new Error(`TUI smoke test exited with ${code}.\n${plain.slice(-4_000)}`); if (!/ZCODE/i.test(plain)) throw new Error(`TUI welcome screen was not rendered.\n${plain.slice(-4_000)}`); -if (!plain.includes(`ZCODE v${packageVersion}`) || !/runtime v\d+/u.test(plain)) { - throw new Error(`The TUI did not render the npm and runtime versions separately.\n${plain.slice(-4_000)}`); +if (!plain.includes(`ZCODE v${packageVersion}`)) { + throw new Error(`The TUI header did not render the CLI version.\n${plain.slice(-4_000)}`); +} +if (!/Runtime version\s+\d+/u.test(plain)) { + throw new Error(`The /status view did not render the runtime version.\n${plain.slice(-4_000)}`); } if (!plain.includes(`Update available! ${packageVersion} → ${availableVersion}`)) { throw new Error(`The TUI did not render the cached update notice.\n${plain.slice(-4_000)}`); diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index abcb805..9889b99 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -479,6 +479,13 @@ export function patchRuntimeTuiBridge(runtime: string): string { const activeTranscriptPattern = /sessionStore\.messages\(\{sessionID:([A-Za-z_$][\w$]*)\.sessionId\}\),[A-Za-z_$][\w$]*=await \1\.sessionStore\.getSession\(\1\.sessionId\);return/u; const activeTurnSteerPattern = /(\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\2\?\.inputId,queryId:\2\?\.queryId,expectedTurnId:\2\?\.expectedTurnId,)(?:delivery:"guide",)?(?:pendingInputId:\2\?\.pendingInputId,)?input:/u; const activeTurnGuidePattern = /\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\1\?\.inputId,queryId:\1\?\.queryId,expectedTurnId:\1\?\.expectedTurnId,delivery:"guide",pendingInputId:\1\?\.pendingInputId,input:/u; + const nativeActiveTurnSteerPattern = /([A-Za-z_$][\w$]*)\?\.delivery==="steer_active_turn".{0,700}?\.steerTurn\(\{commandKind:\1\?\.commandKind,delivery:[^,}]+,expectedTurnId:\1\?\.expectedTurnId,input:[^,}]+,inputId:\1\?\.inputId,intent:.{1,160}?,queryId:\1\?\.queryId,/u; + const nativePromptAdmissionPattern = /\.runtime\.admitPrompt\([^{}]{0,500}\{\.\.\.([A-Za-z_$][\w$]*),delivery:[A-Za-z_$][\w$]*,traceContext:\1\?\.traceContext/u; + const legacyStartedTurnResultPattern = /return ([A-Za-z_$][\w$]*)\.kind!=="started_turn"\?\1:([A-Za-z_$][\w$]*)\(\1\.result,([A-Za-z_$][\w$]*),([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\)\)/u; + const supportsActiveTurnSteer = (value: string): boolean => ( + activeTurnGuidePattern.test(value) + || (nativeActiveTurnSteerPattern.test(value) && nativePromptAdmissionPattern.test(value)) + ); const listSkillsBridgePattern = /\.listSkills=async\(\)=>await [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\)/u; const listSkillsOptionPattern = /listSkills:[A-Za-z_$][\w$]*\.listSkills/u; const listModelOptionsOptionPattern = /listModelOptions:[A-Za-z_$][\w$]*\.listModelOptions/u; @@ -508,7 +515,8 @@ export function patchRuntimeTuiBridge(runtime: string): string { && runtime.includes(interruptWaitForIdleMarker) && runtime.includes(".promoteQueuedInput=async(") && runtime.includes(queuedInputPromotionMarker) - && activeTurnGuidePattern.test(runtime) + && !legacyStartedTurnResultPattern.test(runtime) + && supportsActiveTurnSteer(runtime) && transcriptMessageIdPattern.test(runtime) && transcriptAgentMessageIdPattern.test(runtime) && supportsMultiMessageFileRewind(runtime) @@ -541,7 +549,11 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (alreadyPatched) return runtime; let patched = runtime; - if (!activeTurnGuidePattern.test(patched)) { + patched = patched.replace( + legacyStartedTurnResultPattern, + 'return $1.kind!=="started_turn"?$1:$2(await($1.result??$1.completion),$3,$4($5))' + ); + if (!supportsActiveTurnSteer(patched)) { if (!activeTurnSteerPattern.test(patched)) { throw new Error("ZCode runtime is incompatible with the TUI bridge (active-turn steer delivery anchor missing)."); } diff --git a/test/choice-dialog.test.ts b/test/choice-dialog.test.ts index de41a52..48c50a1 100644 --- a/test/choice-dialog.test.ts +++ b/test/choice-dialog.test.ts @@ -11,6 +11,81 @@ import { choose, promptText } from "../packages/zcode-tui/src/choice-dialog.ts"; import { createTheme } from "../packages/zcode-tui/src/theme.ts"; describe("TUI choice dialog", () => { + test("renders fullscreen dialogs as isolated bottom panes", async () => { + const host = new Container(); + const focusState: { current: Component | null } = { current: null }; + let overlay: { component: Component; options?: Record } | undefined; + let hidden = false; + const ui = { + mode: "fullscreen", + terminal: { columns: 120, rows: 30 }, + requestRender() {}, + setFocus(component: Component | null) { + focusState.current = component; + if (component && "focused" in component) { + (component as Component & { focused: boolean }).focused = true; + } + }, + showOverlay(component: Component, options?: Record) { + overlay = { component, options }; + return { + focus() {}, + hide() { hidden = true; }, + isFocused: () => true, + isHidden: () => false, + setHidden() {}, + unfocus() {} + }; + } + } as unknown as TUI; + const overlayComponent = (): Component => { + const component = overlay?.component; + if (!component) throw new Error("Fullscreen dialog did not mount an overlay."); + return component; + }; + + const pending = choose(ui, host, createTheme(false), { + title: "ZCode settings", + prompt: "Display mode: fullscreen · applied", + items: [ + { value: "providers", label: "Model providers", description: "Saved: local/GLM-5.2" }, + { value: "display", label: "Display mode", description: "Current: Fullscreen" } + ] + }); + + expect(host.children).toHaveLength(0); + expect(overlay?.options).toMatchObject({ + anchor: "bottom-left", + maxHeight: "100%", + width: "100%" + }); + const lines = overlayComponent().render(120); + expect(lines[0]).toBe("─".repeat(120)); + expect(lines.at(-1)).toBe("─".repeat(120)); + expect(lines.join("\n")).toContain(" ZCode settings"); + expect(lines.every((line) => visibleWidth(line) <= 120)).toBe(true); + expect(focusState.current).toBe(overlayComponent()); + + focusState.current?.handleInput?.("\x1b"); + expect(await pending).toBeNull(); + expect(hidden).toBeTrue(); + + hidden = false; + overlay = undefined; + const promptPending = promptText(ui, host, createTheme(false), { + title: "Implementation instructions", + prompt: "Enter guidance for the active turn." + }); + const promptLines = overlayComponent().render(120); + expect(promptLines[0]).toBe("─".repeat(120)); + expect(promptLines.at(-1)).toBe("─".repeat(120)); + focusState.current?.handleInput?.("Keep the bottom pane isolated."); + expect(overlayComponent().render(120).join("\n")).toContain("Keep the bottom pane isolated."); + focusState.current?.handleInput?.("\r"); + expect(await promptPending).toBe("Keep the bottom pane isolated."); + expect(hidden).toBeTrue(); + }); + test("renders after a long transcript instead of compositing into its viewport", async () => { const root = new Container(); const transcript = new Text( diff --git a/test/events.test.ts b/test/events.test.ts index eb8ded8..cd1ef8c 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { + isToolCancellation, historyText, isModelCancellationEvent, modelLabel, @@ -42,6 +43,22 @@ describe("ZCode event adapter", () => { expect(failed && isModelCancellationEvent(failed)).toBeFalse(); }); + test("distinguishes tool cancellation payloads from real tool failures", () => { + expect(isToolCancellation({ + type: "tool_cancelled", + message: "Bash was cancelled and the child process was asked to stop", + code: "TOOL_CANCELLED", + stack: "internal stack" + })).toBeTrue(); + expect(isToolCancellation({ status: "failed", error: { code: "TOOL_CANCELLED" } })).toBeTrue(); + expect(isToolCancellation(new DOMException("Aborted", "AbortError"))).toBeTrue(); + expect(isToolCancellation({ + type: "tool_error", + message: "Command exited with code 1", + code: "COMMAND_FAILED" + })).toBeFalse(); + }); + test("normalizes protocol streaming events", () => { expect(normalizeEvent({ id: "event_stream_delta", diff --git a/test/fixtures/tui-fullscreen-layout.ts b/test/fixtures/tui-fullscreen-layout.ts new file mode 100644 index 0000000..41511c3 --- /dev/null +++ b/test/fixtures/tui-fullscreen-layout.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env bun + +import { runTui } from "../../packages/zcode-tui/src/index.ts"; + +const response = Array.from({ length: 80 }, (_, index) => `transcript line ${index + 1}`).join("\n"); + +await runTui({ + version: "fullscreen-layout-smoke", + workspaceDirectory: process.cwd(), + initialMode: "build", + initialModel: "alpha/model", + initialThoughtLevel: "low", + loginRequired: true, + modelOptions: [{ id: "alpha/model", name: "Alpha" }], + effortOptions: [{ id: "low", label: "Low" }], + writeClipboardText: process.env.ZCODE_TUI_TEST_CLIPBOARD_PATH + ? async (text) => { + await Bun.write(process.env.ZCODE_TUI_TEST_CLIPBOARD_PATH!, text); + } + : undefined, + submitPrompt: async () => ({ response, model: "alpha/model", thoughtLevel: "low" }) +}); diff --git a/test/fixtures/tui-fullscreen.ts b/test/fixtures/tui-fullscreen.ts new file mode 100644 index 0000000..042bf44 --- /dev/null +++ b/test/fixtures/tui-fullscreen.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env bun + +// Minimal fixture that boots ZCodeTui in fullscreen mode via the +// ZCODE_TUI_MODE environment variable. The smoke script +// (scripts/smoke-tui-fullscreen.ts) asserts that TuiAltScreen enters the +// alternate screen (DEC private mode 1049). The fixture exits immediately +// after the editor becomes interactive so the smoke layer can observe the +// startup escape sequence. + +import { runTui } from "../../packages/zcode-tui/src/index.ts"; + +let model = "alpha/model"; +let effort = "low"; + +await runTui({ + version: "fullscreen-smoke", + workspaceDirectory: process.cwd(), + initialMode: "build", + initialModel: model, + initialThoughtLevel: effort, + modelOptions: [ + { alias: "main", id: "alpha/model", name: "Alpha" }, + { alias: "lite", id: "beta/model", name: "Beta" } + ], + effortOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" } + ], + listWorkspacePathSuggestions: async ({ token }) => { + await Bun.sleep(120); + return token === "@ind" + ? { items: [{ kind: "file" as const, path: "src/index.ts" }], truncated: false } + : { items: [], truncated: false }; + }, + submitPrompt: async () => ({ response: "ok", model, thoughtLevel: effort }) +}); diff --git a/test/fixtures/tui-pressure.ts b/test/fixtures/tui-pressure.ts index 6f2d6aa..5d959d2 100644 --- a/test/fixtures/tui-pressure.ts +++ b/test/fixtures/tui-pressure.ts @@ -104,6 +104,17 @@ async function runPressureTurn( await Bun.sleep(30); for (let index = 0; index < (cancellable ? 100_000 : 5_000); index += 1) { if (options.abortSignal?.aborted || semanticInterrupt.signal.aborted) { + await emit(options, "tool_call_error", { + error: { + type: "tool_cancelled", + message: "Bash was cancelled and the child process was asked to stop", + code: "TOOL_CANCELLED", + stack: "Error: internal vendor stack" + }, + input: { command }, + toolCallId, + toolName: "Bash" + }); if (semanticInterrupt.signal.aborted) { await emit(options, "model.network_status", { type: "model_request_failed", @@ -172,7 +183,8 @@ await runTui({ if (pendingInputIds?.length) { throw new Error(`Foreground interrupt received pending inputs: ${pendingInputIds.join(", ")}`); } - activeTurnInterrupt.abort(new Error(reason)); + // Model runtimes may acknowledge stop without aborting the TUI's local + // submission signal. ZCode must settle that signal and timer itself. return { kind: "stopped" }; } if (!reason?.includes("steer instructions")) { diff --git a/test/fullscreen-header.test.ts b/test/fullscreen-header.test.ts new file mode 100644 index 0000000..52943e8 --- /dev/null +++ b/test/fullscreen-header.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; + +import { + displayWorkspacePath, + FullscreenHeader, + SessionWelcome +} from "../packages/zcode-tui/src/fullscreen-header.ts"; +import { createTheme } from "../packages/zcode-tui/src/theme.ts"; + +const options = { + branch: "feat-1", + distributionVersion: "3.9.2-16", + runtimeVersion: "0.16.5", + workspace: "/Users/alice/Documents/code/zcode-cli", + homeDirectory: "/Users/alice" +}; + +describe("fullscreen session header", () => { + test("shortens workspace paths using the home directory", () => { + expect(displayWorkspacePath(options.workspace, options.homeDirectory)) + .toBe("~/Documents/code/zcode-cli"); + expect(displayWorkspacePath("/tmp/zcode", options.homeDirectory)).toBe("/tmp/zcode"); + }); + + test("keeps a one-line rail through narrow widths", () => { + const header = new FullscreenHeader(createTheme(false), options); + header.setPhase("rail"); + for (const width of [1, 8, 20, 40, 80, 120]) { + const lines = header.render(width); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0] ?? "")).toBe(width); + } + expect(header.render(80)[0]).toStartWith("── "); + expect(header.render(80)[0]).toContain("◆ ZCODE"); + expect(header.render(80)[0]).toContain("feat-1"); + }); + + test("keeps welcome frames within tiny and wide terminal widths", () => { + const theme = createTheme(false); + const header = new FullscreenHeader(theme, options); + const welcome = new SessionWelcome( + theme, + (width) => header.location(width), + (width) => header.identity(width), + { includeIdentity: true, loginRequired: true } + ); + + for (const width of [1, 2, 5, 8, 20, 40, 76, 100, 160]) { + const lines = welcome.render(width); + expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); + } + expect(welcome.render(160).every((line) => visibleWidth(line) <= 76)).toBe(true); + }); + + test("uses the versioned identity only for the welcome phase", () => { + const header = new FullscreenHeader(createTheme(false), options); + expect(header.render(80)[0]).toContain("v3.9.2-16"); + header.setPhase("transition"); + expect(header.render(80)[0]).toContain("◆ ZCODE"); + expect(header.render(80)[0]).not.toContain("v3.9.2-16"); + }); + + test("renders regular and fullscreen welcome surfaces without a wide logo", () => { + const theme = createTheme(false); + const header = new FullscreenHeader(theme, options); + const regular = new SessionWelcome( + theme, + (width) => header.location(width), + (width) => header.identity(width), + { includeIdentity: true } + ); + const fullscreen = new SessionWelcome( + theme, + (width) => header.location(width), + (width) => header.identity(width), + { includeIdentity: false, loginRequired: true } + ); + + const regularLines = regular.render(100); + const fullscreenLines = fullscreen.render(100); + expect(regularLines).toHaveLength(4); + expect(regularLines[0]).toStartWith("╭─ "); + expect(regularLines[0]).toContain(" ─"); + expect(regularLines.at(-1)).toStartWith("╰─ "); + expect(regularLines.at(-1)).toContain(" ─"); + expect(regularLines.join("\n")).toContain("ZCODE"); + expect(fullscreenLines).toHaveLength(5); + expect(fullscreenLines[0]).toStartWith("╭─ "); + expect(fullscreenLines[0]).toContain(" ─"); + expect(fullscreenLines.at(-1)).toStartWith("╰─ "); + expect(fullscreenLines.join("\n")).toContain("Run /login"); + expect(fullscreenLines.join("\n")).not.toContain("SYSTEM INITIATED"); + }); +}); diff --git a/test/login-flow.test.ts b/test/login-flow.test.ts index 7925e92..c35dba5 100644 --- a/test/login-flow.test.ts +++ b/test/login-flow.test.ts @@ -4,6 +4,7 @@ import { loginFailureDiagnostic, shouldSuspendForLoginCommand, shouldUseNoBrowserForLogin, + suppressTuiAiSdkWarnings, suspendedZaiLoginCommand } from "../packages/zcode-tui/src/index.ts"; @@ -42,4 +43,24 @@ describe("TUI external login routing", () => { "/goal [action] Show or set the current session goal" ].join("\n"))).toBe("Unknown option '--oauth'"); }); + + test("disables the AI SDK console banner without replacing a runtime handler", () => { + const runtimeGlobal = globalThis as typeof globalThis & { + AI_SDK_LOG_WARNINGS?: unknown; + }; + const previous = runtimeGlobal.AI_SDK_LOG_WARNINGS; + const handler = () => {}; + try { + runtimeGlobal.AI_SDK_LOG_WARNINGS = undefined; + suppressTuiAiSdkWarnings(); + expect(runtimeGlobal.AI_SDK_LOG_WARNINGS).toBe(false); + + runtimeGlobal.AI_SDK_LOG_WARNINGS = handler; + suppressTuiAiSdkWarnings(); + expect(runtimeGlobal.AI_SDK_LOG_WARNINGS).toBe(handler); + } finally { + if (previous === undefined) delete runtimeGlobal.AI_SDK_LOG_WARNINGS; + else runtimeGlobal.AI_SDK_LOG_WARNINGS = previous; + } + }); }); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index 16842ed..8661b63 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -660,6 +660,25 @@ describe("runtime synchronization", () => { expect(modernPatched).toContain("r=await e.sessionStore.getSession(e.sessionId);return p(r?R(t,r):t)"); expect(modernPatched).toContain("targetMessageIds&&t.targetMessageIds.length>0"); expect(modernPatched).not.toContain("Array.isArray(t.targetMessageIds)"); + + const nativeSteerRuntime = `${runtimeWithApp.replace( + "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", + "E.sendInput=async(A,$)=>{let d=$?.delivery??\"auto\";return t.runtime.admitPrompt(A,[],{...$,delivery:d,traceContext:$?.traceContext})}," + + "function admit(A,$){if($?.delivery===\"steer_active_turn\")return this.steerTurn({commandKind:$?.commandKind,delivery:void 0,expectedTurnId:$?.expectedTurnId,input:A,inputId:$?.inputId,intent:I($?.intent,\"queue\"),queryId:$?.queryId,toolDisallowlist:$?.toolDisallowlist})}" + )}async function send(H,Z){let Q=await A(),X=await Q.sendInput(H,Z);return X.kind!==\"started_turn\"?X:l1t(X.result,Q,R5(t))}`; + const nativeSteerPatched = patchRuntimeTuiBridge(nativeSteerRuntime); + expect(nativeSteerPatched).toContain('delivery==="steer_active_turn"'); + expect(nativeSteerPatched).toContain('intent:I($?.intent,"queue")'); + expect(nativeSteerPatched).not.toContain('pendingInputId:$?.pendingInputId'); + expect(nativeSteerPatched).toContain('l1t(await(X.result??X.completion),Q,R5(t))'); + expect(nativeSteerPatched).not.toContain('l1t(X.result,Q,R5(t))'); + expect(patchRuntimeTuiBridge(nativeSteerPatched)).toBe(nativeSteerPatched); + expect(() => patchRuntimeTuiBridge( + nativeSteerRuntime.replace( + "return t.runtime.admitPrompt(A,[],{...$,delivery:d,traceContext:$?.traceContext})", + "return t.runtime.submitPrompt(A,$)" + ) + )).toThrow(/active-turn steer delivery anchor missing/); }); test("upgrades an already-patched runtime that lacks the transient model bridge", () => { diff --git a/test/tool-view.test.ts b/test/tool-view.test.ts index 31b9ab8..7e753a8 100644 --- a/test/tool-view.test.ts +++ b/test/tool-view.test.ts @@ -49,6 +49,46 @@ describe("TUI tool execution view", () => { expect(card).not.toContain('"message": "boom"'); }); + test("renders user-cancelled tools as a quiet single-line state", () => { + const card = toolCard({ + name: "Bash", + state: "cancelled", + input: { command: "bun test 2>&1 | tail -20" }, + error: { + type: "tool_cancelled", + message: "Bash was cancelled and the child process was asked to stop", + code: "TOOL_CANCELLED", + stack: "Error: internal vendor stack" + }, + progress: { stdoutTail: "partial output before cancellation" } + }); + + expect(card).toBe("■ Bash bun test 2>&1 | tail -20 · cancelled"); + expect(card).not.toContain("failed"); + expect(card).not.toContain("Error:"); + expect(card).not.toContain("TOOL_CANCELLED"); + expect(card).not.toContain("vendor stack"); + expect(card).not.toContain("partial output"); + }); + + test("shows only the message for structured tool failures", () => { + const card = toolCard({ + name: "Bash", + state: "failed", + input: { command: "false" }, + error: { + code: "COMMAND_FAILED", + message: "Command exited with code 1", + stack: "Error: internal vendor stack" + } + }); + + expect(card).toContain("✗ Bash false · failed"); + expect(card).toContain("Error: Command exited with code 1"); + expect(card).not.toContain("COMMAND_FAILED"); + expect(card).not.toContain("vendor stack"); + }); + test("keeps active payloads intact and compacts them at the terminal state", () => { const result = { stdout: `HEAD-${"x".repeat(1_000_000)}-TAIL`, success: true }; const view = new ToolExecutionView(createTheme(false), { diff --git a/test/tui-mode.test.ts b/test/tui-mode.test.ts new file mode 100644 index 0000000..8fa7856 --- /dev/null +++ b/test/tui-mode.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readTuiMode, resolveTuiMode, writeTuiMode } from "../packages/zcode-tui/src/tui-mode.ts"; + +describe("TUI mode resolution", () => { + test("uses environment overrides before options and config", () => { + expect(resolveTuiMode({ ZCODE_TUI_MODE: "fullscreen" }, { ui: { tuiMode: "regular" } })) + .toBe("fullscreen"); + expect(resolveTuiMode({}, { ui: { tuiMode: "fullscreen" } })) + .toBe("fullscreen"); + }); + + test("ignores unsupported overrides before falling back", () => { + expect(resolveTuiMode({ ZCODE_TUI_MODE: "unsupported" }, { ui: { tuiMode: "fullscreen" } })) + .toBe("fullscreen"); + expect(resolveTuiMode({}, { ui: { tuiMode: "unsupported" } })) + .toBe("regular"); + }); + + test("reads and writes the persisted mode without replacing other config", async () => { + const home = await mkdtemp(join(tmpdir(), "zcode-tui-mode-test-")); + const configDir = join(home, ".zcode", "cli"); + const env = { HOME: home, USERPROFILE: home }; + try { + await mkdir(configDir, { recursive: true }); + const configPath = join(configDir, "config.json"); + await Bun.write(configPath, JSON.stringify({ model: { main: "zai/glm-5.2" }, ui: { tuiMode: "fullscreen" } })); + expect(await readTuiMode(env)).toBe("fullscreen"); + await writeTuiMode("regular", env); + expect(await Bun.file(configPath).json()).toEqual({ + model: { main: "zai/glm-5.2" }, + ui: { tuiMode: "regular" } + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/test/turn-work-tracker.test.ts b/test/turn-work-tracker.test.ts index b116e1d..2f111c0 100644 --- a/test/turn-work-tracker.test.ts +++ b/test/turn-work-tracker.test.ts @@ -116,4 +116,21 @@ describe("turn work tracker", () => { expect(tracker.finishForeground(true)).toBeTrue(); expect(tracker.reconcile(projection({ currentTurnId: "turn-newer" }))).toBeFalse(); }); + + test("drops all retained work when the user cancels the turn", () => { + const tracker = new TurnWorkTracker(); + tracker.begin(); + tracker.bindTurn("turn-current"); + tracker.handle(event({ type: "background_task_started", taskId: "task-a", turnId: "turn-current" })); + expect(tracker.finishForeground(true)).toBeTrue(); + expect(tracker.reconcile(projection({ + activeToolCalls: [{ toolCallId: "tool-a", toolName: "Bash", status: "running" }], + backgroundJobs: [job("task-a", "running")], + currentTurnId: "turn-current" + }))).toBeTrue(); + + tracker.cancel(); + expect(tracker.isActive()).toBeFalse(); + expect(tracker.ownsTask("task-a")).toBeFalse(); + }); });