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 = {