From 747756a835e2ddbe27a8cd7a308642eaa228883f Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Sun, 12 Jul 2026 02:19:33 +0530 Subject: [PATCH] feat(tui): show busy/idle progress indicator in terminal title Ported from upstream anomalyco/opencode#36297, rebranded OC->DC. --- packages/tui/src/app.tsx | 18 +++++++++++++++--- packages/tui/src/util/session.ts | 10 ++++++++++ packages/tui/test/util/session.test.ts | 15 ++++++++++++++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d370a076ce73..f1ffe0e6cd65 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -59,7 +59,7 @@ import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" -import { isDefaultTitle } from "./util/session" +import { isDefaultTitle, titleStatusGlyph } from "./util/session" import { KVProvider, useKV } from "./context/kv" import * as Model from "./util/model" import { ArgsProvider, useArgs, type Args } from "./context/args" @@ -445,6 +445,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi renderer.clearSelection() } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) + const [titleSpinnerFrame, setTitleSpinnerFrame] = createSignal(0) const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal( kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), ) @@ -459,14 +460,15 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } if (route.data.type === "session") { + const glyph = titleStatusGlyph(sync.data.session_status?.[route.data.sessionID], titleSpinnerFrame()) const session = sync.session.get(route.data.sessionID) if (!session || isDefaultTitle(session.title)) { - renderer.setTerminalTitle("DCode") + renderer.setTerminalTitle(`DC ${glyph}`) return } const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`DC | ${title}`) + renderer.setTerminalTitle(`DC ${glyph} | ${title}`) return } @@ -475,6 +477,16 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } }) + // Animate the busy glyph in the terminal title while the current session is working + createEffect(() => { + if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return + if (route.data.type !== "session") return + const status = sync.data.session_status?.[route.data.sessionID] + if (status?.type !== "busy" && status?.type !== "retry") return + const interval = setInterval(() => setTitleSpinnerFrame((frame) => frame + 1), 500) + onCleanup(() => clearInterval(interval)) + }) + const args = useArgs() onMount(() => { batch(() => { diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d093..96950d94dc23 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,13 @@ +import type { SessionStatus } from "@opencode-ai/sdk/v2" + export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) } + +const TITLE_SPINNER_FRAMES = ["· ", " ·"] as const + +export function titleStatusGlyph(status: SessionStatus | undefined, frame = 0) { + if (status?.type === "busy" || status?.type === "retry") + return TITLE_SPINNER_FRAMES[frame % TITLE_SPINNER_FRAMES.length] + return "*" +} diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index faf889781f59..25344d7b9a20 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isDefaultTitle } from "../../src/util/session" +import { isDefaultTitle, titleStatusGlyph } from "../../src/util/session" describe("util.session", () => { test("recognizes generated parent and child titles", () => { @@ -7,4 +7,17 @@ describe("util.session", () => { expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue() expect(isDefaultTitle("New session - custom")).toBeFalse() }) + + test("animates the busy glyph between spinner frames", () => { + expect(titleStatusGlyph({ type: "busy" }, 0)).toBe("· ") + expect(titleStatusGlyph({ type: "busy" }, 1)).toBe(" ·") + expect(titleStatusGlyph({ type: "busy" }, 2)).toBe("· ") + expect(titleStatusGlyph({ type: "retry", attempt: 1, message: "", next: 0 }, 1)).toBe(" ·") + }) + + test("uses the idle glyph when idle or unknown", () => { + expect(titleStatusGlyph({ type: "idle" }, 0)).toBe("*") + expect(titleStatusGlyph({ type: "idle" }, 1)).toBe("*") + expect(titleStatusGlyph(undefined)).toBe("*") + }) })