lazyclaw 6.3.1 → 6.5.0

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 (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
package/tui/repl.mjs CHANGED
@@ -38,173 +38,23 @@ import { Splash, renderSplashToString } from './splash.mjs';
38
38
  import { Editor } from './editor.mjs';
39
39
  import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
40
40
  import { SLASH_COMMANDS } from './slash_commands.mjs';
41
+ import { argSpecFor } from './slash_args.mjs';
41
42
  import { ModalPicker, filterModalItems, resolveModalPick } from './modal_picker.mjs';
42
43
  import { theme } from './theme.mjs';
43
-
44
- // ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
45
- //
46
- // Wraps the React tree with mount/unmount side-effects that enable the
47
- // terminal alternate screen buffer. Three-layer cleanup so the user never
48
- // gets stranded on the alt canvas:
49
- // 1. React unmount useEffect return-fn writes \x1b[?1049l
50
- // 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') same escape via
51
- // process-level listeners that we install + remove on unmount.
52
- // 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
53
- // below us turned it off.
54
- //
55
- // We deliberately do NOT install an uncaughtException handler — Ink
56
- // already installs one and re-throws; ours would swallow the stack
57
- // trace (violates §1 Truthfulness / no silent catch).
58
- //
59
- // `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
60
- // the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
61
- // no escape sequences leak into stdout.
62
- export const ALT_BUFFER_ENTER = '\x1b[?1049h';
63
- export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
64
- export const CURSOR_VISIBLE = '\x1b[?25h';
65
-
66
- // Rendering-mode decision. Default = Static scrollback (no flicker; splash
67
- // prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
68
- // LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
69
- export function computeAltEnabled(env, hasTTY) {
70
- const e = env || {};
71
- return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
72
- }
73
-
74
- export function FullScreen({ enabled, children }) {
75
- useEffect(() => {
76
- if (!enabled) return undefined;
77
- // Mount: enter alternate screen buffer.
78
- try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
79
-
80
- // Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
81
- // terminal is restored even if React never gets a chance to unmount
82
- // (e.g. parent process kills us with SIGTERM).
83
- const restore = () => {
84
- try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
85
- };
86
- const onExit = () => { restore(); };
87
- const onSignal = () => { restore(); };
88
- process.once('exit', onExit);
89
- process.once('SIGINT', onSignal);
90
- process.once('SIGTERM', onSignal);
91
- process.once('SIGHUP', onSignal);
92
-
93
- return () => {
94
- // React unmount: restore primary buffer.
95
- restore();
96
- process.removeListener('exit', onExit);
97
- process.removeListener('SIGINT', onSignal);
98
- process.removeListener('SIGTERM', onSignal);
99
- process.removeListener('SIGHUP', onSignal);
100
- };
101
- }, [enabled]);
102
- return children;
103
- }
104
-
105
- // ─── Pure state ──────────────────────────────────────────────────────────
106
- //
107
- // makeReplState stays callable with zero args (existing tests rely on it).
108
- // The new fields default to empty so legacy callers see no behavior change.
109
- export function makeReplState(opts) {
110
- const splashItem = opts && opts.splashItem ? opts.splashItem : null;
111
- return {
112
- streaming: false,
113
- controller: null,
114
- pendingPrepend: null,
115
- nextTurnFirstMessage: null,
116
- history: [],
117
- scrollback: splashItem ? [splashItem] : [],
118
- liveAssistant: '',
119
- turnCounter: 0,
120
- };
121
- }
122
-
123
- export function onUserInput(state, { text, controller }) {
124
- if (state.streaming && state.controller) {
125
- // mid-stream interrupt — abort current turn, queue text for next turn.
126
- try { state.controller.abort(); } catch {}
127
- return { ...state, pendingPrepend: text };
128
- }
129
- // idle — start a new turn. Append a 'user' entry to scrollback so the
130
- // sticky-layout caller sees the prompt history above the live stream.
131
- const id = `u-${state.turnCounter}`;
132
- return {
133
- ...state,
134
- streaming: true,
135
- controller,
136
- history: [...state.history, text],
137
- scrollback: [...state.scrollback, { kind: 'user', id, text }],
138
- turnCounter: state.turnCounter + 1,
139
- };
140
- }
141
-
142
- export function onEscape(state) {
143
- if (state.streaming && state.controller) {
144
- try { state.controller.abort(); } catch {}
145
- }
146
- // Drop any partial live assistant text on explicit Esc — the user is
147
- // telling us to discard, not to keep.
148
- return {
149
- ...state,
150
- streaming: false,
151
- controller: null,
152
- pendingPrepend: null,
153
- liveAssistant: '',
154
- };
155
- }
156
-
157
- // Stream chunk arrives. Completed lines are committed to the <Static>
158
- // scrollback immediately (so they scroll up ABOVE the sticky editor), and only
159
- // the in-progress trailing partial stays in the live region. Without this, a
160
- // reply taller than the terminal grew the live frame past the viewport and
161
- // spilled BELOW the input box (long orchestrator replies). Chunks without a
162
- // newline still just accumulate (the prior behaviour), so short replies and the
163
- // existing reducer tests are unchanged.
164
- export function onStreamChunk(state, { chunk }) {
165
- const buf = state.liveAssistant + chunk;
166
- const nl = buf.lastIndexOf('\n');
167
- if (nl < 0) return { ...state, liveAssistant: buf };
168
- const complete = buf.slice(0, nl); // one or more whole lines
169
- const remainder = buf.slice(nl + 1); // trailing partial (may be '')
170
- const id = `as-${state.turnCounter}-${state.scrollback.length}`;
171
- return {
172
- ...state,
173
- scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
174
- liveAssistant: remainder,
175
- };
176
- }
177
-
178
- export function onTurnComplete(state, { reason, error } = {}) {
179
- const promoted = state.pendingPrepend;
180
- const suffix = reason === 'aborted' ? ' [aborted]'
181
- : reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
182
- : '';
183
- const text = (state.liveAssistant || '') + suffix;
184
- // Commit any accumulated live text to scrollback. If the turn produced
185
- // nothing AND wasn't an error/abort, skip the empty append.
186
- const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
187
- const id = `a-${state.turnCounter}`;
188
- const kind = reason === 'error' ? 'error' : 'assistant';
189
- const nextScrollback = shouldCommit
190
- ? [...state.scrollback, { kind, id, text }]
191
- : state.scrollback;
192
- return {
193
- ...state,
194
- streaming: false,
195
- controller: null,
196
- pendingPrepend: null,
197
- nextTurnFirstMessage: promoted,
198
- liveAssistant: '',
199
- scrollback: nextScrollback,
200
- turnCounter: state.turnCounter + 1,
201
- };
202
- }
203
-
204
- export function consumeNextTurnFirstMessage(state) {
205
- const msg = state.nextTurnFirstMessage;
206
- return [{ ...state, nextTurnFirstMessage: null }, msg];
207
- }
44
+ import { StatusBar } from './status_bar.mjs';
45
+ import { onConversationReset, clearTerminalScreen } from './repl_reset.mjs'; export { StatusBar };
46
+ // Alt-buffer (DEC 1049) mount cluster moved to ./repl_altbuffer.mjs and pure
47
+ // state reducers moved to ./repl_reducers.mjs (file-size gate). Re-exported so
48
+ // every existing caller + test sees them on repl.mjs, and imported locally
49
+ // because the ReplApp body binds them directly.
50
+ import { computeAltEnabled, FullScreen } from './repl_altbuffer.mjs';
51
+ export { ALT_BUFFER_ENTER, ALT_BUFFER_LEAVE, CURSOR_VISIBLE, computeAltEnabled, FullScreen } from './repl_altbuffer.mjs';
52
+ import {
53
+ makeReplState, onUserInput, onEscape, onStreamChunk, onTurnComplete,
54
+ } from './repl_reducers.mjs';
55
+ export {
56
+ makeReplState, onUserInput, onEscape, onStreamChunk, onTurnComplete, consumeNextTurnFirstMessage,
57
+ } from './repl_reducers.mjs';
208
58
 
209
59
  // ─── React mount ─────────────────────────────────────────────────────────
210
60
  //
@@ -212,7 +62,7 @@ export function consumeNextTurnFirstMessage(state) {
212
62
  // - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
213
63
  // - runTurn(text, signal) (legacy, stdout)
214
64
  // Legacy mode is preserved verbatim for the existing cli.mjs callsite.
215
- export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo, getStatus, pickerRef }) {
65
+ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, onArgComplete, onArgList, statusInfo, getStatus, pickerRef }) {
216
66
  // statusInfo seeds the StatusBar's provider/model/ctx. getStatus (optional)
217
67
  // returns the live values so the bar refreshes after a /provider or /model
218
68
  // switch and after each turn (token/ctx gauge) — without it the bar would
@@ -255,7 +105,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
255
105
  // sticky-bottom Editor actually pins. Non-alt mode keeps the legacy
256
106
  // content-sized layout so existing tests + non-TTY fallbacks behave
257
107
  // identically. Listen for SIGWINCH-driven resize events.
258
- const { stdout } = useStdout();
108
+ const { stdout, write: writeStdout } = useStdout();
259
109
  const [rows, setRows] = useState(() => (stdout && stdout.rows) || 24);
260
110
  useEffect(() => {
261
111
  if (!stdout) return undefined;
@@ -305,6 +155,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
305
155
  // when the user hits Esc (onEscape aborts state.controller).
306
156
  const result = await onSlashCommand(trimmed, controller.signal);
307
157
  if (result === 'EXIT') { exit(); return; }
158
+ if (result === 'NEW') { clearTerminalScreen(writeStdout); setState((s) => onConversationReset(s)); refreshStatus(); return; } // /new: wipe screen + scrollback so it visually starts over
308
159
  if (typeof result === 'string' && result.length > 0) {
309
160
  setState((s) => onStreamChunk(s, { chunk: result }));
310
161
  }
@@ -330,7 +181,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
330
181
  reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
331
182
  }));
