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/src/index.ts CHANGED
@@ -1,12 +1,23 @@
1
- import { isAbsolute, relative, resolve, sep } from "node:path";
2
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
3
4
  import type { TUI } from "@earendil-works/pi-tui";
4
5
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
6
  import { AgentsStore, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
6
7
  import { statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
8
+ import { generateTitleWithModel } from "./title-llm.ts";
9
+ import { suggestTitle } from "./title-gen.ts";
7
10
  import { BashBgStore } from "./bash-bg-store.ts";
8
11
  import { MaestroStore } from "./maestro-store.ts";
9
12
  import { createSidebarController, type SidebarController } from "./sidebar-controller.ts";
13
+ import { COCKPIT_SPLIT_PANE_MARKER } from "./split-pane.ts";
14
+ import {
15
+ COCKPIT_EDITOR_BOTTOM_MARKER,
16
+ EDITOR_BOTTOM_WIDGET_KEY,
17
+ createEditorBottomController,
18
+ createEditorBottomSentinel,
19
+ type EditorBottomController,
20
+ } from "./editor-bottom.ts";
10
21
  import { BashBgOverlay } from "./bash-bg-overlay.ts";
11
22
  import { renderBashBgSummary } from "./bash-bg-widget.ts";
12
23
  import { registerQuietTools } from "./quiet-tools.ts";
@@ -21,6 +32,10 @@ import { collectExtensionStatuses } from "./extension-status.ts";
21
32
  import { ANIMATION_PERIOD_MS, resolveGlyphs, spinFrame } from "./icons.ts";
22
33
  import { ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
23
34
  import { applyRow, buildRows, rowKeyForAccel, type SaveState } from "./settings-view.ts";
35
+ import { createCockpitSettingsProvider, registerCockpitSettingsProvider } from "./settings/cockpit-provider.ts";
36
+ import { SettingsLocaleState, getMaestroUiPreferencesPath } from "./settings/locale-state.ts";
37
+ import { SettingsProviderRegistry } from "./settings/registry.ts";
38
+ import { showMaestroSettingsShell } from "./settings/settings-shell.ts";
24
39
  import {
25
40
  COCKPIT_MAESTRO_QUERY_EVENT,
26
41
  MAESTRO_UI_SNAPSHOT_EVENT,
@@ -47,6 +62,10 @@ const FOOTER_UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth
47
62
  const BASH_BG_OVERLAY_KEY = "alt+j";
48
63
  const SIDEBAR_RESIZE_KEY = "ctrl+shift+r";
49
64
  const COCKPIT_STATUS_KEY = "cockpit";
65
+ // Claude Code title chrome: a static marker when idle, two braille frames while
66
+ // a turn runs (screens/REPL.tsx TITLE_STATIC_PREFIX / TITLE_ANIMATION_FRAMES).
67
+ const TITLE_STATIC = "✳";
68
+ const TITLE_FRAMES = ["⠂", "⠐"];
50
69
  const WIDTH_POLL_INTERVAL_MS = 250;
51
70
  // Static mode keeps token totals fresh enough without recomputing on every message.
52
71
  const USAGE_REFRESH_THROTTLE_MS = 10_000;
@@ -83,6 +102,82 @@ function formatCwd(cwd: string): string {
83
102
  return rel === "" ? "~" : `~${sep}${rel}`;
84
103
  }
85
104
 
105
+ /**
106
+ * Cheap synchronous git branch read straight from .git/HEAD — no process spawn,
107
+ * so it is safe on every title refresh. Returns the branch name, "detached" for
108
+ * a raw HEAD hash, or null outside a repo (including linked worktrees).
109
+ */
110
+ function readGitBranch(cwd: string): string | null {
111
+ let headPath: string | undefined;
112
+ try {
113
+ const gitDir = join(cwd, ".git");
114
+ const st = statSync(gitDir);
115
+ if (st.isDirectory()) {
116
+ headPath = join(gitDir, "HEAD");
117
+ } else if (st.isFile()) {
118
+ const link = readFileSync(gitDir, "utf8").trim();
119
+ if (link.startsWith("gitdir:")) {
120
+ headPath = join(resolve(cwd, link.slice("gitdir:".length).trim()), "HEAD");
121
+ }
122
+ }
123
+ if (!headPath) return null;
124
+ const head = readFileSync(headPath, "utf8").trim();
125
+ const branch = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
126
+ return branch ? branch[1] : "detached";
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /** Session summary for the title: the session_info name, else the generated
133
+ * title, else the short id — the Claude Code chain of sessionTitle ?? haikuTitle. */
134
+ function sessionTag(sm: { getSessionName(): string | undefined; getSessionId(): string }, generated: string | undefined): string | undefined {
135
+ const name = sm.getSessionName();
136
+ if (name) return name;
137
+ if (generated) return generated;
138
+ const id = sm.getSessionId();
139
+ return id ? id.slice(0, 8) : undefined;
140
+ }
141
+
142
+ /** Active model short id (drops any "provider/" prefix). */
143
+ function modelTag(model: { id: string } | undefined): string | undefined {
144
+ if (!model) return undefined;
145
+ const last = model.id.split("/").at(-1);
146
+ return last || undefined;
147
+ }
148
+
149
+ /** Thinking level tag; "off" and unknown values add no noise. */
150
+ function thinkingTag(level: string | undefined): string | undefined {
151
+ return level === undefined || level === "off" ? undefined : level;
152
+ }
153
+
154
+ /** Maestro workflow tag: run status when a run is active, else session status. */
155
+ function maestroWorkflowTag(
156
+ workflow: { run: { status: string } | null; session: { status: string } } | null | undefined,
157
+ ): string | undefined {
158
+ if (!workflow) return undefined;
159
+ return workflow.run ? workflow.run.status : workflow.session.status;
160
+ }
161
+
162
+ /** Extract plain text from an agent message (string content or text blocks). */
163
+ function messageText(message: unknown): string | undefined {
164
+ if (!message || typeof message !== "object") return undefined;
165
+ const content = (message as { content?: unknown }).content;
166
+ if (typeof content === "string") return content;
167
+ if (!Array.isArray(content)) return undefined;
168
+ const parts: string[] = [];
169
+ for (const block of content) {
170
+ if (
171
+ block && typeof block === "object"
172
+ && (block as { type?: unknown }).type === "text"
173
+ && typeof (block as { text?: unknown }).text === "string"
174
+ ) {
175
+ parts.push((block as { text: string }).text);
176
+ }
177
+ }
178
+ return parts.length > 0 ? parts.join("\n") : undefined;
179
+ }
180
+
86
181
  function isRecord(value: unknown): value is Record<string, unknown> {
87
182
  return typeof value === "object" && value !== null;
88
183
  }
@@ -115,14 +210,28 @@ export default function (pi: ExtensionAPI): void {
115
210
  const bashBg = new BashBgStore();
116
211
  const todos = new TodoStore();
117
212
  const maestro = new MaestroStore();
213
+ const settingsRegistry = new SettingsProviderRegistry({
214
+ on: (event, handler) => pi.events.on(event, handler),
215
+ emit: (event, payload) => pi.events.emit(event, payload),
216
+ });
217
+ settingsRegistry.start();
218
+ const settingsLocale = new SettingsLocaleState(getMaestroUiPreferencesPath(getAgentDir()), settingsRegistry);
118
219
  let config: CockpitConfig = structuredClone(DEFAULT_CONFIG);
119
220
  let lastCtx: ExtensionContext | undefined;
221
+ let settingsCommandCtx: ExtensionCommandContext | undefined;
120
222
  let capturedTui: TUI | undefined;
121
223
  let sidebarController: SidebarController | undefined;
224
+ let editorBottomController: EditorBottomController | undefined;
122
225
  let dockEffectiveVisible = false;
123
226
  let surfaceState: CockpitSurfaceState = "disabled";
124
227
  let running = false;
125
228
  let runningStartedAt: number | undefined;
229
+ // Auto-generated session title from the first turn (the rule-based / LLM
230
+ // stand-in for Claude Code's Haiku title). Cleared per session.
231
+ let aiTitle: string | undefined;
232
+ let firstUserText: string | undefined;
233
+ let titleRequested = false;
234
+ let titleFrameIndex = 0;
126
235
  const activeTools = new Map<string, { name: string; startedAt: number }>();
127
236
  let tick: ReturnType<typeof setInterval> | undefined;
128
237
  // Persisted rather than toasted: a config that failed to load silently downgrades
@@ -176,6 +285,15 @@ export default function (pi: ExtensionAPI): void {
176
285
 
177
286
  // The streaming line, the tab title and the footer status slot are all fed from
178
287
  // the same snapshots the widgets read, so they can never disagree with them.
288
+ // Leading title glyph: spinner frames while a turn runs (advanced by the
289
+ // tick), a static marker when idle; static mode freezes it. Failure keeps
290
+ // its own ✗ and ignores the frame entirely.
291
+ const titleFrame = (isRunning: boolean): string | undefined => {
292
+ if (isRunning && !config.staticMode) {
293
+ return TITLE_FRAMES[titleFrameIndex % TITLE_FRAMES.length];
294
+ }
295
+ return TITLE_STATIC;
296
+ };
179
297
  const refreshAmbient = (): void => {
180
298
  const ctx = lastCtx;
181
299
  if (!ctx || !isTuiContext(ctx)) return;
@@ -187,19 +305,28 @@ export default function (pi: ExtensionAPI): void {
187
305
  return;
188
306
  }
189
307
  const activeTool = [...activeTools.values()].at(-1);
308
+ const title = config.title;
190
309
  const state: AmbientState = {
191
310
  todos: todos.snapshot(),
192
311
  agents: agents.snapshot(),
193
312
  jobs: bashBg.snapshot(),
194
313
  running,
195
- cwd: formatCwd(ctx.sessionManager.getCwd()),
314
+ cwd: title.showCwd ? formatCwd(ctx.sessionManager.getCwd()) : undefined,
196
315
  activeTool: activeTool?.name,
197
316
  workingStartedAt: activeTool?.startedAt ?? runningStartedAt,
198
317
  hideLiveDuration: config.staticMode,
199
318
  separator: ` ${g.separator} `,
200
319
  };
201
320
  ctx.ui.setWorkingMessage(workingMessage(state));
202
- ctx.ui.setTitle(titleFor(state, { ok: g.check, fail: g.cross }, g.separator));
321
+ if (title.enabled) {
322
+ state.session = title.showSession ? sessionTag(ctx.sessionManager, aiTitle) : undefined;
323
+ state.model = title.showModel ? modelTag(ctx.model) : undefined;
324
+ state.thinking = title.showThinking ? thinkingTag(ctx.thinkingLevel) : undefined;
325
+ state.gitBranch = title.showGit ? (readGitBranch(ctx.cwd) ?? undefined) : undefined;
326
+ state.maestro = title.showMaestro ? maestroWorkflowTag(maestro.snapshot()?.workflow) : undefined;
327
+ state.frame = titleFrame(running);
328
+ ctx.ui.setTitle(titleFor(state, { ok: g.check, fail: g.cross }, g.separator, { maxLength: title.maxLength }));
329
+ }
203
330
  ctx.ui.setStatus(COCKPIT_STATUS_KEY, statusText(configProblem, g.blocked));
204
331
  } catch {
205
332
  // ambient surfaces are best-effort; never let them break a render
@@ -243,6 +370,7 @@ export default function (pi: ExtensionAPI): void {
243
370
  agents.snapshot();
244
371
  if (agents.size !== before) req();
245
372
  } else {
373
+ titleFrameIndex += 1;
246
374
  req();
247
375
  }
248
376
  } else {
@@ -331,6 +459,36 @@ export default function (pi: ExtensionAPI): void {
331
459
  ctx.ui.setWidget(AGENT_WIDGET_KEY, undefined);
332
460
  };
333
461
 
462
+ const ensureEditorBottomController = (ctx: ExtensionContext): EditorBottomController => {
463
+ if (editorBottomController) return editorBottomController;
464
+ editorBottomController = createEditorBottomController({
465
+ onError: (error) => ctx.ui.notify(
466
+ `Bottom-pinned input disabled: ${error instanceof Error ? error.message : String(error)}`,
467
+ "warning",
468
+ ),
469
+ });
470
+ return editorBottomController;
471
+ };
472
+
473
+ const installEditorBottom = (ctx: ExtensionContext): void => {
474
+ const controller = ensureEditorBottomController(ctx);
475
+ ctx.ui.setWidget(
476
+ EDITOR_BOTTOM_WIDGET_KEY,
477
+ (tui) => {
478
+ capturedTui = tui;
479
+ controller.attach(tui);
480
+ controller.show();
481
+ return createEditorBottomSentinel();
482
+ },
483
+ { placement: "aboveEditor" },
484
+ );
485
+ };
486
+
487
+ const clearEditorBottom = (ctx: ExtensionContext): void => {
488
+ ctx.ui.setWidget(EDITOR_BOTTOM_WIDGET_KEY, undefined);
489
+ editorBottomController?.hide();
490
+ };
491
+
334
492
  const installWidgets = (ctx: ExtensionContext): void => {
335
493
  ctx.ui.setWidget(
336
494
  STACK_WIDGET_KEY,
@@ -406,6 +564,7 @@ export default function (pi: ExtensionAPI): void {
406
564
 
407
565
  const syncSidebarMode = (ctx: ExtensionContext): void => {
408
566
  const controller = ensureSidebarController(ctx);
567
+ controller.setWidth(config.sidebar.width);
409
568
  if (config.sidebar.mode === "off") {
410
569
  dockEffectiveVisible = false;
411
570
  controller.hide();
@@ -415,10 +574,25 @@ export default function (pi: ExtensionAPI): void {
415
574
  reconcileSurface(ctx);
416
575
  };
417
576
 
577
+ const disposeLayoutControllers = (): void => {
578
+ const render = capturedTui?.render as (TUI["render"] & Record<symbol, unknown>) | undefined;
579
+ const editorBottomIsTop = Boolean(render?.[COCKPIT_EDITOR_BOTTOM_MARKER]);
580
+ const splitPaneIsTop = Boolean(render?.[COCKPIT_SPLIT_PANE_MARKER]);
581
+ if (editorBottomIsTop && !splitPaneIsTop) {
582
+ editorBottomController?.dispose();
583
+ sidebarController?.dispose();
584
+ } else {
585
+ sidebarController?.dispose();
586
+ editorBottomController?.dispose();
587
+ }
588
+ sidebarController = undefined;
589
+ editorBottomController = undefined;
590
+ };
591
+
418
592
  const uninstallUi = (ctx: ExtensionContext): void => {
419
593
  dockEffectiveVisible = false;
420
- sidebarController?.dispose();
421
- sidebarController = undefined;
594
+ clearEditorBottom(ctx);
595
+ disposeLayoutControllers();
422
596
  clearWidgets(ctx);
423
597
  ctx.ui.setFooter(undefined);
424
598
  surfaceState = "disabled";
@@ -442,6 +616,8 @@ export default function (pi: ExtensionAPI): void {
442
616
  return;
443
617
  }
444
618
  ctx.ui.setWorkingIndicator({ frames: [] });
619
+ if (config.pinEditorBottom) installEditorBottom(ctx);
620
+ else clearEditorBottom(ctx);
445
621
  ctx.ui.setFooter((tui, theme, footerData) => {
446
622
  capturedTui = tui;
447
623
  let observedWidth = tui.terminal.columns;
@@ -521,6 +697,59 @@ export default function (pi: ExtensionAPI): void {
521
697
  };
522
698
 
523
699
  // --- teammate lifecycle (custom event bus; subscribed once for the extension lifetime) ---
700
+ // Session-title generation: after the first turn settles, ask the configured
701
+ // model (api-manager provider) with the user message + assistant reply, else
702
+ // fall back to the offline rule-based suggestTitle. A model ref that cannot
703
+ // be resolved or fails is never fatal — the title just stays rule-based.
704
+ const generateTitleWithConfiguredModel = async (
705
+ ctx: ExtensionContext,
706
+ modelRef: string,
707
+ text: string,
708
+ ): Promise<string | null> => {
709
+ const slash = modelRef.indexOf("/");
710
+ if (slash <= 0 || slash === modelRef.length - 1) return null;
711
+ const model = ctx.modelRegistry.find(modelRef.slice(0, slash), modelRef.slice(slash + 1));
712
+ if (!model) return null;
713
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
714
+ if (!auth.ok) return null;
715
+ const controller = new AbortController();
716
+ const timer = setTimeout(() => controller.abort(), 10_000);
717
+ try {
718
+ return await generateTitleWithModel(
719
+ { baseUrl: model.baseUrl, modelId: model.id, apiKey: auth.apiKey, headers: auth.headers },
720
+ text,
721
+ controller.signal,
722
+ );
723
+ } finally {
724
+ clearTimeout(timer);
725
+ }
726
+ };
727
+ const generateSessionTitle = async (
728
+ ctx: ExtensionContext,
729
+ userText: string,
730
+ assistantMessage: unknown,
731
+ refresh: () => void,
732
+ ): Promise<void> => {
733
+ const fallback = suggestTitle(userText);
734
+ const assistantText = messageText(assistantMessage);
735
+ const llmInput = assistantText
736
+ ? `${userText}\n\nAssistant:\n${assistantText.slice(0, 500)}`
737
+ : userText;
738
+ const modelRef = config.title.generationModel;
739
+ const title = modelRef
740
+ ? await generateTitleWithConfiguredModel(ctx, modelRef, llmInput)
741
+ : null;
742
+ if (title) {
743
+ aiTitle = title;
744
+ } else if (fallback) {
745
+ aiTitle = fallback;
746
+ } else {
747
+ titleRequested = false; // nothing usable; a later turn may retry
748
+ return;
749
+ }
750
+ refresh();
751
+ };
752
+
524
753
  pi.events.on(TEAMMATE_STARTED_EVENT, (payload) => {
525
754
  if (!isStartedPayload(payload)) return;
526
755
  agents.applyStarted(payload);
@@ -576,6 +805,10 @@ export default function (pi: ExtensionAPI): void {
576
805
  activeTools.clear();
577
806
  thinkingTimer.reset();
578
807
  maestro.clear();
808
+ aiTitle = undefined;
809
+ firstUserText = undefined;
810
+ titleRequested = false;
811
+ titleFrameIndex = 0;
579
812
  ensureConfigExists();
580
813
  config = loadConfig((m, l) => {
581
814
  try {
@@ -584,6 +817,8 @@ export default function (pi: ExtensionAPI): void {
584
817
  // notify unavailable
585
818
  }
586
819
  });
820
+ settingsLocale.reload();
821
+ settingsRegistry.emitLocale(settingsLocale.locale);
587
822
  invalidateUsageCache();
588
823
  // Quiet mode: register compact tool renderers and fold thinking blocks.
589
824
  // Tools are normally registered at extension load time (above) so they
@@ -622,7 +857,18 @@ export default function (pi: ExtensionAPI): void {
622
857
  req();
623
858
  });
624
859
 
625
- pi.on("session_shutdown", (_e, ctx) => {
860
+ pi.on("session_shutdown", (e, ctx) => {
861
+ // Claude Code clears the terminal title on exit (CLEAR_TERMINAL_TITLE) so
862
+ // the tab does not keep a stale title after the process dies. Only on
863
+ // quit: reload/new/resume/fork are immediately followed by a new session
864
+ // that sets its own title, and clearing would flash the tab.
865
+ if (e.reason === "quit" && lastCtx && isTuiContext(lastCtx) && config.title.enabled) {
866
+ try {
867
+ lastCtx.ui.setTitle("");
868
+ } catch {
869
+ // mid-teardown: best-effort
870
+ }
871
+ }
626
872
  if (lastCtx) uninstallUi(lastCtx);
627
873
  const released: CockpitUiOwnershipV1 = {
628
874
  todo: false,
@@ -681,6 +927,31 @@ export default function (pi: ExtensionAPI): void {
681
927
  invalidateUsageCache();
682
928
  if (isTuiContext(ctx)) req();
683
929
  });
930
+ pi.on("session_info_changed", (_e, ctx) => {
931
+ // A session rename (or the /session name set at startup) changes the
932
+ // session summary the tab title leads with.
933
+ if (isTuiContext(ctx)) req();
934
+ });
935
+ pi.on("thinking_level_select", (_e, ctx) => {
936
+ if (isTuiContext(ctx)) req();
937
+ });
938
+ pi.on("input", (e) => {
939
+ // Remember the first real user message; the title is generated after the
940
+ // first turn completes, so it can reflect the whole exchange. Slash
941
+ // commands and bash-mode inputs are synthetic, not the user's topic.
942
+ if (!firstUserText && e.source === "interactive" && e.text) {
943
+ const isSynthetic = e.text.startsWith("/") || e.text.startsWith("!");
944
+ if (!isSynthetic) firstUserText = e.text;
945
+ }
946
+ });
947
+ pi.on("turn_end", (e, ctx) => {
948
+ // Claude Code generates its title from the first user message; we wait
949
+ // for the first turn to settle so the title can reflect what the model
950
+ // actually did, then ask the configured model (or the rule fallback).
951
+ if (titleRequested || !firstUserText || !isTuiContext(ctx)) return;
952
+ titleRequested = true;
953
+ void generateSessionTitle(ctx, firstUserText, e.message, () => req());
954
+ });
684
955
 
685
956
  const openBashBgOverlay = async (ctx: ExtensionContext): Promise<void> => {
686
957
  if (!ctx.hasUI) return;
@@ -816,6 +1087,53 @@ export default function (pi: ExtensionAPI): void {
816
1087
  return result.ok;
817
1088
  };
818
1089
 
1090
+ const cockpitSettingsProvider = createCockpitSettingsProvider({
1091
+ getRuntimeConfig: () => config,
1092
+ applyRuntimeConfig: async (nextConfig, changedKeys) => {
1093
+ const previous = config;
1094
+ config = nextConfig;
1095
+ const ctx = lastCtx;
1096
+ if (!ctx || !isTuiContext(ctx)) return;
1097
+ if (previous.enabled !== nextConfig.enabled) {
1098
+ if (nextConfig.enabled) {
1099
+ publishUiOwnership();
1100
+ applyUi(ctx);
1101
+ } else {
1102
+ uninstallUi(ctx);
1103
+ publishUiOwnership();
1104
+ }
1105
+ return;
1106
+ }
1107
+ if (!nextConfig.enabled) return;
1108
+ if (previous.staticMode !== nextConfig.staticMode) {
1109
+ syncTick();
1110
+ thinkingTimer.syncMode();
1111
+ }
1112
+ if (previous.quietMode !== nextConfig.quietMode) {
1113
+ applyQuietMode(ctx, previous.quietMode, nextConfig.quietMode);
1114
+ }
1115
+ if (previous.pinEditorBottom !== nextConfig.pinEditorBottom) {
1116
+ if (nextConfig.pinEditorBottom) installEditorBottom(ctx);
1117
+ else clearEditorBottom(ctx);
1118
+ }
1119
+ if (changedKeys.some((key) => key.startsWith("sidebar."))) syncSidebarMode(ctx);
1120
+ publishUiOwnership();
1121
+ req();
1122
+ },
1123
+ getThemeName: () => config.theme || undefined,
1124
+ getThinkingFolded: () => lastCtx ? readHideThinkingBlock(lastCtx.cwd) : undefined,
1125
+ openLegacySettings: async () => { if (settingsCommandCtx) await openSettings(settingsCommandCtx); },
1126
+ openThemeSettings: async () => { if (settingsCommandCtx) await openThemePicker(settingsCommandCtx); },
1127
+ toggleThinkingFold: () => {
1128
+ if (!lastCtx) return;
1129
+ ensureThinkingFolded(capturedTui, lastCtx.cwd, !readHideThinkingBlock(lastCtx.cwd));
1130
+ },
1131
+ });
1132
+ registerCockpitSettingsProvider({
1133
+ on: (event, handler) => pi.events.on(event, handler),
1134
+ emit: (event, payload) => pi.events.emit(event, payload),
1135
+ }, cockpitSettingsProvider);
1136
+
819
1137
  pi.registerShortcut(BASH_BG_OVERLAY_KEY, {
820
1138
  description: "Open background Bash jobs — live status, command, cwd, duration and output tail",
821
1139
  async handler(ctx) {
@@ -834,7 +1152,23 @@ export default function (pi: ExtensionAPI): void {
834
1152
  },
835
1153
  });
836
1154
 
837
- // --- /cockpit: settings plus direct surface controls ---
1155
+ pi.registerCommand("maestro-settings", {
1156
+ description: "Open unified Maestro settings for Cockpit, Flow, Teammate and integrations",
1157
+ async handler(_args, ctx) {
1158
+ if (!ctx.hasUI) {
1159
+ ctx.ui.notify("Maestro settings require interactive TUI mode", "warning");
1160
+ return;
1161
+ }
1162
+ settingsCommandCtx = ctx;
1163
+ try {
1164
+ await showMaestroSettingsShell(ctx, settingsRegistry, settingsLocale);
1165
+ } finally {
1166
+ settingsCommandCtx = undefined;
1167
+ }
1168
+ },
1169
+ });
1170
+
1171
+ // --- /cockpit: legacy settings plus direct surface controls ---
838
1172
  pi.registerCommand("cockpit", {
839
1173
  description: "Open pi-cockpit settings; /cockpit sidebar controls the dock, /cockpit bg shows jobs",
840
1174
  getArgumentCompletions: (prefix) => {
@@ -946,6 +1280,7 @@ export default function (pi: ExtensionAPI): void {
946
1280
  const wasEnabled = config.enabled;
947
1281
  const wasQuiet = config.quietMode;
948
1282
  const wasStatic = config.staticMode;
1283
+ const wasPinEditorBottom = config.pinEditorBottom;
949
1284
  const wasSidebarMode = config.sidebar.mode;
950
1285
  if (key === "theme") {
951
1286
  // Delegate: the picker previews live and reverts on Esc, neither of
@@ -989,7 +1324,12 @@ export default function (pi: ExtensionAPI): void {
989
1324
  publishUiOwnership();
990
1325
  }
991
1326
  } else {
992
- if (config.enabled && wasSidebarMode !== config.sidebar.mode) syncSidebarMode(ctx);
1327
+ if (config.enabled && wasPinEditorBottom !== config.pinEditorBottom) {
1328
+ if (config.pinEditorBottom) installEditorBottom(ctx);
1329
+ else clearEditorBottom(ctx);
1330
+ } else if (config.enabled && wasSidebarMode !== config.sidebar.mode) {
1331
+ syncSidebarMode(ctx);
1332
+ }
993
1333
  publishUiOwnership();
994
1334
  }
995
1335
  if (wasQuiet !== config.quietMode) {
package/src/render.ts CHANGED
@@ -285,6 +285,14 @@ export function renderAgents(
285
285
  { text: theme.fg(status.color, status.glyph), priority: 99, clippable: false },
286
286
  ];
287
287
  if (status.label) segs.push({ text: theme.fg(status.color, status.label), priority: 95, clippable: false });
288
+ if (r.phase) segs.push({ text: theme.fg("dim", r.phase), priority: 92, minWidth: 6 });
289
+ if (r.lastOutcome?.status === "failed") {
290
+ segs.push({
291
+ text: theme.fg("error", r.lastOutcome.message ? `last failed: ${r.lastOutcome.message}` : "last failed"),
292
+ priority: 91,
293
+ minWidth: 8,
294
+ });
295
+ }
288
296
  segs.push({ text: theme.fg(rc, r.role), priority: 90 });
289
297
  if (r.task) segs.push({ text: r.task, priority: 80, minWidth: 6 });
290
298
  const live = r.status === "running" || r.status === "retrying";
@@ -299,9 +307,17 @@ export function renderAgents(
299
307
  if (r.tail) segs.push({ text: theme.fg("dim", r.tail), priority: 40, minWidth: 8 });
300
308
  const model = r.resolvedModel ?? r.requestedModel;
301
309
  if (model) {
302
- const fallback = r.requestedModel && r.resolvedModel && r.requestedModel !== r.resolvedModel;
310
+ const resolved = r.resolvedModel ?? "";
311
+ const requested = r.requestedModel ?? "";
312
+ const baseName = (id: string): string => id.slice(id.lastIndexOf("/") + 1);
313
+ const both = requested !== "" && resolved !== "" && requested !== resolved;
314
+ // Same underlying model written in two id formats (e.g. provider/model vs
315
+ // bare model) is not a fallback; only differing base names are.
316
+ const formatOnly = both && baseName(requested) === baseName(resolved);
317
+ const fallback = both && !formatOnly;
318
+ const displayModel = formatOnly ? (resolved.includes("/") ? resolved : requested) : model;
303
319
  segs.push({
304
- text: theme.fg(fallback ? "warning" : "muted", fallback ? `model ${r.requestedModel}→${r.resolvedModel}` : `model ${model}`),
320
+ text: theme.fg(fallback ? "warning" : "muted", fallback ? `model ${requested}→${resolved}` : `model ${displayModel}`),
305
321
  priority: 25,
306
322
  minWidth: 8,
307
323
  });