@alisio/alisio-code 0.1.0-alpha.10 → 0.1.0-alpha.12

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * OpenCode-style settings menu for the TUI: two-column rows (name + current value), live
3
+ * type-to-search, Enter/Space to change a setting (or navigate), Esc to leave, a `(n/total)`
4
+ * counter and a footer with the highlighted row's description.
5
+ *
6
+ * This component is a thin shell: all decision logic (key decoding, filtering, cycling, counter)
7
+ * lives in the pure `settings.ts` module, which is fully unit-tested without pi-tui.
8
+ */
9
+ import { truncateToWidth } from "@earendil-works/pi-tui";
10
+ import { filterSettingRows, formatSettingValue, initialSettingsMenuState, reduceSettingsInput, settingsCounter, settingsKeyAction, } from "./settings.js";
11
+ import { style } from "./theme.js";
12
+ const MAX_VISIBLE = 10;
13
+ const wrapText = (text, width) => {
14
+ const rows = [];
15
+ let row = "";
16
+ for (const word of text.split(/\s+/)) {
17
+ if (row && [...`${row} ${word}`].length > width) {
18
+ rows.push(row);
19
+ row = word;
20
+ }
21
+ else
22
+ row = row ? `${row} ${word}` : word;
23
+ }
24
+ if (row)
25
+ rows.push(row);
26
+ return rows;
27
+ };
28
+ export class SettingsMenu {
29
+ title;
30
+ onChange;
31
+ onNavigate;
32
+ onCancel;
33
+ detail;
34
+ full;
35
+ filtered;
36
+ state = initialSettingsMenuState();
37
+ constructor(title, rows,
38
+ /** A setting value changed: persist and apply it. The host re-refreshes on success/failure. */
39
+ onChange,
40
+ /** A navigation row was activated (Enter/Space): route to its manager. */
41
+ onNavigate,
42
+ /** Esc pressed: leave the menu (the host returns to the editor). */
43
+ onCancel,
44
+ /** Optional dim lines under the title (e.g. the current provider/model). */
45
+ detail) {
46
+ this.title = title;
47
+ this.onChange = onChange;
48
+ this.onNavigate = onNavigate;
49
+ this.onCancel = onCancel;
50
+ this.detail = detail;
51
+ this.full = rows;
52
+ this.filtered = rows;
53
+ }
54
+ /** Rebuild rows from the live config after a write; keeps the filter and re-selects the row. */
55
+ refresh(rows) {
56
+ const previous = this.filtered[this.state.selected]?.id;
57
+ this.full = rows;
58
+ this.filtered = filterSettingRows(rows, this.state.filter);
59
+ const index = previous ? this.filtered.findIndex((row) => row.id === previous) : -1;
60
+ this.state = { ...this.state, selected: index >= 0 ? index : 0 };
61
+ }
62
+ /** Update the displayed value of one row without rebuilding the whole list. */
63
+ updateValue(id, value) {
64
+ for (const row of this.full)
65
+ if (row.id === id)
66
+ row.current = value;
67
+ }
68
+ invalidate() { }
69
+ handleInput(data) {
70
+ const action = settingsKeyAction(data, this.state);
71
+ const { state, effect } = reduceSettingsInput(this.state, action, this.filtered);
72
+ this.state = state;
73
+ // Re-apply the live filter so typing narrows the list before the next render/input.
74
+ this.filtered = filterSettingRows(this.full, state.filter);
75
+ switch (effect.type) {
76
+ case "change":
77
+ // Optimistically reflect the new value; the host refreshes from the config on success.
78
+ effect.row.current = effect.value;
79
+ this.onChange(effect.row, effect.value);
80
+ break;
81
+ case "navigate":
82
+ this.onNavigate(effect.row);
83
+ break;
84
+ case "cancel":
85
+ this.onCancel();
86
+ break;
87
+ case "none":
88
+ break;
89
+ }
90
+ }
91
+ render(width) {
92
+ const lines = [truncateToWidth(style.bold(style.yellow(this.title)), width)];
93
+ if (this.detail?.trim())
94
+ for (const line of wrapText(this.detail, Math.max(20, width - 2)))
95
+ lines.push(truncateToWidth(style.dim(` ${line}`), width));
96
+ if (this.state.filter)
97
+ lines.push(truncateToWidth(style.dim(` filter: ${this.state.filter}`), width));
98
+ const rows = this.filtered;
99
+ if (!rows.length) {
100
+ lines.push(truncateToWidth(style.yellow(this.full.length ? " No matching settings" : " No settings available"), width));
101
+ lines.push("");
102
+ lines.push(truncateToWidth(style.dim(" Type to search · Esc to cancel"), width));
103
+ return lines;
104
+ }
105
+ // Two-column layout: labels padded to the widest row (capped), values right beside them.
106
+ const maxLabel = Math.min(36, Math.max(...rows.map((row) => [...row.label].length)));
107
+ const start = Math.max(0, Math.min(this.state.selected - Math.floor(MAX_VISIBLE / 2), rows.length - MAX_VISIBLE));
108
+ const visible = rows.slice(start, start + MAX_VISIBLE);
109
+ for (let i = 0; i < visible.length; i++) {
110
+ const row = visible[i];
111
+ if (!row)
112
+ continue;
113
+ const selected = start + i === this.state.selected;
114
+ const label = row.label.padEnd(maxLabel);
115
+ const value = row.kind === "setting" ? formatSettingValue(row, row.current) : "";
116
+ const rendered = selected
117
+ ? `${style.cyan("›")} ${style.bold(style.cyan(label))}${value ? ` ${style.bold(style.cyan(value))}` : ""}`
118
+ : ` ${label}${value ? ` ${value}` : ""}`;
119
+ lines.push(truncateToWidth(rendered, width));
120
+ }
121
+ lines.push(truncateToWidth(style.dim(` ${settingsCounter(rows, this.state.selected)}`), width));
122
+ const selectedRow = rows[this.state.selected];
123
+ if (selectedRow?.description) {
124
+ lines.push("");
125
+ for (const wrapped of wrapText(selectedRow.description, Math.max(20, width - 4)))
126
+ lines.push(truncateToWidth(style.gray(` ${wrapped}`), width));
127
+ lines.push("");
128
+ }
129
+ lines.push(truncateToWidth(style.dim(" Type to search · Enter/Space to change · Esc to cancel"), width));
130
+ return lines;
131
+ }
132
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Pure settings-menu logic for the TUI: row building, filtering, value cycling, counter and
3
+ * keyboard reduction. No terminal or pi-tui imports here — the `SettingsMenu` component in
4
+ * `settings-menu.ts` is a thin shell over this module.
5
+ *
6
+ * Only settings Alisio actually supports are defined here. The row set is the honest, wired
7
+ * list: every "setting" row maps to a real config key persisted through the atomic
8
+ * `setConfigValue` writer (or the MCP consent path) and is applied to the running process.
9
+ */
10
+ export type SettingValueType = "boolean" | "number" | "percent" | "enum";
11
+ export interface SettingRow {
12
+ /** Config key (or MCP consent id) this row edits; navigation rows carry their action id. */
13
+ id: string;
14
+ label: string;
15
+ description: string;
16
+ /** Group the row belongs to; also matched by the filter ("type" of the row). */
17
+ category: string;
18
+ kind: "setting" | "navigation";
19
+ /** Current raw value for setting rows (read live from the config at build time). */
20
+ current?: unknown;
21
+ /** Ordered candidate values cycled by Enter/Space; wraps around at the ends. */
22
+ values?: unknown[];
23
+ valueType?: SettingValueType;
24
+ /** Displayed but not editable in this run (e.g. under `--read-only`). */
25
+ readOnly?: boolean;
26
+ /** Navigation target id for navigation rows; the host maps it to a concrete action. */
27
+ action?: string;
28
+ }
29
+ export interface SettingsNavigationAction {
30
+ id: string;
31
+ label: string;
32
+ description: string;
33
+ }
34
+ /** Ordered candidates of the `websearch.provider` enum, aligned exactly with the config schema. */
35
+ export declare const WEBSEARCH_PROVIDERS: readonly ["searxng", "duckduckgo-instant", "tavily", "brave", "serpapi", "native"];
36
+ /**
37
+ * The config surface the menu reads. A structural view of the loaded config: rows are built only
38
+ * from the documented fields below, so unknown keys in a real config are ignored by construction.
39
+ */
40
+ export interface SettingsConfigView {
41
+ compaction: {
42
+ auto: boolean;
43
+ threshold: number;
44
+ keepTurns: number;
45
+ maxOutputTokens: number;
46
+ };
47
+ context: {
48
+ claudeMdFallback: boolean;
49
+ maxBytes: number;
50
+ };
51
+ limits: {
52
+ maxTurns: number;
53
+ maxOutputTokens: number;
54
+ maxContextChars: number;
55
+ timeoutMs: number;
56
+ };
57
+ tui: {
58
+ paddingX: number;
59
+ skillSlashCommands: boolean;
60
+ };
61
+ mcp: {
62
+ allow?: boolean;
63
+ };
64
+ websearch: {
65
+ provider?: (typeof WEBSEARCH_PROVIDERS)[number];
66
+ };
67
+ pluginHooks: {
68
+ timeoutMs: number;
69
+ };
70
+ }
71
+ export interface SettingsMenuInput {
72
+ config: SettingsConfigView;
73
+ /** Whether global MCP consent (`mcp.allow`) is persisted for this user right now. */
74
+ mcpAllowPersisted: boolean;
75
+ /** `--read-only` run: every row is shown but none can be changed. */
76
+ readOnly: boolean;
77
+ }
78
+ /** The config view with the schema defaults; also the canonical fixture for tests. */
79
+ export declare const defaultConfig: SettingsConfigView;
80
+ interface SettingDefinition {
81
+ id: string;
82
+ label: string;
83
+ category: string;
84
+ description: string;
85
+ valueType: SettingValueType;
86
+ values: unknown[];
87
+ read: (config: SettingsConfigView, mcpAllowPersisted: boolean) => unknown;
88
+ }
89
+ /**
90
+ * Every real, wired setting the menu offers. `read` maps the live config to the current value;
91
+ * the keys are exactly the ones `setConfigValue` accepts (`compaction.*`, `context.*`, `limits.*`,
92
+ * `pluginHooks.timeoutMs`, `tui.*` and `websearch.provider`), and `mcp.allow` flows through the
93
+ * application's consent path instead. `limits.timeoutMs` is displayed in seconds (the values are
94
+ * seconds); the app host multiplies by 1000 before persisting the milliseconds.
95
+ */
96
+ export declare const SETTINGS_DEFINITIONS: readonly SettingDefinition[];
97
+ /**
98
+ * Builds the full settings menu: the real, wired setting rows followed by the navigation rows the
99
+ * host wires to the existing managers (`/model`, `/connect`, `/plugins`, `/skills`, `/mcp`, ...).
100
+ * Under `--read-only` every setting row is marked read-only: nothing can be persisted.
101
+ */
102
+ export declare function settingsMenuRows(input: SettingsMenuInput, navigation: SettingsNavigationAction[]): SettingRow[];
103
+ /** Whether a row matches every whitespace-separated filter term, on label/id/category/description. */
104
+ export declare function matchSettingRow(row: SettingRow, terms: string[]): boolean;
105
+ /** Filters rows by name, id, category (type) and description; an empty query returns everything. */
106
+ export declare function filterSettingRows(rows: SettingRow[], filter: string): SettingRow[];
107
+ /** The value shown in the right-hand column of a setting row. */
108
+ export declare function formatSettingValue(row: SettingRow, value: unknown): string;
109
+ /**
110
+ * The next candidate after the current value in the row's ordered `values`, wrapping around.
111
+ * A current value that is not in the list (hand-edited config) moves to the next candidate at or
112
+ * above it for numbers, or wraps to the first candidate otherwise. Never mutates the row.
113
+ */
114
+ export declare function cycleSettingValue(row: SettingRow, current: unknown): unknown | undefined;
115
+ /** OpenCode-style `(n/total)` counter; empty when there are no rows to count. */
116
+ export declare function settingsCounter(rows: SettingRow[], selected: number): string;
117
+ export interface SettingsMenuState {
118
+ /** Live type-to-search filter (matched against name, id, category and description). */
119
+ filter: string;
120
+ /** Index into the FILTERED row list. */
121
+ selected: number;
122
+ }
123
+ export declare const initialSettingsMenuState: () => SettingsMenuState;
124
+ /**
125
+ * Normalized keyboard actions for the settings menu, decoded from raw terminal input with plain
126
+ * string matching so the logic stays testable without pi-tui. The component just forwards
127
+ * `handleInput(data)` here and maps the resulting effect to its callbacks.
128
+ */
129
+ export type SettingsKeyAction = {
130
+ kind: "move";
131
+ direction: "up" | "down" | "start" | "end" | "pageUp" | "pageDown";
132
+ } | {
133
+ kind: "type";
134
+ char: string;
135
+ } | {
136
+ kind: "backspace";
137
+ }
138
+ /** Enter, or Space while the filter is empty (Space with an active filter types a space). */
139
+ | {
140
+ kind: "change";
141
+ } | {
142
+ kind: "cancel";
143
+ } | {
144
+ kind: "ignore";
145
+ };
146
+ export declare function settingsKeyAction(data: string, state: SettingsMenuState): SettingsKeyAction;
147
+ export type SettingsMenuEffect = {
148
+ type: "change";
149
+ row: SettingRow;
150
+ value: unknown;
151
+ } | {
152
+ type: "navigate";
153
+ row: SettingRow;
154
+ } | {
155
+ type: "cancel";
156
+ } | {
157
+ type: "none";
158
+ };
159
+ /**
160
+ * Pure keyboard reducer over the menu state. `rows` is the current FILTERED list (navigation rows
161
+ * and settings rows alike); Enter/Space changes a setting or navigates, Esc cancels, typing
162
+ * filters, Backspace edits the filter, and movement keys move/clamp the selection.
163
+ */
164
+ export declare function reduceSettingsInput(state: SettingsMenuState, action: SettingsKeyAction, rows: SettingRow[]): {
165
+ state: SettingsMenuState;
166
+ effect: SettingsMenuEffect;
167
+ };
168
+ export {};
@@ -0,0 +1,330 @@
1
+ /** Ordered candidates of the `websearch.provider` enum, aligned exactly with the config schema. */
2
+ export const WEBSEARCH_PROVIDERS = [
3
+ "searxng",
4
+ "duckduckgo-instant",
5
+ "tavily",
6
+ "brave",
7
+ "serpapi",
8
+ "native",
9
+ ];
10
+ /** The config view with the schema defaults; also the canonical fixture for tests. */
11
+ export const defaultConfig = {
12
+ compaction: { auto: true, threshold: 0.85, keepTurns: 2, maxOutputTokens: 16_000 },
13
+ context: { claudeMdFallback: false, maxBytes: 32 * 1024 },
14
+ limits: { maxTurns: 20, maxOutputTokens: 16_384, maxContextChars: 800_000, timeoutMs: 300_000 },
15
+ tui: { paddingX: 1, skillSlashCommands: true },
16
+ mcp: { allow: false },
17
+ websearch: { provider: undefined },
18
+ pluginHooks: { timeoutMs: 15_000 },
19
+ };
20
+ /** Strictly ascending number sequence with exact step arithmetic (0.85 stays 0.85). */
21
+ function stepValues(start, end, step) {
22
+ const values = [];
23
+ for (let value = start; value <= end + 1e-9; value += step)
24
+ values.push(Number(value.toFixed(3)));
25
+ return values;
26
+ }
27
+ /**
28
+ * Every real, wired setting the menu offers. `read` maps the live config to the current value;
29
+ * the keys are exactly the ones `setConfigValue` accepts (`compaction.*`, `context.*`, `limits.*`,
30
+ * `pluginHooks.timeoutMs`, `tui.*` and `websearch.provider`), and `mcp.allow` flows through the
31
+ * application's consent path instead. `limits.timeoutMs` is displayed in seconds (the values are
32
+ * seconds); the app host multiplies by 1000 before persisting the milliseconds.
33
+ */
34
+ export const SETTINGS_DEFINITIONS = [
35
+ {
36
+ id: "compaction.auto",
37
+ label: "Auto-compact",
38
+ category: "Compaction",
39
+ valueType: "boolean",
40
+ values: [false, true],
41
+ read: (config) => config.compaction.auto,
42
+ description: "Summarize older history automatically when context usage crosses the threshold. Off keeps history verbatim until you run /compact manually.",
43
+ },
44
+ {
45
+ id: "compaction.threshold",
46
+ label: "Compaction threshold",
47
+ category: "Compaction",
48
+ valueType: "percent",
49
+ values: stepValues(0.5, 0.95, 0.05),
50
+ read: (config) => config.compaction.threshold,
51
+ description: "Share of the model context window that triggers auto-compaction (or of the char-budget fallback when the window is unknown). Applied from the next run.",
52
+ },
53
+ {
54
+ id: "compaction.keepTurns",
55
+ label: "Keep latest turns",
56
+ category: "Compaction",
57
+ valueType: "number",
58
+ values: stepValues(0, 20, 1),
59
+ read: (config) => config.compaction.keepTurns,
60
+ description: "Recent user turns kept verbatim after an auto or manual compaction. 0 summarizes everything; the newest turns above the cap are kept intact.",
61
+ },
62
+ {
63
+ id: "compaction.maxOutputTokens",
64
+ label: "Compaction max output tokens",
65
+ category: "Compaction",
66
+ valueType: "number",
67
+ values: [8_000, 12_000, 16_000, 24_000, 32_000],
68
+ read: (config) => config.compaction.maxOutputTokens,
69
+ description: "Output token budget for the summarizer call. A summary cut by this budget is kept as partial; raise it for very long sessions.",
70
+ },
71
+ {
72
+ id: "context.claudeMdFallback",
73
+ label: "CLAUDE.md fallback",
74
+ category: "Context",
75
+ valueType: "boolean",
76
+ values: [false, true],
77
+ read: (config) => config.context.claudeMdFallback,
78
+ description: "Use a directory's CLAUDE.md when it has no AGENTS.md (agents.md convention). Loaded as project instructions from the next turn.",
79
+ },
80
+ {
81
+ id: "context.maxBytes",
82
+ label: "AGENTS.md max bytes",
83
+ category: "Context",
84
+ valueType: "number",
85
+ // 4 KiB steps aligned to the default (32 KiB is on-grid); 1024 stays reachable by hand-edit.
86
+ values: stepValues(4096, 1_048_576, 4096),
87
+ read: (config) => config.context.maxBytes,
88
+ description: "Total AGENTS.md bytes injected. The closest instruction files are kept up to this budget; raise it for very large repositories.",
89
+ },
90
+ {
91
+ id: "websearch.provider",
92
+ label: "Web search provider",
93
+ category: "Web",
94
+ valueType: "enum",
95
+ values: [...WEBSEARCH_PROVIDERS],
96
+ read: (config) => config.websearch.provider,
97
+ description: "Backend used by websearch tools. Unset falls back to a plugin extension, then a public SearXNG instance; `native` runs search server-side and must be supported by the active provider.",
98
+ },
99
+ {
100
+ id: "mcp.allow",
101
+ label: "Remember MCP consent",
102
+ category: "MCP",
103
+ valueType: "boolean",
104
+ values: [false, true],
105
+ read: (_config, mcpAllowPersisted) => mcpAllowPersisted,
106
+ description: "Persist process/network consent for MCP servers in your user configuration (mcp.allow). On: every start grants permission and auto-connects enabled servers; use /mcp to connect now. Off: revokes the persisted consent and disconnects servers.",
107
+ },
108
+ {
109
+ id: "limits.maxTurns",
110
+ label: "Max turns",
111
+ category: "Limits",
112
+ valueType: "number",
113
+ values: [5, 10, 15, 20, 30, 50, 100],
114
+ read: (config) => config.limits.maxTurns,
115
+ description: "Maximum agent-loop turns per run before the run ends. Applied from the next run; longer tasks may need a higher budget.",
116
+ },
117
+ {
118
+ id: "limits.maxOutputTokens",
119
+ label: "Agent max output tokens",
120
+ category: "Limits",
121
+ valueType: "number",
122
+ values: [1_024, 2_048, 4_096, 8_192, 16_384],
123
+ read: (config) => config.limits.maxOutputTokens,
124
+ description: "Per-call output token budget for agent turns. A cut response shows a notice suggesting a higher value. Applied from the next run.",
125
+ },
126
+ {
127
+ id: "limits.maxContextChars",
128
+ label: "Context char budget",
129
+ category: "Limits",
130
+ valueType: "number",
131
+ values: [80_000, 120_000, 160_000, 240_000, 320_000, 800_000],
132
+ read: (config) => config.limits.maxContextChars,
133
+ description: "Hard context limit in characters (instruction files + transcript + tool list) per run; the budget fallback that auto-compaction measures when the model window is unknown. Applied from the next run.",
134
+ },
135
+ {
136
+ id: "limits.timeoutMs",
137
+ label: "Run timeout",
138
+ category: "Limits",
139
+ valueType: "number",
140
+ // Displayed in seconds; the app host multiplies by 1000 before persisting milliseconds.
141
+ values: stepValues(30, 600, 30),
142
+ read: (config) => config.limits.timeoutMs / 1000,
143
+ description: "Per-run timeout. A run over the limit is aborted; raise it for very long autonomous tasks.",
144
+ },
145
+ {
146
+ id: "pluginHooks.timeoutMs",
147
+ label: "Plugin hook timeout",
148
+ category: "Plugins",
149
+ valueType: "number",
150
+ values: stepValues(1000, 120_000, 1000),
151
+ read: (config) => config.pluginHooks.timeoutMs,
152
+ description: "Host-enforced plugin hook timeout. Hooks (compaction, session start/end) are aborted when they exceed it; raise it for plugins that summarize slowly.",
153
+ },
154
+ {
155
+ id: "tui.paddingX",
156
+ label: "Editor padding",
157
+ category: "TUI",
158
+ valueType: "number",
159
+ values: [0, 1, 2, 3, 4],
160
+ read: (config) => config.tui.paddingX,
161
+ description: "Horizontal padding (columns) around the editor input box. Applied immediately to the current editor.",
162
+ },
163
+ {
164
+ id: "tui.skillSlashCommands",
165
+ label: "Skill slash commands",
166
+ category: "TUI",
167
+ valueType: "boolean",
168
+ values: [false, true],
169
+ read: (config) => config.tui.skillSlashCommands,
170
+ description: "Offer effective skills as first-class `skill:<id>` editor autocomplete entries. Off hides those entries; the `/skills` manager and its argument completion stay available. Applied immediately to the editor.",
171
+ },
172
+ ];
173
+ /**
174
+ * Builds the full settings menu: the real, wired setting rows followed by the navigation rows the
175
+ * host wires to the existing managers (`/model`, `/connect`, `/plugins`, `/skills`, `/mcp`, ...).
176
+ * Under `--read-only` every setting row is marked read-only: nothing can be persisted.
177
+ */
178
+ export function settingsMenuRows(input, navigation) {
179
+ const rows = [];
180
+ for (const def of SETTINGS_DEFINITIONS) {
181
+ rows.push({
182
+ id: def.id,
183
+ label: def.label,
184
+ category: def.category,
185
+ description: def.description,
186
+ kind: "setting",
187
+ current: def.read(input.config, input.mcpAllowPersisted),
188
+ values: [...def.values],
189
+ valueType: def.valueType,
190
+ ...(input.readOnly ? { readOnly: true } : {}),
191
+ });
192
+ }
193
+ rows.push(...navigation.map((entry) => ({
194
+ id: entry.id,
195
+ label: entry.label,
196
+ description: entry.description,
197
+ category: "Go to",
198
+ kind: "navigation",
199
+ action: entry.id,
200
+ })));
201
+ return rows;
202
+ }
203
+ /** Whether a row matches every whitespace-separated filter term, on label/id/category/description. */
204
+ export function matchSettingRow(row, terms) {
205
+ const haystack = [row.label, row.id, row.category, row.description].join(" ").toLowerCase();
206
+ return terms.every((term) => haystack.includes(term));
207
+ }
208
+ /** Filters rows by name, id, category (type) and description; an empty query returns everything. */
209
+ export function filterSettingRows(rows, filter) {
210
+ const terms = filter.toLowerCase().split(/\s+/).filter(Boolean);
211
+ if (!terms.length)
212
+ return rows;
213
+ return rows.filter((row) => matchSettingRow(row, terms));
214
+ }
215
+ /** The value shown in the right-hand column of a setting row. */
216
+ export function formatSettingValue(row, value) {
217
+ if (row.valueType === "percent" && typeof value === "number")
218
+ return `${Math.round(value * 100)}%`;
219
+ if (row.valueType === "boolean")
220
+ return value === true ? "true" : "false";
221
+ return value === undefined || value === null ? "" : String(value);
222
+ }
223
+ /**
224
+ * The next candidate after the current value in the row's ordered `values`, wrapping around.
225
+ * A current value that is not in the list (hand-edited config) moves to the next candidate at or
226
+ * above it for numbers, or wraps to the first candidate otherwise. Never mutates the row.
227
+ */
228
+ export function cycleSettingValue(row, current) {
229
+ const values = row.values;
230
+ if (!values?.length || row.readOnly)
231
+ return undefined;
232
+ const index = values.findIndex((value) => Object.is(value, current));
233
+ if (index >= 0)
234
+ return values[(index + 1) % values.length];
235
+ if (typeof current === "number" && values.every((value) => typeof value === "number"))
236
+ return values.find((value) => value >= current) ?? values[0];
237
+ return values[0];
238
+ }
239
+ /** OpenCode-style `(n/total)` counter; empty when there are no rows to count. */
240
+ export function settingsCounter(rows, selected) {
241
+ if (!rows.length)
242
+ return "";
243
+ const clamped = Math.min(Math.max(0, selected), rows.length - 1);
244
+ return `(${clamped + 1}/${rows.length})`;
245
+ }
246
+ export const initialSettingsMenuState = () => ({ filter: "", selected: 0 });
247
+ const PAGE_SIZE = 10;
248
+ export function settingsKeyAction(data, state) {
249
+ if (!data)
250
+ return { kind: "ignore" };
251
+ if (data === "\x1b" || data === "\x1b[")
252
+ return { kind: "cancel" };
253
+ if (data === "\r" || data === "\n")
254
+ return { kind: "change" };
255
+ if (data === " " && !state.filter)
256
+ return { kind: "change" };
257
+ if (data === "\x1b[A")
258
+ return { kind: "move", direction: "up" };
259
+ if (data === "\x1b[B")
260
+ return { kind: "move", direction: "down" };
261
+ if (data === "\x1b[H" || data === "\x1b[1~")
262
+ return { kind: "move", direction: "start" };
263
+ if (data === "\x1b[F" || data === "\x1b[4~")
264
+ return { kind: "move", direction: "end" };
265
+ if (data === "\x1b[5~")
266
+ return { kind: "move", direction: "pageUp" };
267
+ if (data === "\x1b[6~")
268
+ return { kind: "move", direction: "pageDown" };
269
+ if (data === "\x7f" || data === "\b" || data === "\x08" || data === "\x1b[3~")
270
+ return { kind: "backspace" };
271
+ if (data.length === 1 && data >= " " && data <= "~")
272
+ return { kind: "type", char: data };
273
+ return { kind: "ignore" };
274
+ }
275
+ /**
276
+ * Pure keyboard reducer over the menu state. `rows` is the current FILTERED list (navigation rows
277
+ * and settings rows alike); Enter/Space changes a setting or navigates, Esc cancels, typing
278
+ * filters, Backspace edits the filter, and movement keys move/clamp the selection.
279
+ */
280
+ export function reduceSettingsInput(state, action, rows) {
281
+ switch (action.kind) {
282
+ case "cancel":
283
+ return { state, effect: { type: "cancel" } };
284
+ case "change": {
285
+ const row = rows[state.selected];
286
+ if (!row)
287
+ return { state, effect: { type: "none" } };
288
+ if (row.kind === "navigation")
289
+ return { state, effect: { type: "navigate", row } };
290
+ const value = cycleSettingValue(row, row.current);
291
+ if (value === undefined)
292
+ return { state, effect: { type: "none" } };
293
+ return { state, effect: { type: "change", row, value } };
294
+ }
295
+ case "type": {
296
+ const filter = `${state.filter}${action.char}`;
297
+ return { state: { filter, selected: 0 }, effect: { type: "none" } };
298
+ }
299
+ case "backspace": {
300
+ const filter = state.filter.slice(0, -1);
301
+ return {
302
+ state: { filter, selected: 0 },
303
+ effect: { type: "none" },
304
+ };
305
+ }
306
+ case "move": {
307
+ if (!rows.length)
308
+ return { state, effect: { type: "none" } };
309
+ const last = rows.length - 1;
310
+ const selected = action.direction === "up"
311
+ ? state.selected === 0
312
+ ? last
313
+ : state.selected - 1
314
+ : action.direction === "down"
315
+ ? state.selected === last
316
+ ? 0
317
+ : state.selected + 1
318
+ : action.direction === "start"
319
+ ? 0
320
+ : action.direction === "end"
321
+ ? last
322
+ : action.direction === "pageUp"
323
+ ? Math.max(0, state.selected - PAGE_SIZE)
324
+ : Math.min(last, state.selected + PAGE_SIZE);
325
+ return { state: { ...state, selected }, effect: { type: "none" } };
326
+ }
327
+ case "ignore":
328
+ return { state, effect: { type: "none" } };
329
+ }
330
+ }