@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
@@ -0,0 +1,91 @@
1
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
+ import * as z from "zod/v4";
3
+ import retainDescription from "../prompts/tools/retain.md" with { type: "text" };
4
+ import type { ToolSession } from ".";
5
+
6
+ const memoryRetainSchema = z.object({
7
+ items: z
8
+ .array(
9
+ z.object({
10
+ content: z.string().describe("information to remember"),
11
+ context: z.string().describe("source context").optional(),
12
+ }),
13
+ )
14
+ .min(1)
15
+ .describe("memories to retain"),
16
+ });
17
+
18
+ export type MemoryRetainParams = z.infer<typeof memoryRetainSchema>;
19
+ export class MemoryRetainTool implements AgentTool<typeof memoryRetainSchema> {
20
+ readonly name = "retain";
21
+ readonly approval = "read" as const;
22
+ readonly label = "Retain";
23
+ readonly description = retainDescription;
24
+ readonly parameters = memoryRetainSchema;
25
+ readonly strict = true;
26
+ readonly loadMode = "discoverable";
27
+ readonly summary = "Store important facts in long-term memory";
28
+
29
+ constructor(private readonly session: ToolSession) {}
30
+
31
+ static createIf(session: ToolSession): MemoryRetainTool | null {
32
+ const backend = session.settings.get("memory.backend");
33
+ if (backend !== "hindsight" && backend !== "mnemosyne") return null;
34
+ return new MemoryRetainTool(session);
35
+ }
36
+
37
+ async execute(_id: string, params: MemoryRetainParams): Promise<AgentToolResult> {
38
+ const backend = this.session.settings.get("memory.backend");
39
+ if (backend === "mnemosyne") {
40
+ const state = this.session.getMnemosyneSessionState?.();
41
+ if (!state) {
42
+ throw new Error("Mnemosyne backend is not initialised for this session.");
43
+ }
44
+
45
+ for (const item of params.items) {
46
+ state.rememberScoped(item.content, {
47
+ source: "coding-agent-retain",
48
+ importance: 0.75,
49
+ metadata: {
50
+ session_id: state.sessionId,
51
+ cwd: state.session.sessionManager.getCwd(),
52
+ context: item.context ?? null,
53
+ tool: "retain",
54
+ },
55
+ scope: "bank",
56
+ extract: true,
57
+ extractEntities: true,
58
+ veracity: "tool",
59
+ memoryType: "fact",
60
+ });
61
+ }
62
+
63
+ const count = params.items.length;
64
+ const noun = count === 1 ? "memory" : "memories";
65
+ return {
66
+ content: [{ type: "text", text: `${count} ${noun} stored.` }],
67
+ details: { count },
68
+ };
69
+ }
70
+
71
+ const state = this.session.getHindsightSessionState?.();
72
+ if (!state) {
73
+ throw new Error("Hindsight backend is not initialised for this session.");
74
+ }
75
+
76
+ // Push every item onto the session-owned queue and return immediately.
77
+ // The queue flushes either when it reaches its batch threshold or when
78
+ // its debounce timer fires. If the eventual batch fails, the queue
79
+ // surfaces a UI-only warning notice — the LLM is not informed.
80
+ for (const item of params.items) {
81
+ state.enqueueRetain(item.content, item.context);
82
+ }
83
+
84
+ const count = params.items.length;
85
+ const noun = count === 1 ? "memory" : "memories";
86
+ return {
87
+ content: [{ type: "text", text: `${count} ${noun} queued.` }],
88
+ details: { count },
89
+ };
90
+ }
91
+ }
@@ -16,13 +16,13 @@ import { astEditToolRenderer } from "./ast-edit";
16
16
  import { astGrepToolRenderer } from "./ast-grep";
17
17
  import { bashToolRenderer } from "./bash";
18
18
  import { browserToolRenderer } from "./browser/render";
19
- import { calculatorToolRenderer } from "./calculator";
20
19
  import { debugToolRenderer } from "./debug";
21
20
  import { evalToolRenderer } from "./eval";
22
21
  import { findToolRenderer } from "./find";
23
22
  import { githubToolRenderer } from "./gh-renderer";
24
23
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
25
24
  import { jobToolRenderer } from "./job";
25
+ import { recallToolRenderer, reflectToolRenderer, retainToolRenderer } from "./memory-render";
26
26
  import { readToolRenderer } from "./read";
