@oh-my-pi/pi-coding-agent 16.4.6 → 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 (141) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/cli.js +3312 -3254
  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 +28 -3
  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/plan-review-overlay.d.ts +2 -0
  22. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  23. package/dist/types/modes/components/welcome.d.ts +4 -0
  24. package/dist/types/modes/print-mode.d.ts +14 -8
  25. package/dist/types/modes/print-mode.test.d.ts +1 -0
  26. package/dist/types/modes/theme/theme.d.ts +2 -1
  27. package/dist/types/sdk.d.ts +5 -1
  28. package/dist/types/session/agent-session.d.ts +42 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/session/session-entries.d.ts +6 -0
  31. package/dist/types/thinking.d.ts +2 -2
  32. package/dist/types/tiny/models.d.ts +1 -1
  33. package/dist/types/tools/browser/launch.d.ts +1 -0
  34. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  35. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  36. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  37. package/dist/types/tools/builtin-names.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/launch.d.ts +121 -0
  40. package/dist/types/tools/render-utils.d.ts +2 -0
  41. package/dist/types/tools/terminal-output.d.ts +5 -0
  42. package/dist/types/vibe/runtime.d.ts +2 -2
  43. package/dist/types/web/search/types.d.ts +0 -8
  44. package/package.json +20 -20
  45. package/src/cli/args.ts +11 -0
  46. package/src/cli/flag-tables.ts +9 -0
  47. package/src/cli/gallery-fixtures/shell.ts +82 -1
  48. package/src/cli.ts +10 -0
  49. package/src/commands/launch.ts +17 -0
  50. package/src/config/model-resolver.ts +110 -31
  51. package/src/config/model-roles.ts +14 -0
  52. package/src/config/settings-schema.ts +71 -7
  53. package/src/edit/renderer.ts +13 -10
  54. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  55. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  56. package/src/eval/completion-bridge.ts +4 -4
  57. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/extensions/model-api.ts +1 -1
  60. package/src/extensibility/extensions/types.ts +1 -1
  61. package/src/internal-urls/docs-index.ts +4 -4
  62. package/src/launch/broker.ts +1017 -0
  63. package/src/launch/client.ts +344 -0
  64. package/src/launch/paths.ts +17 -0
  65. package/src/launch/presence.ts +82 -0
  66. package/src/launch/protocol.ts +386 -0
  67. package/src/launch/terminal-output.ts +46 -0
  68. package/src/main.ts +50 -1
  69. package/src/modes/acp/acp-agent.ts +8 -1
  70. package/src/modes/components/agent-hub.ts +101 -31
  71. package/src/modes/components/compaction-summary-message.ts +8 -2
  72. package/src/modes/components/index.ts +1 -0
  73. package/src/modes/components/model-browser.ts +152 -49
  74. package/src/modes/components/model-hub.ts +55 -142
  75. package/src/modes/components/model-picker.ts +233 -0
  76. package/src/modes/components/plan-review-overlay.ts +7 -0
  77. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  78. package/src/modes/components/status-line/component.test.ts +41 -2
  79. package/src/modes/components/status-line/component.ts +4 -0
  80. package/src/modes/components/status-line/segments.ts +6 -0
  81. package/src/modes/components/status-line/types.ts +3 -0
  82. package/src/modes/components/tips.txt +2 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +9 -1
  85. package/src/modes/controllers/event-controller.ts +31 -32
  86. package/src/modes/controllers/selector-controller.ts +96 -22
  87. package/src/modes/controllers/tan-command-controller.ts +40 -1
  88. package/src/modes/interactive-mode.ts +20 -3
  89. package/src/modes/print-mode.test.ts +71 -0
  90. package/src/modes/print-mode.ts +51 -2
  91. package/src/modes/theme/theme.ts +9 -0
  92. package/src/modes/utils/ui-helpers.ts +25 -4
  93. package/src/prompts/agents/designer.md +1 -1
  94. package/src/prompts/agents/librarian.md +1 -1
  95. package/src/prompts/agents/reviewer.md +1 -1
  96. package/src/prompts/agents/scout.md +1 -1
  97. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  98. package/src/prompts/system/prewalk-checklist.md +7 -0
  99. package/src/prompts/system/prewalk-continue.md +1 -0
  100. package/src/prompts/system/prewalk-plan.md +13 -0
  101. package/src/prompts/system/system-prompt.md +9 -7
  102. package/src/prompts/system/tan-context-switch.md +17 -0
  103. package/src/prompts/tools/bash.md +6 -4
  104. package/src/prompts/tools/browser.md +4 -4
  105. package/src/prompts/tools/launch.md +25 -0
  106. package/src/sdk.ts +8 -2
  107. package/src/session/agent-session.ts +550 -87
  108. package/src/session/session-context.test.ts +10 -5
  109. package/src/session/session-context.ts +25 -8
  110. package/src/session/session-entries.ts +6 -0
  111. package/src/slash-commands/builtin-registry.ts +25 -0
  112. package/src/task/agents.ts +2 -2
  113. package/src/thinking.ts +10 -3
  114. package/src/tiny/models.ts +7 -7
  115. package/src/tools/bash-interactive.ts +5 -8
  116. package/src/tools/bash.ts +38 -24
  117. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  118. package/src/tools/browser/launch.ts +8 -4
  119. package/src/tools/browser/run-cancellation.ts +66 -6
  120. package/src/tools/browser/tab-protocol.ts +6 -0
  121. package/src/tools/browser/tab-supervisor.ts +17 -1
  122. package/src/tools/browser/tab-worker.ts +140 -21
  123. package/src/tools/builtin-names.ts +1 -0
  124. package/src/tools/eval-render.ts +16 -14
  125. package/src/tools/index.ts +5 -0
  126. package/src/tools/inspect-image.ts +2 -2
  127. package/src/tools/launch.ts +643 -0
  128. package/src/tools/render-utils.ts +3 -0
  129. package/src/tools/renderers.ts +2 -0
  130. package/src/tools/terminal-output.ts +141 -0
  131. package/src/tts/speech-enhancer.ts +2 -2
  132. package/src/utils/image-vision-fallback.ts +3 -3
  133. package/src/vibe/runtime.ts +2 -2
  134. package/src/web/search/provider.ts +0 -10
  135. package/src/web/search/providers/perplexity.ts +18 -2
  136. package/src/web/search/providers/public.ts +2 -4
  137. package/src/web/search/types.ts +0 -10
  138. package/dist/types/web/search/providers/bing.d.ts +0 -14
  139. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  140. package/src/web/search/providers/bing.ts +0 -197
  141. 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,9 +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;
