Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 9 additions & 34 deletions packages/tui/src/mini/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,6 @@ export class RunFooter implements FooterApi {
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private noticeTimeout: NodeJS.Timeout | undefined
private noticeRestoreStatus = ""
private statusVersion = 0
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
Expand Down Expand Up @@ -225,6 +223,7 @@ export class RunFooter implements FooterApi {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
notice: "",
model: options.modelLabel,
usage: "",
first: options.first,
Expand Down Expand Up @@ -449,6 +448,7 @@ export class RunFooter implements FooterApi {
if (patch) {
if (typeof patch.status === "string") {
this.clearNoticeTimer()
patch.notice = ""
}
if (next.type === "turn.send") {
this.clearInterruptTimer()
Expand Down Expand Up @@ -479,12 +479,10 @@ export class RunFooter implements FooterApi {
}

const prev = this.state()
if (typeof next.status === "string") {
this.statusVersion++
}
const state = {
phase: next.phase ?? prev.phase,
status: typeof next.status === "string" ? next.status : prev.status,
notice: typeof next.notice === "string" ? next.notice : prev.notice,
model: typeof next.model === "string" ? next.model : prev.model,
usage: typeof next.usage === "string" ? next.usage : prev.usage,
first: typeof next.first === "boolean" ? next.first : prev.first,
Expand Down Expand Up @@ -635,26 +633,13 @@ export class RunFooter implements FooterApi {
}

private setNotice(status: string): void {
const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status
this.clearNoticeTimer(false)
this.patch({ status })
if (!status) {
this.noticeRestoreStatus = ""
return
}
this.clearNoticeTimer()
this.patch({ notice: status })
if (!status) return

this.noticeRestoreStatus = restore
const version = this.statusVersion
this.noticeTimeout = setTimeout(() => {
this.noticeTimeout = undefined
if (this.isGone || version !== this.statusVersion) {
this.noticeRestoreStatus = ""
return
}

const next = this.noticeRestoreStatus
this.noticeRestoreStatus = ""
this.patch({ status: next })
this.patch({ notice: "" })
}, NOTICE_DURATION)
}

Expand Down Expand Up @@ -895,19 +880,9 @@ export class RunFooter implements FooterApi {
this.interruptTimeout = undefined
}

private clearNoticeTimer(reset = true): void {
if (!this.noticeTimeout) {
if (reset) {
this.noticeRestoreStatus = ""
}
return
}

clearTimeout(this.noticeTimeout)
private clearNoticeTimer(): void {
if (this.noticeTimeout) clearTimeout(this.noticeTimeout)
this.noticeTimeout = undefined
if (reset) {
this.noticeRestoreStatus = ""
}
}

private armInterruptTimer(): void {
Expand Down
31 changes: 7 additions & 24 deletions packages/tui/src/mini/footer.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ import type { RunTheme } from "./theme"

registerOpencodeSpinner()

const FOOTER_DETAIL_DURATION = 3000

const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
Expand Down Expand Up @@ -228,23 +226,6 @@ export function RunFooterView(props: RunFooterViewProps) {
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
Expand Down Expand Up @@ -379,6 +360,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const shell = createMemo(() => prompt() && composer.shell())
const menu = createMemo(() => prompt() && composer.visible())
const stateStatus = createMemo(() => props.state().status.trim())
const notice = createMemo(() => props.state().notice.trim())
const modeLabel = createMemo(() => {
if (exiting()) {
return "EXIT"
Expand All @@ -404,7 +386,9 @@ export function RunFooterView(props: RunFooterViewProps) {

if (busy() && armed()) return "again to interrupt"

if (footerNotice()) return footerNotice()
if (notice()) return notice()

if (!footerDetails()) return shell() ? "Shell mode" : ""

if (busy()) return "interrupt"

Expand Down Expand Up @@ -438,7 +422,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().highlight
}

if (busy() || footerNotice().length > 0 || stateStatus().length > 0) {
if (busy() || notice().length > 0 || stateStatus().length > 0) {
return theme().text
}

Expand Down Expand Up @@ -488,7 +472,6 @@ export function RunFooterView(props: RunFooterViewProps) {

onCleanup(() => {
props.onRequestExit?.(undefined)
if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout)
})

Keymap.createLayer(() => ({
Expand Down Expand Up @@ -730,7 +713,7 @@ export function RunFooterView(props: RunFooterViewProps) {
closePanel()
}}
onStatus={() => {
showFooterStatus()
props.onStatus(footerStatus())
closePanel()
}}
onCommand={(name) => {
Expand Down Expand Up @@ -898,7 +881,7 @@ export function RunFooterView(props: RunFooterViewProps) {
</Show>

<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<Show when={busy() && !exiting()} fallback={statusText()}>
<Show when={busy() && !exiting() && (footerDetails() || armed())} fallback={statusText()}>
<Show when={interruptLabel()}>
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
</Show>
Expand Down
1 change: 1 addition & 0 deletions packages/tui/src/mini/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ type FooterPhase = "idle" | "running"
export type FooterState = {
phase: FooterPhase
status: string
notice: string
model: string
usage: string
first: boolean
Expand Down
1 change: 1 addition & 0 deletions packages/tui/test/mini/footer-keymap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ test("down opens subagents from an empty prompt", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
notice: "",
model: "gpt-5",
usage: "",
first: false,
Expand Down
16 changes: 13 additions & 3 deletions packages/tui/test/mini/footer.view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function footerState(input: Partial<FooterState> = {}) {
return createSignal<FooterState>({
phase: "idle",
status: "",
notice: "",
model: "gpt-5",
usage: "",
first: false,
Expand Down Expand Up @@ -1109,6 +1110,7 @@ test("direct footer shows authoritative pending work while running", async () =>
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
notice: "",
model: "gpt-5",
usage: "",
first: false,
Expand Down Expand Up @@ -1324,7 +1326,7 @@ test("direct footer shows full usage metadata when room is available", async ()
}
})

test("direct footer can hide persistent details and briefly reveal changes", async () => {
test("direct footer hides routine activity and shows explicit notices", async () => {
const app = await renderFooter({
state: { usage: "159.6K (16%) · $4.23" },
miniSettings: {
Expand All @@ -1345,13 +1347,21 @@ test("direct footer can hide persistent details and briefly reveal changes", asy
expect(initial).not.toContain("gpt-5")
expect(initial).not.toContain("159.6K")

app.setState((state) => ({ ...state, phase: "running" }))
app.setState((state) => ({ ...state, phase: "running", status: "assistant responding" }))
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(changed).not.toContain("running")
expect(changed).not.toContain("assistant responding")
expect(changed).not.toContain("interrupt")
expect(changed).not.toContain("gpt-5")
expect(changed).not.toContain("159.6K")
expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined()

app.setState((state) => ({ ...state, notice: "variant high" }))
await app.renderOnce()
expect(app.captureCharFrame()).toContain("variant high")
} finally {
app.cleanup()
}
Expand Down
Loading