lazyclaw 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +7 -193
  10. package/commands/agents_registry.mjs +205 -0
  11. package/commands/auth_nodes.mjs +19 -1
  12. package/commands/automation.mjs +28 -94
  13. package/commands/automation_loops.mjs +94 -0
  14. package/commands/channels.mjs +46 -3
  15. package/commands/chat.mjs +127 -704
  16. package/commands/chat_hardening.mjs +23 -0
  17. package/commands/chat_legacy_slash.mjs +716 -0
  18. package/commands/config.mjs +36 -2
  19. package/commands/daemon.mjs +99 -1
  20. package/commands/gateway.mjs +123 -4
  21. package/commands/help_text.mjs +78 -0
  22. package/commands/mcp.mjs +150 -0
  23. package/commands/misc.mjs +20 -0
  24. package/commands/sessions.mjs +68 -8
  25. package/commands/setup.mjs +44 -80
  26. package/commands/setup_channels.mjs +219 -47
  27. package/commands/skills.mjs +19 -1
  28. package/commands/workflow_named.mjs +137 -0
  29. package/config-validate.mjs +10 -1
  30. package/config_features.mjs +45 -0
  31. package/cron.mjs +19 -8
  32. package/daemon/lib/auth.mjs +25 -0
  33. package/daemon/lib/cost.mjs +41 -0
  34. package/daemon/lib/respond.mjs +20 -1
  35. package/daemon/lib/team_inbound.mjs +69 -0
  36. package/daemon/route_table.mjs +5 -1
  37. package/daemon/routes/_deps.mjs +2 -2
  38. package/daemon/routes/config.mjs +11 -6
  39. package/daemon/routes/conversation.mjs +103 -40
  40. package/daemon/routes/events.mjs +45 -0
  41. package/daemon/routes/meta.mjs +38 -7
  42. package/daemon/routes/providers.mjs +26 -6
  43. package/daemon/routes/workflows.mjs +30 -0
  44. package/daemon.mjs +27 -3
  45. package/dotenv_min.mjs +15 -3
  46. package/gateway/challenge_registry.mjs +90 -0
  47. package/gateway/device_auth.mjs +71 -97
  48. package/gateway/http_gateway.mjs +29 -3
  49. package/lib/args.mjs +69 -3
  50. package/lib/config.mjs +85 -3
  51. package/lib/render.mjs +43 -0
  52. package/lib/service_install.mjs +15 -1
  53. package/mas/agent_turn.mjs +68 -12
  54. package/mas/confidence.mjs +20 -1
  55. package/mas/embedder.mjs +100 -0
  56. package/mas/events.mjs +57 -0
  57. package/mas/index_db.mjs +103 -10
  58. package/mas/learning.mjs +96 -72
  59. package/mas/mention_router.mjs +48 -90
  60. package/mas/prompt_stack.mjs +60 -3
  61. package/mas/recall_blend.mjs +56 -0
  62. package/mas/redact.mjs +55 -0
  63. package/mas/router_posting.mjs +96 -0
  64. package/mas/router_termination.mjs +63 -0
  65. package/mas/scrub_env.mjs +21 -6
  66. package/mas/skill_synth.mjs +7 -33
  67. package/mas/tool_runner.mjs +3 -2
  68. package/mas/tools/bash.mjs +9 -2
  69. package/mas/tools/coding.mjs +28 -5
  70. package/mas/tools/delegation.mjs +34 -4
  71. package/mas/tools/git.mjs +19 -9
  72. package/mas/tools/ha.mjs +2 -0
  73. package/mas/tools/learning.mjs +55 -0
  74. package/mas/tools/media.mjs +21 -3
  75. package/mas/tools/os.mjs +70 -16
  76. package/mas/tools/recall.mjs +28 -2
  77. package/mcp/client.mjs +8 -1
  78. package/mcp/server_spawn.mjs +5 -1
  79. package/memory.mjs +24 -0
  80. package/package.json +3 -1
  81. package/providers/anthropic.mjs +101 -6
  82. package/providers/cache.mjs +9 -1
  83. package/providers/claude_cli.mjs +104 -22
  84. package/providers/claude_cli_session.mjs +183 -0
  85. package/providers/cli_error.mjs +38 -0
  86. package/providers/cli_login.mjs +179 -0
  87. package/providers/codex_cli.mjs +70 -12
  88. package/providers/gemini.mjs +104 -3
  89. package/providers/gemini_cli.mjs +78 -17
  90. package/providers/model_catalogue.mjs +33 -2
  91. package/providers/ollama.mjs +104 -3
  92. package/providers/openai.mjs +110 -8
  93. package/providers/openai_compat.mjs +97 -6
  94. package/providers/orchestrator.mjs +112 -12
  95. package/providers/registry.mjs +15 -9
  96. package/providers/retry.mjs +14 -2
  97. package/providers/tool_use/anthropic.mjs +17 -3
  98. package/providers/tool_use/claude_cli.mjs +72 -20
  99. package/providers/tool_use/gemini.mjs +29 -2
  100. package/providers/tool_use/openai.mjs +35 -5
  101. package/sandbox/confiners/seatbelt.mjs +37 -12
  102. package/sandbox/index.mjs +49 -0
  103. package/sandbox/local.mjs +7 -1
  104. package/sandbox/spawn.mjs +144 -0
  105. package/sandbox.mjs +5 -1
  106. package/scripts/loop-worker.mjs +25 -1
  107. package/sessions.mjs +0 -0
  108. package/skills/channel-style.md +20 -0
  109. package/skills/code-review.md +33 -0
  110. package/skills/commit-message.md +30 -0
  111. package/skills/concise.md +24 -0
  112. package/skills/debug-coach.md +25 -0
  113. package/skills/explain.md +24 -0
  114. package/skills/korean.md +25 -0
  115. package/skills/summarize.md +33 -0
  116. package/skills.mjs +24 -2
  117. package/skills_curator.mjs +6 -0
  118. package/skills_install.mjs +10 -2
  119. package/tasks.mjs +6 -1
  120. package/teams.mjs +78 -0
  121. package/tui/banner.mjs +72 -0
  122. package/tui/chat_mode_slash.mjs +59 -0
  123. package/tui/config_picker.mjs +1 -0
  124. package/tui/editor.mjs +0 -0
  125. package/tui/editor_anchor.mjs +46 -0
  126. package/tui/editor_keys.mjs +275 -0
  127. package/tui/hud.mjs +111 -0
  128. package/tui/login_flow.mjs +113 -0
  129. package/tui/modal_picker.mjs +10 -1
  130. package/tui/model_pick.mjs +287 -0
  131. package/tui/orchestrator_flow.mjs +164 -0
  132. package/tui/orchestrator_setup.mjs +135 -0
  133. package/tui/pickers.mjs +218 -248
  134. package/tui/repl.mjs +118 -209
  135. package/tui/repl_altbuffer.mjs +63 -0
  136. package/tui/repl_reducers.mjs +114 -0
  137. package/tui/repl_reset.mjs +37 -0
  138. package/tui/run_turn.mjs +228 -26
  139. package/tui/slash_args.mjs +159 -0
  140. package/tui/slash_channels.mjs +208 -0
  141. package/tui/slash_commands.mjs +7 -5
  142. package/tui/slash_dashboard.mjs +220 -0
  143. package/tui/slash_dispatcher.mjs +339 -774
  144. package/tui/slash_helpers.mjs +68 -0
  145. package/tui/slash_popup.mjs +5 -1
  146. package/tui/slash_trainer.mjs +173 -0
  147. package/tui/splash.mjs +15 -2
  148. package/tui/status_bar.mjs +26 -0
  149. package/tui/theme.mjs +28 -0
  150. package/web/avatars/01.png +0 -0
  151. package/web/avatars/02.png +0 -0
  152. package/web/avatars/03.png +0 -0
  153. package/web/avatars/04.png +0 -0
  154. package/web/avatars/05.png +0 -0
  155. package/web/avatars/06.png +0 -0
  156. package/web/avatars/07.png +0 -0
  157. package/web/avatars/08.png +0 -0
  158. package/web/avatars/09.png +0 -0
  159. package/web/avatars/10.png +0 -0
  160. package/web/avatars/11.png +0 -0
  161. package/web/avatars/12.png +0 -0
  162. package/web/avatars/13.png +0 -0
  163. package/web/avatars/14.png +0 -0
  164. package/web/avatars/15.png +0 -0
  165. package/web/avatars/16.png +0 -0
  166. package/web/avatars/17.png +0 -0
  167. package/web/avatars/18.png +0 -0
  168. package/web/avatars/19.png +0 -0
  169. package/web/avatars/20.png +0 -0
  170. package/web/dashboard.css +77 -0
  171. package/web/dashboard.html +29 -2
  172. package/web/dashboard.js +296 -33
  173. package/workflow/builtin_caps.mjs +94 -0
  174. package/workflow/declarative.mjs +101 -0
  175. package/workflow/named.mjs +71 -0
  176. package/workflow/named_cron.mjs +50 -0
  177. package/workflow/nodes.mjs +67 -0
  178. package/workflow/run_request.mjs +74 -0
  179. package/workflow/yaml_min.mjs +97 -0
