lazyclaw 6.4.0 → 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/commands/agents.mjs +3 -194
- package/commands/agents_registry.mjs +205 -0
- package/commands/automation.mjs +8 -89
- package/commands/automation_loops.mjs +94 -0
- package/commands/chat.mjs +46 -680
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/help_text.mjs +78 -0
- package/commands/setup.mjs +1 -73
- package/daemon/lib/cost.mjs +5 -0
- package/daemon.mjs +1 -1
- package/mas/agent_turn.mjs +7 -0
- package/package.json +1 -1
- package/providers/claude_cli.mjs +1 -1
- package/providers/claude_cli_session.mjs +21 -4
- package/providers/codex_cli.mjs +8 -5
- package/providers/gemini.mjs +4 -1
- package/providers/gemini_cli.mjs +17 -3
- package/providers/tool_use/gemini.mjs +12 -1
- package/providers/tool_use/openai.mjs +7 -0
- package/tui/banner.mjs +72 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +34 -180
- package/tui/repl.mjs +12 -165
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +12 -640
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_trainer.mjs +173 -0
|
@@ -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,135 @@
|
|
|
1
|
+
// tui/orchestrator_setup.mjs — the composite-provider (orchestrator) setup
|
|
2
|
+
// wizard, extracted verbatim from tui/pickers.mjs. It builds
|
|
3
|
+
// `cfg.orchestrator = { planner, workers, maxSubtasks }` interactively and
|
|
4
|
+
// persists it.
|
|
5
|
+
//
|
|
6
|
+
// CIRCULAR IMPORT: this module imports _arrowMenu / _quickPrompt from
|
|
7
|
+
// ./pickers.mjs, and pickers re-exports _setupOrchestratorInteractive from
|
|
8
|
+
// here — a pickers ↔ orchestrator_setup cycle. It is safe because both helpers
|
|
9
|
+
// are referenced at CALL TIME (inside the function body), never at module load,
|
|
10
|
+
// and ES live bindings resolve them once pickers finishes loading. pickers puts
|
|
11
|
+
// its re-export line at the bottom (after _arrowMenu/_quickPrompt are declared),
|
|
12
|
+
// and both helpers are hoisted top-level function declarations.
|
|
13
|
+
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
14
|
+
import { getRegistry } from '../lib/registry_boot.mjs';
|
|
15
|
+
import { paint } from './theme.mjs';
|
|
16
|
+
import { _arrowMenu, _quickPrompt } from './pickers.mjs';
|
|
17
|
+
|
|
18
|
+
// Step-3 alternative for composite providers (currently only the
|
|
19
|
+
// orchestrator). Builds `cfg.orchestrator = { planner, workers,
|
|
20
|
+
// maxSubtasks }` interactively and persists it before returning.
|
|
21
|
+
//
|
|
22
|
+
// planner: single picker over registered non-composite providers.
|
|
23
|
+
// workers: multi-select with a running list + add/remove/done loop.
|
|
24
|
+
// maxSubtasks: typed integer, default 5.
|
|
25
|
+
export async function _setupOrchestratorInteractive() {
|
|
26
|
+
const accent = (s) => paint('38;5;208', s);
|
|
27
|
+
const dim = (s) => paint('2', s);
|
|
28
|
+
const bold = (s) => paint('1', s);
|
|
29
|
+
const ok = (s) => paint('32', s);
|
|
30
|
+
const info = getRegistry().PROVIDER_INFO || {};
|
|
31
|
+
const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
|
|
32
|
+
if (eligibleNames.length === 0) {
|
|
33
|
+
process.stdout.write('\n' + accent('orchestrator setup') + ': no eligible workers — register a real provider first.\n');
|
|
34
|
+
await _quickPrompt(' press Enter to continue ');
|
|
35
|
+
return 'CANCEL';
|
|
36
|
+
}
|
|
37
|
+
const cfg = readConfig();
|
|
38
|
+
const existing = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
|
|
39
|
+
|
|
40
|
+
// ── Pick planner ─────────────────────────────────────────────────
|
|
41
|
+
const plannerItems = eligibleNames.map((name) => {
|
|
42
|
+
const m = info[name] || {};
|
|
43
|
+
const defaultModel = m.defaultModel || '';
|
|
44
|
+
return {
|
|
45
|
+
id: `${name}${defaultModel ? ':' + defaultModel : ''}`,
|
|
46
|
+
label: m.label && m.label !== name ? `${name} — ${m.label}` : name,
|
|
47
|
+
desc: defaultModel ? `default model: ${defaultModel}` : '',
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const plannerPick = await _arrowMenu({
|
|
51
|
+
title: 'LazyClaw setup — Step 3 of 3: orchestrator — pick the planner',
|
|
52
|
+
subtitle: 'The planner decomposes the user request into subtasks and writes the final synthesis. Strong reasoning models work best here.',
|
|
53
|
+
items: plannerItems,
|
|
54
|
+
searchable: true,
|
|
55
|
+
defaultIdx: Math.max(0, plannerItems.findIndex((p) => p.id === existing.planner)),
|
|
56
|
+
});
|
|
57
|
+
if (plannerPick === 'CANCEL') return 'CANCEL';
|
|
58
|
+
if (plannerPick === 'BACK') return 'BACK';
|
|
59
|
+
const planner = plannerPick.id;
|
|
60
|
+
|
|
61
|
+
// ── Pick workers (iterative add/remove) ──────────────────────────
|
|
62
|
+
const workers = Array.isArray(existing.workers) ? existing.workers.slice() : [];
|
|
63
|
+
while (true) {
|
|
64
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
65
|
+
process.stdout.write(accent('Orchestrator workers') + '\n');
|
|
66
|
+
process.stdout.write(dim('Subtasks are dispatched round-robin across this list.') + '\n\n');
|
|
67
|
+
if (workers.length === 0) {
|
|
68
|
+
process.stdout.write(' ' + dim('(none yet — add at least one)') + '\n\n');
|
|
69
|
+
} else {
|
|
70
|
+
workers.forEach((w, i) => {
|
|
71
|
+
process.stdout.write(` ${i + 1}. ${ok(w)}\n`);
|
|
72
|
+
});
|
|
73
|
+
process.stdout.write('\n');
|
|
74
|
+
}
|
|
75
|
+
const items = [
|
|
76
|
+
{ id: '__add__', label: '+ Add a worker', desc: 'pick from registered providers' },
|
|
77
|
+
{ id: '__remove__', label: '- Remove a worker', desc: workers.length ? 'pick which entry to drop' : '(nothing to remove)' },
|
|
78
|
+
{ id: '__done__', label: `Done${workers.length ? ` (${workers.length} worker${workers.length === 1 ? '' : 's'})` : ' — at least one worker required'}`, desc: workers.length ? 'save cfg.orchestrator and finish' : 'add one worker first' },
|
|
79
|
+
];
|
|
80
|
+
const action = await _arrowMenu({
|
|
81
|
+
title: 'LazyClaw setup — orchestrator workers',
|
|
82
|
+
subtitle: `Planner: ${planner}`,
|
|
83
|
+
items,
|
|
84
|
+
});
|
|
85
|
+
if (action === 'CANCEL') return 'CANCEL';
|
|
86
|
+
if (action === 'BACK') return 'BACK';
|
|
87
|
+
if (action.id === '__add__') {
|
|
88
|
+
const wPick = await _arrowMenu({
|
|
89
|
+
title: 'Add worker',
|
|
90
|
+
subtitle: 'Picked entries are appended to the workers list.',
|
|
91
|
+
items: plannerItems.filter((p) => !workers.includes(p.id)),
|
|
92
|
+
searchable: true,
|
|
93
|
+
});
|
|
94
|
+
if (wPick === 'CANCEL' || wPick === 'BACK') continue;
|
|
95
|
+
workers.push(wPick.id);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (action.id === '__remove__') {
|
|
99
|
+
if (!workers.length) continue;
|
|
100
|
+
const rPick = await _arrowMenu({
|
|
101
|
+
title: 'Remove worker',
|
|
102
|
+
subtitle: 'Highlighted entry is removed from the list.',
|
|
103
|
+
items: workers.map((w) => ({ id: w, label: w })),
|
|
104
|
+
});
|
|
105
|
+
if (rPick === 'CANCEL' || rPick === 'BACK') continue;
|
|
106
|
+
const idx = workers.indexOf(rPick.id);
|
|
107
|
+
if (idx >= 0) workers.splice(idx, 1);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (action.id === '__done__') {
|
|
111
|
+
if (workers.length === 0) continue;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── maxSubtasks ──────────────────────────────────────────────────
|
|
117
|
+
const defaultMax = Number.isFinite(existing.maxSubtasks) && existing.maxSubtasks > 0
|
|
118
|
+
? Math.min(10, existing.maxSubtasks)
|
|
119
|
+
: 5;
|
|
120
|
+
const rawMax = (await _quickPrompt(` ${bold('maxSubtasks')} ${dim(`(2..10, blank → ${defaultMax}):`)} `)).trim();
|
|
121
|
+
let maxSubtasks = defaultMax;
|
|
122
|
+
if (rawMax) {
|
|
123
|
+
const n = parseInt(rawMax, 10);
|
|
124
|
+
if (Number.isFinite(n) && n >= 1) maxSubtasks = Math.min(10, Math.max(1, n));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Persist ──────────────────────────────────────────────────────
|
|
128
|
+
cfg.orchestrator = { planner, workers, maxSubtasks };
|
|
129
|
+
writeConfig(cfg);
|
|
130
|
+
process.stdout.write('\n');
|
|
131
|
+
process.stdout.write(` ${ok('✓ orchestrator saved')} ${dim('→')} ` +
|
|
132
|
+
`planner ${ok(planner)} · ${workers.length} worker${workers.length === 1 ? '' : 's'} · maxSubtasks ${maxSubtasks}\n`);
|
|
133
|
+
await _quickPrompt(' press Enter to continue ');
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
package/tui/pickers.mjs
CHANGED
|
@@ -12,6 +12,24 @@ import {
|
|
|
12
12
|
supportsLiveFetch as _supportsLiveFetch,
|
|
13
13
|
} from '../providers/model_catalogue.mjs';
|
|
14
14
|
import { paint } from './theme.mjs';
|
|
15
|
+
// Banner renderers live in a leaf sibling module (tui/banner.mjs). We import
|
|
16
|
+
// the ones pickers' own code still calls (_orange / _ORANGE_RGB in
|
|
17
|
+
// _printChatBanner, _loadBannerAssets / _renderBanner / _orange in
|
|
18
|
+
// _renderV5Banner) and re-export the public surface so existing importers
|
|
19
|
+
// (cli.mjs / commands/setup.mjs / commands/chat.mjs) keep resolving them here.
|
|
20
|
+
import {
|
|
21
|
+
_ORANGE_RGB,
|
|
22
|
+
_orange,
|
|
23
|
+
_renderBanner,
|
|
24
|
+
_loadBannerAssets,
|
|
25
|
+
} from './banner.mjs';
|
|
26
|
+
export {
|
|
27
|
+
_orange,
|
|
28
|
+
_renderMascot,
|
|
29
|
+
_renderMascotTiny,
|
|
30
|
+
_renderBanner,
|
|
31
|
+
_loadBannerAssets,
|
|
32
|
+
} from './banner.mjs';
|
|
15
33
|
|
|
16
34
|
// Read exactly ONE line from a non-TTY stream (piped stdin) and hand the rest
|
|
17
35
|
// back so the next reader (the next picker / a credential prompt) still gets
|
|
@@ -141,73 +159,16 @@ export function _attachGhostAutocomplete(rl) {
|
|
|
141
159
|
};
|
|
142
160
|
}
|
|
143
161
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// Layout invariants: every inner row is forced through `.padEnd(INNER_W)`
|
|
150
|
-
// and is exactly INNER_W single-cell glyphs, so the right border `│` always
|
|
151
|
-
// lands in the same column. _renderMascot / _renderMascotTiny are stubs kept
|
|
152
|
-
// so any leftover caller doesn't crash.
|
|
153
|
-
|
|
154
|
-
const _ORANGE_RGB = '241;130;70'; // #F18246
|
|
155
|
-
export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
|
|
156
|
-
|
|
157
|
-
export function _renderMascot() {
|
|
158
|
-
return ['lazyclaw'];
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function _renderMascotTiny() {
|
|
162
|
-
return 'lazyclaw';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// figlet "standard" "lazy", trimmed of leading blank line. Each row
|
|
166
|
-
// is left-padded by two spaces inside the box, and every row is then
|
|
167
|
-
// padded to INNER_W cells.
|
|
168
|
-
const _LAZY_STANDARD = [
|
|
169
|
-
' _ ',
|
|
170
|
-
'| | __ _ _____ _ ',
|
|
171
|
-
'| |/ _` |_ / | | | ',
|
|
172
|
-
'| | (_| |/ /| |_| | ',
|
|
173
|
-
'|_|\\__,_/___|\\__, | ',
|
|
174
|
-
' |___/ ',
|
|
175
|
-
];
|
|
176
|
-
|
|
177
|
-
const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
|
|
178
|
-
|
|
179
|
-
export function _renderBanner(version) {
|
|
180
|
-
const v = String(version || '?.?.?');
|
|
181
|
-
const cap = ` LazyClaw v${v}`;
|
|
182
|
-
const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
|
|
183
|
-
const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
|
|
184
|
-
const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
|
|
185
|
-
const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
|
|
186
|
-
return [
|
|
187
|
-
top,
|
|
188
|
-
..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
|
|
189
|
-
wrap(padInner(cap)),
|
|
190
|
-
bot,
|
|
191
|
-
];
|
|
192
|
-
}
|
|
162
|
+
// Banner renderers (_ORANGE_RGB / _orange / _renderMascot / _renderMascotTiny
|
|
163
|
+
// / _renderBanner / _loadBannerAssets) moved to tui/banner.mjs — imported at
|
|
164
|
+
// the top of this file and re-exported there for existing callers. The v5
|
|
165
|
+
// splash composer and chat header below still live here because they read
|
|
166
|
+
// pickers-local state and stay close to the wizard that prints them.
|
|
193
167
|
|
|
194
168
|
// v5 hero banner — ANSI Shadow LAZYCLAW wordmark stacked on top of the
|
|
195
169
|
// braille sloth (tui/banner.generated.mjs + tui/wordmark.mjs). Left-aligned
|
|
196
170
|
// with a 2-cell margin so wide terminals don't push the art to the right.
|
|
197
171
|
// Opt out with LAZYCLAW_LEGACY_MENU=1 to fall back to the v4 figlet box.
|
|
198
|
-
let _bannerAssetsCache = null;
|
|
199
|
-
export async function _loadBannerAssets() {
|
|
200
|
-
if (_bannerAssetsCache !== null) return _bannerAssetsCache;
|
|
201
|
-
try {
|
|
202
|
-
const { banner } = await import('./banner.generated.mjs');
|
|
203
|
-
const { wordmark } = await import('./wordmark.mjs');
|
|
204
|
-
_bannerAssetsCache = { banner, wordmark };
|
|
205
|
-
} catch {
|
|
206
|
-
_bannerAssetsCache = null;
|
|
207
|
-
}
|
|
208
|
-
return _bannerAssetsCache;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
172
|
export async function _renderV5Banner(version) {
|
|
212
173
|
const a = await _loadBannerAssets();
|
|
213
174
|
if (!a) return _renderBanner(version); // missing tarball asset → v4 figlet
|
|
@@ -618,124 +579,10 @@ export async function _pickProviderInteractive() {
|
|
|
618
579
|
return { provider: provider.id, model: picked };
|
|
619
580
|
}
|
|
620
581
|
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
// planner: single picker over registered non-composite providers.
|
|
626
|
-
// workers: multi-select with a running list + add/remove/done loop.
|
|
627
|
-
// maxSubtasks: typed integer, default 5.
|
|
628
|
-
export async function _setupOrchestratorInteractive() {
|
|
629
|
-
const accent = (s) => paint('38;5;208', s);
|
|
630
|
-
const dim = (s) => paint('2', s);
|
|
631
|
-
const bold = (s) => paint('1', s);
|
|
632
|
-
const ok = (s) => paint('32', s);
|
|
633
|
-
const info = getRegistry().PROVIDER_INFO || {};
|
|
634
|
-
const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
|
|
635
|
-
if (eligibleNames.length === 0) {
|
|
636
|
-
process.stdout.write('\n' + accent('orchestrator setup') + ': no eligible workers — register a real provider first.\n');
|
|
637
|
-
await _quickPrompt(' press Enter to continue ');
|
|
638
|
-
return 'CANCEL';
|
|
639
|
-
}
|
|
640
|
-
const cfg = readConfig();
|
|
641
|
-
const existing = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
|
|
642
|
-
|
|
643
|
-
// ── Pick planner ─────────────────────────────────────────────────
|
|
644
|
-
const plannerItems = eligibleNames.map((name) => {
|
|
645
|
-
const m = info[name] || {};
|
|
646
|
-
const defaultModel = m.defaultModel || '';
|
|
647
|
-
return {
|
|
648
|
-
id: `${name}${defaultModel ? ':' + defaultModel : ''}`,
|
|
649
|
-
label: m.label && m.label !== name ? `${name} — ${m.label}` : name,
|
|
650
|
-
desc: defaultModel ? `default model: ${defaultModel}` : '',
|
|
651
|
-
};
|
|
652
|
-
});
|
|
653
|
-
const plannerPick = await _arrowMenu({
|
|
654
|
-
title: 'LazyClaw setup — Step 3 of 3: orchestrator — pick the planner',
|
|
655
|
-
subtitle: 'The planner decomposes the user request into subtasks and writes the final synthesis. Strong reasoning models work best here.',
|
|
656
|
-
items: plannerItems,
|
|
657
|
-
searchable: true,
|
|
658
|
-
defaultIdx: Math.max(0, plannerItems.findIndex((p) => p.id === existing.planner)),
|
|
659
|
-
});
|
|
660
|
-
if (plannerPick === 'CANCEL') return 'CANCEL';
|
|
661
|
-
if (plannerPick === 'BACK') return 'BACK';
|
|
662
|
-
const planner = plannerPick.id;
|
|
663
|
-
|
|
664
|
-
// ── Pick workers (iterative add/remove) ──────────────────────────
|
|
665
|
-
const workers = Array.isArray(existing.workers) ? existing.workers.slice() : [];
|
|
666
|
-
while (true) {
|
|
667
|
-
process.stdout.write('\x1b[2J\x1b[H');
|
|
668
|
-
process.stdout.write(accent('Orchestrator workers') + '\n');
|
|
669
|
-
process.stdout.write(dim('Subtasks are dispatched round-robin across this list.') + '\n\n');
|
|
670
|
-
if (workers.length === 0) {
|
|
671
|
-
process.stdout.write(' ' + dim('(none yet — add at least one)') + '\n\n');
|
|
672
|
-
} else {
|
|
673
|
-
workers.forEach((w, i) => {
|
|
674
|
-
process.stdout.write(` ${i + 1}. ${ok(w)}\n`);
|
|
675
|
-
});
|
|
676
|
-
process.stdout.write('\n');
|
|
677
|
-
}
|
|
678
|
-
const items = [
|
|
679
|
-
{ id: '__add__', label: '+ Add a worker', desc: 'pick from registered providers' },
|
|
680
|
-
{ id: '__remove__', label: '- Remove a worker', desc: workers.length ? 'pick which entry to drop' : '(nothing to remove)' },
|
|
681
|
-
{ id: '__done__', label: `Done${workers.length ? ` (${workers.length} worker${workers.length === 1 ? '' : 's'})` : ' — at least one worker required'}`, desc: workers.length ? 'save cfg.orchestrator and finish' : 'add one worker first' },
|
|
682
|
-
];
|
|
683
|
-
const action = await _arrowMenu({
|
|
684
|
-
title: 'LazyClaw setup — orchestrator workers',
|
|
685
|
-
subtitle: `Planner: ${planner}`,
|
|
686
|
-
items,
|
|
687
|
-
});
|
|
688
|
-
if (action === 'CANCEL') return 'CANCEL';
|
|
689
|
-
if (action === 'BACK') return 'BACK';
|
|
690
|
-
if (action.id === '__add__') {
|
|
691
|
-
const wPick = await _arrowMenu({
|
|
692
|
-
title: 'Add worker',
|
|
693
|
-
subtitle: 'Picked entries are appended to the workers list.',
|
|
694
|
-
items: plannerItems.filter((p) => !workers.includes(p.id)),
|
|
695
|
-
searchable: true,
|
|
696
|
-
});
|
|
697
|
-
if (wPick === 'CANCEL' || wPick === 'BACK') continue;
|
|
698
|
-
workers.push(wPick.id);
|
|
699
|
-
continue;
|
|
700
|
-
}
|
|
701
|
-
if (action.id === '__remove__') {
|
|
702
|
-
if (!workers.length) continue;
|
|
703
|
-
const rPick = await _arrowMenu({
|
|
704
|
-
title: 'Remove worker',
|
|
705
|
-
subtitle: 'Highlighted entry is removed from the list.',
|
|
706
|
-
items: workers.map((w) => ({ id: w, label: w })),
|
|
707
|
-
});
|
|
708
|
-
if (rPick === 'CANCEL' || rPick === 'BACK') continue;
|
|
709
|
-
const idx = workers.indexOf(rPick.id);
|
|
710
|
-
if (idx >= 0) workers.splice(idx, 1);
|
|
711
|
-
continue;
|
|
712
|
-
}
|
|
713
|
-
if (action.id === '__done__') {
|
|
714
|
-
if (workers.length === 0) continue;
|
|
715
|
-
break;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// ── maxSubtasks ──────────────────────────────────────────────────
|
|
720
|
-
const defaultMax = Number.isFinite(existing.maxSubtasks) && existing.maxSubtasks > 0
|
|
721
|
-
? Math.min(10, existing.maxSubtasks)
|
|
722
|
-
: 5;
|
|
723
|
-
const rawMax = (await _quickPrompt(` ${bold('maxSubtasks')} ${dim(`(2..10, blank → ${defaultMax}):`)} `)).trim();
|
|
724
|
-
let maxSubtasks = defaultMax;
|
|
725
|
-
if (rawMax) {
|
|
726
|
-
const n = parseInt(rawMax, 10);
|
|
727
|
-
if (Number.isFinite(n) && n >= 1) maxSubtasks = Math.min(10, Math.max(1, n));
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// ── Persist ──────────────────────────────────────────────────────
|
|
731
|
-
cfg.orchestrator = { planner, workers, maxSubtasks };
|
|
732
|
-
writeConfig(cfg);
|
|
733
|
-
process.stdout.write('\n');
|
|
734
|
-
process.stdout.write(` ${ok('✓ orchestrator saved')} ${dim('→')} ` +
|
|
735
|
-
`planner ${ok(planner)} · ${workers.length} worker${workers.length === 1 ? '' : 's'} · maxSubtasks ${maxSubtasks}\n`);
|
|
736
|
-
await _quickPrompt(' press Enter to continue ');
|
|
737
|
-
return { ok: true };
|
|
738
|
-
}
|
|
582
|
+
// _setupOrchestratorInteractive moved to tui/orchestrator_setup.mjs (it imports
|
|
583
|
+
// _arrowMenu / _quickPrompt back from here at call time → a safe cycle). The
|
|
584
|
+
// re-export line sits at the BOTTOM of this file, after those helpers are
|
|
585
|
+
// declared, so the live bindings resolve.
|
|
739
586
|
|
|
740
587
|
// Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
|
|
741
588
|
// (provider / model arrow menu) takes over the terminal. The sub-picker
|
|
@@ -1030,3 +877,10 @@ export async function _quickPromptSecret(label) {
|
|
|
1030
877
|
});
|
|
1031
878
|
return value.trim();
|
|
1032
879
|
}
|
|
880
|
+
|
|
881
|
+
// Re-export the orchestrator setup wizard (moved to tui/orchestrator_setup.mjs).
|
|
882
|
+
// Kept at the BOTTOM so _arrowMenu / _quickPrompt — which that module imports
|
|
883
|
+
// back from here — are already declared when the cycle resolves. The helpers
|
|
884
|
+
// are hoisted top-level function declarations and used only at call time, so
|
|
885
|
+
// the pickers ↔ orchestrator_setup cycle never reads an uninitialised binding.
|
|
886
|
+
export { _setupOrchestratorInteractive } from './orchestrator_setup.mjs';
|
package/tui/repl.mjs
CHANGED
|
@@ -43,171 +43,18 @@ import { ModalPicker, filterModalItems, resolveModalPick } from './modal_picker.
|
|
|
43
43
|
import { theme } from './theme.mjs';
|
|
44
44
|
import { StatusBar } from './status_bar.mjs';
|
|
45
45
|
import { onConversationReset, clearTerminalScreen } from './repl_reset.mjs'; export { StatusBar };
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// We deliberately do NOT install an uncaughtException handler — Ink
|
|
59
|
-
// already installs one and re-throws; ours would swallow the stack
|
|
60
|
-
// trace (violates §1 Truthfulness / no silent catch).
|
|
61
|
-
//
|
|
62
|
-
// `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
|
|
63
|
-
// the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
|
|
64
|
-
// no escape sequences leak into stdout.
|
|
65
|
-
export const ALT_BUFFER_ENTER = '\x1b[?1049h';
|
|
66
|
-
export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
|
|
67
|
-
export const CURSOR_VISIBLE = '\x1b[?25h';
|
|
68
|
-
|
|
69
|
-
// Rendering-mode decision. Default = Static scrollback (no flicker; splash
|
|
70
|
-
// prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
|
|
71
|
-
// LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
|
|
72
|
-
export function computeAltEnabled(env, hasTTY) {
|
|
73
|
-
const e = env || {};
|
|
74
|
-
return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function FullScreen({ enabled, children }) {
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (!enabled) return undefined;
|
|
80
|
-
// Mount: enter alternate screen buffer.
|
|
81
|
-
try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
|
|
82
|
-
|
|
83
|
-
// Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
|
|
84
|
-
// terminal is restored even if React never gets a chance to unmount
|
|
85
|
-
// (e.g. parent process kills us with SIGTERM).
|
|
86
|
-
const restore = () => {
|
|
87
|
-
try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
|
|
88
|
-
};
|
|
89
|
-
const onExit = () => { restore(); };
|
|
90
|
-
const onSignal = () => { restore(); };
|
|
91
|
-
process.once('exit', onExit);
|
|
92
|
-
process.once('SIGINT', onSignal);
|
|
93
|
-
process.once('SIGTERM', onSignal);
|
|
94
|
-
process.once('SIGHUP', onSignal);
|
|
95
|
-
|
|
96
|
-
return () => {
|
|
97
|
-
// React unmount: restore primary buffer.
|
|
98
|
-
restore();
|
|
99
|
-
process.removeListener('exit', onExit);
|
|
100
|
-
process.removeListener('SIGINT', onSignal);
|
|
101
|
-
process.removeListener('SIGTERM', onSignal);
|
|
102
|
-
process.removeListener('SIGHUP', onSignal);
|
|
103
|
-
};
|
|
104
|
-
}, [enabled]);
|
|
105
|
-
return children;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ─── Pure state ──────────────────────────────────────────────────────────
|
|
109
|
-
//
|
|
110
|
-
// makeReplState stays callable with zero args (existing tests rely on it).
|
|
111
|
-
// The new fields default to empty so legacy callers see no behavior change.
|
|
112
|
-
export function makeReplState(opts) {
|
|
113
|
-
const splashItem = opts && opts.splashItem ? opts.splashItem : null;
|
|
114
|
-
return {
|
|
115
|
-
streaming: false,
|
|
116
|
-
controller: null,
|
|
117
|
-
pendingPrepend: null,
|
|
118
|
-
nextTurnFirstMessage: null,
|
|
119
|
-
history: [],
|
|
120
|
-
scrollback: splashItem ? [splashItem] : [],
|
|
121
|
-
liveAssistant: '',
|
|
122
|
-
turnCounter: 0,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function onUserInput(state, { text, controller }) {
|
|
127
|
-
if (state.streaming && state.controller) {
|
|
128
|
-
// mid-stream interrupt — abort current turn, queue text for next turn.
|
|
129
|
-
try { state.controller.abort(); } catch {}
|
|
130
|
-
return { ...state, pendingPrepend: text };
|
|
131
|
-
}
|
|
132
|
-
// idle — start a new turn. Append a 'user' entry to scrollback so the
|
|
133
|
-
// sticky-layout caller sees the prompt history above the live stream.
|
|
134
|
-
const id = `u-${state.turnCounter}`;
|
|
135
|
-
return {
|
|
136
|
-
...state,
|
|
137
|
-
streaming: true,
|
|
138
|
-
controller,
|
|
139
|
-
history: [...state.history, text],
|
|
140
|
-
scrollback: [...state.scrollback, { kind: 'user', id, text }],
|
|
141
|
-
turnCounter: state.turnCounter + 1,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function onEscape(state) {
|
|
146
|
-
if (state.streaming && state.controller) {
|
|
147
|
-
try { state.controller.abort(); } catch {}
|
|
148
|
-
}
|
|
149
|
-
// Drop any partial live assistant text on explicit Esc — the user is
|
|
150
|
-
// telling us to discard, not to keep.
|
|
151
|
-
return {
|
|
152
|
-
...state,
|
|
153
|
-
streaming: false,
|
|
154
|
-
controller: null,
|
|
155
|
-
pendingPrepend: null,
|
|
156
|
-
liveAssistant: '',
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Stream chunk arrives. Completed lines are committed to the <Static>
|
|
161
|
-
// scrollback immediately (so they scroll up ABOVE the sticky editor), and only
|
|
162
|
-
// the in-progress trailing partial stays in the live region. Without this, a
|
|
163
|
-
// reply taller than the terminal grew the live frame past the viewport and
|
|
164
|
-
// spilled BELOW the input box (long orchestrator replies). Chunks without a
|
|
165
|
-
// newline still just accumulate (the prior behaviour), so short replies and the
|
|
166
|
-
// existing reducer tests are unchanged.
|
|
167
|
-
export function onStreamChunk(state, { chunk }) {
|
|
168
|
-
const buf = state.liveAssistant + chunk;
|
|
169
|
-
const nl = buf.lastIndexOf('\n');
|
|
170
|
-
if (nl < 0) return { ...state, liveAssistant: buf };
|
|
171
|
-
const complete = buf.slice(0, nl); // one or more whole lines
|
|
172
|
-
const remainder = buf.slice(nl + 1); // trailing partial (may be '')
|
|
173
|
-
const id = `as-${state.turnCounter}-${state.scrollback.length}`;
|
|
174
|
-
return {
|
|
175
|
-
...state,
|
|
176
|
-
scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
|
|
177
|
-
liveAssistant: remainder,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function onTurnComplete(state, { reason, error } = {}) {
|
|
182
|
-
const promoted = state.pendingPrepend;
|
|
183
|
-
const suffix = reason === 'aborted' ? ' [aborted]'
|
|
184
|
-
: reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
|
|
185
|
-
: '';
|
|
186
|
-
const text = (state.liveAssistant || '') + suffix;
|
|
187
|
-
// Commit any accumulated live text to scrollback. If the turn produced
|
|
188
|
-
// nothing AND wasn't an error/abort, skip the empty append.
|
|
189
|
-
const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
|
|
190
|
-
const id = `a-${state.turnCounter}`;
|
|
191
|
-
const kind = reason === 'error' ? 'error' : 'assistant';
|
|
192
|
-
const nextScrollback = shouldCommit
|
|
193
|
-
? [...state.scrollback, { kind, id, text }]
|
|
194
|
-
: state.scrollback;
|
|
195
|
-
return {
|
|
196
|
-
...state,
|
|
197
|
-
streaming: false,
|
|
198
|
-
controller: null,
|
|
199
|
-
pendingPrepend: null,
|
|
200
|
-
nextTurnFirstMessage: promoted,
|
|
201
|
-
liveAssistant: '',
|
|
202
|
-
scrollback: nextScrollback,
|
|
203
|
-
turnCounter: state.turnCounter + 1,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function consumeNextTurnFirstMessage(state) {
|
|
208
|
-
const msg = state.nextTurnFirstMessage;
|
|
209
|
-
return [{ ...state, nextTurnFirstMessage: null }, msg];
|
|
210
|
-
}
|
|
46
|
+
// Alt-buffer (DEC 1049) mount cluster moved to ./repl_altbuffer.mjs and pure
|
|
47
|
+
// state reducers moved to ./repl_reducers.mjs (file-size gate). Re-exported so
|
|
48
|
+
// every existing caller + test sees them on repl.mjs, and imported locally
|
|
49
|
+
// because the ReplApp body binds them directly.
|
|
50
|
+
import { computeAltEnabled, FullScreen } from './repl_altbuffer.mjs';
|
|
51
|
+
export { ALT_BUFFER_ENTER, ALT_BUFFER_LEAVE, CURSOR_VISIBLE, computeAltEnabled, FullScreen } from './repl_altbuffer.mjs';
|
|
52
|
+
import {
|
|
53
|
+
makeReplState, onUserInput, onEscape, onStreamChunk, onTurnComplete,
|
|
54
|
+
} from './repl_reducers.mjs';
|
|
55
|
+
export {
|
|
56
|
+
makeReplState, onUserInput, onEscape, onStreamChunk, onTurnComplete, consumeNextTurnFirstMessage,
|
|
57
|
+
} from './repl_reducers.mjs';
|
|
211
58
|
|
|
212
59
|
// ─── React mount ─────────────────────────────────────────────────────────
|
|
213
60
|
//
|