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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.19.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.83.0",
71
- "@earendil-works/pi-ai": "0.83.0",
72
- "@earendil-works/pi-coding-agent": "0.83.0",
73
- "@earendil-works/pi-tui": "0.83.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.2.0",
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?: () => string | undefined;
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?: string;
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?: string,
242
+ shortcutHint?: SessionBarHintValue,
235
243
  ): string {
236
244
  const w = Math.max(1, width);
237
245
  if (!shortcutHint) return renderContent(w);
238
- const hint = theme.fg("dim", shortcutHint);
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 with a live
61
- * elapsed value, matching the compact folded-thinking label.
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.activeTool ?? (state.running ? tuiT("ambient.working") : undefined);
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
@@ -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: "control",
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: () => sessionListOverlayActive() ? undefined : tuiT("session.listHint"),
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: sessionListOverlayActive() ? undefined : tuiT("session.listHint") },
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
- return `${sigil}${endpoint.label}${detail ? ` · ${detail}` : ""}`;
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();
@@ -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 ?? {};
@@ -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;
@@ -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,
@@ -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-bar chips and the Alt+A overlay, so monitoring is preserved
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
- * Mirrors pi-tui's viewport-safe diff behavior until the host ships it.
102
- *
103
- * pi-tui 0.82/0.83 clears the screen and scrollback whenever the first changed
104
- * line is above the visible viewport. For stable-height content, those lines
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
- && Array.isArray(nextLines)
128
- && previousLines.length === nextLines.length
129
- && typeof viewportTop === "number"
130
- && Number.isFinite(viewportTop)
131
- && viewportTop > 0
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++) previousLines[index] = nextLines[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?: () => string | undefined;
14
+ getShortcutHint?: () => SessionBarHintValue | undefined;
14
15
  }
15
16
 
16
17
  export interface WindowBarRenderOptions {
17
- shortcutHint?: string;
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 label = `#${tab.label}`;
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}`;