pi-cockpit 0.7.0 → 0.9.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A responsive **agent cockpit** for [Pi](https://pi.dev): wide terminals get a docked Maestro operations sidebar, narrow terminals automatically fall back to the existing Todo and Agent widgets, and every layout keeps the Starship-style footer.
4
4
 
5
- It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.5.1**.
5
+ It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.8.0**.
6
6
 
7
7
  The dock is a non-capturing top-right overlay. Cockpit reserves its columns by wrapping the active TUI renderer at runtime, so the Pi workspace reflows instead of rendering underneath it. No Pi source files are modified.
8
8
 
@@ -73,6 +73,16 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
73
73
  "width": 40,
74
74
  "density": "comfortable"
75
75
  },
76
+ "title": {
77
+ "enabled": true,
78
+ "showSession": true,
79
+ "showCwd": false,
80
+ "showModel": false,
81
+ "showThinking": false,
82
+ "showGit": false,
83
+ "showMaestro": false,
84
+ "maxLength": 80
85
+ },
76
86
  "theme": ""
77
87
  }
78
88
  ```
@@ -86,11 +96,24 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
86
96
  - `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
87
97
  - `sidebar.density`: `"comfortable"` or `"compact"`.
88
98
  - `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
99
+ - `title.enabled`: master switch for the terminal tab title (session summary + working state + opt-in tags). On by default.
100
+ - `title.showSession`: include the session summary (the `session_info` name, else a short session id) right after `pi`. On by default.
101
+ - `title.showCwd`: include the working directory after the session. Off by default — the title stays short, since the tab strip has no room for a wall of tags.
102
+ - `title.showModel`: include the active model tag (`m:gpt-5.6-sol`). Off by default.
103
+ - `title.showThinking`: include the thinking level tag (`t:high`); skipped while off. Off by default.
104
+ - `title.showGit`: include the git branch tag (`git:main`, `git:detached`); read synchronously from `.git/HEAD`, so it costs no process spawn. Off by default.
105
+ - `title.showMaestro`: include the Maestro workflow status tag (`wf:running`, `wf:done`). Off by default.
106
+ - `title.maxLength`: hard cap on the composed title; the middle is ellided to keep the head and the working-state tail. Clamped to `20..200`; default `80`.
107
+ - `title.generationModel`: `"provider/model"` (e.g. `"maestro-qwen/qwen3.8-max-preview"`) of a model registered with the `/api-manager` command (or any other provider pi resolves). When set, the session title is generated by that model over the first completed turn (user prompt + assistant reply) through its OpenAI-compatible endpoint, with a 10s timeout. Empty (default) uses the offline rule-based extractor instead. Generation is best-effort — on any failure the title silently falls back to the rule-based one. Editable from the `/cockpit` settings overlay (`z` or cursor to the `title gen model` row, Enter, type, Enter to save; empty clears back to rule-based).
108
+
109
+ The tab title is `frame + pi - <session> - <working state>`. The session part follows Claude Code's chain (`sessionTitle ?? agentTitle ?? haikuTitle ?? default`): the `/session name` title wins, otherwise the generated title, otherwise a short session id. The frame is Claude Code's title chrome: `⠂`/`⠐` braille spinner while a turn runs, static `✳` when idle; failure replaces it with `✗`. On quit, Cockpit clears the title so no stale tab label lingers (Claude Code's `CLEAR_TERMINAL_TITLE`).
89
110
  - `theme`: named theme override; empty string follows the Pi session theme.
90
111
 
91
112
  ## Commands
92
113
 
93
114
  - `/cockpit` — opens the settings overlay.
115
+ - `/maestro-settings` — opens the unified settings shell (Cockpit, Flow, Teammate and integrations).
116
+ - `Alt+J` — opens the background Bash jobs overlay (live status, command, cwd, duration, output tail).
94
117
  - `/cockpit sidebar` — reports the current sidebar mode, width, and density.
95
118
  - `/cockpit sidebar auto|on|off` — selects dock behavior.
96
119
  - `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
