@oh-my-pi/pi-coding-agent 16.4.8 → 16.5.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.
Files changed (134) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3502 -3444
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +17 -2
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  22. package/dist/types/modes/print-mode.d.ts +14 -8
  23. package/dist/types/modes/print-mode.test.d.ts +1 -0
  24. package/dist/types/modes/theme/theme.d.ts +2 -1
  25. package/dist/types/sdk.d.ts +5 -1
  26. package/dist/types/session/agent-session.d.ts +42 -0
  27. package/dist/types/session/session-context.d.ts +9 -0
  28. package/dist/types/session/session-entries.d.ts +6 -0
  29. package/dist/types/thinking.d.ts +2 -2
  30. package/dist/types/tiny/models.d.ts +1 -1
  31. package/dist/types/tools/browser/launch.d.ts +1 -0
  32. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  33. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  35. package/dist/types/tools/builtin-names.d.ts +1 -1
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/launch.d.ts +121 -0
  38. package/dist/types/tools/render-utils.d.ts +2 -0
  39. package/dist/types/tools/terminal-output.d.ts +5 -0
  40. package/dist/types/vibe/runtime.d.ts +2 -2
  41. package/dist/types/web/search/types.d.ts +0 -8
  42. package/package.json +20 -20
  43. package/src/cli/args.ts +11 -0
  44. package/src/cli/flag-tables.ts +9 -0
  45. package/src/cli/gallery-fixtures/shell.ts +82 -1
  46. package/src/cli.ts +10 -0
  47. package/src/commands/launch.ts +17 -0
  48. package/src/config/model-resolver.ts +110 -31
  49. package/src/config/model-roles.ts +14 -0
  50. package/src/config/settings-schema.ts +71 -7
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  53. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  54. package/src/eval/completion-bridge.ts +4 -4
  55. package/src/export/ttsr.ts +0 -3
  56. package/src/extensibility/extensions/model-api.ts +1 -1
  57. package/src/extensibility/extensions/types.ts +1 -1
  58. package/src/internal-urls/docs-index.ts +4 -4
  59. package/src/launch/broker.ts +1017 -0
  60. package/src/launch/client.ts +344 -0
  61. package/src/launch/paths.ts +17 -0
  62. package/src/launch/presence.ts +82 -0
  63. package/src/launch/protocol.ts +386 -0
  64. package/src/launch/terminal-output.ts +46 -0
  65. package/src/main.ts +50 -1
  66. package/src/modes/acp/acp-agent.ts +8 -1
  67. package/src/modes/components/agent-hub.ts +101 -31
  68. package/src/modes/components/compaction-summary-message.ts +8 -2
  69. package/src/modes/components/index.ts +1 -0
  70. package/src/modes/components/model-browser.ts +111 -21
  71. package/src/modes/components/model-hub.ts +23 -116
  72. package/src/modes/components/model-picker.ts +233 -0
  73. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  74. package/src/modes/components/status-line/component.test.ts +41 -2
  75. package/src/modes/components/status-line/component.ts +4 -0
  76. package/src/modes/components/status-line/segments.ts +6 -0
  77. package/src/modes/components/status-line/types.ts +3 -0
  78. package/src/modes/controllers/command-controller.ts +9 -1
  79. package/src/modes/controllers/event-controller.ts +31 -32
  80. package/src/modes/controllers/selector-controller.ts +96 -22
  81. package/src/modes/controllers/tan-command-controller.ts +40 -1
  82. package/src/modes/interactive-mode.ts +7 -3
  83. package/src/modes/print-mode.test.ts +71 -0
  84. package/src/modes/print-mode.ts +51 -2
  85. package/src/modes/theme/theme.ts +9 -0
  86. package/src/modes/utils/ui-helpers.ts +25 -4
  87. package/src/prompts/agents/designer.md +1 -1
  88. package/src/prompts/agents/librarian.md +1 -1
  89. package/src/prompts/agents/reviewer.md +1 -1
  90. package/src/prompts/agents/scout.md +1 -1
  91. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  92. package/src/prompts/system/prewalk-checklist.md +7 -0
  93. package/src/prompts/system/prewalk-continue.md +1 -0
  94. package/src/prompts/system/prewalk-plan.md +13 -0
  95. package/src/prompts/system/system-prompt.md +9 -7
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/bash.md +6 -4
  98. package/src/prompts/tools/browser.md +4 -4
  99. package/src/prompts/tools/launch.md +25 -0
  100. package/src/sdk.ts +8 -2
  101. package/src/session/agent-session.ts +550 -87
  102. package/src/session/session-context.test.ts +10 -5
  103. package/src/session/session-context.ts +25 -8
  104. package/src/session/session-entries.ts +6 -0
  105. package/src/slash-commands/builtin-registry.ts +25 -0
  106. package/src/task/agents.ts +2 -2
  107. package/src/thinking.ts +10 -3
  108. package/src/tiny/models.ts +7 -7
  109. package/src/tools/bash-interactive.ts +5 -8
  110. package/src/tools/bash.ts +38 -24
  111. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  112. package/src/tools/browser/launch.ts +8 -4
  113. package/src/tools/browser/run-cancellation.ts +66 -6
  114. package/src/tools/browser/tab-protocol.ts +6 -0
  115. package/src/tools/browser/tab-supervisor.ts +17 -1
  116. package/src/tools/browser/tab-worker.ts +140 -21
  117. package/src/tools/builtin-names.ts +1 -0
  118. package/src/tools/eval-render.ts +16 -14
  119. package/src/tools/index.ts +5 -0
  120. package/src/tools/inspect-image.ts +2 -2
  121. package/src/tools/launch.ts +643 -0
  122. package/src/tools/render-utils.ts +3 -0
  123. package/src/tools/renderers.ts +2 -0
  124. package/src/tools/terminal-output.ts +141 -0
  125. package/src/tts/speech-enhancer.ts +2 -2
  126. package/src/utils/image-vision-fallback.ts +3 -3
  127. package/src/vibe/runtime.ts +2 -2
  128. package/src/web/search/provider.ts +0 -10
  129. package/src/web/search/providers/public.ts +2 -4
  130. package/src/web/search/types.ts +0 -10
  131. package/dist/types/web/search/providers/bing.d.ts +0 -14
  132. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  133. package/src/web/search/providers/bing.ts +0 -197
  134. package/src/web/search/providers/yahoo.ts +0 -179
