pi-cockpit 0.4.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.
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.4.0",
4
- "description": "Agent cockpit for Pi: a list-mode status stack (live teammates + todo plan) pinned above the editor, plus a Starship-style footer. Renders only via public extension APIs (setWidget/setFooter).",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-extension",
10
- "tui",
11
- "teammate",
12
- "todo"
13
- ],
14
- "license": "MIT",
15
- "files": [
16
- "src/",
17
- "themes/",
18
- "README.md"
19
- ],
20
- "scripts": {
21
- "test": "node --test --experimental-transform-types tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "pi": {
25
- "extensions": [
26
- "./src/extension/index.ts"
27
- ],
28
- "themes": [
29
- "./themes"
30
- ]
31
- },
32
- "peerDependencies": {
33
- "@earendil-works/pi-agent-core": "*",
34
- "@earendil-works/pi-ai": "*",
35
- "@earendil-works/pi-coding-agent": "*",
36
- "@earendil-works/pi-tui": "*",
37
- "pi-maestro-teammate": "^1.0.0"
38
- },
39
- "peerDependenciesMeta": {
40
- "@earendil-works/pi-agent-core": {
41
- "optional": true
42
- },
43
- "@earendil-works/pi-ai": {
44
- "optional": true
45
- },
46
- "@earendil-works/pi-coding-agent": {
47
- "optional": true
48
- },
49
- "@earendil-works/pi-tui": {
50
- "optional": true
51
- },
52
- "pi-maestro-teammate": {
53
- "optional": true
54
- }
55
- },
56
- "devDependencies": {
57
- "@earendil-works/pi-agent-core": "0.82.1",
58
- "@earendil-works/pi-ai": "0.82.1",
59
- "@earendil-works/pi-coding-agent": "0.82.1",
60
- "@earendil-works/pi-tui": "0.82.1",
61
- "@types/node": "^22.0.0",
62
- "pi-maestro-teammate": "1.2.0",
63
- "typescript": "^5.5.0"
64
- },
65
- "main": "./src/index.ts",
66
- "exports": {
67
- ".": "./src/index.ts",
68
- "./src/*": "./src/*"
69
- }
70
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.5.0",
4
+ "description": "Agent cockpit for Pi: a list-mode status stack (live teammates + todo plan) pinned above the editor, plus a Starship-style footer. Renders only via public extension APIs (setWidget/setFooter).",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test --experimental-transform-types tests/config.test.ts tests/quiet-tools.test.ts tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension/index.ts"
27
+ ],
28
+ "themes": [
29
+ "./themes"
30
+ ]
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-agent-core": "*",
34
+ "@earendil-works/pi-ai": "*",
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*",
37
+ "pi-maestro-teammate": "^1.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-agent-core": {
41
+ "optional": true
42
+ },
43
+ "@earendil-works/pi-ai": {
44
+ "optional": true
45
+ },
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-tui": {
50
+ "optional": true
51
+ },
52
+ "pi-maestro-teammate": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@earendil-works/pi-agent-core": "0.82.1",
58
+ "@earendil-works/pi-ai": "0.82.1",
59
+ "@earendil-works/pi-coding-agent": "0.82.1",
60
+ "@earendil-works/pi-tui": "0.82.1",
61
+ "@types/node": "^22.0.0",
62
+ "pi-maestro-teammate": "1.3.0",
63
+ "typescript": "^5.5.0"
64
+ },
65
+ "main": "./src/index.ts",
66
+ "exports": {
67
+ ".": "./src/index.ts",
68
+ "./src/*": "./src/*"
69
+ }
70
+ }
@@ -78,6 +78,22 @@ export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exi
78
78
  */
79
79
  export const FAILED_LINGER_MS = 30_000;
80
80
 
