diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index f0e2d31b81..6af4b66d26 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => { const service = yield* Config.Service return yield* service.update((draft) => { draft.prompt = { paste: "compact" } - draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide" } + draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true } }) }), ) @@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => { expect(config).toEqual({ animations: true, prompt: { paste: "compact" }, - mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" }, + mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }, }) expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment") } finally { diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 447146e41e..373251a1b4 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -125,13 +125,16 @@ export const Info = Schema.Struct({ mini: Schema.optional( Schema.Struct({ thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ - description: "Show or hide model reasoning in Mini", + description: "Show or hide model reasoning", }), shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ - description: "Show or hide raw shell tool output in Mini", + description: "Show or hide raw shell tool output", }), turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ - description: "Show or hide the agent, model, and duration summary in Mini scrollback", + description: "Show or hide the agent, model, and duration summary in scrollback", + }), + mono: Schema.optional(Schema.Boolean).annotate({ + description: "Use monochrome ASCII output", }), }), ).annotate({ description: "Mini transcript presentation settings" }), diff --git a/packages/tui/src/mini/entry.body.ts b/packages/tui/src/mini/entry.body.ts index 7d13a0d867..2d4a03f7f6 100644 --- a/packages/tui/src/mini/entry.body.ts +++ b/packages/tui/src/mini/entry.body.ts @@ -1,4 +1,5 @@ import { toolEntryBody } from "./tool" +import { monoPrefix, monoToolText } from "./mono" import type { RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" export type EntryFlags = { @@ -48,17 +49,17 @@ function markdownBody(content: string): RunEntryBody { } } -function userBody(raw: string): RunEntryBody { +function userBody(raw: string, mono: boolean): RunEntryBody { if (!raw.trim()) { return RUN_ENTRY_NONE } const lead = raw.match(/^\n+/)?.[0] ?? "" const body = lead ? raw.slice(lead.length) : raw - return textBody(`${lead}› ${body}`) + return textBody(`${lead}${mono ? ">" : "›"} ${body}`) } -function reasoningBody(raw: string): RunEntryBody { +function reasoningBody(raw: string, mono: boolean): RunEntryBody { const clean = raw.replace(/\[REDACTED\]/g, "") if (!clean) { return RUN_ENTRY_NONE @@ -68,16 +69,39 @@ function reasoningBody(raw: string): RunEntryBody { const body = lead ? clean.slice(lead.length) : clean const mark = "Thinking:" if (body.startsWith(mark)) { + if (mono) return textBody(`${lead}${mark} ${body.slice(mark.length).trimStart()}`) return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown") } - return codeBody(clean, "markdown") + return mono ? textBody(clean) : codeBody(clean, "markdown") } function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { return textBody(phase === "progress" ? raw : raw.trim()) } +function monoBody(body: RunEntryBody): RunEntryBody { + if (body.type === "none" || body.type === "text") return body + if (body.type === "code" || body.type === "markdown") return textBody(body.content) + const snapshot = body.snapshot + if (snapshot.kind === "code") return textBody(`${snapshot.title}\n${snapshot.content}`) + if (snapshot.kind === "diff") { + return textBody( + snapshot.items + .map((item) => `${item.title}\n${item.diff.trim() || `-${item.deletions ?? 0} lines`}`) + .join("\n\n"), + ) + } + if (snapshot.kind === "task") { + return textBody([snapshot.title, ...snapshot.rows, snapshot.tail].filter(Boolean).join("\n")) + } + return textBody( + ["# Questions", ...snapshot.items.flatMap((item) => [item.question, item.answer]), snapshot.tail] + .filter(Boolean) + .join("\n"), + ) +} + export function entryFlags(commit: StreamCommit): EntryFlags { if (commit.summary) { return { @@ -168,13 +192,17 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru } const raw = cleanRunText(commit.text) + const mono = options?.mono === true if (commit.kind === "user") { - return userBody(raw) + return userBody(raw, mono) } if (commit.kind === "tool") { - return toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE + const body = toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE + const result = mono ? monoBody(body) : body + if (!mono || body.type !== "text" || result.type !== "text" || commit.phase === "progress") return result + return textBody(monoToolText(result.content, true)) } if (commit.kind === "assistant") { @@ -186,7 +214,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE } - return markdownBody(raw) + return mono ? textBody(raw) : markdownBody(raw) } if (commit.kind === "reasoning") { @@ -198,8 +226,10 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE } - return reasoningBody(raw) + return reasoningBody(raw, mono) } - return systemBody(raw, commit.phase) + const body = systemBody(raw, commit.phase) + if (!mono || body.type !== "text") return body + return textBody(monoPrefix(body.content, true)) } diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 1e18cb19af..869d6b342b 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -62,29 +62,13 @@ type SettingEntry = PanelEntry & { } const PANEL_PAD = 2 +const panelPad = (mono?: boolean) => (mono ? 1 : PANEL_PAD) const PANEL_LIST_ROWS = 10 const PANEL_FRAME_ROWS = 6 export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS const SUBAGENT_LIST_ROWS = 12 export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS const PANEL_PAGE = PANEL_LIST_ROWS - 1 -const PANEL_BORDER = { - topLeft: "", - bottomLeft: "", - vertical: "┃", - topRight: "", - bottomRight: "", - horizontal: " ", - bottomT: "", - topT: "", - cross: "", - leftT: "", - rightT: "", -} -const PANEL_BOTTOM_BORDER = { - ...PANEL_BORDER, - vertical: "╹", -} const HALF_BLOCK_BORDER = { topLeft: "", bottomLeft: "", @@ -280,19 +264,17 @@ function PanelShell(props: { onQuery: (query: string) => void children: JSX.Element hint?: string - dark?: boolean - chrome?: "default" | "minimal" + mono?: boolean }) { - const background = () => (props.dark ? props.theme().shade : props.theme().surface) - const minimal = () => props.chrome === "minimal" + const background = () => props.theme().shade const content = ( <> - {props.hint ? `${props.hint} · ` : ""}esc + {props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc @@ -347,25 +329,11 @@ function PanelShell(props: { ) return ( - {minimal() ? ( - - {content} - - ) : ( - - {content} - - )} - {minimal() ? ( - + + {content} + + + {props.mono ? null : ( - - ) : ( - - - - )} + )} + ) } @@ -418,6 +367,7 @@ export function RunCommandMenuBody(props: { onCommand: (name: string) => void onNew: () => void onExit: () => void + mono?: boolean }) { const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) @@ -433,18 +383,18 @@ export function RunCommandMenuBody(props: { }, ...(props.subagents().length > 0 ? [ - { - action: "subagent" as const, - category: "Session", - display: "View subagents", - footer: - activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`, - keywords: props - .subagents() - .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) - .join(" "), - }, - ] + { + action: "subagent" as const, + category: "Session", + display: "View subagents", + footer: + activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`, + keywords: props + .subagents() + .map((item) => `${item.label} ${item.description} ${item.title ?? ""}`) + .join(" "), + }, + ] : []), { action: "slash", @@ -458,16 +408,16 @@ export function RunCommandMenuBody(props: { const prompt: CommandEntry[] = props.commands() === undefined || skills().length > 0 ? [ - { - action: "skill" as const, - category: "Prompt", - display: "Skills", - footer: "/skills", - keywords: `skill skills ${skills() - .map((item) => `${item.name} ${item.description ?? ""}`) - .join(" ")}`.trim(), - }, - ] + { + action: "skill" as const, + category: "Prompt", + display: "Skills", + footer: "/skills", + keywords: `skill skills ${skills() + .map((item) => `${item.name} ${item.description ?? ""}`) + .join(" ")}`.trim(), + }, + ] : [] const agent: CommandEntry[] = [ { @@ -477,17 +427,17 @@ export function RunCommandMenuBody(props: { }, ...(props.queued().length > 0 ? [ - { - action: "queued" as const, - category: "Agent", - display: "View pending work", - footer: `${props.queued().length} pending`, - keywords: props - .queued() - .map((item) => item.prompt.text) - .join(" "), - }, - ] + { + action: "queued" as const, + category: "Agent", + display: "View pending work", + footer: `${props.queued().length} pending`, + keywords: props + .queued() + .map((item) => item.prompt.text) + .join(" "), + }, + ] : []), { action: "variant.cycle", @@ -498,13 +448,13 @@ export function RunCommandMenuBody(props: { }, ...(props.variants().length > 0 ? [ - { - action: "variant.list" as const, - category: "Agent", - display: "Switch model variant", - keywords: `variant variants ${props.variants().join(" ")}`, - }, - ] + { + action: "variant.list" as const, + category: "Agent", + display: "Switch model variant", + keywords: `variant variants ${props.variants().join(" ")}`, + }, + ] : []), ] const commands = (props.commands() ?? []) @@ -533,7 +483,7 @@ export function RunCommandMenuBody(props: { { action: "settings", category: "System", - display: "Open settings", + display: "Settings", footer: "/settings", keywords: "/settings settings preferences configuration", }, @@ -611,8 +561,7 @@ export function RunCommandMenuBody(props: { theme={props.theme} inputRef={controller.inputRef} onQuery={controller.setQuery} - dark - chrome="minimal" + mono={props.mono} > ) @@ -638,21 +588,20 @@ export function RunSettingsBody(props: { settings: Accessor onClose: () => void onChange: (change: MiniSettingChange) => void | Promise + mono?: boolean }) { const [saving, setSaving] = createSignal() const entries = createMemo(() => [ { category: "Transcript", display: "Thinking", - description: "future sessions", footer: saving() === "thinking" ? "saving" : props.settings().thinking, keywords: `thinking reasoning ${props.settings().thinking}`, key: "thinking", }, { category: "Transcript", - display: "Shell tool output", - description: "model-issued commands", + display: "Shell", footer: saving() === "shell_output" ? "saving" : props.settings().shell_output, keywords: `shell tool command output ${props.settings().shell_output}`, key: "shell_output", @@ -660,18 +609,27 @@ export function RunSettingsBody(props: { { category: "Transcript", display: "Turn summary", - description: "agent, model, and duration", footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary, keywords: `turn summary agent model duration ${props.settings().turn_summary}`, key: "turn_summary", }, + { + category: "Terminal", + display: "Monochrome UI", + footer: saving() === "mono" ? "saving" : props.settings().mono ? "on" : "off", + keywords: `mono monochrome ascii legacy compat terminal ${props.settings().mono ? "on" : "off"}`, + key: "mono", + }, ]) const change = (item: SettingEntry) => { if (saving()) return - const current = props.settings()[item.key] + const next: MiniSettingChange = + item.key === "mono" + ? { key: "mono", value: !props.settings().mono } + : { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" } setSaving(item.key) - void Promise.resolve(props.onChange({ key: item.key, value: current === "show" ? "hide" : "show" })) - .catch(() => {}) + void Promise.resolve(props.onChange(next)) + .catch(() => { }) .finally(() => setSaving()) } const controller = createSearchablePanelController({ @@ -700,8 +658,7 @@ export function RunSettingsBody(props: { inputRef={controller.inputRef} onQuery={controller.setQuery} hint="left/right change" - dark - chrome="minimal" + mono={props.mono} > ) @@ -729,6 +687,7 @@ export function RunSubagentSelectBody(props: { onClose: () => void onSelect: (sessionID: string) => void onRows?: (rows: number) => void + mono?: boolean }) { const entries = createMemo(() => props.tabs().map((item) => { @@ -764,8 +723,7 @@ export function RunSubagentSelectBody(props: { theme={props.theme} inputRef={controller.inputRef} onQuery={controller.setQuery} - dark - chrome="minimal" + mono={props.mono} > ) @@ -790,6 +749,7 @@ export function RunQueuedPromptSelectBody(props: { prompts: Accessor onClose: () => void onRows?: (rows: number) => void + mono?: boolean }) { const entries = createMemo(() => props.prompts().map((prompt) => ({ @@ -818,8 +778,7 @@ export function RunQueuedPromptSelectBody(props: { theme={props.theme} inputRef={controller.inputRef} onQuery={controller.setQuery} - dark - chrome="minimal" + mono={props.mono} > ) @@ -844,6 +804,7 @@ export function RunSkillSelectBody(props: { commands: Accessor onClose: () => void onSelect: (name: string) => void + mono?: boolean }) { const entries = createMemo(() => (props.commands() ?? []) @@ -874,8 +835,7 @@ export function RunSkillSelectBody(props: { theme={props.theme} inputRef={controller.inputRef} onQuery={controller.setQuery} - dark - chrome="minimal" + mono={props.mono} > ) @@ -901,6 +862,7 @@ export function RunVariantSelectBody(props: { current: Accessor onClose: () => void onSelect: (variant: string | undefined) => void + mono?: boolean }) { const entries = createMemo(() => [ { @@ -938,8 +900,7 @@ export function RunVariantSelectBody(props: { theme={props.theme} inputRef={controller.inputRef} onQuery={controller.setQuery} - dark - chrome="minimal" + mono={props.mono} > ) @@ -965,6 +927,7 @@ export function RunModelSelectBody(props: { current: Accessor onClose: () => void onSelect: (model: NonNullable) => void + mono?: boolean }) { const entries = createMemo(() => (props.providers() ?? []) @@ -1025,8 +988,7 @@ export function RunModelSelectBody(props: { theme={props.theme} inputRef={controller.inputRef} onQuery={controller.setQuery} - dark - chrome="minimal" + mono={props.mono} > ) diff --git a/packages/tui/src/mini/footer.form.tsx b/packages/tui/src/mini/footer.form.tsx index 1a0c6780ba..eba1ec3676 100644 --- a/packages/tui/src/mini/footer.form.tsx +++ b/packages/tui/src/mini/footer.form.tsx @@ -43,6 +43,7 @@ export function RunFormBody(props: { openExternal?: (url: string) => Promise state?: FormBodyState onState?: (state: FormBodyState) => void + mono?: boolean }) { const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request)) const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => { @@ -258,7 +259,7 @@ export function RunFormBody(props: { - + {props.mono ? "*" : "◆"} {props.request.title} @@ -352,7 +353,9 @@ export function RunFormBody(props: { onMouseOver={() => setState((previous) => formSetSelected(previous, index()))} onMouseUp={() => choose(index())} > - {index() + 1}. + + {props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`} + {multiple() ? `[${picked() ? "x" : " "}] ` : ""} {row.label} @@ -368,7 +371,9 @@ export function RunFormBody(props: { choose(rows().length)}> - {rows().length + 1}. + {props.mono + ? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.` + : `${rows().length + 1}.`} Type your own answer @@ -413,7 +418,9 @@ export function RunFormBody(props: { ? "enter submit esc dismiss" : textual() || state().editing ? "enter save esc dismiss" - : "↑↓ select enter choose tab next esc dismiss"} + : props.mono + ? "up/down select enter choose tab next esc dismiss" + : "↑↓ select enter choose tab next esc dismiss"} diff --git a/packages/tui/src/mini/footer.menu.tsx b/packages/tui/src/mini/footer.menu.tsx index 2e1a18e630..1bdd5dcfdc 100644 --- a/packages/tui/src/mini/footer.menu.tsx +++ b/packages/tui/src/mini/footer.menu.tsx @@ -6,6 +6,7 @@ import { transparent, type RunFooterTheme } from "./theme" import { Locale } from "../util/locale" import { stringWidth } from "../util/string-width" import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller" +import { monoTruncate } from "./mono" export const FOOTER_MENU_ROWS = 8 @@ -77,6 +78,7 @@ export function RunFooterMenu(props: { grouped?: boolean background?: boolean headerColor?: ColorInput + mono?: boolean }) { const term = useTerminalDimensions() const limit = () => props.limit ?? FOOTER_MENU_ROWS @@ -170,7 +172,8 @@ export function RunFooterMenu(props: { descriptionColumn() - footerWidth - 4 - return Locale.truncate(item.description, Math.max(12, available)) + const width = Math.max(12, available) + return props.mono ? monoTruncate(item.description, width, true) : Locale.truncate(item.description, width) } return ( {border() ? ( - ┃ + {props.mono ? "|" : "┃"} ) : undefined} row.index === props.selected() + const attributes = () => + active() ? TextAttributes.BOLD | (props.mono ? TextAttributes.INVERSE : 0) : undefined const background = () => active() ? props.background @@ -236,7 +241,7 @@ export function RunFooterMenu(props: { {border() ? ( - {active() ? "▌" : " "} + {active() ? (props.mono ? ">" : "▌") : " "} ) : undefined} void, onSelect: (option: PermissionOption) => void, + mono: boolean, ) { return ( @@ -56,7 +57,12 @@ function buttons( if (!disabled) onSelect(option) }} > - {permissionLabel(option)} + + {permissionLabel(option)} + )} @@ -134,12 +140,20 @@ export function RunPermissionBody(props: { theme: RunFooterTheme block: RunBlockTheme onReply: (input: PermissionReply) => void | Promise + mono?: boolean }) { const dims = useTerminalDimensions() const [state, setState] = createSignal(createPermissionBodyState(props.request)) - const info = createMemo(() => permissionInfo(props.request, props.directory?.())) + const info = createMemo(() => permissionInfo(props.request, props.directory?.(), props.mono)) const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) + const scrollbar = createMemo(() => ({ + visible: !props.mono, + trackOptions: { + backgroundColor: props.theme.surface, + foregroundColor: props.theme.line, + }, + })) const opts = createMemo(() => permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0), ) @@ -269,7 +283,9 @@ export function RunPermissionBody(props: { flexShrink={0} > - + + {props.mono ? "!" : "△"} + {title()} @@ -346,16 +362,7 @@ export function RunPermissionBody(props: { - + - + {(line) => ( @@ -472,6 +470,7 @@ export function RunPermissionBody(props: { setState((prev) => permissionHover(prev, option)) }, run, + props.mono ?? false, )} - {"⇆"} select + {props.mono ? "left/right" : "⇆"} select enter confirm diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index 039c5f4100..6d0ece4e14 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -28,6 +28,7 @@ import { import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse" import { Keymap } from "../context/keymap" import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor" +import { monoTruncateMiddle } from "./mono" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types" @@ -70,6 +71,7 @@ type PromptInput = { prompt: Accessor width: Accessor theme: Accessor + mono: Accessor history?: Accessor onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void @@ -302,7 +304,9 @@ export function createPromptState(input: PromptInput): PromptState { const references = createMemo(() => { return input.references().map((item) => ({ kind: "mention", - display: Locale.truncateMiddle("@" + item.name, width()), + display: input.mono() + ? monoTruncateMiddle("@" + item.name, width(), true) + : Locale.truncateMiddle("@" + item.name, width()), value: item.name, description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path), part: { @@ -344,7 +348,9 @@ export function createPromptState(input: PromptInput): PromptState { return { kind: "mention", - display: Locale.truncateMiddle("@" + filename, width()), + display: input.mono() + ? monoTruncateMiddle("@" + filename, width(), true) + : Locale.truncateMiddle("@" + filename, width()), value: filename, directory: item.endsWith("/"), part: { diff --git a/packages/tui/src/mini/footer.subagent.tsx b/packages/tui/src/mini/footer.subagent.tsx index 90da2ac28a..32d3f3e1fe 100644 --- a/packages/tui/src/mini/footer.subagent.tsx +++ b/packages/tui/src/mini/footer.subagent.tsx @@ -28,20 +28,20 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) return theme.highlight } -function statusIcon(status: FooterSubagentTab["status"]) { +function statusIcon(status: FooterSubagentTab["status"], mono: boolean) { if (status === "completed") { - return "●" + return mono ? "*" : "●" } if (status === "cancelled") { - return "○" + return mono ? "-" : "○" } if (status === "error") { - return "◍" + return mono ? "!" : "◍" } - return "◔" + return mono ? "." : "◔" } export function RunFooterSubagentBody(props: { @@ -57,6 +57,7 @@ export function RunFooterSubagentBody(props: { // command itself is dispatched through the keymap in footer.view. interrupt?: () => string | undefined shellOutput?: () => boolean + mono?: boolean }) { const theme = createMemo(() => props.theme()) const footer = createMemo(() => theme().footer) @@ -67,6 +68,7 @@ export function RunFooterSubagentBody(props: { backgroundColor: footer().surface, foregroundColor: footer().line, }, + visible: !props.mono, })) const title = createMemo(() => { const current = tab() @@ -87,7 +89,11 @@ export function RunFooterSubagentBody(props: { const rows = indexArray(commits, (commit, index) => ( {index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? : null} - + )) let scroll: ScrollBoxRenderable | undefined @@ -134,11 +140,15 @@ export function RunFooterSubagentBody(props: { {current().status === "running" ? ( - + ) : ( - {statusIcon(current().status)} + {statusIcon(current().status, props.mono ?? false)} )} diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index 08725f1bdf..b21c249612 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -82,6 +82,7 @@ type RunFooterOptions = { first: boolean history?: RunPrompt[] theme: RunTheme + mono: boolean tuiConfig: RunTuiConfig miniSettings: { current: MiniSettings @@ -202,7 +203,7 @@ export class RunFooter implements FooterApi { private paletteRefreshRunning = false private paletteRefreshQueued = false private themeRefreshTimeouts: NodeJS.Timeout[] = [] - private unsubscribeThemeSignal: () => void + private unsubscribeThemeSignal = () => {} private createScrollback(wrote: boolean): RunScrollbackStream { return new RunScrollbackStream(this.renderer, this.theme(), { @@ -214,6 +215,7 @@ export class RunFooter implements FooterApi { .finally(() => this.destroyTheme(theme)) }, shellOutput: () => this.miniSettings().shell_output === "show", + mono: this.options.mono, }) } @@ -281,10 +283,12 @@ export class RunFooter implements FooterApi { this.scrollback = this.createScrollback(options.wrote ?? false) this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy) - this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette) - this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh) - this.renderer.prependInputHandler(this.handleThemeNotification) - this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal) + if (!options.mono) { + this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette) + this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh) + this.renderer.prependInputHandler(this.handleThemeNotification) + this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal) + } const footer = this void render( @@ -307,6 +311,7 @@ export class RunFooter implements FooterApi { variants: footer.variants, currentVariant: footer.currentVariant, theme: footer.theme, + mono: options.mono, tuiConfig: options.tuiConfig, miniSettings: footer.miniSettings, history: footer.history, @@ -876,7 +881,7 @@ export class RunFooter implements FooterApi { try { this.setMiniSettings(await this.options.miniSettings.update(change)) - this.setNotice("settings updated") + this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated") } catch (error) { this.setNotice("failed to save settings") throw error @@ -1018,7 +1023,7 @@ export class RunFooter implements FooterApi { } private handleThemeRefresh = (): void => { - if (this.isGone) { + if (this.isGone || this.options.mono) { return } diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index ad764c329e..68bb043243 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -31,6 +31,7 @@ import { createFormBodyState, type FormBodyState } from "./form.shared" import { footerWidthPolicy } from "./footer.width" import { Keymap } from "../context/keymap" import { modelInfo } from "./variant.shared" +import { monoShortcut } from "./mono" import type { FooterPromptRoute, @@ -84,6 +85,7 @@ type RunFooterViewProps = { subagent?: () => FooterSubagentState queuedPrompts?: () => FooterQueuedPrompt[] theme: () => RunTheme + mono: boolean tuiConfig: RunTuiConfig miniSettings: () => MiniSettings history?: () => RunPrompt[] @@ -174,14 +176,15 @@ export function RunFooterView(props: RunFooterViewProps) { return current.type === "subagent" ? subagent().details[current.sessionID] : undefined }) const shortcuts = Keymap.useShortcuts() - const command = () => shortcuts.get("command.palette.show") ?? "" - const subagentShortcut = () => shortcuts.get("session.child.first") ?? "" - const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? "" - const backgroundShortcut = () => shortcuts.get("session.background") ?? "" - const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? "" - const interrupt = () => shortcuts.get("session.interrupt") ?? "" - const variantCycle = () => shortcuts.all("variant.cycle") ?? "" - const clearShortcut = () => shortcuts.get("prompt.clear") ?? "" + const shortcut = (id: string) => monoShortcut(shortcuts.get(id) ?? "", props.mono) + const command = () => shortcut("command.palette.show") + const subagentShortcut = () => shortcut("session.child.first") + const queuedShortcut = () => shortcut("session.queued_prompts") + const backgroundShortcut = () => shortcut("session.background") + const subagentInterruptShortcut = () => shortcut("subagent.interrupt") + const interrupt = () => shortcut("session.interrupt") + const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono) + const clearShortcut = () => shortcut("prompt.clear") const busy = createMemo(() => props.state().phase === "running") const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) @@ -197,6 +200,12 @@ export function RunFooterView(props: RunFooterViewProps) { const theme = createMemo(() => runTheme().footer) const block = createMemo(() => runTheme().block) const spin = createMemo(() => { + if (props.mono) { + return { + frames: ["-", "\\", "|", "/"], + color: theme().text, + } + } const options = { color: theme().highlight, style: "blocks" as const, @@ -326,6 +335,7 @@ export function RunFooterView(props: RunFooterViewProps) { prompt, width, theme, + mono: () => props.mono, history: props.history, onSubmit: props.onSubmit, onCycle: props.onCycle, @@ -380,7 +390,7 @@ export function RunFooterView(props: RunFooterViewProps) { return "" } - return usage() + return props.mono ? usage().replaceAll(" · ", " - ") : usage() }) const modelStatus = createMemo(() => { const current = model() @@ -441,7 +451,7 @@ export function RunFooterView(props: RunFooterViewProps) { return { key: command(), label: "cmd" } } }) - const sectionSeparator = () => · + const sectionSeparator = () => {props.mono ? "- " : "· "} createEffect(() => { props.onRequestExit?.(composer.requestExit) @@ -619,7 +629,7 @@ export function RunFooterView(props: RunFooterViewProps) { ? undefined : { ...EMPTY_BORDER, - vertical: "█", + vertical: props.mono ? "|" : "█", } } > @@ -654,6 +664,7 @@ export function RunFooterView(props: RunFooterViewProps) { onClose={closePanel} onSelect={openTab} onRows={setSubagentMenuRows} + mono={props.mono} /> @@ -662,6 +673,7 @@ export function RunFooterView(props: RunFooterViewProps) { prompts={queuedPrompts} onClose={closePanel} onRows={setSubagentMenuRows} + mono={props.mono} /> @@ -696,6 +708,7 @@ export function RunFooterView(props: RunFooterViewProps) { closePanel() }} onExit={props.onExit} + mono={props.mono} /> @@ -715,6 +728,7 @@ export function RunFooterView(props: RunFooterViewProps) { }) closePanel() }} + mono={props.mono} /> @@ -727,6 +741,7 @@ export function RunFooterView(props: RunFooterViewProps) { props.onModelSelect(model) closePanel() }} + mono={props.mono} /> @@ -739,6 +754,7 @@ export function RunFooterView(props: RunFooterViewProps) { props.onVariantSelect(variant) closePanel() }} + mono={props.mono} /> @@ -747,6 +763,7 @@ export function RunFooterView(props: RunFooterViewProps) { settings={props.miniSettings} onClose={closePanel} onChange={props.onMiniSettingChange} + mono={props.mono} /> @@ -756,6 +773,7 @@ export function RunFooterView(props: RunFooterViewProps) { theme={theme()} block={block()} onReply={props.onPermissionReply} + mono={props.mono} /> @@ -779,6 +797,7 @@ export function RunFooterView(props: RunFooterViewProps) { settledForms.add(input.formID) formStates.delete(input.formID) }} + mono={props.mono} /> )} @@ -800,6 +819,7 @@ export function RunFooterView(props: RunFooterViewProps) { limit={FOOTER_MENU_ROWS} border={false} paddingLeft={0} + mono={props.mono} /> @@ -814,7 +834,12 @@ export function RunFooterView(props: RunFooterViewProps) { > {(label) => ( - + {label()} @@ -828,7 +853,7 @@ export function RunFooterView(props: RunFooterViewProps) { flexGrow={1} flexShrink={1} minWidth={12} - paddingLeft={1} + paddingLeft={props.mono ? 0 : 1} paddingRight={1} backgroundColor="transparent" > @@ -914,7 +939,7 @@ export function RunFooterView(props: RunFooterViewProps) { borderColor={theme().highlight} customBorderChars={{ ...EMPTY_BORDER, - vertical: "┃", + vertical: props.mono ? "|" : "┃", }} > subagentInterruptShortcut() || undefined} shellOutput={() => props.miniSettings().shell_output === "show"} + mono={props.mono} /> diff --git a/packages/tui/src/mini/mono.ts b/packages/tui/src/mini/mono.ts new file mode 100644 index 0000000000..389cc9d702 --- /dev/null +++ b/packages/tui/src/mini/mono.ts @@ -0,0 +1,54 @@ +const prefixes: Record = { + 0x2192: "->", + 0x2190: "<-", + 0x2731: "*", + 0x2699: "*", + 0x2716: "!", + 0x2717: "!", + 0x25c8: "*", + 0x25c9: "*", + 0x27f3: "*", +} + +export function monoPrefix(value: string, mono: boolean): string { + if (!mono) return value + const point = value.codePointAt(0) + if (point === undefined) return value + const prefix = prefixes[point] + if (!prefix) return value + return prefix + value.slice(point > 0xffff ? 2 : 1) +} + +export function monoToolText(value: string, mono: boolean): string { + const result = monoPrefix(value, mono) + if (!mono) return result + const separator = ` ${String.fromCodePoint(0xb7)} ` + const index = result.lastIndexOf(separator) + if (index === -1) return result + const head = result.slice(0, index) + if (!head.includes(" completed") && head !== "patch" && !/^\d+ questions$/.test(head)) return result + return result.slice(0, index) + " - " + result.slice(index + separator.length) +} + +export function monoShortcut(value: string, mono: boolean): string { + if (!mono) return value + return value + .replaceAll(String.fromCodePoint(0x2192), "right") + .replaceAll(String.fromCodePoint(0x2190), "left") + .replaceAll(String.fromCodePoint(0x2191), "up") + .replaceAll(String.fromCodePoint(0x2193), "down") +} + +export function monoTruncate(value: string, width: number, mono: boolean): string { + if (!mono || value.length <= width) return value + if (width <= 3) return ".".repeat(Math.max(0, width)) + return value.slice(0, width - 3) + "..." +} + +export function monoTruncateMiddle(value: string, width: number, mono: boolean): string { + if (!mono || value.length <= width) return value + if (width <= 3) return ".".repeat(Math.max(0, width)) + const available = width - 3 + const left = Math.ceil(available / 2) + return value.slice(0, left) + "..." + value.slice(value.length - (available - left)) +} diff --git a/packages/tui/src/mini/permission.shared.ts b/packages/tui/src/mini/permission.shared.ts index 65c57941be..fa4610efcb 100644 --- a/packages/tui/src/mini/permission.shared.ts +++ b/packages/tui/src/mini/permission.shared.ts @@ -1,6 +1,7 @@ import type { MiniPermissionRequest, PermissionReply } from "./types" import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../util/permission" import { toolPath } from "./tool" +import { monoPrefix } from "./mono" export type PermissionStage = "permission" | "always" | "reject" export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel" @@ -44,9 +45,9 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] { return [] } -export function permissionInfo(request: MiniPermissionRequest, directory?: string) { +export function permissionInfo(request: MiniPermissionRequest, directory?: string, mono = false) { const state = request.tool?.state - return permissionPresentation( + const info = permissionPresentation( { action: request.action, resources: request.resources, @@ -56,6 +57,17 @@ export function permissionInfo(request: MiniPermissionRequest, directory?: strin }, (value) => toolPath(value, { home: true, directory }), ) + if (!mono) return info + return { + ...info, + icon: monoPrefix(info.icon, true), + lines: [ + ...(info.diff || info.patch ? [info.diff ?? info.patch!] : []), + ...info.lines.map((line) => monoPrefix(line, true)), + ], + diff: undefined, + patch: undefined, + } } export function permissionLabel(option: PermissionOption): string { diff --git a/packages/tui/src/mini/runtime.boot.ts b/packages/tui/src/mini/runtime.boot.ts index 1e9a004d2d..3828e9a454 100644 --- a/packages/tui/src/mini/runtime.boot.ts +++ b/packages/tui/src/mini/runtime.boot.ts @@ -89,5 +89,6 @@ export function resolveMiniSettings(config?: { mini?: Partial }): thinking: config?.mini?.thinking ?? "hide", shell_output: config?.mini?.shell_output ?? "hide", turn_summary: config?.mini?.turn_summary ?? "show", + mono: config?.mini?.mono ?? false, } } diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 64b68803a6..280cb28517 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -164,6 +164,9 @@ function queueSplash( // the entry splash, RunFooter takes over the footer region. export async function createRuntimeLifecycle(input: LifecycleInput): Promise { const footerTask = import("./footer") + const tuiConfig = await input.tuiConfig + const miniSettings = resolveMiniSettings(tuiConfig) + const mono = miniSettings.mono const renderer = await createCliRenderer({ stdin: input.host.terminal.stdin, targetFps: 30, @@ -172,15 +175,16 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) @@ -226,10 +232,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) @@ -374,10 +383,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise boolean + private mono: boolean private pendingThemes: RunTheme[] = [] constructor( @@ -99,11 +100,13 @@ export class RunScrollbackStream { treeSitterClient?: TreeSitterClient onThemeRelease?: (theme: RunTheme) => void shellOutput?: () => boolean + mono?: boolean } = {}, ) { this.treeSitterClient = options.treeSitterClient this.wrote = options.wrote ?? false this.shellOutput = options.shellOutput ?? (() => true) + this.mono = options.mono ?? false this.onThemeRelease = options.onThemeRelease } @@ -351,13 +354,13 @@ export class RunScrollbackStream { if (commit.summary) { this.writeSpacer(1) - this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme })) + this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme, mono: this.mono })) this.markRendered(commit) this.tail = commit return } - const body = entryBody(commit, { shellOutput: this.shellOutput() }) + const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono }) if (body.type === "none") { if (entryDone(commit)) { this.markRendered(await this.finishActive(false)) diff --git a/packages/tui/src/mini/scrollback.writer.tsx b/packages/tui/src/mini/scrollback.writer.tsx index cf577ab2ab..436397248a 100644 --- a/packages/tui/src/mini/scrollback.writer.tsx +++ b/packages/tui/src/mini/scrollback.writer.tsx @@ -5,7 +5,7 @@ import { entryBody, entryFlags } from "./entry.body" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" -import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types" +import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types" export function entryGroupKey(commit: StreamCommit): string | undefined { if (!commit.partID) { @@ -281,7 +281,7 @@ export function spacerWriter(): ScrollbackWriter { }) } -export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) { +export function turnSummaryWriter(input: TurnSummary & { theme: RunTheme; mono?: boolean }) { return createScrollbackWriter( () => ( @@ -289,7 +289,7 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio {input.agent} {" "} - · {input.model} · {input.duration} + {input.mono ? "-" : "·"} {input.model} {input.mono ? "-" : "·"} {input.duration} diff --git a/packages/tui/src/mini/splash.ts b/packages/tui/src/mini/splash.ts index 2172fdfd26..5bef74b812 100644 --- a/packages/tui/src/mini/splash.ts +++ b/packages/tui/src/mini/splash.ts @@ -19,6 +19,7 @@ import { } from "@opentui/core" import { Locale } from "../util/locale" import { go } from "../logo" +import { monoTruncate, monoTruncateMiddle } from "./mono" import type { RunSplashTheme } from "./theme" const SPLASH_TITLE_LIMIT = 50 @@ -27,6 +28,7 @@ const SPLASH_TITLE_FALLBACK = "Untitled session" type SplashInput = { title: string | undefined session_id: string + mono?: boolean } type SplashWriterInput = SplashInput & { @@ -69,7 +71,7 @@ function cells(line: string): Cell[] { return list } -function title(text: string | undefined): string { +function title(text: string | undefined, mono = false): string { if (!text) { return SPLASH_TITLE_FALLBACK } @@ -94,7 +96,7 @@ function title(text: string | undefined): string { return SPLASH_TITLE_FALLBACK } - return Locale.truncate(value, SPLASH_TITLE_LIMIT) + return mono ? monoTruncate(value, SPLASH_TITLE_LIMIT, true) : Locale.truncate(value, SPLASH_TITLE_LIMIT) } function write( @@ -181,7 +183,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback let height = 1 if (kind === "entry") { - const mark = go.right.slice(1) + const mark = input.mono ? ["[O]"] : go.right.slice(1) const top = 1 const body_left = (mark[0]?.length ?? 0) + 2 @@ -200,16 +202,18 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback lines, body_left, top + 1, - Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)), + input.mono + ? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true) + : Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)), left, undefined, ) } - height = top + mark.length + height = top + Math.max(mark.length, input.detail ? 2 : 1) } if (kind === "exit") { - const mark = go.right.slice(1) + const mark = input.mono ? ["[O]"] : go.right.slice(1) const top = 1 const body_left = (mark[0]?.length ?? 0) + 2 const session = "Session " @@ -239,7 +243,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback undefined, TextAttributes.BOLD, ) - height = top + mark.length + height = top + Math.max(mark.length, 2) } const root = new BoxRenderable(ctx.renderContext, { @@ -266,7 +270,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback export function splashMeta(input: SplashInput): SplashMeta { return { - title: title(input.title), + title: title(input.title, input.mono), session_id: input.session_id, } } diff --git a/packages/tui/src/mini/theme.ts b/packages/tui/src/mini/theme.ts index e8457ce447..736eed08bb 100644 --- a/packages/tui/src/mini/theme.ts +++ b/packages/tui/src/mini/theme.ts @@ -506,7 +506,71 @@ export const RUN_THEME_FALLBACK: RunTheme = { }, } -export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConfig["theme"]): Promise { +function monoTheme(mode: "dark" | "light"): RunTheme { + const foreground = RGBA.defaultForeground(mode === "light" ? "#000000" : "#ffffff") + const background = RGBA.defaultBackground(mode === "light" ? "#ffffff" : "#000000") + return { + background, + footer: { + highlight: foreground, + selected: background, + selectedText: foreground, + warning: foreground, + error: foreground, + muted: foreground, + text: foreground, + status: background, + statusAccent: background, + shade: background, + surface: background, + pane: background, + border: foreground, + line: background, + }, + entry: { + system: tone(foreground), + user: tone(foreground), + assistant: tone(foreground), + reasoning: tone(foreground), + tool: tone(foreground), + error: tone(foreground), + }, + splash: { + left: foreground, + right: foreground, + leftShadow: background, + }, + block: { + text: foreground, + muted: foreground, + diffRemoved: foreground, + diffAddedBg: background, + diffRemovedBg: background, + diffContextBg: background, + diffHighlightAdded: foreground, + diffHighlightRemoved: foreground, + diffLineNumber: foreground, + diffAddedLineNumberBg: background, + diffRemovedLineNumberBg: background, + }, + } +} + +export const RUN_THEME_MONO = monoTheme("dark") +const RUN_THEME_MONO_LIGHT = monoTheme("light") + +export async function resolveRunTheme( + renderer: CliRenderer, + config?: RunTuiConfig["theme"], + mono = false, +): Promise { + if (mono) { + const mode = + config?.mode === "light" || config?.mode === "dark" + ? config.mode + : (renderer.themeMode ?? (await renderer.waitForThemeMode(300))) + return mode === "light" ? RUN_THEME_MONO_LIGHT : RUN_THEME_MONO + } try { const colors = await renderer.getPalette({ size: 256, diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 8d0174ac5e..2ed619abe4 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -185,6 +185,7 @@ export type TurnSummary = { export type ScrollbackOptions = { suppressBackgrounds?: boolean shellOutput?: boolean + mono?: boolean } export type ToolCodeSnapshot = { @@ -397,12 +398,12 @@ export type MiniSettings = { thinking: "show" | "hide" shell_output: "show" | "hide" turn_summary: "show" | "hide" + mono: boolean } export type MiniSettingChange = { - key: keyof MiniSettings - value: "show" | "hide" -} + [Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] } +}[keyof MiniSettings] // Lifecycle phase of a scrollback entry. "start" opens the entry, "progress" // appends content (coalesced in the footer queue), "final" closes it. diff --git a/packages/tui/test/mini/entry.body.test.ts b/packages/tui/test/mini/entry.body.test.ts index 284ece5b4b..a5695305c4 100644 --- a/packages/tui/test/mini/entry.body.test.ts +++ b/packages/tui/test/mini/entry.body.test.ts @@ -43,20 +43,21 @@ function structured(next: StreamCommit) { describe("run entry body", () => { test("renders a failed direct shell as an error instead of completed success", () => { - expect( - entryBody( - commit({ - kind: "tool", - text: "Shell exited with code 7", - phase: "final", - source: "tool", - tool: "shell", - toolState: "error", - toolError: "Shell exited with code 7", - shell: { command: "false" }, - }), - ), - ).toEqual({ type: "text", content: "✖ shell failed: Shell exited with code 7" }) + const failed = commit({ + kind: "tool", + text: "Shell exited with code 7", + phase: "final", + source: "tool", + tool: "shell", + toolState: "error", + toolError: "Shell → exited · code 7", + shell: { command: "false" }, + }) + expect(entryBody(failed)).toEqual({ type: "text", content: "✖ shell failed: Shell → exited · code 7" }) + expect(entryBody(failed, { mono: true })).toEqual({ + type: "text", + content: "! shell failed: Shell → exited · code 7", + }) }) test("renders assistant, reasoning, and user entries in their display formats", () => { @@ -114,6 +115,11 @@ describe("run entry body", () => { type: "text", content: "› Inspect footer tabs", }) + expect( + entryBody(commit({ kind: "user", text: "Inspect footer tabs", phase: "start", source: "system" }), { + mono: true, + }), + ).toEqual({ type: "text", content: "> Inspect footer tabs" }) }) for (const item of [ diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index e6cdbe3068..4e1cf95ac6 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -55,7 +55,8 @@ test("down opens subagents from an empty prompt", async () => { subagent={subagents} theme={() => RUN_THEME_FALLBACK} tuiConfig={config} - miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })} + miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })} + mono={false} onSubmit={() => true} onPermissionReply={() => {}} onFormReply={() => {}} diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index c2aac3c6d4..c77cc50c76 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -121,6 +121,7 @@ async function renderFooter( view?: FooterView onFormReply?: (input: unknown) => void miniSettings?: MiniSettings + mono?: boolean onMiniSettingChange?: (change: MiniSettingChange) => void } = {}, ) { @@ -131,7 +132,7 @@ async function renderFooter( const state = footerState(input.state) const config = input.tuiConfig ?? tuiConfig const [miniSettings] = createSignal( - input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show" }, + input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false }, ) function Harness() { return ( @@ -150,6 +151,7 @@ async function renderFooter( view={view} subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} + mono={input.mono ?? false} tuiConfig={config} miniSettings={miniSettings} onSubmit={input.onSubmit ?? (() => true)} @@ -397,6 +399,7 @@ test("direct command panel renders grouped command palette", async () => { const frame = app.captureCharFrame() expect(frame).toContain("Commands") + expect(frame).toMatch(/^ {2}Commands/m) expect(frame).toContain("Search") expect(frame).toContain("Session") expect(frame).toContain("Agent") @@ -424,6 +427,7 @@ test("direct settings panel changes Mini transcript preferences", async () => { thinking: "hide", shell_output: "hide", turn_summary: "show", + mono: false, }) const app = await testRender( () => ( @@ -435,6 +439,7 @@ test("direct settings panel changes Mini transcript preferences", async () => { onChange={(change) => { setSettings((current) => ({ ...current, [change.key]: change.value })) }} + mono /> ), @@ -443,23 +448,32 @@ test("direct settings panel changes Mini transcript preferences", async () => { try { await app.renderOnce() - expect(app.captureCharFrame()).toContain("Settings") - expect(app.captureCharFrame()).toContain("Thinking") - expect(app.captureCharFrame()).toContain("Shell tool output") - expect(app.captureCharFrame()).toContain("Turn summary") - expect(app.captureCharFrame()).toContain("left/right change") + const frame = app.captureCharFrame() + expect(frame).toContain("Settings") + expect(frame).toMatch(/^ Settings/m) + expect(frame).toContain("Thinking") + expect(frame).toContain("Shell") + expect(frame).toContain("Turn summary") + expect(frame).toContain("Monochrome UI") + expect(frame).toContain("left/right change") + expect(frame).not.toMatch(/[^\x00-\x7F]/) app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() - expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show" }) + expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", mono: false }) app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() - expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide" }) + expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", mono: false }) + + app.mockInput.pressKey("ARROW_DOWN") + app.mockInput.pressKey("ARROW_RIGHT") + await app.renderOnce() + expect(settings().mono).toBe(true) } finally { app.renderer.destroy() } @@ -935,11 +949,11 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async ( await app.renderOnce() app.mockInput.pressEnter() await app.renderOnce() - expect(app.captureCharFrame()).toContain("Shell tool output") + expect(app.captureCharFrame()).toContain("Shell") app.mockInput.pressKey("c", { ctrl: true }) await app.renderOnce() - expect(app.captureCharFrame()).not.toContain("Shell tool output") + expect(app.captureCharFrame()).not.toContain("Shell") expect(app.renderer.currentFocusedEditor?.plainText).toBe("") } finally { app.cleanup() @@ -1113,7 +1127,8 @@ test("direct footer shows authoritative pending work while running", async () => ]} theme={() => RUN_THEME_FALLBACK} tuiConfig={tuiConfig} - miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })} + miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })} + mono={false} onSubmit={() => true} onPermissionReply={() => {}} onFormReply={() => {}} @@ -1255,14 +1270,17 @@ test("direct footer omits interrupt key hint when interrupt is unbound", async ( const app = await renderFooter({ tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }), state: { phase: "running" }, + mono: true, }) try { await app.renderOnce() const frame = app.captureCharFrame() + const statusline = frame.split("\n").find((line) => line.includes("interrupt")) expect(frame).toContain("interrupt") expect(frame).not.toContain("ctrl+l") + expect(statusline).toMatch(/^\S/) } finally { app.cleanup() } diff --git a/packages/tui/test/mini/permission.shared.test.ts b/packages/tui/test/mini/permission.shared.test.ts index b8e30644d0..5a5058523a 100644 --- a/packages/tui/test/mini/permission.shared.test.ts +++ b/packages/tui/test/mini/permission.shared.test.ts @@ -155,28 +155,32 @@ describe("run permission shared", () => { }) test("uses source patch text when an edit has no generated diff", () => { - expect( - permissionInfo( - req({ - action: "edit", - resources: ["src/index.ts"], - source: { type: "tool", messageID: "msg-edit", callID: "call-edit" }, - tool: canonicalToolPart( - "edit", - { - status: "running", - input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" }, - structured: {}, - content: [], - }, - "call-edit", - ), - }), + const patch = '*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+const arrow = "→"\n*** End Patch' + const request = req({ + action: "edit", + resources: ["src/index.ts"], + source: { type: "tool", messageID: "msg-edit", callID: "call-edit" }, + tool: canonicalToolPart( + "edit", + { + status: "running", + input: { patchText: patch }, + structured: {}, + content: [], + }, + "call-edit", ), - ).toMatchObject({ + }) + expect(permissionInfo(request)).toMatchObject({ title: "Edit src/index.ts", diff: undefined, - patch: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch", + patch, + }) + expect(permissionInfo(request, undefined, true)).toMatchObject({ + title: "Edit src/index.ts", + lines: [patch], + diff: undefined, + patch: undefined, }) }) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 341dceaeff..3fbf558012 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -103,11 +103,19 @@ describe("run runtime boot", () => { expect(result.theme).toEqual({ mode: "light" }) expect(result.leader.timeout).toBe(450) expect(result.session?.thinking).toBe("show") - expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", turn_summary: "show" }) - expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide" } })).toEqual({ + expect(resolveMiniSettings(result)).toEqual({ + thinking: "hide", + shell_output: "hide", + turn_summary: "show", + mono: false, + }) + expect( + resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide", mono: true } }), + ).toEqual({ thinking: "show", shell_output: "show", turn_summary: "hide", + mono: true, }) }) diff --git a/packages/tui/test/mini/theme.test.ts b/packages/tui/test/mini/theme.test.ts index 550eae755c..1f8a23fb1b 100644 --- a/packages/tui/test/mini/theme.test.ts +++ b/packages/tui/test/mini/theme.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core" -import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme" +import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme" import { DEFAULT_THEMES } from "../../src/theme" const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const @@ -23,12 +23,14 @@ function terminalColors(input: Partial = {}): TerminalColors { function renderer( input: { themeMode?: "dark" | "light" + resolvedThemeMode?: "dark" | "light" colors?: TerminalColors fail?: boolean } = {}, ) { return { themeMode: input.themeMode, + waitForThemeMode: async () => input.resolvedThemeMode ?? input.themeMode ?? null, getPalette: async () => { if (input.fail) { throw new Error("boom") @@ -61,6 +63,21 @@ function spread(color: RGBA) { test("falls back when palette lookup fails", async () => { expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK) + expect(await resolveRunTheme(renderer({ fail: true }), undefined, true)).toBe(RUN_THEME_MONO) + const light = await resolveRunTheme(renderer({ resolvedThemeMode: "light" }), undefined, true) + expect(expectRgba(light.footer.text).toInts().slice(0, 3)).toEqual([0, 0, 0]) + expect(RUN_THEME_MONO.block.syntax).toBeUndefined() + for (const color of [ + RUN_THEME_MONO.background, + ...Object.values(RUN_THEME_MONO.footer), + ...Object.values(RUN_THEME_MONO.splash), + ...Object.values(RUN_THEME_MONO.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)), + ...Object.entries(RUN_THEME_MONO.block) + .filter(([key]) => key !== "syntax") + .map(([, value]) => value), + ]) { + expect(expectRgba(color).intent).toBe("default") + } }) test("resolveTheme preserves Mini indexed color and result shape semantics", () => {