pi-cockpit 0.3.0 → 0.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.
@@ -0,0 +1,310 @@
1
+ // Quiet mode tool rendering: compress each built-in tool call into a single
2
+ // ✓/✗/⋯ line. Architecture adapted from pi-quiet (github.com/wing900/pi-quiet):
3
+ // a config-driven SPECS table + shared render functions. Execution is delegated
4
+ // to the original built-in tools — only the rendering is replaced.
5
+ //
6
+ // Scope boundary: only the seven built-in tools (read/bash/edit/write/grep/find/ls)
7
+ // are compressed. Compression works by re-registering the tool name and delegating
8
+ // execution back to pi's exported creators. MCP/extension tools (todo, teammate,
9
+ // mcp, lsp, ...) cannot be compressed this way: registerTool is first-name-wins and
10
+ // replaces the whole definition, and the public API exposes neither a render-only
11
+ // hook nor a handle to the original execute — re-registering them would break their
12
+ // execution, so they keep their default rendering.
13
+ //
14
+ // Registered at session_start when config.quietMode is true. Because tools cannot
15
+ // be unregistered, toggling quiet mode off requires /reload or a new session.
16
+
17
+ import type {
18
+ BashToolDetails,
19
+ EditToolDetails,
20
+ ExtensionAPI,
21
+ ExtensionContext,
22
+ ReadToolDetails,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ createBashTool,
26
+ createEditTool,
27
+ createFindTool,
28
+ createGrepTool,
29
+ createLsTool,
30
+ createReadTool,
31
+ createWriteTool,
32
+ } from "@earendil-works/pi-coding-agent";
33
+ import { Text } from "@earendil-works/pi-tui";
34
+ import { homedir } from "node:os";
35
+ import type { CockpitConfig } from "./types.ts";
36
+ import { resolveGlyphs, type IconGlyphs } from "./icons.ts";
37
+
38
+ // ---------- helpers ----------
39
+
40
+ function shortenPath(p: string): string {
41
+ const home = homedir();
42
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
43
+ }
44
+
45
+ function truncate(s: string, max: number): string {
46
+ if (s.length <= max) return s;
47
+ return `${s.slice(0, max - 1)}…`;
48
+ }
49
+
50
+ function termWidth(): number {
51
+ return process.stdout.columns || 100;
52
+ }
53
+
54
+ function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
55
+ const c = result.content.find((c) => c.type === "text" && c.text);
56
+ return c?.text ?? "";
57
+ }
58
+
59
+ function parseBashExit(output: string): number | null {
60
+ const m = output.match(/exit code: (\d+)/);
61
+ return m ? parseInt(m[1], 10) : null;
62
+ }
63
+
64
+ function firstErrorLine(result: { content: Array<{ type: string; text?: string }> }): string {
65
+ return truncate(textOf(result).split("\n").find((l) => l.trim()) || "error", 60);
66
+ }
67
+
68
+ // ---------- built-in tool cache ----------
69
+
70
+ type BuiltInTools = ReturnType<typeof createBuiltInTools>;
71
+ const toolCache = new Map<string, BuiltInTools>();
72
+
73
+ function createBuiltInTools(cwd: string) {
74
+ return {
75
+ read: createReadTool(cwd),
76
+ bash: createBashTool(cwd),
77
+ edit: createEditTool(cwd),
78
+ write: createWriteTool(cwd),
79
+ find: createFindTool(cwd),
80
+ grep: createGrepTool(cwd),
81
+ ls: createLsTool(cwd),
82
+ };
83
+ }
84
+
85
+ function getBuiltInTools(cwd: string): BuiltInTools {
86
+ let tools = toolCache.get(cwd);
87
+ if (!tools) {
88
+ tools = createBuiltInTools(cwd);
89
+ toolCache.set(cwd, tools);
90
+ }
91
+ return tools;
92
+ }
93
+
94
+ // ---------- tool spec ----------
95
+
96
+ interface ToolSpec {
97
+ name: string;
98
+ /** Key argument shown after the tool name (path / command / pattern). */
99
+ arg: (args: any) => string;
100
+ /** Final compact summary after the separator (for example "12M" or "0 · 3L"). */
101
+ summary: (result: any, ctx: any) => string;
102
+ /** Expanded content; empty string means nothing extra. */
103
+ expanded?: (result: any) => string;
104
+ /** Success predicate; defaults to !ctx.isError. */
105
+ ok?: (result: any, ctx: any) => boolean;
106
+ }
107
+
108
+ const SPECS: ToolSpec[] = [
109
+ {
110
+ name: "read",
111
+ arg: (a) => shortenPath(a.path || ""),
112
+ summary: (r) => {
113
+ const c = r.content[0];
114
+ if (c?.type === "image") return "image";
115
+ const lines = c?.type === "text" ? c.text.split("\n").length : 0;
116
+ const d = r.details as ReadToolDetails | undefined;
117
+ let s = `${lines}L`;
118
+ if (d?.truncation?.truncated) s += `/${d.truncation.totalLines}L`;
119
+ return s;
120
+ },
121
+ expanded: (r) => textOf(r).split("\n").slice(0, 15).join("\n"),
122
+ },
123
+ {
124
+ name: "bash",
125
+ arg: (a) => a.command || "",
126
+ summary: (r) => {
127
+ const out = textOf(r);
128
+ const exit = parseBashExit(out);
129
+ const n = out.split("\n").filter((l: string) => l.trim()).length;
130
+ const d = r.details as BashToolDetails | undefined;
131
+ let s = `${exit ?? "?"} · ${n}L`;
132
+ if (d?.truncation?.truncated) s += " · trunc";
133
+ return s;
134
+ },
135
+ expanded: (r) => textOf(r).split("\n").slice(0, 20).join("\n"),
136
+ ok: (r, ctx) => {
137
+ if (ctx.isError) return false;
138
+ const exit = parseBashExit(textOf(r));
139
+ return exit === null || exit === 0;
140
+ },
141
+ },
142
+ {
143
+ name: "edit",
144
+ arg: (a) => shortenPath(a.path || ""),
145
+ summary: (r, ctx) => {
146
+ if (ctx.isError) return firstErrorLine(r);
147
+ const d = r.details as EditToolDetails | undefined;
148
+ if (!d?.diff) return "applied";
149
+ let add = 0;
150
+ let del = 0;
151
+ for (const line of d.diff.split("\n")) {
152
+ if (line.startsWith("+") && !line.startsWith("+++")) add++;
153
+ if (line.startsWith("-") && !line.startsWith("---")) del++;
154
+ }
155
+ return `+${add}/-${del}`;
156
+ },
157
+ expanded: (r) => {
158
+ const d = r.details as EditToolDetails | undefined;
159
+ return d?.diff ?? textOf(r);
160
+ },
161
+ },
162
+ {
163
+ name: "write",
164
+ arg: (a) => shortenPath(a.path || ""),
165
+ summary: (r, ctx) => {
166
+ if (ctx.isError) return firstErrorLine(r);
167
+ const content = typeof ctx.args?.content === "string" ? ctx.args.content : "";
168
+ return `${content === "" ? 0 : content.split("\n").length}L`;
169
+ },
170
+ },
171
+ {
172
+ name: "find",
173
+ arg: (a) => `${a.pattern || ""} @ ${shortenPath(a.path || ".")}`,
174
+ summary: (r, ctx) => (ctx.isError ? firstErrorLine(r) : `${textOf(r).trim().split("\n").filter(Boolean).length}F`),
175
+ expanded: (r) => textOf(r),
176
+ },
177
+ {
178
+ name: "grep",
179
+ arg: (a) => `${a.pattern || ""} @ ${shortenPath(a.path || ".")}`,
180
+ summary: (r, ctx) => (ctx.isError ? firstErrorLine(r) : `${textOf(r).trim().split("\n").filter(Boolean).length}M`),
181
+ expanded: (r) => textOf(r),
182
+ },
183
+ {
184
+ name: "ls",
185
+ arg: (a) => shortenPath(a.path || "."),
186
+ summary: (r, ctx) => (ctx.isError ? firstErrorLine(r) : `${textOf(r).trim().split("\n").filter(Boolean).length}E`),
187
+ expanded: (r) => textOf(r),
188
+ },
189
+ ];
190
+
191
+ // ---------- rendering ----------
192
+
193
+ const TOOL_COLORS: Record<string, string> = {
194
+ bash: "syntaxFunction",
195
+ read: "syntaxType",
196
+ ls: "syntaxType",
197
+ grep: "syntaxKeyword",
198
+ find: "syntaxKeyword",
199
+ edit: "syntaxVariable",
200
+ write: "syntaxVariable",
201
+ };
202
+
203
+ function quietMark(
204
+ mode: CockpitConfig["quietSymbols"],
205
+ state: "running" | "success" | "failure",
206
+ glyphs: IconGlyphs,
207
+ ): string {
208
+ if (mode === "dot") {
209
+ if (state === "running") return glyphs.pending;
210
+ if (state === "success") return glyphs.dotRunning;
211
+ return glyphs.blocked;
212
+ }
213
+ if (state === "running") return glyphs.ellipsis;
214
+ if (state === "success") return glyphs.check;
215
+ return glyphs.cross;
216
+ }
217
+
218
+ function toolName(spec: ToolSpec, theme: any): string {
219
+ return theme.fg(TOOL_COLORS[spec.name] ?? "toolTitle", theme.bold(spec.name));
220
+ }
221
+
222
+ function renderCallLine(
223
+ spec: ToolSpec,
224
+ args: any,
225
+ theme: any,
226
+ glyphs: IconGlyphs,
227
+ mode: CockpitConfig["quietSymbols"],
228
+ ): string {
229
+ const mark = quietMark(mode, "running", glyphs);
230
+ const argCap = Math.max(10, termWidth() - (spec.name.length + mark.length + 5));
231
+ const argText = truncate(spec.arg(args), argCap);
232
+ return ` ${theme.fg("warning", mark)} ${toolName(spec, theme)}${argText ? ` ${theme.fg("accent", argText)}` : ""}`;
233
+ }
234
+
235
+ function renderResultLine(
236
+ spec: ToolSpec,
237
+ args: any,
238
+ result: any,
239
+ ctx: any,
240
+ theme: any,
241
+ expanded: boolean,
242
+ glyphs: IconGlyphs,
243
+ mode: CockpitConfig["quietSymbols"],
244
+ ): string {
245
+ const isOk = spec.ok ? spec.ok(result, ctx) : !ctx.isError;
246
+ const rawMark = quietMark(mode, isOk ? "success" : "failure", glyphs);
247
+ const mark = theme.fg(isOk ? "success" : "error", rawMark);
248
+
249
+ const overhead = 7 + spec.name.length + rawMark.length;
250
+ const budget = Math.max(20, termWidth() - overhead);
251
+ const sumCap = Math.min(35, Math.floor(budget * 0.4));
252
+ const sumRaw = truncate(spec.summary(result, ctx), sumCap);
253
+ const argCap = Math.max(10, budget - sumRaw.length);
254
+ const argText = truncate(spec.arg(args), argCap);
255
+
256
+ let t = ` ${mark} ${toolName(spec, theme)}${argText ? ` ${theme.fg("accent", argText)}` : ""}`;
257
+ if (sumRaw) t += ` ${theme.fg("dim", `· ${sumRaw}`)}`;
258
+
259
+ if (expanded) {
260
+ const full = spec.expanded ? spec.expanded(result) : textOf(result);
261
+ if (full && full.trim()) {
262
+ t += "\n" + theme.fg("dim", full);
263
+ }
264
+ }
265
+ return t;
266
+ }
267
+
268
+ // ---------- registration ----------
269
+
270
+ /**
271
+ * Register compact tool renderers for quiet mode. Called once at session_start
272
+ * when config.quietMode is true. Tools delegate execution to the built-in
273
+ * implementations; only the visual shell is replaced.
274
+ */
275
+ export function registerQuietTools(pi: ExtensionAPI, getConfig: () => CockpitConfig): void {
276
+ for (const spec of SPECS) {
277
+ const original = (getBuiltInTools(process.cwd()) as any)[spec.name];
278
+ pi.registerTool({
279
+ name: spec.name,
280
+ label: spec.name,
281
+ description: original.description,
282
+ parameters: original.parameters,
283
+ renderShell: "self",
284
+
285
+ async execute(toolCallId: string, params: any, signal: any, onUpdate: any, ctx: ExtensionContext) {
286
+ return (getBuiltInTools(ctx.cwd) as any)[spec.name].execute(toolCallId, params, signal, onUpdate);
287
+ },
288
+
289
+ renderCall(args: any, theme: any, ctx: any) {
290
+ // Once the result arrives this slot is empty; only renderResult draws.
291
+ if (!ctx.isPartial) return new Text("", 0, 0);
292
+ const config = getConfig();
293
+ const glyphs = resolveGlyphs(config.icons.mode);
294
+ return new Text(renderCallLine(spec, args, theme, glyphs, config.quietSymbols), 0, 0);
295
+ },
296
+
297
+ renderResult(result: any, { expanded, isPartial }: any, theme: any, ctx: any) {
298
+ // While streaming, leave empty because renderCall owns the row.
299
+ if (isPartial) return new Text("", 0, 0);
300
+ const config = getConfig();
301
+ const glyphs = resolveGlyphs(config.icons.mode);
302
+ return new Text(
303
+ renderResultLine(spec, ctx.args, result, ctx, theme, expanded, glyphs, config.quietSymbols),
304
+ 0,
305
+ 0,
306
+ );
307
+ },
308
+ });
309
+ }
310
+ }
package/src/render.ts CHANGED
@@ -205,6 +205,7 @@ export function renderAgents(
205
205
  if (rows.length === 0) return [];
206
206
  const g = opts.glyphs;
207
207
  const ell = theme.fg("dim", g.ellipsis);
208
+
208
209
  if (mode === "compact") {
209
210
  const active = rows.filter((row) => row.status === "running" || row.status === "retrying").length;
210
211
  const head = `${theme.fg("text", String(rows.length))} ${theme.fg("muted", "agents")}${active ? theme.fg("dim", ` · ${active} active`) : ""}`;
@@ -239,7 +240,7 @@ export function renderAgents(
239
240
  if (status.label) segs.push({ text: theme.fg(status.color, status.label), priority: 95, clippable: false });
240
241
  segs.push({ text: theme.fg(rc, r.role), priority: 90 });
241
242
  if (r.task) segs.push({ text: r.task, priority: 80, minWidth: 6 });
242
- segs.push({ text: theme.fg("muted", formatDuration(now - r.startedAt)), priority: 70, clippable: false });
243
+ segs.push({ text: theme.fg("muted", formatDuration((r.finishedAt ?? now) - r.startedAt)), priority: 70, clippable: false });
243
244
  if (r.dependencies?.length) {
244
245
  segs.push({ text: theme.fg("dim", formatDependencies(r.dependencies, g)), priority: 60, clippable: false });
245
246
  }
@@ -1,129 +1,166 @@
1
- // Pure model for the /cockpit settings panel.
2
- //
3
- // The old panel was a flat list of blind letter accelerators: no cursor, no
4
- // focused row, no indication of what a key would cycle to, and every keystroke
5
- // wrote to disk while failures were swallowed. This module owns the row model and
6
- // the cursor/cycle logic so the behaviour is unit-testable; index.ts only paints
7
- // it and performs the actual save.
8
-
9
- import type { CockpitConfig, IconMode, ViewMode } from "./types.ts";
10
-
11
- export type SaveState =
12
- | { kind: "idle" }
13
- | { kind: "saving" }
14
- | { kind: "saved" }
15
- | { kind: "failed"; message: string };
16
-
17
- export interface SettingsRow {
18
- key: string;
19
- /** Single-letter accelerator, kept as a secondary path alongside the cursor. */
20
- accel: string;
21
- label: string;
22
- value: string;
23
- /** What pressing Enter/Space/accel switches to — shown so the cycle is visible. */
24
- next: string;
25
- }
26
-
27
- const VIEW_MODES: ViewMode[] = ["list", "compact"];
28
- const ICON_MODES: IconMode[] = ["auto", "nerd", "ascii"];
29
-
30
- function cycle<T>(values: readonly T[], current: T): T {
31
- const index = values.indexOf(current);
32
- return values[(index + 1) % values.length];
33
- }
34
-
35
- // Empty means cockpit has not set a theme, so whatever /settings holds is live.
36
- // It is deliberately not called "default": pi's setting is the authority here,
37
- // and cockpit only ever nudges it.
38
- const NO_THEME_LABEL = "(pi settings)";
39
-
40
- function themeLabel(theme: string): string {
41
- return theme === "" ? NO_THEME_LABEL : theme;
42
- }
43
-
44
- export function buildRows(config: CockpitConfig): SettingsRow[] {
45
- return [
46
- {
47
- key: "enabled",
48
- accel: "e",
49
- label: "enabled",
50
- value: config.enabled ? "on" : "off",
51
- next: config.enabled ? "off" : "on",
52
- },
53
- {
54
- key: "agentsMode",
55
- accel: "a",
56
- label: "agents",
57
- value: config.agentsMode,
58
- next: cycle(VIEW_MODES, config.agentsMode),
59
- },
60
- {
61
- key: "todoMode",
62
- accel: "t",
63
- label: "todo",
64
- value: config.todoMode,
65
- next: cycle(VIEW_MODES, config.todoMode),
66
- },
67
- {
68
- key: "todoExpanded",
69
- accel: "x",
70
- label: "todo expand",
71
- value: config.todoExpanded ? "yes" : "no",
72
- next: config.todoExpanded ? "no" : "yes",
73
- },
74
- {
75
- key: "hideNativeAgents",
76
- accel: "n",
77
- label: "hide native",
78
- value: config.hideNativeAgents ? "yes" : "no",
79
- next: config.hideNativeAgents ? "no" : "yes",
80
- },
81
- {
82
- key: "icons",
83
- accel: "i",
84
- label: "icons",
85
- value: config.icons.mode,
86
- next: cycle(ICON_MODES, config.icons.mode),
87
- },
88
- {
89
- key: "theme",
90
- accel: "h",
91
- label: "theme",
92
- value: themeLabel(config.theme),
93
- // Not a cycle: this row opens the /theme picker, which previews live and
94
- // reverts on Esc. Blind-cycling a name list could not do either.
95
- //
96
- // Worded as an affordance, not as a command to go type: every other row's
97
- // `next` is the value Enter produces, and "open /theme" read as "leave this
98
- // panel and run something else" when Enter opens it right here. The ellipsis
99
- // is the usual terminal signal for "this one opens a dialog".
100
- next: "picker…",
101
- },
102
- ];
103
- }
104
-
105
- /** Apply the row's cycle to the config, returning a new config object. */
106
- export function applyRow(config: CockpitConfig, key: string): CockpitConfig {
107
- switch (key) {
108
- case "enabled":
109
- return { ...config, enabled: !config.enabled };
110
- case "agentsMode":
111
- return { ...config, agentsMode: cycle(VIEW_MODES, config.agentsMode) };
112
- case "todoMode":
113
- return { ...config, todoMode: cycle(VIEW_MODES, config.todoMode) };
114
- case "todoExpanded":
115
- return { ...config, todoExpanded: !config.todoExpanded };
116
- case "hideNativeAgents":
117
- return { ...config, hideNativeAgents: !config.hideNativeAgents };
118
- case "icons":
119
- return { ...config, icons: { mode: cycle(ICON_MODES, config.icons.mode) } };
120
- // "theme" is intentionally absent: the row hands off to the /theme picker,
121
- // which owns both the preview and the write-through to pi's settings.
122
- default:
123
- return config;
124
- }
125
- }
126
-
127
- export function rowKeyForAccel(rows: readonly SettingsRow[], accel: string): string | undefined {
128
- return rows.find((row) => row.accel === accel)?.key;
129
- }
1
+ // Pure model for the /cockpit settings panel.
2
+ //
3
+ // The old panel was a flat list of blind letter accelerators: no cursor, no
4
+ // focused row, no indication of what a key would cycle to, and every keystroke
5
+ // wrote to disk while failures were swallowed. This module owns the row model and
6
+ // the cursor/cycle logic so the behaviour is unit-testable; index.ts only paints
7
+ // it and performs the actual save.
8
+
9
+ import type { CockpitConfig, IconMode, QuietSymbolMode, ViewMode } from "./types.ts";
10
+
11
+ export type SaveState =
12
+ | { kind: "idle" }
13
+ | { kind: "saving" }
14
+ | { kind: "saved" }
15
+ | { kind: "failed"; message: string };
16
+
17
+ export interface SettingsRow {
18
+ key: string;
19
+ /** Single-letter accelerator, kept as a secondary path alongside the cursor. */
20
+ accel: string;
21
+ label: string;
22
+ value: string;
23
+ /** What pressing Enter/Space/accel switches to — shown so the cycle is visible. */
24
+ next: string;
25
+ }
26
+
27
+ const VIEW_MODES: ViewMode[] = ["list", "compact"];
28
+ const QUIET_SYMBOL_MODES: QuietSymbolMode[] = ["check", "dot"];
29
+ const ICON_MODES: IconMode[] = ["auto", "nerd", "ascii"];
30
+
31
+ function cycle<T>(values: readonly T[], current: T): T {
32
+ const index = values.indexOf(current);
33
+ return values[(index + 1) % values.length];
34
+ }
35
+
36
+ // Empty means cockpit has not set a theme, so whatever /settings holds is live.
37
+ // It is deliberately not called "default": pi's setting is the authority here,
38
+ // and cockpit only ever nudges it.
39
+ const NO_THEME_LABEL = "(pi settings)";
40
+
41
+ function themeLabel(theme: string): string {
42
+ return theme === "" ? NO_THEME_LABEL : theme;
43
+ }
44
+
45
+ export interface LiveRowState {
46
+ /** pi's effective hideThinkingBlock; the thinking row is a pass-through. */
47
+ thinkingHidden: boolean;
48
+ }
49
+
50
+ export function buildRows(config: CockpitConfig, live?: LiveRowState): SettingsRow[] {
51
+ const thinkingHidden = live?.thinkingHidden ?? false;
52
+ return [
53
+ {
54
+ key: "enabled",
55
+ accel: "e",
56
+ label: "enabled",
57
+ value: config.enabled ? "on" : "off",
58
+ next: config.enabled ? "off" : "on",
59
+ },
60
+ {
61
+ key: "quietMode",
62
+ accel: "q",
63
+ label: "quiet (reload)",
64
+ value: config.quietMode ? "on" : "off",
65
+ next: config.quietMode ? "off" : "on",
66
+ },
67
+ {
68
+ key: "quietSymbols",
69
+ accel: "s",
70
+ label: "quiet symbols",
71
+ value: config.quietSymbols,
72
+ next: cycle(QUIET_SYMBOL_MODES, config.quietSymbols),
73
+ },
74
+ {
75
+ // Pass-through to pi's native thinking toggle: the value mirrors pi's
76
+ // persisted hideThinkingBlock and the panel dispatches the toggle
77
+ // itself — applyRow deliberately ignores this key, like "theme".
78
+ key: "thinkingFold",
79
+ accel: "f",
80
+ label: "thinking fold",
81
+ value: thinkingHidden ? "hidden" : "visible",
82
+ next: thinkingHidden ? "visible" : "hidden",
83
+ },
84
+ {
85
+ key: "agentsMode",
86
+ accel: "a",
87
+ label: "agents",
88
+ value: config.agentsMode,
89
+ next: cycle(VIEW_MODES, config.agentsMode),
90
+ },
91
+ {
92
+ key: "todoMode",
93
+ accel: "t",
94
+ label: "todo",
95
+ value: config.todoMode,
96
+ next: cycle(VIEW_MODES, config.todoMode),
97
+ },
98
+ {
99
+ key: "todoExpanded",
100
+ accel: "x",
101
+ label: "todo expand",
102
+ value: config.todoExpanded ? "yes" : "no",
103
+ next: config.todoExpanded ? "no" : "yes",
104
+ },
105
+ {
106
+ key: "hideNativeAgents",
107
+ accel: "n",
108
+ label: "hide native",
109
+ value: config.hideNativeAgents ? "yes" : "no",
110
+ next: config.hideNativeAgents ? "no" : "yes",
111
+ },
112
+ {
113
+ key: "icons",
114
+ accel: "i",
115
+ label: "icons",
116
+ value: config.icons.mode,
117
+ next: cycle(ICON_MODES, config.icons.mode),
118
+ },
119
+ {
120
+ key: "theme",
121
+ accel: "h",
122
+ label: "theme",
123
+ value: themeLabel(config.theme),
124
+ // Not a cycle: this row opens the /theme picker, which previews live and
125
+ // reverts on Esc. Blind-cycling a name list could not do either.
126
+ //
127
+ // Worded as an affordance, not as a command to go type: every other row's
128
+ // `next` is the value Enter produces, and "open /theme" read as "leave this
129
+ // panel and run something else" when Enter opens it right here. The ellipsis
130
+ // is the usual terminal signal for "this one opens a dialog".
131
+ next: "picker…",
132
+ },
133
+ ];
134
+ }
135
+
136
+ /** Apply the row's cycle to the config, returning a new config object. */
137
+ export function applyRow(config: CockpitConfig, key: string): CockpitConfig {
138
+ switch (key) {
139
+ case "enabled":
140
+ return { ...config, enabled: !config.enabled };
141
+ case "quietMode":
142
+ return { ...config, quietMode: !config.quietMode };
143
+ case "quietSymbols":
144
+ return { ...config, quietSymbols: cycle(QUIET_SYMBOL_MODES, config.quietSymbols) };
145
+ case "agentsMode":
146
+ return { ...config, agentsMode: cycle(VIEW_MODES, config.agentsMode) };
147
+ case "todoMode":
148
+ return { ...config, todoMode: cycle(VIEW_MODES, config.todoMode) };
149
+ case "todoExpanded":
150
+ return { ...config, todoExpanded: !config.todoExpanded };
151
+ case "hideNativeAgents":
152
+ return { ...config, hideNativeAgents: !config.hideNativeAgents };
153
+ case "icons":
154
+ return { ...config, icons: { mode: cycle(ICON_MODES, config.icons.mode) } };
155
+ // "theme" is intentionally absent: the row hands off to the /theme picker,
156
+ // which owns both the preview and the write-through to pi's settings.
157
+ // "thinkingFold" is absent for the same reason: pi owns hideThinkingBlock,
158
+ // and the panel dispatches pi's native toggle instead of cycling config.
159
+ default:
160
+ return config;
161
+ }
162
+ }
163
+
164
+ export function rowKeyForAccel(rows: readonly SettingsRow[], accel: string): string | undefined {
165
+ return rows.find((row) => row.accel === accel)?.key;
166
+ }