mini: add monochrome ASCII mode (#38173)

This commit is contained in:
Simon Klee
2026-07-21 22:01:09 +02:00
committed by GitHub
parent 7ec1b6e580
commit e8d273ae5b
26 changed files with 550 additions and 292 deletions
+2 -2
View File
@@ -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 {
+6 -3
View File
@@ -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" }),
+39 -9
View File
@@ -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))
}
+105 -142
View File
@@ -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 = (
<>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
flexDirection="row"
gap={1}
flexShrink={0}
@@ -308,15 +290,15 @@ function PanelShell(props: {
) : null}
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
{props.hint ? `${props.hint} · ` : ""}esc
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
flexShrink={0}
backgroundColor={background()}
>
@@ -347,25 +329,11 @@ function PanelShell(props: {
)
return (
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{minimal() ? (
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
</box>
) : (
<box
width="100%"
flexDirection="column"
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BORDER}
flexShrink={0}
>
{content}
</box>
)}
{minimal() ? (
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
{content}
</box>
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
{props.mono ? null : (
<box
width="100%"
height={1}
@@ -374,27 +342,8 @@ function PanelShell(props: {
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
) : (
<box
width="100%"
height={1}
border={["left"]}
borderColor={props.theme().highlight}
backgroundColor="transparent"
customBorderChars={PANEL_BOTTOM_BORDER}
flexShrink={0}
>
<box
width="100%"
height={1}
border={["bottom"]}
borderColor={background()}
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
</box>
)}
)}
</box>
</box>
)
}
@@ -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}
>
<RunFooterMenu
theme={props.theme}
@@ -623,11 +572,12 @@ export function RunCommandMenuBody(props: {
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
</PanelShell>
)
@@ -638,21 +588,20 @@ export function RunSettingsBody(props: {
settings: Accessor<MiniSettings>
onClose: () => void
onChange: (change: MiniSettingChange) => void | Promise<void>
mono?: boolean
}) {
const [saving, setSaving] = createSignal<keyof MiniSettings>()
const entries = createMemo<SettingEntry[]>(() => [
{
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}
>
<RunFooterMenu
theme={props.theme}
@@ -712,11 +669,12 @@ export function RunSettingsBody(props: {
limit={PANEL_LIST_ROWS}
empty="No settings found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
</PanelShell>
)
@@ -729,6 +687,7 @@ export function RunSubagentSelectBody(props: {
onClose: () => void
onSelect: (sessionID: string) => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<SubagentEntry[]>(() =>
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}
>
<RunFooterMenu
theme={props.theme}
@@ -776,10 +734,11 @@ export function RunSubagentSelectBody(props: {
limit={SUBAGENT_LIST_ROWS}
empty="No subagents found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -790,6 +749,7 @@ export function RunQueuedPromptSelectBody(props: {
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<QueuedEntry[]>(() =>
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}
>
<RunFooterMenu
theme={props.theme}
@@ -830,10 +789,11 @@ export function RunQueuedPromptSelectBody(props: {
limit={SUBAGENT_LIST_ROWS}
empty="No pending work"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -844,6 +804,7 @@ export function RunSkillSelectBody(props: {
commands: Accessor<RunCommand[] | undefined>
onClose: () => void
onSelect: (name: string) => void
mono?: boolean
}) {
const entries = createMemo<SkillEntry[]>(() =>
(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}
>
<RunFooterMenu
theme={props.theme}
@@ -886,10 +846,11 @@ export function RunSkillSelectBody(props: {
limit={PANEL_LIST_ROWS}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -901,6 +862,7 @@ export function RunVariantSelectBody(props: {
current: Accessor<string | undefined>
onClose: () => void
onSelect: (variant: string | undefined) => void
mono?: boolean
}) {
const entries = createMemo<VariantEntry[]>(() => [
{
@@ -938,8 +900,7 @@ export function RunVariantSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
dark
chrome="minimal"
mono={props.mono}
>
<RunFooterMenu
theme={props.theme}
@@ -950,10 +911,11 @@ export function RunVariantSelectBody(props: {
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -965,6 +927,7 @@ export function RunModelSelectBody(props: {
current: Accessor<RunInput["model"]>
onClose: () => void
onSelect: (model: NonNullable<RunInput["model"]>) => void
mono?: boolean
}) {
const entries = createMemo<ModelEntry[]>(() =>
(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}
>
<RunFooterMenu
theme={props.theme}
@@ -1037,11 +999,12 @@ export function RunModelSelectBody(props: {
limit={PANEL_LIST_ROWS}
empty={props.providers() ? "No results found" : "Models loading"}
border={false}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
</PanelShell>
)
+11 -4
View File
@@ -43,6 +43,7 @@ export function RunFormBody(props: {
openExternal?: (url: string) => Promise<unknown>
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: {
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} flexGrow={1} flexShrink={1}>
<box flexDirection="row" gap={1} flexShrink={0}>
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}></text>
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>{props.mono ? "*" : "◆"}</text>
<text fg={props.theme.text}>{props.request.title}</text>
<Show when={!unsupported() && !formSingle(props.request)}>
<text fg={props.theme.muted}>
@@ -352,7 +353,9 @@ export function RunFormBody(props: {
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
onMouseUp={() => choose(index())}
>
<text fg={active() ? props.theme.highlight : props.theme.muted}>{index() + 1}.</text>
<text fg={active() ? props.theme.highlight : props.theme.muted}>
{props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`}
</text>
<text fg={active() ? props.theme.text : props.theme.muted}>
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
{row.label}
@@ -368,7 +371,9 @@ export function RunFormBody(props: {
<Show when={custom()}>
<box flexDirection="row" gap={1} onMouseUp={() => choose(rows().length)}>
<text fg={state().selected === rows().length ? props.theme.highlight : props.theme.muted}>
{rows().length + 1}.
{props.mono
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
: `${rows().length + 1}.`}
</text>
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
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"}
</text>
<Show when={state().error}>
<text fg={props.theme.error} wrapMode="none" truncate>
+10 -5
View File
@@ -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 (
<box
@@ -187,7 +190,7 @@ export function RunFooterMenu(props: {
>
{border() ? (
<text fg={props.theme().border} wrapMode="none">
{props.mono ? "|" : "┃"}
</text>
) : undefined}
<box
@@ -224,6 +227,8 @@ export function RunFooterMenu(props: {
}
const active = () => 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: {
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
{border() ? (
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
{active() ? "▌" : " "}
{active() ? (props.mono ? ">" : "▌") : " "}
</text>
) : undefined}
<box
@@ -250,7 +255,7 @@ export function RunFooterMenu(props: {
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
<text
fg={active() ? props.theme().selectedText : props.theme().text}
attributes={active() ? TextAttributes.BOLD : undefined}
attributes={attributes()}
wrapMode="none"
truncate
flexShrink={0}
@@ -281,7 +286,7 @@ export function RunFooterMenu(props: {
{row.item.footer ? (
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={active() ? TextAttributes.BOLD : undefined}
attributes={attributes()}
wrapMode="none"
truncate
flexShrink={0}
+24 -25
View File
@@ -11,7 +11,7 @@
// The diff view (when available) uses the same diff component as scrollback
// tool snapshots.
/** @jsxImportSource @opentui/solid */
import type { TextareaRenderable } from "@opentui/core"
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import {
@@ -40,6 +40,7 @@ function buttons(
disabled: boolean,
onHover: (option: PermissionOption) => void,
onSelect: (option: PermissionOption) => void,
mono: boolean,
) {
return (
<box flexDirection="row" gap={1} flexShrink={0}>
@@ -56,7 +57,12 @@ function buttons(
if (!disabled) onSelect(option)
}}
>
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
<text
fg={option === selected ? theme.surface : theme.muted}
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
>
{permissionLabel(option)}
</text>
</box>
)}
</For>
@@ -134,12 +140,20 @@ export function RunPermissionBody(props: {
theme: RunFooterTheme
block: RunBlockTheme
onReply: (input: PermissionReply) => void | Promise<void>
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}
>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}></text>
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>
{props.mono ? "!" : "△"}
</text>
<text fg={props.theme.text}>{title()}</text>
</box>
<Switch>
@@ -346,16 +362,7 @@ export function RunPermissionBody(props: {
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
<Switch>
<Match when={state().stage === "permission"}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
<box width="100%" flexDirection="column" gap={1}>
<Show
when={info().diff}
@@ -427,16 +434,7 @@ export function RunPermissionBody(props: {
</scrollbox>
</Match>
<Match when={true}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line) => (
@@ -472,6 +470,7 @@ export function RunPermissionBody(props: {
setState((prev) => permissionHover(prev, option))
},
run,
props.mono ?? false,
)}
<Show
when={!busy()}
@@ -483,7 +482,7 @@ export function RunPermissionBody(props: {
>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
{props.mono ? "left/right" : "⇆"} <span style={{ fg: props.theme.muted }}>select</span>
</text>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
+8 -2
View File
@@ -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<boolean>
width: Accessor<number>
theme: Accessor<RunFooterTheme>
mono: Accessor<boolean>
history?: Accessor<RunPrompt[]>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
@@ -302,7 +304,9 @@ export function createPromptState(input: PromptInput): PromptState {
const references = createMemo<Auto[]>(() => {
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: {
+18 -8
View File
@@ -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) => (
<box flexDirection="column" gap={0} flexShrink={0}>
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
<RunEntryContent commit={commit()} theme={theme()} opts={{ shellOutput: props.shellOutput?.() ?? true }} />
<RunEntryContent
commit={commit()}
theme={theme()}
opts={{ shellOutput: props.shellOutput?.() ?? true, mono: props.mono }}
/>
</box>
))
let scroll: ScrollBoxRenderable | undefined
@@ -134,11 +140,15 @@ export function RunFooterSubagentBody(props: {
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
{current().status === "running" ? (
<box flexShrink={0}>
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
<spinner
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
interval={props.mono ? 160 : 80}
color={statusColor(footer(), current().status)}
/>
</box>
) : (
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
{statusIcon(current().status)}
{statusIcon(current().status, props.mono ?? false)}
</text>
)}
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
+12 -7
View File
@@ -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
}
+40 -14
View File
@@ -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 = () => <span style={{ fg: theme().muted }}>· </span>
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
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}
/>
</Match>
<Match when={selectingQueued()}>
@@ -662,6 +673,7 @@ export function RunFooterView(props: RunFooterViewProps) {
prompts={queuedPrompts}
onClose={closePanel}
onRows={setSubagentMenuRows}
mono={props.mono}
/>
</Match>
<Match when={commanding()}>
@@ -696,6 +708,7 @@ export function RunFooterView(props: RunFooterViewProps) {
closePanel()
}}
onExit={props.onExit}
mono={props.mono}
/>
</Match>
<Match when={skilling()}>
@@ -715,6 +728,7 @@ export function RunFooterView(props: RunFooterViewProps) {
})
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={modeling()}>
@@ -727,6 +741,7 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onModelSelect(model)
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={varianting()}>
@@ -739,6 +754,7 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onVariantSelect(variant)
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={setting()}>
@@ -747,6 +763,7 @@ export function RunFooterView(props: RunFooterViewProps) {
settings={props.miniSettings}
onClose={closePanel}
onChange={props.onMiniSettingChange}
mono={props.mono}
/>
</Match>
<Match when={active().type === "permission"}>
@@ -756,6 +773,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme()}
block={block()}
onReply={props.onPermissionReply}
mono={props.mono}
/>
</Match>
<Match when={active().type === "form"}>
@@ -779,6 +797,7 @@ export function RunFooterView(props: RunFooterViewProps) {
settledForms.add(input.formID)
formStates.delete(input.formID)
}}
mono={props.mono}
/>
)}
</For>
@@ -800,6 +819,7 @@ export function RunFooterView(props: RunFooterViewProps) {
limit={FOOTER_MENU_ROWS}
border={false}
paddingLeft={0}
mono={props.mono}
/>
</Show>
@@ -814,7 +834,12 @@ export function RunFooterView(props: RunFooterViewProps) {
>
<Show when={modeLabel()}>
{(label) => (
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
<box
paddingLeft={props.mono ? 0 : 1}
paddingRight={1}
backgroundColor={theme().statusAccent}
flexShrink={0}
>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
</text>
@@ -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 ? "|" : "┃",
}}
>
<RunFooterSubagentBody
@@ -928,6 +953,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}
shellOutput={() => props.miniSettings().shell_output === "show"}
mono={props.mono}
/>
</box>
</Show>
+54
View File
@@ -0,0 +1,54 @@
const prefixes: Record<number, string> = {
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))
}
+14 -2
View File
@@ -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 {
+1
View File
@@ -89,5 +89,6 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
thinking: config?.mini?.thinking ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
mono: config?.mini?.mono ?? false,
}
}
+15 -4
View File
@@ -164,6 +164,9 @@ function queueSplash(
// the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
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<Lif
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: input.host.platform === "win32" },
useKittyKeyboard: mono
? { disambiguate: false, alternateKeys: false, events: false, allKeysAsEscapes: false, reportText: false }
: { events: input.host.platform === "win32" },
screenMode: "split-footer",
footerHeight: FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const tuiConfig = await input.tuiConfig
const theme = await resolveRunTheme(renderer, tuiConfig.theme)
const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono)
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
entry: false,
@@ -190,6 +194,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
mono,
})
const labels = footerLabels({
agent: input.agent,
@@ -205,6 +210,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme: theme.splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
}),
)
await renderer.idle().catch(() => {})
@@ -226,10 +232,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
first: input.first,
history: input.history,
theme,
mono,
wrote,
tuiConfig,
miniSettings: {
current: resolveMiniSettings(tuiConfig),
current: miniSettings,
update: input.onMiniSettingChange,
},
onPermissionReply: input.onPermissionReply,
@@ -319,8 +326,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
...splashMeta({
title: splash.title,
session_id: sessionID,
mono,
}),
theme: footer.currentTheme().splash,
mono,
}),
)
await renderer.idle().catch(() => {})
@@ -374,10 +383,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
...splashMeta({
title: splash.title,
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
mono,
}),
theme: footer.currentTheme().splash,
showSession: splash.showSession,
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
mono,
}),
)
renderer.requestRender()
+5 -2
View File
@@ -89,6 +89,7 @@ export class RunScrollbackStream {
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private shellOutput: () => 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))
+3 -3
View File
@@ -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(
() => (
<box width="100%" height={1}>
@@ -289,7 +289,7 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
{" "}
· {input.model} · {input.duration}
{input.mono ? "-" : "·"} {input.model} {input.mono ? "-" : "·"} {input.duration}
</span>
</text>
</box>
+12 -8
View File
@@ -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,
}
}
+65 -1
View File
@@ -506,7 +506,71 @@ export const RUN_THEME_FALLBACK: RunTheme = {
},
}
export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConfig["theme"]): Promise<RunTheme> {
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<RunTheme> {
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,
+4 -3
View File
@@ -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.
+20 -14
View File
@@ -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 [
@@ -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={() => {}}
+29 -11
View File
@@ -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<MiniSettings>(
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
/>
</box>
),
@@ -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()
}
@@ -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,
})
})
+10 -2
View File
@@ -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,
})
})
+18 -1
View File
@@ -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> = {}): 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", () => {