@@ -15,8 +15,8 @@
15
15
  */
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
19
- import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui";
18
+ import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
19
+ import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
20
20
  import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
21
21
  import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../../advisor";
22
22
  import type { KeyId } from "../../config/keybindings";
@@ -25,6 +25,7 @@ import { IrcBus } from "../../irc/bus";
25
25
  import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
26
26
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
27
27
  import { USER_INTERRUPT_LABEL } from "../../session/messages";
28
+ import { parseThinkingLevel } from "../../thinking";
28
29
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
29
30
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
30
31
  import { theme } from "../theme/theme";
@@ -55,20 +56,50 @@ function clampHubLine(line: string, width: number): string {
55
56
 
56
57
  const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
57
58
 
58
- /** Glyph + status word, colored per theme status conventions. */
59
- function statusBadge(status: AgentStatus): string {
59
+ /** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */
60
+ function statusGlyph(status: AgentStatus): string {
60
61
  switch (status) {
61
62
  case "running":
62
- return theme.fg("accent", `${theme.status.running} running`);
63
+ return theme.fg("accent", theme.status.running);
63
64
  case "idle":
64
- return theme.fg("success", `${theme.status.enabled} idle`);
65
+ return theme.fg("success", theme.status.enabled);
65
66
  case "parked":
66
- return theme.fg("muted", `${theme.status.shadowed} parked`);
67
+ return theme.fg("muted", theme.status.shadowed);
67
68
  case "aborted":
68
- return theme.fg("error", `${theme.status.aborted} aborted`);
69
+ return theme.fg("error", theme.status.aborted);
69
70
  }
70
71
  }
71
72
 
73
+ /** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */
74
+ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
75
+ const model = theme.fg("muted", replaceTabs(modelId));
76
+ if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model;
77
+ const display = theme.thinking[level as keyof typeof theme.thinking] ?? level;
78
+ return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
79
+ }
80
+
81
+ /**
82
+ * Active model + reasoning level for a hub row: live session state when the
83
+ * agent is attached, else the executor-reported `resolvedModel` selector
84
+ * (`provider/id`, optionally `:<level>`). Undefined when neither is known
85
+ * (e.g. a parked historical agent restored from disk).
86
+ */
87
+ function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
88
+ const model = ref.session?.model;
89
+ if (model) {
90
+ const level = model.thinking ? ref.session?.thinkingLevel : undefined;
91
+ return formatModelBadge(model.id, level);
92
+ }
93
+ const resolved = observed?.progress?.resolvedModel;
94
+ if (!resolved) return undefined;
95
+ // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
96
+ // suffix as a thinking level when it parses as one.
97
+ const colon = resolved.lastIndexOf(":");
98
+ const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
99
+ const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
100
+ return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
101
+ }
102
+
72
103
  async function registerPersistedSubagents(
73
104
  registry: AgentRegistry,
74
105
  sessionFile: string | null | undefined,
@@ -427,17 +458,31 @@ export class AgentHubOverlayComponent extends Container {
427
458
  } else {
428
459
  const termHeight = process.stdout.rows || 40;
429
460
  // Chrome: 2 borders + title + notice? + blank + hints + border
430
- const maxVisible = Math.max(3, termHeight - 7 - (this.#notice ? 1 : 0));
431
- let start = 0;
432
- if (this.#rows.length > maxVisible) {
433
- start = Math.min(
434
- Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)),
435
- this.#rows.length - maxVisible,
436
- );
461
+ const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0));
462
+ const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width));
463
+ // Entries are 1-2 lines tall; grow a window around the selection until
464
+ // the line budget is spent, so the selected entry stays centered.
465
+ let start = this.#selectedRow;
466
+ let end = this.#selectedRow + 1;
467
+ let used = entries[start]?.length ?? 0;
468
+ for (let grew = true; grew; ) {
469
+ grew = false;
470
+ if (end < entries.length && used + entries[end].length <= budget) {
471
+ used += entries[end].length;
472
+ end++;
473
+ grew = true;
474
+ }
475
+ if (start > 0 && used + entries[start - 1].length <= budget) {
476
+ start--;
477
+ used += entries[start].length;
478
+ grew = true;
479
+ }
480
+ }
481
+ if (start > 0) {
482
+ lines.push(` ${theme.fg("dim", `… ${start} more`)}`);
437
483
  }
