lazyclaw 6.4.0 → 6.6.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/tui/banner.mjs ADDED
@@ -0,0 +1,72 @@
1
+ // LazyClaw banner renderers — extracted from tui/pickers.mjs as a leaf
2
+ // module (no cross-deps). Holds the legacy figlet box (_renderBanner), the
3
+ // orange colour helper (_orange / _ORANGE_RGB), the mascot stubs, and the
4
+ // lazy-loaded v5 banner assets (_loadBannerAssets). The v5 splash composer
5
+ // (_renderV5Banner) and the chat header (_printChatBanner) stay in pickers
6
+ // and import _orange / _ORANGE_RGB / _loadBannerAssets / _renderBanner back.
7
+
8
+ // LazyClaw banner — single source of truth (chat REPL header, no-arg
9
+ // launcher, first-run welcome). Printed once so users see the active
10
+ // provider/model; plain ANSI, auto-skipped when stdout isn't a TTY.
11
+ // Returns an array of pre-formatted lines the caller can splice rows into.
12
+ //
13
+ // Layout invariants: every inner row is forced through `.padEnd(INNER_W)`
14
+ // and is exactly INNER_W single-cell glyphs, so the right border `│` always
15
+ // lands in the same column. _renderMascot / _renderMascotTiny are stubs kept
16
+ // so any leftover caller doesn't crash.
17
+
18
+ export const _ORANGE_RGB = '241;130;70'; // #F18246
19
+ export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
20
+
21
+ export function _renderMascot() {
22
+ return ['lazyclaw'];
23
+ }
24
+
25
+ export function _renderMascotTiny() {
26
+ return 'lazyclaw';
27
+ }
28
+
29
+ // figlet "standard" "lazy", trimmed of leading blank line. Each row
30
+ // is left-padded by two spaces inside the box, and every row is then
31
+ // padded to INNER_W cells.
32
+ const _LAZY_STANDARD = [
33
+ ' _ ',
34
+ '| | __ _ _____ _ ',
35
+ '| |/ _` |_ / | | | ',
36
+ '| | (_| |/ /| |_| | ',
37
+ '|_|\\__,_/___|\\__, | ',
38
+ ' |___/ ',
39
+ ];
40
+
41
+ const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
42
+
43
+ export function _renderBanner(version) {
44
+ const v = String(version || '?.?.?');
45
+ const cap = ` LazyClaw v${v}`;
46
+ const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
47
+ const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
48
+ const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
49
+ const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
50
+ return [
51
+ top,
52
+ ..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
53
+ wrap(padInner(cap)),
54
+ bot,
55
+ ];
56
+ }
57
+
58
+ // v5 hero banner assets — ANSI Shadow LAZYCLAW wordmark stacked on top of the
59
+ // braille sloth (tui/banner.generated.mjs + tui/wordmark.mjs). Lazy-loaded and
60
+ // cached so the missing-asset fallback only probes the dynamic import once.
61
+ let _bannerAssetsCache = null;
62
+ export async function _loadBannerAssets() {
63
+ if (_bannerAssetsCache !== null) return _bannerAssetsCache;
64
+ try {
65
+ const { banner } = await import('./banner.generated.mjs');
66
+ const { wordmark } = await import('./wordmark.mjs');
67
+ _bannerAssetsCache = { banner, wordmark };
68
+ } catch {
69
+ _bannerAssetsCache = null;
70
+ }
71
+ return _bannerAssetsCache;
72
+ }
package/tui/editor.mjs CHANGED
Binary file
@@ -0,0 +1,46 @@
1
+ // tui/editor_anchor.mjs — extracted IME cursor-anchor stdout shim (v5.4.4).
2
+ // Pure leaf module: only touches process.stdout, imports nothing from
3
+ // editor.mjs, so there is no circular import. The shared anchorState object
4
+ // is a singleton — editor.mjs imports the same instance via this export.
5
+ //
6
+ // ─── IME cursor anchor (v5.4.4) ─────────────────────────────────────
7
+ //
8
+ // v5.4.3 shipped an anchor that moved the cursor inside the editor
9
+ // after every render so IME pre-edit composition appeared in the
10
+ // editor box. It also caused visible flicker because Ink's log-update
11
+ // (node_modules/ink/build/log-update.js) emits an eraseLines sequence
12
+ // (`\x1b[2K\x1b[1A...`) on every redraw — and that sequence walks UP
13
+ // from the CURRENT cursor position. With our anchor up inside the
14
+ // editor, eraseLines erased rows ABOVE the frame, then wrote the new
15
+ // frame starting one editor-height higher than the previous one.
16
+ //
17
+ // v5.4.4 fix — monkey-patch process.stdout.write the first time the
18
+ // anchor fires. When the patched writer sees a chunk that BEGINS with
19
+ // `\x1b[2K` (the start of log-update's eraseLines) AND the anchor
20
+ // offset is non-zero, it prepends `\x1b[<offset>B\r` to move the
21
+ // cursor BACK DOWN to the row log-update expects (one below the
22
+ // previous frame's last line). The user sees no flicker; IME still
23
+ // reads the editor cursor position because the anchor lives across
24
+ // the gap between renders.
25
+ export const anchorState = { offset: 0, shimmed: false };
26
+
27
+ export function installAnchorShim() {
28
+ if (anchorState.shimmed) return;
29
+ if (!(process.stdout && typeof process.stdout.write === 'function')) return;
30
+ const orig = process.stdout.write.bind(process.stdout);
31
+ process.stdout.write = function patchedWrite(chunk, ...rest) {
32
+ try {
33
+ if (
34
+ anchorState.offset > 0 &&
35
+ typeof chunk === 'string' &&
36
+ chunk.startsWith('\x1b[2K')
37
+ ) {
38
+ const off = anchorState.offset;
39
+ anchorState.offset = 0;
40
+ return orig.call(this, `\x1b[${off}B\r` + chunk, ...rest);
41
+ }
42
+ } catch { /* fall through to unmodified write */ }
43
+ return orig.call(this, chunk, ...rest);
44
+ };
45
+ anchorState.shimmed = true;
46
+ }
@@ -0,0 +1,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
- // LazyClaw banner single source of truth (chat REPL header, no-arg
145
- // launcher, first-run welcome). Printed once so users see the active
146
- // provider/model; plain ANSI, auto-skipped when stdout isn't a TTY.
147
- // Returns an array of pre-formatted lines the caller can splice rows into.
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
- // Step-3 alternative for composite providers (currently only the
622
- // orchestrator). Builds `cfg.orchestrator = { planner, workers,
623
- // maxSubtasks }` interactively and persists it before returning.
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';