@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 ThemeColor, theme } from "../../../modes/theme/theme";
7
7
  import { shortenPath } from "../../../tools/render-utils";
8
8
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
9
9
  import { sanitizeStatusText } from "../../shared";
10
- import { getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
10
+ import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } from "./context-thresholds";
11
11
  import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types";
12
12
 
13
13
  export type { SegmentContext } from "./types";
@@ -350,7 +350,7 @@ const contextPctSegment: StatusLineSegment = {
350
350
  const window = ctx.contextWindow;
351
351
 
352
352
  const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : "";
353
- const text = `${pct.toFixed(1)}%/${formatNumber(window)}${autoIcon}`;
353
+ const text = `${formatContextUsage(pct, window)}${autoIcon}`;
354
354
 
355
355
  const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
356
356
  const content = withIcon(theme.icon.context, theme.fg(color, text));
@@ -448,6 +448,28 @@ const cacheWriteSegment: StatusLineSegment = {
448
448
  },
449
449
  };
450
450
 
451
+ const cacheHitSegment: StatusLineSegment = {
452
+ id: "cache_hit",
453
+ render(ctx) {
454
+ const { cacheRead, cacheWrite, input } = ctx.usageStats;
455
+ if (!cacheRead) return { content: "", visible: false };
456
+
457
+ // Hit rate = cacheRead / total prompt tokens. The prompt is the sum of
458
+ // cacheRead (served from cache), cacheWrite (newly cached this turn) and
459
+ // input (uncached). Including uncached input keeps the denominator honest
460
+ // for Anthropic/OpenRouter; DeepSeek reports its miss as input with
461
+ // cacheWrite 0, so this still yields hit/(hit+miss).
462
+ const total = cacheRead + cacheWrite + input;
463
+
464
+ const rate = (cacheRead / total) * 100;
465
+ const rateStr = rate.toFixed(2);
466
+
467
+ const parts: string[] = [theme.icon.cache];
468
+ parts.push(theme.fg("statusLineSpend", `${rateStr}%`));
469
+ return { content: parts.join(" "), visible: true };
470
+ },
471
+ };
472
+
451
473
  const sessionNameSegment: StatusLineSegment = {
452
474
  id: "session_name",
453
475
  render(ctx) {
@@ -537,6 +559,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
537
559
  hostname: hostnameSegment,
538
560
  cache_read: cacheReadSegment,
539
561
  cache_write: cacheWriteSegment,
562
+ cache_hit: cacheHitSegment,
540
563
  session_name: sessionNameSegment,
541
564
  usage: usageSegment,
542
565
  };
@@ -0,0 +1,90 @@
1
+ import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { formatBytes } from "@oh-my-pi/pi-utils";
3
+ import { getTinyTitleModelSpec, type TinyTitleLocalModelKey } from "../../tiny/models";
4
+ import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
5
+ import { theme } from "../theme/theme";
6
+
7
+ const DEFAULT_BAR_WIDTH = 24;
8
+
9
+ function padLine(line: string, width: number): string {
10
+ const visible = visibleWidth(line);
11
+ return visible >= width ? truncateToWidth(line, width) : `${line}${" ".repeat(width - visible)}`;
12
+ }
13
+
14
+ function progressBar(progress: number | undefined, width: number): string {
15
+ const barWidth = Math.max(8, Math.min(DEFAULT_BAR_WIDTH, width));
16
+ if (progress === undefined) return theme.fg("muted", "░".repeat(barWidth));
17
+ const ratio = Math.max(0, Math.min(1, progress / 100));
18
+ const filled = Math.round(ratio * barWidth);
19
+ return `${theme.fg("accent", "█".repeat(filled))}${theme.fg("muted", "░".repeat(barWidth - filled))}`;
20
+ }
21
+
22
+ function currentFile(event: TinyTitleProgressEvent | undefined): string | undefined {
23
+ if (!event) return undefined;
24
+ if (event.file) return event.file.split("/").at(-1) ?? event.file;
25
+ if (event.files) {
26
+ let largestFile: string | undefined;
27
+ let largestLoaded = -1;
28
+ for (const file in event.files) {
29
+ const state = event.files[file];
30
+ if (state.loaded <= largestLoaded || state.loaded >= state.total) continue;
31
+ largestFile = file;
32
+ largestLoaded = state.loaded;
33
+ }
34
+ return largestFile?.split("/").at(-1) ?? largestFile;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ function statusLabel(event: TinyTitleProgressEvent | undefined): string {
40
+ if (!event) return "Preparing";
41
+ if (event.status === "error") return "Failed";
42
+ if (event.status === "ready") return "Ready";
43
+ if (event.status === "done") return "Downloaded";
44
+ if (event.status === "download") return "Downloading";
45
+ if (event.status === "progress" || event.status === "progress_total") return "Downloading";
46
+ return "Preparing";
47
+ }
48
+
49
+ function byteLabel(event: TinyTitleProgressEvent | undefined): string | undefined {
50
+ if (!event?.loaded || !event.total) return undefined;
51
+ return `${formatBytes(event.loaded)} / ${formatBytes(event.total)}`;
52
+ }
53
+
54
+ export class TinyTitleDownloadProgressComponent implements Component {
55
+ #modelKey: TinyTitleLocalModelKey;
56
+ #event: TinyTitleProgressEvent | undefined;
57
+
58
+ constructor(modelKey: TinyTitleLocalModelKey) {
59
+ this.#modelKey = modelKey;
60
+ }
61
+
62
+ update(event: TinyTitleProgressEvent): void {
63
+ this.#event = event;
64
+ }
65
+
66
+ isComplete(): boolean {
67
+ return this.#event?.status === "ready" || this.#event?.status === "error";
68
+ }
69
+
70
+ invalidate(): void {
71
+ // No cached state.
72
+ }
73
+
74
+ render(width: number): string[] {
75
+ width = Math.max(1, width);
76
+ const spec = getTinyTitleModelSpec(this.#modelKey);
77
+ const border = theme.fg("border", theme.boxSharp.horizontal.repeat(width));
78
+ const status = statusLabel(this.#event);
79
+ const file = currentFile(this.#event);
80
+ const pct =
81
+ this.#event?.progress === undefined ? "" : `${Math.floor(this.#event.progress).toString().padStart(3, " ")}%`;
82
+ const bytes = byteLabel(this.#event);
83
+ const title = `${theme.fg("accent", "Tiny model")} ${theme.fg("muted", status)} ${spec.label}`;
84
+ const details = [progressBar(this.#event?.progress, Math.max(8, width - 36)), pct, bytes, file]
85
+ .filter((part): part is string => Boolean(part))
86
+ .join(" ");
87
+
88
+ return [border, padLine(` ${title}`, width), padLine(` ${details}`, width), border];
89
+ }
90
+ }
@@ -0,0 +1,12 @@
1
+ Tired of typing "keep going"? Just send a '.'
2
+ You can /btw to ask a side question
3
+ Ctrl+D can be used to exit, but with your draft saved!
4
+ Find out which model you emotionally abuse the most with `omp stats`
5
+ Try task isolation to create CoW worktrees
6
+ Your LLM can call an LLM using `llm(x...)`. Have a big batch of tasks? Ask clanker to use it!
7
+ Next time you see spaghet try: "omp, create a TTSR rule that will prevent this pattern, use omp://"
8
+ Did you know? Each kitty/tmux split keeps its own session — `omp -c` resumes the right one
9
+ Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
10
+ Say `orchestrate` in your message to drive a multi-phase task with parallel subagents — watch it glow as you type
11
+ Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
12
+ Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../tools/json-tree";
32
32
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
33
33
  import { toolRenderers } from "../../tools/renderers";
34
+ import { TODO_WRITE_STRIKE_TOTAL_FRAMES } from "../../tools/todo-write";
34
35
  import { renderStatusLine } from "../../tui";
35
36
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
36
37
  import { renderDiff } from "./diff";
@@ -163,6 +164,8 @@ export class ToolExecutionComponent extends Container {
163
164
  // Spinner animation for partial task results
164
165
  #spinnerFrame?: number;
165
166
  #spinnerInterval?: NodeJS.Timeout;
167
+ // Todo write completion strikethrough reveal animation
168
+ #todoStrikeInterval?: NodeJS.Timeout;
166
169
  // Track if args are still being streamed (for edit/write spinner)
167
170
  #argsComplete = false;
168
171
  #renderState: {
@@ -251,12 +254,24 @@ export class ToolExecutionComponent extends Container {
251
254
  effectiveArgs = args;
252
255
  }
253
256
 
254
- // Coalesce duplicate computes for identical args.
257
+ // Coalesce duplicate computes for identical args. The key pairs the
258
+ // streaming flag with a content hash: the final (args-complete) pass
259
+ // computes an untrimmed diff and must run even when the payload is
260
+ // byte-identical to the last streamed chunk — only `isStreaming` differs,
261
+ // and it flips the trailing-line trim. Without the flag a single-line edit
262
+ // whose trailing payload line never gets a newline stays stuck on the
263
+ // trimmed "no changes" streaming preview and renders no diff. Hashing keeps
264
+ // the retained key tiny instead of holding the whole serialized blob.
265
+ const streamingState = this.#argsComplete ? "final" : "stream";
255
266
  let argsKey: string;
256
267
  try {
257
- argsKey = JSON.stringify(effectiveArgs);
268
+ argsKey = `${streamingState}:${Bun.hash(JSON.stringify(effectiveArgs))}`;
258
269
  } catch {
259
- argsKey = String(Date.now());
270
+ // effectiveArgs isn't JSON-serializable (exotic value in tool args).
271
+ // The raw streamed JSON is a plain string, so hash that instead of a
272
+ // timestamp — a deterministic key keeps the dedup cache working
273
+ // instead of recomputing (and re-reading the file) on every render.
274
+ argsKey = `${streamingState}:partial:${Bun.hash(partialJson ?? "")}`;
260
275
  }
261
276
  if (argsKey === this.#editDiffLastArgsKey) return;
262
277
  this.#editDiffLastArgsKey = argsKey;
@@ -304,6 +319,7 @@ export class ToolExecutionComponent extends Container {
304
319
  this.#argsComplete = true;
305
320
  }
306
321
  this.#updateSpinnerAnimation();
322
+ this.#updateTodoStrikeAnimation();
307
323
  this.#updateDisplay();
308
324
  // Convert non-PNG images to PNG for Kitty protocol (async)
309
325
  this.#maybeConvertImagesForKitty();
@@ -379,6 +395,43 @@ export class ToolExecutionComponent extends Container {
379
395
  }
380
396
  }
381
397
 
398
+ #updateTodoStrikeAnimation(): void {
399
+ if (this.#toolName !== "todo_write" || this.#isPartial || this.#result?.isError) {
400
+ this.#stopTodoStrikeAnimation();
401
+ return;
402
+ }
403
+ const completedTasks = (this.#result?.details as { completedTasks?: unknown[] } | undefined)?.completedTasks;
404
+ if (!completedTasks || completedTasks.length === 0) {
405
+ this.#stopTodoStrikeAnimation();
406
+ return;
407
+ }
408
+ if (this.#todoStrikeInterval) return;
409
+
410
+ this.#spinnerFrame = 0;
411
+ this.#renderState.spinnerFrame = 0;
412
+ this.#todoStrikeInterval = setInterval(() => {
413
+ const nextFrame = (this.#spinnerFrame ?? 0) + 1;
414
+ if (nextFrame > TODO_WRITE_STRIKE_TOTAL_FRAMES) {
415
+ this.#stopTodoStrikeAnimation();
416
+ } else {
417
+ this.#spinnerFrame = nextFrame;
418
+ this.#renderState.spinnerFrame = nextFrame;
419
+ }
420
+ this.#ui.requestRender();
421
+ }, 65);
422
+ }
423
+
424
+ #stopTodoStrikeAnimation(): void {
425
+ if (this.#todoStrikeInterval) {
426
+ clearInterval(this.#todoStrikeInterval);
427
+ this.#todoStrikeInterval = undefined;
428
+ }
429
+ if (!this.#spinnerInterval) {
430
+ this.#spinnerFrame = undefined;
431
+ this.#renderState.spinnerFrame = undefined;
432
+ }
433
+ }
434
+
382
435
  /**
383
436
  * Stop spinner animation and cleanup resources.
384
437
  */
@@ -388,6 +441,7 @@ export class ToolExecutionComponent extends Container {
388
441
  this.#spinnerInterval = undefined;
389
442
  this.#spinnerFrame = undefined;
390
443
  }
444
+ this.#stopTodoStrikeAnimation();
391
445
  this.#editDiffAbort?.abort();
392
446
  this.#editDiffAbort = undefined;
393
447
  }
@@ -428,6 +482,11 @@ export class ToolExecutionComponent extends Container {
428
482
  const inline = Boolean((tool as { inline?: boolean }).inline);
429
483
  this.#contentBox.setBgFn(inline ? undefined : bgFn);
430
484
  this.#contentBox.clear();
485
+ // Mirror the built-in renderer branch so custom renderers (notably the
486
+ // task tool, whose live instance routes through here) receive the same
487
+ // render context — e.g. the `hasResult` flag that suppresses the task
488
+ // call preview once result lines exist.
489
+ this.#renderState.renderContext = this.#buildRenderContext();
431
490
 
432
491
  // Render call component
433
492
  const shouldRenderCall = !this.#result || !mergeCallAndResult;
@@ -696,6 +755,11 @@ export class ToolExecutionComponent extends Container {
696
755
  context.output = output;
697
756
  context.expanded = this.#expanded;
698
757
  context.previewLines = EVAL_DEFAULT_PREVIEW_LINES;
758
+ } else if (this.#toolName === "task") {
759
+ // Once a result snapshot exists the task renderer's `renderResult`
760
+ // draws every dispatched agent as a progress/result line, so tell
761
+ // `renderCall` to drop its duplicate streaming preview list.
762
+ context.hasResult = Boolean(this.#result);
699
763
  } else if (isEditLikeToolName(this.#toolName)) {
700
764
  context.editMode = this.#editMode;
701
765
  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);
@@ -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();
@@ -3,6 +3,7 @@ import { type AgentMessage, ThinkingLevel } 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
5
  import { isSettingsInitialized, settings } from "../../config/settings";
6
+ import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
6
7
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
7
8
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
8
9
  import { theme } from "../../modes/theme/theme";
@@ -10,6 +11,9 @@ import type { InteractiveModeContext } from "../../modes/types";
10
11
  import type { AgentSessionEvent } from "../../session/agent-session";
11
12
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
12
13
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
14
+ import { isTinyTitleLocalModelKey } from "../../tiny/models";
15
+ import { tinyTitleClient } from "../../tiny/title-client";
16
+ import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
13
17
  import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
14
18
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
15
19
  import { ensureSupportedImageInput } from "../../utils/image-loading";
@@ -24,9 +28,61 @@ function isExpandable(obj: unknown): obj is Expandable {
24
28
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
25
29
  }
26
30
 
31
+ const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
32
+ // A cached model fires its file-load events in a short burst and then goes silent
33
+ // while onnxruntime builds the session; a genuine download keeps streaming progress
34
+ // events for seconds. Only reveal the bar once a still-incomplete event arrives after
35
+ // this grace window, so an already-downloaded model never flashes the bar.
36
+ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
37
+
27
38
  export class InputController {
28
39
  constructor(private ctx: InteractiveModeContext) {}
29
40
 
41
+ #showTinyTitleDownloadProgress(modelKey: string): void {
42
+ if (!isTinyTitleLocalModelKey(modelKey) || this.ctx.isBackgrounded) return;
43
+ const component = new TinyTitleDownloadProgressComponent(modelKey);
44
+ let added = false;
45
+ let disposed = false;
46
+ let removeTimer: NodeJS.Timeout | undefined;
47
+ const remove = (): void => {
48
+ if (disposed) return;
49
+ disposed = true;
50
+ unsubscribe();
51
+ if (removeTimer) {
52
+ clearTimeout(removeTimer);
53
+ removeTimer = undefined;
54
+ }
55
+ if (added) {
56
+ this.ctx.chatContainer.removeChild(component);
57
+ this.ctx.ui.requestRender();
58
+ }
59
+ };
60
+ const scheduleRemove = (): void => {
61
+ if (removeTimer) clearTimeout(removeTimer);
62
+ removeTimer = setTimeout(remove, TINY_TITLE_PROGRESS_DONE_TTL_MS);
63
+ removeTimer.unref?.();
64
+ };
65
+ let revealAt = 0;
66
+ const update = (event: TinyTitleProgressEvent): void => {
67
+ if (disposed || event.modelKey !== modelKey) return;
68
+ component.update(event);
69
+ if (revealAt === 0) revealAt = performance.now() + TINY_TITLE_PROGRESS_REVEAL_DELAY_MS;
70
+ const complete = component.isComplete();
71
+ // Reveal only for a download still in flight past the grace window. Cache hits
72
+ // either complete or fall silent (onnx init emits no events) before this fires.
73
+ if (!added && !complete && performance.now() >= revealAt) {
74
+ this.ctx.chatContainer.addChild(component);
75
+ added = true;
76
+ }
77
+ if (added) this.ctx.ui.requestRender();
78
+ if (complete) {
79
+ if (added) scheduleRemove();
80
+ else remove();
81
+ }
82
+ };
83
+ const unsubscribe = tinyTitleClient.onProgress(update);
84
+ }
85
+
30
86
  setupKeyHandlers(): void {
31
87
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
32
88
  this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
@@ -329,6 +385,7 @@ export class InputController {
329
385
  // Generate session title on first message
330
386
  const hasUserMessages = this.ctx.session.messages.some((m: AgentMessage) => m.role === "user");
331
387
  if (!hasUserMessages && !this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE) {
388
+ this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
332
389
  const registry = this.ctx.session.modelRegistry;
333
390
  generateSessionTitle(
334
391
  text,
@@ -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
+ }