438
- const end = Math.min(start + maxVisible, this.#rows.length);
439
484
  for (let i = start; i < end; i++) {
440
- lines.push(this.#renderRow(this.#rows[i], i === this.#selectedRow, width));
485
+ lines.push(...entries[i]);
441
486
  }
442
487
  if (end < this.#rows.length) {
443
488
  lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
@@ -466,26 +511,51 @@ export class AgentHubOverlayComponent extends Container {
466
511
  return parts.join(theme.sep.dot);
467
512
  }
468
513
 
469
- #renderRow(ref: AgentRef, selected: boolean, width: number): string {
514
+ /**
515
+ * One agent entry, 1-2 lines:
516
+ * `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity
517
+ * left, metadata right-aligned (inlined when the terminal is too narrow) —
518
+ * plus an indented dim task line when the agent's work is known.
519
+ */
520
+ #renderEntry(ref: AgentRef, selected: boolean, width: number): string[] {
521
+ const max = Math.max(1, width - 2);
470
522
  const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
471
- const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))];
472
- parts.push(theme.fg("dim", replaceTabs(ref.displayName)));
473
- parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind));
474
- if (ref.kind === "advisor") {
475
- parts.push(theme.fg("warning", "read-only"));
523
+ const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`];
524
+ if (ref.displayName && ref.displayName !== ref.id) {
525
+ fields.push(theme.fg("dim", replaceTabs(ref.displayName)));
476
526
  }
477
- const observed = this.#observableFor(ref.id);
478
- const task = observed?.description ?? observed?.progress?.task;
479
- if (task) {
480
- parts.push(theme.fg("muted", sanitizeLine(task, TRUNCATE_LENGTHS.TITLE)));
527
+ if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
528
+ fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`));
529
+ }
530
+ if (ref.kind === "advisor") {
531
+ fields.push(theme.fg("warning", "read-only"));
481
532
  }