27
27
  import { recipeToolRenderer } from "./recipe/render";
28
28
  import { resolveToolRenderer } from "./resolve";
@@ -54,7 +54,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
54
54
  recipe: recipeToolRenderer as ToolRenderer,
55
55
  debug: debugToolRenderer as ToolRenderer,
56
56
  eval: evalToolRenderer as ToolRenderer,
57
- calc: calculatorToolRenderer as ToolRenderer,
58
57
  edit: editToolRenderer as ToolRenderer,
59
58
  apply_patch: editToolRenderer as ToolRenderer,
60
59
  find: findToolRenderer as ToolRenderer,
@@ -64,6 +63,9 @@ export const toolRenderers: Record<string, ToolRenderer> = {
64
63
  read: readToolRenderer as ToolRenderer,
65
64
  job: jobToolRenderer as ToolRenderer,
66
65
  resolve: resolveToolRenderer as ToolRenderer,
66
+ retain: retainToolRenderer as ToolRenderer,
67
+ recall: recallToolRenderer as ToolRenderer,
68
+ reflect: reflectToolRenderer as ToolRenderer,
67
69
  search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
68
70
  ssh: sshToolRenderer as ToolRenderer,
69
71
  task: taskToolRenderer as ToolRenderer,
@@ -35,9 +35,15 @@ export interface TodoPhase {
35
35
  tasks: TodoItem[];
36
36
  }
37
37
 
38
+ export interface TodoCompletionTransition {
39
+ phase: string;
40
+ content: string;
41
+ }
42
+
38
43
  export interface TodoWriteToolDetails {
39
44
  phases: TodoPhase[];
40
45
  storage: "session" | "memory";
46
+ completedTasks?: TodoCompletionTransition[];
41
47
  }
42
48
 
43
49
  // =============================================================================
@@ -97,6 +103,31 @@ function clonePhases(phases: TodoPhase[]): TodoPhase[] {
97
103
  return phases.map(phase => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) }));
98
104
  }
99
105
 
106
+ function todoTransitionKey(phase: string, content: string): string {
107
+ return `${phase}\u0000${content}`;
108
+ }
109
+
110
+ function getCompletionTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoCompletionTransition[] {
111
+ const previousStatuses = new Map<string, TodoStatus>();
112
+ for (const phase of previous) {
113
+ for (const task of phase.tasks) {
114
+ previousStatuses.set(todoTransitionKey(phase.name, task.content), task.status);
115
+ }
116
+ }
117
+
118
+ const transitions: TodoCompletionTransition[] = [];
119
+ for (const phase of updated) {
120
+ for (const task of phase.tasks) {
121
+ if (task.status !== "completed") continue;
122
+ const previousStatus = previousStatuses.get(todoTransitionKey(phase.name, task.content));
123
+ if (previousStatus && previousStatus !== "completed") {
124
+ transitions.push({ phase: phase.name, content: task.content });
125
+ }
126
+ }
127
+ }
128
+ return transitions;
129
+ }
130
+
100
131
  function normalizeInProgressTask(phases: TodoPhase[]): void {
101
132
  const orderedTasks = phases.flatMap(phase => phase.tasks);
102
133
  if (orderedTasks.length === 0) return;
@@ -577,13 +608,16 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
577
608
  _context?: AgentToolContext,
578
609
  ): Promise<AgentToolResult<TodoWriteToolDetails>> {
579
610
  const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
580
- const { phases: updated, errors } = applyParams(previousPhases, params);
611
+ const { phases: updated, errors } = applyParams(clonePhases(previousPhases), params);
612
+ const completedTasks = getCompletionTransitions(previousPhases, updated);
581
613
  this.session.setTodoPhases?.(updated);
582
614
  const storage = this.session.getSessionFile() ? "session" : "memory";
615
+ const details: TodoWriteToolDetails = { phases: updated, storage };
616
+ if (completedTasks.length > 0) details.completedTasks = completedTasks;
583
617
 
584
618
  return {
585
619
  content: [{ type: "text", text: formatSummary(updated, errors) }],
586
- details: { phases: updated, storage },
620
+ details,
587
621
  isError: errors.length > 0 ? true : undefined,
588
622
  };
589
623
  }
