Parcourir la source

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]>
David Hill il y a 3 mois
Parent
commit
59b87f60f7
4 fichiers modifiés avec 81 ajouts et 21 suppressions
  1. 1 5
      bun.lock
  2. 3 3
      flake.lock
  3. 1 1
      nix/hashes.json
  4. 76 12
      packages/opencode/src/cli/cmd/tui/app.tsx

+ 1 - 5
bun.lock

@@ -1967,7 +1967,7 @@
 
     "babel-plugin-module-resolver": ["[email protected]", "", { "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": ["[email protected].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": ["[email protected].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": ["[email protected]", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
 
@@ -4117,8 +4117,6 @@
 
     "@opentui/solid/@babel/core": ["@babel/[email protected]", "", { "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": ["[email protected]", "", { "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/[email protected]", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
 
     "@pierre/diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
 
-    "pkg-up/find-up/locate-path/path-exists": ["[email protected]", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
-
     "tw-to-css/tailwindcss/chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
 
     "tw-to-css/tailwindcss/chokidar/readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],

+ 3 - 3
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": {

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-CDOAY2h2AAcSuVqV1uyxDmfzSa/vV8lnXOKDgAC4mgg="
+  "nodeModules": "sha256-A4A0VFSDU0hg4utHOG50EidZgePlRsCoVf4x19rM1Zg="
 }

+ 76 - 12
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