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 +22 -1
- package/package.json +75 -71
- package/src/agents-store.ts +55 -12
- package/src/ambient.ts +57 -9
- package/src/config.ts +57 -1
- package/src/editor-bottom.ts +128 -0
- package/src/index.ts +349 -9
- package/src/render.ts +18 -2
- package/src/settings/cockpit-provider.ts +682 -0
- package/src/settings/coordinator.ts +323 -0
- package/src/settings/i18n.ts +150 -0
- package/src/settings/locale-state.ts +138 -0
- package/src/settings/registry.ts +160 -0
- package/src/settings/settings-shell.ts +1147 -0
- package/src/settings-view.ts +75 -0
- package/src/sidebar-controller.ts +2 -0
- package/src/sidebar-render.ts +7 -3
- package/src/title-gen.ts +60 -0
- package/src/title-llm.ts +115 -0
- package/src/types.ts +58 -0
package/src/index.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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", (
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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 ${
|
|
320
|
+
text: theme.fg(fallback ? "warning" : "muted", fallback ? `model ${requested}→${resolved}` : `model ${displayModel}`),
|
|
305
321
|
priority: 25,
|
|
306
322
|
minWidth: 8,
|
|
307
323
|
});
|