@oh-my-pi/pi-tui 18.2.3 → 18.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (750) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +43 -0
  3. package/dist/types/app-keybindings.d.ts +395 -0
  4. package/dist/types/apps/autoresearch-dashboard.d.ts +43 -0
  5. package/dist/types/apps/autoresearch-data.d.ts +15 -0
  6. package/dist/types/apps/cleanse-board.d.ts +66 -0
  7. package/dist/types/apps/cleanse-picker.d.ts +23 -0
  8. package/dist/types/apps/debug/log-formatting.d.ts +6 -0
  9. package/dist/types/apps/debug/log-viewer.d.ts +81 -0
  10. package/dist/types/apps/debug/protocol-probe.d.ts +42 -0
  11. package/dist/types/apps/debug/raw-sse-buffer.d.ts +58 -0
  12. package/dist/types/apps/debug/raw-sse.d.ts +26 -0
  13. package/dist/types/apps/debug/terminal-info.d.ts +34 -0
  14. package/dist/types/apps/debug/viewer-frame.d.ts +52 -0
  15. package/dist/types/apps/git/avatar.d.ts +9 -0
  16. package/dist/types/apps/git/colors.d.ts +49 -0
  17. package/dist/types/apps/git/diff-pane.d.ts +188 -0
  18. package/dist/types/apps/git/git-tui.d.ts +66 -0
  19. package/dist/types/apps/git/sidebar.d.ts +149 -0
  20. package/dist/types/apps/git/state.d.ts +117 -0
  21. package/dist/types/apps/if-bench-board.d.ts +75 -0
  22. package/dist/types/apps/live-visualizer.d.ts +33 -0
  23. package/dist/types/apps/ps-data.d.ts +51 -0
  24. package/dist/types/apps/ps-top.d.ts +33 -0
  25. package/dist/types/apps/session-picker.d.ts +26 -0
  26. package/dist/types/apps/setup-model-picker.d.ts +13 -0
  27. package/dist/types/apps/standalone-picker.d.ts +42 -0
  28. package/dist/types/autocomplete.d.ts +4 -0
  29. package/dist/types/chat/advisor-message.d.ts +11 -0
  30. package/dist/types/chat/assistant-message.d.ts +106 -0
  31. package/dist/types/chat/background-tan-message.d.ts +9 -0
  32. package/dist/types/chat/bash-execution.d.ts +47 -0
  33. package/dist/types/chat/cache-invalidation-marker.d.ts +50 -0
  34. package/dist/types/chat/chat-transcript-builder.d.ts +50 -0
  35. package/dist/types/chat/collab-prompt-message.d.ts +10 -0
  36. package/dist/types/chat/compaction-summary-message.d.ts +54 -0
  37. package/dist/types/chat/custom-message.d.ts +10 -0
  38. package/dist/types/chat/display-preferences.d.ts +13 -0
  39. package/dist/types/chat/eval-execution.d.ts +29 -0
  40. package/dist/types/chat/execution-shared.d.ts +51 -0
  41. package/dist/types/chat/extension-types.d.ts +28 -0
  42. package/dist/types/chat/hook-message.d.ts +10 -0
  43. package/dist/types/chat/image-loading.d.ts +46 -0
  44. package/dist/types/chat/late-diagnostics-message.d.ts +21 -0
  45. package/dist/types/chat/messages.d.ts +189 -0
  46. package/dist/types/chat/reaction.d.ts +30 -0
  47. package/dist/types/chat/read-target.d.ts +4 -0
  48. package/dist/types/chat/read-tool-group.d.ts +67 -0
  49. package/dist/types/chat/served-model-marker.d.ts +47 -0
  50. package/dist/types/chat/skill-message.d.ts +33 -0
  51. package/dist/types/chat/skill-title-input.d.ts +14 -0
  52. package/dist/types/chat/stripped-tool-calls-placeholder.d.ts +12 -0
  53. package/dist/types/chat/thinking-display.d.ts +12 -0
  54. package/dist/types/chat/todo-reminder.d.ts +13 -0
  55. package/dist/types/chat/tool-execution.d.ts +138 -0
  56. package/dist/types/chat/transcript-browser.d.ts +93 -0
  57. package/dist/types/chat/transcript-entry.d.ts +45 -0
  58. package/dist/types/chat/transcript-outline.d.ts +86 -0
  59. package/dist/types/chat/transcript-render-helpers.d.ts +92 -0
  60. package/dist/types/chat/ttsr-notification.d.ts +23 -0
  61. package/dist/types/chat/user-message.d.ts +52 -0
  62. package/dist/types/chrome/chat-block.d.ts +64 -0
  63. package/dist/types/chrome/collab-qrcode.d.ts +28 -0
  64. package/dist/types/chrome/context-thresholds.d.ts +10 -0
  65. package/dist/types/chrome/countdown-timer.d.ts +14 -0
  66. package/dist/types/chrome/diff.d.ts +14 -0
  67. package/dist/types/chrome/dynamic-border.d.ts +16 -0
  68. package/dist/types/chrome/error-block.d.ts +17 -0
  69. package/dist/types/chrome/form-theme.d.ts +3 -0
  70. package/dist/types/chrome/format.d.ts +12 -0
  71. package/dist/types/chrome/index.d.ts +20 -0
  72. package/dist/types/chrome/keybinding-hints.d.ts +40 -0
  73. package/dist/types/chrome/live-board.d.ts +23 -0
  74. package/dist/types/chrome/local-date.d.ts +4 -0
  75. package/dist/types/chrome/message-divider.d.ts +22 -0
  76. package/dist/types/chrome/message-frame.d.ts +52 -0
  77. package/dist/types/chrome/message-notice.d.ts +35 -0
  78. package/dist/types/chrome/overlay-box.d.ts +74 -0
  79. package/dist/types/chrome/qrcode.d.ts +48 -0
  80. package/dist/types/chrome/segment-track.d.ts +27 -0
  81. package/dist/types/chrome/select-list-mouse-routing.d.ts +4 -0
  82. package/dist/types/chrome/selector-helpers.d.ts +52 -0
  83. package/dist/types/chrome/shared.d.ts +5 -0
  84. package/dist/types/chrome/streaming-panel.d.ts +19 -0
  85. package/dist/types/chrome/tool-activity.d.ts +17 -0
  86. package/dist/types/chrome/transcript-container.d.ts +129 -0
  87. package/dist/types/chrome/transcript-status.d.ts +12 -0
  88. package/dist/types/chrome/visual-truncate.d.ts +19 -0
  89. package/dist/types/components/disclosure.d.ts +42 -0
  90. package/dist/types/components/form.d.ts +130 -0
  91. package/dist/types/components/image.d.ts +18 -16
  92. package/dist/types/components/key-value-list.d.ts +24 -0
  93. package/dist/types/components/layout/geometry.d.ts +73 -0
  94. package/dist/types/components/layout/row.d.ts +55 -0
  95. package/dist/types/components/layout/split-pane.d.ts +80 -0
  96. package/dist/types/components/layout/stack.d.ts +50 -0
  97. package/dist/types/components/menu-selection.d.ts +71 -0
  98. package/dist/types/components/metric.d.ts +44 -0
  99. package/dist/types/components/progress-bar.d.ts +36 -0
  100. package/dist/types/components/scroll-view.d.ts +53 -19
  101. package/dist/types/components/scroll-viewport.d.ts +46 -0
  102. package/dist/types/components/section.d.ts +19 -0
  103. package/dist/types/components/select-list.d.ts +46 -2
  104. package/dist/types/components/settings-list.d.ts +20 -0
  105. package/dist/types/components/table.d.ts +32 -0
  106. package/dist/types/components/tree-view.d.ts +130 -0
  107. package/dist/types/components/wizard-step.d.ts +60 -0
  108. package/dist/types/hotkeys-markdown.d.ts +7 -0
  109. package/dist/types/index.d.ts +17 -0
  110. package/dist/types/keybinding-matchers.d.ts +35 -0
  111. package/dist/types/keys.d.ts +2 -2
  112. package/dist/types/lang-from-path.d.ts +9 -0
  113. package/dist/types/overlays/advisor-config.d.ts +114 -0
  114. package/dist/types/overlays/agent-activity.d.ts +41 -0
  115. package/dist/types/overlays/agent-hub-projection.d.ts +33 -0
  116. package/dist/types/overlays/agent-hub-renderer.d.ts +53 -0
  117. package/dist/types/overlays/agent-hub-types.d.ts +99 -0
  118. package/dist/types/overlays/agent-hub.d.ts +126 -0
  119. package/dist/types/overlays/agent-transcript-viewer.d.ts +71 -0
  120. package/dist/types/overlays/agents-hub.d.ts +72 -0
  121. package/dist/types/overlays/ask-dialog.d.ts +88 -0
  122. package/dist/types/overlays/bordered-loader.d.ts +11 -0
  123. package/dist/types/overlays/btw-history-panel.d.ts +29 -0
  124. package/dist/types/overlays/btw-history.d.ts +17 -0
  125. package/dist/types/overlays/btw-panel.d.ts +26 -0
  126. package/dist/types/overlays/cleanse-panel.d.ts +36 -0
  127. package/dist/types/overlays/codex-reset-fireworks.d.ts +52 -0
  128. package/dist/types/overlays/composer-shape-preview.d.ts +47 -0
  129. package/dist/types/overlays/composer-shape-registry.d.ts +50 -0
  130. package/dist/types/overlays/copy-selector.d.ts +48 -0
  131. package/dist/types/overlays/copy-targets.d.ts +63 -0
  132. package/dist/types/overlays/error-banner.d.ts +13 -0
  133. package/dist/types/overlays/extensions/display-text.d.ts +10 -0
  134. package/dist/types/overlays/extensions/extension-dashboard.d.ts +63 -0
  135. package/dist/types/overlays/extensions/extension-list.d.ts +54 -0
  136. package/dist/types/overlays/extensions/inspector-model.d.ts +109 -0
  137. package/dist/types/overlays/extensions/inspector-panel.d.ts +17 -0
  138. package/dist/types/overlays/extensions/live-tool-session.d.ts +28 -0
  139. package/dist/types/overlays/extensions/mcp-runtime.d.ts +107 -0
  140. package/dist/types/overlays/extensions/state-manager.d.ts +42 -0
  141. package/dist/types/overlays/extensions/types.d.ts +165 -0
  142. package/dist/types/overlays/history-search.d.ts +16 -0
  143. package/dist/types/overlays/hook-editor.d.ts +44 -0
  144. package/dist/types/overlays/hook-input.d.ts +20 -0
  145. package/dist/types/overlays/hook-selector.d.ts +71 -0
  146. package/dist/types/overlays/hub-frame.d.ts +77 -0
  147. package/dist/types/overlays/login-dialog.d.ts +43 -0
  148. package/dist/types/overlays/logout-account-selector.d.ts +15 -0
  149. package/dist/types/overlays/mcp-add-wizard.d.ts +87 -0
  150. package/dist/types/overlays/model-browser.d.ts +213 -0
  151. package/dist/types/overlays/model-hub.d.ts +72 -0
  152. package/dist/types/overlays/model-picker.d.ts +74 -0
  153. package/dist/types/overlays/model-selector.d.ts +52 -0
  154. package/dist/types/overlays/move-overlay.d.ts +39 -0
  155. package/dist/types/overlays/oauth-selector.d.ts +41 -0
  156. package/dist/types/overlays/omfg-panel.d.ts +20 -0
  157. package/dist/types/overlays/pause-screen.d.ts +43 -0
  158. package/dist/types/overlays/plan-review-overlay.d.ts +89 -0
  159. package/dist/types/overlays/plan-save-overlay.d.ts +19 -0
  160. package/dist/types/overlays/plan-toc.d.ts +41 -0
  161. package/dist/types/overlays/plugin-selector.d.ts +28 -0
  162. package/dist/types/overlays/plugin-settings.d.ts +180 -0
  163. package/dist/types/overlays/queue-mode-selector.d.ts +11 -0
  164. package/dist/types/overlays/reset-usage-selector.d.ts +23 -0
  165. package/dist/types/overlays/rewind-selector.d.ts +61 -0
  166. package/dist/types/overlays/running-subagent-badge.d.ts +6 -0
  167. package/dist/types/overlays/session-account-selector.d.ts +24 -0
  168. package/dist/types/overlays/session-info-overlay.d.ts +17 -0
  169. package/dist/types/overlays/session-observer-registry.d.ts +77 -0
  170. package/dist/types/overlays/session-selector.d.ts +131 -0
  171. package/dist/types/overlays/settings-defs.d.ts +119 -0
  172. package/dist/types/overlays/settings-selector.d.ts +73 -0
  173. package/dist/types/overlays/show-images-selector.d.ts +11 -0
  174. package/dist/types/overlays/snapcompact-shape-preview.d.ts +31 -0
  175. package/dist/types/overlays/theme-selector.d.ts +12 -0
  176. package/dist/types/overlays/thinking-selector.d.ts +12 -0
  177. package/dist/types/overlays/tiny-title-download-progress.d.ts +21 -0
  178. package/dist/types/overlays/tree-selector.d.ts +92 -0
  179. package/dist/types/overlays/usage-dashboard.d.ts +94 -0
  180. package/dist/types/overlays/usage-display.d.ts +5 -0
  181. package/dist/types/overlays/usage-row.d.ts +17 -0
  182. package/dist/types/prompt/attachment-chips.d.ts +19 -0
  183. package/dist/types/prompt/composer-attachments.d.ts +71 -0
  184. package/dist/types/prompt/composer-cache.d.ts +37 -0
  185. package/dist/types/prompt/composer.d.ts +159 -0
  186. package/dist/types/prompt/custom-editor.d.ts +261 -0
  187. package/dist/types/prompt/editor-top-gap.d.ts +18 -0
  188. package/dist/types/prompt/emoji-autocomplete.d.ts +16 -0
  189. package/dist/types/prompt/github-ref-autocomplete.d.ts +35 -0
  190. package/dist/types/prompt/gradient-highlight.d.ts +32 -0
  191. package/dist/types/prompt/image-format.d.ts +4 -0
  192. package/dist/types/prompt/image-references.d.ts +26 -0
  193. package/dist/types/prompt/interactive-context-helpers.d.ts +43 -0
  194. package/dist/types/prompt/internal-url-autocomplete.d.ts +56 -0
  195. package/dist/types/prompt/macos-spelling.d.ts +44 -0
  196. package/dist/types/prompt/magic-keyword-boundary.d.ts +9 -0
  197. package/dist/types/prompt/magic-keywords.d.ts +26 -0
  198. package/dist/types/prompt/markdown-prose.d.ts +15 -0
  199. package/dist/types/prompt/model-mention-autocomplete.d.ts +25 -0
  200. package/dist/types/prompt/model-mention-syntax.d.ts +24 -0
  201. package/dist/types/prompt/orchestrate.d.ts +13 -0
  202. package/dist/types/prompt/prompt-action-autocomplete.d.ts +56 -0
  203. package/dist/types/prompt/queue-input.d.ts +8 -0
  204. package/dist/types/prompt/skill-tokens.d.ts +13 -0
  205. package/dist/types/prompt/tools-markdown.d.ts +12 -0
  206. package/dist/types/prompt/ultrathink.d.ts +13 -0
  207. package/dist/types/prompt/usage-amounts.d.ts +26 -0
  208. package/dist/types/prompt/video.d.ts +18 -0
  209. package/dist/types/prompt/welcome.d.ts +83 -0
  210. package/dist/types/prompt/workflow.d.ts +13 -0
  211. package/dist/types/render/code-cell.d.ts +51 -0
  212. package/dist/types/render/file-list.d.ts +22 -0
  213. package/dist/types/render/hyperlink.d.ts +80 -0
  214. package/dist/types/render/index.d.ts +16 -0
  215. package/dist/types/render/output-block.d.ts +54 -0
  216. package/dist/types/render/output-pane.d.ts +80 -0
  217. package/dist/types/render/render-utils.d.ts +312 -0
  218. package/dist/types/render/sixel.d.ts +21 -0
  219. package/dist/types/render/status-line.d.ts +23 -0
  220. package/dist/types/render/tool-card.d.ts +71 -0
  221. package/dist/types/render/tree-list.d.ts +29 -0
  222. package/dist/types/render/types.d.ts +17 -0
  223. package/dist/types/render/utils.d.ts +43 -0
  224. package/dist/types/render/width-aware-text.d.ts +23 -0
  225. package/dist/types/setup/lazy.d.ts +3 -0
  226. package/dist/types/setup/scenes/composer.d.ts +3 -0
  227. package/dist/types/setup/scenes/glyph.d.ts +3 -0
  228. package/dist/types/setup/scenes/model.d.ts +3 -0
  229. package/dist/types/setup/scenes/outro.d.ts +2 -0
  230. package/dist/types/setup/scenes/providers.d.ts +3 -0
  231. package/dist/types/setup/scenes/sign-in.d.ts +21 -0
  232. package/dist/types/setup/scenes/splash.d.ts +11 -0
  233. package/dist/types/setup/scenes/theme.d.ts +3 -0
  234. package/dist/types/setup/scenes/types.d.ts +110 -0
  235. package/dist/types/setup/scenes/web-search.d.ts +22 -0
  236. package/dist/types/setup/setup-version.d.ts +11 -0
  237. package/dist/types/setup/startup-splash.d.ts +9 -0
  238. package/dist/types/setup/wizard-overlay.d.ts +16 -0
  239. package/dist/types/setup/wizard.d.ts +25 -0
  240. package/dist/types/status-line/component.d.ts +182 -0
  241. package/dist/types/status-line/context-usage.d.ts +156 -0
  242. package/dist/types/status-line/footer.d.ts +30 -0
  243. package/dist/types/status-line/git-utils.d.ts +22 -0
  244. package/dist/types/status-line/host.d.ts +129 -0
  245. package/dist/types/status-line/index.d.ts +6 -0
  246. package/dist/types/status-line/loop.d.ts +18 -0
  247. package/dist/types/status-line/metrics.d.ts +21 -0
  248. package/dist/types/status-line/presets.d.ts +3 -0
  249. package/dist/types/status-line/schema.d.ts +15 -0
  250. package/dist/types/status-line/segments.d.ts +5 -0
  251. package/dist/types/status-line/separators.d.ts +3 -0
  252. package/dist/types/status-line/types.d.ts +236 -0
  253. package/dist/types/stdin-buffer.d.ts +4 -0
  254. package/dist/types/theme/color.d.ts +24 -0
  255. package/dist/types/theme/defaults/index.d.ts +9489 -0
  256. package/dist/types/theme/index.d.ts +4 -0
  257. package/dist/types/theme/loader.d.ts +23 -0
  258. package/dist/types/theme/mermaid-cache.d.ts +26 -0
  259. package/dist/types/theme/schema-validation.d.ts +3 -0
  260. package/dist/types/theme/schema.d.ts +25 -0
  261. package/dist/types/theme/session-color.d.ts +43 -0
  262. package/dist/types/theme/shimmer.d.ts +44 -0
  263. package/dist/types/theme/symbols.d.ts +34 -0
  264. package/dist/types/theme/theme-class.d.ts +299 -0
  265. package/dist/types/theme/theme.d.ts +111 -0
  266. package/dist/types/theme/tui-adapters.d.ts +30 -0
  267. package/dist/types/thinking.d.ts +165 -0
  268. package/dist/types/tools/agent-tree.d.ts +36 -0
  269. package/dist/types/tools/ask.d.ts +67 -0
  270. package/dist/types/tools/ast-edit.d.ts +53 -0
  271. package/dist/types/tools/ast-grep.d.ts +52 -0
  272. package/dist/types/tools/autoresearch.d.ts +156 -0
  273. package/dist/types/tools/bash-interactive.d.ts +28 -0
  274. package/dist/types/tools/bash.d.ts +103 -0
  275. package/dist/types/tools/conflict-detect.d.ts +43 -0
  276. package/dist/types/tools/debug.d.ts +60 -0
  277. package/dist/types/tools/default-renderer.d.ts +24 -0
  278. package/dist/types/tools/edit.d.ts +158 -0
  279. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +5 -0
  280. package/dist/types/tools/eval-format/index.d.ts +5 -0
  281. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  282. package/dist/types/tools/eval-format/python.d.ts +2 -0
  283. package/dist/types/tools/eval.d.ts +94 -0
  284. package/dist/types/tools/fetch.d.ts +42 -0
  285. package/dist/types/tools/gh-format.d.ts +8 -0
  286. package/dist/types/tools/github.d.ts +144 -0
  287. package/dist/types/tools/glob.d.ts +44 -0
  288. package/dist/types/tools/goal.d.ts +42 -0
  289. package/dist/types/tools/grep.d.ts +64 -0
  290. package/dist/types/tools/grouped-file-output.d.ts +71 -0
  291. package/dist/types/tools/hashline-format.d.ts +28 -0
  292. package/dist/types/tools/hub.d.ts +340 -0
  293. package/dist/types/tools/index.d.ts +4 -0
  294. package/dist/types/tools/json-tree.d.ts +59 -0
  295. package/dist/types/tools/line-ranges.d.ts +29 -0
  296. package/dist/types/tools/list-limit.d.ts +15 -0
  297. package/dist/types/tools/lsp.d.ts +72 -0
  298. package/dist/types/tools/match-line-format.d.ts +11 -0
  299. package/dist/types/tools/mcp.d.ts +81 -0
  300. package/dist/types/tools/memory.d.ts +67 -0
  301. package/dist/types/tools/output-meta.d.ts +151 -0
  302. package/dist/types/tools/read.d.ts +139 -0
  303. package/dist/types/tools/renderer.d.ts +98 -0
  304. package/dist/types/tools/report-tool-issue.d.ts +8 -0
  305. package/dist/types/tools/resolve.d.ts +47 -0
  306. package/dist/types/tools/streaming-output.d.ts +282 -0
  307. package/dist/types/tools/subprocess.d.ts +24 -0
  308. package/dist/types/tools/task-repair-args.d.ts +53 -0
  309. package/dist/types/tools/task-yield-assembly.d.ts +20 -0
  310. package/dist/types/tools/task.d.ts +425 -0
  311. package/dist/types/tools/terminal-output.d.ts +8 -0
  312. package/dist/types/tools/think.d.ts +13 -0
  313. package/dist/types/tools/todo.d.ts +118 -0
  314. package/dist/types/tools/tool-errors.d.ts +7 -0
  315. package/dist/types/tools/vibe.d.ts +120 -0
  316. package/dist/types/tools/web-search.d.ts +195 -0
  317. package/dist/types/tools/write.d.ts +76 -0
  318. package/dist/types/tools/xd-url.d.ts +13 -0
  319. package/dist/types/tools/xdev.d.ts +52 -0
  320. package/dist/types/tui.d.ts +18 -0
  321. package/dist/types/utils.d.ts +2 -0
  322. package/package.json +37 -3
  323. package/src/app-keybindings.ts +760 -0
  324. package/src/apps/autoresearch-dashboard.ts +500 -0
  325. package/src/apps/autoresearch-data.ts +64 -0
  326. package/src/apps/cleanse-board.ts +341 -0
  327. package/src/apps/cleanse-picker.ts +57 -0
  328. package/src/apps/debug/log-formatting.ts +53 -0
  329. package/src/apps/debug/log-viewer.ts +868 -0
  330. package/src/apps/debug/protocol-probe.ts +267 -0
  331. package/src/apps/debug/raw-sse-buffer.ts +449 -0
  332. package/src/apps/debug/raw-sse.ts +282 -0
  333. package/src/apps/debug/terminal-info.ts +115 -0
  334. package/src/apps/debug/viewer-frame.ts +174 -0
  335. package/src/apps/git/avatar.ts +52 -0
  336. package/src/apps/git/colors.ts +109 -0
  337. package/src/apps/git/diff-pane.ts +1512 -0
  338. package/src/apps/git/git-tui.ts +889 -0
  339. package/src/apps/git/sidebar.ts +1204 -0
  340. package/src/apps/git/state.ts +113 -0
  341. package/src/apps/if-bench-board.ts +324 -0
  342. package/src/apps/live-visualizer.ts +238 -0
  343. package/src/apps/ps-data.ts +116 -0
  344. package/src/apps/ps-top.ts +422 -0
  345. package/src/apps/session-picker.ts +95 -0
  346. package/src/apps/setup-model-picker.ts +21 -0
  347. package/src/apps/standalone-picker.ts +111 -0
  348. package/src/autocomplete.ts +9 -13
  349. package/src/chat/advisor-message.ts +175 -0
  350. package/src/chat/assistant-message.ts +1214 -0
  351. package/src/chat/background-tan-message.ts +35 -0
  352. package/src/chat/bash-execution.ts +394 -0
  353. package/src/chat/cache-invalidation-marker.ts +94 -0
  354. package/src/chat/chat-transcript-builder.ts +593 -0
  355. package/src/chat/collab-prompt-message.ts +34 -0
  356. package/src/chat/compaction-summary-message.ts +261 -0
  357. package/src/chat/custom-message.ts +23 -0
  358. package/src/chat/display-preferences.ts +24 -0
  359. package/src/chat/eval-execution.ts +172 -0
  360. package/src/chat/execution-shared.ts +113 -0
  361. package/src/chat/extension-types.ts +46 -0
  362. package/src/chat/hook-message.ts +20 -0
  363. package/src/chat/image-loading.ts +122 -0
  364. package/src/chat/late-diagnostics-message.ts +92 -0
  365. package/src/chat/messages.ts +305 -0
  366. package/src/chat/reaction.ts +65 -0
  367. package/src/chat/read-target.ts +7 -0
  368. package/src/chat/read-tool-group.ts +873 -0
  369. package/src/chat/served-model-marker.ts +91 -0
  370. package/src/chat/skill-message.ts +220 -0
  371. package/src/chat/skill-title-input.ts +41 -0
  372. package/src/chat/stripped-tool-calls-placeholder.ts +35 -0
  373. package/src/chat/thinking-display.ts +312 -0
  374. package/src/chat/todo-reminder.ts +33 -0
  375. package/src/chat/tool-execution.ts +1395 -0
  376. package/src/chat/transcript-browser.ts +268 -0
  377. package/src/chat/transcript-entry.ts +96 -0
  378. package/src/chat/transcript-outline.ts +260 -0
  379. package/src/chat/transcript-render-helpers.ts +315 -0
  380. package/src/chat/ttsr-notification.ts +123 -0
  381. package/src/chat/user-message.ts +287 -0
  382. package/src/chrome/chat-block.ts +110 -0
  383. package/src/chrome/collab-qrcode.ts +74 -0
  384. package/src/chrome/context-thresholds.ts +85 -0
  385. package/src/chrome/countdown-timer.ts +74 -0
  386. package/src/chrome/diff.ts +266 -0
  387. package/src/chrome/dynamic-border.ts +36 -0
  388. package/src/chrome/error-block.ts +63 -0
  389. package/src/chrome/form-theme.ts +10 -0
  390. package/src/chrome/format.ts +66 -0
  391. package/src/chrome/index.ts +21 -0
  392. package/src/chrome/keybinding-hints.ts +55 -0
  393. package/src/chrome/live-board.ts +123 -0
  394. package/src/chrome/local-date.ts +20 -0
  395. package/src/chrome/message-divider.ts +51 -0
  396. package/src/chrome/message-frame.ts +164 -0
  397. package/src/chrome/message-notice.ts +103 -0
  398. package/src/chrome/overlay-box.ts +301 -0
  399. package/src/chrome/qrcode.ts +539 -0
  400. package/src/chrome/segment-track.ts +86 -0
  401. package/src/chrome/select-list-mouse-routing.ts +11 -0
  402. package/src/chrome/selector-helpers.ts +119 -0
  403. package/src/chrome/shared.ts +27 -0
  404. package/src/chrome/streaming-panel.ts +72 -0
  405. package/src/chrome/tool-activity.ts +44 -0
  406. package/src/chrome/transcript-container.ts +902 -0
  407. package/src/chrome/transcript-status.ts +27 -0
  408. package/src/chrome/visual-truncate.ts +70 -0
  409. package/src/components/disclosure.ts +235 -0
  410. package/src/components/editor.ts +10 -12
  411. package/src/components/form.ts +566 -0
  412. package/src/components/image.ts +80 -53
  413. package/src/components/input.ts +6 -27
  414. package/src/components/key-value-list.ts +88 -0
  415. package/src/components/layout/geometry.ts +202 -0
  416. package/src/components/layout/row.ts +283 -0
  417. package/src/components/layout/split-pane.ts +349 -0
  418. package/src/components/layout/stack.ts +261 -0
  419. package/src/components/markdown.ts +16 -12
  420. package/src/components/menu-selection.ts +248 -0
  421. package/src/components/metric.ts +132 -0
  422. package/src/components/progress-bar.ts +140 -0
  423. package/src/components/scroll-view.ts +372 -70
  424. package/src/components/scroll-viewport.ts +154 -0
  425. package/src/components/section.ts +77 -0
  426. package/src/components/select-list.ts +238 -159
  427. package/src/components/settings-list.ts +103 -80
  428. package/src/components/table.ts +127 -0
  429. package/src/components/tree-view.ts +481 -0
  430. package/src/components/wizard-step.ts +229 -0
  431. package/src/deccara.ts +1 -3
  432. package/src/hotkeys-markdown.ts +100 -0
  433. package/src/index.ts +18 -0
  434. package/src/keybinding-matchers.ts +99 -0
  435. package/src/keys.ts +2 -2
  436. package/src/lang-from-path.ts +252 -0
  437. package/src/overlays/advisor-config.ts +761 -0
  438. package/src/overlays/agent-activity.ts +113 -0
  439. package/src/overlays/agent-hub-projection.ts +250 -0
  440. package/src/overlays/agent-hub-renderer.ts +281 -0
  441. package/src/overlays/agent-hub-types.ts +74 -0
  442. package/src/overlays/agent-hub.ts +1562 -0
  443. package/src/overlays/agent-transcript-viewer.ts +669 -0
  444. package/src/overlays/agents-hub.ts +1203 -0
  445. package/src/overlays/ask-dialog.ts +1202 -0
  446. package/src/overlays/bordered-loader.ts +41 -0
  447. package/src/overlays/btw-history-panel.ts +591 -0
  448. package/src/overlays/btw-history.ts +33 -0
  449. package/src/overlays/btw-panel.ts +167 -0
  450. package/src/overlays/cleanse-panel.ts +167 -0
  451. package/src/overlays/codex-reset-fireworks.ts +375 -0
  452. package/src/overlays/composer-shape-preview.ts +137 -0
  453. package/src/overlays/composer-shape-registry.ts +82 -0
  454. package/src/overlays/copy-selector.ts +618 -0
  455. package/src/overlays/copy-targets.ts +210 -0
  456. package/src/overlays/error-banner.ts +39 -0
  457. package/src/overlays/extensions/display-text.ts +39 -0
  458. package/src/overlays/extensions/extension-dashboard.ts +645 -0
  459. package/src/overlays/extensions/extension-list.ts +667 -0
  460. package/src/overlays/extensions/inspector-model.ts +484 -0
  461. package/src/overlays/extensions/inspector-panel.ts +669 -0
  462. package/src/overlays/extensions/live-tool-session.ts +67 -0
  463. package/src/overlays/extensions/mcp-runtime.ts +289 -0
  464. package/src/overlays/extensions/state-manager.ts +337 -0
  465. package/src/overlays/extensions/types.ts +208 -0
  466. package/src/overlays/history-search.ts +268 -0
  467. package/src/overlays/hook-editor.ts +272 -0
  468. package/src/overlays/hook-input.ts +84 -0
  469. package/src/overlays/hook-selector.ts +635 -0
  470. package/src/overlays/hub-frame.ts +262 -0
  471. package/src/overlays/login-dialog.ts +231 -0
  472. package/src/overlays/logout-account-selector.ts +115 -0
  473. package/src/overlays/mcp-add-wizard.ts +1436 -0
  474. package/src/overlays/model-browser.ts +1176 -0
  475. package/src/overlays/model-hub.ts +2279 -0
  476. package/src/overlays/model-picker.ts +285 -0
  477. package/src/overlays/model-selector.ts +107 -0
  478. package/src/overlays/move-overlay.ts +197 -0
  479. package/src/overlays/oauth-selector.ts +446 -0
  480. package/src/overlays/omfg-panel.ts +136 -0
  481. package/src/overlays/pause-screen.ts +198 -0
  482. package/src/overlays/plan-review-overlay.ts +1227 -0
  483. package/src/overlays/plan-save-overlay.ts +74 -0
  484. package/src/overlays/plan-toc.ts +139 -0
  485. package/src/overlays/plugin-selector.ts +97 -0
  486. package/src/overlays/plugin-settings.ts +839 -0
  487. package/src/overlays/queue-mode-selector.ts +55 -0
  488. package/src/overlays/reset-usage-selector.ts +170 -0
  489. package/src/overlays/rewind-selector.ts +499 -0
  490. package/src/overlays/running-subagent-badge.ts +19 -0
  491. package/src/overlays/session-account-selector.ts +68 -0
  492. package/src/overlays/session-info-overlay.ts +96 -0
  493. package/src/overlays/session-observer-registry.ts +289 -0
  494. package/src/overlays/session-selector.ts +1137 -0
  495. package/src/overlays/settings-defs.ts +315 -0
  496. package/src/overlays/settings-selector.ts +1415 -0
  497. package/src/overlays/show-images-selector.ts +44 -0
  498. package/src/overlays/snapcompact-shape-preview-doc.md +14 -0
  499. package/src/overlays/snapcompact-shape-preview.ts +192 -0
  500. package/src/overlays/theme-selector.ts +62 -0
  501. package/src/overlays/thinking-selector.ts +51 -0
  502. package/src/overlays/tiny-title-download-progress.ts +102 -0
  503. package/src/overlays/tree-selector.ts +1121 -0
  504. package/src/overlays/usage-dashboard.ts +629 -0
  505. package/src/overlays/usage-display.ts +45 -0
  506. package/src/overlays/usage-row.ts +118 -0
  507. package/src/prompt/attachment-chips.ts +201 -0
  508. package/src/prompt/composer-attachments.ts +280 -0
  509. package/src/prompt/composer-cache.ts +304 -0
  510. package/src/prompt/composer.ts +934 -0
  511. package/src/prompt/custom-editor.ts +1392 -0
  512. package/src/prompt/data/emojis.json +1 -0
  513. package/src/prompt/editor-top-gap.ts +28 -0
  514. package/src/prompt/emoji-autocomplete.ts +285 -0
  515. package/src/prompt/github-ref-autocomplete.ts +75 -0
  516. package/src/prompt/gradient-highlight.ts +101 -0
  517. package/src/prompt/image-format.ts +28 -0
  518. package/src/prompt/image-references.ts +102 -0
  519. package/src/prompt/interactive-context-helpers.ts +126 -0
  520. package/src/prompt/internal-url-autocomplete.ts +155 -0
  521. package/src/prompt/macos-spelling.ts +456 -0
  522. package/src/prompt/magic-keyword-boundary.ts +23 -0
  523. package/src/prompt/magic-keywords.ts +42 -0
  524. package/src/prompt/markdown-prose.ts +245 -0
  525. package/src/prompt/model-mention-autocomplete.ts +94 -0
  526. package/src/prompt/model-mention-syntax.ts +41 -0
  527. package/src/prompt/orchestrate.ts +26 -0
  528. package/src/prompt/prompt-action-autocomplete.ts +337 -0
  529. package/src/prompt/queue-input.ts +132 -0
  530. package/src/prompt/skill-tokens.ts +36 -0
  531. package/src/prompt/tips.txt +27 -0
  532. package/src/prompt/tools-markdown.ts +29 -0
  533. package/src/prompt/ultrathink.ts +26 -0
  534. package/src/prompt/usage-amounts.ts +99 -0
  535. package/src/prompt/video.ts +47 -0
  536. package/src/prompt/welcome.ts +605 -0
  537. package/src/prompt/workflow.ts +26 -0
  538. package/src/render/code-cell.ts +244 -0
  539. package/src/render/file-list.ts +58 -0
  540. package/src/render/hyperlink.ts +192 -0
  541. package/src/render/index.ts +17 -0
  542. package/src/render/output-block.ts +267 -0
  543. package/src/render/output-pane.ts +299 -0
  544. package/src/render/render-utils.ts +1182 -0
  545. package/src/render/sixel.ts +69 -0
  546. package/src/render/status-line.ts +55 -0
  547. package/src/render/tool-card.ts +272 -0
  548. package/src/render/tree-list.ts +188 -0
  549. package/src/render/types.ts +19 -0
  550. package/src/render/utils.ts +109 -0
  551. package/src/render/width-aware-text.ts +60 -0
  552. package/src/setup/lazy.ts +17 -0
  553. package/src/setup/scenes/composer.ts +127 -0
  554. package/src/setup/scenes/glyph.ts +120 -0
  555. package/src/setup/scenes/model.ts +138 -0
  556. package/src/setup/scenes/outro.ts +24 -0
  557. package/src/setup/scenes/providers.ts +107 -0
  558. package/src/setup/scenes/sign-in.ts +373 -0
  559. package/src/setup/scenes/splash.ts +192 -0
  560. package/src/setup/scenes/theme.ts +343 -0
  561. package/src/setup/scenes/types.ts +111 -0
  562. package/src/setup/scenes/web-search.ts +165 -0
  563. package/src/setup/setup-version.ts +11 -0
  564. package/src/setup/startup-splash.ts +107 -0
  565. package/src/setup/wizard-overlay.ts +319 -0
  566. package/src/setup/wizard.ts +101 -0
  567. package/src/status-line/component.ts +3003 -0
  568. package/src/status-line/context-usage.ts +688 -0
  569. package/src/status-line/footer.ts +323 -0
  570. package/src/status-line/git-utils.ts +42 -0
  571. package/src/status-line/host.ts +97 -0
  572. package/src/status-line/index.ts +6 -0
  573. package/src/status-line/loop.ts +28 -0
  574. package/src/status-line/metrics.ts +94 -0
  575. package/src/status-line/presets.ts +107 -0
  576. package/src/status-line/schema.ts +57 -0
  577. package/src/status-line/segments.ts +934 -0
  578. package/src/status-line/separators.ts +55 -0
  579. package/src/status-line/types.ts +227 -0
  580. package/src/stdin-buffer.ts +4 -2
  581. package/src/theme/color.ts +95 -0
  582. package/src/theme/dark.json +95 -0
  583. package/src/theme/defaults/alabaster.json +93 -0
  584. package/src/theme/defaults/amethyst.json +96 -0
  585. package/src/theme/defaults/anthracite.json +93 -0
  586. package/src/theme/defaults/basalt.json +91 -0
  587. package/src/theme/defaults/birch.json +95 -0
  588. package/src/theme/defaults/dark-abyss.json +91 -0
  589. package/src/theme/defaults/dark-arctic.json +104 -0
  590. package/src/theme/defaults/dark-aurora.json +95 -0
  591. package/src/theme/defaults/dark-catppuccin.json +107 -0
  592. package/src/theme/defaults/dark-cavern.json +91 -0
  593. package/src/theme/defaults/dark-celestial.json +106 -0
  594. package/src/theme/defaults/dark-copper.json +95 -0
  595. package/src/theme/defaults/dark-cosmos.json +90 -0
  596. package/src/theme/defaults/dark-cyberpunk.json +102 -0
  597. package/src/theme/defaults/dark-dracula.json +98 -0
  598. package/src/theme/defaults/dark-eclipse.json +91 -0
  599. package/src/theme/defaults/dark-ember.json +95 -0
  600. package/src/theme/defaults/dark-equinox.json +90 -0
  601. package/src/theme/defaults/dark-forest.json +96 -0
  602. package/src/theme/defaults/dark-github.json +105 -0
  603. package/src/theme/defaults/dark-gruvbox.json +112 -0
  604. package/src/theme/defaults/dark-lavender.json +95 -0
  605. package/src/theme/defaults/dark-lunar.json +89 -0
  606. package/src/theme/defaults/dark-midnight.json +95 -0
  607. package/src/theme/defaults/dark-monochrome.json +94 -0
  608. package/src/theme/defaults/dark-monokai.json +98 -0
  609. package/src/theme/defaults/dark-nebula.json +90 -0
  610. package/src/theme/defaults/dark-nord.json +98 -0
  611. package/src/theme/defaults/dark-ocean.json +101 -0
  612. package/src/theme/defaults/dark-one.json +100 -0
  613. package/src/theme/defaults/dark-poimandres.json +115 -0
  614. package/src/theme/defaults/dark-rainforest.json +91 -0
  615. package/src/theme/defaults/dark-reef.json +91 -0
  616. package/src/theme/defaults/dark-retro.json +92 -0
  617. package/src/theme/defaults/dark-rose-pine.json +96 -0
  618. package/src/theme/defaults/dark-sakura.json +95 -0
  619. package/src/theme/defaults/dark-slate.json +95 -0
  620. package/src/theme/defaults/dark-solarized.json +97 -0
  621. package/src/theme/defaults/dark-solstice.json +90 -0
  622. package/src/theme/defaults/dark-starfall.json +91 -0
  623. package/src/theme/defaults/dark-sunset.json +99 -0
  624. package/src/theme/defaults/dark-swamp.json +90 -0
  625. package/src/theme/defaults/dark-synthwave.json +103 -0
  626. package/src/theme/defaults/dark-taiga.json +91 -0
  627. package/src/theme/defaults/dark-terminal.json +95 -0
  628. package/src/theme/defaults/dark-tokyo-night.json +101 -0
  629. package/src/theme/defaults/dark-tundra.json +91 -0
  630. package/src/theme/defaults/dark-twilight.json +91 -0
  631. package/src/theme/defaults/dark-volcanic.json +91 -0
  632. package/src/theme/defaults/graphite.json +92 -0
  633. package/src/theme/defaults/index.ts +201 -0
  634. package/src/theme/defaults/light-arctic.json +107 -0
  635. package/src/theme/defaults/light-aurora-day.json +91 -0
  636. package/src/theme/defaults/light-canyon.json +91 -0
  637. package/src/theme/defaults/light-catppuccin.json +106 -0
  638. package/src/theme/defaults/light-cirrus.json +90 -0
  639. package/src/theme/defaults/light-coral.json +95 -0
  640. package/src/theme/defaults/light-cyberpunk.json +96 -0
  641. package/src/theme/defaults/light-dawn.json +90 -0
  642. package/src/theme/defaults/light-dunes.json +91 -0
  643. package/src/theme/defaults/light-eucalyptus.json +95 -0
  644. package/src/theme/defaults/light-forest.json +100 -0
  645. package/src/theme/defaults/light-frost.json +95 -0
  646. package/src/theme/defaults/light-github.json +115 -0
  647. package/src/theme/defaults/light-glacier.json +91 -0
  648. package/src/theme/defaults/light-gruvbox.json +108 -0
  649. package/src/theme/defaults/light-haze.json +90 -0
  650. package/src/theme/defaults/light-honeycomb.json +95 -0
  651. package/src/theme/defaults/light-lagoon.json +91 -0
  652. package/src/theme/defaults/light-lavender.json +95 -0
  653. package/src/theme/defaults/light-meadow.json +91 -0
  654. package/src/theme/defaults/light-mint.json +95 -0
  655. package/src/theme/defaults/light-monochrome.json +101 -0
  656. package/src/theme/defaults/light-ocean.json +99 -0
  657. package/src/theme/defaults/light-one.json +99 -0
  658. package/src/theme/defaults/light-opal.json +91 -0
  659. package/src/theme/defaults/light-orchard.json +91 -0
  660. package/src/theme/defaults/light-paper.json +95 -0
  661. package/src/theme/defaults/light-poimandres.json +115 -0
  662. package/src/theme/defaults/light-prism.json +90 -0
  663. package/src/theme/defaults/light-retro.json +98 -0
  664. package/src/theme/defaults/light-sand.json +95 -0
  665. package/src/theme/defaults/light-savanna.json +91 -0
  666. package/src/theme/defaults/light-solarized.json +102 -0
  667. package/src/theme/defaults/light-soleil.json +90 -0
  668. package/src/theme/defaults/light-sunset.json +99 -0
  669. package/src/theme/defaults/light-synthwave.json +98 -0
  670. package/src/theme/defaults/light-tokyo-night.json +111 -0
  671. package/src/theme/defaults/light-wetland.json +91 -0
  672. package/src/theme/defaults/light-zenith.json +89 -0
  673. package/src/theme/defaults/limestone.json +94 -0
  674. package/src/theme/defaults/mahogany.json +97 -0
  675. package/src/theme/defaults/marble.json +93 -0
  676. package/src/theme/defaults/obsidian.json +91 -0
  677. package/src/theme/defaults/onyx.json +91 -0
  678. package/src/theme/defaults/pearl.json +93 -0
  679. package/src/theme/defaults/porcelain.json +91 -0
  680. package/src/theme/defaults/quartz.json +96 -0
  681. package/src/theme/defaults/sandstone.json +95 -0
  682. package/src/theme/defaults/titanium.json +90 -0
  683. package/src/theme/index.ts +5 -0
  684. package/src/theme/light.json +93 -0
  685. package/src/theme/loader.ts +200 -0
  686. package/src/theme/mermaid-cache.ts +92 -0
  687. package/src/theme/schema-validation.ts +119 -0
  688. package/src/theme/schema.ts +168 -0
  689. package/src/theme/session-color.ts +253 -0
  690. package/src/theme/shimmer.ts +307 -0
  691. package/src/theme/symbols.ts +1433 -0
  692. package/src/theme/theme-class.ts +787 -0
  693. package/src/theme/theme-schema.json +463 -0
  694. package/src/theme/theme.ts +810 -0
  695. package/src/theme/tui-adapters.ts +339 -0
  696. package/src/thinking.ts +380 -0
  697. package/src/tools/agent-tree.ts +128 -0
  698. package/src/tools/ask.ts +482 -0
  699. package/src/tools/ast-edit.ts +210 -0
  700. package/src/tools/ast-grep.ts +171 -0
  701. package/src/tools/autoresearch.ts +287 -0
  702. package/src/tools/bash-interactive.ts +295 -0
  703. package/src/tools/bash.ts +468 -0
  704. package/src/tools/conflict-detect.ts +77 -0
  705. package/src/tools/debug.ts +167 -0
  706. package/src/tools/default-renderer.ts +172 -0
  707. package/src/tools/edit.ts +1258 -0
  708. package/src/tools/eval-format/code-mode-declarations.ts +61 -0
  709. package/src/tools/eval-format/index.ts +16 -0
  710. package/src/tools/eval-format/javascript.ts +952 -0
  711. package/src/tools/eval-format/python.ts +544 -0
  712. package/src/tools/eval.ts +835 -0
  713. package/src/tools/fetch.ts +226 -0
  714. package/src/tools/gh-format.ts +18 -0
  715. package/src/tools/github.ts +686 -0
  716. package/src/tools/glob.ts +213 -0
  717. package/src/tools/goal.ts +153 -0
  718. package/src/tools/grep.ts +385 -0
  719. package/src/tools/grouped-file-output.ts +211 -0
  720. package/src/tools/hashline-format.ts +59 -0
  721. package/src/tools/hub.ts +1488 -0
  722. package/src/tools/index.ts +71 -0
  723. package/src/tools/json-tree.ts +388 -0
  724. package/src/tools/line-ranges.ts +98 -0
  725. package/src/tools/list-limit.ts +43 -0
  726. package/src/tools/lsp.ts +702 -0
  727. package/src/tools/match-line-format.ts +20 -0
  728. package/src/tools/mcp.ts +309 -0
  729. package/src/tools/memory.ts +220 -0
  730. package/src/tools/output-meta.ts +367 -0
  731. package/src/tools/read.ts +628 -0
  732. package/src/tools/renderer.ts +107 -0
  733. package/src/tools/report-tool-issue.ts +13 -0
  734. package/src/tools/resolve.ts +139 -0
  735. package/src/tools/streaming-output.ts +1576 -0
  736. package/src/tools/subprocess.ts +39 -0
  737. package/src/tools/task-repair-args.ts +118 -0
  738. package/src/tools/task-yield-assembly.ts +147 -0
  739. package/src/tools/task.ts +2110 -0
  740. package/src/tools/terminal-output.ts +153 -0
  741. package/src/tools/think.ts +28 -0
  742. package/src/tools/todo.ts +580 -0
  743. package/src/tools/tool-errors.ts +15 -0
  744. package/src/tools/vibe.ts +420 -0
  745. package/src/tools/web-search.ts +409 -0
  746. package/src/tools/write.ts +511 -0
  747. package/src/tools/xd-url.ts +28 -0
  748. package/src/tools/xdev.ts +201 -0
  749. package/src/tui.ts +70 -8
  750. package/src/utils.ts +8 -0
