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/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
- // ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
48
- //
49
- // Wraps the React tree with mount/unmount side-effects that enable the
50
- // terminal alternate screen buffer. Three-layer cleanup so the user never
51
- // gets stranded on the alt canvas:
52
- // 1. React unmount → useEffect return-fn writes \x1b[?1049l
53
- // 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
54
- // process-level listeners that we install + remove on unmount.
55
- // 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
56
- // below us turned it off.
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
  //
@@ -0,0 +1,63 @@
1
+ // tui/repl_altbuffer.mjs — alt-buffer (DEC 1049) mount cluster for the REPL.
2
+ //
3
+ // Extracted verbatim from repl.mjs (file-size gate). Wraps the React tree
4
+ // with mount/unmount side-effects that enable the terminal alternate screen
5
+ // buffer. Three-layer cleanup so the user never gets stranded on the alt
6
+ // canvas:
7
+ // 1. React unmount → useEffect return-fn writes \x1b[?1049l
8
+ // 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
9
+ // process-level listeners that we install + remove on unmount.
10
+ // 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
11
+ // below us turned it off.
12
+ //
13
+ // We deliberately do NOT install an uncaughtException handler — Ink
14
+ // already installs one and re-throws; ours would swallow the stack
15
+ // trace (violates §1 Truthfulness / no silent catch).
16
+ //
17
+ // `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
18
+ // the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
19
+ // no escape sequences leak into stdout.
20
+ import { useEffect } from 'react';
21
+
22
+ export const ALT_BUFFER_ENTER = '\x1b[?1049h';
23
+ export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
24
+ export const CURSOR_VISIBLE = '\x1b[?25h';
25
+
26
+ // Rendering-mode decision. Default = Static scrollback (no flicker; splash
27
+ // prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
28
+ // LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
29
+ export function computeAltEnabled(env, hasTTY) {
30
+ const e = env || {};
31
+ return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
32
+ }
33
+
34
+ export function FullScreen({ enabled, children }) {
35
+ useEffect(() => {
36
+ if (!enabled) return undefined;
37
+ // Mount: enter alternate screen buffer.
38
+ try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
39
+
40
+ // Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
41
+ // terminal is restored even if React never gets a chance to unmount
42
+ // (e.g. parent process kills us with SIGTERM).
43
+ const restore = () => {
44
+ try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
45
+ };
46
+ const onExit = () => { restore(); };
47
+ const onSignal = () => { restore(); };
48
+ process.once('exit', onExit);
49
+ process.once('SIGINT', onSignal);
50
+ process.once('SIGTERM', onSignal);
51
+ process.once('SIGHUP', onSignal);
52
+
53
+ return () => {
54
+ // React unmount: restore primary buffer.
55
+ restore();
56
+ process.removeListener('exit', onExit);
57
+ process.removeListener('SIGINT', onSignal);
58
+ process.removeListener('SIGTERM', onSignal);
59
+ process.removeListener('SIGHUP', onSignal);
60
+ };
61
+ }, [enabled]);
62
+ return children;
63
+ }
@@ -0,0 +1,114 @@
1
+ // tui/repl_reducers.mjs — pure state reducers for the REPL host.
2
+ //
3
+ // Extracted verbatim from repl.mjs (file-size gate). These are pure
4
+ // functions: no shared mutable module state, no JSX. They keep their
5
+ // pre-v5.3 shapes — tests/phaseC-repl-interrupt.test.mjs depends on them.
6
+ //
7
+ // Backward-compat contracts (do not break):
8
+ // - makeReplState() — still callable with zero args.
9
+ // - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
10
+ // keep their pre-v5.3 shapes.
11
+
12
+ // ─── Pure state ──────────────────────────────────────────────────────────
13
+ //
14
+ // makeReplState stays callable with zero args (existing tests rely on it).
15
+ // The new fields default to empty so legacy callers see no behavior change.
16
+ export function makeReplState(opts) {
17
+ const splashItem = opts && opts.splashItem ? opts.splashItem : null;
18
+ return {
19
+ streaming: false,
20
+ controller: null,
21
+ pendingPrepend: null,
22
+ nextTurnFirstMessage: null,
23
+ history: [],
24
+ scrollback: splashItem ? [splashItem] : [],
25
+ liveAssistant: '',
26
+ turnCounter: 0,
27
+ };
28
+ }
29
+
30
+ export function onUserInput(state, { text, controller }) {
31
+ if (state.streaming && state.controller) {
32
+ // mid-stream interrupt — abort current turn, queue text for next turn.
33
+ try { state.controller.abort(); } catch {}
34
+ return { ...state, pendingPrepend: text };
35
+ }
36
+ // idle — start a new turn. Append a 'user' entry to scrollback so the
37
+ // sticky-layout caller sees the prompt history above the live stream.
38
+ const id = `u-${state.turnCounter}`;
39
+ return {
40
+ ...state,
41
+ streaming: true,
42
+ controller,
43
+ history: [...state.history, text],
44
+ scrollback: [...state.scrollback, { kind: 'user', id, text }],
45
+ turnCounter: state.turnCounter + 1,
46
+ };
47
+ }
48
+
49
+ export function onEscape(state) {
50
+ if (state.streaming && state.controller) {
51
+ try { state.controller.abort(); } catch {}
52
+ }
53
+ // Drop any partial live assistant text on explicit Esc — the user is
54
+ // telling us to discard, not to keep.
55
+ return {
56
+ ...state,
57
+ streaming: false,
58
+ controller: null,
59
+ pendingPrepend: null,
60
+ liveAssistant: '',
61
+ };
62
+ }
63
+
64
+ // Stream chunk arrives. Completed lines are committed to the <Static>
65
+ // scrollback immediately (so they scroll up ABOVE the sticky editor), and only
66
+ // the in-progress trailing partial stays in the live region. Without this, a
67
+ // reply taller than the terminal grew the live frame past the viewport and
68
+ // spilled BELOW the input box (long orchestrator replies). Chunks without a
69
+ // newline still just accumulate (the prior behaviour), so short replies and the
70
+ // existing reducer tests are unchanged.
71
+ export function onStreamChunk(state, { chunk }) {
72
+ const buf = state.liveAssistant + chunk;
73
+ const nl = buf.lastIndexOf('\n');
74
+ if (nl < 0) return { ...state, liveAssistant: buf };
75
+ const complete = buf.slice(0, nl); // one or more whole lines
76
+ const remainder = buf.slice(nl + 1); // trailing partial (may be '')
77
+ const id = `as-${state.turnCounter}-${state.scrollback.length}`;
78
+ return {
79
+ ...state,
80
+ scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
81
+ liveAssistant: remainder,
82
+ };
83
+ }
84
+
85
+ export function onTurnComplete(state, { reason, error } = {}) {
86
+ const promoted = state.pendingPrepend;
87
+ const suffix = reason === 'aborted' ? ' [aborted]'
88
+ : reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
89
+ : '';
90
+ const text = (state.liveAssistant || '') + suffix;
91
+ // Commit any accumulated live text to scrollback. If the turn produced
92
+ // nothing AND wasn't an error/abort, skip the empty append.
93
+ const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
94
+ const id = `a-${state.turnCounter}`;
95
+ const kind = reason === 'error' ? 'error' : 'assistant';
96
+ const nextScrollback = shouldCommit
97
+ ? [...state.scrollback, { kind, id, text }]
98
+ : state.scrollback;
99
+ return {
100
+ ...state,
101
+ streaming: false,
102
+ controller: null,
103
+ pendingPrepend: null,
104
+ nextTurnFirstMessage: promoted,
105
+ liveAssistant: '',
106
+ scrollback: nextScrollback,
107
+ turnCounter: state.turnCounter + 1,
108
+ };
109
+ }
110
+
111
+ export function consumeNextTurnFirstMessage(state) {
112
+ const msg = state.nextTurnFirstMessage;
113
+ return [{ ...state, nextTurnFirstMessage: null }, msg];
114
+ }
@@ -0,0 +1,208 @@
1
+ // tui/slash_channels.mjs — the /channels and /context slash-command handlers,
2
+ // extracted verbatim from slash_dispatcher.mjs. Imports the shared masked-prompt
3
+ // helper from slash_helpers.mjs and the dotenv shim from a leaf lib, never the
4
+ // dispatcher (no cycle).
5
+
6
+ import { loadDotenvIfAny } from '../dotenv_min.mjs';
7
+ import { _promptText } from './slash_helpers.mjs';
8
+
9
+ // /channels — view configured channels and toggle them. `/channels` lists;
10
+ // `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
11
+ // available, else lib/config directly, so it works on both REPL paths.
12
+ export async function _channels(args, ctx = {}) {
13
+ const cf = await import('../config_features.mjs');
14
+ const cfgMod = await import('../lib/config.mjs');
15
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
16
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
17
+ const toks = (args || '').trim().split(/\s+/).filter(Boolean);
18
+ const [name, action] = toks;
19
+
20
+ // `/channels [<name>] setup` — set the channel's credentials (bot token,
21
+ // homeserver, …) from chat instead of redirecting to /config. Reuses the
22
+ // masked modal prompt (_promptText) so secrets are never echoed. No modal →
23
+ // fall back to the readline channel step (same as /config's channel item).
24
+ const wantSetup = toks.some((t) => /^setup$/i.test(t));
25
+ if (wantSetup) {
26
+ const channelMod = await import('../commands/setup_channels.mjs');
27
+ const picked = toks.find((t) => !/^setup$/i.test(t));
28
+ if (typeof ctx.openPicker !== 'function') {
29
+ // Readline path: hand off to the existing channel wizard step.
30
+ ctx.requestConfigStep = 'channel';
31
+ return 'EXIT';
32
+ }
33
+ let chName = picked && picked.toLowerCase();
34
+ if (!chName) {
35
+ const sel = await ctx.openPicker({
36
+ kind: 'menu',
37
+ title: 'channel — set credentials',
38
+ subtitle: 'pick a channel to configure',
39
+ items: channelMod.CHANNEL_CATALOG.map((c) => ({
40
+ id: c.name,
41
+ label: c.label,
42
+ desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
43
+ })),
44
+ });
45
+ chName = sel && typeof sel === 'object' ? sel.id : sel;
46
+ if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
47
+ }
48
+ const spec = channelMod.channelByName(chName);
49
+ if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
50
+ if (!spec.fields.length) {
51
+ // No creds (e.g. http / whatsapp) — just enable it.
52
+ const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
53
+ channelMod.persistChannel(cfgDirX, chName, {});
54
+ if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
55
+ return `channel ${chName} → enabled (no credentials needed)`;
56
+ }
57
+ const answers = {};
58
+ for (const f of spec.fields) {
59
+ const v = await _promptText(ctx, {
60
+ title: `${spec.label} — ${f.prompt}`,
61
+ subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
62
+ secret: !!f.secret,
63
+ allowEmpty: !!f.optional,
64
+ });
65
+ if (v === null) {
66
+ if (f.optional) continue; // Esc on an optional field → skip it
67
+ return 'channel setup: cancelled';
68
+ }
69
+ if (v) answers[f.key] = v;
70
+ }
71
+ const path = await import('node:path');
72
+ const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
73
+ const entry = channelMod.persistChannel(cfgDirX, chName, answers);
74
+ // Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
75
+ // list is fresh — an in-tree channel whose runtime dep is missing stays
76
+ // disabled (persistChannel gates it) rather than being force-enabled.
77
+ if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
78
+ const setKeys = Object.keys(answers);
79
+ let note = '';
80
+ if (!entry.ready) {
81
+ if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
82
+ if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
83
+ }
84
+ return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
85
+ }
86
+
87
+ // `/channels <name> test` — verify the stored credentials with a live call.
88
+ if (name && /^test$/i.test(action || '')) {
89
+ const channelMod = await import('../commands/setup_channels.mjs');
90
+ try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
91
+ const r = await channelMod.verifyChannel(name.toLowerCase());
92
+ if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
93
+ if (r.ok === null) return `· ${name}: ${r.detail}`;
94
+ return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
95
+ }
96
+
97
+ if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
98
+ const en = /^(on|enable)$/i.test(action);
99
+ const cfg = read();
100
+ const key = name.toLowerCase();
101
+ // Reject unknown names so a typo can't silently create a bogus
102
+ // cfg.channels.<name> section (which would then leak into the list).
103
+ // Stay permissive for pre-existing custom sections.
104
+ const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
105
+ if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
106
+ return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
107
+ }
108
+ cf.channelSetEnabled(cfg, key, en); write(cfg);
109
+ // Legacy fallback path: the readline ctx (_legacyCtx) has no
110
+ // readConfig/writeConfig, so we read/wrote disk above against a fresh
111
+ // cfg object. Mirror the toggle onto the in-session ctx.cfg so a
112
+ // follow-up `/channels` (list) or other in-session read stays
113
+ // consistent instead of showing the stale pre-toggle value.
114
+ if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
115
+ cf.channelSetEnabled(ctx.cfg, key, en);
116
+ }
117
+ return en
118
+ ? `channel ${key} → enabled`
119
+ : `channel ${key} → disabled (re-enable with /channels ${key} on)`;
120
+ }
121
+ // No-arg + modal → an action menu: each row toggles in place, plus a
122
+ // "set credentials" row. Falls through to the text list when no modal.
123
+ if (!toks.length && typeof ctx.openPicker === 'function') {
124
+ const statusRows = cf.channelStatusList(read());
125
+ const items = statusRows.map((c) => ({
126
+ id: `toggle:${c.name}`,
127
+ label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
128
+ desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
129
+ }));
130
+ items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
131
+ const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
132
+ const pid = picked && typeof picked === 'object' ? picked.id : picked;
133
+ if (!pid || typeof pid !== 'string') return 'cancelled';
134
+ if (pid === 'setup') return _channels('setup', ctx);
135
+ if (pid.startsWith('toggle:')) {
136
+ const nm = pid.slice(7);
137
+ const cur = statusRows.find((r) => r.name === nm);
138
+ return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
139
+ }
140
+ }
141
+ const rows = cf.channelStatusList(read());
142
+ if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
143
+ // Cross-reference each channel's required env creds against the loaded env so
144
+ // the list flags a channel that's "enabled" but missing its token.
145
+ let channelMod = null;
146
+ try {
147
+ loadDotenvIfAny(ctx.cfgDir);
148
+ channelMod = await import('../commands/setup_channels.mjs');
149
+ } catch { /* hint is best-effort */ }
150
+ const missingFor = (name) => {
151
+ const spec = channelMod && channelMod.channelByName(name);
152
+ if (!spec) return [];
153
+ return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
154
+ };
155
+ const lines = ['configured channels:'];
156
+ for (const c of rows) {
157
+ const miss = missingFor(c.name);
158
+ const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
159
+ lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
160
+ }
161
+ lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
162
+ return lines.join('\n');
163
+ }
164
+
165
+ // /context — view/set the chat history window (turns + token budget). This is
166
+ // the sliding history budget sent each turn, NOT the model's hard context
167
+ // limit. ctx-or-lib/config fallback so it works on both REPL paths.
168
+ export async function _context(args, ctx = {}) {
169
+ const cf = await import('../config_features.mjs');
170
+ const cfgMod = await import('../lib/config.mjs');
171
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
172
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
173
+ const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
174
+ const parts = (args || '').trim().split(/\s+/).filter(Boolean);
175
+ const sub = (parts[0] || 'status').toLowerCase();
176
+ const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
177
+ // No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
178
+ if (!parts.length && typeof ctx.openPicker === 'function') {
179
+ const w = cf.chatWindowGet(read());
180
+ const action = await ctx.openPicker({
181
+ kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
182
+ items: [
183
+ { id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
184
+ { id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
185
+ { id: 'status', label: 'Status', desc: 'show current' },
186
+ ],
187
+ });
188
+ const aid = action && typeof action === 'object' ? action.id : action;
189
+ if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
190
+ if (aid === 'turns') {
191
+ const np = await ctx.openPicker({ kind: 'menu', title: 'Turns to keep', subtitle: `currently ${w.turns}`, items: [5, 10, 15, 20, 30, 40, 50].map((x) => ({ id: String(x), label: String(x) })) });
192
+ const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
193
+ if (!Number.isFinite(v)) return 'context turns: cancelled';
194
+ const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
195
+ }
196
+ if (aid === 'tokens') {
197
+ const np = await ctx.openPicker({ kind: 'menu', title: 'Token budget', subtitle: `currently ${w.tokens}`, items: [2000, 4000, 8000, 12000, 16000, 32000].map((x) => ({ id: String(x), label: String(x) })) });
198
+ const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
199
+ if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
200
+ const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
201
+ }
202
+ }
203
+ if (sub === 'status') return fmt();
204
+ const n = parseInt(parts[1], 10);
205
+ if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
206
+ if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
207
+ return 'usage: /context [status | turns <N> | tokens <N>]';
208
+ }