@ocis/myagent-cli 0.2.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 (91) hide show
  1. package/README.md +357 -0
  2. package/dist/agent/context.d.ts +33 -0
  3. package/dist/agent/context.js +169 -0
  4. package/dist/agent/modes.d.ts +21 -0
  5. package/dist/agent/modes.js +84 -0
  6. package/dist/agent/prompt-builder.d.ts +9 -0
  7. package/dist/agent/prompt-builder.js +31 -0
  8. package/dist/agent/sessions.d.ts +38 -0
  9. package/dist/agent/sessions.js +130 -0
  10. package/dist/agent/todo.d.ts +18 -0
  11. package/dist/agent/todo.js +61 -0
  12. package/dist/agent/turn.d.ts +309 -0
  13. package/dist/agent/turn.js +1253 -0
  14. package/dist/approval/policy.d.ts +81 -0
  15. package/dist/approval/policy.js +157 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +156 -0
  18. package/dist/git/status.d.ts +89 -0
  19. package/dist/git/status.js +226 -0
  20. package/dist/headless.d.ts +72 -0
  21. package/dist/headless.js +330 -0
  22. package/dist/index.d.ts +60 -0
  23. package/dist/index.js +511 -0
  24. package/dist/protocol/client.d.ts +123 -0
  25. package/dist/protocol/client.js +250 -0
  26. package/dist/protocol/sse-frames.d.ts +6 -0
  27. package/dist/protocol/sse-frames.js +75 -0
  28. package/dist/protocol/types.d.ts +200 -0
  29. package/dist/protocol/types.js +8 -0
  30. package/dist/runtime.d.ts +38 -0
  31. package/dist/runtime.js +166 -0
  32. package/dist/sanitize.d.ts +1 -0
  33. package/dist/sanitize.js +21 -0
  34. package/dist/skills/discovery.d.ts +24 -0
  35. package/dist/skills/discovery.js +109 -0
  36. package/dist/tools/binary.d.ts +2 -0
  37. package/dist/tools/binary.js +22 -0
  38. package/dist/tools/diff.d.ts +1 -0
  39. package/dist/tools/diff.js +49 -0
  40. package/dist/tools/find.d.ts +2 -0
  41. package/dist/tools/find.js +61 -0
  42. package/dist/tools/fs.d.ts +2 -0
  43. package/dist/tools/fs.js +276 -0
  44. package/dist/tools/glob.d.ts +6 -0
  45. package/dist/tools/glob.js +131 -0
  46. package/dist/tools/grep.d.ts +3 -0
  47. package/dist/tools/grep.js +228 -0
  48. package/dist/tools/paths.d.ts +27 -0
  49. package/dist/tools/paths.js +124 -0
  50. package/dist/tools/registry.d.ts +13 -0
  51. package/dist/tools/registry.js +38 -0
  52. package/dist/tools/shell.d.ts +2 -0
  53. package/dist/tools/shell.js +136 -0
  54. package/dist/tools/skills.d.ts +2 -0
  55. package/dist/tools/skills.js +36 -0
  56. package/dist/tools/todo.d.ts +2 -0
  57. package/dist/tools/todo.js +43 -0
  58. package/dist/tools/transfer.d.ts +2 -0
  59. package/dist/tools/transfer.js +145 -0
  60. package/dist/tools/truncate.d.ts +12 -0
  61. package/dist/tools/truncate.js +46 -0
  62. package/dist/tools/types.d.ts +85 -0
  63. package/dist/tools/types.js +63 -0
  64. package/dist/ui/app.d.ts +39 -0
  65. package/dist/ui/app.js +1061 -0
  66. package/dist/ui/colors.d.ts +100 -0
  67. package/dist/ui/colors.js +169 -0
  68. package/dist/ui/components.d.ts +267 -0
  69. package/dist/ui/components.js +811 -0
  70. package/dist/ui/diff.d.ts +37 -0
  71. package/dist/ui/diff.js +143 -0
  72. package/dist/ui/format.d.ts +28 -0
  73. package/dist/ui/format.js +76 -0
  74. package/dist/ui/help.d.ts +6 -0
  75. package/dist/ui/help.js +45 -0
  76. package/dist/ui/highlight.d.ts +20 -0
  77. package/dist/ui/highlight.js +210 -0
  78. package/dist/ui/logo.d.ts +24 -0
  79. package/dist/ui/logo.js +106 -0
  80. package/dist/ui/model-list.d.ts +10 -0
  81. package/dist/ui/model-list.js +33 -0
  82. package/dist/ui/quit-confirm.d.ts +8 -0
  83. package/dist/ui/quit-confirm.js +40 -0
  84. package/dist/ui/select-popup.d.ts +30 -0
  85. package/dist/ui/select-popup.js +54 -0
  86. package/dist/ui/theme.d.ts +4 -0
  87. package/dist/ui/theme.js +41 -0
  88. package/dist/ui/tool-view.d.ts +20 -0
  89. package/dist/ui/tool-view.js +326 -0
  90. package/package.json +44 -0
  91. package/skills/git-commit/SKILL.md +27 -0