@@ -667,16 +701,55 @@ function noteMarker(count: number, uiTheme: Theme): string {
667
701
  return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
668
702
  }
669
703
 
670
- function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
704
+ export const TODO_WRITE_STRIKE_HOLD_FRAMES = 2;
705
+ export const TODO_WRITE_STRIKE_REVEAL_FRAMES = 12;
706
+ export const TODO_WRITE_STRIKE_TOTAL_FRAMES = TODO_WRITE_STRIKE_HOLD_FRAMES + TODO_WRITE_STRIKE_REVEAL_FRAMES;
707
+ const EMPTY_COMPLETION_KEYS = new Set<string>();
708
+ const STRIKE_START = "\x1b[9m";
709
+ const STRIKE_END = "\x1b[29m";
710
+
711
+ function strikethroughText(text: string): string {
712
+ return `${STRIKE_START}${text}${STRIKE_END}`;
713
+ }
714
+
715
+ function partialStrikethrough(text: string, visibleChars: number): string {
716
+ if (visibleChars <= 0) return text;
717
+ const chars = [...text];
718
+ if (visibleChars >= chars.length) return strikethroughText(text);
719
+ return `${strikethroughText(chars.slice(0, visibleChars).join(""))}${chars.slice(visibleChars).join("")}`;
720
+ }
721
+
722
+ function strikeRevealCount(text: string, frame: number | undefined): number | undefined {
723
+ if (frame === undefined) return undefined;
724
+ if (frame <= TODO_WRITE_STRIKE_HOLD_FRAMES) return 0;
725
+ const chars = [...text];
726
+ if (chars.length === 0) return undefined;
727
+ const revealFrame = Math.min(frame - TODO_WRITE_STRIKE_HOLD_FRAMES, TODO_WRITE_STRIKE_REVEAL_FRAMES);
728
+ return Math.ceil((chars.length * revealFrame) / TODO_WRITE_STRIKE_REVEAL_FRAMES);
729
+ }
730
+
731
+ function formatTodoLine(
732
+ item: TodoItem,
733
+ uiTheme: Theme,
734
+ prefix: string,
735
+ completionKeys: Set<string>,
736
+ frame: number | undefined,
737
+ ): string {
671
738
  const checkbox = uiTheme.checkbox;
672
739
  const marker = noteMarker(item.notes?.length ?? 0, uiTheme);
673
740
  switch (item.status) {
674
- case "completed":
675
- return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`) + marker;
741
+ case "completed": {
742
+ const revealCount = completionKeys.has(item.content) ? strikeRevealCount(item.content, frame) : undefined;
743
+ const content =
744
+ revealCount === undefined
745
+ ? strikethroughText(item.content)
746
+ : partialStrikethrough(item.content, revealCount);
747
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${content}`) + marker;
748
+ }
676
749
  case "in_progress":
677
750
  return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
678
751
  case "abandoned":
679
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`) + marker;
752
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`) + marker;
680
753
  default:
681
754
  return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
682
755
  }
