diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index e41b5dda71ed..c62f1e8cb8e3 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,6 +778,16 @@ function TabNavItem(props: { + {props.statusColor() && ( +
+ )} {props.title} 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 c889817be2a7..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,29 +452,50 @@ function App(props: { onSnapshot?: () => Promise }) { kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), ) + 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_EMOJI.green + if (route.data.type !== "session") return "" + 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 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 = {