deepclause-sdk 0.1.2 → 0.2.1

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 (122) hide show
  1. package/README.md +45 -6
  2. package/dist/cli/config.d.ts +2 -2
  3. package/dist/cli/repl.d.ts +55 -4
  4. package/dist/cli/repl.d.ts.map +1 -1
  5. package/dist/cli/repl.js +675 -52
  6. package/dist/cli/repl.js.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/prolog-src/deepclause_mi.pl +516 -2
  12. package/dist/repl/backend.d.ts +9 -0
  13. package/dist/repl/backend.d.ts.map +1 -1
  14. package/dist/repl/backend.js +62 -5
  15. package/dist/repl/backend.js.map +1 -1
  16. package/dist/repl/harness-tools.d.ts +36 -0
  17. package/dist/repl/harness-tools.d.ts.map +1 -1
  18. package/dist/repl/harness-tools.js +173 -0
  19. package/dist/repl/harness-tools.js.map +1 -1
  20. package/dist/repl/history.d.ts +29 -0
  21. package/dist/repl/history.d.ts.map +1 -1
  22. package/dist/repl/history.js +71 -0
  23. package/dist/repl/history.js.map +1 -1
  24. package/dist/repl/index.d.ts +6 -0
  25. package/dist/repl/index.d.ts.map +1 -1
  26. package/dist/repl/index.js +9 -1
  27. package/dist/repl/index.js.map +1 -1
  28. package/dist/repl/picker.d.ts +3 -0
  29. package/dist/repl/picker.d.ts.map +1 -1
  30. package/dist/repl/picker.js +132 -0
  31. package/dist/repl/picker.js.map +1 -1
  32. package/dist/repl/process-backend.d.ts +31 -0
  33. package/dist/repl/process-backend.d.ts.map +1 -0
  34. package/dist/repl/process-backend.js +10 -0
  35. package/dist/repl/process-backend.js.map +1 -0
  36. package/dist/repl/process-executor.d.ts +67 -0
  37. package/dist/repl/process-executor.d.ts.map +1 -0
  38. package/dist/repl/process-executor.js +371 -0
  39. package/dist/repl/process-executor.js.map +1 -0
  40. package/dist/repl/process-worker.d.ts +10 -0
  41. package/dist/repl/process-worker.d.ts.map +1 -0
  42. package/dist/repl/process-worker.js +210 -0
  43. package/dist/repl/process-worker.js.map +1 -0
  44. package/dist/repl/processes.d.ts +282 -0
  45. package/dist/repl/processes.d.ts.map +1 -0
  46. package/dist/repl/processes.js +557 -0
  47. package/dist/repl/processes.js.map +1 -0
  48. package/dist/repl/render.d.ts +52 -2
  49. package/dist/repl/render.d.ts.map +1 -1
  50. package/dist/repl/render.js +52 -17
  51. package/dist/repl/render.js.map +1 -1
  52. package/dist/repl/session.d.ts +135 -3
  53. package/dist/repl/session.d.ts.map +1 -1
  54. package/dist/repl/session.js +413 -4
  55. package/dist/repl/session.js.map +1 -1
  56. package/dist/repl/status.d.ts +3 -1
  57. package/dist/repl/status.d.ts.map +1 -1
  58. package/dist/repl/status.js +4 -0
  59. package/dist/repl/status.js.map +1 -1
  60. package/dist/repl/tui/autocomplete.d.ts +19 -0
  61. package/dist/repl/tui/autocomplete.d.ts.map +1 -0
  62. package/dist/repl/tui/autocomplete.js +48 -0
  63. package/dist/repl/tui/autocomplete.js.map +1 -0
  64. package/dist/repl/tui/document.d.ts +51 -0
  65. package/dist/repl/tui/document.d.ts.map +1 -0
  66. package/dist/repl/tui/document.js +160 -0
  67. package/dist/repl/tui/document.js.map +1 -0
  68. package/dist/repl/tui/harness-view.d.ts +102 -0
  69. package/dist/repl/tui/harness-view.d.ts.map +1 -0
  70. package/dist/repl/tui/harness-view.js +323 -0
  71. package/dist/repl/tui/harness-view.js.map +1 -0
  72. package/dist/repl/tui/picker.d.ts +31 -0
  73. package/dist/repl/tui/picker.d.ts.map +1 -0
  74. package/dist/repl/tui/picker.js +43 -0
  75. package/dist/repl/tui/picker.js.map +1 -0
  76. package/dist/repl/tui/renderer.d.ts +98 -0
  77. package/dist/repl/tui/renderer.d.ts.map +1 -0
  78. package/dist/repl/tui/renderer.js +289 -0
  79. package/dist/repl/tui/renderer.js.map +1 -0
  80. package/dist/repl/tui/services-panel.d.ts +35 -0
  81. package/dist/repl/tui/services-panel.d.ts.map +1 -0
  82. package/dist/repl/tui/services-panel.js +108 -0
  83. package/dist/repl/tui/services-panel.js.map +1 -0
  84. package/dist/repl/tui/status-bar.d.ts +6 -2
  85. package/dist/repl/tui/status-bar.d.ts.map +1 -1
  86. package/dist/repl/tui/status-bar.js +82 -24
  87. package/dist/repl/tui/status-bar.js.map +1 -1
  88. package/dist/repl/tui/terminal.d.ts +28 -0
  89. package/dist/repl/tui/terminal.d.ts.map +1 -0
  90. package/dist/repl/tui/terminal.js +94 -0
  91. package/dist/repl/tui/terminal.js.map +1 -0
  92. package/dist/repl/tui/theme.d.ts +60 -0
  93. package/dist/repl/tui/theme.d.ts.map +1 -1
  94. package/dist/repl/tui/theme.js +142 -0
  95. package/dist/repl/tui/theme.js.map +1 -1
  96. package/dist/repl/tui/tool-pane.d.ts +68 -0
  97. package/dist/repl/tui/tool-pane.d.ts.map +1 -0
  98. package/dist/repl/tui/tool-pane.js +261 -0
  99. package/dist/repl/tui/tool-pane.js.map +1 -0
  100. package/dist/repl/tui/working-line.d.ts +23 -0
  101. package/dist/repl/tui/working-line.d.ts.map +1 -0
  102. package/dist/repl/tui/working-line.js +58 -0
  103. package/dist/repl/tui/working-line.js.map +1 -0
  104. package/dist/repl/worker-host.d.ts +80 -0
  105. package/dist/repl/worker-host.d.ts.map +1 -0
  106. package/dist/repl/worker-host.js +305 -0
  107. package/dist/repl/worker-host.js.map +1 -0
  108. package/dist/repl/worker-protocol.d.ts +123 -0
  109. package/dist/repl/worker-protocol.d.ts.map +1 -0
  110. package/dist/repl/worker-protocol.js +9 -0
  111. package/dist/repl/worker-protocol.js.map +1 -0
  112. package/dist/runner.d.ts +24 -0
  113. package/dist/runner.d.ts.map +1 -1
  114. package/dist/runner.js +47 -0
  115. package/dist/runner.js.map +1 -1
  116. package/dist/system/assets/docs/AGENT_PROMPT.md +193 -0
  117. package/dist/system/assets/docs/DML_AUTHORING.md +21 -0
  118. package/dist/system/assets/docs/DML_REFERENCE.md +145 -0
  119. package/dist/types.d.ts +7 -0
  120. package/dist/types.d.ts.map +1 -1
  121. package/package.json +2 -1
  122. package/src/prolog-src/deepclause_mi.pl +516 -2
