@oh-my-pi/pi-tui 18.4.1 → 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 +14 -0
- package/dist/types/chrome/transcript-container.d.ts +15 -1
- package/dist/types/overlays/model-browser.d.ts +15 -0
- package/dist/types/overlays/rewind-selector.d.ts +2 -1
- package/dist/types/prompt/composer-attachments.d.ts +7 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
- package/dist/types/render/width-aware-text.d.ts +6 -0
- 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/tui.d.ts +3 -2
- package/package.json +9 -9
- package/src/chat/tool-execution.ts +24 -4
- package/src/chrome/transcript-container.ts +76 -14
- package/src/components/editor.ts +28 -6
- package/src/components/image.ts +15 -7
- package/src/components/loader.ts +37 -17
- package/src/overlays/agent-hub.ts +16 -6
- package/src/overlays/copy-selector.ts +5 -0
- package/src/overlays/model-browser.ts +106 -25
- package/src/overlays/rewind-selector.ts +10 -2
- package/src/prompt/composer-attachments.ts +54 -7
- package/src/prompt/composer.ts +5 -2
- package/src/prompt/custom-editor.ts +24 -6
- package/src/prompt/model-mention-autocomplete.ts +22 -10
- package/src/render/width-aware-text.ts +9 -0
- package/src/status-line/component.ts +213 -132
- package/src/status-line/segments.ts +34 -29
- package/src/terminal.ts +37 -11
- 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/tui.ts +142 -112
- package/src/utils.ts +15 -0
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 {
|
|
@@ -317,6 +317,11 @@ export class CopySelectorComponent implements Component {
|
|
|
317
317
|
}
|
|
318
318
|
index += delta;
|
|
319
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
|
+
}
|
|
320
325
|
}
|
|
321
326
|
|
|
322
327
|
// ========================================================================
|
|
@@ -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 {
|
|
@@ -22,7 +22,8 @@
|
|
|
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
24
|
* filter with the selection kept), Enter rewinds to the outlined item, A loads
|
|
25
|
-
* earlier turns without changing selection
|
|
25
|
+
* earlier turns without changing selection (stepping above the oldest replayed
|
|
26
|
+
* turn loads them too), Esc cancels.
|
|
26
27
|
*/
|
|
27
28
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
28
29
|
import {
|
|
@@ -471,7 +472,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
471
472
|
this.#move(delta, () => true);
|
|
472
473
|
}
|
|
473
474
|
|
|
474
|
-
/**
|
|
475
|
+
/**
|
|
476
|
+
* Step the main selection by `delta` to the nearest visible target passing
|
|
477
|
+
* `accept`; stepping above the replayed tail loads the earlier history first.
|
|
478
|
+
*/
|
|
475
479
|
#move(delta: -1 | 1, accept: (target: OutlineTarget) => boolean): void {
|
|
476
480
|
let index = this.#selected + delta;
|
|
477
481
|
while (index >= 0 && index < this.#targets.length) {
|
|
@@ -485,6 +489,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
485
489
|
}
|
|
486
490
|
index += delta;
|
|
487
491
|
}
|
|
492
|
+
if (delta < 0 && this.#truncated) {
|
|
493
|
+
this.#loadFullHistory();
|
|
494
|
+
this.#move(delta, accept);
|
|
495
|
+
}
|
|
488
496
|
}
|
|
489
497
|
|
|
490
498
|
#isMainSelectable(index: number): boolean {
|
|
@@ -50,9 +50,12 @@ const SKILL_ICONS = [...new Set(Object.values(SYMBOL_PRESETS).map(m => m["icon.e
|
|
|
50
50
|
/** Skill names as they appear in chips: word characters and dashes, dots only between segments. */
|
|
51
51
|
const SKILL_NAME_SOURCE = "[\\w-]+(?:\\.[\\w-]+)*";
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
/** Regex source for a chip glyph; word glyphs (ASCII preset) must not continue a preceding word. */
|
|
54
|
+
function glyphSource(icon: string): string {
|
|
55
|
+
return /^[a-z]+$/i.test(icon) ? `(?<![A-Za-z])${RegExp.escape(icon)}` : RegExp.escape(icon);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SKILL_CHIP_SOURCE = `(?:${SKILL_ICONS.map(glyphSource).join("|")}) (${SKILL_NAME_SOURCE})(?![\\w-])`;
|
|
56
59
|
|
|
57
60
|
/**
|
|
58
61
|
* Replaces `/skill:<name>` tokens for known skills with compact chip labels and
|
|
@@ -96,11 +99,16 @@ export function collapseModelMentions(
|
|
|
96
99
|
|
|
97
100
|
const CHIP_ICON_KEY = { image: "chip.image", video: "chip.video", paste: "chip.paste" } as const;
|
|
98
101
|
|
|
102
|
+
/** Chip glyph the active theme renders for `kind`, including `symbols.overrides`. */
|
|
103
|
+
function activeChipIcon(kind: ChipKind): string {
|
|
104
|
+
return typeof theme === "undefined"
|
|
105
|
+
? SYMBOL_PRESETS.unicode[CHIP_ICON_KEY[kind]]
|
|
106
|
+
: theme.symbol(CHIP_ICON_KEY[kind]);
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
/** Compact atomic composer token for attachment `n` in the active symbol preset. */
|
|
100
110
|
export function chipLabel(kind: ChipKind, n: number): string {
|
|
101
|
-
|
|
102
|
-
typeof theme === "undefined" ? SYMBOL_PRESETS.unicode[CHIP_ICON_KEY[kind]] : theme.symbol(CHIP_ICON_KEY[kind]);
|
|
103
|
-
return `${icon} #${n}`;
|
|
111
|
+
return `${activeChipIcon(kind)} #${n}`;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
/** Every glyph a chip token may start with, across all symbol presets. */
|
|
@@ -111,7 +119,7 @@ const CHIP_ICONS: Record<ChipKind, readonly string[]> = {
|
|
|
111
119
|
};
|
|
112
120
|
|
|
113
121
|
const CHIP_TOKEN_SOURCE = `(?:${[...CHIP_ICONS.image, ...CHIP_ICONS.video, ...CHIP_ICONS.paste]
|
|
114
|
-
.map(
|
|
122
|
+
.map(glyphSource)
|
|
115
123
|
.join("|")}) #[1-9]\\d*`;
|
|
116
124
|
|
|
117
125
|
/** Infers an attachment kind from a chip label emitted by any configured symbol preset. */
|
|
@@ -155,6 +163,45 @@ export const COMPOSER_TOKEN_REGEX = new RegExp(
|
|
|
155
163
|
"gu",
|
|
156
164
|
);
|
|
157
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Attachment indices referenced by a composer buffer, per kind. Image/video count compact chips
|
|
168
|
+
* and expanded markers; paste counts compact chips only, since `[Paste #N]` markers number the
|
|
169
|
+
* base editor's separate paste buffer. Registered labels take precedence over theme glyphs so
|
|
170
|
+
* tokens created before a theme switch keep their kind; full numbers prevent `#1` matching `#10`.
|
|
171
|
+
*/
|
|
172
|
+
export function referencedAttachments(
|
|
173
|
+
text: string,
|
|
174
|
+
recorded: ReadonlyMap<string, ChipKind>,
|
|
175
|
+
): Record<ChipKind, Set<number>> {
|
|
176
|
+
const kinds: readonly ChipKind[] = ["image", "video", "paste"];
|
|
177
|
+
const kindByIcon = new Map<string, ChipKind>();
|
|
178
|
+
for (const kind of kinds) for (const icon of CHIP_ICONS[kind]) kindByIcon.set(icon, kind);
|
|
179
|
+
for (const kind of kinds) kindByIcon.set(activeChipIcon(kind), kind);
|
|
180
|
+
const icons = [...kindByIcon.keys()].sort((a, b) => b.length - a.length).map(glyphSource);
|
|
181
|
+
const labels = [...recorded.keys()].sort((a, b) => b.length - a.length).map(label => RegExp.escape(label));
|
|
182
|
+
const recordedSource = labels.length > 0 ? labels.join("|") : "(?!)";
|
|
183
|
+
const scanner = new RegExp(
|
|
184
|
+
`${PLACEHOLDER_REGEX.source}|(${recordedSource})(?!\\d)|(${icons.join("|")}) #([1-9]\\d*)`,
|
|
185
|
+
"gu",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const refs: Record<ChipKind, Set<number>> = { image: new Set(), video: new Set(), paste: new Set() };
|
|
189
|
+
for (const match of text.matchAll(scanner)) {
|
|
190
|
+
if (match[1] === "Image") refs.image.add(Number(match[2]));
|
|
191
|
+
else if (match[1] === "Video") refs.video.add(Number(match[2]));
|
|
192
|
+
const label = match[3];
|
|
193
|
+
if (label !== undefined) {
|
|
194
|
+
const kind = recorded.get(label);
|
|
195
|
+
if (kind !== undefined) refs[kind].add(Number(label.slice(label.lastIndexOf("#") + 1)));
|
|
196
|
+
} else {
|
|
197
|
+
const icon = match[4];
|
|
198
|
+
const kind = icon === undefined ? undefined : kindByIcon.get(icon);
|
|
199
|
+
if (kind !== undefined) refs[kind].add(Number(match[5]));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return refs;
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
/** Add the registered model-chip labels to the composer placeholder matcher. */
|
|
159
206
|
export function composerTokenRegex(mentionLabels: Iterable<string>): RegExp {
|
|
160
207
|
const labels = [...new Set(mentionLabels)].sort((a, b) => b.length - a.length);
|
package/src/prompt/composer.ts
CHANGED
|
@@ -369,12 +369,15 @@ export class Composer implements TerminalFrameProvider {
|
|
|
369
369
|
// editor drifts up above a band of blank rows (#11007).
|
|
370
370
|
this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
|
|
371
371
|
const belowFloor = after.length - transientRows + this.#transientChromeFloor;
|
|
372
|
+
const now = performance.now();
|
|
373
|
+
const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
|
|
374
|
+
// Retirement measures the same live blocks the viewport lays out below;
|
|
375
|
+
// one open frame renders each of them once for both.
|
|
376
|
+
transcript.beginFrame(frame);
|
|
372
377
|
const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
|
|
373
378
|
const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
|
|
374
379
|
const headerRows = headerVisible ? this.#header.render(width) : [];
|
|
375
380
|
const before = [...headerRows, ...preRoots];
|
|
376
|
-
const now = performance.now();
|
|
377
|
-
const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
|
|
378
381
|
// The live tail is laid out against the same baseline retirement is
|
|
379
382
|
// billed against, so its compaction allocator (one row per block, no
|
|
380
383
|
// inter-block blanks) engages only when a block genuinely cannot retire.
|
|
@@ -14,6 +14,7 @@ import { imageAttachmentSource } from "./image-source";
|
|
|
14
14
|
import { isVideoPath } from "./video";
|
|
15
15
|
import {
|
|
16
16
|
attachmentSgr,
|
|
17
|
+
type ChipKind,
|
|
17
18
|
COMPOSER_TOKEN_REGEX,
|
|
18
19
|
chipLabel,
|
|
19
20
|
collapseImageMarkers,
|
|
@@ -21,6 +22,8 @@ import {
|
|
|
21
22
|
collapseSkillTokens,
|
|
22
23
|
composerTokenRegex,
|
|
23
24
|
modelChipStyle,
|
|
25
|
+
PLACEHOLDER_REGEX,
|
|
26
|
+
referencedAttachments,
|
|
24
27
|
renderPlaceholders,
|
|
25
28
|
skillChipLabel,
|
|
26
29
|
skillChipStyle,
|
|
@@ -662,14 +665,29 @@ export class CustomEditor extends Editor {
|
|
|
662
665
|
) {
|
|
663
666
|
return cached.chips;
|
|
664
667
|
}
|
|
665
|
-
const
|
|
668
|
+
const recorded = new Map<string, ChipKind>();
|
|
669
|
+
if (this.pendingImages.length > 0) {
|
|
670
|
+
for (const [label, expansion] of this.atoms) {
|
|
671
|
+
const kind = expansion.startsWith("[Image #")
|
|
672
|
+
? "image"
|
|
673
|
+
: expansion.startsWith("[Video #")
|
|
674
|
+
? "video"
|
|
675
|
+
: undefined;
|
|
676
|
+
if (kind !== undefined && expansion.match(PLACEHOLDER_REGEX)?.[0] === expansion) {
|
|
677
|
+
recorded.set(label, kind);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
for (const entry of this.pendingTexts) {
|
|
682
|
+
// A reused label belongs to the atom currently expanding it, not a deleted paste.
|
|
683
|
+
if (!recorded.has(entry.label)) recorded.set(entry.label, "paste");
|
|
684
|
+
}
|
|
685
|
+
const refs = referencedAttachments(this.getText(), recorded);
|
|
666
686
|
const chips: ComposerChipDescriptor[] = [];
|
|
667
687
|
for (let i = 0; i < this.pendingImages.length; i++) {
|
|
668
688
|
const n = i + 1;
|
|
669
|
-
const video =
|
|
670
|
-
|
|
671
|
-
const image =
|
|
672
|
-
text.includes(chipLabel("image", n)) || text.includes(`[Image #${n}]`) || text.includes(`[Image #${n},`);
|
|
689
|
+
const video = refs.video.has(n);
|
|
690
|
+
const image = refs.image.has(n);
|
|
673
691
|
if (!video && !image) continue;
|
|
674
692
|
chips.push({
|
|
675
693
|
kind: video ? "video" : "image",
|
|
@@ -679,7 +697,7 @@ export class CustomEditor extends Editor {
|
|
|
679
697
|
});
|
|
680
698
|
}
|
|
681
699
|
for (const entry of this.pendingTexts) {
|
|
682
|
-
if (!
|
|
700
|
+
if (!refs.paste.has(entry.n)) continue;
|
|
683
701
|
chips.push({ kind: "paste", n: entry.n, text: entry });
|
|
684
702
|
}
|
|
685
703
|
this.#composerChipsCache = {
|
|
@@ -4,9 +4,11 @@ import type { ModelBrowserRegistry, ModelBrowserSource } from "../overlays/model
|
|
|
4
4
|
import { modelMentionDisplayName } from "./model-mention-syntax";
|
|
5
5
|
import type {
|
|
6
6
|
buildSearchAffinity as BuildSearchAffinity,
|
|
7
|
-
buildSessionModelScope as BuildSessionModelScope,
|
|
8
7
|
ModelBrowserItem,
|
|
9
8
|
rankModelItems as RankModelItems,
|
|
9
|
+
SearchAffinity,
|
|
10
|
+
SessionModelScope,
|
|
11
|
+
SessionModelScopeCache as SessionModelScopeCacheClass,
|
|
10
12
|
} from "../overlays/model-browser";
|
|
11
13
|
import { theme } from "../theme/theme";
|
|
12
14
|
|
|
@@ -17,7 +19,7 @@ const MAX_MODEL_MENTION_SUGGESTIONS = 20;
|
|
|
17
19
|
export type ModelMentionCandidateSource = (query: string) => ReadonlyArray<ModelBrowserItem>;
|
|
18
20
|
|
|
19
21
|
interface ModelBrowserModules {
|
|
20
|
-
|
|
22
|
+
SessionModelScopeCache: typeof SessionModelScopeCacheClass;
|
|
21
23
|
buildSearchAffinity: typeof BuildSearchAffinity;
|
|
22
24
|
rankModelItems: typeof RankModelItems;
|
|
23
25
|
}
|
|
@@ -75,20 +77,30 @@ export function applyModelMentionCompletion(
|
|
|
75
77
|
};
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Create a session-scoped model candidate lookup using picker ordering. The
|
|
82
|
+
* scope and search affinity are reused across queries until their inputs change.
|
|
83
|
+
*/
|
|
79
84
|
export function createModelMentionSource(host: {
|
|
80
85
|
source: ModelBrowserSource;
|
|
81
86
|
registry: ModelBrowserRegistry;
|
|
82
87
|
scopedModels: () => ReadonlyArray<Model>;
|
|
83
88
|
}): ModelMentionCandidateSource {
|
|
89
|
+
let scopeCache: SessionModelScopeCacheClass | undefined;
|
|
90
|
+
let affinityScope: SessionModelScope | undefined;
|
|
91
|
+
let affinityProviderOrder: readonly string[] | undefined;
|
|
92
|
+
let affinity: SearchAffinity | undefined;
|
|
84
93
|
return query => {
|
|
85
|
-
const {
|
|
86
|
-
|
|
94
|
+
const { SessionModelScopeCache, buildSearchAffinity, rankModelItems } = loadModelBrowser();
|
|
95
|
+
scopeCache ??= new SessionModelScopeCache(host.source, host.registry);
|
|
96
|
+
const scope = scopeCache.get(host.scopedModels());
|
|
87
97
|
if (!query.trim()) return scope.items;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
const providerOrder = host.source.modelProviderOrder;
|
|
99
|
+
if (!affinity || affinityScope !== scope || affinityProviderOrder !== providerOrder) {
|
|
100
|
+
affinity = buildSearchAffinity(providerOrder, scope.roles, scope.mruOrder);
|
|
101
|
+
affinityScope = scope;
|
|
102
|
+
affinityProviderOrder = providerOrder;
|
|
103
|
+
}
|
|
104
|
+
return rankModelItems(query, scope.items, { roles: scope.roles, mruOrder: scope.mruOrder, affinity });
|
|
93
105
|
};
|
|
94
106
|
}
|
|
@@ -47,6 +47,15 @@ export class WidthAwareText implements Component {
|
|
|
47
47
|
this.#inner.invalidate();
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Re-run the formatter on the next render because its inputs changed.
|
|
52
|
+
* Unlike {@link invalidate}, the inner `Text` keeps its wrap cache, so a
|
|
53
|
+
* reformat that yields the same string skips re-wrapping and re-tinting.
|
|
54
|
+
*/
|
|
55
|
+
reformat(): void {
|
|
56
|
+
this.#cachedText = undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
render(width: number): readonly string[] {
|
|
51
60
|
const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
|
|
52
61
|
const contentWidth = Math.max(1, width - paddingX * 2);
|