@oh-my-pi/pi-coding-agent 15.5.13 → 15.6.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 (192) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/initial-message.d.ts +1 -1
  4. package/dist/types/cli/tiny-models-cli.d.ts +9 -0
  5. package/dist/types/commands/tiny-models.d.ts +22 -0
  6. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  7. package/dist/types/commit/analysis/summary.d.ts +1 -1
  8. package/dist/types/commit/changelog/generate.d.ts +1 -1
  9. package/dist/types/commit/changelog/index.d.ts +2 -2
  10. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  11. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  12. package/dist/types/config/model-id-affixes.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +1 -1
  14. package/dist/types/config/models-config-schema.d.ts +2 -0
  15. package/dist/types/config/settings-schema.d.ts +233 -17
  16. package/dist/types/discovery/helpers.d.ts +1 -1
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  18. package/dist/types/eval/__tests__/llm-bridge.test.d.ts +1 -0
  19. package/dist/types/eval/js/shared/rewrite-imports.d.ts +16 -1
  20. package/dist/types/eval/llm-bridge.d.ts +25 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +15 -0
  23. package/dist/types/internal-urls/agent-protocol.d.ts +2 -1
  24. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -1
  25. package/dist/types/internal-urls/local-protocol.d.ts +2 -1
  26. package/dist/types/internal-urls/memory-protocol.d.ts +2 -1
  27. package/dist/types/internal-urls/omp-protocol.d.ts +2 -1
  28. package/dist/types/internal-urls/router.d.ts +8 -1
  29. package/dist/types/internal-urls/rule-protocol.d.ts +2 -1
  30. package/dist/types/internal-urls/skill-protocol.d.ts +2 -1
  31. package/dist/types/internal-urls/types.d.ts +26 -0
  32. package/dist/types/memory-backend/index.d.ts +1 -0
  33. package/dist/types/memory-backend/resolve.d.ts +2 -1
  34. package/dist/types/memory-backend/types.d.ts +7 -1
  35. package/dist/types/mnemosyne/backend.d.ts +4 -0
  36. package/dist/types/mnemosyne/config.d.ts +29 -0
  37. package/dist/types/mnemosyne/index.d.ts +3 -0
  38. package/dist/types/mnemosyne/state.d.ts +72 -0
  39. package/dist/types/modes/components/custom-editor.d.ts +2 -3
  40. package/dist/types/modes/components/hook-selector.d.ts +27 -0
  41. package/dist/types/modes/components/index.d.ts +1 -0
  42. package/dist/types/modes/components/status-line/context-thresholds.d.ts +6 -0
  43. package/dist/types/modes/components/tiny-title-download-progress.d.ts +11 -0
  44. package/dist/types/modes/components/welcome.d.ts +1 -0
  45. package/dist/types/modes/controllers/extension-ui-controller.d.ts +4 -1
  46. package/dist/types/modes/gradient-highlight.d.ts +23 -0
  47. package/dist/types/modes/interactive-mode.d.ts +4 -2
  48. package/dist/types/modes/internal-url-autocomplete.d.ts +43 -0
  49. package/dist/types/modes/orchestrate.d.ts +10 -0
  50. package/dist/types/modes/theme/defaults/index.d.ts +8406 -8406
  51. package/dist/types/modes/theme/theme.d.ts +2 -1
  52. package/dist/types/modes/ultrathink.d.ts +3 -3
  53. package/dist/types/modes/utils/keybinding-matchers.d.ts +5 -0
  54. package/dist/types/sdk.d.ts +3 -0
  55. package/dist/types/session/agent-session.d.ts +35 -0
  56. package/dist/types/system-prompt.d.ts +2 -0
  57. package/dist/types/task/executor.d.ts +2 -0
  58. package/dist/types/task/render.d.ts +5 -1
  59. package/dist/types/tiny/models.d.ts +185 -0
  60. package/dist/types/tiny/text.d.ts +4 -0
  61. package/dist/types/tiny/title-client.d.ts +24 -0
  62. package/dist/types/tiny/title-protocol.d.ts +74 -0
  63. package/dist/types/tiny/worker.d.ts +2 -0
  64. package/dist/types/tools/bash.d.ts +3 -1
  65. package/dist/types/tools/index.d.ts +7 -4
  66. package/dist/types/tools/memory-edit.d.ts +40 -0
  67. package/dist/types/tools/{hindsight-recall.d.ts → memory-recall.d.ts} +6 -6
  68. package/dist/types/tools/{hindsight-reflect.d.ts → memory-reflect.d.ts} +6 -6
  69. package/dist/types/tools/memory-render.d.ts +60 -0
  70. package/dist/types/tools/{hindsight-retain.d.ts → memory-retain.d.ts} +6 -6
  71. package/dist/types/tools/todo-write.d.ts +8 -0
  72. package/dist/types/tools/tool-result.d.ts +2 -0
  73. package/dist/types/utils/title-generator.d.ts +3 -0
  74. package/package.json +18 -14
  75. package/scripts/build-binary.ts +1 -0
  76. package/src/cli/tiny-models-cli.ts +127 -0
  77. package/src/cli-commands.ts +1 -0
  78. package/src/cli.ts +8 -8
  79. package/src/commands/tiny-models.ts +36 -0
  80. package/src/config/model-equivalence.ts +43 -2
  81. package/src/config/model-id-affixes.ts +64 -0
  82. package/src/config/model-registry.ts +166 -8
  83. package/src/config/models-config-schema.ts +1 -1
  84. package/src/config/settings-schema.ts +206 -14
  85. package/src/edit/hashline/diff.ts +5 -7
  86. package/src/eval/__tests__/llm-bridge.test.ts +297 -0
  87. package/src/eval/__tests__/shared-executors.test.ts +36 -0
  88. package/src/eval/js/shared/local-module-loader.ts +13 -1
  89. package/src/eval/js/shared/prelude.txt +8 -0
  90. package/src/eval/js/shared/rewrite-imports.ts +31 -26
  91. package/src/eval/js/tool-bridge.ts +4 -0
  92. package/src/eval/llm-bridge.ts +181 -0
  93. package/src/eval/py/prelude.py +52 -31
  94. package/src/export/html/template.generated.ts +1 -1
  95. package/src/export/html/template.js +0 -13
  96. package/src/extensibility/plugins/legacy-pi-compat.ts +60 -23
  97. package/src/internal-urls/agent-protocol.ts +18 -1
  98. package/src/internal-urls/artifact-protocol.ts +19 -1
  99. package/src/internal-urls/docs-index.generated.ts +5 -4
  100. package/src/internal-urls/local-protocol.ts +14 -1
  101. package/src/internal-urls/memory-protocol.ts +6 -1
  102. package/src/internal-urls/omp-protocol.ts +5 -1
  103. package/src/internal-urls/router.ts +20 -1
  104. package/src/internal-urls/rule-protocol.ts +8 -1
  105. package/src/internal-urls/skill-protocol.ts +8 -1
  106. package/src/internal-urls/types.ts +27 -0
  107. package/src/lsp/render.ts +1 -1
  108. package/src/main.ts +4 -0
  109. package/src/mcp/oauth-flow.ts +2 -2
  110. package/src/memory-backend/index.ts +1 -0
  111. package/src/memory-backend/resolve.ts +4 -1
  112. package/src/memory-backend/types.ts +8 -1
  113. package/src/mnemosyne/backend.ts +374 -0
  114. package/src/mnemosyne/config.ts +160 -0
  115. package/src/mnemosyne/index.ts +3 -0
  116. package/src/mnemosyne/state.ts +548 -0
  117. package/src/modes/acp/acp-agent.ts +11 -6
  118. package/src/modes/components/agent-dashboard.ts +4 -4
  119. package/src/modes/components/custom-editor.ts +3 -2
  120. package/src/modes/components/diff.ts +2 -2
  121. package/src/modes/components/extensions/extension-list.ts +3 -2
  122. package/src/modes/components/footer.ts +5 -6
  123. package/src/modes/components/history-search.ts +3 -3
  124. package/src/modes/components/hook-selector.ts +94 -8
  125. package/src/modes/components/index.ts +1 -0
  126. package/src/modes/components/mcp-add-wizard.ts +3 -3
  127. package/src/modes/components/model-selector.ts +124 -26
  128. package/src/modes/components/oauth-selector.ts +3 -3
  129. package/src/modes/components/session-observer-overlay.ts +19 -13
  130. package/src/modes/components/session-selector.ts +3 -3
  131. package/src/modes/components/settings-defs.ts +7 -0
  132. package/src/modes/components/status-line/context-thresholds.ts +11 -0
  133. package/src/modes/components/status-line/presets.ts +1 -0
  134. package/src/modes/components/status-line/segments.ts +25 -2
  135. package/src/modes/components/tiny-title-download-progress.ts +90 -0
  136. package/src/modes/components/tips.txt +12 -0
  137. package/src/modes/components/tool-execution.ts +67 -3
  138. package/src/modes/components/tree-selector.ts +3 -3
  139. package/src/modes/components/user-message-selector.ts +3 -3
  140. package/src/modes/components/welcome.ts +55 -1
  141. package/src/modes/controllers/command-controller.ts +16 -1
  142. package/src/modes/controllers/extension-ui-controller.ts +3 -1
  143. package/src/modes/controllers/input-controller.ts +57 -0
  144. package/src/modes/gradient-highlight.ts +70 -0
  145. package/src/modes/interactive-mode.ts +80 -196
  146. package/src/modes/internal-url-autocomplete.ts +143 -0
  147. package/src/modes/orchestrate.ts +36 -0
  148. package/src/modes/prompt-action-autocomplete.ts +12 -0
  149. package/src/modes/theme/theme.ts +7 -0
  150. package/src/modes/ultrathink.ts +9 -53
  151. package/src/modes/utils/keybinding-matchers.ts +11 -0
  152. package/src/prompts/system/memory-consolidation-system.md +8 -0
  153. package/src/prompts/system/memory-extraction-system.md +26 -0
  154. package/src/prompts/{commands/orchestrate.md → system/orchestrate-notice.md} +5 -16
  155. package/src/prompts/system/system-prompt.md +2 -0
  156. package/src/prompts/system/tiny-title-system.md +8 -0
  157. package/src/prompts/tools/eval.md +2 -0
  158. package/src/prompts/tools/memory-edit.md +8 -0
  159. package/src/prompts/tools/task.md +4 -7
  160. package/src/sdk.ts +8 -6
  161. package/src/session/agent-session.ts +147 -44
  162. package/src/session/session-manager.ts +47 -0
  163. package/src/slash-commands/builtin-registry.ts +10 -1
  164. package/src/system-prompt.ts +4 -0
  165. package/src/task/commands.ts +1 -5
  166. package/src/task/executor.ts +8 -0
  167. package/src/task/index.ts +2 -0
  168. package/src/task/render.ts +69 -26
  169. package/src/tiny/models.ts +217 -0
  170. package/src/tiny/text.ts +19 -0
  171. package/src/tiny/title-client.ts +340 -0
  172. package/src/tiny/title-protocol.ts +51 -0
  173. package/src/tiny/worker.ts +523 -0
  174. package/src/tools/bash.ts +58 -16
  175. package/src/tools/browser/tab-worker.ts +1 -1
  176. package/src/tools/eval.ts +24 -48
  177. package/src/tools/index.ts +17 -15
  178. package/src/tools/memory-edit.ts +59 -0
  179. package/src/tools/memory-recall.ts +100 -0
  180. package/src/tools/memory-reflect.ts +88 -0
  181. package/src/tools/memory-render.ts +185 -0
  182. package/src/tools/memory-retain.ts +91 -0
  183. package/src/tools/renderers.ts +4 -2
  184. package/src/tools/todo-write.ts +128 -29
  185. package/src/tools/tool-result.ts +8 -0
  186. package/src/utils/title-generator.ts +115 -13
  187. package/dist/types/tools/calculator.d.ts +0 -77
  188. package/src/prompts/tools/calculator.md +0 -10
  189. package/src/tools/calculator.ts +0 -541
  190. package/src/tools/hindsight-recall.ts +0 -69
  191. package/src/tools/hindsight-reflect.ts +0 -58
  192. package/src/tools/hindsight-retain.ts +0 -57
