@oh-my-pi/pi-tui 18.3.5 → 18.4.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +9 -2
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/rewind-selector.d.ts +3 -3
  7. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  8. package/dist/types/prompt/composer-cache.d.ts +34 -16
  9. package/dist/types/prompt/composer.d.ts +15 -18
  10. package/dist/types/prompt/welcome.d.ts +5 -2
  11. package/dist/types/status-line/component.d.ts +13 -4
  12. package/dist/types/status-line/metrics.d.ts +0 -1
  13. package/dist/types/status-line/startup.d.ts +38 -0
  14. package/dist/types/status-line/types.d.ts +0 -2
  15. package/dist/types/terminal-capabilities.d.ts +10 -6
  16. package/dist/types/tools/output-meta.d.ts +2 -1
  17. package/dist/types/tools/vibe.d.ts +4 -0
  18. package/package.json +9 -9
  19. package/src/chat/assistant-message.ts +27 -21
  20. package/src/chat/image-loading.ts +81 -0
  21. package/src/chat/tool-execution.ts +38 -14
  22. package/src/chat/transcript-entry.ts +16 -0
  23. package/src/chrome/transcript-container.ts +208 -74
  24. package/src/components/image.ts +10 -2
  25. package/src/overlays/agents-hub.ts +7 -18
  26. package/src/overlays/copy-selector.ts +3 -31
  27. package/src/overlays/model-hub.ts +49 -16
  28. package/src/overlays/rewind-selector.ts +51 -12
  29. package/src/overlays/usage-dashboard.ts +47 -6
  30. package/src/prompt/composer-cache.ts +217 -196
  31. package/src/prompt/composer.ts +40 -70
  32. package/src/prompt/welcome.ts +31 -28
  33. package/src/render/render-utils.ts +43 -22
  34. package/src/status-line/component.ts +73 -50
  35. package/src/status-line/metrics.ts +3 -21
  36. package/src/status-line/segments.ts +34 -50
  37. package/src/status-line/startup.ts +181 -0
  38. package/src/status-line/types.ts +0 -2
  39. package/src/terminal-capabilities.ts +12 -6
  40. package/src/terminal.ts +38 -9
  41. package/src/tools/output-meta.ts +11 -3
  42. package/src/tools/vibe.ts +15 -2
  43. package/src/tui.ts +66 -42
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Status line for the speculative first frame, before any session exists.
3
+ *
4
+ * InteractiveMode persists {@link StatusLineStartupData} (settings plus the last
5
+ * session's model and thinking state); the next launch renders it through the
6
+ * real {@link StatusLineComponent} as a fresh, unnamed session at the live
7
+ * terminal width. Path and git branch are read live; context usage is reported
8
+ * unknown so the gauge shows the window without a percent.
9
+ *
10
+ * @example
11
+ * ```ts ignore
12
+ * const statusLine = createStartupStatusLine(cached.statusLine);
13
+ * statusLine.attachToEditor(editor, getComposerStyle(shape));
14
+ * ```
15
+ */
16
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
+ import type { Model } from "@oh-my-pi/pi-catalog/types";
18
+ import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
19
+ import { parseThinkingLevel } from "../thinking";
20
+ import { StatusLineComponent } from "./component";
21
+ import type { CompactionBoundaries } from "./context-usage";
22
+ import type { StatusLineHost, StatusLineSession } from "./host";
23
+ import {
24
+ CONTEXT_LINE_MODE_VALUES,
25
+ STATUS_LINE_PRESET_VALUES,
26
+ STATUS_LINE_SEGMENT_IDS,
27
+ STATUS_LINE_SEPARATOR_VALUES,
28
+ } from "./schema";
29
+ import type { StatusLineSettings } from "./types";
30
+
31
+ /** Session-independent inputs that reproduce a fresh session's status bar on the next launch. */
32
+ export interface StatusLineStartupData {
33
+ readonly settings: StatusLineSettings;
34
+ readonly gitEnabled: boolean;
35
+ /** Model of the last session; the next fresh session usually starts on it too. */
36
+ readonly model?: Model;
37
+ readonly thinkingLevel?: ThinkingLevel;
38
+ readonly autoThinking: boolean;
39
+ readonly fastMode: boolean;
40
+ /** Whether `model` bills through a subscription (drives the cost segment's prefix). */
41
+ readonly usingSubscription: boolean;
42
+ readonly autoCompactEnabled: boolean;
43
+ readonly compactionBoundaries: CompactionBoundaries | null;
44
+ }
45
+
46
+ const NO_MESSAGES: readonly never[] = [];
47
+ const NO_USAGE = {
48
+ input: 0,
49
+ output: 0,
50
+ cacheRead: 0,
51
+ cacheWrite: 0,
52
+ totalTokens: 0,
53
+ orchestrationInput: 0,
54
+ orchestrationOutput: 0,
55
+ orchestrationCacheRead: 0,
56
+ premiumRequests: 0,
57
+ cost: 0,
58
+ };
59
+
60
+ /** Build the prepaint status line; the caller disposes it once the session-bound bar mounts. */
61
+ export function createStartupStatusLine(data: StatusLineStartupData): StatusLineComponent {
62
+ const session: StatusLineSession = {
63
+ state: { model: data.model, thinkingLevel: data.thinkingLevel, messages: NO_MESSAGES },
64
+ model: data.model,
65
+ messages: NO_MESSAGES,
66
+ isStreaming: false,
67
+ isAutoThinking: data.autoThinking,
68
+ sessionManager: {
69
+ getSessionName: () => undefined,
70
+ getSessionId: () => "",
71
+ getUsageStatistics: () => NO_USAGE,
72
+ },
73
+ modelRegistry: { isUsingOAuth: () => data.usingSubscription },
74
+ getContextUsage: () =>
75
+ data.model?.contextWindow ? { tokens: 0, contextWindow: data.model.contextWindow, percent: null } : undefined,
76
+ autoResolvedThinkingLevel: () => undefined,
77
+ isFastModeActive: () => data.fastMode,
78
+ getAsyncJobSnapshot: () => null,
79
+ getGoalModeState: () => undefined,
80
+ };
81
+ const host: StatusLineHost = {
82
+ getSettings: () => data.settings,
83
+ gitEnabled: () => data.gitEnabled,
84
+ codexResetFireworksEnabled: () => false,
85
+ getSettingsRevision: () => 0,
86
+ getSessionSettingsIdentity: () => undefined,
87
+ getSessionSettingsRevision: () => 0,
88
+ goalStatusInFooter: () => false,
89
+ activeAccount: () => undefined,
90
+ canFetchUsageReports: () => false,
91
+ fetchUsageReports: async () => null,
92
+ resolveActiveRepo: () => null,
93
+ lookupPullRequest: async () => ({ stdout: "", exitCode: 1 }),
94
+ calculateTokensPerSecond: () => null,
95
+ limitMatchesActiveAccount: () => false,
96
+ computeCompactionBoundaries: () => data.compactionBoundaries,
97
+ };
98
+ const statusLine = new StatusLineComponent(session, host);
99
+ statusLine.setAutoCompactEnabled(data.autoCompactEnabled);
100
+ return statusLine;
101
+ }
102
+
103
+ function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T {
104
+ return typeof value === "string" && values.some(candidate => candidate === value);
105
+ }
106
+
107
+ function isOptionalBoolean(value: unknown): boolean {
108
+ return value === undefined || typeof value === "boolean";
109
+ }
110
+
111
+ function isSegmentList(value: unknown): boolean {
112
+ return value === undefined || (Array.isArray(value) && value.every(id => isOneOf(STATUS_LINE_SEGMENT_IDS, id)));
113
+ }
114
+
115
+ /** Shallow check of persisted settings; nested segment options are trusted as written by this version. */
116
+ function isStatusLineSettings(value: unknown): value is StatusLineSettings {
117
+ return (
118
+ isRecord(value) &&
119
+ (value.preset === undefined || isOneOf(STATUS_LINE_PRESET_VALUES, value.preset)) &&
120
+ isSegmentList(value.leftSegments) &&
121
+ isSegmentList(value.rightSegments) &&
122
+ (value.separator === undefined || isOneOf(STATUS_LINE_SEPARATOR_VALUES, value.separator)) &&
123
+ (value.segmentOptions === undefined || isRecord(value.segmentOptions)) &&
124
+ (value.contextLine === undefined || isOneOf(CONTEXT_LINE_MODE_VALUES, value.contextLine)) &&
125
+ isOptionalBoolean(value.showHookStatus) &&
126
+ isOptionalBoolean(value.sessionAccent) &&
127
+ isOptionalBoolean(value.transparent) &&
128
+ isOptionalBoolean(value.compactThinkingLevel)
129
+ );
130
+ }
131
+
132
+ /** Check the model fields the status line reads; the rest is trusted as serialized by this version. */
133
+ function isPersistedModel(value: unknown): value is Model {
134
+ return (
135
+ isRecord(value) &&
136
+ typeof value.id === "string" &&
137
+ typeof value.name === "string" &&
138
+ typeof value.provider === "string" &&
139
+ typeof value.api === "string" &&
140
+ typeof value.contextWindow === "number"
141
+ );
142
+ }
143
+
144
+ function readCompactionBoundaries(value: unknown): CompactionBoundaries | null | undefined {
145
+ if (value === null) return null;
146
+ if (!isRecord(value) || typeof value.thresholdPercent !== "number") return undefined;
147
+ const speculationPercent = value.speculationPercent;
148
+ if (speculationPercent !== null && typeof speculationPercent !== "number") return undefined;
149
+ return { thresholdPercent: value.thresholdPercent, speculationPercent };
150
+ }
151
+
152
+ /** Validate persisted {@link StatusLineStartupData}; `undefined` for anything malformed. */
153
+ export function readStatusLineStartupData(value: unknown): StatusLineStartupData | undefined {
154
+ if (!isRecord(value) || !isStatusLineSettings(value.settings)) return undefined;
155
+ const model = value.model;
156
+ if (model !== undefined && !isPersistedModel(model)) return undefined;
157
+ const thinkingLevel = typeof value.thinkingLevel === "string" ? parseThinkingLevel(value.thinkingLevel) : undefined;
158
+ if (value.thinkingLevel !== undefined && thinkingLevel === undefined) return undefined;
159
+ const compactionBoundaries = readCompactionBoundaries(value.compactionBoundaries);
160
+ if (
161
+ typeof value.gitEnabled !== "boolean" ||
162
+ typeof value.autoThinking !== "boolean" ||
163
+ typeof value.fastMode !== "boolean" ||
164
+ typeof value.usingSubscription !== "boolean" ||
165
+ typeof value.autoCompactEnabled !== "boolean" ||
166
+ compactionBoundaries === undefined
167
+ ) {
168
+ return undefined;
169
+ }
170
+ return {
171
+ settings: value.settings,
172
+ gitEnabled: value.gitEnabled,
173
+ model,
174
+ thinkingLevel,
175
+ autoThinking: value.autoThinking,
176
+ fastMode: value.fastMode,
177
+ usingSubscription: value.usingSubscription,
178
+ autoCompactEnabled: value.autoCompactEnabled,
179
+ compactionBoundaries,
180
+ };
181
+ }
@@ -84,8 +84,6 @@ export interface SegmentContext {
84
84
  sessionAccent?: boolean;
85
85
  /** Stand-in session title for previews; `session_name` renders it when the session is unnamed. */
86
86
  previewTitle?: string;
87
- /** Replace dynamic values with ellipses while preserving each segment's icon, color, and static text. */
88
- startupPlaceholder?: boolean;
89
87
  activeRepo: ActiveRepoContext | null;
90
88
  width: number;
91
89
  options: StatusLineSegmentOptions;
@@ -514,11 +514,15 @@ function parseTmuxVersionFromEnv(env: NodeJS.ProcessEnv): { major: number; minor
514
514
  * Policy (highest precedence first):
515
515
  * 1. Explicit user override (`PI_NO_HYPERLINKS=1` off, `PI_FORCE_HYPERLINKS=1`
516
516
  * on). Opt-out wins ties.
517
- * 2. Static terminal capability — terminals whose {@link TerminalInfo} marks
517
+ * 2. Herdr pane with no nested screen/tmux: on. Herdr hides the outer
518
+ * terminal (`TERM=xterm-256color`, no `TERM_PROGRAM`), but it renders
519
+ * OSC 8 in its own grid and opens links itself on Ctrl+click, so the
520
+ * outer terminal's support does not matter.
521
+ * 3. Static terminal capability — terminals whose {@link TerminalInfo} marks
518
522
  * `hyperlinks: false` (e.g. `base`) stay off unless the user forced on.
519
- * 3. GNU screen's explicit session marker (`STY`) always off, even if tmux is
523
+ * 4. GNU screen's explicit session marker (`STY`) always off, even if tmux is
520
524
  * also present: a screen layer anywhere in the path cannot forward OSC 8.
521
- * 4. tmux session (`TMUX` set): enabled when tmux self-reports >= 3.4 via
525
+ * 5. tmux session (`TMUX` set): enabled when tmux self-reports >= 3.4 via
522
526
  * `TERM_PROGRAM_VERSION` (tmux 3.4 stores OSC 8 as a cell attribute and
523
527
  * forwards it to outer terminals whose `terminal-features` include
524
528
  * `hyperlinks`). Older or unknown versions stay off; on outer terminals
@@ -526,11 +530,11 @@ function parseTmuxVersionFromEnv(env: NodeJS.ProcessEnv): { major: number; minor
526
530
  * identical to today. Checked before the screen-family TERM heuristic
527
531
  * because tmux's historical `default-terminal` is `screen-256color`, so
528
532
  * `TERM=screen*` inside a tmux session must NOT short-circuit to off.
529
- * 5. screen-family TERM without `TMUX` always off: screen never gained OSC 8
533
+ * 6. screen-family TERM without `TMUX` always off: screen never gained OSC 8
530
534
  * support.
531
- * 6. tmux-family TERM without `TMUX` env — unusual (e.g. inspection scripts);
535
+ * 7. tmux-family TERM without `TMUX` env — unusual (e.g. inspection scripts);
532
536
  * no version available, so off.
533
- * 7. Otherwise honor the static terminal capability.
537
+ * 8. Otherwise honor the static terminal capability.
534
538
  */
535
539
  export function shouldEnableHyperlinksByDefault(
536
540
  env: NodeJS.ProcessEnv = Bun.env,
@@ -539,6 +543,8 @@ export function shouldEnableHyperlinksByDefault(
539
543
  const override = hyperlinksUserOverride(env);
540
544
  if (override !== null) return override;
541
545
 
546
+ if (isInsideHerdr(env) && !env.STY && !env.TMUX) return true;
547
+
542
548
  if (!getTerminalInfo(terminalId).hyperlinks) return false;
543
549
 
544
550
  // STY is GNU screen's explicit session marker. It vetoes tmux enabling when
package/src/terminal.ts CHANGED
@@ -171,10 +171,14 @@ export const STDOUT_BACKLOG_CLEAR_BYTES = 256 * 1024;
171
171
  /**
172
172
  * How long an armed backlog may go without any drain progress before the
173
173
  * consumer is declared gone. A slow-but-alive terminal keeps reaching new
174
- * low-water marks (so it never trips); a wedged one that flushes nothing is
175
- * torn down within this window.
174
+ * low-water marks (so it never trips), but a live one can also stop reading
175
+ * for seconds at a time: a busy tmux server holding a slow client, or a
176
+ * container's attach stream. Waiting costs no memory, because frames are
177
+ * deferred while the backlog is up (`TUI.#deferRenderForOutputBacklog`), so the
178
+ * window is long enough to ride those out; a reader that never comes back is
179
+ * still torn down within it.
176
180
  */
177
- const STDOUT_STALL_TIMEOUT_MS = 2_000;
181
+ const STDOUT_STALL_TIMEOUT_MS = 60_000;
178
182
 
179
183
  /** Cadence at which {@link ProcessTerminal} re-samples the backlog while an episode is armed. */
180
184
  const STDOUT_STALL_POLL_MS = 250;
@@ -808,6 +812,11 @@ export class ProcessTerminal implements Terminal {
808
812
  #reportedRows?: number;
809
813
  #mode2031DebounceTimer?: Timer;
810
814
  #windowsTerminalAppearancePollTimer?: Timer;
815
+ #progressActive = false;
816
+ // Ghostty expires OSC 9;4 state without a heartbeat. Persistent hosts such
817
+ // as Windows Terminal restart their indeterminate animation on every write.
818
+ readonly #keepProgressAlive = TERMINAL.id === "ghostty";
819
+ #bracketedPasteRefreshTimer?: Timer;
811
820
  #progressTimer?: Timer;
812
821
 
813
822
  constructor(options?: ProcessTerminalOptions) {
@@ -936,9 +945,8 @@ export class ProcessTerminal implements Terminal {
936
945
  }
937
946
  }
938
947
 
939
- // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
940
- // the viewport while we own the terminal; released in stop(). See
941
- // stderr-guard in pi-utils (mirrors openai/codex#24459).
948
+ // Keep unmanaged native fd-2 writes off the viewport while we own the
949
+ // terminal; released in stop(). See stderr-guard in pi-utils.
942
950
  suppressTerminalStderr();
943
951
 
944
952
  // Set up resize handler immediately. The OS refreshes process.stdout
@@ -1765,7 +1773,15 @@ export class ProcessTerminal implements Terminal {
1765
1773
  // heuristic pure downside — turn it off so stall-batched keystrokes are
1766
1774
  // not misread as a paste (#12540). `supported` is only true here after an
1767
1775
  // explicit DECRPM reply (the DA1-sentinel fallback resolves unsupported).
1768
- if (mode === 2004 && supported) this.#stdinBuffer?.setRawPasteClassification(false);
1776
+ if (mode === 2004 && supported) {
1777
+ this.#stdinBuffer?.setRawPasteClassification(false);
1778
+ // A terminal can reset this mode after the initial probe (for example,
1779
+ // iTerm2's Terminal State toggle). Keep the mode asserted while we own
1780
+ // the TTY, since the raw fallback is disabled after confirmation.
1781
+ this.#bracketedPasteRefreshTimer ??= setInterval(() => {
1782
+ if (this.#active && !this.#dead) this.#safeWrite("\x1b[?2004h");
1783
+ }, 1000);
1784
+ }
1769
1785
  }
1770
1786
 
1771
1787
  #syncWindowsTerminalAppearancePolling(mode2031Supported: boolean): void {
@@ -1897,6 +1913,10 @@ export class ProcessTerminal implements Terminal {
1897
1913
  // Suppress observer/timer callbacks before any teardown can yield or throw.
1898
1914
  this.#active = false;
1899
1915
  this.#inputDeferred = false;
1916
+ if (this.#bracketedPasteRefreshTimer) {
1917
+ clearInterval(this.#bracketedPasteRefreshTimer);
1918
+ this.#bracketedPasteRefreshTimer = undefined;
1919
+ }
1900
1920
  if (this.#headless) return;
1901
1921
  // Unregister from emergency cleanup
1902
1922
  if (activeTerminal === this) {
@@ -1908,7 +1928,9 @@ export class ProcessTerminal implements Terminal {
1908
1928
  // step throws.
1909
1929
  restoreTerminalStderr();
1910
1930
 
1911
- if (this.#clearProgressTimer()) {
1931
+ this.#clearProgressTimer();
1932
+ if (this.#progressActive) {
1933
+ this.#progressActive = false;
1912
1934
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1913
1935
  }
1914
1936
 
@@ -2061,6 +2083,10 @@ export class ProcessTerminal implements Terminal {
2061
2083
  #markTerminalDisconnected(reason: string, err?: unknown): void {
2062
2084
  if (this.#dead) return;
2063
2085
  this.#dead = true;
2086
+ if (this.#bracketedPasteRefreshTimer) {
2087
+ clearInterval(this.#bracketedPasteRefreshTimer);
2088
+ this.#bracketedPasteRefreshTimer = undefined;
2089
+ }
2064
2090
  this.#disarmStdoutStallWatchdog();
2065
2091
  logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
2066
2092
 
@@ -2297,14 +2323,17 @@ export class ProcessTerminal implements Terminal {
2297
2323
  setProgress(active: boolean): void {
2298
2324
  if (this.#headless) return;
2299
2325
  if (active) {
2326
+ if (this.#progressActive) return;
2327
+ this.#progressActive = true;
2300
2328
  this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
2301
- if (!this.#progressTimer) {
2329
+ if (this.#keepProgressAlive && !this.#progressTimer) {
2302
2330
  this.#progressTimer = setInterval(() => {
2303
2331
  this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
2304
2332
  }, TERMINAL_PROGRESS_KEEPALIVE_MS);
2305
2333
  this.#progressTimer.unref?.();
2306
2334
  }
2307
2335
  } else {
2336
+ this.#progressActive = false;
2308
2337
  this.#clearProgressTimer();
2309
2338
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
2310
2339
  }
@@ -58,7 +58,8 @@ export interface DiagnosticMeta {
58
58
  */
59
59
  export interface LimitsMeta {
60
60
  matchLimit?: { reached: number; suggestion: number };
61
- resultLimit?: { reached: number; suggestion: number };
61
+ /** `suggestion` is omitted when the tool is already at its hard cap, so no larger usable limit exists to advise. */
62
+ resultLimit?: { reached: number; suggestion?: number };
62
63
  headLimit?: { reached: number; suggestion: number };
63
64
  /** `unit` may be absent in sessions persisted before it was recorded. */
64
65
  columnTruncated?: { maxColumn: number; unit?: "bytes" | "chars"; artifactId?: string };
@@ -187,7 +188,7 @@ function isGeneratedOutputNoticeLine(line: string): boolean {
187
188
  return (
188
189
  body.startsWith("Showing ") ||
189
190
  /^\d+ matches limit reached\. Use limit=\d+ for more/u.test(body) ||
190
- /^\d+ results limit reached\. Use limit=\d+ for more/u.test(body) ||
191
+ /^\d+ results limit reached(?:\.|$)/u.test(body) ||
191
192
  body.startsWith("Some lines truncated to ")
192
193
  );
193
194
  }
@@ -300,7 +301,14 @@ export function formatOutputNotice(meta: OutputMeta | undefined): string {
300
301
  }
301
302
  if (meta.limits?.resultLimit) {
302
303
  const l = meta.limits.resultLimit;
303
- parts.push(`${l.reached} results limit reached. Use limit=${l.suggestion} for more`);
304
+ // At the tool's hard cap there is no larger usable limit, so the
305
+ // "Use limit=" advice would name a value that gets clamped right
306
+ // back — emit the bare reached notice instead (#13263).
307
+ parts.push(
308
+ l.suggestion === undefined
309
+ ? `${l.reached} results limit reached`
310
+ : `${l.reached} results limit reached. Use limit=${l.suggestion} for more`,
311
+ );
304
312
  }
305
313
  if (meta.limits?.headLimit) {
306
314
  const l = meta.limits.headLimit;
package/src/tools/vibe.ts CHANGED
@@ -23,6 +23,8 @@ export interface VibeToolDetails {
23
23
  op: VibeOp;
24
24
  /** Live TV-wall snapshot of the owner's worker sessions at (or during) the call. */
25
25
  screens: VibeScreenSnapshot[];
26
+ /** Sessions the director killed, left off `screens`; shown as a count. */
27
+ hiddenKilled?: string[];
26
28
  spawned?: { id: string; cli: VibeCli; jobId: string };
27
29
  send?: VibeSendOutcome;
28
30
  wait?: {
@@ -46,6 +48,8 @@ export interface VibeScreenSnapshot {
46
48
  id: string;
47
49
  cli: VibeCli;
48
50
  state: VibeSessionState;
51
+ /** Terminated by the director (`vibe_kill`) or mode teardown; unset for workers that died on their own. */
52
+ killed?: boolean;
49
53
  model?: string;
50
54
  turns: number;
51
55
  queued: number;
@@ -373,13 +377,21 @@ export function createVibeToolRenderer(op: VibeOp) {
373
377
  return new Text(header, 0, 0);
374
378
  }
375
379
 
376
- // wait/list: the TV wall.
380
+ // wait/list: the TV wall. Director-killed sessions arrive pre-filtered
381
+ // (see `hiddenKilled`) so a long-running director's wall stays legible.
377
382
  const screens = details.screens;
383
+ const hiddenKilled = details.hiddenKilled?.length ?? 0;
384
+ const killedMeta = hiddenKilled > 0 ? [uiTheme.fg("dim", `${hiddenKilled} killed hidden`)] : [];
378
385
  if (screens.length === 0) {
379
386
  const fallback = result.content.find(part => part.type === "text")?.text ?? "no sessions";
387
+ const gist = hiddenKilled > 0 ? "no live sessions" : fallback;
380
388
  return new Text(
381
389
  renderStatusLine(
382
- { icon: "warning", title: `vibe ${op}`, meta: [uiTheme.fg("dim", frameText(fallback, 60))] },
390
+ {
391
+ icon: "warning",
392
+ title: `vibe ${op}`,
393
+ meta: [uiTheme.fg("dim", frameText(gist, 60)), ...killedMeta],
394
+ },
383
395
  uiTheme,
384
396
  ),
385
397
  0,
@@ -394,6 +406,7 @@ export function createVibeToolRenderer(op: VibeOp) {
394
406
  if (running > 0) meta.push(uiTheme.fg("accent", `${running} on air`));
395
407
  if (settledById.size > 0) meta.push(uiTheme.fg("success", `${settledById.size} settled`));
396
408
  if (details.wait?.timedOut) meta.push(uiTheme.fg("warning", "timed out"));
409
+ meta.push(...killedMeta);
397
410
  const title =
398
411
  op === "wait"
399
412
  ? waiting
package/src/tui.ts CHANGED
@@ -883,7 +883,8 @@ export class TUI extends Container {
883
883
  // engine paints only the modal on the alt buffer and leaves every
884
884
  // normal-screen accounting field (#previousFrameLength, #viewportTopRow, …)
885
885
  // untouched, so exiting reconciles cleanly against the terminal-restored
886
- // normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
886
+ // normal screen. #altPreviousLines is the last alt frame, diffed row by row
887
+ // against the next one.
887
888
  #altActive = false;
888
889
  #mouseTracking: MouseTrackingState = "off";
889
890
  /** Product-owned probe for opt-in normal-buffer click capture (`tui.mouse`). Read every frame. */
@@ -2867,16 +2868,18 @@ export class TUI extends Container {
2867
2868
  this.#providerWindow.length > 0;
2868
2869
  if (diffable) {
2869
2870
  for (let index = 0; index < rows; index++) {
2870
- const previous = this.#providerPreparedRows[index];
2871
- const current = prepared.rows[index]!;
2872
2871
  if (
2873
- this.#providerWindow[index] === prepared.lines[index] &&
2874
- previous !== undefined &&
2875
- previous.widthEpoch === current.widthEpoch &&
2876
- previous.imageProtocol === current.imageProtocol
2872
+ !this.#rowNeedsRewrite(
2873
+ this.#providerWindow,
2874
+ this.#providerPreparedRows,
2875
+ prepared.lines,
2876
+ prepared.rows,
2877
+ index,
2878
+ )
2877
2879
  ) {
2878
2880
  continue;
2879
2881
  }
2882
+ const current = prepared.rows[index]!;
2880
2883
  buffer += `\x1b[${newTop + index + 1};1H${this.#lineRewriteSequence(
2881
2884
  current,
2882
2885
  width,
@@ -3440,6 +3443,29 @@ export class TUI extends Container {
3440
3443
  return visibleWidth(above);
3441
3444
  }
3442
3445
 
3446
+ /**
3447
+ * Whether screen row `index` must be rewritten to turn the previously painted
3448
+ * frame into this one: its line, or the preparation it was painted with,
3449
+ * changed.
3450
+ */
3451
+ #rowNeedsRewrite(
3452
+ previousLines: readonly string[],
3453
+ previousRows: readonly PreparedLine[],
3454
+ lines: readonly string[],
3455
+ rows: readonly PreparedLine[],
3456
+ index: number,
3457
+ ): boolean {
3458
+ const previous = previousRows[index];
3459
+ const current = rows[index]!;
3460
+ return (
3461
+ previousLines[index] !== lines[index] ||
3462
+ previous === undefined ||
3463
+ previous.width !== current.width ||
3464
+ previous.widthEpoch !== current.widthEpoch ||
3465
+ previous.imageProtocol !== current.imageProtocol
3466
+ );
3467
+ }
3468
+
3443
3469
  #lineRewriteSequence(
3444
3470
  line: PreparedLine,
3445
3471
  width: number,
@@ -3549,9 +3575,11 @@ export class TUI extends Container {
3549
3575
  }
3550
3576
 
3551
3577
  /**
3552
- * Full per-row viewport rewrite on the alt buffer. Emits only sync-output
3553
- * brackets, a cursor home, and per-row rewrites — never ED3 or any
3554
- * native-scrollback byte. The hardware cursor stays hidden here.
3578
+ * Paint a frame on the alt buffer: only the rows that changed since the
3579
+ * previous frame, or every row when the height changed, a repaint is forced,
3580
+ * or a changed frame holds OSC 66 text before or after. Emits only
3581
+ * sync-output brackets, cursor moves, and per-row rewrites — never ED3 or
3582
+ * any native-scrollback byte. The hardware cursor stays hidden here.
3555
3583
  */
3556
3584
  #emitAltFrame(prepared: PreparedLines, width: number, height: number, notifyPaint: boolean): void {
3557
3585
  // The pass that composed this frame ran with `altScreen`, so the normal
@@ -3573,38 +3601,34 @@ export class TUI extends Container {
3573
3601
  for (const seq of imageTransmits) transmitBuffer += seq;
3574
3602
  this.terminal.write(transmitBuffer);
3575
3603
  }
3576
- // Skip an identical repaint (the modal is mostly static between
3577
- // keystrokes) — unless a forced repaint (resetDisplay,
3578
- // requestRender(true)) is pending: the redraw gesture must repair a
3579
- // corrupted modal even when our cached frame is byte-identical.
3604
+ // A forced repaint (resetDisplay, requestRender(true)) rewrites every row
3605
+ // even when the cached frame is byte-identical: the redraw gesture must
3606
+ // repair a corrupted modal. So does a changed frame with OSC 66 text in
3607
+ // it, before or after: a scaled glyph spans the rows below its own and the
3608
+ // terminal drops it when any of them is written, so those rows are not
3609
+ // independent. Otherwise rewrite only the rows that changed (a keystroke
3610
+ // in a modal touches a row or two), and skip an identical frame entirely.
3580
3611
  const force = this.#forceViewportRepaintOnNextRender;
3581
3612
  this.#forceViewportRepaintOnNextRender = false;
3582
- if (!force && this.#altPreviousLines.length === height) {
3583
- let same = true;
3584
- for (let r = 0; r < height; r++) {
3585
- const previous = this.#altPreparedRows[r];
3586
- const current = prepared.rows[r]!;
3587
- if (
3588
- prepared.lines[r] !== this.#altPreviousLines[r] ||
3589
- previous === undefined ||
3590
- previous.width !== current.width ||
3591
- previous.widthEpoch !== current.widthEpoch ||
3592
- previous.imageProtocol !== current.imageProtocol
3593
- ) {
3594
- same = false;
3595
- break;
3596
- }
3597
- }
3598
- if (same) {
3599
- this.#altPreviousLines = prepared.lines;
3600
- this.#altPreparedRows = prepared.rows;
3601
- return;
3602
- }
3603
- }
3604
- let buffer = `${this.#paintBeginSequence}\x1b[H`;
3613
+ const full =
3614
+ force ||
3615
+ this.#altPreviousLines.length !== height ||
3616
+ ((this.#altPreviousLines.some(isOsc66Line) || prepared.lines.some(isOsc66Line)) &&
3617
+ prepared.rows.some((_row, r) =>
3618
+ this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r),
3619
+ ));
3620
+ let rowsBuffer = "";
3605
3621
  for (let r = 0; r < height; r++) {
3606
- if (r > 0) buffer += "\n";
3607
- buffer += this.#lineRewriteSequence(
3622
+ if (full) {
3623
+ if (r > 0) rowsBuffer += "\n";
3624
+ } else if (
3625
+ this.#rowNeedsRewrite(this.#altPreviousLines, this.#altPreparedRows, prepared.lines, prepared.rows, r)
3626
+ ) {
3627
+ rowsBuffer += `\x1b[${r + 1};1H`;
3628
+ } else {
3629
+ continue;
3630
+ }
3631
+ rowsBuffer += this.#lineRewriteSequence(
3608
3632
  prepared.rows[r]!,
3609
3633
  width,
3610
3634
  r,
@@ -3613,10 +3637,10 @@ export class TUI extends Container {
3613
3637
  this.#osc66SpacerGlyphWidth(prepared.lines, r),
3614
3638
  );
3615
3639
  }
3616
- buffer += this.#paintEndSequence;
3617
- this.terminal.write(buffer);
3618
3640
  this.#altPreviousLines = prepared.lines;
3619
3641
  this.#altPreparedRows = prepared.rows;
3642
+ if (rowsBuffer === "") return;
3643
+ this.terminal.write(`${this.#paintBeginSequence}${full ? "\x1b[H" : ""}${rowsBuffer}${this.#paintEndSequence}`);
3620
3644
  this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
3621
3645
  if (notifyPaint) {
3622
3646
  this.#notifyPaint({
@@ -3628,6 +3652,6 @@ export class TUI extends Container {
3628
3652
  rows: height,
3629
3653
  });
3630
3654
  }
3631
- this.#fullRedrawCount += 1;
3655
+ if (full) this.#fullRedrawCount += 1;
3632
3656
  }
3633
3657
  }