@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.6

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 (109) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +3799 -3729
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/cli/bench-cli.d.ts +1 -7
  5. package/dist/types/cli/usage-cli.d.ts +1 -0
  6. package/dist/types/commands/usage.d.ts +7 -0
  7. package/dist/types/config/settings-schema.d.ts +19 -9
  8. package/dist/types/config/settings.d.ts +3 -2
  9. package/dist/types/discovery/helpers.d.ts +2 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  11. package/dist/types/irc/bus.d.ts +4 -0
  12. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  13. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  15. package/dist/types/modes/components/index.d.ts +2 -1
  16. package/dist/types/modes/components/model-browser.d.ts +100 -0
  17. package/dist/types/modes/components/model-hub.d.ts +52 -0
  18. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  19. package/dist/types/modes/components/session-selector.d.ts +13 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  21. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  22. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  23. package/dist/types/modes/interactive-mode.d.ts +3 -0
  24. package/dist/types/modes/queue-input.d.ts +8 -0
  25. package/dist/types/modes/shared.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +1 -1
  28. package/dist/types/session/agent-storage.d.ts +57 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/task/executor.d.ts +26 -13
  31. package/dist/types/task/index.d.ts +12 -11
  32. package/dist/types/task/label.d.ts +4 -0
  33. package/dist/types/task/repair-args.d.ts +8 -8
  34. package/dist/types/task/types.d.ts +31 -56
  35. package/dist/types/tools/ask.d.ts +12 -0
  36. package/dist/types/tools/conflict-detect.d.ts +17 -1
  37. package/dist/types/tools/job.d.ts +16 -0
  38. package/package.json +12 -12
  39. package/scripts/build-binary.ts +0 -1
  40. package/scripts/compile-binary.ts +4 -3
  41. package/src/async/job-manager.ts +9 -0
  42. package/src/cli/bench-cli.ts +7 -26
  43. package/src/cli/usage-cli.ts +11 -0
  44. package/src/commands/usage.ts +13 -2
  45. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  46. package/src/config/settings-schema.ts +18 -7
  47. package/src/config/settings.ts +13 -4
  48. package/src/discovery/helpers.ts +3 -4
  49. package/src/extensibility/custom-tools/loader.ts +70 -37
  50. package/src/extensibility/extensions/types.ts +46 -0
  51. package/src/irc/bus.ts +61 -20
  52. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  53. package/src/modes/components/advisor-config.ts +32 -22
  54. package/src/modes/components/ask-dialog.ts +888 -0
  55. package/src/modes/components/custom-editor.test.ts +58 -1
  56. package/src/modes/components/custom-editor.ts +42 -11
  57. package/src/modes/components/index.ts +2 -1
  58. package/src/modes/components/model-browser.ts +769 -0
  59. package/src/modes/components/model-hub.ts +2002 -0
  60. package/src/modes/components/pause-screen.ts +208 -0
  61. package/src/modes/components/session-selector.ts +299 -42
  62. package/src/modes/components/tool-execution.ts +2 -0
  63. package/src/modes/components/usage-row.ts +5 -6
  64. package/src/modes/controllers/event-controller.ts +8 -2
  65. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  66. package/src/modes/controllers/input-controller.ts +140 -6
  67. package/src/modes/controllers/selector-controller.ts +160 -97
  68. package/src/modes/controllers/tan-command-controller.ts +1 -1
  69. package/src/modes/controllers/todo-command-controller.ts +1 -2
  70. package/src/modes/interactive-mode.ts +8 -0
  71. package/src/modes/queue-input.ts +132 -0
  72. package/src/modes/shared.ts +1 -1
  73. package/src/modes/theme/theme.ts +3 -3
  74. package/src/modes/types.ts +4 -0
  75. package/src/modes/utils/ui-helpers.ts +50 -24
  76. package/src/prompts/agents/scout.md +0 -1
  77. package/src/prompts/agents/task.md +1 -1
  78. package/src/prompts/system/subagent-system-prompt.md +1 -5
  79. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  80. package/src/prompts/system/task-label.md +23 -0
  81. package/src/prompts/tools/job.md +1 -1
  82. package/src/prompts/tools/task-summary.md +3 -0
  83. package/src/prompts/tools/task.md +17 -18
  84. package/src/session/agent-session.ts +186 -49
  85. package/src/session/agent-storage.ts +330 -3
  86. package/src/session/history-storage.ts +1 -34
  87. package/src/session/session-context.test.ts +73 -0
  88. package/src/session/session-context.ts +43 -26
  89. package/src/slash-commands/builtin-registry.ts +18 -0
  90. package/src/task/agents.ts +2 -0
  91. package/src/task/executor.ts +159 -46
  92. package/src/task/index.ts +377 -239
  93. package/src/task/label.ts +38 -0
  94. package/src/task/render.ts +74 -22
  95. package/src/task/repair-args.ts +20 -31
  96. package/src/task/spawn-policy.test.ts +4 -4
  97. package/src/task/types.ts +46 -66
  98. package/src/tools/ask.ts +233 -40
  99. package/src/tools/conflict-detect.ts +102 -5
  100. package/src/tools/index.ts +1 -0
  101. package/src/tools/irc.ts +20 -11
  102. package/src/tools/job.ts +158 -18
  103. package/src/tools/write.ts +70 -6
  104. package/src/vibe/runtime.ts +1 -1
  105. package/src/web/search/providers/browser-headers.ts +30 -13
  106. package/dist/types/modes/components/model-selector.d.ts +0 -37
  107. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  108. package/src/modes/components/model-selector.ts +0 -1291
  109. package/src/tools/bash-command-fixup.ts +0 -4
package/src/tools/ask.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  Markdown,
24
24
  type MarkdownTheme,
25
25
  renderInlineMarkdown,
26
+ replaceTabs,
26
27
  TERMINAL,
27
28
  Text,
28
29
  truncateToWidth,
@@ -44,17 +45,34 @@ import { ToolAbortError } from "./tool-errors";
44
45
  // Types
45
46
  // =============================================================================
46
47
 
48
+ const OTHER_OPTION = "Other (type your own)";
49
+ const CHAT_ABOUT_THIS_OPTION = "Chat about this";
50
+ const NEXT_OPTION = "Next →";
51
+ const RESERVED_OPTION_LABELS: Record<string, true> = {
52
+ [OTHER_OPTION]: true,
53
+ [CHAT_ABOUT_THIS_OPTION]: true,
54
+ [NEXT_OPTION]: true,
55
+ };
56
+
47
57
  const OptionItem = arkType({
48
58
  label: arkType("string").describe("display label"),
49
59
  "description?": arkType("string").describe("optional explanatory text displayed below the label"),
60
+ "preview?": arkType("string").describe("optional rich preview content for interactive ask dialogs"),
50
61
  });
51
62
 
52
63
  const QuestionItem = arkType({
53
64
  id: arkType("string").describe("question id"),
54
65
  question: arkType("string").describe("question text"),
66
+ "header?": arkType("string").describe("optional short display chip for rich ask dialogs"),
55
67
  options: OptionItem.array().describe("available options"),
56
68
  "multi?": arkType("boolean").describe("allow multiple selections"),
57
69
  "recommended?": arkType("number").describe("recommended option index"),
70
+ }).narrow((question, ctx) => {
71
+ const reserved = question.options.find(option => RESERVED_OPTION_LABELS[option.label] === true);
72
+ return (
73
+ reserved === undefined ||
74
+ ctx.mustBe(`defined with option labels that do not collide with reserved runtime labels: ${reserved.label}`)
75
+ );
58
76
  });
59
77
 
60
78
  const askSchema = arkType({
@@ -71,6 +89,8 @@ export interface QuestionResult {
71
89
  multi: boolean;
72
90
  selectedOptions: string[];
73
91
  customInput?: string;
92
+ /** Optional note attached to the selected answer in the rich ask dialog. */
93
+ note?: string;
74
94
  /** True when the answer was auto-selected because the dialog timed out. */
75
95
  timedOut?: boolean;
76
96
  }
@@ -81,10 +101,16 @@ export interface AskToolDetails {
81
101
  multi?: boolean;
82
102
  selectedOptions?: string[];
83
103
  customInput?: string;
104
+ /** Optional note attached to the selected answer in the rich ask dialog. */
105
+ note?: string;
84
106
  /** True when the answer was auto-selected because the dialog timed out. */
85
107
  timedOut?: boolean;
86
108
  /** Multi-part question mode */
87
109
  results?: QuestionResult[];
110
+ /** Chat redirect: the user chose "Chat about this" instead of answering. */
111
+ chatRedirect?: boolean;
112
+ /** Questions surfaced when chatRedirect is true. */
113
+ questions?: string[];
88
114
  }
89
115
 
90
116
  interface AskOption {
@@ -108,7 +134,6 @@ function toSelectOption(option: AskOption, label = option.label): ExtensionUISel
108
134
  // Constants
109
135
  // =============================================================================
110
136
 
111
- const OTHER_OPTION = "Other (type your own)";
112
137
  const RECOMMENDED_SUFFIX = " (Recommended)";
113
138
  // Window after the timeout deadline within which an `undefined` selection is
114
139
  // attributed to a UI-enforced timeout (for surfaces that close the dialog at
@@ -361,6 +386,7 @@ function formatCustomInputTitle(
361
386
  interface SelectionResult {
362
387
  selectedOptions: string[];
363
388
  customInput?: string;
389
+ note?: string;
364
390
  timedOut: boolean;
365
391
  navigation?: "back" | "forward";
366
392
  cancelled?: boolean;
@@ -375,7 +401,7 @@ interface AskSingleQuestionOptions {
375
401
  recommended?: number;
376
402
  timeout?: number;
377
403
  signal?: AbortSignal;
378
- initialSelection?: Pick<SelectionResult, "selectedOptions" | "customInput">;
404
+ initialSelection?: Pick<SelectionResult, "selectedOptions" | "customInput" | "note">;
379
405
  navigation?: NavigationControls;
380
406
  }
381
407
 
@@ -419,6 +445,7 @@ async function askSingleQuestion(
419
445
  const doneLabel = getDoneOptionLabel();
420
446
  let selectedOptions = [...(initialSelection?.selectedOptions ?? [])];
421
447
  let customInput = initialSelection?.customInput;
448
+ const note = initialSelection?.note;
422
449
  let timedOut = false;
423
450
 
424
451
  const selectOption = async (
@@ -547,14 +574,14 @@ async function askSingleQuestion(
547
574
  });
548
575
 
549
576
  if (arrowNavigation) {
550
- return { selectedOptions: Array.from(selected), customInput, timedOut, navigation: arrowNavigation };
577
+ return { selectedOptions: Array.from(selected), customInput, note, timedOut, navigation: arrowNavigation };
551
578
  }
552
579
  if (choice === undefined) {
553
580
  if (selectTimedOut) {
554
581
  timedOut = true;
555
582
  break;
556
583
  }
557
- return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true };
584
+ return { selectedOptions: Array.from(selected), customInput, note, timedOut, cancelled: true };
558
585
  }
559
586
  if (choice === doneLabel) break;
560
587
 
@@ -621,11 +648,11 @@ async function askSingleQuestion(
621
648
  timedOut = selectTimedOut;
622
649
 
623
650
  if (arrowNavigation) {
624
- return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
651
+ return { selectedOptions, customInput, note, timedOut, navigation: arrowNavigation };
625
652
  }
626
653
  if (choice === undefined) {
627
654
  if (!timedOut) {
628
- return { selectedOptions, customInput, timedOut, cancelled: true };
655
+ return { selectedOptions, customInput, note, timedOut, cancelled: true };
629
656
  }
630
657
  break;
631
658
  }
@@ -652,7 +679,7 @@ async function askSingleQuestion(
652
679
  selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
653
680
  }
654
681
  if (navigation?.allowForward) {
655
- return { selectedOptions, customInput, timedOut, navigation: "forward" };
682
+ return { selectedOptions, customInput, note, timedOut, navigation: "forward" };
656
683
  }
657
684
  }
658
685
 
@@ -660,20 +687,58 @@ async function askSingleQuestion(
660
687
  selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
661
688
  }
662
689
 
663
- return { selectedOptions, customInput, timedOut };
690
+ return { selectedOptions, customInput, note, timedOut };
664
691
  }
665
692
 
666
693
  function formatQuestionResult(result: QuestionResult): string {
694
+ const noteSuffix = result.note ? ` (note: ${result.note})` : "";
667
695
  if (result.customInput !== undefined) {
668
- return `${result.id}: "${result.customInput}"`;
696
+ return `${result.id}: "${result.customInput}"${noteSuffix}`;
669
697
  }
670
698
  if (result.selectedOptions.length > 0) {
671
- const suffix = result.timedOut ? " (auto-selected after timeout)" : "";
699
+ const suffix = `${result.timedOut ? " (auto-selected after timeout)" : ""}${noteSuffix}`;
672
700
  return result.multi
673
701
  ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}`
674
702
  : `${result.id}: ${result.selectedOptions[0]}${suffix}`;
675
703
  }
676
- return `${result.id}: (cancelled)`;
704
+ return `${result.id}: (cancelled)${noteSuffix}`;
705
+ }
706
+
707
+ function formatSingleQuestionResponse(result: {
708
+ selectedOptions: string[];
709
+ customInput?: string;
710
+ note?: string;
711
+ timedOut?: boolean;
712
+ multi: boolean;
713
+ }): string {
714
+ const responseParts: string[] = [];
715
+ if (result.selectedOptions.length > 0) {
716
+ const selectedText = result.multi
717
+ ? `User selected: ${result.selectedOptions.join(", ")}`
718
+ : `User selected: ${result.selectedOptions[0]}`;
719
+ responseParts.push(result.timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText);
720
+ }
721
+ if (result.customInput !== undefined) {
722
+ responseParts.push(
723
+ result.customInput.includes("\n")
724
+ ? `User provided custom input:\n${result.customInput
725
+ .split("\n")
726
+ .map(line => ` ${line}`)
727
+ .join("\n")}`
728
+ : `User provided custom input: ${result.customInput}`,
729
+ );
730
+ }
731
+ if (result.note) {
732
+ responseParts.push(
733
+ result.note.includes("\n")
734
+ ? `User added note:\n${result.note
735
+ .split("\n")
736
+ .map(line => ` ${line}`)
737
+ .join("\n")}`
738
+ : `User added note: ${result.note}`,
739
+ );
740
+ }
741
+ return responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection";
677
742
  }
678
743
 
679
744
  // =============================================================================
@@ -810,6 +875,95 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
810
875
  vocalizer.speak(params.questions.map(q => q.question).join("\n"));
811
876
  }
812
877
 
878
+ const richAskDialog = extensionUi.askDialog;
879
+ if (richAskDialog) {
880
+ try {
881
+ const showRichDialog = () =>
882
+ richAskDialog(
883
+ params.questions.map(q => ({
884
+ id: q.id,
885
+ question: q.question,
886
+ ...(q.header?.trim() ? { header: q.header } : {}),
887
+ options: q.options.map(option => ({
888
+ label: option.label,
889
+ ...(option.description?.trim() ? { description: option.description.trim() } : {}),
890
+ ...(option.preview?.trim() ? { preview: option.preview } : {}),
891
+ })),
892
+ ...(q.multi !== undefined ? { multi: q.multi } : {}),
893
+ ...(q.recommended !== undefined ? { recommended: q.recommended } : {}),
894
+ })),
895
+ { timeout: timeout ?? undefined, signal },
896
+ );
897
+ const richResult = signal ? await untilAborted(signal, showRichDialog) : await showRichDialog();
898
+ if (!richResult) {
899
+ context.abort();
900
+ throw new ToolAbortError("Ask tool was cancelled by the user");
901
+ }
902
+ if (richResult.kind === "chat") {
903
+ const questionText = params.questions.map(q => q.question).join("\n");
904
+ return {
905
+ content: [
906
+ {
907
+ type: "text" as const,
908
+ text: `User chose to chat about this instead of answering.\n\nQuestions asked:\n${questionText}`,
909
+ },
910
+ ],
911
+ details: { chatRedirect: true, questions: params.questions.map(q => q.question) },
912
+ };
913
+ }
914
+ if (richResult.results.length !== params.questions.length) {
915
+ throw new Error("Ask dialog returned a result count that does not match the requested questions");
916
+ }
917
+ const results: QuestionResult[] = [];
918
+ for (let index = 0; index < params.questions.length; index++) {
919
+ const question = params.questions[index];
920
+ const result = richResult.results[index];
921
+ if (!question || !result || result.id !== question.id) {
922
+ throw new Error("Ask dialog returned results that do not match the requested question order");
923
+ }
924
+ results.push({
925
+ id: question.id,
926
+ question: question.question,
927
+ options: question.options.map(option => option.label),
928
+ multi: question.multi ?? false,
929
+ selectedOptions: result.selectedOptions,
930
+ customInput: result.customInput,
931
+ note: result.note,
932
+ timedOut: result.timedOut,
933
+ });
934
+ }
935
+ if (params.questions.length === 1) {
936
+ const result = results[0];
937
+ if (
938
+ !result ||
939
+ (!result.timedOut && result.selectedOptions.length === 0 && result.customInput === undefined)
940
+ ) {
941
+ context.abort();
942
+ throw new ToolAbortError("Ask tool was cancelled by the user");
943
+ }
944
+ const details: AskToolDetails = {
945
+ question: result.question,
946
+ options: result.options,
947
+ multi: result.multi,
948
+ selectedOptions: result.selectedOptions,
949
+ customInput: result.customInput,
950
+ note: result.note,
951
+ timedOut: result.timedOut,
952
+ };
953
+ const responseText = formatSingleQuestionResponse(result);
954
+ return { content: [{ type: "text" as const, text: responseText }], details };
955
+ }
956
+ const details: AskToolDetails = { results };
957
+ const responseText = `User answers:\n${results.map(formatQuestionResult).join("\n")}`;
958
+ return { content: [{ type: "text" as const, text: responseText }], details };
959
+ } catch (error) {
960
+ if (error instanceof Error && error.name === "AbortError") {
961
+ throw new ToolAbortError("Ask input was cancelled");
962
+ }
963
+ throw error;
964
+ }
965
+ }
966
+
813
967
  const askQuestion = async (
814
968
  q: AskParams["questions"][number],
815
969
  options?: { previous?: QuestionResult; navigation?: NavigationControls },
@@ -820,7 +974,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
820
974
  }));
821
975
  const optionLabels = questionOptions.map(getAskOptionLabel);
822
976
  try {
823
- const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion(
977
+ const { selectedOptions, customInput, note, navigation, cancelled, timedOut } = await askSingleQuestion(
824
978
  ui,
825
979
  q.question,
826
980
  questionOptions,
@@ -833,7 +987,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
833
987
  navigation: options?.navigation,
834
988
  },
835
989
  );
836
- return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
990
+ return { optionLabels, selectedOptions, customInput, note, navigation, cancelled, timedOut };
837
991
  } catch (error) {
838
992
  if (error instanceof Error && error.name === "AbortError") {
839
993
  throw new ToolAbortError("Ask input was cancelled");
@@ -844,7 +998,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
844
998
 
845
999
  if (params.questions.length === 1) {
846
1000
  const [q] = params.questions;
847
- const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
1001
+ const { optionLabels, selectedOptions, customInput, note, cancelled, timedOut } = await askQuestion(q);
848
1002
 
849
1003
  if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
850
1004
  context.abort();
@@ -856,27 +1010,17 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
856
1010
  multi: q.multi ?? false,
857
1011
  selectedOptions,
858
1012
  customInput,
1013
+ note,
859
1014
  timedOut: timedOut || undefined,
860
1015
  };
861
1016
 
862
- const responseParts: string[] = [];
863
- if (selectedOptions.length > 0) {
864
- const selectedText = q.multi
865
- ? `User selected: ${selectedOptions.join(", ")}`
866
- : `User selected: ${selectedOptions[0]}`;
867
- responseParts.push(timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText);
868
- }
869
- if (customInput !== undefined) {
870
- responseParts.push(
871
- customInput.includes("\n")
872
- ? `User provided custom input:\n${customInput
873
- .split("\n")
874
- .map(line => ` ${line}`)
875
- .join("\n")}`
876
- : `User provided custom input: ${customInput}`,
877
- );
878
- }
879
- const responseText = responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection";
1017
+ const responseText = formatSingleQuestionResponse({
1018
+ selectedOptions,
1019
+ customInput,
1020
+ note,
1021
+ timedOut: timedOut || undefined,
1022
+ multi: q.multi ?? false,
1023
+ });
880
1024
 
881
1025
  return { content: [{ type: "text" as const, text: responseText }], details };
882
1026
  }
@@ -884,7 +1028,8 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
884
1028
  const resultsByIndex: Array<QuestionResult | undefined> = Array.from({ length: params.questions.length });
885
1029
  let questionIndex = 0;
886
1030
  while (questionIndex < params.questions.length) {
887
- const q = params.questions[questionIndex]!;
1031
+ const q = params.questions[questionIndex];
1032
+ if (!q) throw new Error("Ask question index exceeded the requested question list");
888
1033
  const previous = resultsByIndex[questionIndex];
889
1034
  const navigation: NavigationControls = {
890
1035
  allowBack: questionIndex > 0,
@@ -895,6 +1040,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
895
1040
  optionLabels,
896
1041
  selectedOptions,
897
1042
  customInput,
1043
+ note,
898
1044
  navigation: navAction,
899
1045
  cancelled,
900
1046
  timedOut,
@@ -912,6 +1058,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
912
1058
  multi: q.multi ?? false,
913
1059
  selectedOptions,
914
1060
  customInput,
1061
+ note,
915
1062
  timedOut: timedOut || undefined,
916
1063
  };
917
1064
 
@@ -923,9 +1070,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
923
1070
  questionIndex += 1;
924
1071
  }
925
1072
 
926
- const results = resultsByIndex.map((result, index) => {
1073
+ const results = params.questions.map((q, index) => {
1074
+ const result = resultsByIndex[index];
927
1075
  if (result) return result;
928
- const q = params.questions[index]!;
929
1076
  return {
930
1077
  id: q.id,
931
1078
  question: q.question,
@@ -1024,6 +1171,21 @@ function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] {
1024
1171
  return out;
1025
1172
  }
1026
1173
 
1174
+ /** Render an answer note with tab replacement and line-width clamping. */
1175
+ function renderNoteLines(uiTheme: Theme, note: string, width: number): string[] {
1176
+ const prefix = " Note: ";
1177
+ const continuationPrefix = " ";
1178
+ const firstLineWidth = Math.max(1, width - visibleWidth(prefix));
1179
+ const continuationWidth = Math.max(1, width - visibleWidth(continuationPrefix));
1180
+ return replaceTabs(note)
1181
+ .split("\n")
1182
+ .map((line, index) => {
1183
+ const linePrefix = index === 0 ? `${uiTheme.fg("dim", " Note:")} ` : continuationPrefix;
1184
+ const maxWidth = index === 0 ? firstLineWidth : continuationWidth;
1185
+ return `${linePrefix}${uiTheme.fg("toolOutput", truncateToWidth(line, maxWidth))}`;
1186
+ });
1187
+ }
1188
+
1027
1189
  /**
1028
1190
  * Marker glyph for a question option. Single-choice questions render circular radio
1029
1191
  * buttons (pick one); multi-select questions render rectangular checkboxes (pick many).
@@ -1064,6 +1226,8 @@ function renderAnswerOptionLines(
1064
1226
  selectedOptions: string[] | undefined,
1065
1227
  multi: boolean | undefined,
1066
1228
  customInput: string | undefined,
1229
+ note: string | undefined,
1230
+ width: number,
1067
1231
  ): string[] {
1068
1232
  const selected = new Set(selectedOptions ?? []);
1069
1233
  // Prefer the full recorded option set; fall back to the selected labels when
@@ -1071,7 +1235,7 @@ function renderAnswerOptionLines(
1071
1235
  const list = options && options.length > 0 ? options : (selectedOptions ?? []);
1072
1236
 
1073
1237
  // Nothing was chosen (and no custom answer) → a lone cancelled marker.
1074
- if (selected.size === 0 && customInput === undefined) {
1238
+ if (selected.size === 0 && customInput === undefined && note === undefined) {
1075
1239
  return [` ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`];
1076
1240
  }
1077
1241
 
@@ -1086,6 +1250,7 @@ function renderAnswerOptionLines(
1086
1250
  out.push(` ${markerStyled} ${labelStyled}`);
1087
1251
  }
1088
1252
  if (customInput !== undefined) out.push(...renderCustomInputLines(uiTheme, customInput));
1253
+ if (note !== undefined) out.push(...renderNoteLines(uiTheme, note, width));
1089
1254
  return out;
1090
1255
  }
1091
1256
 
@@ -1175,11 +1340,27 @@ export const askToolRenderer = {
1175
1340
  return new Text(`${header}${body}`, 0, 0);
1176
1341
  }
1177
1342
 
1343
+ // Chat redirect: user chose "Chat about this" instead of answering.
1344
+ if (details.chatRedirect) {
1345
+ const header = renderStatusLine({ icon: "info", title: "Ask", meta: ["chat redirect"] }, uiTheme);
1346
+ const questions = details.questions ?? [];
1347
+ return framedBlock(uiTheme, width => ({
1348
+ header,
1349
+ sections: questions.length > 0 ? [{ lines: questions.flatMap(q => md(q, width)) }] : [],
1350
+ state: "warning",
1351
+ borderColor: "borderMuted",
1352
+ width,
1353
+ }));
1354
+ }
1355
+
1178
1356
  // Multi-part results: one divider-labelled section per question.
1179
1357
  if (details.results && details.results.length > 0) {
1180
1358
  const results = details.results;
1181
1359
  const hasAnySelection = results.some(
1182
- r => r.customInput !== undefined || (r.selectedOptions && r.selectedOptions.length > 0),
1360
+ r =>
1361
+ r.customInput !== undefined ||
1362
+ r.note !== undefined ||
1363
+ (r.selectedOptions && r.selectedOptions.length > 0),
1183
1364
  );
1184
1365
  const header = renderStatusLine(
1185
1366
  {
@@ -1194,7 +1375,16 @@ export const askToolRenderer = {
1194
1375
  // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
1195
1376
  const lines = [
1196
1377
  ...md(r.question, width),
1197
- ...renderAnswerOptionLines(uiTheme, mdTheme, r.options, r.selectedOptions, r.multi, r.customInput),
1378
+ ...renderAnswerOptionLines(
1379
+ uiTheme,
1380
+ mdTheme,
1381
+ r.options,
1382
+ r.selectedOptions,
1383
+ r.multi,
1384
+ r.customInput,
1385
+ r.note,
1386
+ width,
1387
+ ),
1198
1388
  ];
1199
1389
  return { label: uiTheme.fg("dim", `[${r.id}]`), lines };
1200
1390
  });
@@ -1217,7 +1407,9 @@ export const askToolRenderer = {
1217
1407
 
1218
1408
  const question = details.question;
1219
1409
  const hasSelection =
1220
- details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0);
1410
+ details.customInput !== undefined ||
1411
+ details.note !== undefined ||
1412
+ (details.selectedOptions && details.selectedOptions.length > 0);
1221
1413
  const header = renderStatusLine(
1222
1414
  hasSelection
1223
1415
  ? { iconOverride: uiTheme.styledSymbol("tool.ask", "accent"), title: "Ask" }
@@ -1228,12 +1420,13 @@ export const askToolRenderer = {
1228
1420
  const dSelected = details.selectedOptions;
1229
1421
  const dMulti = details.multi;
1230
1422
  const dCustom = details.customInput;
1423
+ const dNote = details.note;
1231
1424
  const dTimedOut = details.timedOut;
1232
1425
  return framedBlock(uiTheme, width => {
1233
1426
  // md() returns a shared cached array (module-level Markdown LRU) — copy before appending.
1234
1427
  const bodyLines = [
1235
1428
  ...md(question, width),
1236
- ...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom),
1429
+ ...renderAnswerOptionLines(uiTheme, mdTheme, dOptions, dSelected, dMulti, dCustom, dNote, width),
1237
1430
  ];
1238
1431
  if (dTimedOut) {
1239
1432
  // Distinguish auto-selection from a real user choice in the transcript.
@@ -319,6 +319,15 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
319
319
  return recoveredPrefix !== undefined ? { id, scope, recoveredPrefix } : { id, scope };
320
320
  }
321
321
 
322
+ /** Result of {@link spliceConflict}: the new file text plus any boundary-echo repair applied. */
323
+ export interface ConflictSplice {
324
+ text: string;
325
+ /** Replacement lines dropped because they duplicated the context directly above the region. */
326
+ trimmedLeading: number;
327
+ /** Replacement lines dropped because they duplicated the context directly below the region. */
328
+ trimmedTrailing: number;
329
+ }
330
+
322
331
  /**
323
332
  * Splice the conflict region recorded in `entry` out of `originalText`
324
333
  * and replace it with `replacement` (markers and all sides included).
@@ -328,8 +337,16 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
328
337
  * match), so out-of-band edits earlier in the file that shift line
329
338
  * numbers don't break resolution. Throws clearly when the marker block
330
339
  * has actually been altered or removed.
340
+ *
341
+ * Boundary-echo repair (same philosophy as the edit tool's hashline
342
+ * keeper repair): models frequently paste the "whole resolved function"
343
+ * including the lines that live directly before/after the marker block,
344
+ * which the verbatim splice would duplicate. Replacement lines that
345
+ * exactly echo the adjacent context are dropped when the echo is
346
+ * unambiguous — two or more consecutive lines, or a single line whose
347
+ * removal fixes a delimiter-balance mismatch against the recorded sides.
331
348
  */
332
- export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): string {
349
+ export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): ConflictSplice {
333
350
  const lines = originalText.split("\n");
334
351
  const expected = buildRecordedRegion(entry);
335
352
  const match = locateRegion(lines, expected, entry.startLine - 1);
@@ -341,6 +358,8 @@ export function spliceConflict(originalText: string, entry: ConflictEntry, repla
341
358
 
342
359
  const trimmed = normalizeTrailingNewline(replacement);
343
360
  let replacementLines = trimmed.split("\n").map(stripTrailingCr);
361
+ const echo = trimBoundaryEcho(replacementLines, lines, match, entry);
362
+ replacementLines = echo.lines;
344
363
  // Round-trip fidelity for CRLF files: recorded sections are LF-normalized,
345
364
  // so re-apply \r to spliced lines when the matched region used CRLF. The
346
365
  // final replacement line only carries \r when another line follows it.
@@ -351,7 +370,79 @@ export function spliceConflict(originalText: string, entry: ConflictEntry, repla
351
370
  );
352
371
  }
353
372
  const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
354
- return next.join("\n");
373
+ return { text: next.join("\n"), trimmedLeading: echo.leading, trimmedTrailing: echo.trailing };
374
+ }
375
+
376
+ const MAX_ECHO_LINES = 12;
377
+
378
+ /**
379
+ * Net `{}`/`()`/`[]` count over `lines`. Crude (string/comment-blind) —
380
+ * used only to corroborate single-line echo trims, never alone.
381
+ */
382
+ function delimiterBalance(lines: readonly string[]): number {
383
+ let balance = 0;
384
+ for (const line of lines) {
385
+ for (let i = 0; i < line.length; i++) {
386
+ const ch = line.charCodeAt(i);
387
+ if (ch === 123 /* { */ || ch === 40 /* ( */ || ch === 91 /* [ */) balance++;
388
+ else if (ch === 125 /* } */ || ch === 41 /* ) */ || ch === 93 /* ] */) balance--;
389
+ }
390
+ }
391
+ return balance;
392
+ }
393
+
394
+ /**
395
+ * Drop replacement lines that exactly echo the file lines adjacent to the
396
+ * located region. A multi-line echo is trimmed unconditionally (a correct
397
+ * resolution ending with the exact lines that already follow the region
398
+ * would mean intentionally duplicated code — vanishingly unlikely, and the
399
+ * untrimmed splice produces exactly that duplication). A single-line echo
400
+ * is trimmed only when the recorded sides agree on the region's delimiter
401
+ * balance and dropping the echo is what restores it.
402
+ */
403
+ function trimBoundaryEcho(
404
+ replacement: string[],
405
+ fileLines: readonly string[],
406
+ match: { startIdx: number; endIdx: number },
407
+ entry: ConflictBlock,
408
+ ): { lines: string[]; leading: number; trailing: number } {
409
+ const oursBalance = delimiterBalance(entry.oursLines);
410
+ const expectedBalance = oursBalance === delimiterBalance(entry.theirsLines) ? oursBalance : null;
411
+ const singleEchoJustified = (lines: string[], without: string[]) =>
412
+ expectedBalance !== null &&
413
+ delimiterBalance(lines) !== expectedBalance &&
414
+ delimiterBalance(without) === expectedBalance;
415
+
416
+ let lines = replacement;
417
+ let trailing = 0;
418
+ const after: string[] = [];
419
+ for (let i = match.endIdx + 1; i < fileLines.length && after.length < MAX_ECHO_LINES; i++) {
420
+ after.push(stripTrailingCr(fileLines[i]!));
421
+ }
422
+ for (let k = Math.min(after.length, lines.length - 1); k >= 1; k--) {
423
+ if (!after.slice(0, k).every((line, i) => lines[lines.length - k + i] === line)) continue;
424
+ if (k >= 2 || singleEchoJustified(lines, lines.slice(0, -1))) {
425
+ trailing = k;
426
+ lines = lines.slice(0, lines.length - k);
427
+ }
428
+ break;
429
+ }
430
+
431
+ let leading = 0;
432
+ const before: string[] = [];
433
+ for (let i = match.startIdx - 1; i >= 0 && before.length < MAX_ECHO_LINES; i--) {
434
+ before.unshift(stripTrailingCr(fileLines[i]!));
435
+ }
436
+ for (let k = Math.min(before.length, lines.length - 1); k >= 1; k--) {
437
+ if (!before.slice(before.length - k).every((line, i) => lines[i] === line)) continue;
438
+ if (k >= 2 || singleEchoJustified(lines, lines.slice(1))) {
439
+ leading = k;
440
+ lines = lines.slice(k);
441
+ }
442
+ break;
443
+ }
444
+
445
+ return { lines, leading, trailing };
355
446
  }
356
447
 
357
448
  /** Reconstruct the recorded marker block as it should appear in the file. */
@@ -608,10 +699,16 @@ export function formatConflictWarning(
608
699
  if (theirsLabel) out.push(`- theirs = ${theirsLabel}`);
609
700
  if (anyBase) out.push(`- base = ${baseLabel ?? "(no label)"}`);
610
701
  out.push(
611
- 'NOTICE: Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side). Resolve with `write({ path: "conflict://<N>", content })`, or bulk-resolve every registered conflict with `write({ path: "conflict://*", content })`. Writes replace the whole conflict region (markers + all sides).',
702
+ 'NOTICE: Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side). Resolve with `write({ path: "conflict://<N>", content })`, or bulk-resolve every registered conflict with `write({ path: "conflict://*", content })`. Writes replace ONLY the marker block (markers + all sides) — never repeat the lines before/after it; they stay in place.',
703
+ );
704
+ out.push(
705
+ '`content` shorthand: a line that is exactly `@ours` / `@theirs` / `@base` / `@both` expands to that recorded section. `@both` is ours-then-theirs with no separator — only for additive conflicts where each side adds something different; NEVER for competing edits of the same lines (pick a side or write the combined text). Lines that are not a token pass through verbatim, so `"// keep both\\n@ours\\n@theirs"` literally writes the comment, then ours, then theirs.',
706
+ );
707
+ out.push(
708
+ 'Per-id bulk: `write({ path: "conflict://*", content: "1: @ours\\n2: @theirs\\n…" })` resolves each listed id with that side in ONE call — the cheapest way through many pick-one conflicts; unlisted ids stay registered.',
612
709
  );
613
710
  out.push(
614
- '`content` shorthand: a line that is exactly `@ours` / `@theirs` / `@base` / `@both` expands to that recorded section. `@both` is ours-then-theirs with no separator. Lines that are not a token pass through verbatim, so `"// keep both\\n@ours\\n@theirs"` literally writes the comment, then ours, then theirs.',
711
+ "Resolve each block faithfully: keep one side (`@ours`/`@theirs`), or combine them when both intents apply never invent content beyond the recorded sides, and never stack both sides of competing edits. Resolve several conflicts in a single turn by issuing multiple `write` calls at once; ids stay valid as earlier blocks are resolved.",
615
712
  );
616
713
 
617
714
  for (const entry of entries) {
@@ -674,7 +771,7 @@ export function formatConflictSummary(
674
771
  'NOTICE: Bulk-resolve with `write({ path: "conflict://*", content })`, or address a single block with `write({ path: "conflict://<N>", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).',
675
772
  );
676
773
  lines.push(
677
- "`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.",
774
+ '`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs (additive conflicts only — never for competing edits of the same lines). Per-id bulk: content of `<id>: @side` lines (e.g. "1: @ours\\n2: @theirs") resolves each listed id in one call. Non-token lines pass through verbatim. Writes replace ONLY the marker block — never repeat the surrounding lines. Keep one side or combine faithfully; never invent content beyond the recorded sides.',
678
775
  );
679
776
  lines.push("");
680
777
  const idWidth = String(entries[entries.length - 1]?.id ?? 1).length;
@@ -617,6 +617,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
617
617
  if (name === "web_search") return session.settings.get("web_search.enabled");
618
618
  // search_tool_bm25 is allowed when either legacy mcp.discoveryMode or new tools.discoveryMode is active.
619
619
  if (name === "search_tool_bm25") return discoveryActive;
620
+ if (name === "ask") return session.settings.get("ask.enabled");
620
621
  if (name === "browser") return session.settings.get("browser.enabled");
621
622
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
622
623
  if (name === "irc") return isIrcEnabled(session.settings, session.taskDepth ?? 0);