termux-dev 1.6.0 → 1.8.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 (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
package/dist/cli/ui.js ADDED
@@ -0,0 +1,1021 @@
1
+ import pc from 'picocolors';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { getCurrentTheme } from './theme.js';
5
+ import { t } from '../core/i18n.js';
6
+ import { formatMoney } from '../core/cost.js';
7
+ import { codePointWidth, displayWidth, padEndWidth, stripAnsi, truncateWidth, wrapText, wrapWidth, wrapWords, } from './width.js';
8
+ /**
9
+ * devx visual language.
10
+ *
11
+ * - one accent colour (the active theme), everything else is a quiet grey ramp;
12
+ * - three modes with their own colour: agent (accent), plan (violet), yolo (orange);
13
+ * - rounded frames, a prompt that carries the status (mode, model, context, cost) in its
14
+ * borders, a `●` gutter for the assistant and `◆` lines for tool calls;
15
+ * - every width is measured in terminal cells and every layout has a narrow variant
16
+ * (Termux in portrait is ~40 columns).
17
+ *
18
+ * Everything here is a pure function of its inputs (plus the active theme) so it can be
19
+ * unit-tested without a terminal.
20
+ */
21
+ // ───────────────────────── palette ─────────────────────────
22
+ const colorOn = () => pc.isColorSupported;
23
+ const tokens = () => getCurrentTheme().tokens;
24
+ /** Every colour of the interface comes from the active theme (see theme.ts). */
25
+ export const palette = {
26
+ text: (s) => tokens().text(s),
27
+ muted: (s) => tokens().muted(s),
28
+ faint: (s) => tokens().faint(s),
29
+ rule: (s) => tokens().rule(s),
30
+ ok: (s) => tokens().ok(s),
31
+ warn: (s) => tokens().warn(s),
32
+ err: (s) => tokens().err(s),
33
+ plan: (s) => tokens().plan(s),
34
+ yolo: (s) => tokens().yolo(s),
35
+ shade: (s) => tokens().shade(s),
36
+ accent: (s) => tokens().accent(s),
37
+ bold: (s) => (colorOn() ? `\x1b[1m${s}\x1b[22m` : s),
38
+ };
39
+ export function resolveMode(planMode, yolo) {
40
+ if (planMode)
41
+ return 'plan';
42
+ return yolo ? 'yolo' : 'agent';
43
+ }
44
+ export function modeStyle(mode) {
45
+ switch (mode) {
46
+ case 'plan':
47
+ return { label: 'plan', glyph: '◆', color: palette.plan };
48
+ case 'yolo':
49
+ return { label: 'yolo', glyph: '▲', color: palette.yolo };
50
+ default:
51
+ return { label: 'agent', glyph: '●', color: palette.accent };
52
+ }
53
+ }
54
+ // ───────────────────────── small helpers ─────────────────────────
55
+ export function formatTokens(n) {
56
+ if (n >= 1_000_000)
57
+ return `${(n / 1_000_000).toFixed(1)}M`;
58
+ if (n >= 1_000)
59
+ return `${(n / 1_000).toFixed(1)}k`;
60
+ return `${Math.round(n)}`;
61
+ }
62
+ /** "$0.0123", "~$0.0123" (estimate), "$0.0123+" (some requests unpriced) or "n/a". */
63
+ export function formatCost(cost, state = {}) {
64
+ return formatMoney(cost, { priced: cost > 0 ? 1 : (state.priced ?? 1), ...state });
65
+ }
66
+ export function formatDuration(ms) {
67
+ const s = ms / 1000;
68
+ if (s < 10)
69
+ return `${s.toFixed(1)}s`;
70
+ if (s < 60)
71
+ return `${Math.round(s)}s`;
72
+ const m = Math.floor(s / 60);
73
+ return `${m}m ${Math.round(s - m * 60)}s`;
74
+ }
75
+ /** `/home/me/projects/app` → `~/projects/app`, shortened from the left to fit `max` cells. */
76
+ export function shortenPath(p, max, home = os.homedir()) {
77
+ let s = p;
78
+ if (s === home)
79
+ s = '~';
80
+ else if (s.startsWith(home + path.sep))
81
+ s = '~' + s.slice(home.length);
82
+ s = s.split(path.sep).join('/');
83
+ if (displayWidth(s) <= max)
84
+ return s;
85
+ const parts = s.split('/');
86
+ let out = parts[parts.length - 1];
87
+ for (let i = parts.length - 2; i >= 0; i--) {
88
+ const candidate = `${parts[i]}/${out}`;
89
+ if (displayWidth(`…/${candidate}`) > max)
90
+ break;
91
+ out = candidate;
92
+ }
93
+ return truncateWidth(`…/${out}`, max);
94
+ }
95
+ /** A thin context meter: `━━━━────`. */
96
+ export function renderMeter(percent, cells) {
97
+ const clamped = Math.max(0, Math.min(100, percent));
98
+ const filled = Math.round((clamped / 100) * cells);
99
+ const color = clamped >= 90 ? palette.err : clamped >= 75 ? palette.warn : palette.accent;
100
+ return color('━'.repeat(filled)) + palette.faint('─'.repeat(Math.max(0, cells - filled)));
101
+ }
102
+ const BADGE = /(\[(?:Pasted text #\d+ \+\d+ lines|\d+\.png \d+kb)\])/g;
103
+ /** Attachment tags such as `[Pasted text #1 +12 lines]` are drawn muted. */
104
+ export function formatBadges(text) {
105
+ return text.replace(BADGE, (m) => palette.muted(m));
106
+ }
107
+ /** The header card is a card: it stops growing at a comfortable width. */
108
+ export function contentWidth(cols) {
109
+ return Math.max(24, Math.min(cols - 1, 88));
110
+ }
111
+ /** The prompt and the echo of a sent message use the full terminal width. */
112
+ export function frameWidth(cols) {
113
+ return Math.max(20, cols - 1);
114
+ }
115
+ /** Below this width (a phone in portrait) the prompt is a single compact line. */
116
+ export const COMPACT_COLS = 60;
117
+ export function isCompact(cols) {
118
+ return cols < COMPACT_COLS;
119
+ }
120
+ export const TIPS = [
121
+ 'Press Tab to switch between plan and agent mode',
122
+ 'Type @ to attach a file, or paste a screenshot with Ctrl+V',
123
+ '/undo reverts the last file edits made by the agent',
124
+ '/compact shrinks the context when a session gets long',
125
+ '/serve previews a web app on your phone through a QR code',
126
+ '/memory add <rule> teaches devx the conventions of your project',
127
+ 'Use devx -p "task" for one-shot runs in scripts and CI',
128
+ '/model switches the model, /theme changes the colours',
129
+ ];
130
+ export function pickTip(now = Date.now()) {
131
+ return TIPS[Math.floor(now / 86_400_000) % TIPS.length];
132
+ }
133
+ function brand(version) {
134
+ return palette.accent('▍') + palette.bold('devx') + ' ' + palette.faint(`v${version}`);
135
+ }
136
+ export function renderHeader(info, cols) {
137
+ const W = contentWidth(cols);
138
+ const modePill = info.yolo ? palette.yolo(palette.bold('▲ auto-approve')) : '';
139
+ const branch = info.branch
140
+ ? `${palette.faint('git:')}${palette.muted(info.branch)}${info.dirty ? palette.warn('*') : ''}`
141
+ : '';
142
+ const lines = [];
143
+ // Narrow screens (phone in portrait): no frame, three short lines.
144
+ if (W < 46) {
145
+ const limit = Math.max(8, cols - 1);
146
+ const room = Math.max(6, cols - 3);
147
+ const titleOptions = info.yolo
148
+ ? [`${brand(info.version)} ${modePill}`, `${brand(info.version)} ${palette.yolo('▲ yolo')}`, `${brand(info.version)} ${palette.yolo('▲')}`, brand(info.version)]
149
+ : [brand(info.version)];
150
+ lines.push(titleOptions.find((t) => displayWidth(t) <= limit) ?? truncateWidth(stripAnsi(brand(info.version)), limit));
151
+ lines.push(` ${palette.text(truncateWidth(info.model, room))}`);
152
+ const branchWidth = info.branch ? displayWidth(stripAnsi(branch)) + 2 : 0;
153
+ const showBranch = branch && room - branchWidth >= 8;
154
+ lines.push(` ${palette.muted(shortenPath(info.cwd, showBranch ? room - branchWidth : room))}${showBranch ? ' ' + branch : ''}`);
155
+ if (info.tip && cols >= 36) {
156
+ lines.push('');
157
+ lines.push(` ${palette.faint(truncateWidth(info.tip, room))}`);
158
+ }
159
+ return lines;
160
+ }
161
+ const inner = W - 4; // two border cells + one space of padding on each side
162
+ const row = (content) => palette.rule('│') + ' ' + padEndWidth(content, inner) + ' ' + palette.rule('│');
163
+ const labelW = 9;
164
+ const field = (label, value, right = '') => {
165
+ const room = inner - labelW - (right ? displayWidth(right) + 2 : 0);
166
+ // The hint sits right after the value, not at the far edge of the card.
167
+ return palette.faint(label.padEnd(labelW)) + palette.text(truncateWidth(value, Math.max(6, room))) + (right ? ' ' + right : '');
168
+ };
169
+ lines.push(palette.rule('╭' + '─'.repeat(W - 2) + '╮'));
170
+ lines.push(row(brand(info.version) + (modePill ? ' ' + modePill : '')));
171
+ lines.push(row(''));
172
+ lines.push(row(field('model', info.model, palette.faint('/model'))));
173
+ if (info.provider)
174
+ lines.push(row(field('provider', info.provider)));
175
+ lines.push(row(field('cwd', shortenPath(info.cwd, Math.max(10, inner - labelW - (branch ? 16 : 0))), branch)));
176
+ lines.push(palette.rule('╰' + '─'.repeat(W - 2) + '╯'));
177
+ if (info.tip) {
178
+ const room = W - 7;
179
+ lines.push(` ${palette.accent('tip')} ${palette.muted(truncateWidth(info.tip, room))}`);
180
+ }
181
+ return lines;
182
+ }
183
+ /** The part of the input that fits into `cells`, scrolled so that the cursor stays visible. */
184
+ export function visibleWindow(input, cursorPos, cells) {
185
+ const cps = Array.from(input);
186
+ const widths = cps.map((c) => codePointWidth(c.codePointAt(0)));
187
+ let cursorCp = 0;
188
+ let units = 0;
189
+ while (cursorCp < cps.length && units < cursorPos) {
190
+ units += cps[cursorCp].length;
191
+ cursorCp++;
192
+ }
193
+ const cellsBefore = (end, from = 0) => widths.slice(from, end).reduce((a, b) => a + b, 0);
194
+ const total = cellsBefore(cps.length);
195
+ if (total <= cells) {
196
+ return { text: input, leftEllipsis: false, rightEllipsis: false, cursorCells: cellsBefore(cursorCp) };
197
+ }
198
+ const build = (start) => {
199
+ const left = start > 0 ? 1 : 0;
200
+ const avail = cells - left;
201
+ let end = start;
202
+ let used = 0;
203
+ while (end < cps.length && used + widths[end] <= avail) {
204
+ used += widths[end];
205
+ end++;
206
+ }
207
+ let right = 0;
208
+ if (end < cps.length) {
209
+ right = 1;
210
+ while (end > start && used + 1 > avail) {
211
+ end--;
212
+ used -= widths[end];
213
+ }
214
+ }
215
+ return { start, end, left, right, used };
216
+ };
217
+ // Start with the cursor roughly in the middle of the window.
218
+ const half = Math.floor(cells / 2);
219
+ let start = cursorCp;
220
+ let w = 0;
221
+ while (start > 0 && w + widths[start - 1] <= half) {
222
+ start--;
223
+ w += widths[start];
224
+ }
225
+ let win = build(start);
226
+ // Do not leave empty space at the end when there is text hidden on the left.
227
+ while (win.end >= cps.length && win.start > 0) {
228
+ const trial = build(win.start - 1);
229
+ if (trial.end < cps.length)
230
+ break;
231
+ win = trial;
232
+ }
233
+ // The cursor must be inside the window.
234
+ let guard = 0;
235
+ while (cursorCp > win.end && guard++ < cps.length)
236
+ win = build(win.start + 1);
237
+ return {
238
+ text: cps.slice(win.start, win.end).join(''),
239
+ leftEllipsis: win.left === 1,
240
+ rightEllipsis: win.right === 1,
241
+ cursorCells: cellsBefore(Math.min(cursorCp, win.end), win.start),
242
+ };
243
+ }
244
+ function keyHints(cols, mode) {
245
+ const next = mode === 'plan' ? 'agent' : 'plan';
246
+ const all = [
247
+ ['tab', `${next} mode`],
248
+ ['/', 'commands'],
249
+ ['@', 'files'],
250
+ ['ctrl+c', 'stop'],
251
+ ];
252
+ const compact = [['tab', 'mode'], ['/', 'cmds'], ['@', 'files']];
253
+ const render = (items) => items.map(([k, label]) => palette.text(k) + ' ' + palette.faint(label)).join(palette.faint(' · '));
254
+ for (const items of [all, all.slice(0, 3), compact, compact.slice(0, 2)]) {
255
+ const text = render(items);
256
+ if (displayWidth(text) <= cols - 3)
257
+ return text;
258
+ }
259
+ return '';
260
+ }
261
+ /**
262
+ * `deepseek-v4.1-flash-abliterated · think max` in `max` cells: a long model name is cut in the
263
+ * middle of the name, never the part after it (the reasoning level), which is what changes.
264
+ */
265
+ export function fitModelLabel(label, max) {
266
+ if (displayWidth(label) <= max)
267
+ return label;
268
+ const sep = ' · ';
269
+ const at = label.lastIndexOf(sep);
270
+ if (at > 0) {
271
+ const name = label.slice(0, at);
272
+ const tail = label.slice(at);
273
+ const room = max - displayWidth(tail);
274
+ if (room >= 6)
275
+ return truncateWidth(name, room) + tail;
276
+ }
277
+ return truncateWidth(label, max);
278
+ }
279
+ export function buildPromptFrame(input) {
280
+ // Control characters would move the cursor and tear the frame apart. They are shown as
281
+ // spaces; one character stays one character, so cursor positions keep matching.
282
+ const f = { ...input, input: input.input.replace(/[\x00-\x1f\x7f]/g, ' ') };
283
+ if (isCompact(f.cols))
284
+ return buildCompactFrame(f);
285
+ const W = frameWidth(f.cols);
286
+ const style = modeStyle(f.mode);
287
+ const rule = palette.rule;
288
+ // ── top border: ╭─ ● agent ───────── model ─╮
289
+ const pill = ` ${style.color(palette.bold(`${style.glyph} ${style.label}`))} `;
290
+ const pillW = displayWidth(pill);
291
+ const topFree = W - 4 - pillW;
292
+ let topRight = '';
293
+ if (f.model && topFree >= 18) {
294
+ topRight = ' ' + palette.muted(fitModelLabel(f.model, Math.min(48, topFree - 4))) + ' ';
295
+ }
296
+ const top = rule('╭─') + pill + rule('─'.repeat(Math.max(0, topFree - displayWidth(topRight)))) + topRight + rule('─╮');
297
+ // ── input row: │ ❯ text │
298
+ const contentCells = W - 6;
299
+ const win = visibleWindow(f.input, f.cursorPos, contentCells);
300
+ const content = f.input
301
+ ? (win.leftEllipsis ? palette.faint('…') : '') +
302
+ formatBadges(win.text) +
303
+ (win.rightEllipsis ? palette.faint('…') : '')
304
+ : palette.muted(truncateWidth(f.placeholder, contentCells));
305
+ const inputRow = rule('│') + ' ' + palette.accent('❯') + ' ' + padEndWidth(content, contentCells) + ' ' + rule('│');
306
+ const cursorCol = 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0);
307
+ // ── bottom border: ╰─ ~/project git:main* ──── ━━━━──── 12% · $0.0123 ─╯
308
+ const bottomFree = W - 4;
309
+ const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
310
+ const stat = `${pct}% · ${formatCost(f.cost, f.costState)}`;
311
+ const meterCells = W >= 60 ? 8 : W >= 44 ? 6 : 0;
312
+ const withMeter = meterCells > 0 && bottomFree >= displayWidth(stat) + meterCells + 8;
313
+ const bottomRight = withMeter
314
+ ? ' ' + palette.faint('ctx') + ' ' + renderMeter(pct, meterCells) + ' ' + palette.muted(stat) + ' '
315
+ : bottomFree >= displayWidth(stat) + 4
316
+ ? ' ' + palette.muted(stat) + ' '
317
+ : '';
318
+ const leftRoom = bottomFree - displayWidth(bottomRight) - 2;
319
+ let bottomLeft = '';
320
+ if (f.cwd && leftRoom >= 10) {
321
+ const branch = f.branch ? `${palette.faint('git:')}${palette.muted(f.branch)}${f.dirty ? palette.warn('*') : ''}` : '';
322
+ const branchW = f.branch ? 4 + displayWidth(f.branch) + (f.dirty ? 1 : 0) : 0;
323
+ const showBranch = branch && leftRoom - branchW >= 12;
324
+ const pathRoom = leftRoom - (showBranch ? branchW : 0);
325
+ bottomLeft = ' ' + palette.muted(shortenPath(f.cwd, pathRoom)) + (showBranch ? ' ' + branch : '') + ' ';
326
+ }
327
+ const bottom = rule('╰─') +
328
+ bottomLeft +
329
+ rule('─'.repeat(Math.max(0, bottomFree - displayWidth(bottomLeft) - displayWidth(bottomRight)))) +
330
+ bottomRight +
331
+ rule('─╯');
332
+ // ── footer: key hints, or the suggestion list while one is open
333
+ const footer = f.below && f.below.length > 0 ? f.below.map((l) => ' ' + l) : [hintsRow(f.cols, f.mode)];
334
+ return { lines: [top, inputRow, bottom, ...footer], cursorRow: 1, cursorCol };
335
+ }
336
+ /**
337
+ * Phone layout: one row, no borders, no hints.
338
+ * ● ❯ your text… 12%
339
+ * The coloured glyph tells the mode (● agent, ◆ plan, ▲ yolo), the percentage is the
340
+ * context usage and is hidden when it would squeeze the input.
341
+ */
342
+ function buildCompactFrame(f) {
343
+ const W = frameWidth(f.cols);
344
+ const style = modeStyle(f.mode);
345
+ const prefix = style.color(style.glyph) + ' ' + palette.accent('❯') + ' ';
346
+ const pct = Math.max(0, Math.min(100, Math.round(f.contextPercent)));
347
+ const statText = `${pct}%`;
348
+ const statColor = pct >= 90 ? palette.err : pct >= 75 ? palette.warn : palette.faint;
349
+ const statWidth = displayWidth(statText) + 1;
350
+ const withStat = W - 4 - statWidth >= 14;
351
+ const cells = W - 4 - (withStat ? statWidth : 0);
352
+ const win = visibleWindow(f.input, f.cursorPos, cells);
353
+ const content = f.input
354
+ ? (win.leftEllipsis ? palette.faint('…') : '') +
355
+ formatBadges(win.text) +
356
+ (win.rightEllipsis ? palette.faint('…') : '')
357
+ : palette.muted(truncateWidth(f.placeholder, cells));
358
+ const row = prefix + padEndWidth(content, cells) + (withStat ? ' ' + statColor(statText) : '');
359
+ const below = (f.below ?? []).map((l) => ' ' + l);
360
+ return {
361
+ lines: [row, ...below],
362
+ cursorRow: 0,
363
+ cursorCol: 4 + (f.input ? (win.leftEllipsis ? 1 : 0) + win.cursorCells : 0),
364
+ };
365
+ }
366
+ function hintsRow(cols, mode) {
367
+ const hints = keyHints(cols, mode);
368
+ return hints ? ' ' + hints : '';
369
+ }
370
+ // ───────────────────────── conversation ─────────────────────────
371
+ /** The user's message as it stays in the scrollback: a shaded block with a `❯` marker. */
372
+ export function renderUserEcho(text, cols) {
373
+ const W = frameWidth(cols);
374
+ const cw = W - 4;
375
+ const rows = wrapWidth(text.trimEnd(), cw);
376
+ const shaded = rows.map((line, i) => {
377
+ const marker = i === 0 ? palette.accent('❯') : ' ';
378
+ return palette.shade(' ' + marker + ' ' + padEndWidth(palette.text(line), cw) + ' ');
379
+ });
380
+ // On a phone every row counts: no blank lines around the message.
381
+ return isCompact(cols) ? shaded : ['', ...shaded, ''];
382
+ }
383
+ export function renderNotice(kind, message, cols = process.stdout.columns || 80) {
384
+ const glyph = { info: palette.faint('·'), ok: palette.ok('✓'), warn: palette.warn('!'), error: palette.err('✗') }[kind];
385
+ const color = { info: palette.muted, ok: palette.text, warn: palette.warn, error: palette.err }[kind];
386
+ // A long message continues under its text instead of starting again at the left edge.
387
+ const lines = wrapWords(message.replace(/\s+/g, ' ').trim(), Math.max(10, cols - 6));
388
+ return lines.map((line, i) => (i === 0 ? ` ${glyph} ${color(line)}` : ` ${color(line)}`)).join('\n');
389
+ }
390
+ export function parseAction(actionDesc) {
391
+ const cleaned = actionDesc.replace(/^→\s*/, '').replace(/\.\.\.$/, '').trim();
392
+ const m = cleaned.match(/^([A-Za-z]+):?\s*([\s\S]*)$/);
393
+ if (!m)
394
+ return { verb: cleaned, target: '' };
395
+ return { verb: m[1], target: m[2] };
396
+ }
397
+ /**
398
+ * ` ◆ Read src/core/loop.ts`
399
+ *
400
+ * A long command is wrapped to two rows at most; a multi-line script shows its first line
401
+ * and `(+N lines)`. The full text is always visible in the permission card.
402
+ */
403
+ export function renderToolLine(actionDesc, cols, expanded = false) {
404
+ const { verb, target } = parseAction(actionDesc);
405
+ const head = ` ${palette.accent('◆')} ${palette.bold(verb)}`;
406
+ if (!target.trim())
407
+ return head;
408
+ // Expanded (ctrl+o): the whole command, every line of a script, wrapped under its first line.
409
+ if (expanded) {
410
+ const pad = ' '.repeat(4 + displayWidth(verb) + 2);
411
+ const room = Math.max(10, cols - 1 - displayWidth(pad));
412
+ const lines = target.replace(/\t/g, ' ').split('\n').map((l) => l.replace(/\s+$/, '')).flatMap((l) => (l === '' ? [''] : wrapWidth(l, room)));
413
+ while (lines.length > 1 && lines[lines.length - 1] === '')
414
+ lines.pop();
415
+ const MAX = 60;
416
+ const shown = lines.slice(0, MAX);
417
+ const out = shown.map((l, i) => (i === 0 ? head + ' ' : pad) + palette.text(l));
418
+ if (lines.length > MAX)
419
+ out.push(pad + palette.faint(`… +${lines.length - MAX} lines`));
420
+ return out.join('\n');
421
+ }
422
+ const rawLines = target.split('\n').map((l) => l.trim()).filter(Boolean);
423
+ const first = (rawLines[0] ?? '').replace(/\s+/g, ' ');
424
+ const extra = rawLines.length - 1;
425
+ const suffixPlain = extra > 0 ? ` (+${extra} line${extra === 1 ? '' : 's'})` : '';
426
+ const prefixWidth = 4 + displayWidth(verb) + 2;
427
+ const room = Math.max(10, cols - 1 - prefixWidth);
428
+ // One row: the command as a single line. ctrl+o shows all of it.
429
+ const maxRows = 1;
430
+ const wrapped = wrapWidth(first, room);
431
+ let rows = wrapped.slice(0, maxRows);
432
+ const cut = wrapped.length > maxRows;
433
+ const last = rows.length - 1;
434
+ const lastRoom = room - displayWidth(suffixPlain);
435
+ if (cut || displayWidth(rows[last]) > lastRoom)
436
+ rows[last] = truncateWidth(rows[last] + (cut ? '…' : ''), Math.max(4, lastRoom));
437
+ const body = rows.map((r) => palette.text(r));
438
+ body[last] += palette.faint(suffixPlain);
439
+ return head + ' ' + body[0] + body.slice(1).map((r) => '\n' + ' '.repeat(prefixWidth) + r).join('');
440
+ }
441
+ /** ` ╰ text` under a tool line. */
442
+ export function renderResultLine(text, kind = 'muted', cols = 80) {
443
+ const color = kind === 'error' ? palette.err : kind === 'ok' ? palette.ok : palette.muted;
444
+ return ` ${palette.faint('╰')} ${color(truncateWidth(text, Math.max(8, cols - 8)))}`;
445
+ }
446
+ /**
447
+ * The plan under an `◆ Update Plan & Tasks` line: a closed rounded box in the colours of the
448
+ * theme (same rule colour as the prompt frame), indented like the `╰` result lines.
449
+ *
450
+ * ╭─ Plan & Tasks ──────────── 1/3 ─╮
451
+ * │ [✓] read the project │
452
+ * │ [•] write the scene │
453
+ * │ [ ] check it in a browser │
454
+ * ╰──────────────────────────────────╯
455
+ *
456
+ * Long steps wrap under their own text. No blank rows around it: the space between blocks
457
+ * of the chat is made by whoever prints the next block.
458
+ */
459
+ export function renderTaskCard(tasks, cols) {
460
+ if (!tasks || tasks.length === 0)
461
+ return [];
462
+ const indent = ' ';
463
+ const W = Math.max(24, Math.min(cols - 1 - indent.length, 72));
464
+ const inner = W - 4;
465
+ const rule = palette.rule;
466
+ const done = tasks.filter((t) => t.status === 'completed').length;
467
+ const title = ` ${palette.bold(palette.accent('Plan & Tasks'))} `;
468
+ const count = ` ${palette.muted(`${done}/${tasks.length}`)} `;
469
+ const fill = Math.max(1, W - 4 - displayWidth(title) - displayWidth(count));
470
+ const top = rule('╭─') + title + rule('─'.repeat(fill)) + count + rule('─╮');
471
+ const row = (content) => rule('│') + ' ' + padEndWidth(content, inner) + ' ' + rule('│');
472
+ const rows = [];
473
+ for (const task of tasks) {
474
+ const marker = task.status === 'completed'
475
+ ? palette.ok('[✓]')
476
+ : task.status === 'in_progress'
477
+ ? palette.accent(palette.bold('[•]'))
478
+ : palette.faint('[ ]');
479
+ const paint = task.status === 'completed'
480
+ ? palette.faint
481
+ : task.status === 'in_progress'
482
+ ? (t) => palette.bold(palette.text(t))
483
+ : palette.text;
484
+ const lines = wrapWords(String(task.text ?? '').replace(/\s+/g, ' ').trim() || '…', Math.max(8, inner - 4));
485
+ lines.forEach((line, i) => rows.push(row((i === 0 ? marker : ' ') + ' ' + paint(line))));
486
+ }
487
+ const bottom = rule('╰' + '─'.repeat(W - 2) + '╯');
488
+ return [top, ...rows, bottom].map((l) => indent + l);
489
+ }
490
+ /** Command output as printable rows: no colour codes, tabs or carriage returns, no blank edges. */
491
+ function outputRows(text) {
492
+ const rows = stripAnsi(text)
493
+ .replace(/\r/g, '')
494
+ .replace(/\t/g, ' ')
495
+ .split('\n')
496
+ .map((l) => l.replace(/\s+$/, ''));
497
+ while (rows.length && rows[0] === '')
498
+ rows.shift();
499
+ while (rows.length && rows[rows.length - 1] === '')
500
+ rows.pop();
501
+ return rows;
502
+ }
503
+ /** A result that may be long (an explanation, a hint): wrapped under its first line, never cut off. */
504
+ export function renderResultBlock(text, kind = 'muted', cols = 80) {
505
+ const color = kind === 'error' ? palette.err : kind === 'ok' ? palette.ok : palette.muted;
506
+ const lines = wrapWords(text.replace(/\s+/g, ' ').trim(), Math.max(10, cols - 8));
507
+ return lines.map((line, i) => (i === 0 ? ` ${palette.faint('╰')} ${color(line)}` : ` ${color(line)}`)).join('\n');
508
+ }
509
+ function countLines(text) {
510
+ return text.length === 0 ? 0 : text.replace(/\n$/, '').split('\n').length;
511
+ }
512
+ /**
513
+ * Turns a tool result into the quiet one-liner shown in the transcript (the model still
514
+ * gets the full result). Returns an empty array when there is nothing worth showing.
515
+ */
516
+ /** ` … +12 lines (ctrl+o to expand)` under a folded result. */
517
+ function moreHint(n) {
518
+ return ' ' + palette.faint(`${t('view.moreLines', { n })} (${t('view.expandOutput')})`);
519
+ }
520
+ /**
521
+ * The plan the model finished, as a card under its `plan_ready` call:
522
+ *
523
+ * ╭─ Plan ───────────────────────────────╮
524
+ * │ Fix the score counter │
525
+ * │ │
526
+ * │ Files src/score.ts, src/ui.ts │
527
+ * │ 1. read the counter │
528
+ * ╰───────────────────────────────────────╯
529
+ *
530
+ * A small plan is a few rows; a big one grows with its steps (long ones wrap).
531
+ */
532
+ export function renderPlanCard(plan, cols) {
533
+ const indent = ' ';
534
+ const W = Math.max(24, Math.min(cols - 1 - indent.length, 84));
535
+ const inner = W - 4;
536
+ const rule = palette.rule;
537
+ const row = (content) => rule('│') + ' ' + padEndWidth(content, inner) + ' ' + rule('│');
538
+ const title = ` ${palette.bold(palette.accent(t('plan.cardTitle')))} `;
539
+ const top = rule('╭─') + title + rule('─'.repeat(Math.max(1, W - 4 - displayWidth(title)))) + rule('─╮');
540
+ const rows = [];
541
+ for (const line of wrapText(plan.summary.replace(/\s+/g, ' ').trim() || '…', inner))
542
+ rows.push(row(palette.bold(palette.text(line))));
543
+ const files = (plan.files ?? []).filter(Boolean);
544
+ if (files.length > 0) {
545
+ rows.push(row(''));
546
+ const shown = files.slice(0, 8);
547
+ const label = palette.faint(t('plan.files'));
548
+ wrapText(shown.join(', ') + (files.length > shown.length ? ` +${files.length - shown.length}` : ''), Math.max(10, inner - displayWidth(t('plan.files')) - 2)).forEach((line, i) => {
549
+ rows.push(row((i === 0 ? label + ' ' : ' '.repeat(displayWidth(t('plan.files')) + 2)) + palette.muted(line)));
550
+ });
551
+ }
552
+ const steps = (plan.steps ?? []).filter(Boolean);
553
+ if (steps.length > 0) {
554
+ rows.push(row(''));
555
+ const numW = String(steps.length).length + 2;
556
+ steps.forEach((step, i) => {
557
+ wrapText(step.replace(/\s+/g, ' ').trim(), Math.max(10, inner - numW)).forEach((line, j) => {
558
+ rows.push(row((j === 0 ? palette.accent(`${i + 1}.`.padEnd(numW)) : ' '.repeat(numW)) + palette.text(line)));
559
+ });
560
+ });
561
+ }
562
+ return [top, ...rows, rule('╰' + '─'.repeat(W - 2) + '╯')].map((l) => indent + l);
563
+ }
564
+ /** Rows of raw output for the expanded view (ctrl+o): all of it, up to a generous cap. */
565
+ function fullRows(text, cols, cap = 300) {
566
+ const room = Math.max(8, cols - 8);
567
+ const rows = outputRows(text).flatMap((l) => (l === '' ? [''] : wrapWidth(l, room)));
568
+ const shown = rows.slice(0, cap).map((l) => ' ' + palette.muted(l));
569
+ if (rows.length > cap)
570
+ shown.push(' ' + palette.faint(`… +${rows.length - cap} lines`));
571
+ return shown;
572
+ }
573
+ /**
574
+ * The result of a tool under its `◆` line. Collapsed (the default) it is one quiet row: the first
575
+ * line of the output and how many more there are. Expanded (ctrl+o) it is all of the output.
576
+ */
577
+ export function summarizeToolResult(name, result, cols = 80, expanded = false) {
578
+ const t = name.toLowerCase();
579
+ const text = (result || '').trim();
580
+ if (!text)
581
+ return [];
582
+ // Failures reported by the agent loop itself, whatever the tool.
583
+ if (/^(Error executing tool:|Error: (?:Tool '|The tool arguments|Tool arguments)|Action denied by user)/.test(text)) {
584
+ return [renderResultLine(text.split('\n')[0], 'error', cols)];
585
+ }
586
+ if (t === 'bash' || t === 'run_command' || t === 'exec') {
587
+ const m = text.match(/\n?\[Exit code: (\d+|null)\]\s*$/);
588
+ const stopped = !m ? text.match(/\n?(\[(?:Operation cancelled|Process killed)[^\]]*\])\s*$/) : null;
589
+ const body = text.replace(/\n?\[(?:Exit code: [^\]]*|Operation cancelled[^\]]*|Process killed[^\]]*)\]\s*$/, '');
590
+ const rows = outputRows(body);
591
+ const room = Math.max(8, cols - 8);
592
+ if (stopped)
593
+ return [renderResultLine(stopped[1].replace(/^\[|\]$/g, ''), 'error', cols)];
594
+ const code = m ? m[1] : '?';
595
+ if (!expanded) {
596
+ const nonEmpty = rows.filter((l) => l.trim());
597
+ if (code === '0') {
598
+ if (nonEmpty.length === 0)
599
+ return [renderResultLine('(no output)', 'muted', cols)];
600
+ const rest = nonEmpty.length - 1;
601
+ const row = ` ${palette.faint('╰')} ${palette.muted(truncateWidth(nonEmpty[0].trim(), Math.max(8, cols - 8)))}`;
602
+ return rest > 0 ? [row, moreHint(rest)] : [row];
603
+ }
604
+ const interesting = nonEmpty.find((l) => /error|fail|cannot|can't|not found|no such|denied|exception|✗|×/i.test(l) && !/^\s+at /.test(l)) ?? nonEmpty[nonEmpty.length - 1];
605
+ const head = ` ${palette.faint('╰')} ${palette.err(`exit ${code}`)}`;
606
+ const row = interesting ? head + palette.faint(' · ') + palette.muted(truncateWidth(interesting.trim(), Math.max(6, cols - 8 - displayWidth(`exit ${code} · `)))) : head;
607
+ return nonEmpty.length > 1 ? [row, moreHint(nonEmpty.length - 1)] : [row];
608
+ }
609
+ if (expanded) {
610
+ const head = code === '0' ? (rows.length === 0 ? [renderResultLine('(no output)', 'muted', cols)] : []) : [renderResultLine(`exit ${code}`, 'error', cols)];
611
+ return [...head, ...(rows.length ? fullRows(body, cols) : [])];
612
+ }
613
+ }
614
+ if (expanded && t !== 'bash' && !(t === 'edit_file' || t === 'write_file')) {
615
+ // For these the first line is part of the output, not a summary of it.
616
+ const own = t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url';
617
+ const first = own ? [] : summarizeToolResult(name, result, cols, false);
618
+ return [...first, ...fullRows(text, cols)];
619
+ }
620
+ if (t === 'read_file' || t === 'view_file' || t === 'read') {
621
+ if (text.startsWith('[Binary file'))
622
+ return [renderResultLine('binary file', 'muted', cols)];
623
+ const header = text.match(/^\[Lines (\d+)-(\d+) of (\d+)/);
624
+ if (header)
625
+ return [renderResultLine(`lines ${header[1]}–${header[2]} of ${header[3]}`, 'muted', cols)];
626
+ const n = countLines(text);
627
+ return [renderResultLine(`${n} line${n === 1 ? '' : 's'}`, 'muted', cols)];
628
+ }
629
+ if (t === 'list_dir' || t === 'ls') {
630
+ const n = countLines(text);
631
+ return [renderResultLine(`${n} entr${n === 1 ? 'y' : 'ies'}`, 'muted', cols)];
632
+ }
633
+ if (t === 'search' || t === 'grep_search') {
634
+ if (/^No matches/i.test(text))
635
+ return [renderResultLine('no matches', 'muted', cols)];
636
+ const n = countLines(text);
637
+ return [renderResultLine(`${n} match${n === 1 ? '' : 'es'}`, 'muted', cols)];
638
+ }
639
+ if (t === 'diagnose_code') {
640
+ return [text.startsWith('Diagnostics passed') ? renderResultLine('no errors', 'ok', cols) : renderResultLine('errors found', 'error', cols)];
641
+ }
642
+ if (t === 'mcp_manage') {
643
+ try {
644
+ const servers = JSON.parse(text).servers ?? [];
645
+ if (servers.length > 0)
646
+ return [renderResultLine(servers.map((s) => `${s.name} ${s.status}${s.status === 'connected' ? ` (${s.tools ?? 0} tools)` : ''}`).join(', '), servers.some((s) => s.status === 'failed') ? 'error' : 'ok', cols)];
647
+ }
648
+ catch { }
649
+ }
650
+ // Output of an MCP tool or a web fetch: the first line and how many more there are.
651
+ if (t.startsWith('mcp__') || t === 'web_search' || t === 'fetch_url') {
652
+ const nonEmpty = outputRows(text).filter((l) => l.trim());
653
+ if (nonEmpty.length === 0)
654
+ return [renderResultLine('(no output)', 'muted', cols)];
655
+ const rest = nonEmpty.length - 1;
656
+ const row = ` ${palette.faint('╰')} ${palette.muted(truncateWidth(nonEmpty[0].trim(), Math.max(8, cols - 8)))}`;
657
+ return rest > 0 ? [row, moreHint(rest)] : [row];
658
+ }
659
+ return [];
660
+ }
661
+ export function renderTurnSummary(s) {
662
+ const parts = [formatDuration(s.ms)];
663
+ if (s.tools > 0)
664
+ parts.push(t(s.tools === 1 ? 'summary.toolOne' : 'summary.toolMany', { n: s.tools }));
665
+ if ((s.inputTokens ?? 0) > 0)
666
+ parts.push(t('summary.in', { n: formatTokens(s.inputTokens) }));
667
+ if (s.outputTokens > 0)
668
+ parts.push(t('summary.out', { n: formatTokens(s.outputTokens) }));
669
+ if (s.cost > 0 || (s.costState?.unpriced ?? 0) > 0)
670
+ parts.push(formatCost(s.cost, s.costState));
671
+ const glyph = s.stopped ? palette.warn('■') : palette.ok('✓');
672
+ return ` ${glyph} ${palette.faint((s.stopped ? t('summary.stopped') + ' · ' : '') + parts.join(' · '))}`;
673
+ }
674
+ /**
675
+ * Prints text with a gutter: the first line starts with `firstPrefix`, every following
676
+ * line (including lines created by wrapping) with `restPrefix`.
677
+ *
678
+ * It wraps at word boundaries itself. Without that, the terminal would wrap a streamed
679
+ * line at column 0 and the text would lose its indentation.
680
+ */
681
+ export class GutterWriter {
682
+ firstPrefix;
683
+ restPrefix;
684
+ sink;
685
+ maxCols;
686
+ atLineStart = true;
687
+ isFirst = true;
688
+ col = 0;
689
+ pending = '';
690
+ // Indentation of the current line (plus the width of a leading list marker): wrapped
691
+ // lines continue under the text, not under the marker.
692
+ lead = 0;
693
+ hang = 0;
694
+ sawWord = false;
695
+ // Spaces between words are held back until the next word is known to fit on the line,
696
+ // so wrapped lines never end with a dangling space.
697
+ spaceBuf = '';
698
+ constructor(firstPrefix, restPrefix, sink = (s) => void process.stdout.write(s), maxCols = () => Math.min((process.stdout.columns || 80) - 1, 100)) {
699
+ this.firstPrefix = firstPrefix;
700
+ this.restPrefix = restPrefix;
701
+ this.sink = sink;
702
+ this.maxCols = maxCols;
703
+ }
704
+ write(text) {
705
+ if (!text)
706
+ return;
707
+ // A message starts at its first visible character: leading blank lines are dropped.
708
+ if (this.isFirst && this.atLineStart && this.pending === '')
709
+ text = text.replace(/^\n+/, '');
710
+ if (!text)
711
+ return;
712
+ const parts = (this.pending + text).split(/(\n| +)/);
713
+ this.pending = '';
714
+ // A trailing fragment without whitespace may be half a word (streamed in pieces):
715
+ // keep it until we know where the word ends.
716
+ const last = parts[parts.length - 1];
717
+ if (last !== '' && !/^(\n| +)$/.test(last))
718
+ this.pending = parts.pop();
719
+ let out = '';
720
+ for (const part of parts) {
721
+ if (part === '')
722
+ continue;
723
+ if (part === '\n') {
724
+ out += '\n';
725
+ this.atLineStart = true;
726
+ this.col = 0;
727
+ this.lead = 0;
728
+ this.hang = 0;
729
+ this.sawWord = false;
730
+ this.spaceBuf = '';
731
+ continue;
732
+ }
733
+ const width = displayWidth(part);
734
+ const isSpace = part[0] === ' ';
735
+ const prefixWidth = displayWidth(this.isFirst ? this.firstPrefix : this.restPrefix);
736
+ // Spaces inside a line wait for the word that follows them.
737
+ if (isSpace && !this.atLineStart && this.sawWord) {
738
+ this.spaceBuf += part;
739
+ continue;
740
+ }
741
+ const gap = this.spaceBuf.length;
742
+ if (!this.atLineStart && this.col + gap + width > this.maxCols()) {
743
+ if (isSpace)
744
+ continue;
745
+ out += '\n' + this.restPrefix + ' '.repeat(this.hang);
746
+ this.col = displayWidth(this.restPrefix) + this.hang;
747
+ this.spaceBuf = '';
748
+ }
749
+ else if (this.spaceBuf) {
750
+ out += this.spaceBuf;
751
+ this.col += gap;
752
+ this.spaceBuf = '';
753
+ }
754
+ if (this.atLineStart) {
755
+ out += this.isFirst ? this.firstPrefix : this.restPrefix;
756
+ this.col = prefixWidth;
757
+ this.isFirst = false;
758
+ this.atLineStart = false;
759
+ }
760
+ out += part;
761
+ this.col += width;
762
+ if (isSpace && !this.sawWord) {
763
+ this.lead += width;
764
+ this.hang = this.lead;
765
+ }
766
+ else if (!isSpace && !this.sawWord) {
767
+ this.sawWord = true;
768
+ this.hang = this.lead + (/^(?:[•*-]|\d+[.)])$/.test(stripAnsi(part)) ? width + 1 : 0);
769
+ }
770
+ }
771
+ if (out)
772
+ this.sink(out);
773
+ }
774
+ /** Writes the buffered partial word (call when a stream ends without a trailing newline). */
775
+ flush() {
776
+ if (this.pending) {
777
+ const rest = this.pending;
778
+ this.pending = '';
779
+ this.write(rest + ' ');
780
+ this.spaceBuf = '';
781
+ }
782
+ }
783
+ /** The next printed line gets the first prefix again (a new assistant message). */
784
+ reset() {
785
+ this.pending = '';
786
+ this.spaceBuf = '';
787
+ this.isFirst = true;
788
+ this.atLineStart = true;
789
+ this.col = 0;
790
+ this.lead = 0;
791
+ this.hang = 0;
792
+ this.sawWord = false;
793
+ }
794
+ }
795
+ const MAX_CARD_LINES = 10;
796
+ /** The approval card: what will run, in a frame coloured by how risky it is. */
797
+ export function renderPermissionCard(req, cols) {
798
+ const color = req.danger ? palette.err : palette.warn;
799
+ const label = req.danger ? '✖ dangerous' : '▲ permission';
800
+ const W = frameWidth(cols);
801
+ const all = req.lines.flatMap((l) => l.replace(/\r/g, '').replace(/\t/g, ' ').split('\n'));
802
+ while (all.length && all[all.length - 1].trim() === '')
803
+ all.pop();
804
+ const shown = all.slice(0, MAX_CARD_LINES);
805
+ const hidden = all.length - shown.length;
806
+ // Every row must stay on one terminal line, otherwise erasing the card later would miss rows.
807
+ const notes = (req.notes ?? []).map((n) => ` ${palette.warn('!')} ${palette.warn(truncateWidth(n, Math.max(8, W - 5)))}`);
808
+ if (isCompact(cols)) {
809
+ const rows = [` ${color(palette.bold(label))} ${palette.faint('·')} ${palette.text(truncateWidth(req.title, Math.max(6, W - displayWidth(label) - 5)))}`];
810
+ for (const l of shown)
811
+ rows.push(' ' + palette.text(truncateWidth(l, W - 3)));
812
+ if (hidden > 0)
813
+ rows.push(' ' + palette.faint(`… +${hidden} more line${hidden === 1 ? '' : 's'}`));
814
+ return [...rows, ...notes];
815
+ }
816
+ const inner = W - 4;
817
+ const titleSeg = ` ${color(palette.bold(label))} ${palette.faint('·')} ${palette.text(truncateWidth(req.title, Math.max(6, inner - displayWidth(label) - 6)))} `;
818
+ const top = color('╭─') + titleSeg + color('─'.repeat(Math.max(0, W - 4 - displayWidth(titleSeg)))) + color('─╮');
819
+ const row = (content) => color('│') + ' ' + padEndWidth(content, inner) + ' ' + color('│');
820
+ const rows = shown.map((l) => row(palette.text(truncateWidth(l, inner))));
821
+ if (hidden > 0)
822
+ rows.push(row(palette.faint(`… +${hidden} more line${hidden === 1 ? '' : 's'}`)));
823
+ if (rows.length === 0)
824
+ rows.push(row(''));
825
+ return [top, ...rows, color('╰' + '─'.repeat(W - 2) + '╯'), ...notes];
826
+ }
827
+ /** `❯ ● Allow ○ Deny y/n · ←→ · enter` */
828
+ export function renderPermissionChoice(selected, cols, offer = {}) {
829
+ if (offer.session || offer.always) {
830
+ const item = (id, label, color) => selected === id ? `${color('●')} ${palette.bold(color(label))}` : palette.faint(`○ ${label}`);
831
+ const parts = [item('allow', 'Allow', palette.ok)];
832
+ if (offer.session)
833
+ parts.push(item('session', 'Session', palette.accent));
834
+ if (offer.always)
835
+ parts.push(item('always', 'Always', palette.accent));
836
+ parts.push(item('deny', 'Deny', palette.err));
837
+ const keys = ['y', offer.session ? 's' : '', offer.always ? 'a' : '', 'n'].filter(Boolean).join('/');
838
+ const hintText = isCompact(cols) ? '' : ' ' + palette.faint(`${keys} · ←→ · enter`);
839
+ return ` ${palette.accent('❯')} ${parts.join(' ')}${hintText}`;
840
+ }
841
+ const allow = selected === 'allow' ? `${palette.ok('●')} ${palette.bold(palette.ok('Allow'))}` : palette.faint('○ Allow');
842
+ const deny = selected === 'deny' ? `${palette.err('●')} ${palette.bold(palette.err('Deny'))}` : palette.faint('○ Deny');
843
+ const hint = isCompact(cols) ? '' : ' ' + palette.faint('y/n · ←→ · enter');
844
+ return ` ${palette.accent('❯')} ${allow} ${deny}${hint}`;
845
+ }
846
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
847
+ /**
848
+ * One step of an ease-out animation: the value covers a fixed share of the remaining distance
849
+ * per unit of time, so it starts fast and slows down as it nears the target (exponential
850
+ * smoothing, `tauMs` is the time it takes to cover about 63% of the distance). It never
851
+ * overshoots, works the same for any frame rate, and lands exactly on the target.
852
+ */
853
+ export function easeToward(current, target, dtMs, tauMs = 400) {
854
+ const distance = target - current;
855
+ if (Math.abs(distance) < 0.5)
856
+ return target;
857
+ if (dtMs <= 0)
858
+ return current;
859
+ return current + distance * (1 - Math.exp(-dtMs / tauMs));
860
+ }
861
+ /**
862
+ * The working line: ` ⠹ Read src/x.ts (12s · ↓ 364 tokens · ctrl+c to stop)`.
863
+ * On a narrow screen the details are dropped step by step, the label is cut last.
864
+ */
865
+ export function renderStatusLine({ label: rawLabel, elapsedMs, meta = '', frame, columns }) {
866
+ const elapsed = formatDuration(Math.max(0, elapsedMs));
867
+ const glyph = palette.accent(FRAMES[frame % FRAMES.length]);
868
+ // One row, always: a multi-line command (a heredoc) must not tear the frame under it apart.
869
+ const label = stripAnsi(rawLabel).replace(/^→\s*/, '').replace(/[\x00-\x1f\x7f\s]+/g, ' ').trim();
870
+ // Longest detail text that still leaves the label some room; the label is cut first of all last.
871
+ const options = [
872
+ [elapsed, meta, t('status.stop')],
873
+ [elapsed, meta],
874
+ [elapsed, t('status.stop')],
875
+ [elapsed, '^C'],
876
+ [elapsed],
877
+ ].map((parts) => palette.faint(`(${parts.filter(Boolean).join(' · ')})`));
878
+ const budget = columns - 1 - 4; // two leading spaces, glyph and a space
879
+ const GAP = 2;
880
+ // The label has a field of its own (a long command is cut at FIELD cells).
881
+ const FIELD = 28;
882
+ const suffix = options.find((o) => budget - GAP - displayWidth(o) >= Math.min(12, displayWidth(label))) ?? options[options.length - 1];
883
+ const room = Math.max(1, Math.min(FIELD, budget - GAP - displayWidth(suffix)));
884
+ const cut = truncateWidth(label, room);
885
+ // The details sit close to the label, a little further the longer it is: in steps of 8 cells, so they do not shake.
886
+ const field = Math.min(room, Math.max(12, Math.ceil((displayWidth(cut) + 1) / 8) * 8));
887
+ const text = padEndWidth(cut, field);
888
+ return ` ${glyph} ${palette.text(text)}${' '.repeat(GAP)}${suffix}`;
889
+ }
890
+ /**
891
+ * Animated "working" line: ` ⠹ Read src/x.ts (12s · 1.2k tok · ctrl+c to stop)`.
892
+ * Same start / message / stop API as the clack spinner it replaces.
893
+ */
894
+ let activeSpinner = null;
895
+ /**
896
+ * Silences the running spinner while something else needs the terminal (an approval
897
+ * prompt). Returns a function that brings it back.
898
+ */
899
+ export function pauseSpinners() {
900
+ const resumers = pauseHooks.map((hook) => hook());
901
+ const spinner = activeSpinner;
902
+ if (spinner && spinner.isActive) {
903
+ spinner.suspend();
904
+ resumers.push(() => spinner.resume());
905
+ }
906
+ return () => resumers.forEach((resume) => resume());
907
+ }
908
+ const pauseHooks = [];
909
+ /**
910
+ * Registers something else that must step aside while an approval prompt owns the terminal
911
+ * (the typing line below the chat, for example). The hook returns the function that brings it back.
912
+ */
913
+ export function registerPauseHook(hook) {
914
+ pauseHooks.push(hook);
915
+ return () => {
916
+ const i = pauseHooks.indexOf(hook);
917
+ if (i >= 0)
918
+ pauseHooks.splice(i, 1);
919
+ };
920
+ }
921
+ export class UiSpinner {
922
+ out;
923
+ now;
924
+ intervalMs;
925
+ suspended = false;
926
+ timer = null;
927
+ label = '';
928
+ meta = '';
929
+ metaProvider = null;
930
+ startedAt = 0;
931
+ frame = 0;
932
+ muted = false;
933
+ constructor(out = process.stdout, now = Date.now, intervalMs = 80) {
934
+ this.out = out;
935
+ this.now = now;
936
+ this.intervalMs = intervalMs;
937
+ }
938
+ get isActive() {
939
+ return this.timer !== null || this.suspended;
940
+ }
941
+ /** The working line is drawn elsewhere (by the typing frame): this spinner draws nothing. */
942
+ mute(on = true) {
943
+ if (on && this.timer)
944
+ this.stop();
945
+ this.muted = on;
946
+ }
947
+ start(label = 'Working…') {
948
+ this.label = label;
949
+ this.meta = '';
950
+ this.startedAt = this.now();
951
+ this.frame = 0;
952
+ if (!this.out.isTTY || this.muted)
953
+ return;
954
+ if (this.timer)
955
+ clearInterval(this.timer);
956
+ this.suspended = false;
957
+ activeSpinner = this;
958
+ this.out.write('\x1b[?25l');
959
+ this.draw();
960
+ this.timer = setInterval(() => this.draw(), this.intervalMs);
961
+ this.timer.unref?.();
962
+ }
963
+ /** Stops drawing and erases the line, but remembers the state (see pauseSpinners). */
964
+ suspend() {
965
+ if (!this.timer)
966
+ return;
967
+ clearInterval(this.timer);
968
+ this.timer = null;
969
+ this.suspended = true;
970
+ this.out.write('\r\x1b[2K\x1b[?25h');
971
+ }
972
+ resume() {
973
+ if (!this.suspended || this.muted)
974
+ return;
975
+ this.suspended = false;
976
+ this.out.write('\x1b[?25l');
977
+ this.draw();
978
+ this.timer = setInterval(() => this.draw(), this.intervalMs);
979
+ this.timer.unref?.();
980
+ }
981
+ message(label) {
982
+ this.label = label;
983
+ if (this.timer)
984
+ this.draw();
985
+ }
986
+ /** Extra detail shown inside the brackets, e.g. `1.2k tok`. */
987
+ setMeta(meta) {
988
+ this.meta = meta;
989
+ }
990
+ /** Live detail, asked again on every frame (it survives start() and stop()). */
991
+ setMetaProvider(provider) {
992
+ this.metaProvider = provider;
993
+ }
994
+ stop() {
995
+ if (this.timer) {
996
+ clearInterval(this.timer);
997
+ this.timer = null;
998
+ this.out.write('\r\x1b[2K\x1b[?25h');
999
+ }
1000
+ else if (this.suspended) {
1001
+ this.suspended = false;
1002
+ this.out.write('\x1b[?25h');
1003
+ }
1004
+ if (activeSpinner === this)
1005
+ activeSpinner = null;
1006
+ }
1007
+ /** The text of the current frame (used by the animation and by tests). */
1008
+ renderLine(columns = this.out.columns || 80) {
1009
+ return renderStatusLine({
1010
+ label: this.label,
1011
+ elapsedMs: this.now() - this.startedAt,
1012
+ meta: this.metaProvider?.() || this.meta,
1013
+ frame: this.frame,
1014
+ columns,
1015
+ });
1016
+ }
1017
+ draw() {
1018
+ this.out.write('\r\x1b[2K' + this.renderLine());
1019
+ this.frame++;
1020
+ }
1021
+ }