pi-cockpit 0.7.0 → 0.8.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,6 +96,17 @@ 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.
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
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.8.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.6.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
@@ -375,7 +416,7 @@ export class AgentsStore {
375
416
  correlationId: p.correlationId,
376
417
  agent: p.agent,
377
418
  name: p.name,
378
- status: p.status,
419
+ status: activityFromProgressStatus(p.status),
379
420
  startedAt: p.startedAt,
380
421
  lastActivityAt: p.lastActivityAt,
381
422
  ...(parentCorrelationId !== p.correlationId
@@ -403,6 +444,8 @@ export class AgentsStore {
403
444
  delete row.finishedAt;
404
445
  }
405
446
  }
447
+ if (typeof p.phase === "string") row.phase = clean(p.phase);
448
+ else if (row.status === "sleeping") delete row.phase;
406
449
  row.resultReadyAt = p.resultReadyAt === undefined
407
450
  ? undefined
408
451
  : 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
  /**
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) {
@@ -0,0 +1,128 @@
1
+ import type { Component, TUI } from "@earendil-works/pi-tui";
2
+
3
+ export const EDITOR_BOTTOM_WIDGET_KEY = "cockpit-editor-bottom-anchor";
4
+ export const COCKPIT_EDITOR_BOTTOM_MARKER = Symbol.for("pi-cockpit.editor-bottom-render");
5
+
6
+ const EDITOR_BOTTOM_SENTINEL = "\u2063\u2064\u2063\u2064\u2063";
7
+
8
+ type RenderFunction = TUI["render"];
9
+
10
+ interface RenderMarker {
11
+ owner: object;
12
+ original: RenderFunction;
13
+ }
14
+
15
+ export interface EditorBottomController {
16
+ attach(tui: TUI): void;
17
+ show(): void;
18
+ hide(): void;
19
+ isEnabled(): boolean;
20
+ requestRender(): void;
21
+ dispose(): void;
22
+ }
23
+
24
+ export interface EditorBottomControllerOptions {
25
+ onError?(error: unknown): void;
26
+ }
27
+
28
+ export function createEditorBottomSentinel(): Component {
29
+ return {
30
+ render: () => [EDITOR_BOTTOM_SENTINEL],
31
+ invalidate(): void {},
32
+ };
33
+ }
34
+
35
+ export function createEditorBottomController(
36
+ options: EditorBottomControllerOptions = {},
37
+ ): EditorBottomController {
38
+ const owner = {};
39
+ let tui: TUI | undefined;
40
+ let originalRender: RenderFunction | undefined;
41
+ let wrappedRender: RenderFunction | undefined;
42
+ let enabled = false;
43
+ let disposed = false;
44
+
45
+ const reportError = (error: unknown): void => {
46
+ try {
47
+ options.onError?.(error);
48
+ } catch {
49
+ // Layout failure reporting must not break Pi's renderer.
50
+ }
51
+ };
52
+
53
+ const requestRender = (): void => {
54
+ try {
55
+ tui?.requestRender();
56
+ } catch {
57
+ // The TUI may already be shutting down.
58
+ }
59
+ };
60
+
61
+ const attach = (nextTui: TUI): void => {
62
+ if (disposed) return;
63
+ if (tui === nextTui) return;
64
+ if (tui) throw new Error("Cockpit editor-bottom layout is already attached to another TUI");
65
+ const existing = (nextTui.render as RenderFunction & Record<symbol, RenderMarker | undefined>)[COCKPIT_EDITOR_BOTTOM_MARKER];
66
+ if (existing?.owner === owner) return;
67
+ if (existing) throw new Error("Cockpit editor-bottom layout is already attached to this TUI");
68
+
69
+ tui = nextTui;
70
+ originalRender = nextTui.render;
71
+ const previousRender = nextTui.render;
72
+ wrappedRender = function (this: TUI, width: number): string[] {
73
+ const rendered = previousRender.call(nextTui, width);
74
+ const markerIndex = rendered.findIndex((line) => line.includes(EDITOR_BOTTOM_SENTINEL));
75
+ if (markerIndex < 0) return rendered;
76
+ const withoutMarker = [...rendered.slice(0, markerIndex), ...rendered.slice(markerIndex + 1)];
77
+ if (!enabled) return withoutMarker;
78
+ try {
79
+ const terminalRows = Math.max(1, Math.trunc(nextTui.terminal.rows));
80
+ const padding = Math.max(0, terminalRows - withoutMarker.length);
81
+ if (padding === 0) return withoutMarker;
82
+ return [
83
+ ...withoutMarker.slice(0, markerIndex),
84
+ ...Array.from({ length: padding }, () => ""),
85
+ ...withoutMarker.slice(markerIndex),
86
+ ];
87
+ } catch (error) {
88
+ enabled = false;
89
+ reportError(error);
90
+ return withoutMarker;
91
+ }
92
+ };
93
+ Object.defineProperty(wrappedRender, COCKPIT_EDITOR_BOTTOM_MARKER, {
94
+ value: { owner, original: previousRender } satisfies RenderMarker,
95
+ configurable: false,
96
+ enumerable: false,
97
+ writable: false,
98
+ });
99
+ nextTui.render = wrappedRender;
100
+ requestRender();
101
+ };
102
+
103
+ return {
104
+ attach,
105
+ show() {
106
+ if (disposed || enabled) return;
107
+ enabled = true;
108
+ requestRender();
109
+ },
110
+ hide() {
111
+ if (!enabled) return;
112
+ enabled = false;
113
+ requestRender();
114
+ },
115
+ isEnabled: () => enabled,
116
+ requestRender,
117
+ dispose() {
118
+ if (disposed) return;
119
+ disposed = true;
120
+ enabled = false;
121
+ if (tui && originalRender && tui.render === wrappedRender) tui.render = originalRender;
122
+ requestRender();
123
+ tui = undefined;
124
+ originalRender = undefined;
125
+ wrappedRender = undefined;
126
+ },
127
+ };
128
+ }