@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
@@ -6,7 +6,8 @@
6
6
  * {@link ModelBrowser} body. The Roles view manages assignments directly:
7
7
  * pick a role, pick a model, adjust thinking in an inline strip, or clear the
8
8
  * role back to auto-selection. Locked providers forward to the /login flow.
9
- * Fully mouse-navigable (hover, wheel, click).
9
+ * Fully mouse-navigable (hover, wheel, click). Session-only switching lives
10
+ * in the compact alt+p picker ({@link ./model-picker}).
10
11
  */
11
12
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
12
13
  import type { Model } from "@oh-my-pi/pi-ai";
@@ -27,15 +28,9 @@ import {
27
28
  visibleWidth,
28
29
  } from "@oh-my-pi/pi-tui";
29
30
  import type { ModelRegistry } from "../../config/model-registry";
30
- import { getModelMatchPreferences, resolveModelRoleValue } from "../../config/model-resolver";
31
31
  import { getKnownRoleIds, getRoleInfo } from "../../config/model-roles";
32
32
  import type { Settings } from "../../config/settings";
33
- import {
34
- AUTO_THINKING,
35
- type ConfiguredThinkingLevel,
36
- getConfiguredThinkingLevelMetadata,
37
- parseConfiguredThinkingLevel,
38
- } from "../../thinking";
33
+ import { AUTO_THINKING, type ConfiguredThinkingLevel, getConfiguredThinkingLevelMetadata } from "../../thinking";
39
34
  import { theme } from "../theme/theme";