@@ -7,7 +7,7 @@ import type { AgentSession } from "../../session/agent-session";
7
7
  import { shortenPath } from "../../tools/render-utils";
8
8
  import * as git from "../../utils/git";
9
9
  import { sanitizeStatusText } from "../shared";
10
- import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds";
10
+ import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds";
11
11
 
12
12
  /**
13
13
  * Footer component that shows pwd, token stats, and context usage
@@ -136,7 +136,6 @@ export class FooterComponent implements Component {
136
136
  const contextUsage = this.session.getContextUsage();
137
137
  const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
138
138
  const contextPercentValue = contextUsage?.percent ?? 0;
139
- const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
140
139
 
141
140
  // Replace home directory with ~
142
141
  let pwd = shortenPath(getProjectDir());
@@ -180,10 +179,10 @@ export class FooterComponent implements Component {
180
179
  // Colorize context percentage based on usage
181
180
  let contextPercentStr: string;
182
181
  const autoIndicator = this.#autoCompactEnabled ? " (auto)" : "";
183
- const contextPercentDisplay =
184
- contextPercent === "?"
185
- ? `?/${formatNumber(contextWindow)}${autoIndicator}`
186
- : `${contextPercent}%/${formatNumber(contextWindow)}${autoIndicator}`;
182
+ const contextPercentDisplay = `${formatContextUsage(
183
+ contextUsage?.percent === null ? null : contextPercentValue,
184
+ contextWindow,
185
+ )}${autoIndicator}`;
187
186
  if (contextUsage?.percent !== null && contextUsage?.percent !== undefined) {
188
187
  const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
189
188
  contextPercentStr =
@@ -11,7 +11,7 @@ import {
11
11
  visibleWidth,
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import { theme } from "../../modes/theme/theme";
14
- import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
14
+ import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
15
15
  import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
16
16
  import { DynamicBorder } from "./dynamic-border";
17
17
 
@@ -116,14 +116,14 @@ export class HistorySearchComponent extends Container {
116
116
  }
117
117
 
118
118
  handleInput(keyData: string): void {
119
- if (matchesKey(keyData, "up")) {
119
+ if (matchesSelectUp(keyData)) {
120
120
  if (this.#results.length === 0) return;
121
121
  this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
122
122
  this.#resultsList.setSelectedIndex(this.#selectedIndex);
123
123
  return;
124
124
  }
125
125
 
126
- if (matchesKey(keyData, "down")) {
126
+ if (matchesSelectDown(keyData)) {
127
127
  if (this.#results.length === 0) return;
128
128
  this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + 1);
129
129
  this.#resultsList.setSelectedIndex(this.#selectedIndex);
@@ -15,11 +15,43 @@ import {
15
15
  truncateToWidth,
16
16
  visibleWidth,
17
17
  } from "@oh-my-pi/pi-tui";
18
- import { getMarkdownTheme, theme } from "../../modes/theme/theme";
19
- import { matchesAppExternalEditor, matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
18
+ import { getMarkdownTheme, type ThemeColor, theme } from "../../modes/theme/theme";
19
+ import {
20
+ matchesAppExternalEditor,
21
+ matchesSelectCancel,
22
+ matchesSelectDown,
23
+ matchesSelectUp,
24
+ } from "../../modes/utils/keybinding-matchers";
20
25
  import { CountdownTimer } from "./countdown-timer";
21
26
  import { DynamicBorder } from "./dynamic-border";
22
27
 
28
+ /** One segment of a {@link HookSelectorSlider} — a label, its accent color, and
29
+ * an optional detail line (e.g. the resolved model name) shown beneath the
30
+ * track while the segment is active. */
31
+ export interface HookSelectorSliderSegment {
32
+ label: string;
33
+ /** Theme color for the segment label; defaults to `accent`. */
34
+ color?: ThemeColor;
35
+ /** Secondary line rendered under the track when this segment is selected. */
36
+ detail?: string;
37
+ }
38
+
39
+ /**
40
+ * A horizontal left/right selector rendered above the option list. Unlike the
41
+ * up/down option cursor, the slider is moved with the left/right arrows from
42
+ * any list position, letting the caller capture an orthogonal choice (e.g. the
43
+ * model tier to continue execution with) alongside the selected option.
44
+ */
45
+ export interface HookSelectorSlider {
46
+ /** Dim caption rendered before the slider track (e.g. "continue with"). */
47
+ caption?: string;
48
+ segments: HookSelectorSliderSegment[];
49
+ /** Initially highlighted segment index. */
50
+ index: number;
51
+ /** Invoked with the new index whenever the slider moves. */
52
+ onChange?: (index: number) => void;
53
+ }
54
+
23
55
  export interface HookSelectorOptions {
24
56
  tui?: TUI;
25
57
  timeout?: number;
@@ -31,6 +63,7 @@ export interface HookSelectorOptions {
31
63
  onRight?: () => void;
32
64
  onExternalEditor?: () => void;
33
65
  helpText?: string;
66
+ slider?: HookSelectorSlider;
34
67
  }
35
68
 
36
69
  class OutlinedList extends Container {
@@ -69,6 +102,9 @@ export class HookSelectorComponent extends Container {
69
102
  #onLeftCallback: (() => void) | undefined;
70
103
  #onRightCallback: (() => void) | undefined;
71
104
  #onExternalEditorCallback: (() => void) | undefined;
105
+ #slider: HookSelectorSlider | undefined;
106
+ #sliderIndex: number = 0;
107
+ #sliderComponent: Text | undefined;
72
108
  constructor(
73
109
  title: string,
74
110
  options: string[],
@@ -87,6 +123,10 @@ export class HookSelectorComponent extends Container {
87
123
  this.#onLeftCallback = opts?.onLeft;
88
124
  this.#onRightCallback = opts?.onRight;
89
125
  this.#onExternalEditorCallback = opts?.onExternalEditor;
126
+ if (opts?.slider && opts.slider.segments.length > 0) {
127
+ this.#slider = opts.slider;
128
+ this.#sliderIndex = Math.max(0, Math.min(opts.slider.index, opts.slider.segments.length - 1));
129
+ }
90
130
 
91
131
  this.addChild(new DynamicBorder());
92
132
  this.addChild(new Spacer(1));
@@ -95,6 +135,12 @@ export class HookSelectorComponent extends Container {
95
135
  this.addChild(this.#titleComponent);
96
136
  this.addChild(new Spacer(1));
97
137
 
138
+ if (this.#slider) {
139
+ this.#sliderComponent = new Text(this.#renderSliderLine(), 1, 0);
140
+ this.addChild(this.#sliderComponent);
141
+ this.addChild(new Spacer(1));
142
+ }
143
+
98
144
  if (opts?.timeout && opts.timeout > 0 && opts.tui) {
99
145
  this.#countdown = new CountdownTimer(
100
146
  opts.timeout,
@@ -160,23 +206,63 @@ export class HookSelectorComponent extends Container {
160
206
  }
161
207
  }
162
208
 
209
+ /** Render the slider block: the track (dim caption, edge arrows that brighten
210
+ * while there is room to move, one styled segment per option — active = bold
211
+ * in its color, the rest dim, joined by `›`) plus, when the active segment
212
+ * carries a `detail`, a muted second line beneath it (e.g. the resolved model
213
+ * name). Returns one or two `\n`-joined lines. */
214
+ #renderSliderLine(): string {
215
+ const slider = this.#slider;
216
+ if (!slider) return "";
217
+ const segments = slider.segments;
218
+ const sep = theme.fg("dim", " › ");
219
+ const track = segments
220
+ .map((segment, i) =>
221
+ i === this.#sliderIndex
222
+ ? theme.bold(theme.fg(segment.color ?? "accent", segment.label))
223
+ : theme.fg("dim", segment.label),
224
+ )
225
+ .join(sep);
226
+ const leftArrow = theme.fg(this.#sliderIndex > 0 ? "accent" : "dim", "◂");
227
+ const rightArrow = theme.fg(this.#sliderIndex < segments.length - 1 ? "accent" : "dim", "▸");
228
+ const caption = slider.caption ? `${theme.fg("dim", slider.caption)} ` : "";
229
+ const trackLine = `${caption}${leftArrow} ${theme.fg("dim", "[")} ${track} ${theme.fg("dim", "]")} ${rightArrow}`;
230
+ const detail = segments[this.#sliderIndex]?.detail;
231
+ if (!detail) return trackLine;
232
+ return `${trackLine}\n ${theme.fg("dim", "↳")} ${theme.fg("muted", detail)}`;
233
+ }
234
+
235
+ /** Move the slider by `delta`, clamped to the segment range, refresh the
236
+ * rendered track, and notify the caller only when the index actually moves. */
237
+ #moveSlider(delta: number): void {
238
+ const slider = this.#slider;
239
+ if (!slider) return;
240
+ const next = Math.max(0, Math.min(slider.segments.length - 1, this.#sliderIndex + delta));
241
+ if (next === this.#sliderIndex) return;
242
+ this.#sliderIndex = next;
243
+ this.#sliderComponent?.setText(this.#renderSliderLine());
244
+ slider.onChange?.(next);
245
+ }
246
+
163
247
  handleInput(keyData: string): void {
164
248
  // Reset countdown on any interaction
165
249
  this.#countdown?.reset();
166
250
 
167
- if (matchesKey(keyData, "up") || keyData === "k") {
251
+ if (matchesSelectUp(keyData) || keyData === "k") {
168
252
  this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
169
253
  this.#updateList();
170
- } else if (matchesKey(keyData, "down") || keyData === "j") {
254
+ } else if (matchesSelectDown(keyData) || keyData === "j") {
171
255
  this.#selectedIndex = Math.min(this.#options.length - 1, this.#selectedIndex + 1);
172
256
  this.#updateList();
173
257
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
174
258
  const selected = this.#options[this.#selectedIndex];
175
259
  if (selected) this.#onSelectCallback(selected);
176
- } else if (matchesKey(keyData, "left")) {
177
- this.#onLeftCallback?.();
178
- } else if (matchesKey(keyData, "right")) {
179
- this.#onRightCallback?.();
260
+ } else if (matchesKey(keyData, "left") || (this.#slider && keyData === "h")) {
261
+ if (this.#slider) this.#moveSlider(-1);
262
+ else this.#onLeftCallback?.();
263
+ } else if (matchesKey(keyData, "right") || (this.#slider && keyData === "l")) {
264
+ if (this.#slider) this.#moveSlider(1);
265
+ else this.#onRightCallback?.();
180
266
  } else if (this.#onExternalEditorCallback && matchesAppExternalEditor(keyData)) {
181
267
  this.#onExternalEditorCallback();
182
268
  } else if (matchesSelectCancel(keyData)) {
@@ -26,6 +26,7 @@ export * from "./show-images-selector";
26
26
  export * from "./status-line";
27
27
  export * from "./theme-selector";
28
28
  export * from "./thinking-selector";
29
+ export * from "./tiny-title-download-progress";
29
30
  export * from "./todo-reminder";
30
31
  export * from "./tool-execution";
31
32
  export * from "./tree-selector";
@@ -19,7 +19,7 @@ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discov
19
19
  import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
20
20
  import { shortenPath } from "../../tools/render-utils";
21
21
  import { theme } from "../theme/theme";
22
- import { matchesAppInterrupt } from "../utils/keybinding-matchers";
22
+ import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
23
23
  import { DynamicBorder } from "./dynamic-border";
24
24
 
25
25
  type TransportType = "stdio" | "http" | "sse";
@@ -501,11 +501,11 @@ export class MCPAddWizard extends Container {
501
501
  }
502
502
 
503
503
  // Handle up/down arrows for selectors
504
- if (matchesKey(keyData, "up")) {
504
+ if (matchesSelectUp(keyData)) {
505
505
  this.#moveSelection(-1);
506
506
  return;
507
507
  }
508
- if (matchesKey(keyData, "down")) {
508
+ if (matchesSelectDown(keyData)) {
509
509
  this.#moveSelection(1);
510
510
  return;
511
511
  }
@@ -18,6 +18,7 @@ import { getKnownRoleIds, getRoleInfo, MODEL_ROLE_IDS, MODEL_ROLES } from "../..
18
18
  import { resolveModelRoleValue } from "../../config/model-resolver";
19
19
  import type { Settings } from "../../config/settings";
20
20
  import { type ThemeColor, theme } from "../../modes/theme/theme";
21
+ import { matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
21
22
  import { getThinkingLevelMetadata } from "../../thinking";
22
23
  import { getTabBarTheme } from "../shared";
23
24
  import { DynamicBorder } from "./dynamic-border";
@@ -105,6 +106,8 @@ const STATIC_PROVIDER_TABS: ProviderTabState[] = [
105
106
  { id: CANONICAL_TAB, label: CANONICAL_TAB },
106
107
  ];
107
108
 
109
+ const MODEL_TAB_REFRESH_DEBOUNCE_MS = 120;
110
+
108
111
  function formatProviderTabLabel(providerId: string): string {
109
112
  return providerId.replace(/[-_]+/g, " ").toUpperCase();
110
113
  }
@@ -145,6 +148,10 @@ export class ModelSelectorComponent extends Container {
145
148
  // Tab state
146
149
  #providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
147
150
  #activeTabIndex: number = 0;
151
+ #refreshingProviders: Set<string> = new Set();
152
+ #scheduledProviderRefreshes: Map<string, ReturnType<typeof setTimeout>> = new Map();
153
+ #refreshSpinnerFrame: number = 0;
154
+ #refreshSpinnerInterval?: NodeJS.Timeout;
148
155
 
149
156
  // Context menu state
150
157
  #isMenuOpen: boolean = false;
@@ -371,10 +378,8 @@ export class ModelSelectorComponent extends Container {
371
378
  });
372
379
  }
373
380
 
374
- async #loadModels(): Promise<void> {
381
+ #loadModelsFromCurrentRegistryState(): void {
375
382
  let models: ModelItem[];
376
-
377
- // Use scoped models if provided via --models flag
378
383
  if (this.#scopedModels.length > 0) {
379
384
  models = this.#scopedModels.map(scoped => ({
380
385
  kind: "provider",
@@ -384,10 +389,6 @@ export class ModelSelectorComponent extends Container {
384
389
  selector: `${scoped.model.provider}/${scoped.model.id}`,
385
390
  }));
386
391
  } else {
387
- // Reload config and cached discovery state without blocking on live provider refresh
388
- await this.#modelRegistry.refresh("offline");
389
-
390
- // Check for models.json errors
391
392
  const loadError = this.#modelRegistry.getError();
392
393
  if (loadError) {
393
394
  this.#errorMessage = loadError;
@@ -395,7 +396,6 @@ export class ModelSelectorComponent extends Container {
395
396
  this.#errorMessage = undefined;
396
397
  }
397
398
 
398
- // Load available models (built-in models still work even if models.json failed)
399
399
  try {
400
400
  const availableModels = this.#modelRegistry.getAvailable();
401
401
  models = availableModels.map((model: Model) => ({
@@ -415,15 +415,16 @@ export class ModelSelectorComponent extends Container {
415
415
  }
416
416
  }
417
417
 
418
+ const candidates = models.map(item => item.model);
418
419
  const canonicalRecords = this.#modelRegistry.getCanonicalModels({
419
420
  availableOnly: this.#scopedModels.length === 0,
420
- candidates: models.map(item => item.model),
421
+ candidates,
421
422
  });
422
423
  const canonicalModels = canonicalRecords
423
424
  .map(record => {
424
425
  const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
425
426
  availableOnly: this.#scopedModels.length === 0,
426
- candidates: models.map(item => item.model),
427
+ candidates,
427
428
  });
428
429
  if (!selectedModel) return undefined;
429
430
  const searchText = [
@@ -457,6 +458,14 @@ export class ModelSelectorComponent extends Container {
457
458
  this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
458
459
  }
459
460
 
461
+ async #loadModels(): Promise<void> {
462
+ if (this.#scopedModels.length === 0) {
463
+ // Reload config and cached discovery state without blocking on live provider refresh
464
+ await this.#modelRegistry.refresh("offline");
465
+ }
466
+ this.#loadModelsFromCurrentRegistryState();
467
+ }
468
+
460
469
  #buildProviderTabs(): void {
461
470
  const activeTabId = this.#getActiveTab().id;
462
471
  const providerSet = new Set<string>();
@@ -475,17 +484,100 @@ export class ModelSelectorComponent extends Container {
475
484
  activeIndex >= 0 ? activeIndex : Math.min(this.#activeTabIndex, this.#providers.length - 1);
476
485
  }
477
486
 
478
- async #refreshSelectedProvider(): Promise<void> {
487
+ #getActiveProviderRefreshStatusText(): string | undefined {
488
+ const providerId = this.#getActiveProviderId();
489
+ if (!providerId || !this.#refreshingProviders.has(providerId)) {
490
+ return undefined;
491
+ }
492
+ const spinnerFrames = theme.spinnerFrames;
493
+ const spinner =
494
+ spinnerFrames.length > 0
495
+ ? spinnerFrames[this.#refreshSpinnerFrame % spinnerFrames.length]
496
+ : theme.status.pending;
497
+ return theme.fg("warning", ` ${spinner} Refreshing ${formatProviderTabLabel(providerId)} in background...`);
498
+ }
499
+
500
+ #startRefreshSpinner(): void {
501
+ if (this.#refreshSpinnerInterval) {
502
+ return;
503
+ }
504
+ this.#refreshSpinnerInterval = setInterval(() => {
505
+ const frameCount = theme.spinnerFrames.length;
506
+ if (frameCount > 0) {
507
+ this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
508
+ }
509
+ this.#updateTabBar();
510
+ this.#tui.requestRender();
511
+ }, 80);
512
+ }
513
+
514
+ #stopRefreshSpinner(): void {
515
+ if (this.#refreshingProviders.size > 0) {
516
+ return;
517
+ }
518
+ if (this.#refreshSpinnerInterval) {
519
+ clearInterval(this.#refreshSpinnerInterval);
520
+ this.#refreshSpinnerInterval = undefined;
521
+ }
522
+ this.#refreshSpinnerFrame = 0;
523
+ }
524
+
525
+ #setProviderRefreshing(providerId: string, refreshing: boolean): void {
526
+ if (refreshing) {
527
+ this.#refreshingProviders.add(providerId);
528
+ this.#startRefreshSpinner();
529
+ } else {
530
+ this.#refreshingProviders.delete(providerId);
531
+ this.#stopRefreshSpinner();
532
+ }
533
+ }
534
+
535
+ #cancelScheduledProviderRefreshesExcept(keepProviderId?: string): void {
536
+ for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
537
+ if (providerId === keepProviderId) {
538
+ continue;
539
+ }
540
+ clearTimeout(timer);
541
+ this.#scheduledProviderRefreshes.delete(providerId);
542
+ this.#setProviderRefreshing(providerId, false);
543
+ }
544
+ }
545
+
546
+ #scheduleSelectedProviderRefresh(): void {
479
547
  const providerId = this.#getActiveProviderId();
480
548
  if (this.#scopedModels.length > 0 || !providerId) {
481
549
  return;
482
550
  }
483
- await this.#modelRegistry.refreshProvider(providerId);
484
- await this.#loadModels();
485
- this.#buildProviderTabs();
486
- this.#updateTabBar();
487
- this.#applyTabFilter();
488
- this.#tui.requestRender();
551
+ if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) {
552
+ return;
553
+ }
554
+ this.#setProviderRefreshing(providerId, true);
555
+ const timer = setTimeout(() => {
556
+ this.#scheduledProviderRefreshes.delete(providerId);
557
+ void this.#refreshProviderInBackground(providerId);
558
+ }, MODEL_TAB_REFRESH_DEBOUNCE_MS);
559
+ this.#scheduledProviderRefreshes.set(providerId, timer);
560
+ }
561
+
562
+ async #refreshProviderInBackground(providerId: string): Promise<void> {
563
+ try {
564
+ await this.#modelRegistry.refreshProvider(providerId, "online");
565
+ // Provider refresh already updated the registry snapshot. Re-reading it
566
+ // here must stay purely in-memory — do not call modelRegistry.refresh()
567
+ // again or tab switches will pay an extra whole-registry reload after the
568
+ // network round-trip completes.
569
+ this.#loadModelsFromCurrentRegistryState();
570
+ this.#buildProviderTabs();
571
+ this.#updateTabBar();
572
+ this.#applyTabFilter();
573
+ } catch (error) {
574
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
575
+ this.#updateList();
576
+ } finally {
577
+ this.#setProviderRefreshing(providerId, false);
578
+ this.#updateTabBar();
579
+ this.#tui.requestRender();
580
+ }
489
581
  }
490
582
 
491
583
  #updateTabBar(): void {
@@ -496,15 +588,21 @@ export class ModelSelectorComponent extends Container {
496
588
  tabBar.onTabChange = (_tab, index) => {
497
589
  this.#activeTabIndex = index;
498
590
  this.#selectedIndex = 0;
591
+ this.#cancelScheduledProviderRefreshesExcept(this.#getActiveProviderId());
499
592
  this.#applyTabFilter();
500
- void this.#refreshSelectedProvider().catch(error => {
501
- this.#errorMessage = error instanceof Error ? error.message : String(error);
502
- this.#updateList();
503
- this.#tui.requestRender();
504
- });
593
+ this.#scheduleSelectedProviderRefresh();
594
+ this.#updateTabBar();
595
+ // Let TUI's normal post-input render paint the new tab immediately.
596
+ // The live refresh is debounced onto a later timer so tab cycling never
597
+ // shares a stack frame with provider refresh work.
598
+ this.#tui.requestRender();
505
599
  };
506
600
  this.#tabBar = tabBar;
507
601
  this.#headerContainer.addChild(tabBar);
602
+ const refreshStatusText = this.#getActiveProviderRefreshStatusText();
603
+ if (refreshStatusText) {
604
+ this.#headerContainer.addChild(new Text(refreshStatusText, 0, 0));
605
+ }
508
606
  }
509
607
 
510
608
  #getActiveTab(): ProviderTabState {
@@ -874,7 +972,7 @@ export class ModelSelectorComponent extends Container {
874
972
  }
875
973
 
876
974
  // Up arrow - navigate list (wrap to bottom when at top)
877
- if (matchesKey(keyData, "up")) {
975
+ if (matchesSelectUp(keyData)) {
878
976
  const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
879
977
  if (itemCount === 0) return;
880
978
  this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1;
@@ -883,7 +981,7 @@ export class ModelSelectorComponent extends Container {
883
981
  }
884
982
 
885
983
  // Down arrow - navigate list (wrap to top when at bottom)
886
- if (matchesKey(keyData, "down")) {
984
+ if (matchesSelectDown(keyData)) {
887
985
  const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
888
986
  if (itemCount === 0) return;
889
987
  this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1;
@@ -925,13 +1023,13 @@ export class ModelSelectorComponent extends Container {
925
1023
  : this.#menuRoleActions.length;
926
1024
  if (optionCount === 0) return;
927
1025
 
928
- if (matchesKey(keyData, "up")) {
1026
+ if (matchesSelectUp(keyData)) {
929
1027
  this.#menuSelectedIndex = (this.#menuSelectedIndex - 1 + optionCount) % optionCount;
930
1028
  this.#updateMenu();
931
1029
  return;
932
1030
  }
933
1031
 
934
- if (matchesKey(keyData, "down")) {
1032
+ if (matchesSelectDown(keyData)) {
935
1033
  this.#menuSelectedIndex = (this.#menuSelectedIndex + 1) % optionCount;
936
1034
  this.#updateMenu();
937
1035
  return;
@@ -2,7 +2,7 @@ import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
2
2
  import type { OAuthProviderInfo } from "@oh-my-pi/pi-ai/utils/oauth/types";
3
3
  import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
4
4
  import { theme } from "../../modes/theme/theme";
5
- import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
5
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
6
6
  import type { AuthStorage } from "../../session/auth-storage";
7
7
  import { DynamicBorder } from "./dynamic-border";
8
8
 
@@ -193,7 +193,7 @@ export class OAuthSelectorComponent extends Container {
193
193
  }
194
194
  handleInput(keyData: string): void {
195
195
  // Up arrow
196
- if (matchesKey(keyData, "up")) {
196
+ if (matchesSelectUp(keyData)) {
197
197
  if (this.#allProviders.length > 0) {
198
198
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#allProviders.length - 1 : this.#selectedIndex - 1;
199
199
  }
@@ -201,7 +201,7 @@ export class OAuthSelectorComponent extends Container {
201
201
  this.#updateList();
202
202
  }
203
203
  // Down arrow
204
- else if (matchesKey(keyData, "down")) {
204
+ else if (matchesSelectDown(keyData)) {
205
205
  if (this.#allProviders.length > 0) {
206
206
  this.#selectedIndex = this.#selectedIndex === this.#allProviders.length - 1 ? 0 : this.#selectedIndex + 1;
207
207
  }
@@ -25,7 +25,9 @@ import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "
25
25
  import { toPathList } from "../../tools/search";
26
26
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
27
27
  import { getMarkdownTheme, theme } from "../theme/theme";
28
+ import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
28
29
  import { DynamicBorder } from "./dynamic-border";
30
+ import { formatContextUsage } from "./status-line/context-thresholds";
29
31
 
30
32
  /** Max thinking characters in collapsed state */
31
33
  const MAX_THINKING_CHARS_COLLAPSED = 200;
@@ -266,23 +268,27 @@ export class SessionObserverOverlayComponent extends Container {
266
268
  const progress = session?.progress;
267
269
  if (!progress) return "";
268
270
  const stats: string[] = [];
269
- if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
270
- // Current per-turn context — what the user reads as "how full is the context".
271
- // Falls back to cumulative billing volume (Σ-prefixed) when context size is unknown.
271
+ // Current per-turn context — match the status line's `<pct>%/<window>` gauge (e.g. `5.1%/1M`).
272
272
  if (progress.contextTokens && progress.contextTokens > 0) {
273
273
  const ctx =
274
274
  progress.contextWindow && progress.contextWindow > 0
275
- ? `${formatNumber(progress.contextTokens)}/${formatNumber(progress.contextWindow)} ctx`
276
- : `${formatNumber(progress.contextTokens)} ctx`;
275
+ ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow)
276
+ : `${formatNumber(progress.contextTokens)}`;
277
277
  stats.push(ctx);
278
- if (progress.tokens > 0) stats.push(`Σ${formatNumber(progress.tokens)}`);
279
- } else if (progress.tokens > 0) {
280
- stats.push(`Σ${formatNumber(progress.tokens)}`);
281
278
  }
282
- if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
279
+ if (progress.durationMs > 0) {
280
+ stats.push(formatDuration(progress.durationMs));
281
+ }
283
282
  const parts: string[] = [];
284
- if (stats.length > 0) parts.push(theme.fg("dim", stats.join(theme.sep.dot)));
285
- if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
283
+ if (stats.length > 0 || progress.toolCount > 0) {
284
+ const toolCountStat =
285
+ progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : undefined;
286
+ const statSegments = [toolCountStat, ...stats].filter((segment): segment is string => Boolean(segment));
287
+ parts.push(theme.fg("dim", statSegments.join(theme.sep.dot)));
288
+ }
289
+ if (progress.cost > 0) {
290
+ parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
291
+ }
286
292
  return parts.join(theme.sep.dot);
287
293
  }
288
294
 
@@ -660,7 +666,7 @@ export class SessionObserverOverlayComponent extends Container {
660
666
  }
661
667
 
662
668
  // j / down — move selection down
663
- if (keyData === "j" || matchesKey(keyData, "down")) {
669
+ if (keyData === "j" || matchesSelectDown(keyData)) {
664
670
  if (entryCount > 0) {
665
671
  this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
666
672
  }
@@ -669,7 +675,7 @@ export class SessionObserverOverlayComponent extends Container {
669
675
  }
670
676
 
671
677
  // k / up — move selection up
672
- if (keyData === "k" || matchesKey(keyData, "up")) {
678
+ if (keyData === "k" || matchesSelectUp(keyData)) {
673
679
  if (entryCount > 0) {
674
680
  this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
675
681
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "@oh-my-pi/pi-tui";
14
14
  import { formatBytes } from "@oh-my-pi/pi-utils";
15
15
  import { theme } from "../../modes/theme/theme";
16
- import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
16
+ import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
17
17
  import type { SessionInfo } from "../../session/session-manager";
18
18
  import { DynamicBorder } from "./dynamic-border";
19
19
  import { HookSelectorComponent } from "./hook-selector";
@@ -192,12 +192,12 @@ class SessionList implements Component {
192
192
  }
193
193
 
194
194
  // Up arrow
195
- if (matchesKey(keyData, "up")) {
195
+ if (matchesSelectUp(keyData)) {
196
196
  this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
197
197
  return;
198
198
  }
199
199
  // Down arrow
200
- if (matchesKey(keyData, "down")) {
200
+ if (matchesSelectDown(keyData)) {
201
201
  this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1);
202
202
  return;
203
203
  }
@@ -79,6 +79,13 @@ const CONDITIONS: Record<string, () => boolean> = {
79
79
  return false;
80
80
  }
81
81
  },
82
+ mnemosyneActive: () => {
83
+ try {
84
+ return Settings.instance.get("memory.backend") === "mnemosyne";
85
+ } catch {
86
+ return false;
87
+ }
88
+ },
82
89
  };
83
90
 
84
91
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1,3 +1,4 @@
1
+ import { formatNumber } from "@oh-my-pi/pi-utils";
1
2
  import type { ThemeColor } from "../../../modes/theme/theme";
2
3
 
3
4
  export type ContextUsageLevel = "normal" | "warning" | "purple" | "error";
@@ -54,6 +55,16 @@ export function getContextUsageLevel(contextPercent: number, contextWindow: numb
54
55
  return "normal";
55
56
  }
56
57
 
58
+ /**
59
+ * Format context usage as `<percent>%/<window>` (e.g. `5.1%/1M`), matching the
60
+ * status line's context gauge so subagent and footer renderers stay in sync.
61
+ * A `null`/`undefined` percent (unknown, e.g. right after compaction) renders as `?`.
62
+ */
63
+ export function formatContextUsage(contextPercent: number | null | undefined, contextWindow: number): string {
64
+ const pct = contextPercent === null || contextPercent === undefined ? "?" : `${contextPercent.toFixed(1)}%`;
65
+ return `${pct}/${formatNumber(contextWindow)}`;
66
+ }
67
+
57
68
  export function getContextUsageThemeColor(level: ContextUsageLevel): ThemeColor {
58
69
  switch (level) {
59
70
  case "error":
@@ -36,6 +36,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
36
36
  leftSegments: ["pi", "hostname", "model", "mode", "path", "git", "pr", "subagents"],
37
37
  rightSegments: [
38
38
  "session_name",
39
+ "cache_hit",
39
40
  "token_in",
40
41
  "token_out",
41
42
  "token_rate",