@oh-my-pi/pi-coding-agent 15.5.15 → 15.7.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 (274) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/types/capability/rule-buckets.d.ts +30 -0
  3. package/dist/types/capability/rule.d.ts +7 -0
  4. package/dist/types/cli/classify-install-target.d.ts +0 -10
  5. package/dist/types/cli/completion-gen.d.ts +80 -0
  6. package/dist/types/cli/initial-message.d.ts +1 -1
  7. package/dist/types/cli/tiny-models-cli.d.ts +9 -0
  8. package/dist/types/commands/complete.d.ts +6 -0
  9. package/dist/types/commands/completions.d.ts +13 -0
  10. package/dist/types/commands/setup.d.ts +10 -1
  11. package/dist/types/commands/tiny-models.d.ts +22 -0
  12. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  13. package/dist/types/commit/analysis/summary.d.ts +1 -1
  14. package/dist/types/commit/changelog/generate.d.ts +1 -1
  15. package/dist/types/commit/changelog/index.d.ts +2 -2
  16. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  17. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  18. package/dist/types/config/model-id-affixes.d.ts +10 -0
  19. package/dist/types/config/settings-schema.d.ts +402 -17
  20. package/dist/types/discovery/builtin-defaults.d.ts +1 -0
  21. package/dist/types/discovery/builtin-rules/index.d.ts +7 -0
  22. package/dist/types/discovery/helpers.d.ts +1 -1
  23. package/dist/types/discovery/index.d.ts +1 -0
  24. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  25. package/dist/types/edit/hashline/block-resolver.d.ts +9 -0
  26. package/dist/types/edit/hashline/index.d.ts +1 -0
  27. package/dist/types/eval/js/shared/rewrite-imports.d.ts +16 -1
  28. package/dist/types/eval/py/kernel.d.ts +3 -0
  29. package/dist/types/eval/py/runtime.d.ts +11 -1
  30. package/dist/types/export/html/template.generated.d.ts +1 -1
  31. package/dist/types/internal-urls/agent-protocol.d.ts +2 -1
  32. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -1
  33. package/dist/types/internal-urls/local-protocol.d.ts +2 -1
  34. package/dist/types/internal-urls/memory-protocol.d.ts +2 -1
  35. package/dist/types/internal-urls/omp-protocol.d.ts +2 -1
  36. package/dist/types/internal-urls/router.d.ts +8 -1
  37. package/dist/types/internal-urls/rule-protocol.d.ts +2 -1
  38. package/dist/types/internal-urls/skill-protocol.d.ts +2 -1
  39. package/dist/types/internal-urls/types.d.ts +26 -0
  40. package/dist/types/main.d.ts +1 -0
  41. package/dist/types/memory-backend/index.d.ts +1 -0
  42. package/dist/types/memory-backend/resolve.d.ts +2 -1
  43. package/dist/types/memory-backend/types.d.ts +7 -1
  44. package/dist/types/mnemosyne/backend.d.ts +4 -0
  45. package/dist/types/mnemosyne/config.d.ts +29 -0
  46. package/dist/types/mnemosyne/index.d.ts +3 -0
  47. package/dist/types/mnemosyne/state.d.ts +72 -0
  48. package/dist/types/modes/components/custom-editor.d.ts +2 -3
  49. package/dist/types/modes/components/hook-selector.d.ts +27 -0
  50. package/dist/types/modes/components/index.d.ts +2 -0
  51. package/dist/types/modes/components/segment-track.d.ts +22 -0
  52. package/dist/types/modes/components/status-line/context-thresholds.d.ts +6 -0
  53. package/dist/types/modes/components/tiny-title-download-progress.d.ts +11 -0
  54. package/dist/types/modes/components/welcome.d.ts +22 -0
  55. package/dist/types/modes/controllers/extension-ui-controller.d.ts +4 -1
  56. package/dist/types/modes/gradient-highlight.d.ts +23 -0
  57. package/dist/types/modes/interactive-mode.d.ts +7 -4
  58. package/dist/types/modes/internal-url-autocomplete.d.ts +43 -0
  59. package/dist/types/modes/orchestrate.d.ts +10 -0
  60. package/dist/types/modes/setup-wizard/index.d.ts +16 -0
  61. package/dist/types/modes/setup-wizard/scenes/glyph.d.ts +2 -0
  62. package/dist/types/modes/setup-wizard/scenes/outro.d.ts +2 -0
  63. package/dist/types/modes/setup-wizard/scenes/providers.d.ts +2 -0
  64. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +19 -0
  65. package/dist/types/modes/setup-wizard/scenes/splash.d.ts +11 -0
  66. package/dist/types/modes/setup-wizard/scenes/theme.d.ts +2 -0
  67. package/dist/types/modes/setup-wizard/scenes/types.d.ts +43 -0
  68. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +19 -0
  69. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +14 -0
  70. package/dist/types/modes/theme/defaults/index.d.ts +8406 -8406
  71. package/dist/types/modes/theme/shimmer.d.ts +2 -0
  72. package/dist/types/modes/theme/theme.d.ts +11 -0
  73. package/dist/types/modes/types.d.ts +5 -1
  74. package/dist/types/modes/ultrathink.d.ts +3 -3
  75. package/dist/types/modes/utils/keybinding-matchers.d.ts +5 -0
  76. package/dist/types/sdk.d.ts +3 -0
  77. package/dist/types/session/agent-session.d.ts +33 -0
  78. package/dist/types/system-prompt.d.ts +2 -0
  79. package/dist/types/task/executor.d.ts +2 -0
  80. package/dist/types/task/render.d.ts +5 -1
  81. package/dist/types/tiny/device.d.ts +78 -0
  82. package/dist/types/tiny/dtype.d.ts +85 -0
  83. package/dist/types/tiny/models.d.ts +185 -0
  84. package/dist/types/tiny/text.d.ts +19 -0
  85. package/dist/types/tiny/title-client.d.ts +32 -0
  86. package/dist/types/tiny/title-protocol.d.ts +74 -0
  87. package/dist/types/tiny/worker.d.ts +2 -0
  88. package/dist/types/tools/bash.d.ts +3 -2
  89. package/dist/types/tools/eval.d.ts +1 -1
  90. package/dist/types/tools/index.d.ts +7 -4
  91. package/dist/types/tools/memory-edit.d.ts +40 -0
  92. package/dist/types/tools/{hindsight-recall.d.ts → memory-recall.d.ts} +6 -6
  93. package/dist/types/tools/{hindsight-reflect.d.ts → memory-reflect.d.ts} +6 -6
  94. package/dist/types/tools/memory-render.d.ts +60 -0
  95. package/dist/types/tools/{hindsight-retain.d.ts → memory-retain.d.ts} +6 -6
  96. package/dist/types/tools/todo-write.d.ts +8 -0
  97. package/dist/types/tools/tool-result.d.ts +2 -0
  98. package/dist/types/tui/code-cell.d.ts +2 -0
  99. package/dist/types/tui/output-block.d.ts +17 -0
  100. package/dist/types/utils/title-generator.d.ts +3 -0
  101. package/package.json +18 -14
  102. package/scripts/build-binary.ts +1 -0
  103. package/src/capability/rule-buckets.ts +64 -0
  104. package/src/capability/rule.ts +8 -0
  105. package/src/cli/completion-gen.ts +550 -0
  106. package/src/cli/setup-cli.ts +5 -3
  107. package/src/cli/tiny-models-cli.ts +127 -0
  108. package/src/cli-commands.ts +3 -0
  109. package/src/cli.ts +9 -15
  110. package/src/commands/complete.ts +66 -0
  111. package/src/commands/completions.ts +60 -0
  112. package/src/commands/setup.ts +29 -4
  113. package/src/commands/tiny-models.ts +36 -0
  114. package/src/config/model-equivalence.ts +43 -2
  115. package/src/config/model-id-affixes.ts +64 -0
  116. package/src/config/model-registry.ts +84 -10
  117. package/src/config/settings-schema.ts +275 -15
  118. package/src/discovery/builtin-defaults.ts +39 -0
  119. package/src/discovery/builtin-rules/index.ts +48 -0
  120. package/src/discovery/builtin-rules/rs-box-leak.md +48 -0
  121. package/src/discovery/builtin-rules/rs-future-prelude.md +23 -0
  122. package/src/discovery/builtin-rules/rs-lazylock.md +51 -0
  123. package/src/discovery/builtin-rules/rs-match-ergonomics.md +67 -0
  124. package/src/discovery/builtin-rules/rs-parking-lot.md +44 -0
  125. package/src/discovery/builtin-rules/rs-result-type.md +19 -0
  126. package/src/discovery/builtin-rules/ts-bare-catch.md +38 -0
  127. package/src/discovery/builtin-rules/ts-import-type.md +42 -0
  128. package/src/discovery/builtin-rules/ts-no-any.md +56 -0
  129. package/src/discovery/builtin-rules/ts-no-dynamic-import.md +39 -0
  130. package/src/discovery/builtin-rules/ts-no-return-type.md +45 -0
  131. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +50 -0
  132. package/src/discovery/builtin-rules/ts-promise-with-resolvers.md +65 -0
  133. package/src/discovery/builtin-rules/ts-set-map.md +28 -0
  134. package/src/discovery/index.ts +1 -0
  135. package/src/edit/hashline/block-resolver.ts +14 -0
  136. package/src/edit/hashline/diff.ts +9 -8
  137. package/src/edit/hashline/execute.ts +2 -1
  138. package/src/edit/hashline/index.ts +1 -0
  139. package/src/eval/__tests__/shared-executors.test.ts +36 -0
  140. package/src/eval/js/shared/local-module-loader.ts +13 -1
  141. package/src/eval/js/shared/rewrite-imports.ts +31 -26
  142. package/src/eval/py/kernel.ts +37 -15
  143. package/src/eval/py/runtime.ts +57 -28
  144. package/src/export/html/template.generated.ts +1 -1
  145. package/src/export/html/template.js +0 -12
  146. package/src/export/ttsr.ts +2 -0
  147. package/src/internal-urls/agent-protocol.ts +18 -1
  148. package/src/internal-urls/artifact-protocol.ts +19 -1
  149. package/src/internal-urls/docs-index.generated.ts +8 -7
  150. package/src/internal-urls/local-protocol.ts +14 -1
  151. package/src/internal-urls/memory-protocol.ts +6 -1
  152. package/src/internal-urls/omp-protocol.ts +5 -1
  153. package/src/internal-urls/router.ts +20 -1
  154. package/src/internal-urls/rule-protocol.ts +8 -1
  155. package/src/internal-urls/skill-protocol.ts +8 -1
  156. package/src/internal-urls/types.ts +27 -0
  157. package/src/lsp/render.ts +1 -1
  158. package/src/main.ts +18 -1
  159. package/src/mcp/oauth-flow.ts +2 -2
  160. package/src/memory-backend/index.ts +1 -0
  161. package/src/memory-backend/resolve.ts +4 -1
  162. package/src/memory-backend/types.ts +8 -1
  163. package/src/mnemosyne/backend.ts +374 -0
  164. package/src/mnemosyne/config.ts +160 -0
  165. package/src/mnemosyne/index.ts +3 -0
  166. package/src/mnemosyne/state.ts +548 -0
  167. package/src/modes/acp/acp-agent.ts +11 -6
  168. package/src/modes/components/agent-dashboard.ts +4 -4
  169. package/src/modes/components/custom-editor.ts +3 -2
  170. package/src/modes/components/diff.ts +2 -2
  171. package/src/modes/components/extensions/extension-list.ts +3 -2
  172. package/src/modes/components/footer.ts +5 -6
  173. package/src/modes/components/history-search.ts +3 -3
  174. package/src/modes/components/hook-selector.ts +92 -8
  175. package/src/modes/components/index.ts +2 -0
  176. package/src/modes/components/mcp-add-wizard.ts +3 -3
  177. package/src/modes/components/model-selector.ts +5 -4
  178. package/src/modes/components/oauth-selector.ts +3 -3
  179. package/src/modes/components/segment-track.ts +52 -0
  180. package/src/modes/components/session-observer-overlay.ts +19 -13
  181. package/src/modes/components/session-selector.ts +3 -3
  182. package/src/modes/components/settings-defs.ts +7 -0
  183. package/src/modes/components/status-line/context-thresholds.ts +11 -0
  184. package/src/modes/components/status-line/segments.ts +2 -2
  185. package/src/modes/components/tiny-title-download-progress.ts +90 -0
  186. package/src/modes/components/tips.txt +13 -0
  187. package/src/modes/components/tool-execution.ts +72 -4
  188. package/src/modes/components/tree-selector.ts +3 -3
  189. package/src/modes/components/user-message-selector.ts +3 -3
  190. package/src/modes/components/welcome.ts +102 -43
  191. package/src/modes/controllers/command-controller.ts +16 -1
  192. package/src/modes/controllers/extension-ui-controller.ts +3 -1
  193. package/src/modes/controllers/input-controller.ts +69 -21
  194. package/src/modes/gradient-highlight.ts +70 -0
  195. package/src/modes/interactive-mode.ts +75 -114
  196. package/src/modes/internal-url-autocomplete.ts +143 -0
  197. package/src/modes/orchestrate.ts +36 -0
  198. package/src/modes/prompt-action-autocomplete.ts +12 -0
  199. package/src/modes/setup-wizard/index.ts +88 -0
  200. package/src/modes/setup-wizard/scenes/glyph.ts +96 -0
  201. package/src/modes/setup-wizard/scenes/outro.ts +35 -0
  202. package/src/modes/setup-wizard/scenes/providers.ts +69 -0
  203. package/src/modes/setup-wizard/scenes/sign-in.ts +193 -0
  204. package/src/modes/setup-wizard/scenes/splash.ts +201 -0
  205. package/src/modes/setup-wizard/scenes/theme.ts +299 -0
  206. package/src/modes/setup-wizard/scenes/types.ts +48 -0
  207. package/src/modes/setup-wizard/scenes/web-search.ts +128 -0
  208. package/src/modes/setup-wizard/wizard-overlay.ts +275 -0
  209. package/src/modes/theme/shimmer.ts +5 -0
  210. package/src/modes/theme/theme.ts +44 -20
  211. package/src/modes/types.ts +6 -1
  212. package/src/modes/ultrathink.ts +9 -53
  213. package/src/modes/utils/keybinding-matchers.ts +11 -0
  214. package/src/prompts/system/memory-consolidation-system.md +8 -0
  215. package/src/prompts/system/memory-extraction-system.md +26 -0
  216. package/src/prompts/{commands/orchestrate.md → system/orchestrate-notice.md} +6 -17
  217. package/src/prompts/system/system-prompt.md +2 -0
  218. package/src/prompts/system/tiny-title-system.md +8 -0
  219. package/src/prompts/tools/memory-edit.md +8 -0
  220. package/src/prompts/tools/read.md +4 -0
  221. package/src/prompts/tools/task.md +4 -7
  222. package/src/sdk.ts +13 -21
  223. package/src/session/agent-session.ts +128 -44
  224. package/src/slash-commands/builtin-registry.ts +18 -1
  225. package/src/system-prompt.ts +4 -0
  226. package/src/task/commands.ts +1 -5
  227. package/src/task/executor.ts +8 -0
  228. package/src/task/index.ts +2 -0
  229. package/src/task/render.ts +69 -26
  230. package/src/tiny/device.ts +117 -0
  231. package/src/tiny/dtype.ts +101 -0
  232. package/src/tiny/models.ts +218 -0
  233. package/src/tiny/text.ts +54 -0
  234. package/src/tiny/title-client.ts +395 -0
  235. package/src/tiny/title-protocol.ts +51 -0
  236. package/src/tiny/worker.ts +587 -0
  237. package/src/tools/bash.ts +74 -29
  238. package/src/tools/browser/tab-worker.ts +1 -1
  239. package/src/tools/eval.ts +9 -4
  240. package/src/tools/index.ts +17 -22
  241. package/src/tools/memory-edit.ts +59 -0
  242. package/src/tools/memory-recall.ts +100 -0
  243. package/src/tools/memory-reflect.ts +88 -0
  244. package/src/tools/memory-render.ts +185 -0
  245. package/src/tools/memory-retain.ts +91 -0
  246. package/src/tools/read.ts +1 -0
  247. package/src/tools/renderers.ts +4 -2
  248. package/src/tools/todo-write.ts +128 -29
  249. package/src/tools/tool-result.ts +8 -0
  250. package/src/tui/code-cell.ts +6 -1
  251. package/src/tui/output-block.ts +199 -38
  252. package/src/utils/title-generator.ts +115 -13
  253. package/dist/types/tools/recipe/index.d.ts +0 -46
  254. package/dist/types/tools/recipe/render.d.ts +0 -36
  255. package/dist/types/tools/recipe/runner.d.ts +0 -60
  256. package/dist/types/tools/recipe/runners/cargo.d.ts +0 -16
  257. package/dist/types/tools/recipe/runners/index.d.ts +0 -2
  258. package/dist/types/tools/recipe/runners/just.d.ts +0 -2
  259. package/dist/types/tools/recipe/runners/make.d.ts +0 -2
  260. package/dist/types/tools/recipe/runners/pkg.d.ts +0 -2
  261. package/dist/types/tools/recipe/runners/task.d.ts +0 -2
  262. package/src/prompts/tools/recipe.md +0 -16
  263. package/src/tools/hindsight-recall.ts +0 -69
  264. package/src/tools/hindsight-reflect.ts +0 -58
  265. package/src/tools/hindsight-retain.ts +0 -57
  266. package/src/tools/recipe/index.ts +0 -81
  267. package/src/tools/recipe/render.ts +0 -19
  268. package/src/tools/recipe/runner.ts +0 -219
  269. package/src/tools/recipe/runners/cargo.ts +0 -131
  270. package/src/tools/recipe/runners/index.ts +0 -8
  271. package/src/tools/recipe/runners/just.ts +0 -73
  272. package/src/tools/recipe/runners/make.ts +0 -101
  273. package/src/tools/recipe/runners/pkg.ts +0 -167
  274. package/src/tools/recipe/runners/task.ts +0 -72
