lazyclaw 6.3.1 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +41 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +27 -3
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +68 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +29 -2
- package/providers/tool_use/openai.mjs +35 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/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
|
+
];
|
package/tui/config_picker.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const CONFIG_ITEMS = [
|
|
|
17
17
|
{ id: 'provider', label: 'provider', desc: 'switch the chat provider (family → vendor picker)' },
|
|
18
18
|
{ id: 'model', label: 'model', desc: 'switch the model (live list when the provider supports it)' },
|
|
19
19
|
{ id: 'context', label: 'context window', desc: 'history turns / token budget sent per turn' },
|
|
20
|
+
{ id: 'hud', label: 'HUD status bar', desc: 'show/hide the usage · models · cost row above the input' },
|
|
20
21
|
{ id: 'trainer', label: 'trainer', desc: 'learning-loop provider/model (auto = $0 on claude-cli)' },
|
|
21
22
|
{ id: 'orchestrator', label: 'orchestrator', desc: 'multi-agent on/off, planner, workers' },
|
|
22
23
|
{ id: 'channel', label: 'channel credentials', desc: 'Slack/Telegram/Matrix tokens — leaves chat for the prompts, then returns' },
|
package/tui/editor.mjs
CHANGED
|
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
|
+
}
|