81
+ /**
82
+ * How long a completed agent's tombstone suppresses self-healing.
83
+ *
84
+ * `complete` deletes a row, but the teammate extension can still publish
85
+ * progress afterwards: the flush gate may re-arm after disposal, a graph task
86
+ * that returned result-ready carries `status:"running"` until its lifecycle
87
+ * confirms (up to a 60s deadline), and nested/IPC deltas reorder freely. Any
88
+ * such late delta would otherwise self-heal a ghost row that no second
89
+ * `complete` ever removes — the footer keeps showing an agent as running long
90
+ * after its tool result was already returned. The tombstone blocks that
91
+ * rebuild. It must outlive the lifecycle-confirmation deadline with margin.
92
+ * An explicit `started` (a genuine new lifecycle, e.g. a woken agent) clears
93
+ * the tombstone, so legitimate reuse is never suppressed.
94
+ */
95
+ export const COMPLETED_TOMBSTONE_MS = 120_000;
96
+
81
97
  function deriveRole(agent: string | undefined, name: string | undefined): string {
82
98
  if (agent && !agent.startsWith("graph(")) return clean(agent);
83
99
  return clean(name) || "agent";
@@ -145,9 +161,21 @@ function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
145
161
  // always represented as long as it keeps emitting activity.
146
162
  export class AgentsStore {
147
163
  private readonly roster = new Map<string, AgentRow>();
164
+ /** correlationId -> completion time; suppresses post-complete self-healing. */
165
+ private readonly completedAt = new Map<string, number>();
166
+
167
+ private isTombstoned(id: string | undefined, now: number): boolean {
168
+ if (id === undefined) return false;
169
+ const at = this.completedAt.get(id);
170
+ return at !== undefined && now - at < COMPLETED_TOMBSTONE_MS;
171
+ }
148
172
 
149
173
  applyStarted(p: StartedPayload, now: number = Date.now()): void {
150
174
  const id = p.correlationId;
175
+ // An explicit start is authoritative: a woken or re-dispatched agent
176
+ // reuses its correlationId and must reappear even if an earlier run was
177
+ // tombstoned.
178
+ this.completedAt.delete(id);
151
179
  const prev = this.roster.get(id);
152
180
  const row: AgentRow = {
153
181
  ...prev,
@@ -175,11 +203,16 @@ export class AgentsStore {
175
203
  for (const progress of p.progress) this.applyProgress(p.correlationId, progress, now);
176
204
  }
177
205
  const targetId = p.taskCorrelationId ?? p.correlationId;
178
- if (!this.roster.has(targetId)) {
206
+ if (!this.roster.has(targetId)
207
+ && !this.isTombstoned(targetId, now)
208
+ && !this.isTombstoned(p.correlationId, now)) {
179
209
  // Self-heal the roster: a running agent must stay visible even when its
180
210
  // `started` delta was missed (cold start, event reorder, or a foreground
181
211
  // dispatch that detached to background). Materialize the row from this
182
212
  // event instead of dropping it; later deltas refine the placeholder.
213
+ // A tombstoned target (or graph parent) means `complete` already ran, so
214
+ // this delta is a late ghost, not a missed start — dropping it keeps the
215
+ // row deleted.
183
216
  this.applyStarted({
184
217
  correlationId: targetId,
185
218
  agent: p.agent ?? "",
@@ -242,6 +275,9 @@ export class AgentsStore {
242
275
  delete row.activeTool;
243
276
  } else {
244
277
  this.roster.delete(id);
278
+ // Tombstone even when the row never existed: a `complete` that beats
279
+ // the first delta must still suppress the self-heal that follows.
280
+ this.completedAt.set(id, now);
245
281
  }
246
282
  }
247
283
  }
@@ -255,6 +291,9 @@ export class AgentsStore {
255
291
  changed = true;
256
292
  }
257
293
  }
294
+ for (const [id, at] of this.completedAt) {
295
+ if (now - at >= COMPLETED_TOMBSTONE_MS) this.completedAt.delete(id);
296
+ }
258
297
  return changed;
259
298
  }
260
299
 
@@ -267,9 +306,13 @@ export class AgentsStore {
267
306
  }
268
307
 
269
308
  private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
270
- if (!this.roster.has(p.correlationId)) {
309
+ if (!this.roster.has(p.correlationId)
310
+ && !this.isTombstoned(p.correlationId, now)
311
+ && !this.isTombstoned(parentCorrelationId, now)) {
271
312
  // Self-heal: see applyMessage. A graph child whose `started` delta was
272
- // missed still materializes from its first progress event.
313
+ // missed still materializes from its first progress event — unless the
314
+ // child or its graph parent already completed, in which case this late
315
+ // snapshot entry is a ghost and the row must stay deleted.
273
316
  this.applyStarted({
274
317
  correlationId: p.correlationId,
275
318
  agent: p.agent,
@@ -339,5 +382,6 @@ export class AgentsStore {
339
382
 
340
383
  clear(): void {
341
384
  this.roster.clear();
385
+ this.completedAt.clear();
342
386
  }
343
387
  }
package/src/config.ts CHANGED
@@ -8,16 +8,19 @@ export function getConfigPath(): string {
8
8
  }
9
9
 
10
10
  // Flat, field-by-field merge: type-safe and forward-compatible (unknown keys ignored).
11
- function deepMerge(base: CockpitConfig, over: unknown): CockpitConfig {
11
+ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
12
12
  if (!over || typeof over !== "object" || Array.isArray(over)) return base;
13
13
  const o = over as Record<string, unknown>;
14
14
  const isMode = (v: unknown): v is "list" | "compact" => v === "list" || v === "compact";
15
+ const isQuietSymbolMode = (v: unknown): v is "check" | "dot" => v === "check" || v === "dot";
15
16
  const isIconMode = (v: unknown): v is "auto" | "nerd" | "ascii" => v === "auto" || v === "nerd" || v === "ascii";
16
17
  const iconsRaw = o.icons && typeof o.icons === "object" && !Array.isArray(o.icons)
17
18
  ? (o.icons as Record<string, unknown>)
18
19
  : undefined;
19
20
  return {
20
21
  enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
22
+ quietMode: typeof o.quietMode === "boolean" ? o.quietMode : base.quietMode,
23
+ quietSymbols: isQuietSymbolMode(o.quietSymbols) ? o.quietSymbols : base.quietSymbols,
21
24
  agentsMode: isMode(o.agentsMode) ? o.agentsMode : base.agentsMode,
22
25
  todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
23
26
  todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
@@ -46,7 +49,7 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
46
49
  return structuredClone(DEFAULT_CONFIG);
47
50
  }
48
51
  try {
49
- return deepMerge(DEFAULT_CONFIG, JSON.parse(readFileSync(path, "utf8")));
52
+ return mergeConfig(DEFAULT_CONFIG, JSON.parse(readFileSync(path, "utf8")));
50
53
  } catch (err) {
51
54
  notify?.(`pi-cockpit config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
52
55
  return structuredClone(DEFAULT_CONFIG);
package/src/footer.ts CHANGED
@@ -202,6 +202,7 @@ export function renderFooter(p: FooterParts): string[] {
202
202
  if (width <= 0) return [""];
203
203
  const ell = theme.fg("dim", g.ellipsis);
204
204
  const sep = theme.fg("dim", g.separator.trim());
205
+
205
206
  const identitySeparator = ` ${sep} `;
206
207
  const visibleStatuses = (p.extensionStatuses ?? []).filter(
207
208
  (status) => isVisibleExtensionStatus(status, p.thinking),
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@ import { statusText, titleFor, workingMessage, type AmbientState } from "./ambie
7
7
  import { BashBgStore } from "./bash-bg-store.ts";
8
8
  import { BashBgOverlay } from "./bash-bg-overlay.ts";
9
9
  import { renderBashBgSummary } from "./bash-bg-widget.ts";
10
+ import { registerQuietTools } from "./quiet-tools.ts";
11
+ import { ensureThinkingFolded, readHideThinkingBlock } from "./thinking-fold.ts";
12
+ import { ThinkingFoldTimer } from "./thinking-timer.ts";
10
13
  import { TodoStore } from "./todo-store.ts";
11
14
  import { makeTodoWidget, makeAgentWidget, terminalRows } from "./stack-widget.ts";
12
15
  import { activeThemeName, ThemePicker } from "./theme-picker.ts";
@@ -35,6 +38,9 @@ const FOOTER_UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth
35
38
  const BASH_BG_OVERLAY_KEY = "alt+j";
36
39
  const COCKPIT_STATUS_KEY = "cockpit";
37
40
  const WIDTH_POLL_INTERVAL_MS = 250;
41
+ // Quiet mode's rename of pi's hidden-thinking label. The live thinking timer
42
+ // derives its final "thoughts · 8.4s" labels from the same word.
43
+ const QUIET_THINKING_LABEL = "thoughts";
38
44
 
39
45
  function isTuiContext(ctx: ExtensionContext): boolean {
40
46
  try {
@@ -67,6 +73,34 @@ export default function (pi: ExtensionAPI): void {
67
73
  // Persisted rather than toasted: a config that failed to load silently downgrades
68
74
  // the whole session to defaults, so it belongs in a slot that does not scroll away.
69
75
  let configProblem: string | undefined;
76
+ let quietToolsRegistered = false;
77
+
78
+ // Live labels for folded thinking rows: a spinner with running elapsed
79
+ // time while the model thinks, then the actual duration once it settles.
80
+ // Pi owns the fold; this only renames the label it already renders.
81
+ const thinkingTimer = new ThinkingFoldTimer({
82
+ getTui: () => capturedTui,
83
+ requestRender: () => {
84
+ try {
85
+ capturedTui?.requestRender();
86
+ } catch {
87
+ // tui may be gone between sessions
88
+ }
89
+ },
90
+ getBaseLabel: () => (config.quietMode ? QUIET_THINKING_LABEL : undefined),
91
+ getGlyphs: () => resolveGlyphs(config.icons.mode),
92
+ isThinkingHidden: () => (lastCtx ? readHideThinkingBlock(lastCtx.cwd) : false),
93
+ isEnabled: () => config.enabled,
94
+ setGlobalLabel: (label) => {
95
+ const ctx = lastCtx;
96
+ if (!ctx || !isTuiContext(ctx)) return;
97
+ try {
98
+ ctx.ui.setHiddenThinkingLabel(label);
99
+ } catch {
100
+ // non-TUI or mid-teardown
101
+ }
102
+ },
103
+ });
70
104
 
71
105
  // The streaming line, the tab title and the footer status slot are all fed from
72
106
  // the same snapshots the widgets read, so they can never disagree with them.
@@ -135,6 +169,8 @@ export default function (pi: ExtensionAPI): void {
135
169
  agents: config.enabled && config.hideNativeAgents,
136
170
  footer: config.enabled,
137
171
  todoExpanded: config.todoExpanded,
172
+ quiet: config.enabled && config.quietMode,
173
+ quietSymbols: config.quietSymbols,
138
174
  });
139
175
  };
140
176
  const setTodoExpanded = (expanded: boolean): void => {
@@ -145,6 +181,45 @@ export default function (pi: ExtensionAPI): void {
145
181
  req();
146
182
  };
147
183
 
184
+ // Quiet mode live toggle: widgets and footer read config on every render so
185
+ // they switch immediately. Tool rendering is a one-way latch (registerTool
186
+ // cannot be undone), so turning ON registers immediately but turning OFF
187
+ // requires /reload to restore the default tool shells.
188
+ const applyQuietMode = (ctx: ExtensionContext, was: boolean, now: boolean): void => {
189
+ // Broadcast so cross-extension surfaces (e.g. pi-maestro-flow's todo tool
190
+ // rendering) can follow quiet mode regardless of which path toggled it.
191
+ publishUiOwnership();
192
+ if (now && !was) {
193
+ if (!quietToolsRegistered) {
194
+ registerQuietTools(pi, () => config);
195
+ quietToolsRegistered = true;
196
+ }
197
+ try {
198
+ ctx.ui.setHiddenThinkingLabel(QUIET_THINKING_LABEL);
199
+ } catch { /* non-TUI */ }
200
+ // The label only renames an already-hidden block; the fold itself
201
+ // rides pi's native toggle, which also persists it. Report what
202
+ // actually happened instead of promising a fold we could not reach.
203
+ const folded = ensureThinkingFolded(capturedTui, ctx.cwd, true);
204
+ ctx.ui.notify(
205
+ folded
206
+ ? "quiet mode on — tools compressed, thinking folded"
207
+ : "quiet mode on — tools compressed",
208
+ "info",
209
+ );
210
+ } else if (!now && was) {
211
+ try {
212
+ ctx.ui.setHiddenThinkingLabel(undefined);
213
+ } catch { /* non-TUI */ }
214
+ ctx.ui.notify(
215
+ quietToolsRegistered
216
+ ? "quiet mode off — /reload to restore default tool rendering"
217
+ : "quiet mode off",
218
+ "info",
219
+ );
220
+ }
221
+ };
222
+
148
223
  const uninstallUi = (ctx: ExtensionContext): void => {
149
224
  ctx.ui.setWidget(STACK_WIDGET_KEY, undefined);
150
225
  ctx.ui.setWidget(AGENT_WIDGET_KEY, undefined);
@@ -302,6 +377,7 @@ export default function (pi: ExtensionAPI): void {
302
377
  pi.on("session_start", (_e, ctx) => {
303
378
  lastCtx = ctx;
304
379
  activeTools.clear();
380
+ thinkingTimer.reset();
305
381
  ensureConfigExists();
306
382
  config = loadConfig((m, l) => {
307
383
  try {
@@ -311,6 +387,19 @@ export default function (pi: ExtensionAPI): void {
311
387
  }
312
388
  });
313
389
  invalidateUsageCache();
390
+ // Quiet mode: register compact tool renderers and fold thinking blocks.
391
+ // Tools cannot be unregistered, so this is a one-way latch per process.
392
+ if (config.quietMode && !quietToolsRegistered) {
393
+ registerQuietTools(pi, () => config);
394
+ quietToolsRegistered = true;
395
+ }
396
+ if (config.quietMode) {
397
+ try {
398
+ ctx.ui.setHiddenThinkingLabel(QUIET_THINKING_LABEL);
399
+ } catch {
400
+ // non-TUI mode
401
+ }
402
+ }
314
403
  // The theme is deliberately NOT re-applied here. ctx.ui.setTheme writes
315
404
  // through to pi's own settings, so pi already restores the user's choice on
316
405
  // its own. Replaying cockpit's copy would overwrite whatever the user set
@@ -324,6 +413,9 @@ export default function (pi: ExtensionAPI): void {
324
413
  applyUi(ctx);
325
414
  publishUiOwnership();
326
415
  }
416
+ // applyUi captured the TUI synchronously, so the native toggle is
417
+ // reachable already; a no-op when cockpit is disabled or non-TUI.
418
+ if (config.quietMode) ensureThinkingFolded(capturedTui, ctx.cwd, true);
327
419
  pi.events.emit(BASH_BG_QUERY_EVENT, undefined);
328
420
  req();
329
421
  });
@@ -335,10 +427,13 @@ export default function (pi: ExtensionAPI): void {
335
427
  agents: false,
336
428
  footer: false,
337
429
  todoExpanded: config.todoExpanded,
430
+ quiet: false,
431
+ quietSymbols: config.quietSymbols,
338
432
  });
339
433
  lastCtx = undefined;
340
434
  running = false;
341
435
  activeTools.clear();
436
+ thinkingTimer.reset();
342
437
  invalidateUsageCache();
343
438
  agents.clear();
344
439
  bashBg.clear();
@@ -352,12 +447,21 @@ export default function (pi: ExtensionAPI): void {
352
447
  pi.on("agent_end", () => {
353
448
  running = false;
354
449
  activeTools.clear();
450
+ thinkingTimer.stop();
355
451
  syncTick();
356
452
  req();
357
453
  });
358
454
 
455
+ // --- live labels for folded thinking rows ---
456
+ pi.on("message_update", (e) => {
457
+ thinkingTimer.onAssistantMessageEvent(e.assistantMessageEvent);
458
+ });
459
+
359
460
  // --- redraw triggers for the footer's live data ---
360
- pi.on("message_end", (_e, ctx) => {
461
+ pi.on("message_end", (e, ctx) => {
462
+ // Settle an interrupted thinking run: message_end fires for aborted
463
+ // and failed messages too, while the row is still mounted.
464
+ if (e.message.role === "assistant") thinkingTimer.onAssistantMessageEnd();
361
465
  invalidateUsageCache();
362
466
  if (isTuiContext(ctx)) req();
363
467
  });
@@ -474,7 +578,13 @@ export default function (pi: ExtensionAPI): void {
474
578
 
475
579
  // --- /cockpit: toggle list/compact + enabled + hide-native ---
476
580
  pi.registerCommand("cockpit", {
477
- description: "Open pi-cockpit settings; use /cockpit bg for background Bash job details",
581
+ description: "Open pi-cockpit settings; /cockpit quiet toggles quiet mode, /cockpit bg shows jobs",
582
+ getArgumentCompletions: (prefix) => {
583
+ const query = prefix.trim().toLowerCase();
584
+ const candidates = ["quiet", "quiet on", "quiet off", "bg", "jobs", "todo", "todo expand", "todo collapse"];
585
+ const matches = candidates.filter((c) => c.startsWith(query));
586
+ return matches.length > 0 ? matches.map((c) => ({ value: c, label: c })) : null;
587
+ },
478
588
  handler: async (args, ctx) => {
479
589
  if (!ctx.hasUI) return;
480
590
  const action = args.trim().toLowerCase();
@@ -491,6 +601,24 @@ export default function (pi: ExtensionAPI): void {
491
601
  setTodoExpanded(action.endsWith("expand"));
492
602
  return;
493
603
  }
604
+ if (action === "quiet" || action === "quiet toggle") {
605
+ const was = config.quietMode;
606
+ config = { ...config, quietMode: !was };
607
+ saveConfig(config);
608
+ applyQuietMode(ctx, was, config.quietMode);
609
+ req();
610
+ return;
611
+ }
612
+ if (action === "quiet on" || action === "quiet off") {
613
+ const target = action === "quiet on";
614
+ if (config.quietMode === target) return;
615
+ const was = config.quietMode;
616
+ config = { ...config, quietMode: target };
617
+ saveConfig(config);
618
+ applyQuietMode(ctx, was, target);
619
+ req();
620
+ return;
621
+ }
494
622
  await openSettings(ctx);
495
623
  },
496
624
  });
@@ -499,6 +627,10 @@ export default function (pi: ExtensionAPI): void {
499
627
  ctx.ui.custom<void>((tui, theme, _kb, done) => {
500
628
  let settingsCursor = 0;
501
629
  let saveState: SaveState = { kind: "idle" };
630
+ // Mirror of pi's hideThinkingBlock, read when the panel opens. The
631
+ // overlay captures input, so Ctrl+T cannot flip it while this is open
632
+ // and the mirror stays exact until the panel closes.
633
+ let thinkingHidden = readHideThinkingBlock(ctx.cwd);
502
634
  // The theme row expands in place rather than closing the panel and opening
503
635
  // a second overlay: pi's `theme` is a live Proxy, so both surfaces repaint
504
636
  // in the previewed colours, and Esc lands back on the row it was invoked
@@ -511,6 +643,7 @@ export default function (pi: ExtensionAPI): void {
511
643
  };
512
644
  const apply = (key: string): void => {
513
645
  const wasEnabled = config.enabled;
646
+ const wasQuiet = config.quietMode;
514
647
  if (key === "theme") {
515
648
  // Delegate: the picker previews live and reverts on Esc, neither of
516
649
  // which a blind one-key cycle through the name list can do.
@@ -518,6 +651,18 @@ export default function (pi: ExtensionAPI): void {
518
651
  tui.requestRender();
519
652
  return;
520
653
  }
654
+ if (key === "thinkingFold") {
655
+ // Pass-through row: pi owns hideThinkingBlock, so bring it to the
656
+ // wanted state through the native toggle instead of saving config.
657
+ // pi persists the flip synchronously through its settingsManager.
658
+ const target = !thinkingHidden;
659
+ const ok = ensureThinkingFolded(tui, ctx.cwd, target);
660
+ if (ok) thinkingHidden = target;
661
+ saveState = ok
662
+ ? { kind: "saved" }
663
+ : { kind: "failed", message: "editor unreachable" };
664
+ return;
665
+ }
521
666
  config = applyRow(config, key);
522
667
  saveState = { kind: "saving" };
523
668
  const result = saveConfig(config);
@@ -537,6 +682,9 @@ export default function (pi: ExtensionAPI): void {
537
682
  } else {
538
683
  publishUiOwnership();
539
684
  }
685
+ if (wasQuiet !== config.quietMode) {
686
+ applyQuietMode(ctx, wasQuiet, config.quietMode);
687
+ }
540
688
  req();
541
689
  };
542
690
  const ui = {
@@ -546,7 +694,7 @@ export default function (pi: ExtensionAPI): void {
546
694
  // rows saying nothing the picker does not already say.
547
695
  if (sub) return sub.render(width);
548
696
  const paint: PaintTheme = theme;
549
- const rows = buildRows(config);
697
+ const rows = buildRows(config, { thinkingHidden });
550
698
  settingsCursor = Math.max(0, Math.min(settingsCursor, rows.length - 1));
551
699
  const w = Math.min(width, 52);
552
700
  const labelWidth = Math.max(...rows.map((r) => visibleWidth(r.label)));
@@ -576,7 +724,9 @@ export default function (pi: ExtensionAPI): void {
576
724
  }
577
725
  // The theme row opens /theme; /settings additionally pairs a light
578
726
  // and a dark theme, which neither of cockpit's surfaces can express.
579
- lines.push(paint.fg("dim", "theme is stored by pi · /settings pairs light+dark"));
727
+ // Thinking fold is pi's setting too (Ctrl+T), mirrored live here.
728
+ lines.push(paint.fg("dim", "theme & thinking are stored by pi"));
729
+ lines.push(paint.fg("dim", "/settings pairs light+dark"));
580
730
  lines.push(paint.fg("dim", "↑↓ move · Enter change · letter jumps · Esc close"));
581
731
  return lines.map((line) => truncateToWidth(line, width, "…"));
582
732
  },
@@ -589,7 +739,7 @@ export default function (pi: ExtensionAPI): void {
589
739
  tui.requestRender();
590
740
  return;
591
741
  }
592
- const rows = buildRows(config);
742
+ const rows = buildRows(config, { thinkingHidden });
593
743
  if (matchesKey(data, Key.escape)) {
594
744
  done(undefined);
595
745
  return;