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,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
+ }
@@ -0,0 +1,113 @@
1
+ // tui/login_flow.mjs — interactive "connect / log in" flow for the keyless
2
+ // CLI providers, factored out of slash_dispatcher.mjs so the dispatcher stays
3
+ // small. Drives the inline menu (browser OAuth / paste API key / install) that
4
+ // /provider and /login surface when codex-cli or gemini-cli is picked but not
5
+ // signed in. The actual subprocess (codex login, gemini, npm i -g) runs from
6
+ // the chat post-loop guard via providers/cli_login.mjs once Ink releases stdin;
7
+ // here we only decide what to queue and persist the chosen provider.
8
+
9
+ import { CLI_LOGIN_PROVIDERS, cliLoginStatus } from '../providers/cli_login.mjs';
10
+ import { setAuthKey } from '../providers/auth_store.mjs';
11
+
12
+ // Persist the chosen provider so the chat re-entered after a foreground login
13
+ // comes back on it. Best-effort; the in-memory switch already happened.
14
+ function _persistProvider(ctx, provName) {
15
+ try {
16
+ if (typeof ctx.readConfig === 'function' && typeof ctx.writeConfig === 'function') {
17
+ const c = ctx.readConfig();
18
+ c.provider = provName;
19
+ ctx.writeConfig(c);
20
+ if (ctx.cfg) ctx.cfg.provider = provName;
21
+ }
22
+ } catch (_) { /* best-effort */ }
23
+ }
24
+
25
+ // Detect connection state for a keyless CLI provider and, when not connected,
26
+ // offer an inline action. Returns:
27
+ // null already connected / not a CLI-login provider / can't prompt
28
+ // { exit:true } a foreground action was queued (ctx.requestLogin set)
29
+ // { exit:false, msg } handled inline (key saved / skipped / cancelled)
30
+ export async function maybeLoginForCli(ctx, provName, { promptText, statusDeps } = {}) {
31
+ const spec = CLI_LOGIN_PROVIDERS[provName];
32
+ if (!spec) return null;
33
+ if (typeof ctx.openPicker !== 'function') return null;
34
+ const hasStoredKey = typeof ctx.resolveAuthKey === 'function' && !!ctx.resolveAuthKey(provName);
35
+ let status;
36
+ try {
37
+ status = await cliLoginStatus(provName, { hasStoredKey, ...(statusDeps || {}) });
38
+ } catch (_) {
39
+ return null; // detection failure must not block the provider switch
40
+ }
41
+ if (!status.supported || status.loggedIn) return null;
42
+
43
+ const items = [];
44
+ if (status.binMissing) {
45
+ items.push({ id: 'install', label: `▶ install ${spec.pkg}`, desc: `runs: npm i -g ${spec.pkg} (then sign in)` });
46
+ } else {
47
+ items.push({ id: 'browser', label: '▶ log in via browser', desc: `runs: ${spec.browserHint}` });
48
+ }
49
+ items.push({ id: 'apikey', label: 'paste an API key instead', desc: spec.apiKeyHint });
50
+ items.push({ id: 'skip', label: 'skip for now', desc: `connect later via /login ${provName}` });
51
+
52
+ const picked = await ctx.openPicker({
53
+ kind: 'menu',
54
+ title: `${provName} — not connected`,
55
+ subtitle: status.binMissing ? `the \`${spec.bin}\` CLI is not installed` : `\`${spec.bin}\` is installed but not signed in`,
56
+ items,
57
+ });
58
+ const id = picked && typeof picked === 'object' ? picked.id : picked;
59
+ if (!id || id === 'skip') {
60
+ return { exit: false, msg: `provider → ${provName} (not connected — run /login ${provName} when ready)` };
61
+ }
62
+ if (id === 'browser' || id === 'install') {
63
+ ctx.requestLogin = { provider: provName, mode: id };
64
+ return { exit: true };
65
+ }
66
+ // id === 'apikey'
67
+ const key = typeof promptText === 'function'
68
+ ? await promptText(ctx, { title: `${provName} — API key`, subtitle: spec.apiKeyHint })
69
+ : null;
70
+ if (!key) return { exit: false, msg: 'cancelled' };
71
+ if (provName === 'codex-cli') {
72
+ // codex persists the key itself via `codex login --with-api-key` (stdin),
73
+ // which needs a subprocess — defer to the foreground guard.
74
+ ctx.requestLogin = { provider: provName, mode: 'apikey', apiKey: key };
75
+ return { exit: true };
76
+ }
77
+ // gemini: save the key in lazyclaw; the provider injects it as GEMINI_API_KEY.
78
+ if (typeof ctx.readConfig === 'function' && typeof ctx.writeConfig === 'function') {
79
+ const next = setAuthKey({ readConfig: ctx.readConfig, writeConfig: ctx.writeConfig, provider: provName, key });
80
+ if (ctx.cfg && next) { ctx.cfg.authProfiles = next.authProfiles; ctx.cfg.authActiveProfile = next.authActiveProfile; }
81
+ }
82
+ return { exit: false, msg: `✓ ${provName} API key saved — provider → ${provName}` };
83
+ }
84
+
85
+ // Called from /provider after a keyless CLI provider is selected. Returns a
86
+ // string to surface (or 'EXIT' to hand off to the foreground login), or null
87
+ // to let /provider print its default "provider → x" line.
88
+ export async function runProviderLogin(ctx, provName, deps = {}) {
89
+ const login = await maybeLoginForCli(ctx, provName, deps);
90
+ if (login && login.exit) { _persistProvider(ctx, provName); return 'EXIT'; }
91
+ return (login && login.msg) ? login.msg : null;
92
+ }
93
+
94
+ // `/login [provider]` — connect a keyless CLI provider without leaving chat.
95
+ export async function loginSlash(args, ctx, deps = {}) {
96
+ const provName = (args && args.trim()) || (ctx.getActiveProvName ? ctx.getActiveProvName() : '');
97
+ const spec = CLI_LOGIN_PROVIDERS[provName];
98
+ if (!spec) {
99
+ const known = Object.keys(CLI_LOGIN_PROVIDERS).join(' · ');
100
+ return `/login is for the keyless CLI providers (${known}). Current: ${provName || '(none)'}.`;
101
+ }
102
+ if (typeof ctx.openPicker !== 'function') {
103
+ return `/login needs the interactive UI. To connect ${provName}, run \`${spec.browserHint}\` in a shell, or set an API key.`;
104
+ }
105
+ const login = await maybeLoginForCli(ctx, provName, deps);
106
+ if (login && login.exit) {
107
+ _persistProvider(ctx, provName);
108
+ if (ctx.setActiveProvName) ctx.setActiveProvName(provName);
109
+ return 'EXIT';
110
+ }
111
+ if (login && login.msg) return login.msg;
112
+ return `${provName} is already connected.`;
113
+ }
@@ -33,6 +33,10 @@ const DEFAULT_MAX_ROWS = 12;
33
33
  // selectedIndex:number — host-owned selection cursor (in filtered list)
