@visual-z/xeno 0.8.4 → 0.9.0

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 (350) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +31 -746
  3. package/bin/xeno.exe +26 -0
  4. package/install.cjs +88 -0
  5. package/package.json +18 -48
  6. package/docs/app-server.md +0 -115
  7. package/docs/foundations.md +0 -88
  8. package/docs/install.md +0 -244
  9. package/docs/providers.md +0 -172
  10. package/docs/research/foundation-clients-2026-10-09.md +0 -27
  11. package/docs/research/foundation-editing-2026-10-09.md +0 -56
  12. package/docs/research/foundation-network-2026-10-09.md +0 -37
  13. package/docs/research/foundation-workflow-2026-10-09.md +0 -37
  14. package/src/apps/acp-server.ts +0 -41
  15. package/src/apps/agent-profiles.ts +0 -19
  16. package/src/apps/app-server.ts +0 -30
  17. package/src/apps/approve.ts +0 -49
  18. package/src/apps/cli.ts +0 -99
  19. package/src/apps/commands.ts +0 -319
  20. package/src/apps/completions.ts +0 -87
  21. package/src/apps/control-transport.ts +0 -90
  22. package/src/apps/export-cli.ts +0 -24
  23. package/src/apps/goal-flow.ts +0 -70
  24. package/src/apps/image-draft.ts +0 -46
  25. package/src/apps/input.ts +0 -312
  26. package/src/apps/model-catalog.ts +0 -69
  27. package/src/apps/preferences.ts +0 -20
  28. package/src/apps/project-trust.ts +0 -9
  29. package/src/apps/provider-connect.ts +0 -346
  30. package/src/apps/question-ui.ts +0 -27
  31. package/src/apps/render.ts +0 -181
  32. package/src/apps/resources.ts +0 -66
  33. package/src/apps/review.ts +0 -109
  34. package/src/apps/session-hub.ts +0 -96
  35. package/src/apps/session-lifecycle.ts +0 -64
  36. package/src/apps/shell.ts +0 -2071
  37. package/src/apps/startup-input.ts +0 -135
  38. package/src/apps/startup.ts +0 -1677
  39. package/src/apps/ui/app.ts +0 -1818
  40. package/src/apps/ui/control-model.ts +0 -43
  41. package/src/apps/ui/extension-host.ts +0 -194
  42. package/src/apps/ui/image-cache.ts +0 -121
  43. package/src/apps/ui/palette.ts +0 -88
  44. package/src/apps/ui/project.ts +0 -127
  45. package/src/apps/ui/standalone.ts +0 -148
  46. package/src/apps/ui/status-model.ts +0 -68
  47. package/src/apps/ui/surface.ts +0 -91
  48. package/src/apps/ui/theme.ts +0 -86
  49. package/src/apps/update-check.ts +0 -112
  50. package/src/apps/workflow.ts +0 -58
  51. package/src/core/acceptance.ts +0 -139
  52. package/src/core/action-defaults.ts +0 -58
  53. package/src/core/activity-status.ts +0 -52
  54. package/src/core/agent-profile.ts +0 -130
  55. package/src/core/attachment-input.ts +0 -17
  56. package/src/core/blind-checks.ts +0 -72
  57. package/src/core/boundary.ts +0 -174
  58. package/src/core/branch-receipt.ts +0 -26
  59. package/src/core/catalog-merge.ts +0 -19
  60. package/src/core/checkpoints.ts +0 -85
  61. package/src/core/churn.ts +0 -61
  62. package/src/core/command-artifacts.ts +0 -94
  63. package/src/core/command-contract.ts +0 -151
  64. package/src/core/completion-action.ts +0 -20
  65. package/src/core/completion-review.ts +0 -98
  66. package/src/core/context-detail.ts +0 -37
  67. package/src/core/context-policy.ts +0 -133
  68. package/src/core/context-window.ts +0 -54
  69. package/src/core/continuation.ts +0 -130
  70. package/src/core/control-protocol.ts +0 -69
  71. package/src/core/control-view.ts +0 -39
  72. package/src/core/cycle.ts +0 -17
  73. package/src/core/data-query.ts +0 -201
  74. package/src/core/deadline.ts +0 -40
  75. package/src/core/derive.ts +0 -516
  76. package/src/core/draft-images.ts +0 -56
  77. package/src/core/edit-plan.ts +0 -200
  78. package/src/core/environment.ts +0 -181
  79. package/src/core/event-validation.ts +0 -220
  80. package/src/core/events.ts +0 -670
  81. package/src/core/evidence-ladder.ts +0 -62
  82. package/src/core/evidence-links.ts +0 -68
  83. package/src/core/evidence.ts +0 -240
  84. package/src/core/execution.ts +0 -18
  85. package/src/core/exhaustive.ts +0 -18
  86. package/src/core/export.ts +0 -179
  87. package/src/core/extension-flags.ts +0 -14
  88. package/src/core/external-effects.ts +0 -488
  89. package/src/core/failure-recovery.ts +0 -173
  90. package/src/core/file-precondition.ts +0 -78
  91. package/src/core/file-references.ts +0 -35
  92. package/src/core/frontmatter.ts +0 -213
  93. package/src/core/git-status.ts +0 -29
  94. package/src/core/goal-view.ts +0 -64
  95. package/src/core/goal.ts +0 -200
  96. package/src/core/handoff.ts +0 -479
  97. package/src/core/history-query.ts +0 -139
  98. package/src/core/history.ts +0 -237
  99. package/src/core/image-limits.ts +0 -72
  100. package/src/core/images.ts +0 -46
  101. package/src/core/integration-health.ts +0 -34
  102. package/src/core/json-event.ts +0 -131
  103. package/src/core/json-pointer.ts +0 -46
  104. package/src/core/keymap.ts +0 -153
  105. package/src/core/lsp-framing.ts +0 -34
  106. package/src/core/mcp-config.ts +0 -68
  107. package/src/core/model-picker.ts +0 -64
  108. package/src/core/model-preferences.ts +0 -28
  109. package/src/core/model-replay.ts +0 -22
  110. package/src/core/model-scope.ts +0 -53
  111. package/src/core/note-guidance.ts +0 -34
  112. package/src/core/oauth-fields.ts +0 -44
  113. package/src/core/observation-view.ts +0 -152
  114. package/src/core/outcome.ts +0 -44
  115. package/src/core/overflow.ts +0 -59
  116. package/src/core/packages.ts +0 -47
  117. package/src/core/patch.ts +0 -130
  118. package/src/core/path-suggestions.ts +0 -59
  119. package/src/core/permissions.ts +0 -410
  120. package/src/core/process-receipt.ts +0 -105
  121. package/src/core/process-state.ts +0 -37
  122. package/src/core/program-status.ts +0 -40
  123. package/src/core/progress.ts +0 -150
  124. package/src/core/project.ts +0 -431
  125. package/src/core/prompt-policy.ts +0 -46
  126. package/src/core/prompts.ts +0 -111
  127. package/src/core/question.ts +0 -94
  128. package/src/core/queue.ts +0 -78
  129. package/src/core/read-bounds.ts +0 -24
  130. package/src/core/read-observation.ts +0 -106
  131. package/src/core/recovery-sources.ts +0 -74
  132. package/src/core/regex-dialect.ts +0 -25
  133. package/src/core/request-budget.ts +0 -23
  134. package/src/core/request-progress.ts +0 -87
  135. package/src/core/request-state.ts +0 -273
  136. package/src/core/resource-selection.ts +0 -36
  137. package/src/core/response-recovery.ts +0 -59
  138. package/src/core/resume.ts +0 -59
  139. package/src/core/review.ts +0 -68
  140. package/src/core/ripgrep-release.ts +0 -64
  141. package/src/core/runtime-target.ts +0 -94
  142. package/src/core/scheduling.ts +0 -8
  143. package/src/core/search-observation.ts +0 -97
  144. package/src/core/secrets.ts +0 -188
  145. package/src/core/session-archive.ts +0 -4
  146. package/src/core/session-browser.ts +0 -82
  147. package/src/core/session-entry.ts +0 -61
  148. package/src/core/session-exit.ts +0 -42
  149. package/src/core/session-format.ts +0 -169
  150. package/src/core/session-position.ts +0 -26
  151. package/src/core/session-tree.ts +0 -147
  152. package/src/core/settings.ts +0 -192
  153. package/src/core/skills.ts +0 -165
  154. package/src/core/sql-query.ts +0 -211
  155. package/src/core/stall.ts +0 -128
  156. package/src/core/stop-report.ts +0 -68
  157. package/src/core/text-normalize.ts +0 -81
  158. package/src/core/theme.ts +0 -166
  159. package/src/core/thinking.ts +0 -26
  160. package/src/core/tokens.ts +0 -151
  161. package/src/core/tool-contract.ts +0 -153
  162. package/src/core/tool-discovery.ts +0 -87
  163. package/src/core/tool-failure.ts +0 -81
  164. package/src/core/tool-input.ts +0 -44
  165. package/src/core/transcript.ts +0 -108
  166. package/src/core/tui-mode.ts +0 -29
  167. package/src/core/update-check.ts +0 -157
  168. package/src/core/version.ts +0 -55
  169. package/src/core/web-content.ts +0 -80
  170. package/src/core/work-state.ts +0 -154
  171. package/src/core/workflow-mode.ts +0 -34
  172. package/src/core/working-set.ts +0 -119
  173. package/src/core/yaml.ts +0 -282
  174. package/src/runtime/acp-agent.ts +0 -289
  175. package/src/runtime/agent-profiles.ts +0 -21
  176. package/src/runtime/auth.ts +0 -367
  177. package/src/runtime/blind-check.ts +0 -36
  178. package/src/runtime/branch-jobs.ts +0 -36
  179. package/src/runtime/catalog.ts +0 -285
  180. package/src/runtime/clipboard.ts +0 -125
  181. package/src/runtime/command-artifacts.ts +0 -64
  182. package/src/runtime/connection-reuse.ts +0 -40
  183. package/src/runtime/control-client.ts +0 -149
  184. package/src/runtime/control-connection.ts +0 -139
  185. package/src/runtime/counter-check.ts +0 -74
  186. package/src/runtime/environment-tools.ts +0 -60
  187. package/src/runtime/evidence-notes.ts +0 -35
  188. package/src/runtime/exec-sessions.ts +0 -456
  189. package/src/runtime/execution-control.ts +0 -40
  190. package/src/runtime/extension-loader.ts +0 -37
  191. package/src/runtime/extension-ui.ts +0 -47
  192. package/src/runtime/extensions.ts +0 -370
  193. package/src/runtime/formatter-tool.ts +0 -38
  194. package/src/runtime/formatter.ts +0 -50
  195. package/src/runtime/foundation.ts +0 -116
  196. package/src/runtime/history-cache.ts +0 -57
  197. package/src/runtime/history-tools.ts +0 -168
  198. package/src/runtime/images.ts +0 -80
  199. package/src/runtime/integrations.ts +0 -216
  200. package/src/runtime/loop.ts +0 -3108
  201. package/src/runtime/lsp-client.ts +0 -201
  202. package/src/runtime/mcp-client.ts +0 -264
  203. package/src/runtime/mcp-oauth.ts +0 -168
  204. package/src/runtime/mcp-schema.ts +0 -51
  205. package/src/runtime/model-config.ts +0 -232
  206. package/src/runtime/model-data.LICENSE +0 -27
  207. package/src/runtime/model-data.ts +0 -21
  208. package/src/runtime/model-discovery.ts +0 -208
  209. package/src/runtime/model-replay.ts +0 -10
  210. package/src/runtime/model-scope.ts +0 -20
  211. package/src/runtime/model-sources.ts +0 -151
  212. package/src/runtime/model.ts +0 -686
  213. package/src/runtime/models-fetch.ts +0 -57
  214. package/src/runtime/oauth-device.ts +0 -86
  215. package/src/runtime/oauth.ts +0 -853
  216. package/src/runtime/open-image.ts +0 -42
  217. package/src/runtime/openai-responses.ts +0 -304
  218. package/src/runtime/openai.ts +0 -697
  219. package/src/runtime/output-schema.ts +0 -26
  220. package/src/runtime/package-manager.ts +0 -125
  221. package/src/runtime/patch-tool.ts +0 -38
  222. package/src/runtime/permission-resources.ts +0 -18
  223. package/src/runtime/pi-provider.ts +0 -423
  224. package/src/runtime/program-status.ts +0 -38
  225. package/src/runtime/progress.ts +0 -26
  226. package/src/runtime/provider-access.ts +0 -21
  227. package/src/runtime/provider-connect.ts +0 -109
  228. package/src/runtime/provider-presets.ts +0 -98
  229. package/src/runtime/provider-protocols.ts +0 -29
  230. package/src/runtime/provider-request.ts +0 -87
  231. package/src/runtime/provider-tool-policy.ts +0 -33
  232. package/src/runtime/provider.ts +0 -580
  233. package/src/runtime/question-tool.ts +0 -62
  234. package/src/runtime/retry.ts +0 -447
  235. package/src/runtime/review-tool.ts +0 -48
  236. package/src/runtime/rich-text.ts +0 -36
  237. package/src/runtime/runtime-target.ts +0 -149
  238. package/src/runtime/secrets.ts +0 -79
  239. package/src/runtime/self-update.ts +0 -74
  240. package/src/runtime/session-control.ts +0 -265
  241. package/src/runtime/session-hub.ts +0 -85
  242. package/src/runtime/stream-idle.ts +0 -80
  243. package/src/runtime/tool-broker.ts +0 -137
  244. package/src/runtime/tool-discovery.ts +0 -58
  245. package/src/runtime/tool-queue.ts +0 -77
  246. package/src/runtime/tools.ts +0 -1820
  247. package/src/runtime/turn-effects.ts +0 -31
  248. package/src/runtime/update-check.ts +0 -48
  249. package/src/runtime/usage.ts +0 -253
  250. package/src/runtime/version.ts +0 -12
  251. package/src/runtime/web-http.ts +0 -56
  252. package/src/runtime/web-tools.ts +0 -88
  253. package/src/runtime/working-notes.ts +0 -60
  254. package/src/runtime/workspace-lifecycle.ts +0 -141
  255. package/src/store/acceptance.ts +0 -15
  256. package/src/store/agent-profile-files.ts +0 -13
  257. package/src/store/atomic.ts +0 -31
  258. package/src/store/auth-store.ts +0 -50
  259. package/src/store/blind-checks.ts +0 -32
  260. package/src/store/blobs.ts +0 -143
  261. package/src/store/checkpoint-history.ts +0 -32
  262. package/src/store/command-artifacts.ts +0 -96
  263. package/src/store/command.ts +0 -111
  264. package/src/store/config.ts +0 -69
  265. package/src/store/context-files.ts +0 -436
  266. package/src/store/file-checkpoints.ts +0 -108
  267. package/src/store/file-index.ts +0 -174
  268. package/src/store/foundation-config.ts +0 -24
  269. package/src/store/git-checkpoints.ts +0 -67
  270. package/src/store/git-status.ts +0 -15
  271. package/src/store/history-search.ts +0 -19
  272. package/src/store/log.ts +0 -725
  273. package/src/store/lsp-document.ts +0 -30
  274. package/src/store/mcp-credentials.ts +0 -85
  275. package/src/store/models.ts +0 -60
  276. package/src/store/observations.ts +0 -224
  277. package/src/store/packages.ts +0 -98
  278. package/src/store/patch.ts +0 -92
  279. package/src/store/permission-paths.ts +0 -34
  280. package/src/store/process-receipts.ts +0 -34
  281. package/src/store/query-source.ts +0 -49
  282. package/src/store/recovery.ts +0 -44
  283. package/src/store/replay.ts +0 -76
  284. package/src/store/resource-index.ts +0 -170
  285. package/src/store/resources.ts +0 -285
  286. package/src/store/restore.ts +0 -110
  287. package/src/store/review.ts +0 -109
  288. package/src/store/runtime-target.ts +0 -64
  289. package/src/store/search.ts +0 -728
  290. package/src/store/secrets.ts +0 -44
  291. package/src/store/serial.ts +0 -13
  292. package/src/store/session-archive.ts +0 -165
  293. package/src/store/session-browser.ts +0 -68
  294. package/src/store/session-position.ts +0 -27
  295. package/src/store/sessions.ts +0 -525
  296. package/src/store/source-cancellation.ts +0 -6
  297. package/src/store/tool-binaries.ts +0 -90
  298. package/src/store/trust.ts +0 -82
  299. package/src/store/update-cache.ts +0 -36
  300. package/src/store/user-settings.ts +0 -30
  301. package/src/store/workspace-clone.ts +0 -42
  302. package/src/store/workspace-observation.ts +0 -113
  303. package/src/store/workspace.ts +0 -115
  304. package/src/store/worktree.ts +0 -278
  305. package/src/tui/ansi-spans.ts +0 -126
  306. package/src/tui/diff.ts +0 -160
  307. package/src/tui/exhaustive.ts +0 -14
  308. package/src/tui/fuzzy.ts +0 -75
  309. package/src/tui/style.ts +0 -208
  310. package/src/tui/ui/atomic-draft.ts +0 -121
  311. package/src/tui/ui/branch-panel.ts +0 -47
  312. package/src/tui/ui/canvas.ts +0 -96
  313. package/src/tui/ui/colors.ts +0 -70
  314. package/src/tui/ui/composer.ts +0 -199
  315. package/src/tui/ui/control-panel.ts +0 -25
  316. package/src/tui/ui/control-sidebar.ts +0 -49
  317. package/src/tui/ui/dialog.ts +0 -398
  318. package/src/tui/ui/draft.ts +0 -23
  319. package/src/tui/ui/duration.ts +0 -16
  320. package/src/tui/ui/edge.ts +0 -186
  321. package/src/tui/ui/image-size.ts +0 -44
  322. package/src/tui/ui/ink.ts +0 -210
  323. package/src/tui/ui/items.ts +0 -42
  324. package/src/tui/ui/keys.ts +0 -40
  325. package/src/tui/ui/layout.ts +0 -74
  326. package/src/tui/ui/list-model.ts +0 -86
  327. package/src/tui/ui/list-view.ts +0 -172
  328. package/src/tui/ui/mascot.ts +0 -67
  329. package/src/tui/ui/messages.ts +0 -518
  330. package/src/tui/ui/motion.ts +0 -28
  331. package/src/tui/ui/paths.ts +0 -10
  332. package/src/tui/ui/pictures.ts +0 -108
  333. package/src/tui/ui/prompt-history.ts +0 -60
  334. package/src/tui/ui/screen.ts +0 -579
  335. package/src/tui/ui/status.ts +0 -403
  336. package/src/tui/ui/styled.ts +0 -85
  337. package/src/tui/ui/tool-card.ts +0 -150
  338. package/src/tui/width.ts +0 -567
  339. package/src/vendor/pi-rich/LICENSE.upstream +0 -21
  340. package/src/vendor/pi-rich/UPSTREAM.json +0 -17
  341. package/src/vendor/pi-rich/clipboard-command.ts +0 -44
  342. package/src/vendor/pi-rich/exif-orientation.ts +0 -182
  343. package/src/vendor/pi-rich/html.ts +0 -51
  344. package/src/vendor/pi-rich/image-convert.ts +0 -49
  345. package/src/vendor/pi-rich/image-resize-core.ts +0 -164
  346. package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
  347. package/src/vendor/pi-rich/image-resize.ts +0 -123
  348. package/src/vendor/pi-rich/mime.ts +0 -116
  349. package/src/vendor/pi-rich/photon.ts +0 -139
  350. package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
