pi-cockpit 0.19.0 → 0.21.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,85 +1,85 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.19.0",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-extension",
10
- "tui",
11
- "teammate",
12
- "todo"
13
- ],
14
- "license": "MIT",
15
- "files": [
16
- "src/",
17
- "themes/",
18
- "README.md"
19
- ],
20
- "scripts": {
21
- "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/teammate-message.test.ts tests/input-routing.test.ts tests/input-history-store.test.ts tests/unified-editor.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/zen-render.test.ts tests/zen-browse.test.ts tests/zen-sheet.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts tests/usage-core.test.ts tests/usage-extension.test.ts tests/usage-history.test.ts tests/usage-sparkline.test.ts",
22
- "typecheck": "tsc --noEmit",
23
- "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
- "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
25
- },
26
- "pi": {
27
- "extensions": [
28
- "./src/extension/index.ts"
29
- ],
30
- "themes": [
31
- "./themes"
32
- ]
33
- },
34
- "dependencies": {
35
- "pi-maestro-settings-core": "0.2.1",
36
- "proper-lockfile": "4.1.2"
37
- },
38
- "bundledDependencies": [
39
- "pi-maestro-settings-core"
40
- ],
41
- "peerDependencies": {
42
- "@earendil-works/pi-agent-core": "*",
43
- "@earendil-works/pi-ai": "*",
44
- "@earendil-works/pi-coding-agent": "*",
45
- "@earendil-works/pi-tui": "*",
46
- "pi-maestro-teammate": "^2.1.0",
47
- "typebox": "*"
48
- },
49
- "peerDependenciesMeta": {
50
- "@earendil-works/pi-agent-core": {
51
- "optional": true
52
- },
53
- "@earendil-works/pi-ai": {
54
- "optional": true
55
- },
56
- "@earendil-works/pi-coding-agent": {
57
- "optional": true
58
- },
59
- "@earendil-works/pi-tui": {
60
- "optional": true
61
- },
62
- "pi-maestro-teammate": {
63
- "optional": true
64
- },
65
- "typebox": {
66
- "optional": true
67
- }
68
- },
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",
74
- "@types/node": "^22.0.0",
75
- "pi-maestro-teammate": "2.2.0",
76
- "typebox": "1.3.7",
77
- "typescript": "^5.5.0"
78
- },
79
- "main": "./src/index.ts",
80
- "exports": {
81
- ".": "./src/index.ts",
82
- "./v1/events": "./src/public/v1/events.ts",
83
- "./src/*": "./src/*"
84
- }
85
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.21.0",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/teammate-message.test.ts tests/input-routing.test.ts tests/input-history-store.test.ts tests/unified-editor.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/zen-render.test.ts tests/zen-browse.test.ts tests/zen-sheet.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts tests/usage-core.test.ts tests/usage-extension.test.ts tests/usage-history.test.ts tests/usage-sparkline.test.ts",
22
+ "typecheck": "tsc --noEmit",
23
+ "prepack": "node ../../scripts/bundle-settings-core.mjs",
24
+ "postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./src/extension/index.ts"
29
+ ],
30
+ "themes": [
31
+ "./themes"
32
+ ]
33
+ },
34
+ "dependencies": {
35
+ "pi-maestro-settings-core": "0.2.1",
36
+ "proper-lockfile": "4.1.2"
37
+ },
38
+ "bundledDependencies": [
39
+ "pi-maestro-settings-core"
40
+ ],
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-agent-core": "*",
43
+ "@earendil-works/pi-ai": "*",
44
+ "@earendil-works/pi-coding-agent": "*",
45
+ "@earendil-works/pi-tui": "*",
46
+ "pi-maestro-teammate": "^2.4.0",
47
+ "typebox": "*"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@earendil-works/pi-agent-core": {
51
+ "optional": true
52
+ },
53
+ "@earendil-works/pi-ai": {
54
+ "optional": true
55
+ },
56
+ "@earendil-works/pi-coding-agent": {
57
+ "optional": true
58
+ },
59
+ "@earendil-works/pi-tui": {
60
+ "optional": true
61
+ },
62
+ "pi-maestro-teammate": {
63
+ "optional": true
64
+ },
65
+ "typebox": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "devDependencies": {
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
+ "@types/node": "^22.0.0",
75
+ "pi-maestro-teammate": "2.4.0",
76
+ "typebox": "1.3.7",
77
+ "typescript": "^5.5.0"
78
+ },
79
+ "main": "./src/index.ts",
80
+ "exports": {
81
+ ".": "./src/index.ts",
82
+ "./v1/events": "./src/public/v1/events.ts",
83
+ "./src/*": "./src/*"
84
+ }
85
+ }
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
package/src/config.ts CHANGED
@@ -76,6 +76,7 @@ const isCurrencyMode = (v: unknown): v is CurrencyMode => v === "usd" || v === "
76
76
  agentsMode: isMode(o.agentsMode) ? o.agentsMode : base.agentsMode,
