@ouro.bot/cli 0.1.0-alpha.175 → 0.1.0-alpha.176

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.
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MarkdownStreamer = exports.InputController = exports.Spinner = exports.StreamingWordWrapper = exports.wrapCliText = exports.formatEchoedInputSummary = void 0;
36
+ exports.InputQueue = exports.MarkdownStreamer = exports.InputController = exports.Spinner = exports.StreamingWordWrapper = exports.wrapCliText = exports.formatEchoedInputSummary = void 0;
37
37
  exports.formatPendingPrefix = formatPendingPrefix;
38
38
  exports.getCliContinuityIngressTexts = getCliContinuityIngressTexts;
39
39
  exports.writeCliAsyncAssistantMessage = writeCliAsyncAssistantMessage;
@@ -75,6 +75,8 @@ const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
75
75
  const agent_config_v2_1 = require("../heart/daemon/hooks/agent-config-v2");
76
76
  const bundle_manifest_1 = require("../mind/bundle-manifest");
77
77
  const cli_layout_1 = require("./cli-layout");
78
+ const spinner_imperative_1 = require("./cli/spinner-imperative");
79
+ const tool_display_1 = require("./cli/tool-display");
78
80
  var cli_layout_2 = require("./cli-layout");
79
81
  Object.defineProperty(exports, "formatEchoedInputSummary", { enumerable: true, get: function () { return cli_layout_2.formatEchoedInputSummary; } });
80
82
  Object.defineProperty(exports, "wrapCliText", { enumerable: true, get: function () { return cli_layout_2.wrapCliText; } });
@@ -96,7 +98,7 @@ function getCliContinuityIngressTexts(input) {
96
98
  const trimmed = input.trim();
97
99
  return trimmed ? [trimmed] : [];
98
100
  }
99
- const CLI_PROMPT = "\x1b[36m> \x1b[0m";
101
+ const CLI_PROMPT = "\x1b[36m) \x1b[0m";
100
102
  function writeCliAsyncAssistantMessage(rl, message, stdout = process.stdout) {
101
103
  const rlInt = rl;
102
104
  const currentLine = rlInt.line ?? "";
@@ -119,82 +121,9 @@ function pauseActiveSpinner() { _activeSpinner?.pause(); }
119
121
  function resumeActiveSpinner() { _activeSpinner?.resume(); }
120
122
  /* v8 ignore stop */
121
123
  function setActiveSpinner(s) { _activeSpinner = s; }
122
- // spinner that only touches stderr, cleans up after itself
123
- // exported for direct testability (stop-without-start branch)
124
- class Spinner {
125
- frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
126
- i = 0;
127
- iv = null;
128
- piv = null;
129
- msg = "";
130
- phrases = null;
131
- lastPhrase = "";
132
- stopped = false;
133
- constructor(m = "working", phrases) {
134
- this.msg = m;
135
- if (phrases && phrases.length > 0)
136
- this.phrases = phrases;
137
- }
138
- start() {
139
- this.stopped = false;
140
- process.stderr.write("\r\x1b[K");
141
- this.spin();
142
- this.iv = setInterval(() => this.spin(), 80);
143
- if (this.phrases) {
144
- this.piv = setInterval(() => this.rotatePhrase(), 1500);
145
- }
146
- }
147
- spin() {
148
- // Guard: clearInterval can't prevent already-dequeued callbacks
149
- /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
150
- if (this.stopped)
151
- return;
152
- process.stderr.write(`\r\x1b[K${this.frames[this.i]} ${this.msg}... `);
153
- this.i = (this.i + 1) % this.frames.length;
154
- }
155
- rotatePhrase() {
156
- /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
157
- if (this.stopped)
158
- return;
159
- const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
160
- this.lastPhrase = next;
161
- this.msg = next;
162
- }
163
- /* v8 ignore start -- pause/resume: exercised at runtime via log sink coordination @preserve */
164
- /** Clear the spinner line temporarily so other output can print cleanly. */
165
- pause() {
166
- if (this.stopped)
167
- return;
168
- process.stderr.write("\r\x1b[K");
169
- }
170
- /** Restore the spinner line after a pause. */
171
- resume() {
172
- if (this.stopped)
173
- return;
174
- this.spin();
175
- }
176
- /* v8 ignore stop */
177
- stop(ok) {
178
- this.stopped = true;
179
- if (this.iv) {
180
- clearInterval(this.iv);
181
- this.iv = null;
182
- }
183
- if (this.piv) {
184
- clearInterval(this.piv);
185
- this.piv = null;
186
- }
187
- process.stderr.write("\r\x1b[K");
188
- /* v8 ignore next -- ok parameter currently unused by callers @preserve */
189
- if (ok)
190
- process.stderr.write(`\x1b[32m\u2713\x1b[0m ${ok}\n`);
191
- }
192
- fail(msg) {
193
- this.stop();
194
- process.stderr.write(`\x1b[31m\u2717\x1b[0m ${msg}\n`);
195
- }
196
- }
197
- exports.Spinner = Spinner;
124
+ // Re-export ImperativeSpinner as Spinner for backward compatibility
125
+ var spinner_imperative_2 = require("./cli/spinner-imperative");
126
+ Object.defineProperty(exports, "Spinner", { enumerable: true, get: function () { return spinner_imperative_2.ImperativeSpinner; } });
198
127
  // Input controller: pauses readline during model/tool execution.
199
128
  // Does NOT touch raw mode — readline with terminal:true manages raw mode
200
129
  // internally. Touching it causes ^C to be echoed by the terminal driver.
@@ -377,7 +306,7 @@ function createCliCallbacks() {
377
306
  const phrases = (0, phrases_1.getPhrases)();
378
307
  const pool = hadToolRun ? phrases.followup : phrases.thinking;
379
308
  const first = (0, phrases_1.pickPhrase)(pool);
380
- setSpinner(new Spinner(first, pool));
309
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, pool));
381
310
  currentSpinner.start();