@@ -1,403 +0,0 @@
1
- /**
2
- * Session status as rows of styled segments (docs/tasks/refactor-tui-dock-layout.md). Pure.
3
- *
4
- * The app gathers the numbers (log projection, handoff preview, usage
5
- * totals); these functions only decide what fits where. Which fields a tier
6
- * shows is layout.ts's decision; this module formats them for the sidebar,
7
- * the top bar and the input box edge.
8
- *
9
- * Field order follows what users ask for most (research 2026-10-06: context
10
- * share, model and effort, location, cost, files, tasks, background work),
11
- * then xeno's own: the handoff the next segment would start from. Every block
12
- * appears only when it has something to say; complaints about status that is
13
- * "a paragraph" were as common as requests for more of it.
14
- */
15
- import { clipSegs, clipText, segsWidth, splitLine, type Label, type Seg } from "./edge.ts";
16
- import { displayWidth } from "../width.ts";
17
- import { formatElapsed } from "./duration.ts";
18
-
19
- export interface ContextStatus {
20
- readonly used: number;
21
- readonly window?: number;
22
- /** Where the next segment cut happens, in the same units; null when the policy never cuts. */
23
- readonly trigger: number | null;
24
- readonly estimated: boolean;
25
- readonly segment: number;
26
- }
27
-
28
- export interface FileStatus { readonly path: string; readonly edited: boolean; readonly added: number; readonly removed: number }
29
-
30
- export interface StatusModel {
31
- readonly version: string;
32
- /** `/name`, else the first prompt; null for a session with neither. */
33
- readonly title: string | null;
34
- /**
35
- * An explicit /goal, prepared by apps from the log (core/goal-view.ts). The
36
- * block exists only while a goal is set, and for one message after it is met.
37
- */
38
- readonly goal?: GoalStatusView;
39
- /** Without a goal: the latest check after this request's last edit, when it changed files. */
40
- readonly checks?: { readonly status: "unchecked" | "pass" | "fail" | "stale" | "partial"; readonly command?: string; readonly exit?: number };
41
- readonly workspace: string;
42
- readonly workspaceShort: string;
43
- readonly branch: string | null;
44
- readonly mode?: { readonly yolo: boolean; readonly workflow: "build" | "plan" };
45
- readonly provider?: { readonly name: string; readonly api: string; readonly host: string | null; readonly role: string | null; readonly ready: boolean };
46
- readonly activity?: { readonly state: string; readonly label: string; readonly detail: string; readonly queued: number };
47
- readonly git?: { readonly staged: number; readonly changed: number; readonly untracked: number; readonly conflicted: number; readonly ahead: number; readonly behind: number } | null;
48
- readonly context?: ContextStatus;
49
- /** `speed`: output tokens per second over this turn's model requests (runtime/usage.ts generationSpeed). */
50
- readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null; readonly speed?: number };
51
- readonly tools: { readonly running: number; readonly done: number; readonly failed: number };
52
- readonly handoff?: { readonly verified: readonly string[]; readonly inFlight: readonly string[]; readonly unverified: readonly string[] };
53
- readonly tasks?: readonly { readonly what: string; readonly status: "active" | "blocked" }[];
54
- readonly files?: readonly FileStatus[];
55
- readonly background?: readonly string[];
56
- readonly resources?: { readonly contextFiles: readonly string[]; readonly skills: number; readonly extensions: number };
57
- readonly extension?: string;
58
- }
59
-
60
- export interface GoalStatusView {
61
- readonly phase: "drafting" | "working" | "needs_you" | "met" | "stopped";
62
- readonly objective: string;
63
- readonly elapsedMs: number;
64
- readonly checks: readonly { readonly id: string; readonly kind: "command" | "human" | "fresh_check"; readonly status: string; readonly guard?: true; readonly exit?: number }[];
65
- /** S.1: one glyph per rung (ran, tests the change, independent, human), prepared by apps. */
66
- readonly ladder?: { readonly glyphs: string; readonly summary: string };
67
- /** Model requests since the goal was set (from the log) and output tokens this process saw. */
68
- readonly requests: number;
69
- readonly tokens?: number;
70
- }
71
-
72
- export type Line = readonly Seg[];
73
-
74
- /** 1234 → "1.2k", 1234567 → "1.2m". The same compact form everywhere a token count appears. */
75
- export function compact(value: number): string {
76
- return value >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}m` : value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(Math.round(value));
77
- }
78
-
79
- /**
80
- * Share of the budget before the next segment cut. The cut, not the model
81
- * window, is when this segment's context is replaced by a handoff, so it is
82
- * the number that says how much room is left (D1/D8).
83
- */
84
- export function contextShare(context: ContextStatus): number | undefined {
85
- const limit = context.trigger ?? context.window;
86
- return limit ? Math.max(0, Math.min(1, context.used / limit)) : undefined;
87
- }
88
-
89
- /** Above this share the context figure turns to the warning colour. Assumed, matching the old gauge. */
90
- const CONTEXT_WARNING = 0.8;
91
-
92
- function shareSeg(context: ContextStatus): Seg | undefined {
93
- const share = contextShare(context);
94
- if (share === undefined) return undefined;
95
- return { text: `${context.estimated ? "~" : ""}${Math.round(share * 100)}%`, role: share >= CONTEXT_WARNING ? "warning" : "text" };
96
- }
97
-
98
- /** The input box's bottom-right label names the measured budget: `seg ~12%` or `model ~12%`. */
99
- export function contextLabel(model: StatusModel): Label | undefined {
100
- const share = model.context && shareSeg(model.context);
101
- return share ? { segs: [{ text: model.context?.trigger === null ? "model " : "seg ", role: "muted" }, share], priority: 3 } : undefined;
102
- }
103
-
104
- export function modeLabels(model: StatusModel): Label[] {
105
- if (!model.mode) return [];
106
- return [
107
- { segs: [{ text: model.mode.yolo ? "YOLO" : "Approval", role: model.mode.yolo ? "warning" : "muted", bold: model.mode.yolo }], priority: 0 },
108
- { segs: [{ text: model.mode.workflow === "plan" ? "Plan" : "Build", role: "accent" }], priority: 0 },
109
- ];
110
- }
111
-
112
- export function terminalTitle(model: StatusModel): string {
113
- return ["xeno", model.branch, model.title ? clipText(firstLine(model.title), 48) : null].filter(Boolean).join(" · ");
114
- }
115
-
116
- function firstLine(text: string): string { return text.split("\n").find(line => line.trim())?.trim() ?? ""; }
117
-
118
- // ------------------------------------------------------------------- top bar
119
-
120
- /**
121
- * `xeno title ………… ~/P/w/xeno-tui ⎇ branch ✓3 …1 ⧗ 1 $0.42`
122
- * Labels read in display order; priority decides what drops first when the
123
- * row is narrow: location goes before counts, as OpenCode #570 asked.
124
- */
125
- export function topbarRow(model: StatusModel, width: number): Seg[] {
126
- if (model.provider) {
127
- const provider = model.provider;
128
- return splitLine(width, [
129
- { segs: [{ text: "xeno", role: "text", bold: true }], priority: 0 },
130
- { segs: [{ text: `${provider.ready ? "" : "! "}${provider.name}`, role: provider.ready ? "text" : "warning" }], priority: 0, min: 6 },
131
- { segs: [{ text: provider.api, role: "muted" }], priority: 2, min: 8 },
132
- ...(provider.host ? [{ segs: [{ text: provider.host, role: "muted" as const }], priority: 3, min: 8 }] : []),
133
- ...(provider.role ? [{ segs: [{ text: `agent ${provider.role}`, role: "accent" as const }], priority: 1, min: 8 }] : []),
134
- ], model.title ? [{ segs: [{ text: firstLine(model.title), role: "muted" }], priority: 4, min: 12 }] : []);
135
- }
136
- const left: Label[] = [
137
- { segs: [{ text: "xeno", role: "text", bold: true }], priority: 0 },
138
- // Never dropped, but gives up width after cost and background counts, before the handoff counts go.
139
- { segs: [{ text: model.title ? firstLine(model.title) : "New session", role: model.title ? "text" : "muted" }], priority: 0, min: 12, shrinkPriority: 1.5 },
140
- ];
141
- const right: Label[] = [{ segs: [{ text: model.workspaceShort, role: "muted" }], priority: 6 }];
142
- if (model.branch) right.push({ segs: [{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }], priority: 5, min: 8, shrink: 1 });
143
- const counts = handoffCounts(model);
144
- if (counts.length) right.push({ segs: counts, priority: 1 });
145
- if (model.background?.length) right.push({ segs: [{ text: "⧗ ", role: "accent" }, { text: String(model.background.length), role: "text" }], priority: 2 });
146
- if (model.extension) right.push({ segs: [{ text: model.extension, role: "muted" }], priority: 4, min: 10 });
147
- if (model.usage?.cost) right.push({ segs: [{ text: model.usage.cost, role: "muted" }], priority: 3 });
148
- return splitLine(width, left, right);
149
- }
150
-
151
- function handoffCounts(model: StatusModel): Seg[] {
152
- const h = model.handoff;
153
- if (!h) return [];
154
- const out: Seg[] = [];
155
- const add = (glyph: string, role: Seg["role"], count: number) => { if (count) out.push(...(out.length ? [{ text: " ", role: "muted" as const }] : []), { text: glyph, role }, { text: String(count), role: "text" }); };
156
- add("✓", "success", h.verified.length);
157
- add("…", "accent", h.inFlight.length);
158
- add("?", "warning", h.unverified.length);
159
- return out;
160
- }
161
-
162
- // ------------------------------------------------------------------- sidebar
163
-
164
- interface Block {
165
- /** Rows that are always shown together (a header and its fixed facts). */
166
- readonly head: readonly Line[];
167
- /** Rows that may be cut, with a "+N more" row. */
168
- readonly items: readonly Line[];
169
- /** A list block with no items is not shown at all. */
170
- readonly list: boolean;
171
- /** Rows shown after the items, kept with the head (the goal's ladder and spend). */
172
- readonly tail?: readonly Line[];
173
- /** A list shown even without items: a goal being drafted has no checks yet. */
174
- readonly keep?: boolean;
175
- }
176
-
177
- /**
178
- * The sidebar, `height` rows of at most `width` cells. Blocks are placed in
179
- * priority order; a list gets at least two rows (or all of its items), then
180
- * leftover rows go to the lists in the same order. A cut list ends in
181
- * "+N more" so nothing disappears silently.
182
- */
183
- export function sidebarLines(model: StatusModel, width: number, height: number): Line[] {
184
- const w = Math.max(1, Math.floor(width)), h = Math.max(0, Math.floor(height));
185
- const blocks = sidebarBlocks(model, w).filter(block => !block.list || block.items.length > 0 || block.keep);
186
- const plan: { block: Block; rows: number }[] = [];
187
- let used = 0;
188
- for (const block of blocks) {
189
- const minimum = block.list ? Math.min(block.items.length, 2) : 0;
190
- const need = (plan.length ? 1 : 0) + block.head.length + minimum + (block.tail?.length ?? 0);
191
- if (used + need > h) continue;
192
- used += need;
193
- plan.push({ block, rows: minimum });
194
- }
195
- for (const entry of plan) {
196
- if (!entry.block.list) continue;
197
- const extra = Math.min(entry.block.items.length - entry.rows, h - used);
198
- if (extra > 0) { entry.rows += extra; used += extra; }
199
- }
200
- const out: Line[] = [];
201
- plan.forEach((entry, index) => {
202
- if (index) out.push([]);
203
- out.push(...entry.block.head);
204
- const items = entry.block.items;
205
- if (entry.rows >= items.length) out.push(...items);
206
- else if (entry.rows > 0) {
207
- out.push(...items.slice(0, entry.rows - 1));
208
- out.push([{ text: `+${items.length - entry.rows + 1} more`, role: "muted" }]);
209
- }
210
- out.push(...entry.block.tail ?? []);
211
- });
212
- return out.slice(0, h).map(line => clipSegs(line, w));
213
- }
214
-
215
- function sidebarBlocks(model: StatusModel, w: number): Block[] {
216
- const blocks: Block[] = [];
217
- const row = (left: readonly Seg[], right: readonly Seg[] = []): Line => right.length ? splitLine(w, [{ segs: left, priority: 0 }], [{ segs: right, priority: 1 }], 1) : left;
218
- const title = model.title ? wrap(firstLine(model.title), w, 2) : ["New session"];
219
- blocks.push({ list: false, items: [], head: [
220
- row([{ text: "xeno", role: "text", bold: true }], [{ text: model.version, role: "muted" }]),
221
- [],
222
- ...title.map(text => [{ text, role: model.title ? "text" : "muted", bold: Boolean(model.title) }] as Line),
223
- [{ text: clipText(model.workspace, w), role: "muted" }],
224
- ...(model.branch ? [[{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }] as Line] : []),
225
- ...(model.git ? [[{ text: `${model.git.staged} staged · ${model.git.changed} changed · ${model.git.untracked} new`, role: "muted" }] as Line,
226
- ...(model.git.conflicted ? [[{ text: `${model.git.conflicted} conflicts`, role: "warning" }] as Line] : []),
227
- ...(model.git.ahead || model.git.behind ? [[{ text: `↑${model.git.ahead} ↓${model.git.behind}`, role: "muted" }] as Line] : [])] : model.git === null ? [[{ text: "Git unavailable", role: "muted" }] as Line] : []),
228
- ] });
229
- const goal = model.goal;
230
- if (goal) blocks.push(goalBlock(goal, w, row));
231
- else if (model.checks) blocks.push({ list: false, items: [], head: [checksLine(model.checks, w)] });
232
- const context = model.context;
233
- if (context) {
234
- const share = contextShare(context), est = context.estimated ? "~" : "";
235
- const filled = share === undefined ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * w));
236
- const tone = share !== undefined && share >= CONTEXT_WARNING ? "warning" : "accent";
237
- const shareText = shareSeg(context);
238
- // Two budgets, each named: the segment cut (what the bar shows) and the model window. Both shares
239
- // ride on the heading, both denominators on one row below the bar.
240
- const modelShare = context.window && context.trigger !== null ? `${est}${Math.round(context.used / context.window * 100)}%` : undefined;
241
- const segment = context.trigger !== null ? `seg ${est}${compact(context.used)} / ${est}${compact(context.trigger)}` : `${est}${compact(context.used)} · no cut`;
242
- blocks.push({ list: false, items: [], head: [
243
- row([{ text: "Context", role: "text", bold: true }], [
244
- ...(shareText ? [{ text: context.trigger === null ? "model " : "seg ", role: "muted" as const }, shareText] : []),
245
- ...(modelShare ? [{ text: ` · model ${modelShare}`, role: "muted" as const }] : [])]),
246
- ...(share === undefined ? [] : [[{ text: "━".repeat(filled), role: tone }, { text: "─".repeat(Math.max(0, w - filled)), role: "line" }] as Line]),
247
- row([{ text: segment, role: "muted" }], [{ text: context.window ? `model ${compact(context.window)}` : "window unknown", role: "muted" }]),
248
- ] });
249
- }
250
- const usage = model.usage, tools = model.tools;
251
- const toolCount = tools.done + tools.failed + tools.running;
252
- if (usage || toolCount) {
253
- const figures = usage ? [`↑${compact(usage.input)} ↓${compact(usage.output)}`, `cache ${Math.round(usage.cacheRate * 100)}%`,
254
- ...(usage.speed !== undefined ? [speedText(usage.speed)] : [])].join(" · ") : "";
255
- // Heading with the cost; one row of figures below it. Together they need about 49 columns, more than the 34-42 the sidebar has.
256
- blocks.push({ list: false, items: [], head: [
257
- row([{ text: "Usage", role: "text", bold: true }], usage?.cost ? [{ text: usage.cost, role: "text" }] : []),
258
- ...(figures ? [[{ text: figures, role: "muted" }] as Line] : []),
259
- ...(tools.failed || tools.running ? [[
260
- { text: `${tools.done} done`, role: "muted" },
261
- ...(tools.failed ? [{ text: " · ", role: "muted" as const }, { text: `${tools.failed} failed`, role: "error" as const }] : []),
262
- ...(tools.running ? [{ text: " · ", role: "muted" as const }, { text: `${tools.running} running`, role: "accent" as const }] : []),
263
- ] as Line] : []),
264
- ] });
265
- }
266
- const handoff = model.handoff;
267
- if (handoff) {
268
- const items: Line[] = [
269
- ...handoff.verified.map(what => [{ text: "✓ ", role: "success" }, { text: what, role: "text" }] as Line),
270
- ...handoff.inFlight.map(what => [{ text: "… ", role: "accent" }, { text: what, role: "text" }] as Line),
271
- ...handoff.unverified.map(what => [{ text: "? ", role: "warning" }, { text: what, role: "text" }] as Line),
272
- ];
273
- blocks.push({ list: true, items, head: [row([{ text: "Handoff", role: "text", bold: true }], [{ text: "if cut now", role: "muted" }])] });
274
- }
275
- const tasks = model.tasks ?? [];
276
- blocks.push({ list: true, head: [row([{ text: "Tasks", role: "text", bold: true }], [{ text: `${tasks.length} open`, role: "muted" }])],
277
- items: tasks.map(task => [{ text: task.status === "blocked" ? "! " : "◐ ", role: task.status === "blocked" ? "warning" : "accent" }, { text: task.what, role: "text" }] as Line) });
278
- const files = model.files ?? [];
279
- blocks.push({ list: true, head: [row([{ text: "Agent files", role: "text", bold: true }], [{ text: String(files.length), role: "muted" }])],
280
- items: files.map(file => fileLine(file, w)) });
281
- const background = model.background ?? [];
282
- blocks.push({ list: true, head: [row([{ text: "Background", role: "text", bold: true }], [{ text: String(background.length), role: "muted" }])],
283
- items: background.map(what => [{ text: "⧗ ", role: "accent" }, { text: what, role: "text" }] as Line) });
284
- const resources = model.resources;
285
- if (resources && (resources.contextFiles.length || resources.skills || resources.extensions)) {
286
- const parts = [
287
- ...(resources.contextFiles.length ? [resources.contextFiles.length > 1 ? `${resources.contextFiles[0]} ×${resources.contextFiles.length}` : resources.contextFiles[0]!] : []),
288
- ...(resources.skills ? [`${resources.skills} skill${resources.skills === 1 ? "" : "s"}`] : []),
289
- ...(resources.extensions ? [`${resources.extensions} extension${resources.extensions === 1 ? "" : "s"}`] : []),
290
- ];
291
- blocks.push({ list: false, items: [], head: [[{ text: "Loaded", role: "text", bold: true }], [{ text: parts.join(" · "), role: "muted" }]] });
292
- }
293
- if (model.extension) blocks.push({ list: false, items: [], head: wrap(model.extension, w, 3).map(text => [{ text, role: "muted" }] as Line) });
294
- return blocks;
295
- }
296
-
297
- /** `42 tok/s`; under 10 one decimal, so a slow upstream does not read as zero. */
298
- export function speedText(speed: number): string {
299
- return `${speed < 10 ? speed.toFixed(1) : Math.round(speed)} tok/s`;
300
- }
301
-
302
- const PHASE: Record<GoalStatusView["phase"], { label: string; role: Seg["role"] }> = {
303
- drafting: { label: "drafting", role: "accent" }, working: { label: "working", role: "accent" },
304
- needs_you: { label: "needs you", role: "warning" }, met: { label: "met", role: "success" }, stopped: { label: "stopped", role: "warning" },
305
- };
306
-
307
- /**
308
- * `Goal · needs you 4m 12s`, the objective, one row per accepted check with
309
- * what it needs, the evidence ladder and what the goal has spent. A met goal
310
- * is one summary row. The sidebar may cut the checks; a cut ends in "+N more".
311
- */
312
- function goalBlock(goal: GoalStatusView, w: number, row: (left: readonly Seg[], right?: readonly Seg[]) => Line): Block {
313
- const phase = PHASE[goal.phase];
314
- const passed = goal.checks.filter(check => check.status === "pass" || check.status === "accepted").length;
315
- const elapsed = goal.phase === "drafting" ? [] : [{ text: formatElapsed(goal.elapsedMs), role: "muted" as const }];
316
- const title = [{ text: "Goal", role: "text" as const, bold: true }, { text: ` · ${phase.label}`, role: phase.role }];
317
- if (goal.phase === "met") return { list: false, items: [], head: [row(title, [{ text: `${passed}/${goal.checks.length} · `, role: "muted" }, ...elapsed])] };
318
- const items = goal.checks.map(check => {
319
- const done = check.status === "pass" || check.status === "accepted";
320
- const glyph: Seg = done ? { text: "✓ ", role: "success" } : check.status === "fail" ? { text: "✗ ", role: "error" } : { text: "○ ", role: "muted" };
321
- const need = check.kind === "human" && !done ? `/accept ${check.id}` : check.status === "fail" && check.exit !== undefined ? `exit ${check.exit}`
322
- : check.status === "stale" ? "rerun" : check.guard ? "guard" : "";
323
- return row([glyph, { text: check.id, role: "text" }], need ? [{ text: need, role: check.kind === "human" && !done ? "warning" : "muted" }] : []);
324
- });
325
- const spent = [`${goal.requests} ${goal.requests === 1 ? "request" : "requests"}`, ...(goal.tokens ? [`${compact(goal.tokens)} tokens this run`] : [])].join(" · ");
326
- return { list: true, keep: true, head: [row(title, elapsed), ...wrap(firstLine(goal.objective), w, 2).map(text => [{ text, role: "text" }] as Line)],
327
- items, ...(goal.phase === "drafting" ? {} : { tail: [...(goal.ladder ? [[{ text: goal.ladder.glyphs, role: "text" }] as Line] : []), [{ text: spent, role: "muted" }] as Line] }) };
328
- }
329
-
330
- /** Without a goal: whether a check has passed since this request's last edit. */
331
- function checksLine(checks: NonNullable<StatusModel["checks"]>, w: number): Line {
332
- if (checks.status === "unchecked" || !checks.command) return [{ text: "Checks", role: "text", bold: true }, { text: " · not run since last edit", role: "warning" }];
333
- // partial: some of a declared contract holds; the rest may only be waiting for the user, so it is not a failure mark.
334
- const mark: Seg[] = checks.status === "pass" ? [{ text: " ✓", role: "success" }] : checks.status === "partial" ? []
335
- : checks.status === "fail" ? [{ text: " ✗", role: "error" }, ...(checks.exit === undefined ? [] : [{ text: ` exit ${checks.exit}`, role: "muted" as const }])]
336
- : [{ text: " stale", role: "warning" }];
337
- const room = Math.max(1, w - segsWidth(mark) - "Checks · ".length);
338
- return [{ text: "Checks", role: "text", bold: true }, { text: ` · ${clipText(checks.command, room)}`, role: "muted" }, ...mark];
339
- }
340
-
341
- /** Without a sidebar the goal rides on the input box's bottom edge: `goal 2/3 · needs you · 4m 12s`. */
342
- export function goalLabel(model: StatusModel): Label | undefined {
343
- const goal = model.goal;
344
- if (!goal) return undefined;
345
- const passed = goal.checks.filter(check => check.status === "pass" || check.status === "accepted").length;
346
- const parts = [`goal ${goal.phase === "drafting" ? "" : `${passed}/${goal.checks.length} · `}${PHASE[goal.phase].label}`,
347
- ...(goal.phase === "drafting" ? [] : [formatElapsed(goal.elapsedMs)])];
348
- return { segs: [{ text: parts.join(" · "), role: goal.phase === "needs_you" ? "warning" : "muted" }], priority: 2, min: 8 };
349
- }
350
-
351
- function fileLine(file: FileStatus, w: number): Line {
352
- const name = file.path.split("/").pop() || file.path;
353
- const counts: Seg[] = file.edited && (file.added || file.removed)
354
- ? [{ text: `+${file.added}`, role: "success" }, { text: " ", role: "muted" }, { text: `−${file.removed}`, role: "error" }] : [];
355
- const glyph: Seg = { text: file.edited ? "✎ " : "· ", role: file.edited ? "text" : "muted" };
356
- if (!counts.length) return [glyph, { text: name, role: file.edited ? "text" : "muted" }];
357
- const room = Math.max(1, w - 2 - segsWidth(counts) - 1);
358
- const shown = clipText(name, room);
359
- return [glyph, { text: shown, role: "text" }, { text: " ".repeat(Math.max(1, w - 2 - displayWidth(shown) - segsWidth(counts))), role: "muted" }, ...counts];
360
- }
361
-
362
- /** The leading characters of `text` that fit in `width` cells. */
363
- function cells(text: string, width: number): string {
364
- let out = "", used = 0;
365
- for (const ch of text) { const w = displayWidth(ch); if (used + w > width) break; out += ch; used += w; }
366
- return out;
367
- }
368
-
369
- /** Word-aware wrap (CJK breaks per character); at most `lines` rows, the last one cut with "…". */
370
- export function wrap(text: string, width: number, lines: number): string[] {
371
- const w = Math.max(1, width);
372
- const tokens = text.match(/\s+|[⺀-鿿豈-﫿＀-￯]|[^\s⺀-鿿豈-﫿＀-￯]+/g) ?? [];
373
- const out: string[] = [];
374
- let current = "";
375
- for (const token of tokens) {
376
- if (/^\s+$/.test(token)) { if (current && displayWidth(current) < w) current += " "; continue; }
377
- if (displayWidth(current + token) <= w) { current += token; continue; }
378
- if (current.trim()) out.push(current.trimEnd());
379
- current = displayWidth(token) > w ? clipText(token, w) : token;
380
- }
381
- if (current.trim()) out.push(current.trimEnd());
382
- if (out.length <= lines) return out;
383
- const kept = out.slice(0, lines);
384
- const last = kept[lines - 1]!;
385
- kept[lines - 1] = `${displayWidth(last) + 1 <= w ? last : cells(last, w - 1).trimEnd()}…`;
386
- return kept;
387
- }
388
-
389
- /**
390
- * The input box's top-edge label while no turn runs: the outcome in its own
391
- * colour, then how long the last turn took (Codex "Worked for 2m 5s", Claude
392
- * Code "Cooked for 1m 6s"). A plain grey "Turn finished" read the same as
393
- * "nothing happened" (fix-finished-turn-status).
394
- */
395
- export function idleActivityLabel(activity: NonNullable<StatusModel["activity"]>, turnMs?: number): Label {
396
- const tone = activity.state === "done" ? { glyph: "✓", role: "success" as const }
397
- : activity.state === "error" ? { glyph: "✗", role: "error" as const }
398
- : activity.state === "blocked" ? { glyph: activity.label === "Stopped" ? "■" : "", role: "warning" as const }
399
- : { glyph: "", role: "muted" as const };
400
- const text = activity.label === "Stopped" && activity.detail ? `Stopped · ${activity.detail}` : activity.label;
401
- return { segs: [...(tone.glyph ? [{ text: tone.glyph, role: tone.role }, { text: " ", role: "muted" as const }] : []), { text, role: tone.role },
402
- ...(turnMs !== undefined ? [{ text: ` · ${formatElapsed(turnMs)}`, role: "muted" as const }] : [])], priority: 1, min: 4, shrink: tone.glyph ? 2 : 0 };
403
- }
@@ -1,85 +0,0 @@
1
- /**
2
- * Small helpers that build OpenTUI StyledText from parts, plus the bridge
3
- * from ANSI lines (extension renderers, coloured shell output) to chunks.
4
- */
5
- import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core";
6
- import { ansiSpans, type SpanColor } from "../ansi-spans.ts";
7
- import { rgba } from "./colors.ts";
8
-
9
- export interface Part {
10
- readonly text: string;
11
- /** An OpenTUI colour, or #rrggbb from a custom theme. */
12
- readonly fg?: RGBA | string;
13
- readonly bg?: RGBA | string;
14
- readonly bold?: boolean;
15
- readonly dim?: boolean;
16
- readonly italic?: boolean;
17
- readonly underline?: boolean;
18
- /** Swap foreground and background (SGR 7): emphasis without painting a colour of our own. */
19
- readonly inverse?: boolean;
20
- }
21
-
22
- /**
23
- * OpenTUI draws uncoloured text in opaque white. Text with no colour of its
24
- * own is the terminal's foreground instead (SGR 39), which is what keeps it
25
- * legible on a light terminal (refactor-tui-terminal-colors).
26
- */
27
- const TERMINAL_TEXT: RGBA = RGBA.defaultForeground();
28
-
29
- function chunk(text: string, fg?: RGBA, bg?: RGBA, attributes = 0, link?: string): TextChunk {
30
- return { __isChunk: true, text, ...(fg ? { fg } : {}), ...(bg ? { bg } : {}), ...(attributes ? { attributes } : {}), ...(link ? { link: { url: link } } : {}) };
31
- }
32
-
33
- export function parts(...items: (Part | string | false | undefined)[]): StyledText {
34
- const chunks: TextChunk[] = [];
35
- for (const item of items) {
36
- if (!item) continue;
37
- if (typeof item === "string") { chunks.push(chunk(item, TERMINAL_TEXT)); continue; }
38
- let attributes = 0;
39
- if (item.bold) attributes |= TextAttributes.BOLD;
40
- if (item.dim) attributes |= TextAttributes.DIM;
41
- if (item.italic) attributes |= TextAttributes.ITALIC;
42
- if (item.underline) attributes |= TextAttributes.UNDERLINE;
43
- if (item.inverse) attributes |= TextAttributes.INVERSE;
44
- chunks.push(chunk(item.text, color(item.fg) ?? TERMINAL_TEXT, color(item.bg), attributes));
45
- }
46
- return new StyledText(chunks);
47
- }
48
-
49
- function color(value: RGBA | string | undefined): RGBA | undefined {
50
- return value === undefined ? undefined : typeof value === "string" ? rgba(value) : value;
51
- }
52
-
53
- /**
54
- * Indexed colours from shell output, diffs and highlighters stay indexed, so
55
- * the terminal draws them from its own palette (refactor-tui-terminal-colors);
56
- * converting them to xterm's default RGB would repaint them in someone else's.
57
- */
58
- const indexedCache = new Map<number, RGBA>();
59
- function spanColor(value: SpanColor | undefined): RGBA | undefined {
60
- if (!value) return undefined;
61
- if (value.kind === "rgb") return RGBA.fromInts(value.r, value.g, value.b, 255);
62
- let ink = indexedCache.get(value.index);
63
- if (!ink) { ink = RGBA.fromIndex(value.index); indexedCache.set(value.index, ink); }
64
- return ink;
65
- }
66
-
67
- /** ANSI lines → one StyledText with newlines between rows. */
68
- export function ansiText(lines: readonly string[]): StyledText {
69
- const chunks: TextChunk[] = [];
70
- lines.forEach((line, row) => {
71
- if (row > 0) chunks.push(chunk("\n"));
72
- for (const span of ansiSpans(line)) {
73
- const s = span.style;
74
- let attributes = 0;
75
- if (s.bold) attributes |= TextAttributes.BOLD;
76
- if (s.dim) attributes |= TextAttributes.DIM;
77
- if (s.italic) attributes |= TextAttributes.ITALIC;
78
- if (s.underline) attributes |= TextAttributes.UNDERLINE;
79
- if (s.reverse) attributes |= TextAttributes.INVERSE;
80
- if (s.strikethrough) attributes |= TextAttributes.STRIKETHROUGH;
81
- chunks.push(chunk(span.text, spanColor(s.fg) ?? TERMINAL_TEXT, spanColor(s.bg), attributes, s.link));
82
- }
83
- });
84
- return new StyledText(chunks);
85
- }
@@ -1,150 +0,0 @@
1
- /**
2
- * Tool call → card summary, evidence and expanded detail. Pure.
3
- *
4
- * The card's one-line summary comes from the call's input (what was asked);
5
- * its evidence comes from the logged result (what happened): an exit code,
6
- * a diff's +/- counts, a line count. A model's narration is never evidence
7
- * (D11), so nothing here reads assistant text.
8
- */
9
- import { diffLines, renderDiff, wrapPlain } from "../diff.ts";
10
- import { ANSI, IDENTITY, PLAIN_ATTRS, paint, type Style } from "../style.ts";
11
- import { displayWidth } from "../width.ts";
12
- import type { ToolStatus } from "./items.ts";
13
-
14
- export interface ToolCall { readonly name: string; readonly input: unknown; readonly result: { readonly text: string; readonly ok: boolean; readonly data?: unknown } | null }
15
- export interface ToolCard { readonly summary: string; readonly status: ToolStatus; readonly evidence?: string }
16
-
17
- function record(value: unknown): Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
18
- function str(value: unknown): string { return typeof value === "string" ? value : value === undefined || value === null ? "" : JSON.stringify(value); }
19
-
20
- export function exitCode(text: string): number | null {
21
- const match = /^exit (-?\d+)/.exec(text);
22
- return match ? Number(match[1]) : null;
23
- }
24
-
25
- function edits(input: Record<string, unknown>): { old: string; new: string }[] {
26
- const list = Array.isArray(input["edits"]) ? input["edits"].map(record) : [input];
27
- return list.map(edit => ({ old: str(edit["old_string"] ?? edit["old"]), new: str(edit["new_string"] ?? edit["new"]) }));
28
- }
29
-
30
- export function diffCounts(input: unknown): { added: number; removed: number } {
31
- let added = 0, removed = 0;
32
- for (const edit of edits(record(input))) for (const op of diffLines(edit.old, edit.new)) { if (op.kind === "add") added++; else if (op.kind === "del") removed++; }
33
- return { added, removed };
34
- }
35
-
36
- /** One line: the argument a reader needs to recognise the call. */
37
- export function toolSummary(name: string, input: unknown): string {
38
- const args = record(input);
39
- switch (name) {
40
- case "bash": return str(args["command"]).split("\n")[0] ?? "";
41
- case "read": {
42
- const offset = typeof args["offset"] === "number" ? args["offset"] : undefined;
43
- const limit = typeof args["limit"] === "number" ? args["limit"] : undefined;
44
- return `${str(args["path"])}${offset !== undefined ? `:${offset}${limit !== undefined ? `+${limit}` : ""}` : ""}`;
45
- }
46
- case "write": case "edit": return str(args["path"]);
47
- case "grep": case "find": case "glob": return [str(args["pattern"]), str(args["path"])].filter(Boolean).join(" in ");
48
- case "history": return [str(args["section"]) || (args["recent"] ? "recent activity" : "session history"), typeof args["node"] === "number" ? `node ${args["node"]}` : ""].filter(Boolean).join(" · ");
49
- default: {
50
- const first = Object.entries(args).find(([, value]) => typeof value === "string" && value.length > 0);
51
- return first ? `${first[0]}=${str(first[1]).split("\n")[0]}` : "";
52
- }
53
- }
54
- }
55
-
56
- export function toolCard(call: ToolCall): ToolCard {
57
- const summary = toolSummary(call.name, call.input);
58
- if (!call.result) return { summary, status: "pending", ...(call.name === "edit" ? { evidence: plusMinus(call.input) } : {}) };
59
- const { text, ok } = call.result;
60
- const status: ToolStatus = ok ? "ok" : "error";
61
- switch (call.name) {
62
- case "bash": {
63
- const code = exitCode(text);
64
- return { summary, status, ...(code !== null ? { evidence: `exit ${code}` } : ok ? {} : { evidence: "failed" }) };
65
- }
66
- case "edit": return { summary, status, evidence: ok ? plusMinus(call.input) : "failed" };
67
- case "write": {
68
- const lines = str(record(call.input)["content"]).split("\n").length;
69
- return { summary, status, evidence: ok ? `${lines} lines` : "failed" };
70
- }
71
- case "read": {
72
- const data = record(call.result.data), start = data["start"], end = data["end"];
73
- const lines = typeof start === "number" && typeof end === "number" && end >= start ? end - start + 1 : text ? text.split("\n").length : 0;
74
- return { summary, status, evidence: ok ? `${lines} lines` : "failed" };
75
- }
76
- case "history": {
77
- const data = record(jsonValue(text));
78
- return { summary, status, ...(ok && typeof data["matches"] === "number" ? { evidence: `${data["matches"]} matches` } : ok ? {} : { evidence: "failed" }) };
79
- }
80
- default: return { summary, status, ...(ok ? {} : { evidence: "failed" }) };
81
- }
82
- }
83
-
84
- function plusMinus(input: unknown): string {
85
- const { added, removed } = diffCounts(input);
86
- return `+${added} −${removed}`;
87
- }
88
-
89
- /** Expanded body: input that matters, then the result. Width-bounded ANSI lines. */
90
- export function toolDetail(call: ToolCall, width: number, style: Style = ANSI): string[] {
91
- const w = Math.max(1, width);
92
- // Semantic colour, never SGR faint: faint made already-muted arguments unreadable.
93
- const dim = (text: string) => style.token?.("muted", text) ?? text;
94
- const red = (text: string) => style === IDENTITY ? text : paint(text, { ...PLAIN_ATTRS, error: true }, style);
95
- const clip = (line: string) => truncate(line, w);
96
- const args = record(call.input);
97
- const out: string[] = [];
98
- switch (call.name) {
99
- case "bash": out.push(...str(args["command"]).split("\n").map(line => clip(`$ ${line}`))); break;
100
- case "edit": for (const edit of edits(args)) out.push(...renderDiff(edit.old, edit.new, w, style)); break;
101
- case "write": out.push(...str(args["content"]).split("\n").flatMap(line => wrapPlain(line, w)).map(dim)); break;
102
- case "read": break;
103
- default: {
104
- const json = JSON.stringify(call.input, null, 2) ?? "";
105
- out.push(...json.split("\n").flatMap(line => wrapPlain(line, w)).map(dim));
106
- }
107
- }
108
- if (call.result) {
109
- const body = call.name === "bash" ? call.result.text.replace(/^exit -?\d+[^\n]*\n?/, "") : call.result.text;
110
- const parsed = jsonValue(body);
111
- const readable = parsed !== undefined ? JSON.stringify(parsed, null, 2) : body;
112
- const lines = readable ? readable.replace(/\s+$/, "").split("\n").flatMap(line => wrapPlain(line, w)) : [];
113
- if (lines.length && out.length) out.push("");
114
- const paintLine = call.result.ok ? (line: string) => clip(line) : (line: string) => red(clip(line));
115
- out.push(...lines.map(paintLine));
116
- } else out.push(dim("running…"));
117
- return out;
118
- }
119
-
120
- function jsonValue(text: string): unknown {
121
- if (!/^[\s]*[\[{]/.test(text)) return undefined;
122
- try { return JSON.parse(text) as unknown; } catch { return undefined; }
123
- }
124
-
125
- /** Brief logged output stays visible even when details are folded. */
126
- export function toolPreview(call: ToolCall): string[] {
127
- if (!call.result) return [];
128
- let raw = call.result.text.replace(/^exit -?\d+[^\n]*\n?/, "").trim();
129
- // The read result's provenance remains in expanded details. Its preview
130
- // shows the selected source lines instead of an opaque version fingerprint.
131
- if (call.name === "read") raw = raw.replace(/^\[Read selection:[^\n]*\nsha256:[^\n]*\n/, "");
132
- const parsed = jsonValue(raw), data = record(parsed);
133
- if (Array.isArray(data["items"])) {
134
- const items = data["items"] as unknown[];
135
- return items.slice(0, 2).map(value => { const item = record(value); return str(item["text"] ?? item["what"] ?? item["path"] ?? item["kind"] ?? value).replace(/\s+/g, " "); });
136
- }
137
- if (parsed !== undefined) return Object.entries(data).filter(([key]) => key !== "source").slice(0, 2).map(([key, value]) => `${key} ${typeof value === "object" && value !== null ? Array.isArray(value) ? `${value.length} items` : Object.keys(value).join(", ") : str(value)}`);
138
- return raw.split("\n").filter(Boolean).slice(0, 2);
139
- }
140
-
141
- function truncate(line: string, width: number): string {
142
- if (displayWidth(line) <= width) return line;
143
- let out = "", used = 0;
144
- for (const ch of line) {
145
- const w = displayWidth(ch);
146
- if (used + w > width - 1) break;
147
- out += ch; used += w;
148
- }
149
- return out + "…";
150
- }