diff options
| author | David Hill <[email protected]> | 2025-12-23 19:38:10 +0000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-12-23 13:38:10 -0600 |
| commit | 59b87f60f72e547d35a16a37bb93ea3719eaa67b (patch) | |
| tree | 409b03dc1def3b9278c88eecbb1f1da33d9df1ee /packages | |
| parent | d10089a0bf97a00938bc7ceeeb67fac3746644ec (diff) | |
| download | opencode-59b87f60f72e547d35a16a37bb93ea3719eaa67b.tar.gz opencode-59b87f60f72e547d35a16a37bb93ea3719eaa67b.zip | |
Add animated braille spinner to terminal title when agent is running (#5984)
Co-authored-by: Aiden Cline <[email protected]>
Co-authored-by: Github Action <[email protected]>
Co-authored-by: Aiden Cline <[email protected]>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/app.tsx | 88 |
1 files changed, 76 insertions, 12 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5105ee3c6..1aee07ad6 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -2,9 +2,19 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu import { Clipboard } from "@tui/util/clipboard" import { TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" +import { + Switch, + Match, + createEffect, + untrack, + ErrorBoundary, + createSignal, + onMount, + onCleanup, + batch, + on, +} from "solid-js" import { Installation } from "@/installation" -import { Global } from "@/global" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" @@ -35,6 +45,7 @@ import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { iife } from "@/util/iife" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -181,29 +192,82 @@ function App() { const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) - createEffect(() => { - console.log(JSON.stringify(route.data)) + // Update terminal window title based on current route and session + // Braille spinner animation frames for when agent is running (space + single character for consistent width with "OC") + const spinnerFrames = [" ⠋", " ⠙", " ⠹", " ⠸", " ⠼", " ⠴", " ⠦", " ⠧", " ⠇", " ⠏"] + // Permission request animation frames (flashing triangle with leading space) + const permissionFrames = [" ◭", " "] + let spinnerInterval: ReturnType<typeof setInterval> | undefined + let spinnerIndex = 0 + let currentTitle = "" + let currentAnimationType: "spinner" | "permission" | undefined + + // Cleanup interval on component unmount + onCleanup(() => { + if (spinnerInterval) { + clearInterval(spinnerInterval) + spinnerInterval = undefined + } }) - // Update terminal window title based on current route and session createEffect(() => { if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return if (route.data.type === "home") { + if (spinnerInterval) { + clearInterval(spinnerInterval) + spinnerInterval = undefined + currentAnimationType = undefined + } renderer.setTerminalTitle("OpenCode") return } if (route.data.type === "session") { - const session = sync.session.get(route.data.sessionID) - if (!session || SessionApi.isDefaultTitle(session.title)) { - renderer.setTerminalTitle("OpenCode") + const sessionID = route.data.sessionID + const session = sync.session.get(sessionID) + const status = sync.data.session_status[sessionID] + const isBusy = status?.type === "busy" + const permissions = sync.data.permission[sessionID] ?? [] + const hasPermissionRequest = permissions.length > 0 + const hasTitle = session && !SessionApi.isDefaultTitle(session.title) + + // Truncate title to 40 chars max, fallback to "OpenCode" if no title yet + currentTitle = iife(() => { + if (!hasTitle) return "OpenCode" + if (session.title.length > 40) return session.title.slice(0, 37) + "..." + return session.title + }) + + // Determine which animation to show (permission takes priority) + const targetAnimation = hasPermissionRequest ? "permission" : isBusy ? "spinner" : undefined + const frames = hasPermissionRequest ? permissionFrames : spinnerFrames + + if (!targetAnimation) { + // Stop animation and show static title + if (spinnerInterval) { + clearInterval(spinnerInterval) + spinnerInterval = undefined + currentAnimationType = undefined + } + renderer.setTerminalTitle(hasTitle ? `OC | ${currentTitle}` : "OpenCode") return } - // Truncate title to 40 chars max - const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`OC | ${title}`) + // Start or switch animation + if (!spinnerInterval || currentAnimationType !== targetAnimation) { + if (spinnerInterval) clearInterval(spinnerInterval) + spinnerIndex = 0 + currentAnimationType = targetAnimation + renderer.setTerminalTitle(`${frames[spinnerIndex]} | ${currentTitle}`) + spinnerInterval = setInterval( + () => { + spinnerIndex = (spinnerIndex + 1) % frames.length + renderer.setTerminalTitle(`${frames[spinnerIndex]} | ${currentTitle}`) + }, + hasPermissionRequest ? 400 : 80, + ) + } } }) @@ -525,7 +589,7 @@ function App() { sdk.event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error const message = (() => { - if (!error) return "An error occured" + if (!error) return "An error occurred" if (typeof error === "object") { const data = error.data |
