@cargo-ai/cli 1.0.126 → 1.0.127

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 (38) hide show
  1. package/build/commands/ask/harness/bridgeOutput.d.ts +19 -0
  2. package/build/commands/ask/harness/bridgeOutput.d.ts.map +1 -0
  3. package/build/commands/ask/harness/bridgeOutput.js +42 -0
  4. package/build/commands/ask/harness/index.d.ts +3 -2
  5. package/build/commands/ask/harness/index.d.ts.map +1 -1
  6. package/build/commands/ask/harness/index.js +2 -1
  7. package/build/commands/ask/harness/turn.d.ts +17 -0
  8. package/build/commands/ask/harness/turn.d.ts.map +1 -1
  9. package/build/commands/ask/harness/turn.js +85 -3
  10. package/build/commands/ask/harness/usage.d.ts +19 -0
  11. package/build/commands/ask/harness/usage.d.ts.map +1 -1
  12. package/build/commands/ask/harness/usage.js +46 -0
  13. package/build/commands/ask/input/completer.d.ts +1 -1
  14. package/build/commands/ask/input/completer.d.ts.map +1 -1
  15. package/build/commands/ask/input/completer.js +2 -0
  16. package/build/commands/ask/input/prompt.d.ts.map +1 -1
  17. package/build/commands/ask/input/prompt.js +54 -8
  18. package/build/commands/ask/input/slash.d.ts +7 -1
  19. package/build/commands/ask/input/slash.d.ts.map +1 -1
  20. package/build/commands/ask/input/slash.js +10 -0
  21. package/build/commands/ask/session/index.d.ts +2 -1
  22. package/build/commands/ask/session/index.d.ts.map +1 -1
  23. package/build/commands/ask/session/index.js +2 -1
  24. package/build/commands/ask/session/markdown.d.ts +20 -0
  25. package/build/commands/ask/session/markdown.d.ts.map +1 -0
  26. package/build/commands/ask/session/markdown.js +451 -0
  27. package/build/commands/ask/session/run.d.ts.map +1 -1
  28. package/build/commands/ask/session/run.js +194 -12
  29. package/build/commands/ask/session/tree.d.ts +81 -0
  30. package/build/commands/ask/session/tree.d.ts.map +1 -1
  31. package/build/commands/ask/session/tree.js +322 -51
  32. package/build/commands/runHandler.d.ts +1 -0
  33. package/build/commands/runHandler.d.ts.map +1 -1
  34. package/build/commands/runHandler.js +6 -3
  35. package/build/utils/textWidth.d.ts +25 -0
  36. package/build/utils/textWidth.d.ts.map +1 -0
  37. package/build/utils/textWidth.js +147 -0
  38. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
- import { isAbsolute, relative } from "node:path";
1
+ import { basename, isAbsolute, relative } from "node:path";
2
2
  import { selectOne } from "@cargo-ai/cdk/cli";
3
+ import { clipToCells, textWidth } from "../../../utils/textWidth.js";
3
4
  import { clipToWidth, colors } from "../../runHandler.js";
4
5
  import { ASK_USER_QUESTION_TOOL_NAME, readAskUserQuestions, } from "../harness/askUserQuestion.js";