package/dist/cli/repl.js CHANGED
@@ -14,14 +14,25 @@ import { createHarnessRepl } from '../repl/index.js';
14
14
  import { HistoryStore } from '../repl/history.js';
15
15
  import { resolveReplJudgeSelection } from '../repl/judge.js';
16
16
  import { ReplRenderer } from '../repl/render.js';
17
+ import { TranscriptDocument } from '../repl/tui/document.js';
18
+ import { ServicesPanel } from '../repl/tui/services-panel.js';
19
+ import { formatProcessCode, formatProcessTable } from '../repl/harness-tools.js';
20
+ import { ChoicePicker } from '../repl/tui/picker.js';
21
+ import { openPiRuntime } from '../repl/backend.js';
22
+ import { listLoginProviders, loginToProvider } from '../repl/auth.js';
23
+ import { HarnessView } from '../repl/tui/harness-view.js';
24
+ import { TuiRenderer } from '../repl/tui/renderer.js';
25
+ import { WorkingLine } from '../repl/tui/working-line.js';
26
+ import { createReplAutocomplete } from '../repl/tui/autocomplete.js';
27
+ import { initTuiTheme, scrollbarStyles } from '../repl/tui/theme.js';
28
+ import { SizedTerminal } from '../repl/tui/terminal.js';
17
29
  import { ReplStatusTracker } from '../repl/status.js';
18
- import { LineSink, TranscriptComponent } from '../repl/tui/transcript.js';
19
30
  import { StatusBarComponent } from '../repl/tui/status-bar.js';
20
- import { Container, ProcessTerminal, Spacer, TuiMainScreen, matchesKey } from '@earendil-works/pi-tui';
31
+ import { Container, HStack, ScrollView, Spacer, TuiAltScreen, VStack, matchesKey, } from '@earendil-works/pi-tui';
21
32
  import { editorTheme, formatInputAnswer } from '../repl/tui/theme.js';
22
33
  import { PromptEditor } from '../repl/tui/editor.js';
23
34
  import { loadProjectContext } from '../repl/project-context.js';
24
- import { createPiBackend } from '../repl/backend.js';
35
+ import { createPiBackend, THINKING_LEVELS } from '../repl/backend.js';
25
36
  import { createReplToolset } from '../repl/toolset.js';
26
37
  import { takeStartupPrologMessages } from '../prolog/loader.js';
27
38
  import { createPiImageBackend } from '../repl/imagegen.js';
