lazyclaw 6.3.1 → 6.4.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 +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +90 -33
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- 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 +36 -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 +26 -2
- 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 +61 -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 +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- 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 +18 -2
- package/providers/tool_use/openai.mjs +28 -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/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- 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/pickers.mjs +196 -80
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- 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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// tui/chat_mode_slash.mjs — /agentic and /plan slash handlers (Group 1).
|
|
2
|
+
//
|
|
3
|
+
// Extracted from slash_dispatcher.mjs (at its file-size ratchet ceiling) so
|
|
4
|
+
// the agentic REPL / plan mode toggles can grow without forcing that file
|
|
5
|
+
// over its pinned limit. slash_dispatcher imports + registers these in
|
|
6
|
+
// SLASH_HANDLERS and the shared SLASH_COMMANDS catalog.
|
|
7
|
+
//
|
|
8
|
+
// Both mirror the /hud toggle (tui/hud.mjs::hudSlash): read cfg, flip the
|
|
9
|
+
// chat.* key, persist via writeConfig when wired, and mirror onto the
|
|
10
|
+
// in-memory cfg so the next turn (makeRunTurn → chatAgenticGet /
|
|
11
|
+
// chatPlanModeGet) sees it live. No-arg flips the current value, or opens
|
|
12
|
+
// an on/off picker in the Ink UI.
|
|
13
|
+
|
|
14
|
+
import { chatAgenticGet, chatPlanModeGet, chatSet } from '../config_features.mjs';
|
|
15
|
+
|
|
16
|
+
async function _chatToggleSlash(args, ctx, { key, label, pickerDesc }) {
|
|
17
|
+
const getter = key === 'agentic' ? chatAgenticGet : chatPlanModeGet;
|
|
18
|
+
const cfg = ctx.readConfig ? ctx.readConfig() : (ctx.cfg || {});
|
|
19
|
+
const cur = getter(cfg);
|
|
20
|
+
const a = String(args || '').trim().toLowerCase();
|
|
21
|
+
let next;
|
|
22
|
+
if (a === 'on') next = true;
|
|
23
|
+
else if (a === 'off') next = false;
|
|
24
|
+
else if (!a && typeof ctx.openPicker === 'function') {
|
|
25
|
+
const picked = await ctx.openPicker({
|
|
26
|
+
kind: 'menu',
|
|
27
|
+
title: `${label} mode`,
|
|
28
|
+
subtitle: `currently ${cur ? 'on' : 'off'}`,
|
|
29
|
+
items: [
|
|
30
|
+
{ id: 'on', label: 'on', desc: pickerDesc },
|
|
31
|
+
{ id: 'off', label: 'off', desc: 'off' },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
const id = picked && typeof picked === 'object' ? picked.id : picked;
|
|
35
|
+
if (id !== 'on' && id !== 'off') return `${label}: cancelled`;
|
|
36
|
+
next = id === 'on';
|
|
37
|
+
} else next = !cur;
|
|
38
|
+
chatSet(cfg, key, next);
|
|
39
|
+
if (ctx.writeConfig) ctx.writeConfig(cfg);
|
|
40
|
+
if (ctx.cfg && ctx.cfg !== cfg) chatSet(ctx.cfg, key, next);
|
|
41
|
+
return `${label} ${next ? 'on' : 'off'}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const agenticSlash = (a, ctx) => _chatToggleSlash(a, ctx, {
|
|
45
|
+
key: 'agentic', label: 'agentic',
|
|
46
|
+
pickerDesc: 'run tools behind the approval gate (read/grep/skill by default)',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const planSlash = (a, ctx) => _chatToggleSlash(a, ctx, {
|
|
50
|
+
key: 'planMode', label: 'plan',
|
|
51
|
+
pickerDesc: 'read-only: propose a plan, do not mutate',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Catalog entries for the shared SLASH_COMMANDS list (consumed by /help, the
|
|
55
|
+
// popup, ghost-autocomplete, and the d6 drift-guard).
|
|
56
|
+
export const CHAT_MODE_SLASH_COMMANDS = [
|
|
57
|
+
{ cmd: '/agentic', help: 'toggle the agentic REPL (tools behind approval): /agentic on|off' },
|
|
58
|
+
{ cmd: '/plan', help: 'toggle plan mode (read-only: propose, do not mutate): /plan on|off' },
|
|
59
|
+
];
|
package/tui/config_picker.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const CONFIG_ITEMS = [
|
|
|
17
17
|
{ id: 'provider', label: 'provider', desc: 'switch the chat provider (family → vendor picker)' },
|
|
18
18
|
{ id: 'model', label: 'model', desc: 'switch the model (live list when the provider supports it)' },
|
|
19
19
|
{ id: 'context', label: 'context window', desc: 'history turns / token budget sent per turn' },
|
|
20
|
+
{ id: 'hud', label: 'HUD status bar', desc: 'show/hide the usage · models · cost row above the input' },
|
|
20
21
|
{ id: 'trainer', label: 'trainer', desc: 'learning-loop provider/model (auto = $0 on claude-cli)' },
|
|
21
22
|
{ id: 'orchestrator', label: 'orchestrator', desc: 'multi-agent on/off, planner, workers' },
|
|
22
23
|
{ id: 'channel', label: 'channel credentials', desc: 'Slack/Telegram/Matrix tokens — leaves chat for the prompts, then returns' },
|
package/tui/editor.mjs
CHANGED
|
@@ -30,25 +30,39 @@
|
|
|
30
30
|
// on long Korean buffers.
|
|
31
31
|
import React, { useState, useEffect, useRef } from 'react';
|
|
32
32
|
import { Box, Text, useInput } from 'ink';
|
|
33
|
-
import stringWidth from 'string-width';
|
|
34
33
|
import { theme } from './theme.mjs';
|
|
34
|
+
import {
|
|
35
|
+
nextCodepointIndex,
|
|
36
|
+
INTERRUPT_WINDOW_MS,
|
|
37
|
+
PROMPT_PREFIX,
|
|
38
|
+
PROMPT_WIDTH,
|
|
39
|
+
CONTINUATION_GUTTER,
|
|
40
|
+
CONTINUATION_WIDTH,
|
|
41
|
+
displayWidth,
|
|
42
|
+
wrapToBudget,
|
|
43
|
+
cursorDisplayCol,
|
|
44
|
+
makeEditorState,
|
|
45
|
+
applyKey,
|
|
46
|
+
fillSlashCommand,
|
|
47
|
+
fillArgToken,
|
|
48
|
+
} from './editor_keys.mjs';
|
|
35
49
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
50
|
+
// Re-export the pure state machine + measurement helpers (moved to
|
|
51
|
+
// editor_keys.mjs to keep this file under the 500-line gate) so every
|
|
52
|
+
// existing import path (`from '../tui/editor.mjs'`) keeps working.
|
|
53
|
+
export {
|
|
54
|
+
INTERRUPT_WINDOW_MS,
|
|
55
|
+
PROMPT_PREFIX,
|
|
56
|
+
PROMPT_WIDTH,
|
|
57
|
+
CONTINUATION_GUTTER,
|
|
58
|
+
CONTINUATION_WIDTH,
|
|
59
|
+
displayWidth,
|
|
60
|
+
wrapToBudget,
|
|
61
|
+
cursorDisplayCol,
|
|
62
|
+
makeEditorState,
|
|
63
|
+
applyKey,
|
|
64
|
+
fillSlashCommand,
|
|
65
|
+
};
|
|
52
66
|
|
|
53
67
|
// ─── IME cursor anchor (v5.4.4) ─────────────────────────────────────
|
|
54
68
|
//
|
|
@@ -92,126 +106,21 @@ function _installAnchorShim() {
|
|
|
92
106
|
_anchorState.shimmed = true;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
// Cell-aware soft-wrap. Returns an array of visual rows whose width
|
|
96
|
-
// respects the budget (first row uses `firstBudget`, subsequent rows
|
|
97
|
-
// use `contBudget`). Hoisted to module level (was inner-fn) so the
|
|
98
|
-
// cursor-anchor useEffect in <Editor/> can reuse it.
|
|
99
|
-
export function wrapToBudget(text, firstBudget, contBudget) {
|
|
100
|
-
if (!text) return [''];
|
|
101
|
-
const out = [];
|
|
102
|
-
let line = '';
|
|
103
|
-
let lineW = 0;
|
|
104
|
-
let budget = firstBudget;
|
|
105
|
-
for (const ch of text) {
|
|
106
|
-
const w = stringWidth(ch);
|
|
107
|
-
if (lineW + w > budget) {
|
|
108
|
-
out.push(line);
|
|
109
|
-
line = ch;
|
|
110
|
-
lineW = w;
|
|
111
|
-
budget = contBudget;
|
|
112
|
-
} else {
|
|
113
|
-
line += ch;
|
|
114
|
-
lineW += w;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
out.push(line);
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Display column of the caret given a state. Counts wide chars as 2.
|
|
122
|
-
// On the first rendered line this is offset by PROMPT_WIDTH; on
|
|
123
|
-
// continuation lines (after a Shift+Enter) it is offset by
|
|
124
|
-
// CONTINUATION_WIDTH. Callers that only need the in-buffer column can
|
|
125
|
-
// pass `{ withPrefix: false }`.
|
|
126
|
-
export function cursorDisplayCol(state, { withPrefix = true } = {}) {
|
|
127
|
-
const before = String(state.buffer || '').slice(0, state.cursor || 0);
|
|
128
|
-
const newlineIdx = before.lastIndexOf('\n');
|
|
129
|
-
const lineSlice = newlineIdx === -1 ? before : before.slice(newlineIdx + 1);
|
|
130
|
-
const inLine = stringWidth(lineSlice);
|
|
131
|
-
if (!withPrefix) return inLine;
|
|
132
|
-
const prefix = newlineIdx === -1 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
|
|
133
|
-
return prefix + inLine;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function makeEditorState({ history = [] } = {}) {
|
|
137
|
-
return {
|
|
138
|
-
buffer: '',
|
|
139
|
-
cursor: 0,
|
|
140
|
-
historyIdx: history.length,
|
|
141
|
-
history,
|
|
142
|
-
lastSubmit: null,
|
|
143
|
-
lastWasPaste: false,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export function applyKey(state, evt) {
|
|
148
|
-
const { input = '', key = {}, paste = false } = evt;
|
|
149
|
-
const next = { ...state, lastSubmit: null, lastWasPaste: false };
|
|
150
|
-
|
|
151
|
-
if (key.return && key.shift) {
|
|
152
|
-
next.buffer = state.buffer + '\n';
|
|
153
|
-
next.cursor = next.buffer.length;
|
|
154
|
-
return next;
|
|
155
|
-
}
|
|
156
|
-
if (key.return) {
|
|
157
|
-
next.lastSubmit = state.buffer;
|
|
158
|
-
next.buffer = '';
|
|
159
|
-
next.cursor = 0;
|
|
160
|
-
next.historyIdx = state.history.length;
|
|
161
|
-
return next;
|
|
162
|
-
}
|
|
163
|
-
if (key.upArrow) {
|
|
164
|
-
const idx = Math.max(0, state.historyIdx - 1);
|
|
165
|
-
if (state.history[idx] !== undefined) {
|
|
166
|
-
next.historyIdx = idx;
|
|
167
|
-
next.buffer = state.history[idx];
|
|
168
|
-
next.cursor = next.buffer.length;
|
|
169
|
-
}
|
|
170
|
-
return next;
|
|
171
|
-
}
|
|
172
|
-
if (key.downArrow) {
|
|
173
|
-
const idx = Math.min(state.history.length, state.historyIdx + 1);
|
|
174
|
-
next.historyIdx = idx;
|
|
175
|
-
next.buffer = state.history[idx] !== undefined ? state.history[idx] : '';
|
|
176
|
-
next.cursor = next.buffer.length;
|
|
177
|
-
return next;
|
|
178
|
-
}
|
|
179
|
-
if (key.backspace || key.delete) {
|
|
180
|
-
next.buffer = state.buffer.slice(0, -1);
|
|
181
|
-
next.cursor = next.buffer.length;
|
|
182
|
-
return next;
|
|
183
|
-
}
|
|
184
|
-
if (input) {
|
|
185
|
-
next.buffer = state.buffer + input;
|
|
186
|
-
next.cursor = next.buffer.length;
|
|
187
|
-
next.lastWasPaste = paste || input.length >= 16;
|
|
188
|
-
return next;
|
|
189
|
-
}
|
|
190
|
-
return next;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Pure helper used by the slash-popup branch in <Editor/>. Replaces the
|
|
194
|
-
// editor buffer with `${cmd} ` (note trailing space so the user can keep
|
|
195
|
-
// typing args without an extra keystroke). Does NOT submit.
|
|
196
|
-
export function fillSlashCommand(state, cmd) {
|
|
197
|
-
const filled = cmd.endsWith(' ') ? cmd : cmd + ' ';
|
|
198
|
-
return {
|
|
199
|
-
...state,
|
|
200
|
-
buffer: filled,
|
|
201
|
-
cursor: filled.length,
|
|
202
|
-
lastSubmit: null,
|
|
203
|
-
lastWasPaste: false,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
109
|
export function Editor({
|
|
208
110
|
history,
|
|
209
111
|
onSubmit,
|
|
210
112
|
onEscape,
|
|
211
113
|
onBufferChange,
|
|
114
|
+
// v6.4 2-stage Ctrl+C (optional — pre-v6.4 callers pass none). First press
|
|
115
|
+
// fires onInterrupt (cancel in-flight turn / clear buffer); a second press
|
|
116
|
+
// within INTERRUPT_WINDOW_MS fires onExit. Only reachable when Ink is
|
|
117
|
+
// rendered with exitOnCtrlC:false (otherwise Ink intercepts Ctrl+C first).
|
|
118
|
+
onInterrupt, // () => void
|
|
119
|
+
onExit, // () => void
|
|
212
120
|
// v5.4 slash-popup wiring (all optional — pre-v5.4 callers pass none):
|
|
213
121
|
slashSuggestions, // Array<{cmd,help}> | null
|
|
214
122
|
slashSelectedIndex, // number
|
|
123
|
+
slashFillMode, // 'command' (default) | 'arg' — arg fills the token via fillArgToken
|
|
215
124
|
onSlashMove, // (delta: -1 | +1) => void
|
|
216
125
|
onSlashDismiss, // () => void
|
|
217
126
|
// v5.4.3 modal-picker wiring (all optional — pre-v5.4.3 callers pass none).
|
|
@@ -230,6 +139,14 @@ export function Editor({
|
|
|
230
139
|
// pre-edit overlay at the actual typing position. Opt-out via the
|
|
231
140
|
// LAZYCLAW_NO_CURSOR_ANCHOR env var (handled internally).
|
|
232
141
|
altEnabled,
|
|
142
|
+
// v6.x slash-argument completion (all optional). When the buffer is a
|
|
143
|
+
// command + a completable value, the host sets argCompletable; Tab then
|
|
144
|
+
// calls onArgComplete(buffer) so the host opens the modal picker. The host
|
|
145
|
+
// pushes the chosen value back via argInject={value,nonce}, which the
|
|
146
|
+
// Editor applies with fillArgToken (only token under the cursor changes).
|
|
147
|
+
argCompletable, // boolean
|
|
148
|
+
onArgComplete, // (buffer: string) => void
|
|
149
|
+
argInject, // { value: string, nonce: number } | null
|
|
233
150
|
}) {
|
|
234
151
|
const [state, setState] = useState(() => makeEditorState({ history }));
|
|
235
152
|
const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
|
|
@@ -249,8 +166,48 @@ export function Editor({
|
|
|
249
166
|
setState(next);
|
|
250
167
|
};
|
|
251
168
|
|
|
169
|
+
// v6.4 — this-session input history. The parent (repl) grows `history` as
|
|
170
|
+
// each prompt is submitted, but makeEditorState only snapshots it at mount,
|
|
171
|
+
// so without this sync the editor's Up/Down never saw this-session prompts
|
|
172
|
+
// (the audit gap). When the prop array CHANGES, mirror it into state and
|
|
173
|
+
// reset historyIdx to the tip so the next Up recalls the newest submission.
|
|
174
|
+
// The buffer/cursor are left untouched (don't disturb an in-progress draft).
|
|
175
|
+
const historyKey = Array.isArray(history) ? history.join('') : '';
|
|
176
|
+
const prevHistoryKeyRef = useRef(null);
|
|
177
|
+
if (prevHistoryKeyRef.current === null) prevHistoryKeyRef.current = historyKey;
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (prevHistoryKeyRef.current === historyKey) return;
|
|
180
|
+
prevHistoryKeyRef.current = historyKey;
|
|
181
|
+
const list = Array.isArray(history) ? history : [];
|
|
182
|
+
commit({ ...stateRef.current, history: list, historyIdx: list.length });
|
|
183
|
+
}, [historyKey]);
|
|
184
|
+
|
|
185
|
+
// v6.4 — 2-stage Ctrl+C. Tracks the timestamp of the last Ctrl+C so a
|
|
186
|
+
// second press within the window exits; the first only interrupts.
|
|
187
|
+
const lastCtrlCRef = useRef(0);
|
|
188
|
+
|
|
252
189
|
useInput((input, key) => {
|
|
253
190
|
const current = stateRef.current;
|
|
191
|
+
// ─── 2-stage Ctrl+C (highest priority — v6.4) ───────────────────────
|
|
192
|
+
// Only reached when Ink is rendered with exitOnCtrlC:false; otherwise
|
|
193
|
+
// Ink swallows Ctrl+C and exits before useInput sees it. First press
|
|
194
|
+
// cancels the in-flight turn + clears the buffer (consistent with Esc);
|
|
195
|
+
// a second press within INTERRUPT_WINDOW_MS exits.
|
|
196
|
+
if (key.ctrl && (input === 'c' || input === 'C')) {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
if (now - lastCtrlCRef.current <= INTERRUPT_WINDOW_MS) {
|
|
199
|
+
lastCtrlCRef.current = 0;
|
|
200
|
+
if (onExit) onExit();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
lastCtrlCRef.current = now;
|
|
204
|
+
// Clear the draft + cancel in-flight work.
|
|
205
|
+
const cleared = { ...current, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
|
|
206
|
+
commit(cleared);
|
|
207
|
+
if (onBufferChange) { try { onBufferChange(''); } catch {} }
|
|
208
|
+
if (onInterrupt) onInterrupt();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
254
211
|
// ─── Modal-picker keyboard contract (highest priority — v5.4.3) ──
|
|
255
212
|
// When a host-owned picker is up, EVERY key is consumed by the
|
|
256
213
|
// picker. The chat buffer is never mutated and onSubmit is never
|
|
@@ -313,25 +270,50 @@ export function Editor({
|
|
|
313
270
|
if (key.tab || key.return) {
|
|
314
271
|
const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
|
|
315
272
|
const picked = slashSuggestions[safeIdx];
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
273
|
+
if (slashFillMode === 'arg') {
|
|
274
|
+
// Arg popup: picked.cmd holds the candidate VALUE. Fill it into the
|
|
275
|
+
// token in place. When the token already equals the candidate, Enter
|
|
276
|
+
// falls through to submit (Tab no-ops).
|
|
277
|
+
const start = Math.max(current.buffer.lastIndexOf(' '), current.buffer.lastIndexOf(',')) + 1;
|
|
278
|
+
const exact = !!picked && current.buffer.slice(start) === picked.cmd;
|
|
279
|
+
if (exact && key.return) {
|
|
280
|
+
// token complete → fall through to applyKey/submit
|
|
281
|
+
} else {
|
|
282
|
+
if (!exact && picked) {
|
|
283
|
+
const next = fillArgToken(current, picked.cmd);
|
|
284
|
+
commit(next);
|
|
285
|
+
if (onBufferChange) { try { onBufferChange(next.buffer); } catch {} }
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
321
289
|
} else {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
290
|
+
const bufTrim = current.buffer.replace(/\s+$/, '');
|
|
291
|
+
const alreadyExact = !!picked && (current.buffer === picked.cmd || bufTrim === picked.cmd);
|
|
292
|
+
if (alreadyExact) {
|
|
293
|
+
if (key.tab) return; // no completion to make
|
|
294
|
+
// key.return on exact match → fall through to applyKey/submit.
|
|
295
|
+
} else {
|
|
296
|
+
if (picked) {
|
|
297
|
+
const next = fillSlashCommand(current, picked.cmd);
|
|
298
|
+
commit(next);
|
|
299
|
+
if (onBufferChange) {
|
|
300
|
+
try { onBufferChange(next.buffer); } catch {}
|
|
301
|
+
}
|
|
327
302
|
}
|
|
303
|
+
return;
|
|
328
304
|
}
|
|
329
|
-
return;
|
|
330
305
|
}
|
|
331
306
|
}
|
|
332
307
|
// Anything else (printable, backspace) falls through to applyKey.
|
|
333
308
|
}
|
|
334
309
|
|
|
310
|
+
// Arg completion: Tab while typing a command's value (slash popup closed,
|
|
311
|
+
// no modal) hands the buffer to the host, which opens the picker and pushes
|
|
312
|
+
// the choice back via argInject. Without a completer, Tab falls through.
|
|
313
|
+
if (!slashOpen && key.tab && argCompletable && onArgComplete) {
|
|
314
|
+
onArgComplete(stateRef.current.buffer);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
335
317
|
// Esc: forward to host (ReplApp uses this to abort an in-flight turn).
|
|
336
318
|
// Do not mutate the buffer — the user may want to keep typing.
|
|
337
319
|
if (key.escape) { if (onEscape) onEscape(); return; }
|
|
@@ -345,6 +327,19 @@ export function Editor({
|
|
|
345
327
|
if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
|
|
346
328
|
}, [state.lastSubmit]);
|
|
347
329
|
|
|
330
|
+
// Apply a host-pushed arg-completion value into the buffer (the picker the
|
|
331
|
+
// host opened resolved to `value`). Guarded by a monotonic nonce so the same
|
|
332
|
+
// value can be injected twice and re-renders don't re-apply a stale value.
|
|
333
|
+
const injectNonceRef = useRef(0);
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (argInject && argInject.nonce !== injectNonceRef.current && typeof argInject.value === 'string') {
|
|
336
|
+
injectNonceRef.current = argInject.nonce;
|
|
337
|
+
const nextState = fillArgToken(stateRef.current, argInject.value);
|
|
338
|
+
commit(nextState);
|
|
339
|
+
if (onBufferChange) { try { onBufferChange(nextState.buffer); } catch {} }
|
|
340
|
+
}
|
|
341
|
+
}, [argInject]);
|
|
342
|
+
|
|
348
343
|
// v5.4.3 — IME cursor anchor.
|
|
349
344
|
//
|
|
350
345
|
// After every Ink render commit, move the terminal cursor BACK inside
|
|
@@ -393,7 +388,7 @@ export function Editor({
|
|
|
393
388
|
const lastWrapped = wrapToBudget(lastBefore, fb, cb);
|
|
394
389
|
rowInEditor += lastWrapped.length - 1;
|
|
395
390
|
const lastSegment = lastWrapped[lastWrapped.length - 1] || '';
|
|
396
|
-
const colInLine =
|
|
391
|
+
const colInLine = displayWidth(lastSegment);
|
|
397
392
|
|
|
398
393
|
// After Ink writes `<frame>\n`, cursor sits on the line below the
|
|
399
394
|
// editor's bottom border at column 0. Editor geometry: 1 top border
|
|
@@ -440,27 +435,59 @@ export function Editor({
|
|
|
440
435
|
// Box overhead: 1 border + 1 padX on each side = 4 cells; first row
|
|
441
436
|
// also reserves PROMPT_WIDTH; continuation rows reserve CONTINUATION_WIDTH.
|
|
442
437
|
const innerCells = Math.max(8, TERM - 4); // 2 border + 2 padX
|
|
438
|
+
// Caret position in BUFFER code units (split by logical line). caretLine is
|
|
439
|
+
// the index into `lines`; caretCol is the code-unit offset within that line.
|
|
440
|
+
const beforeCaret = state.buffer.slice(0, state.cursor || 0);
|
|
441
|
+
const beforeLines = beforeCaret.split('\n');
|
|
442
|
+
const caretLine = beforeLines.length - 1;
|
|
443
|
+
const caretCol = beforeLines[beforeLines.length - 1].length;
|
|
443
444
|
const renderedLines = [];
|
|
445
|
+
// While wrapping each logical line, find which rendered row the caret sits
|
|
446
|
+
// on and its code-unit offset within that row's text (so the inverse-video
|
|
447
|
+
// caret can be drawn AT the cursor, not always at the end).
|
|
448
|
+
let caretRow = -1;
|
|
449
|
+
let caretRowOffset = 0;
|
|
444
450
|
for (let li = 0; li < lines.length; li++) {
|
|
445
451
|
const wrapped = wrapToBudget(lines[li], innerCells - PROMPT_WIDTH, innerCells - CONTINUATION_WIDTH);
|
|
452
|
+
let consumed = 0; // code units of this logical line placed into prior rows
|
|
446
453
|
for (let wi = 0; wi < wrapped.length; wi++) {
|
|
447
454
|
const isFirstLogical = li === 0 && wi === 0;
|
|
455
|
+
const seg = wrapped[wi];
|
|
456
|
+
if (li === caretLine && caretRow === -1) {
|
|
457
|
+
const isLastSeg = wi === wrapped.length - 1;
|
|
458
|
+
// The caret belongs to this row if its column falls within the row's
|
|
459
|
+
// span, or it's the last segment (end-of-line caret rests here).
|
|
460
|
+
if (caretCol <= consumed + seg.length || isLastSeg) {
|
|
461
|
+
caretRow = renderedLines.length;
|
|
462
|
+
caretRowOffset = Math.max(0, caretCol - consumed);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
448
465
|
renderedLines.push({
|
|
449
466
|
prefix: isFirstLogical ? theme.accent(PROMPT_PREFIX) : CONTINUATION_GUTTER,
|
|
450
|
-
text:
|
|
467
|
+
text: seg,
|
|
451
468
|
});
|
|
469
|
+
consumed += seg.length;
|
|
452
470
|
}
|
|
453
471
|
}
|
|
454
|
-
// Always-visible caret: an inverse-video cell drawn AT the cursor
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
// the keyboard so it can't masquerade as an active prompt.
|
|
472
|
+
// Always-visible caret: an inverse-video cell drawn AT the cursor. Mid-line,
|
|
473
|
+
// it highlights the character under the cursor (block caret); at end-of-row
|
|
474
|
+
// it appends an inverse space. The real terminal cursor is anchored to the
|
|
475
|
+
// same cell for IME pre-edit; this glyph keeps the position visible even
|
|
476
|
+
// between anchor writes (e.g. while another component renders). Hidden while
|
|
477
|
+
// a modal picker owns the keyboard so it can't masquerade as an active prompt.
|
|
461
478
|
if (!modalOpen && renderedLines.length > 0) {
|
|
462
|
-
|
|
463
|
-
|
|
479
|
+
if (caretRow === -1) caretRow = renderedLines.length - 1;
|
|
480
|
+
const row = renderedLines[caretRow];
|
|
481
|
+
const off = Math.min(caretRowOffset, row.text.length);
|
|
482
|
+
const head = row.text.slice(0, off);
|
|
483
|
+
const rest = row.text.slice(off);
|
|
484
|
+
if (rest.length > 0) {
|
|
485
|
+
// Highlight the character at the cursor (codepoint-safe slice).
|
|
486
|
+
const at = nextCodepointIndex(rest, 0);
|
|
487
|
+
row.text = `${head}\x1b[7m${rest.slice(0, at)}\x1b[27m${rest.slice(at)}`;
|
|
488
|
+
} else {
|
|
489
|
+
row.text = `${head}\x1b[7m \x1b[27m`;
|
|
490
|
+
}
|
|
464
491
|
}
|
|
465
492
|
return React.createElement(
|
|
466
493
|
Box,
|