package/package.json CHANGED
@@ -1,71 +1,75 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.7.0",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
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/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.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/tick-policy.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.5.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.5.0",
63
- "typescript": "^5.5.0"
64
- },
65
- "main": "./src/index.ts",
66
- "exports": {
67
- ".": "./src/index.ts",
68
- "./v1/events": "./src/public/v1/events.ts",
69
- "./src/*": "./src/*"
70
- }
71
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.9.0",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
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/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.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/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension/index.ts"
27
+ ],
28
+ "themes": [
29
+ "./themes"
30
+ ]
31
+ },
32
+ "dependencies": {
33
+ "pi-maestro-settings-core": "0.1.0",
34
+ "proper-lockfile": "4.1.2"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-agent-core": "*",
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "pi-maestro-teammate": "^1.6.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@earendil-works/pi-agent-core": {
45
+ "optional": true
46
+ },
47
+ "@earendil-works/pi-ai": {
48
+ "optional": true
49
+ },
50
+ "@earendil-works/pi-coding-agent": {
51
+ "optional": true
52
+ },
53
+ "@earendil-works/pi-tui": {
54
+ "optional": true
55
+ },
56
+ "pi-maestro-teammate": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@earendil-works/pi-agent-core": "0.83.0",
62
+ "@earendil-works/pi-ai": "0.83.0",
63
+ "@earendil-works/pi-coding-agent": "0.83.0",
64
+ "@earendil-works/pi-tui": "0.83.0",
65
+ "@types/node": "^22.0.0",
66
+ "pi-maestro-teammate": "1.7.0",
67
+ "typescript": "^5.5.0"
68
+ },
69
+ "main": "./src/index.ts",
70
+ "exports": {
71
+ ".": "./src/index.ts",
72
+ "./v1/events": "./src/public/v1/events.ts",
73
+ "./src/*": "./src/*"
74
+ }
75
+ }
@@ -114,6 +114,13 @@ export function mapAgentStatus(status: unknown): AgentStatus {
114
114
  }
115
115
  }
116
116
 
117
+ function activityFromProgressStatus(status: unknown): "running" | "sleeping" | undefined {
118
+ if (typeof status !== "string") return undefined;
119
+ return status === "sleeping" || status === "completed" || status === "failed" || status === "terminated"
120
+ ? "sleeping"
121
+ : "running";
122
+ }
123
+
117
124
  export const AGENT_STALL_TIMEOUT_MS = 30_000;
118
125
  export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
119
126
 
@@ -138,9 +145,10 @@ function terminalTime(
138
145
  now: number,
139
146
  ): number {
140
147
  const completedAt = normalizeStartedAt(payload.completedAt, Number.NaN);
141
- if (Number.isFinite(completedAt)) return Math.max(row.startedAt, completedAt);
148
+ const turnStartedAt = row.turnStartedAt ?? row.startedAt;
149
+ if (Number.isFinite(completedAt)) return Math.max(turnStartedAt, completedAt);
142
150
  if (typeof payload.durationMs === "number" && Number.isFinite(payload.durationMs)) {
143
- return row.startedAt + Math.max(0, payload.durationMs);
151
+ return turnStartedAt + Math.max(0, payload.durationMs);
144
152
  }
145
153
  return row.finishedAt ?? now;
146
154
  }
@@ -198,6 +206,14 @@ export class AgentsStore {
198
206
  role: deriveRole(p.agent, p.name),
199
207
  task: prev?.task ?? clean(p.name),
200
208
  status: p.status === undefined ? prev?.status ?? "running" : mapAgentStatus(p.status),
209
+ phase: typeof p.phase === "string" ? clean(p.phase) : prev?.phase,
210
+ lastOutcome: p.lastOutcome
211
+ ? {
212
+ status: p.lastOutcome.status,
213
+ ...(p.lastOutcome.message ? { message: truncateTail(p.lastOutcome.message) } : {}),
214
+ settledAt: p.lastOutcome.settledAt,
215
+ }
216
+ : prev?.lastOutcome,
201
217
  tail: prev?.tail ?? "",
202
218
  startedAt: prev?.startedAt ?? normalizeStartedAt(p.startedAt, now),
203
219
  lastActivityAt: normalizeStartedAt(p.lastActivityAt, now),
@@ -207,7 +223,15 @@ export class AgentsStore {
207
223
  ? { parentCorrelationId: prev.parentCorrelationId }
208
224
  : {}),
209
225
  };
