@visual-z/xeno 0.1.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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * A pickable list: applySelectKey(state, key) is pure, renderSelect paints.
3
+ *
4
+ * The visible window follows the selection so a /model list of dozens
5
+ * does not dump every row into the live region. An empty list must say
6
+ * why it is empty -- a blank box looks like a render bug.
7
+ */
8
+
9
+ import { assertNever } from "./exhaustive.ts";
10
+ import type { Key } from "./keys.ts";
11
+ import type { Component } from "./text.ts";
12
+ import { displayWidth } from "./width.ts";
13
+ import { PLAIN_ATTRS, paint, type Style } from "./style.ts";
14
+ import { Input } from "../vendor/pi-tui/components/input.ts";
15
+ import { SelectList as PiSelectList } from "../vendor/pi-tui/components/select-list.ts";
16
+ import { fuzzyFilter as piFuzzyFilter } from "../vendor/pi-tui/fuzzy.ts";
17
+ import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
18
+ import { encodeKey, keyMatches } from "./input.ts";
19
+
20
+ /**
21
+ * Default visible rows. Assumed, not measured: enough to scan a /model
22
+ * list without eating the whole scrollback. Callers pass maxVisible.
23
+ */
24
+ export const DEFAULT_MAX_VISIBLE = 10;
25
+
26
+ export interface SelectItem {
27
+ readonly value: string;
28
+ readonly label: string;
29
+ readonly description?: string;
30
+ }
31
+
32
+ export interface CreateSelectOptions {
33
+ readonly maxVisible?: number;
34
+ readonly emptyMessage?: string;
35
+ readonly style?: Style;
36
+ readonly title?: string;
37
+ readonly selectedValue?: string;
38
+ readonly search?: boolean;
39
+ readonly hint?: string;
40
+ }
41
+
42
+ export interface SelectState {
43
+ readonly items: readonly SelectItem[];
44
+ readonly selected: number;
45
+ readonly maxVisible: number;
46
+ readonly confirmed: SelectItem | null;
47
+ readonly cancelled: boolean;
48
+ readonly emptyMessage: string;
49
+ }
50
+
51
+ export function createSelect(
52
+ items: readonly SelectItem[],
53
+ opts: CreateSelectOptions = {},
54
+ ): SelectState {
55
+ return {
56
+ items,
57
+ selected: 0,
58
+ maxVisible: opts.maxVisible ?? DEFAULT_MAX_VISIBLE,
59
+ confirmed: null,
60
+ cancelled: false,
61
+ emptyMessage: opts.emptyMessage ?? "No matching items",
62
+ };
63
+ }
64
+
65
+ export function applySelectKey(state: SelectState, key: Key): SelectState {
66
+ switch (key.type) {
67
+ case "up":
68
+ return move(state, -1);
69
+ case "down":
70
+ return move(state, 1);
71
+ case "enter":
72
+ return confirm(state);
73
+ case "escape":
74
+ return { ...state, cancelled: true, confirmed: null };
75
+ case "char":
76
+ case "backspace":
77
+ case "delete":
78
+ case "tab":
79
+ case "left":
80
+ case "right":
81
+ case "home":
82
+ case "end":
83
+ case "pageup":
84
+ case "pagedown":
85
+ case "ctrl":
86
+ case "alt":
87
+ return state;
88
+ default:
89
+ return assertNever(key, "applySelectKey");
90
+ }
91
+ }
92
+
93
+ function move(state: SelectState, dir: -1 | 1): SelectState {
94
+ const n = state.items.length;
95
+ if (n === 0) return state;
96
+ const selected = (state.selected + dir + n) % n;
97
+ return { ...state, selected, confirmed: null, cancelled: false };
98
+ }
99
+
100
+ function confirm(state: SelectState): SelectState {
101
+ const item = state.items[state.selected];
102
+ if (item === undefined) return state;
103
+ return { ...state, confirmed: item, cancelled: false };
104
+ }
105
+
106
+ interface Window {
107
+ readonly start: number;
108
+ readonly end: number;
109
+ }
110
+
111
+ function visibleWindow(state: SelectState): Window {
112
+ const n = state.items.length;
113
+ const cap = state.maxVisible;
114
+ if (n <= cap) return { start: 0, end: n };
115
+ const start = Math.max(
116
+ 0,
117
+ Math.min(state.selected - Math.floor(cap / 2), n - cap),
118
+ );
119
+ return { start, end: Math.min(start + cap, n) };
120
+ }
121
+
122
+ /**
123
+ * Truncate on display columns. Slicing with s.length would cut a CJK
124
+ * label in half and the next cell would paint garbage.
125
+ */
126
+ function truncate(s: string, width: number): string {
127
+ if (width <= 0) return "";
128
+ if (displayWidth(s) <= width) return s;
129
+ let out = "";
130
+ let w = 0;
131
+ for (const ch of s) {
132
+ const cw = displayWidth(ch);
133
+ if (w + cw > width) break;
134
+ out += ch;
135
+ w += cw;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ export function renderSelect(state: SelectState, width: number, style?: Style): string[] {
141
+ if (state.items.length === 0) return [state.emptyMessage];
142
+
143
+ const { start, end } = visibleWindow(state);
144
+ const lines: string[] = [];
145
+ for (let i = start; i < end; i++) {
146
+ const item = state.items[i];
147
+ if (item === undefined) continue;
148
+ const selected = i === state.selected;
149
+ const prefix = selected ? "→ " : " ";
150
+ const budget = width - displayWidth(prefix);
151
+ let body = item.label;
152
+ const desc = item.description;
153
+ if (desc !== undefined && desc.length > 0) body = `${item.label} ${desc}`;
154
+ const line = prefix + truncate(body, budget);
155
+ lines.push(
156
+ selected && style !== undefined ? paint(line, { ...PLAIN_ATTRS, selected: true }, style) : line,
157
+ );
158
+ }
159
+
160
+ const hidden = state.items.length - (end - start);
161
+ // Card copy is Chinese on purpose: this is the one user-visible string
162
+ // the completion definition named. English would silently relax it.
163
+ if (hidden > 0) lines.push(`还有 ${hidden} 条`);
164
+ return lines;
165
+ }
166
+
167
+ export class SelectList implements Component {
168
+ private current: SelectState;
169
+ private style: Style | undefined;
170
+ private readonly items: readonly SelectItem[];
171
+ private readonly options: CreateSelectOptions;
172
+ private readonly search = new Input({ prompt: "Search: " });
173
+ private list: PiSelectList;
174
+ onInput: ((data: string) => void) | undefined;
175
+
176
+ constructor(items: readonly SelectItem[], opts: CreateSelectOptions = {}) {
177
+ this.current = createSelect(items, opts);
178
+ this.items = items;
179
+ this.options = opts;
180
+ this.style = opts.style;
181
+ this.list = this.makeList();
182
+ this.list.setSelectedIndex(Math.max(0, items.findIndex((item) => item.value === opts.selectedValue)));
183
+ }
184
+
185
+ get state(): SelectState {
186
+ const selected = this.list.getSelectedItem();
187
+ return { ...this.current, selected: Math.max(0, this.current.items.findIndex((item) => item.value === selected?.value)) };
188
+ }
189
+
190
+ applyKey(key: Key): SelectState {
191
+ return this.acceptInput(encodeKey(key));
192
+ }
193
+
194
+ get focused(): boolean { return this.search.focused; }
195
+ set focused(value: boolean) { this.search.focused = value; }
196
+ invalidate(): void { this.search.invalidate(); this.list.invalidate(); }
197
+ handleInput(data: string): void { if (this.onInput) this.onInput(data); else this.acceptInput(data); }
198
+
199
+ setStyle(style: Style): void {
200
+ if (this.style === style) return;
201
+ const selected = this.state.selected;
202
+ this.style = style;
203
+ this.list = this.makeList();
204
+ this.list.setSelectedIndex(selected);
205
+ }
206
+
207
+ acceptInput(data: string): SelectState {
208
+ this.current = { ...this.current, confirmed: null, cancelled: false };
209
+ if (["up", "down", "enter", "escape", "ctrl+c"].some((key) => keyMatches(data, key))) this.list.handleInput(data);
210
+ else if (keyMatches(data, "pageUp") || keyMatches(data, "pageDown")) {
211
+ this.list.setSelectedIndex(this.state.selected + (keyMatches(data, "pageUp") ? -1 : 1) * this.current.maxVisible);
212
+ } else if (this.options.search !== false) {
213
+ this.search.handleInput(data);
214
+ const filtered = piFuzzyFilter([...this.items], this.search.getValue(), (item) => `${item.label} ${item.description ?? ""}`);
215
+ this.current = { ...this.current, items: filtered, selected: 0 };
216
+ this.list = this.makeList();
217
+ }
218
+ return this.state;
219
+ }
220
+
221
+ private makeList(): PiSelectList {
222
+ const selected = (text: string) => this.style ? paint(text, { ...PLAIN_ATTRS, selected: true }, this.style) : text;
223
+ const dim = (text: string) => this.style ? paint(text, { ...PLAIN_ATTRS, dim: true }, this.style) : text;
224
+ const list = new PiSelectList([...this.current.items], this.current.maxVisible, {
225
+ selectedPrefix: selected, selectedText: selected, description: dim, scrollInfo: dim, noMatch: dim,
226
+ });
227
+ list.onSelect = (item) => { this.current = { ...this.current, confirmed: item }; };
228
+ list.onCancel = () => { this.current = { ...this.current, cancelled: true }; };
229
+ return list;
230
+ }
231
+
232
+ render(width: number): string[] {
233
+ if (width <= 0) return [""];
234
+ const lines = [this.options.title ?? "Select"];
235
+ if (this.options.search !== false) lines.push(...this.search.render(width));
236
+ lines.push(...(this.current.items.length ? this.list.render(Math.max(4, width)) : [this.current.emptyMessage]));
237
+ lines.push(this.options.hint ?? "↑↓ select · Enter confirm · Esc cancel");
238
+ return lines.map((line) => truncateToWidth(line, width, ""));
239
+ }
240
+ }
@@ -0,0 +1,40 @@
1
+ import { SettingsList, type SettingItem } from "../vendor/pi-tui/components/settings-list.ts";
2
+ import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
3
+ import { PLAIN_ATTRS, paint, type Style } from "./style.ts";
4
+
5
+ /** Pi owns settings navigation and cycling; apps persists the returned changes. */
6
+ export class Settings {
7
+ private readonly items: SettingItem[];
8
+ private list: SettingsList;
9
+ private style: Style;
10
+ private changes: { id: string; value: string }[] = [];
11
+ cancelled = false;
12
+ onInput: ((data: string) => void) | undefined;
13
+ constructor(items: SettingItem[], style: Style) {
14
+ this.items = items;
15
+ this.style = style;
16
+ this.list = this.createList();
17
+ }
18
+ private createList(): SettingsList {
19
+ return new SettingsList(this.items, 8, {
20
+ label: (text, selected) => paint(text, { ...PLAIN_ATTRS, selected }, this.style),
21
+ value: (text) => paint(text, { ...PLAIN_ATTRS, status: true }, this.style),
22
+ description: (text) => paint(text, { ...PLAIN_ATTRS, dim: true }, this.style),
23
+ cursor: "→ ", hint: (text) => paint(text, { ...PLAIN_ATTRS, dim: true }, this.style),
24
+ }, (id, value) => { this.changes.push({ id, value }); }, () => { this.cancelled = true; }, { enableSearch: true });
25
+ }
26
+ setStyle(style: Style): void { this.style = style; }
27
+ handleInput(data: string): void { if (this.onInput) this.onInput(data); else this.acceptInput(data); }
28
+ acceptInput(data: string): { id: string; value: string }[] {
29
+ this.changes = [];
30
+ this.list.handleInput(data);
31
+ return this.changes;
32
+ }
33
+ updateValue(id: string, value: string): void { this.list.updateValue(id, value); }
34
+ invalidate(): void { this.list.invalidate(); }
35
+ render(width: number): string[] {
36
+ if (width <= 0) return [""];
37
+ return ["Settings · changes apply immediately", ...this.list.render(Math.max(8, width))]
38
+ .map((line) => truncateToWidth(line, width, ""));
39
+ }
40
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Footer: pi's two-line shape, xeno's cut budget.
3
+ *
4
+ * Line 1 is cwd (with git branch) and session name. Line 2 is usage
5
+ * (↑ ↓ R W CH), cost, the context gauge, and the model. The gauge's
6
+ * denominator is shouldCut's triggerTokens, not a model context window —
7
+ * that number is the reason this project has a TUI at all.
8
+ *
9
+ * State in, string[] out. No terminal, no clock, no git.
10
+ */
11
+ import { isAbsolute, relative, resolve, sep } from "node:path";
12
+ import type { Component } from "./text.ts";
13
+ import { displayWidth } from "./width.ts";
14
+ import { PLAIN_ATTRS, paint, type Style } from "./style.ts";
15
+
16
+ export interface StatusState {
17
+ cwd: string;
18
+ branch: string | null;
19
+ tokens: number;
20
+ triggerTokens: number;
21
+ model: string;
22
+ thinking: string;
23
+ /**
24
+ * From shouldCut in apps, not inferred from the percentage. TUI must not
25
+ * recompute minNodeWorkTokens -- that floor lives in core.
26
+ */
27
+ cut: boolean;
28
+ reason: string;
29
+ sessionName?: string;
30
+ home?: string;
31
+ inputTokens?: number;
32
+ outputTokens?: number;
33
+ cacheRead?: number;
34
+ cacheWrite?: number;
35
+ cacheHitRate?: number;
36
+ cost?: number;
37
+ /** Follow-ups waiting; painted in the stats line so it is not truncated off a padded row. */
38
+ queued?: number;
39
+ }
40
+
41
+ /** Kept so /hotkeys and the queue count can still name the bindings. */
42
+ export const STATUS_HINT = "escape interrupt · / commands · /quit · ctrl+o tools · ctrl+t think";
43
+
44
+ const ELLIPSIS = "...";
45
+
46
+ /**
47
+ * Compact token counts the way pi's footer does. 30_000 → "30k" so the
48
+ * spelling matches the number shouldCut compares.
49
+ */
50
+ export function formatTokens(count: number): string {
51
+ if (count < 1000) return count.toString();
52
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
53
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
54
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
55
+ return `${Math.round(count / 1000000)}M`;
56
+ }
57
+
58
+ export function formatCwdForFooter(cwd: string, home: string | undefined): string {
59
+ if (!home) return cwd;
60
+ const resolvedCwd = resolve(cwd);
61
+ const resolvedHome = resolve(home);
62
+ const relativeToHome = relative(resolvedHome, resolvedCwd);
63
+ const isInsideHome =
64
+ relativeToHome === "" ||
65
+ (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
66
+ if (!isInsideHome) return cwd;
67
+ return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
68
+ }
69
+
70
+ export function renderStatus(state: StatusState, width: number, style?: Style): string[] {
71
+ const w = Math.max(0, width);
72
+ const pwd = pwdLine(state, w);
73
+ const stats = statsLine(state, w);
74
+ const lines = [pwd, stats];
75
+ if (style === undefined) return lines;
76
+ const attrs = { ...PLAIN_ATTRS, status: true, dim: true };
77
+ return lines.map((line) => paint(line, attrs, style));
78
+ }
79
+
80
+ export function withStatus(body: Component, state: StatusState): Component {
81
+ return {
82
+ render(width: number): string[] {
83
+ return [...body.render(width), ...renderStatus(state, width)];
84
+ },
85
+ };
86
+ }
87
+
88
+ function pwdLine(state: StatusState, width: number): string {
89
+ let pwd = formatCwdForFooter(state.cwd, state.home);
90
+ if (state.branch) pwd = `${pwd} (${state.branch})`;
91
+ if (state.sessionName) pwd = `${pwd} • ${state.sessionName}`;
92
+ return truncateToWidth(pwd, width);
93
+ }
94
+
95
+ function statsLine(state: StatusState, width: number): string {
96
+ const parts: string[] = [];
97
+ if (state.inputTokens) parts.push(`↑${formatTokens(state.inputTokens)}`);
98
+ if (state.outputTokens) parts.push(`↓${formatTokens(state.outputTokens)}`);
99
+ if (state.cacheRead) parts.push(`R${formatTokens(state.cacheRead)}`);
100
+ if (state.cacheWrite) parts.push(`W${formatTokens(state.cacheWrite)}`);
101
+ if (
102
+ ((state.cacheRead ?? 0) > 0 || (state.cacheWrite ?? 0) > 0) &&
103
+ state.cacheHitRate !== undefined
104
+ ) {
105
+ parts.push(`CH${state.cacheHitRate.toFixed(1)}%`);
106
+ }
107
+ if (state.cost !== undefined && state.cost > 0) {
108
+ parts.push(`$${state.cost.toFixed(3)}`);
109
+ }
110
+ if (state.queued !== undefined && state.queued > 0) {
111
+ parts.push(`${state.queued} queued`);
112
+ }
113
+
114
+ // Floor, not round: 29999/30000 must not read as 100% while shouldCut
115
+ // is still below_trigger. Pi's shape is N%/Mk — the Mk is our trigger.
116
+ const trigger = state.triggerTokens;
117
+ const pct = trigger <= 0 ? 0 : Math.floor((100 * state.tokens) / trigger);
118
+ parts.push(`${pct}%/${formatTokens(trigger)}`);
119
+
120
+ let statsLeft = parts.join(" ");
121
+ if (displayWidth(statsLeft) > width) {
122
+ statsLeft = truncateToWidth(statsLeft, width);
123
+ }
124
+
125
+ let right = state.model;
126
+ if (state.thinking.length > 0) {
127
+ right = `${state.model} • ${state.thinking}`;
128
+ }
129
+ const leftW = displayWidth(statsLeft);
130
+ const rightW = displayWidth(right);
131
+ const minPadding = 2;
132
+ if (leftW + minPadding + rightW <= width) {
133
+ return statsLeft + " ".repeat(width - leftW - rightW) + right;
134
+ }
135
+ const availableForRight = width - leftW - minPadding;
136
+ if (availableForRight > 0) {
137
+ const truncatedRight = truncateToWidth(right, availableForRight);
138
+ const pad = Math.max(0, width - leftW - displayWidth(truncatedRight));
139
+ return statsLeft + " ".repeat(pad) + truncatedRight;
140
+ }
141
+ return statsLeft;
142
+ }
143
+
144
+ function truncateToWidth(s: string, width: number): string {
145
+ if (width <= 0) return "";
146
+ if (displayWidth(s) <= width) return s;
147
+ const ew = displayWidth(ELLIPSIS);
148
+ if (width <= ew) return takePrefix(s, width);
149
+ return takePrefix(s, width - ew) + ELLIPSIS;
150
+ }
151
+
152
+ function takePrefix(s: string, width: number): string {
153
+ let out = "";
154
+ let w = 0;
155
+ for (const ch of s) {
156
+ const cw = displayWidth(ch);
157
+ if (w + cw > width) break;
158
+ out += ch;
159
+ w += cw;
160
+ }
161
+ return out;
162
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * SGR composition for markdown.
3
+ *
4
+ * Terminals do not keep attributes across a newline, and the differential
5
+ * renderer rewrites by line (`src/tui/render.ts`). A wrap-the-whole-string
6
+ * helper would leave only the first row with an open code, so a later
7
+ * rewrite of row 2 paints in the default style. Every output row therefore
8
+ * carries its own open and close.
9
+ *
10
+ * Identity emits no ESC bytes: `-p` and pipes parse the transcript, and a
11
+ * CSI in that stream is a parse error waiting for a script (D17, same
12
+ * reason `createPalette(false)` exists).
13
+ *
14
+ * Colours are data: createStyle reads token → SGR params. Hard-coding a
15
+ * CSI colour here would be a colour the theme cannot reach.
16
+ */
17
+
18
+ export interface Attrs {
19
+ readonly bold: boolean;
20
+ readonly italic: boolean;
21
+ readonly dim: boolean;
22
+ readonly code: boolean;
23
+ readonly heading: boolean;
24
+ readonly link: boolean;
25
+ /** Optional: tool diffs and exit codes. Absent keeps markdown Attrs unchanged. */
26
+ readonly red?: boolean;
27
+ readonly green?: boolean;
28
+ readonly yellow?: boolean;
29
+ readonly thinking?: boolean;
30
+ readonly tool?: boolean;
31
+ readonly result?: boolean;
32
+ readonly error?: boolean;
33
+ readonly status?: boolean;
34
+ readonly prompt?: boolean;
35
+ readonly selected?: boolean;
36
+ }
37
+
38
+ export const PLAIN_ATTRS: Attrs = {
39
+ bold: false,
40
+ italic: false,
41
+ dim: false,
42
+ code: false,
43
+ heading: false,
44
+ link: false,
45
+ };
46
+
47
+ export interface Style {
48
+ open(attrs: Attrs): string;
49
+ close(attrs: Attrs): string;
50
+ token?(name: string, text: string): string;
51
+ }
52
+
53
+ /**
54
+ * Token → SGR colour params. Same names as core/theme.ts; duplicated so
55
+ * tui never imports core. Empty string is the terminal default.
56
+ */
57
+ export type ColorTokens = {
58
+ readonly text: string;
59
+ readonly thinkingText: string;
60
+ readonly toolTitle: string;
61
+ readonly toolOutput: string;
62
+ readonly error: string;
63
+ readonly toolDiffAdded: string;
64
+ readonly toolDiffRemoved: string;
65
+ readonly status: string;
66
+ readonly prompt: string;
67
+ readonly selectedBg: string;
68
+ readonly muted: string;
69
+ readonly warning: string;
70
+ readonly mdHeading: string;
71
+ readonly mdCode: string;
72
+ readonly mdLink: string;
73
+ };
74
+
75
+ /** Must stay in lockstep with core/theme.ts dark builtin. */
76
+ export const DEFAULT_COLORS: ColorTokens = {
77
+ "text": "38;2;212;212;212",
78
+ "thinkingText": "38;2;128;128;128",
79
+ "toolTitle": "38;2;212;212;212",
80
+ "toolOutput": "38;2;128;128;128",
81
+ "error": "38;2;204;102;102",
82
+ "toolDiffAdded": "38;2;181;189;104",
83
+ "toolDiffRemoved": "38;2;204;102;102",
84
+ "status": "38;2;128;128;128",
85
+ "prompt": "38;2;212;212;212",
86
+ "selectedBg": "48;2;58;58;74",
87
+ "muted": "38;2;128;128;128",
88
+ "warning": "38;2;255;255;0",
89
+ "mdHeading": "38;2;240;198;116",
90
+ "mdCode": "38;2;138;190;183",
91
+ "mdLink": "38;2;129;162;190"
92
+ };
93
+
94
+ function anyAttr(a: Attrs, color: string): boolean {
95
+ return (
96
+ a.bold ||
97
+ a.italic ||
98
+ a.dim ||
99
+ a.code ||
100
+ a.heading ||
101
+ a.link ||
102
+ a.red === true ||
103
+ a.green === true ||
104
+ a.yellow === true ||
105
+ a.thinking === true ||
106
+ a.tool === true ||
107
+ a.result === true ||
108
+ a.error === true ||
109
+ a.status === true ||
110
+ a.prompt === true ||
111
+ a.selected === true ||
112
+ color.length > 0
113
+ );
114
+ }
115
+
116
+ function pickColor(attrs: Attrs, colors: ColorTokens): string {
117
+ if (attrs.selected === true) return colors.selectedBg;
118
+ if (attrs.prompt === true) return colors.prompt;
119
+ if (attrs.status === true) return colors.status;
120
+ if (attrs.thinking === true) return colors.thinkingText;
121
+ if (attrs.error === true) return colors.error;
122
+ if (attrs.red === true) return colors.toolDiffRemoved;
123
+ if (attrs.green === true) return colors.toolDiffAdded;
124
+ if (attrs.yellow === true) return colors.warning;
125
+ if (attrs.tool === true) return colors.toolTitle;
126
+ if (attrs.result === true) return colors.toolOutput;
127
+ if (attrs.code) return colors.mdCode;
128
+ if (attrs.heading) return colors.mdHeading;
129
+ if (attrs.link) return colors.mdLink;
130
+ if (attrs.dim) return colors.muted;
131
+ return colors.text;
132
+ }
133
+
134
+ /** Full reset, not a stacked 22/23/24. Nested closes would drop a wrapping attr on wrap. */
135
+ const RESET = "\x1b[0m";
136
+
137
+ export const IDENTITY: Style = {
138
+ open: () => "",
139
+ close: () => "",
140
+ };
141
+
142
+ /**
143
+ * Build a Style from theme tokens. `enabled` is the NO_COLOR / non-tty gate:
144
+ * a themed Style that still emits ESC in a pipe is the same class of bug as
145
+ * createPalette(true) on stdout that is not a tty.
146
+ */
147
+ export function createStyle(colors: { readonly [token: string]: string }, enabled = true): Style {
148
+ if (!enabled) return IDENTITY;
149
+ const tokens: ColorTokens = {
150
+ text: colors["text"] ?? "",
151
+ thinkingText: colors["thinkingText"] ?? "",
152
+ toolTitle: colors["toolTitle"] ?? "",
153
+ toolOutput: colors["toolOutput"] ?? "",
154
+ error: colors["error"] ?? "",
155
+ toolDiffAdded: colors["toolDiffAdded"] ?? "",
156
+ toolDiffRemoved: colors["toolDiffRemoved"] ?? "",
157
+ status: colors["status"] ?? "",
158
+ prompt: colors["prompt"] ?? "",
159
+ selectedBg: colors["selectedBg"] ?? "",
160
+ muted: colors["muted"] ?? "",
161
+ warning: colors["warning"] ?? "",
162
+ mdHeading: colors["mdHeading"] ?? "",
163
+ mdCode: colors["mdCode"] ?? "",
164
+ mdLink: colors["mdLink"] ?? "",
165
+ };
166
+ return {
167
+ token(name, text) {
168
+ const color = colors[name];
169
+ return color ? `\x1b[${color}m${text}${RESET}` : text;
170
+ },
171
+ open(attrs) {
172
+ const color = pickColor(attrs, tokens);
173
+ if (!anyAttr(attrs, color)) return "";
174
+ const params: string[] = [];
175
+ if (attrs.bold || attrs.heading) params.push("1");
176
+ if (attrs.italic) params.push("3");
177
+ if (attrs.link) params.push("4");
178
+ if (color.length > 0) params.push(...color.split(";"));
179
+ const dim = attrs.dim ? "\x1b[2m" : "";
180
+ return dim + (params.length > 0 ? `\x1b[${params.join(";")}m` : "");
181
+ },
182
+ close(attrs) {
183
+ const color = pickColor(attrs, tokens);
184
+ return anyAttr(attrs, color) ? RESET : "";
185
+ },
186
+ };
187
+ }
188
+
189
+ export const ANSI: Style = createStyle(DEFAULT_COLORS);
190
+
191
+ export function withDim(base: Style): Style {
192
+ return {
193
+ open(attrs) {
194
+ return base.open({ ...attrs, dim: true });
195
+ },
196
+ close(attrs) {
197
+ return base.close({ ...attrs, dim: true });
198
+ },
199
+ };
200
+ }
201
+
202
+ export function paint(text: string, attrs: Attrs, style: Style): string {
203
+ if (text.length === 0) return text;
204
+ return style.open(attrs) + text + style.close(attrs);
205
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The only thing a renderer can be observed to do is write bytes.
3
+ *
4
+ * A real tty makes that unassertable -- tests would have to inspect a live
5
+ * terminal's screen, and would change its state while doing so. Injecting
6
+ * this interface is what makes "a frame that changed nothing writes nothing"
7
+ * a unit test rather than a hope.
8
+ */
9
+
10
+ export interface Terminal {
11
+ readonly columns: number;
12
+ readonly rows: number;
13
+ write(data: string): void;
14
+ }
15
+
16
+ /**
17
+ * A pty opened without TIOCSWINSZ reports `columns === 0`, not
18
+ * `undefined`. `n ?? 80` keeps the zero, SelectList's truncate budget
19
+ * goes negative, and every label becomes "" -- `/model` shows only `→`.
20
+ * Treat non-positive as "size unknown" and fall back to the same 80
21
+ * StdoutTerminal already used for a missing ioctl.
22
+ */
23
+ export function usableColumns(n: number | undefined): number {
24
+ return n && n > 0 ? n : 80;
25
+ }
26
+
27
+ /** Same trap as usableColumns: a 0-row ioctl is not a 0-row screen. */
28
+ export function usableRows(n: number | undefined): number {
29
+ return n && n > 0 ? n : 24;
30
+ }