6
+ import { renderMarkdown } from "./markdown.js";
5
7
  /**
6
8
  * What an assistant turn is marked with, alongside `◇` for a step and `│` for
7
9
  * the rail.
@@ -35,7 +37,7 @@ export function wrapLine(text, width) {
35
37
  const lines = [];
36
38
  for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
37
39
  const current = lines[lines.length - 1];
38
- if (current === undefined || `${current} ${word}`.length > width) {
40
+ if (current === undefined || textWidth(`${current} ${word}`) > width) {
39
41
  lines.push(word);
40
42
  continue;
41
43
  }
@@ -63,16 +65,6 @@ function writeRailWrapped(body) {
63
65
  writeRail(line);
64
66
  }
65
67
  }
66
- /**
67
- * Light cleanup so model markdown does not fight the tree. Keeps the words;
68
- * drops heading marks, bold markers, and horizontal rules.
69
- */
70
- function forTerminal(text) {
71
- return text
72
- .replace(/^#{1,6}\s+/gm, "")
73
- .replace(/\*\*([^*]+)\*\*/g, "$1")
74
- .replace(/^---+$/gm, "");
75
- }
76
68
  /**
77
69
  * A `◇` step: the first line beside the marker, the rest continuing on the
78
70
  * rail under it.
@@ -115,44 +107,66 @@ export function showUserTurn(body) {
115
107
  writeDiamondBlock(body);
116
108
  }
117
109
  /**
118
- * Display lines for an assistant body. First line is the brick header body;
119
- * later lines sit on the │ rail. Blank paragraphs keep an empty rail.
110
+ * Where the finished part of a reply ends: just past its last blank line, as
111
+ * long as that line is not inside a code fence. Never before `from`.
120
112
  */
121
- function assistantDisplayLines(body) {
122
- const paragraphs = forTerminal(body).split("\n");
123
- const lines = [];
124
- for (let index = 0; index < paragraphs.length; index += 1) {
125
- const paragraph = paragraphs[index];
126
- if (paragraph === undefined) {
127
- continue;
128
- }
129
- if (paragraph === "") {
130
- lines.push("");
131
- continue;
113
+ function commitPoint(buffer, from) {
114
+ const cursor = { at: buffer.lastIndexOf("\n\n") };
115
+ while (cursor.at >= from) {
116
+ const fences = buffer
117
+ .slice(0, cursor.at)
118
+ .split("\n")
119
+ .filter((line) => /^\s*(```|~~~)/.test(line) === true).length;
120
+ if (fences % 2 === 0) {
121
+ return cursor.at + 2;
132
122
  }
133
- const wrapped = wrapLine(paragraph, treeBodyWidth());
134
- if (wrapped.length === 0) {
135
- lines.push("");
136
- continue;
137
- }
138
- for (const line of wrapped) {
139
- lines.push(line);
123
+ cursor.at = buffer.lastIndexOf("\n\n", cursor.at - 1);
124
+ }
125
+ return from;
126
+ }
127
+ /**
128
+ * Write reply lines: the first beside `■` until one has been printed for
129
+ * good, the rest on the │ rail. Returns how many rows were written.
130
+ */
131
+ function writeAssistantLines(live, lines) {
132
+ lines.forEach((line, index) => {
133
+ if (index === 0 && live.markerCommitted === false) {
134
+ process.stderr.write(`${ASSISTANT_MARKER} ${line}\n`);
135
+ return;
140
136
  }
137
+ writeRail(line);
138
+ });
139
+ return lines.length;
140
+ }
141
+ /**
142
+ * Markdown lines for part of a reply, after the blank row that separates it
143
+ * from paragraphs already printed for good.
144
+ */
145
+ function assistantLines(text, withGap) {
146
+ const body = text.replace(/^\n+/, "").replace(/\n+$/, "");
147
+ if (body === "") {
148
+ return [];
141
149
  }
150
+ const lines = renderMarkdown(body, treeBodyWidth());
151
+ return withGap === true ? ["", ...lines] : lines;
152
+ }
153
+ /** Print reply lines for good: they are never cleared or drawn again. */
154
+ function commitAssistantLines(live, lines) {
142
155
  if (lines.length === 0) {
143
- return [""];
156
+ return;
144
157
  }
145
- return lines;
158
+ writeAssistantLines(live, lines);
159
+ live.markerCommitted = true;
146
160
  }
147
- function writeAssistantLines(lines) {
148
- const first = lines[0] === undefined ? "" : lines[0];
149
- process.stderr.write(`${ASSISTANT_MARKER} ${first}\n`);
150
- for (let index = 1; index < lines.length; index += 1) {
151
- const line = lines[index];
152
- if (line !== undefined) {
153
- writeRail(line);
154
- }
155
- }
161
+ /**
162
+ * How many rows the redrawn part of a reply may take. Clearing it means
163
+ * moving the cursor up past every row, and the cursor stops at the top of the
164
+ * screen: rows that scrolled off would be drawn again under themselves. Two
165
+ * rows are kept for the typed line and the margin.
166
+ */
167
+ function liveRowLimit() {
168
+ const rows = process.stderr.rows === undefined ? 24 : process.stderr.rows;
169
+ return Math.max(3, rows - 2);
156
170
  }
157
171
  function clearPaintedLines(count) {
158
172
  if (count <= 0 || process.stderr.isTTY !== true) {
@@ -164,7 +178,15 @@ function clearPaintedLines(count) {
164
178
  export function beginAssistantTurn() {
165
179
  writeRail("");
166
180
  process.stderr.write(`${ASSISTANT_MARKER} \n`);
167
- return { buffer: "", paintedLines: 1, footer: "" };
181
+ return {
182
+ buffer: "",
183
+ committed: 0,
184
+ tailLinesCommitted: 0,
185
+ tailHasGap: false,
186
+ markerCommitted: false,
187
+ paintedLines: 1,
188
+ footer: "",
189
+ };
168
190
  }
169
191
  export function appendAssistantDelta(live, delta) {
170
192
  // A tool between two pieces of narration inserts a paragraph break, so that
@@ -182,10 +204,27 @@ export function appendAssistantDelta(live, delta) {
182
204
  }
183
205
  /** Draw the block again from its buffer, with the typed line under it. */
184
206
  function repaintAssistantTurn(live) {
185
- const lines = assistantDisplayLines(live.buffer);
186
207
  clearPaintedLines(live.paintedLines);
187
- writeAssistantLines(lines);
188
- live.paintedLines = lines.length;
208
+ live.paintedLines = 0;
209
+ const cut = commitPoint(live.buffer, live.committed);
210
+ if (cut > live.committed) {
211
+ // Rows of it may already be out, from when it was the unfinished part.
212
+ commitAssistantLines(live, assistantLines(live.buffer.slice(live.committed, cut), live.tailHasGap).slice(live.tailLinesCommitted));
213
+ live.committed = cut;
214
+ live.tailLinesCommitted = 0;
215
+ live.tailHasGap = live.markerCommitted;
216
+ }
217
+ const tail = assistantLines(live.buffer.slice(live.committed), live.tailHasGap).slice(live.tailLinesCommitted);
218
+ const overflow = Math.max(0, tail.length - liveRowLimit());
219
+ // A table or a code block taller than the screen: its top rows go out for
220
+ // good. A table can still widen below them; that beats drawing it twice.
221
+ if (overflow > 0) {
222
+ commitAssistantLines(live, tail.slice(0, overflow));
223
+ live.tailLinesCommitted += overflow;
224
+ }
225
+ const visible = tail.slice(overflow);
226
+ // Nothing said yet: keep the bare marker up, as `beginAssistantTurn` drew it.
227
+ live.paintedLines = writeAssistantLines(live, visible.length === 0 && live.markerCommitted === false ? [""] : visible);
189
228
  if (live.footer !== "") {
190
229
  // Clipped for the same reason the spinner's is: it has to occupy exactly
191
230
  // one row, or the next repaint clears from the wrong line.
@@ -216,6 +255,171 @@ export function showAssistantTurn(body) {
216
255
  appendAssistantDelta(live, body);
217
256
  endAssistantTurn(live);
218
257
  }
258
+ /**
259
+ * How Claude Code's thinking is drawn, the same choices `@ai-sdk/tui` offers:
260
+ *
261
+ * - `full`: the whole summary stays in the transcript.
262
+ * - `auto-collapsed`: the latest lines show while it thinks, then fold into
263
+ * one "Thought for 4s" line once it moves on.
264
+ * - `collapsed`: only that one line.
265
+ * - `hidden`: nothing beyond the spinner.
266
+ */
267
+ export const REASONING_DISPLAYS = [
268
+ "full",
269
+ "auto-collapsed",
270
+ "collapsed",
271
+ "hidden",
272
+ ];
273
+ /**
274
+ * How finished tool calls are drawn: `full` adds the start of each result
275
+ * under its line, `collapsed` keeps one line per call (and the first line of
276
+ * an error), `hidden` leaves only the spinner while they run.
277
+ */
278
+ export const TOOL_DISPLAYS = ["full", "collapsed", "hidden"];
279
+ const REASONING_MARKER = "∴";
280
+ /** Rows of thinking kept on screen while it streams. */
281
+ const REASONING_LIVE_LINES = 6;
282
+ /** Rows of a tool result shown under it in `full`. */
283
+ const TOOL_OUTPUT_PREVIEW_LINES = 6;
284
+ /**
285
+ * Open a thinking block. Only the expanded displays draw while it streams;
286
+ * `collapsed` leaves the spinner up and adds its one line at the end.
287
+ */
288
+ export function beginReasoning(display) {
289
+ const live = {
290
+ buffer: "",
291
+ display,
292
+ startedAtMs: Date.now(),
293
+ paintedLines: 0,
294
+ footer: "",
295
+ };
296
+ // No gap before it: thinking sits with the tool lines, not the replies.
297
+ if (drawsWhileThinking(display) === true) {
298
+ repaintReasoning(live);
299
+ }
300
+ return live;
301
+ }
302
+ export function appendReasoningDelta(live, delta) {
303
+ live.buffer += delta;
304
+ if (drawsWhileThinking(live.display) === true) {
305
+ repaintReasoning(live);
306
+ }
307
+ }
308
+ export function setReasoningFooter(live, footer) {
309
+ if (live.footer === footer) {
310
+ return;
311
+ }
312
+ live.footer = footer;
313
+ if (drawsWhileThinking(live.display) === true) {
314
+ repaintReasoning(live);
315
+ }
316
+ }
317
+ /** Close the block, leaving what its display keeps in the transcript. */
318
+ export function endReasoning(live, spinner) {
319
+ clearPaintedLines(live.paintedLines);
320
+ live.paintedLines = 0;
321
+ if (live.display === "hidden") {
322
+ return;
323
+ }
324
+ const header = `${colors.dim(REASONING_MARKER)} ${colors.dim(`Thought for ${formatDuration(Date.now() - live.startedAtMs)}`)}`;
325
+ if (live.display === "collapsed") {
326
+ // The spinner stayed up through this one; log above it.
327
+ if (spinner !== undefined) {
328
+ spinner.log(header);
329
+ return;
330
+ }
331
+ process.stderr.write(`${header}\n`);
332
+ return;
333
+ }
334
+ process.stderr.write(`${header}\n`);
335
+ if (live.display === "full") {
336
+ for (const line of reasoningLines(live.buffer)) {
337
+ writeRail(line);
338
+ }
339
+ }
340
+ }
341
+ function drawsWhileThinking(display) {
342
+ return display === "full" || display === "auto-collapsed";
343
+ }
344
+ function repaintReasoning(live) {
345
+ clearPaintedLines(live.paintedLines);
346
+ const lines = reasoningLines(live.buffer).slice(-REASONING_LIVE_LINES);
347
+ process.stderr.write(`${colors.dim(REASONING_MARKER)} ${colors.dim("Thinking…")}\n`);
348
+ for (const line of lines) {
349
+ writeRail(line);
350
+ }
351
+ live.paintedLines = lines.length + 1;
352
+ if (live.footer !== "") {
353
+ process.stderr.write(`${colors.dim(clipToWidth(live.footer))}\n`);
354
+ live.paintedLines += 1;
355
+ }
356
+ }
357
+ function reasoningLines(text) {
358
+ const body = text.trim();
359
+ if (body === "") {
360
+ return [];
361
+ }
362
+ return renderMarkdown(body, treeBodyWidth(), "dim");
363
+ }
364
+ /** `4s`, `1m 12s`. */
365
+ function formatDuration(ms) {
366
+ const seconds = Math.max(1, Math.round(ms / 1000));
367
+ if (seconds < 60) {
368
+ return `${String(seconds)}s`;
369
+ }
370
+ return `${String(Math.floor(seconds / 60))}m ${String(seconds % 60)}s`;
371
+ }
372
+ /**
373
+ * What a turn cost, under the reply: tokens written, how fast they came, how
374
+ * full the context window is, and the credits it was billed.
375
+ *
376
+ * │ 2.4k tokens written · 38 tok/s · 92k of 1M context (9%) · 6.10 credits
377
+ */
378
+ export function showTurnStats(stats) {
379
+ if (stats.outputTokens === 0) {
380
+ return;
381
+ }
382
+ // Written, not read: what the model read is the context share below.
383
+ const parts = [`${formatTokens(stats.outputTokens)} tokens written`];
384
+ // Under a second of generation is too short to measure a rate from.
385
+ if (stats.generationMs >= 1000) {
386
+ parts.push(`${String(Math.round(stats.outputTokens / (stats.generationMs / 1000)))} tok/s`);
387
+ }
388
+ if (stats.contextTokens !== undefined && stats.contextTokens > 0) {
389
+ const percent = Math.min(100, Math.max(1, Math.round((stats.contextTokens / stats.contextWindow) * 100)));
390
+ parts.push(`${formatTokens(stats.contextTokens)} of ${formatTokens(stats.contextWindow)} context (${String(percent)}%)`);
391
+ }
392
+ if (stats.credits > 0) {
393
+ parts.push(`${stats.credits.toFixed(2)} credit${stats.credits === 1 ? "" : "s"}`);
394
+ }
395
+ // Broken between items, never inside one, when the line is wider than the
396
+ // rail leaves room for: wrapped by the terminal it would start its next row
397
+ // at column zero, outside the tree.
398
+ const width = treeBodyWidth();
399
+ const rows = [];
400
+ for (const part of parts.map((entry) => clipToCells(entry, width))) {
401
+ const last = rows[rows.length - 1];
402
+ if (last !== undefined && textWidth(`${last} · ${part}`) <= width) {
403
+ rows[rows.length - 1] = `${last} · ${part}`;
404
+ continue;
405
+ }
406
+ rows.push(part);
407
+ }
408
+ for (const row of rows) {
409
+ writeRail(colors.dim(row));
410
+ }
411
+ writeRail("");
412
+ }
413
+ /** `950`, `2.4k`, `1M`. */
414
+ function formatTokens(count) {
415
+ if (count >= 1_000_000) {
416
+ return `${String(Math.round(count / 100_000) / 10)}M`;
417
+ }
418
+ if (count >= 1_000) {
419
+ return `${String(Math.round(count / 100) / 10)}k`;
420
+ }
421
+ return String(count);
422
+ }
219
423
  /** Common tool names arrive lowercase (`bash`); natives keep their case. */
220
424
  function toolLabel(toolName) {
221
425
  if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
@@ -233,9 +437,7 @@ export function describeToolActivity(toolName, summary) {
233
437
  return label;
234
438
  }
235
439
  const line = `${label}: ${summary}`;
236
- return line.length > ACTIVITY_MAX_LENGTH
237
- ? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
238
- : line;
440
+ return clipToCells(line, ACTIVITY_MAX_LENGTH);
239
441
  }
240
442
  /**
241
443
  * A tool that has finished, left in the transcript.
@@ -250,15 +452,84 @@ export function describeToolActivity(toolName, summary) {
250
452
  * line, leaves this behind, and lets the next frame redraw underneath.
251
453
  */
252
454
  export function showToolRun(input) {
455
+ if (input.display === "hidden") {
456
+ return;
457
+ }
253
458
  const marker = input.isError === true ? colors.red("✗") : colors.dim(TOOL_MARKER);
254
459
  const label = describeToolActivity(input.toolName, input.summary);
255
- const line = `${marker} ${colors.dim(label)}`;
460
+ const lines = [
461
+ `${marker} ${colors.dim(label)}`,
462
+ ...toolOutputPreview(input.output, input.display, input.isError).map((line) => `${colors.dim("│")} ${colors.dim(line)}`),
463
+ ];
464
+ for (const line of lines) {
465
+ if (input.spinner !== undefined) {
466
+ input.spinner.log(line);
467
+ }
468
+ else {
469
+ process.stderr.write(`${line}\n`);
470
+ }
471
+ }
472
+ }
473
+ /**
474
+ * Several calls of one tool that finished in a row, as one line:
475
+ *
476
+ * ⏺ Read 6 files: run.ts, tree.ts, markdown.ts, …
477
+ *
478
+ * Six `Read` lines say less than this one, and push the reply off screen.
479
+ * Only for successful calls under `collapsed`: a failure keeps its own line
480
+ * and its error, and `full` shows each result.
481
+ */
482
+ export function showToolGroup(input) {
483
+ const count = input.summaries.length;
484
+ const verb = TOOL_GROUP_VERBS.find((entry) => entry.toolName === input.toolName.toLowerCase());
485
+ const head = verb === undefined
486
+ ? `${toolLabel(input.toolName)} ×${String(count)}`
487
+ : `${verb.label} ${String(count)} ${verb.noun}`;
488
+ // Paths shown by file name: the directory is usually the same for all.
489
+ const items = input.summaries
490
+ .filter((summary) => summary !== "")
491
+ .map((summary) => isAbsolute(summary) === true ? basename(summary) : summary);
492
+ const label = clipToCells(items.length === 0 ? head : `${head}: ${items.join(", ")}`, treeBodyWidth());
493
+ const line = `${colors.dim(TOOL_MARKER)} ${colors.dim(label)}`;
256
494
  if (input.spinner !== undefined) {
257
495
  input.spinner.log(line);
258
496
  return;
259
497
  }
260
498
  process.stderr.write(`${line}\n`);
261
499
  }
500
+ /** How a run of one built-in tool is counted. Names arrive lowercase. */
501
+ const TOOL_GROUP_VERBS = [
502
+ { toolName: "read", label: "Read", noun: "files" },
503
+ { toolName: "edit", label: "Edited", noun: "files" },
504
+ { toolName: "multiedit", label: "Edited", noun: "files" },
505
+ { toolName: "write", label: "Wrote", noun: "files" },
506
+ { toolName: "bash", label: "Ran", noun: "commands" },
507
+ { toolName: "grep", label: "Ran", noun: "searches" },
508
+ { toolName: "glob", label: "Listed", noun: "patterns" },
509
+ { toolName: "webfetch", label: "Fetched", noun: "pages" },
510
+ { toolName: "websearch", label: "Ran", noun: "web searches" },
511
+ ];
512
+ /**
513
+ * The start of a tool's result, clipped to the rail. A failure always gets
514
+ * its first line, so a ✗ says why without switching the display.
515
+ */
516
+ function toolOutputPreview(output, display, isError) {
517
+ const limit = display === "full" ? TOOL_OUTPUT_PREVIEW_LINES : isError === true ? 1 : 0;
518
+ if (limit === 0) {
519
+ return [];
520
+ }
521
+ const lines = output
522
+ .replace(/\t/g, " ")
523
+ .split("\n")
524
+ .map((line) => line.trimEnd())
525
+ .filter((line) => line !== "");
526
+ const width = treeBodyWidth();
527
+ const shown = lines.slice(0, limit).map((line) => clipToCells(line, width));
528
+ if (lines.length > limit) {
529
+ shown.push(`… ${String(lines.length - limit)} more line${lines.length - limit === 1 ? "" : "s"}`);
530
+ }
531
+ return shown;
532
+ }
262
533
  /**
263
534
  * A path inside the session directory reads better relative to it; anything
264
535
  * outside stays absolute so it is obvious the turn left the project.
@@ -7,6 +7,7 @@ export declare const ExitCodes: {
7
7
  readonly NotFound: 5;
8
8
  };
9
9
  export type ExitCode = (typeof ExitCodes)[keyof typeof ExitCodes];
10
+ export declare const colorize: (text: string, code: string) => string;
10
11
  export declare const colors: {
11
12
  red: (s: string) => string;
12
13
  green: (s: string) => string;
@@ -1 +1 @@
1
- {"version":3,"file":"runHandler.d.ts","sourceRoot":"","sources":["../../src/commands/runHandler.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,SAAS;;;;;;;CAOZ,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,OAAO,SAAS,CAAC,CAAC;AAclE,eAAO,MAAM,MAAM;aACR,MAAM,KAAG,MAAM;eACb,MAAM,KAAG,MAAM;gBACd,MAAM,KAAG,MAAM;cACjB,MAAM,KAAG,MAAM;aAChB,MAAM,KAAG,MAAM;cACd,MAAM,KAAG,MAAM;CAC1B,CAAC;AAEF,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAM/C;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAID,wBAAsB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUhE;AAED,KAAK,YAAY,GAAG;IAClB,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC,CAAC;AAEF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,KAAK,CAgCpE;AAQD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAiBlE;AAED,MAAM,MAAM,OAAO,GAAG;IAEpB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAGlC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAU5B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC;AAIF,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAwBrD;AAgED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,GAAG,CAQhE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAQ1E;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,OAAO,EAAE,CAAC,EAAE,EACZ,KAAK,EAAE,MAAM,GACZ;IAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,YAAY,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASxD;AAKD,KAAK,iBAAiB,GAAG;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACzB;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAEF,wBAAsB,aAAa,CAAC,CAAC,EACnC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,CAAC,EAAE,iBAAiB,GACvB,OAAO,CAAC,CAAC,CAAC,CAgEZ;AA0ED,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAC3B,aAAa,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,EAC9D,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,CAAC,CAAC,CAyBZ"}
1
+ {"version":3,"file":"runHandler.d.ts","sourceRoot":"","sources":["../../src/commands/runHandler.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,SAAS;;;;;;;CAOZ,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,OAAO,SAAS,CAAC,CAAC;AAOlE,eAAO,MAAM,QAAQ,SAAU,MAAM,QAAQ,MAAM,KAAG,MAKrD,CAAC;AAEF,eAAO,MAAM,MAAM;aACR,MAAM,KAAG,MAAM;eACb,MAAM,KAAG,MAAM;gBACd,MAAM,KAAG,MAAM;cACjB,MAAM,KAAG,MAAM;aAChB,MAAM,KAAG,MAAM;cACd,MAAM,KAAG,MAAM;CAC1B,CAAC;AAEF,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAM/C;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAID,wBAAsB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUhE;AAED,KAAK,YAAY,GAAG;IAClB,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC,CAAC;AAEF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,KAAK,CAgCpE;AAQD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAmBlE;AAED,MAAM,MAAM,OAAO,GAAG;IAEpB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAGlC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAU5B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC;AAIF,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAwBrD;AAgED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,GAAG,CAQhE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAQ1E;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EACjC,OAAO,EAAE,CAAC,EAAE,EACZ,KAAK,EAAE,MAAM,GACZ;IAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,YAAY,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASxD;AAKD,KAAK,iBAAiB,GAAG;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACzB;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B,CAAC;AAEF,wBAAsB,aAAa,CAAC,CAAC,EACnC,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,CAAC,EAAE,iBAAiB,GACvB,OAAO,CAAC,CAAC,CAAC,CAgEZ;AA0ED,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAC3B,aAAa,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,EAC9D,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,CAAC,CAAC,CAyBZ"}
@@ -1,4 +1,5 @@
1
1
  import { determineIfIsFetcherError } from "@cargo-ai/api";
2
+ import { clipToCells } from "../utils/textWidth.js";
2
3
  export const ExitCodes = {
3
4
  Success: 0,
4
5
  GenericError: 1,
@@ -10,7 +11,7 @@ export const ExitCodes = {
10
11
  const useColor = process.stderr.isTTY === true &&
11
12
  process.env["NO_COLOR"] === undefined &&
12
13
  process.env["CI"] !== "true";
13
- const colorize = (text, code) => {
14
+ export const colorize = (text, code) => {
14
15
  if (useColor !== true) {
15
16
  return text;
16
17
  }
@@ -99,10 +100,12 @@ export function clipToWidth(text, columns) {
99
100
  // One column short of the edge: writing into the last cell makes some
100
101
  // terminals wrap to the next row, which is the thing being avoided.
101
102
  const limit = width - 1;
102
- if (limit <= 1 || text.length <= limit) {
103
+ if (limit <= 1) {
103
104
  return text;
104
105
  }
105
- return `${text.slice(0, limit - 1)}…`;
106
+ // Counted in columns: an emoji or a CJK character is two of them, and a
107
+ // line that wraps throws off every redraw that assumed one row.
108
+ return clipToCells(text, limit);
106
109
  }
107
110
  let activeSpinner;
108
111
  export function startSpinner(message) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How many terminal columns text takes. `length` counts UTF-16 units, which
3
+ * is wrong both ways: an emoji or a CJK character is one code point drawn two
4
+ * columns wide, and a combining mark or a variation selector is drawn in none.
5
+ * Every width the terminal UI lines up — wrapping, table columns, clipping a
6
+ * line to one row — has to use this, or a single emoji pushes the row past
7
+ * the edge and the next redraw starts from the wrong line.
8
+ *
9
+ * A range table rather than full Unicode East Asian Width data: it covers
10
+ * what a model writes (CJK, Hangul, fullwidth forms, emoji) and nothing else.
11
+ */
12
+ export declare function textWidth(text: string): number;
13
+ /**
14
+ * Text split into what the terminal draws as one character: a code point
15
+ * with the zero-width marks after it (a combining accent, `U+FE0F` asking for
16
+ * the emoji form, a skin tone). Walk these, not code points, to lay text out.
17
+ */
18
+ export declare function textCells(text: string): string[];
19
+ /** Columns one entry of `textCells` takes: 0, 1 or 2. */
20
+ export declare function cellWidth(cell: string): number;
21
+ /** The longest prefix of `text` that fits in `width` columns. */
22
+ export declare function sliceToWidth(text: string, width: number): string;
23
+ /** `text` cut to `width` columns, ending in `…` when anything was cut. */
24
+ export declare function clipToCells(text: string, width: number): string;
25
+ //# sourceMappingURL=textWidth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"textWidth.d.ts","sourceRoot":"","sources":["../../src/utils/textWidth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAehD;AAED,yDAAyD;AACzD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAsB9C;AAED,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAehE;AAED,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAM/D"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * How many terminal columns text takes. `length` counts UTF-16 units, which
3
+ * is wrong both ways: an emoji or a CJK character is one code point drawn two
4
+ * columns wide, and a combining mark or a variation selector is drawn in none.
5
+ * Every width the terminal UI lines up — wrapping, table columns, clipping a
6
+ * line to one row — has to use this, or a single emoji pushes the row past
7
+ * the edge and the next redraw starts from the wrong line.
8
+ *
9
+ * A range table rather than full Unicode East Asian Width data: it covers
10
+ * what a model writes (CJK, Hangul, fullwidth forms, emoji) and nothing else.
11
+ */
12
+ export function textWidth(text) {
13
+ return textCells(text).reduce((total, cell) => total + cellWidth(cell), 0);
14
+ }
15
+ /**
16
+ * Text split into what the terminal draws as one character: a code point
17
+ * with the zero-width marks after it (a combining accent, `U+FE0F` asking for
18
+ * the emoji form, a skin tone). Walk these, not code points, to lay text out.
19
+ */
20
+ export function textCells(text) {
21
+ const cells = [];
22
+ for (const character of Array.from(text)) {
23
+ const last = cells[cells.length - 1];
24
+ if (last !== undefined && isZeroWidth(character) === true) {
25
+ cells[cells.length - 1] = `${last}${character}`;
26
+ continue;
27
+ }
28
+ cells.push(character);
29
+ }
30
+ return cells;
31
+ }
32
+ /** Columns one entry of `textCells` takes: 0, 1 or 2. */
33
+ export function cellWidth(cell) {
34
+ const base = cell.codePointAt(0);
35
+ if (base === undefined) {
36
+ return 0;
37
+ }
38
+ if (isZeroWidth(String.fromCodePoint(base)) === true) {
39
+ return 0;
40
+ }
41
+ // `⚠️`: a symbol that is narrow on its own is drawn as a wide emoji when
42
+ // `U+FE0F` follows it.
43
+ if (cell.includes(EMOJI_PRESENTATION) === true) {
44
+ return 2;
45
+ }
46
+ if (inRanges(base, DOUBLE_WIDTH) === true) {
47
+ return 2;
48
+ }
49
+ return 1;
50
+ }
51
+ /** The longest prefix of `text` that fits in `width` columns. */
52
+ export function sliceToWidth(text, width) {
53
+ const state = { used: 0, prefix: "" };
54
+ for (const cell of textCells(text)) {
55
+ const cells = cellWidth(cell);
56
+ if (state.used + cells > width) {
57
+ break;
58
+ }
59
+ state.used += cells;
60
+ state.prefix += cell;
61
+ }
62
+ return state.prefix;
63
+ }
64
+ /** `text` cut to `width` columns, ending in `…` when anything was cut. */
65
+ export function clipToCells(text, width) {
66
+ if (textWidth(text) <= width) {
67
+ return text;
68
+ }
69
+ return `${sliceToWidth(text, Math.max(0, width - 1))}…`;
70
+ }
71
+ function isZeroWidth(character) {
72
+ const code = character.codePointAt(0);
73
+ return code !== undefined && inRanges(code, ZERO_WIDTH) === true;
74
+ }
75
+ function inRanges(code, ranges) {
76
+ return ranges.some(([from, to]) => code >= from && code <= to);
77
+ }
78
+ const EMOJI_PRESENTATION = "️";
79
+ const ZERO_WIDTH = [
80
+ [0x0300, 0x036f], // combining diacritical marks
81
+ [0x200b, 0x200f], // zero-width space, joiners, direction marks
82
+ [0x20d0, 0x20ff], // combining marks for symbols
83
+ [0xfe00, 0xfe0f], // variation selectors
84
+ [0x1f3fb, 0x1f3ff], // emoji skin tones, drawn into the emoji before them
85
+ ];
86
+ /**
87
+ * East Asian Wide ranges, and the symbols Unicode draws as emoji by default
88
+ * (`Emoji_Presentation`) inside otherwise narrow blocks: ✅ ✨ ❌ ⭐ and the
89
+ * like, which terminals draw two columns wide.
90
+ */
91
+ const DOUBLE_WIDTH = [
92
+ [0x1100, 0x115f], // Hangul Jamo
93
+ [0x231a, 0x231b], // ⌚ ⌛
94
+ [0x23e9, 0x23ec], // ⏩ … ⏬
95
+ [0x23f0, 0x23f0], // ⏰
96
+ [0x23f3, 0x23f3], // ⏳
97
+ [0x25fd, 0x25fe], // ◽ ◾
98
+ [0x2614, 0x2615], // ☔ ☕
99
+ [0x2648, 0x2653], // ♈ … ♓
100
+ [0x267f, 0x267f], // ♿
101
+ [0x2693, 0x2693], // ⚓
102
+ [0x26a1, 0x26a1], // ⚡
103
+ [0x26aa, 0x26ab], // ⚪ ⚫
104
+ [0x26bd, 0x26be], // ⚽ ⚾
105
+ [0x26c4, 0x26c5], // ⛄ ⛅
106
+ [0x26ce, 0x26ce], // ⛎
107
+ [0x26d4, 0x26d4], // ⛔
108
+ [0x26ea, 0x26ea], // ⛪
109
+ [0x26f2, 0x26f3], // ⛲ ⛳
110
+ [0x26f5, 0x26f5], // ⛵
111
+ [0x26fa, 0x26fa], // ⛺
112
+ [0x26fd, 0x26fd], // ⛽
113
+ [0x2705, 0x2705], // ✅
114
+ [0x270a, 0x270b], // ✊ ✋
115
+ [0x2728, 0x2728], // ✨
116
+ [0x274c, 0x274c], // ❌
117
+ [0x274e, 0x274e], // ❎
118
+ [0x2753, 0x2755], // ❓ ❔ ❕
119
+ [0x2757, 0x2757], // ❗
120
+ [0x2795, 0x2797], // ➕ ➖ ➗
121
+ [0x27b0, 0x27b0], // ➰
122
+ [0x27bf, 0x27bf], // ➿
123
+ [0x2b1b, 0x2b1c], // ⬛ ⬜
124
+ [0x2b50, 0x2b50], // ⭐
125
+ [0x2b55, 0x2b55], // ⭕
126
+ [0x2e80, 0x303e], // CJK radicals, punctuation
127
+ [0x3041, 0x33ff], // kana, CJK compatibility
128
+ [0x3400, 0x4dbf], // CJK extension A
129
+ [0x4e00, 0x9fff], // CJK unified ideographs
130
+ [0xa000, 0xa4cf], // Yi
131
+ [0xac00, 0xd7a3], // Hangul syllables
132
+ [0xf900, 0xfaff], // CJK compatibility ideographs
133
+ [0xfe30, 0xfe4f], // CJK compatibility forms
134
+ [0xff00, 0xff60], // fullwidth forms
135
+ [0xffe0, 0xffe6], // fullwidth signs
136
+ [0x1f004, 0x1f004], // 🀄
137
+ [0x1f0cf, 0x1f0cf], // 🃏
138
+ [0x1f18e, 0x1f18e], // 🆎
139
+ [0x1f191, 0x1f19a], // 🆑 … 🆚
140
+ [0x1f200, 0x1f2ff], // enclosed ideographic supplement
141
+ [0x1f300, 0x1f64f], // pictographs, emoticons
142
+ [0x1f680, 0x1f6ff], // transport and map symbols
143
+ [0x1f7e0, 0x1f7eb], // 🟠 … 🟫
144
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
145
+ [0x1fa70, 0x1faff], // symbols and pictographs extended-A
146
+ [0x20000, 0x3fffd], // CJK extensions B and on
147
+ ];