332
183
  }
333
- }, [exit, onSlashCommand, refreshStatus]);
184
+ }, [exit, onSlashCommand, refreshStatus, writeStdout]);
334
185
 
335
186
  // Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
336
187
  // state.nextTurnFirstMessage so the effect re-fires when promoted.
@@ -347,6 +198,11 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
347
198
  setState((s) => onEscape(s));
348
199
  }, []);
349
200
 
201
+ // v6.4 — 2-stage Ctrl+C exit handler. First Ctrl+C reuses onEscapeKey
202
+ // (cancel in-flight turn + clear, same reducer as Esc); a second press
203
+ // within the Editor's window calls onExit. Active when exitOnCtrlC:false.
204
+ const onExitKey = useCallback(() => { exit(); }, [exit]);
205
+
350
206
  // ─── Slash popup state (v5.4) ──────────────────────────────────────
351
207
  // The editor reports its current buffer via onBufferChange; we derive
352
208
  // the filtered command list from that and own the selection index.
@@ -357,44 +213,66 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
357
213
  );
358
214
  const [bufferPeek, setBufferPeek] = useState('');
359
215
  const [selectedSuggestion, setSelectedSuggestion] = useState(0);
216
+ const activeLenRef = useRef(0); // length of whatever popup list is active (commands or arg candidates)
360
217
  const filtered = useMemo(
361
218
  () => filterSlashCommands(bufferPeek, catalog),
362
219
  [bufferPeek, catalog]
363
220
  );