482
533
  const unread = this.#irc.unreadCount(ref.id);
483
534
  if (unread > 0) {
484
- parts.push(theme.fg("warning", `⧉ ${unread}`));
535
+ fields.push(theme.fg("warning", `⧉ ${unread}`));
536
+ }
537
+ const left = ` ${fields.join(" ")}`;
538
+
539
+ const observed = this.#observableFor(ref.id);
540
+ const meta: string[] = [];
541
+ const badge = modelBadge(ref, observed);
542
+ if (badge) meta.push(badge);
543
+ meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
544
+ const right = meta.join(theme.sep.dot);
545
+
546
+ const leftWidth = visibleWidth(left);
547
+ const rightWidth = visibleWidth(right);
548
+ const line =
549
+ leftWidth + 2 + rightWidth <= max
550
+ ? left + padding(max - leftWidth - rightWidth) + right
551
+ : truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max);
552
+ const entry = [line];
553
+
554
+ const task = observed?.description ?? observed?.progress?.task ?? ref.activity;
555
+ if (task) {
556
+ entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`);
485
557
  }
486
- parts.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
487
- const rawLine = ` ${cursor} ${parts.join(theme.sep.dot)}`;
488
- return truncateToWidth(rawLine.replace(/[\r\n]+/g, " "), Math.max(1, width - 1));
558
+ return entry;
489
559
  }
490
560
 
491
561
  #handleTableInput(keyData: string): void {
@@ -87,7 +87,12 @@ export class CompactionSummaryMessageComponent implements Component {
87
87
 
88
88
  constructor(private readonly message: CompactionSummaryMessage) {
89
89
  this.#divider = new SummaryDividerComponent({
90
- label: () => `${theme.icon.camera} compacted`,
90
+ // A dead-end warning stamped by the progress guard badges the bar;
91
+ // the full text lives in the ctrl+o detail block below.
92
+ label: () =>
93
+ this.message.warning
94
+ ? `${theme.icon.camera} compacted ${theme.fg("warning", theme.icon.warning)}`
95
+ : `${theme.icon.camera} compacted`,
91
96
  detailMarkdown: () => this.#detailMarkdown(),
92
97
  });
93
98
  }
@@ -109,7 +114,8 @@ export class CompactionSummaryMessageComponent implements Component {
109
114
  const frameCount = this.message.images?.length ?? 0;
110
115
  const frameNote =
111
116
  frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
112
- return `**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`;
117
+ const warningNote = this.message.warning ? `\n\n${theme.icon.warning} **Warning:** ${this.message.warning}` : "";
118
+ return `**Compacted from ${tokenStr} tokens**${warningNote}\n\n${this.message.summary}${frameNote}`;
113
119
  }
114
120
  }
115
121
 
@@ -20,6 +20,7 @@ export * from "./login-dialog";
20
20
  export * from "./logout-account-selector";
21
21
  export * from "./model-browser";
22
22
  export * from "./model-hub";
23
+ export * from "./model-picker";
23
24
  export * from "./oauth-selector";
24
25
  export * from "./queue-mode-selector";
25
26
  export * from "./read-tool-group";
@@ -22,11 +22,12 @@ import {
22
22
  visibleWidth,
23
23
  } from "@oh-my-pi/pi-tui";
24
24
  import { formatNumber } from "@oh-my-pi/pi-utils";
25
- import { getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles";
25
+ import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
26
+ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS } from "../../config/model-roles";
26
27
  import type { Settings } from "../../config/settings";
27
28
  import type { ModelPerfStats } from "../../session/agent-storage";
28
- import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
29
- import { theme } from "../theme/theme";
29
+ import { AUTO_THINKING, type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../../thinking";
30
+ import { type ThemeColor, theme } from "../theme/theme";
30
31
  import {
31
32
  matchesSelectCancel,
32
33
  matchesSelectDown,
@@ -35,12 +36,14 @@ import {
35
36
  matchesSelectUp,
36
37
  } from "../utils/keybinding-matchers";
37
38
 
38
- /** One selectable model row. `selector` is the canonical `provider/id` key. */
39
+ /** One selectable row. `selector` is a canonical model key or host-specific virtual key. */
39
40
  export interface ModelBrowserItem {
40
41
  provider: string;
41
42
  id: string;
42
43
  model: Model;
43
44
  selector: string;
45
+ /** Optional foreground color for the row label. */
46
+ labelColor?: ThemeColor;
44
47
  }
45
48
 
46
49
  /** Resolved role assignment as displayed by the browser and the hub. */
@@ -54,6 +57,67 @@ export interface RoleAssignment {
54
57
  /** Map of role id to its resolved assignment (absent roles are unresolved). */
55
58
  export type RoleAssignments = Record<string, RoleAssignment | undefined>;
56
59
 
60
+ /**
61
+ * Resolve every known role to its display assignment: configured role values
62
+ * resolve against `allModels`; unconfigured roles fall back to auto-selection
63
+ * over `autoCandidates` (skipped when empty). Shared by the /models hub and
64
+ * the alt+p session picker.
65
+ */
66
+ export function resolveRoleAssignments(
67
+ settings: Settings,
68
+ allModels: ReadonlyArray<Model>,
69
+ autoCandidates: ReadonlyArray<Model>,
70
+ ): RoleAssignments {
71
+ const resolvedThinkingLevel = (
72
+ role: string,
73
+ resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
74
+ ): ConfiguredThinkingLevel => {
75
+ if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
76
+ return resolved.thinkingLevel;
77
+ }
78
+ if (role === "default") {
79
+ return parseConfiguredThinkingLevel(settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
80
+ }
81
+ return ThinkingLevel.Inherit;
82
+ };
83
+
84
+ const roles: RoleAssignments = {};
85
+ const matchPreferences = getModelMatchPreferences(settings);
86
+ const knownRoles = getKnownRoleIds(settings);
87
+ const configuredRoles = new Set<string>();
88
+ const catalog = [...allModels];
89
+
90
+ for (const role of knownRoles) {
91
+ const roleValue = settings.getModelRole(role);
92
+ if (!roleValue) continue;
93
+ configuredRoles.add(role);
94
+ const resolved = resolveModelRoleValue(roleValue, catalog, { settings, matchPreferences });
95
+ if (resolved.model) {
96
+ roles[role] = {
97
+ model: resolved.model,
98
+ thinkingLevel: resolvedThinkingLevel(role, resolved),
99
+ autoSelected: false,
100
+ };
101
+ }
102
+ }
103
+
104
+ if (autoCandidates.length > 0) {
105
+ const candidates = [...autoCandidates];
106
+ for (const role of knownRoles) {
107
+ if (configuredRoles.has(role)) continue;
108
+ const resolved = resolveModelRoleValue(`pi/${role}`, candidates, { settings, matchPreferences });
109
+ if (!resolved.model) continue;
110
+ roles[role] = {
111
+ model: resolved.model,
112
+ thinkingLevel: resolvedThinkingLevel(role, resolved),
113
+ autoSelected: true,
114
+ };
115
+ }
116
+ }
117
+
118
+ return roles;
119
+ }
120
+
57
121
  /** Wrap raw models into browser items. */
58
122
  export function buildBrowserItems(models: ReadonlyArray<Model>): ModelBrowserItem[] {
59
123
  return models.map(model => ({
@@ -256,7 +320,6 @@ export interface ModelBrowserOptions {
256
320
  disableOverContext?: boolean;
257
321
  /** Host-provided empty-state text (e.g. provider discovery status). */
258
322
  emptyText?: () => string | undefined;
259
- initialQuery?: string;
260
323
  }
261
324
 
262
325
  /** Rendered rows before the list window: search row + blank. */
@@ -290,11 +353,15 @@ export class ModelBrowser implements Component {
290
353
  #currentContextTokens: number;
291
354
  #disableOverContext: boolean;
292
355
  #emptyText?: () => string | undefined;
356
+ /** Keep role-like virtual rows in their host-defined order during search. */
357
+ #preserveQueryOrder = false;
293
358
  /** First visible list row; panned by the wheel, snapped to the selection on keyboard navigation. */
294
359
  #windowStart = 0;
295
360
  #windowCount = 0;
296
361
  /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
297
362
  #focused = true;
363
+ /** `provider/id` of the session's active model; marked in rows and detail. */
364
+ #currentSelector: string | undefined;
298
365
 
299
366
  /** Enter or click-on-selected. */
300
367
  onActivate?: (item: ModelBrowserItem) => void;
@@ -310,9 +377,11 @@ export class ModelBrowser implements Component {
310
377
  this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0;
311
378
  this.#disableOverContext = options.disableOverContext ?? false;
312
379
  this.#emptyText = options.emptyText;
313
- if (options.initialQuery) {
314
- this.#searchInput.setValue(options.initialQuery);
315
- }
380
+ }
381
+
382
+ /** Mark `selector` as the session's active model (undefined clears the mark). */
383
+ setCurrentSelector(selector: string | undefined): void {
384
+ this.#currentSelector = selector;
316
385
  }
317
386
 
318
387
  /** Replace the scope's base items; the live query re-applies and selection is pinned by selector. */
@@ -347,6 +416,14 @@ export class ModelBrowser implements Component {
347
416
  setShowProvider(show: boolean): void {
348
417
  this.#showProvider = show;
349
418
  }
419
+ /** Keep the source order after fuzzy filtering instead of applying model-specific ranking. */
420
+ setPreserveQueryOrder(preserve: boolean): void {
421
+ this.#preserveQueryOrder = preserve;
422
+ }
423
+ /** Allow hosts to toggle context-window eligibility between browser modes. */
424
+ setDisableOverContext(disable: boolean): void {
425
+ this.#disableOverContext = disable;
426
+ }
350
427
  /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
351
428
  setFocused(focused: boolean): void {
352
429
  this.#focused = focused;
@@ -495,17 +572,21 @@ export class ModelBrowser implements Component {
495
572
  // queries all flow through the same fuzzy matcher.
496
573
  const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
497
574
  const matches = ranked.map(result => result.item);
498
- // Match quality is the primary key while searching: an exact
499
- // "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
500
- // role rank is skipped and MRU only breaks ties. Scores are
501
- // bucketed so sub-point position noise (provider-name length)
502
- // can't split equally good matches; within a bucket the stable
503
- // sort keeps sortModelItems' MRU/version order.
504
- sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
505
- const buckets = new Map<ModelBrowserItem, number>();
506
- for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
507
- matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
508
- items = matches;
575
+ if (this.#preserveQueryOrder) {
576
+ items = matches;
577
+ } else {
578
+ // Match quality is the primary key while searching: an exact
579
+ // "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
580
+ // role rank is skipped and MRU only breaks ties. Scores are
581
+ // bucketed so sub-point position noise (provider-name length)
582
+ // can't split equally good matches; within a bucket the stable
583
+ // sort keeps sortModelItems' MRU/version order.
584
+ sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
585
+ const buckets = new Map<ModelBrowserItem, number>();
586
+ for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
587
+ matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
588
+ items = matches;
589
+ }
509
590
  } else {
510
591
  items = this.#baseItems;
511
592
  }
@@ -640,11 +721,17 @@ export class ModelBrowser implements Component {
640
721
  const disabled = this.#isDisabled(item);
641
722
  const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
642
723
  const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
643
- const name = selected ? theme.fg("accent", item.id) : item.id;
724
+ const name = item.labelColor
725
+ ? theme.fg(item.labelColor, item.id)
726
+ : selected
727
+ ? theme.fg("accent", item.id)
728
+ : item.id;
729
+ const currentMark =
730
+ item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : "";
644
731
  const overLimit = disabled
645
732
  ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
646
733
  : "";
647
- let left = `${prefix}${providerPrefix}${name}${overLimit}`;
734
+ let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`;
648
735
 
649
736
  // Perf column collapses entirely when no visible row has measurements.
650
737
  const perfCol =
@@ -691,6 +778,9 @@ export class ModelBrowser implements Component {
691
778
  }
692
779
 
693
780
  const chips: string[] = [];
781
+ if (selected.selector === this.#currentSelector) {
782
+ chips.push(theme.fg("success", `${theme.status.enabled} current`));
783
+ }
694
784
  const seen = new Set<string>();
695
785
  const pushRole = (role: string) => {
696
786
  if (seen.has(role)) return;