@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.
Files changed (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -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
- return `${icon} ${parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(` ${theme.fg("dim", "·")} `)}`;
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
- return parts.length > 0 ? theme.fg("dim", `↳ ${parts.join(" · ")}`) : undefined;
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 = [truncateToWidth(agentRowSummary(this.details, this.live, this.theme, this.clock.now()), width)];
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
 
@@ -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
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
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
- /** Truncate text to a single line, max `len` chars. */
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
- const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
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?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
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((a.completedAt ?? this.clock.now()) - a.startedAt);
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
- return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
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.find(tag => tag.startsWith("thinking: "));
522
+ const thinkingTag = compactThinkingTag(tags);
490
523
  if (thinkingTag) parts.push(thinkingTag);
491
524
  }
492
- if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
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
- if (lines.length > 1) {
525
- const last = lines.length - 1;
526
- lines[last] = lines[last].replace("├─", "└─");
527
- // If last item is a running agent activity line, fix indent of that line
528
- // and fix the header line above it.
529
- if (runningLines.length > 0 && !queuedLine) {
530
- // The last two lines are the last running agent's header + activity.
531
- if (last >= 2) {
532
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
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
- lines.push(row(this.composer.render(innerW)[0] ?? ""));
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
- const footer = lines.pop() ?? row("");
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 parts = model ? [model, ...tags] : tags;
731
- if (parts.length === 0) return undefined;
732
- return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
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
+ }