364
- // Reset selection whenever the match list changes length (typing
365
- // narrows results, so highlight the first row again).
366
- const lastLenRef = useRef(0);
367
- useEffect(() => {
368
- if (filtered.length !== lastLenRef.current) {
369
- setSelectedSuggestion(0);
370
- lastLenRef.current = filtered.length;
371
- } else if (selectedSuggestion >= filtered.length) {
372
- setSelectedSuggestion(Math.max(0, filtered.length - 1));
373
- }
374
- }, [filtered.length, selectedSuggestion]);
375
221
 
376
222
  const handleBufferChange = useCallback((buf) => {
377
223
  setBufferPeek(buf || '');
378
224
  }, []);
379
225
  const handleSlashMove = useCallback((delta) => {
380
226
  setSelectedSuggestion((i) => {
381
- const max = Math.max(0, filtered.length - 1);
227
+ const max = Math.max(0, activeLenRef.current - 1);
382
228
  const n = i + delta;
383
229
  if (n < 0) return 0;
384
230
  if (n > max) return max;
385
231
  return n;
386
232
  });
387
- }, [filtered.length]);
233
+ }, []);
388
234
  const handleSlashDismiss = useCallback(() => {
389
235
  setBufferPeek('');
390
236
  setSelectedSuggestion(0);
391
237
  }, []);
