@cargo-ai/cli 1.0.87 → 1.0.89

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 (31) hide show
  1. package/README.md +1 -1
  2. package/build/commands/ask/claudeConfig.d.ts +2 -0
  3. package/build/commands/ask/claudeConfig.d.ts.map +1 -1
  4. package/build/commands/ask/claudeConfig.js +67 -1
  5. package/build/commands/ask/index.js +1 -1
  6. package/build/commands/ask/localHarness.d.ts +15 -1
  7. package/build/commands/ask/localHarness.d.ts.map +1 -1
  8. package/build/commands/ask/localHarness.js +127 -42
  9. package/build/commands/ask/messageStream.js +1 -1
  10. package/build/commands/ask/prompt.d.ts +7 -1
  11. package/build/commands/ask/prompt.d.ts.map +1 -1
  12. package/build/commands/ask/prompt.js +9 -3
  13. package/build/commands/ask/session.d.ts +8 -1
  14. package/build/commands/ask/session.d.ts.map +1 -1
  15. package/build/commands/ask/session.js +364 -62
  16. package/build/commands/ask/thinking.d.ts +21 -0
  17. package/build/commands/ask/thinking.d.ts.map +1 -0
  18. package/build/commands/ask/thinking.js +57 -0
  19. package/build/commands/ask/typeAhead.d.ts +67 -0
  20. package/build/commands/ask/typeAhead.d.ts.map +1 -0
  21. package/build/commands/ask/typeAhead.js +169 -0
  22. package/build/commands/auth/workspace.js +1 -1
  23. package/build/commands/runHandler.d.ts +7 -0
  24. package/build/commands/runHandler.d.ts.map +1 -1
  25. package/build/commands/runHandler.js +39 -4
  26. package/build/commands/workspaceManagement/envVar.d.ts +4 -0
  27. package/build/commands/workspaceManagement/envVar.d.ts.map +1 -0
  28. package/build/commands/workspaceManagement/envVar.js +71 -0
  29. package/build/commands/workspaceManagement/index.d.ts.map +1 -1
  30. package/build/commands/workspaceManagement/index.js +3 -1
  31. package/package.json +8 -6
@@ -4,7 +4,7 @@ import { determineIfIsFetcherError } from "@cargo-ai/api";
4
4
  import { isInteractive, selectOne } from "@cargo-ai/cdk/cli";
5
5
  import { getConfig } from "../../config.js";
6
6
  import { openPromptInput } from "../../utils/prompt.js";
