@cargo-ai/cli 1.0.86 → 1.0.88

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.
@@ -4,14 +4,16 @@ 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
- import { createLocalHarness, HarnessTurnStuck, } from "./localHarness.js";
9
+ import { createLocalHarness, HarnessTurnStalled, HarnessTurnStuck, isBridgeClosedError, } from "./localHarness.js";
10
10
  import { streamMessageTextDeltas } from "./messageStream.js";
11
11
  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
@@ -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);
@@ -374,11 +440,60 @@ function beginCancellableTurn(onCancel) {
374
440
  return {
375
441
  signal: controller.signal,
376
442
  wasCancelled: () => state.cancelled,
443
+ abort: () => {
444
+ controller.abort();
445
+ },
446
+ beginTeardown: () => {
447
+ state.tearingDown = true;
448
+ },
449
+ interrupt: handleInterrupt,
377
450
  release: () => {
378
451
  process.removeListener("SIGINT", handleInterrupt);
379
452
  },
380
453
  };
381
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
+ }
382
497
  /**
383
498
  * Stop a remote turn the user walked away from.
384
499
  *
@@ -415,6 +530,28 @@ function describeToolActivity(toolName, summary) {
415
530
  ? `${line.slice(0, ACTIVITY_MAX_LENGTH - 1)}…`
416
531
  : line;
417
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
+ }
418
555
  /**
419
556
  * A path inside the session directory reads better relative to it; anything
420
557
  * outside stays absolute so it is obvious the turn left the project.
@@ -481,7 +618,9 @@ async function askToolApproval(approval) {
481
618
  * and path-like tokens. Empty Enter leaves. A first message from the palette
482
619
  * is written as a finished user turn, then sent.
483
620
  */
484
- 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 = "") {
485
624
  if (firstMessage !== undefined) {
486
625
  showUserTurn(firstMessage);
487
626
  return firstMessage;
@@ -493,7 +632,7 @@ async function readQuestion(firstMessage, dir) {
493
632
  });
494
633
  }
495
634
  try {
496
- const answer = await readAskLine(input.stream, dir);
635
+ const answer = await readAskLine(input.stream, dir, typedAhead);
497
636
  if (answer === undefined) {
498
637
  return undefined;
499
638
  }
@@ -504,10 +643,36 @@ async function readQuestion(firstMessage, dir) {
504
643
  input.close();
505
644
  }
506
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
+ }
507
672
  /**
508
673
  * Same tree grammar as the palette:
509
674
  * ◇ <your message>
510
- * 🧱 reply…
675
+ * ■ reply…
511
676
  * ◆
512
677
  * Empty Enter or /exit leaves to the parent palette.
513
678
  */