392
238
 
393
- // Hide the popup when the buffer already exactly matches the only
394
- // remaining suggestion (with or without a trailing space). Otherwise
395
- // the popup intercepts Enter and the fully-typed command (e.g.
396
- // '/exit') never reaches handleSubmit. Belt-and-suspenders with the
397
- // editor-side fall-through in tui/editor.mjs.
239
+ // ─── Slash-argument completion (v6.x) ──────────────────────────────
240
+ // argSpec resolves what (if anything) is completable after the command.
241
+ // kind 'inline' candidates render in the popup (onArgList → argList);
242
+ // ↑/↓ select, Tab/Enter fill the token (fillArgToken).
243
+ // kind 'modal' → a "↹ pick" hint shows; Tab opens the drill-in picker
244
+ // (handleArgComplete → onArgComplete → argInject).
245
+ const argSpec = useMemo(
246
+ () => argSpecFor(bufferPeek, catalog),
247
+ [bufferPeek, catalog],
248
+ );
249
+ const [argList, setArgList] = useState([]);
250
+ useEffect(() => {
251
+ let cancelled = false;
252
+ if (argSpec && argSpec.kind === 'inline' && typeof onArgList === 'function') {
253
+ Promise.resolve(onArgList(bufferPeek))
254
+ .then((items) => { if (!cancelled) setArgList(Array.isArray(items) ? items : []); })
255
+ .catch(() => { if (!cancelled) setArgList([]); });
256
+ } else {
257
+ setArgList((prev) => (prev.length ? [] : prev));
258
+ }
259
+ return () => { cancelled = true; };
260
+ }, [bufferPeek, argSpec, onArgList]);
261
+ const argCompletable = !!argSpec && argSpec.kind === 'modal' && typeof onArgComplete === 'function';
262
+ const [argInject, setArgInject] = useState(null);
263
+ const argNonceRef = useRef(0);
264
+ const handleArgComplete = useCallback(async (buf) => {
265
+ if (typeof onArgComplete !== 'function') return;
266
+ let value = null;
267
+ try { value = await onArgComplete(buf); } catch { value = null; }
268
+ if (typeof value === 'string' && value) {
269
+ argNonceRef.current += 1;
270
+ setArgInject({ value, nonce: argNonceRef.current });
271
+ }
272
+ }, [onArgComplete]);
273
+
274
+ // Hide the command popup when the buffer already exactly matches the only
275
+ // remaining suggestion (so Enter submits /exit etc. instead of re-filling).
398
276
  const _bufTrimmed = bufferPeek.replace(/\s+$/, '');
399
277
  const _exactOnly =
400
278
  filtered.length === 1 &&
@@ -434,6 +312,9 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
434
312
  subtitle: opts.subtitle || '',
435
313
  items: Array.isArray(opts.items) ? opts.items : [],
436
314
  searchable: opts.searchable !== false,
315
+ // Carry the secret flag so a credential entry (api-key / token) masks
316
+ // the typed query — dropping it here echoed the secret in plaintext.
317
+ secret: !!opts.secret,
437
318
  resolve,
438
319
  });
439
320
  });
@@ -478,16 +359,34 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
478
359
  }, []);
479
360
  const modalOpen = !!modal;
480
361
 
