From 59b87f60f72e547d35a16a37bb93ea3719eaa67b Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:38:10 +0000 Subject: [PATCH] Add animated braille spinner to terminal title when agent is running (#5984) Co-authored-by: Aiden Cline Co-authored-by: Github Action Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- bun.lock | 6 +- flake.lock | 6 +- nix/hashes.json | 2 +- packages/opencode/src/cli/cmd/tui/app.tsx | 88 +++++++++++++++++++---- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index c8a98fca6e..fe3aaca6a6 100644 --- a/bun.lock +++ b/bun.lock @@ -1967,7 +1967,7 @@ "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], - "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], + "babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -4117,8 +4117,6 @@ "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -5103,8 +5101,6 @@ "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], - "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - "tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], diff --git a/flake.lock b/flake.lock index 6beb162c74..f464145b09 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766314097, - "narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=", + "lastModified": 1766410818, + "narHash": "sha256-ruVneSx6wFy5PMw1ow3BE+znl653TJ6+eeNUj4B/9y8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055", + "rev": "3a7affa77a5a539afa1c7859e2c31abdb1aeadf3", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index 4184ed1d58..ff97dd86a7 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-CDOAY2h2AAcSuVqV1uyxDmfzSa/vV8lnXOKDgAC4mgg=" + "nodeModules": "sha256-A4A0VFSDU0hg4utHOG50EidZgePlRsCoVf4x19rM1Zg=" } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5105ee3c63..1aee07ad60 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 | 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