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.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +41 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +27 -3
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +68 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +29 -2
- package/providers/tool_use/openai.mjs +35 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- 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
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
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
|
-
}, [
|
|
233
|
+
}, []);
|
|
388
234
|
const handleSlashDismiss = useCallback(() => {
|
|
389
235
|
setBufferPeek('');
|
|
390
236
|
setSelectedSuggestion(0);
|
|
391
237
|
}, []);
|
|
392
238
|
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
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
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
644
|
-
//
|
|
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
|
+
}
|