481
- // Slash popup is suppressed whenever a modal picker is active so the
482
- // overlays don't stack, AND once the buffer has a space (the user is typing
483
- // ARGS, e.g. `/orchestrator off`). Without the space guard the popup stayed
484
- // open as a one-row hint and the editor treated Enter as "fill the matched
485
- // command", which dropped the args and reverted the buffer to the bare
486
- // command. With args, Enter must submit the full line instead.
487
- const showSlashPopup =
362
+ // Command popup (no space) vs inline-arg popup (has space + an inline spec) —
363
+ // mutually exclusive via the space. Both feed the same Editor/SlashPopup
364
+ // machinery; slashFillMode tells the Editor which fill to apply (whole
365
+ // command vs the arg token). Modal-kind args show a hint instead (Tab opens
366
+ // the drill-in picker). Suppressed entirely while a modal picker is up.
367
+ const cmdPopup =
488
368
  !modalOpen &&
489
369
  bufferPeek.startsWith('/') && bufferPeek.indexOf(' ') < 0 &&
490
370
  filtered.length > 0 && !_exactOnly;
371
+ const argPopup = !modalOpen && !cmdPopup && !!argSpec && argSpec.kind === 'inline' && argList.length > 0;
372
+ const popupRows = cmdPopup
373
+ ? filtered
374
+ : argPopup
375
+ ? argList.map((i) => ({ cmd: i.value, help: i.desc || '' }))
376
+ : [];
377
+ const showSlashPopup = popupRows.length > 0;
378
+ const slashFillMode = argPopup ? 'arg' : 'command';
379
+ activeLenRef.current = popupRows.length;
380
+ // Reset / clamp the highlighted row when the active list changes.
381
+ const lastLenRef = useRef(0);
382
+ useEffect(() => {
383
+ if (popupRows.length !== lastLenRef.current) {
384
+ setSelectedSuggestion(0);
385
+ lastLenRef.current = popupRows.length;
386
+ } else if (selectedSuggestion >= popupRows.length) {
387
+ setSelectedSuggestion(Math.max(0, popupRows.length - 1));
388
+ }
389
+ }, [popupRows.length, selectedSuggestion]);
491
390
 
492
391
  // Outer column height: pinned to rows-1 in alt-buffer mode so the
493
392
  // Editor truly sticks to the bottom. Non-alt keeps content-sized layout
@@ -554,10 +453,21 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
554
453
  showSlashPopup
555
454
  ? React.createElement(SlashPopup, {
556
455
  buffer: bufferPeek,
557
- commands: filtered,
456
+ commands: popupRows,
558
457
  selectedIndex: selectedSuggestion,
458
+ forceChooser: slashFillMode === 'arg',
559
459
  })
560
460
  : null,
461
+ // 3a) Modal-kind arg hint — for 2-step specs (/model, /trainer set,
462
+ // /orchestrator planner) Tab opens the drill-in picker. Inline-kind
463
+ // args render their candidates in the popup above instead.
464
+ (!showSlashPopup && argSpec && argSpec.kind === 'modal' && !modalOpen)
465
+ ? React.createElement(
466
+ Box,
467
+ { paddingX: 1, key: 'arghint' },
468
+ React.createElement(Text, { dimColor: true }, `↹ pick ${argSpec.name}`)
469
+ )
470
+ : null,
561
471
  // 3b) Modal picker (v5.4.3) — flex sibling above StatusBar, only
562
472
  // visible while ReplApp's `modal` state is set. Suppresses the
563
473
  // slash popup so the overlays don't stack.
@@ -569,6 +479,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
569
479
  selectedIndex: modalIdx,
570
480
  query: modalQuery,
571
481
  searchable: modal.searchable,
482
+ secret: modal.secret,
572
483
  })
573
484
  : null,
574
485
  // 4) Status bar (sticky, single row above input). flexShrink:0 so
@@ -579,6 +490,7 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
579
490
  streaming: state.streaming,
580
491
  ctxUsed: _status.ctxUsed,
581
492
  ctxTotal: _status.ctxTotal,
493
+ hud: _status.hud,
582
494
  }),
