@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.
- package/CHANGELOG.md +34 -0
- package/dist/types/chat/image-loading.d.ts +24 -0
- package/dist/types/chat/transcript-entry.d.ts +9 -0
- package/dist/types/chrome/transcript-container.d.ts +9 -2
- package/dist/types/overlays/agents-hub.d.ts +7 -4
- package/dist/types/overlays/rewind-selector.d.ts +3 -3
- package/dist/types/overlays/usage-dashboard.d.ts +9 -2
- package/dist/types/prompt/composer-cache.d.ts +34 -16
- package/dist/types/prompt/composer.d.ts +15 -18
- package/dist/types/prompt/welcome.d.ts +5 -2
- package/dist/types/status-line/component.d.ts +13 -4
- package/dist/types/status-line/metrics.d.ts +0 -1
- package/dist/types/status-line/startup.d.ts +38 -0
- package/dist/types/status-line/types.d.ts +0 -2
- package/dist/types/terminal-capabilities.d.ts +10 -6
- package/dist/types/tools/output-meta.d.ts +2 -1
- package/dist/types/tools/vibe.d.ts +4 -0
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +27 -21
- package/src/chat/image-loading.ts +81 -0
- package/src/chat/tool-execution.ts +38 -14
- package/src/chat/transcript-entry.ts +16 -0
- package/src/chrome/transcript-container.ts +208 -74
- package/src/components/image.ts +10 -2
- package/src/overlays/agents-hub.ts +7 -18
- package/src/overlays/copy-selector.ts +3 -31
- package/src/overlays/model-hub.ts +49 -16
- package/src/overlays/rewind-selector.ts +51 -12
- package/src/overlays/usage-dashboard.ts +47 -6
- package/src/prompt/composer-cache.ts +217 -196
- package/src/prompt/composer.ts +40 -70
- package/src/prompt/welcome.ts +31 -28
- package/src/render/render-utils.ts +43 -22
- package/src/status-line/component.ts +73 -50
- package/src/status-line/metrics.ts +3 -21
- package/src/status-line/segments.ts +34 -50
- package/src/status-line/startup.ts +181 -0
- package/src/status-line/types.ts +0 -2
- package/src/terminal-capabilities.ts +12 -6
- package/src/terminal.ts +38 -9
- package/src/tools/output-meta.ts +11 -3
- package/src/tools/vibe.ts +15 -2
- 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
|
+
}
|
package/src/status-line/types.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
533
|
+
* 6. screen-family TERM without `TMUX` always off: screen never gained OSC 8
|
|
530
534
|
* support.
|
|
531
|
-
*
|
|
535
|
+
* 7. tmux-family TERM without `TMUX` env — unusual (e.g. inspection scripts);
|
|
532
536
|
* no version available, so off.
|
|
533
|
-
*
|
|
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)
|
|
175
|
-
*
|
|
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 =
|
|
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
|
|
940
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/output-meta.ts
CHANGED
|
@@ -58,7 +58,8 @@ export interface DiagnosticMeta {
|
|
|
58
58
|
*/
|
|
59
59
|
export interface LimitsMeta {
|
|
60
60
|
matchLimit?: { reached: number; suggestion: number };
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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,
|
|
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.#
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
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
|
-
*
|
|
3553
|
-
*
|
|
3554
|
-
*
|
|
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
|
-
//
|
|
3577
|
-
//
|
|
3578
|
-
//
|
|
3579
|
-
//
|
|
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
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
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 (
|
|
3607
|
-
|
|
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
|
}
|