@@ -722,6 +795,16 @@ export const todoWriteToolRenderer = {
722
795
  _args?: TodoWriteRenderArgs,
723
796
  ): Component {
724
797
  const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
798
+ const completedTasks = result.details?.completedTasks ?? [];
799
+ const completionKeysByPhase = new Map<string, Set<string>>();
800
+ for (const task of completedTasks) {
801
+ let keys = completionKeysByPhase.get(task.phase);
802
+ if (!keys) {
803
+ keys = new Set<string>();
804
+ completionKeysByPhase.set(task.phase, keys);
805
+ }
806
+ keys.add(task.content);
807
+ }
725
808
  const allTasks = phases.flatMap(phase => phase.tasks);
726
809
  const header = renderStatusLine(
727
810
  { icon: "success", title: "Todo Write", meta: [`${allTasks.length} tasks`] },
@@ -732,29 +815,45 @@ export const todoWriteToolRenderer = {
732
815
  return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
733
816
  }
734
817
 
735
- const { expanded } = options;
736
- const lines: string[] = [header];
737
- for (let p = 0; p < phases.length; p++) {
738
- const phase = phases[p];
739
- if (phases.length > 1) {
740
- lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
741
- }
742
- const treeLines = renderTreeList(
743
- {
744
- items: phase.tasks,
745
- expanded,
746
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
747
- itemType: "todo",
748
- renderItem: todo => formatTodoLine(todo, uiTheme, ""),
749
- },
750
- uiTheme,
751
- );
752
- for (const line of treeLines) {
753
- lines.push(` ${line}`);
754
- }
755
- }
756
- lines.push(...renderNoteAttachments(phases, uiTheme));
757
- return new Text(lines.join("\n"), 0, 0);
818
+ let cachedKey: string | undefined;
819
+ let cachedLines: string[] | undefined;
820
+ return {
821
+ invalidate(): void {
822
+ cachedKey = undefined;
823
+ cachedLines = undefined;
824
+ },
825
+ render(width: number): string[] {
826
+ const { expanded, spinnerFrame } = options;
827
+ const key = `${expanded ? 1 : 0}:${spinnerFrame ?? -1}:${width}`;
828
+ if (cachedKey === key && cachedLines) return cachedLines;
829
+
830
+ const lines: string[] = [header];
831
+ for (let p = 0; p < phases.length; p++) {
832
+ const phase = phases[p];
833
+ if (phases.length > 1) {
834
+ lines.push(uiTheme.fg("accent", chalk.bold(` ${formatPhaseDisplayName(phase.name, p + 1)}`)));
835
+ }
836
+ const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
837
+ const treeLines = renderTreeList(
838
+ {
839
+ items: phase.tasks,
840
+ expanded,
841
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
842
+ itemType: "todo",
843
+ renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
844
+ },
845
+ uiTheme,
846
+ );
847
+ for (const line of treeLines) {
848
+ lines.push(` ${line}`);
849
+ }
850
+ }
851
+ lines.push(...renderNoteAttachments(phases, uiTheme));
852
+ cachedKey = key;
853
+ cachedLines = lines;
854
+ return lines;
855
+ },
856
+ };
758
857
  },
759
858
  mergeCallAndResult: true,
760
859
  };
