@visual-z/xeno 0.2.1 → 0.4.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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,279 @@
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
+
18
+ export interface ContextStatus {
19
+ readonly used: number;
20
+ readonly window?: number;
21
+ /** Where the next segment cut happens, in the same units; null when the policy never cuts. */
22
+ readonly trigger: number | null;
23
+ readonly estimated: boolean;
24
+ readonly segment: number;
25
+ }
26
+
27
+ export interface FileStatus { readonly path: string; readonly edited: boolean; readonly added: number; readonly removed: number }
28
+
29
+ export interface StatusModel {
30
+ readonly version: string;
31
+ /** `/name`, else the first prompt; null for a session with neither. */
32
+ readonly title: string | null;
33
+ readonly goal?: { readonly text: string; readonly status: string; readonly execution: string; readonly observations: number; readonly retries: number };
34
+ readonly workspace: string;
35
+ readonly workspaceShort: string;
36
+ readonly branch: string | null;
37
+ readonly context?: ContextStatus;
38
+ readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null };
39
+ readonly tools: { readonly running: number; readonly done: number; readonly failed: number };
40
+ readonly handoff?: { readonly verified: readonly string[]; readonly inFlight: readonly string[]; readonly unverified: readonly string[] };
41
+ readonly tasks?: readonly { readonly what: string; readonly status: "active" | "blocked" }[];
42
+ readonly files?: readonly FileStatus[];
43
+ readonly background?: readonly string[];
44
+ readonly resources?: { readonly contextFiles: readonly string[]; readonly skills: number; readonly extensions: number };
45
+ readonly extension?: string;
46
+ }
47
+
48
+ export type Line = readonly Seg[];
49
+
50
+ /** 1234 → "1.2k", 1234567 → "1.2m". The same compact form everywhere a token count appears. */
51
+ export function compact(value: number): string {
52
+ return value >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}m` : value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(Math.round(value));
53
+ }
54
+
55
+ /**
56
+ * Share of the budget before the next segment cut. The cut, not the model
57
+ * window, is when this segment's context is replaced by a handoff, so it is
58
+ * the number that says how much room is left (D1/D8).
59
+ */
60
+ export function contextShare(context: ContextStatus): number | undefined {
61
+ const limit = context.trigger ?? context.window;
62
+ return limit ? Math.max(0, Math.min(1, context.used / limit)) : undefined;
63
+ }
64
+
65
+ /** Above this share the context figure turns to the warning colour. Assumed, matching the old gauge. */
66
+ const CONTEXT_WARNING = 0.8;
67
+
68
+ function shareSeg(context: ContextStatus): Seg | undefined {
69
+ const share = contextShare(context);
70
+ if (share === undefined) return undefined;
71
+ return { text: `${context.estimated ? "~" : ""}${Math.round(share * 100)}%`, role: share >= CONTEXT_WARNING ? "warning" : "text" };
72
+ }
73
+
74
+ /** The input box's bottom-right label where there is no sidebar: `ctx ~12%`. */
75
+ export function contextLabel(model: StatusModel): Label | undefined {
76
+ const share = model.context && shareSeg(model.context);
77
+ return share ? { segs: [{ text: "ctx ", role: "muted" }, share], priority: 1 } : undefined;
78
+ }
79
+
80
+ export function terminalTitle(model: StatusModel): string {
81
+ return ["xeno", model.branch, model.title ? clipText(firstLine(model.title), 48) : null].filter(Boolean).join(" · ");
82
+ }
83
+
84
+ function firstLine(text: string): string { return text.split("\n").find(line => line.trim())?.trim() ?? ""; }
85
+
86
+ // ------------------------------------------------------------------- top bar
87
+
88
+ /**
89
+ * `xeno title ………… ~/P/w/xeno-tui ⎇ branch ✓3 …1 ⧗ 1 $0.42`
90
+ * Labels read in display order; priority decides what drops first when the
91
+ * row is narrow: location goes before counts, as OpenCode #570 asked.
92
+ */
93
+ export function topbarRow(model: StatusModel, width: number): Seg[] {
94
+ const left: Label[] = [
95
+ { segs: [{ text: "xeno", role: "text", bold: true }], priority: 0 },
96
+ // Never dropped, but gives up width after cost and background counts, before the handoff counts go.
97
+ { segs: [{ text: model.title ? firstLine(model.title) : "New session", role: model.title ? "text" : "muted" }], priority: 0, min: 12, shrinkPriority: 1.5 },
98
+ ];
99
+ const right: Label[] = [{ segs: [{ text: model.workspaceShort, role: "muted" }], priority: 6 }];
100
+ if (model.branch) right.push({ segs: [{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }], priority: 5, min: 8, shrink: 1 });
101
+ const counts = handoffCounts(model);
102
+ if (counts.length) right.push({ segs: counts, priority: 1 });
103
+ if (model.background?.length) right.push({ segs: [{ text: "⧗ ", role: "accent" }, { text: String(model.background.length), role: "text" }], priority: 2 });
104
+ if (model.extension) right.push({ segs: [{ text: model.extension, role: "muted" }], priority: 4, min: 10 });
105
+ if (model.usage?.cost) right.push({ segs: [{ text: model.usage.cost, role: "muted" }], priority: 3 });
106
+ return splitLine(width, left, right);
107
+ }
108
+
109
+ function handoffCounts(model: StatusModel): Seg[] {
110
+ const h = model.handoff;
111
+ if (!h) return [];
112
+ const out: Seg[] = [];
113
+ 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" }); };
114
+ add("✓", "success", h.verified.length);
115
+ add("…", "accent", h.inFlight.length);
116
+ add("?", "warning", h.unverified.length);
117
+ return out;
118
+ }
119
+
120
+ // ------------------------------------------------------------------- sidebar
121
+
122
+ interface Block {
123
+ /** Rows that are always shown together (a header and its fixed facts). */
124
+ readonly head: readonly Line[];
125
+ /** Rows that may be cut, with a "+N more" row. */
126
+ readonly items: readonly Line[];
127
+ /** A list block with no items is not shown at all. */
128
+ readonly list: boolean;
129
+ }
130
+
131
+ /**
132
+ * The sidebar, `height` rows of at most `width` cells. Blocks are placed in
133
+ * priority order; a list gets at least two rows (or all of its items), then
134
+ * leftover rows go to the lists in the same order. A cut list ends in
135
+ * "+N more" so nothing disappears silently.
136
+ */
137
+ export function sidebarLines(model: StatusModel, width: number, height: number): Line[] {
138
+ const w = Math.max(1, Math.floor(width)), h = Math.max(0, Math.floor(height));
139
+ const blocks = sidebarBlocks(model, w).filter(block => !block.list || block.items.length > 0);
140
+ const plan: { block: Block; rows: number }[] = [];
141
+ let used = 0;
142
+ for (const block of blocks) {
143
+ const minimum = block.list ? Math.min(block.items.length, 2) : 0;
144
+ const need = (plan.length ? 1 : 0) + block.head.length + minimum;
145
+ if (used + need > h) continue;
146
+ used += need;
147
+ plan.push({ block, rows: minimum });
148
+ }
149
+ for (const entry of plan) {
150
+ if (!entry.block.list) continue;
151
+ const extra = Math.min(entry.block.items.length - entry.rows, h - used);
152
+ if (extra > 0) { entry.rows += extra; used += extra; }
153
+ }
154
+ const out: Line[] = [];
155
+ plan.forEach((entry, index) => {
156
+ if (index) out.push([]);
157
+ out.push(...entry.block.head);
158
+ const items = entry.block.items;
159
+ if (entry.rows >= items.length) out.push(...items);
160
+ else if (entry.rows > 0) {
161
+ out.push(...items.slice(0, entry.rows - 1));
162
+ out.push([{ text: `+${items.length - entry.rows + 1} more`, role: "muted" }]);
163
+ }
164
+ });
165
+ return out.slice(0, h).map(line => clipSegs(line, w));
166
+ }
167
+
168
+ function sidebarBlocks(model: StatusModel, w: number): Block[] {
169
+ const blocks: Block[] = [];
170
+ const row = (left: readonly Seg[], right: readonly Seg[] = []): Line => right.length ? splitLine(w, [{ segs: left, priority: 0 }], [{ segs: right, priority: 1 }], 1) : left;
171
+ const title = model.title ? wrap(firstLine(model.title), w, 2) : ["New session"];
172
+ blocks.push({ list: false, items: [], head: [
173
+ row([{ text: "xeno", role: "text", bold: true }], [{ text: model.version, role: "muted" }]),
174
+ [],
175
+ ...title.map(text => [{ text, role: model.title ? "text" : "muted", bold: Boolean(model.title) }] as Line),
176
+ [{ text: clipText(model.workspace, w), role: "muted" }],
177
+ ...(model.branch ? [[{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }] as Line] : []),
178
+ ] });
179
+ const context = model.context;
180
+ if (model.goal) blocks.push({ list: false, items: [], head: [
181
+ [{ text: "Goal", role: "text", bold: true }, { text: ` · ${model.goal.status}`, role: model.goal.status === "verified" ? "success" : "warning" }],
182
+ ...wrap(firstLine(model.goal.text), w, 2).map(text => [{ text, role: "text" }] as Line),
183
+ [{ text: model.goal.execution, role: "muted" }],
184
+ [{ text: `${model.goal.observations} observations · ${model.goal.retries} retries`, role: "muted" }],
185
+ [{ text: "/control · goal, evidence, context", role: "muted" }],
186
+ ] });
187
+ if (context) {
188
+ const share = contextShare(context), est = context.estimated ? "~" : "";
189
+ const filled = share === undefined ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * w));
190
+ const tone = share !== undefined && share >= CONTEXT_WARNING ? "warning" : "accent";
191
+ const shareText = shareSeg(context);
192
+ blocks.push({ list: false, items: [], head: [
193
+ row([{ text: "Context", role: "text", bold: true }], shareText ? [shareText] : []),
194
+ ...(share === undefined ? [] : [[{ text: "━".repeat(filled), role: tone }, { text: "─".repeat(Math.max(0, w - filled)), role: "line" }] as Line]),
195
+ [{ text: `${est}${compact(context.used)}${context.window ? ` / ${compact(context.window)}` : ""}`, role: "text" }, { text: ` · node ${context.segment}`, role: "muted" }],
196
+ ...(context.trigger !== null ? [[{ text: `cut at ${est}${compact(context.trigger)}`, role: "muted" }] as Line] : []),
197
+ ] });
198
+ }
199
+ const usage = model.usage, tools = model.tools;
200
+ const toolCount = tools.done + tools.failed + tools.running;
201
+ if (usage || toolCount) {
202
+ blocks.push({ list: false, items: [], head: [
203
+ row([{ text: "Usage", role: "text", bold: true }], usage?.cost ? [{ text: usage.cost, role: "text" }] : []),
204
+ ...(usage ? [[{ text: `↑${compact(usage.input)} ↓${compact(usage.output)}`, role: "text" }, { text: ` cache ${Math.round(usage.cacheRate * 100)}%`, role: "muted" }] as Line] : []),
205
+ ...(toolCount ? [[
206
+ { text: `${tools.done} done`, role: "muted" },
207
+ ...(tools.failed ? [{ text: " · ", role: "muted" as const }, { text: `${tools.failed} failed`, role: "error" as const }] : []),
208
+ ...(tools.running ? [{ text: " · ", role: "muted" as const }, { text: `${tools.running} running`, role: "accent" as const }] : []),
209
+ ] as Line] : []),
210
+ ] });
211
+ }
212
+ const handoff = model.handoff;
213
+ if (handoff) {
214
+ const items: Line[] = [
215
+ ...handoff.verified.map(what => [{ text: "✓ ", role: "success" }, { text: what, role: "text" }] as Line),
216
+ ...handoff.inFlight.map(what => [{ text: "… ", role: "accent" }, { text: what, role: "text" }] as Line),
217
+ ...handoff.unverified.map(what => [{ text: "? ", role: "warning" }, { text: what, role: "text" }] as Line),
218
+ ];
219
+ blocks.push({ list: true, items, head: [row([{ text: "Handoff", role: "text", bold: true }], [{ text: "if cut now", role: "muted" }])] });
220
+ }
221
+ const tasks = model.tasks ?? [];
222
+ blocks.push({ list: true, head: [row([{ text: "Tasks", role: "text", bold: true }], [{ text: `${tasks.length} open`, role: "muted" }])],
223
+ items: tasks.map(task => [{ text: task.status === "blocked" ? "! " : "◐ ", role: task.status === "blocked" ? "warning" : "accent" }, { text: task.what, role: "text" }] as Line) });
224
+ const files = model.files ?? [];
225
+ blocks.push({ list: true, head: [row([{ text: "Files", role: "text", bold: true }], [{ text: String(files.length), role: "muted" }])],
226
+ items: files.map(file => fileLine(file, w)) });
227
+ const background = model.background ?? [];
228
+ blocks.push({ list: true, head: [row([{ text: "Background", role: "text", bold: true }], [{ text: String(background.length), role: "muted" }])],
229
+ items: background.map(what => [{ text: "⧗ ", role: "accent" }, { text: what, role: "text" }] as Line) });
230
+ const resources = model.resources;
231
+ if (resources && (resources.contextFiles.length || resources.skills || resources.extensions)) {
232
+ const parts = [
233
+ ...(resources.contextFiles.length ? [resources.contextFiles.length > 1 ? `${resources.contextFiles[0]} ×${resources.contextFiles.length}` : resources.contextFiles[0]!] : []),
234
+ ...(resources.skills ? [`${resources.skills} skill${resources.skills === 1 ? "" : "s"}`] : []),
235
+ ...(resources.extensions ? [`${resources.extensions} extension${resources.extensions === 1 ? "" : "s"}`] : []),
236
+ ];
237
+ blocks.push({ list: false, items: [], head: [[{ text: "Loaded", role: "text", bold: true }], [{ text: parts.join(" · "), role: "muted" }]] });
238
+ }
239
+ if (model.extension) blocks.push({ list: false, items: [], head: wrap(model.extension, w, 3).map(text => [{ text, role: "muted" }] as Line) });
240
+ return blocks;
241
+ }
242
+
243
+ function fileLine(file: FileStatus, w: number): Line {
244
+ const name = file.path.split("/").pop() || file.path;
245
+ const counts: Seg[] = file.edited && (file.added || file.removed)
246
+ ? [{ text: `+${file.added}`, role: "success" }, { text: " ", role: "muted" }, { text: `−${file.removed}`, role: "error" }] : [];
247
+ const glyph: Seg = { text: file.edited ? "✎ " : "· ", role: file.edited ? "text" : "muted" };
248
+ if (!counts.length) return [glyph, { text: name, role: file.edited ? "text" : "muted" }];
249
+ const room = Math.max(1, w - 2 - segsWidth(counts) - 1);
250
+ const shown = clipText(name, room);
251
+ return [glyph, { text: shown, role: "text" }, { text: " ".repeat(Math.max(1, w - 2 - displayWidth(shown) - segsWidth(counts))), role: "muted" }, ...counts];
252
+ }
253
+
254
+ /** The leading characters of `text` that fit in `width` cells. */
255
+ function cells(text: string, width: number): string {
256
+ let out = "", used = 0;
257
+ for (const ch of text) { const w = displayWidth(ch); if (used + w > width) break; out += ch; used += w; }
258
+ return out;
259
+ }
260
+
261
+ /** Word-aware wrap (CJK breaks per character); at most `lines` rows, the last one cut with "…". */
262
+ export function wrap(text: string, width: number, lines: number): string[] {
263
+ const w = Math.max(1, width);
264
+ const tokens = text.match(/\s+|[⺀-鿿豈-﫿＀-￯]|[^\s⺀-鿿豈-﫿＀-￯]+/g) ?? [];
265
+ const out: string[] = [];
266
+ let current = "";
267
+ for (const token of tokens) {
268
+ if (/^\s+$/.test(token)) { if (current && displayWidth(current) < w) current += " "; continue; }
269
+ if (displayWidth(current + token) <= w) { current += token; continue; }
270
+ if (current.trim()) out.push(current.trimEnd());
271
+ current = displayWidth(token) > w ? clipText(token, w) : token;
272
+ }
273
+ if (current.trim()) out.push(current.trimEnd());
274
+ if (out.length <= lines) return out;
275
+ const kept = out.slice(0, lines);
276
+ const last = kept[lines - 1]!;
277
+ kept[lines - 1] = `${displayWidth(last) + 1 <= w ? last : cells(last, w - 1).trimEnd()}…`;
278
+ return kept;
279
+ }
@@ -0,0 +1,85 @@
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
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The headless renderer, re-exported so tests reach OpenTUI through the one
3
+ * directory allowed to import it (scripts/check-boundaries.ts, D20).
4
+ */
5
+ export { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing";
@@ -0,0 +1,150 @@
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
+ }