mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 20:25:36 -04:00
mini: add hideable footer details (#38192)
This commit is contained in:
@@ -133,6 +133,9 @@ export const Info = Schema.Struct({
|
||||
turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide the agent, model, and duration summary in scrollback",
|
||||
}),
|
||||
footer: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide persistent activity, model, usage, and context details in the footer",
|
||||
}),
|
||||
mono: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use monochrome ASCII output",
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ type CommandEntry =
|
||||
| (PanelEntry & { action: "skill" })
|
||||
| (PanelEntry & { action: "queued" })
|
||||
| (PanelEntry & { action: "subagent" })
|
||||
| (PanelEntry & { action: "status" })
|
||||
| (PanelEntry & { action: "variant.cycle" })
|
||||
| (PanelEntry & { action: "variant.list" })
|
||||
| (PanelEntry & { action: "settings" })
|
||||
@@ -363,6 +364,7 @@ export function RunCommandMenuBody(props: {
|
||||
onQueued: () => void
|
||||
onVariant: () => void
|
||||
onVariantCycle: () => void
|
||||
onStatus: () => void
|
||||
onSettings: () => void
|
||||
onCommand: (name: string) => void
|
||||
onNew: () => void
|
||||
@@ -381,6 +383,12 @@ export function RunCommandMenuBody(props: {
|
||||
footer: "/editor",
|
||||
keywords: "editor compose draft external editor",
|
||||
},
|
||||
{
|
||||
action: "status",
|
||||
category: "Session",
|
||||
display: "Show status",
|
||||
keywords: "status activity model context usage footer",
|
||||
},
|
||||
...(props.subagents().length > 0
|
||||
? [
|
||||
{
|
||||
@@ -526,6 +534,11 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "status") {
|
||||
props.onStatus()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.action === "settings") {
|
||||
props.onSettings()
|
||||
return
|
||||
@@ -613,6 +626,13 @@ export function RunSettingsBody(props: {
|
||||
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
|
||||
key: "turn_summary",
|
||||
},
|
||||
{
|
||||
category: "Terminal",
|
||||
display: "Footer details",
|
||||
footer: saving() === "footer" ? "saving" : props.settings().footer,
|
||||
keywords: `footer status activity model context usage ${props.settings().footer}`,
|
||||
key: "footer",
|
||||
},
|
||||
{
|
||||
category: "Terminal",
|
||||
display: "Monochrome UI",
|
||||
|
||||
@@ -56,6 +56,8 @@ import type { RunTheme } from "./theme"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
const FOOTER_DETAIL_DURATION = 3000
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
@@ -189,6 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const footerDetails = createMemo(() => props.miniSettings().footer === "show")
|
||||
const interruptLabel = createMemo(() => {
|
||||
if (!interrupt()) {
|
||||
return
|
||||
@@ -217,6 +220,33 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
color: createColors(options),
|
||||
}
|
||||
})
|
||||
const footerStatus = createMemo(() => {
|
||||
const current = model() ?? props.state().model.trim()
|
||||
const variant = props.currentVariant()
|
||||
const details = [busy() ? "running" : "idle"]
|
||||
if (current) details.push(variant ? `${current} ${variant}` : current)
|
||||
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
|
||||
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
|
||||
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
|
||||
return details.join(props.mono ? " - " : " · ")
|
||||
})
|
||||
const [footerNotice, setFooterNotice] = createSignal("")
|
||||
let footerNoticeTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let previousFooterStatus: string | undefined
|
||||
const showFooterStatus = () => {
|
||||
if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout)
|
||||
setFooterNotice(footerStatus())
|
||||
footerNoticeTimeout = setTimeout(() => {
|
||||
footerNoticeTimeout = undefined
|
||||
setFooterNotice("")
|
||||
}, FOOTER_DETAIL_DURATION)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const current = footerStatus()
|
||||
if (previousFooterStatus !== undefined && previousFooterStatus !== current && !footerDetails()) showFooterStatus()
|
||||
previousFooterStatus = current
|
||||
})
|
||||
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
|
||||
const view = active()
|
||||
return view.type === "permission" ? view : undefined
|
||||
@@ -375,9 +405,11 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return `Press ${clearShortcut() || "ctrl+c"} again to exit`
|
||||
}
|
||||
|
||||
if (busy()) {
|
||||
return armed() ? "again to interrupt" : "interrupt"
|
||||
}
|
||||
if (busy() && armed()) return "again to interrupt"
|
||||
|
||||
if (footerNotice()) return footerNotice()
|
||||
|
||||
if (busy()) return "interrupt"
|
||||
|
||||
if (stateStatus().length > 0) {
|
||||
return stateStatus()
|
||||
@@ -386,7 +418,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return shell() ? "Shell mode" : ""
|
||||
})
|
||||
const activityMeta = createMemo(() => {
|
||||
if (!responsive().statusline.showActivityMeta || usage().length === 0) {
|
||||
if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -394,7 +426,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = model()
|
||||
if (!prompt() || !responsive().statusline.showModel || !current) return
|
||||
if (!footerDetails() || !prompt() || !responsive().statusline.showModel || !current) return
|
||||
return {
|
||||
model: current,
|
||||
variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
|
||||
@@ -409,7 +441,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return theme().highlight
|
||||
}
|
||||
|
||||
if (busy() || stateStatus().length > 0) {
|
||||
if (busy() || footerNotice().length > 0 || stateStatus().length > 0) {
|
||||
return theme().text
|
||||
}
|
||||
|
||||
@@ -419,7 +451,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
|
||||
const hasModelStatus = createMemo(() => Boolean(modelStatus()))
|
||||
const contextHints = createMemo(() => {
|
||||
if (!prompt() || shell() || !responsive().statusline.showContextHints) {
|
||||
if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -459,6 +491,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
onCleanup(() => {
|
||||
props.onRequestExit?.(undefined)
|
||||
if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout)
|
||||
})
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -699,6 +732,10 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.onCycle()
|
||||
closePanel()
|
||||
}}
|
||||
onStatus={() => {
|
||||
showFooterStatus()
|
||||
closePanel()
|
||||
}}
|
||||
onCommand={(name) => {
|
||||
composer.submitText(`/${name}`)
|
||||
closePanel()
|
||||
@@ -857,7 +894,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
<Show when={busy() && !exiting()}>
|
||||
<Show when={footerDetails() && busy() && !exiting()}>
|
||||
<box flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
|
||||
@@ -89,6 +89,7 @@ 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",
|
||||
footer: config?.mini?.footer ?? "show",
|
||||
mono: config?.mini?.mono ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +398,7 @@ export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
shell_output: "show" | "hide"
|
||||
turn_summary: "show" | "hide"
|
||||
footer: "show" | "hide"
|
||||
mono: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ 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", mono: false })}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
|
||||
mono={false}
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
|
||||
@@ -101,7 +101,7 @@ function footerState(input: Partial<FooterState> = {}) {
|
||||
interrupt: 0,
|
||||
exit: 0,
|
||||
...input,
|
||||
})[0]
|
||||
})
|
||||
}
|
||||
|
||||
async function renderFooter(
|
||||
@@ -129,10 +129,10 @@ async function renderFooter(
|
||||
const [subagents] = createSignal<FooterSubagentState>(
|
||||
input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] },
|
||||
)
|
||||
const state = footerState(input.state)
|
||||
const [state, setState] = footerState(input.state)
|
||||
const config = input.tuiConfig ?? tuiConfig
|
||||
const [miniSettings] = createSignal<MiniSettings>(
|
||||
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false },
|
||||
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false },
|
||||
)
|
||||
function Harness() {
|
||||
return (
|
||||
@@ -186,6 +186,7 @@ async function renderFooter(
|
||||
return {
|
||||
...app,
|
||||
setView,
|
||||
setState,
|
||||
cleanup() {
|
||||
app.renderer.currentFocusedRenderable?.blur()
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
@@ -362,6 +363,7 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
])
|
||||
const [subagents] = createSignal([])
|
||||
const [variants] = createSignal(["high", "minimal"])
|
||||
let status = 0
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
@@ -381,6 +383,7 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onStatus={() => status++}
|
||||
onSettings={() => {}}
|
||||
onCommand={() => {}}
|
||||
onNew={() => {}}
|
||||
@@ -406,6 +409,7 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
expect(frame).toContain("Prompt")
|
||||
expect(frame).toContain("Open editor")
|
||||
expect(frame).toContain("/editor")
|
||||
expect(frame).toContain("Show status")
|
||||
expect(frame).toContain("Switch model")
|
||||
expect(frame).toContain("Skills")
|
||||
expect(frame).toContain("/skills")
|
||||
@@ -417,6 +421,12 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
expect(frame).not.toContain("Cycle reasoning effort for future turns")
|
||||
expect(frame).not.toContain("Review code")
|
||||
expect(frame).not.toContain("Commands 8")
|
||||
|
||||
await app.mockInput.typeText("status")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Show status")
|
||||
app.mockInput.pressEnter()
|
||||
expect(status).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -427,6 +437,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "show",
|
||||
footer: "show",
|
||||
mono: false,
|
||||
})
|
||||
const app = await testRender(
|
||||
@@ -454,6 +465,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
expect(frame).toContain("Thinking")
|
||||
expect(frame).toContain("Shell")
|
||||
expect(frame).toContain("Turn summary")
|
||||
expect(frame).toContain("Footer details")
|
||||
expect(frame).toContain("Monochrome UI")
|
||||
expect(frame).toContain("left/right change")
|
||||
expect(frame).not.toMatch(/[^\x00-\x7F]/)
|
||||
@@ -461,15 +473,16 @@ test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", mono: false })
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", footer: "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", mono: false })
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", footer: "show", mono: false })
|
||||
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
@@ -589,6 +602,7 @@ test("direct command panel shows subagent entry when available", async () => {
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onStatus={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCommand={() => {}}
|
||||
onNew={() => {}}
|
||||
@@ -638,6 +652,7 @@ test("direct command panel keeps completed subagents available", async () => {
|
||||
onQueued={() => {}}
|
||||
onVariant={() => {}}
|
||||
onVariantCycle={() => {}}
|
||||
onStatus={() => {}}
|
||||
onSettings={() => {}}
|
||||
onCommand={() => {}}
|
||||
onNew={() => {}}
|
||||
@@ -1127,7 +1142,7 @@ 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", mono: false })}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
|
||||
mono={false}
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
@@ -1301,6 +1316,39 @@ test("direct footer shows full usage metadata when room is available", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer can hide persistent details and briefly reveal changes", async () => {
|
||||
const app = await renderFooter({
|
||||
state: { usage: "159.6K (16%) · $4.23" },
|
||||
miniSettings: {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "show",
|
||||
footer: "hide",
|
||||
mono: true,
|
||||
},
|
||||
mono: true,
|
||||
width: 160,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const initial = app.captureCharFrame()
|
||||
expect(initial).toContain("ctrl+p cmd")
|
||||
expect(initial).not.toContain("gpt-5")
|
||||
expect(initial).not.toContain("159.6K")
|
||||
|
||||
app.setState((state) => ({ ...state, phase: "running" }))
|
||||
await app.renderOnce()
|
||||
const changed = app.captureCharFrame()
|
||||
const statusline = footerStatusline(app.renderer.root)
|
||||
|
||||
expect(changed).toContain("running - gpt-5 - 159.6K (16%) - $4.23")
|
||||
expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined()
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("direct footer does not label normal mode as build", async () => {
|
||||
const app = await renderFooter()
|
||||
|
||||
|
||||
@@ -107,14 +107,18 @@ describe("run runtime boot", () => {
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "show",
|
||||
footer: "show",
|
||||
mono: false,
|
||||
})
|
||||
expect(
|
||||
resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide", mono: true } }),
|
||||
resolveMiniSettings({
|
||||
mini: { thinking: "show", shell_output: "show", turn_summary: "hide", footer: "hide", mono: true },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: "show",
|
||||
shell_output: "show",
|
||||
turn_summary: "hide",
|
||||
footer: "hide",
|
||||
mono: true,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user