583
495
  // 5) Editor — sticky bottom, content-sized. Wrapped in a flexShrink:0
584
496
  // Box so Yoga doesn't squeeze the input row when scrollback fills.
@@ -590,10 +502,19 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
590
502
  onSubmit: handleSubmit,
591
503
  onEscape: onEscapeKey,
592
504
  onBufferChange: handleBufferChange,
593
- slashSuggestions: showSlashPopup ? filtered : null,
505
+ // v6.4 2-stage Ctrl+C wiring (active when exitOnCtrlC:false).
506
+ onInterrupt: onEscapeKey,
507
+ onExit: onExitKey,
508
+ slashSuggestions: showSlashPopup ? popupRows : null,
594
509
  slashSelectedIndex: selectedSuggestion,
510
+ slashFillMode,
595
511
  onSlashMove: handleSlashMove,
596
512
  onSlashDismiss: handleSlashDismiss,
513
+ // v6.x slash-argument completion (inline fills the token; modal kind
514
+ // uses argCompletable+onArgComplete to open the drill-in picker).
515
+ argCompletable,
516
+ onArgComplete: handleArgComplete,
517
+ argInject,
597
518
  // v5.4.3 — modal picker key contract. When modalOpen the
598
519
  // Editor swallows all keys (no buffer mutation, no submit).
599
520
  modalOpen,
@@ -640,20 +561,8 @@ export const ScrollbackItem = React.memo(function ScrollbackItem({ item }) {
640
561
  return React.createElement(Text, { color: theme.fg }, item.text);
641
562
  });
642
563
 
643
- // StatusBar single row, provider · model · ctx · streaming indicator.
644
- // Kept intentionally minimal in v5.3; token gauges land separately once
645
- // usage metrics flow into state.
646
- export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal }) {
647
- const ctx = (ctxUsed != null && ctxTotal != null) ? `${ctxUsed}/${ctxTotal}` : '--';
648
- const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
649
- const prov = provider || '?';
650
- const mdl = model || '?';
651
- return React.createElement(
652
- Box,
653
- { flexShrink: 0, paddingX: 1 },
654
- React.createElement(Text, null, `${indicator} ${prov} · ${mdl} ctx ${ctx}`)
655
- );
656
- }
564
+ // StatusBar moved to ./status_bar.mjs (re-exported above) so the HUD row can
565
+ // grow without pushing repl.mjs over the file-size ratchet.
657
566
 
658
567
  // Exported for tests that want to verify the splash snapshot without a TTY.