@@ -15,6 +15,7 @@ import {
15
15
  } from "@oh-my-pi/pi-tui";
16
16
  import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
17
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
18
+ import { shimmerEnabled } from "../../modes/theme/shimmer";
18
19
  import type { Theme } from "../../modes/theme/theme";
19
20
  import { theme } from "../../modes/theme/theme";
20
21
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -31,6 +32,7 @@ import {
31
32
  } from "../../tools/json-tree";
32
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
33
34
  import { toolRenderers } from "../../tools/renderers";
35
+ import { TODO_WRITE_STRIKE_TOTAL_FRAMES } from "../../tools/todo-write";
34
36
  import { renderStatusLine } from "../../tui";
35
37
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
36
38
  import { renderDiff } from "./diff";
@@ -163,6 +165,8 @@ export class ToolExecutionComponent extends Container {
163
165
  // Spinner animation for partial task results
164
166
  #spinnerFrame?: number;
165
167
  #spinnerInterval?: NodeJS.Timeout;
168
+ // Todo write completion strikethrough reveal animation
169
+ #todoStrikeInterval?: NodeJS.Timeout;
166
170
  // Track if args are still being streamed (for edit/write spinner)
167
171
  #argsComplete = false;
168
172
  #renderState: {
@@ -251,12 +255,24 @@ export class ToolExecutionComponent extends Container {
251
255
  effectiveArgs = args;
252
256
  }
253
257
 
254
- // Coalesce duplicate computes for identical args.
258
+ // Coalesce duplicate computes for identical args. The key pairs the
259
+ // streaming flag with a content hash: the final (args-complete) pass
260
+ // computes an untrimmed diff and must run even when the payload is
261
+ // byte-identical to the last streamed chunk — only `isStreaming` differs,
262
+ // and it flips the trailing-line trim. Without the flag a single-line edit
263
+ // whose trailing payload line never gets a newline stays stuck on the
264
+ // trimmed "no changes" streaming preview and renders no diff. Hashing keeps
265
+ // the retained key tiny instead of holding the whole serialized blob.
266
+ const streamingState = this.#argsComplete ? "final" : "stream";
255
267
  let argsKey: string;
256
268
  try {
257
- argsKey = JSON.stringify(effectiveArgs);
269
+ argsKey = `${streamingState}:${Bun.hash(JSON.stringify(effectiveArgs))}`;
258
270
  } catch {
259
- argsKey = String(Date.now());
271
+ // effectiveArgs isn't JSON-serializable (exotic value in tool args).
272
+ // The raw streamed JSON is a plain string, so hash that instead of a
273
+ // timestamp — a deterministic key keeps the dedup cache working
274
+ // instead of recomputing (and re-reading the file) on every render.
275
+ argsKey = `${streamingState}:partial:${Bun.hash(partialJson ?? "")}`;
260
276
  }
261
277
  if (argsKey === this.#editDiffLastArgsKey) return;
262
278
  this.#editDiffLastArgsKey = argsKey;
@@ -304,6 +320,7 @@ export class ToolExecutionComponent extends Container {
304
320
  this.#argsComplete = true;
305
321
  }
306
322
  this.#updateSpinnerAnimation();
323
+ this.#updateTodoStrikeAnimation();
307
324
  this.#updateDisplay();
308
325
  // Convert non-PNG images to PNG for Kitty protocol (async)
309
326
  this.#maybeConvertImagesForKitty();
@@ -364,7 +381,10 @@ export class ToolExecutionComponent extends Container {
364
381
  this.#toolName === "task" &&
365
382
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
366
383
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
367
- const needsSpinner = isStreamingArgs || isPartialTask;
384
+ // Sweep the border of bash/eval execution blocks while they're pending.
385
+ const isPendingExecBlock =
386
+ this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
387
+ const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
368
388
  if (needsSpinner && !this.#spinnerInterval) {
369
389
  this.#spinnerInterval = setInterval(() => {
370
390
  const frameCount = theme.spinnerFrames.length;
@@ -379,6 +399,43 @@ export class ToolExecutionComponent extends Container {
379
399
  }
380
400
  }
381
401
 
402
+ #updateTodoStrikeAnimation(): void {
403
+ if (this.#toolName !== "todo_write" || this.#isPartial || this.#result?.isError) {
404
+ this.#stopTodoStrikeAnimation();
405
+ return;
406
+ }
407
+ const completedTasks = (this.#result?.details as { completedTasks?: unknown[] } | undefined)?.completedTasks;
408
+ if (!completedTasks || completedTasks.length === 0) {
409
+ this.#stopTodoStrikeAnimation();
410
+ return;
411
+ }
412
+ if (this.#todoStrikeInterval) return;
413
+
414
+ this.#spinnerFrame = 0;
415
+ this.#renderState.spinnerFrame = 0;
416
+ this.#todoStrikeInterval = setInterval(() => {
417
+ const nextFrame = (this.#spinnerFrame ?? 0) + 1;
418
+ if (nextFrame > TODO_WRITE_STRIKE_TOTAL_FRAMES) {
419
+ this.#stopTodoStrikeAnimation();
420
+ } else {
421
+ this.#spinnerFrame = nextFrame;
422
+ this.#renderState.spinnerFrame = nextFrame;
423
+ }
424
+ this.#ui.requestRender();
425
+ }, 65);
426
+ }
427
+
428
+ #stopTodoStrikeAnimation(): void {
429
+ if (this.#todoStrikeInterval) {
430
+ clearInterval(this.#todoStrikeInterval);
431
+ this.#todoStrikeInterval = undefined;
432
+ }
433
+ if (!this.#spinnerInterval) {
434
+ this.#spinnerFrame = undefined;
435
+ this.#renderState.spinnerFrame = undefined;
436
+ }
437
+ }
438
+
382
439
  /**
383
440
  * Stop spinner animation and cleanup resources.
384
441
  */
@@ -388,6 +445,7 @@ export class ToolExecutionComponent extends Container {
388
445
  this.#spinnerInterval = undefined;
389
446
  this.#spinnerFrame = undefined;
390
447
  }
448
+ this.#stopTodoStrikeAnimation();
391
449
  this.#editDiffAbort?.abort();
392
450
  this.#editDiffAbort = undefined;
393
451
  }
@@ -428,6 +486,11 @@ export class ToolExecutionComponent extends Container {
428
486
  const inline = Boolean((tool as { inline?: boolean }).inline);
429
487
  this.#contentBox.setBgFn(inline ? undefined : bgFn);
430
488
  this.#contentBox.clear();
489
+ // Mirror the built-in renderer branch so custom renderers (notably the
490
+ // task tool, whose live instance routes through here) receive the same
491
+ // render context — e.g. the `hasResult` flag that suppresses the task
492
+ // call preview once result lines exist.
493
+ this.#renderState.renderContext = this.#buildRenderContext();
431
494
 
432
495
  // Render call component
433
496
  const shouldRenderCall = !this.#result || !mergeCallAndResult;
@@ -696,6 +759,11 @@ export class ToolExecutionComponent extends Container {
696
759
  context.output = output;
697
760
  context.expanded = this.#expanded;
698
761
  context.previewLines = EVAL_DEFAULT_PREVIEW_LINES;
762
+ } else if (this.#toolName === "task") {
763
+ // Once a result snapshot exists the task renderer's `renderResult`
764
+ // draws every dispatched agent as a progress/result line, so tell
765
+ // `renderCall` to drop its duplicate streaming preview list.
766
+ context.hasResult = Boolean(this.#result);
699
767
  } else if (isEditLikeToolName(this.#toolName)) {
700
768
  context.editMode = this.#editMode;
701
769
  const previews = this.#editDiffPreview;
@@ -12,7 +12,7 @@ import {
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import type { TreeFilterMode } from "../../config/settings-schema";
14
14
  import { theme } from "../../modes/theme/theme";
15
- import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
15
+ import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
16
16
  import type { SessionTreeNode } from "../../session/session-manager";
17
17
  import { shortenPath } from "../../tools/render-utils";
18
18
  import { toPathList } from "../../tools/search";
@@ -718,9 +718,9 @@ class TreeList implements Component {
718
718
  }
719
719
 
720
720
  handleInput(keyData: string): void {
721
- if (matchesKey(keyData, "up")) {
721
+ if (matchesSelectUp(keyData)) {
722
722
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredNodes.length - 1 : this.#selectedIndex - 1;
723
- } else if (matchesKey(keyData, "down")) {
723
+ } else if (matchesSelectDown(keyData)) {
724
724
  this.#selectedIndex = this.#selectedIndex === this.#filteredNodes.length - 1 ? 0 : this.#selectedIndex + 1;
725
725
  } else if (matchesKey(keyData, "left")) {
726
726
  // Page up
@@ -1,6 +1,6 @@
1
1
  import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
2
2
  import { theme } from "../../modes/theme/theme";
3
- import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
3
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
4
4
  import { DynamicBorder } from "./dynamic-border";
5
5
 
6
6
  interface UserMessageItem {
@@ -78,11 +78,11 @@ class UserMessageList implements Component {
78
78
 
79
79
  handleInput(keyData: string): void {
80
80
  // Up arrow - go to previous (older) message, wrap to bottom when at top
81
- if (matchesKey(keyData, "up")) {
81
+ if (matchesSelectUp(keyData)) {
82
82
  this.#selectedIndex = this.#selectedIndex === 0 ? this.messages.length - 1 : this.#selectedIndex - 1;
83
83
  }
84
84
  // Down arrow - go to next (newer) message, wrap to top when at bottom
85
- else if (matchesKey(keyData, "down")) {
85
+ else if (matchesSelectDown(keyData)) {
86
86
  this.#selectedIndex = this.#selectedIndex === this.messages.length - 1 ? 0 : this.#selectedIndex + 1;
87
87
  }
88
88
  // Enter - select message and branch
@@ -1,6 +1,45 @@
1
- import { type Component, padding, TERMINAL, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ padding,
4
+ replaceTabs,
5
+ TERMINAL,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ wrapTextWithAnsi,
9
+ } from "@oh-my-pi/pi-tui";
2
10
  import { APP_NAME } from "@oh-my-pi/pi-utils";
3
11
  import { theme } from "../../modes/theme/theme";
12
+ import tipsText from "./tips.txt" with { type: "text" };
13
+
14
+ /** Tips embedded at build time, one per line; blanks dropped. */
15
+ const TIPS: readonly string[] = tipsText
16
+ .split("\n")
17
+ .map(line => line.trim())
18
+ .filter(line => line.length > 0);
19
+
20
+ export function renderWelcomeTip(tip: string, boxWidth: number): string[] {
21
+ const label = "Tip: ";
22
+ const labelWidth = visibleWidth(label);
23
+ const bodyBudget = boxWidth - 1 - labelWidth; // 1 = leading indent
24
+ if (bodyBudget < 8) return [];
25
+
26
+ const wrappedBody = wrapTextWithAnsi(replaceTabs(tip), bodyBudget);
27
+ if (wrappedBody.length === 0) return [];
28
+
29
+ const encoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
30
+ const purple = Bun.color("#b48cff", encoding) ?? "";
31
+ const lightBlue = Bun.color("#9ccfff", encoding) ?? "";
32
+ const italic = "\x1b[3m";
33
+ const dim = "\x1b[2m";
34
+ const reset = "\x1b[0m";
35
+ const continuationIndent = padding(labelWidth);
36
+
37
+ return wrappedBody.map((body, index) =>
38
+ index === 0
39
+ ? ` ${italic}${purple}${label}${dim}${lightBlue}${body}${reset}`
40
+ : ` ${italic}${continuationIndent}${dim}${lightBlue}${body}${reset}`,
41
+ );
42
+ }
4
43
 
5
44
  export interface RecentSession {
6
45
  name: string;
@@ -19,6 +58,8 @@ export interface LspServerInfo {
19
58
  export class WelcomeComponent implements Component {
20
59
  #animStart: number | null = null;
21
60
  #animTimer: ReturnType<typeof setInterval> | null = null;
61
+ /** Tip chosen once per instance so re-renders (intro, LSP updates) don't shuffle it. */
62
+ readonly #tip: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
22
63
 
23
64
  constructor(
24
65
  private readonly version: string,
@@ -212,9 +253,22 @@ export class WelcomeComponent implements Component {
212
253
  lines.push(bl + h.repeat(leftCol) + br);
213
254
  }
214
255
 
256
+ // Randomly picked tip, rendered directly beneath the box.
257
+ lines.push(...this.#renderTip(boxWidth));
258
+
215
259
  return lines;
216
260
  }
217
261
 
262
+ /**
263
+ * Render the per-instance tip line: a purple "Tip:" label followed by the
264
+ * tip body in dimmed light blue, the whole line italicized. Returns `[]`
265
+ * when no tip is available or the box is too narrow to be useful.
266
+ */
267
+ #renderTip(boxWidth: number): string[] {
268
+ if (!this.#tip) return [];
269
+ return renderWelcomeTip(this.#tip, boxWidth);
270
+ }
271
+
218
272
  /** Center text within a given width */
219
273
  #centerText(text: string, width: number): string {
220
274
  const visLen = visibleWidth(text);
@@ -271,8 +325,7 @@ export class WelcomeComponent implements Component {
271
325
  }
272
326
  }
273
327
 
274
- // biome-ignore format: preserve ASCII art layout
275
- const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
328
+ export const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
276
329
 
277
330
  /** Multi-stop palette for the diagonal gradient. */
278
331
  const GRADIENT_STOPS: ReadonlyArray<readonly [number, number, number]> = [
@@ -289,63 +342,69 @@ const GRADIENT_RAMP_256 = [199, 171, 135, 99, 75, 51, 87];
289
342
  /** Half-width of the shine highlight band, expressed in gradient-t units. */
290
343
  const SHINE_HALF_WIDTH = 0.18;
291
344
 
292
- interface ShineConfig {
345
+ export interface ShineConfig {
293
346
  /** Overall opacity of the shine overlay, in [0, 1]. */
294
347
  strength: number;
295
348
  /** Center of the shine band along the diagonal, in [0, 1]. */
296
349
  pos: number;
297
350
  }
298
351
 
352
+ /**
353
+ * Resolve the gradient SGR foreground escape for a normalized position `t`
354
+ * (0..1) along the diagonal, compositing the optional sliding shine highlight.
355
+ * Shared by {@link gradientLogo} and the setup splash so both stay
356
+ * color-identical (truecolor when available, 256-color ramp otherwise).
357
+ */
358
+ export function gradientEscape(t: number, shine?: ShineConfig): string {
359
+ const shineStrength = shine && shine.strength > 0 ? shine.strength : 0;
360
+ const shinePos = shine ? shine.pos : 0;
361
+ if (TERMINAL.trueColor) {
362
+ // 5-stop palette widens the visible color range and avoids the
363
+ // deep-blue valley a naive HSL lerp falls into.
364
+ const stops = GRADIENT_STOPS;
365
+ const seg = t * (stops.length - 1);
366
+ const i = Math.min(stops.length - 2, Math.floor(seg));
367
+ const f = seg - i;
368
+ const a = stops[i];
369
+ const b = stops[i + 1];
370
+ let r = a[0] + (b[0] - a[0]) * f;
371
+ let g = a[1] + (b[1] - a[1]) * f;
372
+ let bl = a[2] + (b[2] - a[2]) * f;
373
+ if (shineStrength > 0) {
374
+ const dist = Math.abs(t - shinePos);
375
+ const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
376
+ if (intensity > 0) {
377
+ r += (255 - r) * intensity;
378
+ g += (255 - g) * intensity;
379
+ bl += (255 - bl) * intensity;
380
+ }
381
+ }
382
+ return `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(bl)}m`;
383
+ }
384
+ const ramp = GRADIENT_RAMP_256;
385
+ let idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
386
+ if (shineStrength > 0) {
387
+ const dist = Math.abs(t - shinePos);
388
+ const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
389
+ // Promote to the brightest ramp slot when the shine band peaks here.
390
+ if (intensity > 0.5) idx = ramp.length - 1;
391
+ }
392
+ return `\x1b[38;5;${ramp[idx]}m`;
393
+ }
394
+
299
395
  /**
300
396
  * Apply a multi-stop diagonal gradient (bottom-left → top-right) plus an
301
397
  * optional sliding shine band across multi-line art. `phase` (0..1) shifts the
302
398
  * gradient along the diagonal, wrapping at 1. When `shine` is provided, a soft
303
399
  * white highlight is composited on top, centered at `shine.pos`.
304
400
  */
305
- function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig): string[] {
401
+ export function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig): string[] {
306
402
  const reset = "\x1b[0m";
307
403
  const rows = lines.length;
308
404
  const cols = Math.max(...lines.map(l => l.length));
309
405
  // span+1 so `base` stays strictly < 1: avoids the wrap-around at the
310
406
  // far corner mapping back to t=0 (hot pink) on the resting frame.
311
407
  const span = Math.max(1, cols + rows - 1);
312
- const shineStrength = shine && shine.strength > 0 ? shine.strength : 0;
313
- const shinePos = shine ? shine.pos : 0;
314
- const colorAt = TERMINAL.trueColor
315
- ? (t: number): string => {
316
- // 5-stop palette widens the visible color range and avoids the
317
- // deep-blue valley a naive HSL lerp falls into.
318
- const stops = GRADIENT_STOPS;
319
- const seg = t * (stops.length - 1);
320
- const i = Math.min(stops.length - 2, Math.floor(seg));
321
- const f = seg - i;
322
- const a = stops[i];
323
- const b = stops[i + 1];
324
- let r = a[0] + (b[0] - a[0]) * f;
325
- let g = a[1] + (b[1] - a[1]) * f;
326
- let bl = a[2] + (b[2] - a[2]) * f;
327
- if (shineStrength > 0) {
328
- const dist = Math.abs(t - shinePos);
329
- const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
330
- if (intensity > 0) {
331
- r += (255 - r) * intensity;
332
- g += (255 - g) * intensity;
333
- bl += (255 - bl) * intensity;
334
- }
335
- }
336
- return `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(bl)}m`;
337
- }
338
- : (t: number): string => {
339
- const ramp = GRADIENT_RAMP_256;
340
- let idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
341
- if (shineStrength > 0) {
342
- const dist = Math.abs(t - shinePos);
343
- const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
344
- // Promote to the brightest ramp slot when the shine band peaks here.
345
- if (intensity > 0.5) idx = ramp.length - 1;
346
- }
347
- return `\x1b[38;5;${ramp[idx]}m`;
348
- };
349
408
  return lines.map((line, y) => {
350
409
  let result = "";
351
410
  for (let x = 0; x < line.length; x++) {
@@ -357,7 +416,7 @@ function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig):
357
416
  // Diagonal: bottom-left (x=0, y=rows-1) → top-right (x=cols-1, y=0)
358
417
  const base = (x + (rows - 1 - y)) / span;
359
418
  const t = (((base + phase) % 1) + 1) % 1;
360
- result += colorAt(t) + char + reset;
419
+ result += gradientEscape(t, shine) + char + reset;
361
420
  }
362
421
  return result;
363
422
  });
@@ -620,12 +620,27 @@ export class CommandController {
620
620
  return;
621
621
  }
622
622
 
623
+ if (action === "stats" || action === "diagnose") {
624
+ const hook = action === "stats" ? backend.stats : backend.diagnose;
625
+ try {
626
+ const payload = await hook?.(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session);
627
+ if (!payload) {
628
+ this.ctx.showWarning(`Memory ${action} is not available for the ${backend.id} backend.`);
629
+ return;
630
+ }
631
+ showMarkdownPanel(this.ctx, `Memory ${action === "stats" ? "Stats" : "Diagnostics"}`, payload);
632
+ } catch (error) {
633
+ this.ctx.showError(`Memory ${action} failed: ${error instanceof Error ? error.message : String(error)}`);
634
+ }
635
+ return;
636
+ }
637
+
623
638
  if (action === "mm") {
624
639
  await this.#handleMentalModelsSubcommand(argumentText);
625
640
  return;
626
641
  }
627
642
 
628
- this.ctx.showError("Usage: /memory <view|clear|reset|enqueue|rebuild|mm ...>");
643
+ this.ctx.showError("Usage: /memory <view|stats|diagnose|clear|reset|enqueue|rebuild|mm ...>");
629
644
  }
630
645
 
631
646
  async #handleMentalModelsSubcommand(argumentText: string): Promise<void> {
@@ -19,7 +19,7 @@ import type {
19
19
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
20
20
  import { HookEditorComponent } from "../../modes/components/hook-editor";
21
21
  import { HookInputComponent } from "../../modes/components/hook-input";
22
- import { HookSelectorComponent } from "../../modes/components/hook-selector";
22
+ import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
23
23
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
24
24
  import type { InteractiveModeContext } from "../../modes/types";
25
25
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
@@ -583,6 +583,7 @@ export class ExtensionUiController {
583
583
  title: string,
584
584
  options: string[],
585
585
  dialogOptions?: ExtensionUIDialogOptions,
586
+ extra?: { slider?: HookSelectorSlider },
586
587
  ): Promise<string | undefined> {
587
588
  const { promise, finish, attachAbort } = this.#createHookDialogState(
588
589
  () => this.hideHookSelector(),
@@ -623,6 +624,7 @@ export class ExtensionUiController {
623
624
  tui: this.ctx.ui,
624
625
  outline: dialogOptions?.outline,
625
626
  maxVisible,
627
+ slider: extra?.slider,
626
628
  },
627
629
  );
628
630
  this.ctx.editorContainer.clear();
@@ -1,8 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
- import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
4
  import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
5
+ import { getRoleInfo } from "../../config/model-registry";
5
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
+ import { renderSegmentTrack } from "../../modes/components/segment-track";
8
+ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
6
9
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
7
10
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
8
11
  import { theme } from "../../modes/theme/theme";
@@ -10,6 +13,9 @@ import type { InteractiveModeContext } from "../../modes/types";
10
13
  import type { AgentSessionEvent } from "../../session/agent-session";
11
14
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
12
15
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
16
+ import { isTinyTitleLocalModelKey } from "../../tiny/models";
17
+ import { tinyTitleClient } from "../../tiny/title-client";
18
+ import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
13
19
  import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
14
20
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
15
21
  import { ensureSupportedImageInput } from "../../utils/image-loading";
@@ -24,9 +30,61 @@ function isExpandable(obj: unknown): obj is Expandable {
24
30
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
25
31
  }
26
32
 
33
+ const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
34
+ // A cached model fires its file-load events in a short burst and then goes silent
35
+ // while onnxruntime builds the session; a genuine download keeps streaming progress
36
+ // events for seconds. Only reveal the bar once a still-incomplete event arrives after
37
+ // this grace window, so an already-downloaded model never flashes the bar.
38
+ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
39
+
27
40
  export class InputController {
28
41
  constructor(private ctx: InteractiveModeContext) {}
29
42
 
43
+ #showTinyTitleDownloadProgress(modelKey: string): void {
44
+ if (!isTinyTitleLocalModelKey(modelKey) || this.ctx.isBackgrounded) return;
45
+ const component = new TinyTitleDownloadProgressComponent(modelKey);
46
+ let added = false;
47
+ let disposed = false;
48
+ let removeTimer: NodeJS.Timeout | undefined;
49
+ const remove = (): void => {
50
+ if (disposed) return;
51
+ disposed = true;
52
+ unsubscribe();
53
+ if (removeTimer) {
54
+ clearTimeout(removeTimer);
55
+ removeTimer = undefined;
56
+ }
57
+ if (added) {
58
+ this.ctx.chatContainer.removeChild(component);
59
+ this.ctx.ui.requestRender();
60
+ }
61
+ };
62
+ const scheduleRemove = (): void => {
63
+ if (removeTimer) clearTimeout(removeTimer);
64
+ removeTimer = setTimeout(remove, TINY_TITLE_PROGRESS_DONE_TTL_MS);
65
+ removeTimer.unref?.();
66
+ };
67
+ let revealAt = 0;
68
+ const update = (event: TinyTitleProgressEvent): void => {
69
+ if (disposed || event.modelKey !== modelKey) return;
70
+ component.update(event);
71
+ if (revealAt === 0) revealAt = performance.now() + TINY_TITLE_PROGRESS_REVEAL_DELAY_MS;
72
+ const complete = component.isComplete();
73
+ // Reveal only for a download still in flight past the grace window. Cache hits
74
+ // either complete or fall silent (onnx init emits no events) before this fires.
75
+ if (!added && !complete && performance.now() >= revealAt) {
76
+ this.ctx.chatContainer.addChild(component);
77
+ added = true;
78
+ }
79
+ if (added) this.ctx.ui.requestRender();
80
+ if (complete) {
81
+ if (added) scheduleRemove();
82
+ else remove();
83
+ }
84
+ };
85
+ const unsubscribe = tinyTitleClient.onProgress(update);
86
+ }
87
+
30
88
  setupKeyHandlers(): void {
31
89
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
32
90
  this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
@@ -329,6 +387,7 @@ export class InputController {
329
387
  // Generate session title on first message
330
388
  const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
331
389
  if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
390
+ this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
332
391
  const registry = this.ctx.session.modelRegistry;
333
392
  generateSessionTitle(
334
393
  text,
@@ -712,27 +771,16 @@ export class InputController {
712
771
 
713
772
  this.ctx.statusLine.invalidate();
714
773
  this.ctx.updateEditorBorderColor();
715
- const roleLabel = result.role === "default" ? "default" : result.role;
716
- const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
717
- const thinkingStr =
718
- result.model.thinking && result.thinkingLevel !== ThinkingLevel.Off
719
- ? ` (thinking: ${result.thinkingLevel})`
720
- : "";
721
- const tempLabel = options?.temporary ? " (temporary)" : "";
722
- const cycleSeparator = theme.fg("dim", " > ");
723
- const cycleLabel = cycleOrder
724
- .map(role => {
725
- if (role === result.role) {
726
- return theme.bold(theme.fg("accent", role));
727
- }
728
- return theme.fg("muted", role);
729
- })
730
- .join(cycleSeparator);
731
- const orderLabel = ` (cycle: ${cycleLabel})`;
732
- this.ctx.showStatus(
733
- `Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`,
734
- { dim: false },
774
+ // The status line already reports the resolved model + thinking level, so
775
+ // the cycle status is just a status-line-style chip track (active role
776
+ // filled), matching the plan-approval model slider. A dim suffix flags a
777
+ // temporary switch since that isn't shown elsewhere.
778
+ const track = renderSegmentTrack(
779
+ cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
780
+ cycleOrder.indexOf(result.role),
735
781
  );
782
+ const tempLabel = options?.temporary ? theme.fg("dim", " (temporary)") : "";
783
+ this.ctx.showStatus(`${track}${tempLabel}`, { dim: false });
736
784
  } catch (error) {
737
785
  this.ctx.showError(error instanceof Error ? error.message : String(error));
738
786
  }
@@ -0,0 +1,70 @@
1
+ import { theme } from "./theme/theme";
2
+
3
+ const FG_RESET = "\x1b[39m";
4
+
5
+ /** Declarative spec for {@link createGradientHighlighter}. */
6
+ export interface GradientHighlightSpec {
7
+ /** Cheap, stateless presence probe used to skip the boundary regex on most lines. Must be non-global. */
8
+ probe: RegExp;
9
+ /** Global, word-bounded match regex walked by `.replace`. */
10
+ highlight: RegExp;
11
+ /** Number of color stops swept across the gradient. */
12
+ stops: number;
13
+ /** Maps a normalized position `t` in [0, 1) to an HSL hue in degrees. */
14
+ hue: (t: number) => number;
15
+ /** HSL saturation percentage. Default 90. */
16
+ saturation?: number;
17
+ /** HSL lightness percentage. Default 62. */
18
+ lightness?: number;
19
+ }
20
+
21
+ /**
22
+ * Build a stateless highlighter that paints each standalone match of `highlight`
23
+ * with a smooth HSL gradient for editor display. The returned function adds only
24
+ * zero-width SGR escapes — the visible width is unchanged — and returns the input
25
+ * untouched when `probe` does not match. The palette is compiled lazily and
26
+ * memoized per active color mode.
27
+ */
28
+ export function createGradientHighlighter(spec: GradientHighlightSpec): (text: string) => string {
29
+ const { probe, highlight, stops, hue, saturation = 90, lightness = 62 } = spec;
30
+
31
+ let cachedMode: string | undefined;
32
+ let cachedPalette: readonly string[] | undefined;
33
+
34
+ /** Gradient foreground escapes for the active color mode, compiled once per mode. */
35
+ const palette = (): readonly string[] => {
36
+ const mode = theme.getColorMode();
37
+ if (cachedPalette && cachedMode === mode) return cachedPalette;
38
+ const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
39
+ const next: string[] = [];
40
+ for (let i = 0; i < stops; i++) {
41
+ next.push(Bun.color(`hsl(${Math.round(hue(i / stops))}, ${saturation}%, ${lightness}%)`, format) ?? "");
42
+ }
43
+ cachedMode = mode;
44
+ cachedPalette = next;
45
+ return next;
46
+ };
47
+
48
+ /** Paint each character of `word` with the next gradient stop, resetting fg after. */
49
+ const paint = (word: string): string => {
50
+ const stopsArr = palette();
51
+ const n = word.length;
52
+ let out = "";
53
+ let prev = "";
54
+ for (let i = 0; i < n; i++) {
55
+ const color = stopsArr[Math.floor((i / n) * stopsArr.length)] ?? stopsArr[0] ?? "";
56
+ // Coalesce consecutive characters that resolve to the same stop.
57
+ if (color !== prev) {
58
+ out += color;
59
+ prev = color;
60
+ }
61
+ out += word[i];
62
+ }
63
+ return `${out}${FG_RESET}`;
64
+ };
65
+
66
+ return (text: string): string => {
67
+ if (!probe.test(text)) return text;
68
+ return text.replace(highlight, paint);
69
+ };
70
+ }