@@ -71,6 +82,13 @@ export function parseVizArgs(arg) {
71
82
  export function formatTurnInput(turn) {
72
83
  return turn.mode === 'goal' ? `/goal ${turn.request}` : turn.request;
73
84
  }
85
+ /**
86
+ * Slash commands are executed (or queued), never steered into a running model
87
+ * turn: `/send`, `/call`, `/ps`, `/stop` … address processes, not the model.
88
+ */
89
+ export function isSlashCommand(line) {
90
+ return line.trim().startsWith('/');
91
+ }
74
92
  /**
75
93
  * Classify one REPL input line.
76
94
  *
@@ -113,6 +131,15 @@ export function classifyReplInput(line) {
113
131
  return { kind: 'export', path: arg || undefined };
114
132
  case 'model':
115
133
  return { kind: 'model', model: arg || undefined };
134
+ case 'harness':
135
+ case 'side':
136
+ return { kind: 'harness', width: arg || undefined };
137
+ case 'reasoning':
138
+ case 'reasoning-level':
139
+ return { kind: 'reasoning', level: arg || undefined };
140
+ case 'show-reasoning':
141
+ case 'reasoning-summary':
142
+ return { kind: 'show-reasoning' };
116
143
  case 'freeze':
117
144
  return { kind: 'freeze' };
118
145
  case 'unfreeze':
@@ -132,6 +159,36 @@ export function classifyReplInput(line) {
132
159
  case 'gen':
133
160
  case 'imagine':
134
161
  return arg ? { kind: 'gen', prompt: arg } : { kind: 'unknown-command', name: 'gen (missing prompt)' };
162
+ case 'spawn':
163
+ case 'serve':
164
+ return arg ? { kind: 'spawn', goal: arg } : { kind: 'unknown-command', name: 'spawn (missing goal)' };
165
+ case 'ps':
166
+ case 'processes': {
167
+ const watch = /(?:^|\s)--watch(?:\s|$)/.test(arg);
168
+ const logs = /(?:^|\s)--logs(?:\s|$)/.test(arg);
169
+ // `--code` alone shows the current harness version; `--code <target>`
170
+ // prints the harness snapshot that process is running.
171
+ const codeMatch = /(?:^|\s)--code(?:=(\S+)|\s+(\S+))?/.exec(arg);
172
+ const code = codeMatch ? (codeMatch[1] ?? codeMatch[2] ?? '') : undefined;
173
+ return { kind: 'ps', watch, logs, code };
174
+ }
175
+ case 'send':
176
+ case 'cast': {
177
+ const [target, ...rest] = arg.split(/\s+/);
178
+ return target && rest.length > 0
179
+ ? { kind: 'send', target, message: rest.join(' ') }
180
+ : { kind: 'unknown-command', name: 'send (usage: /send <name> <message>)' };
181
+ }
182
+ case 'call': {
183
+ const [target, ...rest] = arg.split(/\s+/);
184
+ return target && rest.length > 0
185
+ ? { kind: 'call', target, message: rest.join(' ') }
186
+ : { kind: 'unknown-command', name: 'call (usage: /call <name> <message>)' };
187
+ }
188
+ case 'stop':
189
+ return arg ? { kind: 'stop', target: arg } : { kind: 'unknown-command', name: 'stop (usage: /stop <name>)' };
190
+ case 'login':
191
+ return { kind: 'login' };
135
192
  default:
136
193
  return { kind: 'unknown-command', name };
137
194
  }
@@ -444,6 +501,7 @@ export async function createReplRuntime(options) {
444
501
  modelResolver,
445
502
  history: historyOptions,
446
503
  harness: harnessName,
504
+ execution: options.execution,
447
505
  autoLoadHarness: !(options.load?.length),
448
506
  compaction: { settings: replCompaction, auto: replCompaction.auto },
449
507
  contextWindow,
@@ -472,7 +530,7 @@ export async function createReplRuntime(options) {
472
530
  + "or run 'deepclause set-backend pi'.";
473
531
  }
474
532
  }
475
- return {
533
+ const runtime = {
476
534
  repl,
477
535
  renderer,
478
536
  workspace,
@@ -484,6 +542,10 @@ export async function createReplRuntime(options) {
484
542
  backend: backendKind,
485
543
  contextWindow,
486
544
  reasoning,
545
+ reasoningMode: options.showReasoning ?? config.repl.showReasoning,
546
+ modelResolver,
547
+ llmBackend,
548
+ setThinkingLevel: (level) => statusTracker.setThinkingLevel(level),
487
549
  thinkingLevel,
488
550
  sessionId: repl.session.getSessionId(),
489
551
  harnessName,
@@ -495,6 +557,23 @@ export async function createReplRuntime(options) {
495
557
  setOnInput: (handler) => repl.setOnInput(handler),
496
558
  missingCredential,
497
559
  };
560
+ // Session-scoped subagents render their lifecycle and output through the
561
+ // active renderer (the TUI swaps it in after this runtime is built).
562
+ repl.session.setProcessEventSink((event) => {
563
+ const label = `⚙ ${event.name}`;
564
+ const sink = runtime.renderer;
565
+ if (event.type === 'output' && event.content) {
566
+ sink.writeLine(`${label} ${event.content.trimEnd()}`);
567
+ }
568
+ else if (event.type === 'answer') {
569
+ sink.writeLine(`${label} › ${String(event.answer ?? 'ok')}`);
570
+ }
571
+ else if (event.type === 'exited' && event.state !== 'done') {
572
+ const reason = event.reason && event.reason !== 'normal' ? ` (${String(event.reason)})` : '';
573
+ sink.writeLine(`${label} ${event.state}${reason}`);
574
+ }
575
+ });
576
+ return runtime;
498
577
  }
499
578
  /** Package version, read once from package.json for the banner/`--version`. */
500
579
  export const PACKAGE_VERSION = (() => {
@@ -546,13 +625,28 @@ async function printBanner(runtime, loaded) {
546
625
  if (harnessFresh) {
547
626
  lines.push('', ` ${bold('Two modes.')} Plain English authors the harness; ${accent('/goal')} runs DML directly.`, '', ` ${accent('<plain English>')} default. Ask for anything: add a new skill, add a tool,`, ` change existing logic, run or test code in the living`, ` module, or just ask a question about the harness. An authoring`, ` agent writes the DML, tests it with ${dim('execute')}, and commits it.`, ` ${accent('/goal <dml>')} run DML/Prolog directly against the current harness.`, '', ` ${bold('Example')}`, ` add a research(Question, Answer) skill that answers with task/2`, ` run research on "why is the sky blue?"`, ` A = "…"`, '', ` ...or run DML directly (${accent('/goal')}):`, ` ${accent('/goal')} research("why is the sky blue?", A).`, ` A = "…"`, '', ` When you're ready, export the harness and re-use it whenever you need:`, ` /export research.dml save a portable harness`, ` ${dim('$')} deepclause repl -l research.dml load it back any time`, ` ${dim('$')} deepclause repl --session last resume the last session`);
548
627
  }
549
- lines.push('', ` ${accent('/goal')} ${dim('<dml>')} run a DML goal, e.g. ${dim('/goal member(X, [1,2,3])')}`, ` ${accent('<plain English>')} author the harness`, '', ` ${accent('/help')} ${accent('/listing')} ${accent('/viz')} ${accent('/export')} ${accent('/freeze')} ${accent('/model')} ${accent('/halt')}`);
628
+ lines.push('', ` ${accent('/goal')} ${dim('<dml>')} run a DML goal, e.g. ${dim('/goal member(X, [1,2,3])')}`, ` ${accent('<plain English>')} author the harness`, ` ${accent('/spawn')} ${dim('<goal>')} start a session-scoped subagent`, ` ${accent('/ps')} ${dim('[--watch|--logs|--code <pid>]')} list subagents / inspect a snapshot`, ` ${accent('/send')} ${accent('/call')} ${accent('/stop')} message, call, or stop a subagent`, '', ` ${accent('/help')} ${accent('/listing')} ${accent('/viz')} ${accent('/export')} ${accent('/freeze')} ${accent('/model')} ${accent('/halt')}`);
550
629
  if (runtime.historyEnabled) {
551
630
  lines.push(` ${dim('resume later:')} deepclause repl --session ${runtime.sessionId}`);
552
631
  }
553
632
  lines.push('');
554
633
  runtime.renderer.writeLine(lines.join('\n'));
555
634
  }
635
+ /**
636
+ * Replay a resumed session's prior events so the transcript continues instead of
637
+ * starting blank. Bounded, and skipped when the session was not resumed or
638
+ * tracing was off.
639
+ */
640
+ async function renderResumedSession(runtime) {
641
+ const events = await runtime.repl.resumedTranscript().catch(() => []);
642
+ if (events.length === 0)
643
+ return;
644
+ runtime.renderer.writeLine('');
645
+ runtime.renderer.writeLine(`── resumed session ${runtime.sessionId} ──`);
646
+ for (const event of events)
647
+ runtime.renderer.render(event);
648
+ runtime.renderer.writeLine('');
649
+ }
556
650
  /** Dispatch one classified input. Returns false when the REPL should exit. */
557
651
  export async function dispatchReplInput(runtime, input, options = {}) {
558
652
  const { repl, renderer } = runtime;
@@ -561,6 +655,53 @@ export async function dispatchReplInput(runtime, input, options = {}) {
561
655
  case 'empty':
562
656
  case 'comment':
563
657
  return true;
658
+ case 'spawn': {
659
+ try {
660
+ const pid = await repl.session.spawnProcess(input.goal, input.name ? { name: input.name } : {});
661
+ print(`spawned ${formatProcessTable(repl.session.processes().filter((p) => p.pid.id === pid.id))}`);
662
+ }
663
+ catch (error) {
664
+ print(`ERROR: ${error.message}`);
665
+ }
666
+ return true;
667
+ }
668
+ case 'ps': {
669
+ if (input.code !== undefined) {
670
+ for (const line of formatProcessCode(repl.session, input.code).split('\n'))
671
+ print(line);
672
+ return true;
673
+ }
674
+ for (const line of formatProcessTable(repl.session.processes(), repl.session.currentHarnessVersion()).split('\n'))
675
+ print(line);
676
+ return true;
677
+ }
678
+ case 'send': {
679
+ try {
680
+ repl.session.sendProcess(input.target, input.message);
681
+ }
682
+ catch (error) {
683
+ print(`ERROR: ${error.message}`);
684
+ }
685
+ return true;
686
+ }
687
+ case 'call': {
688
+ try {
689
+ const reply = await repl.session.callProcess(input.target, input.message, 60_000);
690
+ print(`${input.target} › ${reply}`);
691
+ }
692
+ catch (error) {
693
+ print(`ERROR: ${error.message}`);
694
+ }
695
+ return true;
696
+ }
697
+ case 'stop': {
698
+ await repl.session.stopProcess(input.target);
699
+ print(`stopped ${input.target}`);
700
+ return true;
701
+ }
702
+ case 'login':
703
+ print("sign in from the terminal: deepclause login (or /login in the TUI)");
704
+ return true;
564
705
  case 'unknown-command':
565
706
  print(`unknown command: /${input.name} — try /help.`);
566
707
  return true;
@@ -739,6 +880,11 @@ function helpText() {
739
880
  ' question. An agent writes, tests, and commits the DML.',
740
881
  '',
741
882
  ' Commands',
883
+ ' /spawn <goal> start a session-scoped subagent (alias /serve)',
884
+ ' /ps [--watch|--logs|--code <pid>] list subagents; --watch pins a panel, --logs follows output, --code shows a process harness snapshot',
885
+ ' /send <name> <msg> send a DML term to a subagent (alias /cast)',
886
+ ' /call <name> <msg> request/reply with a service subagent',
887
+ ' /stop <name> stop a subagent',
742
888
  ' /listing [filter] inspect the harness (all | Name/Arity | tools | scripts)',
743
889
  ' /viz [entry] [--spec] diagram of a skill, drawn in the terminal',
744
890
  ' (--graphics=auto|kitty|iterm2|ascii|off)',
@@ -750,6 +896,7 @@ function helpText() {
750
896
  ' /compact [notes] compact the authoring memory',
751
897
  ' /export [file] save the harness (use .zip to bundle linked files)',
752
898
  ' /model [provider/id] show / set the model',
899
+ ' /login sign in to a provider (from the terminal or the TUI)',
753
900
  ' /freeze /unfreeze read-only lock',
754
901
  ' /params show session parameters',
755
902
  ' /trace /notrace toggle verbose logging',
@@ -782,18 +929,15 @@ async function executeLoad(runtime, files) {
782
929
  }
783
930
  /** Non-interactive mode: read classified inputs line by line until EOF. */
784
931
  /**
785
- * Route Ctrl-C to the active turn. While a turn is running the first Ctrl-C
786
- * aborts it (the session then saves the harness to a temp file); a Ctrl-C while
787
- * idle exits.
932
+ * Ctrl-C exits, in every mode: this is the plain/readline path, where there is no
933
+ * raw key to distinguish Escape from an editing sequence, so the TUI's
934
+ * "Esc interrupts, Ctrl-C exits" cannot be mirrored exactly. A running turn is
935
+ * aborted first, so nothing keeps writing after the UI is gone.
788
936
  */
789
- function installSigint(runtime, holder, rl, onExit) {
937
+ function installSigint(holder, rl, onExit) {
790
938
  const handler = () => {
791
- if (holder.turn) {
792
- holder.turn.abort(new Error('cancelled by user'));
793
- runtime.renderer.writeErrorLine('(cancelling current turn… press Ctrl-C again to exit)');
794
- }
795
- else if (onExit) {
796
- // Save the harness before exiting (Ctrl-C while idle).
939
+ holder.turn?.abort(new Error('cancelled by user'));
940
+ if (onExit) {
797
941
  void onExit().finally(() => process.exit(130));
798
942
  }
799
943
  else {
@@ -811,7 +955,7 @@ async function runNonInteractive(runtime, options, onExit) {
811
955
  let exitCode = 0;
812
956
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
813
957
  const holder = { turn: null };
814
- const removeSigint = installSigint(runtime, holder, rl, onExit);
958
+ const removeSigint = installSigint(holder, rl, onExit);
815
959
  try {
816
960
  for await (const line of rl) {
817
961
  const input = classifyReplInput(line);
@@ -881,7 +1025,7 @@ async function runInteractive(runtime, options, onExit) {
881
1025
  return answer === null ? undefined : answer;
882
1026
  });
883
1027
  const holder = { turn: null };
884
- const removeSigint = installSigint(runtime, holder, rl, onExit);
1028
+ const removeSigint = installSigint(holder, rl, onExit);
885
1029
  try {
886
1030
  while (true) {
887
1031
  const line = await ask(promptPrefix);
@@ -976,11 +1120,36 @@ export async function runInteractiveGoal(repl, renderer, goal, options, ask, sig
976
1120
  * input prompts never fight the main prompt for stdin.
977
1121
  */
978
1122
  export async function runTui(runtime, options, onExit, loaded = []) {
979
- const terminal = new ProcessTerminal();
980
- const tui = new TuiMainScreen(terminal);
981
- const transcript = new TranscriptComponent();
982
- const sink = new LineSink(transcript);
983
- const runState = { running: false, steerable: false };
1123
+ initTuiTheme();
1124
+ // Sized, because pi-tui falls back to a hard 24 rows when the stream reports no
1125
+ // size, which leaves the bottom of a taller terminal untouched.
1126
+ const terminal = new SizedTerminal();
1127
+ // Fullscreen: an application-owned viewport (ScrollView) with the editor and
1128
+ // status bar pinned in a dock. In this mode pi-tui hands scrolling, mouse wheel,
1129
+ // text selection and search to the viewport, and reassigns layout on resize —
1130
+ // which is what makes the transcript scroll inside the app and the input box
1131
+ // behave when the terminal changes size.
1132
+ const tui = new TuiAltScreen(terminal, undefined, undefined, {
1133
+ mouse: true,
1134
+ wheelScrollLines: 'auto',
1135
+ copyOnSelect: true,
1136
+ });
1137
+ const document = new TranscriptDocument();
1138
+ const transcript = document;
1139
+ // The TUI renders components rather than lines: tool calls become panes and
1140
+ // streamed text becomes markdown. There is no external sink to wire.
1141
+ const working = new WorkingLine(tui);
1142
+ const renderer = new TuiRenderer({
1143
+ document,
1144
+ onWorking: (label) => {
1145
+ working.set(label);
1146
+ tui.requestRender();
1147
+ },
1148
+ });
1149
+ runtime.renderer = renderer;
1150
+ const runState = { running: false, steerable: false, bufferable: false };
1151
+ /** Slash commands typed during a turn/command, run when it finishes. */
1152
+ const pendingCommands = [];
984
1153
  /** Lines typed during a goal query, consumed by the solution pager. */
985
1154
  const bufferedInput = [];
986
1155
  const statusBar = new StatusBarComponent({
@@ -991,14 +1160,25 @@ export async function runTui(runtime, options, onExit, loaded = []) {
991
1160
  backend: runtime.backend,
992
1161
  isFrozen: () => runtime.repl.isFrozen(),
993
1162
  isRunning: () => runState.running,
1163
+ processLabel: () => {
1164
+ const active = runtime.repl.session
1165
+ .processes()
1166
+ .filter((process) => ['running', 'runnable', 'waiting', 'input'].includes(process.state));
1167
+ if (active.length === 0)
1168
+ return undefined;
1169
+ const attention = active.some((process) => process.state === 'input');
1170
+ return `⚙ ${active.length} svc${attention ? ' !' : ''}`;
1171
+ },
994
1172
  });
995
1173
  transcript.onChange = () => tui.requestRender();
996
- // Redirect the renderer into the transcript (including error lines).
997
- runtime.renderer.setSink((text) => sink.write(text), (text) => sink.write(text));
998
- runtime.renderer.setImageSink((lines) => transcript.appendImageLines(lines));
999
- runtime.renderer.setWidth(terminal.columns);
1174
+ renderer.setImageSink((lines) => document.appendImageLines(lines));
1175
+ renderer.setWidth(terminal.columns);
1176
+ // pi-tui re-renders on resize but does not invalidate the component tree, and a
1177
+ // component that caches by width (a tool pane, markdown) would draw stale lines.
1000
1178
  const onResize = () => {
1001
- runtime.renderer.setWidth(terminal.columns);
1179
+ terminal.refreshSize();
1180
+ renderer.setWidth(terminal.columns);
1181
+ tui.invalidate();
1002
1182
  tui.requestRender();
1003
1183
  };
1004
1184
  process.stdout.on('resize', onResize);
@@ -1027,7 +1207,9 @@ export async function runTui(runtime, options, onExit, loaded = []) {
1027
1207
  }
1028
1208
  else if (activePrompt === runtime.promptPrefix) {
1029
1209
  // A user turn: separate it from the previous output and from the
1030
- // response that follows; render it as a pi-style blue block.
1210
+ // response that follows; render it as a pi-style blue block. The previous
1211
+ // turn's text block is closed first, so the new response starts below this.
1212
+ runtime.renderer.endTextBlock?.();
1031
1213
  if (transcript.getLines().length > 0)
1032
1214
  transcript.append('');
1033
1215
  transcript.append(text, 'user');
@@ -1037,15 +1219,58 @@ export async function runTui(runtime, options, onExit, loaded = []) {
1037
1219
  }
1038
1220
  else if (text.trim()) {
1039
1221
  // No pending ask means a turn is running.
1040
- if (runState.steerable) {
1222
+ const trimmed = text.trim();
1223
+ if (isSlashCommand(trimmed)) {
1224
+ // Slash commands are never steering. UI toggles and read-only
1225
+ // snapshots act now; everything else is queued for when the current
1226
+ // turn/command finishes, so /send, /call, /stop … reach the processes
1227
+ // instead of being injected into the model's conversation.
1228
+ const immediate = classifyReplInput(trimmed);
1229
+ transcript.append(trimmed, 'user');
1230
+ if (immediate.kind === 'ps') {
1231
+ if (immediate.logs) {
1232
+ const on = !(servicesVisible && servicesLogs);
1233
+ servicesLogs = on;
1234
+ servicesVisible = true;
1235
+ }
1236
+ else if (immediate.watch) {
1237
+ servicesVisible = !servicesVisible;
1238
+ }
1239
+ else if (immediate.code !== undefined) {
1240
+ for (const line of formatProcessCode(runtime.repl.session, immediate.code).split('\n')) {
1241
+ renderer.writeLine(line);
1242
+ }
1243
+ }
1244
+ else {
1245
+ for (const line of formatProcessTable(runtime.repl.session.processes(), runtime.repl.session.currentHarnessVersion()).split('\n')) {
1246
+ renderer.writeLine(line);
1247
+ }
1248
+ }
1249
+ tui.requestRender();
1250
+ }
1251
+ else if (immediate.kind === 'harness') {
1252
+ void handleHarnessCommand(immediate.width);
1253
+ }
1254
+ else {
1255
+ pendingCommands.push(trimmed);
1256
+ transcript.append(' (queued — runs when the current turn finishes)');
1257
+ }
1258
+ }
1259
+ else if (runState.steerable) {
1260
+ runtime.renderer.endTextBlock?.();
1041
1261
  transcript.append(text, 'steer');
1042
1262
  runtime.repl.steer(text);
1043
1263
  }
1044
- else {
1264
+ else if (runState.bufferable) {
1045
1265
  // A goal query is running: queue the line for the solution pager.
1046
1266
  bufferedInput.push(text);
1047
1267
  transcript.append(text, 'steer');
1048
1268
  }
1269
+ else {
1270
+ // A command (e.g. /call) is running and owns the prompt; typing does
1271
+ // not steer it. Say so rather than mislabeling the line as steering.
1272
+ transcript.append(' (a command is running — wait, or press Ctrl-C)');
1273
+ }
1049
1274
  }
1050
1275
  if (editor) {
1051
1276
  editor.setPrompt(null);
@@ -1059,6 +1284,7 @@ export async function runTui(runtime, options, onExit, loaded = []) {
1059
1284
  };
1060
1285
  const createEditor = () => {
1061
1286
  editor = new PromptEditor(tui, editorTheme());
1287
+ editor.setAutocompleteProvider(createReplAutocomplete(runtime.workspace));
1062
1288
  editor.onSubmit = handleSubmit;
1063
1289
  editorContainer.clear();
1064
1290
  editorContainer.addChild(editor);
@@ -1097,55 +1323,434 @@ export async function runTui(runtime, options, onExit, loaded = []) {
1097
1323
  const answer = await ask(`${prompt}${suffix}`, true);
1098
1324
  return answer === null ? undefined : answer;
1099
1325
  });
1100
- const cancelTurn = () => {
1101
- if (holder.turn) {
1102
- holder.turn.abort(new Error('cancelled by user'));
1103
- transcript.append('(cancelling current turn… press Ctrl-C again to exit)');
1104
- tui.requestRender();
1105
- return;
1106
- }
1326
+ /** Interrupt the running turn, if any. Returns whether there was one. */
1327
+ const interruptTurn = () => {
1328
+ if (!holder.turn)
1329
+ return false;
1330
+ holder.turn.abort(new Error('cancelled by user'));
1331
+ // Close the streamed block and clear the dock spinner so an interrupted turn
1332
+ // leaves no live line behind.
1333
+ runtime.renderer.endTextBlock?.();
1334
+ working.set(null);
1335
+ transcript.append('(interrupted — Ctrl-C exits)');
1336
+ tui.requestRender();
1337
+ return true;
1338
+ };
1339
+ /** Leave the REPL, aborting anything still running so nothing outlives the UI. */
1340
+ const exitRepl = () => {
1341
+ holder.turn?.abort(new Error('cancelled by user'));
1107
1342
  void (onExit ? onExit() : Promise.resolve()).finally(() => {
1108
1343
  tui.stop();
1109
1344
  process.exit(130);
1110
1345
  });
1111
1346
  };
1112
- // Ctrl-C aborts the current turn; Ctrl-D (on an empty editor) exits. Use pi's
1113
- // key matcher so kitty/meta key encodings are recognized, not just raw bytes.
1347
+ // Esc interrupts, Ctrl-C exits, Ctrl-D (on an empty editor) exits. Escape is
1348
+ // left alone while a completion list or an overlay owns it, so it keeps its
1349
+ // usual meaning there. Use pi's key matcher so kitty/meta encodings are
1350
+ // recognized, not just raw bytes.
1114
1351
  tui.addInputListener((data) => {
1115
1352
  if (matchesKey(data, 'ctrl+c')) {
1116
- cancelTurn();
1353
+ exitRepl();
1117
1354
  return { consume: true };
1118
1355
  }
1119
1356
  if (matchesKey(data, 'ctrl+d') && !holder.turn && (editor?.getText().length ?? 0) === 0) {
1120
- cancelTurn();
1357
+ exitRepl();
1121
1358
  return { consume: true };
1122
1359
  }
1360
+ if (matchesKey(data, 'escape')
1361
+ && !tui.hasOverlay()
1362
+ && !(editor?.isShowingAutocomplete() ?? false)) {
1363
+ if (interruptTurn())
1364
+ return { consume: true };
1365
+ return undefined;
1366
+ }
1367
+ // ctrl+o expands or collapses the most recent tool call, the way pi reveals
1368
+ // full tool output on demand.
1369
+ if (matchesKey(data, 'ctrl+o')) {
1370
+ if (renderer.toggleLastPane())
1371
+ return { consume: true };
1372
+ return undefined;
1373
+ }
1123
1374
  return undefined;
1124
1375
  });
1125
- // Fallback for terminals/raw-mode setups that deliver SIGINT instead of \x03.
1126
- process.on('SIGINT', cancelTurn);
1376
+ // Fallback for terminals/raw-mode setups that deliver SIGINT instead of \x03:
1377
+ // the terminal's own Ctrl-C, which now exits.
1378
+ process.on('SIGINT', exitRepl);
1127
1379
  createEditor();
1128
1380
  for (const line of runtime.inputHistory)
1129
1381
  editor?.addToHistory(line);
1130
- tui.addChild(transcript);
1131
- tui.addChild(new Spacer(1));
1132
- tui.addChild(editorContainer);
1133
- tui.addChild(statusBar);
1382
+ /**
1383
+ * Show a chooser over the transcript and resolve when it closes.
1384
+ *
1385
+ * The overlay owns input while it is up (pi-tui routes keys to the focused
1386
+ * overlay), so the editor does not see the keys the list is using.
1387
+ */
1388
+ const choose = (build) => {
1389
+ return new Promise((resolve) => {
1390
+ let handle;
1391
+ const done = (picked) => {
1392
+ handle?.hide();
1393
+ if (editor)
1394
+ tui.setFocus(editor);
1395
+ tui.requestRender();
1396
+ resolve(picked);
1397
+ };
1398
+ const picker = build(done);
1399
+ handle = tui.showOverlay(picker, {
1400
+ anchor: 'center',
1401
+ width: '70%',
1402
+ maxHeight: picker.contentHeight + 2,
1403
+ });
1404
+ });
1405
+ };
1406
+ const pickReasoningMode = async () => {
1407
+ const modes = ['off', 'summary', 'full'];
1408
+ const picked = await choose((done) => new ChoicePicker({
1409
+ title: 'Reasoning',
1410
+ hint: 'How much of the model\'s thinking to show in the transcript',
1411
+ current: runtime.reasoningMode,
1412
+ items: [
1413
+ { value: 'off', label: 'off', description: 'hide reasoning entirely' },
1414
+ { value: 'summary', label: 'summary', description: 'a bounded preview' },
1415
+ { value: 'full', label: 'full', description: 'everything the model emits' },
1416
+ ].filter((item) => modes.includes(item.value)),
1417
+ onSelect: (item) => done(item.value),
1418
+ onCancel: () => done(),
1419
+ }));
1420
+ if (!picked)
1421
+ return;
1422
+ const mode = picked;
1423
+ runtime.reasoningMode = mode;
1424
+ runtime.renderer.setReasoningMode?.(mode);
1425
+ await persistSetting({ repl: { showReasoning: mode } }, 'reasoning display');
1426
+ renderer.writeLine(`reasoning display: ${mode}`);
1427
+ };
1428
+ /**
1429
+ * The model's reasoning level (its thinking effort), as opposed to how much of
1430
+ * that reasoning is displayed — `/show-reasoning` is the other one.
1431
+ */
1432
+ /**
1433
+ * Persist a setting, but never let that failure stop it applying.
1434
+ *
1435
+ * `updateConfig` requires an existing config file (`deepclause init`), and a
1436
+ * picker that threw before applying the change left the selection doing nothing at
1437
+ * all — which read as "I cannot adjust the reasoning".
1438
+ */
1439
+ const persistSetting = async (patch, label) => {
1440
+ try {
1441
+ await updateConfig(runtime.workspace, patch);
1442
+ }
1443
+ catch (error) {
1444
+ const message = error instanceof Error ? error.message : String(error);
1445
+ renderer.writeLine(`${label} applied to this session, but not saved: ${message.split('\n')[0]}`);
1446
+ }
1447
+ };
1448
+ const pickReasoningLevel = async (level) => {
1449
+ const backend = runtime.llmBackend;
1450
+ const supported = (backend?.supportedThinkingLevels?.() ?? THINKING_LEVELS.slice());
1451
+ const apply = async (chosen) => {
1452
+ backend?.setThinkingLevel?.(chosen);
1453
+ runtime.thinkingLevel = chosen;
1454
+ runtime.setThinkingLevel?.(chosen);
1455
+ await persistSetting({ backend: { kind: 'pi', thinkingLevel: chosen } }, 'reasoning level');
1456
+ renderer.writeLine(`reasoning level: ${chosen}`);
1457
+ };
1458
+ if (level) {
1459
+ if (!supported.includes(level)) {
1460
+ renderer.writeLine(`unknown reasoning level '${level}'; supported: ${supported.join(', ')}`);
1461
+ return;
1462
+ }
1463
+ await apply(level);
1464
+ return;
1465
+ }
1466
+ const chosen = await choose((done) => new ChoicePicker({
1467
+ title: 'Reasoning level',
1468
+ hint: 'How hard the model is asked to think (not how much is shown)',
1469
+ current: runtime.thinkingLevel,
1470
+ items: supported.map((value) => ({
1471
+ value,
1472
+ label: value,
1473
+ description: value === 'off' ? 'no reasoning requested' : undefined,
1474
+ })),
1475
+ onSelect: (item) => done(item.value),
1476
+ onCancel: () => done(),
1477
+ }));
1478
+ if (chosen)
1479
+ await apply(chosen);
1480
+ };
1481
+ /**
1482
+ * `/harness` toggles the side view; `/harness 40`, `wider` and `narrower` resize it.
1483
+ * The width is a layout basis, so a resize rebuilds the root.
1484
+ */
1485
+ const handleHarnessCommand = async (width) => {
1486
+ if (!width) {
1487
+ harnessVisible = !harnessVisible;
1488
+ applyLayout();
1489
+ if (harnessVisible)
1490
+ harnessView.refresh();
1491
+ tui.invalidate();
1492
+ tui.requestRender();
1493
+ renderer.writeLine(`harness view ${harnessVisible ? 'on' : 'off'}${harnessVisible ? ` (${harnessWidth} columns)` : ''}`);
1494
+ return;
1495
+ }
1496
+ const requested = width === 'wider' ? harnessWidth + 8
1497
+ : width === 'narrower' ? harnessWidth - 8
1498
+ : Number(width);
1499
+ if (!Number.isFinite(requested) || requested < 20 || requested > 120) {
1500
+ renderer.writeLine(`harness view width must be 20-120 columns (got '${width}')`);
1501
+ return;
1502
+ }
1503
+ harnessWidth = Math.round(requested);
1504
+ if (!harnessVisible)
1505
+ harnessVisible = true;
1506
+ applyLayout();
1507
+ tui.invalidate();
1508
+ tui.requestRender();
1509
+ renderer.writeLine(`harness view ${harnessWidth} columns`);
1510
+ };
1511
+ const pickModel = async () => {
1512
+ const catalog = runtime.modelResolver?.catalog() ?? [];
1513
+ if (catalog.length === 0) {
1514
+ renderer.writeLine(`model: ${runtime.model} (no catalog to choose from; set one in config)`);
1515
+ return;
1516
+ }
1517
+ const current = runtime.model.includes(':') ? runtime.model : undefined;
1518
+ const picked = await choose((done) => new ChoicePicker({
1519
+ title: 'Model',
1520
+ hint: 'The default for this workspace (applies to the next session)',
1521
+ current,
1522
+ items: catalog.map((entry) => ({
1523
+ value: entry.model,
1524
+ label: entry.model,
1525
+ description: entry.inputCost !== undefined
1526
+ ? `$${entry.inputCost}/$${entry.outputCost ?? '?'} per 1M in/out`
1527
+ : (entry.contextWindow ? `${Math.round(entry.contextWindow / 1000)}k context` : undefined),
1528
+ })),
1529
+ onSelect: (item) => done(item.value),
1530
+ onCancel: () => done(),
1531
+ }));
1532
+ if (!picked)
1533
+ return;
1534
+ await persistSetting({ backend: { kind: 'pi', model: picked } }, 'model');
1535
+ renderer.writeLine(`default model set to ${picked} (takes effect on the next start)`);
1536
+ };
1537
+ /** `/login`: sign in to a provider from inside the TUI. */
1538
+ const runLogin = async () => {
1539
+ let handle;
1540
+ try {
1541
+ handle = await openPiRuntime({});
1542
+ }
1543
+ catch (error) {
1544
+ renderer.writeLine(`login unavailable: ${error instanceof Error ? error.message : String(error)}`);
1545
+ return;
1546
+ }
1547
+ const providers = listLoginProviders(handle);
1548
+ if (providers.length === 0) {
1549
+ renderer.writeLine('no login providers available');
1550
+ return;
1551
+ }
1552
+ const picked = await choose((done) => new ChoicePicker({
1553
+ title: 'Sign in to a provider',
1554
+ hint: 'Esc to cancel',
1555
+ items: providers.map((provider) => ({
1556
+ value: provider.id,
1557
+ label: `${provider.name} (${provider.id})`,
1558
+ description: provider.configured ? '✓ configured' : provider.authType,
1559
+ })),
1560
+ onSelect: (item) => done(item.value),
1561
+ onCancel: () => done(),
1562
+ }));
1563
+ if (!picked)
1564
+ return;
1565
+ const interaction = {
1566
+ prompt: async (prompt) => {
1567
+ if (prompt.options && prompt.options.length > 0) {
1568
+ const method = await choose((done) => new ChoicePicker({
1569
+ title: prompt.message,
1570
+ items: prompt.options.map((option) => ({
1571
+ value: option.id,
1572
+ label: option.label,
1573
+ description: option.description,
1574
+ })),
1575
+ onSelect: (item) => done(item.value),
1576
+ onCancel: () => done(),
1577
+ }));
1578
+ if (!method)
1579
+ throw new Error('cancelled');
1580
+ return method;
1581
+ }
1582
+ const answer = await ask(`${prompt.message}${prompt.placeholder ? ` (${prompt.placeholder})` : ''} `);
1583
+ if (answer === null)
1584
+ throw new Error('cancelled');
1585
+ return answer;
1586
+ },
1587
+ notify: (event) => {
1588
+ if (event.type === 'auth_url' && event.url)
1589
+ renderer.writeLine(`open this URL to authorize: ${event.url}`);
1590
+ else if (event.type === 'device_code')
1591
+ renderer.writeLine(`visit ${event.verificationUri} and enter code ${event.userCode}`);
1592
+ else if (event.message)
1593
+ renderer.writeLine(event.message);
1594
+ },
1595
+ };
1596
+ try {
1597
+ await loginToProvider(handle, picked, { interaction });
1598
+ await persistSetting({ backend: { kind: 'pi' } }, 'login');
1599
+ renderer.writeLine(`signed in to ${picked} (run /model to pick one of its models)`);
1600
+ }
1601
+ catch (error) {
1602
+ renderer.writeLine(`login failed: ${error instanceof Error ? error.message : String(error)}`);
1603
+ }
1604
+ };
1605
+ // The experiment: a live side view of the harness, beside the transcript. It reads
1606
+ // the session module (so uncommitted edits show) and refreshes after anything that
1607
+ // can change it. Hidden on a narrow terminal, where there is no room for a column.
1608
+ const harnessView = new HarnessView(() => runtime.repl.session.harnessView(), {
1609
+ // The view asks; the host fetches (the listing query is async) and hands it back.
1610
+ requestSource: (indicator) => {
1611
+ void runtime.repl.session.listing(indicator).then((text) => {
1612
+ harnessView.setSource(indicator, text.split('\n'));
1613
+ tui.requestRender();
1614
+ });
1615
+ },
1616
+ // Escape with nothing drilled into: hand the keyboard back to the editor.
1617
+ onExit: () => {
1618
+ if (editor)
1619
+ tui.setFocus(editor);
1620
+ tui.requestRender();
1621
+ },
1622
+ });
1623
+ harnessView.refresh();
1624
+ let harnessVisible = options.harnessView ?? false;
1625
+ let servicesVisible = false;
1626
+ let servicesLogs = false;
1627
+ const servicesPanel = new ServicesPanel({
1628
+ getProcesses: () => runtime.repl.session.processes(),
1629
+ getLog: () => runtime.repl.session.processLog(200),
1630
+ isVisible: () => servicesVisible,
1631
+ mode: () => (servicesLogs ? 'logs' : 'summary'),
1632
+ currentHarnessVersion: () => runtime.repl.session.currentHarnessVersion(),
1633
+ });
1634
+ const refreshHarness = () => {
1635
+ if (!harnessVisible)
1636
+ return;
1637
+ harnessView.refresh();
1638
+ tui.requestRender();
1639
+ };
1640
+ // Poll while the view is open. It is meant to be watched, and a change has to be
1641
+ // visible when it happens, not at the next turn boundary.
1642
+ const harnessTimer = setInterval(refreshHarness, 600);
1643
+ const servicesTimer = setInterval(() => {
1644
+ if (!servicesVisible)
1645
+ return;
1646
+ const active = runtime.repl.session
1647
+ .processes()
1648
+ .some((process) => ['running', 'runnable', 'waiting', 'input'].includes(process.state));
1649
+ if (active || servicesLogs)
1650
+ tui.requestRender();
1651
+ }, 600);
1652
+ // Transcript grows and scrolls; everything below it stays put.
1653
+ const scroll = new ScrollView(document, {
1654
+ follow: 'end',
1655
+ primary: true,
1656
+ scrollbar: 'auto',
1657
+ scrollbarTrackStyle: scrollbarStyles.track,
1658
+ scrollbarThumbStyle: scrollbarStyles.thumb,
1659
+ });
1660
+ // Margins: a blank row above the transcript and one between it and the editor, so
1661
+ // the first and last line of output are not jammed against the chrome. Fixed
1662
+ // spacers rather than padding inside the scroll view: they do not scroll away and
1663
+ // do not consume scrollback.
1664
+ const dock = new VStack([
1665
+ { component: working, basis: 'auto', grow: 0, shrink: 1, minSize: 0 },
1666
+ { component: servicesPanel, basis: 'auto', grow: 0, shrink: 1, minSize: 0 },
1667
+ { component: new Spacer(1), basis: 'auto', grow: 0, shrink: 1, minSize: 1 },
1668
+ { component: editorContainer, basis: 'auto', grow: 0, shrink: 1, minSize: 1 },
1669
+ { component: statusBar, basis: 'auto', grow: 0, shrink: 1, minSize: 1 },
1670
+ ]);
1671
+ // Rebuilt rather than mutated: a stack entry's basis is read from the entries given
1672
+ // at construction, so resizing means handing the TUI a new root.
1673
+ let harnessWidth = 34;
1674
+ const applyLayout = () => {
1675
+ const columns = new HStack([
1676
+ { component: scroll, basis: 0, grow: 1, shrink: 1, minSize: 20 },
1677
+ {
1678
+ component: harnessView,
1679
+ basis: harnessWidth,
1680
+ grow: 0,
1681
+ shrink: 0,
1682
+ minSize: 20,
1683
+ visible: (viewport) => harnessVisible && viewport.width >= 96,
1684
+ },
1685
+ ], { gap: 1 });
1686
+ tui.setLayoutRoot(new VStack([
1687
+ { component: new Spacer(1), basis: 'auto', grow: 0, shrink: 1, minSize: 1 },
1688
+ { component: columns, basis: 0, grow: 1, shrink: 1, minSize: 1 },
1689
+ { component: dock, basis: 'auto', grow: 0, shrink: 1, minSize: 1 },
1690
+ ]));
1691
+ };
1692
+ applyLayout();
1134
1693
  tui.start();
1135
1694
  await printBanner(runtime, loaded);
1695
+ await renderResumedSession(runtime);
1136
1696
  tui.requestRender();
1137
1697
  try {
1138
1698
  while (true) {
1139
- const line = await ask(runtime.promptPrefix);
1140
- if (line === null)
1141
- break;
1699
+ // A slash command typed while a turn/command was running: run it now that
1700
+ // the REPL is idle, before asking for fresh input.
1701
+ let line;
1702
+ if (pendingCommands.length > 0) {
1703
+ line = pendingCommands.shift();
1704
+ tui.requestRender();
1705
+ }
1706
+ else {
1707
+ line = await ask(runtime.promptPrefix);
1708
+ if (line === null)
1709
+ break;
1710
+ }
1142
1711
  const input = classifyReplInput(line);
1143
1712
  if (input.kind === 'halt')
1144
1713
  break;
1714
+ // The pickers are overlays over the transcript, so they need the TUI and are
1715
+ // handled here rather than in the command dispatcher the readline path shares.
1716
+ if (input.kind === 'model' && !input.model) {
1717
+ await pickModel();
1718
+ continue;
1719
+ }
1720
+ if (input.kind === 'reasoning') {
1721
+ await pickReasoningLevel(input.level);
1722
+ continue;
1723
+ }
1724
+ if (input.kind === 'show-reasoning') {
1725
+ await pickReasoningMode();
1726
+ continue;
1727
+ }
1728
+ if (input.kind === 'harness') {
1729
+ await handleHarnessCommand(input.width);
1730
+ continue;
1731
+ }
1732
+ if (input.kind === 'login') {
1733
+ await runLogin();
1734
+ continue;
1735
+ }
1736
+ if (input.kind === 'ps' && (input.watch || input.logs)) {
1737
+ if (input.logs) {
1738
+ // Toggle the follow-the-log view; it is always visible when on.
1739
+ const turningOn = !(servicesVisible && servicesLogs);
1740
+ servicesLogs = turningOn;
1741
+ servicesVisible = true;
1742
+ }
1743
+ else {
1744
+ servicesVisible = !servicesVisible;
1745
+ }
1746
+ tui.requestRender();
1747
+ continue;
1748
+ }
1145
1749
  const controller = new AbortController();
1146
1750
  holder.turn = controller;
1147
1751
  runState.running = true;
1148
1752
  runState.steerable = input.kind === 'prompt';
1753
+ runState.bufferable = input.kind === 'goal';
1149
1754
  bufferedInput.length = 0;
1150
1755
  tui.requestRender();
1151
1756
  try {
@@ -1171,8 +1776,11 @@ export async function runTui(runtime, options, onExit, loaded = []) {
1171
1776
  }
1172
1777
  finally {
1173
1778
  tui.stop();
1174
- process.off('SIGINT', cancelTurn);
1779
+ process.off('SIGINT', exitRepl);
1175
1780
  process.stdout.off('resize', onResize);
1781
+ clearInterval(harnessTimer);
1782
+ clearInterval(servicesTimer);
1783
+ working.stop();
1176
1784
  // Restore stdout so the exit footer and any later output are visible.
1177
1785
  runtime.renderer.setSink((text) => process.stdout.write(text), (text) => process.stderr.write(text));
1178
1786
  }
@@ -1194,6 +1802,9 @@ export async function startRepl(options) {
1194
1802
  channel: message.channel,
1195
1803
  });
1196
1804
  }
1805
+ if (options.execution === 'worker') {
1806
+ runtime.renderer.writeLine('note: subagents run in a worker thread (--execution worker)');
1807
+ }
1197
1808
  }
1198
1809
  catch (error) {
1199
1810
  process.stderr.write(`ERROR: ${error.message}\n`);
@@ -1239,7 +1850,7 @@ export async function startRepl(options) {
1239
1850
  if (goals.length > 0) {
1240
1851
  let exitCode = 0;
1241
1852
  const controller = new AbortController();
1242
- const removeSigint = installSigint(runtime, { turn: controller });
1853
+ const removeSigint = installSigint({ turn: controller });
1243
1854
  try {
1244
1855
  for (const goal of goals) {
1245
1856
  if (controller.signal.aborted)
@@ -1271,6 +1882,7 @@ export async function startRepl(options) {
1271
1882
  return await runTui(runtime, options, saveHarnessOnExit, options.load ?? []);
1272
1883
  }
1273
1884
  await printBanner(runtime, options.load ?? []);
1885
+ await renderResumedSession(runtime);
1274
1886
  return await runInteractive(runtime, options, saveHarnessOnExit);
1275
1887
  }
1276
1888
  finally {
@@ -1330,6 +1942,14 @@ export function registerReplCommand(program) {
1330
1942
  .option('--no-tools', 'register no capability host tools')
1331
1943
  .option('--sandbox', 'run shell tools in AgentVM')
1332
1944
  .option('--require-sandbox', 'refuse to run unless the shell is sandboxed (bwrap/AgentVM/sandbox-exec)')
1945
+ .option('--harness-view', 'open the live harness side view (experiment)')
1946
+ .option('--execution <mode>', 'process execution mode: worker (L2, default) or inline (L1)', (value) => {
1947
+ if (value !== 'inline' && value !== 'worker') {
1948
+ throw new Error(`invalid --execution: ${value} (use inline or worker)`);
1949
+ }
1950
+ return value;
1951
+ })
1952
+ .option('--workers <n>', 'worker pool size for --execution worker', parseInt)
1333
1953
  .option('--no-ask', 'make ask_user / input/2 fail instead of prompting')
1334
1954
  .option('--image-model <provider:id>', 'image model for generation (default: first available)')
1335
1955
  .option('--no-open', 'do not hand images to the OS viewer on terminals without inline images')
@@ -1375,6 +1995,9 @@ export function registerReplCommand(program) {
1375
1995
  noTools: options.tools === false,
1376
1996
  sandbox: options.sandbox,
1377
1997
  requireSandbox: options.requireSandbox,
1998
+ harnessView: options.harnessView,
1999
+ execution: options.execution,
2000
+ workers: options.workers,
1378
2001
  noAsk: options.ask === false,
1379
2002
  imageModel: options.imageModel,
1380
2003
  open: options.open,