@@ -0,0 +1,811 @@
1
+ // -------------------------------------------------------------------
2
+ // Transcript + panel components for the CLI TUI.
3
+ //
4
+ // Every custom component follows pi-tui's contract: `render(width)` returns
5
+ // lines that never exceed `width`, and `invalidate()` drops caches.
6
+ // -------------------------------------------------------------------
7
+ import { Editor, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+ import { MODES } from "../agent/modes.js";
9
+ import { AUTO_COLOR, GRAY_COLOR, GRAY_DIM_COLOR, SESSION_TITLE_COLOR, TITLE_COLOR, assistantColor, bgRgb, diffAdd, diffDel, paintLine, sectionFg, style, userMessageAccent, userMessageColor } from "./colors.js";
10
+ import { fit, formatCacheHit, formatContextNumbers, formatContextPercent, formatCost, formatTokens, stripAnsi, todoMark } from "./format.js";
11
+ import { diffNumber, diffNumberWidth, parseDiffLines, renderPairRows } from "./diff.js";
12
+ import { sanitizeForTerminal } from "../sanitize.js";
13
+ import { markdownTheme } from "./theme.js";
14
+ import { displayToolName, toolBody, toolSummary } from "./tool-view.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Transcript entries
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Messages submitted while a run is active, floating between the transcript
20
+ * and the prompt editor. A steered message is NOT part of the conversation
21
+ * until the server takes it — until then it stays here, one dim preview line
22
+ * per pending message, and is released (into the transcript) on acceptance
23
+ * or when a re-queued follow-up turn actually starts.
24
+ */
25
+ export class QueuedMessagesComponent {
26
+ entries = [];
27
+ invalidate() { }
28
+ add(text) {
29
+ this.entries.push(text);
30
+ }
31
+ /** Release the oldest entry with exactly this text (FIFO — steering the
32
+ * same text twice releases one float per acceptance). */
33
+ release(text) {
34
+ const index = this.entries.indexOf(text);
35
+ if (index >= 0)
36
+ this.entries.splice(index, 1);
37
+ }
38
+ render(width) {
39
+ // Inset to align with the prompt box (Box(2, 0)); one compact line per
40
+ // pending message — the full text renders once it enters the transcript.
41
+ const inset = 2;
42
+ const prefix = "⏳ ";
43
+ const budget = Math.max(4, width - inset - visibleWidth(prefix));
44
+ return this.entries.map((text) => {
45
+ const firstLine = text.split("\n")[0] ?? "";
46
+ const more = text.includes("\n") ? " …" : "";
47
+ const preview = truncateToWidth(firstLine + more, budget, "…", true);
48
+ return `${" ".repeat(inset)}${GRAY_DIM_COLOR(style.italic(`${prefix}${preview}`))}`;
49
+ });
50
+ }
51
+ }
52
+ /**
53
+ * A submitted prompt: a leading blank row (margin from the previous message),
54
+ * a violet block bar down the left edge, and a content-sized tinted bubble
55
+ * (with one row of vertical padding) when the terminal background is known.
56
+ */
57
+ export class UserMessageComponent {
58
+ text;
59
+ queued;
60
+ options;
61
+ constructor(text, queued, options = {}) {
62
+ this.text = text;
63
+ this.queued = queued;
64
+ this.options = options;
65
+ }
66
+ invalidate() { }
67
+ render(width) {
68
+ const bg = this.options.bg?.();
69
+ const indent = " ";
70
+ const contentWidth = Math.max(1, width - indent.length - 2);
71
+ const lines = wrapTextWithAnsi(this.text.trim() || "(empty)", contentWidth);
72
+ const suffix = this.queued ? style.dim(" (queued)") : "";
73
+ // Every line carries the block, so the marker reads as one bar down the
74
+ // whole bubble instead of a one-line accent.
75
+ const rendered = lines.map((line, i) => `${userMessageAccent("▌ ")}${userMessageColor(line)}${i === 0 ? suffix : ""}`);
76
+ // The bubble hugs its content: marker + widest line + a little right padding.
77
+ const bandWidth = Math.max(1, Math.min(width - indent.length, rendered.reduce((widest, line) => Math.max(widest, visibleWidth(line)), 0) + 2));
78
+ const paint = (text) => `${indent}${paintLine(text, bandWidth, bg)}`;
79
+ return [
80
+ "",
81
+ paint(""),
82
+ ...rendered.map(paint),
83
+ paint(""),
84
+ ];
85
+ }
86
+ }
87
+ export class AssistantMessageComponent {
88
+ md;
89
+ /** Raw stream (unsanitized) — the sanitizing pass needs the whole text. */
90
+ raw = "";
91
+ text = "";
92
+ constructor() {
93
+ this.md = new Markdown("", 0, 0, markdownTheme, { color: assistantColor });
94
+ }
95
+ append(delta) {
96
+ // Model text lands on the user's real terminal and can quote untrusted
97
+ // material (file bodies, fetched pages) verbatim — strip terminal
98
+ // sequences, like tool output and headless deltas do. Sanitizing the
99
+ // ACCUMULATED text, not the delta: a sequence split across two deltas
100
+ // would slip through a per-delta pass.
101
+ this.raw += delta;
102
+ this.text = sanitizeForTerminal(this.raw);
103
+ this.md.setText(this.text);
104
+ }
105
+ get content() {
106
+ return this.text;
107
+ }
108
+ render(width) {
109
+ // Render at the widest margin any line gets (list/table lines take 4):
110
+ // pi-tui pads every line to this width, so a narrower inner would push
111
+ // those lines past `width` and fit() would truncate them ("..." on
112
+ // blank lines). Prose/code lines just carry a little less right padding.
113
+ const inner = Math.max(1, width - 4);
114
+ const lines = [];
115
+ let table = false;
116
+ let inCode = false;
117
+ let inList = false;
118
+ for (const line of this.md.render(inner)) {
119
+ const plain = stripAnsi(line);
120
+ // Fenced code otherwise renders with literal ``` fence lines — the
121
+ // raw-markdown tell. Drop them; the codeBlockIndent plus the syntax
122
+ // highlighting (theme.highlightCode) carry the block.
123
+ if (plain.startsWith("```")) {
124
+ inCode = !inCode;
125
+ continue;
126
+ }
127
+ // Code blocks indent first (col 4, level with the list blocks), then
128
+ // the dim bar marks the block: pi-tui's 2-cell codeBlockIndent
129
+ // (pinned in the theme) is swapped out so the code lands at col 6.
130
+ // Code lines must not join the list logic below.
131
+ if (inCode) {
132
+ lines.push(fit(` ${style.dim("▎ ")}${line.slice(2)}`, width));
133
+ continue;
134
+ }
135
+ // List blocks — items, nested items, wrapped continuations — indent one
136
+ // level deeper than prose so they read as a group. A blank line does
137
+ // not end the list (items are separated by blanks); any other
138
+ // non-indented line does.
139
+ if (/^\s*(• |\d+[.)] )/.test(plain))
140
+ inList = true;
141
+ else if (plain.trim() && !/^\s/.test(plain))
142
+ inList = false;
143
+ // h3-h6 render with their literal "### " prefix — strip it; the
144
+ // heading style (h1 additionally underlined) carries the level.
145
+ const headingPrefix = /^#{3,6} /.exec(plain)?.[0];
146
+ const content = headingPrefix ? line.replace(headingPrefix, "") : line;
147
+ const startsTable = content.startsWith("┌");
148
+ const isTable = table || startsTable;
149
+ if (startsTable)
150
+ table = true;
151
+ if (content.startsWith("└"))
152
+ table = false;
153
+ // Table lines are re-indented: drop their trailing padding first so
154
+ // fit() cannot clip the right border.
155
+ const margin = isTable || inList ? " " : " ";
156
+ lines.push(fit(`${margin}${isTable ? content.trimEnd() : content}`, width));
157
+ }
158
+ return lines;
159
+ }
160
+ invalidate() {
161
+ this.md.invalidate();
162
+ }
163
+ }
164
+ export class ReasoningBlockComponent {
165
+ raw = "";
166
+ text = "";
167
+ expanded = false;
168
+ append(delta) {
169
+ // Same rule as assistant text: reasoning is model output rendered to the
170
+ // real terminal, and it quotes tool output freely.
171
+ this.raw += delta;
172
+ this.text = sanitizeForTerminal(this.raw);
173
+ }
174
+ toggle() {
175
+ this.expanded = !this.expanded;
176
+ }
177
+ get hasContent() {
178
+ return this.text.trim().length > 0;
179
+ }
180
+ invalidate() { }
181
+ render(width) {
182
+ if (!this.hasContent)
183
+ return [];
184
+ if (!this.expanded) {
185
+ const chars = this.text.trim().length;
186
+ return [truncateToWidth(style.dim(` ▸ thinking (${chars} chars · Ctrl+T)`), width)];
187
+ }
188
+ const body = wrapTextWithAnsi(style.dim(this.text.trim()), Math.max(1, width - 4));
189
+ return [
190
+ truncateToWidth(style.dim(" ▾ thinking"), width),
191
+ ...body.map((line) => truncateToWidth(` ${line}`, width)),
192
+ "",
193
+ ];
194
+ }
195
+ }
196
+ export class ToolCallComponent {
197
+ name;
198
+ args;
199
+ status = "running";
200
+ expanded = false;
201
+ output = "";
202
+ bodyCache;
203
+ constructor(name, args) {
204
+ this.name = name;
205
+ this.args = args;
206
+ }
207
+ setResult(output, isError) {
208
+ this.output = output;
209
+ this.status = isError ? "error" : "done";
210
+ }
211
+ toggle() {
212
+ this.expanded = !this.expanded;
213
+ }
214
+ /** The whole row is the button: press is consumed, the release click toggles. */
215
+ handleMouse(event) {
216
+ if (event.type !== "press" && event.type !== "click")
217
+ return undefined;
218
+ if (event.type === "press")
219
+ return { render: false };
220
+ this.toggle();
221
+ return {};
222
+ }
223
+ invalidate() { }
224
+ /** Tool-specific expanded body, cached per width/result (renders on spinner ticks). */
225
+ body(width) {
226
+ const isError = this.status === "error";
227
+ const cached = this.bodyCache;
228
+ if (cached && cached.width === width && cached.output === this.output && cached.isError === isError) {
229
+ return cached.lines;
230
+ }
231
+ const lines = toolBody(this.name, this.args, this.output, isError, width);
232
+ this.bodyCache = { width, output: this.output, isError, lines };
233
+ return lines;
234
+ }
235
+ render(width) {
236
+ const glyph = this.status === "running" ? style.yellow("●") : this.status === "error" ? style.red("✗") : style.green("✓");
237
+ const marker = style.dim(this.expanded ? "▾" : "▸");
238
+ const summary = toolSummary(this.name, this.args);
239
+ const header = ` ${marker} ${glyph} ${style.bold(TITLE_COLOR(displayToolName(this.name)))}${summary ? ` ${style.dim(summary)}` : ""}`;
240
+ const lines = [truncateToWidth(header, width)];
241
+ if (this.expanded) {
242
+ const bodyWidth = Math.max(8, width - 6);
243
+ for (const line of this.body(bodyWidth))
244
+ lines.push(truncateToWidth(` ${line}`, width));
245
+ }
246
+ lines.push("");
247
+ return lines;
248
+ }
249
+ }
250
+ export class NoticeComponent {
251
+ text;
252
+ level;
253
+ constructor(text, level) {
254
+ this.text = text;
255
+ this.level = level;
256
+ }
257
+ invalidate() { }
258
+ render(width) {
259
+ const color = this.level === "error" ? style.red : this.level === "warn" ? style.yellow : style.dim;
260
+ // Notices carry server-driven text and error messages — untrusted.
261
+ return [truncateToWidth(color(` ${sanitizeForTerminal(this.text)}`), width), ""];
262
+ }
263
+ }
264
+ // ---------------------------------------------------------------------------
265
+ // TODO panel
266
+ // ---------------------------------------------------------------------------
267
+ /** Checkbox glyph colored by status (sidebar TODO section). */
268
+ function todoGlyph(status) {
269
+ const mark = todoMark(status);
270
+ return status === "completed" ? style.green(mark) : status === "in_progress" ? style.yellow(mark) : style.dim(mark);
271
+ }
272
+ const COLLAPSED_FILES = 10;
273
+ export class SidebarComponent {
274
+ state;
275
+ opts;
276
+ expanded = false;
277
+ targets = new Map();
278
+ constructor(state, opts) {
279
+ this.state = state;
280
+ this.opts = opts;
281
+ }
282
+ invalidate() { }
283
+ toggleChanges() {
284
+ this.expanded = !this.expanded;
285
+ }
286
+ /** What a click at content line `y` hits (coordinates include scrollTop). */
287
+ targetAt(y) {
288
+ return this.targets.get(y);
289
+ }
290
+ render(width) {
291
+ const w = Math.max(8, width);
292
+ const panel = this.opts.panelBg?.();
293
+ const fill = (line) => {
294
+ const padded = truncateToWidth(line, w, "…", true);
295
+ return panel ? bgRgb(panel, padded) : padded;
296
+ };
297
+ const lines = [];
298
+ const push = (line) => lines.push(fill(line));
299
+ const blank = () => push("");
300
+ const track = (target) => {
301
+ this.targets.set(lines.length - 1, target);
302
+ };
303
+ // Section header: a hue-colored accent bar + the soft light gray title.
304
+ const section = (name, label) => {
305
+ push(` ${sectionFg(name, panel)("▌")}${style.bold(TITLE_COLOR(label))}`);
306
+ };
307
+ // Section content is indented under its header.
308
+ const value = (line) => push(` ${line}`);
309
+ this.targets.clear();
310
+ // Breathing room: a blank line above/below the title and after every
311
+ // section (the panel background makes the gaps read as intentional).
312
+ blank();
313
+ // The session title is the panel's headline — brighter than the section
314
+ // headers below it. The "New session" placeholder stays at section-header
315
+ // level so it doesn't read as a real title.
316
+ push(this.state.title
317
+ ? ` ${style.bold(SESSION_TITLE_COLOR(this.state.title))}`
318
+ : ` ${style.bold(TITLE_COLOR("New session"))}`);
319
+ blank();
320
+ const usage = this.state.usage;
321
+ if (usage) {
322
+ section("context", "CONTEXT");
323
+ const percent = formatContextPercent(usage.contextTokens, usage.contextWindow);
324
+ value(GRAY_COLOR(`${formatContextNumbers(usage.contextTokens, usage.contextWindow)}${percent === "—" ? "" : ` (${percent} used)`}`));
325
+ const cost = formatCost(usage);
326
+ const costText = cost ? style.yellow(`~${cost}`) : style.dim("—");
327
+ value(`${costText} ${GRAY_DIM_COLOR(`(${formatCacheHit(usage)} cached)`)}`);
328
+ blank();
329
+ }
330
+ const todoItems = this.opts.todos.list();
331
+ if (todoItems.length > 0) {
332
+ section("todo", `TODO (${this.opts.todos.doneCount}/${todoItems.length})`);
333
+ for (const item of todoItems) {
334
+ const content = item.content.replace(/\s+/g, " ");
335
+ value(`${todoGlyph(item.status)} ${item.status === "completed" ? GRAY_DIM_COLOR(content) : GRAY_COLOR(content)}`);
336
+ }
337
+ blank();
338
+ }
339
+ if (this.state.git.isRepo) {
340
+ const files = this.state.git.files;
341
+ section("changes", `CHANGES (${files.length})`);
342
+ if (files.length === 0) {
343
+ value(GRAY_DIM_COLOR("clean"));
344
+ }
345
+ else {
346
+ const shown = this.expanded ? files : files.slice(0, COLLAPSED_FILES);
347
+ for (const file of shown) {
348
+ const counts = file.insertions === 0 && file.deletions === 0 ? "" :
349
+ ` ${diffAdd(`+${file.insertions}`)} ${diffDel(`-${file.deletions}`)}`;
350
+ const label = GRAY_COLOR(truncateToWidth(`${file.status} ${file.path}`, Math.max(4, w - 14)));
351
+ push(` ${label}${counts}`);
352
+ track({ type: "file", file });
353
+ }
354
+ if (files.length > shown.length) {
355
+ value(`${style.cyan("…")} ${GRAY_COLOR(`${files.length - shown.length} more`)} ${GRAY_DIM_COLOR("(click to expand)")}`);
356
+ track({ type: "toggle-changes" });
357
+ }
358
+ else if (this.expanded && files.length > COLLAPSED_FILES) {
359
+ value(`${style.cyan("−")} ${GRAY_COLOR("collapse")}`);
360
+ track({ type: "toggle-changes" });
361
+ }
362
+ if (this.state.git.totalInsertions || this.state.git.totalDeletions) {
363
+ value(`${diffAdd(`+${this.state.git.totalInsertions}`)} ${diffDel(`-${this.state.git.totalDeletions}`)} ${GRAY_COLOR("total")}`);
364
+ }
365
+ }
366
+ blank();
367
+ }
368
+ // Fill the empty space below the content so the panel background covers
369
+ // the whole column. Only when content is shorter than the viewport.
370
+ const viewport = this.opts.viewportHeight?.() ?? 0;
371
+ const filler = panel ? bgRgb(panel, " ".repeat(w)) : " ".repeat(w);
372
+ while (lines.length < viewport)
373
+ lines.push(filler);
374
+ return lines;
375
+ }
376
+ }
377
+ export class PromptEditor extends Editor {
378
+ mode;
379
+ model = null;
380
+ thinking;
381
+ /** Fired when a bottom-edge chip (mode / model / thinking) is clicked. */
382
+ onChipClick;
383
+ /** Chip column extents from the last bottom-edge render, for hit-testing. */
384
+ chipRanges = [];
385
+ /** Bottom-edge row in this component's coordinates (-1 = no bottom edge). */
386
+ bottomBorderRow = -1;
387
+ /**
388
+ * The exact bottom-edge line rendered this frame. render() locates the
389
+ * edge by identity against it — scanning for a "╰" substring would misfire
390
+ * on user content that happens to contain the border glyph.
391
+ */
392
+ lastBottomBorder = "";
393
+ constructor(tui, theme, mode, thinking) {
394
+ super(tui, theme, { paddingX: 2 });
395
+ this.mode = mode;
396
+ this.thinking = thinking;
397
+ }
398
+ renderTopBorder(width, _hiddenLineCount) {
399
+ return this.borderColor(`╭${"─".repeat(Math.max(0, width))}╮`);
400
+ }
401
+ /**
402
+ * Alt+Enter inserts a newline exactly like Shift+Enter — the base editor
403
+ * only knows Shift+Enter/Ctrl+J, but terminals send Alt+Enter as ESC+CR
404
+ * (legacy) or a Kitty/modifyOtherKeys sequence.
405
+ */
406
+ handleInput(data) {
407
+ if (matchesKey(data, "alt+enter")) {
408
+ this.insertTextAtCursor("\n");
409
+ return;
410
+ }
411
+ super.handleInput(data);
412
+ }
413
+ renderBottomBorder(width, hiddenLineCount) {
414
+ const label = ` ${MODES[this.mode].label} `;
415
+ const modelText = this.model ?? "no model";
416
+ const thinkingText = this.thinking ?? "default";
417
+ const info = `· ${modelText} · ${thinkingText} `;
418
+ const suffix = hiddenLineCount > 0 ? `↓ ${hiddenLineCount} more ` : "";
419
+ const fill = width - visibleWidth(label) - visibleWidth(info) - visibleWidth(suffix) - 1;
420
+ if (fill < 0) {
421
+ this.chipRanges = [];
422
+ this.lastBottomBorder = this.borderColor(`╰${"─".repeat(Math.max(0, width))}╯`);
423
+ return this.lastBottomBorder;
424
+ }
425
+ // Chip extents for mouse hit-testing, in columns of the rendered edge
426
+ // line (which spans the full component width, ╰ included).
427
+ const labelStart = 2; // "╰─"
428
+ const infoStart = labelStart + visibleWidth(label);
429
+ const modelStart = infoStart + 2; // "· "
430
+ const thinkingStart = modelStart + visibleWidth(modelText) + 3; // " · "
431
+ this.chipRanges = [
432
+ { chip: "mode", start: labelStart, end: infoStart },
433
+ { chip: "model", start: modelStart, end: modelStart + visibleWidth(modelText) },
434
+ { chip: "thinking", start: thinkingStart, end: thinkingStart + visibleWidth(thinkingText) },
435
+ ];
436
+ // The gray info segment resets the foreground, so re-open the frame color
437
+ // for the trailing rule.
438
+ this.lastBottomBorder = this.borderColor(`╰─${style.bold(label)}`) + GRAY_COLOR(info)
439
+ + this.borderColor(`${"─".repeat(fill)}${suffix}╯`);
440
+ return this.lastBottomBorder;
441
+ }
442
+ /**
443
+ * Mouse dispatch for the framed editor. The bottom edge carries the
444
+ * mode/model/thinking chips; every other event is translated into the base
445
+ * editor's coordinate system — our frame inserts a padding row below the
446
+ * top edge and above the bottom edge, and the side borders shift text rows
447
+ * one column right, so without the shift the base's cursor/autocomplete
448
+ * hit-testing lands one row and one column off.
449
+ */
450
+ handleMouse(event) {
451
+ if (event.type === "click" && event.button === "left" && event.y === this.bottomBorderRow) {
452
+ const chip = this.chipRanges.find((range) => event.x >= range.start && event.x < range.end);
453
+ if (chip) {
454
+ this.onChipClick?.(chip.chip);
455
+ return { handled: true, focus: true };
456
+ }
457
+ }
458
+ if (this.bottomBorderRow === -1)
459
+ return super.handleMouse(event);
460
+ const y = event.y <= this.bottomBorderRow ? event.y - 1 : event.y - 2;
461
+ const x = event.y >= 2 && event.y <= this.bottomBorderRow ? event.x - 1 : event.x;
462
+ return super.handleMouse({ ...event, x, y });
463
+ }
464
+ render(width) {
465
+ // The frame owns one column on each side.
466
+ const inner = Math.max(4, width - 2);
467
+ const side = (line) => `${this.borderColor("│")}${line}${this.borderColor("│")}`;
468
+ // Reset before the base render — renderBottomBorder (called inside
469
+ // super.render) refills it, so a stale value can never match.
470
+ this.lastBottomBorder = "";
471
+ const lines = super.render(inner);
472
+ const bottom = this.lastBottomBorder
473
+ ? lines.findIndex((line, index) => index > 0 && line === this.lastBottomBorder)
474
+ : -1;
475
+ if (bottom === -1) {
476
+ this.bottomBorderRow = -1;
477
+ return lines.map((line, i) => (i > 0 ? side(line) : line));
478
+ }
479
+ // One blank row of vertical padding just inside the frame, on each side.
480
+ this.bottomBorderRow = bottom + 2;
481
+ const padding = side(" ".repeat(inner));
482
+ return [
483
+ lines[0],
484
+ padding,
485
+ ...lines.slice(1, bottom).map(side),
486
+ padding,
487
+ ...lines.slice(bottom), // bottom edge and the autocomplete list
488
+ ];
489
+ }
490
+ }
491
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
492
+ /** Left indent of the footer's item row (also the ASK/AUTO badge's column). */
493
+ const FOOTER_INDENT = 4;
494
+ export class FooterComponent {
495
+ state;
496
+ onApprovalClick;
497
+ /** Fired when the ASK/AUTO badge is clicked. */
498
+ constructor(state, onApprovalClick) {
499
+ this.state = state;
500
+ this.onApprovalClick = onApprovalClick;
501
+ }
502
+ invalidate() { }
503
+ handleMouse(event) {
504
+ if (event.type !== "click" || event.button !== "left" || !this.onApprovalClick)
505
+ return undefined;
506
+ // The badge is the leftmost item of the row.
507
+ const badge = this.state.approval === "auto" ? "AUTO" : "ASK";
508
+ if (event.x >= FOOTER_INDENT && event.x < FOOTER_INDENT + badge.length) {
509
+ this.onApprovalClick();
510
+ return { handled: true };
511
+ }
512
+ return undefined;
513
+ }
514
+ render(width) {
515
+ const dim = GRAY_DIM_COLOR;
516
+ // AUTO runs unattended: amber, so it stands out next to the neutral ASK.
517
+ const approval = this.state.approval === "auto" ? AUTO_COLOR("AUTO") : GRAY_COLOR("ASK");
518
+ const status = this.state.pendingQuit
519
+ ? style.yellow("Ctrl+C again to quit · /quit")
520
+ : this.state.runState === "running"
521
+ ? style.yellow(`${SPINNER[this.state.spinnerFrame % SPINNER.length]} working`)
522
+ : this.state.runState === "stopping"
523
+ ? style.yellow("stopping…")
524
+ : dim("idle");
525
+ const tokens = dim(`↑${formatTokens(this.state.usage?.input ?? 0)} ↓${formatTokens(this.state.usage?.output ?? 0)}`);
526
+ const cost = formatCost(this.state.usage);
527
+ const path = this.compactPath(this.state.workspace);
528
+ // Run state, single-run tokens and cost follow the approval badge; the
529
+ // workspace path is flush right.
530
+ const left = [approval, status, tokens, cost ? dim(cost) : ""].filter(Boolean).join(dim(" · "));
531
+ const right = dim(this.state.branch ? `${path}:${this.state.branch}` : path);
532
+ const rightMargin = 4;
533
+ const gap = width - FOOTER_INDENT - rightMargin - visibleWidth(left) - visibleWidth(right);
534
+ if (gap >= 1) {
535
+ return [truncateToWidth(`${" ".repeat(FOOTER_INDENT)}${left}${" ".repeat(gap)}${right}`, width)];
536
+ }
537
+ return [truncateToWidth(`${" ".repeat(FOOTER_INDENT)}${left}${dim(" · ")}${right}`, width)];
538
+ }
539
+ compactPath(path) {
540
+ const home = process.env.HOME;
541
+ return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
542
+ }
543
+ }
544
+ /**
545
+ * Draw one popup card: a rounded frame with the title in the top edge and an
546
+ * optional hint in the bottom edge. Every line is padded to exactly `width`
547
+ * (and tinted) because pi-tui composites overlays over the base content and
548
+ * fills only the columns outside each rendered line.
549
+ */
550
+ function renderCard(width, title, body, options) {
551
+ const bg = options.bg?.();
552
+ const inner = Math.max(1, width - 4); // two frame columns + one padding column each side
553
+ const fill = (text) => paintLine(text, width, bg);
554
+ const edge = (corners, label, labelStyle) => {
555
+ const available = Math.max(0, width - 2);
556
+ if (!label)
557
+ return fill(options.frame(`${corners[0]}${"─".repeat(available)}${corners[1]}`));
558
+ const labelWidth = visibleWidth(label);
559
+ const rules = available - labelWidth - 3; // "─ " + label + " " + trailing rules
560
+ if (rules < 1) {
561
+ return fill(options.frame(`${corners[0]}${truncateToWidth(` ${labelStyle(label)} `, available, "…", true)}${corners[1]}`));
562
+ }
563
+ return fill(options.frame(`${corners[0]}─ ${labelStyle(label)} ${"─".repeat(rules)}${corners[1]}`));
564
+ };
565
+ const lines = [edge(["╭", "╮"], title, style.bold)];
566
+ const blank = fill(`${options.frame("│")} ${" ".repeat(inner)} ${options.frame("│")}`);
567
+ lines.push(blank);
568
+ for (const line of body) {
569
+ const content = truncateToWidth(line, inner, "…", true);
570
+ const pad = " ".repeat(Math.max(0, inner - visibleWidth(content)));
571
+ lines.push(fill(`${options.frame("│")} ${content}${pad} ${options.frame("│")}`));
572
+ }
573
+ lines.push(blank);
574
+ lines.push(edge(["╰", "╯"], options.hint ?? "", style.dim));
575
+ return lines;
576
+ }
577
+ /**
578
+ * A pending approval, floating between the transcript and the prompt (NOT a
579
+ * modal overlay — the editor keeps focus, so typing stays possible while the
580
+ * user decides). Answered with ctrl+y / ctrl+n from the app's global key
581
+ * listener; the card itself is render-only.
582
+ */
583
+ export class ApprovalFloatComponent {
584
+ request = null;
585
+ invalidate() { }
586
+ /** Show (or clear) the approval being decided. */
587
+ set(request) {
588
+ this.request = request;
589
+ }
590
+ render(width) {
591
+ const request = this.request;
592
+ if (!request)
593
+ return [];
594
+ // Inset to align with the prompt box (Box(2, 0)), like the steer float.
595
+ const inset = 2;
596
+ const budget = Math.max(4, width - inset);
597
+ // The tool name and summary are derived from model-authored arguments
598
+ // (a shell command, a path): unsanitized they could rewrite or hide the
599
+ // very line the user is about to approve.
600
+ const head = truncateToWidth(sanitizeForTerminal(`⏸ Approve ${request.tool}? ${request.summary}`), budget, "…", true);
601
+ const hint = "ctrl+y approve · ctrl+n deny";
602
+ return [
603
+ `${" ".repeat(inset)}${style.yellow(style.bold(head))}`,
604
+ `${" ".repeat(inset)}${GRAY_DIM_COLOR(style.dim(hint))}`,
605
+ ];
606
+ }
607
+ }
608
+ export class InfoOverlay {
609
+ title;
610
+ body;
611
+ close;
612
+ options;
613
+ constructor(title, body, close, options = {}) {
614
+ this.title = title;
615
+ this.body = body;
616
+ this.close = close;
617
+ this.options = options;
618
+ }
619
+ handleInput(data) {
620
+ if (data === "\u001b" || data === "q" || data === "\r" || data === "\n" || data === " ")
621
+ this.close();
622
+ }
623
+ invalidate() { }
624
+ render(width) {
625
+ return renderCard(width, this.title, this.body, {
626
+ frame: style.cyan,
627
+ bg: this.options.bg,
628
+ hint: this.options.hint ?? "Esc / q to close",
629
+ });
630
+ }
631
+ }
632
+ /**
633
+ * Scrollable popup for a single file's unified diff. Overlays are clipped, not
634
+ * scrolled by the framework, so the window is managed here: the popup renders
635
+ * exactly `viewportHeight() + 2` lines (top edge, body, bottom edge) and moves the
636
+ * window with the arrow/page keys.
637
+ */
638
+ export class DiffOverlay {
639
+ title;
640
+ options;
641
+ scrollTop = 0;
642
+ mode = "inline";
643
+ rowCount;
644
+ /** Content rows shown in the viewer (file headers and hunk headers hidden). */
645
+ visible;
646
+ cache;
647
+ constructor(title, diff, options) {
648
+ this.title = title;
649
+ this.options = options;
650
+ // Diff text is raw workspace file content (git output) — sanitize before
651
+ // parsing so embedded OSC/CSI sequences cannot reach the terminal.
652
+ const lines = parseDiffLines(sanitizeForTerminal(diff));
653
+ const content = lines.filter((line) => line.kind !== "meta" && line.kind !== "hunk");
654
+ // Metadata-only diffs (binary files, mode changes) keep their notice.
655
+ this.visible = content.length > 0 ? content : lines;
656
+ this.rowCount = this.visible.length; // until the first render, inline rows
657
+ }
658
+ viewHeight() {
659
+ return Math.max(1, this.options.viewportHeight());
660
+ }
661
+ get maxTop() {
662
+ return Math.max(0, this.rowCount - this.viewHeight());
663
+ }
664
+ scrollBy(delta) {
665
+ this.scrollTop = Math.max(0, Math.min(this.maxTop, this.scrollTop + delta));
666
+ }
667
+ /** Paint one diff line, using the same palette as the transcript. */
668
+ paint(line) {
669
+ if (line.kind === "add")
670
+ return diffAdd(line.text);
671
+ if (line.kind === "del")
672
+ return diffDel(line.text);
673
+ return line.kind === "context" ? line.text : style.dim(line.text);
674
+ }
675
+ buildRows(contentWidth) {
676
+ const nums = diffNumberWidth(this.visible);
677
+ // " <num> │ " prefix per row; meta/hunk rows keep it blank for alignment.
678
+ const lead = (oldNumber, newNumber) => `${diffNumber(oldNumber, nums)} ${diffNumber(newNumber, nums)} ${style.dim("│")} `;
679
+ const textWidth = Math.max(4, contentWidth - nums * 2 - 4);
680
+ if (this.mode === "inline") {
681
+ return this.visible.map((line) => `${lead(line.oldLine, line.newLine)}${truncateToWidth(this.paint(line), textWidth, "…")}`);
682
+ }
683
+ return renderPairRows(this.visible, contentWidth, (line) => this.paint(line));
684
+ }
685
+ rows(contentWidth) {
686
+ if (this.cache && this.cache.width === contentWidth && this.cache.mode === this.mode) {
687
+ return this.cache.rows;
688
+ }
689
+ const rows = this.buildRows(contentWidth);
690
+ this.rowCount = rows.length;
691
+ this.cache = { width: contentWidth, mode: this.mode, rows };
692
+ return rows;
693
+ }
694
+ handleInput(data) {
695
+ if (matchesKey(data, "escape") || data === "q" || matchesKey(data, "ctrl+c")) {
696
+ this.options.close();
697
+ }
698
+ else if (data === "t") {
699
+ this.mode = this.mode === "inline" ? "pair" : "inline";
700
+ }
701
+ else if (data === "i") {
702
+ this.mode = "inline";
703
+ }
704
+ else if (data === "p") {
705
+ this.mode = "pair";
706
+ }
707
+ else if (matchesKey(data, "up")) {
708
+ this.scrollBy(-1);
709
+ }
710
+ else if (matchesKey(data, "down")) {
711
+ this.scrollBy(1);
712
+ }
713
+ else if (matchesKey(data, "pageUp")) {
714
+ this.scrollBy(-Math.max(1, this.viewHeight() - 2));
715
+ }
716
+ else if (matchesKey(data, "pageDown")) {
717
+ this.scrollBy(Math.max(1, this.viewHeight() - 2));
718
+ }
719
+ else if (matchesKey(data, "home")) {
720
+ this.scrollTop = 0;
721
+ }
722
+ else if (matchesKey(data, "end")) {
723
+ this.scrollTop = this.maxTop;
724
+ }
725
+ }
726
+ /** The mouse wheel scrolls the diff like the arrow keys do. */
727
+ handleMouse(event) {
728
+ if (event.type !== "wheel" || !event.wheelDelta)
729
+ return undefined;
730
+ this.scrollBy(event.wheelDelta);
731
+ return { render: true };
732
+ }
733
+ invalidate() { }
734
+ render(width) {
735
+ // Full-bleed canvas, with a rounded gray card drawn inside it (the prompt
736
+ // editor's look) so nothing behind the viewer leaks through.
737
+ const inner = Math.max(24, width);
738
+ const margin = 2;
739
+ const frameWidth = Math.max(20, inner - margin * 2);
740
+ const contentWidth = Math.max(10, frameWidth - 4);
741
+ const rows = this.rows(contentWidth);
742
+ const view = this.viewHeight(); // live: a zoom/resize refits the card
743
+ const start = Math.min(this.scrollTop, Math.max(0, rows.length - view));
744
+ this.scrollTop = start; // keep the clamped position across resizes
745
+ const end = Math.min(rows.length, start + view);
746
+ const position = rows.length === 0
747
+ ? "no diff"
748
+ : `${start + 1}-${Math.max(start + 1, end)} / ${rows.length}${this.options.truncated ? " · truncated" : ""}`;
749
+ const frame = GRAY_COLOR;
750
+ const line = (text) => truncateToWidth(`${" ".repeat(margin)}${text}${" ".repeat(margin)}`, inner, "…", true);
751
+ const body = (text) => line(`${frame("│")} ${truncateToWidth(text, contentWidth, "…", true)} ${frame("│")}`);
752
+ // A card edge is a rounded rule with its label centered in it.
753
+ const edgeWidth = Math.max(0, frameWidth - 2);
754
+ const edge = (corners, label, labelWidth) => {
755
+ const before = Math.max(1, Math.floor((edgeWidth - labelWidth) / 2));
756
+ const after = edgeWidth - before - labelWidth;
757
+ return after >= 0
758
+ ? frame(`${corners[0]}${"─".repeat(before)}${label}${"─".repeat(after)}${corners[1]}`)
759
+ : frame(`${corners[0]}${truncateToWidth(label, edgeWidth, "…")}${corners[1]}`);
760
+ };
761
+ const title = ` ${this.title} ${position} · ${this.mode} `;
762
+ const titleStyled = ` ${style.bold(this.title)} ${style.dim(`${position} · ${this.mode}`)} `;
763
+ const topEdge = edge(["╭", "╮"], titleStyled, visibleWidth(title));
764
+ const hint = " ↑/↓ PgUp/PgDn · t inline/pair · Esc to close ";
765
+ const lines = [
766
+ line(topEdge),
767
+ ...(rows.length === 0
768
+ ? [body(style.dim("No diff available."))]
769
+ : rows.slice(start, end).map((row) => body(row))),
770
+ ];
771
+ // The card is a fixed-height canvas: pad the body to the viewport.
772
+ while (lines.length < view + 1)
773
+ lines.push(body(""));
774
+ lines.push(line(edge(["╰", "╯"], hint, visibleWidth(hint))));
775
+ return lines;
776
+ }
777
+ }
778
+ /**
779
+ * Select list inside a popup card, usable as a focused overlay: input and
780
+ * mouse events are forwarded to the inner SelectList (a bare Container would
781
+ * swallow them), with the card's top edge and padding row skipped in the
782
+ * mouse coordinates (renderCard: edge, blank, then list rows).
783
+ */
784
+ export class SelectOverlay {
785
+ title;
786
+ list;
787
+ options;
788
+ constructor(title, list, options = {}) {
789
+ this.title = title;
790
+ this.list = list;
791
+ this.options = options;
792
+ }
793
+ handleInput(data) {
794
+ this.list.handleInput(data);
795
+ }
796
+ handleMouse(event) {
797
+ return this.list.handleMouse({ ...event, y: event.y - 2 });
798
+ }
799
+ invalidate() {
800
+ this.list.invalidate();
801
+ }
802
+ render(width) {
803
+ if (width < 8)
804
+ return [truncateToWidth(this.title, Math.max(1, width))];
805
+ return renderCard(width, this.title, this.list.render(Math.max(1, width - 4)), {
806
+ frame: GRAY_COLOR,
807
+ bg: this.options.bg,
808
+ hint: this.options.hint ?? "↑/↓ · Enter · Esc",
809
+ });
810
+ }
811
+ }