@oh-my-pi/pi-tui 18.4.0 → 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 +28 -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 +29 -13
- 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/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
package/src/prompt/composer.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { getComposerStyle } from "../components/composer/registry";
|
|
2
2
|
import { Spacer } from "../components/spacer";
|
|
3
|
+
import type { StatusLineComponent } from "../status-line/component";
|
|
4
|
+
import type { StatusLineSession } from "../status-line/host";
|
|
5
|
+
import { createStartupStatusLine, type StatusLineStartupData } from "../status-line/startup";
|
|
3
6
|
import { isInsideTerminalMultiplexer } from "../terminal-multiplexer";
|
|
4
7
|
import { ProcessTerminal, type Terminal } from "../terminal";
|
|
5
8
|
import {
|
|
@@ -12,7 +15,7 @@ import {
|
|
|
12
15
|
type TUIOptions,
|
|
13
16
|
type ViewportSize,
|
|
14
17
|
} from "../tui";
|
|
15
|
-
import { sliceWithWidth,
|
|
18
|
+
import { sliceWithWidth, visibleWidth } from "../utils";
|
|
16
19
|
import { postmortem } from "@oh-my-pi/pi-utils";
|
|
17
20
|
import { CustomEditor } from "./custom-editor";
|
|
18
21
|
import type { WordCompletionMethod } from "./word-completion";
|
|
@@ -56,28 +59,22 @@ export interface ComposerWelcomeUpdate {
|
|
|
56
59
|
readonly modelName?: string;
|
|
57
60
|
readonly providerName?: string;
|
|
58
61
|
readonly recentSessions?: readonly RecentSession[];
|
|
59
|
-
|
|
62
|
+
/** Detected project servers; `null` means LSP is disabled and hides the welcome section. */
|
|
63
|
+
readonly lspServers?: readonly LspServerInfo[] | null;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
67
|
+
* Status-bar inputs persisted by the last session. The first frame renders
|
|
68
|
+
* them through a startup {@link StatusLineComponent} at the live width until
|
|
69
|
+
* the session-aware status line attaches.
|
|
66
70
|
*/
|
|
67
|
-
export interface
|
|
68
|
-
|
|
69
|
-
/** ANSI wrapper of the editor border at snapshot time (session accent or thinking color). */
|
|
71
|
+
export interface ComposerStatusCache {
|
|
72
|
+
/** ANSI wrapper of the editor border when persisted (session accent or thinking color). */
|
|
70
73
|
readonly borderColor?: {
|
|
71
74
|
readonly prefix: string;
|
|
72
75
|
readonly suffix: string;
|
|
73
76
|
};
|
|
74
|
-
|
|
75
|
-
readonly topBorder?: {
|
|
76
|
-
readonly content: string;
|
|
77
|
-
readonly width: number;
|
|
78
|
-
};
|
|
79
|
-
/** Standalone bottom-bar rows (`pi`/`claude` shapes), gap row included. */
|
|
80
|
-
readonly bottomLines: readonly string[];
|
|
77
|
+
readonly statusLine: StatusLineStartupData;
|
|
81
78
|
}
|
|
82
79
|
|
|
83
80
|
/** Optional dependencies and initial state for a standalone composer. */
|
|
@@ -87,7 +84,7 @@ export interface ComposerOptions {
|
|
|
87
84
|
readonly tuiOptions?: TUIOptions;
|
|
88
85
|
readonly preferences?: Partial<ComposerPreferences>;
|
|
89
86
|
readonly welcome?: ComposerWelcomeUpdate;
|
|
90
|
-
readonly status?:
|
|
87
|
+
readonly status?: ComposerStatusCache;
|
|
91
88
|
readonly exit?: (code: number) => void;
|
|
92
89
|
readonly now?: () => number;
|
|
93
90
|
}
|
|
@@ -117,30 +114,16 @@ export interface ComposerStartOptions {
|
|
|
117
114
|
readonly deferInput?: boolean;
|
|
118
115
|
}
|
|
119
116
|
|
|
120
|
-
/**
|
|
121
|
-
* Mount slot for the session-aware status component below the editor. Shows
|
|
122
|
-
* placeholder rows during startup until the real component mounts.
|
|
123
|
-
*/
|
|
117
|
+
/** Mount slot below the editor: the startup status line, then the session-aware one. */
|
|
124
118
|
class StatusHost implements Component {
|
|
125
|
-
#lines: readonly string[] = [];
|
|
126
119
|
#component: Component | undefined;
|
|
127
120
|
|
|
128
|
-
get mounted(): boolean {
|
|
129
|
-
return this.#component !== undefined;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
setLines(lines: readonly string[]): void {
|
|
133
|
-
this.#lines = lines;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
121
|
setComponent(component: Component): void {
|
|
137
122
|
this.#component = component;
|
|
138
|
-
this.#lines = [];
|
|
139
123
|
}
|
|
140
124
|
|
|
141
125
|
render(width: number): readonly string[] {
|
|
142
|
-
|
|
143
|
-
return this.#lines.map(line => truncateToWidth(line, width));
|
|
126
|
+
return this.#component?.render(width) ?? [];
|
|
144
127
|
}
|
|
145
128
|
}
|
|
146
129
|
|
|
@@ -220,11 +203,12 @@ export class Composer implements TerminalFrameProvider {
|
|
|
220
203
|
#modelName = "";
|
|
221
204
|
#providerName = "";
|
|
222
205
|
#recentSessions: RecentSession[] = [];
|
|
223
|
-
#lspServers: LspServerInfo[] = [];
|
|
206
|
+
#lspServers: LspServerInfo[] | null = [];
|
|
224
207
|
#headerBefore: readonly Component[] = [];
|
|
225
208
|
#headerAfter: readonly Component[] = [];
|
|
226
209
|
#runtimeChildren: readonly Component[] = [];
|
|
227
|
-
|
|
210
|
+
/** Cache-driven status line shown until {@link setStatusComponent} mounts the session's. */
|
|
211
|
+
#startupStatus: StatusLineComponent | undefined;
|
|
228
212
|
#runtimeMounted = false;
|
|
229
213
|
// Composer-owned history id space. Transcript batch ids restart across
|
|
230
214
|
// container clears/swaps; the composer translates them into one monotonic
|
|
@@ -291,7 +275,6 @@ export class Composer implements TerminalFrameProvider {
|
|
|
291
275
|
this.#exit = options.exit ?? (code => postmortem.exitProcess(code));
|
|
292
276
|
this.#now = options.now ?? Date.now;
|
|
293
277
|
this.#preferences = { ...COMPOSER_DEFAULTS, ...options.preferences };
|
|
294
|
-
this.#statusSnapshot = options.status;
|
|
295
278
|
this.#applyWelcomeUpdate(options.welcome ?? {});
|
|
296
279
|
|
|
297
280
|
this.ui = new TUI(
|
|
@@ -318,7 +301,13 @@ export class Composer implements TerminalFrameProvider {
|
|
|
318
301
|
} catch {
|
|
319
302
|
// Extension-defined styles arrive with the session; InteractiveMode reapplies them.
|
|
320
303
|
}
|
|
321
|
-
|
|
304
|
+
if (options.status) {
|
|
305
|
+
const { borderColor, statusLine } = options.status;
|
|
306
|
+
if (borderColor) this.editor.borderColor = text => `${borderColor.prefix}${text}${borderColor.suffix}`;
|
|
307
|
+
this.#startupStatus = createStartupStatusLine(statusLine);
|
|
308
|
+
this.#statusHost.setComponent(this.#startupStatus);
|
|
309
|
+
this.#startupStatus.attachToEditor(this.editor, getComposerStyle(this.#preferences.composerShape));
|
|
310
|
+
}
|
|
322
311
|
// Emergency controls stay active until InteractiveMode installs configured bindings.
|
|
323
312
|
// They deliberately mirror the interactive editor's contract so a stalled startup
|
|
324
313
|
// never behaves differently from a healthy one: Ctrl+C clears the draft and a second
|
|
@@ -635,7 +624,9 @@ export class Composer implements TerminalFrameProvider {
|
|
|
635
624
|
// retires first so transcript prefixes can follow in order.
|
|
636
625
|
const renderedHeader = this.#header.render(width);
|
|
637
626
|
if (renderedHeader.length > 0) {
|
|
638
|
-
|
|
627
|
+
// Only the comparison below reads the height, so the walk stops at
|
|
628
|
+
// the budget instead of rendering every replayed block (#12933).
|
|
629
|
+
const liveRows = transcript.liveRowCount(width, Math.max(0, rows - renderedHeader.length - chromeRows));
|
|
639
630
|
if (!this.#historyFlush && renderedHeader.length + chromeRows + liveRows <= rows) return undefined;
|
|
640
631
|
this.#offeredHistory = {
|
|
641
632
|
id: this.#nextHistoryId++,
|
|
@@ -789,7 +780,7 @@ export class Composer implements TerminalFrameProvider {
|
|
|
789
780
|
autocomplete: this.#preferences.spellingAutocomplete,
|
|
790
781
|
autocorrect: this.#preferences.spellingAutocorrect,
|
|
791
782
|
});
|
|
792
|
-
this.#
|
|
783
|
+
this.#startupStatus?.attachToEditor(this.editor, getComposerStyle(this.#preferences.composerShape));
|
|
793
784
|
if (this.#preferences.quiet) {
|
|
794
785
|
this.#welcome?.stopIntro();
|
|
795
786
|
this.#welcome = undefined;
|
|
@@ -834,42 +825,20 @@ export class Composer implements TerminalFrameProvider {
|
|
|
834
825
|
}
|
|
835
826
|
|
|
836
827
|
/**
|
|
837
|
-
* Mount the session-aware status component into the slot below the editor
|
|
838
|
-
*
|
|
828
|
+
* Mount the session-aware status component into the slot below the editor,
|
|
829
|
+
* retiring the startup status line; the caller installs the real top-border
|
|
839
830
|
* provider through its composer-shape sync.
|
|
840
831
|
*/
|
|
841
|
-
setStatusComponent(component:
|
|
832
|
+
setStatusComponent<TSession extends StatusLineSession>(component: StatusLineComponent<TSession>): void {
|
|
833
|
+
if (this.#startupStatus) component.adoptGitStatus(this.#startupStatus);
|
|
834
|
+
this.#disposeStartupStatus();
|
|
842
835
|
this.#statusHost.setComponent(component);
|
|
843
|
-
this.#statusSnapshot = undefined;
|
|
844
836
|
this.editor.setTopBorderProvider(undefined);
|
|
845
837
|
}
|
|
846
838
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (!border) return undefined;
|
|
851
|
-
if (border.width <= availableWidth) return { content: border.content, width: border.width };
|
|
852
|
-
const content = truncateToWidth(border.content, availableWidth);
|
|
853
|
-
return { content, width: visibleWidth(content) };
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/** Install the cached chrome for the current shape; a shape mismatch clears it. */
|
|
857
|
-
#applyStatusSnapshot(): void {
|
|
858
|
-
if (this.#statusHost.mounted) return;
|
|
859
|
-
const snapshot = this.#statusSnapshot;
|
|
860
|
-
if (!snapshot || snapshot.shape !== this.#preferences.composerShape) {
|
|
861
|
-
this.editor.setTopBorderProvider(undefined);
|
|
862
|
-
this.#statusHost.setLines([]);
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
if (snapshot.borderColor) {
|
|
866
|
-
const { prefix, suffix } = snapshot.borderColor;
|
|
867
|
-
this.editor.borderColor = text => `${prefix}${text}${suffix}`;
|
|
868
|
-
}
|
|
869
|
-
this.editor.setTopBorderProvider(
|
|
870
|
-
snapshot.topBorder ? availableWidth => this.#speculativeTopBorder(availableWidth) : undefined,
|
|
871
|
-
);
|
|
872
|
-
this.#statusHost.setLines(snapshot.bottomLines);
|
|
839
|
+
#disposeStartupStatus(): void {
|
|
840
|
+
this.#startupStatus?.dispose();
|
|
841
|
+
this.#startupStatus = undefined;
|
|
873
842
|
}
|
|
874
843
|
|
|
875
844
|
/** Mount or replace session-aware root children while preserving the header and status hosts. */
|
|
@@ -908,6 +877,7 @@ export class Composer implements TerminalFrameProvider {
|
|
|
908
877
|
stop(): void {
|
|
909
878
|
if (!this.#started || this.#stopped || this.#transferred) return;
|
|
910
879
|
this.#welcome?.stopIntro();
|
|
880
|
+
this.#disposeStartupStatus();
|
|
911
881
|
this.ui.stop();
|
|
912
882
|
this.#stopped = true;
|
|
913
883
|
}
|
|
@@ -917,7 +887,7 @@ export class Composer implements TerminalFrameProvider {
|
|
|
917
887
|
if (update.modelName !== undefined) this.#modelName = update.modelName;
|
|
918
888
|
if (update.providerName !== undefined) this.#providerName = update.providerName;
|
|
919
889
|
if (update.recentSessions !== undefined) this.#recentSessions = [...update.recentSessions];
|
|
920
|
-
if (update.lspServers !== undefined) this.#lspServers = [...update.lspServers];
|
|
890
|
+
if (update.lspServers !== undefined) this.#lspServers = update.lspServers && [...update.lspServers];
|
|
921
891
|
}
|
|
922
892
|
|
|
923
893
|
#ensureWelcome(): void {
|
package/src/prompt/welcome.ts
CHANGED
|
@@ -186,7 +186,8 @@ export class WelcomeComponent implements Component {
|
|
|
186
186
|
private modelName: string,
|
|
187
187
|
private providerName: string,
|
|
188
188
|
private recentSessions: RecentSession[] = [],
|
|
189
|
-
|
|
189
|
+
/** Detected project servers; `null` means LSP is disabled and hides the section. */
|
|
190
|
+
private lspServers: LspServerInfo[] | null = [],
|
|
190
191
|
) {}
|
|
191
192
|
get tip(): string | undefined {
|
|
192
193
|
this.#nagRoll ??= Math.random();
|
|
@@ -272,7 +273,7 @@ export class WelcomeComponent implements Component {
|
|
|
272
273
|
this.invalidate();
|
|
273
274
|
}
|
|
274
275
|
|
|
275
|
-
setLspServers(servers: LspServerInfo[]): void {
|
|
276
|
+
setLspServers(servers: LspServerInfo[] | null): void {
|
|
276
277
|
this.lspServers = servers;
|
|
277
278
|
this.invalidate();
|
|
278
279
|
}
|
|
@@ -365,29 +366,6 @@ export class WelcomeComponent implements Component {
|
|
|
365
366
|
sessionLines.push("");
|
|
366
367
|
}
|
|
367
368
|
|
|
368
|
-
// LSP servers content
|
|
369
|
-
const lspLines: string[] = [];
|
|
370
|
-
if (this.lspServers.length === 0) {
|
|
371
|
-
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
|
|
372
|
-
} else {
|
|
373
|
-
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
|
|
374
|
-
const icon =
|
|
375
|
-
server.status === "ready"
|
|
376
|
-
? theme.styledSymbol("status.enabled", "success")
|
|
377
|
-
: server.status === "available"
|
|
378
|
-
? theme.styledSymbol("status.enabled", "dim")
|
|
379
|
-
: server.status === "connecting"
|
|
380
|
-
? theme.styledSymbol("status.pending", "muted")
|
|
381
|
-
: theme.styledSymbol("status.error", "error");
|
|
382
|
-
const exts = server.fileTypes.slice(0, 3).join(" ");
|
|
383
|
-
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
// Pad to the fixed slot count so the box height doesn't depend on server count.
|
|
387
|
-
while (lspLines.length < WELCOME_LSP_SLOTS) {
|
|
388
|
-
lspLines.push("");
|
|
389
|
-
}
|
|
390
|
-
|
|
391
369
|
// Right column
|
|
392
370
|
const rightLines = [
|
|
393
371
|
` ${theme.bold(theme.fg("accent", "Tips"))}`,
|
|
@@ -395,9 +373,7 @@ export class WelcomeComponent implements Component {
|
|
|
395
373
|
` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
|
|
396
374
|
` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
|
|
397
375
|
` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
|
|
398
|
-
separator,
|
|
399
|
-
` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
|
|
400
|
-
...lspLines,
|
|
376
|
+
...this.#renderLspSection(separator),
|
|
401
377
|
separator,
|
|
402
378
|
` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
|
|
403
379
|
...sessionLines,
|
|
@@ -452,6 +428,33 @@ export class WelcomeComponent implements Component {
|
|
|
452
428
|
return lines;
|
|
453
429
|
}
|
|
454
430
|
|
|
431
|
+
/** Right-column LSP rows padded to a fixed height; empty when LSP is disabled. */
|
|
432
|
+
#renderLspSection(separator: string): string[] {
|
|
433
|
+
if (this.lspServers === null) return [];
|
|
434
|
+
const lspLines: string[] = [];
|
|
435
|
+
if (this.lspServers.length === 0) {
|
|
436
|
+
lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
|
|
437
|
+
} else {
|
|
438
|
+
for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
|
|
439
|
+
const icon =
|
|
440
|
+
server.status === "ready"
|
|
441
|
+
? theme.styledSymbol("status.enabled", "success")
|
|
442
|
+
: server.status === "available"
|
|
443
|
+
? theme.styledSymbol("status.enabled", "dim")
|
|
444
|
+
: server.status === "connecting"
|
|
445
|
+
? theme.styledSymbol("status.pending", "muted")
|
|
446
|
+
: theme.styledSymbol("status.error", "error");
|
|
447
|
+
const exts = server.fileTypes.slice(0, 3).join(" ");
|
|
448
|
+
lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Pad to the fixed slot count so the box height doesn't depend on server count.
|
|
452
|
+
while (lspLines.length < WELCOME_LSP_SLOTS) {
|
|
453
|
+
lspLines.push("");
|
|
454
|
+
}
|
|
455
|
+
return [separator, ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`, ...lspLines];
|
|
456
|
+
}
|
|
457
|
+
|
|
455
458
|
/**
|
|
456
459
|
* Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
|
|
457
460
|
* label followed by a `muted` body, the whole line italicized. Returns `[]`
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
StatusLineHost,
|
|
26
26
|
StatusLineSession,
|
|
27
27
|
} from "./host";
|
|
28
|
+
import type { Editor } from "../components/editor";
|
|
28
29
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../theme/session-color";
|
|
29
30
|
import { sanitizeStatusText } from "../chrome/shared";
|
|
30
31
|
import { getThemeEpoch, theme } from "../theme";
|
|
@@ -247,6 +248,8 @@ interface ContextUsageMemo {
|
|
|
247
248
|
tokenizerRef: unknown;
|
|
248
249
|
usedTokens: number;
|
|
249
250
|
contextWindow: number;
|
|
251
|
+
/** The session reported `percent: null`: usage is unknown, so no percent is shown. */
|
|
252
|
+
percentUnknown: boolean;
|
|
250
253
|
systemPromptRef: readonly string[] | undefined;
|
|
251
254
|
toolsRef: readonly any[] | undefined;
|
|
252
255
|
skillsRef: readonly any[] | undefined;
|
|
@@ -312,7 +315,6 @@ interface CachedStatusLine {
|
|
|
312
315
|
availableWidth: number;
|
|
313
316
|
renderRevision: number;
|
|
314
317
|
inputRevision: number;
|
|
315
|
-
placeholders: boolean;
|
|
316
318
|
previewTitle: string | undefined;
|
|
317
319
|
externalInputs: StatusLineExternalInputs;
|
|
318
320
|
}
|
|
@@ -420,8 +422,10 @@ function formatEmbeddedContextPercent(percent: number): string {
|
|
|
420
422
|
return `${percent > 0 && percent < 1 ? percent.toFixed(1) : Math.round(percent)}%`;
|
|
421
423
|
}
|
|
422
424
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
+
/** Gap width the embedded gauge needs for its labels; an unknown percent (`null`) shows the window label alone. */
|
|
426
|
+
function embeddedContextGaugeMinWidth(percent: number | null, contextWindow: number): number {
|
|
427
|
+
const percentWidth = percent === null ? 0 : formatEmbeddedContextPercent(percent).length + 2;
|
|
428
|
+
return percentWidth + formatNumber(contextWindow).length + 2;
|
|
425
429
|
}
|
|
426
430
|
|
|
427
431
|
function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
@@ -1483,6 +1487,24 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1483
1487
|
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
|
|
1484
1488
|
}
|
|
1485
1489
|
|
|
1490
|
+
/**
|
|
1491
|
+
* Seed working-tree status from the status line this one replaces (the
|
|
1492
|
+
* startup bar), so the dirty counts stay on screen instead of blanking until
|
|
1493
|
+
* this instance's first fetch lands. Normal TTL refreshes follow.
|
|
1494
|
+
*/
|
|
1495
|
+
adoptGitStatus(source: StatusLineComponent): void {
|
|
1496
|
+
if (this.#cachedGitStatusCwd === undefined && source.#cachedGitStatusCwd !== undefined) {
|
|
1497
|
+
this.#cachedGitStatus = source.#cachedGitStatus;
|
|
1498
|
+
this.#cachedGitStatusCwd = source.#cachedGitStatusCwd;
|
|
1499
|
+
this.#gitStatusLastFetch = source.#gitStatusLastFetch;
|
|
1500
|
+
}
|
|
1501
|
+
if (this.#jjStatusLastFetch === 0 && source.#jjStatusLastFetch !== 0) {
|
|
1502
|
+
this.#cachedJjStatus = source.#cachedJjStatus;
|
|
1503
|
+
this.#jjStatusLastFetch = source.#jjStatusLastFetch;
|
|
1504
|
+
}
|
|
1505
|
+
this.#invalidateStatusLineRenderCache();
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1486
1508
|
#lookupPr(activeRepoCache: ActiveRepoCache = this.#resolveActiveRepoCache()): {
|
|
1487
1509
|
number: number;
|
|
1488
1510
|
url: string;
|
|
@@ -2088,6 +2110,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2088
2110
|
const usedTokens = usage?.tokens ?? 0;
|
|
2089
2111
|
const contextWindow = usage?.contextWindow ?? modelContextWindow;
|
|
2090
2112
|
this.#contextUsageCache = {
|
|
2113
|
+
percentUnknown: usage?.percent === null,
|
|
2091
2114
|
messagesRef: messages,
|
|
2092
2115
|
length,
|
|
2093
2116
|
lastFingerprint,
|
|
@@ -2141,7 +2164,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2141
2164
|
const breakdown = this.getCachedContextBreakdown();
|
|
2142
2165
|
let contextTokens = breakdown.usedTokens;
|
|
2143
2166
|
contextWindow = breakdown.contextWindow || contextWindow;
|
|
2144
|
-
let contextPercent: number | null =
|
|
2167
|
+
let contextPercent: number | null =
|
|
2168
|
+
contextWindow > 0 && !this.#contextUsageCache?.percentUnknown
|
|
2169
|
+
? (breakdown.usedTokens / contextWindow) * 100
|
|
2170
|
+
: null;
|
|
2145
2171
|
// Collab guest: context comes from the host's state frames — the local
|
|
2146
2172
|
// replica does no accounting of its own.
|
|
2147
2173
|
const collabState = this.#collabStatus?.stateOverride;
|
|
@@ -2439,8 +2465,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2439
2465
|
* clocks, countdowns, and VCS fallback polling advance only at their own
|
|
2440
2466
|
* display/probe cadence.
|
|
2441
2467
|
*/
|
|
2442
|
-
#statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings
|
|
2443
|
-
if (placeholders) return 0;
|
|
2468
|
+
#statusLineClock(nowMs: number, effectiveSettings: EffectiveStatusLineSettings): number {
|
|
2444
2469
|
const leftSegments = effectiveSettings.leftSegments;
|
|
2445
2470
|
const rightSegments = effectiveSettings.rightSegments;
|
|
2446
2471
|
const meter = this.#meter();
|
|
@@ -2469,17 +2494,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2469
2494
|
return 0;
|
|
2470
2495
|
}
|
|
2471
2496
|
|
|
2472
|
-
#buildStatusLine(
|
|
2473
|
-
width: number,
|
|
2474
|
-
layout: StatusLineLayout = "box",
|
|
2475
|
-
previewTitle?: string,
|
|
2476
|
-
options?: { readonly placeholders?: boolean },
|
|
2477
|
-
): CachedStatusLine {
|
|
2497
|
+
#buildStatusLine(width: number, layout: StatusLineLayout = "box", previewTitle?: string): CachedStatusLine {
|
|
2478
2498
|
const effectiveSettings = this.#resolveSettings();
|
|
2479
|
-
const placeholders = options?.placeholders === true;
|
|
2480
2499
|
const externalInputs = this.#readStatusLineExternalInputs();
|
|
2481
2500
|
const nowMs = Date.now();
|
|
2482
|
-
const clockTick = this.#statusLineClock(nowMs, effectiveSettings
|
|
2501
|
+
const clockTick = this.#statusLineClock(nowMs, effectiveSettings);
|
|
2483
2502
|
if (clockTick !== this.#statusLineClockTick) {
|
|
2484
2503
|
this.#statusLineClockTick = clockTick;
|
|
2485
2504
|
this.#invalidateStatusLineRenderCache();
|
|
@@ -2491,14 +2510,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2491
2510
|
cached.availableWidth === width &&
|
|
2492
2511
|
cached.renderRevision === this.#renderRevision &&
|
|
2493
2512
|
cached.inputRevision === this.#statusLineInputRevision &&
|
|
2494
|
-
cached.placeholders === placeholders &&
|
|
2495
2513
|
cached.previewTitle === previewTitle &&
|
|
2496
2514
|
this.#sameStatusLineExternalInputs(cached.externalInputs, externalInputs)
|
|
2497
2515
|
) {
|
|
2498
2516
|
return cached;
|
|
2499
2517
|
}
|
|
2500
2518
|
|
|
2501
|
-
const content = this.#renderStatusLine(width, layout, previewTitle,
|
|
2519
|
+
const content = this.#renderStatusLine(width, layout, previewTitle, nowMs);
|
|
2502
2520
|
const result = {
|
|
2503
2521
|
content,
|
|
2504
2522
|
dimmedContent: this.#dimWhileFocusProxied(content),
|
|
@@ -2506,7 +2524,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2506
2524
|
availableWidth: width,
|
|
2507
2525
|
renderRevision: this.#renderRevision,
|
|
2508
2526
|
inputRevision: this.#statusLineInputRevision,
|
|
2509
|
-
placeholders,
|
|
2510
2527
|
previewTitle,
|
|
2511
2528
|
externalInputs,
|
|
2512
2529
|
};
|
|
@@ -2529,16 +2546,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2529
2546
|
* `previewTitle` is a stand-in session title for composer previews; the
|
|
2530
2547
|
* `session_name` segment renders it when the session is unnamed.
|
|
2531
2548
|
*/
|
|
2532
|
-
#renderStatusLine(
|
|
2533
|
-
width: number,
|
|
2534
|
-
layout: StatusLineLayout,
|
|
2535
|
-
previewTitle: string | undefined,
|
|
2536
|
-
options: { readonly placeholders?: boolean } | undefined,
|
|
2537
|
-
nowMs: number,
|
|
2538
|
-
): string {
|
|
2549
|
+
#renderStatusLine(width: number, layout: StatusLineLayout, previewTitle: string | undefined, nowMs: number): string {
|
|
2539
2550
|
const effectiveSettings = this.#resolveSettings();
|
|
2540
2551
|
this.#syncPricingTimer();
|
|
2541
|
-
const placeholders = options?.placeholders === true;
|
|
2542
2552
|
const plain = layout !== "box" && layout !== "band";
|
|
2543
2553
|
const includePath =
|
|
2544
2554
|
hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
|
|
@@ -2548,7 +2558,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2548
2558
|
(hasGitSegment(effectiveSettings.leftSegments) || hasGitSegment(effectiveSettings.rightSegments));
|
|
2549
2559
|
const includePr =
|
|
2550
2560
|
gitEnabled && (hasPrSegment(effectiveSettings.leftSegments) || hasPrSegment(effectiveSettings.rightSegments));
|
|
2551
|
-
const
|
|
2561
|
+
const ctx = this.#buildSegmentContext(
|
|
2552
2562
|
width,
|
|
2553
2563
|
effectiveSettings.segmentOptions,
|
|
2554
2564
|
includePath,
|
|
@@ -2557,7 +2567,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2557
2567
|
nowMs,
|
|
2558
2568
|
previewTitle,
|
|
2559
2569
|
);
|
|
2560
|
-
const ctx: SegmentContext = placeholders ? { ...liveCtx, startupPlaceholder: true } : liveCtx;
|
|
2561
2570
|
const separatorDef = plain
|
|
2562
2571
|
? { left: "·", right: "·" }
|
|
2563
2572
|
: getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
|
|
@@ -2609,8 +2618,6 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2609
2618
|
const embedContext =
|
|
2610
2619
|
!plain &&
|
|
2611
2620
|
effectiveSettings.contextLine === "embedded" &&
|
|
2612
|
-
ctx.contextPercent !== null &&
|
|
2613
|
-
ctx.contextPercent !== undefined &&
|
|
2614
2621
|
ctx.contextWindow > 0 &&
|
|
2615
2622
|
(hasContextSegment(leftSegIds) || hasContextSegment(rightSegIds)) &&
|
|
2616
2623
|
(hasNonContextSegment(leftSegIds) || hasNonContextSegment(rightSegIds));
|
|
@@ -2630,13 +2637,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2630
2637
|
job => job.type !== "task" || job.agentId === undefined || !this.#runningSubagentIds.has(job.agentId),
|
|
2631
2638
|
).length ?? 0;
|
|
2632
2639
|
if (runningBackgroundJobs > 0) {
|
|
2633
|
-
|
|
2634
|
-
rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${count}`));
|
|
2635
|
-
}
|
|
2636
|
-
if (subagentBadge) {
|
|
2637
|
-
const content = placeholders ? [theme.icon.agents, "…"].filter(Boolean).join(" ") : subagentBadge;
|
|
2638
|
-
rightParts.unshift(placeholders ? theme.fg("statusLineSubagents", content) : content);
|
|
2640
|
+
rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
|
|
2639
2641
|
}
|
|
2642
|
+
if (subagentBadge) rightParts.unshift(subagentBadge);
|
|
2640
2643
|
}
|
|
2641
2644
|
const topFillWidth = Math.max(0, width);
|
|
2642
2645
|
const left = [...leftParts];
|
|
@@ -2668,9 +2671,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2668
2671
|
// this budget a long path/session title can leave a one-cell gap: the
|
|
2669
2672
|
// context segment is gone, and the gauge silently omits its labels too.
|
|
2670
2673
|
const embeddedContextWidth = embedContext
|
|
2671
|
-
? ctx.
|
|
2672
|
-
? "…%".length + "…".length + 4
|
|
2673
|
-
: embeddedContextGaugeMinWidth(ctx.contextPercent ?? 0, ctx.contextWindow)
|
|
2674
|
+
? embeddedContextGaugeMinWidth(ctx.contextPercent, ctx.contextWindow)
|
|
2674
2675
|
: 0;
|
|
2675
2676
|
const minimumGapWidth = (): number => {
|
|
2676
2677
|
if (!embeddedContextWidth) return left.length > 0 && right.length > 0 ? 1 : 0;
|
|
@@ -2824,11 +2825,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2824
2825
|
const horizontal = theme.boxRound.horizontal;
|
|
2825
2826
|
const mode = effectiveSettings.contextLine ?? "embedded";
|
|
2826
2827
|
const pct = ctx.contextPercent;
|
|
2827
|
-
if (mode === "off" || pct === null
|
|
2828
|
+
if (mode === "off" || (pct === null && ctx.contextWindow <= 0)) {
|
|
2828
2829
|
return `\x1b[49m${usedColor}${horizontal.repeat(gapWidth)}\x1b[39m`;
|
|
2829
2830
|
}
|
|
2830
2831
|
|
|
2831
|
-
|
|
2832
|
+
// Unknown usage against a known window (startup prepaint) draws the fresh-session
|
|
2833
|
+
// gauge: one lit cell and the window label, no percent.
|
|
2834
|
+
const clampedPct = pct === null ? 0 : Math.min(100, Math.max(0, pct));
|
|
2832
2835
|
let percentLabel = "";
|
|
2833
2836
|
let windowLabel = "";
|
|
2834
2837
|
let percentStart = -1;
|
|
@@ -2837,14 +2840,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2837
2840
|
// >100%: usage anchored past the active window (e.g. model switch to a
|
|
2838
2841
|
// smaller window). The bar clamps full, but the embedded label breaks
|
|
2839
2842
|
// past the window label — `──200K─120%` with the percent in error color.
|
|
2840
|
-
const percentOverflow = pct > 100;
|
|
2843
|
+
const percentOverflow = pct !== null && pct > 100;
|
|
2841
2844
|
if (embedContext) {
|
|
2842
|
-
const candidatePercent =
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
const candidateWindow = ctx.startupPlaceholder ? "…" : formatNumber(ctx.contextWindow);
|
|
2846
|
-
const minimumLabelWidth = candidatePercent.length + candidateWindow.length + 4;
|
|
2847
|
-
if (gapWidth >= minimumLabelWidth) {
|
|
2845
|
+
const candidatePercent = pct === null ? "" : formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
|
|
2846
|
+
const candidateWindow = formatNumber(ctx.contextWindow);
|
|
2847
|
+
if (gapWidth >= embeddedContextGaugeMinWidth(pct, ctx.contextWindow)) {
|
|
2848
2848
|
percentLabel = candidatePercent;
|
|
2849
2849
|
windowLabel = candidateWindow;
|
|
2850
2850
|
if (percentOverflow) {
|
|
@@ -2946,9 +2946,32 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2946
2946
|
}
|
|
2947
2947
|
}
|
|
2948
2948
|
|
|
2949
|
-
/**
|
|
2950
|
-
|
|
2951
|
-
|
|
2949
|
+
/**
|
|
2950
|
+
* Wire this bar into `editor` for a composer layout: the matching top-border
|
|
2951
|
+
* provider, the autocomplete probe, and the standalone bottom-bar placement.
|
|
2952
|
+
* Callers re-run it whenever the composer shape changes.
|
|
2953
|
+
*/
|
|
2954
|
+
attachToEditor(
|
|
2955
|
+
editor: Pick<Editor, "isAutocompleteActive" | "setTopBorderProvider" | "setTopBorder">,
|
|
2956
|
+
style: Pick<ComposerStyle, "statusAttachment" | "bottomBar" | "bottomBarGap">,
|
|
2957
|
+
): void {
|
|
2958
|
+
this.setAutocompleteActiveProbe(() => editor.isAutocompleteActive());
|
|
2959
|
+
switch (style.statusAttachment) {
|
|
2960
|
+
case "top-border":
|
|
2961
|
+
editor.setTopBorderProvider(availableWidth => this.getTopBorder(availableWidth));
|
|
2962
|
+
break;
|
|
2963
|
+
case "top-band":
|
|
2964
|
+
editor.setTopBorderProvider(availableWidth => this.getBandTopBorder(availableWidth));
|
|
2965
|
+
break;
|
|
2966
|
+
case "top-rule-chip":
|
|
2967
|
+
editor.setTopBorderProvider(availableWidth => this.getStandaloneTopBorder(availableWidth));
|
|
2968
|
+
break;
|
|
2969
|
+
case "none":
|
|
2970
|
+
editor.setTopBorderProvider(undefined);
|
|
2971
|
+
editor.setTopBorder(undefined);
|
|
2972
|
+
break;
|
|
2973
|
+
}
|
|
2974
|
+
this.setComposerStyle(style);
|
|
2952
2975
|
}
|
|
2953
2976
|
|
|
2954
2977
|
getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
|
|
@@ -7,7 +7,6 @@ export interface BillingSummaryOptions {
|
|
|
7
7
|
readonly usingSubscription: boolean;
|
|
8
8
|
readonly premiumRequests: number;
|
|
9
9
|
readonly fractionDigits: number;
|
|
10
|
-
readonly startupPlaceholder?: boolean;
|
|
11
10
|
readonly pricingPeriod?: "peak" | "off-peak";
|
|
12
11
|
readonly advisor?: {
|
|
13
12
|
readonly cost: number;
|
|
@@ -30,24 +29,13 @@ function formatSpend(amount: number, usingSubscription: boolean, fractionDigits:
|
|
|
30
29
|
return `S${formatted}`;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
function formatSpendPlaceholder(usingSubscription: boolean, uiTheme: Theme): string {
|
|
34
|
-
if (!usingSubscription) return "$…";
|
|
35
|
-
if (uiTheme.getSymbolPreset() === "nerd" && uiTheme.icon.subscription) {
|
|
36
|
-
return `${uiTheme.icon.subscription} …`;
|
|
37
|
-
}
|
|
38
|
-
return "S…";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
32
|
function formatAdvisorSpend(
|
|
42
33
|
amount: number,
|
|
43
34
|
usingSubscription: boolean,
|
|
44
35
|
fractionDigits: number,
|
|
45
|
-
placeholder: boolean,
|
|
46
36
|
uiTheme: Theme,
|
|
47
37
|
): string {
|
|
48
|
-
const spend =
|
|
49
|
-
? formatSpendPlaceholder(usingSubscription, uiTheme)
|
|
50
|
-
: formatSpend(amount, usingSubscription, fractionDigits, uiTheme);
|
|
38
|
+
const spend = formatSpend(amount, usingSubscription, fractionDigits, uiTheme);
|
|
51
39
|
const icon = uiTheme.icon.advisor;
|
|
52
40
|
return icon && icon !== "(adv)" ? `${icon} ${spend}` : `${spend} (adv)`;
|
|
53
41
|
}
|
|
@@ -63,21 +51,16 @@ export function formatBillingSummary(options: BillingSummaryOptions, uiTheme: Th
|
|
|
63
51
|
return undefined;
|
|
64
52
|
}
|
|
65
53
|
|
|
66
|
-
const placeholder = options.startupPlaceholder === true;
|
|
67
54
|
const parts: string[] = [];
|
|
68
55
|
if (options.cost || options.pricingPeriod) {
|
|
69
|
-
parts.push(
|
|
70
|
-
placeholder
|
|
71
|
-
? formatSpendPlaceholder(options.usingSubscription, uiTheme)
|
|
72
|
-
: formatSpend(options.cost, options.usingSubscription, options.fractionDigits, uiTheme),
|
|
73
|
-
);
|
|
56
|
+
parts.push(formatSpend(options.cost, options.usingSubscription, options.fractionDigits, uiTheme));
|
|
74
57
|
} else if (options.usingSubscription) {
|
|
75
58
|
parts.push(
|
|
76
59
|
uiTheme.getSymbolPreset() === "nerd" && uiTheme.icon.subscription ? uiTheme.icon.subscription : "(sub)",
|
|
77
60
|
);
|
|
78
61
|
}
|
|
79
62
|
if (options.pricingPeriod) parts.push(options.pricingPeriod === "peak" ? "↑" : "↓");
|
|
80
|
-
if (premiumRequests) parts.push(`★ ${
|
|
63
|
+
if (premiumRequests) parts.push(`★ ${formatNumber(premiumRequests)}`);
|
|
81
64
|
if (advisorCost && options.advisor) {
|
|
82
65
|
const prefix = parts.length > 0 ? "+ " : "";
|
|
83
66
|
parts.push(
|
|
@@ -85,7 +68,6 @@ export function formatBillingSummary(options: BillingSummaryOptions, uiTheme: Th
|
|
|
85
68
|
advisorCost,
|
|
86
69
|
options.advisor.usingSubscription,
|
|
87
70
|
options.fractionDigits,
|
|
88
|
-
placeholder,
|
|
89
71
|
uiTheme,
|
|
90
72
|
)}`,
|
|
91
73
|
);
|