77
77
  todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
78
78
  todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
79
+ todoDurationChart: typeof o.todoDurationChart === "boolean" ? o.todoDurationChart : base.todoDurationChart,
79
80
  stackStyle: isStackStyle(o.stackStyle) ? o.stackStyle : base.stackStyle,
80
81
  hideNativeAgents: typeof o.hideNativeAgents === "boolean" ? o.hideNativeAgents : base.hideNativeAgents,
81
82
  pinEditorBottom: typeof o.pinEditorBottom === "boolean" ? o.pinEditorBottom : base.pinEditorBottom,
@@ -168,6 +169,7 @@ export function mergeConfigDocument(raw: unknown, config: CockpitConfig): Record
168
169
  agentsMode: config.agentsMode,
169
170
  todoMode: config.todoMode,
170
171
  todoExpanded: config.todoExpanded,
172
+ todoDurationChart: config.todoDurationChart,
171
173
  stackStyle: config.stackStyle,
172
174
  hideNativeAgents: config.hideNativeAgents,
173
175
  pinEditorBottom: config.pinEditorBottom,
@@ -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";
@@ -350,6 +357,27 @@ export default function (pi: ExtensionAPI): void {
350
357
  // thinking label / teammate tree still stream above the visible viewport.
351
358
  let stabilityTui: TUI | undefined;
352
359
  let viewportStabilityPatch: ViewportStabilityPatch | undefined;
360
+ const ensureViewportStability = (tui: TUI): void => {
361
+ // Native fullscreen owns a fixed application viewport and has no main-screen
362
+ // applyLineResets hook. Do not cache that expected miss: the same dynamic TUI
363
+ // reference can later dispatch to the regular renderer.
364
+ if (tui.mode === "fullscreen") return;
365
+ if (stabilityTui === tui && viewportStabilityPatch?.active) return;
366
+ viewportStabilityPatch?.detach();
367
+ const patch = attachViewportStability(tui);
368
+ viewportStabilityPatch = patch;
369
+ stabilityTui = patch.active ? tui : undefined;
370
+ };
371
+ const requestCapturedRender = (): void => {
372
+ try {
373
+ const tui = capturedTui;
374
+ if (!tui) return;
375
+ ensureViewportStability(tui);
376
+ tui.requestRender();
377
+ } catch {
378
+ // tui may be gone between sessions
379
+ }
380
+ };
353
381
  // True while Cockpit owns a capturing overlay (bash jobs, theme picker,
354
382
  // settings panel). Split-pane resize must yield to the overlay's focus and
355
383
  // refuse to start while one is open; otherwise the resize listener — a
@@ -383,6 +411,8 @@ export default function (pi: ExtensionAPI): void {
383
411
  let sessionTheme: Theme | undefined;
384
412
  // Fullscreen (alternate-screen fixed editor) controller, reload-gated.
385
413
  let fullscreenController: FullscreenController | undefined;
414
+ /** Disposer for the legacy Alt+Shift+T compatibility hook (per applyUi). */
415
+ let todoOverlayShortcutDisposer: (() => void) | undefined;
386
416
  /** Disposer for the session ←/→ navigation hook (per applyUi). */
387
417
  let sessionBarNavDisposer: (() => void) | undefined;
388
418
  /** Disposer for the agent-list Shift+↑/↓ scroll hook (per applyUi). */
@@ -394,6 +424,7 @@ export default function (pi: ExtensionAPI): void {
394
424
  let surfaceState: CockpitSurfaceState = "disabled";
395
425
  let running = false;
396
426
  let runningStartedAt: number | undefined;
427
+ let uiPromptDepth = 0;
397
428
  // Auto-generated session title from the first turn (the rule-based / LLM
398
429
  // stand-in for Claude Code's Haiku title). Cleared per session.
399
430
  let aiTitle: string | undefined;
@@ -445,13 +476,7 @@ export default function (pi: ExtensionAPI): void {
445
476
  // while the model thinks, then the settled duration. Pi owns the fold.
446
477
  const thinkingTimer = new ThinkingFoldTimer({
447
478
  getTui: () => capturedTui,
448
- requestRender: () => {
449
- try {
450
- capturedTui?.requestRender();
451
- } catch {
452
- // tui may be gone between sessions
453
- }
454
- },
479
+ requestRender: requestCapturedRender,
455
480
  getBaseLabel: () => (config.quietMode ? quietThinkingLabel() : undefined),
456
481
  getGlyphs: () => resolveGlyphs(config.icons.mode),
457
482
  isThinkingHidden: () => (lastCtx ? readHideThinkingBlock(lastCtx.cwd) : false),
@@ -496,10 +521,11 @@ export default function (pi: ExtensionAPI): void {
496
521
  agents: agents.snapshot(),
497
522
  jobs: bashBg.snapshot(),
498
523
  running,
524
+ waitingForInput: uiPromptDepth > 0,
499
525
  cwd: title.showCwd ? formatCwd(ctx.sessionManager.getCwd()) : undefined,
500
526
  activeTool: activeTool?.name,
501
527
  workingStartedAt: activeTool?.startedAt ?? runningStartedAt,
502
- hideLiveDuration: config.staticMode,
528
+ hideLiveDuration: shouldHideWorkingDuration(capturedTui?.mode, config.staticMode),
503
529
  separator: ` ${g.separator} `,
504
530
  };
505
531
  ambientSurfaces.setWorkingMessage((message) => ctx.ui.setWorkingMessage(message), workingMessage(state, now));
@@ -512,7 +538,7 @@ export default function (pi: ExtensionAPI): void {
512
538
  state.maestroKnowledge = title.showMaestro
513
539
  ? maestroKnowledgeTag(maestro.snapshot()?.workflow?.knowledge)
514
540
  : undefined;
515
- state.frame = titleFrame(running);
541
+ state.frame = titleFrame(running && uiPromptDepth === 0);
516
542
  ambientSurfaces.setTitle(
517
543
  (value) => ctx.ui.setTitle(value),
518
544
  titleFor(state, { ok: g.check, fail: g.cross }, g.separator, { maxLength: title.maxLength }),
@@ -590,7 +616,7 @@ export default function (pi: ExtensionAPI): void {
590
616
  if (refreshAmbientSurface) refreshAmbient(now);
591
617
  if (agentPriorityChanged) publishUiOwnership();
592
618
  try {
593
- capturedTui?.requestRender();
619
+ requestCapturedRender();
594
620
  activeAgentOverlayRender?.();
595
621
  sidebarController?.requestRender();
596
622
  } catch {
@@ -680,6 +706,7 @@ export default function (pi: ExtensionAPI): void {
680
706
  sidebar: ownsDock,
681
707
  goal: ownsDock,
682
708
  todoExpanded: effectiveTodoExpanded(),
709
+ todoDurationChart: config.todoDurationChart,
683
710
  quiet: config.enabled && config.quietMode,
684
711
  quietSymbols: config.quietSymbols,
685
712
  static: config.staticMode,
@@ -736,13 +763,6 @@ export default function (pi: ExtensionAPI): void {
736
763
  };
737
764
 
738
765
 
739
- const ensureViewportStability = (tui: TUI): void => {
740
- if (stabilityTui === tui) return;
741
- viewportStabilityPatch?.detach();
742
- viewportStabilityPatch = attachViewportStability(tui);
743
- stabilityTui = tui;
744
- };
745
-
746
766
  const sessionListOverlayActive = (): boolean =>
747
767
  capturingOverlayActive || ambientKeysShouldYield(capturedTui);
748
768
 
@@ -883,6 +903,12 @@ export default function (pi: ExtensionAPI): void {
883
903
  };
884
904
  };
885
905
 
906
+ const selectedHashWindowTargets = new Map<string, WindowAutocompleteTarget>();
907
+ const windowAutocompleteTargets = () => buildWindowAutocompleteTargets(endpoints.snapshot(), {
908
+ current: tuiT("window.autocompleteCurrent"),
909
+ peer: tuiT("window.autocompletePeer"),
910
+ });
911
+
886
912
  const publishInputTarget = (force = false): void => {
887
913
  const target = sessionUi.mode === "window" ? selectedWindowInputTarget() : selectedAgentTarget();
888
914
  const sigil = target && "sigil" in target ? target.sigil : "@";
@@ -1082,6 +1108,13 @@ export default function (pi: ExtensionAPI): void {
1082
1108
  if (config.enabled) syncAgentWidgetPlacement(ctx, "belowEditor");
1083
1109
  };
1084
1110
 
1111
+ const sessionBarHint = () => {
1112
+ if (sessionListOverlayActive()) return undefined;
1113
+ return maestro.snapshot()?.artifact?.available
1114
+ ? { text: tuiT("artifact.hint"), color: "accent" as const }
1115
+ : tuiT(sessionUi.mode === "agent" ? "session.agentListHint" : "session.listHint");
1116
+ };
1117
+
1085
1118
  const installSessionBar = (ctx: ExtensionContext): void => {
1086
1119
  ctx.ui.setWidget(
1087
1120
  SESSION_BAR_WIDGET_KEY,
@@ -1093,7 +1126,7 @@ export default function (pi: ExtensionAPI): void {
1093
1126
  getState: () => sessionUi,
1094
1127
  getNow: () => nowSnapshot,
1095
1128
  isMainRunning: () => running,
1096
- getShortcutHint: () => sessionListOverlayActive() ? undefined : tuiT("session.listHint"),
1129
+ getShortcutHint: sessionBarHint,
1097
1130
  })(tui, theme);
1098
1131
  return {
1099
1132
  render(width: number): string[] {
@@ -1104,7 +1137,7 @@ export default function (pi: ExtensionAPI): void {
1104
1137
  sessionUi,
1105
1138
  width,
1106
1139
  theme,
1107
- { shortcutHint: sessionListOverlayActive() ? undefined : tuiT("session.listHint") },
1140
+ { shortcutHint: sessionBarHint() },
1108
1141
  )
1109
1142
  : agentWidget.render(width);
1110
1143
  },
@@ -1329,6 +1362,8 @@ export default function (pi: ExtensionAPI): void {
1329
1362
  clearWidgets(ctx);
1330
1363
  ctx.ui.setWidget(SESSION_BAR_WIDGET_KEY, undefined);
1331
1364
  ctx.ui.setWidget(SESSION_DETAIL_WIDGET_KEY, undefined);
1365
+ todoOverlayShortcutDisposer?.();
1366
+ todoOverlayShortcutDisposer = undefined;
1332
1367
  sessionBarNavDisposer?.();
1333
1368
  sessionBarNavDisposer = undefined;
1334
1369
  agentScrollDisposer?.();
@@ -1495,6 +1530,16 @@ export default function (pi: ExtensionAPI): void {
1495
1530
  // window mode swaps in the window bar. Installed on every surface (dock
1496
1531
  // and widgets), inserted last so it sits closest to the editor.
1497
1532
  installSessionBar(ctx);
1533
+ // Legacy terminals encode Alt+Shift+T as ESC + uppercase T, which the
1534
+ // host shortcut matcher cannot distinguish without enhanced key reporting.
1535
+ todoOverlayShortcutDisposer?.();
1536
+ todoOverlayShortcutDisposer = ctx.ui.onTerminalInput((data) => {
1537
+ if (!isLegacyTodoOverlayInput(data) || ambientKeysShouldYield(capturedTui)) return undefined;
1538
+ void openTodoOverlay(ctx).catch((error) => {
1539
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
1540
+ });
1541
+ return { consume: true };
1542
+ });
1498
1543
  // ←/→ cycles tabs only with an empty composer. Window mode additionally
1499
1544
  // accepts Alt+←/→ so drafts can be switched without losing cursor arrows.
1500
1545
  // A capturing modal overlay owns navigation while it is open.
@@ -1766,6 +1811,7 @@ export default function (pi: ExtensionAPI): void {
1766
1811
  // --- session + agent lifecycle ---
1767
1812
  pi.on("session_start", (_e, ctx) => {
1768
1813
  lastCtx = ctx;
1814
+ uiPromptDepth = 0;
1769
1815
  ambientSurfaces.reset();
1770
1816
  agentReads.bindSession(ctx.sessionManager.getSessionId());
1771
1817
  agents = agentReads.current;
@@ -1804,6 +1850,12 @@ export default function (pi: ExtensionAPI): void {
1804
1850
  });
1805
1851
  settingsLocale.reload();
1806
1852
  settingsRegistry.emitLocale(settingsLocale.locale);
1853
+ selectedHashWindowTargets.clear();
1854
+ ctx.ui.addAutocompleteProvider((current) => createWindowAutocompleteProvider(
1855
+ current,
1856
+ () => config.enabled ? windowAutocompleteTargets() : [],
1857
+ (target) => selectedHashWindowTargets.set(target.token.toLocaleLowerCase("en"), target),
1858
+ ));
1807
1859
  invalidateUsageCache();
1808
1860
  // Quiet mode: register compact tool renderers and fold thinking blocks.
1809
1861
  // Tools are normally registered at extension load time (above) so they
@@ -1873,6 +1925,7 @@ export default function (pi: ExtensionAPI): void {
1873
1925
  sidebar: false,
1874
1926
  goal: false,
1875
1927
  todoExpanded: config.todoExpanded,
1928
+ todoDurationChart: config.todoDurationChart,
1876
1929
  quiet: false,
1877
1930
  quietSymbols: config.quietSymbols,
1878
1931
  static: config.staticMode,
@@ -1898,6 +1951,7 @@ export default function (pi: ExtensionAPI): void {
1898
1951
  lastCtx = undefined;
1899
1952
  running = false;
1900
1953
  runningStartedAt = undefined;
1954
+ uiPromptDepth = 0;
1901
1955
  activeTools.clear();
1902
1956
  thinkingTimer.reset();
1903
1957
  invalidateUsageCache();
@@ -1927,6 +1981,16 @@ export default function (pi: ExtensionAPI): void {
1927
1981
  syncTick();
1928
1982
  req();
1929
1983
  });
1984
+ pi.on("ui_prompt_start", () => {
1985
+ uiPromptDepth = nextUiPromptDepth(uiPromptDepth, "start");
1986
+ req();
1987
+ });
1988
+ pi.on("ui_prompt_end", () => {
1989
+ const nextDepth = nextUiPromptDepth(uiPromptDepth, "end");
1990
+ if (nextDepth === uiPromptDepth) return;
1991
+ uiPromptDepth = nextDepth;
1992
+ req();
1993
+ });
1930
1994
 
1931
1995
  // --- live elapsed for folded thinking rows ---
1932
1996
  pi.on("message_start", (e) => {
@@ -1971,6 +2035,60 @@ export default function (pi: ExtensionAPI): void {
1971
2035
  const hasImages = (e.images?.length ?? 0) > 0;
1972
2036
  const interactiveText = e.source === "interactive" && e.text.trim().length > 0;
1973
2037
  const isSynthetic = e.text.startsWith("/") || e.text.startsWith("!");
2038
+ const hashWindowRoute = config.enabled && e.source === "interactive" && !isSynthetic
2039
+ ? resolveWindowRouteInput(e.text, windowAutocompleteTargets(), selectedHashWindowTargets)
2040
+ : undefined;
2041
+
2042
+ if (hashWindowRoute) {
2043
+ if (hashWindowRoute.code !== "resolved") {
2044
+ ctx.ui.notify(tuiT(hashWindowRoute.code === "stale"
2045
+ ? "notice.windowTargetUnavailable"
2046
+ : "notice.windowTargetAmbiguous"), "warning");
2047
+ ctx.ui.setEditorText(e.text);
2048
+ return { action: "handled" as const };
2049
+ }
2050
+ if (!hashWindowRoute.message) {
2051
+ ctx.ui.notify(tuiT("notice.windowMessageRequired"), "warning");
2052
+ ctx.ui.setEditorText(e.text);
2053
+ return { action: "handled" as const };
2054
+ }
2055
+ if (hashWindowRoute.target.current) {
2056
+ selectedHashWindowTargets.delete(hashWindowRoute.token);
2057
+ if (!firstUserText) firstUserText = hashWindowRoute.message;
2058
+ return { action: "transform" as const, text: hashWindowRoute.message };
2059
+ }
2060
+ if (hasImages) {
2061
+ ctx.ui.notify(tuiT("notice.imagePeer"), "warning");
2062
+ ctx.ui.setEditorText(e.text);
2063
+ return { action: "handled" as const };
2064
+ }
2065
+ if (!registry) {
2066
+ ctx.ui.notify(tuiT("notice.peerDeliveryUnavailable"), "warning");
2067
+ ctx.ui.setEditorText(e.text);
2068
+ return { action: "handled" as const };
2069
+ }
2070
+ const delivery = await (registry.send?.({
2071
+ selector: hashWindowRoute.target.routeSelector,
2072
+ message: hashWindowRoute.message,
2073
+ mode: e.streamingBehavior === "steer" ? "steer" : "follow_up",
2074
+ source: "user",
2075
+ }) ?? registry.router?.route({
2076
+ selector: hashWindowRoute.target.routeSelector,
2077
+ message: hashWindowRoute.message,
2078
+ mode: e.streamingBehavior === "steer" ? "steer" : "follow_up",
2079
+ source: "user",
2080
+ }));
2081
+ if (!delivery?.delivered) {
2082
+ ctx.ui.notify(tuiT("notice.peerMessageFailed", {
2083
+ label: hashWindowRoute.target.label,
2084
+ message: delivery?.error ?? tuiT("notice.deliveryRegistryUnavailable"),
2085
+ }), "error");
2086
+ ctx.ui.setEditorText(e.text);
2087
+ } else {
2088
+ selectedHashWindowTargets.delete(hashWindowRoute.token);
2089
+ }
2090
+ return { action: "handled" as const };
2091
+ }
1974
2092
 
1975
2093
  if (e.source === "interactive" && !hasImages && e.text.trim() === "monitor") {
1976
2094
  if (!registry?.requestWindowMode) {
@@ -1981,7 +2099,7 @@ export default function (pi: ExtensionAPI): void {
1981
2099
  return { action: "handled" as const };
1982
2100
  }
1983
2101
 
1984
- if (sessionUi.mode === "window" && (interactiveText || hasImages) && !isSynthetic) {
2102
+ if (config.enabled && sessionUi.mode === "window" && (interactiveText || hasImages) && !isSynthetic) {
1985
2103
  const target = selectedWindowEndpoint();
1986
2104
  if (!target) {
1987
2105
  ctx.ui.notify(tuiT("notice.noMonitorWindow"), "warning");
@@ -2221,7 +2339,11 @@ export default function (pi: ExtensionAPI): void {
2221
2339
  : "";
2222
2340
  const detail = [current, tuiStatus(endpoint.status), agentCount].filter(Boolean).join(" · ");
2223
2341
  const sigil = mode === "window" ? "#" : "@";
2224
- return `${sigil}${endpoint.label}${detail ? ` · ${detail}` : ""}`;
2342
+ const showOwner = mode === "window" || endpoint.kind === "root";
2343
+ const label = showOwner && endpoint.registryEndpoint
2344
+ ? ownerDisplayToken(endpoint.label, endpoint.registryEndpoint.ownerId)
2345
+ : endpoint.label;
2346
+ return `${sigil}${label}${detail ? ` · ${detail}` : ""}`;
2225
2347
  });
2226
2348
  let previewAgent = false;
2227
2349
  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
 
@@ -185,6 +195,8 @@ export interface CockpitUiOwnershipV1 {
185
195
  /** True only while the dock renders Maestro Goal state. */
186
196
  goal: boolean;
187
197
  todoExpanded: boolean;
198
+ /** Whether Flow should render the duration chart after a terminal todo advance. */
199
+ todoDurationChart?: boolean;
188
200
  quiet: boolean;
189
201
  quietSymbols: "check" | "dot";
190
202
  /** Static mode: per-second churn (elapsed ticks, spinners) is suppressed. */
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,
@@ -68,6 +68,7 @@ const CONFIG_KEYS = [
68
68
  "agentsMode",
69
69
  "todoMode",
70
70
  "todoExpanded",
71
+ "todoDurationChart",
71
72
  "stackStyle",
72
73
  "hideNativeAgents",
73
74
  "toolPalette",
@@ -164,6 +165,8 @@ const CATALOGS = {
164
165
  "cockpit.agentsMode": "Agent display",
165
166
  "cockpit.todoMode": "Todo display",
166
167
  "cockpit.todoExpanded": "Todo expanded",
168
+ "cockpit.todoDurationChart": "Advance duration chart",
169
+ "cockpit.todoDurationChart.description": "Show a task-duration chart after advance completes the entire Todo list.",
167
170
  "cockpit.stackStyle": "Stack projection",
168
171
  "cockpit.hideNativeAgents": "Hide native agent widget",
169
172
  "cockpit.icons.mode": "Icon mode",
@@ -249,6 +252,8 @@ const CATALOGS = {
249
252
  "cockpit.agentsMode": "Agent 显示",
250
253
  "cockpit.todoMode": "Todo 显示",
251
254
  "cockpit.todoExpanded": "展开 Todo",
255
+ "cockpit.todoDurationChart": "Advance 耗时图",
256
+ "cockpit.todoDurationChart.description": "当 advance 完成整个 Todo 列表后显示任务耗时统计图。",
252
257
  "cockpit.stackStyle": "栈投影",
253
258
  "cockpit.hideNativeAgents": "隐藏原生 Agent 面板",
254
259
  "cockpit.icons.mode": "图标模式",
@@ -367,8 +372,9 @@ const DEFINITIONS: readonly SettingDefinition[] = [
367
372
  enumDefinition("agentsMode", "cockpit.group.panels", 0, "cockpit.agentsMode", ["list", "compact"], "live"),
368
373
  enumDefinition("todoMode", "cockpit.group.panels", 1, "cockpit.todoMode", ["list", "compact"], "live"),
369
374
  booleanDefinition("todoExpanded", "cockpit.group.panels", 2, "cockpit.todoExpanded", "live"),
375
+ booleanDefinition("todoDurationChart", "cockpit.group.panels", 3, "cockpit.todoDurationChart", "live", "cockpit.todoDurationChart.description"),
370
376
  enumDefinition("stackStyle", "cockpit.group.layout", 4, "cockpit.stackStyle", ["classic", "zen"], "live"),
371
- booleanDefinition("hideNativeAgents", "cockpit.group.panels", 3, "cockpit.hideNativeAgents", "live"),
377
+ booleanDefinition("hideNativeAgents", "cockpit.group.panels", 4, "cockpit.hideNativeAgents", "live"),
372
378
  enumDefinition("sidebar.mode", "cockpit.group.sidebar", 0, "cockpit.sidebar.mode", ["auto", "on", "off"], "live"),
373
379
  {
374
380
  key: "sidebar.width",
@@ -789,7 +795,7 @@ function setUsageValue(usage: CockpitConfig["usage"], field: string, value: Json
789
795
  }
790
796
 
791
797
  function validValue(key: CockpitSettingKey, value: JsonValue): boolean {
792
- if (["enabled", "staticMode", "pinEditorBottom", "doubleEscapeClearInput", "fullscreenInput", "copyOnSelect", "historyEnabled", "quietMode", "todoExpanded", "hideNativeAgents"].includes(key)) return typeof value === "boolean";
798
+ if (["enabled", "staticMode", "pinEditorBottom", "doubleEscapeClearInput", "fullscreenInput", "copyOnSelect", "historyEnabled", "quietMode", "todoExpanded", "todoDurationChart", "hideNativeAgents"].includes(key)) return typeof value === "boolean";
793
799
  if (key === "sidebar.width") return typeof value === "number" && Number.isSafeInteger(value) && value >= 32 && value <= 56;
794
800
  if (key === "title.maxLength") return typeof value === "number" && Number.isSafeInteger(value) && value >= 20 && value <= 200;
795
801
  if (key === "title.generationModel") return typeof value === "string";
@@ -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": "发送注册表不可用",
package/src/types.ts CHANGED
@@ -260,6 +260,8 @@ export interface CockpitConfig {
260
260
  agentsMode: ViewMode;
261
261
  todoMode: ViewMode;
262
262
  todoExpanded: boolean;
263
+ /** Show the duration chart after a terminal todo advance. */
264
+ todoDurationChart: boolean;
263
265
  /** Above-editor stack projection: classic Todo/Agents widgets or the unified Zen stack. */
264
266
  stackStyle: StackStyle;
265
267
  hideNativeAgents: boolean;
@@ -318,6 +320,7 @@ export const DEFAULT_CONFIG: CockpitConfig = {
318
320
  agentsMode: "list",
319
321
  todoMode: "list",
320
322
  todoExpanded: false,
323
+ todoDurationChart: true,
321
324
  stackStyle: "classic",
322
325
  hideNativeAgents: true,
323
326
  pinEditorBottom: false,
@@ -10,7 +10,8 @@ interface ViewportTuiInternals {
10
10
  previousLines?: string[];
11
11
  previousViewportTop?: number;
12
12
  previousHeight?: number;
13
- terminal?: { rows: number };
13
+ previousWidth?: number;
14
+ terminal?: { columns: number; rows: number };
14
15
  }
15
16
 
16
17
  interface ViewportStabilityMarker {
@@ -98,15 +99,17 @@ function resolveApplyLineResetsSlot(internals: ViewportTuiInternals): ApplyLineR
98
99
  }
99
100
 
100
101
  /**
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.
102
+ * Treats the hidden viewport prefix as immutable scrollback while frame size is
103
+ * stable. The next frame keeps the terminal's actual hidden text, so visible
104
+ * changes can use pi-tui's differential path instead of clearing and replaying
105
+ * the screen. Structural, resize and image changes retain the native path.
107
106
  */
108
107
  export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
109
108
  try {
109
+ // Native fullscreen has an application-owned fixed viewport. Its inherited
110
+ // applyLineResets method is not part of that renderer's diff path, so patching
111
+ // it would report a false-positive attachment on the shared TUI base class.
112
+ if (tui.mode === "fullscreen") return { active: false, detach() {} };
110
113
  const internals = tui as unknown as ViewportTuiInternals;
111
114
  const slot = resolveApplyLineResetsSlot(internals);
112
115
  if (!slot) return { active: false, detach() {} };
@@ -120,24 +123,23 @@ export function attachViewportStability(tui: TUI): ViewportStabilityPatch {
120
123
  const nextLines = original.call(this, lines);
121
124
  const previousLines = this.previousLines;
122
125
  const viewportTop = this.previousViewportTop;
123
- const previousHeight = this.previousHeight;
124
- const currentHeight = this.terminal?.rows;
125
126
  if (
126
127
  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
128
+ && previousLines.length === nextLines.length
129
+ && typeof viewportTop === "number"
130
+ && Number.isFinite(viewportTop)
131
+ && viewportTop > 0
132
+ && this.previousHeight === this.terminal?.rows
133
+ && this.previousWidth === this.terminal?.columns
135
134
  ) {
136
135
  const hiddenEnd = Math.min(previousLines.length, Math.trunc(viewportTop));
137
136
  const hiddenHasKittyImage = previousLines.slice(0, hiddenEnd).some(isKittyImageLine)
138
137
  || nextLines.slice(0, hiddenEnd).some(isKittyImageLine);
139
138
  if (!hiddenHasKittyImage) {
140
- for (let index = 0; index < hiddenEnd; index++) previousLines[index] = nextLines[index];
139
+ for (let index = 0; index < hiddenEnd; index += 1) {
140
+ const previous = previousLines[index];
141
+ if (previous !== undefined) nextLines[index] = previous;
142
+ }
141
143
  }
142
144
  }
143
145
  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}`;