40
35
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
41
36
  import {
@@ -43,15 +38,13 @@ import {
43
38
  ModelBrowser,
44
39
  type ModelBrowserItem,
45
40
  type RoleAssignments,
41
+ resolveRoleAssignments,
46
42
  sortModelItems,
47
43
  thinkingLevelGlyph,
48
44
  } from "./model-browser";
49
45
  import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box";
50
46
  import { renderSegmentTrack } from "./segment-track";
51
47
 
52
- /** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */
53
- export type ModelHubMode = "roles" | "pick";
54
-
55
48
  /**
56
49
  * A row of the Roles view: a role, a model/wildcard chain-key header, one of a
57
50
  * chain's fallback entries, or the trailing "+ New role…". Fallback rows under
@@ -89,8 +82,6 @@ export interface ModelHubCallbacks {
89
82
  onUnassign: (role: string) => void;
90
83
  /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */
91
84
  onFallbackChainChange?: (role: string, chain: string[]) => void;
92
- /** Pick-mode activation: session-only switch or embedded pick. */
93
- onPick?: (model: Model, selector: string) => void;
94
85
  /** Locked provider activation: forward to the /login flow. */
95
86
  onLoginRequest?: (providerId: string) => void;
96
87
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
@@ -99,14 +90,8 @@ export interface ModelHubCallbacks {
99
90
  }
100
91
 
101
92
  export interface ModelHubOptions {
102
- mode?: ModelHubMode;
103
- /** Session token count; in pick mode, models with smaller context windows are disabled. */
104
- currentContextTokens?: number;
105
93
  /** Preselect this provider's sidebar entry (e.g. when reopening after /login). */
106
94
  initialProviderId?: string;
107
- /** Status-row hint shown in pick mode. */
108
- pickerHint?: string;
109
- initialQuery?: string;
110
95
  }
111
96
 
112
97
  interface SidebarEntry {
@@ -158,8 +143,6 @@ const RECENT_LIMIT = 15;
158
143
  const SIDEBAR_MIN_WIDTH = 18;
159
144
  const SIDEBAR_MAX_WIDTH = 26;
160
145
 
161
- const PICK_MODE_HINT = "Session-only switch — role models stay unchanged";
162
-
163
146
  /**
164
147
  * Providers already auto-refreshed this process. Selecting a provider fetches
165
148
  * its live model list at most once per application lifetime (surviving hub
@@ -182,8 +165,6 @@ export class ModelHubComponent implements Component {
182
165
  #registry: ModelRegistry;
183
166
  #scopedModels: ReadonlyArray<ScopedModelItem>;
184
167
  #callbacks: ModelHubCallbacks;
185
- #mode: ModelHubMode;
186
- #pickerHint: string;
187
168
 
188
169
  #browser: ModelBrowser;
189
170
  #roles: RoleAssignments = {};
@@ -250,14 +231,9 @@ export class ModelHubComponent implements Component {
250
231
  this.#registry = registry;
251
232
  this.#scopedModels = scopedModels;
252
233
  this.#callbacks = callbacks;
253
- this.#mode = options.mode ?? "roles";
254
- this.#pickerHint = options.pickerHint ?? PICK_MODE_HINT;
255
234
 
256
235
  this.#browser = new ModelBrowser(settings, {
257
- currentContextTokens: options.currentContextTokens,
258
- disableOverContext: this.#mode === "pick",
259
236
  emptyText: () => this.#emptyStateMessage(),
260
- initialQuery: options.initialQuery,
261
237
  });
262
238
  this.#browser.onActivate = item => this.#activateItem(item);
263
239
  this.#browser.onCancel = () => this.#callbacks.onCancel();
@@ -275,10 +251,6 @@ export class ModelHubComponent implements Component {
275
251
  this.#setActiveEntry("all");
276
252
  }
277
253
 
278
- if (this.#mode === "pick") {
279
- this.#focus = "list";
280
- }
281
-
282
254
  // Reconcile with cached discovery state in the background. A --models
283
255
  // scope is registry-independent, so the offline reload would only repeat
284
256
  // the synchronous hydration above.
@@ -314,62 +286,10 @@ export class ModelHubComponent implements Component {
314
286
  return getKnownRoleIds(this.#settings).filter(role => !getRoleInfo(role, this.#settings).hidden);
315
287
  }
316
288
 
317
- #getResolvedRoleThinkingLevel(
318
- role: string,
319
- resolved: { explicitThinkingLevel: boolean; thinkingLevel?: ConfiguredThinkingLevel },
320
- ): ConfiguredThinkingLevel {
321
- if (resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined) {
322
- return resolved.thinkingLevel;
323
- }
324
- if (role === "default") {
325
- return parseConfiguredThinkingLevel(this.#settings.get("defaultThinkingLevel")) ?? ThinkingLevel.Inherit;
326
- }
327
- return ThinkingLevel.Inherit;
328
- }
329
-
330
289
  /** Resolve every known role: configured values first, auto-selection for the rest. */
331
290
  #reloadRoles(autoCandidates: ReadonlyArray<Model>): void {
332
- const nextRoles: RoleAssignments = {};
333
- const allModels = this.#scopedModels.length > 0 ? [...autoCandidates] : this.#registry.getAll();
334
- const matchPreferences = getModelMatchPreferences(this.#settings);
335
- const knownRoles = getKnownRoleIds(this.#settings);
336
- const configuredRoles = new Set<string>();
337
-
338
- for (const role of knownRoles) {
339
- const roleValue = this.#settings.getModelRole(role);
340
- if (!roleValue) continue;
341
- configuredRoles.add(role);
342
- const resolved = resolveModelRoleValue(roleValue, allModels, {
343
- settings: this.#settings,
344
- matchPreferences,
345
- });
346
- if (resolved.model) {
347
- nextRoles[role] = {
348
- model: resolved.model,
349
- thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
350
- autoSelected: false,
351
- };
352
- }
353
- }
354
-
355
- if (autoCandidates.length > 0) {
356
- const candidates = [...autoCandidates];
357
- for (const role of knownRoles) {
358
- if (configuredRoles.has(role)) continue;
359
- const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
360
- settings: this.#settings,
361
- matchPreferences,
362
- });
363
- if (!resolved.model) continue;
364
- nextRoles[role] = {
365
- model: resolved.model,
366
- thinkingLevel: this.#getResolvedRoleThinkingLevel(role, resolved),
367
- autoSelected: true,
368
- };
369
- }
370
- }
371
-
372
- this.#roles = nextRoles;
291
+ const allModels = this.#scopedModels.length > 0 ? autoCandidates : this.#registry.getAll();
292
+ this.#roles = resolveRoleAssignments(this.#settings, allModels, autoCandidates);
373
293
  }
374
294
 
375
295
  /** Rebuild items, roles, and the sidebar from the registry's in-memory state. */
@@ -475,16 +395,15 @@ export class ModelHubComponent implements Component {
475
395
 
476
396
  // Roles leads the fixed section so downward hops from Recent head into
477
397
  // model scopes instead of being captured by the roles view.
478
- const fixed: SidebarEntry[] = [];
479
- if (this.#mode === "roles") {
480
- fixed.push({
398
+ const fixed: SidebarEntry[] = [
399
+ {
481
400
  id: "roles",
482
401
  kind: "roles",
483
402
  label: "Roles",
484
403
  annotation: `${assignedCount}/${visibleRoles.length}`,
485
- });
486
- }
487
- fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
404
+ },
405
+ { id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) },
406
+ ];
488
407
 
489
408
  this.#fixedEntries = fixed;
490
409
  this.#unlockedProviderEntries = [...unlocked]
@@ -811,10 +730,6 @@ export class ModelHubComponent implements Component {
811
730
  // ═══════════════════════════════════════════════════════════════════════
812
731
 
813
732
  #activateItem(item: ModelBrowserItem): void {
814
- if (this.#mode === "pick") {
815
- this.#callbacks.onPick?.(item.model, item.selector);
816
- return;
817
- }
818
733
  if (this.#assigning) {
819
734
  const target = this.#assigning;
820
735
  this.#assigning = null;
@@ -917,7 +832,7 @@ export class ModelHubComponent implements Component {
917
832
  const strip = this.#strip;
918
833
  this.#strip = null;
919
834
  this.#chipRanges = [];
920
- if (strip?.kind === "thinking" && strip.returnToRoles && this.#mode === "roles") {
835
+ if (strip?.kind === "thinking" && strip.returnToRoles) {
921
836
  this.#setActiveEntry("roles");
922
837
  this.#focus = "list";
923
838
  }
@@ -1030,14 +945,12 @@ export class ModelHubComponent implements Component {
1030
945
  }
1031
946
  this.#setFallbackChain(target.role, chain);
1032
947
  this.#browser.setQuery("");
1033
- if (this.#mode === "roles") {
1034
- this.#setActiveEntry("roles");
1035
- this.#focus = "list";
1036
- const rowIndex = this.#rolesRows.findIndex(
1037
- row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
1038
- );
1039
- if (rowIndex >= 0) this.#roleIndex = rowIndex;
1040
- }
948
+ this.#setActiveEntry("roles");
949
+ this.#focus = "list";
950
+ const rowIndex = this.#rolesRows.findIndex(
951
+ row => row.kind === "fallback" && row.role === target.role && row.selector === selector,
952
+ );
953
+ if (rowIndex >= 0) this.#roleIndex = rowIndex;
1041
954
  }
1042
955
 
1043
956
  /** Persist `role`'s chain through the host callback and rebuild dependent state. */
@@ -1076,10 +989,8 @@ export class ModelHubComponent implements Component {
1076
989
  #cancelAssign(): void {
1077
990
  this.#assigning = null;
1078
991
  this.#browser.setQuery("");
1079
- if (this.#mode === "roles") {
1080
- this.#setActiveEntry("roles");
1081
- this.#focus = "list";
1082
- }
992
+ this.#setActiveEntry("roles");
993
+ this.#focus = "list";
1083
994
  }
1084
995
 
1085
996
  // ═══════════════════════════════════════════════════════════════════════
@@ -1655,7 +1566,7 @@ export class ModelHubComponent implements Component {
1655
1566
  let text: string;
1656
1567
  switch (entry.kind) {
1657
1568
  case "recent":
1658
- text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`;
1569
+ text = `Recently used models${scopedSuffix}`;
1659
1570
  break;
1660
1571
  case "roles":
1661
1572
  text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection";
@@ -1670,7 +1581,7 @@ export class ModelHubComponent implements Component {
1670
1581
  }
1671
1582
  break;
1672
1583
  default:
1673
- text = this.#mode === "pick" ? this.#pickerHint : `All available models${scopedSuffix}`;
1584
+ text = `All available models${scopedSuffix}`;
1674
1585
  break;
1675
1586
  }
1676
1587
  if (this.#configError && entry.kind !== "provider") {
@@ -1895,9 +1806,6 @@ export class ModelHubComponent implements Component {
1895
1806
  }
1896
1807
  const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
1897
1808
  const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
1898
- if (this.#mode === "pick") {
1899
- return `Enter use for this session · ${arrows} · type to search${refresh} · Esc close`;
1900
- }
1901
1809
  return `Enter assign roles · ${arrows} · type to search${refresh} · Esc close`;
1902
1810
  }
1903
1811
 
@@ -1992,9 +1900,8 @@ export class ModelHubComponent implements Component {
1992
1900
 
1993
1901
  const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows);
1994
1902
 
1995
- const title = this.#mode === "pick" ? "Switch Model" : "Models";
1996
1903
  const out: string[] = [];
1997
- out.push(topBorderSplit(width, title, sidebarWidth));
1904
+ out.push(topBorderSplit(width, "Models", sidebarWidth));
1998
1905
  this.#contentRowStart = out.length;
1999
1906
  for (let i = 0; i < contentRows; i++) {
2000
1907
  out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth));
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Compact session-model picker (alt+p / `/switch`): a bottom-anchored
3
+ * floating overlay hosting just a {@link ModelBrowser} — no provider sidebar.
4
+ * Model entries switch the current session only; a search beginning with `@`
5
+ * exposes the configured ctrl+p quick roles.
6
+ */
7
+ import type { Model } from "@oh-my-pi/pi-ai";
8
+ import type { Component, TUI } from "@oh-my-pi/pi-tui";
9
+ import type { ModelRegistry } from "../../config/model-registry";
10
+ import type { Settings } from "../../config/settings";
11
+ import type { ResolvedRoleModel } from "../../session/agent-session";
12
+ import { theme } from "../theme/theme";
13
+ import {
14
+ buildBrowserItems,
15
+ ModelBrowser,
16
+ type ModelBrowserItem,
17
+ resolveRoleAssignments,
18
+ sortModelItems,
19
+ } from "./model-browser";
20
+ import type { ScopedModelItem } from "./model-hub";
21
+ import { bottomBorder, row, topBorder } from "./overlay-box";
22
+ import { resolveSegmentPalette } from "./segment-track";
23
+
24
+ export interface ModelPickerCallbacks {
25
+ /** A model was chosen for a session-only switch. `selector` is `provider/id`. */
26
+ onPick: (model: Model, selector: string) => void;
27
+ /** A configured ctrl+p quick role was chosen. */
28
+ onPickRole?: (entry: ResolvedRoleModel) => void;
29
+ /** The picker was dismissed. */
30
+ onCancel: () => void;
31
+ }
32
+
33
+ export interface ModelPickerOptions {
34
+ /** Session token count; models with smaller context windows are disabled. */
35
+ currentContextTokens?: number;
36
+ /** `provider/id` of the session's active model; highlighted and preselected. */
37
+ currentSelector?: string;
38
+ /** Resolved role models in the same order used by the ctrl+p quick-role cycle. */
39
+ quickRoles?: ReadonlyArray<ResolvedRoleModel>;
40
+ /** Complete ctrl+p order, including unavailable roles, to preserve segment colors. */
41
+ quickRoleOrder?: ReadonlyArray<string>;
42
+ /** Active quick role, highlighted when the search begins with `@`. */
43
+ currentQuickRole?: string;
44
+ }
45
+
46
+ /** Fixed chrome rows: top border, status row, footer, bottom border. */
47
+ const CHROME_ROWS = 4;
48
+ /** Rows the browser renders around its list window (search + blank, blank + two detail rows). */
49
+ const BROWSER_FRAME_ROWS = 5;
50
+ /** Minimum rows for the browser list window on short terminals. */
51
+ const MIN_VISIBLE = 5;
52
+ /** Fraction of the terminal height the floating overlay occupies. */
53
+ const HEIGHT_FRACTION = 0.4;
54
+
55
+ const STATUS_HINT = "Session-only switch — role models stay unchanged";
56
+ const QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session";
57
+ const FOOTER_HINT = "↑/↓ models · Enter use for this session · type to search · @ quick roles · Esc close";
58
+ const QUICK_ROLE_FOOTER_HINT = "↑/↓ roles · Enter apply role model · type to search · Esc close";
59
+
60
+ /**
61
+ * The alt+p picker component. Hosted as a non-fullscreen bottom-anchored
62
+ * overlay (`ui.showOverlay(..., { anchor: "bottom-center" })`); keyboard-only,
63
+ * since mouse tracking is reserved for fullscreen overlays.
64
+ */
65
+ export class ModelPickerComponent implements Component {
66
+ #tui: TUI;
67
+ #settings: Settings;
68
+ #registry: ModelRegistry;
69
+ #scopedModels: ReadonlyArray<ScopedModelItem>;
70
+ #browser: ModelBrowser;
71
+ #configError: string | undefined;
72
+ #currentSelector: string | undefined;
73
+ #currentQuickRoleSelector: string | undefined;
74
+ #modelItems: ModelBrowserItem[] = [];
75
+ #quickRoleItems: ModelBrowserItem[] = [];
76
+ #quickRoles = new Map<string, ResolvedRoleModel>();
77
+ #roleMode = false;
78
+
79
+ constructor(
80
+ tui: TUI,
81
+ settings: Settings,
82
+ registry: ModelRegistry,
83
+ scopedModels: ReadonlyArray<ScopedModelItem>,
84
+ callbacks: ModelPickerCallbacks,
85
+ options: ModelPickerOptions = {},
86
+ ) {
87
+ this.#tui = tui;
88
+ this.#settings = settings;
89
+ this.#registry = registry;
90
+ this.#scopedModels = scopedModels;
91
+ this.#currentSelector = options.currentSelector;
92
+ this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined;
93
+ this.#quickRoleItems = this.#buildQuickRoleItems(
94
+ options.quickRoles ?? [],
95
+ options.quickRoleOrder ?? options.quickRoles?.map(entry => entry.role) ?? [],
96
+ );
97
+
98
+ this.#browser = new ModelBrowser(settings, {
99
+ currentContextTokens: options.currentContextTokens,
100
+ disableOverContext: true,
101
+ emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined),
102
+ });
103
+ this.#browser.onActivate = item => {
104
+ const quickRole = this.#quickRoles.get(item.selector);
105
+ if (quickRole) {
106
+ callbacks.onPickRole?.(quickRole);
107
+ return;
108
+ }
109
+ callbacks.onPick(item.model, item.selector);
110
+ };
111
+ this.#browser.onCancel = () => callbacks.onCancel();
112
+ this.#browser.onQueryChange = query => this.#syncItemsForQuery(query);
113
+
114
+ // Hydrate synchronously from the current registry snapshot so the first
115
+ // Enter after opening acts on cached models instead of being dropped
116
+ // while the offline refresh promise is still pending.
117
+ this.#syncFromRegistryState();
118
+ if (options.currentSelector) {
119
+ this.#browser.selectSelector(options.currentSelector);
120
+ }
121
+
122
+ // Reconcile with cached discovery state in the background. A --models
123
+ // scope is registry-independent, so the offline reload would only repeat
124
+ // the synchronous hydration above.
125
+ if (this.#scopedModels.length === 0) {
126
+ this.#registry
127
+ .refresh("offline")
128
+ .then(() => this.#syncFromRegistryState())
129
+ .catch(error => {
130
+ this.#configError = error instanceof Error ? error.message : String(error);
131
+ })
132
+ .finally(() => this.#tui.requestRender());
133
+ }
134
+ }
135
+
136
+ invalidate(): void {}
137
+
138
+ /** Rebuild model items and role chips from the registry's in-memory state. */
139
+ #syncFromRegistryState(): void {
140
+ let models: ReadonlyArray<Model>;
141
+ if (this.#scopedModels.length > 0) {
142
+ models = this.#scopedModels.map(scoped => scoped.model);
143
+ this.#configError = undefined;
144
+ } else {
145
+ const loadError = this.#registry.getError();
146
+ this.#configError = loadError ? String(loadError) : undefined;
147
+ try {
148
+ models = this.#registry.getAvailable();
149
+ } catch (error) {
150
+ this.#configError = error instanceof Error ? error.message : String(error);
151
+ models = [];
152
+ }
153
+ }
154
+
155
+ const allModels = this.#scopedModels.length > 0 ? models : this.#registry.getAll();
156
+ const roles = resolveRoleAssignments(this.#settings, allModels, models);
157
+ const storage = this.#settings.getStorage();
158
+ const mruOrder = storage?.getModelUsageOrder() ?? [];
159
+ this.#modelItems = buildBrowserItems(models);
160
+ sortModelItems(this.#modelItems, { roles, mruOrder });
161
+ this.#browser.setRoles(roles);
162
+ this.#browser.setMruOrder(mruOrder);
163
+ this.#browser.setPerfStats(storage?.getModelPerf() ?? new Map());
164
+ this.#syncItemsForQuery(this.#browser.query, true);
165
+ }
166
+
167
+ /** Build virtual `@role` rows, colored by their ctrl+p segment position. */
168
+ #buildQuickRoleItems(
169
+ quickRoles: ReadonlyArray<ResolvedRoleModel>,
170
+ quickRoleOrder: ReadonlyArray<string>,
171
+ ): ModelBrowserItem[] {
172
+ const order = quickRoleOrder.length > 0 ? quickRoleOrder : quickRoles.map(entry => entry.role);
173
+ const palette = resolveSegmentPalette(order.length);
174
+ return quickRoles.map((entry, index) => {
175
+ const selector = `@${entry.role}`;
176
+ this.#quickRoles.set(selector, entry);
177
+ const orderIndex = order.indexOf(entry.role);
178
+ return {
179
+ provider: "",
180
+ id: selector,
181
+ model: entry.model,
182
+ selector,
183
+ labelColor: palette[(orderIndex >= 0 ? orderIndex : index) % palette.length],
184
+ };
185
+ });
186
+ }
187
+
188
+ /** Switch browser content only when a leading `@` changes the search mode. */
189
+ #syncItemsForQuery(query: string, refresh = false): void {
190
+ const roleMode = query.startsWith("@");
191
+ const modeChanged = roleMode !== this.#roleMode;
192
+ if (!modeChanged && !refresh) return;
193
+
194
+ this.#roleMode = roleMode;
195
+ this.#browser.setShowProvider(!roleMode);
196
+ this.#browser.setDisableOverContext(!roleMode);
197
+ this.#browser.setPreserveQueryOrder(roleMode);
198
+ const currentSelector = roleMode ? this.#currentQuickRoleSelector : this.#currentSelector;
199
+ this.#browser.setCurrentSelector(currentSelector);
200
+ this.#browser.setItems(roleMode ? this.#quickRoleItems : this.#modelItems);
201
+ if (modeChanged && currentSelector) {
202
+ this.#browser.selectSelector(currentSelector);
203
+ }
204
+ }
205
+
206
+ handleInput(data: string): void {
207
+ // Mouse tracking is off outside fullscreen overlays; drop any stray SGR
208
+ // reports instead of feeding them to the search input.
209
+ if (data.startsWith("\x1b[<")) return;
210
+ this.#browser.handleInput(data);
211
+ }
212
+
213
+ render(width: number): string[] {
214
+ const termRows = Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40);
215
+ const listBudget = Math.floor(termRows * HEIGHT_FRACTION) - CHROME_ROWS - BROWSER_FRAME_ROWS;
216
+ this.#browser.setMaxVisible(Math.max(MIN_VISIBLE, listBudget));
217
+
218
+ const inner = Math.max(1, width - 4);
219
+ const status = this.#configError
220
+ ? theme.fg("error", ` ${this.#configError}`)
221
+ : theme.fg("muted", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`);
222
+
223
+ const out: string[] = [];
224
+ out.push(topBorder(width, "Switch Model"));
225
+ out.push(row(status, width));
226
+ for (const line of this.#browser.render(inner)) {
227
+ out.push(row(line, width));
228
+ }
229
+ out.push(row(theme.fg("dim", this.#roleMode ? QUICK_ROLE_FOOTER_HINT : FOOTER_HINT), width));
230
+ out.push(bottomBorder(width));
231
+ return out;
232
+ }
233
+ }
@@ -1,18 +1,14 @@
1
- # User
2
- Fix the settings overlay crash. Wheeling past the last row throws.
1
+ ¶user:Fix the settings overlay crash. Wheeling past the last row throws.
3
2
 
4
- # Tool call
5
- //Reading the select-list hit test
6
- read(path="src/select-list.ts:140-180")
3
+ ¶call:read(path="src/select-list.ts:140-180")//Reading the select-list hit test
7
4
  <out>
8
5
  162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
9
6
  </out>
10
7
 
11
- # Assistant
12
- Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
8
+ ¶ai:Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
13
9
 
14
- # User
15
- Does the fix survive filtering?
10
+ ¶user:Does the fix survive filtering?
16
11
 
17
- # Assistant
18
- Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
12
+ ¶think:Check whether the clamp runs before or after filtering.
13
+
14
+ ¶ai:Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
@@ -4,15 +4,43 @@ import type { AgentSession } from "../../../session/agent-session";
4
4
  import { getThemeByName, setThemeInstance } from "../../theme/theme";
5
5
  import { StatusLineComponent } from "./component";
6
6
 
7
- function makeSessionWithLastMessage(lastMessage: unknown) {
7
+ function makeSessionWithLastMessage(lastMessage: unknown, prewalkArmed: boolean = false) {
8
8
  return {
9
- messages: [lastMessage],
9
+ messages: lastMessage ? [lastMessage] : [],
10
10
  model: { contextWindow: 128000 },
11
11
  contextUsageRevision: 0,
12
12
  systemPrompt: [],
13
13
  agent: { state: { tools: [] } },
14
14
  skills: [],
15
15
  getContextUsage: () => ({ tokens: 42, contextWindow: 128000 }),
16
+ state: {
17
+ messages: lastMessage ? [lastMessage] : [],
18
+ model: { contextWindow: 128000 },
19
+ },
20
+ sessionManager: {
21
+ getUsageStatistics: () => ({
22
+ input: 0,
23
+ output: 0,
24
+ cacheRead: 0,
25
+ cacheWrite: 0,
26
+ totalTokens: 0,
27
+ orchestrationInput: 0,
28
+ orchestrationOutput: 0,
29
+ orchestrationCacheRead: 0,
30
+ premiumRequests: 0,
31
+ cost: 0,
32
+ tokensPerSecond: null,
33
+ }),
34
+ getSessionName: () => "test-session",
35
+ },
36
+ getPrewalkState: () => (prewalkArmed ? { target: { id: "cheap-model", provider: "openai" } } : undefined),
37
+ getAsyncJobSnapshot: () => undefined,
38
+ isAdvisorActive: () => false,
39
+ isFastModeActive: () => false,
40
+ configuredThinkingLevel: () => undefined,
41
+ modelRegistry: {
42
+ isUsingOAuth: () => false,
43
+ },
16
44
  };
17
45
  }
18
46
 
@@ -41,4 +69,15 @@ describe("StatusLineComponent", () => {
41
69
 
42
70
  expect(statusLine.getCachedContextBreakdown()).toEqual({ usedTokens: 42, contextWindow: 128000 });
43
71
  });
72
+
73
+ it("renders Prewalk annotation when prewalk is armed", () => {
74
+ const statusLine = new StatusLineComponent(makeSessionWithLastMessage(null, true) as unknown as AgentSession);
75
+
76
+ // By default preset, 'mode' segment is included in left/right segments.
77
+ // Let's get the border and see if Prewalk is rendered.
78
+ const border = statusLine.getTopBorder(100);
79
+ // SGR codes might be included, so we check if the stripped content contains "Prewalk"
80
+ const stripped = border.content.replace(/\x1b\[[0-9;]*m/g, "");
81
+ expect(stripped).toContain("Prewalk");
82
+ });
44
83
  });
@@ -1051,6 +1051,10 @@ export class StatusLineComponent implements Component {
1051
1051
  compactThinkingLevel: this.#resolveSettings().compactThinkingLevel ?? false,
1052
1052
  planMode: this.#planModeStatus,
1053
1053
  loopMode: this.#loopModeStatus,
1054
+ prewalk:
1055
+ typeof this.session.getPrewalkState === "function" && this.session.getPrewalkState()
1056
+ ? { enabled: true }
1057
+ : null,
1054
1058
  goalMode: this.#goalModeStatus,
1055
1059
  vibeMode: this.#vibeModeStatus,
1056
1060
  collab: this.#collabStatus,
@@ -208,6 +208,12 @@ const modeSegment: StatusLineSegment = {
208
208
  return { content: theme.fg(color, content), visible: true };
209
209
  }
210
210
 
211
+ const prewalk = ctx.prewalk;
212
+ if (prewalk?.enabled) {
213
+ const content = withIcon(theme.icon.prewalk, "Prewalk");
214
+ return { content: theme.fg("accent", content), visible: true };
215
+ }
216
+
211
217
  const goal = ctx.goalMode;
212
218
  if (goal && (goal.enabled || goal.paused)) {
213
219
  return renderGoalMode(ctx, goal);
@@ -60,6 +60,9 @@ export interface SegmentContext {
60
60
  enabled: boolean;
61
61
  paused: boolean;
62
62
  } | null;
63
+ prewalk: {
64
+ enabled: boolean;
65
+ } | null;
63
66
  loopMode: {
64
67
  enabled: boolean;
65
68
  } | null;
@@ -1200,7 +1200,15 @@ export class CommandController {
1200
1200
  this.ctx.rebuildChatFromMessages();
1201
1201
 
1202
1202
  this.ctx.statusLine.invalidate();
1203
- this.ctx.ui.requestRender();
1203
+ // Same as the auto-compaction rebuild: a collapsed transcript is an
1204
+ // intentional replacement, so drop the stale pre-compaction scrollback
1205
+ // instead of repainting the shrunken frame below it. With collapse
1206
+ // disabled the full history stays inline and scrollback is kept.
1207
+ if (this.ctx.settings.get("display.collapseCompacted")) {
1208
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
1209
+ } else {
1210
+ this.ctx.ui.requestRender();
1211
+ }
1204
1212
  } catch (error) {
1205
1213
  if (error instanceof CompactionCancelledError) {
1206
1214
  outcome = "cancelled";