lazyclaw 5.1.0 → 5.3.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/agents.mjs +10 -8
- package/chat_window.mjs +39 -0
- package/cli.mjs +280 -55
- package/daemon.mjs +87 -7
- package/mas/agent_turn.mjs +81 -6
- package/mas/index_db.mjs +62 -2
- package/mas/learning.mjs +371 -0
- package/mas/mention_router.mjs +94 -13
- package/mas/orchestra.mjs +56 -0
- package/mas/skill_synth.mjs +36 -2
- package/mas/tool_runner.mjs +8 -1
- package/mas/tools/git.mjs +18 -1
- package/mas/tools/recall.mjs +64 -0
- package/package.json +2 -1
- package/providers/anthropic.mjs +26 -10
- package/providers/orchestrator.mjs +99 -21
- package/providers/registry.mjs +6 -0
- package/providers/tool_use/anthropic.mjs +43 -7
- package/skills.mjs +42 -3
- package/tasks.mjs +24 -1
- package/tui/editor.mjs +81 -1
- package/tui/repl.mjs +259 -22
- package/tui/run_turn.mjs +138 -0
- package/tui/slash_commands.mjs +37 -0
- package/tui/slash_popup.mjs +157 -0
- package/tui/splash.mjs +65 -43
- package/web/dashboard.html +1 -1
package/tui/run_turn.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// tui/run_turn.mjs — shared chat-turn streaming closure (v5 Group C, C7).
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for the streaming + persist + post-task
|
|
4
|
+
// learning loop. The same factory backs:
|
|
5
|
+
// - the ink REPL path (ReplApp.runTurn)
|
|
6
|
+
// - the legacy readline path (cli.mjs's `for await (const line of rl)` body)
|
|
7
|
+
//
|
|
8
|
+
// One factory ⇒ one set of bugs. Both call sites get the same buffered
|
|
9
|
+
// writer (CJK-safe 30 ms coalescing), the same persistTurn dual-write
|
|
10
|
+
// (sessions + memory/recent), and the same fire-and-forget learning
|
|
11
|
+
// hook (queueMicrotask).
|
|
12
|
+
//
|
|
13
|
+
// `ctx` carries the chat-session state. Getter functions close over
|
|
14
|
+
// *current* bindings so a mid-session /provider switch takes effect on
|
|
15
|
+
// the very next turn without re-creating the closure.
|
|
16
|
+
//
|
|
17
|
+
// `writeFn` is the sink for streamed chunks — the legacy path passes
|
|
18
|
+
// `(s) => process.stdout.write(s)`; the ink path also writes to stdout
|
|
19
|
+
// for v5.0.10 (interleaves with Ink output; visual jank accepted in
|
|
20
|
+
// exchange for unblocking the chat loop). v5.1 TODO: route the ink
|
|
21
|
+
// writeFn through a scrollback ref in ReplApp.
|
|
22
|
+
//
|
|
23
|
+
// Errors are swallowed (matches v4 legacy behavior) — we write
|
|
24
|
+
// "error: ..." into writeFn but do not re-throw, so the caller's
|
|
25
|
+
// turn-completion logic (ReplApp onTurnComplete, legacy `rl.prompt`)
|
|
26
|
+
// runs unconditionally.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} RunTurnCtx
|
|
30
|
+
* @property {Object} cfg Active config.
|
|
31
|
+
* @property {string} cfgDir Resolved configDir.
|
|
32
|
+
* @property {Object|null} sandboxSpec Parsed --sandbox spec (or null).
|
|
33
|
+
* @property {string} syntheticChatSessionId Session-id fallback when --session not set.
|
|
34
|
+
* @property {() => Array<{role:string,content:string}>} getMessages
|
|
35
|
+
* @property {() => { sendMessage: Function, name?: string }} getProv
|
|
36
|
+
* @property {() => string} getActiveProvName
|
|
37
|
+
* @property {() => string|null} getActiveModel
|
|
38
|
+
* @property {() => string|null} getSessionId
|
|
39
|
+
* @property {(role: string, content: string) => void} persistTurn
|
|
40
|
+
* @property {(u: any) => void} accumulateUsage
|
|
41
|
+
* @property {(provider: string) => string} resolveAuthKey Caller supplies; mirrors cli.mjs::_resolveAuthKey.
|
|
42
|
+
* @property {(n: number) => void} [onCharsSent] Optional observer (legacy path increments charsSent).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a runTurn(text, signal) closure that drives one provider turn
|
|
47
|
+
* end-to-end.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ ctx: RunTurnCtx, writeFn: (chunk: string) => void }} args
|
|
50
|
+
* @returns {(text: string, signal?: AbortSignal) => Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
export function makeRunTurn({ ctx, writeFn }) {
|
|
53
|
+
return async function runTurn(text, signal) {
|
|
54
|
+
if (signal?.aborted) return;
|
|
55
|
+
const messages = ctx.getMessages();
|
|
56
|
+
messages.push({ role: 'user', content: text });
|
|
57
|
+
try { ctx.onCharsSent && ctx.onCharsSent(text.length); }
|
|
58
|
+
catch { /* observer is best-effort */ }
|
|
59
|
+
ctx.persistTurn('user', text);
|
|
60
|
+
|
|
61
|
+
let acc = '';
|
|
62
|
+
let _writeBuf = '';
|
|
63
|
+
let _writeTimer = null;
|
|
64
|
+
const _flush = () => {
|
|
65
|
+
if (_writeBuf) {
|
|
66
|
+
try { writeFn(_writeBuf); }
|
|
67
|
+
catch { /* sink failure must not kill the turn */ }
|
|
68
|
+
_writeBuf = '';
|
|
69
|
+
}
|
|
70
|
+
_writeTimer = null;
|
|
71
|
+
};
|
|
72
|
+
const _writeChunk = (s) => {
|
|
73
|
+
_writeBuf += s;
|
|
74
|
+
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const sysMsg = messages.find((m) => m.role === 'system');
|
|
79
|
+
const prov = ctx.getProv();
|
|
80
|
+
const activeProvName = ctx.getActiveProvName();
|
|
81
|
+
const activeModel = ctx.getActiveModel();
|
|
82
|
+
// C8 — prompt-cache the static system prefix. The Anthropic
|
|
83
|
+
// provider prefers `systemStatic` when present; non-Anthropic
|
|
84
|
+
// providers ignore the field and fall back to the legacy
|
|
85
|
+
// single-block path with `cache:true`.
|
|
86
|
+
for await (const chunk of prov.sendMessage(messages, {
|
|
87
|
+
apiKey: ctx.resolveAuthKey(activeProvName),
|
|
88
|
+
model: activeModel,
|
|
89
|
+
sandbox: ctx.sandboxSpec,
|
|
90
|
+
signal,
|
|
91
|
+
onUsage: ctx.accumulateUsage,
|
|
92
|
+
cache: true,
|
|
93
|
+
...(sysMsg ? { systemStatic: sysMsg.content } : {}),
|
|
94
|
+
})) {
|
|
95
|
+
_writeChunk(chunk);
|
|
96
|
+
acc += chunk;
|
|
97
|
+
}
|
|
98
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
99
|
+
_flush();
|
|
100
|
+
try { writeFn('\n'); }
|
|
101
|
+
catch { /* sink failure must not kill the turn */ }
|
|
102
|
+
messages.push({ role: 'assistant', content: acc });
|
|
103
|
+
ctx.persistTurn('assistant', acc);
|
|
104
|
+
// v5 Group A (C1): close the post-task learning loop on every
|
|
105
|
+
// successful chat turn. Fire-and-forget via queueMicrotask so the
|
|
106
|
+
// next prompt is never blocked on trajectory write / synth.
|
|
107
|
+
try {
|
|
108
|
+
queueMicrotask(() => {
|
|
109
|
+
import('../mas/learning.mjs').then((mod) => mod.runLearning('post-task', {
|
|
110
|
+
agent: { name: 'chat', provider: activeProvName, model: activeModel, role: '' },
|
|
111
|
+
task: {
|
|
112
|
+
id: ctx.getSessionId() || ctx.syntheticChatSessionId,
|
|
113
|
+
title: '(chat turn)',
|
|
114
|
+
turns: messages.slice(-2).map((m) => ({
|
|
115
|
+
agent: m.role === 'user' ? 'user' : 'chat',
|
|
116
|
+
text: m.content,
|
|
117
|
+
ts: new Date().toISOString(),
|
|
118
|
+
})),
|
|
119
|
+
},
|
|
120
|
+
configDir: ctx.cfgDir,
|
|
121
|
+
cfg: ctx.cfg,
|
|
122
|
+
transcript: acc.slice(0, 8000),
|
|
123
|
+
})).catch(() => { /* learning loop is best-effort */ });
|
|
124
|
+
});
|
|
125
|
+
} catch { /* never let learning hook break the chat */ }
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
128
|
+
_flush();
|
|
129
|
+
// ABORT errors are user-initiated; drop the partial reply (don't
|
|
130
|
+
// push an incomplete assistant message — next turn would treat
|
|
131
|
+
// it as a complete reply and confuse the model).
|
|
132
|
+
if (err?.code !== 'ABORT' && !signal?.aborted) {
|
|
133
|
+
try { writeFn(`error: ${err?.message || String(err)}\n`); }
|
|
134
|
+
catch { /* sink failure must not mask err */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// tui/slash_commands.mjs — single source of truth for the REPL slash-command
|
|
2
|
+
// catalog. Imported by both cli.mjs (legacy ghost-autocomplete + /help dump)
|
|
3
|
+
// and tui/slash_popup.mjs (Ink popup chooser). Keeping the list here avoids
|
|
4
|
+
// a tui/ → cli.mjs circular import.
|
|
5
|
+
//
|
|
6
|
+
// Schema:
|
|
7
|
+
// { cmd: string, help: string }
|
|
8
|
+
//
|
|
9
|
+
// Order matters — it is the order shown in the popup and in /help. New
|
|
10
|
+
// commands should be appended unless there is a UX reason to slot them in.
|
|
11
|
+
|
|
12
|
+
export const SLASH_COMMANDS = [
|
|
13
|
+
{ cmd: '/help', help: 'list available slash commands' },
|
|
14
|
+
{ cmd: '/status', help: 'print current provider, model, masked key' },
|
|
15
|
+
{ cmd: '/version', help: 'print version + node + platform' },
|
|
16
|
+
{ cmd: '/new', help: 'clear conversation and start over' },
|
|
17
|
+
{ cmd: '/reset', help: 'alias for /new' },
|
|
18
|
+
{ cmd: '/usage', help: 'show message count + chars sent so far' },
|
|
19
|
+
{ cmd: '/skills', help: 'list and activate skills (alias /skill)' },
|
|
20
|
+
{ cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
|
|
21
|
+
{ cmd: '/tools', help: 'list available tool registry verbs' },
|
|
22
|
+
{ cmd: '/provider', help: 'switch provider: /provider openai (no arg → print current)' },
|
|
23
|
+
{ cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
|
|
24
|
+
{ cmd: '/trainer', help: 'configure trainer provider/model for learning loop' },
|
|
25
|
+
{ cmd: '/personality', help: 'switch agent personality preset' },
|
|
26
|
+
{ cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
|
|
27
|
+
{ cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
|
|
28
|
+
{ cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
|
|
29
|
+
{ cmd: '/recall', help: 'FTS5 recall across sessions and memory' },
|
|
30
|
+
{ cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
|
|
31
|
+
{ cmd: '/agent', help: 'spawn or switch agent: /agent NAME' },
|
|
32
|
+
{ cmd: '/team', help: 'list, create, or join a team' },
|
|
33
|
+
{ cmd: '/task', help: 'create, list, or manage tasks' },
|
|
34
|
+
{ cmd: '/handoff', help: 'hand current task off to another agent' },
|
|
35
|
+
{ cmd: '/exit', help: 'leave the chat' },
|
|
36
|
+
{ cmd: '/quit', help: 'alias for /exit' },
|
|
37
|
+
];
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// tui/slash_popup.mjs — Ink popup chooser for slash commands.
|
|
2
|
+
//
|
|
3
|
+
// Renders a bordered, vertically-stacked list of slash-command suggestions
|
|
4
|
+
// just above the input row. Selection state lives in the parent (ReplApp),
|
|
5
|
+
// because the Editor owns the keyboard. This component is purely
|
|
6
|
+
// presentational so it can be snapshotted and rendered without an Ink
|
|
7
|
+
// runtime.
|
|
8
|
+
//
|
|
9
|
+
// Ink has no absolute positioning, so "popup" here means "flex sibling
|
|
10
|
+
// rendered between the live region and the StatusBar". When the popup is
|
|
11
|
+
// visible the live region naturally shrinks by its row count, which is the
|
|
12
|
+
// same trick Claude CLI uses.
|
|
13
|
+
//
|
|
14
|
+
// Two exports:
|
|
15
|
+
// - filterSlashCommands(buffer, commands) → pure, unit-testable
|
|
16
|
+
// - <SlashPopup buffer commands selectedIndex maxRows /> → presentational
|
|
17
|
+
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import { Box, Text } from 'ink';
|
|
20
|
+
import { theme } from './theme.mjs';
|
|
21
|
+
import { SLASH_COMMANDS as DEFAULT_SLASH_COMMANDS } from './slash_commands.mjs';
|
|
22
|
+
|
|
23
|
+
export { DEFAULT_SLASH_COMMANDS };
|
|
24
|
+
|
|
25
|
+
// ─── Pure filter ─────────────────────────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Returns the subset of `commands` to show in the popup for the current
|
|
28
|
+
// `buffer`. Empty array means "dismiss popup". See the inline cases for
|
|
29
|
+
// the precedence — prefix first, then substring fallback, then space-arg
|
|
30
|
+
// degeneration into a single-row inline hint.
|
|
31
|
+
export function filterSlashCommands(buffer, commands) {
|
|
32
|
+
if (!buffer || !buffer.startsWith('/')) return [];
|
|
33
|
+
const space = buffer.indexOf(' ');
|
|
34
|
+
if (space > 0) {
|
|
35
|
+
// User has typed args. Popup degenerates into a one-row inline hint
|
|
36
|
+
// showing only the matched command's help text.
|
|
37
|
+
const head = buffer.slice(0, space);
|
|
38
|
+
return commands.filter((c) => c.cmd === head);
|
|
39
|
+
}
|
|
40
|
+
if (buffer === '/') return commands.slice();
|
|
41
|
+
const q = buffer.toLowerCase();
|
|
42
|
+
const prefix = commands.filter((c) => c.cmd.toLowerCase().startsWith(q));
|
|
43
|
+
if (prefix.length > 0) return prefix;
|
|
44
|
+
// Substring fallback: '/em' → /memory. Strip the leading '/' on both
|
|
45
|
+
// sides so the substring search isn't dominated by the trigger char.
|
|
46
|
+
const sub = commands.filter((c) =>
|
|
47
|
+
c.cmd.slice(1).toLowerCase().includes(q.slice(1))
|
|
48
|
+
);
|
|
49
|
+
return sub;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Truncate help text to a column budget, preserving the leading space.
|
|
53
|
+
// Never wraps — the popup is a single row per command.
|
|
54
|
+
function _truncate(s, max) {
|
|
55
|
+
if (max <= 0) return '';
|
|
56
|
+
if (s.length <= max) return s;
|
|
57
|
+
if (max <= 1) return s.slice(0, max);
|
|
58
|
+
return s.slice(0, max - 1) + '…';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Window a long match list around the selected row so the popup doesn't
|
|
62
|
+
// grow taller than `maxRows`. Returns `{ visible, windowStart }`.
|
|
63
|
+
export function _computeWindow(matches, selectedIndex, maxRows) {
|
|
64
|
+
if (matches.length <= maxRows) return { visible: matches, windowStart: 0 };
|
|
65
|
+
const half = Math.floor(maxRows / 2);
|
|
66
|
+
let start = Math.max(0, selectedIndex - half);
|
|
67
|
+
const end = Math.min(matches.length, start + maxRows);
|
|
68
|
+
// Re-anchor if we overflowed past the end.
|
|
69
|
+
start = Math.max(0, end - maxRows);
|
|
70
|
+
return { visible: matches.slice(start, start + maxRows), windowStart: start };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Component ───────────────────────────────────────────────────────────
|
|
74
|
+
//
|
|
75
|
+
// Props:
|
|
76
|
+
// buffer — string, current editor buffer (used for the inline hint)
|
|
77
|
+
// commands — Array<{cmd,help}>, already filtered by caller
|
|
78
|
+
// selectedIndex — number, index into `commands`
|
|
79
|
+
// maxRows — number, max visible rows (defaults to 8)
|
|
80
|
+
// columns — number, terminal width (defaults to process.stdout.columns)
|
|
81
|
+
//
|
|
82
|
+
// When `columns < 50` the popup collapses to a single column (cmd only).
|
|
83
|
+
export function SlashPopup({
|
|
84
|
+
buffer = '',
|
|
85
|
+
commands = [],
|
|
86
|
+
selectedIndex = 0,
|
|
87
|
+
maxRows = 8,
|
|
88
|
+
columns,
|
|
89
|
+
}) {
|
|
90
|
+
if (!commands || commands.length === 0) return null;
|
|
91
|
+
const cols = columns
|
|
92
|
+
|| (process.stdout && process.stdout.columns)
|
|
93
|
+
|| 80;
|
|
94
|
+
const compact = cols < 50;
|
|
95
|
+
const safeSelected = Math.max(0, Math.min(commands.length - 1, selectedIndex));
|
|
96
|
+
const { visible, windowStart } = _computeWindow(commands, safeSelected, maxRows);
|
|
97
|
+
|
|
98
|
+
// Inline-hint mode: buffer already has args + a single match. Render
|
|
99
|
+
// the help text on one dimmed line. No border, no chooser.
|
|
100
|
+
const isInlineHint = buffer.includes(' ') && commands.length === 1;
|
|
101
|
+
if (isInlineHint) {
|
|
102
|
+
const c = commands[0];
|
|
103
|
+
return React.createElement(
|
|
104
|
+
Box,
|
|
105
|
+
{ paddingX: 1 },
|
|
106
|
+
React.createElement(Text, { dimColor: true },
|
|
107
|
+
`${c.cmd} — ${_truncate(c.help, Math.max(10, cols - c.cmd.length - 5))}`
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Reserve 2 chars border + 2 padding + cmd column (14) + 1 gap.
|
|
113
|
+
const cmdCol = 14;
|
|
114
|
+
const helpBudget = Math.max(0, cols - cmdCol - 6);
|
|
115
|
+
|
|
116
|
+
return React.createElement(
|
|
117
|
+
Box,
|
|
118
|
+
{
|
|
119
|
+
flexDirection: 'column',
|
|
120
|
+
borderStyle: 'round',
|
|
121
|
+
borderColor: 'gray',
|
|
122
|
+
paddingX: 1,
|
|
123
|
+
},
|
|
124
|
+
visible.map((c, i) => {
|
|
125
|
+
const absIdx = i + windowStart;
|
|
126
|
+
const isSel = absIdx === safeSelected;
|
|
127
|
+
const cmdText = c.cmd.padEnd(cmdCol);
|
|
128
|
+
return React.createElement(
|
|
129
|
+
Box,
|
|
130
|
+
{ key: c.cmd },
|
|
131
|
+
React.createElement(
|
|
132
|
+
Text,
|
|
133
|
+
{
|
|
134
|
+
inverse: isSel,
|
|
135
|
+
color: isSel ? theme.amber : undefined,
|
|
136
|
+
bold: isSel,
|
|
137
|
+
},
|
|
138
|
+
cmdText
|
|
139
|
+
),
|
|
140
|
+
!compact
|
|
141
|
+
? React.createElement(
|
|
142
|
+
Text,
|
|
143
|
+
{ dimColor: true },
|
|
144
|
+
' ' + _truncate(c.help, helpBudget)
|
|
145
|
+
)
|
|
146
|
+
: null
|
|
147
|
+
);
|
|
148
|
+
}),
|
|
149
|
+
commands.length > maxRows
|
|
150
|
+
? React.createElement(
|
|
151
|
+
Text,
|
|
152
|
+
{ dimColor: true },
|
|
153
|
+
` ${safeSelected + 1}/${commands.length}`
|
|
154
|
+
)
|
|
155
|
+
: null
|
|
156
|
+
);
|
|
157
|
+
}
|
package/tui/splash.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Layout (terminal-width responsive across four tiers):
|
|
5
5
|
//
|
|
6
|
-
// WIDE (cols >= 140) — full wordmark + panel + sloth
|
|
7
|
-
// MEDIUM ( 90 <= cols < 140) — compact headline,
|
|
8
|
-
// NARROW (
|
|
9
|
-
// MINIMAL (cols <
|
|
6
|
+
// WIDE (cols >= 140) — full wordmark + panel + sloth side-by-side
|
|
7
|
+
// MEDIUM ( 90 <= cols < 140) — compact headline, sloth side-by-side, wrapped right column
|
|
8
|
+
// NARROW ( 45 <= cols < 90) — sloth STACKED above full-width panel, wrapped verbs
|
|
9
|
+
// MINIMAL (cols < 45) — headline + provider + cwd + /help line only
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { Box, Text } from 'ink';
|
|
12
12
|
import stringWidth from 'string-width';
|
|
@@ -19,11 +19,12 @@ const TITLE = ' trainer-split · FTS5 recall · 6-backend sandbox ';
|
|
|
19
19
|
|
|
20
20
|
// Tier breakpoints. Wordmark is 120 cols wide + LMARGIN(2)*2 = 124 minimum;
|
|
21
21
|
// the user constraint pins WIDE at >=140 to give comfortable slack. Below
|
|
22
|
-
// 90 the sloth (48 cols)
|
|
23
|
-
//
|
|
22
|
+
// 90 the sloth (48 cols) cannot share a row with a usable right column,
|
|
23
|
+
// so NARROW stacks the sloth ABOVE a full-width wrapped panel. Below 45
|
|
24
|
+
// even a stacked sloth overflows, so MINIMAL absorbs that range.
|
|
24
25
|
const WORDMARK_BREAKPOINT = 140; // drop wordmark below this
|
|
25
|
-
const
|
|
26
|
-
const
|
|
26
|
+
const MEDIUM_BREAKPOINT = 90; // side-by-side sloth+panel above this; stacked below
|
|
27
|
+
const NARROW_BREAKPOINT = 45; // headline-only fallback below this
|
|
27
28
|
|
|
28
29
|
// Subcommand catalog — grouped for the splash so a new user sees the
|
|
29
30
|
// surface area at a glance. Mirrors SUBCOMMANDS in cli.mjs.
|
|
@@ -99,16 +100,6 @@ function wrapVerbs(label, verbs, maxWidth) {
|
|
|
99
100
|
return rows;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
// Crush-style truncation for NARROW tier — take first N verbs, append '…' if more.
|
|
103
|
-
function truncateRow(label, verbs, maxWidth, take = 3) {
|
|
104
|
-
const head = label.padEnd(12) + ' ';
|
|
105
|
-
const tail = verbs.slice(0, take).join(' · ');
|
|
106
|
-
let line = head + tail;
|
|
107
|
-
if (verbs.length > take) line += ' …';
|
|
108
|
-
if (stringWidth(line) <= maxWidth) return line;
|
|
109
|
-
return fit(line, maxWidth).trimEnd();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
103
|
// Wide tier — original v5.0.9 layout, kept verbatim.
|
|
113
104
|
function renderWide(props, cols) {
|
|
114
105
|
const PANEL_W = cols - LMARGIN.length * 2;
|
|
@@ -274,45 +265,76 @@ function renderMedium(props, cols) {
|
|
|
274
265
|
return lines;
|
|
275
266
|
}
|
|
276
267
|
|
|
277
|
-
// Narrow tier —
|
|
268
|
+
// Narrow tier — sloth STACKED above a full-width panel; verb lists wrap
|
|
269
|
+
// onto multiple rows instead of being truncated. Used for 45 <= cols < 90.
|
|
278
270
|
function renderNarrow(props, cols) {
|
|
279
|
-
const
|
|
271
|
+
const PANEL_W = cols - LMARGIN.length * 2;
|
|
272
|
+
const INNER = PANEL_W - 4;
|
|
273
|
+
const SLOTH_W = banner.width;
|
|
280
274
|
const lines = [];
|
|
281
275
|
|
|
282
|
-
// 1)
|
|
276
|
+
// 1) sloth banner CENTERED above panel (stacked layout).
|
|
277
|
+
// Only emit if the sloth itself fits within the terminal; otherwise
|
|
278
|
+
// skip it (MINIMAL absorbs the truly tiny case below NARROW_BREAKPOINT).
|
|
279
|
+
if (cols >= SLOTH_W + LMARGIN.length * 2) {
|
|
280
|
+
const leftPad = ' '.repeat(Math.max(0, Math.floor((cols - SLOTH_W) / 2)));
|
|
281
|
+
for (const r of banner.rows) lines.push(leftPad + r);
|
|
282
|
+
lines.push('');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 2) compact headline (no wordmark — too wide).
|
|
283
286
|
lines.push(`${LMARGIN}lazyclaw ${props.version || ''}`.trimEnd());
|
|
284
287
|
lines.push('');
|
|
285
288
|
|
|
286
|
-
|
|
289
|
+
// 3) panel top — version label only, dashes fill remainder.
|
|
290
|
+
const versionLabel = ` lazyclaw ${props.version || ''} `;
|
|
291
|
+
const dashLeft = '─'.repeat(2);
|
|
292
|
+
const dashRight = '─'.repeat(Math.max(2, PANEL_W - 2 - dashLeft.length - stringWidth(versionLabel)));
|
|
293
|
+
lines.push(`${LMARGIN}╭${dashLeft}${versionLabel}${dashRight}╮`);
|
|
287
294
|
|
|
288
|
-
//
|
|
289
|
-
|
|
295
|
+
// 4) panel body — full-width single column, wrapped via wrapVerbs.
|
|
296
|
+
const { tools = [], skills = [] } = props;
|
|
297
|
+
const body = [];
|
|
298
|
+
body.push('Subcommands');
|
|
290
299
|
for (const [label, verbs] of SUBCOMMAND_GROUPS) {
|
|
291
|
-
|
|
300
|
+
for (const r of wrapVerbs(label, verbs, INNER)) body.push(r);
|
|
292
301
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// 3) tools
|
|
296
|
-
lines.push(`${LMARGIN}Available Tools`);
|
|
302
|
+
body.push('');
|
|
303
|
+
body.push('Available Tools');
|
|
297
304
|
for (const t of tools.slice(0, 14)) {
|
|
298
305
|
const label = t.sensitive ? `${t.category}*` : t.category;
|
|
299
|
-
|
|
306
|
+
for (const r of wrapVerbs(label, t.verbs.slice(0, 6), INNER)) body.push(r);
|
|
300
307
|
}
|
|
301
|
-
if (tools.length > 14)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
lines.push(`${LMARGIN}Available Skills`);
|
|
306
|
-
if (skills.length === 0) lines.push(`${LMARGIN}(none installed)`);
|
|
308
|
+
if (tools.length > 14) body.push(`(and ${tools.length - 14} more...)`);
|
|
309
|
+
body.push('');
|
|
310
|
+
body.push('Available Skills');
|
|
311
|
+
if (skills.length === 0) body.push('(none installed)');
|
|
307
312
|
else {
|
|
308
313
|
for (const s of skills.slice(0, 8)) {
|
|
309
|
-
|
|
314
|
+
for (const r of wrapVerbs(s.group, s.names.slice(0, 6), INNER)) body.push(r);
|
|
310
315
|
}
|
|
311
|
-
if (skills.length > 8)
|
|
316
|
+
if (skills.length > 8) body.push(`(and ${skills.length - 8} more skill groups...)`);
|
|
312
317
|
}
|
|
318
|
+
body.push('');
|
|
319
|
+
const subcmdCount = SUBCOMMAND_GROUPS.reduce((n, [, v]) => n + v.length, 0);
|
|
320
|
+
const summary = `${subcmdCount} subcmds · ${tools.length} tools · ${skills.length} skills · /help`;
|
|
321
|
+
if (stringWidth(summary) > INNER) {
|
|
322
|
+
body.push(`${subcmdCount} subcmds · ${tools.length} tools · ${skills.length} skills`);
|
|
323
|
+
body.push('/help for commands');
|
|
324
|
+
} else {
|
|
325
|
+
body.push(summary);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 5) emit panel rows (single column, full INNER width, pad with spaces).
|
|
329
|
+
for (const row of body) {
|
|
330
|
+
const padded = row + ' '.repeat(Math.max(0, INNER - stringWidth(row)));
|
|
331
|
+
lines.push(`${LMARGIN}│ ${padded} │`);
|
|
332
|
+
}
|
|
333
|
+
lines.push(`${LMARGIN}╰${'─'.repeat(PANEL_W - 2)}╯`);
|
|
313
334
|
lines.push('');
|
|
314
335
|
|
|
315
|
-
//
|
|
336
|
+
// 6) provider / session info (single line each, fit-truncated for safety).
|
|
337
|
+
const { provider, model, trainer = {}, sessionId, cwd } = props;
|
|
316
338
|
const tProv = trainer.provider || provider;
|
|
317
339
|
const tModel = trainer.model || model;
|
|
318
340
|
lines.push(fit(`${LMARGIN}${provider} · ${model} · trainer ${tProv} · ${tModel}`, cols).trimEnd());
|
|
@@ -322,7 +344,7 @@ function renderNarrow(props, cols) {
|
|
|
322
344
|
lines.push(fit(`${LMARGIN}Welcome to lazyclaw. /help for commands.`, cols).trimEnd());
|
|
323
345
|
lines.push('');
|
|
324
346
|
|
|
325
|
-
//
|
|
347
|
+
// 7) compact status — single line, no separator dashes.
|
|
326
348
|
const ctx = props.ctxUsed != null && props.ctxTotal != null
|
|
327
349
|
? `${props.ctxUsed}/${props.ctxTotal}`
|
|
328
350
|
: '--';
|
|
@@ -331,7 +353,7 @@ function renderNarrow(props, cols) {
|
|
|
331
353
|
return lines;
|
|
332
354
|
}
|
|
333
355
|
|
|
334
|
-
// Minimal tier — bare-bones fallback for cols <
|
|
356
|
+
// Minimal tier — bare-bones fallback for cols < 45.
|
|
335
357
|
function renderMinimal(props) {
|
|
336
358
|
const { version, provider, model, sessionId, cwd } = props;
|
|
337
359
|
const lines = [];
|
|
@@ -346,8 +368,8 @@ function renderMinimal(props) {
|
|
|
346
368
|
export function renderSplashToString(props, opts = {}) {
|
|
347
369
|
const cols = opts.columns || process.stdout.columns || 100;
|
|
348
370
|
let lines;
|
|
349
|
-
if (cols <
|
|
350
|
-
else if (cols <
|
|
371
|
+
if (cols < NARROW_BREAKPOINT) lines = renderMinimal(props);
|
|
372
|
+
else if (cols < MEDIUM_BREAKPOINT) lines = renderNarrow(props, cols);
|
|
351
373
|
else if (cols < WORDMARK_BREAKPOINT) lines = renderMedium(props, cols);
|
|
352
374
|
else lines = renderWide(props, cols);
|
|
353
375
|
return lines.join('\n');
|
package/web/dashboard.html
CHANGED
|
@@ -1780,7 +1780,7 @@
|
|
|
1780
1780
|
<div class="name" style="margin-left:8px;">${escHtml(String(label))}</div>
|
|
1781
1781
|
<div class="dim row-actions">bm25 ${Number(h.bm25 || 0).toFixed(2)}</div>
|
|
1782
1782
|
</div>
|
|
1783
|
-
<div class="dim" style="margin-top:6px;font-size:12px;">${h.snippet || ''}</div>
|
|
1783
|
+
<div class="dim" style="margin-top:6px;font-size:12px;">${escHtml(h.snippet || '').replace(/<mark>/g,'<mark>').replace(/<\/mark>/g,'</mark>')}</div>
|
|
1784
1784
|
</div>`;
|
|
1785
1785
|
}).join('');
|
|
1786
1786
|
} catch (e) {
|