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.
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.3.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.0.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
+ }
@@ -37,6 +37,8 @@ export interface ProgressPayload {
37
37
  dependencies?: number[];
38
38
  status?: string;
39
39
  startedAt?: number | string;
40
+ completedAt?: number | string;
41
+ durationMs?: number;
40
42
  lastActivityAt?: number | string;
41
43
  recentTools?: Array<string | { name?: string; status?: string }>;
42
44
  toolCount?: number;
@@ -65,7 +67,8 @@ export interface MessagePayload {
65
67
  lastActivityAt?: number | string;
66
68
  progress?: ProgressPayload[];
67
69
  }
68
- export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">;
70
+ export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">
71
+ & Partial<Pick<TeammateCompleteEvent, "durationMs">>;
69
72
 
70
73
  /**
71
74
  * How long a failed agent stays on screen after it completes.
@@ -75,6 +78,22 @@ export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exi
75
78
  */
76
79
  export const FAILED_LINGER_MS = 30_000;
77
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
+
78
97
  function deriveRole(agent: string | undefined, name: string | undefined): string {
79
98
  if (agent && !agent.startsWith("graph(")) return clean(agent);
80
99
  return clean(name) || "agent";
@@ -109,6 +128,19 @@ function normalizeStartedAt(value: number | string | undefined, fallback: number
109
128
  return fallback;
110
129
  }
111
130
 
131
+ function terminalTime(
132
+ row: AgentRow,
133
+ payload: { completedAt?: number | string; durationMs?: number },
134
+ now: number,
135
+ ): number {
136
+ const completedAt = normalizeStartedAt(payload.completedAt, Number.NaN);
137
+ if (Number.isFinite(completedAt)) return Math.max(row.startedAt, completedAt);
138
+ if (typeof payload.durationMs === "number" && Number.isFinite(payload.durationMs)) {
139
+ return row.startedAt + Math.max(0, payload.durationMs);
140
+ }
141
+ return row.finishedAt ?? now;
142
+ }
143
+
112
144
  function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
113
145
  if (!tools?.length) return undefined;
114
146
  const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
@@ -129,11 +161,23 @@ function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
129
161
  // always represented as long as it keeps emitting activity.
130
162
  export class AgentsStore {
131
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
+ }
132
172
 
133
173
  applyStarted(p: StartedPayload, now: number = Date.now()): void {
134
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);
135
179
  const prev = this.roster.get(id);
136
- this.roster.set(id, {
180
+ const row: AgentRow = {
137
181
  ...prev,
138
182
  correlationId: id,
139
183
  agent: clean(p.agent) || prev?.agent || "",
@@ -149,7 +193,9 @@ export class AgentsStore {
149
193
  : prev?.parentCorrelationId
150
194
  ? { parentCorrelationId: prev.parentCorrelationId }
151
195
  : {}),
152
- });
196
+ };
197
+ if (row.status !== "done" && row.status !== "failed") delete row.finishedAt;
198
+ this.roster.set(id, row);
153
199
  }
154
200
 
155
201
  applyMessage(p: MessagePayload, now = Date.now()): void {
@@ -157,11 +203,16 @@ export class AgentsStore {
157
203
  for (const progress of p.progress) this.applyProgress(p.correlationId, progress, now);
158
204
  }
159
205
  const targetId = p.taskCorrelationId ?? p.correlationId;
160
- if (!this.roster.has(targetId)) {
206
+ if (!this.roster.has(targetId)
207
+ && !this.isTombstoned(targetId, now)
208
+ && !this.isTombstoned(p.correlationId, now)) {
161
209
  // Self-heal the roster: a running agent must stay visible even when its
162
210
  // `started` delta was missed (cold start, event reorder, or a foreground
163
211
  // dispatch that detached to background). Materialize the row from this
164
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.
165
216
  this.applyStarted({
166
217
  correlationId: targetId,
167
218
  agent: p.agent ?? "",
@@ -191,6 +242,8 @@ export class AgentsStore {
191
242
  if (typeof p.status === "string") {
192
243
  row.taskStatus = p.status;
193
244
  row.status = mapAgentStatus(p.status);
245
+ if (row.status === "done" || row.status === "failed") row.finishedAt ??= now;
246
+ else delete row.finishedAt;
194
247
  }
195
248
  if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
196
249
  if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
@@ -216,11 +269,15 @@ export class AgentsStore {
216
269
  if (row && (row.status === "failed" || failedByExitCode)) {
217
270
  row.status = "failed";
218
271
  row.taskStatus = "failed";
272
+ row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
219
273
  row.failedAt = now;
220
274
  row.lastActivityAt = now;
221
275
  delete row.activeTool;
222
276
  } else {
223
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);
224
281
  }
225
282
  }
226
283
  }
@@ -234,6 +291,9 @@ export class AgentsStore {
234
291
  changed = true;
235
292
  }
236
293
  }
294
+ for (const [id, at] of this.completedAt) {
295
+ if (now - at >= COMPLETED_TOMBSTONE_MS) this.completedAt.delete(id);
296
+ }
237
297
  return changed;
238
298
  }
239
299
 
@@ -246,9 +306,13 @@ export class AgentsStore {
246
306
  }
247
307
 
248
308
  private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
249
- if (!this.roster.has(p.correlationId)) {
309
+ if (!this.roster.has(p.correlationId)
310
+ && !this.isTombstoned(p.correlationId, now)
311
+ && !this.isTombstoned(parentCorrelationId, now)) {
250
312
  // Self-heal: see applyMessage. A graph child whose `started` delta was
251
- // 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.
252
316
  this.applyStarted({
253
317
  correlationId: p.correlationId,
254
318
  agent: p.agent,
@@ -263,20 +327,26 @@ export class AgentsStore {
263
327
  }
264
328
  const row = this.roster.get(p.correlationId);
265
329
  if (!row) return;
266
- row.parentCorrelationId = parentCorrelationId === p.correlationId
267
- ? row.parentCorrelationId
268
- : parentCorrelationId;
330
+ if (
331
+ parentCorrelationId !== p.correlationId
332
+ && row.parentCorrelationId === undefined
333
+ ) row.parentCorrelationId = parentCorrelationId;
269
334
  row.agent = clean(p.agent) || row.agent;
270
335
  row.name = p.name === undefined ? row.name : clean(p.name);
271
336
  row.role = deriveRole(p.agent, p.name);
272
337
  row.task = p.name === undefined ? row.task : clean(p.name);
338
+ if (p.startedAt !== undefined) row.startedAt = normalizeStartedAt(p.startedAt, row.startedAt);
273
339
  if (typeof p.status === "string") {
274
340
  row.taskStatus = p.status;
275
341
  row.status = mapAgentStatus(p.status);
342
+ if (row.status === "done" || row.status === "failed") {
343
+ row.finishedAt = terminalTime(row, p, now);
344
+ } else {
345
+ delete row.finishedAt;
346
+ }
276
347
  }
277
348
  row.taskIndex = p.taskIndex;
278
349
  row.dependencies = Array.isArray(p.dependencies) ? [...p.dependencies] : [];
279
- if (p.startedAt !== undefined) row.startedAt = normalizeStartedAt(p.startedAt, row.startedAt);
280
350
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
281
351
  if (p.recentTools) {
282
352
  const tool = latestTool(p.recentTools);
@@ -312,5 +382,6 @@ export class AgentsStore {
312
382
 
313
383
  clear(): void {
314
384
  this.roster.clear();
385
+ this.completedAt.clear();
315
386
  }
316
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;