@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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 +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
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. */
|
|
@@ -106,11 +114,21 @@ export function fgPreservingNestedStyles(theme: Theme, color: string, text: stri
|
|
|
106
114
|
return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Format a token count as a bare magnitude: "950", "18.4k", "1.2M". The unit is
|
|
119
|
+
* picked against the ROUNDED thousands, so 999,999 reads "1.0M", never "1000.0k".
|
|
120
|
+
* Every token formatter wraps this one so the boundary is fixed in one place.
|
|
121
|
+
*/
|
|
122
|
+
export function formatCompactTokens(count: number): string {
|
|
123
|
+
const thousands = Number((count / 1_000).toFixed(1));
|
|
124
|
+
if (thousands >= 1_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
125
|
+
if (count >= 1_000) return `${thousands.toFixed(1)}k`;
|
|
126
|
+
return `${count}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
109
129
|
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
110
130
|
export function formatTokens(count: number): string {
|
|
111
|
-
|
|
112
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
|
|
113
|
-
return `${count} token`;
|
|
131
|
+
return `${formatCompactTokens(count)} token`;
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
/**
|
|
@@ -212,15 +230,27 @@ export function buildInvocationTags(
|
|
|
212
230
|
if (thinking) tags.push(`thinking: ${thinking}`);
|
|
213
231
|
if (invocation.isolated) tags.push("isolated");
|
|
214
232
|
if (invocation.isolation === "worktree") tags.push("worktree");
|
|
233
|
+
if (invocation.planMode) tags.push("plan");
|
|
215
234
|
if (invocation.inheritContext) tags.push("inherit context");
|
|
216
235
|
if (invocation.runInBackground) tags.push("background");
|
|
217
236
|
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
|
|
218
237
|
return { modelName: invocation.modelName, modelId: invocation.modelId, tags };
|
|
219
238
|
}
|
|
220
239
|
|
|
221
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* The thinking tag from {@link buildInvocationTags} as `think:high`, or "" when
|
|
242
|
+
* there is none. Both agent lists use it so the configured level cannot read as
|
|
243
|
+
* a duplicate of the live `thinking · 4s` activity on the same row (#144).
|
|
244
|
+
*/
|
|
245
|
+
export function compactThinkingTag(tags: readonly string[]): string {
|
|
246
|
+
return (tags.find(tag => tag.startsWith("thinking: ")) ?? "").replace(/^thinking: /, "think:");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Truncate text to its first non-blank line, max `len` chars. */
|
|
222
250
|
function truncateLine(text: string, len = 60): string {
|
|
223
|
-
|
|
251
|
+
// Trimming the whole text first drops any leading blank lines, so its first
|
|
252
|
+
// line is the first non-blank one (or "" for blank text).
|
|
253
|
+
const line = text.trim().split("\n")[0].trimEnd();
|
|
224
254
|
if (line.length <= len) return line;
|
|
225
255
|
return line.slice(0, len) + "…";
|
|
226
256
|
}
|
|
@@ -389,9 +419,9 @@ export class AgentWidget {
|
|
|
389
419
|
}
|
|
390
420
|
|
|
391
421
|
/** Render a finished agent line. */
|
|
392
|
-
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt
|
|
422
|
+
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
423
|
const modeLabel = getPromptModeLabel(a.type);
|
|
394
|
-
const duration = formatMs(
|
|
424
|
+
const duration = formatMs(a.completedAt - a.startedAt);
|
|
395
425
|
|
|
396
426
|
let icon: string;
|
|
397
427
|
let statusText: string;
|
|
@@ -418,6 +448,7 @@ export class AgentWidget {
|
|
|
418
448
|
const activity = this.agentActivity.get(a.id);
|
|
419
449
|
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
420
450
|
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
|
|
451
|
+
// Not gated by `showModel`: a substituted model is never a cosmetic detail.
|
|
421
452
|
// From the record, not the activity tracker: that entry is deleted the
|
|
422
453
|
// moment an agent finishes, and "what did it cost" is a question asked
|
|
423
454
|
// about finished agents.
|
|
@@ -426,7 +457,8 @@ export class AgentWidget {
|
|
|
426
457
|
parts.push(duration);
|
|
427
458
|
|
|
428
459
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
429
|
-
|
|
460
|
+
const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
|
|
461
|
+
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
462
|
}
|
|
431
463
|
|
|
432
464
|
/**
|
|
@@ -437,8 +469,8 @@ export class AgentWidget {
|
|
|
437
469
|
const allAgents = this.widgetAgents();
|
|
438
470
|
const running = allAgents.filter(a => a.status === "running");
|
|
439
471
|
const queued = allAgents.filter(a => a.status === "queued");
|
|
440
|
-
const finished = allAgents.filter(a =>
|
|
441
|
-
a.status !== "running" && a.status !== "queued" && a.completedAt
|
|
472
|
+
const finished = allAgents.filter((a): a is typeof a & { completedAt: number } =>
|
|
473
|
+
a.status !== "running" && a.status !== "queued" && !!a.completedAt
|
|
442
474
|
&& this.shouldShowFinished(a.id, a.status),
|
|
443
475
|
);
|
|
444
476
|
|
|
@@ -466,6 +498,7 @@ export class AgentWidget {
|
|
|
466
498
|
for (const a of running) {
|
|
467
499
|
const modeLabel = getPromptModeLabel(a.type);
|
|
468
500
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
501
|
+
const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
|
|
469
502
|
const elapsed = formatMs(this.clock.now() - a.startedAt);
|
|
470
503
|
|
|
471
504
|
const bg = this.agentActivity.get(a.id);
|
|
@@ -486,10 +519,10 @@ export class AgentWidget {
|
|
|
486
519
|
// rebuilt so the "(asked X)" annotation survives.
|
|
487
520
|
const { modelName, tags } = buildInvocationTags(a.invocation);
|
|
488
521
|
if (modelName) parts.push(modelName);
|
|
489
|
-
const thinkingTag = tags
|
|
522
|
+
const thinkingTag = compactThinkingTag(tags);
|
|
490
523
|
if (thinkingTag) parts.push(thinkingTag);
|
|
491
524
|
}
|
|
492
|
-
|
|
525
|
+
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
|
|
493
526
|
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
494
527
|
if (tokenText) parts.push(tokenText);
|
|
495
528
|
if (costText) parts.push(costText);
|
|
@@ -499,7 +532,7 @@ export class AgentWidget {
|
|
|
499
532
|
const activity = bg ? describeActivity(bg.activeTools, bg.responseText, bg.modelCall, this.clock.now()) : "thinking…";
|
|
500
533
|
|
|
501
534
|
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)}`),
|
|
535
|
+
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
536
|
truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
|
|
504
537
|
]);
|
|
505
538
|
}
|
|
@@ -521,18 +554,15 @@ export class AgentWidget {
|
|
|
521
554
|
if (queuedLine) lines.push(queuedLine);
|
|
522
555
|
|
|
523
556
|
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
lines[last] = lines[last].replace("│ ", " ");
|
|
534
|
-
}
|
|
535
|
-
}
|
|
557
|
+
// Something is active or finished (checked above), so the body is never
|
|
558
|
+
// empty and `last` is past the heading.
|
|
559
|
+
const last = lines.length - 1;
|
|
560
|
+
lines[last] = lines[last].replace("├─", "└─");
|
|
561
|
+
// If the last item is a running agent, its header and activity are the
|
|
562
|
+
// last two lines: fix the header's connector and the activity's indent.
|
|
563
|
+
if (runningLines.length > 0 && !queuedLine) {
|
|
564
|
+
lines[last - 1] = lines[last - 1].replace("├─", "└─");
|
|
565
|
+
lines[last] = lines[last].replace("│ ", " ");
|
|
536
566
|
}
|
|
537
567
|
} else {
|
|
538
568
|
// Overflow — prioritize: running > queued > finished.
|
|
@@ -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. */
|
|
@@ -545,7 +546,8 @@ export class ConversationViewer implements Component, Focusable {
|
|
|
545
546
|
if (this.composer) {
|
|
546
547
|
// Composer row: the Input renders its own `> ` prompt and cursor.
|
|
547
548
|
this.composer.focused = this.focused;
|
|
548
|
-
|
|
549
|
+
// Input always renders exactly one row.
|
|
550
|
+
lines.push(row(this.composer.render(innerW)[0]));
|
|
549
551
|
footerActions.push({ label: "Enter send", key: "\r" }, { label: "Esc cancel", key: "\x1b" });
|
|
550
552
|
const composeHint = th.fg("dim", "Enter send · Esc cancel");
|
|
551
553
|
const composeLeft = th.fg("accent", "✎ steer");
|
|
@@ -589,7 +591,8 @@ export class ConversationViewer implements Component, Focusable {
|
|
|
589
591
|
return lines;
|
|
590
592
|
}
|
|
591
593
|
// Keep the footer on screen even in a terminal shorter than the chrome.
|
|
592
|
-
|
|
594
|
+
// Never empty: the header and footer rows were pushed above.
|
|
595
|
+
const footer = lines.pop()!;
|
|
593
596
|
const output = rows > 0 ? [...lines.slice(0, rows - 1), footer] : [];
|
|
594
597
|
const footerText = stripTerminalSequences(footer);
|
|
595
598
|
for (const action of footerActions) {
|
|
@@ -727,9 +730,11 @@ export class ConversationViewer implements Component, Focusable {
|
|
|
727
730
|
// models whose short names read alike.
|
|
728
731
|
const { modelName, modelId, tags } = buildInvocationTags(this.record.invocation);
|
|
729
732
|
const model = modelId ?? modelName;
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
+
const flag = fallbackFlag(this.theme, this.record);
|
|
734
|
+
const parts = [...(model ? [model] : []), ...tags];
|
|
735
|
+
if (parts.length === 0 && !flag) return undefined;
|
|
736
|
+
// The flag leads the (possibly very long) canonical model id.
|
|
737
|
+
return `${this.theme.fg("dim", " ↳")} ${flag ? `${flag} ` : ""}${this.theme.fg("dim", parts.join(" · "))}`;
|
|
733
738
|
}
|
|
734
739
|
|
|
735
740
|
private buildContentLines(width: number): string[] {
|
|
@@ -0,0 +1,114 @@
|
|
|
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 { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } 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
|
+
* Wrap control text into a fixed row budget while preserving both its opening
|
|
61
|
+
* and final diagnosis around an explicit omitted-rows marker. The budget is at
|
|
62
|
+
* least three rows: one each for the opening, the marker and the diagnosis.
|
|
63
|
+
* A non-finite `maxRows` gets that floor rather than poisoning the slices.
|
|
64
|
+
*/
|
|
65
|
+
export function boundedWrappedRows(text: string, width: number, maxRows: number, marker: string): string[] {
|
|
66
|
+
const rows = wrapTextWithAnsi(text, width);
|
|
67
|
+
const budget = Number.isFinite(maxRows) ? Math.max(3, Math.trunc(maxRows)) : 3;
|
|
68
|
+
if (rows.length <= budget) return rows;
|
|
69
|
+
|
|
70
|
+
// Reserve a row for the omission marker and favor the final rows: fallback
|
|
71
|
+
// diagnoses live at the end, while the first row still names the control text.
|
|
72
|
+
// At least two rows are omitted, so the marker never replaces a single row.
|
|
73
|
+
const firstCount = Math.max(1, Math.floor((budget - 1) / 2));
|
|
74
|
+
const lastCount = budget - firstCount - 1;
|
|
75
|
+
const omitted = rows.length - firstCount - lastCount;
|
|
76
|
+
const omission = stripTerminalSequences(
|
|
77
|
+
truncateToWidth(`${marker} ${omitted} more lines`, width, ""),
|
|
78
|
+
);
|
|
79
|
+
return [...rows.slice(0, firstCount), omission, ...rows.slice(-lastCount)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `prefix + flag + rest`, never wider than `width`, and the flag is never cut.
|
|
84
|
+
*
|
|
85
|
+
* Normal case: the prefix and the flag are reserved first and `rest` (a string,
|
|
86
|
+
* or a function of the width left for it) is truncated to what remains.
|
|
87
|
+
*
|
|
88
|
+
* Narrow case, when `prefix + flag + " "` exceeds `width`: the prefix and then
|
|
89
|
+
* the rest are dropped before the flag is. If the flag alone still does not
|
|
90
|
+
* fit, it degrades to a plain `[fb]`, then `!`. Only a zero width renders
|
|
91
|
+
* nothing. An empty string `rest` renders no separator after the flag, so a
|
|
92
|
+
* caller sizing the flag alone gets just the flag.
|
|
93
|
+
*/
|
|
94
|
+
export function withLeadingFlag(
|
|
95
|
+
prefix: string,
|
|
96
|
+
flag: string,
|
|
97
|
+
rest: string | ((inner: number) => string),
|
|
98
|
+
width: number,
|
|
99
|
+
): string {
|
|
100
|
+
const lead = flag ? `${prefix}${flag}${rest === "" ? "" : " "}` : prefix;
|
|
101
|
+
if (!flag || visibleWidth(lead) <= width) {
|
|
102
|
+
const inner = Math.max(0, width - visibleWidth(lead));
|
|
103
|
+
const body = typeof rest === "function" ? rest(inner) : truncateToWidth(rest, inner);
|
|
104
|
+
return truncateToWidth(lead + body, width);
|
|
105
|
+
}
|
|
106
|
+
const flagWidth = visibleWidth(flag);
|
|
107
|
+
if (flagWidth <= width) {
|
|
108
|
+
const inner = width - flagWidth - 1;
|
|
109
|
+
if (inner < 1 || rest === "") return flag;
|
|
110
|
+
const body = typeof rest === "function" ? rest(inner) : truncateToWidth(rest, inner);
|
|
111
|
+
return truncateToWidth(`${flag} ${body}`, width);
|
|
112
|
+
}
|
|
113
|
+
return MINIMAL_FLAGS.find(m => visibleWidth(m) <= width) ?? "";
|
|
114
|
+
}
|