34
34
  // query:string — host-owned filter buffer
35
35
  // searchable?:boolean — show the filter row (default true)
36
+ // secret?:boolean — mask the typed query (api-key / token entry) so it
37
+ // isn't echoed to scrollback / screen-share. The host
38
+ // still owns + submits the real buffer; this only
39
+ // changes how it is drawn.
36
40
  // maxRows?:number — viewport height (default 12)
37
41
  // toast?:string — transient one-line message below the list
38
42
  // columns?:number — terminal width override (tests)
@@ -43,6 +47,7 @@ export function ModalPicker({
43
47
  selectedIndex,
44
48
  query,
45
49
  searchable = true,
50
+ secret = false,
46
51
  maxRows = DEFAULT_MAX_ROWS,
47
52
  toast,
48
53
  columns,
@@ -74,7 +79,11 @@ export function ModalPicker({
74
79
  }
75
80
  // Filter row.
76
81
  if (searchable) {
77
- const q = String(query || '');
82
+ const raw = String(query || '');
83
+ // For a secret field, never echo the characters — render one bullet per
84
+ // *visible* glyph (so CJK width stays correct) while the host keeps the
85
+ // real buffer for submission.
86
+ const q = secret ? '•'.repeat(stringWidth(raw)) : raw;
78
87
  const tail = total > 0 ? `${total} match${total === 1 ? '' : 'es'}` : 'no matches';
79
88
  rows.push(React.createElement(
80
89
  Text, { key: 'filter' },