@oh-my-pi/pi-tui 18.4.0 → 18.4.2
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 +42 -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 +24 -3
- package/dist/types/overlays/agents-hub.d.ts +7 -4
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/rewind-selector.d.ts +4 -3
- package/dist/types/overlays/usage-dashboard.d.ts +9 -2
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/composer-cache.d.ts +34 -16
- package/dist/types/prompt/composer.d.ts +15 -18
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/prompt/welcome.d.ts +5 -2
- package/dist/types/render/width-aware-text.d.ts +6 -0
- 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/terminal.d.ts +7 -0
- package/dist/types/theme/color.d.ts +1 -0
- package/dist/types/theme/session-color.d.ts +3 -3
- package/dist/types/theme/shimmer.d.ts +1 -1
- package/dist/types/theme/theme-class.d.ts +1 -1
- package/dist/types/tools/output-meta.d.ts +2 -1
- package/dist/types/tools/vibe.d.ts +4 -0
- package/dist/types/tui.d.ts +3 -2
- 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 +53 -17
- package/src/chat/transcript-entry.ts +16 -0
- package/src/chrome/transcript-container.ts +282 -86
- package/src/components/editor.ts +28 -6
- package/src/components/image.ts +25 -9
- package/src/components/loader.ts +37 -17
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/agents-hub.ts +7 -18
- package/src/overlays/copy-selector.ts +8 -31
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/model-hub.ts +49 -16
- package/src/overlays/rewind-selector.ts +60 -13
- package/src/overlays/usage-dashboard.ts +47 -6
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer-cache.ts +217 -196
- package/src/prompt/composer.ts +45 -72
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/prompt/welcome.ts +31 -28
- package/src/render/width-aware-text.ts +9 -0
- package/src/status-line/component.ts +286 -182
- package/src/status-line/metrics.ts +3 -21
- package/src/status-line/segments.ts +68 -79
- 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 +75 -20
- package/src/theme/color.ts +15 -0
- package/src/theme/session-color.ts +26 -4
- package/src/theme/shimmer.ts +55 -76
- package/src/theme/theme-class.ts +29 -12
- package/src/tools/output-meta.ts +11 -3
- package/src/tools/vibe.ts +15 -2
- package/src/tui.ts +208 -154
- package/src/utils.ts +15 -0
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,
|
|
@@ -84,7 +85,7 @@ interface SurfaceSplit {
|
|
|
84
85
|
* id so a partial pass reproduces the on-screen live/text split without a
|
|
85
86
|
* full, correctly-ordered walk.
|
|
86
87
|
*/
|
|
87
|
-
suppressedIds: Set<number>;
|
|
88
|
+
readonly suppressedIds: Set<number>;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
function newSurfaceSplit(): SurfaceSplit {
|
|
@@ -96,7 +97,7 @@ function resetSurfaceSplit(split: SurfaceSplit): void {
|
|
|
96
97
|
split.onTerminal = 0;
|
|
97
98
|
split.planned = 0;
|
|
98
99
|
split.lastTotal = 0;
|
|
99
|
-
split.suppressedIds
|
|
100
|
+
if (split.suppressedIds.size > 0) split.suppressedIds.clear();
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
|
|
@@ -283,8 +284,8 @@ export class ImageBudget {
|
|
|
283
284
|
*/
|
|
284
285
|
beginPass(stable = false, altScreen = false): void {
|
|
285
286
|
this.#passIds.length = 0;
|
|
286
|
-
this.#passSuppression.clear();
|
|
287
|
-
this.#passIndex.clear();
|
|
287
|
+
if (this.#passSuppression.size > 0) this.#passSuppression.clear();
|
|
288
|
+
if (this.#passIndex.size > 0) this.#passIndex.clear();
|
|
288
289
|
this.#stablePass = stable;
|
|
289
290
|
this.#surface = altScreen ? "alt" : "screen";
|
|
290
291
|
this.#split = altScreen ? this.#altSplit : this.#screenSplit;
|
|
@@ -295,7 +296,7 @@ export class ImageBudget {
|
|
|
295
296
|
// first. Note that leaving alt mode is not the same as unstacking a
|
|
296
297
|
// fullscreen overlay: the flush must exclude one that is still stacked
|
|
297
298
|
// from the pass itself, which is that caller's job, not this line's.
|
|
298
|
-
if (!altScreen) this.#liveIds.alt.clear();
|
|
299
|
+
if (!altScreen && this.#liveIds.alt.size > 0) this.#liveIds.alt.clear();
|
|
299
300
|
this.#applyingReset = !stable && this.#cap > 0 && this.#split.planned > this.#split.onTerminal;
|
|
300
301
|
}
|
|
301
302
|
|
|
@@ -348,7 +349,10 @@ export class ImageBudget {
|
|
|
348
349
|
// [0, onTerminal) is what this surface currently shows as text. Partial
|
|
349
350
|
// passes replay this per id (see #stablePass) instead of re-deriving it
|
|
350
351
|
// from a reversed, tail-only walk.
|
|
351
|
-
|
|
352
|
+
const suppressedIds = split.suppressedIds;
|
|
353
|
+
if (suppressedIds.size > 0) suppressedIds.clear();
|
|
354
|
+
const suppressedCount = Math.min(total, split.onTerminal);
|
|
355
|
+
for (let i = 0; i < suppressedCount; i++) suppressedIds.add(this.#passIds[i]);
|
|
352
356
|
return retry;
|
|
353
357
|
}
|
|
354
358
|
|
|
@@ -361,7 +365,12 @@ export class ImageBudget {
|
|
|
361
365
|
* the next pass on the *other* surface knows what it may not destroy.
|
|
362
366
|
*/
|
|
363
367
|
limitResidentImages(): void {
|
|
364
|
-
this.#liveIds[this.#surface]
|
|
368
|
+
const liveIds = this.#liveIds[this.#surface];
|
|
369
|
+
if (liveIds.size > 0) liveIds.clear();
|
|
370
|
+
for (let i = 0; i < this.#passIds.length; i++) {
|
|
371
|
+
const id = this.#passIds[i];
|
|
372
|
+
if (this.#passShowsLive(id)) liveIds.add(id);
|
|
373
|
+
}
|
|
365
374
|
const transmitted = this.#transmitted[this.#surface];
|
|
366
375
|
if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
|
|
367
376
|
for (const id of transmitted) {
|
|
@@ -765,6 +774,13 @@ export class Image implements Component {
|
|
|
765
774
|
// toward (and are demoted by) the budget; without a protocol every image is
|
|
766
775
|
// already text.
|
|
767
776
|
const suppressed = hasProtocol && this.#budget !== undefined ? this.#budget.observe(this.#imageId ?? 0) : false;
|
|
777
|
+
// Only Kitty images with a budget id transmit their data separately from
|
|
778
|
+
// the placement; a pending re-transmit (after a purge or history clear)
|
|
779
|
+
// must rebuild the lines. SIXEL and iTerm2 carry the image inside the line
|
|
780
|
+
// itself and never register a transmit, so gating their cache on it would
|
|
781
|
+
// re-encode the full image on every render pass.
|
|
782
|
+
const imageId = this.#imageId;
|
|
783
|
+
const transmitsSeparately = imageProtocol === ImageProtocol.Kitty && imageId != null;
|
|
768
784
|
|
|
769
785
|
if (
|
|
770
786
|
this.#cachedLines &&
|
|
@@ -774,7 +790,7 @@ export class Image implements Component {
|
|
|
774
790
|
this.#cachedCellWidthPx === cellDimensions.widthPx &&
|
|
775
791
|
this.#cachedCellHeightPx === cellDimensions.heightPx &&
|
|
776
792
|
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
|
|
777
|
-
(
|
|
793
|
+
(!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true)
|
|
778
794
|
) {
|
|
779
795
|
return this.#cachedLines;
|
|
780
796
|
}
|
|
@@ -787,7 +803,7 @@ export class Image implements Component {
|
|
|
787
803
|
if (hasProtocol && !suppressed) {
|
|
788
804
|
// Transmit the data once (keyed by id); thereafter renderImage returns
|
|
789
805
|
// just the placement, so repaints never re-send the base64.
|
|
790
|
-
const needsTransmit =
|
|
806
|
+
const needsTransmit = transmitsSeparately && (this.#budget?.shouldTransmit(imageId) ?? false);
|
|
791
807
|
const result = renderImage(this.#base64Data, this.#dimensions, {
|
|
792
808
|
maxWidthCells: maxWidth,
|
|
793
809
|
maxHeightCells: this.#options.maxHeightCells,
|
package/src/components/loader.ts
CHANGED
|
@@ -26,7 +26,14 @@ export class Loader extends Text {
|
|
|
26
26
|
#ui: TUI | null = null;
|
|
27
27
|
#lastSpinnerTick = 0;
|
|
28
28
|
#layoutSource?: readonly string[];
|
|
29
|
-
#layout?: readonly {
|
|
29
|
+
#layout?: readonly {
|
|
30
|
+
leading: string;
|
|
31
|
+
message: string;
|
|
32
|
+
trailing: string;
|
|
33
|
+
spinner: boolean;
|
|
34
|
+
separator: string;
|
|
35
|
+
bodyWidth?: number;
|
|
36
|
+
}[];
|
|
30
37
|
#layoutFrames: readonly string[];
|
|
31
38
|
#layoutFrame: string;
|
|
32
39
|
#trailer?: () => string | undefined;
|
|
@@ -72,14 +79,28 @@ export class Loader extends Text {
|
|
|
72
79
|
if (source !== this.#layoutSource) {
|
|
73
80
|
const paddingX = getPaddingX(1);
|
|
74
81
|
this.#layoutSource = source;
|
|
75
|
-
this.#layout = source.map(line => {
|
|
82
|
+
this.#layout = source.map((line, i) => {
|
|
76
83
|
const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
|
|
77
84
|
const body = clamped.slice(paddingX);
|
|
78
85
|
const content = body.trimEnd();
|
|
86
|
+
const leading = clamped.slice(0, paddingX);
|
|
87
|
+
const spinner = i === 0 && content.startsWith(this.#layoutFrame);
|
|
88
|
+
const remainder = spinner ? content.slice(this.#layoutFrame.length) : content;
|
|
89
|
+
const separator = spinner && remainder.startsWith(" ") ? " " : "";
|
|
90
|
+
const message = remainder.slice(separator.length);
|
|
91
|
+
const plainMessage = i === 0 ? Bun.stripANSI(message) : "";
|
|
92
|
+
// A visible non-whitespace ending makes trimEnd independent of
|
|
93
|
+
// colorizer ANSI placement. The separator isolates spinner width
|
|
94
|
+
// from message graphemes; other cases retain whole-body measuring.
|
|
95
|
+
const stableBodyWidth =
|
|
96
|
+
plainMessage.length > 0 && plainMessage.trimEnd() === plainMessage && (!spinner || separator === " ");
|
|
79
97
|
return {
|
|
80
|
-
leading
|
|
81
|
-
|
|
98
|
+
leading,
|
|
99
|
+
message,
|
|
82
100
|
trailing: body.slice(content.length),
|
|
101
|
+
spinner,
|
|
102
|
+
separator,
|
|
103
|
+
bodyWidth: stableBodyWidth ? visibleWidth(leading + separator + message) : undefined,
|
|
83
104
|
};
|
|
84
105
|
});
|
|
85
106
|
}
|
|
@@ -88,28 +109,27 @@ export class Loader extends Text {
|
|
|
88
109
|
// The wrapped text carries one stable representative per frame width.
|
|
89
110
|
// Same-width frames swap only the visible glyph here; crossing widths
|
|
90
111
|
// rewraps against the representative selected by #syncText.
|
|
91
|
-
const sentinel = this.#layoutFrame;
|
|
92
112
|
const lines = [""];
|
|
93
113
|
const layout = this.#layout ?? [];
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
const separator = remainder.startsWith(" ") ? " " : "";
|
|
99
|
-
const message = remainder.slice(separator.length);
|
|
100
|
-
lines.push(
|
|
101
|
-
`${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
|
|
102
|
-
);
|
|
103
|
-
} else {
|
|
104
|
-
lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
|
|
114
|
+
let coloredSpinner = "";
|
|
115
|
+
for (const { leading, message, trailing, spinner, separator } of layout) {
|
|
116
|
+
if (spinner) {
|
|
117
|
+
coloredSpinner = this.spinnerColorFn(frame);
|
|
105
118
|
}
|
|
119
|
+
lines.push(
|
|
120
|
+
`${leading}${spinner ? coloredSpinner : ""}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
|
|
121
|
+
);
|
|
106
122
|
}
|
|
107
123
|
if (this.#trailer && lines.length > 1) {
|
|
108
124
|
const trailer = this.#trailer();
|
|
109
125
|
if (trailer) {
|
|
110
126
|
// Text pads rows to full width; drop that pad before docking right.
|
|
111
127
|
const body = lines[1].trimEnd();
|
|
112
|
-
const
|
|
128
|
+
const bodyWidth =
|
|
129
|
+
layout[0].bodyWidth === undefined
|
|
130
|
+
? visibleWidth(body)
|
|
131
|
+
: layout[0].bodyWidth + visibleWidth(coloredSpinner);
|
|
132
|
+
const gap = width - bodyWidth - visibleWidth(trailer);
|
|
113
133
|
if (gap >= 2) lines[1] = body + padding(gap) + trailer;
|
|
114
134
|
}
|
|
115
135
|
}
|
|
@@ -102,13 +102,23 @@ function activityGlyph(row: AgentActivityRow): string {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
const ACTIVITY_CLOCK_FORMAT = new Intl.DateTimeFormat(undefined, {
|
|
106
|
+
hour: "2-digit",
|
|
107
|
+
minute: "2-digit",
|
|
108
|
+
second: "2-digit",
|
|
109
|
+
hour12: false,
|
|
110
|
+
});
|
|
111
|
+
const ACTIVITY_CLOCK_CACHE_LIMIT = 512;
|
|
112
|
+
const activityClockCache = new Map<number, string>();
|
|
113
|
+
|
|
105
114
|
function activityClock(timestamp: number): string {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
115
|
+
let text = activityClockCache.get(timestamp);
|
|
116
|
+
if (text === undefined) {
|
|
117
|
+
text = ACTIVITY_CLOCK_FORMAT.format(timestamp);
|
|
118
|
+
if (activityClockCache.size >= ACTIVITY_CLOCK_CACHE_LIMIT) activityClockCache.clear();
|
|
119
|
+
activityClockCache.set(timestamp, text);
|
|
120
|
+
}
|
|
121
|
+
return text;
|
|
112
122
|
}
|
|
113
123
|
/** Result of one host-backed transcript read for the Agent Hub viewer. */
|
|
114
124
|
export interface AgentHubRemoteTranscript {
|
|
@@ -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);
|
|
@@ -324,6 +317,11 @@ export class CopySelectorComponent implements Component {
|
|
|
324
317
|
}
|
|
325
318
|
index += delta;
|
|
326
319
|
}
|
|
320
|
+
// Stepping above the replayed tail continues into the earlier history.
|
|
321
|
+
if (delta < 0 && this.#truncated) {
|
|
322
|
+
this.#loadFullHistory();
|
|
323
|
+
this.#moveVertical(delta);
|
|
324
|
+
}
|
|
327
325
|
}
|
|
328
326
|
|
|
329
327
|
// ========================================================================
|
|
@@ -478,27 +476,6 @@ export class CopySelectorComponent implements Component {
|
|
|
478
476
|
}
|
|
479
477
|
}
|
|
480
478
|
|
|
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
479
|
/** Raw multi-line text of a user message (string or text blocks). */
|
|
503
480
|
function rawUserText(message: Extract<SessionMessageEntry["message"], { role: "user" }>): string {
|
|
504
481
|
if (typeof message.content === "string") return message.content;
|
|
@@ -113,6 +113,11 @@ export interface ResolvedModelRoleValue {
|
|
|
113
113
|
|
|
114
114
|
/** Host-provided preferences and model-role resolution for the browser. */
|
|
115
115
|
export interface ModelBrowserSource extends ModelRoleLookup {
|
|
116
|
+
/**
|
|
117
|
+
* Changes whenever any preference this source reads or resolves against changes,
|
|
118
|
+
* except the storage-backed `mruOrder` and `modelPerf`. Keys derived-scope caches.
|
|
119
|
+
*/
|
|
120
|
+
readonly revision: number;
|
|
116
121
|
readonly defaultThinkingLevel: string;
|
|
117
122
|
readonly modelProviderOrder: readonly string[];
|
|
118
123
|
readonly knownRoleIds: readonly string[];
|
|
@@ -175,16 +180,32 @@ export function resolveRoleAssignments(
|
|
|
175
180
|
return ThinkingLevel.Inherit;
|
|
176
181
|
};
|
|
177
182
|
|
|
183
|
+
// Roles sharing an `accepts` predicate share one filtered array, so the
|
|
184
|
+
// resolver's array-keyed indexes are built once per pool, not once per role.
|
|
185
|
+
const eligible = (
|
|
186
|
+
pool: ReadonlyArray<Model>,
|
|
187
|
+
byAccepts: Map<ModelBrowserRoleInfo["accepts"], Model[]>,
|
|
188
|
+
role: string,
|
|
189
|
+
): Model[] => {
|
|
190
|
+
const accepts = settings.getRoleInfo(role).accepts;
|
|
191
|
+
let models = byAccepts.get(accepts);
|
|
192
|
+
if (!models) {
|
|
193
|
+
models = pool.filter(accepts);
|
|
194
|
+
byAccepts.set(accepts, models);
|
|
195
|
+
}
|
|
196
|
+
return models;
|
|
197
|
+
};
|
|
198
|
+
|
|
178
199
|
const roles: RoleAssignments = {};
|
|
179
200
|
const knownRoles = settings.knownRoleIds;
|
|
180
201
|
const configuredRoles = new Set<string>();
|
|
181
|
-
const
|
|
202
|
+
const catalogByAccepts = new Map<ModelBrowserRoleInfo["accepts"], Model[]>();
|
|
182
203
|
|
|
183
204
|
for (const role of knownRoles) {
|
|
184
205
|
const roleValue = settings.getModelRole(role);
|
|
185
206
|
if (!roleValue) continue;
|
|
186
207
|
configuredRoles.add(role);
|
|
187
|
-
const resolved = settings.resolveRoleValue(roleValue,
|
|
208
|
+
const resolved = settings.resolveRoleValue(roleValue, eligible(allModels, catalogByAccepts, role));
|
|
188
209
|
if (resolved.model) {
|
|
189
210
|
roles[role] = {
|
|
190
211
|
model: resolved.model,
|
|
@@ -195,13 +216,10 @@ export function resolveRoleAssignments(
|
|
|
195
216
|
}
|
|
196
217
|
|
|
197
218
|
if (autoCandidates.length > 0) {
|
|
198
|
-
const
|
|
219
|
+
const candidatesByAccepts = new Map<ModelBrowserRoleInfo["accepts"], Model[]>();
|
|
199
220
|
for (const role of knownRoles) {
|
|
200
221
|
if (configuredRoles.has(role)) continue;
|
|
201
|
-
const resolved = settings.resolveRoleValue(
|
|
202
|
-
`pi/${role}`,
|
|
203
|
-
candidates.filter(settings.getRoleInfo(role).accepts),
|
|
204
|
-
);
|
|
222
|
+
const resolved = settings.resolveRoleValue(`pi/${role}`, eligible(autoCandidates, candidatesByAccepts, role));
|
|
205
223
|
if (!resolved.model) continue;
|
|
206
224
|
roles[role] = {
|
|
207
225
|
model: resolved.model,
|
|
@@ -332,32 +350,95 @@ export interface SessionModelScope {
|
|
|
332
350
|
error: string | undefined;
|
|
333
351
|
}
|
|
334
352
|
|
|
353
|
+
/** Catalog inputs a {@link SessionModelScope} is derived from. */
|
|
354
|
+
interface SessionModelScopeInputs {
|
|
355
|
+
models: ReadonlyArray<Model>;
|
|
356
|
+
allModels: ReadonlyArray<Model>;
|
|
357
|
+
error: string | undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readSessionModelScopeInputs(
|
|
361
|
+
registry: ModelBrowserRegistry,
|
|
362
|
+
scopedModels: ReadonlyArray<Model>,
|
|
363
|
+
): SessionModelScopeInputs {
|
|
364
|
+
if (scopedModels.length > 0) return { models: scopedModels, allModels: scopedModels, error: undefined };
|
|
365
|
+
const loadError = registry.getError();
|
|
366
|
+
let error = loadError ? String(loadError) : undefined;
|
|
367
|
+
let models: ReadonlyArray<Model>;
|
|
368
|
+
try {
|
|
369
|
+
models = registry.getAvailable();
|
|
370
|
+
} catch (cause) {
|
|
371
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
372
|
+
models = [];
|
|
373
|
+
}
|
|
374
|
+
return { models, allModels: registry.getAll("all"), error };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function scopeFromInputs(settings: ModelBrowserSource, inputs: SessionModelScopeInputs): SessionModelScope {
|
|
378
|
+
const roles = resolveRoleAssignments(settings, inputs.allModels, inputs.models);
|
|
379
|
+
const mruOrder = settings.mruOrder;
|
|
380
|
+
const items = buildBrowserItems(inputs.models);
|
|
381
|
+
sortModelItems(items, { roles, mruOrder });
|
|
382
|
+
return { items, roles, mruOrder, error: inputs.error };
|
|
383
|
+
}
|
|
384
|
+
|
|
335
385
|
/** Build the session picker's current scope without creating an interactive browser. */
|
|
336
386
|
export function buildSessionModelScope(
|
|
337
387
|
settings: ModelBrowserSource,
|
|
338
388
|
registry: ModelBrowserRegistry,
|
|
339
389
|
scopedModels: ReadonlyArray<Model>,
|
|
340
390
|
): SessionModelScope {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
391
|
+
return scopeFromInputs(settings, readSessionModelScopeInputs(registry, scopedModels));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sameEntries<T>(a: ReadonlyArray<T>, b: ReadonlyArray<T>): boolean {
|
|
395
|
+
if (a === b) return true;
|
|
396
|
+
if (a.length !== b.length) return false;
|
|
397
|
+
for (let i = 0; i < a.length; i++) {
|
|
398
|
+
if (a[i] !== b[i]) return false;
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* {@link buildSessionModelScope} for per-keystroke callers: returns the same
|
|
405
|
+
* scope until the source revision, MRU order, scoped models, or the registry's
|
|
406
|
+
* available models, catalog, or load error change.
|
|
407
|
+
*/
|
|
408
|
+
export class SessionModelScopeCache {
|
|
409
|
+
#settings: ModelBrowserSource;
|
|
410
|
+
#registry: ModelBrowserRegistry;
|
|
411
|
+
#revision = 0;
|
|
412
|
+
#inputs: SessionModelScopeInputs | undefined;
|
|
413
|
+
#scope: SessionModelScope | undefined;
|
|
414
|
+
|
|
415
|
+
constructor(settings: ModelBrowserSource, registry: ModelBrowserRegistry) {
|
|
416
|
+
this.#settings = settings;
|
|
417
|
+
this.#registry = registry;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
get(scopedModels: ReadonlyArray<Model>): SessionModelScope {
|
|
421
|
+
const revision = this.#settings.revision;
|
|
422
|
+
const inputs = readSessionModelScopeInputs(this.#registry, scopedModels);
|
|
423
|
+
const cachedInputs = this.#inputs;
|
|
424
|
+
const cached = this.#scope;
|
|
425
|
+
if (
|
|
426
|
+
cached &&
|
|
427
|
+
cachedInputs &&
|
|
428
|
+
revision === this.#revision &&
|
|
429
|
+
inputs.error === cachedInputs.error &&
|
|
430
|
+
sameEntries(inputs.models, cachedInputs.models) &&
|
|
431
|
+
sameEntries(inputs.allModels, cachedInputs.allModels) &&
|
|
432
|
+
sameEntries(this.#settings.mruOrder, cached.mruOrder)
|
|
433
|
+
) {
|
|
434
|
+
return cached;
|
|
353
435
|
}
|
|
436
|
+
const scope = scopeFromInputs(this.#settings, inputs);
|
|
437
|
+
this.#revision = revision;
|
|
438
|
+
this.#inputs = inputs;
|
|
439
|
+
this.#scope = scope;
|
|
440
|
+
return scope;
|
|
354
441
|
}
|
|
355
|
-
const allModels = scopedModels.length > 0 ? models : registry.getAll("all");
|
|
356
|
-
const roles = resolveRoleAssignments(settings, allModels, models);
|
|
357
|
-
const mruOrder = settings.mruOrder;
|
|
358
|
-
const items = buildBrowserItems(models);
|
|
359
|
-
sortModelItems(items, { roles, mruOrder });
|
|
360
|
-
return { items, roles, mruOrder, error };
|
|
361
442
|
}
|
|
362
443
|
|
|
363
444
|
interface RoleProviderStats {
|
|
@@ -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
|