mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:25:32 -04:00
mini: add monochrome ASCII mode (#38173)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user