7
- import { colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
7
+ import { clipToWidth, colors, ExitCodes, failWith, handleApiCall, outputJson, pollUntilFinished, startSpinner, } from "../runHandler.js";
8
8
  import { loadChatHistoryPreamble } from "./history.js";
9
9
  import { createLocalHarness, HarnessTurnStalled, HarnessTurnStuck, isBridgeClosedError, } from "./localHarness.js";
10
10
  import { streamMessageTextDeltas } from "./messageStream.js";
@@ -12,6 +12,8 @@ import { saveAskState } from "./persist.js";
12
12
  import { readAskLine } from "./prompt.js";
13
13
  import { parseSlash } from "./slash.js";
14
14
  import { ASK_STATE_VERSION } from "./state.js";
15
+ import { pickThinkingWord } from "./thinking.js";
16
+ import { collectTypeAhead, EMPTY_TYPE_AHEAD, } from "./typeAhead.js";
15
17
  function requireInteractive() {
16
18
  if (isInteractive() === false || process.stdout.isTTY !== true) {
17
19
  failWith("Ask needs a terminal. Re-run with --print for one JSON turn.", {
@@ -21,6 +23,18 @@ function requireInteractive() {
21
23
  }
22
24
  /** Sentinel for `/agent` → Local workspace (no agentUuid). */
23
25
  const LOCAL_WORKSPACE = "__local_workspace__";
26
+ /**
27
+ * What an assistant turn is marked with, alongside `◇` for a step and `│` for
28
+ * the rail.
29
+ *
30
+ * A text glyph rather than an emoji, so it takes the same `marker + two
31
+ * spaces` as every other row and the whole tree stays on one left edge. An
32
+ * emoji is two columns wide and needs its own spacing rule to line up, which
33
+ * is one more thing to get wrong every time a line is drawn.
34
+ */
35
+ const ASSISTANT_MARKER = "■";
36
+ /** Completed tool call, so the transcript keeps what was actually run. */
37
+ const TOOL_MARKER = "⏺";
24
38
  /**
25
39
  * How long before a proxy token's stated expiry to stop trusting it. Covers
26
40
  * the gap between deciding to reuse the harness and the first proxy call the
@@ -107,7 +121,7 @@ async function ensureChat(input) {
107
121
  }
108
122
  await handleApiCall(() => input.api.ai.chat.create({
109
123
  uuid: input.chatUuid,
110
- trigger: { type: "app" },
124
+ trigger: { type: "cli" },
111
125
  agentUuid: input.agentUuid,
112
126
  }), { spinner: false });
113
127
  }
@@ -193,14 +207,18 @@ export async function doesChatExist(api, chatUuid) {
193
207
  */
194
208
  function treeBodyWidth() {
195
209
  const columns = process.stderr.columns === undefined ? 80 : process.stderr.columns;
196
- // Marker + two spaces (`◇ `, `│ `, `◆ `).
197
- return Math.max(24, columns - 3);
210
+ // Marker + two spaces (`◇ `, `│ `, `◆ `), and one column of margin: a
211
+ // line filling the last cell makes some terminals wrap on their own, and the
212
+ // `\n` after it then lands a row further down than the tree expects.
213
+ return Math.max(24, columns - 4);
198
214
  }
199
215
  /**
200
216
  * Greedy wrap on spaces. A word longer than the width keeps its own line
201
217
  * rather than being cut mid-token.
218
+ *
219
+ * Exported for tests.
202
220
  */
203
- function wrapLine(text, width) {
221
+ export function wrapLine(text, width) {
204
222
  const lines = [];
205
223
  for (const word of text.split(/\s+/).filter((part) => part.length > 0)) {
206
224
  const current = lines[lines.length - 1];
@@ -248,8 +266,25 @@ function forTerminal(text) {
248
266
  * ◇ Agent
249
267
  * │ reply…
250
268
  */
269
+ /**
270
+ * A `◇` step: the first line beside the marker, the rest continuing on the
271
+ * rail under it.
272
+ *
273
+ * Wrapped here rather than left to the terminal. A label long enough to reach
274
+ * the edge — a stall notice, an API error — soft-wraps mid-word and starts its
275
+ * next line at column zero, outside the tree, which is how a one-sentence
276
+ * notice ends up looking like the output is corrupted.
277
+ */
278
+ function writeDiamondBlock(body) {
279
+ const lines = body === "" ? [""] : wrapLine(body, treeBodyWidth());
280
+ const first = lines[0] === undefined ? "" : lines[0];
281
+ process.stderr.write(`${colors.dim("◇")} ${first}\n`);
282
+ for (const line of lines.slice(1)) {
283
+ writeRail(line);
284
+ }
285
+ }
251
286
  function showStep(label, body) {
252
- process.stderr.write(`${colors.dim("◇")} ${label}\n`);
287
+ writeDiamondBlock(label);
253
288
  for (const paragraph of body.split("\n")) {
254
289
  writeRailWrapped(paragraph);
255
290
  }
@@ -270,15 +305,7 @@ function showCard(body) {
270
305
  * `◇ ask hey --continue`. No "You" label.
271
306
  */
272
307
  function showUserTurn(body) {
273
- const lines = body === "" ? [""] : wrapLine(body, treeBodyWidth());
274
- const first = lines.length === 0 ? "" : lines[0] === undefined ? "" : lines[0];
275
- process.stderr.write(`${colors.dim("◇")} ${first}\n`);
276
- for (let index = 1; index < lines.length; index += 1) {
277
- const line = lines[index];
278
- if (line !== undefined) {
279
- writeRail(line);
280
- }
281
- }
308
+ writeDiamondBlock(body);
282
309
  }
283
310
  /**
284
311
  * Display lines for an assistant body. First line is the brick header body;
@@ -312,9 +339,7 @@ function assistantDisplayLines(body) {
312
339
  }
313
340
  function writeAssistantLines(lines) {
314
341
  const first = lines[0] === undefined ? "" : lines[0];
315
- // One space after the brick: the emoji is already two columns, so `🧱 `
316
- // lines up with `◇ ` / `│ `.
317
- process.stderr.write(`🧱 ${first}\n`);
342
+ process.stderr.write(`${ASSISTANT_MARKER} ${first}\n`);
318
343
  for (let index = 1; index < lines.length; index += 1) {
319
344
  const line = lines[index];
320
345
  if (line !== undefined) {
@@ -328,25 +353,56 @@ function clearPaintedLines(count) {
328
353
  }
329
354
  process.stderr.write(`\x1B[${String(count)}A\r\x1B[J`);
330
355
  }
331
- /** Rail gap, then an empty brick line ready for deltas. */
356
+ /** Rail gap, then an empty marker line ready for deltas. */
332
357
  function beginAssistantTurn() {
333
358
  writeRail("");
334
- process.stderr.write(`🧱 \n`);
335
- return { buffer: "", paintedLines: 1 };
359
+ process.stderr.write(`${ASSISTANT_MARKER} \n`);
360
+ return { buffer: "", paintedLines: 1, footer: "" };
336
361
  }
337
362
  function appendAssistantDelta(live, delta) {
338
- live.buffer += delta;
363
+ // A tool between two pieces of narration inserts a paragraph break, so that
364
+ // "Let me look." and what it found do not run together. When the tool also
365
+ // closed the block on screen, that break arrives as the first thing in the
366
+ // *next* block — and renders as an empty rail line under the marker before a
367
+ // word has been said. Kept in the stored answer, where the paragraphs still
368
+ // belong; dropped here, where the block itself is already the separation.
369
+ const text = live.buffer === "" ? delta.replace(/^\n+/, "") : delta;
370
+ if (text === "") {
371
+ return;
372
+ }
373
+ live.buffer += text;
374
+ repaintAssistantTurn(live);
375
+ }
376
+ /** Draw the block again from its buffer, with the typed line under it. */
377
+ function repaintAssistantTurn(live) {
339
378
  const lines = assistantDisplayLines(live.buffer);
340
379
  clearPaintedLines(live.paintedLines);
341
380
  writeAssistantLines(lines);
342
381
  live.paintedLines = lines.length;
382
+ if (live.footer !== "") {
383
+ // Clipped for the same reason the spinner's is: it has to occupy exactly
384
+ // one row, or the next repaint clears from the wrong line.
385
+ process.stderr.write(`${colors.dim(clipToWidth(live.footer))}\n`);
386
+ live.paintedLines += 1;
387
+ }
388
+ }
389
+ /** Show, change or remove the question being typed over this reply. */
390
+ function setAssistantFooter(live, footer) {
391
+ if (live.footer === footer) {
392
+ return;
393
+ }
394
+ live.footer = footer;
395
+ repaintAssistantTurn(live);
343
396
  }
344
397
  /** Trailing rail gap before the next user turn / ◆ prompt. */
345
- function endAssistantTurn(_live) {
398
+ function endAssistantTurn(live) {
399
+ // The typed line belongs to whatever draws next — the spinner, or the block
400
+ // after this one. Left here it would be printed twice.
401
+ setAssistantFooter(live, "");
346
402
  writeRail("");
347
403
  }
348
404
  /**
349
- * Finished assistant turn — same tree as a user step, but `🧱` instead of `◇`.
405
+ * Finished assistant turn — same tree as a user step, with `■` instead of `◇`.
350
406
  */
351
407
  function showAssistantTurn(body) {
352
408
  const live = beginAssistantTurn();
@@ -357,11 +413,21 @@ function showAssistantTurn(body) {
357
413
  * Ctrl-C during a turn cancels the turn, not the CLI: the harness interrupts
358
414
  * Claude Code, the tree says so, and the ◆ prompt comes back. A second Ctrl-C
359
415
  * while the first is still settling exits for real.
416
+ *
417
+ * Except once the turn has reached teardown, where exiting is the one thing
418
+ * that must not happen: `process.exit` skips the `finally` that shuts Claude
419
+ * Code down, so an impatient second press — the natural reaction to a few
420
+ * seconds of apparently nothing — is exactly what strands the process this
421
+ * teardown exists to stop. Presses are ignored for the length of it, which is
422
+ * bounded: `close` gives up after `SESSION_DESTROY_TIMEOUT_MS` either way.
360
423
  */
361
424
  function beginCancellableTurn(onCancel) {
362
425
  const controller = new AbortController();
363
- const state = { cancelled: false };
426
+ const state = { cancelled: false, tearingDown: false };
364
427
  const handleInterrupt = () => {
428
+ if (state.tearingDown === true) {
429
+ return;
430
+ }
365
431
  if (state.cancelled === true) {
366
432
  // 130 is the conventional "terminated by SIGINT" status.
367
433
  process.exit(130);
@@ -377,11 +443,57 @@ function beginCancellableTurn(onCancel) {
377
443
  abort: () => {
378
444
  controller.abort();
379
445
  },
446
+ beginTeardown: () => {
447
+ state.tearingDown = true;
448
+ },
449
+ interrupt: handleInterrupt,
380
450
  release: () => {
381
451
  process.removeListener("SIGINT", handleInterrupt);
382
452
  },
383
453
  };
384
454
  }
455
+ /**
456
+ * Let the next question be typed while this one is still being answered.
457
+ *
458
+ * The collector owns the terminal for the length of the turn; the spinner
459
+ * shows what has been typed so far, because raw mode means the terminal is no
460
+ * longer echoing it.
461
+ *
462
+ * Degrades to no type-ahead rather than failing when there is no terminal to
463
+ * read — `--print` and the non-interactive paths never get here, but a session
464
+ * whose stdin went away should still finish the turn it is running.
465
+ */
466
+ function beginTurnTyping(input) {
467
+ const terminal = openPromptInput();
468
+ if (terminal === undefined) {
469
+ return {
470
+ pause: async () => undefined,
471
+ resume: () => undefined,
472
+ finish: () => EMPTY_TYPE_AHEAD,
473
+ };
474
+ }
475
+ const collector = collectTypeAhead({
476
+ stream: terminal.stream,
477
+ initialLine: input.carriedLine,
478
+ onInterrupt: input.onInterrupt,
479
+ onChange: input.onTyped,
480
+ });
481
+ input.onTyped(input.carriedLine);
482
+ return {
483
+ pause: collector.pause,
484
+ resume: () => {
485
+ collector.resume();
486
+ // The prompt that borrowed the terminal drew over the line; put it back.
487
+ input.onTyped(collector.current());
488
+ },
489
+ finish: () => {
490
+ const typed = collector.stop();
491
+ input.onTyped("");
492
+ terminal.close();
493
+ return typed;
494
+ },
495
+ };
496
+ }
385
497
  /**
386
498
  * Stop a remote turn the user walked away from.
387
499
  *
@@ -418,6 +530,28 @@ function describeToolActivity(toolName, summary) {
418
530
  ? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
419
531
  : line;
420
532
  }
533
+ /**
534
+ * A tool that has finished, left in the transcript.
535
+ *
536
+ * The spinner shows what is running, but it is one line that is overwritten
537
+ * and then cleared, so once a turn is over there is no record of what it did —
538
+ * only the prose describing it. A turn that read four files and ran two
539
+ * commands should say so afterwards, the way the approval prompt does for the
540
+ * tools it happened to ask about.
541
+ *
542
+ * Written through the spinner's `log` when one is up, which wipes the spinner
543
+ * line, leaves this behind, and lets the next frame redraw underneath.
544
+ */
545
+ function showToolRun(input) {
546
+ const marker = input.isError === true ? colors.red("✗") : colors.dim(TOOL_MARKER);
547
+ const label = describeToolActivity(input.toolName, input.summary);
548
+ const line = `${marker} ${colors.dim(label)}`;
549
+ if (input.spinner !== undefined) {
550
+ input.spinner.log(line);
551
+ return;
552
+ }
553
+ process.stderr.write(`${line}\n`);
554
+ }
421
555
  /**
422
556
  * A path inside the session directory reads better relative to it; anything
423
557
  * outside stays absolute so it is obvious the turn left the project.
@@ -484,7 +618,9 @@ async function askToolApproval(approval) {
484
618
  * and path-like tokens. Empty Enter leaves. A first message from the palette
485
619
  * is written as a finished user turn, then sent.
486
620
  */
487
- async function readQuestion(firstMessage, dir) {
621
+ async function readQuestion(firstMessage, dir,
622
+ /** Typed while the last turn was still answering, and not sent as its own. */
623
+ typedAhead = "") {
488
624
  if (firstMessage !== undefined) {
489
625
  showUserTurn(firstMessage);
490
626
  return firstMessage;
@@ -496,7 +632,7 @@ async function readQuestion(firstMessage, dir) {
496
632
  });
497
633
  }
498
634
  try {
499
- const answer = await readAskLine(input.stream, dir);
635
+ const answer = await readAskLine(input.stream, dir, typedAhead);
500
636
  if (answer === undefined) {
501
637
  return undefined;
502
638
  }
@@ -507,10 +643,36 @@ async function readQuestion(firstMessage, dir) {
507
643
  input.close();
508
644
  }
509
645
  }
646
+ /**
647
+ * Where the next question comes from, once type-ahead is in the picture.
648
+ *
649
+ * A line the user finished with Enter during the last reply is already their
650
+ * next question, so it is sent rather than parked at the prompt — the way it
651
+ * would have been had they waited. Anything typed after that Enter, finished
652
+ * or not, is carried into the turn it kicks off so it survives to the prompt
653
+ * after that: one question can be queued behind a reply, not a series of them.
654
+ * An unfinished line with no Enter simply opens the prompt with itself in
655
+ * place.
656
+ */
657
+ async function nextQuestion(input) {
658
+ const { queued } = input;
659
+ const pending = `${input.carried}${queued.pending}`;
660
+ if (queued.submitted !== undefined) {
661
+ return {
662
+ question: await readQuestion(queued.submitted, input.dir),
663
+ carriedLine: pending,
664
+ };
665
+ }
666
+ return {
667
+ // Consumed by the prompt, so nothing is carried past it.
668
+ question: await readQuestion(input.firstMessage, input.dir, pending),
669
+ carriedLine: "",
670
+ };
671
+ }
510
672
  /**
511
673
  * Same tree grammar as the palette:
512
674
  * ◇ <your message>
513
- * 🧱 reply…
675
+ * ■ reply…
514
676
  * ◆
515
677
  * Empty Enter or /exit leaves to the parent palette.
516
678
  */
@@ -539,14 +701,29 @@ export async function runAskSession(options) {
539
701
  const sessionId = randomUUID();
540
702
  // "Yes to everything" lasts until the chat is reset, never on disk.
541
703
  let approveEveryTool = false;
704
+ // Typed while the last turn was still answering: a finished line goes
705
+ // straight through as the next question, an unfinished one opens the prompt
706
+ // with it already in place.
707
+ let typedAhead = EMPTY_TYPE_AHEAD;
708
+ // The tail of a type-ahead whose first line was submitted, waiting for the
709
+ // next turn's collector to pick up where it left off.
710
+ let carriedLine = "";
542
711
  if (options.continueChat === true && options.chatUuid !== undefined) {
543
712
  chatCreated = await doesChatExist(options.api, options.chatUuid);
544
713
  }
545
714
  try {
546
715
  for (;;) {
547
716
  const mode = modeForAgent(agent);
548
- const question = await readQuestion(pendingFirst, options.dir);
717
+ const next = await nextQuestion({
718
+ queued: typedAhead,
719
+ carried: carriedLine,
720
+ firstMessage: pendingFirst,
721
+ dir: options.dir,
722
+ });
723
+ typedAhead = EMPTY_TYPE_AHEAD;
724
+ carriedLine = next.carriedLine;
549
725
  pendingFirst = undefined;
726
+ const { question } = next;
550
727
  if (question === undefined) {
551
728
  return "palette";
552
729
  }
@@ -618,25 +795,39 @@ export async function runAskSession(options) {
618
795
  local = undefined;
619
796
  }
620
797
  if (local === undefined) {
621
- const minted = await handleApiCall(() => options.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
622
- localTokenMintedAtMs = Date.now();
623
- localTokenTtlMs = minted.expiresInSeconds * 1000;
624
- // A new Claude Code session knows nothing of this chat, whether it
625
- // is being continued from an earlier run or was reset by /agent.
626
- const history = await loadChatHistoryPreamble({
627
- api: options.api,
628
- chatUuid,
629
- });
630
- local = await createLocalHarness({
631
- api: options.api,
632
- apiBaseUrl: config.baseUrl,
633
- dir: options.dir,
634
- chatUuid,
635
- agentUuid: agent === undefined ? undefined : agent.uuid,
636
- proxyToken: minted.token,
637
- history,
638
- turnOwnerId: sessionId,
639
- });
798
+ // Nothing has been drawn since the question was echoed, and the
799
+ // Claude Code that has to answer it is not running yet: on a cold
800
+ // config directory that is ten to fifteen seconds of a blank tree,
801
+ // which reads as the CLI having hung on the first thing it was ever
802
+ // asked. Warm, this comes and goes inside a fifth of a second.
803
+ //
804
+ // Retired by `startSpinner` when the turn starts its own, and by the
805
+ // `finally` on the path where the setup throws instead.
806
+ const starting = startSpinner("Starting the coding session…");
807
+ try {
808
+ const minted = await handleApiCall(() => options.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
809
+ localTokenMintedAtMs = Date.now();
810
+ localTokenTtlMs = minted.expiresInSeconds * 1000;
811
+ // A new Claude Code session knows nothing of this chat, whether it
812
+ // is being continued from an earlier run or was reset by /agent.
813
+ const history = await loadChatHistoryPreamble({
814
+ api: options.api,
815
+ chatUuid,
816
+ });
817
+ local = await createLocalHarness({
818
+ api: options.api,
819
+ apiBaseUrl: config.baseUrl,
820
+ dir: options.dir,
821
+ chatUuid,
822
+ agentUuid: agent === undefined ? undefined : agent.uuid,
823
+ proxyToken: minted.token,
824
+ history,
825
+ turnOwnerId: sessionId,
826
+ });
827
+ }
828
+ finally {
829
+ starting.stop();
830
+ }
640
831
  }
641
832
  // Nothing about a local turn is visible to the server until it is
642
833
  // ingested, so the chat reads as idle while Claude Code works and a
@@ -669,8 +860,30 @@ export async function runAskSession(options) {
669
860
  const touchedFiles = new Set();
670
861
  const notices = [];
671
862
  // Reads and Cargo MCP tools are batched; the first one to finish must
672
- // not wipe the spinner back to "Asking…" while siblings still run.
863
+ // not wipe the spinner back to a thinking word while siblings run.
673
864
  const activeTools = [];
865
+ // What the user has typed into this turn so far, drawn under the
866
+ // spinner. It lives here rather than on the spinner because a turn
867
+ // stops and restarts several of those — for an approval, for each tool
868
+ // label — and the line has to outlive all of them.
869
+ let typedLine = "";
870
+ // Plain text: it is clipped to the terminal width, and slicing a
871
+ // styled string would cut an escape sequence in half.
872
+ //
873
+ // Drawn by whichever of the two owns the screen. A reply streaming in
874
+ // has replaced the spinner with a live block, and that is exactly when
875
+ // someone is most likely to be typing over it — routing this to the
876
+ // spinner alone leaves the keyboard working and nothing showing for it.
877
+ const paintTyped = () => {
878
+ const footer = typedLine === "" ? "" : `◆ ${typedLine}`;
879
+ if (live !== undefined) {
880
+ setAssistantFooter(live, footer);
881
+ return;
882
+ }
883
+ if (spinner !== undefined) {
884
+ spinner.footer(footer);
885
+ }
886
+ };
674
887
  const stopSpinner = () => {
675
888
  if (spinner !== undefined) {
676
889
  spinner.stop();
@@ -680,18 +893,32 @@ export async function runAskSession(options) {
680
893
  const showSpinner = (label) => {
681
894
  if (spinner === undefined) {
682
895
  spinner = startSpinner(label);
683
- return;
684
896
  }
685
- spinner.update(label);
897
+ else {
898
+ spinner.update(label);
899
+ }
900
+ paintTyped();
686
901
  };
902
+ // A fresh word each time the turn goes back to thinking, so a long
903
+ // turn visibly moves through its steps instead of sitting on one label
904
+ // from the first second to the last. Held between draws — the spinner
905
+ // is repainted on every tool start and finish, and re-rolling there
906
+ // would make the word flicker rather than mark progress.
907
+ let thinkingWord = pickThinkingWord();
908
+ let wasRunningTool = false;
687
909
  // The most recent tool labels the spinner. Nothing running means the
688
910
  // turn is thinking rather than working.
689
911
  const showActiveToolSpinner = () => {
690
912
  const current = activeTools[activeTools.length - 1];
691
913
  if (current === undefined) {
692
- showSpinner("Asking…");
914
+ if (wasRunningTool === true) {
915
+ thinkingWord = pickThinkingWord(thinkingWord);
916
+ wasRunningTool = false;
917
+ }
918
+ showSpinner(thinkingWord);
693
919
  return;
694
920
  }
921
+ wasRunningTool = true;
695
922
  showSpinner(describeToolActivity(current.toolName, current.summary));
696
923
  };
697
924
  // A tool is announced twice — once when its name arrives, again with a
@@ -718,7 +945,16 @@ export async function runAskSession(options) {
718
945
  // clack's spinner is already stopped when its own handler fires —
719
946
  // otherwise Ctrl-C prints its cancel line under ours.
720
947
  const cancellation = beginCancellableTurn(stopSpinner);
721
- spinner = startSpinner("Asking…");
948
+ spinner = startSpinner(thinkingWord);
949
+ const typing = beginTurnTyping({
950
+ carriedLine,
951
+ onInterrupt: () => cancellation.interrupt(),
952
+ onTyped: (text) => {
953
+ typedLine = text;
954
+ paintTyped();
955
+ },
956
+ });
957
+ carriedLine = "";
722
958
  try {
723
959
  await local.streamPrompt(question, {
724
960
  abortSignal: cancellation.signal,
@@ -729,6 +965,8 @@ export async function runAskSession(options) {
729
965
  if (live === undefined) {
730
966
  stopSpinner();
731
967
  live = beginAssistantTurn();
968
+ // The spinner was carrying the typed line; it goes with it.
969
+ paintTyped();
732
970
  }
733
971
  saidSomething = true;
734
972
  appendAssistantDelta(live, delta);
@@ -739,10 +977,24 @@ export async function runAskSession(options) {
739
977
  // so a tool usually starts with live text on screen: close that
740
978
  // block first, and the next delta opens a new one.
741
979
  //
742
- // "dropped" is a tool the harness announced and then abandoned,
743
- // so it has no result coming. Same handling as a finish: forget
744
- // it, or the label outlives the work.
745
- if (activity.kind === "end" || activity.kind === "dropped") {
980
+ // "dropped" is a tool the harness announced and then abandoned.
981
+ // It has no result coming and it never ran, so it is forgotten
982
+ // without a line — the transcript should hold what happened, not
983
+ // what was briefly considered.
984
+ if (activity.kind === "dropped") {
985
+ forgetTool(activity.toolCallId);
986
+ return;
987
+ }
988
+ if (activity.kind === "end") {
989
+ // Read back before forgetting: only the remembered call knows
990
+ // the input, and `end` carries the name and the outcome alone.
991
+ const finished = activeTools.find((active) => active.toolCallId === activity.toolCallId);
992
+ showToolRun({
993
+ spinner,
994
+ toolName: activity.toolName,
995
+ summary: finished === undefined ? "" : finished.summary,
996
+ isError: activity.isError,
997
+ });
746
998
  forgetTool(activity.toolCallId);
747
999
  return;
748
1000
  }
@@ -769,9 +1021,25 @@ export async function runAskSession(options) {
769
1021
  live = undefined;
770
1022
  }
771
1023
  stopSpinner();
772
- const decision = approveEveryTool === true
773
- ? "always"
774
- : await askToolApproval(approval);
1024
+ let decision;
1025
+ if (approveEveryTool === true) {
1026
+ decision = "always";
1027
+ }
1028
+ else {
1029
+ // The picker reads the keyboard itself, so the type-ahead
1030
+ // collector has to let go of it — otherwise it swallows the
1031
+ // arrow keys and the approval can never be answered. Awaited,
1032
+ // because letting go settles first: the picker appears under
1033
+ // whatever the user is already typing and defaults to Yes, so
1034
+ // a keystroke in flight must not be able to answer it.
1035
+ await typing.pause();
1036
+ try {
1037
+ decision = await askToolApproval(approval);
1038
+ }
1039
+ finally {
1040
+ typing.resume();
1041
+ }
1042
+ }
775
1043
  if (decision === "always") {
776
1044
  approveEveryTool = true;
777
1045
  }
@@ -780,7 +1048,7 @@ export async function runAskSession(options) {
780
1048
  return false;
781
1049
  }
782
1050
  // The call was announced before the prompt; keep its label up
783
- // while it runs instead of falling back to a blank "Asking…".
1051
+ // while it runs instead of falling back to a thinking word.
784
1052
  rememberTool({
785
1053
  toolCallId: approval.toolCallId,
786
1054
  toolName: approval.toolName,
@@ -804,7 +1072,35 @@ export async function runAskSession(options) {
804
1072
  if (live !== undefined) {
805
1073
  endAssistantTurn(live);
806
1074
  }
1075
+ // The turn is over, whichever way it went: every branch below either
1076
+ // replaces the Claude Code session or unwinds the command. So a
1077
+ // Ctrl-C from here cannot stop anything the user would want stopped
1078
+ // — all it can do is skip the teardown, since `process.exit` runs no
1079
+ // `finally` and the `finally` blocks are what stop Claude Code.
1080
+ //
1081
+ // Marked for the whole unwind rather than the cancel branch alone. A
1082
+ // stall waits on the same `close` with the collector still holding
1083
+ // the terminal, and it is the likelier press of the two: minutes of
1084
+ // spinner is what makes someone reach for Ctrl-C in the first place.
1085
+ //
1086
+ // It also freezes the flag the branch below reads, so a press
1087
+ // landing here cannot make a stall look like a user cancel.
1088
+ cancellation.beginTeardown();
807
1089
  if (cancellation.wasCancelled() === true) {
1090
+ // Ctrl-C aborts the harness stream, and what it leaves behind is a
1091
+ // session that answers nothing: the next question opens a stream
1092
+ // that yields its `start` part and then goes silent until the
1093
+ // stall watchdog gives up on it two minutes later. The harness
1094
+ // does not report it as an unfinished turn, so there is nothing to
1095
+ // drain and no way to tell from the outside — the only cure is a
1096
+ // new Claude Code process.
1097
+ //
1098
+ // Paid here rather than on the next question so the wait belongs
1099
+ // to the interrupt the user just pressed. The chat, its history
1100
+ // and the working copy all survive; the next turn replays the
1101
+ // history into a fresh session.
1102
+ await local.close();
1103
+ local = undefined;
808
1104
  showTouchedFiles(touchedFiles, options.dir);
809
1105
  showNotices(notices);
810
1106
  showCard("Cancelled.");
@@ -832,6 +1128,10 @@ export async function runAskSession(options) {
832
1128
  throw error;
833
1129
  }
834
1130
  finally {
1131
+ // Before `cancellation.release()`: while the collector still owns
1132
+ // the terminal, Ctrl-C is a keypress it turns into an interrupt, and
1133
+ // dropping the handler first would leave a window with neither.
1134
+ typedAhead = typing.finish();
835
1135
  cancellation.release();
836
1136
  await releaseChatTurn(options.api, chatUuid, sessionId);
837
1137
  }
@@ -869,7 +1169,9 @@ export async function runAskSession(options) {
869
1169
  // Registered before the spinner so our handler runs before clack's own
870
1170
  // — same ordering as the local turn.
871
1171
  const cancellation = beginCancellableTurn(stopRemoteSpinner);
872
- spinner = startSpinner("Asking…");
1172
+ // A remote turn is one stream with no tool steps of its own to mark, so
1173
+ // it gets the one word rather than a sequence of them.
1174
+ spinner = startSpinner(pickThinkingWord());
873
1175
  let assistantMessageUuid;
874
1176
  try {
875
1177
  const deltas = streamMessageTextDeltas({