Skip to content
Open
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
12 changes: 11 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { StatusLineProvider, useStatusLine } from "./context/statusline"

async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
Expand Down Expand Up @@ -148,6 +149,7 @@ export function tui(input: {
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<StatusLineProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
Expand All @@ -163,7 +165,8 @@ export function tui(input: {
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</StatusLineProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
Expand Down Expand Up @@ -210,6 +213,7 @@ function App() {
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const statusline = useStatusLine()

useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
Expand Down Expand Up @@ -260,6 +264,12 @@ function App() {
createEffect(() => {
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return

const custom = statusline.templates().terminal_title
if (custom) {
renderer.setTerminalTitle(custom)
return
}

if (route.data.type === "home") {
renderer.setTerminalTitle("OpenCode")
return
Expand Down
49 changes: 49 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/statusline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createSignal, createEffect, onCleanup, on } from "solid-js"
import { useSDK } from "./sdk"
import { useRoute } from "./route"
import { createSimpleContext } from "./helper"

export const { use: useStatusLine, provider: StatusLineProvider } = createSimpleContext({
name: "StatusLine",
init: () => {
const sdk = useSDK()
const route = useRoute()
const [templates, setTemplates] = createSignal<Record<string, string>>({})
const [frequency, setFrequency] = createSignal(10)

let timer: Timer | undefined

const poll = async () => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
const result = await sdk.client.tui.statusline({ sessionID }).catch(() => undefined)
if (!result?.data) return
setTemplates(result.data.templates)
setFrequency(result.data.interval)
}

const start = () => {
if (timer) clearInterval(timer)
poll()
timer = setInterval(poll, frequency() * 1000)
}
Comment on lines +24 to +28
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The start function uses the current value of frequency() when setting up the interval, but if the interval changes after the first poll (via setFrequency), the timer won't be updated to use the new interval. The timer continues using the old frequency value until start is called again. Consider restarting the timer when the frequency changes, or use a different approach to ensure the interval is dynamically updated.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The frequency only changes once (first poll sets it from the default to the configured value). After that, start() is re-invoked on every session change via the createEffect, which restarts the timer with the current frequency. A dynamic timer update mechanism would add complexity without practical benefit.


const stop = () => {
if (!timer) return
clearInterval(timer)
timer = undefined
}

createEffect(
on(
() => route.data.type === "session" ? route.data.sessionID : undefined,
() => {
start()
},
),
)

onCleanup(stop)

return { templates, interval: frequency }
},
})
49 changes: 28 additions & 21 deletions packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useStatusLine } from "../context/statusline"

// TODO: what is the best way to do this?
let once = false
Expand Down Expand Up @@ -88,6 +89,7 @@ export function Home() {
}
})
const directory = useDirectory()
const statusline = useStatusLine()

const keybind = useKeybind()

Expand Down Expand Up @@ -118,27 +120,32 @@ export function Home() {
<Toast />
</box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}>⊙ </span>
</Match>
<Match when={true}>
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
</Match>
</Switch>
{connectedMcpCount()} MCP
</text>
<text fg={theme.textMuted}>/status</text>
</Show>
</box>
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
<Show
when={!statusline.templates().home_footer}
fallback={<text fg={theme.textMuted}>{statusline.templates().home_footer}</text>}
>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
<Show when={mcp()}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
<span style={{ fg: theme.error }}>⊙ </span>
</Match>
<Match when={true}>
<span style={{ fg: connectedMcpCount() > 0 ? theme.success : theme.textMuted }}>⊙ </span>
</Match>
</Switch>
{connectedMcpCount()} MCP
</text>
<text fg={theme.textMuted}>/status</text>
</Show>
</box>
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>
</Show>
</box>
</>
)
Expand Down
12 changes: 9 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { useStatusLine } from "../../context/statusline"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
Expand Down Expand Up @@ -115,6 +115,7 @@ export function Session() {
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
const statusline = useStatusLine()
const session = createMemo(() => sync.session.get(route.sessionID))
const children = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id
Expand Down Expand Up @@ -1051,8 +1052,8 @@ export function Session() {
</text>
)}
</For>
</box>
</Show>
</box>
</Show>
</box>
</box>
)
Expand Down Expand Up @@ -1114,6 +1115,11 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={statusline.templates().session_footer}>
<box flexShrink={0} paddingTop={1}>
<text fg={theme.textMuted}>{statusline.templates().session_footer}</text>
</box>
</Show>
</Show>
<Toast />
</box>
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,26 @@ export namespace Config {
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
status_line: z
.object({
templates: z
.record(z.enum(["terminal_title", "session_footer", "home_footer"]), z.string())
.optional()
.describe("Template strings per display target with {variable:format} placeholders"),
interval: z
.number()
.int()
.min(1)
.max(300)
.optional()
.describe("Polling interval in seconds"),
commands: z
.record(z.string(), z.string())
.optional()
.describe("Named shell commands available as {shell:name} in templates"),
})
.optional()
.describe("Status line template configuration"),
})

export const Server = z
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/server/routes/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Bus } from "../../bus"
import { Session } from "../../session"
import { StatusLine } from "../../statusline"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
Expand Down Expand Up @@ -375,5 +376,35 @@ export const TuiRoutes = lazy(() =>
return c.json(true)
},
)
.get(
"/statusline",
describeRoute({
summary: "Get resolved status line",
description: "Resolve status line templates for each display target with current variables.",
operationId: "tui.statusline",
responses: {
200: {
description: "Resolved status line templates",
content: {
"application/json": {
schema: resolver(
z
.object({
templates: z.record(z.string(), z.string()),
interval: z.number(),
})
.nullable(),
),
},
},
},
},
}),
validator("query", z.object({ sessionID: z.string().optional() })),
async (c) => {
const result = await StatusLine.get(c.req.valid("query").sessionID)
return c.json(result ?? null)
},
)
.route("/control", TuiControlRoutes),
)
Loading
Loading