@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.8
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/README.md +124 -37
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +131 -28
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +191 -90
- package/src/invocation-config.ts +3 -16
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +22 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +6 -3
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +36 -18
- package/src/settings.ts +42 -12
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +27 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
package/src/skill-loader.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { Dirent } from "node:fs";
|
|
|
25
25
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { dirname, join } from "node:path";
|
|
28
|
-
import {
|
|
28
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
29
29
|
import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
|
|
30
30
|
|
|
31
31
|
export interface PreloadedSkill {
|
|
@@ -33,21 +33,22 @@ export interface PreloadedSkill {
|
|
|
33
33
|
content: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
37
|
-
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
36
|
+
export function preloadSkills(skillNames: string[], cwd: string, homeDir = homedir()): PreloadedSkill[] {
|
|
37
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd, homeDir) }));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function loadSkillContent(name: string, cwd: string): string {
|
|
40
|
+
function loadSkillContent(name: string, cwd: string, homeDir: string): string {
|
|
41
41
|
if (isUnsafeName(name)) {
|
|
42
42
|
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
43
43
|
}
|
|
44
44
|
// `trusted`: user-owned root, symlinks followed. Project roots stay strict.
|
|
45
|
+
const agentDir = resolveAgentDir(homeDir);
|
|
45
46
|
const roots = [
|
|
46
47
|
{ path: join(cwd, ".pi", "skills"), trusted: false }, // project — Pi standard
|
|
47
48
|
{ path: join(cwd, ".agents", "skills"), trusted: false }, // project — Agent Skills spec
|
|
48
|
-
{ path: join(
|
|
49
|
-
{ path: join(
|
|
50
|
-
{ path: join(
|
|
49
|
+
{ path: join(agentDir, "skills"), trusted: true }, // user — Pi standard
|
|
50
|
+
{ path: join(homeDir, ".agents", "skills"), trusted: true }, // user — Agent Skills spec
|
|
51
|
+
{ path: join(homeDir, ".pi", "skills"), trusted: true }, // legacy global, pre-Pi
|
|
51
52
|
];
|
|
52
53
|
for (const root of roots) {
|
|
53
54
|
const content = findInRoot(root.path, name, root.trusted);
|
package/src/stale-ctx.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stale-ctx.ts -- recognising pi's "extension ctx is stale" failure.
|
|
3
|
+
*
|
|
4
|
+
* pi invalidates an extension runner on reload and on session replacement
|
|
5
|
+
* (/new, /fork, /resume). Anything that still reaches the old runner or its
|
|
6
|
+
* `pi` throws this message (#145).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Matched on the stable prefix of core's message. */
|
|
10
|
+
export const STALE_CTX_RE = /extension ctx is stale after session replacement or reload/i;
|
|
11
|
+
|
|
12
|
+
/** Whether `error` (an `Error` or a message string) is the stale-ctx failure. */
|
|
13
|
+
export function isStaleCtxError(error: unknown): boolean {
|
|
14
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
15
|
+
return STALE_CTX_RE.test(message);
|
|
16
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
6
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ModelFallback } from "./model-resolver.js";
|
|
8
|
+
import type { ModelSelection } from "./model-selection.js";
|
|
9
|
+
import type { TierDisclosure } from "./model-tiers.js";
|
|
7
10
|
import type { BudgetLease } from "./process-budget.js";
|
|
8
11
|
import type { LifetimeUsage } from "./usage.js";
|
|
9
12
|
|
|
@@ -147,6 +150,9 @@ export interface AgentTombstone {
|
|
|
147
150
|
description: string;
|
|
148
151
|
/** Always set — a record with no session file is never tombstoned. */
|
|
149
152
|
sessionFile: string;
|
|
153
|
+
/** The evicted run's tier and fallback, handed back when `@handle` reopens it. */
|
|
154
|
+
modelTier?: TierDisclosure;
|
|
155
|
+
modelFallback?: ModelFallback;
|
|
150
156
|
completedAt: number;
|
|
151
157
|
}
|
|
152
158
|
|
|
@@ -270,6 +276,24 @@ export interface AgentRecord {
|
|
|
270
276
|
isBackground?: boolean;
|
|
271
277
|
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
272
278
|
invocation?: AgentInvocation;
|
|
279
|
+
/**
|
|
280
|
+
* Set when a configured model did not resolve and the agent runs on the parent
|
|
281
|
+
* model. The single source for every disclosure surface; a resume reuses it
|
|
282
|
+
* as is, never recomputes it from the resuming call's config.
|
|
283
|
+
*/
|
|
284
|
+
modelFallback?: ModelFallback;
|
|
285
|
+
/**
|
|
286
|
+
* This record's selection, retained across live resumes and physical relabelling.
|
|
287
|
+
* An evicted-session reopen selects anew; current fallback disclosure always
|
|
288
|
+
* comes from that selection.
|
|
289
|
+
*/
|
|
290
|
+
modelSelection?: ModelSelection;
|
|
291
|
+
/** Actual run's tier provenance, preserved through eviction and replay. */
|
|
292
|
+
modelTier?: TierDisclosure;
|
|
293
|
+
/** Prior evicted run's outcome, never the continuation's selection or disclosure. */
|
|
294
|
+
previousModelTier?: TierDisclosure;
|
|
295
|
+
/** Prior evicted run's fallback, retained separately from the continuation's current disclosure. */
|
|
296
|
+
previousModelFallback?: ModelFallback;
|
|
273
297
|
/** Nesting depth: top-level subagent = 1. */
|
|
274
298
|
depth?: number;
|
|
275
299
|
/**
|
|
@@ -350,6 +374,9 @@ export interface NotificationDetails {
|
|
|
350
374
|
durationMs: number;
|
|
351
375
|
outputFile?: string;
|
|
352
376
|
error?: string;
|
|
377
|
+
/** The `Note:` line when the agent ran on the parent model because its configured one did not resolve. */
|
|
378
|
+
modelNote?: string;
|
|
379
|
+
modelTier?: TierDisclosure;
|
|
353
380
|
resultPreview: string;
|
|
354
381
|
/** Additional agents in a group notification. */
|
|
355
382
|
others?: NotificationDetails[];
|
package/src/ui/agent-row.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { pathToFileURL } from "node:url";
|
|
|
14
14
|
import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
15
|
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
16
16
|
import { formatClock } from "../model-call.js";
|
|
17
|
+
import { modelSelectionNote } from "../model-selection.js";
|
|
17
18
|
import type { AgentRecord } from "../types.js";
|
|
18
19
|
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
19
20
|
import {
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
SPINNER,
|
|
27
28
|
type Theme,
|
|
28
29
|
} from "./agent-widget.js";
|
|
30
|
+
import { fallbackFlag, withLeadingFlag } from "./fallback-flag.js";
|
|
29
31
|
|
|
30
32
|
/** Lines of the result shown when expanded; the rest is one `get_subagent_result` away. */
|
|
31
33
|
const EXPANDED_RESULT_LINES = 50;
|
|
@@ -89,6 +91,7 @@ export function agentRowSummary(
|
|
|
89
91
|
live: AgentRowLive,
|
|
90
92
|
theme: Theme,
|
|
91
93
|
now: number = Date.now(),
|
|
94
|
+
width?: number,
|
|
92
95
|
): string {
|
|
93
96
|
const record = details.agentId ? live.getRecord(details.agentId) : undefined;
|
|
94
97
|
const activity = details.agentId ? live.getActivity(details.agentId) : undefined;
|
|
@@ -106,7 +109,10 @@ export function agentRowSummary(
|
|
|
106
109
|
if (tokenText) parts.push(tokenText);
|
|
107
110
|
if (cost) parts.push(cost);
|
|
108
111
|
if (!finished && details.backgroundHint && elapsed >= 2000) parts.push("(ctrl+b to run in background)");
|
|
109
|
-
|
|
112
|
+
// Leading, ahead of the status text, which may carry a 300-char error.
|
|
113
|
+
const flag = fallbackFlag(theme, record?.modelFallback || record?.modelTier ? record : details);
|
|
114
|
+
const body = parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(` ${theme.fg("dim", "·")} `);
|
|
115
|
+
return width === undefined ? `${icon} ${flag ? `${flag} ` : ""}${body}` : withLeadingFlag(`${icon} `, flag, body, width);
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/**
|
|
@@ -116,7 +122,9 @@ export function agentRowSummary(
|
|
|
116
122
|
*/
|
|
117
123
|
function tagsLine(details: AgentDetails, theme: Theme): string | undefined {
|
|
118
124
|
const parts = [...(details.modelName ? [details.modelName] : []), ...(details.tags ?? [])];
|
|
119
|
-
|
|
125
|
+
const flag = fallbackFlag(theme, details);
|
|
126
|
+
if (parts.length === 0 && !flag) return undefined;
|
|
127
|
+
return `${theme.fg("dim", "↳")} ${flag ? `${flag} ` : ""}${theme.fg("dim", parts.join(" · "))}`;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
/** The transcript as an OSC 8 hyperlink, so a click opens it. */
|
|
@@ -189,12 +197,14 @@ export class AgentResultView implements Component {
|
|
|
189
197
|
) {}
|
|
190
198
|
|
|
191
199
|
render(width: number): string[] {
|
|
192
|
-
const lines = [
|
|
200
|
+
const lines = [agentRowSummary(this.details, this.live, this.theme, this.clock.now(), width)];
|
|
193
201
|
if (!this.expanded) return lines;
|
|
194
202
|
|
|
195
203
|
const record = this.details.agentId ? this.live.getRecord(this.details.agentId) : undefined;
|
|
196
204
|
const tags = tagsLine(this.details, this.theme);
|
|
197
205
|
if (tags) lines.push(truncateToWidth(tags, width));
|
|
206
|
+
const note = modelSelectionNote(record) || this.details.modelNote;
|
|
207
|
+
if (note) lines.push(...wrapTextWithAnsi(this.theme.fg("warning", note), width));
|
|
198
208
|
const transcript = record?.outputFile ?? this.details.outputFile;
|
|
199
209
|
if (transcript) lines.push(truncateToWidth(transcriptLink(transcript, this.theme), width));
|
|
200
210
|
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -11,8 +11,11 @@ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
|
|
|
11
11
|
import { getConfig } from "../agent-types.js";
|
|
12
12
|
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
13
13
|
import { describeModelCall, type ModelCallState } from "../model-call.js";
|
|
14
|
+
import type { ModelFallback } from "../model-resolver.js";
|
|
15
|
+
import type { TierDisclosure } from "../model-tiers.js";
|
|
14
16
|
import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
|
|
15
17
|
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
|
|
18
|
+
import { fallbackFlag } from "./fallback-flag.js";
|
|
16
19
|
|
|
17
20
|
// ---- Constants ----
|
|
18
21
|
|
|
@@ -83,6 +86,11 @@ export interface AgentDetails {
|
|
|
83
86
|
backgroundHint?: boolean;
|
|
84
87
|
/** Short label for the model the run used, e.g. "haiku 4.5". */
|
|
85
88
|
modelName?: string;
|
|
89
|
+
/** Set when a configured model did not resolve and the run fell back to the parent model. */
|
|
90
|
+
modelNote?: string;
|
|
91
|
+
/** The structured form of {@link modelNote}, for the one-line label. */
|
|
92
|
+
modelFallback?: ModelFallback;
|
|
93
|
+
modelTier?: TierDisclosure;
|
|
86
94
|
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
|
|
87
95
|
tags?: string[];
|
|
88
96
|
/** Current turn count. */
|
|
@@ -389,7 +397,7 @@ export class AgentWidget {
|
|
|
389
397
|
}
|
|
390
398
|
|
|
391
399
|
/** Render a finished agent line. */
|
|
392
|
-
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
|
|
400
|
+
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage; modelFallback?: ModelFallback; modelTier?: TierDisclosure }, theme: Theme): string {
|
|
393
401
|
const modeLabel = getPromptModeLabel(a.type);
|
|
394
402
|
const duration = formatMs((a.completedAt ?? this.clock.now()) - a.startedAt);
|
|
395
403
|
|
|
@@ -418,6 +426,7 @@ export class AgentWidget {
|
|
|
418
426
|
const activity = this.agentActivity.get(a.id);
|
|
419
427
|
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
420
428
|
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
|
|
429
|
+
// Not gated by `showModel`: a substituted model is never a cosmetic detail.
|
|
421
430
|
// From the record, not the activity tracker: that entry is deleted the
|
|
422
431
|
// moment an agent finishes, and "what did it cost" is a question asked
|
|
423
432
|
// about finished agents.
|
|
@@ -426,7 +435,8 @@ export class AgentWidget {
|
|
|
426
435
|
parts.push(duration);
|
|
427
436
|
|
|
428
437
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
429
|
-
|
|
438
|
+
const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
|
|
439
|
+
return `${icon} ${flag}${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
430
440
|
}
|
|
431
441
|
|
|
432
442
|
/**
|
|
@@ -466,6 +476,7 @@ export class AgentWidget {
|
|
|
466
476
|
for (const a of running) {
|
|
467
477
|
const modeLabel = getPromptModeLabel(a.type);
|
|
468
478
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
479
|
+
const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
|
|
469
480
|
const elapsed = formatMs(this.clock.now() - a.startedAt);
|
|
470
481
|
|
|
471
482
|
const bg = this.agentActivity.get(a.id);
|
|
@@ -489,7 +500,7 @@ export class AgentWidget {
|
|
|
489
500
|
const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
|
|
490
501
|
if (thinkingTag) parts.push(thinkingTag);
|
|
491
502
|
}
|
|
492
|
-
|
|
503
|
+
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
|
|
493
504
|
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
494
505
|
if (tokenText) parts.push(tokenText);
|
|
495
506
|
if (costText) parts.push(costText);
|
|
@@ -499,7 +510,7 @@ export class AgentWidget {
|
|
|
499
510
|
const activity = bg ? describeActivity(bg.activeTools, bg.responseText, bg.modelCall, this.clock.now()) : "thinking…";
|
|
500
511
|
|
|
501
512
|
runningLines.push([
|
|
502
|
-
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
|
|
513
|
+
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${flag}${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
|
|
503
514
|
truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
|
|
504
515
|
]);
|
|
505
516
|
}
|
|
@@ -14,6 +14,7 @@ import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
|
|
|
14
14
|
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
15
15
|
import type { Theme } from "./agent-widget.js";
|
|
16
16
|
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
|
|
17
|
+
import { fallbackFlag } from "./fallback-flag.js";
|
|
17
18
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
18
19
|
|
|
19
20
|
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
@@ -727,9 +728,11 @@ export class ConversationViewer implements Component, Focusable {
|
|
|
727
728
|
// models whose short names read alike.
|
|
728
729
|
const { modelName, modelId, tags } = buildInvocationTags(this.record.invocation);
|
|
729
730
|
const model = modelId ?? modelName;
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
731
|
+
const flag = fallbackFlag(this.theme, this.record);
|
|
732
|
+
const parts = [...(model ? [model] : []), ...tags];
|
|
733
|
+
if (parts.length === 0 && !flag) return undefined;
|
|
734
|
+
// The flag leads the (possibly very long) canonical model id.
|
|
735
|
+
return `${this.theme.fg("dim", " ↳")} ${flag ? `${flag} ` : ""}${this.theme.fg("dim", parts.join(" · "))}`;
|
|
733
736
|
}
|
|
734
737
|
|
|
735
738
|
private buildContentLines(width: number): string[] {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fallback-flag.ts -- the ONE compact indicator for "this agent ran on another
|
|
3
|
+
* model than configured", shared by every renderer that shows model info.
|
|
4
|
+
*
|
|
5
|
+
* Rows are `label + stats` clamped from the right, so anything appended after
|
|
6
|
+
* unbounded text (a description, an error, a canonical model id) is the first
|
|
7
|
+
* thing a narrow terminal cuts. The flag is therefore always rendered FIRST and
|
|
8
|
+
* the rest is truncated around it. Renderers must use these helpers rather than
|
|
9
|
+
* building their own marker; `test/fallback-flag-renderers.test.ts` walks every
|
|
10
|
+
* renderer and fails the ones that do not.
|
|
11
|
+
*/
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { TierDisclosure } from "../model-tiers.js";
|
|
14
|
+
|
|
15
|
+
/** Minimal theme surface, so this module has no dependency on the widgets that use it. */
|
|
16
|
+
interface FlagTheme {
|
|
17
|
+
fg(color: string, text: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Anything that can carry the fallback: a record, details or notice details. */
|
|
21
|
+
export interface FallbackCarrier {
|
|
22
|
+
modelFallback?: unknown;
|
|
23
|
+
modelNote?: string;
|
|
24
|
+
modelTier?: TierDisclosure;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether this tier's answer differs from the member/profile admitted for it. */
|
|
28
|
+
function switchedWithinTier(tier: TierDisclosure): boolean {
|
|
29
|
+
if (tier.kind !== "routed" && tier.kind !== "static") return false;
|
|
30
|
+
const baseline = tier.kind === "routed" ? tier.admitted : tier.member;
|
|
31
|
+
return baseline !== undefined && tier.answered !== undefined && tier.answered !== baseline;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether `carrier` needs an actionable fallback warning, not merely tier identity. */
|
|
35
|
+
export const hasFallback = (carrier: FallbackCarrier | undefined): boolean => {
|
|
36
|
+
if (carrier?.modelFallback !== undefined) return true;
|
|
37
|
+
const tier = carrier?.modelTier;
|
|
38
|
+
if (!tier) return carrier?.modelNote !== undefined;
|
|
39
|
+
return tier.kind === "unmapped" || tier.kind === "unavailable" || switchedWithinTier(tier);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Plain tier identity/outcome flag, or `[fallback]`; group counts retain the same identity. */
|
|
43
|
+
export function fallbackFlagText(count = 1, carrier?: FallbackCarrier): string {
|
|
44
|
+
const tier = carrier?.modelTier;
|
|
45
|
+
const suffix = count > 1 ? ` x${count}` : "";
|
|
46
|
+
return tier ? `[@${tier.tier}${tier.kind !== "routed" && tier.kind !== "static" ? ` ${tier.kind}` : ""}${suffix}]`
|
|
47
|
+
: `[fallback${suffix}]`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The themed tier identity or actionable fallback flag for `carrier`. */
|
|
51
|
+
export function fallbackFlag(theme: FlagTheme, carrier: FallbackCarrier | undefined, count = 1): string {
|
|
52
|
+
if (carrier?.modelTier) return theme.fg(hasFallback(carrier) ? "warning" : "dim", fallbackFlagText(count, carrier));
|
|
53
|
+
return hasFallback(carrier) ? theme.fg("warning", fallbackFlagText(count, carrier)) : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a flag degrades to when even it does not fit; plain so it cannot depend on a theme. */
|
|
57
|
+
const MINIMAL_FLAGS = ["[fb]", "!"] as const;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `prefix + flag + rest`, never wider than `width`, and the flag is never cut.
|
|
61
|
+
*
|
|
62
|
+
* Normal case: the prefix and the flag are reserved first and `rest` (a string,
|
|
63
|
+
* or a function of the width left for it) is truncated to what remains.
|
|
64
|
+
*
|
|
65
|
+
* Narrow case, when `prefix + flag + " "` exceeds `width`: the prefix and then
|
|
66
|
+
* the rest are dropped before the flag is. If the flag alone still does not
|
|
67
|
+
* fit, it degrades to a plain `[fb]`, then `!`. Only a zero width renders
|
|
68
|
+
* nothing.
|
|
69
|
+
*/
|
|
70
|
+
export function withLeadingFlag(
|
|
71
|
+
prefix: string,
|
|
72
|
+
flag: string,
|
|
73
|
+
rest: string | ((inner: number) => string),
|
|
74
|
+
width: number,
|
|
75
|
+
): string {
|
|
76
|
+
const lead = flag ? `${prefix}${flag} ` : prefix;
|
|
77
|
+
if (!flag || visibleWidth(lead) <= width) {
|
|
78
|
+
const inner = Math.max(0, width - visibleWidth(lead));
|
|
79
|
+
const body = typeof rest === "function" ? rest(inner) : truncateToWidth(rest, inner);
|
|
80
|
+
return truncateToWidth(lead + body, width);
|
|
81
|
+
}
|
|
82
|
+
const flagWidth = visibleWidth(flag);
|
|
83
|
+
if (flagWidth <= width) {
|
|
84
|
+
const inner = width - flagWidth - 1;
|
|
85
|
+
if (inner < 1) return flag;
|
|
86
|
+
const body = typeof rest === "function" ? rest(inner) : truncateToWidth(rest, inner);
|
|
87
|
+
return truncateToWidth(`${flag} ${body}`, width);
|
|
88
|
+
}
|
|
89
|
+
return MINIMAL_FLAGS.find(m => visibleWidth(m) <= width) ?? "";
|
|
90
|
+
}
|