361
+ /** Whether the host pane owns arrow keys; drives cursor strength and the selected-row band. */
362
+ #focused = true;
363
+ /** `provider/id` of the session's active model; marked in rows and detail. */
364
+ #currentSelector: string | undefined;
296
365
 
297
366
  /** Enter or click-on-selected. */
298
367
  onActivate?: (item: ModelBrowserItem) => void;
@@ -308,9 +377,11 @@ export class ModelBrowser implements Component {
308
377
  this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0;
309
378
  this.#disableOverContext = options.disableOverContext ?? false;
310
379
  this.#emptyText = options.emptyText;
311
- if (options.initialQuery) {
312
- this.#searchInput.setValue(options.initialQuery);
313
- }
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;
314
385
  }
315
386
 
316
387
  /** Replace the scope's base items; the live query re-applies and selection is pinned by selector. */
@@ -345,6 +416,18 @@ export class ModelBrowser implements Component {
345
416
  setShowProvider(show: boolean): void {
346
417
  this.#showProvider = show;
347
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
+ }
427
+ /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
428
+ setFocused(focused: boolean): void {
429
+ this.#focused = focused;
430
+ }
348
431
 
349
432
  /** Total rendered height for the current `maxVisible` (host layout budgeting). */
350
433
  get renderedRows(): number {
@@ -416,18 +499,27 @@ export class ModelBrowser implements Component {
416
499
  this.#windowStart = this.#clampWindowStart(this.#windowStart);
417
500
  }
418
501
 
419
- moveSelection(delta: number): void {
502
+ /**
503
+ * Move the selection by `delta` rows, skipping disabled rows. Single steps
504
+ * wrap at the ends; `wrap: false` (page/home/end jumps) clamps instead.
505
+ */
506
+ moveSelection(delta: number, options: { wrap?: boolean } = {}): void {
420
507
  const count = this.#visibleItems.length;
421
508
  if (count === 0) return;
422
- let index = this.#selectedIndex;
423
- for (let step = 0; step < count; step++) {
424
- index = (index + delta + count) % count;
425
- const item = this.#visibleItems[index];
426
- if (item && !this.#isDisabled(item)) {
427
- this.#setSelectedIndex(index);
428
- return;
509
+ if (options.wrap ?? true) {
510
+ let index = this.#selectedIndex;
511
+ for (let step = 0; step < count; step++) {
512
+ index = (index + delta + count) % count;
513
+ const item = this.#visibleItems[index];
514
+ if (item && !this.#isDisabled(item)) {
515
+ this.#setSelectedIndex(index);
516
+ return;
517
+ }
429
518
  }
519
+ return;
430
520
  }
521
+ const target = Math.max(0, Math.min(this.#selectedIndex + delta, count - 1));
522
+ this.#setSelectedIndex(this.#coerceSelectedIndex(target));
431
523
  }
432
524
 
433
525
  #setSelectedIndex(index: number): void {
@@ -480,17 +572,21 @@ export class ModelBrowser implements Component {
480
572
  // queries all flow through the same fuzzy matcher.
481
573
  const ranked = fuzzyRank(this.#baseItems, query, ({ provider, id }) => `${provider}/${id}`);
482
574
  const matches = ranked.map(result => result.item);
483
- // Match quality is the primary key while searching: an exact
484
- // "gpt-5.5" must beat the MRU (or role-assigned) "gpt-5.6", so
485
- // role rank is skipped and MRU only breaks ties. Scores are
486
- // bucketed so sub-point position noise (provider-name length)
487
- // can't split equally good matches; within a bucket the stable
488
- // sort keeps sortModelItems' MRU/version order.
489
- sortModelItems(matches, { roles: this.#roles, mruOrder: this.#mruOrder, skipRoleRank: true });
490
- const buckets = new Map<ModelBrowserItem, number>();
491
- for (const result of ranked) buckets.set(result.item, Math.round(result.score / 10));
492
- matches.sort((a, b) => (buckets.get(a) ?? 0) - (buckets.get(b) ?? 0));
493
- 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
+ }
494
590
  } else {
495
591
  items = this.#baseItems;
496
592
  }
@@ -514,11 +610,19 @@ export class ModelBrowser implements Component {
514
610
  return;
515
611
  }
516
612
  if (matchesSelectPageUp(data)) {
517
- this.moveSelection(-this.#maxVisible);
613
+ this.moveSelection(-this.#maxVisible, { wrap: false });
518
614
  return;
519
615
  }
520
616
  if (matchesSelectPageDown(data)) {
521
- this.moveSelection(this.#maxVisible);
617
+ this.moveSelection(this.#maxVisible, { wrap: false });
618
+ return;
619
+ }
620
+ if (matchesKey(data, "home")) {
621
+ this.moveSelection(-this.#visibleItems.length, { wrap: false });
622
+ return;
623
+ }
624
+ if (matchesKey(data, "end")) {
625
+ this.moveSelection(this.#visibleItems.length, { wrap: false });
522
626
  return;
523
627
  }
524
628
  if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
@@ -574,6 +678,10 @@ export class ModelBrowser implements Component {
574
678
  this.#setSelectedIndex(index);
575
679
  }
576
680
  }
681
+ /** Drop the hover band. Hosts call this when the pointer leaves the browser pane. */
682
+ clearHover(): void {
683
+ this.#hoveredIndex = null;
684
+ }
577
685
 
578
686
  /** List index under a frame-local row, or null when off-list or on a disabled row. */
579
687
  #hoverIndexAt(line: number): number | null {
@@ -585,22 +693,6 @@ export class ModelBrowser implements Component {
585
693
  return index;
586
694
  }
587
695
 
588
- #chipsFor(model: Model): string {
589
- const parts: string[] = [];
590
- const seen = new Set<string>();
591
- const pushChip = (role: string) => {
592
- if (seen.has(role)) return;
593
- seen.add(role);
594
- const assignment = this.#roles[role];
595
- if (!assignment || !modelsAreEqual(assignment.model, model)) return;
596
- if (getRoleInfo(role, this.#settings).hidden) return;
597
- parts.push(formatRoleChip(role, assignment, this.#settings));
598
- };
599
- for (const role of MODEL_ROLE_IDS) pushChip(role);
600
- for (const role in this.#roles) pushChip(role);
601
- return parts.length > 0 ? ` ${parts.join(" ")}` : "";
602
- }
603
-
604
696
  /** `0.9s 118t/s` measured-perf cell for the row's meta block; empty when unmeasured or the column is off. */
605
697
  #perfCell(item: ModelBrowserItem, mode: PerfMode): string {
606
698
  if (mode === "off") return "";
@@ -627,13 +719,19 @@ export class ModelBrowser implements Component {
627
719
  return ` ${line} `;
628
720
  }
629
721
  const disabled = this.#isDisabled(item);
630
- const prefix = selected ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
722
+ const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
631
723
  const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
632
- 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)}` : "";
633
731
  const overLimit = disabled
634
732
  ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
635
733
  : "";
636
- let left = `${prefix}${providerPrefix}${name}${this.#chipsFor(item.model)}${overLimit}`;
734
+ let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`;
637
735
 
638
736
  // Perf column collapses entirely when no visible row has measurements.
639
737
  const perfCol =
@@ -648,7 +746,9 @@ export class ModelBrowser implements Component {
648
746
  if (disabled) {
649
747
  line = theme.fg("dim", Bun.stripANSI(line));
650
748
  }
651
- if (hovered && !selected && !disabled) {
749
+ // The bg band is reserved for the mouse: it marks hover, nothing else.
750
+ // Keyboard selection is the cursor glyph + accent name.
751
+ if (hovered && !disabled) {
652
752
  line = theme.bg("selectedBg", line);
653
753
  }
654
754
  return line;
@@ -678,6 +778,9 @@ export class ModelBrowser implements Component {
678
778
  }
679
779
 
680
780
  const chips: string[] = [];
781
+ if (selected.selector === this.#currentSelector) {
782
+ chips.push(theme.fg("success", `${theme.status.enabled} current`));
783
+ }
681
784
  const seen = new Set<string>();
682
785
  const pushRole = (role: string) => {
683
786
  if (seen.has(role)) return;