659
568
  export function _renderSplashToString(splashProps) {
@@ -0,0 +1,63 @@
1
+ // tui/repl_altbuffer.mjs — alt-buffer (DEC 1049) mount cluster for the REPL.
2
+ //
3
+ // Extracted verbatim from repl.mjs (file-size gate). Wraps the React tree
4
+ // with mount/unmount side-effects that enable the terminal alternate screen
5
+ // buffer. Three-layer cleanup so the user never gets stranded on the alt
6
+ // canvas:
7
+ // 1. React unmount → useEffect return-fn writes \x1b[?1049l
8
+ // 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
9
+ // process-level listeners that we install + remove on unmount.
10
+ // 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
11
+ // below us turned it off.
12
+ //
13
+ // We deliberately do NOT install an uncaughtException handler — Ink
14
+ // already installs one and re-throws; ours would swallow the stack
15
+ // trace (violates §1 Truthfulness / no silent catch).
16
+ //
17
+ // `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
18
+ // the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
19
+ // no escape sequences leak into stdout.
20
+ import { useEffect } from 'react';
21
+
22
+ export const ALT_BUFFER_ENTER = '\x1b[?1049h';
23
+ export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
24
+ export const CURSOR_VISIBLE = '\x1b[?25h';
25
+
26
+ // Rendering-mode decision. Default = Static scrollback (no flicker; splash
27
+ // prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
28
+ // LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
29
+ export function computeAltEnabled(env, hasTTY) {
30
+ const e = env || {};
31
+ return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
32
+ }
33
+
34
+ export function FullScreen({ enabled, children }) {
35
+ useEffect(() => {
36
+ if (!enabled) return undefined;
37
+ // Mount: enter alternate screen buffer.
38
+ try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
39
+
40
+ // Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
41
+ // terminal is restored even if React never gets a chance to unmount
42
+ // (e.g. parent process kills us with SIGTERM).
43
+ const restore = () => {
44
+ try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
45
+ };
46
+ const onExit = () => { restore(); };
47
+ const onSignal = () => { restore(); };
48
+ process.once('exit', onExit);
49
+ process.once('SIGINT', onSignal);
50
+ process.once('SIGTERM', onSignal);
51
+ process.once('SIGHUP', onSignal);
52
+
53
+ return () => {
54
+ // React unmount: restore primary buffer.
55
+ restore();
56
+ process.removeListener('exit', onExit);
57
+ process.removeListener('SIGINT', onSignal);
58
+ process.removeListener('SIGTERM', onSignal);
59
+ process.removeListener('SIGHUP', onSignal);
60
+ };
61
+ }, [enabled]);
62
+ return children;
63
+ }
@@ -0,0 +1,114 @@
1
+ // tui/repl_reducers.mjs — pure state reducers for the REPL host.
2
+ //
3
+ // Extracted verbatim from repl.mjs (file-size gate). These are pure
4
+ // functions: no shared mutable module state, no JSX. They keep their
5
+ // pre-v5.3 shapes — tests/phaseC-repl-interrupt.test.mjs depends on them.
6
+ //
7
+ // Backward-compat contracts (do not break):
8
+ // - makeReplState() — still callable with zero args.
9
+ // - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
10
+ // keep their pre-v5.3 shapes.
11
+
12
+ // ─── Pure state ──────────────────────────────────────────────────────────
13
+ //
14
+ // makeReplState stays callable with zero args (existing tests rely on it).
15
+ // The new fields default to empty so legacy callers see no behavior change.
16
+ export function makeReplState(opts) {
17
+ const splashItem = opts && opts.splashItem ? opts.splashItem : null;
18
+ return {
19
+ streaming: false,
20
+ controller: null,
21
+ pendingPrepend: null,
22
+ nextTurnFirstMessage: null,
23
+ history: [],
24
+ scrollback: splashItem ? [splashItem] : [],
25
+ liveAssistant: '',
26
+ turnCounter: 0,
27
+ };
28
+ }
29
+
30
+ export function onUserInput(state, { text, controller }) {
31
+ if (state.streaming && state.controller) {
32
+ // mid-stream interrupt — abort current turn, queue text for next turn.
33
+ try { state.controller.abort(); } catch {}
34
+ return { ...state, pendingPrepend: text };
35
+ }
36
+ // idle — start a new turn. Append a 'user' entry to scrollback so the
37
+ // sticky-layout caller sees the prompt history above the live stream.
38
+ const id = `u-${state.turnCounter}`;
39
+ return {
40
+ ...state,
41
+ streaming: true,
42
+ controller,
43
+ history: [...state.history, text],
44
+ scrollback: [...state.scrollback, { kind: 'user', id, text }],
45
+ turnCounter: state.turnCounter + 1,
46
+ };
47
+ }
48
+
49
+ export function onEscape(state) {
50
+ if (state.streaming && state.controller) {
51
+ try { state.controller.abort(); } catch {}
52
+ }
53
+ // Drop any partial live assistant text on explicit Esc — the user is
54
+ // telling us to discard, not to keep.
55
+ return {
56
+ ...state,
57
+ streaming: false,
58
+ controller: null,
59
+ pendingPrepend: null,
60
+ liveAssistant: '',
61
+ };
62
+ }
63
+
64
+ // Stream chunk arrives. Completed lines are committed to the <Static>
65
+ // scrollback immediately (so they scroll up ABOVE the sticky editor), and only
66
+ // the in-progress trailing partial stays in the live region. Without this, a
67
+ // reply taller than the terminal grew the live frame past the viewport and
68
+ // spilled BELOW the input box (long orchestrator replies). Chunks without a
69
+ // newline still just accumulate (the prior behaviour), so short replies and the
70
+ // existing reducer tests are unchanged.
71
+ export function onStreamChunk(state, { chunk }) {
72
+ const buf = state.liveAssistant + chunk;
73
+ const nl = buf.lastIndexOf('\n');
74
+ if (nl < 0) return { ...state, liveAssistant: buf };
75
+ const complete = buf.slice(0, nl); // one or more whole lines
76
+ const remainder = buf.slice(nl + 1); // trailing partial (may be '')
77
+ const id = `as-${state.turnCounter}-${state.scrollback.length}`;
78
+ return {
79
+ ...state,
80
+ scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
81
+ liveAssistant: remainder,
82
+ };
83
+ }
84
+
85
+ export function onTurnComplete(state, { reason, error } = {}) {
86
+ const promoted = state.pendingPrepend;
87
+ const suffix = reason === 'aborted' ? ' [aborted]'
88
+ : reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
89
+ : '';
90
+ const text = (state.liveAssistant || '') + suffix;
91
+ // Commit any accumulated live text to scrollback. If the turn produced
92
+ // nothing AND wasn't an error/abort, skip the empty append.
93
+ const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
94
+ const id = `a-${state.turnCounter}`;
95
+ const kind = reason === 'error' ? 'error' : 'assistant';
96
+ const nextScrollback = shouldCommit
97
+ ? [...state.scrollback, { kind, id, text }]
98
+ : state.scrollback;
99
+ return {
100
+ ...state,
101
+ streaming: false,
102
+ controller: null,
103
+ pendingPrepend: null,
104
+ nextTurnFirstMessage: promoted,
105
+ liveAssistant: '',
106
+ scrollback: nextScrollback,
107
+ turnCounter: state.turnCounter + 1,
108
+ };
109
+ }
110
+
111
+ export function consumeNextTurnFirstMessage(state) {
112
+ const msg = state.nextTurnFirstMessage;
113
+ return [{ ...state, nextTurnFirstMessage: null }, msg];
114
+ }
@@ -0,0 +1,37 @@
1
+ // tui/repl_reset.mjs — pure reducer for the `/new` conversation reset.
2
+ //
3
+ // Lives in its own file (not repl.mjs) only because repl.mjs is at its
4
+ // file-size ceiling (scripts/lint-file-size.mjs). It is the scrollback-clearing
5
+ // counterpart to the dispatcher's in-memory reset: /new wiped ctx.setMessages
6
+ // but left the <Static/> scrollback intact, so the old conversation stayed on
7
+ // screen. This empties the scrollback back to the splash item (the established
8
+ // fresh-start look) and drops any in-flight / live-region state.
9
+
10
+ export function onConversationReset(state) {
11
+ // Keep the splash header so a reset screen looks like a fresh launch, not a
12
+ // blank void. Everything else (user lines, assistant replies, live partial)
13
+ // is discarded.
14
+ const splash = state.scrollback.find((it) => it && it.kind === 'splash') || null;
15
+ return {
16
+ ...state,
17
+ streaming: false,
18
+ controller: null,
19
+ pendingPrepend: null,
20
+ nextTurnFirstMessage: null,
21
+ liveAssistant: '',
22
+ history: [],
23
+ scrollback: splash ? [splash] : [],
24
+ };
25
+ }
26
+
27
+ // Erase the terminal screen + scrollback buffer. Resetting the React scrollback
28
+ // state alone cannot un-print Ink's <Static/> output (it is write-once and
29
+ // append-only), so /new must also wipe the physical terminal. `\x1b[3J` drops
30
+ // the scrollback buffer so the old conversation can't be scrolled back to.
31
+ // `write` is the Ink-provided useStdout().write (clears the live frame first).
32
+ export const CLEAR_TERMINAL = '\x1b[2J\x1b[3J\x1b[H';
33
+ export function clearTerminalScreen(write) {
34
+ if (typeof write === 'function') {
35
+ try { write(CLEAR_TERMINAL); } catch { /* swallow — stdout may be closed */ }
36
+ }
37
+ }