382
311
  },
383
312
  onModelStreamStart: () => {
@@ -424,16 +353,14 @@ function createCliCallbacks() {
424
353
  }
425
354
  const toolPhrases = (0, phrases_1.getPhrases)().tool;
426
355
  const first = (0, phrases_1.pickPhrase)(toolPhrases);
427
- setSpinner(new Spinner(first, toolPhrases));
356
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, toolPhrases));
428
357
  currentSpinner.start();
429
358
  hadToolRun = true;
430
359
  },
431
360
  onToolEnd: (name, argSummary, success) => {
432
361
  currentSpinner?.stop();
433
362
  setSpinner(null);
434
- const msg = (0, format_1.formatToolResult)(name, argSummary, success);
435
- const color = success ? "\x1b[32m" : "\x1b[31m";
436
- process.stderr.write(`${color}${msg}\x1b[0m\n`);
363
+ (0, tool_display_1.writeToolEnd)(name, argSummary, success);
437
364
  },
438
365
  onError: (error, severity) => {
439
366
  if (severity === "transient") {
@@ -509,29 +436,191 @@ async function* createDebouncedLines(source, debounceMs) {
509
436
  yield lines.join("\n");
510
437
  }
511
438
  }
439
+ /**
440
+ * Async queue that bridges push-based Ink input to pull-based async iteration.
441
+ * Input from Ink's onSubmit callback is pushed; the business logic loop awaits via for-await.
442
+ */
443
+ class InputQueue {
444
+ queue = [];
445
+ resolve = null;
446
+ done = false;
447
+ push(input) {
448
+ if (this.done)
449
+ return;
450
+ if (this.resolve) {
451
+ const r = this.resolve;
452
+ this.resolve = null;
453
+ r({ value: input, done: false });
454
+ }
455
+ else {
456
+ this.queue.push(input);
457
+ }
458
+ }
459
+ close() {
460
+ this.done = true;
461
+ if (this.resolve) {
462
+ const r = this.resolve;
463
+ this.resolve = null;
464
+ r({ value: undefined, done: true });
465
+ }
466
+ }
467
+ [Symbol.asyncIterator]() {
468
+ return {
469
+ next: () => {
470
+ if (this.queue.length > 0) {
471
+ return Promise.resolve({ value: this.queue.shift(), done: false });
472
+ }
473
+ if (this.done) {
474
+ return Promise.resolve({ value: undefined, done: true });
475
+ }
476
+ return new Promise((resolve) => {
477
+ this.resolve = resolve;
478
+ });
479
+ },
480
+ };
481
+ }
482
+ }
483
+ exports.InputQueue = InputQueue;
512
484
  async function runCliSession(options) {
513
485
  /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
514
- const pasteDebounceMs = options.pasteDebounceMs ?? 50;
515
486
  const registry = (0, commands_1.createCommandRegistry)();
516
487
  if (!options.disableCommands) {
517
488
  (0, commands_1.registerDefaultCommands)(registry);
518
489
  }
519
490
  const messages = options.messages
520
491
  ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
521
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
522
- const ctrl = new InputController(rl);
492
+ // ─── Rendering: TUI (Ink + Static) for TTY, imperative for tests/pipes ───
493
+ const useTui = !options._testInputSource && process.stdin.isTTY === true;
523
494
  let currentAbort = null;
524
- const history = [];
525
495
  let closed = false;
526
- rl.on("close", () => { closed = true; });
527
- if (options.banner !== false) {
528
- const bannerText = typeof options.banner === "string"
529
- ? options.banner
530
- : `${options.agentName} (type /commands for help)`;
531
- // eslint-disable-next-line no-console -- terminal UX: startup banner
532
- console.log(`\n${bannerText}\n`);
496
+ let cliCallbacks;
497
+ let tuiStore = null;
498
+ let inkRef = null;
499
+ const inputQueue = useTui ? new InputQueue() : null;
500
+ let rl = null;
501
+ if (useTui) {
502
+ try {
503
+ const [ink, React, tuiMod, storeMod] = await Promise.all([
504
+ Promise.resolve().then(() => __importStar(require("ink"))),
505
+ Promise.resolve().then(() => __importStar(require("react"))),
506
+ Promise.resolve().then(() => __importStar(require("./cli/ouro-tui"))),
507
+ Promise.resolve().then(() => __importStar(require("./cli/tui-store"))),
508
+ ]);
509
+ const { OuroTui } = tuiMod;
510
+ const { TuiStore, createTuiCallbacks } = storeMod;
511
+ tuiStore = new TuiStore();
512
+ cliCallbacks = createTuiCallbacks(tuiStore);
513
+ // Seed input history from previous session (for up/down arrows) — NOT display
514
+ const prevUserMsgs = messages
515
+ .filter((msg) => msg.role === "user" && typeof msg.content === "string")
516
+ .map(msg => msg.content);
517
+ tuiStore.seedHistory(prevUserMsgs);
518
+ // Ctrl-C state machine (Claude Code behavior):
519
+ // During generation: abort current request
520
+ // Idle with text: clear input
521
+ // Idle empty (first): warn
522
+ // Idle empty (second): exit
523
+ let ctrlCWarned = false;
524
+ const handleCtrlC = (hasInput) => {
525
+ if (currentAbort) {
526
+ // During generation: abort the request
527
+ currentAbort.abort();
528
+ ctrlCWarned = false;
529
+ return "abort";
530
+ }
531
+ if (hasInput) {
532
+ ctrlCWarned = false;
533
+ return "clear";
534
+ }
535
+ if (ctrlCWarned) {
536
+ ctrlCWarned = false;
537
+ closed = true;
538
+ inputQueue.close();
539
+ return "exit";
540
+ }
541
+ ctrlCWarned = true;
542
+ return "warn";
543
+ };
544
+ // TUI root: subscribes to store, passes props to OuroTui
545
+ // Elapsed timer is local React state (no store.notify overhead)
546
+ const storeRef = tuiStore;
547
+ function TuiRoot() {
548
+ const [, forceUpdate] = React.useState(0);
549
+ const [elapsed, setElapsed] = React.useState(0);
550
+ React.useEffect(() => storeRef.subscribe(() => {
551
+ forceUpdate((n) => n + 1);
552
+ // Reset ctrlC warning on any state change (new turn, etc.)
553
+ }), []);
554
+ React.useEffect(() => {
555
+ const iv = setInterval(() => setElapsed(storeRef.getElapsed()), 1000);
556
+ return () => clearInterval(iv);
557
+ }, []);
558
+ return React.createElement(OuroTui, {
559
+ agentName: options.agentName,
560
+ model: (0, identity_1.loadAgentConfig)().humanFacing?.model ?? "",
561
+ completedMessages: storeRef.completedMessages,
562
+ inputHistory: storeRef.inputHistory,
563
+ live: storeRef.live,
564
+ elapsedSeconds: elapsed,
565
+ contextPercent: 0,
566
+ onSubmit: (text) => { ctrlCWarned = false; inputQueue.push(text); },
567
+ onCtrlC: handleCtrlC,
568
+ headerShown: storeRef.headerShown,
569
+ cwd: process.cwd().replace(process.env.HOME ?? "", "~"),
570
+ });
571
+ }
572
+ inkRef = ink.render(React.createElement(TuiRoot), { exitOnCtrlC: false, patchConsole: false });
573
+ }
574
+ catch (err) {
575
+ // Ink failed to load (CJS compat, missing deps, etc.) — fall through to imperative
576
+ (0, runtime_1.emitNervesEvent)({
577
+ component: "senses",
578
+ event: "senses.tui_fallback",
579
+ message: `TUI failed to load, falling back to imperative: ${err instanceof Error ? err.message : String(err)}`,
580
+ meta: {},
581
+ });
582
+ }
583
+ }
584
+ // Fallback to imperative callbacks if TUI didn't initialize
585
+ if (!tuiStore) {
586
+ cliCallbacks = createCliCallbacks();
587
+ if (options.banner !== false) {
588
+ const bannerText = typeof options.banner === "string"
589
+ ? options.banner
590
+ : `${options.agentName} (type /commands for help)`;
591
+ // eslint-disable-next-line no-console -- terminal UX: startup banner
592
+ console.log(`\n${bannerText}\n`);
593
+ }
533
594
  }
534
- const cliCallbacks = createCliCallbacks();
595
+ // Display helpers: route to TUI store or imperative stderr/stdout
596
+ const display = {
597
+ error: (msg) => {
598
+ if (tuiStore)
599
+ tuiStore.setError(msg);
600
+ else
601
+ process.stderr.write(`\x1b[31m${msg}\x1b[0m\n`);
602
+ },
603
+ warn: (msg) => {
604
+ if (tuiStore)
605
+ tuiStore.setError(msg); // TUI shows warnings as errors (amber color handled in component)
606
+ else
607
+ process.stderr.write(`\x1b[33m${msg}\x1b[0m\n`);
608
+ },
609
+ text: (msg) => {
610
+ if (tuiStore)
611
+ tuiStore.appendText(msg);
612
+ else
613
+ process.stdout.write(`${msg}\n`);
614
+ },
615
+ suppressInput: () => {
616
+ if (tuiStore)
617
+ tuiStore.suppressInput();
618
+ },
619
+ restoreInput: () => {
620
+ if (tuiStore)
621
+ tuiStore.restoreInput();
622
+ },
623
+ };
535
624
  const effectiveToolContext = {
536
625
  signin: options.toolContext?.signin ?? (async () => undefined),
537
626
  ...options.toolContext,
@@ -543,7 +632,7 @@ async function runCliSession(options) {
543
632
  };
544
633
  messages.push(assistantMessage);
545
634
  await options.onAsyncAssistantMessage?.(messages, assistantMessage);
546
- writeCliAsyncAssistantMessage(rl, message);
635
+ display.text(message);
547
636
  await options.toolContext?.codingFeedback?.send(message);
548
637
  },
549
638
  },
@@ -558,8 +647,6 @@ async function runCliSession(options) {
558
647
  if (name === options.exitOnToolCall) {
559
648
  exitToolResult = result;
560
649
  exitToolFired = true;
561
- // Abort immediately so the model doesn't generate more output
562
- // (e.g. reasoning about calling settle after complete_adoption)
563
650
  currentAbort?.abort();
564
651
  }
565
652
  return result;
@@ -567,28 +654,6 @@ async function runCliSession(options) {
567
654
  : resolvedExecTool;
568
655
  // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
569
656
  const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
570
- // Ctrl-C at the input prompt: clear line or warn/exit
571
- rl.on("SIGINT", () => {
572
- const rlInt = rl;
573
- const currentLine = rlInt.line || "";
574
- const result = handleSigint(rl, currentLine);
575
- if (result === "clear") {
576
- rlInt.line = "";
577
- rlInt.cursor = 0;
578
- process.stdout.write(`\r\x1b[K${CLI_PROMPT}`);
579
- }
580
- else if (result === "warn") {
581
- rlInt.line = "";
582
- rlInt.cursor = 0;
583
- process.stdout.write("\r\x1b[K");
584
- process.stderr.write("press Ctrl-C again to exit\n");
585
- process.stdout.write(CLI_PROMPT);
586
- }
587
- else {
588
- rl.close();
589
- }
590
- });
591
- const debouncedLines = (source) => createDebouncedLines(source, pasteDebounceMs);
592
657
  (0, runtime_1.emitNervesEvent)({
593
658
  component: "senses",
594
659
  event: "senses.cli_session_start",
@@ -601,7 +666,7 @@ async function runCliSession(options) {
601
666
  if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
602
667
  currentAbort = new AbortController();
603
668
  const traceId = (0, nerves_1.createTraceId)();
604
- ctrl.suppress(() => currentAbort.abort());
669
+ display.suppressInput();
605
670
  let result;
606
671
  try {
607
672
  result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
@@ -613,51 +678,60 @@ async function runCliSession(options) {
613
678
  });
614
679
  }
615
680
  catch (err) {
616
- // AbortError (Ctrl-C) -- silently continue to prompt
617
- // All other errors: show the user what happened
618
681
  if (!(err instanceof DOMException && err.name === "AbortError")) {
619
- process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
682
+ display.error(err instanceof Error ? err.message : String(err));
620
683
  }
621
684
  }
622
685
  cliCallbacks.flushMarkdown();
623
- ctrl.restore();
686
+ display.restoreInput();
624
687
  currentAbort = null;
625
688
  if (exitToolFired) {
626
689
  exitReason = "tool_exit";
627
- rl.close();
690
+ closed = true;
628
691
  }
629
692
  else {
630
693
  const lastMsg = messages[messages.length - 1];
631
694
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
632
- process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
695
+ display.warn("(empty response)");
633
696
  }
634
- process.stdout.write("\n\n");
635
697
  if (options.onTurnEnd) {
636
698
  await options.onTurnEnd(messages, result ?? { usage: undefined });
637
699
  }
638
700
  }
639
701
  }
640
- if (!exitToolFired) {
641
- process.stdout.write(CLI_PROMPT);
642
- }
643
702
  try {
644
- for await (const input of debouncedLines(rl)) {
703
+ // Input source: TUI queue for Ink, test source for tests, readline fallback
704
+ let inputSource;
705
+ let inputCtrl = null;
706
+ if (tuiStore && inputQueue) {
707
+ // TUI path: input comes from Ink's onSubmit → InputQueue
708
+ inputSource = inputQueue;
709
+ }
710
+ else if (options._testInputSource) {
711
+ inputSource = options._testInputSource;
712
+ }
713
+ else {
714
+ // Imperative fallback: readline
715
+ const isTTY = process.stdin.isTTY === true;
716
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: isTTY });
717
+ inputCtrl = new InputController(rl);
718
+ inputSource = rl;
719
+ process.stdout.write(CLI_PROMPT);
720
+ }
721
+ for await (const input of inputSource) {
645
722
  if (closed)
646
723
  break;
647
- if (!input.trim()) {
648
- process.stdout.write(CLI_PROMPT);
724
+ if (!input.trim())
649
725
  continue;
650
- }
651
726
  // Optional input gate (e.g. trust gate in main)
652
727
  if (options.onInput) {
653
728
  const gate = options.onInput(input);
654
729
  if (!gate.allowed) {
655
730
  if (gate.reply) {
656
- process.stdout.write(`${gate.reply}\n`);
731
+ display.text(gate.reply);
657
732
  }
658
733
  if (closed)
659
734
  break;
660
- process.stdout.write(CLI_PROMPT);
661
735
  continue;
662
736
  }
663
737
  }
@@ -676,30 +750,27 @@ async function runCliSession(options) {
676
750
  await options.onNewSession?.();
677
751
  // eslint-disable-next-line no-console -- terminal UX: session cleared
678
752
  console.log("session cleared");
679
- process.stdout.write(CLI_PROMPT);
680
753
  continue;
681
754
  }
682
755
  else if (dispatchResult.result.action === "response") {
683
- // eslint-disable-next-line no-console -- terminal UX: command dispatch result
684
- console.log(dispatchResult.result.message || "");
685
- process.stdout.write(CLI_PROMPT);
756
+ display.text(dispatchResult.result.message || "");
686
757
  continue;
687
758
  }
688
759
  }
689
760
  }
690
761
  }
691
- // Re-style the echoed input lines without leaving wrapped paste remnants behind.
692
- const cols = process.stdout.columns || 80;
693
- process.stdout.write((0, cli_layout_1.formatEchoedInputSummary)(input, cols));
694
- addHistory(history, input);
762
+ // Track user message in TUI for display
763
+ if (tuiStore)
764
+ tuiStore.addUserMessage(input);
695
765
  currentAbort = new AbortController();
696
- ctrl.suppress(() => currentAbort.abort());
766
+ if (tuiStore)
767
+ tuiStore.suppressInput();
768
+ inputCtrl?.suppress(() => { currentAbort?.abort(); });
697
769
  let result;
698
770
  try {
699
771
  if (options.runTurn) {
700
772
  // Pipeline-based turn: the runTurn callback handles user message assembly,
701
773
  // pending drain, trust gate, runAgent, postTurn, and token accumulation.
702
- // Commands (e.g. /debug, /exit, /new) are intercepted in the pipeline.
703
774
  result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
704
775
  // Handle pipeline-intercepted commands with loop-control side effects
705
776
  if (result?.turnOutcome === "command") {
@@ -712,12 +783,10 @@ async function runCliSession(options) {
712
783
  await options.onNewSession?.();
713
784
  // eslint-disable-next-line no-console -- terminal UX: session cleared
714
785
  console.log("session cleared");
715
- process.stdout.write(CLI_PROMPT);
716
786
  continue;
717
787
  }
718
788
  // For "response" commands: the pipeline already emitted the response via onTextChunk
719
789
  cliCallbacks.flushMarkdown();
720
- process.stdout.write(CLI_PROMPT);
721
790
  continue;
722
791
  }
723
792
  }
@@ -736,14 +805,16 @@ async function runCliSession(options) {
736
805
  }
737
806
  }
738
807
  catch (err) {
739
- // AbortError (Ctrl-C) -- silently return to prompt
740
- // All other errors: show the user what happened
741
808
  if (!(err instanceof DOMException && err.name === "AbortError")) {
742
- process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
809
+ display.error(err instanceof Error ? err.message : String(err));
743
810
  }
744
811
  }
745
812
  cliCallbacks.flushMarkdown();
746
- ctrl.restore();
813
+ if (!tuiStore)
814
+ process.stdout.write("\n"); // ensure response ends with newline before prompt (imperative only)
815
+ if (tuiStore)
816
+ tuiStore.restoreInput();
817
+ inputCtrl?.restore();
747
818
  currentAbort = null;
748
819
  // Check if exit tool was fired during this turn
749
820
  if (exitToolFired) {
@@ -753,20 +824,21 @@ async function runCliSession(options) {
753
824
  // Safety net: never silently swallow an empty response
754
825
  const lastMsg = messages[messages.length - 1];
755
826
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
756
- process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
827
+ display.warn("(empty response)");
757
828
  }
758
- process.stdout.write("\n\n");
759
829
  // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
760
830
  if (options.onTurnEnd) {
761
831
  await options.onTurnEnd(messages, result ?? { usage: undefined });
762
832
  }
763
833
  if (closed)
764
834
  break;
765
- process.stdout.write(CLI_PROMPT);
766
835
  }
767
836
  }
768
837
  finally {
769
- rl.close();
838
+ rl?.close();
839
+ if (inkRef) {
840
+ inkRef.unmount();
841
+ }
770
842
  if (options.banner !== false) {
771
843
  // eslint-disable-next-line no-console -- terminal UX: goodbye
772
844
  console.log("bye");
@@ -838,6 +910,7 @@ async function main(agentName, options) {
838
910
  agentName: currentAgentName,
839
911
  pasteDebounceMs,
840
912
  messages: sessionMessages,
913
+ _testInputSource: options?._testInputSource,
841
914
  onAsyncAssistantMessage: async (messages, _assistantMessage) => {
842
915
  (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
843
916
  },
@@ -933,3 +1006,4 @@ async function main(agentName, options) {
933
1006
  sessionLock?.release();
934
1007
  }
935
1008
  }
1009
+ // CI trigger