210
- if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") delete row.finishedAt;
226
+ if (row.status === "running") {
227
+ row.turnStartedAt = now;
228
+ delete row.finishedAt;
229
+ delete row.failedAt;
230
+ } else if (row.status === "sleeping") {
231
+ row.finishedAt = row.lastOutcome?.settledAt ?? prev?.finishedAt ?? now;
232
+ } else if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") {
233
+ delete row.finishedAt;
234
+ }
211
235
  this.roster.set(id, row);
212
236
  }
213
237
 
@@ -239,7 +263,7 @@ export class AgentsStore {
239
263
  correlationId: targetId,
240
264
  agent: p.agent ?? "",
241
265
  name: p.name,
242
- status: p.status,
266
+ status: activityFromProgressStatus(p.status),
243
267
  lastActivityAt: p.lastActivityAt,
244
268
  ...(p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
245
269
  ? { spawnedBy: p.correlationId }
@@ -278,6 +302,8 @@ export class AgentsStore {
278
302
  delete row.finishedAt;
279
303
  }
280
304
  }
305
+ if (typeof p.phase === "string") row.phase = clean(p.phase);
306
+ else if (row.status === "sleeping") delete row.phase;
281
307
  if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
282
308
  if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
283
309
  }
@@ -307,23 +333,38 @@ export class AgentsStore {
307
333
  row.status = "terminated";
308
334
  row.taskStatus = "terminated";
309
335
  row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
336
+ row.lastOutcome = { status: "terminated", settledAt: row.finishedAt };
310
337
  row.lastActivityAt = now;
311
338
  delete row.activeTool;
312
339
  delete row.failedAt;
340
+ } else if (row && id === p.correlationId && p.wakeable) {
341
+ // A recoverable logical agent is sleeping regardless of the previous
342
+ // run outcome. Failure remains visible as orthogonal outcome metadata.
343
+ row.status = "sleeping";
344
+ row.taskStatus = "sleeping";
345
+ row.phase = undefined;
346
+ row.finishedAt = terminalTime(row, p, now);
347
+ row.lastActivityAt = now;
348
+ row.lastOutcome = {
349
+ status: failedByExitCode ? "failed" : "completed",
350
+ ...(failedByExitCode && row.error ? { message: row.error } : {}),
351
+ settledAt: row.finishedAt,
352
+ };
353
+ if (failedByExitCode) row.failedAt = now;
354
+ else delete row.failedAt;
355
+ delete row.activeTool;
313
356
  } else if (row && (row.status === "failed" || failedByExitCode)) {
314
357
  row.status = "failed";
315
358
  row.taskStatus = "failed";
316
359
  row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
360
+ row.lastOutcome = {
361
+ status: "failed",
362
+ ...(row.error ? { message: row.error } : {}),
363
+ settledAt: row.finishedAt,
364
+ };
317
365
  row.failedAt = now;
318
366
  row.lastActivityAt = now;
319
367
  delete row.activeTool;
320
- } else if (row && id === p.correlationId && p.wakeable && p.exitCode === 0) {
321
- // A wakeable agent enters sleeping state after completion.
322
- // Keep the row visible so the user can see it is idle but recallable.
323
- row.status = "sleeping";
324
- row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
325
- row.lastActivityAt = now;
326
- delete row.activeTool;
327
368
  } else {
328
369
  this.roster.delete(id);
329
370
  // Tombstone even when the row never existed: a `complete` that beats
@@ -364,6 +405,14 @@ export class AgentsStore {
364
405
  return false;
365
406
  }
366
407
 
408
+ /** True while any agent (foreground or background) is still running, so elapsed/stall repaints keep the loop alive. */
409
+ hasActive(): boolean {
410
+ for (const row of this.roster.values()) {
411
+ if (row.status === "running") return true;
412
+ }
413
+ return false;
414
+ }
415
+
367
416
  private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
368
417
  if (!this.roster.has(p.correlationId)
369
418
  && this.canMaterialize(p.correlationId, parentCorrelationId, now)) {
@@ -375,7 +424,7 @@ export class AgentsStore {
375
424
  correlationId: p.correlationId,
376
425
  agent: p.agent,
377
426
  name: p.name,
378
- status: p.status,
427
+ status: activityFromProgressStatus(p.status),
379
428
  startedAt: p.startedAt,
380
429
  lastActivityAt: p.lastActivityAt,
381
430
  ...(parentCorrelationId !== p.correlationId
@@ -403,6 +452,8 @@ export class AgentsStore {
403
452
  delete row.finishedAt;
404
453
  }
405
454
  }
455
+ if (typeof p.phase === "string") row.phase = clean(p.phase);
456
+ else if (row.status === "sleeping") delete row.phase;
406
457
  row.resultReadyAt = p.resultReadyAt === undefined
407
458
  ? undefined
408
459
  : normalizeStartedAt(p.resultReadyAt, now);
package/src/ambient.ts CHANGED
@@ -21,6 +21,22 @@ export interface AmbientState {
21
21
  hideLiveDuration?: boolean;
22
22
  /** Separator glyph between label and elapsed time (e.g. " · "). */
23
23
  separator?: string;
24
+ /** Session summary — the session_info name, else a short session id. */
25
+ session?: string;
26
+ /** Active model short id. */
27
+ model?: string;
28
+ /** Active thinking level (omitted while off). */
29
+ thinking?: string;
30
+ /** Git branch name, or "detached". */
31
+ gitBranch?: string;
32
+ /** Maestro workflow status (run status when a run is active). */
33
+ maestro?: string;
34
+ /**
35
+ * Leading title glyph: spinner frames while a turn runs, a static marker
36
+ * when idle (Claude Code's `✳` / braille frames). Failure keeps its own
37
+ * mark and ignores this. Absent when the caller opts out.
38
+ */
39
+ frame?: string;
24
40
  }
25
41
 
26
42
  function liveAgents(agents: readonly AgentRow[]): AgentRow[] {
@@ -51,24 +67,56 @@ export function workingMessage(state: AmbientState, now = Date.now()): string |
51
67
  return `\x1b[3m${text}\x1b[23m`;
52
68
  }
53
69
 
70
+ /** Composition options for titleFor. */
71
+ export interface TitleOptions {
72
+ /** Hard cap on the composed title. The middle is ellided, keeping head + state tail. */
73
+ maxLength?: number;
74
+ }
75
+
54
76
  /**
55
77
  * The terminal tab title.
56
78
  *
57
79
  * A developer with several tabs open cannot otherwise tell which run finished and
58
80
  * which one needs them. Failure outranks progress, because that is the state that
59
- * actually requires a human.
81
+ * actually requires a human. The session summary (when present) sits right after
82
+ * "pi"; model / thinking / git / maestro tags follow the working state. Fields
83
+ * are only appended when present, so callers decide what to expose per config.
60
84
  */
61
- export function titleFor(state: AmbientState, marks: { ok: string; fail: string }, sep = " - "): string {
62
- const base = state.cwd ? `pi${sep}${state.cwd}` : "pi";
85
+ export function titleFor(
86
+ state: AmbientState,
87
+ marks: { ok: string; fail: string },
88
+ sep = " - ",
89
+ opts?: TitleOptions,
90
+ ): string {
91
+ const head = ["pi"];
92
+ if (state.session) head.push(state.session);
93
+ if (state.cwd) head.push(state.cwd);
94
+ const base = head.join(sep);
63
95
  const broken = failedAgents(state.agents).length + failedJobs(state.jobs).length;
64
- if (broken > 0) return `${marks.fail} ${base}${sep}${broken} failed`;
65
- if (state.running) {
96
+ const prefix = state.frame ? `${state.frame} ` : "";
97
+ let title: string;
98
+ if (broken > 0) {
99
+ title = `${marks.fail} ${base}${sep}${broken} failed`;
100
+ } else if (state.running) {
66
101
  const live = liveAgents(state.agents).length;
67
- return live > 0 ? `${base}${sep}${live} agents` : `${base}${sep}working`;
102
+ title = live > 0 ? `${prefix}${base}${sep}${live} agents` : `${prefix}${base}${sep}working`;
103
+ } else {
104
+ const jobs = state.jobs.filter((job) => job.status === "running").length;
105
+ title = jobs > 0 ? `${prefix}${base}${sep}${jobs} bg` : `${prefix}${base}`;
106
+ }
107
+ const tags: string[] = [];
108
+ if (state.model) tags.push(`m:${state.model}`);
109
+ if (state.thinking) tags.push(`t:${state.thinking}`);
110
+ if (state.gitBranch) tags.push(`git:${state.gitBranch}`);
111
+ if (state.maestro) tags.push(`wf:${state.maestro}`);
112
+ if (tags.length > 0) title = `${title}${sep}${tags.join(sep)}`;
113
+ if (opts?.maxLength !== undefined && title.length > opts.maxLength) {
114
+ const cap = opts.maxLength;
115
+ const headLen = Math.max(8, Math.floor(cap * 0.4));
116
+ const tailLen = Math.max(8, Math.floor(cap * 0.5));
117
+ title = `${title.slice(0, headLen)}…${title.slice(-tailLen)}`;
68
118
  }
69
- const jobs = state.jobs.filter((job) => job.status === "running").length;
70
- if (jobs > 0) return `${base}${sep}${jobs} bg`;
71
- return base;
119
+ return title;
72
120
  }
73
121
 
74
122
  /**
@@ -1,7 +1,9 @@
1
1
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ Key,
3
4
  type Component,
4
5
  type Focusable,
6
+ matchesKey,
5
7
  truncateToWidth,
6
8
  visibleWidth,
7
9
  wrapTextWithAnsi,
@@ -56,7 +58,7 @@ export class BashBgOverlay implements Component, Focusable {
56
58
  }
57
59
 
58
60
  handleInput(data: string): void {
59
- if (data === "\x1b") {
61
+ if (matchesKey(data, Key.escape)) {
60
62
  if (this.mode === "detail") this.mode = "list";
61
63
  else this.params.close();
62
64
  this.params.requestRender();
@@ -64,20 +66,20 @@ export class BashBgOverlay implements Component, Focusable {
64
66
  }
65
67
  // Plain letters stay reserved for future filter/command modes, so navigation
66
68
  // and refresh use arrows and a non-letter key only.
67
- if (data === "\x1b[A") {
69
+ if (matchesKey(data, Key.up)) {
68
70
  this.move(-1);
69
71
  return;
70
72
  }
71
- if (data === "\x1b[B") {
73
+ if (matchesKey(data, Key.down)) {
72
74
  this.move(1);
73
75
  return;
74
76
  }
75
- if (data === "\r" || data === "\n") {
77
+ if (matchesKey(data, Key.enter)) {
76
78
  if (this.selectedJob()) this.mode = this.mode === "detail" ? "list" : "detail";
77
79
  this.params.requestRender();
78
80
  return;
79
81
  }
80
- if (data === "\x12" || data === "\x1b[15~") {
82
+ if (data === "\x12" || matchesKey(data, Key.f5)) {
81
83
  // Ctrl+R / F5 — refresh is acknowledged immediately even though the
82
84
  // authoritative snapshot only arrives later. Schedule the ack's own
83
85
  // expiry so it disappears even without any follow-up repaint.
package/src/config.ts CHANGED
@@ -30,6 +30,13 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
30
30
  const sidebarWidth = sidebarRaw && typeof sidebarRaw.width === "number" && Number.isFinite(sidebarRaw.width)
31
31
  ? Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(sidebarRaw.width)))
32
32
  : base.sidebar.width;
33
+ const titleRaw = o.title && typeof o.title === "object" && !Array.isArray(o.title)
34
+ ? (o.title as Record<string, unknown>)
35
+ : undefined;
36
+ const isBool = (v: unknown): v is boolean => typeof v === "boolean";
37
+ const titleMaxLength = titleRaw && typeof titleRaw.maxLength === "number" && Number.isFinite(titleRaw.maxLength)
38
+ ? Math.min(200, Math.max(20, Math.round(titleRaw.maxLength)))
39
+ : base.title.maxLength;
33
40
  return {
34
41
  enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
35
42
  staticMode: typeof o.staticMode === "boolean" ? o.staticMode : base.staticMode,
@@ -40,12 +47,26 @@ export function mergeConfig(base: CockpitConfig, over: unknown): CockpitConfig {
40
47
  todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
41
48
  todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
42
49
  hideNativeAgents: typeof o.hideNativeAgents === "boolean" ? o.hideNativeAgents : base.hideNativeAgents,
50
+ pinEditorBottom: typeof o.pinEditorBottom === "boolean" ? o.pinEditorBottom : base.pinEditorBottom,
43
51
  icons: { mode: iconsRaw && isIconMode(iconsRaw.mode) ? iconsRaw.mode : base.icons.mode },
44
52
  sidebar: {
45
53
  mode: sidebarRaw && isSidebarMode(sidebarRaw.mode) ? sidebarRaw.mode : base.sidebar.mode,
46
54
  width: sidebarWidth,
47
55
  density: sidebarRaw && isSidebarDensity(sidebarRaw.density) ? sidebarRaw.density : base.sidebar.density,
48
56
  },
57
+ title: {
58
+ enabled: isBool(titleRaw?.enabled) ? titleRaw!.enabled : base.title.enabled,
59
+ showSession: isBool(titleRaw?.showSession) ? titleRaw!.showSession : base.title.showSession,
60
+ showCwd: isBool(titleRaw?.showCwd) ? titleRaw!.showCwd : base.title.showCwd,
61
+ showModel: isBool(titleRaw?.showModel) ? titleRaw!.showModel : base.title.showModel,
62
+ showThinking: isBool(titleRaw?.showThinking) ? titleRaw!.showThinking : base.title.showThinking,
63
+ showGit: isBool(titleRaw?.showGit) ? titleRaw!.showGit : base.title.showGit,
64
+ showMaestro: isBool(titleRaw?.showMaestro) ? titleRaw!.showMaestro : base.title.showMaestro,
65
+ generationModel: titleRaw && typeof titleRaw.generationModel === "string"
66
+ ? titleRaw.generationModel
67
+ : base.title.generationModel,
68
+ maxLength: titleMaxLength,
69
+ },
49
70
  theme: typeof o.theme === "string" ? o.theme : base.theme,
50
71
  };
51
72
  }
@@ -76,6 +97,39 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
76
97
  }
77
98
  }
78
99
 
100
+ export function mergeConfigDocument(raw: unknown, config: CockpitConfig): Record<string, unknown> {
101
+ const root = raw && typeof raw === "object" && !Array.isArray(raw)
102
+ ? { ...(raw as Record<string, unknown>) }
103
+ : {};
104
+ const icons = root.icons && typeof root.icons === "object" && !Array.isArray(root.icons)
105
+ ? { ...(root.icons as Record<string, unknown>), mode: config.icons.mode }
106
+ : { mode: config.icons.mode };
107
+ const sidebar = root.sidebar && typeof root.sidebar === "object" && !Array.isArray(root.sidebar)
108
+ ? {
109
+ ...(root.sidebar as Record<string, unknown>),
110
+ mode: config.sidebar.mode,
111
+ width: config.sidebar.width,
112
+ density: config.sidebar.density,
113
+ }
114
+ : { ...config.sidebar };
115
+ return {
116
+ ...root,
117
+ enabled: config.enabled,
118
+ staticMode: config.staticMode,
119
+ quietMode: config.quietMode,
120
+ quietSymbols: config.quietSymbols,
121
+ toolPalette: config.toolPalette,
122
+ agentsMode: config.agentsMode,
123
+ todoMode: config.todoMode,
124
+ todoExpanded: config.todoExpanded,
125
+ hideNativeAgents: config.hideNativeAgents,
126
+ pinEditorBottom: config.pinEditorBottom,
127
+ icons,
128
+ sidebar,
129
+ theme: config.theme,
130
+ };
131
+ }
132
+
79
133
  export interface SaveResult {
80
134
  ok: boolean;
81
135
  error?: string;
@@ -95,7 +149,9 @@ export function saveConfig(config: CockpitConfig): SaveResult {
95
149
  try {
96
150
  const dir = getAgentDir();
97
151
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
98
- writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
152
+ let raw: unknown = {};
153
+ try { if (existsSync(path)) raw = JSON.parse(readFileSync(path, "utf8")); } catch { /* replace malformed config */ }
154
+ writeFileSync(tmp, JSON.stringify(mergeConfigDocument(raw, config), null, 2) + "\n", "utf8");
99
155
  renameSync(tmp, path);
100
156
  return { ok: true };
101
157
  } catch (err) {