@@ -0,0 +1,3003 @@
1
+ import * as path from "node:path";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
4
+ import {
5
+ getAntigravityCounterKeyForModel,
6
+ scopeAntigravityLimitsForModel,
7
+ } from "@oh-my-pi/pi-ai/usage/google-antigravity";
8
+ import { getNextTimeBasedPricingTransition } from "@oh-my-pi/pi-catalog/models";
9
+ import type { Model, ModelCost } from "@oh-my-pi/pi-catalog/types";
10
+ import type { VcsRepo } from "@oh-my-pi/pi-natives";
11
+ import * as vcs from "@oh-my-pi/pi-natives/vcs";
12
+ import {
13
+ type Component,
14
+ type ComposerStyle,
15
+ claudeComposerStyle,
16
+ padding,
17
+ SPINNER_ADVANCE_MS,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "../index";
21
+ import { adjustHsv, formatNumber, getProjectDir, hexToRgb, rgbToHex } from "@oh-my-pi/pi-utils";
22
+ import type {
23
+ ActiveRepoContext,
24
+ StatusAccountIdentity as OAuthAccountIdentity,
25
+ StatusLineHost,
26
+ StatusLineSession,
27
+ } from "./host";
28
+ import { getSessionAccentAnsi, getSessionAccentHex } from "../theme/session-color";
29
+ import { sanitizeStatusText } from "../chrome/shared";
30
+ import { getThemeEpoch, theme } from "../theme";
31
+ import { type CompactionBoundaries, EMPTY_STRING_PARTS, getToolSchemaMetadataRevision } from "./context-usage";
32
+ import {
33
+ type CodexResetFireworksEvent,
34
+ type CodexResetUsageSnapshot,
35
+ detectCodexResetFireworks,
36
+ } from "../overlays/codex-reset-fireworks";
37
+ import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCacheContext } from "./git-utils";
38
+ import { getPreset } from "./presets";
39
+ import { renderSegment, type SegmentContext } from "./segments";
40
+ import { getSeparator } from "./separators";
41
+ import type {
42
+ CollabStatus,
43
+ EffectiveStatusLineSettings,
44
+ StatusLineSegmentId,
45
+ StatusLineSegmentOptions,
46
+ StatusLineSettings,
47
+ } from "./types";
48
+
49
+ const JJ_REFRESH_TTL_MS = 5000;
50
+ const JJ_COMMAND_TIMEOUT_MS = 5_000;
51
+ const WATCHER_FAILURE_POLL_TTL_MS = 5000;
52
+ /** Brand-color fade duration across working-state edges (rust omp's `BRAND_FADE`). */
53
+ const BRAND_FADE_MS = 450;
54
+ /** Repaint cadence while the brand fade is in flight (rust omp's `FADE_FRAME`). */
55
+ const BRAND_FADE_FRAME_MS = 40;
56
+
57
+ /** A displayable limit after provider, account, model, and window filtering. */
58
+ interface UsageWindowCandidate {
59
+ id?: string;
60
+ windowClass: "5h" | "daily" | "7d" | "monthly";
61
+ fraction: number;
62
+ resetsAt?: number;
63
+ }
64
+
65
+ /** Limits sharing one model/tier scope, ranked as an indivisible display unit. */
66
+ interface UsageScopeGroup {
67
+ priority: number;
68
+ tier?: string;
69
+ candidates: UsageWindowCandidate[];
70
+ }
71
+
72
+ function normalizeUsageScopeValue(value: unknown): string | undefined {
73
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
74
+ }
75
+
76
+ /**
77
+ * Fireworks are stateful, so their report match must be stricter than the
78
+ * status display's fallback matching: every known credential identifier must
79
+ * be present and equal or a workspace sibling can mutate this account's
80
+ * baseline.
81
+ */
82
+ function codexReportMatchesExactIdentity(report: UsageReport, identity: OAuthAccountIdentity | undefined): boolean {
83
+ if (!identity) return false;
84
+ const accountId = normalizeUsageScopeValue(identity.accountId);
85
+ const email = normalizeUsageScopeValue(identity.email);
86
+ const projectId = normalizeUsageScopeValue(identity.projectId);
87
+ const orgId = normalizeUsageScopeValue(identity.orgId);
88
+ if (!accountId && !email && !projectId && !orgId) return false;
89
+
90
+ const metadata = report.metadata ?? {};
91
+ const reportAccountId =
92
+ normalizeUsageScopeValue(metadata.accountId) ?? normalizeUsageScopeValue(metadata.account_id);
93
+ const reportProjectId =
94
+ normalizeUsageScopeValue(metadata.projectId) ?? normalizeUsageScopeValue(metadata.project_id);
95
+ if (accountId && reportAccountId !== accountId) return false;
96
+ if (email && normalizeUsageScopeValue(metadata.email) !== email) return false;
97
+ if (projectId && reportProjectId !== projectId) return false;
98
+ if (orgId && normalizeUsageScopeValue(metadata.orgId) !== orgId) return false;
99
+ return true;
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════
103
+ // Context-usage memo
104
+ // ═══════════════════════════════════════════════════════════════════════════
105
+
106
+ /**
107
+ * Cheap structural fingerprint of a message's tokenizable content. O(blocks) —
108
+ * only reads string `.length` and primitives, never copies or serializes.
109
+ * Detects in-place growth of the streaming tail (and other in-place mutations)
110
+ * so the cached `getContextUsage()` result is recomputed when — and only when —
111
+ * the numbers it depends on change.
112
+ */
113
+ function messageFingerprint(msg: AgentMessage): string {
114
+ const role = (msg as { role?: string }).role ?? "";
115
+ const ts = (msg as { timestamp?: number }).timestamp ?? 0;
116
+ let textLen = 0;
117
+ let blocks = 0;
118
+ let images = 0;
119
+ if (role === "bashExecution") {
120
+ const b = msg as { command?: unknown; output?: unknown };
121
+ if (typeof b.command === "string") textLen += b.command.length;
122
+ if (typeof b.output === "string") textLen += b.output.length;
123
+ } else if (role === "user") {
124
+ const content = (msg as { content?: unknown }).content;
125
+ if (typeof content === "string") {
126
+ textLen += content.length;
127
+ } else if (Array.isArray(content)) {
128
+ blocks = content.length;
129
+ for (const block of content) {
130
+ if (block?.type === "text" && typeof block.text === "string") textLen += block.text.length;
131
+ }
132
+ }
133
+ } else if (role === "assistant") {
134
+ const assistantMsg = msg as AssistantMessage;
135
+ const usageExt = assistantMsg.usage as unknown as { promptTokensDetails?: unknown };
136
+ const usageTotal = assistantMsg.usage?.totalTokens ?? 0;
137
+ const promptBuckets = usageExt?.promptTokensDetails ? 1 : 0;
138
+ const stopReason = assistantMsg.stopReason ?? "";
139
+
140
+ let signatureLen = 0;
141
+ let redactedLen = 0;
142
+ const msgExt = assistantMsg as unknown as {
143
+ thinkingSignature?: string;
144
+ textSignature?: string;
145
+ thoughtSignature?: string;
146
+ redactedThinking?: { data?: string };
147
+ };
148
+ const thinkingSignature = msgExt.thinkingSignature;
149
+ if (typeof thinkingSignature === "string") {
150
+ signatureLen += thinkingSignature.length;
151
+ }
152
+ const textSignature = msgExt.textSignature;
153
+ if (typeof textSignature === "string") {
154
+ signatureLen += textSignature.length;
155
+ }
156
+ const thoughtSignature = msgExt.thoughtSignature;
157
+ if (typeof thoughtSignature === "string") {
158
+ signatureLen += thoughtSignature.length;
159
+ }
160
+ const redactedData = msgExt.redactedThinking?.data;
161
+ if (typeof redactedData === "string") {
162
+ redactedLen += redactedData.length;
163
+ }
164
+
165
+ const content = (msg as { content?: unknown }).content;
166
+ if (Array.isArray(content)) {
167
+ blocks = content.length;
168
+ for (const block of content) {
169
+ if (!block || typeof block !== "object") continue;
170
+ const b = block as {
171
+ type?: string;
172
+ text?: string;
173
+ thinking?: string;
174
+ thinkingSignature?: string;
175
+ signature?: string;
176
+ textSignature?: string;
177
+ thoughtSignature?: string;
178
+ data?: string;
179
+ name?: string;
180
+ arguments?: unknown;
181
+ };
182
+ if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
183
+ else if (b.type === "thinking") {
184
+ if (typeof b.thinking === "string") textLen += b.thinking.length;
185
+ if (typeof b.thinkingSignature === "string") signatureLen += b.thinkingSignature.length;
186
+ if (typeof b.signature === "string") signatureLen += b.signature.length;
187
+ if (typeof b.textSignature === "string") signatureLen += b.textSignature.length;
188
+ if (typeof b.thoughtSignature === "string") signatureLen += b.thoughtSignature.length;
189
+ } else if (b.type === "redactedThinking" && typeof b.data === "string") {
190
+ redactedLen += b.data.length;
191
+ } else if (b.type === "toolCall") {
192
+ if (typeof b.name === "string") textLen += b.name.length;
193
+ if (b.arguments !== undefined) {
194
+ try {
195
+ textLen += JSON.stringify(b.arguments, (_key, value) =>
196
+ typeof value === "bigint" ? value.toString() : value,
197
+ ).length;
198
+ } catch {
199
+ textLen += String(b.arguments).length;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ return `${role}:${ts}:${textLen}:${blocks}:${images}:${signatureLen}:${redactedLen}:${usageTotal}:${promptBuckets}:${stopReason}`;
206
+ } else if (role === "toolResult" || role === "hookMessage") {
207
+ const content = (msg as { content?: unknown }).content;
208
+ if (typeof content === "string") {
209
+ textLen += content.length;
210
+ } else if (Array.isArray(content)) {
211
+ blocks = content.length;
212
+ for (const block of content) {
213
+ if (!block || typeof block !== "object") continue;
214
+ const b = block as { type?: string; text?: string };
215
+ if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
216
+ else if (b.type === "image") images++;
217
+ }
218
+ }
219
+ } else if (role === "branchSummary" || role === "compactionSummary") {
220
+ const s = (msg as { summary?: unknown }).summary;
221
+ if (typeof s === "string") textLen += s.length;
222
+ }
223
+ return `${role}:${ts}:${textLen}:${blocks}:${images}`;
224
+ }
225
+
226
+ interface ContextUsageMemo {
227
+ messagesRef: readonly AgentMessage[];
228
+ length: number;
229
+ lastFingerprint: string | undefined;
230
+ modelContextWindow: number;
231
+ contextUsageRevision: number;
232
+ toolSchemaMetadataRevision: number;
233
+ settingsRevision: number;
234
+ tokenizerRef: unknown;
235
+ usedTokens: number;
236
+ contextWindow: number;
237
+ systemPromptRef: readonly string[] | undefined;
238
+ toolsRef: readonly any[] | undefined;
239
+ skillsRef: readonly any[] | undefined;
240
+ }
241
+
242
+ interface StatusLineExternalInputs {
243
+ themeRef: unknown;
244
+ themeEpoch: number;
245
+ projectDir: string;
246
+ sessionRef: StatusLineSession;
247
+ sessionFile: string | undefined;
248
+ sessionSettingsRef: unknown;
249
+ sessionSettingsRevision: number;
250
+ globalSettingsRevision: number;
251
+ stateRef: unknown;
252
+ stateMessagesRef: readonly AgentMessage[];
253
+ stateMessagesLength: number;
254
+ stateLastMessageRef: AgentMessage | undefined;
255
+ stateLastMessageContentRef: unknown;
256
+ messagesRef: readonly AgentMessage[];
257
+ messagesLength: number;
258
+ lastMessageRef: AgentMessage | undefined;
259
+ lastMessageRole: string | undefined;
260
+ lastMessageTimestamp: number;
261
+ lastMessageContentSize: number;
262
+ lastMessageBlockCount: number;
263
+ lastMessageUsageTotal: number;
264
+ lastMessageStopReason: string | undefined;
265
+ modelRef: unknown;
266
+ sessionModelRef: unknown;
267
+ sessionModelId: string | undefined;
268
+ sessionModelContextWindow: Model["contextWindow"] | undefined;
269
+ modelId: string | undefined;
270
+ modelName: string | undefined;
271
+ modelProvider: string | undefined;
272
+ modelContextWindow: Model["contextWindow"] | undefined;
273
+ modelThinking: Model["thinking"];
274
+ thinkingLevel: unknown;
275
+ modelCostRef: unknown;
276
+ contextUsageRevision: number;
277
+ systemPromptRef: unknown;
278
+ systemPromptLength: number;
279
+ systemPromptContentSize: number;
280
+ toolsRef: readonly unknown[] | undefined;
281
+ toolsLength: number;
282
+ toolSchemaMetadataRevision: number;
283
+ tokenizerRef: unknown;
284
+ skillsRef: unknown;
285
+ skillsLength: number;
286
+ sessionName: string | undefined;
287
+ sessionId: string | undefined;
288
+ isStreaming: boolean | undefined;
289
+ isAutoThinking: boolean | undefined;
290
+ isFastModeActive: boolean;
291
+ compactionSpeculation: unknown;
292
+ }
293
+
294
+ interface CachedStatusLine {
295
+ content: string;
296
+ dimmedContent: string;
297
+ width: number;
298
+ availableWidth: number;
299
+ renderRevision: number;
300
+ inputRevision: number;
301
+ placeholders: boolean;
302
+ previewTitle: string | undefined;
303
+ externalInputs: StatusLineExternalInputs;
304
+ }
305
+
306
+ interface ActiveRepoCache {
307
+ projectDir: string;
308
+ activeRepo: ActiveRepoContext | null;
309
+ effectiveGitCwd: string;
310
+ repository: VcsRepo | null;
311
+ displayRepository: VcsRepo | null;
312
+ displayRepositoryCheckedAt: number;
313
+ repositoryCheckedAt: number;
314
+ /** Project + worktree dir name when `projectDir` is a linked worktree, else null. */
315
+ worktree: WorktreeContext | null;
316
+ }
317
+
318
+ interface BranchResolveRequest {
319
+ id: number;
320
+ cwd: string;
321
+ controller: AbortController;
322
+ }
323
+
324
+ interface JjResolveRequest {
325
+ id: number;
326
+ controller: AbortController;
327
+ }
328
+
329
+ interface WorktreeContext {
330
+ /** Primary-checkout (project) name shown by the path segment. */
331
+ projectName: string;
332
+ /** Worktree directory name — suppressed from the path when it equals the branch. */
333
+ worktreeName: string;
334
+ }
335
+
336
+ /**
337
+ * Project + worktree-dir names when `cwd` is a linked git worktree, else null.
338
+ * The project name comes from the shared primary checkout; bare-repo worktrees
339
+ * resolve to the shared `foo.git` dir, so a trailing `.git` is stripped.
340
+ */
341
+ function resolveWorktreeContext(cwd: string): WorktreeContext | null {
342
+ const worktree = vcs.git(cwd)?.linkedWorktree();
343
+ if (!worktree) return null;
344
+ const base = path.basename(worktree.primaryRoot);
345
+ const projectName = base.endsWith(".git") ? base.slice(0, -4) : base;
346
+ if (!projectName) return null;
347
+ return { projectName, worktreeName: path.basename(worktree.root) };
348
+ }
349
+
350
+ /**
351
+ * Per-{@link StatusLineSession} active-processing meter for the `time_spent`
352
+ * segment. `activeMs` is the union of every completed `agent_start`→
353
+ * `agent_end` window; `activeStartedAt` is the start timestamp of the
354
+ * currently-running window, or `null` when idle.
355
+ *
356
+ * `sessionFile` snapshots the loaded session-file path at meter-creation
357
+ * time. `StatusLineSession.switchSession` (/resume, /move, ACP fork, RPC
358
+ * `switch_session`, extension `switchSession`) mutates the loaded file
359
+ * under the same {@link StatusLineSession} ref, so the WeakMap key alone
360
+ * cannot tell two conversations apart. `#meter()` compares this snapshot
361
+ * against the live `session.sessionFile`, and a real-to-real change
362
+ * starts the meter fresh instead of crediting the new conversation with
363
+ * the previous one's accumulated active time. The undefined → real
364
+ * first-save transition does not reset, since the session identity has
365
+ * not changed.
366
+ */
367
+ interface ActiveMeter {
368
+ activeMs: number;
369
+ activeStartedAt: number | null;
370
+ sessionFile: string | undefined;
371
+ }
372
+
373
+ const EMPTY_MESSAGES: readonly AgentMessage[] = [];
374
+ const STATUS_USAGE_START_DELAY_MS = 0;
375
+ const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
376
+
377
+ function isContextSegment(segment: StatusLineSegmentId): boolean {
378
+ return segment === "context_pct" || segment === "context_total";
379
+ }
380
+
381
+ function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
382
+ return segments.includes("context_pct") || segments.includes("context_total");
383
+ }
384
+
385
+ function hasNonContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
386
+ for (const segment of segments) {
387
+ if (!isContextSegment(segment)) return true;
388
+ }
389
+ return false;
390
+ }
391
+
392
+ function removeContextSegments(parts: string[], segments: StatusLineSegmentId[]): void {
393
+ let writeIndex = 0;
394
+ for (let readIndex = 0; readIndex < segments.length; readIndex++) {
395
+ const segment = segments[readIndex];
396
+ if (isContextSegment(segment)) continue;
397
+ parts[writeIndex] = parts[readIndex];
398
+ segments[writeIndex] = segment;
399
+ writeIndex++;
400
+ }
401
+ parts.length = writeIndex;
402
+ segments.length = writeIndex;
403
+ }
404
+
405
+ function formatEmbeddedContextPercent(percent: number): string {
406
+ return `${percent > 0 && percent < 1 ? percent.toFixed(1) : Math.round(percent)}%`;
407
+ }
408
+
409
+ function embeddedContextGaugeMinWidth(percent: number, contextWindow: number): number {
410
+ return formatEmbeddedContextPercent(percent).length + formatNumber(contextWindow).length + 4;
411
+ }
412
+
413
+ function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
414
+ return segments.includes("git");
415
+ }
416
+
417
+ function hasPrSegment(segments: readonly StatusLineSegmentId[]): boolean {
418
+ return segments.includes("pr");
419
+ }
420
+ function hasPathSegment(segments: readonly StatusLineSegmentId[]): boolean {
421
+ return segments.includes("path");
422
+ }
423
+
424
+ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean {
425
+ return hasGitSegment(segments) || hasPrSegment(segments);
426
+ }
427
+
428
+ type StatusLineLayout = "box" | "band" | "plain-full" | "plain-left" | "plain-right";
429
+
430
+ // ═══════════════════════════════════════════════════════════════════════════
431
+ // StatusLineComponent
432
+ // ═══════════════════════════════════════════════════════════════════════════
433
+
434
+ export class StatusLineComponent<TSession extends StatusLineSession = StatusLineSession> implements Component {
435
+ #standalone: false | "full" | "left-only" = false;
436
+ #topAttachment: ComposerStyle["statusAttachment"] = "top-border";
437
+ #standaloneGap = false;
438
+ #autocompleteActiveProbe: (() => boolean) | undefined;
439
+ #renderRevision = 0;
440
+ /** Fully rendered bars for the current revision/clock tick, keyed by layout. */
441
+ #statusLineRenderCache: Record<StatusLineLayout, CachedStatusLine | undefined> = {
442
+ box: undefined,
443
+ band: undefined,
444
+ "plain-full": undefined,
445
+ "plain-left": undefined,
446
+ "plain-right": undefined,
447
+ };
448
+ #statusLineInputRevision = 0;
449
+ #statusLineClockTick = 0;
450
+ #settings: StatusLineSettings = {};
451
+ #effectiveSettings: EffectiveStatusLineSettings | undefined;
452
+ #cachedBranch: string | null | undefined = undefined;
453
+ #cachedBranchRepoId: string | null | undefined = undefined;
454
+ #cachedBranchCwd: string | undefined = undefined;
455
+ // In-flight reftable resolve slot. Ownership is the launch id, not the cwd:
456
+ // two live resolves can share a cwd string across an invalidation, and a
457
+ // stale one must never free (or poison) a slot it no longer owns.
458
+ #branchResolveSeq = 0;
459
+ #branchResolveActive: BranchResolveRequest | undefined = undefined;
460
+ // Bumped on every branch-cache reset (#invalidateGitCaches — a HEAD move or
461
+ // repo-context change). An in-flight reftable resolve captures this at
462
+ // launch; a mismatch on resolve means the cache was invalidated underneath
463
+ // it (a newer resolve superseded it), so its result is stale and must be
464
+ // dropped rather than overwrite the value the newer resolve committed.
465
+ // Mirrors #jjCacheGeneration / #getBranchLabel in this file.
466
+ // Timestamp of the latest branch read; only bounds cache freshness when the
467
+ // HEAD watcher could not be installed.
468
+ #branchLastFetch: number | undefined = undefined;
469
+ // Bumped on every branch-cache reset (invalidateGitCaches — a HEAD move or
470
+ // repo-context change). An in-flight reftable resolve captures this at
471
+ // launch; a mismatch on resolve means the cache was invalidated underneath
472
+ // it (a newer resolve superseded it), so its result is stale and must be
473
+ // dropped rather than overwrite the value the newer resolve committed.
474
+ // Mirrors #jjCacheGeneration / #getBranchLabel in this file.
475
+ #branchCacheGeneration = 0;
476
+ #gitUnwatch: (() => void) | null = null;
477
+ #gitWatcherUnavailable = false;
478
+ #onBranchChange: (() => void) | null = null;
479
+ #disposed = false;
480
+ #autoCompactEnabled: boolean = true;
481
+ /** Pulse timer for the running-speculation indicator; live only while speculation runs. */
482
+ #speculationBlinkTimer: NodeJS.Timeout | undefined;
483
+ #speculationBlinkOn = true;
484
+ /** In-flight brand-color fade across a working-state edge; null when settled. */
485
+ #brandFade: { fromHex: string; toHex: string; startedAt: number } | null = null;
486
+ /** Working flag as of the last brand render, for edge detection. */
487
+ #brandWorking = false;
488
+ /** Frame timer driving repaints while the brand fade is unsettled. */
489
+ #brandFadeTimer: NodeJS.Timeout | undefined;
490
+ /** One wall-clock wakeup for the active model's next tariff change, including while idle. */
491
+ #pricingTimer: NodeJS.Timeout | undefined;
492
+ #pricingTimerCost: ModelCost | undefined;
493
+ #pricingTransition: number | undefined;
494
+ #hookStatuses: Map<string, string> = new Map();
495
+ #sortedHookStatuses: readonly string[] = [];
496
+ #subagentCount: number = 0;
497
+ #runningSubagentIds = new Set<string>();
498
+ /**
499
+ * Active-processing accounting for the `time_spent` segment, keyed per
500
+ * {@link StatusLineSession} so the focus-controller mid-turn attach path
501
+ * cannot leak an unmatched synthesized `agent_start` from a subagent
502
+ * into the main session's meter.
503
+ *
504
+ * Each meter is `{ activeMs, activeStartedAt }`: `activeMs` is the union
505
+ * of every completed `agent_start`→`agent_end` window since
506
+ * {@link resetActiveTime} last reset it; `activeStartedAt` is the start
507
+ * timestamp of the currently-running window (or `null` when idle).
508
+ * `getActiveMs()` returns `activeMs + (now - activeStartedAt)` for the
509
+ * currently-attached session, so the counter ticks live during a turn
510
+ * and freezes the instant the agent yields.
511
+ *
512
+ * WeakMap so meters die with their session (e.g. a parked subagent
513
+ * dropped from the registry); the main session's meter survives focus
514
+ * round-trips because the same {@link StatusLineSession} ref is reused.
515
+ */
516
+ #activeMeters: WeakMap<TSession, ActiveMeter> = new WeakMap();
517
+ #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
518
+ #loopModeStatus: SegmentContext["loopMode"] = null;
519
+ #goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
520
+ #vibeModeStatus: { enabled: boolean } | null = null;
521
+ #vimStatus: SegmentContext["vim"] = null;
522
+ /**
523
+ * Injected aggregator that returns the aggregate tok/s of this session's
524
+ * live vibe worker sessions, or null when no workers are streaming. Kept as
525
+ * a callback so the render layer doesn't import the heavy vibe/task
526
+ * dependency graph; interactive-mode wires it to VibeSessionRegistry.
527
+ */
528
+ #vibeWorkerTokenRate: (() => number | null) | null = null;
529
+ #collabStatus: CollabStatus | null = null;
530
+ #streamStatus: { viewers: number } | null = null;
531
+ #focusedAgentId: string | undefined;
532
+ #activeRepoCache: ActiveRepoCache | undefined;
533
+
534
+ // Git status caching (1s TTL)
535
+ #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
536
+ #cachedGitStatusCwd: string | undefined = undefined;
537
+ #gitStatusLastFetch = 0;
538
+ #gitStatusInFlightCwd: string | undefined = undefined;
539
+ #cachedJjBranch: string | null = null;
540
+ #jjBranchLastFetch = 0;
541
+ #jjResolveSeq = 0;
542
+ #jjBranchActive: JjResolveRequest | undefined = undefined;
543
+ #cachedJjStatus: { staged: number; unstaged: number; untracked: number } | null = null;
544
+ #jjStatusLastFetch = 0;
545
+ #jjStatusActive: JjResolveRequest | undefined = undefined;
546
+ // Bumped on every jj-cache reset — a cwd switch (#applyCwdChange) or a HEAD /
547
+ // bookmark move (#invalidateGitCaches). An in-flight jj query captures this
548
+ // at launch; a mismatch on resolve means the caches were reset underneath it
549
+ // (including a reset that re-resolves to the SAME root, which a root-equality
550
+ // check cannot detect), so the result is stale and must be dropped rather
551
+ // than poison the fresh cache or advance its throttle.
552
+ #jjCacheGeneration = 0;
553
+
554
+ // PR lookup caching (invalidated on branch/repo context changes)
555
+ #cachedPr: { number: number; url: string } | null | undefined = undefined;
556
+ #cachedPrContext: PrCacheContext | undefined = undefined;
557
+ #prLookupInFlight = false;
558
+ #defaultBranch?: string;
559
+ #defaultBranchCwd: string | undefined = undefined;
560
+ #lastTokensPerSecond: number | null = null;
561
+ #lastTokensPerSecondTimestamp: number | null = null;
562
+
563
+ // Provider usage caching (5-min TTL, OAuth/sub only)
564
+ #cachedUsage: {
565
+ tier?: string;
566
+ fiveHour?: { percent: number; resetMinutes?: number };
567
+ daily?: { percent: number; resetMinutes?: number };
568
+ sevenDay?: { percent: number; resetHours?: number };
569
+ monthly?: { percent: number; resetHours?: number };
570
+ } | null = null;
571
+ #cachedUsageContextKey: string | null = null;
572
+ #usageFetchedAt = 0;
573
+ #usageInFlight = false;
574
+ #usageStartTimer: Timer | null = null;
575
+ // A timed-out request may still resolve. Its result remains eligible only
576
+ // until a newer request has applied.
577
+ #usageRefreshSequence = 0;
578
+ #latestAppliedUsageRefreshSequence = 0;
579
+ #codexResetSnapshots = new Map<string, CodexResetUsageSnapshot>();
580
+ #onCodexResetFireworks: ((event: CodexResetFireworksEvent) => void) | undefined;
581
+ // Context-usage memo. The status line redraws on every agent event, so the
582
+ // hot path must not recompute context tokens unless an input changed.
583
+ // `getContextUsage()` anchors on the last assistant's real prompt-token
584
+ // count (matching the provider and the `/context` panel), so a stable
585
+ // message list + model window yields a stable result we can return verbatim.
586
+ #contextUsageCache: ContextUsageMemo | undefined;
587
+
588
+ constructor(
589
+ private session: TSession,
590
+ private readonly host: StatusLineHost<TSession>,
591
+ ) {
592
+ this.#settings = host.getSettings();
593
+ }
594
+
595
+ #gitEnabled(): boolean {
596
+ return this.host.gitEnabled();
597
+ }
598
+ #hasGitBackedSegment(): boolean {
599
+ const effectiveSettings = this.#resolveSettings();
600
+ return (
601
+ hasGitBackedSegment(effectiveSettings.leftSegments) || hasGitBackedSegment(effectiveSettings.rightSegments)
602
+ );
603
+ }
604
+
605
+ #resolveActiveRepoCache(): ActiveRepoCache {
606
+ const projectDir = getProjectDir();
607
+ if (this.#activeRepoCache?.projectDir === projectDir) {
608
+ return this.#activeRepoCache;
609
+ }
610
+
611
+ const projectRepository = vcs.repo(projectDir);
612
+ const activeRepo = projectRepository ? null : this.host.resolveActiveRepo(projectDir);
613
+ const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
614
+ const repository = projectRepository ?? (activeRepo ? vcs.repo(effectiveGitCwd) : null);
615
+ // Presentation follows a second detector whose only policy difference
616
+ // is preferring jj on equal-root ties (see detect_for_display);
617
+ // automation keeps `repository` above. A failed display lookup (or a
618
+ // caller that only stubs the operational detector) degrades to the
619
+ // operational repository rather than hiding the segment.
620
+ let displayRepository: VcsRepo | null;
621
+ try {
622
+ displayRepository = vcs.repoForDisplay(effectiveGitCwd);
623
+ } catch {
624
+ displayRepository = null;
625
+ }
626
+ displayRepository ??= repository;
627
+ // Only collapse the bare-cwd case: a single-direct-child-repo context
628
+ // (activeRepo set) renders `<parent> ↳ <child>`, which we leave intact.
629
+ const worktree = activeRepo ? null : resolveWorktreeContext(effectiveGitCwd);
630
+ this.#activeRepoCache = {
631
+ projectDir,
632
+ activeRepo,
633
+ effectiveGitCwd,
634
+ repository,
635
+ displayRepository,
636
+ displayRepositoryCheckedAt: Date.now(),
637
+ repositoryCheckedAt: Date.now(),
638
+ worktree,
639
+ };
640
+ return this.#activeRepoCache;
641
+ }
642
+
643
+ #resolveRepository(cache: ActiveRepoCache): VcsRepo | null {
644
+ if (cache.repository) return cache.repository;
645
+ const now = Date.now();
646
+ if (now - cache.repositoryCheckedAt < WATCHER_FAILURE_POLL_TTL_MS) return null;
647
+ cache.repository = vcs.repo(cache.effectiveGitCwd);
648
+ cache.repositoryCheckedAt = now;
649
+ return cache.repository;
650
+ }
651
+
652
+ #resolveDisplayRepository(cache: ActiveRepoCache): VcsRepo | null {
653
+ if (cache.displayRepository) return cache.displayRepository;
654
+ const now = Date.now();
655
+ if (now - cache.displayRepositoryCheckedAt < WATCHER_FAILURE_POLL_TTL_MS) return null;
656
+ let display: VcsRepo | null;
657
+ try {
658
+ display = vcs.repoForDisplay(cache.effectiveGitCwd);
659
+ } catch {
660
+ display = null;
661
+ }
662
+ cache.displayRepository = display ?? cache.repository;
663
+ cache.displayRepositoryCheckedAt = now;
664
+ return cache.displayRepository;
665
+ }
666
+
667
+ /**
668
+ * Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
669
+ * from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
670
+ */
671
+ setSession(session: TSession, focusedAgentId?: string): void {
672
+ const sessionChanged = this.session !== session;
673
+ if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
674
+ this.session = session;
675
+ this.#focusedAgentId = focusedAgentId;
676
+ if (sessionChanged) {
677
+ this.#invalidateSessionCaches();
678
+ this.#closeStaleActiveWindow();
679
+ }
680
+ this.invalidate();
681
+ }
682
+
683
+ /**
684
+ * Drop a meter's in-flight window when the newly-attached session is no
685
+ * longer streaming. Handles the case where the focus controller
686
+ * synthesized an `agent_start` on a mid-turn attach but the matching
687
+ * real `agent_end` never reached us — the user detached before it
688
+ * fired, and re-focusing later (after the agent finished) would
689
+ * otherwise tick over the entire detached gap. Crediting that gap to
690
+ * `activeMs` would be wrong (the agent finished at some point we never
691
+ * observed), so the window is dropped rather than folded in.
692
+ */
693
+ #closeStaleActiveWindow(): void {
694
+ const meter = this.#meter();
695
+ if (meter.activeStartedAt === null) return;
696
+ if (this.session.isStreaming) return;
697
+ meter.activeStartedAt = null;
698
+ }
699
+
700
+ updateSettings(settings: StatusLineSettings): void {
701
+ this.#settings = settings;
702
+ this.#effectiveSettings = undefined;
703
+ this.#invalidateStatusLineRenderCache();
704
+ if (this.#onBranchChange) this.#setupGitWatcher();
705
+ this.#syncPricingTimer();
706
+ }
707
+
708
+ getEffectiveSettingsForTest(): EffectiveStatusLineSettings {
709
+ return this.#resolveSettings();
710
+ }
711
+
712
+ setAutoCompactEnabled(enabled: boolean): void {
713
+ if (this.#autoCompactEnabled === enabled) return;
714
+ this.#autoCompactEnabled = enabled;
715
+ this.#invalidateStatusLineRenderCache();
716
+ }
717
+
718
+ setRunningSubagents(agentIds: readonly string[]): void {
719
+ if (
720
+ agentIds.length === this.#runningSubagentIds.size &&
721
+ agentIds.every(agentId => this.#runningSubagentIds.has(agentId))
722
+ ) {
723
+ return;
724
+ }
725
+ this.#subagentCount = agentIds.length;
726
+ this.#runningSubagentIds = new Set(agentIds);
727
+ this.#invalidateStatusLineRenderCache();
728
+ }
729
+
730
+ /**
731
+ * Compatibility shim for callers predating the simplified subagent badge.
732
+ * The status line now intentionally shows only the active count.
733
+ */
734
+ setSubagentHubHint(_hint: string | undefined): void {}
735
+
736
+ /** Active subagent count as currently displayed (collab state mirroring). */
737
+ get subagentCount(): number {
738
+ return this.#subagentCount;
739
+ }
740
+
741
+ /**
742
+ * Reset the currently-attached session's active-time accumulators so
743
+ * the `time_spent` segment starts from zero. Called from `/clear`,
744
+ * fresh-session, and joined-collab paths; both the completed
745
+ * accumulator and any in-flight window are dropped, so a reset
746
+ * mid-turn ignores the running window (the matching `markActivityEnd`
747
+ * will see an idle meter and no-op).
748
+ */
749
+ resetActiveTime(): void {
750
+ const meter = this.#meter();
751
+ if (meter.activeMs === 0 && meter.activeStartedAt === null) return;
752
+ meter.activeMs = 0;
753
+ meter.activeStartedAt = null;
754
+ this.#invalidateStatusLineRenderCache();
755
+ }
756
+
757
+ /**
758
+ * Mark the currently-attached session as having started a unit of
759
+ * active processing. Idempotent: a second start while a window is
760
+ * already open is a no-op, so reentrant `agent_start` events (e.g.
761
+ * nested auto-compaction loops, focus-controller mid-turn attach onto
762
+ * an already-running window) do not double-count.
763
+ */
764
+ markActivityStart(): void {
765
+ const meter = this.#meter();
766
+ if (meter.activeStartedAt !== null) return;
767
+ meter.activeStartedAt = Date.now();
768
+ this.#invalidateStatusLineRenderCache();
769
+ }
770
+
771
+ /**
772
+ * Close the currently-attached session's open active-processing
773
+ * window, folding its elapsed time into the accumulator. Idempotent
774
+ * when the meter is already idle so callers can fire it on every
775
+ * `agent_end` without guarding.
776
+ */
777
+ markActivityEnd(): void {
778
+ const meter = this.#meter();
779
+ if (meter.activeStartedAt === null) return;
780
+ meter.activeMs += Math.max(0, Date.now() - meter.activeStartedAt);
781
+ meter.activeStartedAt = null;
782
+ this.#invalidateStatusLineRenderCache();
783
+ }
784
+
785
+ /**
786
+ * Snapshot of total active-processing time for the currently-attached
787
+ * session, including any in-flight window. Exposed for the segment
788
+ * context builder; tests assert against this too.
789
+ */
790
+ getActiveMs(): number {
791
+ const meter = this.#meter();
792
+ if (meter.activeStartedAt === null) return meter.activeMs;
793
+ return meter.activeMs + Math.max(0, Date.now() - meter.activeStartedAt);
794
+ }
795
+ /**
796
+ * Elapsed ms of the currently-open active-processing window, or null when
797
+ * idle. Feeds the `pi` segment's working spinner + turn timer.
798
+ */
799
+ getTurnElapsedMs(): number | null {
800
+ const startedAt = this.#meter().activeStartedAt;
801
+ return startedAt === null ? null : Math.max(0, Date.now() - startedAt);
802
+ }
803
+
804
+ /**
805
+ * Return (lazily creating) the meter for the currently-attached
806
+ * session. Detects an in-place session-file swap under the same
807
+ * {@link StatusLineSession} ref (`switchSession` paths: `/resume`, `/move`,
808
+ * ACP fork/load, RPC `switch_session`, extension `switchSession`):
809
+ * a real-to-real change starts the meter fresh so the new
810
+ * conversation does not inherit the previous one's accumulated active
811
+ * time. The undefined → real first-save transition only refreshes the
812
+ * snapshot — the conversation identity has not changed.
813
+ */
814
+ #meter(): ActiveMeter {
815
+ const currentFile = this.session.sessionFile;
816
+ let meter = this.#activeMeters.get(this.session);
817
+ if (meter) {
818
+ const switched =
819
+ currentFile !== undefined && meter.sessionFile !== undefined && meter.sessionFile !== currentFile;
820
+ if (switched) {
821
+ meter = undefined;
822
+ } else {
823
+ meter.sessionFile = currentFile;
824
+ }
825
+ }
826
+ if (!meter) {
827
+ meter = { activeMs: 0, activeStartedAt: null, sessionFile: currentFile };
828
+ this.#activeMeters.set(this.session, meter);
829
+ }
830
+ return meter;
831
+ }
832
+
833
+ setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
834
+ const next = status ?? null;
835
+ if (
836
+ this.#planModeStatus === next ||
837
+ (this.#planModeStatus?.enabled === next?.enabled && this.#planModeStatus?.paused === next?.paused)
838
+ ) {
839
+ return;
840
+ }
841
+ this.#planModeStatus = next;
842
+ this.#invalidateStatusLineRenderCache();
843
+ }
844
+
845
+ setLoopModeStatus(status: NonNullable<SegmentContext["loopMode"]> | undefined): void {
846
+ const next = status ?? null;
847
+ if (
848
+ this.#loopModeStatus === next ||
849
+ (this.#loopModeStatus?.state === next?.state &&
850
+ this.#loopModeStatus?.limit === next?.limit &&
851
+ this.#loopModeStatus?.condition === next?.condition)
852
+ ) {
853
+ return;
854
+ }
855
+ this.#loopModeStatus = next;
856
+ this.#invalidateStatusLineRenderCache();
857
+ }
858
+
859
+ setGoalModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
860
+ const next = status ?? null;
861
+ if (
862
+ this.#goalModeStatus === next ||
863
+ (this.#goalModeStatus?.enabled === next?.enabled && this.#goalModeStatus?.paused === next?.paused)
864
+ ) {
865
+ return;
866
+ }
867
+ this.#goalModeStatus = next;
868
+ this.#invalidateStatusLineRenderCache();
869
+ }
870
+
871
+ setVibeModeStatus(status: { enabled: boolean } | undefined): void {
872
+ const next = status ?? null;
873
+ if (this.#vibeModeStatus === next || this.#vibeModeStatus?.enabled === next?.enabled) return;
874
+ this.#vibeModeStatus = next;
875
+ this.#invalidateStatusLineRenderCache();
876
+ }
877
+
878
+ /** Mirror of the editor's modal state; `undefined` clears it (Vim mode off). */
879
+ setVimStatus(status: NonNullable<SegmentContext["vim"]> | undefined): void {
880
+ const next = status ?? null;
881
+ if (
882
+ this.#vimStatus === next ||
883
+ (this.#vimStatus?.mode === next?.mode &&
884
+ this.#vimStatus?.pending === next?.pending &&
885
+ this.#vimStatus?.selectedLines === next?.selectedLines &&
886
+ this.#vimStatus?.display === next?.display)
887
+ ) {
888
+ return;
889
+ }
890
+ this.#vimStatus = next;
891
+ this.#invalidateStatusLineRenderCache();
892
+ }
893
+
894
+ /**
895
+ * Inject the aggregator that returns the aggregate tok/s of this session's
896
+ * live vibe worker sessions (null when no workers are streaming). Wired by
897
+ * interactive-mode, which owns the VibeSessionRegistry coupling, so the
898
+ * render layer stays off the heavy vibe/task dependency graph. Pass
899
+ * `undefined` to clear.
900
+ */
901
+ setVibeWorkerTokenRateProvider(provider: (() => number | null) | undefined): void {
902
+ this.#vibeWorkerTokenRate = provider ?? null;
903
+ this.#invalidateStatusLineRenderCache();
904
+ }
905
+
906
+ setCollabStatus(status: CollabStatus | null): void {
907
+ if (
908
+ !this.#collabStatus?.stateOverride &&
909
+ !status?.stateOverride &&
910
+ this.#collabStatus?.role === status?.role &&
911
+ this.#collabStatus?.participantCount === status?.participantCount
912
+ ) {
913
+ return;
914
+ }
915
+ this.#collabStatus = status;
916
+ this.#invalidateStatusLineRenderCache();
917
+ }
918
+
919
+ setStreamStatus(status: { viewers: number } | null): void {
920
+ if (this.#streamStatus?.viewers === status?.viewers) return;
921
+ this.#streamStatus = status;
922
+ this.#invalidateStatusLineRenderCache();
923
+ }
924
+
925
+ /** Set the callback that presents detected Codex reset celebrations, or clear it with `undefined`. */
926
+ setCodexResetFireworksHandler(handler: ((event: CodexResetFireworksEvent) => void) | undefined): void {
927
+ this.#onCodexResetFireworks = handler;
928
+ }
929
+
930
+ setHookStatus(key: string, text: string | undefined): void {
931
+ if (text === undefined) {
932
+ if (!this.#hookStatuses.delete(key)) return;
933
+ } else {
934
+ if (this.#hookStatuses.get(key) === text) return;
935
+ this.#hookStatuses.set(key, text);
936
+ }
937
+ this.#sortedHookStatuses = Array.from(this.#hookStatuses.entries())
938
+ .sort(([a], [b]) => a.localeCompare(b))
939
+ .map(([, status]) => status);
940
+ this.#invalidateStatusLineRenderCache();
941
+ }
942
+
943
+ watchBranch(onBranchChange: () => void): void {
944
+ this.#onBranchChange = onBranchChange;
945
+ this.#setupGitWatcher();
946
+ this.#syncPricingTimer();
947
+ }
948
+
949
+ #setupGitWatcher(): void {
950
+ this.#retireGitWatcher();
951
+ this.#gitWatcherUnavailable = false;
952
+
953
+ if (!this.#gitEnabled() || !this.#hasGitBackedSegment()) {
954
+ this.invalidateGitCaches();
955
+ return;
956
+ }
957
+
958
+ const activeRepoCache = this.#resolveActiveRepoCache();
959
+ const repository = this.#resolveDisplayRepository(activeRepoCache);
960
+ if (!repository) {
961
+ // There is no path to watch yet. Cache the negative result only for the
962
+ // fallback poll interval so a later `git init` becomes visible without
963
+ // generic invalidations or a render-path probe on every paint.
964
+ this.#gitWatcherUnavailable = true;
965
+ return;
966
+ }
967
+
968
+ // Backends atomically replace their head markers, so stat-poll the
969
+ // backend-selected target instead of binding a watcher to one inode.
970
+ try {
971
+ const unwatch = vcs.watch(repository, () => {
972
+ if (this.#disposed || this.#gitUnwatch !== unwatch) return;
973
+ this.invalidateGitCaches();
974
+ this.#onBranchChange?.();
975
+ });
976
+ this.#gitUnwatch = unwatch;
977
+ } catch {
978
+ this.#gitWatcherUnavailable = true;
979
+ }
980
+ }
981
+
982
+ #retireGitWatcher(): void {
983
+ const unwatch = this.#gitUnwatch;
984
+ this.#gitUnwatch = null;
985
+ unwatch?.();
986
+ }
987
+
988
+ dispose(): void {
989
+ this.#disposed = true;
990
+ this.#branchResolveActive?.controller.abort();
991
+ this.#branchResolveActive = undefined;
992
+ this.#resetJjRequests();
993
+ this.#onBranchChange = null;
994
+ this.#stopSpeculationBlink();
995
+ this.#stopBrandFadeTimer();
996
+ this.#stopPricingTimer();
997
+ this.#clearUsageStartTimer();
998
+ this.#onCodexResetFireworks = undefined;
999
+ this.#codexResetSnapshots.clear();
1000
+ this.#retireGitWatcher();
1001
+ }
1002
+
1003
+ /**
1004
+ * Drive the context segment's pulse while a background speculative
1005
+ * compaction runs: a slow toggle that invalidates and repaints through the
1006
+ * same host callback async git/PR resolves use. Stops (and resets phase)
1007
+ * the first render after speculation leaves the running state.
1008
+ */
1009
+ #syncSpeculationBlink(state: "idle" | "running" | "armed"): void {
1010
+ if (state === "running" && !this.#disposed) {
1011
+ this.#speculationBlinkTimer ??= setInterval(() => {
1012
+ this.#speculationBlinkOn = !this.#speculationBlinkOn;
1013
+ this.invalidate();
1014
+ this.#onBranchChange?.();
1015
+ }, 600);
1016
+ return;
1017
+ }
1018
+ this.#stopSpeculationBlink();
1019
+ }
1020
+
1021
+ #stopSpeculationBlink(): void {
1022
+ if (!this.#speculationBlinkTimer) return;
1023
+ clearInterval(this.#speculationBlinkTimer);
1024
+ this.#speculationBlinkTimer = undefined;
1025
+ this.#speculationBlinkOn = true;
1026
+ }
1027
+ /**
1028
+ * Foreground ANSI for the `pi` brand segment: dim gray while idle, fading
1029
+ * to the accent (session accent when enabled, else theme accent) while a
1030
+ * turn runs — a port of rust omp's status-band brand fade (450ms cubic
1031
+ * ease-in-out). A working-state edge retargets the tween from the color
1032
+ * currently on screen, so interrupting a running fade never jumps, and arms
1033
+ * a 40ms frame timer so the fade keeps animating after the working loader
1034
+ * (the usual repaint driver) has stopped.
1035
+ */
1036
+ #brandFgAnsi(working: boolean, sessionAccentEnabled: boolean): string {
1037
+ const sessionName = sessionAccentEnabled ? this.session.sessionManager?.getSessionName() : undefined;
1038
+ const idleHex = theme.getColorHex("dim");
1039
+ const workingHex =
1040
+ (sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined) ??
1041
+ theme.getColorHex("accent");
1042
+ const now = Date.now();
1043
+ if (working !== this.#brandWorking) {
1044
+ const previousTargetHex = this.#brandWorking ? workingHex : idleHex;
1045
+ this.#brandFade = {
1046
+ fromHex: this.#sampleBrandHex(previousTargetHex, now),
1047
+ toHex: working ? workingHex : idleHex,
1048
+ startedAt: now,
1049
+ };
1050
+ this.#brandWorking = working;
1051
+ this.#startBrandFadeTimer();
1052
+ }
1053
+ const hex = this.#sampleBrandHex(working ? workingHex : idleHex, now);
1054
+ return getSessionAccentAnsi(hex) ?? theme.getFgAnsi(working ? "accent" : "dim");
1055
+ }
1056
+
1057
+ /**
1058
+ * The brand hex at `now`: the eased blend while a fade is in flight,
1059
+ * otherwise `settledHex`. A fade past its deadline is cleared here so the
1060
+ * next frame timer tick shuts the timer down.
1061
+ */
1062
+ #sampleBrandHex(settledHex: string, now: number): string {
1063
+ const fade = this.#brandFade;
1064
+ if (!fade) return settledHex;
1065
+ const t = (now - fade.startedAt) / BRAND_FADE_MS;
1066
+ if (t >= 1) {
1067
+ this.#brandFade = null;
1068
+ return settledHex;
1069
+ }
1070
+ // Cubic ease-in-out, matching rust omp's Easing::EaseInOut.
1071
+ const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
1072
+ const from = hexToRgb(fade.fromHex);
1073
+ const to = hexToRgb(fade.toHex);
1074
+ return rgbToHex({
1075
+ r: Math.round(from.r + (to.r - from.r) * eased),
1076
+ g: Math.round(from.g + (to.g - from.g) * eased),
1077
+ b: Math.round(from.b + (to.b - from.b) * eased),
1078
+ });
1079
+ }
1080
+
1081
+ #startBrandFadeTimer(): void {
1082
+ if (this.#brandFadeTimer || this.#disposed) return;
1083
+ this.#brandFadeTimer = setInterval(() => {
1084
+ const fade = this.#brandFade;
1085
+ if (!fade || Date.now() - fade.startedAt >= BRAND_FADE_MS) this.#stopBrandFadeTimer();
1086
+ // One trailing repaint after the stop paints the settled color.
1087
+ this.invalidate();
1088
+ this.#onBranchChange?.();
1089
+ }, BRAND_FADE_FRAME_MS);
1090
+ }
1091
+
1092
+ #stopBrandFadeTimer(): void {
1093
+ if (!this.#brandFadeTimer) return;
1094
+ clearInterval(this.#brandFadeTimer);
1095
+ this.#brandFadeTimer = undefined;
1096
+ }
1097
+
1098
+ #stopPricingTimer(): void {
1099
+ clearTimeout(this.#pricingTimer);
1100
+ this.#pricingTimer = undefined;
1101
+ this.#pricingTimerCost = undefined;
1102
+ this.#pricingTransition = undefined;
1103
+ }
1104
+
1105
+ #syncPricingTimer(): void {
1106
+ const cost = this.session.state.model?.cost;
1107
+ const effectiveSettings = this.#resolveSettings();
1108
+ const costVisible =
1109
+ (effectiveSettings.leftSegments.includes("cost") &&
1110
+ (this.#standalone !== false ||
1111
+ this.#topAttachment === "top-border" ||
1112
+ this.#topAttachment === "top-band")) ||
1113
+ (effectiveSettings.rightSegments.includes("cost") &&
1114
+ (this.#standalone === "full" || this.#topAttachment !== "none"));
1115
+ if (this.#disposed || !this.#onBranchChange || !cost?.timeBased || !costVisible) {
1116
+ this.#stopPricingTimer();
1117
+ return;
1118
+ }
1119
+ const now = Date.now();
1120
+ if (this.#pricingTimerCost === cost && this.#pricingTransition !== undefined && this.#pricingTransition > now) {
1121
+ return;
1122
+ }
1123
+ this.#stopPricingTimer();
1124
+ const transition = getNextTimeBasedPricingTransition(cost, now);
1125
+ if (transition === undefined) return;
1126
+ this.#pricingTimerCost = cost;
1127
+ this.#pricingTransition = transition;
1128
+ const timer = setTimeout(() => {
1129
+ if (this.#disposed || this.#pricingTimer !== timer) return;
1130
+ this.#stopPricingTimer();
1131
+ this.invalidate();
1132
+ this.#onBranchChange?.();
1133
+ }, transition - now);
1134
+ this.#pricingTimer = timer;
1135
+ timer.unref();
1136
+ }
1137
+
1138
+ #clearUsageStartTimer(): void {
1139
+ if (!this.#usageStartTimer) return;
1140
+ clearTimeout(this.#usageStartTimer);
1141
+ this.#usageStartTimer = null;
1142
+ }
1143
+
1144
+ #invalidateStatusLineRenderCache(): void {
1145
+ this.#statusLineInputRevision++;
1146
+ }
1147
+
1148
+ invalidate(): void {
1149
+ this.#renderRevision++;
1150
+ this.#syncPricingTimer();
1151
+ // Generic repaint invalidation (theme change, message event, model
1152
+ // switch, …). Must NOT abort or restart a live reftable HEAD/PR resolve:
1153
+ // the render path self-invalidates via cwd/context cache-miss checks, so
1154
+ // a generic paint only needs to re-render — not tear down in-flight VCS
1155
+ // work. Aborting here would fan out a new git subprocess on every agent
1156
+ // event, re-introducing the render-path spawn churn the async resolve
1157
+ // was designed to avoid. Explicit Git/repository invalidation (watcher
1158
+ // HEAD-move, cwd/repo switch) goes through {@link invalidateGitCaches}.
1159
+ // A tool may open, close, or merge a PR without moving HEAD. Expire the
1160
+ // settled PR context on ordinary activity while leaving HEAD work intact.
1161
+ this.#cachedPrContext = undefined;
1162
+ }
1163
+ #invalidateSessionCaches(): void {
1164
+ this.#clearUsageStartTimer();
1165
+ this.#cachedUsage = null;
1166
+ this.#usageFetchedAt = 0;
1167
+ this.#usageInFlight = false;
1168
+ this.#contextUsageCache = undefined;
1169
+ this.#lastTokensPerSecond = null;
1170
+ this.#lastTokensPerSecondTimestamp = null;
1171
+ }
1172
+
1173
+ /**
1174
+ * Explicit Git/repository cache invalidation. Aborts any in-flight
1175
+ * reftable HEAD/PR resolve, bumps the stale-result generation, and drops
1176
+ * the branch/PR/jj caches so the next render refetches from disk. Called
1177
+ * by the git watcher on a HEAD move and by {@link applyCwdChange} on a
1178
+ * repo/cwd switch. Generic repaints use {@link invalidate} instead and
1179
+ * must never reach this path.
1180
+ */
1181
+ invalidateGitCaches(): void {
1182
+ this.#invalidateStatusLineRenderCache();
1183
+ this.#cachedBranch = undefined;
1184
+ this.#cachedBranchRepoId = undefined;
1185
+ this.#cachedBranchCwd = undefined;
1186
+ // Abort before releasing the in-flight slot. Releasing alone would allow
1187
+ // repeated invalidations to fan out still-running git subprocesses.
1188
+ this.#branchResolveActive?.controller.abort();
1189
+ this.#branchResolveActive = undefined;
1190
+ this.#branchLastFetch = undefined;
1191
+ this.#branchCacheGeneration++;
1192
+ this.#cachedPrContext = undefined;
1193
+ // jj label/status share the git segment's lifecycle: a HEAD move (e.g. a
1194
+ // colocated `jj new`/bookmark move) must drop the throttled jj caches too,
1195
+ // so the next render refetches.
1196
+ this.#resetJjRequests();
1197
+ this.#cachedJjBranch = null;
1198
+ this.#jjBranchLastFetch = 0;
1199
+ this.#cachedJjStatus = null;
1200
+ this.#jjStatusLastFetch = 0;
1201
+ this.#jjCacheGeneration++;
1202
+ }
1203
+
1204
+ /**
1205
+ * Re-point the status line's VCS watcher and caches at a new cwd/repository.
1206
+ * Atomically retires the old watcher/listeners, invalidates VCS caches and
1207
+ * in-flight controllers, then runs watcher setup for the new cwd and requests
1208
+ * a repaint. Called by {@link InteractiveMode.applyCwdChange} after the
1209
+ * SessionManager's cwd has moved — the watcher ownership always follows the
1210
+ * effective cwd/repo, so a stale watcher for the previous repo can never
1211
+ * invalidate the new one. Generic repaints use {@link invalidate} and must
1212
+ * never retire the watcher or abort a live resolve.
1213
+ */
1214
+ applyCwdChange(): void {
1215
+ this.#retireGitWatcher();
1216
+ this.invalidateGitCaches();
1217
+ this.#setupGitWatcher();
1218
+ this.#onBranchChange?.();
1219
+ }
1220
+
1221
+ #resetJjRequests(): void {
1222
+ this.#jjBranchActive?.controller.abort();
1223
+ this.#jjBranchActive = undefined;
1224
+ this.#jjStatusActive?.controller.abort();
1225
+ this.#jjStatusActive = undefined;
1226
+ }
1227
+ #getBranchLabel(
1228
+ activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache(),
1229
+ // Presentation defaults to the display detector; PR lookup passes the
1230
+ // operational repository so a jj label never becomes a GitHub head.
1231
+ repository: VcsRepo | null = this.#resolveDisplayRepository(activeRepoCache),
1232
+ ): string | null {
1233
+ if (!this.#gitEnabled()) return null;
1234
+
1235
+ const gitCwd = activeRepoCache.effectiveGitCwd;
1236
+ if (!repository) return null;
1237
+ const gitRepository = repository.asGit();
1238
+ if (!gitRepository) {
1239
+ if (this.#jjBranchActive || Date.now() - this.#jjBranchLastFetch < JJ_REFRESH_TTL_MS) {
1240
+ return this.#cachedJjBranch;
1241
+ }
1242
+ const request: JjResolveRequest = {
1243
+ id: ++this.#jjResolveSeq,
1244
+ controller: new AbortController(),
1245
+ };
1246
+ this.#jjBranchActive = request;
1247
+ const generation = this.#jjCacheGeneration;
1248
+ (async () => {
1249
+ let next: string | null = null;
1250
+ try {
1251
+ const raw =
1252
+ (await repository.label(
1253
+ AbortSignal.any([request.controller.signal, AbortSignal.timeout(JJ_COMMAND_TIMEOUT_MS)]),
1254
+ )) ?? null;
1255
+ // Repository-controlled jj metadata can carry control
1256
+ // characters; sanitize at the cache boundary (the git segment
1257
+ // renders the label verbatim).
1258
+ next = raw === null ? null : sanitizeStatusText(raw);
1259
+ } catch {
1260
+ next = null;
1261
+ } finally {
1262
+ if (this.#jjBranchActive?.id === request.id) this.#jjBranchActive = undefined;
1263
+ if (this.#jjCacheGeneration === generation) this.#jjBranchLastFetch = Date.now();
1264
+ }
1265
+ if (this.#jjCacheGeneration !== generation || this.#disposed) return;
1266
+ const changed = next !== this.#cachedJjBranch;
1267
+ this.#cachedJjBranch = next;
1268
+ if (changed) {
1269
+ this.#invalidateStatusLineRenderCache();
1270
+ this.#onBranchChange?.();
1271
+ }
1272
+ })();
1273
+ return this.#cachedJjBranch;
1274
+ }
1275
+ const fallbackCacheExpired =
1276
+ this.#gitWatcherUnavailable &&
1277
+ (this.#branchLastFetch === undefined || Date.now() - this.#branchLastFetch >= WATCHER_FAILURE_POLL_TTL_MS);
1278
+ if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd && !fallbackCacheExpired) {
1279
+ return this.#cachedBranch;
1280
+ }
1281
+
1282
+ // A reftable repo resolves HEAD by spawning `git symbolic-ref` +
1283
+ // `git rev-parse` — the unbounded spawn that froze the render path (F7).
1284
+ // A non-reftable repo resolves HEAD with cheap sync filesystem reads, so
1285
+ // only the reftable branch moves off the render path, mirroring
1286
+ // #getStatus and the Jujutsu label path above.
1287
+ const repoInfo = vcs.gitInfo(gitCwd);
1288
+ if (repoInfo?.isReftable) {
1289
+ if (this.#branchResolveActive !== undefined) {
1290
+ return this.#branchResolveActive.cwd === gitCwd && this.#cachedBranchCwd === gitCwd
1291
+ ? (this.#cachedBranch ?? null)
1292
+ : null;
1293
+ }
1294
+ const request: BranchResolveRequest = {
1295
+ id: ++this.#branchResolveSeq,
1296
+ cwd: gitCwd,
1297
+ controller: new AbortController(),
1298
+ };
1299
+ this.#branchResolveActive = request;
1300
+ // Capture the cache generation at launch. invalidateGitCaches bumps it
1301
+ // on a HEAD move and clears the in-flight slot, so a fresher resolve can
1302
+ // start while this one is still pending. Without a generation check the
1303
+ // older resolve would finish later, install its stale HEAD, and clear the
1304
+ // slot — dropping the fresh result and freezing the status line on the
1305
+ // pre-change branch. Mirrors #jjCacheGeneration / #getBranchLabel.
1306
+ const generation = this.#branchCacheGeneration;
1307
+ (async () => {
1308
+ let next: string | null = null;
1309
+ let repoId: string | null = null;
1310
+ try {
1311
+ const headState = await gitRepository.head(request.controller.signal);
1312
+ repoId = repoInfo.headPath;
1313
+ next = headState.kind === "ref" ? (headState.branch ?? headState.refName ?? "HEAD") : "detached";
1314
+ } catch {
1315
+ next = null;
1316
+ } finally {
1317
+ // Release the slot only if this resolve still owns it: after an
1318
+ // invalidation a fresher resolve may hold it, and freeing that
1319
+ // slot here would let a third same-generation resolve launch and
1320
+ // race the fresh one to the cache commit.
1321
+ if (this.#branchResolveActive?.id === request.id) this.#branchResolveActive = undefined;
1322
+ }
1323
+ // Only the latest generation may update the cache; a mismatch means a
1324
+ // newer resolve superseded this one (or the component disposed).
1325
+ if (this.#branchCacheGeneration !== generation || this.#disposed) return;
1326
+ const prev = this.#cachedBranchCwd === gitCwd ? this.#cachedBranch : undefined;
1327
+ this.#cachedBranchCwd = gitCwd;
1328
+ this.#cachedBranchRepoId = repoId;
1329
+ this.#cachedBranch = next;
1330
+ this.#branchLastFetch = Date.now();
1331
+ if (prev !== next) {
1332
+ this.#invalidateStatusLineRenderCache();
1333
+ this.#onBranchChange?.();
1334
+ }
1335
+ })();
1336
+ return this.#cachedBranchCwd === gitCwd ? (this.#cachedBranch ?? null) : null;
1337
+ }
1338
+
1339
+ // Non-reftable: cheap sync filesystem read, safe on the render path.
1340
+ const head = (() => {
1341
+ try {
1342
+ return gitRepository.headSync();
1343
+ } catch {
1344
+ return null;
1345
+ }
1346
+ })();
1347
+ const gitHeadPath = repoInfo?.headPath ?? null;
1348
+ this.#cachedBranchCwd = gitCwd;
1349
+ this.#cachedBranchRepoId = gitHeadPath;
1350
+ this.#branchLastFetch = Date.now();
1351
+ if (!head) {
1352
+ this.#cachedBranch = null;
1353
+ return null;
1354
+ }
1355
+ this.#cachedBranch = head.kind === "ref" ? (head.branch ?? head.refName ?? "HEAD") : "detached";
1356
+ return this.#cachedBranch ?? null;
1357
+ }
1358
+
1359
+ #isDefaultBranch(branch: string, effectiveGitCwd: string): boolean {
1360
+ if (this.#defaultBranchCwd !== effectiveGitCwd) {
1361
+ this.#defaultBranch = undefined;
1362
+ this.#defaultBranchCwd = effectiveGitCwd;
1363
+ }
1364
+
1365
+ if (this.#defaultBranch === undefined) {
1366
+ this.#defaultBranch = "main";
1367
+ const lookupCwd = effectiveGitCwd;
1368
+ (async () => {
1369
+ const resolved = await vcs.git(lookupCwd)?.defaultBranch();
1370
+ if (this.#disposed || this.#defaultBranchCwd !== lookupCwd) return;
1371
+ if (resolved) {
1372
+ this.#defaultBranch = resolved;
1373
+ this.#invalidateStatusLineRenderCache();
1374
+ this.#onBranchChange?.();
1375
+ }
1376
+ })();
1377
+ }
1378
+ return branch === this.#defaultBranch;
1379
+ }
1380
+
1381
+ #getStatus(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
1382
+ staged: number;
1383
+ unstaged: number;
1384
+ untracked: number;
1385
+ } | null {
1386
+ if (!this.#gitEnabled()) return null;
1387
+
1388
+ const gitCwd = activeRepoCache.effectiveGitCwd;
1389
+ const repository = this.#resolveRepository(activeRepoCache);
1390
+ if (!repository) return null;
1391
+ if (repository.kind() === "jj") {
1392
+ if (this.#jjStatusActive || Date.now() - this.#jjStatusLastFetch < JJ_REFRESH_TTL_MS) {
1393
+ return this.#cachedJjStatus;
1394
+ }
1395
+ const request: JjResolveRequest = {
1396
+ id: ++this.#jjResolveSeq,
1397
+ controller: new AbortController(),
1398
+ };
1399
+ this.#jjStatusActive = request;
1400
+ const generation = this.#jjCacheGeneration;
1401
+ (async () => {
1402
+ let next: { staged: number; unstaged: number; untracked: number } | null = null;
1403
+ try {
1404
+ next = await repository.statusSummary(
1405
+ AbortSignal.any([request.controller.signal, AbortSignal.timeout(JJ_COMMAND_TIMEOUT_MS)]),
1406
+ );
1407
+ } catch {
1408
+ next = null;
1409
+ } finally {
1410
+ if (this.#jjStatusActive?.id === request.id) this.#jjStatusActive = undefined;
1411
+ if (this.#jjCacheGeneration === generation) this.#jjStatusLastFetch = Date.now();
1412
+ }
1413
+ if (this.#jjCacheGeneration !== generation || this.#disposed) return;
1414
+ const prev = this.#cachedJjStatus;
1415
+ this.#cachedJjStatus = next;
1416
+ if (JSON.stringify(prev) !== JSON.stringify(next)) {
1417
+ this.#invalidateStatusLineRenderCache();
1418
+ this.#onBranchChange?.();
1419
+ }
1420
+ })();
1421
+ return this.#cachedJjStatus;
1422
+ }
1423
+
1424
+ if (this.#gitStatusInFlightCwd !== undefined) {
1425
+ return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1426
+ }
1427
+ if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) {
1428
+ return this.#cachedGitStatus;
1429
+ }
1430
+
1431
+ this.#gitStatusInFlightCwd = gitCwd;
1432
+
1433
+ (async () => {
1434
+ let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null;
1435
+ try {
1436
+ nextStatus = (await repository.statusSummary()) ?? null;
1437
+ } catch {
1438
+ nextStatus = null;
1439
+ } finally {
1440
+ if (this.#gitStatusInFlightCwd === gitCwd) {
1441
+ const prev = this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1442
+ this.#cachedGitStatus = nextStatus;
1443
+ this.#cachedGitStatusCwd = gitCwd;
1444
+ this.#gitStatusLastFetch = Date.now();
1445
+ this.#gitStatusInFlightCwd = undefined;
1446
+ if (!this.#disposed && JSON.stringify(prev) !== JSON.stringify(nextStatus)) {
1447
+ this.#invalidateStatusLineRenderCache();
1448
+ this.#onBranchChange?.();
1449
+ }
1450
+ }
1451
+ }
1452
+ })();
1453
+
1454
+ return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
1455
+ }
1456
+
1457
+ #lookupPr(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
1458
+ number: number;
1459
+ url: string;
1460
+ } | null {
1461
+ if (!this.#gitEnabled()) return null;
1462
+
1463
+ const gitCwd = activeRepoCache.effectiveGitCwd;
1464
+ if (this.#resolveRepository(activeRepoCache)?.kind() !== "git") return null;
1465
+ const branch = this.#getBranchLabel(activeRepoCache, this.#resolveRepository(activeRepoCache));
1466
+ const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
1467
+
1468
+ if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) {
1469
+ return this.#cachedPr ?? null;
1470
+ }
1471
+
1472
+ const stalePr = this.#cachedPr;
1473
+
1474
+ if (!branch) {
1475
+ this.#cachedPr = null;
1476
+ this.#cachedPrContext = undefined;
1477
+ return null;
1478
+ }
1479
+
1480
+ // Don't look up if detached, default branch, or already in flight.
1481
+ if (branch === "detached" || this.#isDefaultBranch(branch, gitCwd) || this.#prLookupInFlight) {
1482
+ return stalePr ?? null;
1483
+ }
1484
+
1485
+ this.#prLookupInFlight = true;
1486
+ const lookupContext = currentContext;
1487
+ const lookupCwd = gitCwd;
1488
+
1489
+ // Fire async lookup, keep stale value visible until resolved
1490
+ (async () => {
1491
+ // Helper: only write cache if branch/repo context hasn't changed since launch
1492
+ const setCachedPr = (value: { number: number; url: string } | null) => {
1493
+ const latestActiveRepoCache = this.#resolveActiveRepoCache();
1494
+ if (latestActiveRepoCache.effectiveGitCwd !== lookupCwd) return;
1495
+ const latestBranch = this.#getBranchLabel(
1496
+ latestActiveRepoCache,
1497
+ this.#resolveRepository(latestActiveRepoCache),
1498
+ );
1499
+ const latestContext = latestBranch
1500
+ ? createPrCacheContext(latestBranch, this.#cachedBranchRepoId ?? null)
1501
+ : undefined;
1502
+ if (lookupContext && isSamePrCacheContext(latestContext, lookupContext)) {
1503
+ this.#cachedPr = value;
1504
+ this.#cachedPrContext = lookupContext;
1505
+ }
1506
+ };
1507
+ try {
1508
+ // Route through the shared `gh` helper so the child inherits
1509
+ // `GH_NON_INTERACTIVE_ENV` (disables terminal/keychain prompts) and
1510
+ // hard-terminates on the git command deadline instead of stalling
1511
+ // the status-line indefinitely (#4234). Requires `gh repo set-default`;
1512
+ // non-zero exit still falls through to the null cache below.
1513
+ const result = await this.host.lookupPullRequest(lookupCwd);
1514
+ if (this.#disposed) return;
1515
+ if (result.exitCode !== 0) {
1516
+ setCachedPr(null);
1517
+ return;
1518
+ }
1519
+ const pr = JSON.parse(result.stdout) as { number: number; url: string };
1520
+ if (typeof pr.number === "number") {
1521
+ setCachedPr({ number: pr.number, url: pr.url });
1522
+ } else {
1523
+ setCachedPr(null);
1524
+ }
1525
+ } catch {
1526
+ if (this.#disposed) return;
1527
+ setCachedPr(null);
1528
+ } finally {
1529
+ this.#prLookupInFlight = false;
1530
+ if (!this.#disposed) {
1531
+ this.#invalidateStatusLineRenderCache();
1532
+ this.#onBranchChange?.();
1533
+ }
1534
+ }
1535
+ })();
1536
+
1537
+ return stalePr ?? null;
1538
+ }
1539
+
1540
+ #getTokensPerSecond(): number | null {
1541
+ // Aggregate tok/s across the main session AND every live vibe worker.
1542
+ // In vibe mode the director is often idle while workers stream, so the
1543
+ // main session's own rate alone would show a stale/zero value while
1544
+ // parallel work is actively generating tokens.
1545
+ const workerRate = this.#getVibeWorkerTokensPerSecond();
1546
+ if (workerRate !== null) {
1547
+ // At least one worker is streaming — add the director's live rate
1548
+ // only when it is itself streaming (a finalized last-turn rate would
1549
+ // double-count and overstate throughput).
1550
+ const mainRate = this.session.isStreaming
1551
+ ? this.host.calculateTokensPerSecond(this.session.state.messages, true)
1552
+ : 0;
1553
+ return (mainRate ?? 0) + workerRate;
1554
+ }
1555
+
1556
+ // No workers streaming — fall back to the main session's own rate with
1557
+ // its sticky per-assistant-message cache so the badge doesn't flicker
1558
+ // off in the brief gap between stream end and the finalized message.
1559
+ return this.#getMainSessionTokensPerSecond();
1560
+ }
1561
+
1562
+ /**
1563
+ * Main session's tok/s with sticky caching keyed on the last assistant
1564
+ * message timestamp. Preserves the pre-aggregation behavior when no vibe
1565
+ * workers are active.
1566
+ */
1567
+ #getMainSessionTokensPerSecond(): number | null {
1568
+ let lastAssistantTimestamp: number | null = null;
1569
+ for (let i = this.session.state.messages.length - 1; i >= 0; i--) {
1570
+ const message = this.session.state.messages[i];
1571
+ if (message?.role === "assistant") {
1572
+ lastAssistantTimestamp = message.timestamp;
1573
+ break;
1574
+ }
1575
+ }
1576
+
1577
+ if (lastAssistantTimestamp === null) {
1578
+ this.#lastTokensPerSecond = null;
1579
+ this.#lastTokensPerSecondTimestamp = null;
1580
+ return null;
1581
+ }
1582
+
1583
+ const rate = this.host.calculateTokensPerSecond(this.session.state.messages, this.session.isStreaming);
1584
+ if (rate !== null) {
1585
+ this.#lastTokensPerSecond = rate;
1586
+ this.#lastTokensPerSecondTimestamp = lastAssistantTimestamp;
1587
+ return rate;
1588
+ }
1589
+
1590
+ if (this.#lastTokensPerSecondTimestamp === lastAssistantTimestamp) {
1591
+ return this.#lastTokensPerSecond;
1592
+ }
1593
+
1594
+ return null;
1595
+ }
1596
+
1597
+ /**
1598
+ * Aggregate tok/s across every live vibe worker session owned by this
1599
+ * session. Returns null when no workers are streaming (so the main
1600
+ * session's own rate shines through unchanged). The aggregation itself is
1601
+ * injected via {@link setVibeWorkerTokenRateProvider} to keep this render
1602
+ * layer off the heavy vibe/task dependency graph.
1603
+ */
1604
+ #getVibeWorkerTokensPerSecond(): number | null {
1605
+ return this.#vibeWorkerTokenRate?.() ?? null;
1606
+ }
1607
+
1608
+ #formatUsageContextKey(activeProvider: string | undefined, identity: OAuthAccountIdentity | undefined): string {
1609
+ if (!activeProvider) return "";
1610
+ // orgId is part of the key: rotating between two same-email Anthropic
1611
+ // subscriptions must invalidate the cached usage immediately instead of
1612
+ // showing the previous org's quota for the rest of the cache TTL.
1613
+ return [
1614
+ activeProvider,
1615
+ identity?.accountId ?? "",
1616
+ identity?.email ?? "",
1617
+ identity?.projectId ?? "",
1618
+ identity?.orgId ?? "",
1619
+ ].join("\0");
1620
+ }
1621
+
1622
+ #getUsageContextKey(session: TSession): string {
1623
+ const activeProvider = session.state.model?.provider ?? session.model?.provider;
1624
+ const identity = activeProvider ? this.host.activeAccount(session, activeProvider) : undefined;
1625
+ // Model id is part of the invalidation key (but not the account-scoped
1626
+ // fireworks key): normalized usage now selects a model-scoped window group,
1627
+ // so switching models must drop the previous model's cached scope instead
1628
+ // of showing it for the rest of the TTL.
1629
+ const activeModelId = session.state.model?.id ?? session.model?.id ?? "";
1630
+ return `${this.#formatUsageContextKey(activeProvider, identity)}\0${activeModelId}`;
1631
+ }
1632
+
1633
+ /**
1634
+ * Startup redraws only arm a short-delayed task; timeout releases the render
1635
+ * cadence while a late successful fetch can still refresh the cached segment.
1636
+ */
1637
+ refreshUsageInBackground(): void {
1638
+ const now = Date.now();
1639
+ const session = this.session;
1640
+ const usageContextKey = this.#getUsageContextKey(session);
1641
+ if (this.#cachedUsageContextKey !== usageContextKey) {
1642
+ this.#cachedUsage = null;
1643
+ this.#usageFetchedAt = 0;
1644
+ this.#cachedUsageContextKey = usageContextKey;
1645
+ }
1646
+ if (this.#usageInFlight || this.#usageStartTimer) return;
1647
+ if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
1648
+ if (!this.host.canFetchUsageReports(session)) return;
1649
+ this.#usageInFlight = true;
1650
+ this.#usageStartTimer = setTimeout(() => {
1651
+ this.#usageStartTimer = null;
1652
+ void this.#runUsageRefresh(session);
1653
+ }, STATUS_USAGE_START_DELAY_MS);
1654
+ }
1655
+
1656
+ async #runUsageRefresh(session: TSession): Promise<void> {
1657
+ if (this.#disposed || this.session !== session) {
1658
+ this.#usageInFlight = false;
1659
+ return;
1660
+ }
1661
+ const sequence = ++this.#usageRefreshSequence;
1662
+ const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
1663
+ let reportsPromise: Promise<unknown> | undefined;
1664
+ try {
1665
+ reportsPromise = this.host.fetchUsageReports(session, signal);
1666
+ this.#applyUsageRefreshReports(
1667
+ session,
1668
+ await this.#raceUsageRefreshWithSignal(reportsPromise, signal),
1669
+ sequence,
1670
+ );
1671
+ } catch {
1672
+ if (this.session !== session) return;
1673
+ this.#usageFetchedAt = Date.now();
1674
+ if (signal.aborted && reportsPromise) {
1675
+ this.#observeLateUsageRefresh(session, reportsPromise, sequence);
1676
+ }
1677
+ } finally {
1678
+ if (this.session === session) this.#usageInFlight = false;
1679
+ }
1680
+ }
1681
+
1682
+ #applyUsageRefreshReports(session: TSession, reports: unknown, sequence: number): void {
1683
+ if (this.#disposed || this.session !== session || sequence < this.#latestAppliedUsageRefreshSequence) {
1684
+ return;
1685
+ }
1686
+ this.#latestAppliedUsageRefreshSequence = sequence;
1687
+ const activeProvider = session.state.model?.provider ?? session.model?.provider;
1688
+ const activeModelId = session.state.model?.id ?? session.model?.id;
1689
+ const activeIdentity = activeProvider ? this.host.activeAccount(session, activeProvider) : undefined;
1690
+ const normalized = this.#normalizeUsageReports(reports, {
1691
+ provider: activeProvider,
1692
+ modelId: activeModelId,
1693
+ identity: activeIdentity,
1694
+ });
1695
+ const resetSnapshot =
1696
+ activeProvider === "openai-codex" ? this.#normalizeCodexResetSnapshot(reports, activeIdentity) : null;
1697
+ const usageChanged = this.#cachedUsage !== normalized;
1698
+ this.#cachedUsage = normalized;
1699
+ this.#usageFetchedAt = Date.now();
1700
+ // Usage fetch is async; without a repaint the top border stays blank until
1701
+ // some unrelated event (git resolve, keystroke, …) rebuilds it.
1702
+ if (usageChanged) {
1703
+ this.#invalidateStatusLineRenderCache();
1704
+ this.#onBranchChange?.();
1705
+ }
1706
+ if (!resetSnapshot) return;
1707
+ const contextKey = this.#formatUsageContextKey(activeProvider, activeIdentity);
1708
+ const previous = this.#codexResetSnapshots.get(contextKey);
1709
+ this.#codexResetSnapshots.set(contextKey, resetSnapshot);
1710
+ if (!previous || !this.host.codexResetFireworksEnabled()) return;
1711
+ const event = detectCodexResetFireworks(previous, resetSnapshot);
1712
+ if (event) this.#onCodexResetFireworks?.(event);
1713
+ }
1714
+
1715
+ #observeLateUsageRefresh(session: TSession, reportsPromise: Promise<unknown>, sequence: number): void {
1716
+ void reportsPromise
1717
+ .then(reports => {
1718
+ this.#applyUsageRefreshReports(session, reports, sequence);
1719
+ })
1720
+ .catch(() => {
1721
+ if (this.#disposed || this.session !== session || sequence < this.#latestAppliedUsageRefreshSequence) {
1722
+ return;
1723
+ }
1724
+ this.#usageFetchedAt = Date.now();
1725
+ });
1726
+ }
1727
+
1728
+ async #raceUsageRefreshWithSignal(promise: Promise<unknown>, signal: AbortSignal): Promise<unknown> {
1729
+ if (signal.aborted) throw signal.reason;
1730
+ const aborted = Promise.withResolvers<never>();
1731
+ const onAbort = () => aborted.reject(signal.reason);
1732
+ signal.addEventListener("abort", onAbort, { once: true });
1733
+ try {
1734
+ return await Promise.race([promise, aborted.promise]);
1735
+ } finally {
1736
+ signal.removeEventListener("abort", onAbort);
1737
+ }
1738
+ }
1739
+
1740
+ #normalizeCodexResetSnapshot(
1741
+ reports: unknown,
1742
+ activeIdentity: OAuthAccountIdentity | undefined,
1743
+ ): CodexResetUsageSnapshot | null {
1744
+ if (!Array.isArray(reports)) return null;
1745
+ let matchingReport: UsageReport | undefined;
1746
+ for (const report of reports) {
1747
+ if (!report || typeof report !== "object") continue;
1748
+ if (
1749
+ !("provider" in report) ||
1750
+ report.provider !== "openai-codex" ||
1751
+ !("limits" in report) ||
1752
+ !Array.isArray(report.limits)
1753
+ ) {
1754
+ continue;
1755
+ }
1756
+ // The report boundary above validates the fields this extractor iterates;
1757
+ // optional metadata and credit fields are narrowed again before use.
1758
+ const usageReport = report as UsageReport;
1759
+ if (!codexReportMatchesExactIdentity(usageReport, activeIdentity)) continue;
1760
+ matchingReport = usageReport;
1761
+ break;
1762
+ }
1763
+ if (!matchingReport) return null;
1764
+
1765
+ const plan =
1766
+ typeof matchingReport.metadata?.planType === "string" && matchingReport.metadata.planType
1767
+ ? matchingReport.metadata.planType
1768
+ : undefined;
1769
+ let sevenDay: CodexResetUsageSnapshot["sevenDay"];
1770
+ let sevenDayTier: string | undefined;
1771
+ for (const limit of matchingReport.limits) {
1772
+ if (!limit || typeof limit !== "object") continue;
1773
+ const candidate = limit as {
1774
+ scope?: { windowId?: string; tier?: string };
1775
+ window?: { resetsAt?: number };
1776
+ amount?: { usedFraction?: number };
1777
+ };
1778
+ const fraction = candidate.amount?.usedFraction;
1779
+ if (candidate.scope?.windowId !== "7d" || typeof fraction !== "number" || !Number.isFinite(fraction)) {
1780
+ continue;
1781
+ }
1782
+ const tier =
1783
+ typeof candidate.scope?.tier === "string" && candidate.scope.tier ? candidate.scope.tier : undefined;
1784
+ if (sevenDay && (sevenDayTier === undefined || tier)) continue;
1785
+ const resetsAt = candidate.window?.resetsAt;
1786
+ sevenDay = {
1787
+ percent: fraction * 100,
1788
+ resetsAt: typeof resetsAt === "number" && Number.isFinite(resetsAt) ? resetsAt : undefined,
1789
+ tier,
1790
+ plan,
1791
+ };
1792
+ sevenDayTier = tier;
1793
+ }
1794
+
1795
+ const fetchedAt = matchingReport.fetchedAt;
1796
+ const availableCount = matchingReport.resetCredits?.availableCount;
1797
+ const observedAt = typeof fetchedAt === "number" && Number.isFinite(fetchedAt) ? fetchedAt : undefined;
1798
+ const savedResets =
1799
+ typeof availableCount === "number" && Number.isFinite(availableCount)
1800
+ ? Math.max(0, Math.trunc(availableCount))
1801
+ : undefined;
1802
+ if (!sevenDay && savedResets === undefined) return null;
1803
+ return { observedAt, sevenDay, savedResets };
1804
+ }
1805
+
1806
+ #normalizeUsageReports(
1807
+ reports: unknown,
1808
+ context: { provider?: string; modelId?: string; identity?: OAuthAccountIdentity },
1809
+ ): {
1810
+ tier?: string;
1811
+ fiveHour?: { percent: number; resetMinutes?: number };
1812
+ daily?: { percent: number; resetMinutes?: number };
1813
+ sevenDay?: { percent: number; resetHours?: number };
1814
+ monthly?: { percent: number; resetHours?: number };
1815
+ } | null {
1816
+ if (!Array.isArray(reports)) return null;
1817
+ const now = Date.now();
1818
+ const activeModelId = normalizeUsageScopeValue(context.modelId);
1819
+ const activeAntigravityCounter =
1820
+ context.provider === "google-antigravity" ? getAntigravityCounterKeyForModel(context.modelId) : undefined;
1821
+ const scopeGroups = new Map<string, UsageScopeGroup>();
1822
+ for (const report of reports) {
1823
+ if (!report || typeof report !== "object") continue;
1824
+ const provider = "provider" in report ? report.provider : undefined;
1825
+ if (context.provider && provider !== context.provider) continue;
1826
+ const reportLimits = "limits" in report ? report.limits : undefined;
1827
+ if (!Array.isArray(reportLimits)) continue;
1828
+ // fetchUsageReports supplies normalized rows; the guards above protect
1829
+ // the unknown session boundary before the account matcher reads metadata.
1830
+ const usageReport = report as UsageReport;
1831
+ const limits =
1832
+ provider === "google-antigravity" && activeAntigravityCounter
1833
+ ? scopeAntigravityLimitsForModel(usageReport, context)
1834
+ : reportLimits;
1835
+ for (const limit of limits) {
1836
+ if (
1837
+ !limit ||
1838
+ typeof limit !== "object" ||
1839
+ !("scope" in limit) ||
1840
+ !limit.scope ||
1841
+ typeof limit.scope !== "object" ||
1842
+ !("amount" in limit) ||
1843
+ !limit.amount ||
1844
+ typeof limit.amount !== "object"
1845
+ ) {
1846
+ continue;
1847
+ }
1848
+ const scope = limit.scope;
1849
+ const amount = limit.amount;
1850
+ // The matcher only reads scope identity fields, whose object boundary
1851
+ // is validated above; other required UsageLimit fields are irrelevant.
1852
+ const usageLimit = limit as UsageLimit;
1853
+ if (context.identity && !this.host.limitMatchesActiveAccount(usageReport, usageLimit, context.identity))
1854
+ continue;
1855
+
1856
+ const fraction = "usedFraction" in amount ? amount.usedFraction : undefined;
1857
+ if (typeof fraction !== "number") continue;
1858
+ const window =
1859
+ "window" in limit && limit.window && typeof limit.window === "object" ? limit.window : undefined;
1860
+ const windowId = "windowId" in scope ? scope.windowId : undefined;
1861
+ const durationValue = window && "durationMs" in window ? window.durationMs : undefined;
1862
+ const durationMs = typeof durationValue === "number" ? durationValue : undefined;
1863
+ const subscriptionWindow =
1864
+ windowId === "5h" || windowId === "7d"
1865
+ ? windowId
1866
+ : windowId === "daily" || windowId === "24h" || windowId === "1d"
1867
+ ? "daily"
1868
+ : durationMs !== undefined && Math.abs(durationMs - 5 * 3_600_000) <= 60_000
1869
+ ? "5h"
1870
+ : durationMs !== undefined && Math.abs(durationMs - 86_400_000) <= 60_000
1871
+ ? "daily"
1872
+ : durationMs !== undefined && Math.abs(durationMs - 7 * 86_400_000) <= 60_000
1873
+ ? "7d"
1874
+ : undefined;
1875
+ const windowClass =
1876
+ subscriptionWindow ??
1877
+ ((context.provider === "cursor" || context.provider === "opencode-go") &&
1878
+ (windowId === "monthly" || windowId === "30d")
1879
+ ? "monthly"
1880
+ : undefined);
1881
+ if (!windowClass) continue;
1882
+
1883
+ const modelId = normalizeUsageScopeValue("modelId" in scope ? scope.modelId : undefined);
1884
+ if (modelId && modelId !== activeModelId) continue;
1885
+ const rawTier = "tier" in scope ? scope.tier : undefined;
1886
+ const rawPlanType = usageReport.metadata?.planType;
1887
+ // Scoped tiers (Claude Fable, Codex Spark) win; plan-wide tiers
1888
+ // (Z.AI `pro`, Codex `pro`) label otherwise-untiered windows.
1889
+ const tier =
1890
+ (typeof rawTier === "string" && rawTier.trim() ? rawTier.trim() : undefined) ??
1891
+ (typeof rawPlanType === "string" && rawPlanType.trim() ? rawPlanType.trim() : undefined);
1892
+ const normalizedTier = normalizeUsageScopeValue(tier);
1893
+ const scopeKey = `${modelId ?? ""}\0${normalizedTier ?? ""}`;
1894
+ // Exact-model groups outrank provider-wide groups; within either
1895
+ // specificity, untiered limits preserve the historical preference.
1896
+ const priority = modelId ? (normalizedTier ? 1 : 0) : normalizedTier ? 3 : 2;
1897
+ const id = "id" in limit && typeof limit.id === "string" ? limit.id : undefined;
1898
+ const resetValue = window && "resetsAt" in window ? window.resetsAt : undefined;
1899
+ const displayCandidate: UsageWindowCandidate = {
1900
+ id,
1901
+ windowClass,
1902
+ fraction,
1903
+ resetsAt: typeof resetValue === "number" ? resetValue : undefined,
1904
+ };
1905
+ const group = scopeGroups.get(scopeKey);
1906
+ if (group) {
1907
+ group.candidates.push(displayCandidate);
1908
+ } else {
1909
+ scopeGroups.set(scopeKey, { priority, tier, candidates: [displayCandidate] });
1910
+ }
1911
+ }
1912
+ }
1913
+
1914
+ let selectedGroup: UsageScopeGroup | undefined;
1915
+ for (const group of scopeGroups.values()) {
1916
+ if (!selectedGroup || group.priority < selectedGroup.priority) selectedGroup = group;
1917
+ }
1918
+ if (!selectedGroup) return null;
1919
+
1920
+ let fiveHour: { percent: number; resetMinutes?: number } | undefined;
1921
+ let daily: { percent: number; resetMinutes?: number } | undefined;
1922
+ let sevenDay: { percent: number; resetHours?: number } | undefined;
1923
+ let monthly: { percent: number; resetHours?: number } | undefined;
1924
+ let monthlyPriority = Number.POSITIVE_INFINITY;
1925
+ const cursorMonthlyPriority = (limitId: unknown): number => {
1926
+ // When /auth/usage and /api/usage-summary are merged, prefer the personal
1927
+ // dashboard rails over legacy per-model request fractions.
1928
+ if (limitId === "cursor:usd:individual-auto") return 0;
1929
+ if (limitId === "cursor:usd:individual-plan" || limitId === "cursor:usd:individual-overall") return 1;
1930
+ if (typeof limitId === "string" && limitId.startsWith("cursor:usd:individual-")) return 2;
1931
+ return 3;
1932
+ };
1933
+ for (const candidate of selectedGroup.candidates) {
1934
+ if (candidate.windowClass === "5h" && !fiveHour) {
1935
+ fiveHour = {
1936
+ percent: candidate.fraction * 100,
1937
+ resetMinutes:
1938
+ typeof candidate.resetsAt === "number"
1939
+ ? Math.max(0, Math.round((candidate.resetsAt - now) / 60_000))
1940
+ : undefined,
1941
+ };
1942
+ }
1943
+ if (candidate.windowClass === "daily" && !daily) {
1944
+ daily = {
1945
+ percent: candidate.fraction * 100,
1946
+ resetMinutes:
1947
+ typeof candidate.resetsAt === "number"
1948
+ ? Math.max(0, Math.round((candidate.resetsAt - now) / 60_000))
1949
+ : undefined,
1950
+ };
1951
+ }
1952
+ if (candidate.windowClass === "7d" && !sevenDay) {
1953
+ sevenDay = {
1954
+ percent: candidate.fraction * 100,
1955
+ resetHours:
1956
+ typeof candidate.resetsAt === "number"
1957
+ ? Math.max(0, Math.round((candidate.resetsAt - now) / 3_600_000))
1958
+ : undefined,
1959
+ };
1960
+ }
1961
+ if (candidate.windowClass === "monthly") {
1962
+ const priority = cursorMonthlyPriority(candidate.id);
1963
+ if (priority < monthlyPriority) {
1964
+ monthly = {
1965
+ percent: candidate.fraction * 100,
1966
+ resetHours:
1967
+ typeof candidate.resetsAt === "number"
1968
+ ? Math.max(0, Math.round((candidate.resetsAt - now) / 3_600_000))
1969
+ : undefined,
1970
+ };
1971
+ monthlyPriority = priority;
1972
+ }
1973
+ }
1974
+ }
1975
+ if (!fiveHour && !daily && !sevenDay && !monthly) return null;
1976
+ return { tier: selectedGroup.tier, fiveHour, daily, sevenDay, monthly };
1977
+ }
1978
+
1979
+ /**
1980
+ * Used-tokens / context-window totals for the status-line context% segment,
1981
+ * memoized so the per-event redraw stays O(1) when nothing changed.
1982
+ *
1983
+ * The numerator comes from `session.getContextUsage()`, which anchors on the
1984
+ * last assistant's real prompt-token count — so the bar matches the provider
1985
+ * and the `/context` panel — and reports `null` while that count is unknown
1986
+ * (right after compaction, before the next response). Exposed (non-private)
1987
+ * for unit tests and the collab host's state broadcast.
1988
+ */
1989
+ getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
1990
+ const messages = this.session.messages ?? EMPTY_MESSAGES;
1991
+ const modelContextWindow = this.session.model?.contextWindow ?? 0;
1992
+ const length = messages.length;
1993
+ const lastFingerprint = length > 0 ? messageFingerprint(messages[length - 1]!) : undefined;
1994
+ // Bumps when the in-flight pending snapshot is set/cleared. Without it a
1995
+ // value computed mid-turn (estimate of the active tail) would survive after
1996
+ // the turn ends/aborts, since clearing the snapshot touches no message.
1997
+ const contextUsageRevision = this.session.contextUsageRevision ?? 0;
1998
+
1999
+ const systemPrompt = this.session.systemPrompt;
2000
+ const tools = this.session.agent?.state?.tools;
2001
+ const toolSchemaMetadataRevision = tools ? getToolSchemaMetadataRevision(tools) : 0;
2002
+ const settingsRevision = this.host.getSessionSettingsRevision(this.session);
2003
+ const tokenizer = this.session.agent?.tokenizer;
2004
+ const skills = this.session.skills;
2005
+
2006
+ const cache = this.#contextUsageCache;
2007
+ if (
2008
+ cache &&
2009
+ cache.messagesRef === messages &&
2010
+ cache.length === length &&
2011
+ cache.lastFingerprint === lastFingerprint &&
2012
+ cache.modelContextWindow === modelContextWindow &&
2013
+ cache.contextUsageRevision === contextUsageRevision &&
2014
+ cache.systemPromptRef === systemPrompt &&
2015
+ cache.toolsRef === tools &&
2016
+ cache.toolSchemaMetadataRevision === toolSchemaMetadataRevision &&
2017
+ cache.settingsRevision === settingsRevision &&
2018
+ cache.tokenizerRef === tokenizer &&
2019
+ cache.skillsRef === skills
2020
+ ) {
2021
+ return { usedTokens: cache.usedTokens, contextWindow: cache.contextWindow };
2022
+ }
2023
+
2024
+ const usage = typeof this.session.getContextUsage === "function" ? this.session.getContextUsage() : undefined;
2025
+ const usedTokens = usage?.tokens ?? 0;
2026
+ const contextWindow = usage?.contextWindow ?? modelContextWindow;
2027
+ this.#contextUsageCache = {
2028
+ messagesRef: messages,
2029
+ length,
2030
+ lastFingerprint,
2031
+ modelContextWindow,
2032
+ contextUsageRevision,
2033
+ toolSchemaMetadataRevision,
2034
+ settingsRevision,
2035
+ tokenizerRef: tokenizer,
2036
+ usedTokens,
2037
+ contextWindow,
2038
+ systemPromptRef: systemPrompt,
2039
+ toolsRef: tools,
2040
+ skillsRef: skills,
2041
+ };
2042
+ return { usedTokens, contextWindow };
2043
+ }
2044
+
2045
+ #buildSegmentContext(
2046
+ width: number,
2047
+ segmentOptions: StatusLineSettings["segmentOptions"],
2048
+ includePath: boolean,
2049
+ includeGit: boolean,
2050
+ includePr: boolean,
2051
+ nowMs: number,
2052
+ previewTitle?: string,
2053
+ ): SegmentContext {
2054
+ const state = this.session.state;
2055
+
2056
+ // Trigger background fetch (5-min TTL); render uses cached value
2057
+ this.refreshUsageInBackground();
2058
+
2059
+ // Get usage statistics
2060
+ const aggregateUsageStats = this.session.sessionManager?.getUsageStatistics() ?? {
2061
+ input: 0,
2062
+ output: 0,
2063
+ cacheRead: 0,
2064
+ cacheWrite: 0,
2065
+ totalTokens: 0,
2066
+ orchestrationInput: 0,
2067
+ orchestrationOutput: 0,
2068
+ orchestrationCacheRead: 0,
2069
+ premiumRequests: 0,
2070
+ cost: 0,
2071
+ };
2072
+ const usageStats = {
2073
+ ...aggregateUsageStats,
2074
+ tokensPerSecond: this.#getTokensPerSecond(),
2075
+ };
2076
+
2077
+ let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
2078
+ const breakdown = this.getCachedContextBreakdown();
2079
+ let contextTokens = breakdown.usedTokens;
2080
+ contextWindow = breakdown.contextWindow || contextWindow;
2081
+ let contextPercent: number | null = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
2082
+ // Collab guest: context comes from the host's state frames — the local
2083
+ // replica does no accounting of its own.
2084
+ const collabState = this.#collabStatus?.stateOverride;
2085
+ if (collabState?.contextUsage) {
2086
+ contextWindow = collabState.contextUsage.contextWindow || contextWindow;
2087
+ contextTokens = collabState.contextUsage.tokens ?? contextTokens;
2088
+ contextPercent = collabState.contextUsage.percent ?? contextPercent;
2089
+ }
2090
+
2091
+ const shouldResolveActiveRepo = this.#gitEnabled() && (includePath || includeGit || includePr);
2092
+ const projectDir = getProjectDir();
2093
+ const activeRepoCache = shouldResolveActiveRepo
2094
+ ? this.#resolveActiveRepoCache()
2095
+ : {
2096
+ projectDir,
2097
+ activeRepo: null,
2098
+ effectiveGitCwd: projectDir,
2099
+ repository: null,
2100
+ displayRepository: null,
2101
+ displayRepositoryCheckedAt: Date.now(),
2102
+ repositoryCheckedAt: Date.now(),
2103
+ worktree: null,
2104
+ };
2105
+ const gitBranch = includeGit || includePr ? this.#getBranchLabel(activeRepoCache) : null;
2106
+ const gitStatus = includeGit ? this.#getStatus(activeRepoCache) : null;
2107
+ const gitPr = includePr ? this.#lookupPr(activeRepoCache) : null;
2108
+ const compactionSpeculation = this.session.compactionSpeculation ?? "idle";
2109
+ this.#syncSpeculationBlink(compactionSpeculation);
2110
+ const sessionAccentEnabled = this.#resolveSettings().sessionAccent !== false;
2111
+ const turnElapsedMs = this.getTurnElapsedMs();
2112
+ return {
2113
+ session: this.session,
2114
+ focusedAgentId: this.#focusedAgentId,
2115
+ sessionAccent: sessionAccentEnabled,
2116
+ previewTitle,
2117
+ activeRepo: activeRepoCache.activeRepo,
2118
+ width,
2119
+ options: segmentOptions ?? {},
2120
+ compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
2121
+ hookStatuses: this.#sortedHookStatuses,
2122
+ planMode: this.#planModeStatus,
2123
+ loopMode: this.#loopModeStatus,
2124
+ prewalk:
2125
+ typeof this.session.getPrewalkState === "function" && this.session.getPrewalkState()
2126
+ ? { enabled: true }
2127
+ : null,
2128
+ goalMode: this.#goalModeStatus,
2129
+ goalStatusInFooter: this.#goalModeStatus ? this.host.goalStatusInFooter(this.session) : false,
2130
+ vibeMode: this.#vibeModeStatus,
2131
+ vim: this.#vimStatus,
2132
+ collab: this.#collabStatus,
2133
+ stream: this.#streamStatus,
2134
+ usageStats,
2135
+ contextPercent,
2136
+ contextTokens,
2137
+ contextWindow,
2138
+ autoCompactEnabled: this.#autoCompactEnabled,
2139
+ compactionSpeculation,
2140
+ speculationBlinkOn: this.#speculationBlinkOn,
2141
+ subagentCount: this.#subagentCount,
2142
+ activeMs: this.getActiveMs(),
2143
+ turnElapsedMs,
2144
+ now: new Date(nowMs),
2145
+ brandFgAnsi: this.#brandFgAnsi(turnElapsedMs !== null, sessionAccentEnabled),
2146
+ git: {
2147
+ branch: gitBranch,
2148
+ status: gitStatus,
2149
+ pr: gitPr,
2150
+ },
2151
+ worktree: activeRepoCache.worktree,
2152
+ usage: this.#cachedUsage,
2153
+ };
2154
+ }
2155
+
2156
+ #resolveSettings(): EffectiveStatusLineSettings {
2157
+ if (this.#effectiveSettings === undefined) {
2158
+ this.#effectiveSettings = this.#computeEffectiveSettings();
2159
+ }
2160
+ return this.#effectiveSettings;
2161
+ }
2162
+
2163
+ #computeEffectiveSettings(): EffectiveStatusLineSettings {
2164
+ const preset = this.#settings.preset ?? "default";
2165
+ const presetDef = getPreset(preset);
2166
+ const useCustomSegments = preset === "custom";
2167
+ const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {};
2168
+
2169
+ for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
2170
+ mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record<string, unknown>) };
2171
+ }
2172
+
2173
+ for (const [segment, options] of Object.entries(this.#settings.segmentOptions ?? {})) {
2174
+ const current = mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] ?? {};
2175
+ mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = {
2176
+ ...(current as Record<string, unknown>),
2177
+ ...(options as Record<string, unknown>),
2178
+ };
2179
+ }
2180
+
2181
+ const leftSegments = useCustomSegments
2182
+ ? (this.#settings.leftSegments ?? presetDef.leftSegments)
2183
+ : presetDef.leftSegments;
2184
+ const rightSegments = useCustomSegments
2185
+ ? (this.#settings.rightSegments ?? presetDef.rightSegments)
2186
+ : presetDef.rightSegments;
2187
+
2188
+ return {
2189
+ ...this.#settings,
2190
+ leftSegments,
2191
+ rightSegments,
2192
+ separator: this.#settings.separator ?? presetDef.separator,
2193
+ segmentOptions: mergedSegmentOptions,
2194
+ };
2195
+ }
2196
+
2197
+ #subagentBadgeText(): string | undefined {
2198
+ if (this.#subagentCount === 0) return undefined;
2199
+ return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount}`);
2200
+ }
2201
+
2202
+ /**
2203
+ * Cheap, non-rendering inputs whose mutation can change a segment without
2204
+ * going through this component's setters. This deliberately uses refs,
2205
+ * scalars, and string lengths only: no schema estimation, serialization,
2206
+ * usage aggregation, VCS probing, or segment getters run on a cache hit.
2207
+ */
2208
+ #readStatusLineExternalInputs(): StatusLineExternalInputs {
2209
+ const state = this.session.state;
2210
+ const stateMessages = state.messages ?? EMPTY_MESSAGES;
2211
+ const stateLastMessage = stateMessages[stateMessages.length - 1];
2212
+ const stateLastMessageContent =
2213
+ stateLastMessage && typeof stateLastMessage === "object" && "content" in stateLastMessage
2214
+ ? stateLastMessage.content
2215
+ : undefined;
2216
+ const messages = this.session.messages ?? EMPTY_MESSAGES;
2217
+ const lastMessage = messages[messages.length - 1];
2218
+ const lastMessageView = lastMessage as
2219
+ | {
2220
+ role?: string;
2221
+ timestamp?: number;
2222
+ content?: unknown;
2223
+ usage?: { totalTokens?: number };
2224
+ stopReason?: string;
2225
+ }
2226
+ | undefined;
2227
+ const content = lastMessageView?.content;
2228
+ let lastMessageContentSize = 0;
2229
+ let lastMessageBlockCount = 0;
2230
+ if (typeof content === "string") {
2231
+ lastMessageContentSize = content.length;
2232
+ } else if (Array.isArray(content)) {
2233
+ lastMessageBlockCount = content.length;
2234
+ for (const block of content) {
2235
+ if (!block || typeof block !== "object") continue;
2236
+ const candidate = block as {
2237
+ text?: unknown;
2238
+ thinking?: unknown;
2239
+ data?: unknown;
2240
+ name?: unknown;
2241
+ arguments?: unknown;
2242
+ };
2243
+ if (typeof candidate.text === "string") lastMessageContentSize += candidate.text.length;
2244
+ if (typeof candidate.thinking === "string") lastMessageContentSize += candidate.thinking.length;
2245
+ if (typeof candidate.data === "string") lastMessageContentSize += candidate.data.length;
2246
+ if (typeof candidate.name === "string") lastMessageContentSize += candidate.name.length;
2247
+ if (typeof candidate.arguments === "string") lastMessageContentSize += candidate.arguments.length;
2248
+ }
2249
+ }
2250
+
2251
+ const model = state.model;
2252
+ const sessionModel = this.session.model;
2253
+ const systemPrompt = this.session.systemPrompt;
2254
+ let systemPromptContentSize = 0;
2255
+ for (const part of systemPrompt ?? EMPTY_STRING_PARTS) {
2256
+ if (typeof part === "string") systemPromptContentSize += part.length;
2257
+ }
2258
+ const tools = this.session.agent?.state?.tools;
2259
+ return {
2260
+ themeRef: theme,
2261
+ themeEpoch: getThemeEpoch(),
2262
+ projectDir: getProjectDir(),
2263
+ sessionRef: this.session,
2264
+ sessionFile: this.session.sessionFile,
2265
+ sessionSettingsRef: this.host.getSessionSettingsIdentity(this.session),
2266
+ sessionSettingsRevision: this.host.getSessionSettingsRevision(this.session),
2267
+ globalSettingsRevision: this.host.getSettingsRevision(),
2268
+ stateRef: state,
2269
+ stateMessagesRef: stateMessages,
2270
+ stateMessagesLength: stateMessages.length,
2271
+ stateLastMessageRef: stateLastMessage,
2272
+ stateLastMessageContentRef: stateLastMessageContent,
2273
+ messagesRef: messages,
2274
+ messagesLength: messages.length,
2275
+ lastMessageRef: lastMessage,
2276
+ lastMessageRole: lastMessageView?.role,
2277
+ lastMessageTimestamp: lastMessageView?.timestamp ?? 0,
2278
+ lastMessageContentSize,
2279
+ lastMessageBlockCount,
2280
+ lastMessageUsageTotal: lastMessageView?.usage?.totalTokens ?? 0,
2281
+ lastMessageStopReason: lastMessageView?.stopReason,
2282
+ modelRef: model,
2283
+ sessionModelRef: sessionModel,
2284
+ sessionModelId: sessionModel?.id,
2285
+ sessionModelContextWindow: sessionModel?.contextWindow,
2286
+ modelId: model?.id,
2287
+ modelName: model?.name,
2288
+ modelProvider: model?.provider,
2289
+ modelContextWindow: model?.contextWindow,
2290
+ modelThinking: model?.thinking,
2291
+ thinkingLevel: state.thinkingLevel,
2292
+ modelCostRef: model?.cost,
2293
+ contextUsageRevision: this.session.contextUsageRevision ?? 0,
2294
+ systemPromptRef: systemPrompt,
2295
+ systemPromptLength: systemPrompt?.length ?? 0,
2296
+ systemPromptContentSize,
2297
+ toolsRef: tools,
2298
+ toolsLength: tools?.length ?? 0,
2299
+ toolSchemaMetadataRevision: tools ? getToolSchemaMetadataRevision(tools) : 0,
2300
+ tokenizerRef: this.session.agent?.tokenizer,
2301
+ skillsRef: this.session.skills,
2302
+ skillsLength: this.session.skills?.length ?? 0,
2303
+ sessionName: this.session.sessionManager?.getSessionName?.(),
2304
+ sessionId: this.session.sessionManager?.getSessionId?.(),
2305
+ isStreaming: this.session.isStreaming,
2306
+ isAutoThinking: this.session.isAutoThinking,
2307
+ isFastModeActive:
2308
+ typeof this.session.isFastModeActive === "function" ? this.session.isFastModeActive() : false,
2309
+ compactionSpeculation: this.session.compactionSpeculation,
2310
+ };
2311
+ }
2312
+
2313
+ #sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
2314
+ return (
2315
+ left.themeRef === right.themeRef &&
2316
+ left.themeEpoch === right.themeEpoch &&
2317
+ left.projectDir === right.projectDir &&
2318
+ left.sessionRef === right.sessionRef &&
2319
+ left.sessionFile === right.sessionFile &&
2320
+ left.sessionSettingsRef === right.sessionSettingsRef &&
2321
+ left.sessionSettingsRevision === right.sessionSettingsRevision &&
2322
+ left.globalSettingsRevision === right.globalSettingsRevision &&
2323
+ left.stateRef === right.stateRef &&
2324
+ left.stateMessagesRef === right.stateMessagesRef &&
2325
+ left.stateMessagesLength === right.stateMessagesLength &&
2326
+ left.stateLastMessageRef === right.stateLastMessageRef &&
2327
+ left.stateLastMessageContentRef === right.stateLastMessageContentRef &&
2328
+ left.messagesRef === right.messagesRef &&
2329
+ left.messagesLength === right.messagesLength &&
2330
+ left.lastMessageRef === right.lastMessageRef &&
2331
+ left.lastMessageRole === right.lastMessageRole &&
2332
+ left.lastMessageTimestamp === right.lastMessageTimestamp &&
2333
+ left.lastMessageContentSize === right.lastMessageContentSize &&
2334
+ left.lastMessageBlockCount === right.lastMessageBlockCount &&
2335
+ left.lastMessageUsageTotal === right.lastMessageUsageTotal &&
2336
+ left.lastMessageStopReason === right.lastMessageStopReason &&
2337
+ left.modelRef === right.modelRef &&
2338
+ left.sessionModelRef === right.sessionModelRef &&
2339
+ left.sessionModelId === right.sessionModelId &&
2340
+ left.sessionModelContextWindow === right.sessionModelContextWindow &&
2341
+ left.modelId === right.modelId &&
2342
+ left.modelName === right.modelName &&
2343
+ left.modelProvider === right.modelProvider &&
2344
+ left.modelContextWindow === right.modelContextWindow &&
2345
+ left.modelThinking === right.modelThinking &&
2346
+ left.thinkingLevel === right.thinkingLevel &&
2347
+ left.modelCostRef === right.modelCostRef &&
2348
+ left.contextUsageRevision === right.contextUsageRevision &&
2349
+ left.systemPromptRef === right.systemPromptRef &&
2350
+ left.systemPromptLength === right.systemPromptLength &&
2351
+ left.systemPromptContentSize === right.systemPromptContentSize &&
2352
+ left.toolsRef === right.toolsRef &&
2353
+ left.toolsLength === right.toolsLength &&
2354
+ left.toolSchemaMetadataRevision === right.toolSchemaMetadataRevision &&
2355
+ left.tokenizerRef === right.tokenizerRef &&
2356
+ left.skillsRef === right.skillsRef &&
2357
+ left.skillsLength === right.skillsLength &&
2358
+ left.sessionName === right.sessionName &&
2359
+ left.sessionId === right.sessionId &&
2360
+ left.isStreaming === right.isStreaming &&
2361
+ left.isAutoThinking === right.isAutoThinking &&
2362
+ left.isFastModeActive === right.isFastModeActive &&
2363
+ left.compactionSpeculation === right.compactionSpeculation
2364
+ );
2365
+ }
2366
+
2367
+ /**
2368
+ * Smallest wall-clock unit that can change a visible configured segment.
2369
+ * A zero tick keeps truly static bars cached indefinitely; active animation,
2370
+ * clocks, countdowns, and VCS fallback polling advance only at their own
2371
+ * display/probe cadence.
2372
+ */
2373
+ #statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings, placeholders: boolean): number {
2374
+ if (placeholders) return 0;
2375
+ const leftSegments = effectiveSettings.leftSegments;
2376
+ const rightSegments = effectiveSettings.rightSegments;
2377
+ const meter = this.#meter();
2378
+ if (meter.activeStartedAt !== null || this.#brandFade !== null) {
2379
+ return Math.floor(nowMs / SPINNER_ADVANCE_MS);
2380
+ }
2381
+ const includesTime = leftSegments.includes("time") || rightSegments.includes("time");
2382
+ if (
2383
+ leftSegments.includes("time_spent") ||
2384
+ rightSegments.includes("time_spent") ||
2385
+ (this.#loopModeStatus?.limit?.kind === "duration" &&
2386
+ (leftSegments.includes("mode") || rightSegments.includes("mode"))) ||
2387
+ (includesTime && effectiveSettings.segmentOptions?.time?.showSeconds === true)
2388
+ ) {
2389
+ return Math.floor(nowMs / 1_000);
2390
+ }
2391
+ if (includesTime) {
2392
+ return Math.floor(nowMs / 60_000);
2393
+ }
2394
+ if (this.#gitEnabled() && (hasGitBackedSegment(leftSegments) || hasGitBackedSegment(rightSegments))) {
2395
+ return Math.floor(nowMs / 1_000);
2396
+ }
2397
+ if (this.#gitEnabled() && (hasPathSegment(leftSegments) || hasPathSegment(rightSegments))) {
2398
+ return Math.floor(nowMs / WATCHER_FAILURE_POLL_TTL_MS);
2399
+ }
2400
+ return 0;
2401
+ }
2402
+
2403
+ #buildStatusLine(
2404
+ width: number,
2405
+ layout: StatusLineLayout = "box",
2406
+ previewTitle?: string,
2407
+ options?: { readonly placeholders?: boolean },
2408
+ ): CachedStatusLine {
2409
+ const effectiveSettings = this.#resolveSettings();
2410
+ const placeholders = options?.placeholders === true;
2411
+ const externalInputs = this.#readStatusLineExternalInputs();
2412
+ const nowMs = Date.now();
2413
+ const clockTick = this.#statusLineClock(nowMs, effectiveSettings, placeholders);
2414
+ if (clockTick !== this.#statusLineClockTick) {
2415
+ this.#statusLineClockTick = clockTick;
2416
+ this.#invalidateStatusLineRenderCache();
2417
+ }
2418
+
2419
+ const cached = this.#statusLineRenderCache[layout];
2420
+ if (
2421
+ cached &&
2422
+ cached.availableWidth === width &&
2423
+ cached.renderRevision === this.#renderRevision &&
2424
+ cached.inputRevision === this.#statusLineInputRevision &&
2425
+ cached.placeholders === placeholders &&
2426
+ cached.previewTitle === previewTitle &&
2427
+ this.#sameStatusLineExternalInputs(cached.externalInputs, externalInputs)
2428
+ ) {
2429
+ return cached;
2430
+ }
2431
+
2432
+ const content = this.#renderStatusLine(width, layout, previewTitle, options, nowMs);
2433
+ const result = {
2434
+ content,
2435
+ dimmedContent: this.#dimWhileFocusProxied(content),
2436
+ width: visibleWidth(content),
2437
+ availableWidth: width,
2438
+ renderRevision: this.#renderRevision,
2439
+ inputRevision: this.#statusLineInputRevision,
2440
+ placeholders,
2441
+ previewTitle,
2442
+ externalInputs,
2443
+ };
2444
+ this.#statusLineRenderCache[layout] = result;
2445
+ return result;
2446
+ }
2447
+
2448
+ /**
2449
+ * Build the status bar for one of five layouts:
2450
+ * - `box`: powerline groups joined by the context-reactive gauge line
2451
+ * (embedded in the editor's top border).
2452
+ * - `band`: the box layout opened by a soft flush-left cap, for the band
2453
+ * composer's frameless full-width top row.
2454
+ * - `plain-full`: no background, no powerline caps, dot separators, gap is
2455
+ * plain spaces — the standalone bottom bar for pi/borderless composers.
2456
+ * - `plain-left`: left segments only (claude composer; the right group
2457
+ * lives in the editor's top rule).
2458
+ * - `plain-right`: right segments only (claude composer's top rule).
2459
+ *
2460
+ * `previewTitle` is a stand-in session title for composer previews; the
2461
+ * `session_name` segment renders it when the session is unnamed.
2462
+ */
2463
+ #renderStatusLine(
2464
+ width: number,
2465
+ layout: StatusLineLayout,
2466
+ previewTitle: string | undefined,
2467
+ options: { readonly placeholders?: boolean } | undefined,
2468
+ nowMs: number,
2469
+ ): string {
2470
+ const effectiveSettings = this.#resolveSettings();
2471
+ this.#syncPricingTimer();
2472
+ const placeholders = options?.placeholders === true;
2473
+ const plain = layout !== "box" && layout !== "band";
2474
+ const includePath =
2475
+ hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
2476
+ const gitEnabled = this.#gitEnabled();
2477
+ const includeGit =
2478
+ gitEnabled &&
2479
+ (hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
2480
+ const includePr =
2481
+ gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
2482
+ const liveCtx = this.#buildSegmentContext(
2483
+ width,
2484
+ effectiveSettings.segmentOptions,
2485
+ includePath,
2486
+ includeGit,
2487
+ includePr,
2488
+ nowMs,
2489
+ previewTitle,
2490
+ );
2491
+ const ctx: SegmentContext = placeholders ? { ...liveCtx, startupPlaceholder: true } : liveCtx;
2492
+ const separatorDef = plain
2493
+ ? { left: "·", right: "·" }
2494
+ : getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
2495
+
2496
+ // `transparent` reuses the empty-string sentinel (`\x1b[49m`) so the bar
2497
+ // inherits the terminal's default background, matching custom themes that
2498
+ // set `statusLineBg: ""`. Powerline end caps need a contrasting fill to
2499
+ // bridge the bar into the surrounding terminal; without one they read as
2500
+ // stray glyphs, so the cap renderer drops them when the fill is empty.
2501
+ const TRANSPARENT_BG_ANSI = "\x1b[49m";
2502
+ const themeBgAnsi = theme.getBgAnsi("statusLineBg");
2503
+ // Plain bottom bars drop the background entirely; the claude top-rule
2504
+ // chip (`plain-right`) keeps it so the group reads as a chip on the rule.
2505
+ const transparentLayout = layout === "plain-full" || layout === "plain-left";
2506
+ const bgAnsi = transparentLayout || effectiveSettings.transparent ? TRANSPARENT_BG_ANSI : themeBgAnsi;
2507
+ const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
2508
+ const fgAnsi = theme.getFgAnsi("text");
2509
+ const sepAnsi = theme.getFgAnsi("statusLineSep");
2510
+ const subagentBadge = this.#subagentBadgeText();
2511
+
2512
+ // Collect visible segment contents
2513
+ const leftParts: string[] = [];
2514
+ const leftSegIds: StatusLineSegmentId[] = [];
2515
+ const leftSegmentIds = layout === "plain-right" ? [] : effectiveSettings.leftSegments;
2516
+ for (const segId of leftSegmentIds) {
2517
+ if (subagentBadge && segId === "subagents") continue;
2518
+ // The band composer relocates the title to the working row's trailer.
2519
+ if (layout === "band" && segId === "session_name") continue;
2520
+ const rendered = renderSegment(segId, ctx);
2521
+ if (rendered.visible && rendered.content) {
2522
+ leftParts.push(rendered.content);
2523
+ leftSegIds.push(segId);
2524
+ }
2525
+ }
2526
+
2527
+ const rightParts: string[] = [];
2528
+ const rightSegIds: StatusLineSegmentId[] = [];
2529
+ const rightSegmentIds = layout === "plain-left" ? [] : effectiveSettings.rightSegments;
2530
+ for (const segId of rightSegmentIds) {
2531
+ if (subagentBadge && segId === "subagents") continue;
2532
+ if (layout === "band" && segId === "session_name") continue;
2533
+ const rendered = renderSegment(segId, ctx);
2534
+ if (rendered.visible && rendered.content) {
2535
+ rightParts.push(rendered.content);
2536
+ rightSegIds.push(segId);
2537
+ }
2538
+ }
2539
+
2540
+ const embedContext =
2541
+ !plain &&
2542
+ effectiveSettings.contextLine === "embedded" &&
2543
+ ctx.contextPercent !== null &&
2544
+ ctx.contextPercent !== undefined &&
2545
+ ctx.contextWindow > 0 &&
2546
+ (hasContextSegment(leftSegIds) || hasContextSegment(rightSegIds)) &&
2547
+ (hasNonContextSegment(leftSegIds) || hasNonContextSegment(rightSegIds));
2548
+ if (embedContext) {
2549
+ removeContextSegments(leftParts, leftSegIds);
2550
+ removeContextSegments(rightParts, rightSegIds);
2551
+ }
2552
+
2553
+ if (layout !== "plain-left") {
2554
+ // Count task jobs only until their AgentRegistry ref appears. Once it is
2555
+ // running, the subagent badge represents that same agent; bash and eval
2556
+ // jobs always remain independent background work.
2557
+ const runningBackgroundJobs =
2558
+ this.session
2559
+ .getAsyncJobSnapshot()
2560
+ ?.running.filter(
2561
+ job => job.type !== "task" || job.agentId === undefined || !this.#runningSubagentIds.has(job.agentId),
2562
+ ).length ?? 0;
2563
+ if (runningBackgroundJobs > 0) {
2564
+ const count = placeholders ? "…" : `${runningBackgroundJobs}`;
2565
+ rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${count}`));
2566
+ }
2567
+ if (subagentBadge) {
2568
+ const content = placeholders ? [theme.icon.agents, "…"].filter(Boolean).join(" ") : subagentBadge;
2569
+ rightParts.unshift(placeholders ? theme.fg("statusLineSubagents", content) : content);
2570
+ }
2571
+ }
2572
+ const topFillWidth = Math.max(0, width);
2573
+ const left = [...leftParts];
2574
+ const right = [...rightParts];
2575
+
2576
+ const leftSepWidth = visibleWidth(separatorDef.left);
2577
+ const rightSepWidth = visibleWidth(separatorDef.right);
2578
+ // Transparent mode drops powerline caps (they need a bg fill to bridge),
2579
+ // so the width budget excludes them too.
2580
+ const leftCapWidth = separatorDef.endCaps && !transparentBg ? visibleWidth(separatorDef.endCaps.right) : 0;
2581
+ const rightCapWidth = separatorDef.endCaps && !transparentBg ? visibleWidth(separatorDef.endCaps.left) : 0;
2582
+ // The band layout opens flush against the terminal edge with a soft cap
2583
+ // (rust omp's status band). Like the other caps it needs an opaque
2584
+ // background to bridge, and only powerline separator styles carry caps.
2585
+ const bandCap = layout === "band" && separatorDef.endCaps && !transparentBg ? theme.sep.powerlineCapLeft : "";
2586
+ const bandCapWidth = visibleWidth(bandCap);
2587
+
2588
+ const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
2589
+ if (parts.length === 0) return 0;
2590
+ const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
2591
+ const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
2592
+ return partsWidth + sepTotal + 2 + capWidth;
2593
+ };
2594
+
2595
+ let leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2596
+ let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2597
+ // Embedded mode removes the standalone context segment before overflow
2598
+ // handling, so the gauge must reserve enough room for both labels. Without
2599
+ // this budget a long path/session title can leave a one-cell gap: the
2600
+ // context segment is gone, and the gauge silently omits its labels too.
2601
+ const embeddedContextWidth = embedContext
2602
+ ? ctx.startupPlaceholder
2603
+ ? "…%".length + "…".length + 4
2604
+ : embeddedContextGaugeMinWidth(ctx.contextPercent ?? 0, ctx.contextWindow)
2605
+ : 0;
2606
+ const minimumGapWidth = (): number => {
2607
+ if (!embeddedContextWidth) return left.length > 0 && right.length > 0 ? 1 : 0;
2608
+ // If the labels cannot coexist with the last surviving segment, fall
2609
+ // back to the original one-cell gauge instead of dropping the entire
2610
+ // status line. At this width the labels cannot render either way.
2611
+ if (left.length + right.length === 1 && leftWidth + rightWidth + embeddedContextWidth > topFillWidth) {
2612
+ return 1;
2613
+ }
2614
+ return embeddedContextWidth;
2615
+ };
2616
+ const totalWidth = () => leftWidth + rightWidth + minimumGapWidth();
2617
+
2618
+ if (topFillWidth > 0) {
2619
+ // Truncate the session-name segment before dropping right segments —
2620
+ // the title is the only elastic one on the right, and dropping it
2621
+ // wholesale left narrow bars (and the ≤76-col composer previews)
2622
+ // without any title.
2623
+ const nameSegIdx = rightSegIds.indexOf("session_name");
2624
+ if (nameSegIdx >= 0 && totalWidth() > topFillWidth) {
2625
+ // Badge/job parts were unshifted ahead of the tracked segment ids.
2626
+ const nameIdx = nameSegIdx + (right.length - rightSegIds.length);
2627
+ const currentNameVW = visibleWidth(right[nameIdx]);
2628
+ const minNameVW = 8;
2629
+ const shrinkBy = Math.min(Math.max(0, currentNameVW - minNameVW), totalWidth() - topFillWidth);
2630
+ if (shrinkBy > 0) {
2631
+ right[nameIdx] = truncateToWidth(right[nameIdx], currentNameVW - shrinkBy);
2632
+ rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2633
+ }
2634
+ }
2635
+ while (totalWidth() > topFillWidth && right.length > 0) {
2636
+ right.pop();
2637
+ rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
2638
+ }
2639
+ // Shrink path before dropping left segments — path is the only elastic segment
2640
+ const pathIdx = leftSegIds.indexOf("path");
2641
+ if (pathIdx >= 0 && totalWidth() > topFillWidth) {
2642
+ const overflow = totalWidth() - topFillWidth;
2643
+ const currentPathVW = visibleWidth(left[pathIdx]);
2644
+ const minPathVW = 8; // icon + ellipsis + a few chars
2645
+ const shrinkable = currentPathVW - minPathVW;
2646
+ if (shrinkable > 0) {
2647
+ const shrinkBy = Math.min(shrinkable, overflow);
2648
+ const currentMaxLen = ctx.options.path?.maxLength ?? 40;
2649
+ let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
2650
+ const pathCtx = (maxLen: number): SegmentContext => ({
2651
+ ...ctx,
2652
+ options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
2653
+ });
2654
+ let reRendered = renderSegment("path", pathCtx(newMaxLen));
2655
+ if (reRendered.visible && reRendered.content) {
2656
+ // maxLength governs path text, not icon prefix; iterate to compensate
2657
+ for (let i = 0; i < 8; i++) {
2658
+ const saved = currentPathVW - visibleWidth(reRendered.content);
2659
+ if (saved >= shrinkBy) break;
2660
+ const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
2661
+ if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
2662
+ newMaxLen = nextMaxLen;
2663
+ const adjusted = renderSegment("path", pathCtx(newMaxLen));
2664
+ if (!adjusted.visible || !adjusted.content) break;
2665
+ reRendered = adjusted;
2666
+ }
2667
+ left[pathIdx] = reRendered.content;
2668
+ leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2669
+ }
2670
+ }
2671
+ }
2672
+ const leftOverflowDropIndex = (): number => {
2673
+ // Preserve the current working directory as long as possible. The
2674
+ // previous right-to-left pop could collapse a normal-width bar to
2675
+ // just the model segment, hiding the path before less-critical left
2676
+ // segments such as model/mode/collab were removed.
2677
+ for (let i = leftSegIds.length - 1; i >= 0; i--) {
2678
+ if (leftSegIds[i] !== "path") return i;
2679
+ }
2680
+ return left.length - 1;
2681
+ };
2682
+
2683
+ while (totalWidth() > topFillWidth && left.length > 0) {
2684
+ const dropIdx = leftOverflowDropIndex();
2685
+ left.splice(dropIdx, 1);
2686
+ leftSegIds.splice(dropIdx, 1);
2687
+ leftWidth = groupWidth(left, leftCapWidth + bandCapWidth, leftSepWidth);
2688
+ }
2689
+ }
2690
+
2691
+ const renderGroup = (parts: string[], direction: "left" | "right"): string => {
2692
+ if (parts.length === 0) return "";
2693
+ const sep = direction === "left" ? separatorDef.left : separatorDef.right;
2694
+ const cap =
2695
+ separatorDef.endCaps && !transparentBg
2696
+ ? direction === "left"
2697
+ ? separatorDef.endCaps.right
2698
+ : separatorDef.endCaps.left
2699
+ : "";
2700
+ const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
2701
+ const capText = cap ? `${capPrefix}${this.#focusedAgentId ? "\x1b[22m" : ""}${cap}\x1b[0m` : "";
2702
+ const openText =
2703
+ direction === "left" && bandCap
2704
+ ? `${capPrefix}${this.#focusedAgentId ? "\x1b[22m" : ""}${bandCap}\x1b[0m`
2705
+ : "";
2706
+
2707
+ let content = bgAnsi + fgAnsi;
2708
+ content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
2709
+ content += "\x1b[0m";
2710
+
2711
+ if (direction === "right") return capText + content;
2712
+ return openText + content + capText;
2713
+ };
2714
+
2715
+ const leftGroup = renderGroup(left, "left");
2716
+ const rightGroup = renderGroup(right, "right");
2717
+ if (!leftGroup && !rightGroup) return "";
2718
+
2719
+ if (topFillWidth === 0 || (plain && (left.length === 0 || right.length === 0))) {
2720
+ return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
2721
+ }
2722
+
2723
+ const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
2724
+ if (plain) {
2725
+ // Standalone composers: no gauge line between the groups, just air.
2726
+ return leftGroup + padding(gapWidth) + rightGroup;
2727
+ }
2728
+ // Box layout: with one group absent (an unnamed session hides
2729
+ // `session_name`, emptying the default preset's right group) the gauge
2730
+ // runs to the border edge instead of disappearing, so embedded context
2731
+ // labels don't fall back to a context chip until the session is titled.
2732
+ return leftGroup + this.#buildContextGaugeFill(gapWidth, ctx, effectiveSettings, embedContext) + rightGroup;
2733
+ }
2734
+
2735
+ /**
2736
+ * The gauge line bridging the left and right groups in box layout. Driven
2737
+ * by `statusLine.contextLine`:
2738
+ * - `off`: solid accent line (no context feedback).
2739
+ * - `percentage`: used portion in accent, remainder in the border color.
2740
+ * - `annotated` : percentage plus preset-aware markers where
2741
+ * speculative compaction starts and where auto-compaction fires.
2742
+ * - `embedded` (default): annotated markers plus percentage/window labels absorbed
2743
+ * from configured context segments.
2744
+ */
2745
+ #buildContextGaugeFill(
2746
+ gapWidth: number,
2747
+ ctx: SegmentContext,
2748
+ effectiveSettings: EffectiveStatusLineSettings,
2749
+ embedContext: boolean,
2750
+ ): string {
2751
+ const sessionName =
2752
+ effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
2753
+ const accentHex = sessionName ? getSessionAccentHex(sessionName, theme.sessionAccentInputs) : undefined;
2754
+ const usedColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("borderAccent");
2755
+ const horizontal = theme.boxRound.horizontal;
2756
+ const mode = effectiveSettings.contextLine ?? "embedded";
2757
+ const pct = ctx.contextPercent;
2758
+ if (mode === "off" || pct === null || pct === undefined) {
2759
+ return `\x1b[49m${usedColor}${horizontal.repeat(gapWidth)}\x1b[39m`;
2760
+ }
2761
+
2762
+ const clampedPct = Math.min(100, Math.max(0, pct));
2763
+ let percentLabel = "";
2764
+ let windowLabel = "";
2765
+ let percentStart = -1;
2766
+ let windowStart = -1;
2767
+ let scaleWidth = gapWidth;
2768
+ // >100%: usage anchored past the active window (e.g. model switch to a
2769
+ // smaller window). The bar clamps full, but the embedded label breaks
2770
+ // past the window label — `──200K─120%` with the percent in error color.
2771
+ const percentOverflow = pct > 100;
2772
+ if (embedContext) {
2773
+ const candidatePercent = ctx.startupPlaceholder
2774
+ ? "…%"
2775
+ : formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
2776
+ const candidateWindow = ctx.startupPlaceholder ? "…" : formatNumber(ctx.contextWindow);
2777
+ const minimumLabelWidth = candidatePercent.length + candidateWindow.length + 4;
2778
+ if (gapWidth >= minimumLabelWidth) {
2779
+ percentLabel = candidatePercent;
2780
+ windowLabel = candidateWindow;
2781
+ if (percentOverflow) {
2782
+ percentStart = gapWidth - percentLabel.length;
2783
+ windowStart = percentStart - 1 - windowLabel.length;
2784
+ } else {
2785
+ windowStart = gapWidth - windowLabel.length - 1;
2786
+ }
2787
+ scaleWidth = windowStart;
2788
+ }
2789
+ }
2790
+
2791
+ // At least one accent cell: a fresh session still shows the session-accent
2792
+ // line starting at the left instead of a fully dim bar.
2793
+ const usedCount = Math.min(scaleWidth, Math.max(1, Math.round((clampedPct / 100) * scaleWidth)));
2794
+ const unusedColor = theme.getFgAnsi("border");
2795
+
2796
+ // Boundary markers are only meaningful when auto-compaction can fire and
2797
+ // the line is long enough for the markers to read as positions.
2798
+ let speculationIdx = -1;
2799
+ let thresholdIdx = -1;
2800
+ if ((mode === "annotated" || mode === "embedded") && ctx.autoCompactEnabled && gapWidth >= 8) {
2801
+ const boundaries = this.#compactionBoundaries(ctx.contextWindow);
2802
+ if (boundaries) {
2803
+ const cellFor = (percent: number) =>
2804
+ Math.min(scaleWidth - 1, Math.max(0, Math.round((percent / 100) * scaleWidth)));
2805
+ thresholdIdx = cellFor(boundaries.thresholdPercent);
2806
+ // null = no background speculation will run (async disabled or the
2807
+ // first available method is local/instant) — no tick to show.
2808
+ if (boundaries.speculationPercent !== null) speculationIdx = cellFor(boundaries.speculationPercent);
2809
+ if (speculationIdx === thresholdIdx) speculationIdx = -1; // threshold wins the cell
2810
+ }
2811
+ }
2812
+
2813
+ if (percentLabel && percentStart < 0) {
2814
+ const maxStart = scaleWidth - percentLabel.length - 1;
2815
+ const preferredStart = Math.min(maxStart, Math.max(1, usedCount));
2816
+ const overlapsBoundary = (start: number): boolean => {
2817
+ const end = start + percentLabel.length;
2818
+ return (speculationIdx >= start && speculationIdx < end) || (thresholdIdx >= start && thresholdIdx < end);
2819
+ };
2820
+ for (let distance = 0; distance <= maxStart; distance++) {
2821
+ const left = preferredStart - distance;
2822
+ if (left >= 1 && !overlapsBoundary(left)) {
2823
+ percentStart = left;
2824
+ break;
2825
+ }
2826
+ if (distance === 0) continue;
2827
+ const right = preferredStart + distance;
2828
+ if (right <= maxStart && !overlapsBoundary(right)) {
2829
+ percentStart = right;
2830
+ break;
2831
+ }
2832
+ }
2833
+ }
2834
+
2835
+ const speculationGlyph = theme.symbol("context.speculation");
2836
+ const thresholdGlyph = theme.symbol("context.compaction");
2837
+ const speculationColor = theme.getFgAnsi("muted");
2838
+ const overflowColor = theme.getFgAnsi("error");
2839
+ const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent");
2840
+ const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 });
2841
+ const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor;
2842
+
2843
+ let out = "\x1b[49m";
2844
+ let activeColor = "";
2845
+ for (let i = 0; i < gapWidth; i++) {
2846
+ let color = i < usedCount ? usedColor : unusedColor;
2847
+ let glyph = horizontal;
2848
+ if (percentStart >= 0 && i >= percentStart && i < percentStart + percentLabel.length) {
2849
+ color = percentOverflow ? overflowColor : usedColor;
2850
+ glyph = percentLabel.charAt(i - percentStart);
2851
+ } else if (i === thresholdIdx) {
2852
+ color = thresholdColor;
2853
+ glyph = thresholdGlyph;
2854
+ } else if (i === speculationIdx) {
2855
+ color = speculationColor;
2856
+ glyph = speculationGlyph;
2857
+ } else if (windowStart >= 0 && i >= windowStart && i < windowStart + windowLabel.length) {
2858
+ color = thresholdColor;
2859
+ glyph = windowLabel.charAt(i - windowStart);
2860
+ }
2861
+ if (color !== activeColor) {
2862
+ out += color;
2863
+ activeColor = color;
2864
+ }
2865
+ out += glyph;
2866
+ }
2867
+ return `${out}\x1b[39m`;
2868
+ }
2869
+
2870
+ /** Auto-compaction boundary percents, or null when unavailable (disabled, no window). */
2871
+ #compactionBoundaries(contextWindow: number): CompactionBoundaries | null {
2872
+ const model = this.session.state?.model ?? this.session.model;
2873
+ try {
2874
+ return this.host.computeCompactionBoundaries(this.session, contextWindow, model);
2875
+ } catch {
2876
+ return null;
2877
+ }
2878
+ }
2879
+
2880
+ /** Render startup ellipses inside each segment's normal icon, color, and static chrome. */
2881
+ renderStartupPlaceholder(width: number, layout: StatusLineLayout): string {
2882
+ return this.#buildStatusLine(width, layout, undefined, { placeholders: true }).content;
2883
+ }
2884
+
2885
+ getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
2886
+ const statusLine = this.#buildStatusLine(width, "box", previewTitle);
2887
+ return {
2888
+ content: statusLine.dimmedContent,
2889
+ width: statusLine.width,
2890
+ revision: this.#renderRevision,
2891
+ };
2892
+ }
2893
+
2894
+ /** Flush-left soft-capped powerline band (the band composer's top row). */
2895
+ getBandTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
2896
+ const statusLine = this.#buildStatusLine(width, "band", previewTitle);
2897
+ return {
2898
+ content: statusLine.dimmedContent,
2899
+ width: statusLine.width,
2900
+ revision: this.#renderRevision,
2901
+ };
2902
+ }
2903
+
2904
+ /** Dim the whole bar while focus-proxied. Group/cap terminators emit full
2905
+ * `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each. */
2906
+ #dimWhileFocusProxied(content: string): string {
2907
+ if (!this.#focusedAgentId || !content) return content;
2908
+ return `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
2909
+ }
2910
+ /**
2911
+ * Standalone bar placement derived from the composer style. `bottomBar`
2912
+ * `"full"` renders both groups on the bottom bar (pi/borderless/field/rail);
2913
+ * `"left"` renders just the left group there — the right group attaches to
2914
+ * the editor's top rule via {@link getStandaloneTopBorder} (claude/rule);
2915
+ * `"none"` returns the bar to the box composer's embedded top border.
2916
+ * `bottomBarGap` inserts a blank spacer row above the bar for styles whose
2917
+ * editor has no bottom chrome.
2918
+ */
2919
+ setComposerStyle(style: Pick<ComposerStyle, "statusAttachment" | "bottomBar" | "bottomBarGap">): void {
2920
+ this.#standalone = style.bottomBar === "none" ? false : style.bottomBar === "left" ? "left-only" : "full";
2921
+ this.#topAttachment = style.statusAttachment;
2922
+ this.#standaloneGap = style.bottomBarGap;
2923
+ this.#syncPricingTimer();
2924
+ }
2925
+
2926
+ /** While true, the standalone bar yields its row to the editor's autocomplete menu. */
2927
+ setAutocompleteActiveProbe(probe: (() => boolean) | undefined): void {
2928
+ this.#autocompleteActiveProbe = probe;
2929
+ }
2930
+
2931
+ /** Plain right-group content for the claude composer's top rule. */
2932
+ getStandaloneTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
2933
+ const statusLine = this.#buildStatusLine(width, "plain-right", previewTitle);
2934
+ return {
2935
+ content: statusLine.dimmedContent,
2936
+ width: statusLine.width,
2937
+ revision: this.#renderRevision,
2938
+ };
2939
+ }
2940
+
2941
+ /**
2942
+ * The plain standalone bottom bar through the real segment/gauge pipeline —
2943
+ * `groups` picks which segment groups it carries. Used by the live render
2944
+ * loop and by composer previews (which inject a candidate layout instead of
2945
+ * the active one).
2946
+ */
2947
+ renderBottomBar(width: number, groups: "left" | "full", previewTitle?: string): string {
2948
+ return this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full", previewTitle).dimmedContent;
2949
+ }
2950
+ /**
2951
+ * Status bar lines for a composer layout, rendered through the real
2952
+ * pipeline — the single source for the /settings appearance preview.
2953
+ * `style` overrides the layout (candidate composer shape); omitted, the
2954
+ * active layout is used. Ignores the autocomplete probe: previews always
2955
+ * render.
2956
+ */
2957
+ getPreviewLines(width: number, style?: Pick<ComposerStyle, "statusAttachment" | "bottomBar">): string[] {
2958
+ const attachment = style?.statusAttachment ?? this.#topAttachment;
2959
+ const bottomBar =
2960
+ style?.bottomBar ?? (this.#standalone === false ? "none" : this.#standalone === "left-only" ? "left" : "full");
2961
+ const lines: string[] = [];
2962
+ if (attachment === "top-border") {
2963
+ const border = this.getTopBorder(width);
2964
+ if (border.content) lines.push(border.content);
2965
+ } else if (attachment === "top-band") {
2966
+ const band = this.getBandTopBorder(width);
2967
+ if (band.content) lines.push(band.content);
2968
+ } else if (attachment === "top-rule-chip") {
2969
+ // Render the chip on its rule exactly as the claude composer does.
2970
+ const rule = claudeComposerStyle.renderTop({
2971
+ width,
2972
+ paddingX: 0,
2973
+ borderColor: str => theme.fg("border", str),
2974
+ accentColor: str => theme.fg("accent", str),
2975
+ surfaceColor: str => theme.bgFill("userMessageBg", theme.fgOnBg("userMessageText", "userMessageBg", str)),
2976
+ box: theme.boxRound,
2977
+ topBorder: this.getStandaloneTopBorder(width),
2978
+ });
2979
+ if (rule !== undefined) lines.push(rule);
2980
+ }
2981
+ if (bottomBar !== "none") {
2982
+ const main = this.renderBottomBar(width, bottomBar);
2983
+ if (main) lines.push(main);
2984
+ }
2985
+ return lines;
2986
+ }
2987
+
2988
+ render(width: number): readonly string[] {
2989
+ const lines: string[] = [];
2990
+ if (this.#standalone && !this.#autocompleteActiveProbe?.()) {
2991
+ const content = this.renderBottomBar(width, this.#standalone === "left-only" ? "left" : "full");
2992
+ if (content) {
2993
+ if (this.#standaloneGap) lines.push("");
2994
+ lines.push(content);
2995
+ }
2996
+ }
2997
+ const showHooks = this.#settings.showHookStatus ?? true;
2998
+ if (showHooks && this.#sortedHookStatuses.length > 0) {
2999
+ lines.push(...this.#sortedHookStatuses.map(text => truncateToWidth(sanitizeStatusText(text), width)));
3000
+ }
3001
+ return lines;
3002
+ }
3003
+ }