lazyclaw 5.2.0 → 5.3.1
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/cli.mjs +44 -0
- package/package.json +1 -1
- package/tui/editor.mjs +112 -3
- package/tui/repl.mjs +298 -29
- package/tui/slash_commands.mjs +37 -0
- package/tui/slash_popup.mjs +157 -0
- package/tui/splash.mjs +108 -45
- package/tui/theme.mjs +6 -0
package/cli.mjs
CHANGED
|
@@ -2634,9 +2634,53 @@ async function cmdChat(flags = {}) {
|
|
|
2634
2634
|
ctx: _inkCtx,
|
|
2635
2635
|
writeFn: (chunk) => process.stdout.write(chunk),
|
|
2636
2636
|
});
|
|
2637
|
+
// Minimal slash dispatcher for the Ink branch. Covers the read-only
|
|
2638
|
+
// info commands so the user sees something useful instead of having
|
|
2639
|
+
// their slash command sent to the model as a prompt. /exit + /quit
|
|
2640
|
+
// are intercepted inside ReplApp before this fires. Returning a
|
|
2641
|
+
// string causes ReplApp to render the result into scrollback.
|
|
2642
|
+
//
|
|
2643
|
+
// The full set of mutating commands (/model, /provider, /skill,
|
|
2644
|
+
// /personality, ...) still lives in the legacy readline path and
|
|
2645
|
+
// remains accessible via LAZYCLAW_NO_INK=1. Wiring those through
|
|
2646
|
+
// the Ink branch is a follow-up; today we at least stop sending
|
|
2647
|
+
// them as prompts.
|
|
2648
|
+
const _inkSlashHandler = async (line) => {
|
|
2649
|
+
const cmd = line.split(/\s+/)[0];
|
|
2650
|
+
switch (cmd) {
|
|
2651
|
+
case '/help': {
|
|
2652
|
+
const lines = ['slash commands:'];
|
|
2653
|
+
for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
|
|
2654
|
+
return lines.join('\n') + '\n';
|
|
2655
|
+
}
|
|
2656
|
+
case '/status': {
|
|
2657
|
+
const out = {
|
|
2658
|
+
provider: activeProvName,
|
|
2659
|
+
model: activeModel,
|
|
2660
|
+
keyMasked: _registryMod.maskApiKey(cfg['api-key']),
|
|
2661
|
+
messageCount: _inkMessages.length,
|
|
2662
|
+
};
|
|
2663
|
+
return JSON.stringify(out) + '\n';
|
|
2664
|
+
}
|
|
2665
|
+
case '/version': {
|
|
2666
|
+
return `lazyclaw ${readVersionFromRepo()} (node ${process.version}, ${process.platform})\n`;
|
|
2667
|
+
}
|
|
2668
|
+
case '/exit':
|
|
2669
|
+
case '/quit':
|
|
2670
|
+
// ReplApp intercepts these before onSlashCommand fires, but
|
|
2671
|
+
// return EXIT defensively in case that contract ever changes.
|
|
2672
|
+
return 'EXIT';
|
|
2673
|
+
default:
|
|
2674
|
+
// Mutating commands still require the legacy readline path.
|
|
2675
|
+
// Telling the user explicitly beats silently sending the
|
|
2676
|
+
// slash text to the model as a prompt.
|
|
2677
|
+
return `${cmd} is not yet wired into the ink REPL — set LAZYCLAW_NO_INK=1 and restart to use it.\n`;
|
|
2678
|
+
}
|
|
2679
|
+
};
|
|
2637
2680
|
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
2638
2681
|
splashProps,
|
|
2639
2682
|
runTurn: _inkRunTurn,
|
|
2683
|
+
onSlashCommand: _inkSlashHandler,
|
|
2640
2684
|
}));
|
|
2641
2685
|
await ink.waitUntilExit();
|
|
2642
2686
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.1",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/tui/editor.mjs
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Pure-functional core (makeEditorState, applyKey) so it is testable
|
|
4
4
|
// without ink stdin. The React component <Editor/> wraps useInput().
|
|
5
|
+
//
|
|
6
|
+
// v5.4: slash-popup integration. When the parent passes a non-empty
|
|
7
|
+
// `slashSuggestions` array, ↑/↓ move the popup selection (instead of
|
|
8
|
+
// scrolling history) and Tab/Enter fill the buffer with the highlighted
|
|
9
|
+
// command WITHOUT submitting it (Anthropic's recent UX rule: first
|
|
10
|
+
// Enter fills, second Enter runs). Esc clears the buffer + dismisses.
|
|
11
|
+
// All popup-aware branches are guarded by `slashOpen` so legacy callers
|
|
12
|
+
// see the pre-v5.4 behavior verbatim.
|
|
13
|
+
//
|
|
14
|
+
// v5.5: <Editor/> now renders inside a round-bordered Box — the
|
|
15
|
+
// Claude-CLI-style input frame. The border uses `theme.border` (a
|
|
16
|
+
// muted gray) so the accent `›` and sloth gutter stay the dominant
|
|
17
|
+
// amber notes. The box auto-fills the available terminal width via
|
|
18
|
+
// Ink's flex defaults and grows vertically as the buffer wraps onto
|
|
19
|
+
// new lines (Shift+Enter).
|
|
5
20
|
import React, { useState, useEffect } from 'react';
|
|
6
21
|
import { Box, Text, useInput } from 'ink';
|
|
7
22
|
import { theme } from './theme.mjs';
|
|
@@ -63,11 +78,95 @@ export function applyKey(state, evt) {
|
|
|
63
78
|
return next;
|
|
64
79
|
}
|
|
65
80
|
|
|
66
|
-
|
|
81
|
+
// Pure helper used by the slash-popup branch in <Editor/>. Replaces the
|
|
82
|
+
// editor buffer with `${cmd} ` (note trailing space so the user can keep
|
|
83
|
+
// typing args without an extra keystroke). Does NOT submit.
|
|
84
|
+
export function fillSlashCommand(state, cmd) {
|
|
85
|
+
const filled = cmd.endsWith(' ') ? cmd : cmd + ' ';
|
|
86
|
+
return {
|
|
87
|
+
...state,
|
|
88
|
+
buffer: filled,
|
|
89
|
+
cursor: filled.length,
|
|
90
|
+
lastSubmit: null,
|
|
91
|
+
lastWasPaste: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function Editor({
|
|
96
|
+
history,
|
|
97
|
+
onSubmit,
|
|
98
|
+
onEscape,
|
|
99
|
+
onBufferChange,
|
|
100
|
+
// v5.4 slash-popup wiring (all optional — pre-v5.4 callers pass none):
|
|
101
|
+
slashSuggestions, // Array<{cmd,help}> | null
|
|
102
|
+
slashSelectedIndex, // number
|
|
103
|
+
onSlashMove, // (delta: -1 | +1) => void
|
|
104
|
+
onSlashDismiss, // () => void
|
|
105
|
+
}) {
|
|
67
106
|
const [state, setState] = useState(() => makeEditorState({ history }));
|
|
107
|
+
const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
|
|
108
|
+
|
|
68
109
|
useInput((input, key) => {
|
|
110
|
+
// ─── Slash-popup keyboard contract (highest priority when open) ──
|
|
111
|
+
if (slashOpen) {
|
|
112
|
+
// Esc: clear the buffer and dismiss the popup. The host's onEscape
|
|
113
|
+
// is NOT called in this branch — Esc here is a popup gesture, not
|
|
114
|
+
// a turn-abort. (Outside of popup mode Esc still aborts streaming.)
|
|
115
|
+
if (key.escape) {
|
|
116
|
+
const cleared = { ...state, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
|
|
117
|
+
setState(cleared);
|
|
118
|
+
if (onBufferChange) {
|
|
119
|
+
try { onBufferChange(''); } catch {}
|
|
120
|
+
}
|
|
121
|
+
if (onSlashDismiss) onSlashDismiss();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// ↑/↓ navigate the popup instead of history.
|
|
125
|
+
if (key.upArrow) {
|
|
126
|
+
if (onSlashMove) onSlashMove(-1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (key.downArrow) {
|
|
130
|
+
if (onSlashMove) onSlashMove(+1);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Tab / Enter — fill the buffer with the highlighted command.
|
|
134
|
+
// First Enter fills, second Enter runs (matches Anthropic's UX).
|
|
135
|
+
// Exception: if the buffer already exactly matches the picked
|
|
136
|
+
// command (with or without a trailing space), there is nothing left
|
|
137
|
+
// to autocomplete. For Enter, fall through to the normal submit
|
|
138
|
+
// path so /exit, /quit, /help etc. fire on a single Enter. For Tab
|
|
139
|
+
// on an exact match, no-op.
|
|
140
|
+
if (key.tab || key.return) {
|
|
141
|
+
const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
|
|
142
|
+
const picked = slashSuggestions[safeIdx];
|
|
143
|
+
const bufTrim = state.buffer.replace(/\s+$/, '');
|
|
144
|
+
const alreadyExact = !!picked && (state.buffer === picked.cmd || bufTrim === picked.cmd);
|
|
145
|
+
if (alreadyExact) {
|
|
146
|
+
if (key.tab) return; // no completion to make
|
|
147
|
+
// key.return on exact match → fall through to applyKey/submit.
|
|
148
|
+
} else {
|
|
149
|
+
if (picked) {
|
|
150
|
+
const next = fillSlashCommand(state, picked.cmd);
|
|
151
|
+
setState(next);
|
|
152
|
+
if (onBufferChange) {
|
|
153
|
+
try { onBufferChange(next.buffer); } catch {}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Anything else (printable, backspace) falls through to applyKey.
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Esc: forward to host (ReplApp uses this to abort an in-flight turn).
|
|
163
|
+
// Do not mutate the buffer — the user may want to keep typing.
|
|
164
|
+
if (key.escape) { if (onEscape) onEscape(); return; }
|
|
69
165
|
const next = applyKey(state, { input, key });
|
|
70
166
|
setState(next);
|
|
167
|
+
if (onBufferChange) {
|
|
168
|
+
try { onBufferChange(next.buffer); } catch { /* observer is best-effort */ }
|
|
169
|
+
}
|
|
71
170
|
});
|
|
72
171
|
useEffect(() => {
|
|
73
172
|
if (state.lastSubmit !== null && onSubmit) onSubmit(state.lastSubmit);
|
|
@@ -76,7 +175,17 @@ export function Editor({ history, onSubmit }) {
|
|
|
76
175
|
const lines = state.buffer.split('\n');
|
|
77
176
|
return React.createElement(
|
|
78
177
|
Box,
|
|
79
|
-
{
|
|
80
|
-
|
|
178
|
+
{
|
|
179
|
+
borderStyle: 'round',
|
|
180
|
+
borderColor: theme.border,
|
|
181
|
+
paddingX: 1,
|
|
182
|
+
flexDirection: 'column',
|
|
183
|
+
flexShrink: 0,
|
|
184
|
+
},
|
|
185
|
+
lines.map((ln, i) => React.createElement(
|
|
186
|
+
Text,
|
|
187
|
+
{ key: i },
|
|
188
|
+
i === 0 ? theme.accent('› ') + ln : ' ' + ln,
|
|
189
|
+
)),
|
|
81
190
|
);
|
|
82
191
|
}
|
package/tui/repl.mjs
CHANGED
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
// tui/repl.mjs — REPL host with mid-stream interrupt-and-redirect
|
|
2
|
-
// (spec §5.8)
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// (spec §5.8) AND a sticky-bottom chat layout (v5.3).
|
|
3
|
+
//
|
|
4
|
+
// Layout (top → bottom inside the outer column):
|
|
5
|
+
// 1. <Static items={scrollback}/> — splash item + per-turn user/assistant
|
|
6
|
+
// blocks. Static renders each item ONCE to terminal scrollback and
|
|
7
|
+
// never re-renders it, so the splash + history scroll away naturally
|
|
8
|
+
// as new content appends. This is the Claude CLI / opencode pattern
|
|
9
|
+
// translated to Ink's idiom — Static IS the scroll buffer.
|
|
10
|
+
// 2. Live region — partial assistant stream (state.liveAssistant) and
|
|
11
|
+
// optional <SlashHints/> while the input buffer starts with '/'.
|
|
12
|
+
// This Box re-renders on every chunk; the rest of the tree does not.
|
|
13
|
+
// 3. <StatusBar/> — single row above the input. flexShrink:0.
|
|
14
|
+
// 4. <Editor/> — sticky bottom, content-sized (1 line idle, grows with
|
|
15
|
+
// multiline). Last sibling in the column so it pins to the bottom.
|
|
16
|
+
//
|
|
17
|
+
// Streaming chunks now flow into React state via an injected writeFn
|
|
18
|
+
// (state.liveAssistant += chunk). On turn completion the accumulated
|
|
19
|
+
// text is committed to scrollback so React stops re-rendering it.
|
|
20
|
+
// This closes the v5.0.10 TODO from cli.mjs:2628-2632.
|
|
21
|
+
//
|
|
22
|
+
// Backward-compat contracts (do not break):
|
|
23
|
+
// - makeReplState() — still callable with zero args.
|
|
24
|
+
// - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
|
|
25
|
+
// keep their pre-v5.3 shapes (tests/phaseC-repl-interrupt.test.mjs).
|
|
26
|
+
// - ReplApp({ splashProps, runTurn }) — legacy callsite (cli.mjs:2637)
|
|
27
|
+
// still works; runTurn writes go to wherever its writeFn points.
|
|
28
|
+
// - ReplApp({ splashProps, runTurnFactory }) — new callsite for the
|
|
29
|
+
// sticky layout; ReplApp injects writeFn → scrollback.
|
|
30
|
+
//
|
|
31
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
32
|
+
import { Box, Static, Text, useApp } from 'ink';
|
|
33
|
+
import { Splash, renderSplashToString } from './splash.mjs';
|
|
7
34
|
import { Editor } from './editor.mjs';
|
|
35
|
+
import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
|
|
36
|
+
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
37
|
+
import { theme } from './theme.mjs';
|
|
8
38
|
|
|
9
|
-
|
|
39
|
+
// ─── Pure state ──────────────────────────────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// makeReplState stays callable with zero args (existing tests rely on it).
|
|
42
|
+
// The new fields default to empty so legacy callers see no behavior change.
|
|
43
|
+
export function makeReplState(opts) {
|
|
44
|
+
const splashItem = opts && opts.splashItem ? opts.splashItem : null;
|
|
10
45
|
return {
|
|
11
46
|
streaming: false,
|
|
12
47
|
controller: null,
|
|
13
48
|
pendingPrepend: null,
|
|
14
49
|
nextTurnFirstMessage: null,
|
|
15
50
|
history: [],
|
|
51
|
+
scrollback: splashItem ? [splashItem] : [],
|
|
52
|
+
liveAssistant: '',
|
|
53
|
+
turnCounter: 0,
|
|
16
54
|
};
|
|
17
55
|
}
|
|
18
56
|
|
|
@@ -22,12 +60,16 @@ export function onUserInput(state, { text, controller }) {
|
|
|
22
60
|
try { state.controller.abort(); } catch {}
|
|
23
61
|
return { ...state, pendingPrepend: text };
|
|
24
62
|
}
|
|
25
|
-
// idle — start a new turn.
|
|
63
|
+
// idle — start a new turn. Append a 'user' entry to scrollback so the
|
|
64
|
+
// sticky-layout caller sees the prompt history above the live stream.
|
|
65
|
+
const id = `u-${state.turnCounter}`;
|
|
26
66
|
return {
|
|
27
67
|
...state,
|
|
28
68
|
streaming: true,
|
|
29
69
|
controller,
|
|
30
70
|
history: [...state.history, text],
|
|
71
|
+
scrollback: [...state.scrollback, { kind: 'user', id, text }],
|
|
72
|
+
turnCounter: state.turnCounter + 1,
|
|
31
73
|
};
|
|
32
74
|
}
|
|
33
75
|
|
|
@@ -35,18 +77,45 @@ export function onEscape(state) {
|
|
|
35
77
|
if (state.streaming && state.controller) {
|
|
36
78
|
try { state.controller.abort(); } catch {}
|
|
37
79
|
}
|
|
38
|
-
|
|
80
|
+
// Drop any partial live assistant text on explicit Esc — the user is
|
|
81
|
+
// telling us to discard, not to keep.
|
|
82
|
+
return {
|
|
83
|
+
...state,
|
|
84
|
+
streaming: false,
|
|
85
|
+
controller: null,
|
|
86
|
+
pendingPrepend: null,
|
|
87
|
+
liveAssistant: '',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// New reducer: stream chunk arrives, accumulate in liveAssistant.
|
|
92
|
+
export function onStreamChunk(state, { chunk }) {
|
|
93
|
+
return { ...state, liveAssistant: state.liveAssistant + chunk };
|
|
39
94
|
}
|
|
40
95
|
|
|
41
96
|
export function onTurnComplete(state, { reason } = {}) {
|
|
42
|
-
void reason;
|
|
43
97
|
const promoted = state.pendingPrepend;
|
|
98
|
+
const suffix = reason === 'aborted' ? ' [aborted]'
|
|
99
|
+
: reason === 'error' ? ' [error]'
|
|
100
|
+
: '';
|
|
101
|
+
const text = (state.liveAssistant || '') + suffix;
|
|
102
|
+
// Commit any accumulated live text to scrollback. If the turn produced
|
|
103
|
+
// nothing AND wasn't an error/abort, skip the empty append.
|
|
104
|
+
const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
|
|
105
|
+
const id = `a-${state.turnCounter}`;
|
|
106
|
+
const kind = reason === 'error' ? 'error' : 'assistant';
|
|
107
|
+
const nextScrollback = shouldCommit
|
|
108
|
+
? [...state.scrollback, { kind, id, text }]
|
|
109
|
+
: state.scrollback;
|
|
44
110
|
return {
|
|
45
111
|
...state,
|
|
46
112
|
streaming: false,
|
|
47
113
|
controller: null,
|
|
48
114
|
pendingPrepend: null,
|
|
49
115
|
nextTurnFirstMessage: promoted,
|
|
116
|
+
liveAssistant: '',
|
|
117
|
+
scrollback: nextScrollback,
|
|
118
|
+
turnCounter: state.turnCounter + 1,
|
|
50
119
|
};
|
|
51
120
|
}
|
|
52
121
|
|
|
@@ -56,40 +125,240 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
56
125
|
}
|
|
57
126
|
|
|
58
127
|
// ─── React mount ─────────────────────────────────────────────────────────
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
128
|
+
//
|
|
129
|
+
// Two prop modes:
|
|
130
|
+
// - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
|
|
131
|
+
// - runTurn(text, signal) (legacy, stdout)
|
|
132
|
+
// Legacy mode is preserved verbatim for the existing cli.mjs callsite.
|
|
133
|
+
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand }) {
|
|
134
|
+
// Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
|
|
135
|
+
// so SSR-style imports without a TTY don't crash on process.stdout.
|
|
136
|
+
const splashItemRef = useRef(null);
|
|
137
|
+
if (splashItemRef.current === null) {
|
|
138
|
+
splashItemRef.current = splashProps
|
|
139
|
+
? { kind: 'splash', id: 'splash-0', splashProps }
|
|
140
|
+
: null;
|
|
141
|
+
}
|
|
142
|
+
const [state, setState] = useState(() => makeReplState({ splashItem: splashItemRef.current }));
|
|
67
143
|
const { exit } = useApp();
|
|
68
144
|
|
|
69
|
-
|
|
70
|
-
|
|
145
|
+
// writeFn for run_turn: route chunks into React state instead of stdout.
|
|
146
|
+
// Only used when runTurnFactory is provided; the legacy `runTurn` prop
|
|
147
|
+
// keeps writing wherever its caller wired it.
|
|
148
|
+
const writeFn = useCallback((chunk) => {
|
|
149
|
+
setState((s) => onStreamChunk(s, { chunk }));
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
// Build the actual runTurn once. Prefer factory (new); fall back to
|
|
153
|
+
// the legacy `runTurn` prop (existing cli.mjs callsite).
|
|
154
|
+
const runTurnRef = useRef(null);
|
|
155
|
+
if (runTurnRef.current === null) {
|
|
156
|
+
if (typeof runTurnFactory === 'function') {
|
|
157
|
+
runTurnRef.current = runTurnFactory(writeFn);
|
|
158
|
+
} else if (typeof runTurn === 'function') {
|
|
159
|
+
runTurnRef.current = runTurn;
|
|
160
|
+
} else {
|
|
161
|
+
runTurnRef.current = async () => {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const handleSubmit = useCallback(async (text) => {
|
|
166
|
+
// Normalize trailing whitespace so '/exit ' (left over from a popup
|
|
167
|
+
// fill) is treated identically to '/exit'. Empty input → no-op.
|
|
168
|
+
const trimmed = (text || '').replace(/\s+$/, '');
|
|
169
|
+
if (!trimmed) return;
|
|
170
|
+
// /exit + /quit unmount the Ink app. Done inline so the popup path
|
|
171
|
+
// and the no-popup path both terminate cleanly.
|
|
172
|
+
if (trimmed === '/exit' || trimmed === '/quit') { exit(); return; }
|
|
173
|
+
// Other slash commands: hand off to the host's slash dispatcher
|
|
174
|
+
// (cli.mjs handleSlash) when one is provided. The host returns a
|
|
175
|
+
// string (or void) which we append to scrollback as an assistant
|
|
176
|
+
// turn so the user sees the result inline. If no dispatcher is
|
|
177
|
+
// wired, fall through to runTurn (legacy behavior).
|
|
178
|
+
if (trimmed.startsWith('/') && typeof onSlashCommand === 'function') {
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
setState((s) => onUserInput(s, { text: trimmed, controller }));
|
|
181
|
+
try {
|
|
182
|
+
const result = await onSlashCommand(trimmed);
|
|
183
|
+
if (result === 'EXIT') { exit(); return; }
|
|
184
|
+
if (typeof result === 'string' && result.length > 0) {
|
|
185
|
+
setState((s) => onStreamChunk(s, { chunk: result }));
|
|
186
|
+
}
|
|
187
|
+
setState((s) => onTurnComplete(s, { reason: 'done' }));
|
|
188
|
+
} catch (err) {
|
|
189
|
+
setState((s) => onTurnComplete(s, {
|
|
190
|
+
reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
71
195
|
const controller = new AbortController();
|
|
72
|
-
setState((s) => onUserInput(s, { text, controller }));
|
|
196
|
+
setState((s) => onUserInput(s, { text: trimmed, controller }));
|
|
73
197
|
try {
|
|
74
|
-
await
|
|
198
|
+
await runTurnRef.current(trimmed, controller.signal);
|
|
75
199
|
setState((s) => onTurnComplete(s, { reason: 'done' }));
|
|
76
200
|
} catch (err) {
|
|
77
|
-
setState((s) => onTurnComplete(s, {
|
|
201
|
+
setState((s) => onTurnComplete(s, {
|
|
202
|
+
reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
|
|
203
|
+
}));
|
|
78
204
|
}
|
|
79
|
-
}
|
|
205
|
+
}, [exit, onSlashCommand]);
|
|
80
206
|
|
|
207
|
+
// Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
|
|
208
|
+
// state.nextTurnFirstMessage so the effect re-fires when promoted.
|
|
81
209
|
useEffect(() => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
setState(
|
|
210
|
+
if (state.nextTurnFirstMessage) {
|
|
211
|
+
const msg = state.nextTurnFirstMessage;
|
|
212
|
+
setState((s) => ({ ...s, nextTurnFirstMessage: null }));
|
|
85
213
|
handleSubmit(msg);
|
|
86
214
|
}
|
|
87
|
-
}, [state.nextTurnFirstMessage]);
|
|
215
|
+
}, [state.nextTurnFirstMessage, handleSubmit]);
|
|
216
|
+
|
|
217
|
+
// Esc handler — abort streaming turn, drop partial output.
|
|
218
|
+
const onEscapeKey = useCallback(() => {
|
|
219
|
+
setState((s) => onEscape(s));
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
// ─── Slash popup state (v5.4) ──────────────────────────────────────
|
|
223
|
+
// The editor reports its current buffer via onBufferChange; we derive
|
|
224
|
+
// the filtered command list from that and own the selection index.
|
|
225
|
+
const catalog = useMemo(
|
|
226
|
+
() => (Array.isArray(slashCommands) && slashCommands.length > 0
|
|
227
|
+
? slashCommands : SLASH_COMMANDS),
|
|
228
|
+
[slashCommands]
|
|
229
|
+
);
|
|
230
|
+
const [bufferPeek, setBufferPeek] = useState('');
|
|
231
|
+
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
|
232
|
+
const filtered = useMemo(
|
|
233
|
+
() => filterSlashCommands(bufferPeek, catalog),
|
|
234
|
+
[bufferPeek, catalog]
|
|
235
|
+
);
|
|
236
|
+
// Reset selection whenever the match list changes length (typing
|
|
237
|
+
// narrows results, so highlight the first row again).
|
|
238
|
+
const lastLenRef = useRef(0);
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (filtered.length !== lastLenRef.current) {
|
|
241
|
+
setSelectedSuggestion(0);
|
|
242
|
+
lastLenRef.current = filtered.length;
|
|
243
|
+
} else if (selectedSuggestion >= filtered.length) {
|
|
244
|
+
setSelectedSuggestion(Math.max(0, filtered.length - 1));
|
|
245
|
+
}
|
|
246
|
+
}, [filtered.length, selectedSuggestion]);
|
|
247
|
+
|
|
248
|
+
const handleBufferChange = useCallback((buf) => {
|
|
249
|
+
setBufferPeek(buf || '');
|
|
250
|
+
}, []);
|
|
251
|
+
const handleSlashMove = useCallback((delta) => {
|
|
252
|
+
setSelectedSuggestion((i) => {
|
|
253
|
+
const max = Math.max(0, filtered.length - 1);
|
|
254
|
+
const n = i + delta;
|
|
255
|
+
if (n < 0) return 0;
|
|
256
|
+
if (n > max) return max;
|
|
257
|
+
return n;
|
|
258
|
+
});
|
|
259
|
+
}, [filtered.length]);
|
|
260
|
+
const handleSlashDismiss = useCallback(() => {
|
|
261
|
+
setBufferPeek('');
|
|
262
|
+
setSelectedSuggestion(0);
|
|
263
|
+
}, []);
|
|
264
|
+
|
|
265
|
+
// Hide the popup when the buffer already exactly matches the only
|
|
266
|
+
// remaining suggestion (with or without a trailing space). Otherwise
|
|
267
|
+
// the popup intercepts Enter and the fully-typed command (e.g.
|
|
268
|
+
// '/exit') never reaches handleSubmit. Belt-and-suspenders with the
|
|
269
|
+
// editor-side fall-through in tui/editor.mjs.
|
|
270
|
+
const _bufTrimmed = bufferPeek.replace(/\s+$/, '');
|
|
271
|
+
const _exactOnly =
|
|
272
|
+
filtered.length === 1 &&
|
|
273
|
+
(filtered[0].cmd === bufferPeek || filtered[0].cmd === _bufTrimmed);
|
|
274
|
+
const showSlashPopup =
|
|
275
|
+
bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
|
|
88
276
|
|
|
89
277
|
return React.createElement(
|
|
90
278
|
Box,
|
|
91
279
|
{ flexDirection: 'column' },
|
|
92
|
-
|
|
93
|
-
React.createElement(
|
|
280
|
+
// 1) Scrollback (write-once via Ink Static)
|
|
281
|
+
React.createElement(
|
|
282
|
+
Static,
|
|
283
|
+
{ items: state.scrollback },
|
|
284
|
+
(item) => React.createElement(ScrollbackItem, { key: item.id, item })
|
|
285
|
+
),
|
|
286
|
+
// 2) Live region — partial assistant stream
|
|
287
|
+
state.liveAssistant
|
|
288
|
+
? React.createElement(
|
|
289
|
+
Box,
|
|
290
|
+
{ flexDirection: 'column' },
|
|
291
|
+
React.createElement(Text, { color: theme.fg }, state.liveAssistant)
|
|
292
|
+
)
|
|
293
|
+
: null,
|
|
294
|
+
// 3) Slash popup — flex sibling above the StatusBar; Ink can't
|
|
295
|
+
// absolutely position so this is the "just above input" pattern.
|
|
296
|
+
showSlashPopup
|
|
297
|
+
? React.createElement(SlashPopup, {
|
|
298
|
+
buffer: bufferPeek,
|
|
299
|
+
commands: filtered,
|
|
300
|
+
selectedIndex: selectedSuggestion,
|
|
301
|
+
})
|
|
302
|
+
: null,
|
|
303
|
+
// 4) Status bar (sticky, single row above input)
|
|
304
|
+
React.createElement(StatusBar, {
|
|
305
|
+
provider: splashProps && splashProps.provider,
|
|
306
|
+
model: splashProps && splashProps.model,
|
|
307
|
+
streaming: state.streaming,
|
|
308
|
+
ctxUsed: splashProps && splashProps.ctxUsed,
|
|
309
|
+
ctxTotal: splashProps && splashProps.ctxTotal,
|
|
310
|
+
}),
|
|
311
|
+
// 5) Editor — sticky bottom, content-sized
|
|
312
|
+
React.createElement(Editor, {
|
|
313
|
+
history: state.history,
|
|
314
|
+
onSubmit: handleSubmit,
|
|
315
|
+
onEscape: onEscapeKey,
|
|
316
|
+
onBufferChange: handleBufferChange,
|
|
317
|
+
slashSuggestions: showSlashPopup ? filtered : null,
|
|
318
|
+
slashSelectedIndex: selectedSuggestion,
|
|
319
|
+
onSlashMove: handleSlashMove,
|
|
320
|
+
onSlashDismiss: handleSlashDismiss,
|
|
321
|
+
})
|
|
94
322
|
);
|
|
95
323
|
}
|
|
324
|
+
|
|
325
|
+
// ScrollbackItem renders each <Static/> child. Splash renders via the
|
|
326
|
+
// real <Splash/> component (preserves gradient wordmark colorization);
|
|
327
|
+
// everything else is plain Text.
|
|
328
|
+
export function ScrollbackItem({ item }) {
|
|
329
|
+
if (item.kind === 'splash') {
|
|
330
|
+
return React.createElement(Splash, item.splashProps);
|
|
331
|
+
}
|
|
332
|
+
if (item.kind === 'user') {
|
|
333
|
+
return React.createElement(
|
|
334
|
+
Box,
|
|
335
|
+
{ flexDirection: 'column' },
|
|
336
|
+
React.createElement(Text, null, theme.accent('› ') + item.text)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (item.kind === 'error') {
|
|
340
|
+
return React.createElement(Text, { color: 'red' }, item.text);
|
|
341
|
+
}
|
|
342
|
+
// 'assistant' (default)
|
|
343
|
+
return React.createElement(Text, { color: theme.fg }, item.text);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// StatusBar — single row, provider · model · ctx · streaming indicator.
|
|
347
|
+
// Kept intentionally minimal in v5.3; token gauges land separately once
|
|
348
|
+
// usage metrics flow into state.
|
|
349
|
+
export function StatusBar({ provider, model, streaming, ctxUsed, ctxTotal }) {
|
|
350
|
+
const ctx = (ctxUsed != null && ctxTotal != null) ? `${ctxUsed}/${ctxTotal}` : '--';
|
|
351
|
+
const indicator = streaming ? theme.accent('● streaming') : theme.dim('○ idle');
|
|
352
|
+
const prov = provider || '?';
|
|
353
|
+
const mdl = model || '?';
|
|
354
|
+
return React.createElement(
|
|
355
|
+
Box,
|
|
356
|
+
{ flexShrink: 0, paddingX: 1 },
|
|
357
|
+
React.createElement(Text, null, `${indicator} ${prov} · ${mdl} ctx ${ctx}`)
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Exported for tests that want to verify the splash snapshot without a TTY.
|
|
362
|
+
export function _renderSplashToString(splashProps) {
|
|
363
|
+
return renderSplashToString(splashProps);
|
|
364
|
+
}
|
|
@@ -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...)`);
|
|
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} │`);
|
|
312
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');
|
|
@@ -360,14 +382,55 @@ export function Splash(props) {
|
|
|
360
382
|
const palette = wordmark.palette;
|
|
361
383
|
const gradient = wordmark.gradient;
|
|
362
384
|
const showWordmark = cols >= WORDMARK_BREAKPOINT;
|
|
385
|
+
// Sloth banner is emitted at the TOP of NARROW output (45..89) only when
|
|
386
|
+
// it fits inside the terminal width — see renderNarrow() guard.
|
|
387
|
+
const showSlothNarrow =
|
|
388
|
+
cols >= NARROW_BREAKPOINT && cols < MEDIUM_BREAKPOINT &&
|
|
389
|
+
cols >= banner.width + LMARGIN.length * 2;
|
|
390
|
+
|
|
391
|
+
// Per-tier sloth row range [start, end). MEDIUM interleaves the sloth
|
|
392
|
+
// inside panel rows, so it gets colored via the border regex below — no
|
|
393
|
+
// dedicated band is needed for that tier.
|
|
394
|
+
let slothStart = -1, slothEnd = -1;
|
|
395
|
+
if (showWordmark) {
|
|
396
|
+
slothStart = wordmark.height + 1 + 1; // wordmark + blank + panel-top
|
|
397
|
+
slothEnd = slothStart + banner.height;
|
|
398
|
+
} else if (showSlothNarrow) {
|
|
399
|
+
slothStart = 0; // sloth is the first thing emitted
|
|
400
|
+
slothEnd = banner.height;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Section headers / summary / compact headline on NARROW that should be
|
|
404
|
+
// amber to match the WIDE wordmark accent. Matched by exact content.
|
|
405
|
+
const ACCENT_HEADERS = new Set(['Subcommands', 'Available Tools', 'Available Skills']);
|
|
406
|
+
// Panel border / status separator glyphs (leading box-drawing after
|
|
407
|
+
// optional whitespace). Catches ╭ ╰ │ as well as ─ separators.
|
|
408
|
+
const BORDER_RE = /^\s*[╭╰│├┤┬┴┼─╮╯]/;
|
|
409
|
+
// NARROW compact headline (e.g. " lazyclaw 5.3.0").
|
|
410
|
+
const HEADLINE_RE = /^\s*lazyclaw\s+\S/;
|
|
411
|
+
// NARROW summary line ("N subcmds · M tools · K skills · /help" or its
|
|
412
|
+
// wrapped variant). Also catches "/help for commands".
|
|
413
|
+
const SUMMARY_RE = /(subcmds\s+·|tools\s+·\s+\d+\s+skills|\/help\s+for\s+commands)/;
|
|
363
414
|
|
|
364
415
|
return React.createElement(
|
|
365
416
|
Box,
|
|
366
417
|
{ flexDirection: 'column' },
|
|
367
418
|
lines.map((line, i) => {
|
|
368
419
|
let color;
|
|
369
|
-
|
|
370
|
-
|
|
420
|
+
const trimmed = line.trim();
|
|
421
|
+
if (showWordmark && i < wordmark.height) {
|
|
422
|
+
color = palette[gradient[i] ?? 1]; // wordmark gradient
|
|
423
|
+
} else if (i >= slothStart && i < slothEnd) {
|
|
424
|
+
color = theme.fg; // sloth band — any tier that stacks the sloth
|
|
425
|
+
} else if (BORDER_RE.test(line)) {
|
|
426
|
+
color = theme.fg; // panel borders + status separators
|
|
427
|
+
} else if (ACCENT_HEADERS.has(trimmed)) {
|
|
428
|
+
color = theme.fg; // section headers
|
|
429
|
+
} else if (!showWordmark && HEADLINE_RE.test(line) && /\d/.test(line)) {
|
|
430
|
+
color = theme.fg; // narrow/medium compact headline ("lazyclaw 5.x.y")
|
|
431
|
+
} else if (!showWordmark && SUMMARY_RE.test(line)) {
|
|
432
|
+
color = theme.fg; // narrow/medium summary line
|
|
433
|
+
}
|
|
371
434
|
return React.createElement(Text, { key: i, color }, line);
|
|
372
435
|
})
|
|
373
436
|
);
|
package/tui/theme.mjs
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// tui/theme.mjs — single source of truth for lazyclaw v5 color tokens.
|
|
2
2
|
// The amber hex is also stamped into tui/banner.generated.mjs so the
|
|
3
3
|
// sloth gutter and the prompt accent stay visually paired.
|
|
4
|
+
//
|
|
5
|
+
// v5.5: added `border` token for the chat-input frame (Claude-CLI-style
|
|
6
|
+
// rounded box around the editor). Kept subtly grayer than `amber` so the
|
|
7
|
+
// frame doesn't compete with the accent `›` or the sloth gutter.
|
|
4
8
|
import chalk from 'chalk';
|
|
5
9
|
|
|
6
10
|
const AMBER_HEX = '#FFB347';
|
|
11
|
+
const BORDER_HEX = '#5A5A5A';
|
|
7
12
|
|
|
8
13
|
function amber(text) {
|
|
9
14
|
return chalk.hex(AMBER_HEX)(text);
|
|
@@ -28,6 +33,7 @@ function plain(text) {
|
|
|
28
33
|
export const theme = {
|
|
29
34
|
amber: AMBER_HEX,
|
|
30
35
|
fg: AMBER_HEX,
|
|
36
|
+
border: BORDER_HEX,
|
|
31
37
|
colorize: amber,
|
|
32
38
|
dim,
|
|
33
39
|
accent,
|