From dcb8b2197cec4991e7d373b10fe63890829f549c Mon Sep 17 00:00:00 2001 From: Niu Shuai Date: Mon, 1 Jun 2026 11:29:33 +0800 Subject: [PATCH 1/3] feat(tui): add status light indicator in terminal title --- packages/opencode/src/cli/cmd/tui/app.tsx | 36 ++++++++++++++++++++--- packages/opencode/src/config/config.ts | 3 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c889817be2a7..973d448ae437 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -451,29 +451,57 @@ function App(props: { onSnapshot?: () => Promise }) { kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), ) + const STATUS_GREEN = "\u{1F7E2} " + const STATUS_YELLOW = "\u{1F7E1} " + const STATUS_RED = "\u{1F534} " + + const trafficLight = createMemo(() => { + if (!sync.data.config.status_light) return "" + if (route.data.type === "home") return STATUS_GREEN + if (route.data.type !== "session") return "" + const sessionStatus = sync.data.session_status?.[route.data.sessionID] + if (!sessionStatus || sessionStatus.type === "idle") return STATUS_GREEN + const messages = sync.data.message[route.data.sessionID] + if (!messages) return STATUS_GREEN + const pendingInput = (sync.data.permission?.[route.data.sessionID]?.length ?? 0) > 0 + || (sync.data.question?.[route.data.sessionID]?.length ?? 0) > 0 + if (pendingInput) return STATUS_GREEN + const lastAssistant = messages.findLast((m) => m.role === "assistant") + if (!lastAssistant) return STATUS_YELLOW + const parts = sync.data.part[lastAssistant.id] + if (!parts) return STATUS_YELLOW + const hasRunningTool = parts.some( + (p) => p.type === "tool" && (p.state?.status === "running" || p.state?.status === "pending"), + ) + if (hasRunningTool) return STATUS_RED + const hasTextOutput = parts.some((p) => p.type === "text" && !p.synthetic && !p.ignored) + if (hasTextOutput) return STATUS_RED + return STATUS_YELLOW + }) + // Update terminal window title based on current route and session createEffect(() => { if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return if (route.data.type === "home") { - renderer.setTerminalTitle("OpenCode") + renderer.setTerminalTitle(trafficLight() + "OpenCode") return } if (route.data.type === "session") { const session = sync.session.get(route.data.sessionID) if (!session || SessionApi.isDefaultTitle(session.title)) { - renderer.setTerminalTitle("OpenCode") + renderer.setTerminalTitle(trafficLight() + "OpenCode") return } const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`OC | ${title}`) + renderer.setTerminalTitle(trafficLight() + `OC | ${title}`) return } if (route.data.type === "plugin") { - renderer.setTerminalTitle(`OC | ${route.data.id}`) + renderer.setTerminalTitle(trafficLight() + `OC | ${route.data.id}`) } }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8dc8c6ee54a8..701ff64c88d1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -307,6 +307,9 @@ export const Info = Schema.Struct({ }), }), ), + status_light: Schema.optional(Schema.Boolean).annotate({ + description: "Show traffic light status indicator in terminal title", + }), }).annotate({ identifier: "Config" }) // Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d70c7a887576..2b32aa2edb23 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1910,6 +1910,7 @@ export type Config = { mcp_timeout?: number policies?: Array } + status_light?: boolean } export type Model = { From 28f7174642b2c2ad64b93763f8342c94d239f8e3 Mon Sep 17 00:00:00 2001 From: Niu Shuai Date: Mon, 1 Jun 2026 16:17:30 +0800 Subject: [PATCH 2/3] feat(core): extract shared status light logic for TUI and Web UI - Add computeStatusLight() in @opencode-ai/core/session/status-light - Refactor TUI trafficLight memo to use shared function - Add status light dot to Web UI session tabs (V2 titlebar) - Shows colored dot when config.status_light is enabled - Falls back to title text when disabled --- packages/app/src/components/titlebar.tsx | 35 +++++++++++++++++++-- packages/core/src/session/status-light.ts | 27 ++++++++++++++++ packages/opencode/src/cli/cmd/tui/app.tsx | 38 ++++++++++------------- 3 files changed, 76 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/session/status-light.ts diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e41b5dda71ed..03737c8288ff 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -19,6 +19,7 @@ import { applyPath, backPath, forwardPath } from "./titlebar-history" import { useServerSync } from "@/context/server-sync" import { decodeDirectory } from "@/pages/directory-layout" import { iife } from "@opencode-ai/core/util/iife" +import { computeStatusLight, type StatusLightColor } from "@opencode-ai/core/session/status-light" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" @@ -451,7 +452,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { (tab) => { const sync = serverSync.createDirSyncContext(tab.dir) const session = sync.session.get(tab.sessionId) - return session ? { ...tab, info: session } : null + if (!session) return null + const statusColor = createMemo((): StatusLightColor | null => { + const messages = sync.data.message[tab.sessionId] + const lastAssistant = messages?.findLast((m) => m.role === "assistant") + return computeStatusLight({ + enabled: !!sync.data.config.status_light, + sessionStatus: sync.data.session_status[tab.sessionId], + messages, + pendingInput: + (sync.data.permission?.[tab.sessionId]?.length ?? 0) > 0 + || (sync.data.question?.[tab.sessionId]?.length ?? 0) > 0, + parts: lastAssistant ? sync.data.part[lastAssistant.id] : undefined, + }) + }) + return { ...tab, info: session, statusColor } }, ) @@ -494,6 +509,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { project={projectForSession(tab.info, projects(), projectByID())} directory={tab.dir} sessionId={tab.info.id} + statusColor={tab.statusColor} onClose={() => tabsStoreActions.removeTab(tab.href)} /> @@ -736,6 +752,7 @@ function TabNavItem(props: { directory: string sessionId: string hideClose?: boolean + statusColor: () => StatusLightColor | null onClose: () => void }) { const match = useMatch(() => props.href) @@ -761,7 +778,21 @@ function TabNavItem(props: { - {props.title} + {props.title}} + > + {(color) => ( +
+ )} +
diff --git a/packages/core/src/session/status-light.ts b/packages/core/src/session/status-light.ts new file mode 100644 index 000000000000..6e61ac23c6b2 --- /dev/null +++ b/packages/core/src/session/status-light.ts @@ -0,0 +1,27 @@ +export type StatusLightColor = "green" | "yellow" | "red" + +export function computeStatusLight(input: { + enabled: boolean + sessionStatus?: { type: string } + messages?: readonly { role: string; id: string }[] + pendingInput: boolean + parts?: readonly { type: string; state?: { status?: string }; synthetic?: boolean; ignored?: boolean }[] +}): StatusLightColor | null { + if (!input.enabled) return null + if (!input.sessionStatus || input.sessionStatus.type === "idle") return "green" + if (!input.messages) return "green" + if (input.pendingInput) return "green" + const lastAssistant = input.messages.findLast((m) => m.role === "assistant") + if (!lastAssistant) return "yellow" + if (!input.parts) return "yellow" + if ( + input.parts.some( + (p) => p.type === "tool" && (p.state?.status === "running" || p.state?.status === "pending"), + ) + ) + return "red" + if (input.parts.some((p) => p.type === "text" && !p.synthetic && !p.ignored)) return "red" + return "yellow" +} + +export * as StatusLight from "./status-light" diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 973d448ae437..b588e99fc13e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -20,6 +20,7 @@ import { } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Flag } from "@opencode-ai/core/flag/flag" +import { computeStatusLight } from "@opencode-ai/core/session/status-light" import semver from "semver" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" @@ -451,32 +452,25 @@ function App(props: { onSnapshot?: () => Promise }) { kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), ) - const STATUS_GREEN = "\u{1F7E2} " - const STATUS_YELLOW = "\u{1F7E1} " - const STATUS_RED = "\u{1F534} " + const STATUS_EMOJI = { green: "\u{1F7E2} ", yellow: "\u{1F7E1} ", red: "\u{1F534} " } as const const trafficLight = createMemo(() => { if (!sync.data.config.status_light) return "" - if (route.data.type === "home") return STATUS_GREEN + if (route.data.type === "home") return STATUS_EMOJI.green if (route.data.type !== "session") return "" - const sessionStatus = sync.data.session_status?.[route.data.sessionID] - if (!sessionStatus || sessionStatus.type === "idle") return STATUS_GREEN - const messages = sync.data.message[route.data.sessionID] - if (!messages) return STATUS_GREEN - const pendingInput = (sync.data.permission?.[route.data.sessionID]?.length ?? 0) > 0 - || (sync.data.question?.[route.data.sessionID]?.length ?? 0) > 0 - if (pendingInput) return STATUS_GREEN - const lastAssistant = messages.findLast((m) => m.role === "assistant") - if (!lastAssistant) return STATUS_YELLOW - const parts = sync.data.part[lastAssistant.id] - if (!parts) return STATUS_YELLOW - const hasRunningTool = parts.some( - (p) => p.type === "tool" && (p.state?.status === "running" || p.state?.status === "pending"), - ) - if (hasRunningTool) return STATUS_RED - const hasTextOutput = parts.some((p) => p.type === "text" && !p.synthetic && !p.ignored) - if (hasTextOutput) return STATUS_RED - return STATUS_YELLOW + const sid = route.data.sessionID + const messages = sync.data.message[sid] + const lastAssistant = messages?.findLast((m) => m.role === "assistant") + const color = computeStatusLight({ + enabled: true, + sessionStatus: sync.data.session_status?.[sid], + messages, + pendingInput: + (sync.data.permission?.[sid]?.length ?? 0) > 0 + || (sync.data.question?.[sid]?.length ?? 0) > 0, + parts: lastAssistant ? sync.data.part[lastAssistant.id] : undefined, + }) + return color ? STATUS_EMOJI[color] : "" }) // Update terminal window title based on current route and session From 5b1f488ff0e71fd2153f056da063848482cc8457 Mon Sep 17 00:00:00 2001 From: Niu Shuai Date: Tue, 2 Jun 2026 02:07:07 +0800 Subject: [PATCH 3/3] fix(app): show status light alongside tab title instead of replacing it --- packages/app/src/components/titlebar.tsx | 26 ++++++++++-------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 03737c8288ff..c62f1e8cb8e3 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -778,21 +778,17 @@ function TabNavItem(props: { - {props.title}} - > - {(color) => ( -
- )} - + {props.statusColor() && ( +
+ )} + {props.title}