@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
|
@@ -21,8 +21,9 @@
|
|
|
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
|
-
*
|
|
24
|
+
* filter with the selection kept), Enter rewinds to the outlined item, A loads
|
|
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 {
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
truncateToWidth,
|
|
37
38
|
} from "../index";
|
|
38
39
|
import type { MessageRenderer } from "../chat/extension-types";
|
|
39
|
-
import type
|
|
40
|
+
import { recentTranscriptEntries, type TranscriptEntryLike as TranscriptEntry } from "../chat/transcript-entry";
|
|
40
41
|
import { theme } from "../theme/theme";
|
|
41
42
|
import { matchesAppToolsExpand, matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
|
|
42
43
|
import { ChatTranscriptBuilder } from "../chat/chat-transcript-builder";
|
|
@@ -116,6 +117,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
116
117
|
#slide: { from: number; to: number; startedAt: number } | undefined;
|
|
117
118
|
#slideTimer: NodeJS.Timeout | undefined;
|
|
118
119
|
|
|
120
|
+
/** Whole branch; the selector may currently replay only its tail. */
|
|
121
|
+
#entries: TranscriptEntry[];
|
|
122
|
+
/** True while older history is still unreplayed. */
|
|
123
|
+
#truncated = false;
|
|
119
124
|
/** Filter query while the filter prompt is open; undefined shows the full transcript. */
|
|
120
125
|
#filter: string | undefined;
|
|
121
126
|
/** Main transcript rows from the last frame; the filter matches what is on screen. */
|
|
@@ -127,8 +132,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
127
132
|
entries: TranscriptEntry[],
|
|
128
133
|
private readonly deps: RewindSelectorDeps,
|
|
129
134
|
) {
|
|
130
|
-
this.#
|
|
131
|
-
|
|
135
|
+
this.#entries = entries;
|
|
136
|
+
const tail = recentTranscriptEntries(entries);
|
|
137
|
+
this.#truncated = tail.length < entries.length;
|
|
138
|
+
this.#builder = this.#replay(tail);
|
|
132
139
|
this.#selected = Math.max(0, this.#targets.length - 1);
|
|
133
140
|
this.#browser = new TranscriptBrowser({
|
|
134
141
|
getHeight: () => this.deps.ui.terminal?.rows || process.stdout.rows || 40,
|
|
@@ -142,7 +149,7 @@ export class RewindSelectorComponent implements Component {
|
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
#newBuilder(): ChatTranscriptBuilder {
|
|
145
|
-
|
|
152
|
+
const builder = new ChatTranscriptBuilder({
|
|
146
153
|
ui: this.deps.ui,
|
|
147
154
|
getTool: this.deps.getTool,
|
|
148
155
|
isBuiltInTool: this.deps.isBuiltInTool,
|
|
@@ -153,6 +160,30 @@ export class RewindSelectorComponent implements Component {
|
|
|
153
160
|
linkTargets: this.deps.linkTargets,
|
|
154
161
|
requestRender: this.deps.requestRender,
|
|
155
162
|
});
|
|
163
|
+
builder.setExpanded(this.#expanded);
|
|
164
|
+
return builder;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Build a transcript for `entries` and adopt its targets. */
|
|
168
|
+
#replay(entries: TranscriptEntry[]): ChatTranscriptBuilder {
|
|
169
|
+
const builder = this.#newBuilder();
|
|
170
|
+
this.#targets = appendOutlineEntries(builder, entries);
|
|
171
|
+
return builder;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Replay the whole branch, keeping the main outline on the same turn. */
|
|
175
|
+
#loadFullHistory(): void {
|
|
176
|
+
if (!this.#truncated) return;
|
|
177
|
+
const selectedId = this.#targets[this.#selected]?.turnId;
|
|
178
|
+
const previous = this.#builder;
|
|
179
|
+
this.#builder = this.#replay(this.#entries);
|
|
180
|
+
previous.dispose();
|
|
181
|
+
this.#truncated = false;
|
|
182
|
+
this.#mainVisible = undefined;
|
|
183
|
+
this.#mainRows = [];
|
|
184
|
+
const restored = selectedId ? this.#targets.findIndex(target => target.turnId === selectedId) : -1;
|
|
185
|
+
this.#selected = restored >= 0 ? restored : Math.max(0, this.#targets.length - 1);
|
|
186
|
+
this.deps.requestRender();
|
|
156
187
|
}
|
|
157
188
|
|
|
158
189
|
invalidate(): void {
|
|
@@ -186,7 +217,6 @@ export class RewindSelectorComponent implements Component {
|
|
|
186
217
|
for (const sibling of this.deps.siblingPaths(target.turnId)) {
|
|
187
218
|
if (sibling.entries.length === 0) continue;
|
|
188
219
|
const builder = this.#newBuilder();
|
|
189
|
-
builder.setExpanded(this.#expanded);
|
|
190
220
|
const targets = appendOutlineEntries(builder, sibling.entries);
|
|
191
221
|
const firstUser = sibling.entries.find(isUserTurnEntry);
|
|
192
222
|
const label = (firstUser && userTurnLabel(firstUser)) || sibling.rootId;
|
|
@@ -257,6 +287,8 @@ export class RewindSelectorComponent implements Component {
|
|
|
257
287
|
return;
|
|
258
288
|
}
|
|
259
289
|
if (matchesKey(data, "f")) {
|
|
290
|
+
// The filter must search the whole branch, not just the startup tail.
|
|
291
|
+
this.#loadFullHistory();
|
|
260
292
|
this.#filter = "";
|
|
261
293
|
this.#activeVariant = 0;
|
|
262
294
|
this.#siblingSelected = 0;
|
|
@@ -291,6 +323,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
291
323
|
}
|
|
292
324
|
return;
|
|
293
325
|
}
|
|
326
|
+
if (data === "a" || data === "A") {
|
|
327
|
+
this.#loadFullHistory();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
294
330
|
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
295
331
|
const target = this.#outlinedTarget();
|
|
296
332
|
if (target) this.deps.onSelect(target.entryId);
|
|
@@ -384,15 +420,19 @@ export class RewindSelectorComponent implements Component {
|
|
|
384
420
|
|
|
385
421
|
/**
|
|
386
422
|
* 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`)
|
|
423
|
+
* whitespace-separated Latin query word as a whole word (case-insensitive:
|
|
424
|
+
* `ls` matches `ls -la` but not `tools`). Other scripts match substrings so
|
|
425
|
+
* a Chinese query can find text inside a sentence without spaces.
|
|
426
|
+
* All visible targets match an empty query.
|
|
389
427
|
* Matching the rendered rows keeps results honest: collapsed tool output
|
|
390
428
|
* only matches once Ctrl+O expands it.
|
|
391
429
|
*/
|
|
392
430
|
#filterMatches(): number[] {
|
|
393
431
|
const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
394
|
-
const patterns = words.map(
|
|
395
|
-
|
|
432
|
+
const patterns = words.map(word =>
|
|
433
|
+
/^[\p{Script=Latin}\p{N}_]+$/u.test(word)
|
|
434
|
+
? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
|
|
435
|
+
: new RegExp(RegExp.escape(word), "u"),
|
|
396
436
|
);
|
|
397
437
|
const matches: number[] = [];
|
|
398
438
|
for (let index = 0; index < this.#targets.length; index++) {
|
|
@@ -432,7 +472,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
432
472
|
this.#move(delta, () => true);
|
|
433
473
|
}
|
|
434
474
|
|
|
435
|
-
/**
|
|
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
|
+
*/
|
|
436
479
|
#move(delta: -1 | 1, accept: (target: OutlineTarget) => boolean): void {
|
|
437
480
|
let index = this.#selected + delta;
|
|
438
481
|
while (index >= 0 && index < this.#targets.length) {
|
|
@@ -446,6 +489,10 @@ export class RewindSelectorComponent implements Component {
|
|
|
446
489
|
}
|
|
447
490
|
index += delta;
|
|
448
491
|
}
|
|
492
|
+
if (delta < 0 && this.#truncated) {
|
|
493
|
+
this.#loadFullHistory();
|
|
494
|
+
this.#move(delta, accept);
|
|
495
|
+
}
|
|
449
496
|
}
|
|
450
497
|
|
|
451
498
|
#isMainSelectable(index: number): boolean {
|
|
@@ -492,7 +539,7 @@ export class RewindSelectorComponent implements Component {
|
|
|
492
539
|
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
493
540
|
const leftRight = formatKeyHints(["left", "right"]);
|
|
494
541
|
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`;
|
|
542
|
+
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
543
|
return {
|
|
497
544
|
header: [this.#header()],
|
|
498
545
|
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;
|
|
@@ -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);
|