@@ -536,14 +701,29 @@ export async function runAskSession(options) {
536
701
  const sessionId = randomUUID();
537
702
  // "Yes to everything" lasts until the chat is reset, never on disk.
538
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 = "";
539
711
  if (options.continueChat === true && options.chatUuid !== undefined) {
540
712
  chatCreated = await doesChatExist(options.api, options.chatUuid);
541
713
  }
542
714
  try {
543
715
  for (;;) {
544
716
  const mode = modeForAgent(agent);
545
- 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;
546
725
  pendingFirst = undefined;
726
+ const { question } = next;
547
727
  if (question === undefined) {
548
728
  return "palette";
549
729
  }
@@ -615,25 +795,39 @@ export async function runAskSession(options) {
615
795
  local = undefined;
616
796
  }
617
797
  if (local === undefined) {
618
- const minted = await handleApiCall(() => options.api.ai.chat.mintHarnessProxyToken(chatUuid), { spinner: false });
619
- localTokenMintedAtMs = Date.now();
620
- localTokenTtlMs = minted.expiresInSeconds * 1000;
621
- // A new Claude Code session knows nothing of this chat, whether it
622
- // is being continued from an earlier run or was reset by /agent.
623
- const history = await loadChatHistoryPreamble({
624
- api: options.api,
625
- chatUuid,
626
- });
627
- local = await createLocalHarness({
628
- api: options.api,
629
- apiBaseUrl: config.baseUrl,
630
- dir: options.dir,
631
- chatUuid,
632
- agentUuid: agent === undefined ? undefined : agent.uuid,
633
- proxyToken: minted.token,
634
- history,
635
- turnOwnerId: sessionId,
636
- });
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
+ }
637
831
  }
638
832
  // Nothing about a local turn is visible to the server until it is
639
833
  // ingested, so the chat reads as idle while Claude Code works and a
@@ -665,6 +859,31 @@ export async function runAskSession(options) {
665
859
  let saidSomething = false;
666
860
  const touchedFiles = new Set();
667
861
  const notices = [];
862
+ // Reads and Cargo MCP tools are batched; the first one to finish must
863
+ // not wipe the spinner back to a thinking word while siblings run.
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
+ };
668
887
  const stopSpinner = () => {
669
888
  if (spinner !== undefined) {
670
889
  spinner.stop();
@@ -674,22 +893,80 @@ export async function runAskSession(options) {
674
893
  const showSpinner = (label) => {
675
894
  if (spinner === undefined) {
676
895
  spinner = startSpinner(label);
896
+ }
897
+ else {
898
+ spinner.update(label);
899
+ }
900
+ paintTyped();
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;
909
+ // The most recent tool labels the spinner. Nothing running means the
910
+ // turn is thinking rather than working.
911
+ const showActiveToolSpinner = () => {
912
+ const current = activeTools[activeTools.length - 1];
913
+ if (current === undefined) {
914
+ if (wasRunningTool === true) {
915
+ thinkingWord = pickThinkingWord(thinkingWord);
916
+ wasRunningTool = false;
917
+ }
918
+ showSpinner(thinkingWord);
677
919
  return;
678
920
  }
679
- spinner.update(label);
921
+ wasRunningTool = true;
922
+ showSpinner(describeToolActivity(current.toolName, current.summary));
923
+ };
924
+ // A tool is announced twice — once when its name arrives, again with a
925
+ // summary once its input is known — so the second has to overwrite the
926
+ // first rather than stack a duplicate label.
927
+ const rememberTool = (tool) => {
928
+ const at = activeTools.findIndex((active) => active.toolCallId === tool.toolCallId);
929
+ if (at === -1) {
930
+ activeTools.push(tool);
931
+ }
932
+ else {
933
+ activeTools[at] = tool;
934
+ }
935
+ showActiveToolSpinner();
936
+ };
937
+ const forgetTool = (toolCallId) => {
938
+ const at = activeTools.findIndex((active) => active.toolCallId === toolCallId);
939
+ if (at !== -1) {
940
+ activeTools.splice(at, 1);
941
+ }
942
+ showActiveToolSpinner();
680
943
  };
681
944
  // Registered before the spinner so our SIGINT handler runs first and
682
945
  // clack's spinner is already stopped when its own handler fires —
683
946
  // otherwise Ctrl-C prints its cancel line under ours.
684
947
  const cancellation = beginCancellableTurn(stopSpinner);
685
- 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 = "";
686
958
  try {
687
959
  await local.streamPrompt(question, {
688
960
  abortSignal: cancellation.signal,
961
+ onStall: () => {
962
+ cancellation.abort();
963
+ },
689
964
  onTextDelta: (delta) => {
690
965
  if (live === undefined) {
691
966
  stopSpinner();
692
967
  live = beginAssistantTurn();
968
+ // The spinner was carrying the typed line; it goes with it.
969
+ paintTyped();
693
970
  }
694
971
  saidSomething = true;
695
972
  appendAssistantDelta(live, delta);
@@ -699,15 +976,37 @@ export async function runAskSession(options) {
699
976
  // not an unexplained pause. Claude Code narrates before it acts,
700
977
  // so a tool usually starts with live text on screen: close that
701
978
  // block first, and the next delta opens a new one.
979
+ //
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
+ }
702
988
  if (activity.kind === "end") {
703
- showSpinner("Asking…");
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
+ });
998
+ forgetTool(activity.toolCallId);
704
999
  return;
705
1000
  }
706
1001
  if (live !== undefined) {
707
1002
  endAssistantTurn(live);
708
1003
  live = undefined;
709
1004
  }
710
- showSpinner(describeToolActivity(activity.toolName, activity.summary));
1005
+ rememberTool({
1006
+ toolCallId: activity.toolCallId,
1007
+ toolName: activity.toolName,
1008
+ summary: activity.summary,
1009
+ });
711
1010
  },
712
1011
  onFileTouched: (path) => {
713
1012
  touchedFiles.add(path);
@@ -722,14 +1021,40 @@ export async function runAskSession(options) {
722
1021
  live = undefined;
723
1022
  }
724
1023
  stopSpinner();
725
- const decision = approveEveryTool === true
726
- ? "always"
727
- : 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
+ }
728
1043
  if (decision === "always") {
729
1044
  approveEveryTool = true;
730
1045
  }
731
- showSpinner("Asking…");
732
- return decision !== "no";
1046
+ if (decision === "no") {
1047
+ forgetTool(approval.toolCallId);
1048
+ return false;
1049
+ }
1050
+ // The call was announced before the prompt; keep its label up
1051
+ // while it runs instead of falling back to a thinking word.
1052
+ rememberTool({
1053
+ toolCallId: approval.toolCallId,
1054
+ toolName: approval.toolName,
1055
+ summary: approval.summary,
1056
+ });
1057
+ return true;
733
1058
  },
734
1059
  });
735
1060
  stopSpinner();
@@ -747,7 +1072,35 @@ export async function runAskSession(options) {
747
1072
  if (live !== undefined) {
748
1073
  endAssistantTurn(live);
749
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();
750
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;
751
1104
  showTouchedFiles(touchedFiles, options.dir);
752
1105
  showNotices(notices);
753
1106
  showCard("Cancelled.");
@@ -758,19 +1111,27 @@ export async function runAskSession(options) {
758
1111
  continue;
759
1112
  }
760
1113
  showNotices(notices);
761
- // The runtime could not be wound down, so this session will refuse
762
- // every question from here. Replace it rather than leaving the user
763
- // to guess that /new is the way out — the chat, its history and the
1114
+ // The runtime could not be wound down, went silent, or the bridge
1115
+ // dropped — this session will refuse every question from here.
1116
+ // Replace it rather than exiting Ask: the chat, its history and the
764
1117
  // working copy all survive; only the Claude Code process is new.
765
- if (error instanceof HarnessTurnStuck) {
1118
+ if (error instanceof HarnessTurnStuck ||
1119
+ error instanceof HarnessTurnStalled ||
1120
+ isBridgeClosedError(error) === true) {
766
1121
  await local.close();
767
1122
  local = undefined;
768
- showCard("That coding session stopped responding, so it has been replaced. Ask again — the chat is unchanged.");
1123
+ showCard(error instanceof HarnessTurnStalled
1124
+ ? `${error.message} The coding session has been replaced — ask again, or try smaller steps.`
1125
+ : "That coding session stopped responding, so it has been replaced. Ask again — the chat is unchanged.");
769
1126
  continue;
770
1127
  }
771
1128
  throw error;
772
1129
  }
773
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();
774
1135
  cancellation.release();
775
1136
  await releaseChatTurn(options.api, chatUuid, sessionId);
776
1137
  }
@@ -808,7 +1169,9 @@ export async function runAskSession(options) {
808
1169
  // Registered before the spinner so our handler runs before clack's own
809
1170
  // — same ordering as the local turn.
810
1171
  const cancellation = beginCancellableTurn(stopRemoteSpinner);
811
- 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());
812
1175
  let assistantMessageUuid;
813
1176
  try {
814
1177
  const deltas = streamMessageTextDeltas({