package/teams.mjs CHANGED
@@ -98,6 +98,7 @@ export function registerTeam({ name, displayName, agents = [], lead = null, slac
98
98
  slackChannel: String(slackChannel || ''),
99
99
  };
100
100
  writeAtomic(p, data);
101
+ _invalidateTeamIndex(configDir);
101
102
  return data;
102
103
  }
103
104
 
@@ -133,6 +134,7 @@ export function patchTeam(name, patch, configDir = defaultConfigDir()) {
133
134
  if (patch.agents !== undefined) next.agents = [...new Set(patch.agents)];
134
135
  validateAgentRefs(next.agents, next.lead, configDir);
135
136
  writeAtomic(teamPath(name, configDir), next);
137
+ _invalidateTeamIndex(configDir);
136
138
  return next;
137
139
  }
138
140
 
@@ -142,6 +144,7 @@ export function removeTeam(name, configDir = defaultConfigDir()) {
142
144
  throw new TeamError(`no team "${name}"`, 'TEAM_NO_TEAM');
143
145
  }
144
146
  fs.unlinkSync(p);
147
+ _invalidateTeamIndex(configDir);
145
148
  return { name, removed: true };
146
149
  }
147
150
 
@@ -197,3 +200,78 @@ export function parseListFlag(raw) {
197
200
  if (s === '') return [];
198
201
  return s.split(',').map(x => x.trim()).filter(Boolean);
199
202
  }
