mini: add monochrome ASCII mode (#38173)

This commit is contained in:
Simon Klee 2026-07-21 22:01:09 +02:00 committed by GitHub
commit e8d273ae5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 550 additions and 292 deletions

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 [

View file

@ -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={() => {}}

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()
}

View file

@ -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,
})
})

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,
})
})

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