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", () => {