203
+
204
+ // Build the team's org tree for the dashboard: a nested { agent, children[] }
205
+ // rooted at team.lead. A member nests under its `manager` when that manager is
206
+ // also a team member; otherwise (no manager, or a manager outside the team) it
207
+ // attaches directly under the lead, so the result is always a single tree.
208
+ //
209
+ // @param {{lead:string, agents:string[]}} team
210
+ // @param {Record<string, object>} agentsById name → agent record
211
+ export function teamTree(team, agentsById = {}) {
212
+ const lead = team && team.lead;
213
+ if (!lead) return null;
214
+ const memberNames = new Set((team.agents || []));
215
+ const byManager = new Map(); // manager name → child names
216
+ for (const n of memberNames) {
217
+ if (n === lead) continue;
218
+ const rec = agentsById[n];
219
+ const mgr = rec && rec.manager && memberNames.has(rec.manager) && rec.manager !== n
220
+ ? rec.manager
221
+ : lead;
222
+ if (!byManager.has(mgr)) byManager.set(mgr, []);
223
+ byManager.get(mgr).push(n);
224
+ }
225
+ const build = (name, seen) => {
226
+ if (seen.has(name)) return null; // cycle guard (defensive — register/patch reject cycles)
227
+ const next = new Set(seen).add(name);
228
+ const node = { agent: agentsById[name] || { name }, children: [] };
229
+ for (const child of (byManager.get(name) || []).sort()) {
230
+ const c = build(child, next);
231
+ if (c) node.children.push(c);
232
+ }
233
+ return node;
234
+ };
235
+ return build(lead, new Set());
236
+ }
237
+
238
+ // Find the team bound to an inbound channel id (team.slackChannel), or null.
239
+ // Used by the daemon to auto-route a Slack message to a team's multi-agent loop.
240
+ export function teamForChannel(teams, channel) {
241
+ if (!channel) return null;
242
+ const c = String(channel);
243
+ return (teams || []).find((t) => t && t.slackChannel && String(t.slackChannel) === c) || null;
244
+ }
245
+
246
+ // slackChannel→team index, keyed by configDir and the teams/ dir mtime. Every
247
+ // inbound Slack message used to re-scan the whole teams/ directory (readdir + N
248
+ // JSON.parse) just to find one channel's team; this builds the lookup once and
249
+ // reuses it until the directory changes. register/patch/removeTeam invalidate
250
+ // explicitly (deterministic), and the dir-mtime key also catches manual edits.
251
+ const _channelIndex = new Map(); // configDir → { mtimeMs, byChannel: Map<channel, team> }
252
+
253
+ export function _invalidateTeamIndex(configDir = defaultConfigDir()) {
254
+ _channelIndex.delete(configDir);
255
+ }
256
+
257
+ // O(1) channel→team lookup for the inbound hot path. Returns null when no team
258
+ // is bound to the channel (or there is no teams/ directory yet).
259
+ export function teamForChannelCached(channel, configDir = defaultConfigDir()) {
260
+ if (!channel) return null;
261
+ const dir = teamsDir(configDir);
262
+ let mtimeMs = 0;
263
+ try { mtimeMs = fs.statSync(dir).mtimeMs; }
264
+ catch { return null; } // no teams dir → no team bound to anything
265
+ const cached = _channelIndex.get(configDir);
266
+ let byChannel;
267
+ if (cached && cached.mtimeMs === mtimeMs) {
268
+ byChannel = cached.byChannel;
269
+ } else {
270
+ byChannel = new Map();
271
+ for (const t of listTeams(configDir)) {
272
+ if (t && t.slackChannel) byChannel.set(String(t.slackChannel), t);
273
+ }
274
+ _channelIndex.set(configDir, { mtimeMs, byChannel });
275
+ }
276
+ return byChannel.get(String(channel)) || null;
277
+ }
package/tui/banner.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // LazyClaw banner renderers — extracted from tui/pickers.mjs as a leaf
2
+ // module (no cross-deps). Holds the legacy figlet box (_renderBanner), the
3
+ // orange colour helper (_orange / _ORANGE_RGB), the mascot stubs, and the
4
+ // lazy-loaded v5 banner assets (_loadBannerAssets). The v5 splash composer
5
+ // (_renderV5Banner) and the chat header (_printChatBanner) stay in pickers
6
+ // and import _orange / _ORANGE_RGB / _loadBannerAssets / _renderBanner back.
7
+
8
+ // LazyClaw banner — single source of truth (chat REPL header, no-arg
9
+ // launcher, first-run welcome). Printed once so users see the active
10
+ // provider/model; plain ANSI, auto-skipped when stdout isn't a TTY.
11
+ // Returns an array of pre-formatted lines the caller can splice rows into.
12
+ //
13
+ // Layout invariants: every inner row is forced through `.padEnd(INNER_W)`
14
+ // and is exactly INNER_W single-cell glyphs, so the right border `│` always
15
+ // lands in the same column. _renderMascot / _renderMascotTiny are stubs kept
16
+ // so any leftover caller doesn't crash.
17
+
18
+ export const _ORANGE_RGB = '241;130;70'; // #F18246
19
+ export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
20
+
21
+ export function _renderMascot() {
22
+ return ['lazyclaw'];
23
+ }
24
+
25
+ export function _renderMascotTiny() {
26
+ return 'lazyclaw';
27
+ }
28
+
29
+ // figlet "standard" "lazy", trimmed of leading blank line. Each row
30
+ // is left-padded by two spaces inside the box, and every row is then
31
+ // padded to INNER_W cells.
32
+ const _LAZY_STANDARD = [
33
+ ' _ ',
34
+ '| | __ _ _____ _ ',
35
+ '| |/ _` |_ / | | | ',
36
+ '| | (_| |/ /| |_| | ',
37
+ '|_|\\__,_/___|\\__, | ',
38
+ ' |___/ ',
39
+ ];
40
+
41
+ const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
42
+
43
+ export function _renderBanner(version) {
44
+ const v = String(version || '?.?.?');
45
+ const cap = ` LazyClaw v${v}`;
46
+ const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
47
+ const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
48
+ const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
49
+ const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
50
+ return [
51
+ top,
52
+ ..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
53
+ wrap(padInner(cap)),
54
+ bot,
55
+ ];
56
+ }
57
+
58
+ // v5 hero banner assets — ANSI Shadow LAZYCLAW wordmark stacked on top of the
59
+ // braille sloth (tui/banner.generated.mjs + tui/wordmark.mjs). Lazy-loaded and
60
+ // cached so the missing-asset fallback only probes the dynamic import once.
61
+ let _bannerAssetsCache = null;
62
+ export async function _loadBannerAssets() {
63
+ if (_bannerAssetsCache !== null) return _bannerAssetsCache;
64
+ try {
65
+ const { banner } = await import('./banner.generated.mjs');
66
+ const { wordmark } = await import('./wordmark.mjs');
67
+ _bannerAssetsCache = { banner, wordmark };
68
+ } catch {
69
+ _bannerAssetsCache = null;
70
+ }
71
+ return _bannerAssetsCache;
72
+ }
@@ -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
Binary file
@@ -0,0 +1,46 @@
1
+ // tui/editor_anchor.mjs — extracted IME cursor-anchor stdout shim (v5.4.4).
2
+ // Pure leaf module: only touches process.stdout, imports nothing from
3
+ // editor.mjs, so there is no circular import. The shared anchorState object
4
+ // is a singleton — editor.mjs imports the same instance via this export.
5
+ //
6
+ // ─── IME cursor anchor (v5.4.4) ─────────────────────────────────────
7
+ //
8
+ // v5.4.3 shipped an anchor that moved the cursor inside the editor
9
+ // after every render so IME pre-edit composition appeared in the
10
+ // editor box. It also caused visible flicker because Ink's log-update
11
+ // (node_modules/ink/build/log-update.js) emits an eraseLines sequence
12
+ // (`\x1b[2K\x1b[1A...`) on every redraw — and that sequence walks UP
13
+ // from the CURRENT cursor position. With our anchor up inside the
14
+ // editor, eraseLines erased rows ABOVE the frame, then wrote the new
15
+ // frame starting one editor-height higher than the previous one.
16
+ //
17
+ // v5.4.4 fix — monkey-patch process.stdout.write the first time the
18
+ // anchor fires. When the patched writer sees a chunk that BEGINS with
19
+ // `\x1b[2K` (the start of log-update's eraseLines) AND the anchor
20
+ // offset is non-zero, it prepends `\x1b[<offset>B\r` to move the
21
+ // cursor BACK DOWN to the row log-update expects (one below the
22
+ // previous frame's last line). The user sees no flicker; IME still
23
+ // reads the editor cursor position because the anchor lives across
24
+ // the gap between renders.
25
+ export const anchorState = { offset: 0, shimmed: false };
26
+
27
+ export function installAnchorShim() {
28
+ if (anchorState.shimmed) return;
29
+ if (!(process.stdout && typeof process.stdout.write === 'function')) return;
30
+ const orig = process.stdout.write.bind(process.stdout);
31
+ process.stdout.write = function patchedWrite(chunk, ...rest) {
32
+ try {
33
+ if (
34
+ anchorState.offset > 0 &&
35
+ typeof chunk === 'string' &&
36
+ chunk.startsWith('\x1b[2K')
37
+ ) {
38
+ const off = anchorState.offset;
39
+ anchorState.offset = 0;
40
+ return orig.call(this, `\x1b[${off}B\r` + chunk, ...rest);
41
+ }
42
+ } catch { /* fall through to unmodified write */ }
43
+ return orig.call(this, chunk, ...rest);
44
+ };
45
+ anchorState.shimmed = true;
46
+ }
@@ -0,0 +1,275 @@
1
+ // tui/editor_keys.mjs — pure editor state machine + cursor-aware editing
2
+ // helpers for <Editor/> (v6.4).
3
+ //
4
+ // Extracted from tui/editor.mjs to keep that file under the 500-line gate.
5
+ // editor.mjs re-exports the public surface (makeEditorState, applyKey,
6
+ // displayWidth, cursorDisplayCol, wrapToBudget, fillSlashCommand and the
7
+ // PROMPT_* / CONTINUATION_* constants) so all existing import paths stay
8
+ // valid. These operate on the editor state's `buffer` (a string) and
9
+ // `cursor` (a UTF-16 code-unit index — the same unit `buffer.slice(0,
10
+ // cursor)` expects). Movement steps over whole codepoints so a surrogate
11
+ // pair (e.g. an emoji) is never split mid-character.
12
+ import stringWidth from 'string-width';
13
+
14
+ // v6.4 — 2-stage Ctrl+C window. A second Ctrl+C within this many ms exits;
15
+ // otherwise the first press only cancels/clears and resets the timer.
16
+ export const INTERRUPT_WINDOW_MS = 1500;
17
+
18
+ // The accent prompt (`› `) prepended to the first rendered line. Its display
19
+ // width matters for any caller that wants the usable inner width of the box.
20
+ export const PROMPT_PREFIX = '› ';
21
+ export const PROMPT_WIDTH = stringWidth(PROMPT_PREFIX);
22
+ export const CONTINUATION_GUTTER = ' ';
23
+ export const CONTINUATION_WIDTH = stringWidth(CONTINUATION_GUTTER);
24
+
25
+ // Public helper: display width of a buffer (or any substring), counting wide
26
+ // chars (CJK, fullwidth, most emoji) as 2 cells and ignoring ANSI escapes.
27
+ // Use this — never `.length` — for column math.
28
+ export function displayWidth(text) {
29
+ if (!text) return 0;
30
+ return stringWidth(String(text));
31
+ }
32
+
33
+ // Cell-aware soft-wrap. Returns an array of visual rows whose width respects
34
+ // the budget (first row uses `firstBudget`, subsequent rows use `contBudget`).
35
+ export function wrapToBudget(text, firstBudget, contBudget) {
36
+ if (!text) return [''];
37
+ const out = [];
38
+ let line = '';
39
+ let lineW = 0;
40
+ let budget = firstBudget;
41
+ for (const ch of text) {
42
+ const w = stringWidth(ch);
43
+ if (lineW + w > budget) {
44
+ out.push(line);
45
+ line = ch;
46
+ lineW = w;
47
+ budget = contBudget;
48
+ } else {
49
+ line += ch;
50
+ lineW += w;
51
+ }
52
+ }
53
+ out.push(line);
54
+ return out;
55
+ }
56
+
57
+ // Display column of the caret given a state. Counts wide chars as 2. On the
58
+ // first rendered line this is offset by PROMPT_WIDTH; on continuation lines
59
+ // (after a Shift+Enter) it is offset by CONTINUATION_WIDTH. Callers that only
60
+ // need the in-buffer column can pass `{ withPrefix: false }`.
61
+ export function cursorDisplayCol(state, { withPrefix = true } = {}) {
62
+ const before = String(state.buffer || '').slice(0, state.cursor || 0);
63
+ const newlineIdx = before.lastIndexOf('\n');
64
+ const lineSlice = newlineIdx === -1 ? before : before.slice(newlineIdx + 1);
65
+ const inLine = stringWidth(lineSlice);
66
+ if (!withPrefix) return inLine;
67
+ const prefix = newlineIdx === -1 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
68
+ return prefix + inLine;
69
+ }
70
+
71
+ export function makeEditorState({ history = [] } = {}) {
72
+ return {
73
+ buffer: '',
74
+ cursor: 0,
75
+ historyIdx: history.length,
76
+ history,
77
+ lastSubmit: null,
78
+ lastWasPaste: false,
79
+ };
80
+ }
81
+
82
+ // Pure reducer: apply one key event to the editor state, returning the next
83
+ // state. Cursor-aware (v6.4) — insert/Backspace act AT the cursor, with
84
+ // Left/Right/Home/End/Ctrl+A/E/K/W movement & edit shortcuts.
85
+ export function applyKey(state, evt) {
86
+ const { input = '', key = {}, paste = false } = evt;
87
+ const next = { ...state, lastSubmit: null, lastWasPaste: false };
88
+
89
+ if (key.return && key.shift) {
90
+ // Insert a literal newline AT the cursor (cursor-aware multiline edit).
91
+ const r = insertAt(state.buffer, state.cursor, '\n');
92
+ next.buffer = r.buffer;
93
+ next.cursor = r.cursor;
94
+ return next;
95
+ }
96
+ if (key.return) {
97
+ next.lastSubmit = state.buffer;
98
+ next.buffer = '';
99
+ next.cursor = 0;
100
+ next.historyIdx = state.history.length;
101
+ return next;
102
+ }
103
+ if (key.upArrow) {
104
+ const idx = Math.max(0, state.historyIdx - 1);
105
+ if (state.history[idx] !== undefined) {
106
+ next.historyIdx = idx;
107
+ next.buffer = state.history[idx];
108
+ next.cursor = next.buffer.length;
109
+ }
110
+ return next;
111
+ }
112
+ if (key.downArrow) {
113
+ const idx = Math.min(state.history.length, state.historyIdx + 1);
114
+ next.historyIdx = idx;
115
+ next.buffer = state.history[idx] !== undefined ? state.history[idx] : '';
116
+ next.cursor = next.buffer.length;
117
+ return next;
118
+ }
119
+ // ─── Mid-line cursor movement (v6.4) ──────────────────────────────────
120
+ if (key.leftArrow) {
121
+ next.cursor = prevCodepointIndex(state.buffer, state.cursor);
122
+ return next;
123
+ }
124
+ if (key.rightArrow) {
125
+ next.cursor = nextCodepointIndex(state.buffer, state.cursor);
126
+ return next;
127
+ }
128
+ // Home / Ctrl+A → start of the current line; End / Ctrl+E → end of it.
129
+ if (key.home || (key.ctrl && (input === 'a' || input === 'A'))) {
130
+ next.cursor = lineStartIndex(state.buffer, state.cursor);
131
+ return next;
132
+ }
133
+ if (key.end || (key.ctrl && (input === 'e' || input === 'E'))) {
134
+ next.cursor = lineEndIndex(state.buffer, state.cursor);
135
+ return next;
136
+ }
137
+ // Ctrl+K kill-to-end-of-line; Ctrl+W delete-word-backward.
138
+ if (key.ctrl && (input === 'k' || input === 'K')) {
139
+ const r = killToLineEnd(state.buffer, state.cursor);
140
+ next.buffer = r.buffer;
141
+ next.cursor = r.cursor;
142
+ return next;
143
+ }
144
+ if (key.ctrl && (input === 'w' || input === 'W')) {
145
+ const r = deleteWordBackward(state.buffer, state.cursor);
146
+ next.buffer = r.buffer;
147
+ next.cursor = r.cursor;
148
+ return next;
149
+ }
150
+ if (key.backspace || key.delete) {
151
+ // Delete the codepoint BEFORE the cursor (not always the end-of-buffer).
152
+ const r = deleteBackward(state.buffer, state.cursor);
153
+ next.buffer = r.buffer;
154
+ next.cursor = r.cursor;
155
+ return next;
156
+ }
157
+ if (input) {
158
+ // Other Ctrl/Meta chords are not insertable text — swallow so they
159
+ // don't land as literal letters in the buffer.
160
+ if (key.ctrl || key.meta) return next;
161
+ // Insert AT the cursor (was: always append at end).
162
+ const r = insertAt(state.buffer, state.cursor, input);
163
+ next.buffer = r.buffer;
164
+ next.cursor = r.cursor;
165
+ next.lastWasPaste = paste || input.length >= 16;
166
+ return next;
167
+ }
168
+ return next;
169
+ }
170
+
171
+ // Pure helper used by the slash-popup branch in <Editor/>. Replaces the
172
+ // editor buffer with `${cmd} ` (trailing space so the user can keep typing
173
+ // args without an extra keystroke). Does NOT submit.
174
+ export function fillSlashCommand(state, cmd) {
175
+ const filled = cmd.endsWith(' ') ? cmd : cmd + ' ';
176
+ return {
177
+ ...state,
178
+ buffer: filled,
179
+ cursor: filled.length,
180
+ lastSubmit: null,
181
+ lastWasPaste: false,
182
+ };
183
+ }
184
+
185
+ // Replace the whitespace-delimited token that ENDS the buffer with `value`.
186
+ // Used by slash-argument completion: the user types `/model gpt`, picks
187
+ // `gpt-4.1` from the modal, and the partial token is swapped in place. Does NOT
188
+ // submit. Leaves the cursor at the end of the inserted value. When the buffer
189
+ // ends in a space (empty arg token), `value` is appended.
190
+ export function fillArgToken(state, value) {
191
+ const buffer = state.buffer || '';
192
+ // Replace the token after the last separator. Comma is a separator too so
193
+ // comma-lists (`/skill a,b`) complete the trailing segment in place.
194
+ const start = Math.max(buffer.lastIndexOf(' '), buffer.lastIndexOf(',')) + 1;
195
+ const filled = buffer.slice(0, start) + value;
196
+ return {
197
+ ...state,
198
+ buffer: filled,
199
+ cursor: filled.length,
200
+ lastSubmit: null,
201
+ lastWasPaste: false,
202
+ };
203
+ }
204
+
205
+ // Step one codepoint LEFT of `idx` in `buffer` (UTF-16 aware). Returns the
206
+ // new index (>= 0). If the char before idx is a low surrogate, skip both
207
+ // units so we land before the full astral codepoint.
208
+ export function prevCodepointIndex(buffer, idx) {
209
+ if (idx <= 0) return 0;
210
+ const code = buffer.charCodeAt(idx - 1);
211
+ // Low surrogate (0xDC00–0xDFFF) preceded by a high surrogate → 2 units.
212
+ if (code >= 0xdc00 && code <= 0xdfff && idx >= 2) {
213
+ const hi = buffer.charCodeAt(idx - 2);
214
+ if (hi >= 0xd800 && hi <= 0xdbff) return idx - 2;
215
+ }
216
+ return idx - 1;
217
+ }
218
+
219
+ // Step one codepoint RIGHT of `idx` in `buffer`. Returns the new index
220
+ // (<= buffer.length). High surrogate at idx → consume both units.
221
+ export function nextCodepointIndex(buffer, idx) {
222
+ const len = buffer.length;
223
+ if (idx >= len) return len;
224
+ const code = buffer.charCodeAt(idx);
225
+ if (code >= 0xd800 && code <= 0xdbff && idx + 1 < len) {
226
+ const lo = buffer.charCodeAt(idx + 1);
227
+ if (lo >= 0xdc00 && lo <= 0xdfff) return idx + 2;
228
+ }
229
+ return idx + 1;
230
+ }
231
+
232
+ // Index of the start of the current logical line (after the last '\n' at or
233
+ // before `idx`). Home / Ctrl+A target.
234
+ export function lineStartIndex(buffer, idx) {
235
+ const nl = buffer.lastIndexOf('\n', Math.max(0, idx - 1));
236
+ return nl === -1 ? 0 : nl + 1;
237
+ }
238
+
239
+ // Index of the end of the current logical line (the next '\n' at or after
240
+ // `idx`, or end of buffer). End / Ctrl+E target.
241
+ export function lineEndIndex(buffer, idx) {
242
+ const nl = buffer.indexOf('\n', idx);
243
+ return nl === -1 ? buffer.length : nl;
244
+ }
245
+
246
+ // Insert `text` at `cursor`, returning { buffer, cursor }. Cursor advances by
247
+ // the inserted text's code-unit length so it rests just after the insertion.
248
+ export function insertAt(buffer, cursor, text) {
249
+ const before = buffer.slice(0, cursor);
250
+ const after = buffer.slice(cursor);
251
+ return { buffer: before + text + after, cursor: cursor + text.length };
252
+ }
253
+
254
+ // Backspace: delete one codepoint BEFORE the cursor (no-op at column 0).
255
+ export function deleteBackward(buffer, cursor) {
256
+ if (cursor <= 0) return { buffer, cursor };
257
+ const start = prevCodepointIndex(buffer, cursor);
258
+ return { buffer: buffer.slice(0, start) + buffer.slice(cursor), cursor: start };
259
+ }
260
+
261
+ // Ctrl+K: kill from the cursor to the end of the current logical line.
262
+ export function killToLineEnd(buffer, cursor) {
263
+ const end = lineEndIndex(buffer, cursor);
264
+ return { buffer: buffer.slice(0, cursor) + buffer.slice(end), cursor };
265
+ }
266
+
267
+ // Ctrl+W: delete the whitespace-delimited word before the cursor. Eats any
268
+ // run of trailing spaces first (so "foo " + Ctrl+W removes "foo "), matching
269
+ // readline's unix-word-rubout.
270
+ export function deleteWordBackward(buffer, cursor) {
271
+ let i = cursor;
272
+ while (i > 0 && /\s/.test(buffer[i - 1])) i -= 1; // trailing whitespace
273
+ while (i > 0 && !/\s/.test(buffer[i - 1])) i -= 1; // the word itself
274
+ return { buffer: buffer.slice(0, i) + buffer.slice(cursor), cursor: i };
275
+ }
package/tui/hud.mjs ADDED
@@ -0,0 +1,111 @@
1
+ // tui/hud.mjs — claude-hud-style status row. The compact status bar shows
2
+ // provider · model · ctx; with the HUD enabled (cfg.chat.hud, default on) a
3
+ // second line adds real-time usage, session cost, the trainer model, and the
4
+ // orchestrator shape. Toggle from /config → "HUD status bar" or the /hud slash.
5
+ //
6
+ // Kept out of repl.mjs/chat.mjs (both at the file-size ratchet) so the gauge
7
+ // can grow without those files growing.
8
+
9
+ import { costFromUsage } from '../providers/rates.mjs';
10
+ import { resolveTrainer } from '../providers/registry.mjs';
11
+ import chalk from 'chalk';
12
+
13
+ // HUD on unless explicitly disabled — new users see the richer bar by default.
14
+ export function hudEnabled(cfg) {
15
+ return !cfg || !cfg.chat || cfg.chat.hud !== false;
16
+ }
17
+
18
+ // Build the HUD field bundle from live chat state, or null when disabled.
19
+ // `usage` is the running session usage (_inkRunningUsage / runningUsage).
20
+ export function hudStatus(cfg, usage) {
21
+ if (!hudEnabled(cfg)) return null;
22
+ const u = usage || {};
23
+ const inTok = Number(u.inputTokens) || 0;
24
+ const outTok = Number(u.outputTokens) || 0;
25
+ // Session cost: a provider-reported total (claude-cli) or rate-card math.
26
+ let costUsd = 0;
27
+ try {
28
+ const c = costFromUsage({ provider: cfg && cfg.provider, model: cfg && cfg.model, usage: u }, cfg && cfg.rates);
29
+ costUsd = (c && Number(c.cost)) || 0;
30
+ } catch (_) { /* no rate card → no cost segment */ }
31
+ // Trainer (learning-loop) model — "auto"/omitted mirrors the chat provider.
32
+ let trainer = '';
33
+ try {
34
+ const t = resolveTrainer(cfg || {});
35
+ if (t && t.provider) trainer = t.model ? `${t.provider}:${t.model}` : t.provider;
36
+ } catch (_) { /* ignore */ }
37
+ // Orchestrator shape, only when it's the active provider.
38
+ let orch = '';
39
+ if (cfg && cfg.provider === 'orchestrator' && cfg.orchestrator) {
40
+ const o = cfg.orchestrator;
41
+ const w = Array.isArray(o.workers) ? o.workers.length : 0;
42
+ orch = `${o.planner || '?'} +${w}w`;
43
+ }
44
+ return { inTok, outTok, costUsd, trainer, orch };
45
+ }
46
+
47
+ const fmtTok = (n) => (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n | 0));
48
+
49
+ // Context gauge: compact counts + percent + a tiny inline bar, with a warn/
50
+ // danger marker once the window fills up. Pure (used, budget) → string so the
51
+ // status bar and tests can share it. chalk is level-gated (0 under NO_COLOR /
52
+ // non-TTY), so colors degrade to plain text automatically; the warn/danger
53
+ // markers are also plain glyphs so they survive even with color off.
54
+ const GAUGE_CELLS = 8;
55
+ const GAUGE_FILLED = '▰';
56
+ const GAUGE_EMPTY = '▱';
57
+ export function formatGauge(used, budget) {
58
+ const u = Number(used);
59
+ const b = Number(budget);
60
+ if (!Number.isFinite(u) || !Number.isFinite(b) || b <= 0) return '--';
61
+ const pct = (u / b) * 100;
62
+ const filled = Math.min(GAUGE_CELLS, Math.max(0, Math.round((pct / 100) * GAUGE_CELLS)));
63
+ const bar = GAUGE_FILLED.repeat(filled) + GAUGE_EMPTY.repeat(GAUGE_CELLS - filled);
64
+ const body = `${fmtTok(u)}/${fmtTok(b)} ${Math.round(pct)}% ${bar}`;
65
+ // >=95% danger, >=80% warn — prefix a plain marker so it's legible without
66
+ // color, then tint the whole gauge so it stands out at a glance.
67
+ if (pct >= 95) return chalk.red(`! ${body}`);
68
+ if (pct >= 80) return chalk.yellow(`⚠ ${body}`);
69
+ return body;
70
+ }
71
+
72
+ // Render the HUD line (the extra row below the compact status line). Returns
73
+ // '' when there's nothing worth showing.
74
+ export function formatHudRow(f) {
75
+ if (!f) return '';
76
+ const seg = [`↑${fmtTok(f.inTok)} ↓${fmtTok(f.outTok)} tok`];
77
+ if (f.costUsd > 0) seg.push(`$${f.costUsd.toFixed(4)}`);
78
+ if (f.trainer) seg.push(`trainer ${f.trainer}`);
79
+ if (f.orch) seg.push(`orch ${f.orch}`);
80
+ return seg.join(' ');
81
+ }
82
+
83
+ // `/hud [on|off]` — toggle the HUD row. /config delegates here. No arg opens an
84
+ // on/off picker in the Ink UI, or flips the current value on the legacy path.
85
+ export async function hudSlash(args, ctx) {
86
+ const cfg = ctx.readConfig ? ctx.readConfig() : (ctx.cfg || {});
87
+ const cur = hudEnabled(cfg);
88
+ const a = String(args || '').trim().toLowerCase();
89
+ let next;
90
+ if (a === 'on') next = true;
91
+ else if (a === 'off') next = false;
92
+ else if (!a && typeof ctx.openPicker === 'function') {
93
+ const picked = await ctx.openPicker({
94
+ kind: 'menu',
95
+ title: 'HUD status bar',
96
+ subtitle: `currently ${cur ? 'on' : 'off'}`,
97
+ items: [
98
+ { id: 'on', label: 'on', desc: 'usage / cost / trainer / orchestrator row' },
99
+ { id: 'off', label: 'off', desc: 'compact bar (provider · model · ctx)' },
100
+ ],
101
+ });
102
+ const id = picked && typeof picked === 'object' ? picked.id : picked;
103
+ if (id !== 'on' && id !== 'off') return 'hud: cancelled';
104
+ next = id === 'on';
105
+ } else next = !cur;
106
+ cfg.chat = (cfg.chat && typeof cfg.chat === 'object') ? cfg.chat : {};
107
+ cfg.chat.hud = next;
108
+ if (ctx.writeConfig) ctx.writeConfig(cfg);
109
+ if (ctx.cfg) ctx.cfg.chat = cfg.chat; // mirror so getStatus sees it live
110
+ return `HUD ${next ? 'on' : 'off'}`;
111
+ }