@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/components/image.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getCellDimensions,
|
|
4
4
|
getImageDimensions,
|
|
5
5
|
type ImageDimensions,
|
|
6
|
+
ImageProtocol,
|
|
6
7
|
imageFallback,
|
|
7
8
|
renderImage,
|
|
8
9
|
TERMINAL,
|
|
@@ -765,6 +766,13 @@ export class Image implements Component {
|
|
|
765
766
|
// toward (and are demoted by) the budget; without a protocol every image is
|
|
766
767
|
// already text.
|
|
767
768
|
const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
|
|
769
|
+
// Only Kitty images with a budget id transmit their data separately from
|
|
770
|
+
// the placement; a pending re-transmit (after a purge or history clear)
|
|
771
|
+
// must rebuild the lines. SIXEL and iTerm2 carry the image inside the line
|
|
772
|
+
// itself and never register a transmit, so gating their cache on it would
|
|
773
|
+
// re-encode the full image on every render pass.
|
|
774
|
+
const imageId = this.#imageId;
|
|
775
|
+
const transmitsSeparately = imageProtocol === ImageProtocol.Kitty && imageId != null;
|
|
768
776
|
|
|
769
777
|
if (
|
|
770
778
|
this.#cachedLines &&
|
|
@@ -774,7 +782,7 @@ export class Image implements Component {
|
|
|
774
782
|
this.#cachedCellWidthPx === cellDimensions.widthPx &&
|
|
775
783
|
this.#cachedCellHeightPx === cellDimensions.heightPx &&
|
|
776
784
|
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
|
|
777
|
-
(
|
|
785
|
+
(!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true)
|
|
778
786
|
) {
|
|
779
787
|
return this.#cachedLines;
|
|
780
788
|
}
|
|
@@ -787,7 +795,7 @@ export class Image implements Component {
|
|
|
787
795
|
if (hasProtocol && !suppressed) {
|
|
788
796
|
// Transmit the data once (keyed by id); thereafter renderImage returns
|
|
789
797
|
// just the placement, so repaints never re-send the base64.
|
|
790
|
-
const needsTransmit =
|
|
798
|
+
const needsTransmit = transmitsSeparately && (this.#budget?.shouldTransmit(imageId) ?? false);
|
|
791
799
|
const result = renderImage(this.#base64Data, this.#dimensions, {
|
|
792
800
|
maxWidthCells: maxWidth,
|
|
793
801
|
maxHeightCells: this.#options.maxHeightCells,
|
|
@@ -81,7 +81,7 @@ interface SidebarEntry extends HubSidebarEntry<"all" | "source" | "new" | "separ
|
|
|
81
81
|
type ListRow = { kind: "agent"; agent: HubAgent } | { kind: "new" };
|
|
82
82
|
|
|
83
83
|
/** The per-agent knob a strip or the model browser is editing. */
|
|
84
|
-
type PropertyKind = "model" | "prewalk" | "advisor";
|
|
84
|
+
export type PropertyKind = "model" | "prewalk" | "advisor";
|
|
85
85
|
|
|
86
86
|
type StripChip = HubStripChip<
|
|
87
87
|
| { kind: "toggle" }
|
|
@@ -110,8 +110,10 @@ export interface AgentsHubDeps {
|
|
|
110
110
|
resolvePatterns: (patterns: string[]) => string | undefined;
|
|
111
111
|
effectivePrewalkPattern: (agent: HubAgent) => string | undefined;
|
|
112
112
|
effectiveAdvisorPattern: (agent: HubAgent) => string | undefined;
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/** Persist one agent's enabled state; other agents are untouched. */
|
|
114
|
+
setAgentDisabled: (name: string, options: { disabled: boolean }) => void;
|
|
115
|
+
/** Persist one agent's override for `property`; `undefined` clears it. Other agents are untouched. */
|
|
116
|
+
setAgentOverride: (property: PropertyKind, name: string, value: string | undefined) => void;
|
|
115
117
|
generateAgent: (description: string, onText: (text: string) => void) => Promise<string>;
|
|
116
118
|
saveAgent: (scope: "project" | "user", spec: GeneratedAgentSpec) => Promise<string>;
|
|
117
119
|
}
|
|
@@ -341,24 +343,11 @@ export class AgentsHubComponent implements Component {
|
|
|
341
343
|
|
|
342
344
|
#toggleAgent(agent: HubAgent): void {
|
|
343
345
|
agent.disabled = !agent.disabled;
|
|
344
|
-
|
|
345
|
-
.filter(entry => entry.disabled)
|
|
346
|
-
.map(entry => entry.name)
|
|
347
|
-
.sort((a, b) => a.localeCompare(b));
|
|
348
|
-
this.#deps.setDisabledAgents(disabled);
|
|
346
|
+
this.#deps.setAgentDisabled(agent.name, { disabled: agent.disabled });
|
|
349
347
|
this.#notice = `${agent.name} ${agent.disabled ? "disabled" : "enabled"}`;
|
|
350
348
|
this.#tui.requestRender();
|
|
351
349
|
}
|
|
352
350
|
|
|
353
|
-
#persistRecord(property: PropertyKind): void {
|
|
354
|
-
const overrides: Record<string, string> = {};
|
|
355
|
-
for (const agent of this.#allAgents) {
|
|
356
|
-
const value = this.#overrideFor(agent, property)?.trim();
|
|
357
|
-
if (value) overrides[agent.name] = value;
|
|
358
|
-
}
|
|
359
|
-
this.#deps.setOverrides(property, overrides);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
351
|
#overrideFor(agent: HubAgent, property: PropertyKind): string | undefined {
|
|
363
352
|
switch (property) {
|
|
364
353
|
case "model":
|
|
@@ -383,7 +372,7 @@ export class AgentsHubComponent implements Component {
|
|
|
383
372
|
agent.advisorOverride = trimmed;
|
|
384
373
|
break;
|
|
385
374
|
}
|
|
386
|
-
this.#
|
|
375
|
+
this.#deps.setAgentOverride(property, agent.name, trimmed);
|
|
387
376
|
this.#notice = this.#describeProperty(agent, property);
|
|
388
377
|
this.#tui.requestRender();
|
|
389
378
|
}
|
|
@@ -18,12 +18,12 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
|
18
18
|
import { type Component, matchesKey, routeSgrMouseInput, type TUI, truncateToWidth, visibleWidth } from "../index";
|
|
19
19
|
import type { MessageRenderer } from "../chat/extension-types";
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
recentTranscriptEntries,
|
|
22
|
+
type SessionMessageEntryLike as SessionMessageEntry,
|
|
22
23
|
type TranscriptEntryLike as TranscriptEntry,
|
|
23
24
|
transcriptEntryMessage,
|
|
24
25
|
userTurnDraft,
|
|
25
26
|
} from "../chat/transcript-entry";
|
|
26
|
-
import type { SessionMessageEntryLike as SessionMessageEntry } from "../chat/transcript-entry";
|
|
27
27
|
import { expandKeyHint, replaceTabs } from "../render/render-utils";
|
|
28
28
|
import { formatKeyHint } from "../app-keybindings";
|
|
29
29
|
import { editorKey, editorKeys } from "../chrome/keybinding-hints";
|
|
@@ -92,13 +92,6 @@ export interface CopyBlock {
|
|
|
92
92
|
const BLOCK_PREVIEW_LINES = 12;
|
|
93
93
|
/** The copy picker's outline stroke — green, distinct from the rewind selector's accent. */
|
|
94
94
|
const OUTLINE_COLOR: ThemeColor = "success";
|
|
95
|
-
/**
|
|
96
|
-
* Entries replayed when the picker opens. Replaying a long session's whole
|
|
97
|
-
* branch costs seconds before the first frame (one component built and
|
|
98
|
-
* rendered per entry), and the clipboard target is almost always recent, so
|
|
99
|
-
* the picker starts at this tail and loads the rest on demand (`a`).
|
|
100
|
-
*/
|
|
101
|
-
const INITIAL_ENTRIES = 600;
|
|
102
95
|
|
|
103
96
|
/** A clickable control on a block caption, in composed-column columns. */
|
|
104
97
|
interface ControlRegion {
|
|
@@ -132,7 +125,7 @@ export class CopySelectorComponent implements Component {
|
|
|
132
125
|
private readonly deps: CopySelectorDeps,
|
|
133
126
|
) {
|
|
134
127
|
this.#entries = entries;
|
|
135
|
-
const tail =
|
|
128
|
+
const tail = recentTranscriptEntries(entries);
|
|
136
129
|
this.#truncated = tail.length < entries.length;
|
|
137
130
|
this.#builder = this.#replay(tail);
|
|
138
131
|
this.#selected = Math.max(0, this.#targets.length - 1);
|
|
@@ -478,27 +471,6 @@ export class CopySelectorComponent implements Component {
|
|
|
478
471
|
}
|
|
479
472
|
}
|
|
480
473
|
|
|
481
|
-
/**
|
|
482
|
-
* The trailing slice starting at the last turn initiator at or before
|
|
483
|
-
* `entries.length - limit`: a user message, or a custom message that starts
|
|
484
|
-
* a user-attributed turn (a directly invoked `/skill:` prompt, a collab peer's
|
|
485
|
-
* prompt), the same boundary `ChatTranscriptBuilder` uses.
|
|
486
|
-
*
|
|
487
|
-
* The cut has to land on a turn boundary: the builder drops a tool result
|
|
488
|
-
* whose initiating call was sliced away, so a tail beginning mid-turn renders
|
|
489
|
-
* without its command — and a tail of nothing but orphaned results would
|
|
490
|
-
* leave the picker with no target at all. Scanning backwards keeps the whole
|
|
491
|
-
* final turn instead, and a branch whose last turn is itself longer than
|
|
492
|
-
* `limit` replays in full.
|
|
493
|
-
*/
|
|
494
|
-
function recentEntries(entries: TranscriptEntry[], limit: number): TranscriptEntry[] {
|
|
495
|
-
if (entries.length <= limit) return entries;
|
|
496
|
-
for (let index = entries.length - limit; index > 0; index--) {
|
|
497
|
-
if (isUserRequestEntry(entries[index]!)) return entries.slice(index);
|
|
498
|
-
}
|
|
499
|
-
return entries;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
474
|
/** Raw multi-line text of a user message (string or text blocks). */
|
|
503
475
|
function rawUserText(message: Extract<SessionMessageEntry["message"], { role: "user" }>): string {
|
|
504
476
|
if (typeof message.content === "string") return message.content;
|
|
@@ -984,7 +984,11 @@ export class ModelHubComponent implements Component {
|
|
|
984
984
|
);
|
|
985
985
|
}
|
|
986
986
|
|
|
987
|
-
/**
|
|
987
|
+
/**
|
|
988
|
+
* Persist `role → item`, preserving a still-supported thinking level, then
|
|
989
|
+
* open the thinking strip — skipped for models with no reasoning surface,
|
|
990
|
+
* where every chip would be a no-op ({@link #thinkingOptionsFor}).
|
|
991
|
+
*/
|
|
988
992
|
#assignRole(item: ModelBrowserItem, role: string, returnToRoles: boolean, scope?: ModelRoleSelectionScope): void {
|
|
989
993
|
if (this.#settings.modelRoleStorage === "project" && scope === undefined) {
|
|
990
994
|
this.#openScopeStrip(item, role, returnToRoles);
|
|
@@ -1003,6 +1007,13 @@ export class ModelHubComponent implements Component {
|
|
|
1003
1007
|
const result = this.#callbacks.onAssign(item.model, role, level, item.selector, scope);
|
|
1004
1008
|
this.#finishAssignment(result, () => {
|
|
1005
1009
|
this.#refreshAfterMutation();
|
|
1010
|
+
if (supported.length === 0) {
|
|
1011
|
+
if (returnToRoles) {
|
|
1012
|
+
this.#setActiveEntry("roles");
|
|
1013
|
+
this.#focus = "list";
|
|
1014
|
+
}
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1006
1017
|
this.#openThinkingStrip(item, role, returnToRoles, scope, level);
|
|
1007
1018
|
});
|
|
1008
1019
|
}
|
|
@@ -1019,10 +1030,39 @@ export class ModelHubComponent implements Component {
|
|
|
1019
1030
|
this.#refreshAfterMutation();
|
|
1020
1031
|
}
|
|
1021
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Thinking levels a role assignment can actually apply. A model that does
|
|
1035
|
+
* not reason gets none: no request path sends reasoning for it, and
|
|
1036
|
+
* `applyAutoThinkingLevel` early-returns on `!model.reasoning`, so
|
|
1037
|
+
* `inherit`/`off`/`auto` would all be no-ops. Reasoners without an effort
|
|
1038
|
+
* dial (`thinking: undefined`, e.g. `xai/grok-code-fast-1`) keep the three
|
|
1039
|
+
* always-on levels — their thinking is real, only the ladder is absent.
|
|
1040
|
+
*/
|
|
1022
1041
|
#thinkingOptionsFor(model: Model): ConfiguredThinkingLevel[] {
|
|
1042
|
+
if (!model.reasoning) return [];
|
|
1023
1043
|
return [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(model)];
|
|
1024
1044
|
}
|
|
1025
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* Resolve what `t` on a role row would edit: the role's model in its
|
|
1048
|
+
* persisted scope. Undefined when the role is unassigned or its model has
|
|
1049
|
+
* no thinking levels to offer, which makes both `t` and its footer hint
|
|
1050
|
+
* inert — the same rule wildcard fallback rows follow.
|
|
1051
|
+
*/
|
|
1052
|
+
#roleThinkingTarget(role: string): { item: ModelBrowserItem; scope?: ModelRoleSelectionScope } | undefined {
|
|
1053
|
+
const assignment = this.#roles[role];
|
|
1054
|
+
if (!assignment) return undefined;
|
|
1055
|
+
const source =
|
|
1056
|
+
this.#settings.modelRoleStorage === "project" ? this.#settings.getModelRoleSource(role) : "default";
|
|
1057
|
+
const scope = source === "project" || source === "global" ? source : undefined;
|
|
1058
|
+
const model = scope ? this.#roleForScope(role, scope).model : assignment.model;
|
|
1059
|
+
if (!model || this.#thinkingOptionsFor(model).length === 0) return undefined;
|
|
1060
|
+
return {
|
|
1061
|
+
item: { provider: model.provider, id: model.id, model, selector: `${model.provider}/${model.id}` },
|
|
1062
|
+
scope,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1026
1066
|
/** Offer only the roles this model can actually fill (chat roles for chat models, `web` for search runners, …). */
|
|
1027
1067
|
#openRoleStrip(item: ModelBrowserItem): void {
|
|
1028
1068
|
const chips: StripChip[] = [];
|
|
@@ -1821,20 +1861,9 @@ export class ModelHubComponent implements Component {
|
|
|
1821
1861
|
return;
|
|
1822
1862
|
}
|
|
1823
1863
|
if (printable === "t") {
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
this.#settings.modelRoleStorage === "project" ? this.#settings.getModelRoleSource(role) : "default";
|
|
1828
|
-
const scope = source === "project" || source === "global" ? source : undefined;
|
|
1829
|
-
const scopedModel = scope ? this.#roleForScope(role, scope).model : assignment.model;
|
|
1830
|
-
if (!scopedModel) return;
|
|
1831
|
-
const item: ModelBrowserItem = {
|
|
1832
|
-
provider: scopedModel.provider,
|
|
1833
|
-
id: scopedModel.id,
|
|
1834
|
-
model: scopedModel,
|
|
1835
|
-
selector: `${scopedModel.provider}/${scopedModel.id}`,
|
|
1836
|
-
};
|
|
1837
|
-
this.#openThinkingStrip(item, role, true, scope);
|
|
1864
|
+
if (role) {
|
|
1865
|
+
const target = this.#roleThinkingTarget(role);
|
|
1866
|
+
if (target) this.#openThinkingStrip(target.item, role, true, target.scope);
|
|
1838
1867
|
} else if (row?.kind === "fallback") {
|
|
1839
1868
|
this.#openFallbackThinkingStrip(row);
|
|
1840
1869
|
}
|
|
@@ -2338,7 +2367,11 @@ export class ModelHubComponent implements Component {
|
|
|
2338
2367
|
if (row?.kind === "newFallback") {
|
|
2339
2368
|
return `${upDown} rows · ${enter} new model/provider fallback chain · ${left} providers`;
|
|
2340
2369
|
}
|
|
2341
|
-
|
|
2370
|
+
// Same rule as fallback rows: advertise `t` only where a strip would
|
|
2371
|
+
// open — an assigned role whose model has thinking levels to offer.
|
|
2372
|
+
const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
|
|
2373
|
+
const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
|
|
2374
|
+
return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new`;
|
|
2342
2375
|
}
|
|
2343
2376
|
if (entry.kind === "provider" && entry.locked) {
|
|
2344
2377
|
return entry.oauth
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* active column when a strip is open), Left/Right slide between branch
|
|
22
22
|
* variants at a fork and jump between user turns elsewhere, `f` opens a
|
|
23
23
|
* filter (typing narrows the current path to matching items; Esc leaves the
|
|
24
|
-
* filter with the selection kept), Enter rewinds to the outlined item,
|
|
25
|
-
* cancels.
|
|
24
|
+
* filter with the selection kept), Enter rewinds to the outlined item, A loads
|
|
25
|
+
* earlier turns without changing selection, Esc cancels.
|
|
26
26
|
*/
|
|
27
27
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
28
28
|
import {
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
truncateToWidth,
|
|
37
37
|
} from "../index";
|
|
38
38
|
import type { MessageRenderer } from "../chat/extension-types";
|
|
39
|
-
import type
|
|
39
|
+
import { recentTranscriptEntries, type TranscriptEntryLike as TranscriptEntry } from "../chat/transcript-entry";
|
|
40
40
|
import { theme } from "../theme/theme";
|
|
41
41
|
import { matchesAppToolsExpand, matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
|
|
42
42
|
import { ChatTranscriptBuilder } from "../chat/chat-transcript-builder";
|
|
@@ -116,6 +116,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
116
116
|
#slide: { from: number; to: number; startedAt: number } | undefined;
|
|
117
117
|
#slideTimer: NodeJS.Timeout | undefined;
|
|
118
118
|
|
|
119
|
+
/** Whole branch; the selector may currently replay only its tail. */
|
|
120
|
+
#entries: TranscriptEntry[];
|
|
121
|
+
/** True while older history is still unreplayed. */
|
|
122
|
+
#truncated = false;
|
|
119
123
|
/** Filter query while the filter prompt is open; undefined shows the full transcript. */
|
|
120
124
|
#filter: string | undefined;
|
|
121
125
|
/** Main transcript rows from the last frame; the filter matches what is on screen. */
|
|
@@ -127,8 +131,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
127
131
|
entries: TranscriptEntry[],
|
|
128
132
|
private readonly deps: RewindSelectorDeps,
|
|
129
133
|
) {
|
|
130
|
-
this.#
|
|
131
|
-
|
|
134
|
+
this.#entries = entries;
|
|
135
|
+
const tail = recentTranscriptEntries(entries);
|
|
136
|
+
this.#truncated = tail.length < entries.length;
|
|
137
|
+
this.#builder = this.#replay(tail);
|
|
132
138
|
this.#selected = Math.max(0, this.#targets.length - 1);
|
|
133
139
|
this.#browser = new TranscriptBrowser({
|
|
134
140
|
getHeight: () => this.deps.ui.terminal?.rows || process.stdout.rows || 40,
|
|
@@ -142,7 +148,7 @@ export class RewindSelectorComponent implements Component {
|
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
#newBuilder(): ChatTranscriptBuilder {
|
|
145
|
-
|
|
151
|
+
const builder = new ChatTranscriptBuilder({
|
|
146
152
|
ui: this.deps.ui,
|
|
147
153
|
getTool: this.deps.getTool,
|
|
148
154
|
isBuiltInTool: this.deps.isBuiltInTool,
|
|
@@ -153,6 +159,30 @@ export class RewindSelectorComponent implements Component {
|
|
|
153
159
|
linkTargets: this.deps.linkTargets,
|
|
154
160
|
requestRender: this.deps.requestRender,
|
|
155
161
|
});
|
|
162
|
+
builder.setExpanded(this.#expanded);
|
|
163
|
+
return builder;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build a transcript for `entries` and adopt its targets. */
|
|
167
|
+
#replay(entries: TranscriptEntry[]): ChatTranscriptBuilder {
|
|
168
|
+
const builder = this.#newBuilder();
|
|
169
|
+
this.#targets = appendOutlineEntries(builder, entries);
|
|
170
|
+
return builder;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Replay the whole branch, keeping the main outline on the same turn. */
|
|
174
|
+
#loadFullHistory(): void {
|
|
175
|
+
if (!this.#truncated) return;
|
|
176
|
+
const selectedId = this.#targets[this.#selected]?.turnId;
|
|
177
|
+
const previous = this.#builder;
|
|
178
|
+
this.#builder = this.#replay(this.#entries);
|
|
179
|
+
previous.dispose();
|
|
180
|
+
this.#truncated = false;
|
|
181
|
+
this.#mainVisible = undefined;
|
|
182
|
+
this.#mainRows = [];
|
|
183
|
+
const restored = selectedId ? this.#targets.findIndex(target => target.turnId === selectedId) : -1;
|
|
184
|
+
this.#selected = restored >= 0 ? restored : Math.max(0, this.#targets.length - 1);
|
|
185
|
+
this.deps.requestRender();
|
|
156
186
|
}
|
|
157
187
|
|
|
158
188
|
invalidate(): void {
|
|
@@ -186,7 +216,6 @@ export class RewindSelectorComponent implements Component {
|
|
|
186
216
|
for (const sibling of this.deps.siblingPaths(target.turnId)) {
|
|
187
217
|
if (sibling.entries.length === 0) continue;
|
|
188
218
|
const builder = this.#newBuilder();
|
|
189
|
-
builder.setExpanded(this.#expanded);
|
|
190
219
|
const targets = appendOutlineEntries(builder, sibling.entries);
|
|
191
220
|
const firstUser = sibling.entries.find(isUserTurnEntry);
|
|
192
221
|
const label = (firstUser && userTurnLabel(firstUser)) || sibling.rootId;
|
|
@@ -257,6 +286,8 @@ export class RewindSelectorComponent implements Component {
|
|
|
257
286
|
return;
|
|
258
287
|
}
|
|
259
288
|
if (matchesKey(data, "f")) {
|
|
289
|
+
// The filter must search the whole branch, not just the startup tail.
|
|
290
|
+
this.#loadFullHistory();
|
|
260
291
|
this.#filter = "";
|
|
261
292
|
this.#activeVariant = 0;
|
|
262
293
|
this.#siblingSelected = 0;
|
|
@@ -291,6 +322,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
291
322
|
}
|
|
292
323
|
return;
|
|
293
324
|
}
|
|
325
|
+
if (data === "a" || data === "A") {
|
|
326
|
+
this.#loadFullHistory();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
294
329
|
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
295
330
|
const target = this.#outlinedTarget();
|
|
296
331
|
if (target) this.deps.onSelect(target.entryId);
|
|
@@ -384,15 +419,19 @@ export class RewindSelectorComponent implements Component {
|
|
|
384
419
|
|
|
385
420
|
/**
|
|
386
421
|
* Visible main-path target indices whose rendered text contains every
|
|
387
|
-
* whitespace-separated query word as a whole word (case-insensitive:
|
|
388
|
-
* matches `ls -la` but not `tools`)
|
|
422
|
+
* whitespace-separated Latin query word as a whole word (case-insensitive:
|
|
423
|
+
* `ls` matches `ls -la` but not `tools`). Other scripts match substrings so
|
|
424
|
+
* a Chinese query can find text inside a sentence without spaces.
|
|
425
|
+
* All visible targets match an empty query.
|
|
389
426
|
* Matching the rendered rows keeps results honest: collapsed tool output
|
|
390
427
|
* only matches once Ctrl+O expands it.
|
|
391
428
|
*/
|
|
392
429
|
#filterMatches(): number[] {
|
|
393
430
|
const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
394
|
-
const patterns = words.map(
|
|
395
|
-
|
|
431
|
+
const patterns = words.map(word =>
|
|
432
|
+
/^[\p{Script=Latin}\p{N}_]+$/u.test(word)
|
|
433
|
+
? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
|
|
434
|
+
: new RegExp(RegExp.escape(word), "u"),
|
|
396
435
|
);
|
|
397
436
|
const matches: number[] = [];
|
|
398
437
|
for (let index = 0; index < this.#targets.length; index++) {
|
|
@@ -492,7 +531,7 @@ export class RewindSelectorComponent implements Component {
|
|
|
492
531
|
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
493
532
|
const leftRight = formatKeyHints(["left", "right"]);
|
|
494
533
|
const lateral = columns.length > 0 ? `${leftRight} branches` : `${leftRight} user turns`;
|
|
495
|
-
const keys = `${upDown} step ${lateral} ${formatKeyHint("f")} filter ${formatKeyHint("enter")} rewind ${expandKeyHint()} expand ${editorKey("tui.select.cancel")} cancel`;
|
|
534
|
+
const keys = `${upDown} step ${lateral} ${formatKeyHint("f")} filter ${formatKeyHint("enter")} rewind ${this.#truncated ? `${formatKeyHint("a")} earlier turns ` : ""}${expandKeyHint()} expand ${editorKey("tui.select.cancel")} cancel`;
|
|
496
535
|
return {
|
|
497
536
|
header: [this.#header()],
|
|
498
537
|
body: {
|
|
@@ -67,12 +67,19 @@ export interface CardWindowRow {
|
|
|
67
67
|
usedText?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** A connected account whose usage lookup produced no attributable report. */
|
|
71
|
+
export interface UnavailableUsageAccount {
|
|
72
|
+
provider: string;
|
|
73
|
+
label: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
/** Compact per-provider summary backing one card in the subscriptions grid. */
|
|
71
77
|
export interface ProviderCard {
|
|
72
78
|
provider: string;
|
|
73
79
|
name: string;
|
|
74
|
-
/** Number of accounts
|
|
80
|
+
/** Number of represented accounts, including unavailable usage lookups. */
|
|
75
81
|
accounts: number;
|
|
82
|
+
unavailableAccounts: string[];
|
|
76
83
|
/** Window rows sorted most-pressing first. */
|
|
77
84
|
windows: CardWindowRow[];
|
|
78
85
|
/** True when every account reports no limits (e.g. enterprise plans). */
|
|
@@ -104,6 +111,15 @@ function aggregateStatus(limits: readonly { status?: UsageLimit["status"] }[]):
|
|
|
104
111
|
return "unknown";
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Card status when some connected accounts reported no usage: the missing
|
|
116
|
+
* report raises the card to a warning but never hides an exhausted quota.
|
|
117
|
+
*/
|
|
118
|
+
function statusWithUnavailableAccounts(windows: readonly { status?: UsageLimit["status"] }[]): UsageLimit["status"] {
|
|
119
|
+
if (windows.length === 0) return "unknown";
|
|
120
|
+
return aggregateStatus(windows) === "exhausted" ? "exhausted" : "warning";
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
/** Fraction below which a window counts as untouched (renders as 100% free). */
|
|
108
124
|
const IDLE_FRACTION = 0.005;
|
|
109
125
|
/**
|
|
@@ -129,7 +145,11 @@ function compactWindowTag(window: NonNullable<UsageLimit["window"]>): string {
|
|
|
129
145
|
* most-used account's reset countdown. Cards sort most-pressing first so
|
|
130
146
|
* what's burning is on top-left; fully idle providers collapse into a tick.
|
|
131
147
|
*/
|
|
132
|
-
export function buildProviderCards(
|
|
148
|
+
export function buildProviderCards(
|
|
149
|
+
reports: UsageReport[],
|
|
150
|
+
nowMs: number,
|
|
151
|
+
unavailableAccounts: readonly UnavailableUsageAccount[] = [],
|
|
152
|
+
): ProviderCard[] {
|
|
133
153
|
const displayReports = collapseSharedUsageReports(reports);
|
|
134
154
|
const grouped = new Map<string, UsageReport[]>();
|
|
135
155
|
for (const report of displayReports) {
|
|
@@ -137,9 +157,15 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
|
|
|
137
157
|
list.push(report);
|
|
138
158
|
grouped.set(report.provider, list);
|
|
139
159
|
}
|
|
160
|
+
for (const account of unavailableAccounts) {
|
|
161
|
+
if (!grouped.has(account.provider)) grouped.set(account.provider, []);
|
|
162
|
+
}
|
|
140
163
|
|
|
141
164
|
const cards: ProviderCard[] = [];
|
|
142
165
|
for (const [provider, providerReports] of grouped) {
|
|
166
|
+
const unavailable = unavailableAccounts
|
|
167
|
+
.filter(account => account.provider === provider)
|
|
168
|
+
.map(account => account.label);
|
|
143
169
|
const buckets = new Map<string, { label: string; limits: UsageLimit[] }>();
|
|
144
170
|
for (const report of providerReports) {
|
|
145
171
|
for (const limit of report.limits) {
|
|
@@ -220,10 +246,12 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
|
|
|
220
246
|
cards.push({
|
|
221
247
|
provider,
|
|
222
248
|
name: formatProviderName(provider),
|
|
223
|
-
accounts: providerReports.length,
|
|
249
|
+
accounts: providerReports.length + unavailable.length,
|
|
250
|
+
unavailableAccounts: unavailable,
|
|
224
251
|
windows,
|
|
225
|
-
unlimited: windows.length === 0,
|
|
252
|
+
unlimited: windows.length === 0 && unavailable.length === 0,
|
|
226
253
|
idle:
|
|
254
|
+
unavailable.length === 0 &&
|
|
227
255
|
!resetCredits &&
|
|
228
256
|
daybreakAccounts.length === 0 &&
|
|
229
257
|
windows.every(window => window.fraction !== undefined && window.fraction < IDLE_FRACTION),
|
|
@@ -328,6 +356,7 @@ export function buildHeatmapLayout(points: DailyActivityPoint[], weeks: number,
|
|
|
328
356
|
/** Callbacks and data sources for {@link UsageDashboardComponent}. */
|
|
329
357
|
export interface UsageDashboardOptions {
|
|
330
358
|
reports: UsageReport[];
|
|
359
|
+
unavailableAccounts?: readonly UnavailableUsageAccount[];
|
|
331
360
|
/**
|
|
332
361
|
* Full classic `/usage` report for the expanded detail view; re-invoked per
|
|
333
362
|
* terminal width.
|
|
@@ -395,7 +424,7 @@ export class UsageDashboardComponent implements Component {
|
|
|
395
424
|
ensureThemeSync();
|
|
396
425
|
this.#options = options;
|
|
397
426
|
this.#nowMs = Date.now();
|
|
398
|
-
this.#cards = buildProviderCards(options.reports, this.#nowMs);
|
|
427
|
+
this.#cards = buildProviderCards(options.reports, this.#nowMs, options.unavailableAccounts);
|
|
399
428
|
this.#panel = new OverlayPanel("Usage");
|
|
400
429
|
this.#header = new PanelRows();
|
|
401
430
|
this.#header.setHeight(1);
|
|
@@ -464,7 +493,12 @@ export class UsageDashboardComponent implements Component {
|
|
|
464
493
|
|
|
465
494
|
#renderCardLines(card: ProviderCard, width: number, labels: string[][], layout: CardRowLayout): string[] {
|
|
466
495
|
const lines: string[] = [];
|
|
467
|
-
const cardStatus =
|
|
496
|
+
const cardStatus =
|
|
497
|
+
card.unavailableAccounts.length > 0
|
|
498
|
+
? statusWithUnavailableAccounts(card.windows)
|
|
499
|
+
: card.unlimited
|
|
500
|
+
? "ok"
|
|
501
|
+
: aggregateStatus(card.windows);
|
|
468
502
|
const accountsText = card.accounts > 1 ? theme.fg("dim", `${card.accounts} accts`) : "";
|
|
469
503
|
const titleBudget = width - 2 - visibleWidth(accountsText) - (accountsText ? 1 : 0);
|
|
470
504
|
const title = theme.bold(truncateToWidth(card.name, Math.max(4, titleBudget)));
|
|
@@ -495,6 +529,13 @@ export class UsageDashboardComponent implements Component {
|
|
|
495
529
|
}
|
|
496
530
|
}
|
|
497
531
|
|
|
532
|
+
for (const account of card.unavailableAccounts) {
|
|
533
|
+
const text = sanitizeDisplayLine(`${account} — usage unavailable`);
|
|
534
|
+
for (const line of wrapTextWithAnsi(text, Math.max(1, width - 2))) {
|
|
535
|
+
lines.push(` ${theme.fg("dim", line)}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
498
539
|
if (card.unlimited) {
|
|
499
540
|
lines.push(` ${theme.fg("dim", "no limits")}`);
|
|
500
541
|
return lines;
|