lazyclaw 6.3.0 → 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.
Files changed (165) 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 +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. 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
+ ];
@@ -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
- // The accent prompt (`› `) prepended to the first rendered line. Its
37
- // display width matters for any caller that wants to know the usable
38
- // inner width of the editor box. Defined once so the value stays in
39
- // sync if the prompt glyph ever changes.
40
- export const PROMPT_PREFIX = '› ';
41
- export const PROMPT_WIDTH = stringWidth(PROMPT_PREFIX);
42
- export const CONTINUATION_GUTTER = ' ';
43
- export const CONTINUATION_WIDTH = stringWidth(CONTINUATION_GUTTER);
44
-
45
- // Public helper: display width of a buffer (or any substring of it),
46
- // counting wide chars (CJK, fullwidth, most emoji) as 2 cells and
47
- // ignoring ANSI escapes. Use this — never `.length` — for column math.
48
- export function displayWidth(text) {
49
- if (!text) return 0;
50
- return stringWidth(String(text));
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
- const bufTrim = current.buffer.replace(/\s+$/, '');
317
- const alreadyExact = !!picked && (current.buffer === picked.cmd || bufTrim === picked.cmd);
318
- if (alreadyExact) {
319
- if (key.tab) return; // no completion to make
320
- // key.return on exact match fall through to applyKey/submit.
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
- if (picked) {
323
- const next = fillSlashCommand(current, picked.cmd);
324
- commit(next);
325
- if (onBufferChange) {
326
- try { onBufferChange(next.buffer); } catch {}
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 = stringWidth(lastSegment);
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: wrapped[wi],
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 (the
455
- // editor has no mid-line cursor movement, so the cursor is always at the
456
- // end of the buffer including column 0 of an empty box). The real
457
- // terminal cursor is anchored to the same cell for IME pre-edit; this
458
- // glyph keeps the position visible even between anchor writes (e.g.
459
- // while another component renders). Hidden while a modal picker owns
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
- const last = renderedLines[renderedLines.length - 1];
463
- last.text = `${last.text}\x1b[7m \x1b[27m`;
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,