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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"ui": {
"locale": "auto",
"theme": "auto",
"tuiMode": "regular",
"notifications": {
"method": "auto",
"condition": "unfocused"
Expand Down
25 changes: 25 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/zcode-tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 92 additions & 6 deletions packages/zcode-tui/src/choice-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
truncateToWidth,
type Component,
type Container,
type OverlayHandle,
type SelectItem,
type TUI
} from "@earendil-works/pi-tui";
Expand All @@ -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,
Expand Down Expand Up @@ -329,27 +387,39 @@ 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);
};
const onAbort = () => finish(null);
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);
});
}

class TextPromptDialog implements Component {
focused = false;

constructor(
private readonly title: string,
private readonly prompt: string,
Expand All @@ -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),
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions packages/zcode-tui/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading