@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.1

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 (172) hide show
  1. package/CHANGELOG.md +113 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +15 -0
  7. package/dist/types/config/settings-schema.d.ts +38 -24
  8. package/dist/types/debug/protocol-probe.d.ts +38 -0
  9. package/dist/types/debug/terminal-info.d.ts +34 -0
  10. package/dist/types/exa/mcp-client.d.ts +2 -1
  11. package/dist/types/export/html/template.generated.d.ts +1 -1
  12. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  14. package/dist/types/extensibility/shared-events.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  22. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  23. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  25. package/dist/types/modes/components/session-selector.d.ts +8 -3
  26. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  27. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  28. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  29. package/dist/types/modes/interactive-mode.d.ts +2 -1
  30. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  31. package/dist/types/modes/theme/theme.d.ts +20 -1
  32. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  33. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  34. package/dist/types/session/agent-session.d.ts +5 -2
  35. package/dist/types/session/history-storage.d.ts +3 -4
  36. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  37. package/dist/types/session/messages.d.ts +1 -0
  38. package/dist/types/session/redis-session-storage.d.ts +12 -85
  39. package/dist/types/session/session-manager.d.ts +21 -0
  40. package/dist/types/session/session-storage.d.ts +5 -7
  41. package/dist/types/session/sql-session-storage.d.ts +16 -85
  42. package/dist/types/slash-commands/types.d.ts +17 -4
  43. package/dist/types/task/executor.d.ts +9 -0
  44. package/dist/types/task/index.d.ts +3 -1
  45. package/dist/types/telemetry-export.d.ts +19 -0
  46. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  47. package/dist/types/tiny/text.d.ts +17 -0
  48. package/dist/types/tools/ask.d.ts +1 -0
  49. package/dist/types/tools/index.d.ts +4 -2
  50. package/dist/types/tools/path-utils.d.ts +1 -1
  51. package/dist/types/tools/search.d.ts +2 -2
  52. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  53. package/dist/types/utils/session-color.d.ts +7 -2
  54. package/dist/types/web/search/index.d.ts +1 -1
  55. package/dist/types/web/search/provider.d.ts +2 -2
  56. package/dist/types/web/search/providers/base.d.ts +14 -0
  57. package/dist/types/web/search/providers/exa.d.ts +9 -0
  58. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  59. package/dist/types/web/search/types.d.ts +66 -1
  60. package/package.json +15 -9
  61. package/scripts/build-binary.ts +12 -0
  62. package/src/capability/skill.ts +7 -0
  63. package/src/cli/args.ts +1 -1
  64. package/src/cli/session-picker.ts +1 -0
  65. package/src/cli/update-cli.ts +54 -2
  66. package/src/commands/completions.ts +1 -1
  67. package/src/config/append-only-context-mode.ts +37 -0
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +24 -57
  70. package/src/debug/index.ts +67 -1
  71. package/src/debug/protocol-probe.ts +267 -0
  72. package/src/debug/terminal-info.ts +127 -0
  73. package/src/exa/mcp-client.ts +11 -5
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +3 -3
  76. package/src/extensibility/custom-tools/types.ts +1 -1
  77. package/src/extensibility/extensions/types.ts +11 -0
  78. package/src/extensibility/shared-events.ts +1 -1
  79. package/src/extensibility/skills.ts +3 -3
  80. package/src/index.ts +1 -0
  81. package/src/internal-urls/docs-index.generated.ts +7 -7
  82. package/src/main.ts +16 -2
  83. package/src/mcp/json-rpc.ts +8 -0
  84. package/src/mcp/render.ts +3 -0
  85. package/src/mcp/tool-bridge.ts +10 -2
  86. package/src/mcp/transports/http.ts +33 -16
  87. package/src/mcp/transports/stdio.ts +37 -12
  88. package/src/mnemopi/state.ts +4 -4
  89. package/src/modes/acp/acp-agent.ts +168 -3
  90. package/src/modes/acp/acp-event-mapper.ts +7 -7
  91. package/src/modes/components/agent-dashboard.ts +103 -31
  92. package/src/modes/components/assistant-message.ts +3 -2
  93. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  94. package/src/modes/components/history-search.ts +128 -14
  95. package/src/modes/components/hook-selector.ts +149 -14
  96. package/src/modes/components/plugin-settings.ts +270 -36
  97. package/src/modes/components/session-selector.ts +81 -19
  98. package/src/modes/components/settings-selector.ts +1 -1
  99. package/src/modes/components/status-line/segments.ts +2 -1
  100. package/src/modes/components/status-line.ts +1 -1
  101. package/src/modes/components/tips.txt +6 -2
  102. package/src/modes/components/todo-reminder.ts +1 -1
  103. package/src/modes/components/tool-execution.ts +9 -4
  104. package/src/modes/components/transcript-container.ts +109 -0
  105. package/src/modes/controllers/command-controller.ts +4 -3
  106. package/src/modes/controllers/event-controller.ts +12 -7
  107. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  108. package/src/modes/controllers/input-controller.ts +10 -5
  109. package/src/modes/controllers/selector-controller.ts +30 -19
  110. package/src/modes/controllers/todo-command-controller.ts +1 -1
  111. package/src/modes/interactive-mode.ts +56 -9
  112. package/src/modes/print-mode.ts +5 -0
  113. package/src/modes/rpc/rpc-types.ts +1 -1
  114. package/src/modes/theme/theme.ts +48 -8
  115. package/src/modes/utils/keybinding-matchers.ts +10 -0
  116. package/src/modes/utils/ui-helpers.ts +1 -0
  117. package/src/plan-mode/plan-handoff.ts +37 -0
  118. package/src/priority.json +4 -0
  119. package/src/prompts/goals/goal-continuation.md +1 -1
  120. package/src/prompts/steering/user-interjection.md +10 -0
  121. package/src/prompts/system/agent-creation-architect.md +1 -26
  122. package/src/prompts/system/eager-todo.md +3 -3
  123. package/src/prompts/system/orchestrate-notice.md +5 -5
  124. package/src/prompts/system/plan-mode-approved.md +14 -17
  125. package/src/prompts/system/plan-mode-reference.md +3 -6
  126. package/src/prompts/system/subagent-system-prompt.md +11 -0
  127. package/src/prompts/system/system-prompt.md +143 -145
  128. package/src/prompts/system/title-system.md +3 -2
  129. package/src/prompts/system/workflow-notice.md +1 -1
  130. package/src/prompts/tools/browser.md +33 -30
  131. package/src/prompts/tools/render-mermaid.md +2 -2
  132. package/src/prompts/tools/search.md +1 -1
  133. package/src/prompts/tools/task.md +3 -1
  134. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  135. package/src/sdk.ts +6 -21
  136. package/src/session/agent-session.ts +44 -21
  137. package/src/session/history-storage.ts +11 -18
  138. package/src/session/indexed-session-storage.ts +430 -0
  139. package/src/session/messages.ts +80 -0
  140. package/src/session/redis-session-storage.ts +66 -377
  141. package/src/session/session-manager.ts +109 -23
  142. package/src/session/session-storage.ts +148 -68
  143. package/src/session/sql-session-storage.ts +131 -382
  144. package/src/slash-commands/helpers/todo.ts +2 -2
  145. package/src/slash-commands/types.ts +27 -10
  146. package/src/task/executor.ts +9 -1
  147. package/src/task/index.ts +51 -1
  148. package/src/telemetry-export.ts +126 -0
  149. package/src/tiny/compiled-runtime.ts +179 -0
  150. package/src/tiny/text.ts +112 -1
  151. package/src/tiny/worker.ts +24 -2
  152. package/src/tools/ask.ts +133 -87
  153. package/src/tools/fetch.ts +17 -4
  154. package/src/tools/find.ts +2 -2
  155. package/src/tools/index.ts +6 -4
  156. package/src/tools/memory-recall.ts +1 -1
  157. package/src/tools/memory-reflect.ts +1 -1
  158. package/src/tools/path-utils.ts +16 -7
  159. package/src/tools/renderers.ts +2 -2
  160. package/src/tools/search.ts +6 -3
  161. package/src/tools/ssh.ts +26 -10
  162. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  163. package/src/tools/write.ts +14 -2
  164. package/src/tui/status-line.ts +15 -4
  165. package/src/utils/session-color.ts +39 -14
  166. package/src/utils/title-generator.ts +9 -2
  167. package/src/web/search/index.ts +4 -2
  168. package/src/web/search/provider.ts +19 -35
  169. package/src/web/search/providers/base.ts +17 -0
  170. package/src/web/search/providers/exa.ts +111 -7
  171. package/src/web/search/providers/perplexity.ts +8 -4
  172. package/src/web/search/types.ts +74 -32
package/src/tools/ask.ts CHANGED
@@ -16,7 +16,15 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
- import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
19
+ import {
20
+ type Component,
21
+ Container,
22
+ Markdown,
23
+ type MarkdownTheme,
24
+ renderInlineMarkdown,
25
+ TERMINAL,
26
+ Text,
27
+ } from "@oh-my-pi/pi-tui";
20
28
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
21
29
  import * as z from "zod/v4";
22
30
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -164,6 +172,9 @@ interface UIContext {
164
172
  onLeft?: () => void;
165
173
  onRight?: () => void;
166
174
  helpText?: string;
175
+ selectionMarker?: "radio" | "checkbox";
176
+ checkedIndices?: readonly number[];
177
+ markableCount?: number;
167
178
  },
168
179
  ): Promise<string | undefined>;
169
180
  editor(
@@ -191,6 +202,7 @@ async function askSingleQuestion(
191
202
  prompt: string,
192
203
  optionsToShow: ExtensionUISelectItem[],
193
204
  initialIndex?: number,
205
+ marker?: { selectionMarker: "radio" | "checkbox"; checkedIndices?: readonly number[]; markableCount: number },
194
206
  ): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => {
195
207
  let timeoutTriggered = false;
196
208
  const onTimeout = () => {
@@ -207,6 +219,9 @@ async function askSingleQuestion(
207
219
  outline: true,
208
220
  onTimeout,
209
221
  helpText,
222
+ selectionMarker: marker?.selectionMarker,
223
+ checkedIndices: marker?.checkedIndices,
224
+ markableCount: marker?.markableCount,
210
225
  onLeft: navigation?.allowBack
211
226
  ? () => {
212
227
  navigationAction = "back";
@@ -245,25 +260,27 @@ async function askSingleQuestion(
245
260
  if (selectedIndex >= 0) cursorIndex = selectedIndex;
246
261
  }
247
262
  while (true) {
248
- const opts: ExtensionUISelectItem[] = [];
249
-
250
- for (const opt of questionOptions) {
251
- const checkbox = selected.has(opt.label) ? theme.checkbox.checked : theme.checkbox.unchecked;
252
- const displayLabel = `${checkbox} ${opt.label}`;
253
- opts.push(toSelectOption(opt, displayLabel));
254
- }
263
+ const opts: ExtensionUISelectItem[] = questionOptions.map(opt => toSelectOption(opt));
255
264
 
256
265
  if (!navigation?.allowForward && selected.size > 0) {
257
266
  opts.push(doneLabel);
258
267
  }
259
268
  opts.push(OTHER_OPTION);
260
269
 
270
+ const checkedIndices: number[] = [];
271
+ for (let i = 0; i < questionOptions.length; i++) {
272
+ if (selected.has(questionOptions[i]!.label)) checkedIndices.push(i);
273
+ }
261
274
  const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
262
275
  const {
263
276
  choice,
264
277
  timedOut: selectTimedOut,
265
278
  navigation: arrowNavigation,
266
- } = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex);
279
+ } = await selectOption(`${prefix}${promptWithProgress}`, opts, cursorIndex, {
280
+ selectionMarker: "checkbox",
281
+ checkedIndices,
282
+ markableCount: questionOptions.length,
283
+ });
267
284
 
268
285
  if (arrowNavigation) {
269
286
  return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation };
@@ -295,20 +312,10 @@ async function askSingleQuestion(
295
312
  cursorIndex = selectedIdx;
296
313
  }
297
314
 
298
- const checkedPrefix = `${theme.checkbox.checked} `;
299
- const uncheckedPrefix = `${theme.checkbox.unchecked} `;
300
- let opt: string | undefined;
301
- if (choice.startsWith(checkedPrefix)) {
302
- opt = choice.slice(checkedPrefix.length);
303
- } else if (choice.startsWith(uncheckedPrefix)) {
304
- opt = choice.slice(uncheckedPrefix.length);
305
- }
306
- if (opt) {
307
- if (selected.has(opt)) {
308
- selected.delete(opt);
309
- } else {
310
- selected.add(opt);
311
- }
315
+ if (selected.has(choice)) {
316
+ selected.delete(choice);
317
+ } else {
318
+ selected.add(choice);
312
319
  }
313
320
 
314
321
  if (selectTimedOut) {
@@ -338,7 +345,10 @@ async function askSingleQuestion(
338
345
  choice,
339
346
  timedOut: selectTimedOut,
340
347
  navigation: arrowNavigation,
341
- } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex);
348
+ } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
349
+ selectionMarker: "radio",
350
+ markableCount: displayOptions.length,
351
+ });
342
352
  timedOut = selectTimedOut;
343
353
 
344
354
  if (arrowNavigation) {
@@ -419,7 +429,13 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
419
429
  #sendAskNotification(): void {
420
430
  const method = this.session.settings.get("ask.notify");
421
431
  if (method === "off") return;
422
- TERMINAL.sendNotification("Waiting for input");
432
+ TERMINAL.sendNotification({
433
+ title: "Oh My Pi",
434
+ body: "Waiting for input",
435
+ type: "ask",
436
+ urgency: "normal",
437
+ actions: "focus",
438
+ });
423
439
  }
424
440
 
425
441
  async execute(
@@ -629,7 +645,65 @@ function renderCustomInput(
629
645
  return text;
630
646
  }
631
647
 
648
+ /**
649
+ * Marker glyph for a question option. Single-choice questions render circular radio
650
+ * buttons (pick one); multi-select questions render rectangular checkboxes (pick many).
651
+ */
652
+ function optionMarker(uiTheme: Theme, multi: boolean | undefined, selected: boolean): string {
653
+ if (multi) return selected ? uiTheme.checkbox.checked : uiTheme.checkbox.unchecked;
654
+ return selected ? uiTheme.radio.selected : uiTheme.radio.unselected;
655
+ }
656
+
657
+ /**
658
+ * Render the answered option list for a question: every offered option with its
659
+ * selection marker filled in, plus any custom free-text answer. This keeps the
660
+ * result visually identical to the question form (`renderCall`) so the answer
661
+ * reads in place rather than as a detached summary block.
662
+ *
663
+ * `linePrefix` is the indent that precedes each entry's tree branch — a single
664
+ * leading space for top-level (single-question) entries, or the question's
665
+ * vertical continuation for nested (multi-question) entries.
666
+ */
667
+ function renderAnswerOptions(
668
+ uiTheme: Theme,
669
+ mdTheme: MarkdownTheme,
670
+ linePrefix: string,
671
+ options: string[] | undefined,
672
+ selectedOptions: string[] | undefined,
673
+ multi: boolean | undefined,
674
+ customInput: string | undefined,
675
+ ): string {
676
+ const selected = new Set(selectedOptions ?? []);
677
+ // Prefer the full recorded option set; fall back to the selected labels when
678
+ // details omit the options array.
679
+ const list = options && options.length > 0 ? options : (selectedOptions ?? []);
680
+
681
+ // Nothing was chosen (and no custom answer) → a lone cancelled marker.
682
+ if (selected.size === 0 && customInput === undefined) {
683
+ return `${linePrefix}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
684
+ }
685
+
686
+ let text = "";
687
+ for (let i = 0; i < list.length; i++) {
688
+ const label = list[i];
689
+ const isSelected = selected.has(label);
690
+ const isLastEntry = i === list.length - 1 && customInput === undefined;
691
+ const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch;
692
+ const marker = optionMarker(uiTheme, multi, isSelected);
693
+ const markerStyled = isSelected ? uiTheme.fg("success", marker) : uiTheme.fg("dim", marker);
694
+ const labelStyled = renderInlineMarkdown(label, mdTheme, t =>
695
+ isSelected ? uiTheme.fg("toolOutput", t) : uiTheme.fg("muted", t),
696
+ );
697
+ text += `${text ? "\n" : ""}${linePrefix}${uiTheme.fg("dim", branch)} ${markerStyled} ${labelStyled}`;
698
+ }
699
+ if (customInput !== undefined) {
700
+ text += renderCustomInput(uiTheme, linePrefix, customInput, true, text.length > 0);
701
+ }
702
+ return text;
703
+ }
704
+
632
705
  export const askToolRenderer = {
706
+ mergeCallAndResult: true,
633
707
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
634
708
  const label = formatTitle("Ask", uiTheme);
635
709
  const mdTheme = getMarkdownTheme();
@@ -663,7 +737,7 @@ export const askToolRenderer = {
663
737
  const isLastOpt = j === q.options.length - 1;
664
738
  const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
665
739
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
666
- optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
740
+ optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", optionMarker(uiTheme, q.multi, false))} ${optLabel}`;
667
741
  if (opt.description?.trim()) {
668
742
  const optContinuation = isLastOpt ? " " : uiTheme.tree.vertical;
669
743
  const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t =>
@@ -697,7 +771,7 @@ export const askToolRenderer = {
697
771
  const isLast = i === args.options.length - 1;
698
772
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
699
773
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
700
- optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
774
+ optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", optionMarker(uiTheme, args.multi, false))} ${optLabel}`;
701
775
  if (opt.description?.trim()) {
702
776
  const continuation = isLast ? " " : uiTheme.tree.vertical;
703
777
  const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t => uiTheme.fg("dim", t));
@@ -745,43 +819,27 @@ export const askToolRenderer = {
745
819
  for (let i = 0; i < details.results.length; i++) {
746
820
  const r = details.results[i];
747
821
  const isLastQuestion = i === details.results.length - 1;
748
- const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch;
749
- const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
750
- const hasSelection = r.customInput !== undefined || r.selectedOptions.length > 0;
751
- const statusIcon = hasSelection
752
- ? uiTheme.styledSymbol("status.success", "success")
753
- : uiTheme.styledSymbol("status.warning", "warning");
822
+ const qBranch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch;
823
+ const continuation = isLastQuestion ? " " : uiTheme.tree.vertical;
824
+ const linePrefix = ` ${uiTheme.fg("dim", continuation)} `;
754
825
 
755
- container.addChild(
756
- new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0),
757
- );
826
+ container.addChild(new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0));
758
827
  container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
759
-
760
- const answerLines: string[] = [];
761
- for (let j = 0; j < r.selectedOptions.length; j++) {
762
- const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
763
- const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
764
- const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
765
- uiTheme.fg("toolOutput", t),
766
- );
767
- answerLines.push(
768
- `${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
769
- );
770
- }
771
- if (answerLines.length > 0) {
772
- container.addChild(new Text(answerLines.join("\n"), 0, 0));
773
- }
774
- if (r.customInput !== undefined) {
775
- container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0));
776
- } else if (r.selectedOptions.length === 0) {
777
- container.addChild(
778
- new Text(
779
- `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
780
- 0,
781
- 0,
828
+ container.addChild(
829
+ new Text(
830
+ renderAnswerOptions(
831
+ uiTheme,
832
+ mdTheme,
833
+ linePrefix,
834
+ r.options,
835
+ r.selectedOptions,
836
+ r.multi,
837
+ r.customInput,
782
838
  ),
783
- );
784
- }
839
+ 0,
840
+ 0,
841
+ ),
842
+ );
785
843
  }
786
844
  return container;
787
845
  }
@@ -800,33 +858,21 @@ export const askToolRenderer = {
800
858
  container.addChild(new Text(header, 0, 0));
801
859
  container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
802
860
 
803
- const answerLines: string[] = [];
804
- if (details.selectedOptions && details.selectedOptions.length > 0) {
805
- for (let i = 0; i < details.selectedOptions.length; i++) {
806
- const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined;
807
- const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
808
- const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
809
- uiTheme.fg("toolOutput", t),
810
- );
811
- answerLines.push(
812
- ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
813
- );
814
- }
815
- }
816
- if (answerLines.length > 0) {
817
- container.addChild(new Text(answerLines.join("\n"), 0, 0));
818
- }
819
- if (details.customInput !== undefined) {
820
- container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0));
821
- } else if (!details.selectedOptions || details.selectedOptions.length === 0) {
822
- container.addChild(
823
- new Text(
824
- ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
825
- 0,
826
- 0,
861
+ container.addChild(
862
+ new Text(
863
+ renderAnswerOptions(
864
+ uiTheme,
865
+ mdTheme,
866
+ " ",
867
+ details.options,
868
+ details.selectedOptions,
869
+ details.multi,
870
+ details.customInput,
827
871
  ),
828
- );
829
- }
872
+ 0,
873
+ 0,
874
+ ),
875
+ );
830
876
 
831
877
  return container;
832
878
  },
@@ -127,9 +127,21 @@ function buildLlmEndpointCandidates(url: string): string[] {
127
127
  }
128
128
 
129
129
  /**
130
- * Normalize URL (add scheme if missing)
130
+ * Repair a URL whose scheme `//` collapsed to a single `/`. Node's `path.normalize`/
131
+ * `path.resolve` collapse `//` → `/`, so any URL routed through path normalization arrives
132
+ * as `https:/host/x` instead of `https://host/x`. No local filesystem path begins with
133
+ * `http:/` or `https:/`, so repairing the scheme back to `//` is unambiguous.
134
+ */
135
+ function repairCollapsedScheme(value: string): string {
136
+ const m = value.match(/^(https?):\/(?!\/)/i);
137
+ return m ? `${m[1]}://${value.slice(m[0].length)}` : value;
138
+ }
139
+
140
+ /**
141
+ * Normalize URL (repair a collapsed scheme, then add a scheme if one is missing).
131
142
  */
132
143
  function normalizeUrl(url: string): string {
144
+ url = repairCollapsedScheme(url);
133
145
  if (!url.match(/^https?:\/\//i)) {
134
146
  return `https://${url}`;
135
147
  }
@@ -137,7 +149,7 @@ function normalizeUrl(url: string): string {
137
149
  }
138
150
 
139
151
  export function isReadableUrlPath(value: string): boolean {
140
- return /^https?:\/\//i.test(value) || /^www\./i.test(value);
152
+ return /^https?:\/\/?/i.test(value) || /^www\./i.test(value);
141
153
  }
142
154
 
143
155
  // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:5-10,20-30`, `:raw`,
@@ -168,8 +180,9 @@ function isUrlSelectorToken(token: string): boolean {
168
180
  }
169
181
 
170
182
  export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null {
171
- const embedded = tryExtractEmbeddedUrlSelector(readPath);
172
- const urlPath = embedded?.path ?? readPath;
183
+ const repaired = repairCollapsedScheme(readPath);
184
+ const embedded = tryExtractEmbeddedUrlSelector(repaired);
185
+ const urlPath = embedded?.path ?? repaired;
173
186
  if (!isReadableUrlPath(urlPath)) {
174
187
  return null;
175
188
  }
package/src/tools/find.ts CHANGED
@@ -368,8 +368,8 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
368
368
  let timedOut = false;
369
369
  try {
370
370
  const result = await doGlob(useGitignore);
371
- // Sort by mtime descending (most recent first) in JS instead of native.
372
- // This allows native glob to early-terminate at maxResults.
371
+ // Native glob returns a bounded mtime-ranked set; keep the JS sort for
372
+ // deterministic ordering across cached and uncached native paths.
373
373
  result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
374
374
  matches = result.matches;
375
375
  } catch (error) {
@@ -53,7 +53,7 @@ import { reportFindingTool } from "./review";
53
53
  import { SearchTool } from "./search";
54
54
  import { SearchToolBm25Tool } from "./search-tool-bm25";
55
55
  import { loadSshTool } from "./ssh";
56
- import { type TodoPhase, TodoWriteTool } from "./todo-write";
56
+ import { type TodoPhase, TodoTool } from "./todo";
57
57
  import { WriteTool } from "./write";
58
58
  import { YieldTool } from "./yield";
59
59
 
@@ -94,7 +94,7 @@ export * from "./review";
94
94
  export * from "./search";
95
95
  export * from "./search-tool-bm25";
96
96
  export * from "./ssh";
97
- export * from "./todo-write";
97
+ export * from "./todo";
98
98
  export * from "./tts";
99
99
  export * from "./write";
100
100
  export * from "./yield";
@@ -191,6 +191,8 @@ export interface ToolSession {
191
191
  settings: Settings;
192
192
  /** Plan mode state (if active) */
193
193
  getPlanModeState?: () => PlanModeState | undefined;
194
+ /** Path of the session's active plan reference (e.g. `local://<title>.md`); defaults to `local://PLAN.md`. */
195
+ getPlanReferencePath?: () => string;
194
196
  /** Goal mode state (if active or paused) */
195
197
  getGoalModeState?: () => GoalModeState | undefined;
196
198
  /** Goal runtime for the active agent session. */
@@ -317,7 +319,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
317
319
  task: s => TaskTool.create(s),
318
320
  job: JobTool.createIf,
319
321
  irc: IrcTool.createIf,
320
- todo_write: s => new TodoWriteTool(s),
322
+ todo: s => new TodoTool(s),
321
323
  web_search: s => new WebSearchTool(s),
322
324
  search_tool_bm25: SearchToolBm25Tool.createIf,
323
325
  write: s => new WriteTool(s),
@@ -419,7 +421,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
419
421
  if (name === "bash") return true;
420
422
  if (name === "eval") return allowEval;
421
423
  if (name === "debug") return session.settings.get("debug.enabled");
422
- if (name === "todo_write") return !includeYield && session.settings.get("todo.enabled");
424
+ if (name === "todo") return !includeYield && session.settings.get("todo.enabled");
423
425
  if (name === "find") return session.settings.get("find.enabled");
424
426
  if (name === "search") return session.settings.get("search.enabled");
425
427
  if (name === "github") return session.settings.get("github.enabled");
@@ -38,7 +38,7 @@ export class MemoryRecallTool implements AgentTool<typeof memoryRecallSchema> {
38
38
  throw new Error("Mnemopi backend is not initialised for this session.");
39
39
  }
40
40
  try {
41
- const results = state.recallResultsScoped(params.query);
41
+ const results = await state.recallResultsScoped(params.query);
42
42
  if (results.length === 0) {
43
43
  return {
44
44
  content: [{ type: "text", text: "No relevant memories found." }],
@@ -43,7 +43,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
43
43
  const query = params.context?.trim()
44
44
  ? `${params.query.trim()}\n\nAdditional context:\n${params.context.trim()}`
45
45
  : params.query;
46
- const results = state.recallResultsScoped(query);
46
+ const results = await state.recallResultsScoped(query);
47
47
  if (results.length === 0) {
48
48
  return {
49
49
  content: [{ type: "text", text: "No relevant information found to reflect on." }],
@@ -7,14 +7,22 @@ import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
7
7
  import { ToolError } from "./tool-errors";
8
8
 
9
9
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
10
- const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
11
- const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
10
+ // A single line-range chunk: `N`, `N-M`, `N+K`, or open-ended `N-`. `..` is
11
+ // accepted everywhere `-` is, as a forgiving alias for Rust/Python-style ranges
12
+ // (e.g. `2724..2727` == `2724-2727`, `2724..` == `2724-`); it is normalized to
13
+ // `-` in parseLineRangeChunk. Keep this fragment and LINE_RANGE_CHUNK_RE in sync.
14
+ const RANGE_CHUNK_SRC = String.raw`L?\d+(?:(?:[-+]|\.\.)L?\d+|-|\.\.)?`;
15
+ const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
16
+ const FILE_LINE_RANGE_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
17
+ const FILE_LINE_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
12
18
  const FILE_RAW_ONLY_RE = /^raw$/i;
13
19
  // Permissive selector chunk for internal URLs — accepts well-formed selectors
14
20
  // plus common malformed shapes (e.g. `:-N`) so the read tool peels the entire
15
21
  // selector chain off before dispatching to a protocol handler.
16
- const INTERNAL_URL_SELECTOR_PART_RE =
17
- /^(?:raw|conflicts|L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|-\d+(?:[-+]\d+)?)$/i;
22
+ const INTERNAL_URL_SELECTOR_PART_RE = new RegExp(
23
+ String.raw`^(?:raw|conflicts|${RANGE_LIST_SRC}|-\d+(?:[-+]\d+)?)$`,
24
+ "i",
25
+ );
18
26
  // Schemes whose host grammar is identifier-shaped, so any trailing
19
27
  // `:<selector-chunk>` is unambiguously a read-tool selector. `mcp://` is
20
28
  // excluded because mcp resource URIs may legitimately contain colons.
@@ -144,9 +152,9 @@ export interface LineRange {
144
152
  endLine: number | undefined;
145
153
  }
146
154
 
147
- const LINE_RANGE_CHUNK_RE = /^L?(\d+)(?:([-+])L?(\d+)?)?$/i;
155
+ const LINE_RANGE_CHUNK_RE = /^L?(\d+)(?:(\.\.|[-+])L?(\d+)?)?$/i;
148
156
 
149
- /** Parse a single `N`, `N-M`, `N-`, or `N+K` chunk. Throws via {@link ToolError} on invalid bounds. */
157
+ /** Parse a single `N`, `N-M`, `N-`, `N+K`, or `..`-aliased (`N..M`, `N..`) chunk. Throws via {@link ToolError} on invalid bounds. */
150
158
  export function parseLineRangeChunk(sel: string): LineRange | null {
151
159
  const lineMatch = LINE_RANGE_CHUNK_RE.exec(sel);
152
160
  if (!lineMatch) return null;
@@ -154,7 +162,8 @@ export function parseLineRangeChunk(sel: string): LineRange | null {
154
162
  if (rawStart < 1) {
155
163
  throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
156
164
  }
157
- const sep = lineMatch[2];
165
+ // `..` is a forgiving alias for `-` (e.g. `2724..2727` == `2724-2727`).
166
+ const sep = lineMatch[2] === ".." ? "-" : lineMatch[2];
158
167
  const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
159
168
  let rawEnd: number | undefined;
160
169
  if (sep === "+") {
@@ -28,7 +28,7 @@ import { resolveToolRenderer } from "./resolve";
28
28
  import { searchToolRenderer } from "./search";
29
29
  import { searchToolBm25Renderer } from "./search-tool-bm25";
30
30
  import { sshToolRenderer } from "./ssh";
31
- import { todoWriteToolRenderer } from "./todo-write";
31
+ import { todoToolRenderer } from "./todo";
32
32
  import { writeToolRenderer } from "./write";
33
33
 
34
34
  type ToolRenderer = {
@@ -67,7 +67,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
67
67
  search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
68
68
  ssh: sshToolRenderer as ToolRenderer,
69
69
  task: taskToolRenderer as ToolRenderer,
70
- todo_write: todoWriteToolRenderer as ToolRenderer,
70
+ todo: todoToolRenderer as ToolRenderer,
71
71
  github: githubToolRenderer as ToolRenderer,
72
72
  goal: goalToolRenderer as ToolRenderer,
73
73
  web_search: webSearchToolRenderer as ToolRenderer,
@@ -61,9 +61,10 @@ const searchSchema = z
61
61
  .object({
62
62
  pattern: z.string().describe("regex pattern"),
63
63
  paths: z
64
- .union([searchPathEntrySchema, z.array(searchPathEntrySchema).min(1)])
64
+ .union([searchPathEntrySchema, z.array(searchPathEntrySchema)])
65
+ .optional()
65
66
  .describe(
66
- "file, directory, glob, internal URL, or array of those to search; append `:<lines>` to scope a file to specific line ranges",
67
+ 'file, directory, glob, internal URL, or array of those to search; append `:<lines>` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")',
67
68
  ),
68
69
  i: z.boolean().optional().describe("case-insensitive search"),
69
70
  gitignore: z.boolean().optional().describe("respect gitignore"),
@@ -638,7 +639,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
638
639
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
639
640
  throw new ToolError("Skip must be a non-negative number");
640
641
  }
641
- const rawEntries = await expandDelimitedPathEntries(toPathList(rawPaths), this.session.cwd);
642
+ const scopedPaths = toPathList(rawPaths);
643
+ const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
644
+ const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
642
645
  const pathSpecs = parsePathSpecs(rawEntries);
643
646
  const paths = pathSpecs.map(spec => spec.clean);
644
647
  const {
package/src/tools/ssh.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
- import { Text } from "@oh-my-pi/pi-tui";
4
3
  import { prompt } from "@oh-my-pi/pi-utils";
5
4
  import * as z from "zod/v4";
6
5
  import type { SSHHost } from "../capability/ssh";
@@ -18,6 +17,7 @@ import { CachedOutputBlock } from "../tui/output-block";
18
17
  import type { ToolSession } from ".";
19
18
  import { truncateForPrompt } from "./approval";
20
19
  import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
20
+ import { replaceTabs } from "./render-utils";
21
21
  import { ToolError } from "./tool-errors";
22
22
  import { toolResult } from "./tool-result";
23
23
  import { clampTimeout } from "./tool-timeouts";
@@ -230,12 +230,30 @@ interface SshRenderContext {
230
230
  totalVisualLines?: number;
231
231
  }
232
232
 
233
+ function formatSshCommandLines(command: string, uiTheme: Theme): string[] {
234
+ const sanitized = replaceTabs(command);
235
+ const rawLines = sanitized.length > 0 ? sanitized.split("\n") : ["…"];
236
+ const prefix = uiTheme.fg("dim", "$ ");
237
+ return rawLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
238
+ }
239
+
233
240
  export const sshToolRenderer = {
234
241
  renderCall(args: SshRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
235
242
  const host = args.host || "…";
236
- const command = args.command || "";
237
- const text = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme);
238
- return new Text(text, 0, 0);
243
+ const command = args.command ?? "";
244
+ const header = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}]` }, uiTheme);
245
+ const cmdLines = formatSshCommandLines(command, uiTheme);
246
+ const outputBlock = new CachedOutputBlock();
247
+ return {
248
+ render: (width: number): string[] =>
249
+ outputBlock.render(
250
+ { header, state: "pending", sections: [{ lines: cmdLines }], width, animate: true },
251
+ uiTheme,
252
+ ),
253
+ invalidate: () => {
254
+ outputBlock.invalidate();
255
+ },
256
+ };
239
257
  },
240
258
 
241
259
  renderResult(
@@ -249,11 +267,9 @@ export const sshToolRenderer = {
249
267
  ): Component {
250
268
  const details = result.details;
251
269
  const host = args?.host || "…";
252
- const command = args?.command || "";
253
- const header = renderStatusLine(
254
- { icon: "success", title: "SSH", description: `[${host}] $ ${command}` },
255
- uiTheme,
256
- );
270
+ const command = args?.command ?? "";
271
+ const header = renderStatusLine({ icon: "success", title: "SSH", description: `[${host}]` }, uiTheme);
272
+ const cmdLines = formatSshCommandLines(command, uiTheme);
257
273
  const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
258
274
  const outputBlock = new CachedOutputBlock();
259
275
 
@@ -303,7 +319,7 @@ export const sshToolRenderer = {
303
319
  {
304
320
  header,
305
321
  state: "success",
306
- sections: [{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }],
322
+ sections: [{ lines: cmdLines }, { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }],
307
323
  width,
308
324
  },
309
325
  uiTheme,