pi-cockpit 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/agent-bar.ts +15 -5
- package/src/ambient.ts +22 -4
- package/src/endpoint-store.ts +1 -1
- package/src/index.ts +120 -8
- package/src/input-routing.ts +4 -0
- package/src/maestro-store.ts +18 -0
- package/src/public/v1/events.ts +10 -0
- package/src/render.ts +11 -0
- package/src/stack-widget.ts +1 -1
- package/src/tui-i18n.ts +18 -0
- package/src/viewport-stability.ts +28 -17
- package/src/window-autocomplete.ts +192 -0
- package/src/window-bar.ts +6 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -67,12 +67,12 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@earendil-works/pi-agent-core": "0.
|
|
71
|
-
"@earendil-works/pi-ai": "0.
|
|
72
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
73
|
-
"@earendil-works/pi-tui": "0.
|
|
70
|
+
"@earendil-works/pi-agent-core": "0.84.4",
|
|
71
|
+
"@earendil-works/pi-ai": "0.84.4",
|
|
72
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
73
|
+
"@earendil-works/pi-tui": "0.84.4",
|
|
74
74
|
"@types/node": "^22.0.0",
|
|
75
|
-
"pi-maestro-teammate": "2.
|
|
75
|
+
"pi-maestro-teammate": "2.3.0",
|
|
76
76
|
"typebox": "1.3.7",
|
|
77
77
|
"typescript": "^5.5.0"
|
|
78
78
|
},
|
package/src/agent-bar.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { AgentRow } from "./types.ts";
|
|
|
15
15
|
import { effectiveAgentStatus, isCliAgent, type AgentDisplayStatus } from "./agents-store.ts";
|
|
16
16
|
import { visibleAgentRows } from "./stack-widget.ts";
|
|
17
17
|
import { tuiT } from "./tui-i18n.ts";
|
|
18
|
+
import { ownerDisplayToken } from "./window-autocomplete.ts";
|
|
18
19
|
|
|
19
20
|
export const SESSION_BAR_WIDGET_KEY = "cockpit-session-bar";
|
|
20
21
|
export const AGENT_BAR_WIDGET_KEY = SESSION_BAR_WIDGET_KEY;
|
|
@@ -37,12 +38,19 @@ export interface SessionBarDeps {
|
|
|
37
38
|
getCurrentSession?: () => { label: string; color: ThemeColor } | undefined;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
export interface SessionBarHint {
|
|
42
|
+
text: string;
|
|
43
|
+
color?: ThemeColor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SessionBarHintValue = string | SessionBarHint;
|
|
47
|
+
|
|
40
48
|
export interface AgentBarDeps {
|
|
41
49
|
getEndpoints: () => readonly CockpitEndpoint[];
|
|
42
50
|
getState: () => SessionUiState;
|
|
43
51
|
getNow: () => number;
|
|
44
52
|
isMainRunning?: () => boolean;
|
|
45
|
-
getShortcutHint?: () =>
|
|
53
|
+
getShortcutHint?: () => SessionBarHintValue | undefined;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export const AGENT_SESSION_COLORS = [
|
|
@@ -154,7 +162,7 @@ function endpointColor(endpoint: CockpitEndpoint, status: AgentBarStatus, now: n
|
|
|
154
162
|
export interface AgentBarRenderOptions {
|
|
155
163
|
mainRunning?: boolean;
|
|
156
164
|
now?: number;
|
|
157
|
-
shortcutHint?:
|
|
165
|
+
shortcutHint?: SessionBarHintValue;
|
|
158
166
|
}
|
|
159
167
|
|
|
160
168
|
/** Chip-to-chip separator in the agent bar. */
|
|
@@ -231,11 +239,12 @@ export function renderSessionBarLine(
|
|
|
231
239
|
renderContent: (width: number) => string,
|
|
232
240
|
width: number,
|
|
233
241
|
theme: Theme,
|
|
234
|
-
shortcutHint?:
|
|
242
|
+
shortcutHint?: SessionBarHintValue,
|
|
235
243
|
): string {
|
|
236
244
|
const w = Math.max(1, width);
|
|
237
245
|
if (!shortcutHint) return renderContent(w);
|
|
238
|
-
const
|
|
246
|
+
const hintSpec = typeof shortcutHint === "string" ? { text: shortcutHint, color: "dim" as const } : shortcutHint;
|
|
247
|
+
const hint = theme.fg(hintSpec.color ?? "dim", hintSpec.text);
|
|
239
248
|
const hintWidth = visibleWidth(hint);
|
|
240
249
|
const gap = 2;
|
|
241
250
|
if (w < hintWidth + gap + 8) return renderContent(w);
|
|
@@ -269,9 +278,10 @@ export function renderAgentBar(
|
|
|
269
278
|
const outcome = status === "done" || status === "failed" || status === "terminated" || status === "sleeping"
|
|
270
279
|
? endpoint.agentRow?.lastOutcome
|
|
271
280
|
: undefined;
|
|
281
|
+
const ownerId = endpoint.kind === "root" ? endpoint.registryEndpoint?.ownerId : undefined;
|
|
272
282
|
return {
|
|
273
283
|
id: endpoint.id,
|
|
274
|
-
label: endpoint.label,
|
|
284
|
+
label: ownerId ? ownerDisplayToken(endpoint.label, ownerId) : endpoint.label,
|
|
275
285
|
ordinal: endpoint.ordinal,
|
|
276
286
|
unread: state.endpoint(endpoint.id).unread,
|
|
277
287
|
endpoint,
|
package/src/ambient.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface AmbientState {
|
|
|
16
16
|
agents: readonly AgentRow[];
|
|
17
17
|
jobs: readonly BashBgJob[];
|
|
18
18
|
running: boolean;
|
|
19
|
+
waitingForInput?: boolean;
|
|
19
20
|
cwd?: string;
|
|
20
21
|
activeTool?: string;
|
|
21
22
|
workingStartedAt?: number;
|
|
@@ -54,17 +55,32 @@ function failedJobs(jobs: readonly BashBgJob[]): BashBgJob[] {
|
|
|
54
55
|
return jobs.filter((job) => job.status === "failed" || (job.exitCode !== null && job.exitCode !== 0));
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export function nextUiPromptDepth(depth: number, event: "start" | "end"): number {
|
|
59
|
+
return event === "start" ? depth + 1 : Math.max(0, depth - 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function shouldHideWorkingDuration(
|
|
63
|
+
mode: "regular" | "fullscreen" | undefined,
|
|
64
|
+
staticMode: boolean,
|
|
65
|
+
): boolean {
|
|
66
|
+
// Pi's regular renderer shares scrollback between the transcript and status
|
|
67
|
+
// row, so a moving live timer can be preserved as transcript height changes.
|
|
68
|
+
return staticMode || mode !== "fullscreen";
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
/**
|
|
58
72
|
* The streaming working line.
|
|
59
73
|
*
|
|
60
|
-
* Cockpit hides the host indicator and renders the active state
|
|
61
|
-
* elapsed value
|
|
74
|
+
* Cockpit hides the host indicator and renders the active state. Fullscreen may
|
|
75
|
+
* add a live elapsed value; the regular main screen keeps this row stable.
|
|
62
76
|
*/
|
|
63
77
|
export function workingMessage(state: AmbientState, now = Date.now()): string | undefined {
|
|
64
|
-
const label = state.
|
|
78
|
+
const label = state.waitingForInput
|
|
79
|
+
? tuiT("ambient.waitingForInput")
|
|
80
|
+
: state.activeTool ?? (state.running ? tuiT("ambient.working") : undefined);
|
|
65
81
|
if (!label) return undefined;
|
|
66
82
|
const sep = state.separator ?? " ";
|
|
67
|
-
const text = (state.hideLiveDuration || state.workingStartedAt === undefined)
|
|
83
|
+
const text = (state.waitingForInput || state.hideLiveDuration || state.workingStartedAt === undefined)
|
|
68
84
|
? label
|
|
69
85
|
: `${label}${sep}${formatThinkingDuration(now - state.workingStartedAt)}`;
|
|
70
86
|
return `\x1b[3m${text}\x1b[23m`;
|
|
@@ -100,6 +116,8 @@ export function titleFor(
|
|
|
100
116
|
let title: string;
|
|
101
117
|
if (broken > 0) {
|
|
102
118
|
title = `${marks.fail} ${base}${sep}${tuiT("ambient.failed", { count: broken })}`;
|
|
119
|
+
} else if (state.waitingForInput) {
|
|
120
|
+
title = `${prefix}${base}${sep}${tuiT("ambient.waitingForInput")}`;
|
|
103
121
|
} else if (state.running) {
|
|
104
122
|
const live = liveAgents(state.agents).length;
|
|
105
123
|
title = live > 0
|
package/src/endpoint-store.ts
CHANGED
|
@@ -507,7 +507,7 @@ export class EndpointStore {
|
|
|
507
507
|
id: `${MONITOR_CONTROL_ENDPOINT_PREFIX}${encodeURIComponent(root.id)}`,
|
|
508
508
|
logicalKey: `monitor-control:${root.ownerId}:${root.ownerNonce}`,
|
|
509
509
|
kind: "window",
|
|
510
|
-
label:
|
|
510
|
+
label: `control·${root.ownerId.slice(0, 6)}`,
|
|
511
511
|
ordinal: Number.MIN_SAFE_INTEGER,
|
|
512
512
|
status: root.status,
|
|
513
513
|
contentRevision: revisionOf([root.contentRevision, controlActivityRevision]),
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { TUI } from "@earendil-works/pi-tui";
|
|
|
5
5
|
import { ambientKeysShouldYield, capturingOverlayVisible } from "./capturing-overlay.ts";
|
|
6
6
|
import { Key, decodeKittyPrintable, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import { AgentReadStoreRouter, effectiveAgentStatus, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
|
|
8
|
-
import { AmbientSurfaceCache, statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
|
|
8
|
+
import { AmbientSurfaceCache, nextUiPromptDepth, shouldHideWorkingDuration, statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
|
|
9
9
|
import { generateTitleWithModel } from "./title-llm.ts";
|
|
10
10
|
import { suggestTitle } from "./title-gen.ts";
|
|
11
11
|
import { BashBgStore } from "./bash-bg-store.ts";
|
|
@@ -56,6 +56,13 @@ import {
|
|
|
56
56
|
import { SessionUiState } from "./session-ui-state.ts";
|
|
57
57
|
import { nextSessionTabId } from "./session-tabs.ts";
|
|
58
58
|
import { renderWindowBar, windowSessionColor } from "./window-bar.ts";
|
|
59
|
+
import {
|
|
60
|
+
buildWindowAutocompleteTargets,
|
|
61
|
+
createWindowAutocompleteProvider,
|
|
62
|
+
ownerDisplayToken,
|
|
63
|
+
resolveWindowRouteInput,
|
|
64
|
+
type WindowAutocompleteTarget,
|
|
65
|
+
} from "./window-autocomplete.ts";
|
|
59
66
|
import { makeWindowThreadWidget } from "./window-thread-view.ts";
|
|
60
67
|
import { agentPanelRows, panelRows } from "./viewport.ts";
|
|
61
68
|
import { createZenBrowseController, type ZenBrowseController } from "./zen-browse.ts";
|
|
@@ -68,7 +75,7 @@ import {
|
|
|
68
75
|
buildZenTaskSheet,
|
|
69
76
|
type ZenSheetDocument,
|
|
70
77
|
} from "./zen-sheet.ts";
|
|
71
|
-
import { routeAgentInput } from "./input-routing.ts";
|
|
78
|
+
import { isLegacyTodoOverlayInput, routeAgentInput } from "./input-routing.ts";
|
|
72
79
|
import { activeThemeName, ThemePicker } from "./theme-picker.ts";
|
|
73
80
|
import { ModelPicker, type ModelPickerEntry } from "./model-picker.ts";
|
|
74
81
|
import { getUsageTotals, invalidateUsageCache, renderFooter, setUsageThrottle, type PaintTheme, type WidthUtils } from "./footer.ts";
|
|
@@ -383,6 +390,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
383
390
|
let sessionTheme: Theme | undefined;
|
|
384
391
|
// Fullscreen (alternate-screen fixed editor) controller, reload-gated.
|
|
385
392
|
let fullscreenController: FullscreenController | undefined;
|
|
393
|
+
/** Disposer for the legacy Alt+Shift+T compatibility hook (per applyUi). */
|
|
394
|
+
let todoOverlayShortcutDisposer: (() => void) | undefined;
|
|
386
395
|
/** Disposer for the session ←/→ navigation hook (per applyUi). */
|
|
387
396
|
let sessionBarNavDisposer: (() => void) | undefined;
|
|
388
397
|
/** Disposer for the agent-list Shift+↑/↓ scroll hook (per applyUi). */
|
|
@@ -394,6 +403,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
394
403
|
let surfaceState: CockpitSurfaceState = "disabled";
|
|
395
404
|
let running = false;
|
|
396
405
|
let runningStartedAt: number | undefined;
|
|
406
|
+
let uiPromptDepth = 0;
|
|
397
407
|
// Auto-generated session title from the first turn (the rule-based / LLM
|
|
398
408
|
// stand-in for Claude Code's Haiku title). Cleared per session.
|
|
399
409
|
let aiTitle: string | undefined;
|
|
@@ -496,10 +506,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
496
506
|
agents: agents.snapshot(),
|
|
497
507
|
jobs: bashBg.snapshot(),
|
|
498
508
|
running,
|
|
509
|
+
waitingForInput: uiPromptDepth > 0,
|
|
499
510
|
cwd: title.showCwd ? formatCwd(ctx.sessionManager.getCwd()) : undefined,
|
|
500
511
|
activeTool: activeTool?.name,
|
|
501
512
|
workingStartedAt: activeTool?.startedAt ?? runningStartedAt,
|
|
502
|
-
hideLiveDuration: config.staticMode,
|
|
513
|
+
hideLiveDuration: shouldHideWorkingDuration(capturedTui?.mode, config.staticMode),
|
|
503
514
|
separator: ` ${g.separator} `,
|
|
504
515
|
};
|
|
505
516
|
ambientSurfaces.setWorkingMessage((message) => ctx.ui.setWorkingMessage(message), workingMessage(state, now));
|
|
@@ -512,7 +523,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
512
523
|
state.maestroKnowledge = title.showMaestro
|
|
513
524
|
? maestroKnowledgeTag(maestro.snapshot()?.workflow?.knowledge)
|
|
514
525
|
: undefined;
|
|
515
|
-
state.frame = titleFrame(running);
|
|
526
|
+
state.frame = titleFrame(running && uiPromptDepth === 0);
|
|
516
527
|
ambientSurfaces.setTitle(
|
|
517
528
|
(value) => ctx.ui.setTitle(value),
|
|
518
529
|
titleFor(state, { ok: g.check, fail: g.cross }, g.separator, { maxLength: title.maxLength }),
|
|
@@ -883,6 +894,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
883
894
|
};
|
|
884
895
|
};
|
|
885
896
|
|
|
897
|
+
const selectedHashWindowTargets = new Map<string, WindowAutocompleteTarget>();
|
|
898
|
+
const windowAutocompleteTargets = () => buildWindowAutocompleteTargets(endpoints.snapshot(), {
|
|
899
|
+
current: tuiT("window.autocompleteCurrent"),
|
|
900
|
+
peer: tuiT("window.autocompletePeer"),
|
|
901
|
+
});
|
|
902
|
+
|
|
886
903
|
const publishInputTarget = (force = false): void => {
|
|
887
904
|
const target = sessionUi.mode === "window" ? selectedWindowInputTarget() : selectedAgentTarget();
|
|
888
905
|
const sigil = target && "sigil" in target ? target.sigil : "@";
|
|
@@ -1082,6 +1099,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1082
1099
|
if (config.enabled) syncAgentWidgetPlacement(ctx, "belowEditor");
|
|
1083
1100
|
};
|
|
1084
1101
|
|
|
1102
|
+
const sessionBarHint = () => {
|
|
1103
|
+
if (sessionListOverlayActive()) return undefined;
|
|
1104
|
+
return maestro.snapshot()?.artifact?.available
|
|
1105
|
+
? { text: tuiT("artifact.hint"), color: "accent" as const }
|
|
1106
|
+
: tuiT(sessionUi.mode === "agent" ? "session.agentListHint" : "session.listHint");
|
|
1107
|
+
};
|
|
1108
|
+
|
|
1085
1109
|
const installSessionBar = (ctx: ExtensionContext): void => {
|
|
1086
1110
|
ctx.ui.setWidget(
|
|
1087
1111
|
SESSION_BAR_WIDGET_KEY,
|
|
@@ -1093,7 +1117,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1093
1117
|
getState: () => sessionUi,
|
|
1094
1118
|
getNow: () => nowSnapshot,
|
|
1095
1119
|
isMainRunning: () => running,
|
|
1096
|
-
getShortcutHint:
|
|
1120
|
+
getShortcutHint: sessionBarHint,
|
|
1097
1121
|
})(tui, theme);
|
|
1098
1122
|
return {
|
|
1099
1123
|
render(width: number): string[] {
|
|
@@ -1104,7 +1128,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1104
1128
|
sessionUi,
|
|
1105
1129
|
width,
|
|
1106
1130
|
theme,
|
|
1107
|
-
{ shortcutHint:
|
|
1131
|
+
{ shortcutHint: sessionBarHint() },
|
|
1108
1132
|
)
|
|
1109
1133
|
: agentWidget.render(width);
|
|
1110
1134
|
},
|
|
@@ -1329,6 +1353,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1329
1353
|
clearWidgets(ctx);
|
|
1330
1354
|
ctx.ui.setWidget(SESSION_BAR_WIDGET_KEY, undefined);
|
|
1331
1355
|
ctx.ui.setWidget(SESSION_DETAIL_WIDGET_KEY, undefined);
|
|
1356
|
+
todoOverlayShortcutDisposer?.();
|
|
1357
|
+
todoOverlayShortcutDisposer = undefined;
|
|
1332
1358
|
sessionBarNavDisposer?.();
|
|
1333
1359
|
sessionBarNavDisposer = undefined;
|
|
1334
1360
|
agentScrollDisposer?.();
|
|
@@ -1495,6 +1521,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1495
1521
|
// window mode swaps in the window bar. Installed on every surface (dock
|
|
1496
1522
|
// and widgets), inserted last so it sits closest to the editor.
|
|
1497
1523
|
installSessionBar(ctx);
|
|
1524
|
+
// Legacy terminals encode Alt+Shift+T as ESC + uppercase T, which the
|
|
1525
|
+
// host shortcut matcher cannot distinguish without enhanced key reporting.
|
|
1526
|
+
todoOverlayShortcutDisposer?.();
|
|
1527
|
+
todoOverlayShortcutDisposer = ctx.ui.onTerminalInput((data) => {
|
|
1528
|
+
if (!isLegacyTodoOverlayInput(data) || ambientKeysShouldYield(capturedTui)) return undefined;
|
|
1529
|
+
void openTodoOverlay(ctx).catch((error) => {
|
|
1530
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
1531
|
+
});
|
|
1532
|
+
return { consume: true };
|
|
1533
|
+
});
|
|
1498
1534
|
// ←/→ cycles tabs only with an empty composer. Window mode additionally
|
|
1499
1535
|
// accepts Alt+←/→ so drafts can be switched without losing cursor arrows.
|
|
1500
1536
|
// A capturing modal overlay owns navigation while it is open.
|
|
@@ -1766,6 +1802,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1766
1802
|
// --- session + agent lifecycle ---
|
|
1767
1803
|
pi.on("session_start", (_e, ctx) => {
|
|
1768
1804
|
lastCtx = ctx;
|
|
1805
|
+
uiPromptDepth = 0;
|
|
1769
1806
|
ambientSurfaces.reset();
|
|
1770
1807
|
agentReads.bindSession(ctx.sessionManager.getSessionId());
|
|
1771
1808
|
agents = agentReads.current;
|
|
@@ -1804,6 +1841,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1804
1841
|
});
|
|
1805
1842
|
settingsLocale.reload();
|
|
1806
1843
|
settingsRegistry.emitLocale(settingsLocale.locale);
|
|
1844
|
+
selectedHashWindowTargets.clear();
|
|
1845
|
+
ctx.ui.addAutocompleteProvider((current) => createWindowAutocompleteProvider(
|
|
1846
|
+
current,
|
|
1847
|
+
() => config.enabled ? windowAutocompleteTargets() : [],
|
|
1848
|
+
(target) => selectedHashWindowTargets.set(target.token.toLocaleLowerCase("en"), target),
|
|
1849
|
+
));
|
|
1807
1850
|
invalidateUsageCache();
|
|
1808
1851
|
// Quiet mode: register compact tool renderers and fold thinking blocks.
|
|
1809
1852
|
// Tools are normally registered at extension load time (above) so they
|
|
@@ -1898,6 +1941,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1898
1941
|
lastCtx = undefined;
|
|
1899
1942
|
running = false;
|
|
1900
1943
|
runningStartedAt = undefined;
|
|
1944
|
+
uiPromptDepth = 0;
|
|
1901
1945
|
activeTools.clear();
|
|
1902
1946
|
thinkingTimer.reset();
|
|
1903
1947
|
invalidateUsageCache();
|
|
@@ -1927,6 +1971,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1927
1971
|
syncTick();
|
|
1928
1972
|
req();
|
|
1929
1973
|
});
|
|
1974
|
+
pi.on("ui_prompt_start", () => {
|
|
1975
|
+
uiPromptDepth = nextUiPromptDepth(uiPromptDepth, "start");
|
|
1976
|
+
req();
|
|
1977
|
+
});
|
|
1978
|
+
pi.on("ui_prompt_end", () => {
|
|
1979
|
+
const nextDepth = nextUiPromptDepth(uiPromptDepth, "end");
|
|
1980
|
+
if (nextDepth === uiPromptDepth) return;
|
|
1981
|
+
uiPromptDepth = nextDepth;
|
|
1982
|
+
req();
|
|
1983
|
+
});
|
|
1930
1984
|
|
|
1931
1985
|
// --- live elapsed for folded thinking rows ---
|
|
1932
1986
|
pi.on("message_start", (e) => {
|
|
@@ -1971,6 +2025,60 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1971
2025
|
const hasImages = (e.images?.length ?? 0) > 0;
|
|
1972
2026
|
const interactiveText = e.source === "interactive" && e.text.trim().length > 0;
|
|
1973
2027
|
const isSynthetic = e.text.startsWith("/") || e.text.startsWith("!");
|
|
2028
|
+
const hashWindowRoute = config.enabled && e.source === "interactive" && !isSynthetic
|
|
2029
|
+
? resolveWindowRouteInput(e.text, windowAutocompleteTargets(), selectedHashWindowTargets)
|
|
2030
|
+
: undefined;
|
|
2031
|
+
|
|
2032
|
+
if (hashWindowRoute) {
|
|
2033
|
+
if (hashWindowRoute.code !== "resolved") {
|
|
2034
|
+
ctx.ui.notify(tuiT(hashWindowRoute.code === "stale"
|
|
2035
|
+
? "notice.windowTargetUnavailable"
|
|
2036
|
+
: "notice.windowTargetAmbiguous"), "warning");
|
|
2037
|
+
ctx.ui.setEditorText(e.text);
|
|
2038
|
+
return { action: "handled" as const };
|
|
2039
|
+
}
|
|
2040
|
+
if (!hashWindowRoute.message) {
|
|
2041
|
+
ctx.ui.notify(tuiT("notice.windowMessageRequired"), "warning");
|
|
2042
|
+
ctx.ui.setEditorText(e.text);
|
|
2043
|
+
return { action: "handled" as const };
|
|
2044
|
+
}
|
|
2045
|
+
if (hashWindowRoute.target.current) {
|
|
2046
|
+
selectedHashWindowTargets.delete(hashWindowRoute.token);
|
|
2047
|
+
if (!firstUserText) firstUserText = hashWindowRoute.message;
|
|
2048
|
+
return { action: "transform" as const, text: hashWindowRoute.message };
|
|
2049
|
+
}
|
|
2050
|
+
if (hasImages) {
|
|
2051
|
+
ctx.ui.notify(tuiT("notice.imagePeer"), "warning");
|
|
2052
|
+
ctx.ui.setEditorText(e.text);
|
|
2053
|
+
return { action: "handled" as const };
|
|
2054
|
+
}
|
|
2055
|
+
if (!registry) {
|
|
2056
|
+
ctx.ui.notify(tuiT("notice.peerDeliveryUnavailable"), "warning");
|
|
2057
|
+
ctx.ui.setEditorText(e.text);
|
|
2058
|
+
return { action: "handled" as const };
|
|
2059
|
+
}
|
|
2060
|
+
const delivery = await (registry.send?.({
|
|
2061
|
+
selector: hashWindowRoute.target.routeSelector,
|
|
2062
|
+
message: hashWindowRoute.message,
|
|
2063
|
+
mode: e.streamingBehavior === "steer" ? "steer" : "follow_up",
|
|
2064
|
+
source: "user",
|
|
2065
|
+
}) ?? registry.router?.route({
|
|
2066
|
+
selector: hashWindowRoute.target.routeSelector,
|
|
2067
|
+
message: hashWindowRoute.message,
|
|
2068
|
+
mode: e.streamingBehavior === "steer" ? "steer" : "follow_up",
|
|
2069
|
+
source: "user",
|
|
2070
|
+
}));
|
|
2071
|
+
if (!delivery?.delivered) {
|
|
2072
|
+
ctx.ui.notify(tuiT("notice.peerMessageFailed", {
|
|
2073
|
+
label: hashWindowRoute.target.label,
|
|
2074
|
+
message: delivery?.error ?? tuiT("notice.deliveryRegistryUnavailable"),
|
|
2075
|
+
}), "error");
|
|
2076
|
+
ctx.ui.setEditorText(e.text);
|
|
2077
|
+
} else {
|
|
2078
|
+
selectedHashWindowTargets.delete(hashWindowRoute.token);
|
|
2079
|
+
}
|
|
2080
|
+
return { action: "handled" as const };
|
|
2081
|
+
}
|
|
1974
2082
|
|
|
1975
2083
|
if (e.source === "interactive" && !hasImages && e.text.trim() === "monitor") {
|
|
1976
2084
|
if (!registry?.requestWindowMode) {
|
|
@@ -1981,7 +2089,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1981
2089
|
return { action: "handled" as const };
|
|
1982
2090
|
}
|
|
1983
2091
|
|
|
1984
|
-
if (sessionUi.mode === "window" && (interactiveText || hasImages) && !isSynthetic) {
|
|
2092
|
+
if (config.enabled && sessionUi.mode === "window" && (interactiveText || hasImages) && !isSynthetic) {
|
|
1985
2093
|
const target = selectedWindowEndpoint();
|
|
1986
2094
|
if (!target) {
|
|
1987
2095
|
ctx.ui.notify(tuiT("notice.noMonitorWindow"), "warning");
|
|
@@ -2221,7 +2329,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2221
2329
|
: "";
|
|
2222
2330
|
const detail = [current, tuiStatus(endpoint.status), agentCount].filter(Boolean).join(" · ");
|
|
2223
2331
|
const sigil = mode === "window" ? "#" : "@";
|
|
2224
|
-
|
|
2332
|
+
const showOwner = mode === "window" || endpoint.kind === "root";
|
|
2333
|
+
const label = showOwner && endpoint.registryEndpoint
|
|
2334
|
+
? ownerDisplayToken(endpoint.label, endpoint.registryEndpoint.ownerId)
|
|
2335
|
+
: endpoint.label;
|
|
2336
|
+
return `${sigil}${label}${detail ? ` · ${detail}` : ""}`;
|
|
2225
2337
|
});
|
|
2226
2338
|
let previewAgent = false;
|
|
2227
2339
|
enterCapturingOverlay();
|
package/src/input-routing.ts
CHANGED
|
@@ -34,6 +34,10 @@ export interface AgentInputRegistries {
|
|
|
34
34
|
|
|
35
35
|
export type AgentInputRegistryProvider = MailboxHostRegistry | AgentInputRegistries;
|
|
36
36
|
|
|
37
|
+
export function isLegacyTodoOverlayInput(data: string): boolean {
|
|
38
|
+
return data === "\x1bT";
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
function inputRegistries(provider: AgentInputRegistryProvider | undefined): AgentInputRegistries {
|
|
38
42
|
if (provider && "deliverAgentMessage" in provider) return { mailbox: provider };
|
|
39
43
|
return provider ?? {};
|
package/src/maestro-store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { sanitizeExtensionStatusText } from "./extension-status.ts";
|
|
2
2
|
import {
|
|
3
3
|
MAESTRO_UI_SNAPSHOT_VERSION,
|
|
4
|
+
type MaestroArtifactV1,
|
|
4
5
|
type MaestroGoalV1,
|
|
5
6
|
type MaestroModeV1,
|
|
6
7
|
type MaestroSwarmBestV1,
|
|
@@ -87,6 +88,8 @@ function parseSnapshot(value: unknown): MaestroUiSnapshotV1 | undefined {
|
|
|
87
88
|
}
|
|
88
89
|
const swarm = value.swarm === null ? null : parseSwarm(value.swarm);
|
|
89
90
|
if (swarm === undefined) return undefined;
|
|
91
|
+
const artifact = value.artifact === undefined ? undefined : parseArtifact(value.artifact);
|
|
92
|
+
if (value.artifact !== undefined && artifact === undefined) return undefined;
|
|
90
93
|
const mode = parseMode(value.mode);
|
|
91
94
|
if (mode === undefined) return undefined;
|
|
92
95
|
if (value.currentGoalId !== undefined && !isOpaqueId(value.currentGoalId)) return undefined;
|
|
@@ -98,6 +101,7 @@ function parseSnapshot(value: unknown): MaestroUiSnapshotV1 | undefined {
|
|
|
98
101
|
goals,
|
|
99
102
|
...(value.currentGoalId === undefined ? {} : { currentGoalId: value.currentGoalId }),
|
|
100
103
|
swarm,
|
|
104
|
+
...(artifact === undefined ? {} : { artifact }),
|
|
101
105
|
mode,
|
|
102
106
|
};
|
|
103
107
|
}
|
|
@@ -239,6 +243,20 @@ function parseSwarmBest(value: unknown): MaestroSwarmBestV1 | undefined {
|
|
|
239
243
|
};
|
|
240
244
|
}
|
|
241
245
|
|
|
246
|
+
function parseArtifact(value: unknown): MaestroArtifactV1 | undefined {
|
|
247
|
+
if (!isRecord(value) || typeof value.available !== "boolean") return undefined;
|
|
248
|
+
if (value.planRevision !== undefined && !isNonNegativeInteger(value.planRevision)) return undefined;
|
|
249
|
+
const planStatus = value.planStatus === undefined ? undefined : displayString(value.planStatus);
|
|
250
|
+
if (value.planStatus !== undefined && planStatus === undefined) return undefined;
|
|
251
|
+
if (value.knowledgeSessionId !== undefined && !isOpaqueId(value.knowledgeSessionId)) return undefined;
|
|
252
|
+
return {
|
|
253
|
+
available: value.available,
|
|
254
|
+
...(value.planRevision === undefined ? {} : { planRevision: value.planRevision }),
|
|
255
|
+
...(planStatus === undefined ? {} : { planStatus }),
|
|
256
|
+
...(value.knowledgeSessionId === undefined ? {} : { knowledgeSessionId: value.knowledgeSessionId }),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
242
260
|
function parseMode(value: unknown): MaestroModeV1 | undefined {
|
|
243
261
|
if (typeof value === "string") return sanitizeExtensionStatusText(value);
|
|
244
262
|
if (!isRecord(value)) return undefined;
|
package/src/public/v1/events.ts
CHANGED
|
@@ -138,6 +138,14 @@ export interface MaestroSwarmV1 {
|
|
|
138
138
|
updatedAt: number;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** Session-bound documents exposed through `/artifact`. */
|
|
142
|
+
export interface MaestroArtifactV1 {
|
|
143
|
+
available: boolean;
|
|
144
|
+
planRevision?: number;
|
|
145
|
+
planStatus?: string;
|
|
146
|
+
knowledgeSessionId?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
141
149
|
/**
|
|
142
150
|
* The active Maestro projection. String values are deliberately open so adding
|
|
143
151
|
* a producer mode does not require a protocol revision.
|
|
@@ -164,6 +172,8 @@ export interface MaestroUiStateSnapshotV1 extends MaestroUiEnvelopeV1 {
|
|
|
164
172
|
goals: MaestroGoalV1[];
|
|
165
173
|
currentGoalId?: string;
|
|
166
174
|
swarm: MaestroSwarmV1 | null;
|
|
175
|
+
/** Optional for compatibility with older Maestro producers. */
|
|
176
|
+
artifact?: MaestroArtifactV1;
|
|
167
177
|
mode: MaestroModeV1;
|
|
168
178
|
}
|
|
169
179
|
|
package/src/render.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface RenderOpts {
|
|
|
63
63
|
* owner overrides this with the always-available `/cockpit todo`.
|
|
64
64
|
*/
|
|
65
65
|
toggleHint?: string;
|
|
66
|
+
/** How the user opens the full Todo overlay. Cockpit registers this shortcut itself. */
|
|
67
|
+
detailsHint?: string;
|
|
66
68
|
/**
|
|
67
69
|
* Compact agent mode prints its own roster count. When the widget already
|
|
68
70
|
* renders an "Agents N running" header, that would be the same sentence twice
|
|
@@ -80,6 +82,7 @@ export interface RenderOpts {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
export const DEFAULT_TOGGLE_HINT = altKey("T");
|
|
85
|
+
export const DEFAULT_TODO_DETAILS_HINT = altKey("Shift+T");
|
|
83
86
|
|
|
84
87
|
// One cell per task stops scaling once the plan outgrows the terminal, so beyond
|
|
85
88
|
// that the bar switches to a proportional summary instead of eating the whole line.
|
|
@@ -469,6 +472,7 @@ export function renderTodos(
|
|
|
469
472
|
const ell = theme.fg("dim", g.ellipsis);
|
|
470
473
|
const expanded = opts.expanded !== false;
|
|
471
474
|
const toggleHint = opts.toggleHint ?? DEFAULT_TOGGLE_HINT;
|
|
475
|
+
const detailsHint = opts.detailsHint ?? DEFAULT_TODO_DETAILS_HINT;
|
|
472
476
|
|
|
473
477
|
if (mode === "compact") {
|
|
474
478
|
const total = items.length;
|
|
@@ -522,6 +526,13 @@ export function renderTodos(
|
|
|
522
526
|
const ntext = `${g.arrow} ${theme.fg(np.glyphColor, np.glyph)}${nactor ? ` ${theme.fg("mdLink", nactor)}` : ""} ${todoSubject(np, next.subject, theme)}`;
|
|
523
527
|
summarySegs.push({ text: ntext, priority: 20, clippable: false });
|
|
524
528
|
}
|
|
529
|
+
if (expanded) {
|
|
530
|
+
summarySegs.push({
|
|
531
|
+
text: theme.fg("dim", tuiT("widget.todo.details", { shortcut: detailsHint })),
|
|
532
|
+
priority: 35,
|
|
533
|
+
clippable: false,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
525
536
|
summarySegs.push({
|
|
526
537
|
text: theme.fg("dim", `(${toggleHint} ${tuiT(expanded ? "widget.todo.collapse" : "widget.todo.expandAction")})`),
|
|
527
538
|
priority: 10,
|
package/src/stack-widget.ts
CHANGED
|
@@ -141,7 +141,7 @@ export function makeAgentWidget(deps: AgentWidgetDeps) {
|
|
|
141
141
|
// Focused-session priority: while a selected session's detail block is
|
|
142
142
|
// open, it owns the Agent height allowance and the roster collapses to
|
|
143
143
|
// this one-line summary. The per-agent rows stay reachable through the
|
|
144
|
-
// session
|
|
144
|
+
// session bar and its Alt+R selection flow, so monitoring is preserved
|
|
145
145
|
// while the session content gains the released rows.
|
|
146
146
|
if (deps.hasSessionDetail?.()) return [headerLine];
|
|
147
147
|
|
package/src/tui-i18n.ts
CHANGED
|
@@ -68,6 +68,8 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
68
68
|
"common.ruleBased": "(rule-based)",
|
|
69
69
|
"common.customRef": "custom ref…",
|
|
70
70
|
"session.listHint": `${altKey("R")} list`,
|
|
71
|
+
"session.agentListHint": `${altKey("R")} Agent`,
|
|
72
|
+
"artifact.hint": "/artifact",
|
|
71
73
|
"session.detailHint": `${altKey("R")} preview · ${altKey("E")} hide`,
|
|
72
74
|
"session.previewHint": "PgUp/PgDn scroll output",
|
|
73
75
|
"session.thinking": "thinking…",
|
|
@@ -76,6 +78,8 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
76
78
|
"window.empty": "Windows · no peer sessions",
|
|
77
79
|
"window.title": "Windows",
|
|
78
80
|
"window.context": "context {percent}%",
|
|
81
|
+
"window.autocompleteCurrent": "current window",
|
|
82
|
+
"window.autocompletePeer": "peer window",
|
|
79
83
|
"window.currentCoordination": "Current session · monitor coordination",
|
|
80
84
|
"window.noMessages": "No messages in this window thread.",
|
|
81
85
|
"window.messages": "Messages",
|
|
@@ -151,6 +155,7 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
151
155
|
"widget.agents.noun": "agents",
|
|
152
156
|
"widget.todo.title": "Todo",
|
|
153
157
|
"widget.todo.expand": "({shortcut} expand)",
|
|
158
|
+
"widget.todo.details": "{shortcut} full list",
|
|
154
159
|
"widget.todo.collapse": "collapse",
|
|
155
160
|
"widget.todo.expandAction": "expand",
|
|
156
161
|
"widget.agent.lastFailed": "last failed",
|
|
@@ -235,6 +240,7 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
235
240
|
"sidebar.unavailable": "Cockpit sidebar unavailable",
|
|
236
241
|
"sidebar.requiresTui": "Cockpit sidebar requires TUI mode",
|
|
237
242
|
"ambient.working": "working",
|
|
243
|
+
"ambient.waitingForInput": "WAITING FOR INPUT",
|
|
238
244
|
"ambient.failed": "{count} failed",
|
|
239
245
|
"ambient.agents": "{count} agents",
|
|
240
246
|
"legacy.label.enabled": "enabled",
|
|
@@ -280,6 +286,9 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
280
286
|
"notice.noMonitorWindow": "No monitor window is selected.",
|
|
281
287
|
"notice.monitorUnavailable": "Window monitor mode is unavailable in this session.",
|
|
282
288
|
"notice.imagePeer": "Image input cannot be routed to a peer window; reattach it in #control or after leaving monitor mode.",
|
|
289
|
+
"notice.windowMessageRequired": "Enter a message after the selected #window.",
|
|
290
|
+
"notice.windowTargetUnavailable": "The selected #window is no longer available; the message was not sent.",
|
|
291
|
+
"notice.windowTargetAmbiguous": "The selected #window is ambiguous; choose it again before sending.",
|
|
283
292
|
"notice.peerDeliveryUnavailable": "Peer window delivery is unavailable.",
|
|
284
293
|
"notice.peerMessageFailed": "Message to #{label} was not sent: {message}",
|
|
285
294
|
"notice.deliveryRegistryUnavailable": "delivery registry unavailable",
|
|
@@ -387,6 +396,8 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
387
396
|
"common.ruleBased": "(规则生成)",
|
|
388
397
|
"common.customRef": "自定义引用…",
|
|
389
398
|
"session.listHint": `${altKey("R")} 列表`,
|
|
399
|
+
"session.agentListHint": `${altKey("R")} Agent`,
|
|
400
|
+
"artifact.hint": "/artifact",
|
|
390
401
|
"session.detailHint": `${altKey("R")} 预览 · ${altKey("E")} 隐藏`,
|
|
391
402
|
"session.previewHint": "PgUp/PgDn 滚动输出",
|
|
392
403
|
"session.thinking": "思考中…",
|
|
@@ -395,6 +406,8 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
395
406
|
"window.empty": "窗口 · 没有对等会话",
|
|
396
407
|
"window.title": "窗口",
|
|
397
408
|
"window.context": "上下文 {percent}%",
|
|
409
|
+
"window.autocompleteCurrent": "当前窗口",
|
|
410
|
+
"window.autocompletePeer": "对等窗口",
|
|
398
411
|
"window.currentCoordination": "当前会话 · Monitor 协调",
|
|
399
412
|
"window.noMessages": "此窗口线程中没有消息。",
|
|
400
413
|
"window.messages": "消息",
|
|
@@ -470,6 +483,7 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
470
483
|
"widget.agents.noun": "Agent",
|
|
471
484
|
"widget.todo.title": "Todo",
|
|
472
485
|
"widget.todo.expand": "({shortcut} 展开)",
|
|
486
|
+
"widget.todo.details": "{shortcut} 详细列表",
|
|
473
487
|
"widget.todo.collapse": "收起",
|
|
474
488
|
"widget.todo.expandAction": "展开",
|
|
475
489
|
"widget.agent.lastFailed": "上次失败",
|
|
@@ -554,6 +568,7 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
554
568
|
"sidebar.unavailable": "Cockpit 侧边栏不可用",
|
|
555
569
|
"sidebar.requiresTui": "Cockpit 侧边栏需要 TUI 模式",
|
|
556
570
|
"ambient.working": "工作中",
|
|
571
|
+
"ambient.waitingForInput": "等待输入",
|
|
557
572
|
"ambient.failed": "{count} 项失败",
|
|
558
573
|
"ambient.agents": "{count} 个 Agent",
|
|
559
574
|
"legacy.label.enabled": "启用",
|
|
@@ -599,6 +614,9 @@ export const COCKPIT_TUI_CATALOGS: TranslationCatalogs = {
|
|
|
599
614
|
"notice.noMonitorWindow": "未选择 Monitor 窗口。",
|
|
600
615
|
"notice.monitorUnavailable": "当前会话不支持窗口监控模式。",
|
|
601
616
|
"notice.imagePeer": "无法将图片路由到对等窗口;请在 #control 中或离开 Monitor 模式后重新附加。",
|
|
617
|
+
"notice.windowMessageRequired": "请在选中的 #窗口 后输入消息。",
|
|
618
|
+
"notice.windowTargetUnavailable": "选中的 #窗口 已不可用;消息未发送。",
|
|
619
|
+
"notice.windowTargetAmbiguous": "选中的 #窗口 存在歧义;请重新选择后再发送。",
|
|
602
620
|
"notice.peerDeliveryUnavailable": "对等窗口消息发送不可用。",
|
|
603
621
|
"notice.peerMessageFailed": "发送到 #{label} 的消息失败:{message}",
|
|
604
622
|
"notice.deliveryRegistryUnavailable": "发送注册表不可用",
|
|
@@ -44,6 +44,18 @@ function markerOf(fn: ApplyLineResets): ViewportStabilityMarker | undefined {
|
|
|
44
44
|
return (fn as ApplyLineResets & Record<symbol, ViewportStabilityMarker | undefined>)[VIEWPORT_STABILITY_MARKER];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const ANSI_ESCAPE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
|
|
48
|
+
const TRAILING_DURATION = /(\s(?:·\s*)?)(?:\d+(?:\.\d+)?s|\d+m\d{1,2}s)$/;
|
|
49
|
+
|
|
50
|
+
function differsOnlyByTrailingDuration(previous: string, next: string): boolean {
|
|
51
|
+
const previousPlain = previous.replace(ANSI_ESCAPE, "");
|
|
52
|
+
const nextPlain = next.replace(ANSI_ESCAPE, "");
|
|
53
|
+
if (previousPlain === nextPlain) return false;
|
|
54
|
+
if (!TRAILING_DURATION.test(previousPlain) || !TRAILING_DURATION.test(nextPlain)) return false;
|
|
55
|
+
return previousPlain.replace(TRAILING_DURATION, "$1<duration>")
|
|
56
|
+
=== nextPlain.replace(TRAILING_DURATION, "$1<duration>");
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
function isKittyImageLine(line: string | undefined): boolean {
|
|
48
60
|
return typeof line === "string" && line.includes("\x1b_G");
|
|
49
61
|
}
|
|
@@ -98,12 +110,10 @@ function resolveApplyLineResetsSlot(internals: ViewportTuiInternals): ApplyLineR
|
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* cannot be updated in terminal scrollback and should only advance the diff
|
|
106
|
-
* baseline. Visible changes are then rendered normally from viewportTop.
|
|
113
|
+
* Prevents timer-only churn above the visible viewport from forcing a full
|
|
114
|
+
* redraw. The next frame keeps the terminal's actual hidden text, so the diff
|
|
115
|
+
* baseline remains truthful; status, content, layout and image changes retain
|
|
116
|
+
* pi-tui's native redraw path.
|
|
107
117
|
*/
|
|
108
118
|
export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
|
|
109
119
|
try {
|
|
@@ -120,24 +130,25 @@ export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
|
|
|
120
130
|
const nextLines = original.call(this, lines);
|
|
121
131
|
const previousLines = this.previousLines;
|
|
122
132
|
const viewportTop = this.previousViewportTop;
|
|
123
|
-
const previousHeight = this.previousHeight;
|
|
124
|
-
const currentHeight = this.terminal?.rows;
|
|
125
133
|
if (
|
|
126
134
|
Array.isArray(previousLines)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
&& typeof previousHeight === "number"
|
|
133
|
-
&& Number.isFinite(previousHeight)
|
|
134
|
-
&& previousHeight === currentHeight
|
|
135
|
+
&& previousLines.length === nextLines.length
|
|
136
|
+
&& typeof viewportTop === "number"
|
|
137
|
+
&& Number.isFinite(viewportTop)
|
|
138
|
+
&& viewportTop > 0
|
|
139
|
+
&& this.previousHeight === this.terminal?.rows
|
|
135
140
|
) {
|
|
136
141
|
const hiddenEnd = Math.min(previousLines.length, Math.trunc(viewportTop));
|
|
137
142
|
const hiddenHasKittyImage = previousLines.slice(0, hiddenEnd).some(isKittyImageLine)
|
|
138
143
|
|| nextLines.slice(0, hiddenEnd).some(isKittyImageLine);
|
|
139
144
|
if (!hiddenHasKittyImage) {
|
|
140
|
-
for (let index = 0; index < hiddenEnd; index
|
|
145
|
+
for (let index = 0; index < hiddenEnd; index += 1) {
|
|
146
|
+
const previous = previousLines[index];
|
|
147
|
+
const next = nextLines[index];
|
|
148
|
+
if (previous !== undefined && next !== undefined && differsOnlyByTrailingDuration(previous, next)) {
|
|
149
|
+
nextLines[index] = previous;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
141
152
|
}
|
|
142
153
|
}
|
|
143
154
|
return nextLines;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutocompleteItem,
|
|
3
|
+
AutocompleteProvider,
|
|
4
|
+
AutocompleteSuggestions,
|
|
5
|
+
} from "@earendil-works/pi-tui";
|
|
6
|
+
import type { CockpitEndpoint, EndpointStoreSnapshot } from "./endpoint-store.ts";
|
|
7
|
+
|
|
8
|
+
const OWNER_LABEL_LENGTH = 6;
|
|
9
|
+
const MAX_WINDOW_SUGGESTIONS = 20;
|
|
10
|
+
|
|
11
|
+
export interface WindowAutocompleteLabels {
|
|
12
|
+
current: string;
|
|
13
|
+
peer: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WindowAutocompleteTarget {
|
|
17
|
+
token: string;
|
|
18
|
+
label: string;
|
|
19
|
+
description: string;
|
|
20
|
+
routeSelector: string;
|
|
21
|
+
ownerId: string;
|
|
22
|
+
ownerNonce: string;
|
|
23
|
+
current: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WindowRouteResolution = {
|
|
27
|
+
code: "resolved";
|
|
28
|
+
token: string;
|
|
29
|
+
target: WindowAutocompleteTarget;
|
|
30
|
+
message: string;
|
|
31
|
+
} | {
|
|
32
|
+
code: "stale" | "ambiguous";
|
|
33
|
+
token: string;
|
|
34
|
+
message: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function ownerDisplayToken(label: string, ownerId: string): string {
|
|
38
|
+
const normalized = label
|
|
39
|
+
.replace(/[\s#]+/g, "-")
|
|
40
|
+
.replace(/^-+|-+$/g, "") || "window";
|
|
41
|
+
const suffix = `·${ownerId.slice(0, OWNER_LABEL_LENGTH)}`;
|
|
42
|
+
return normalized.endsWith(suffix) ? normalized : `${normalized}${suffix}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function targetFromEndpoint(
|
|
46
|
+
endpoint: CockpitEndpoint,
|
|
47
|
+
current: boolean,
|
|
48
|
+
labels: WindowAutocompleteLabels,
|
|
49
|
+
): WindowAutocompleteTarget | undefined {
|
|
50
|
+
const registryEndpoint = endpoint.registryEndpoint;
|
|
51
|
+
if (!registryEndpoint || registryEndpoint.kind !== "root") return undefined;
|
|
52
|
+
const ownerTarget = `owner:${registryEndpoint.ownerId}`;
|
|
53
|
+
const displayLabel = current ? "control" : endpoint.label;
|
|
54
|
+
return {
|
|
55
|
+
token: ownerDisplayToken(displayLabel, registryEndpoint.ownerId),
|
|
56
|
+
label: displayLabel,
|
|
57
|
+
description: [
|
|
58
|
+
current ? labels.current : labels.peer,
|
|
59
|
+
ownerTarget,
|
|
60
|
+
`incarnation:${registryEndpoint.ownerNonce}`,
|
|
61
|
+
registryEndpoint.sessionId ? `session:${registryEndpoint.sessionId}` : undefined,
|
|
62
|
+
].filter(Boolean).join(" · "),
|
|
63
|
+
routeSelector: endpoint.routeSelector,
|
|
64
|
+
ownerId: registryEndpoint.ownerId,
|
|
65
|
+
ownerNonce: registryEndpoint.ownerNonce,
|
|
66
|
+
current,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildWindowAutocompleteTargets(
|
|
71
|
+
snapshot: EndpointStoreSnapshot,
|
|
72
|
+
labels: WindowAutocompleteLabels,
|
|
73
|
+
): WindowAutocompleteTarget[] {
|
|
74
|
+
const currentRoot = snapshot.endpoints.find((endpoint) =>
|
|
75
|
+
endpoint.kind === "root" && endpoint.registryEndpoint?.scope === "local"
|
|
76
|
+
);
|
|
77
|
+
const targets: WindowAutocompleteTarget[] = [];
|
|
78
|
+
if (currentRoot) {
|
|
79
|
+
const current = targetFromEndpoint(currentRoot, true, labels);
|
|
80
|
+
if (current) targets.push(current);
|
|
81
|
+
}
|
|
82
|
+
for (const endpoint of snapshot.windows) {
|
|
83
|
+
if (endpoint.registryEndpoint?.scope !== "workspace-peer") continue;
|
|
84
|
+
const target = targetFromEndpoint(endpoint, false, labels);
|
|
85
|
+
if (target) targets.push(target);
|
|
86
|
+
}
|
|
87
|
+
return targets;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function extractWindowCompletionQuery(textBeforeCursor: string): string | undefined {
|
|
91
|
+
return /^[ \t]*#([^\s#]*)$/.exec(textBeforeCursor)?.[1];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function matchingTargets(
|
|
95
|
+
targets: readonly WindowAutocompleteTarget[],
|
|
96
|
+
query: string,
|
|
97
|
+
): WindowAutocompleteTarget[] {
|
|
98
|
+
const normalized = query.trim().toLocaleLowerCase("en");
|
|
99
|
+
if (!normalized) return targets.slice(0, MAX_WINDOW_SUGGESTIONS);
|
|
100
|
+
return targets.filter((target) => [
|
|
101
|
+
target.token,
|
|
102
|
+
target.label,
|
|
103
|
+
target.ownerId,
|
|
104
|
+
target.description,
|
|
105
|
+
].some((value) => value.toLocaleLowerCase("en").includes(normalized)))
|
|
106
|
+
.slice(0, MAX_WINDOW_SUGGESTIONS);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function completionItem(target: WindowAutocompleteTarget): AutocompleteItem {
|
|
110
|
+
return {
|
|
111
|
+
value: `#${target.token}`,
|
|
112
|
+
label: `#${target.token}`,
|
|
113
|
+
description: target.description,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createWindowAutocompleteProvider(
|
|
118
|
+
current: AutocompleteProvider,
|
|
119
|
+
getTargets: () => readonly WindowAutocompleteTarget[],
|
|
120
|
+
onSelect: (target: WindowAutocompleteTarget) => void,
|
|
121
|
+
): AutocompleteProvider {
|
|
122
|
+
const suggestedTargets = new Map<string, WindowAutocompleteTarget>();
|
|
123
|
+
const suggestionKey = (item: AutocompleteItem): string => `${item.value}\0${item.description ?? ""}`;
|
|
124
|
+
return {
|
|
125
|
+
triggerCharacters: [...new Set([...(current.triggerCharacters ?? []), "#"])],
|
|
126
|
+
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
|
|
127
|
+
if (cursorLine !== 0) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
128
|
+
const currentLine = lines[cursorLine] ?? "";
|
|
129
|
+
const query = extractWindowCompletionQuery(currentLine.slice(0, cursorCol));
|
|
130
|
+
if (query === undefined) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
131
|
+
const targets = matchingTargets(getTargets(), query);
|
|
132
|
+
if (targets.length === 0) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
133
|
+
const items = targets.map(completionItem);
|
|
134
|
+
for (let index = 0; index < items.length; index++) {
|
|
135
|
+
suggestedTargets.set(suggestionKey(items[index]!), targets[index]!);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
items,
|
|
139
|
+
prefix: `#${query}`,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
143
|
+
const selected = suggestedTargets.get(suggestionKey(item));
|
|
144
|
+
const currentLine = lines[cursorLine] ?? "";
|
|
145
|
+
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
|
|
146
|
+
if (!selected || !prefix.startsWith("#") || beforePrefix.trim()) {
|
|
147
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
148
|
+
}
|
|
149
|
+
onSelect(selected);
|
|
150
|
+
const afterCursor = currentLine.slice(cursorCol);
|
|
151
|
+
const separator = afterCursor.length === 0 || !/^\s/.test(afterCursor) ? " " : "";
|
|
152
|
+
const nextLine = `${beforePrefix}${item.value}${separator}${afterCursor}`;
|
|
153
|
+
const nextLines = [...lines];
|
|
154
|
+
nextLines[cursorLine] = nextLine;
|
|
155
|
+
return {
|
|
156
|
+
lines: nextLines,
|
|
157
|
+
cursorLine,
|
|
158
|
+
cursorCol: beforePrefix.length + item.value.length + separator.length,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
162
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveWindowRouteInput(
|
|
168
|
+
text: string,
|
|
169
|
+
targets: readonly WindowAutocompleteTarget[],
|
|
170
|
+
selectedTargets: ReadonlyMap<string, WindowAutocompleteTarget> = new Map(),
|
|
171
|
+
): WindowRouteResolution | undefined {
|
|
172
|
+
const match = /^[ \t]*#([^\s#]+)(?:\s+([\s\S]*))?$/.exec(text);
|
|
173
|
+
if (!match) return undefined;
|
|
174
|
+
const rawToken = match[1]!;
|
|
175
|
+
const token = rawToken.toLocaleLowerCase("en");
|
|
176
|
+
const message = (match[2] ?? "").trim();
|
|
177
|
+
const selected = selectedTargets.get(token);
|
|
178
|
+
if (selected) {
|
|
179
|
+
const live = targets.filter((candidate) =>
|
|
180
|
+
candidate.routeSelector === selected.routeSelector
|
|
181
|
+
&& candidate.ownerId === selected.ownerId
|
|
182
|
+
&& candidate.ownerNonce === selected.ownerNonce
|
|
183
|
+
);
|
|
184
|
+
return live.length === 1
|
|
185
|
+
? { code: "resolved", token, target: live[0]!, message }
|
|
186
|
+
: { code: "stale", token: rawToken, message };
|
|
187
|
+
}
|
|
188
|
+
const matches = targets.filter((candidate) => candidate.token.toLocaleLowerCase("en") === token);
|
|
189
|
+
if (matches.length > 1) return { code: "ambiguous", token: rawToken, message };
|
|
190
|
+
if (matches.length === 0) return undefined;
|
|
191
|
+
return { code: "resolved", token, target: matches[0]!, message };
|
|
192
|
+
}
|
package/src/window-bar.ts
CHANGED
|
@@ -3,18 +3,19 @@ import { truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import type { CockpitEndpoint } from "./endpoint-store.ts";
|
|
4
4
|
import { isMonitorControlEndpoint } from "./endpoint-store.ts";
|
|
5
5
|
import type { SessionUiState } from "./session-ui-state.ts";
|
|
6
|
-
import { assignedAgentColor, renderSessionBarLine } from "./agent-bar.ts";
|
|
6
|
+
import { assignedAgentColor, renderSessionBarLine, type SessionBarHintValue } from "./agent-bar.ts";
|
|
7
7
|
import { tuiT } from "./tui-i18n.ts";
|
|
8
8
|
import { formatUnreadCount, renderSessionTabLine, type SessionTab } from "./session-tabs.ts";
|
|
9
|
+
import { ownerDisplayToken } from "./window-autocomplete.ts";
|
|
9
10
|
|
|
10
11
|
export interface WindowBarDeps {
|
|
11
12
|
getWindows: () => readonly CockpitEndpoint[];
|
|
12
13
|
getState: () => SessionUiState;
|
|
13
|
-
getShortcutHint?: () =>
|
|
14
|
+
getShortcutHint?: () => SessionBarHintValue | undefined;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface WindowBarRenderOptions {
|
|
17
|
-
shortcutHint?:
|
|
18
|
+
shortcutHint?: SessionBarHintValue;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
interface WindowTab extends SessionTab {
|
|
@@ -36,7 +37,8 @@ function renderTab(tab: WindowTab, selected: boolean, theme: Theme): string {
|
|
|
36
37
|
? theme.fg("accent", "◆")
|
|
37
38
|
: theme.fg("muted", "○");
|
|
38
39
|
const unread = (tab.unread ?? 0) > 0 ? theme.fg("warning", ` ${formatUnreadCount(tab.unread ?? 0)}`) : "";
|
|
39
|
-
const
|
|
40
|
+
const ownerId = tab.endpoint.registryEndpoint?.ownerId;
|
|
41
|
+
const label = `#${ownerId ? ownerDisplayToken(tab.label, ownerId) : tab.label}`;
|
|
40
42
|
return selected
|
|
41
43
|
? `${marker} ${theme.fg(color, theme.bold(`▸ ${label}`))}${unread}`
|
|
42
44
|
: `${marker} ${theme.fg(color, label)}${unread}`;
|