@genee/omp-opsx-addon 0.7.0 → 0.9.0

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.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Shared /pick-model rendering (change: pick-model-ux).
3
+ *
4
+ * One compact block skeleton for all four command outputs (choices /
5
+ * selector switch / refresh / reset) plus error paths:
6
+ * 状态行 → 明细区(变更行或 agent 清单)→ 候选概览 → 脚注
7
+ *
8
+ * - No markdown tables, no horizontal rules, no emoji. Status glyphs come
9
+ * from the host `SYMBOL_PRESETS` table, resolved per render from the
10
+ * `symbolPreset` setting (invalid/missing → `unicode`). The TUI theme
11
+ * singleton is deliberately NOT consulted: headless and in-process
12
+ * subagent contexts may not have it initialized.
13
+ * - Internal quantities (tier=, gap=, class=) are translated to plain
14
+ * phrases for display; the data-layer fields (`pickedReason`/`decision`
15
+ * with their `class=` annotations) are untouched.
16
+ * - Agent inventory classification mirrors OMP native resolution
17
+ * (`resolveEffectiveAgentModelSelection`, `isSessionInheritedAgentPattern`,
18
+ * `resolveConfiguredRolePattern`).
19
+ *
20
+ * Everything here is a pure function; index.ts assembles the parts and
21
+ * calls `renderPickModelReport` once per command output.
22
+ */
23
+ import { SYMBOL_PRESETS, isValidSymbolPreset } from '@oh-my-pi/pi-coding-agent/modes/theme/symbols';
24
+ import type { AgentDefinition } from '@oh-my-pi/pi-coding-agent/task/types';
25
+ import { providerDisplayName } from './provider-variants.js';
26
+
27
+ /** Minimal settings surface for the `symbolPreset` read (host `pi.pi.settings`). */
28
+ export interface SymbolSettings {
29
+ get(key: string): unknown;
30
+ }
31
+
32
+ /** Internal glyph set used by every pick-model output (design D2). */
33
+ export interface RenderSymbols {
34
+ /** 成功/已应用(status.success) */
35
+ success: string;
36
+ /** 失败/错误(status.error) */
37
+ error: string;
38
+ /** 警告/受限(status.warning) */
39
+ warning: string;
40
+ /** 候选/未选中(status.pending) */
41
+ pending: string;
42
+ /** 选中/主会话/状态行(status.info) */
43
+ selected: string;
44
+ /** agent 清单「跟随切换」(status.pending) */
45
+ follow: string;
46
+ /** agent 清单「固定」(status.shadowed) */
47
+ fixed: string;
48
+ }
49
+
50
+ /**
51
+ * Read the current preset per render; undefined/invalid — or a settings
52
+ * read that throws (early startup / partial init) — falls back to unicode
53
+ * without ever surfacing an error into a command output.
54
+ */
55
+ export function resolveSymbols(settings?: SymbolSettings | null): RenderSymbols {
56
+ let raw: unknown;
57
+ try {
58
+ raw = settings?.get('symbolPreset');
59
+ } catch {
60
+ raw = undefined;
61
+ }
62
+ const preset = typeof raw === 'string' && isValidSymbolPreset(raw) ? raw : 'unicode';
63
+ const map = SYMBOL_PRESETS[preset];
64
+ return {
65
+ success: map['status.success'],
66
+ error: map['status.error'],
67
+ warning: map['status.warning'],
68
+ pending: map['status.pending'],
69
+ selected: map['status.info'],
70
+ follow: map['status.pending'],
71
+ fixed: map['status.shadowed'],
72
+ };
73
+ }
74
+
75
+ // ── 人话文案映射(design D3)────────────────────────────────────────
76
+
77
+ const COVERAGE_HUMAN: Record<string, string> = {
78
+ '0': '计划内',
79
+ '1': '按量直连',
80
+ '2': '按量兜底',
81
+ plan: '计划内',
82
+ standard: '按量直连',
83
+ payg: '按量兜底',
84
+ '①plan': '计划内',
85
+ '②standard': '按量直连',
86
+ '③payg': '按量兜底',
87
+ };
88
+
89
+ /** Coverage class (numeric, `class=…`, or `①plan` form) → plain phrase. */
90
+ export function coverageLabel(cls: number | string | undefined | null): string {
91
+ if (cls === undefined || cls === null) return '';
92
+ const key = String(cls);
93
+ return COVERAGE_HUMAN[key] ?? key;
94
+ }
95
+
96
+ const TIER_HUMAN: Record<string, string> = {
97
+ tiny: '轻量',
98
+ low: '低档',
99
+ mid: '中档',
100
+ high: '高档',
101
+ top: '旗舰',
102
+ };
103
+
104
+ /** Tier name → plain phrase (轻量/低档/中档/高档/旗舰). */
105
+ export function tierLabel(tier: string | undefined | null): string {
106
+ if (!tier) return '';
107
+ return TIER_HUMAN[tier] ?? tier;
108
+ }
109
+
110
+ const ROLE_KIND_HUMAN: Record<string, string> = {
111
+ auto: '自动',
112
+ 'omp-alias': 'role 链',
113
+ model: '已指定',
114
+ 'provider-model': '已指定',
115
+ };
116
+
117
+ /** Role config kind → plain phrase (已指定/自动/role 链). */
118
+ export function roleKindLabel(kind: string | undefined | null): string {
119
+ if (!kind) return '';
120
+ return ROLE_KIND_HUMAN[kind] ?? kind;
121
+ }
122
+
123
+ // ── agent 来源分类(design D4)──────────────────────────────────────
124
+
125
+ /** Classification of one agent's model resolution source. */
126
+ export interface AgentModelSource {
127
+ /** Plain-phrase label; carries the alias/literal where applicable. */
128
+ label: string;
129
+ /** Whether the agent's model follows /pick-model switches. */
130
+ follows: boolean;
131
+ }
132
+
133
+ /**
134
+ * Classify an agent by its frontmatter `model` patterns (design D4 table).
135
+ * Missing / `default` / `@default` / `@task` → inherit the primary session
136
+ * model; `@smol`/`@slow` follow the written roles; `@designer` follows via
137
+ * default inheritance; `@tiny`/`@advisor` resolve OMP static priority chains
138
+ * (unless config-pinned, which always wins over the label); literal patterns
139
+ * are pins; other `@role` values stay on the config layer.
140
+ */
141
+ export function classifyAgentModelSource(model?: string[]): AgentModelSource {
142
+ const first = model?.find((m) => typeof m === 'string' && m.trim() !== '')?.trim();
143
+ if (!first || first === 'default' || first === '@default' || first === '@task') {
144
+ return { label: '继承主会话', follows: true };
145
+ }
146
+ if (first === '@smol' || first === '@slow') {
147
+ return { label: `role 链 ${first}`, follows: true };
148
+ }
149
+ if (first === '@designer') {
150
+ return { label: 'role 链 @designer(经 default 继承)', follows: true };
151
+ }
152
+ if (first === '@tiny' || first === '@advisor') {
153
+ return { label: `OMP 自动链 ${first}(静态 priority 模式)`, follows: false };
154
+ }
155
+ if (first.startsWith('@')) {
156
+ return { label: `自定义 role ${first}`, follows: false };
157
+ }
158
+ return { label: `钉值 ${model!.map((m) => m.trim()).join(' / ')}`, follows: false };
159
+ }
160
+
161
+ /** Resolution phrase for one inventory row (`跟随切换 · role 链 @smol` …). */
162
+ function agentResolvePhrase(model: string[] | undefined, source: AgentModelSource): string {
163
+ if (source.label === '继承主会话') {
164
+ const first = model?.find((m) => typeof m === 'string' && m.trim() !== '')?.trim();
165
+ return first === '@task'
166
+ ? '继承主会话(@task 未配置回落主会话)'
167
+ : '继承主会话';
168
+ }
169
+ if (source.follows) return `跟随切换 · ${source.label}`;
170
+ if (source.label.startsWith('OMP 自动链')) return source.label;
171
+ return `固定 · ${source.label}`;
172
+ }
173
+
174
+ const SOURCE_GROUP_ORDER = ['project', 'user', 'bundled'] as const;
175
+ const SOURCE_GROUP_LABEL: Record<string, string> = {
176
+ project: '项目',
177
+ user: '用户',
178
+ bundled: '内置',
179
+ };
180
+
181
+ /**
182
+ * Full agent inventory block for choices: `── agent 清单(N)──` header,
183
+ * grouped project → user → bundled, name-sorted inside each group, one
184
+ * ` <glyph> <name> — <phrase>` row per agent.
185
+ */
186
+ export function formatAgentInventory(agents: AgentDefinition[], symbols: RenderSymbols): string {
187
+ if (agents.length === 0) {
188
+ return '── agent 清单 ──\n(未发现任何 agent)';
189
+ }
190
+ const bySource: Record<string, AgentDefinition[]> = {};
191
+ for (const agent of [...agents].sort((a, b) => a.name.localeCompare(b.name))) {
192
+ (bySource[agent.source] ??= []).push(agent);
193
+ }
194
+ const lines: string[] = [`── agent 清单(${agents.length})──`];
195
+ for (const source of SOURCE_GROUP_ORDER) {
196
+ const list = bySource[source];
197
+ if (!list || list.length === 0) continue;
198
+ lines.push(SOURCE_GROUP_LABEL[source] ?? source);
199
+ for (const agent of list) {
200
+ const classified = classifyAgentModelSource(agent.model);
201
+ const phrase = agentResolvePhrase(agent.model, classified);
202
+ const glyph = classified.follows ? symbols.follow : symbols.fixed;
203
+ lines.push(` ${glyph} ${agent.name} — ${phrase}`);
204
+ }
205
+ }
206
+ return lines.join('\n');
207
+ }
208
+
209
+ // ── 选择结果的展示辅助 ─────────────────────────────────────────────
210
+
211
+ /** Structural subset of a selector candidate the renderer needs. */
212
+ export interface RenderCandidate {
213
+ model: { provider: string; id: string };
214
+ /** Dedup key (`provider/id`). */
215
+ selector: string;
216
+ tier: string;
217
+ coverageClass: number;
218
+ reputation: number;
219
+ rank: number;
220
+ exhausted: boolean;
221
+ }
222
+
223
+ /** Structural subset of a SelectorResult the renderer needs. */
224
+ export interface RenderRoleResult {
225
+ picked?: { provider: string; id: string } | null;
226
+ candidates?: RenderCandidate[];
227
+ allCandidates?: RenderCandidate[];
228
+ }
229
+
230
+ /**
231
+ * Plain-phrase coverage/tier of a picked model, looked up in the result's
232
+ * candidates. Empty when there is no pick or the candidate is absent.
233
+ */
234
+ export function annotationOfPicked(
235
+ result: RenderRoleResult,
236
+ picked?: { provider: string; id: string } | null,
237
+ ): { coverage?: string; tier?: string } {
238
+ const target = picked ?? result.picked;
239
+ if (!target) return {};
240
+ const list = [...(result.candidates ?? []), ...(result.allCandidates ?? [])];
241
+ const candidate = list.find((c) => c.model.provider === target.provider && c.model.id === target.id);
242
+ if (!candidate) return {};
243
+ return { coverage: coverageLabel(candidate.coverageClass), tier: tierLabel(candidate.tier) };
244
+ }
245
+
246
+ /** One role row for `formatRoleLines`. */
247
+ export interface RoleLineInput {
248
+ /** Display name (agent name, e.g. `coder` / `code-reviewer`). */
249
+ name: string;
250
+ /** Role config kind (auto/omp-alias/model/provider-model). */
251
+ kind?: string;
252
+ /** Configured model shown when the result has no pick. */
253
+ fallbackModel?: string;
254
+ result: RenderRoleResult;
255
+ }
256
+
257
+ /**
258
+ Role rows for choices: `主会话 <model> — 计划内 · 高档`, then one
259
+ `coder → <model> — 计划内 · 中档 · 自动` line per role.
260
+ */
261
+ export function formatRoleLines(
262
+ rows: RoleLineInput[],
263
+ primary?: { model: { provider: string; id: string }; tier?: string },
264
+ ): string[] {
265
+ const lines: string[] = [];
266
+ if (primary) {
267
+ const allCandidates = rows.flatMap((r) => r.result.allCandidates ?? []);
268
+ const annotation = annotationOfPicked({ allCandidates }, primary.model);
269
+ const segments = [annotation.coverage, annotation.tier ?? tierLabel(primary.tier)].filter(Boolean);
270
+ lines.push(`主会话 ${primary.model.provider}/${primary.model.id}${segments.length > 0 ? ` — ${segments.join(' · ')}` : ''}`);
271
+ }
272
+ for (const row of rows) {
273
+ const target = row.result.picked ?? null;
274
+ const modelText = target ? `${target.provider}/${target.id}` : (row.fallbackModel ?? '(none)');
275
+ const annotation = annotationOfPicked(row.result, target);
276
+ const segments = [annotation.coverage, annotation.tier, roleKindLabel(row.kind)].filter(Boolean);
277
+ lines.push(`${row.name} → ${modelText}${segments.length > 0 ? ` — ${segments.join(' · ')}` : ''}`);
278
+ }
279
+ return lines;
280
+ }
281
+
282
+ /** One change line for switch/refresh/reset detail blocks. */
283
+ export function formatChangeLine(opts: {
284
+ name: string;
285
+ from?: string;
286
+ to: string;
287
+ coverage?: string;
288
+ tier?: string;
289
+ kind?: string;
290
+ }): string {
291
+ const head = opts.from ? `${opts.name} ${opts.from} → ${opts.to}` : `${opts.name} → ${opts.to}`;
292
+ const annotation = [opts.coverage, opts.tier, opts.kind].filter(Boolean).join(' · ');
293
+ return annotation ? `${head}(${annotation})` : head;
294
+ }
295
+
296
+ /**
297
+ * Family × tier candidate overview (`── 候选 ──` block): per family the
298
+ * best candidate per tier by reputation, tagged with the plain coverage
299
+ * label of the family's class.
300
+ */
301
+ export function formatCandidateOverview(results: RenderRoleResult[]): string {
302
+ const byFamily = new Map<string, Map<string, { modelId: string; rep: number; rank: number }>>();
303
+ // Coverage class is per-provider, so every candidate of a family carries
304
+ // the same class — first sighting wins (exhausted candidates are skipped
305
+ // entirely, mirroring the selector's display semantics).
306
+ const familyClass = new Map<string, number>();
307
+ for (const result of results) {
308
+ for (const c of result.allCandidates ?? []) {
309
+ if (c.exhausted) continue;
310
+ const fam = c.model.provider;
311
+ if (!familyClass.has(fam)) familyClass.set(fam, c.coverageClass);
312
+ const tierMap = byFamily.get(fam) ?? new Map<string, { modelId: string; rep: number; rank: number }>();
313
+ byFamily.set(fam, tierMap);
314
+ const existing = tierMap.get(c.tier);
315
+ if (!existing || c.reputation > existing.rep || (c.reputation === existing.rep && c.rank > existing.rank)) {
316
+ tierMap.set(c.tier, { modelId: c.model.id, rep: c.reputation, rank: c.rank });
317
+ }
318
+ }
319
+ }
320
+ const lines: string[] = ['── 候选 ──'];
321
+ const families = [...byFamily.keys()].sort();
322
+ let rendered = 0;
323
+ for (const fam of families) {
324
+ const tierMap = byFamily.get(fam)!;
325
+ const parts: string[] = [];
326
+ for (const tier of ['top', 'high', 'mid', 'low', 'tiny']) {
327
+ const m = tierMap.get(tier);
328
+ if (!m) continue;
329
+ const shortId = m.modelId.length > 35 ? `${m.modelId.slice(0, 32)}...` : m.modelId;
330
+ parts.push(`${tierLabel(tier)}→${shortId}`);
331
+ }
332
+ if (parts.length === 0) continue;
333
+ rendered++;
334
+ lines.push(`${providerDisplayName(fam)} [${coverageLabel(familyClass.get(fam) ?? -1)}]: ${parts.join(' ')}`);
335
+ }
336
+ if (rendered === 0) lines.push('(无可用模型)');
337
+ return lines.join('\n');
338
+ }
339
+
340
+ /**
341
+ * Choices footnote: distinct-candidate score count, plain-phrase partition
342
+ * counts with the PAYG fold suffix, and the command hints.
343
+ */
344
+ export function formatFootnote(results: RenderRoleResult[], paygLastResort: boolean | undefined): string {
345
+ let total = 0;
346
+ const classCounts: Record<number, number> = { 0: 0, 1: 0, 2: 0 };
347
+ const counted = new Set<string>();
348
+ for (const result of results) {
349
+ total += result.allCandidates?.length ?? 0;
350
+ for (const c of result.allCandidates ?? []) {
351
+ if (!counted.has(c.selector)) {
352
+ counted.add(c.selector);
353
+ classCounts[c.coverageClass] = (classCounts[c.coverageClass] ?? 0) + 1;
354
+ }
355
+ }
356
+ }
357
+ const foldSuffix = paygLastResort === false
358
+ ? '(分区已折叠)'
359
+ : classCounts[0] + classCounts[1] > 0
360
+ ? '(兜底未参与)'
361
+ : '';
362
+ const partition = `分区: ${coverageLabel(0)} ${classCounts[0]} · ${coverageLabel(1)} ${classCounts[1]} · ${coverageLabel(2)} ${classCounts[2]}${foldSuffix}`;
363
+ return `${total} 个候选参与评分 · ${partition}\n/pick-model refresh 切换耗尽模型 · /pick-model update 更新 tier 数据`;
364
+ }
365
+
366
+ // ── 段式骨架(design D1)───────────────────────────────────────────
367
+
368
+ export type ReportKind = 'choices' | 'switch' | 'refresh' | 'reset' | 'error';
369
+
370
+ /** Parts assembled by each command branch; unknown sections are omitted. */
371
+ export interface ReportParts {
372
+ /** 动作短语(状态行主体),如 `已切换 selector 约束` / `当前选择`。 */
373
+ action: string;
374
+ /** 约束段(formatFamilyConstraint 原文),嵌入状态行。 */
375
+ constraint?: string;
376
+ /** 状态符号(缺省 `selected`;error kind 恒用 `error`)。 */
377
+ status?: 'success' | 'error' | 'warning';
378
+ /** 明细区块(变更行组 / role 行 / agent 清单段),块间空行。 */
379
+ detail?: string[];
380
+ /** 候选概览段。 */
381
+ overview?: string;
382
+ /** 概览后的附加段(如并发控制小节)。 */
383
+ appendix?: string;
384
+ /** 脚注段。 */
385
+ footnote?: string;
386
+ }
387
+
388
+ /**
389
+ * Render one report: 状态行 → 明细区 → 候选概览 → 脚注, blocks separated
390
+ * by blank lines. choices embeds the constraint with the `constraint: `
391
+ * prefix; other kinds wrap it in parens after the action phrase.
392
+ */
393
+ export function renderPickModelReport(kind: ReportKind, parts: ReportParts, symbols: RenderSymbols): string {
394
+ const mark = kind === 'error'
395
+ ? symbols.error
396
+ : parts.status === 'error'
397
+ ? symbols.error
398
+ : parts.status === 'warning'
399
+ ? symbols.warning
400
+ : parts.status === 'success'
401
+ ? symbols.success
402
+ : symbols.selected;
403
+ const constraint = parts.constraint
404
+ ? (kind === 'choices' ? ` · constraint: ${parts.constraint}` : ` (${parts.constraint})`)
405
+ : '';
406
+ const blocks = [`${mark} ${parts.action}${constraint}`];
407
+ for (const detail of parts.detail ?? []) {
408
+ if (detail) blocks.push(detail);
409
+ }
410
+ if (parts.overview) blocks.push(parts.overview);
411
+ if (parts.appendix) blocks.push(parts.appendix);
412
+ if (parts.footnote) blocks.push(parts.footnote);
413
+ return blocks.join('\n\n');
414
+ }