@@ -12,6 +12,7 @@ export class ToolResultBuilder<TDetails extends DetailsWithMeta> {
12
12
  #details: TDetails;
13
13
  #meta = outputMeta();
14
14
  #content: ToolContent = [];
15
+ #isError = false;
15
16
 
16
17
  constructor(details?: TDetails) {
17
18
  this.#details = details ?? ({} as TDetails);
@@ -67,6 +68,12 @@ export class ToolResultBuilder<TDetails extends DetailsWithMeta> {
67
68
  return this;
68
69
  }
69
70
 
71
+ /** Flag the result as a non-throwing failure (agent-loop surfaces it as a tool error). */
72
+ error(value = true): this {
73
+ this.#isError = value;
74
+ return this;
75
+ }
76
+
70
77
  done(): AgentToolResult<TDetails> {
71
78
  const meta = this.#meta.get();
72
79
  if (meta) {
@@ -77,6 +84,7 @@ export class ToolResultBuilder<TDetails extends DetailsWithMeta> {
77
84
  return {
78
85
  content: this.#content,
79
86
  details: hasDetails ? this.#details : undefined,
87
+ ...(this.#isError ? { isError: true } : {}),
80
88
  };
81
89
  }
82
90
  }
@@ -9,13 +9,16 @@ import type { ModelRegistry } from "../config/model-registry";
9
9
  import { resolveRoleSelection } from "../config/model-resolver";
10
10
  import type { Settings } from "../config/settings";
11
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
12
+ import { ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
13
+ import { formatTitleUserMessage, normalizeGeneratedTitle } from "../tiny/text";
14
+ import { tinyTitleClient } from "../tiny/title-client";
12
15
 
13
16
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
14
17
 
15
18
  const DEFAULT_TERMINAL_TITLE = "π";
16
19
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
17
20
 
18
- const MAX_INPUT_CHARS = 2000;
21
+ export const TITLE_LOCAL_FALLBACK_DELAY_MS = 10_000;
19
22
  const TITLE_MAX_TOKENS = 30;
20
23
  const REASONING_SAFE_MAX_TOKENS = 1024;
21
24
  const SET_TITLE_TOOL_NAME = "set_title";
@@ -48,6 +51,78 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
48
51
  return undefined;
49
52
  }
50
53
 
54
+ export async function raceFirstNonNull<T>(
55
+ primary: Promise<T | null>,
56
+ startFallback: () => Promise<T | null>,
57
+ delayMs: number = TITLE_LOCAL_FALLBACK_DELAY_MS,
58
+ onPrimaryWinAfterFallback?: () => void,
59
+ ): Promise<T | null> {
60
+ const { promise, resolve } = Promise.withResolvers<T | null>();
61
+ let resolved = false;
62
+ let primarySettled = false;
63
+ let fallbackStarted = false;
64
+ let fallbackSettled = false;
65
+
66
+ const resolveOnce = (value: T | null): void => {
67
+ if (resolved) return;
68
+ resolved = true;
69
+ resolve(value);
70
+ };
71
+ const maybeResolveNull = (): void => {
72
+ if (primarySettled && fallbackStarted && fallbackSettled) resolveOnce(null);
73
+ };
74
+ const startFallbackOnce = (): void => {
75
+ if (fallbackStarted || resolved) return;
76
+ fallbackStarted = true;
77
+ let fallback: Promise<T | null>;
78
+ try {
79
+ fallback = startFallback();
80
+ } catch {
81
+ fallbackSettled = true;
82
+ maybeResolveNull();
83
+ return;
84
+ }
85
+ void fallback.then(
86
+ value => {
87
+ fallbackSettled = true;
88
+ if (value !== null) resolveOnce(value);
89
+ else maybeResolveNull();
90
+ },
91
+ () => {
92
+ fallbackSettled = true;
93
+ maybeResolveNull();
94
+ },
95
+ );
96
+ };
97
+
98
+ const timer = setTimeout(startFallbackOnce, delayMs);
99
+ void primary.then(
100
+ value => {
101
+ primarySettled = true;
102
+ clearTimeout(timer);
103
+ if (value !== null) {
104
+ if (fallbackStarted) onPrimaryWinAfterFallback?.();
105
+ resolveOnce(value);
106
+ return;
107
+ }
108
+ startFallbackOnce();
109
+ maybeResolveNull();
110
+ },
111
+ () => {
112
+ primarySettled = true;
113
+ clearTimeout(timer);
114
+ startFallbackOnce();
115
+ maybeResolveNull();
116
+ },
117
+ );
118
+
119
+ try {
120
+ return await promise;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
51
126
  /**
52
127
  * Generate a title for a session based on the first user message.
53
128
  *
@@ -68,6 +143,41 @@ export async function generateSessionTitle(
68
143
  sessionId?: string,
69
144
  currentModel?: Model<Api>,
70
145
  metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
146
+ ): Promise<string | null> {
147
+ const tinyModel = settings.get("providers.tinyModel");
148
+ if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
149
+ return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
150
+ }
151
+
152
+ const onlineAbortController = new AbortController();
153
+ const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
154
+ title => title || null,
155
+ () => null,
156
+ );
157
+ const startOnline = (): Promise<string | null> =>
158
+ generateTitleOnline(
159
+ firstMessage,
160
+ registry,
161
+ settings,
162
+ sessionId,
163
+ currentModel,
164
+ metadataResolver,
165
+ onlineAbortController.signal,
166
+ );
167
+
168
+ return raceFirstNonNull(localTitle, startOnline, TITLE_LOCAL_FALLBACK_DELAY_MS, () => {
169
+ onlineAbortController.abort();
170
+ });
171
+ }
172
+
173
+ export async function generateTitleOnline(
174
+ firstMessage: string,
175
+ registry: ModelRegistry,
176
+ settings: Settings,
177
+ sessionId?: string,
178
+ currentModel?: Model<Api>,
179
+ metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
180
+ signal?: AbortSignal,
71
181
  ): Promise<string | null> {
72
182
  const model = getTitleModel(registry, settings, currentModel);
73
183
  if (!model) {
@@ -75,12 +185,7 @@ export async function generateSessionTitle(
75
185
  return null;
76
186
  }
77
187
 
78
- // Truncate message if too long
79
- const truncatedMessage =
80
- firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}…` : firstMessage;
81
- const userMessage = `<user-message>
82
- ${truncatedMessage}
83
- </user-message>`;
188
+ const userMessage = formatTitleUserMessage(firstMessage);
84
189
 
85
190
  const apiKey = await registry.getApiKey(model, sessionId);
86
191
  if (!apiKey) {
@@ -122,6 +227,7 @@ ${truncatedMessage}
122
227
  disableReasoning: true,
123
228
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
124
229
  metadata,
230
+ signal,
125
231
  },
126
232
  );
127
233
 
@@ -134,7 +240,7 @@ ${truncatedMessage}
134
240
  return null;
135
241
  }
136
242
 
137
- const title = extractGeneratedTitle(response.content);
243
+ const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
138
244
 
139
245
  logger.debug("title-generator: response", {
140
246
  model: request.model,
@@ -143,11 +249,7 @@ ${truncatedMessage}
143
249
  stopReason: response.stopReason,
144
250
  });
145
251
 
146
- if (!title) {
147
- return null;
148
- }
149
-
150
- return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
252
+ return title;
151
253
  } catch (err) {
152
254
  logger.debug("title-generator: error", {
153
255
  model: request.model,
@@ -1,77 +0,0 @@
1
- import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
- import type { Component } from "@oh-my-pi/pi-tui";
3
- import * as z from "zod/v4";
4
- import type { RenderResultOptions } from "../extensibility/custom-tools/types";
5
- import type { Theme } from "../modes/theme/theme";
6
- import type { ToolSession } from ".";
7
- declare const calculatorSchema: z.ZodObject<{
8
- calculations: z.ZodArray<z.ZodObject<{
9
- expression: z.ZodString;
10
- prefix: z.ZodString;
11
- suffix: z.ZodString;
12
- }, z.core.$strip>>;
13
- }, z.core.$strip>;
14
- export interface CalculatorToolDetails {
15
- results: Array<{
16
- expression: string;
17
- value: number;
18
- output: string;
19
- }>;
20
- }
21
- type CalculatorParams = z.infer<typeof calculatorSchema>;
22
- /**
23
- * Calculator tool for evaluating mathematical expressions.
24
- *
25
- * Supports decimal, hex (0x), binary (0b), octal (0o) literals,
26
- * standard arithmetic operators, and parentheses.
27
- */
28
- export declare class CalculatorTool implements AgentTool<typeof calculatorSchema, CalculatorToolDetails> {
29
- readonly name = "calc";
30
- readonly approval: "read";
31
- readonly label = "Calc";
32
- readonly summary = "Evaluate a mathematical expression";
33
- readonly loadMode = "discoverable";
34
- readonly description: string;
35
- readonly parameters: z.ZodObject<{
36
- calculations: z.ZodArray<z.ZodObject<{
37
- expression: z.ZodString;
38
- prefix: z.ZodString;
39
- suffix: z.ZodString;
40
- }, z.core.$strip>>;
41
- }, z.core.$strip>;
42
- readonly strict = true;
43
- constructor(_session: ToolSession);
44
- execute(_toolCallId: string, { calculations }: CalculatorParams, signal?: AbortSignal): Promise<AgentToolResult<CalculatorToolDetails>>;
45
- }
46
- interface CalculatorRenderArgs {
47
- calculations?: Array<{
48
- expression: string;
49
- prefix?: string;
50
- suffix?: string;
51
- }>;
52
- }
53
- /**
54
- * TUI renderer for calculator tool calls and results.
55
- * Handles both collapsed (preview) and expanded (full) display modes.
56
- */
57
- export declare const calculatorToolRenderer: {
58
- /**
59
- * Render the tool call header showing the first expression and count.
60
- * Format: "Calc <expression> (N calcs)"
61
- */
62
- renderCall(args: CalculatorRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component;
63
- /**
64
- * Render calculation results as a tree list.
65
- * Collapsed mode shows first N items with expand hint; expanded shows all.
66
- */
67
- renderResult(result: {
68
- content: Array<{
69
- type: string;
70
- text?: string;
71
- }>;
72
- details?: CalculatorToolDetails;
73
- isError?: boolean;
74
- }, options: RenderResultOptions, uiTheme: Theme, args?: CalculatorRenderArgs): Component;
75
- mergeCallAndResult: boolean;
76
- };
77
- export {};
@@ -1,10 +0,0 @@
1
- Performs basic calculations.
2
-
3
- <instruction>
4
- - Supports +, -, *, /, %, ** and parentheses
5
- - Supports decimal, hex (0x), binary (0b), and octal (0o) literals
6
- </instruction>
7
-
8
- <output>
9
- Returns each calculation result with its prefix and suffix applied.
10
- </output>