@oh-my-pi/pi-coding-agent 12.3.0 → 12.5.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 (57) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/docs/custom-tools.md +21 -6
  3. package/docs/extensions.md +20 -0
  4. package/package.json +12 -12
  5. package/src/cli/setup-cli.ts +62 -2
  6. package/src/commands/setup.ts +1 -1
  7. package/src/config/keybindings.ts +6 -2
  8. package/src/config/settings-schema.ts +58 -4
  9. package/src/config/settings.ts +23 -9
  10. package/src/debug/index.ts +26 -19
  11. package/src/debug/log-formatting.ts +60 -0
  12. package/src/debug/log-viewer.ts +903 -0
  13. package/src/debug/report-bundle.ts +87 -8
  14. package/src/discovery/helpers.ts +131 -137
  15. package/src/extensibility/custom-tools/types.ts +44 -6
  16. package/src/extensibility/extensions/types.ts +60 -0
  17. package/src/extensibility/hooks/types.ts +60 -0
  18. package/src/extensibility/skills.ts +4 -2
  19. package/src/lsp/render.ts +1 -1
  20. package/src/main.ts +7 -1
  21. package/src/memories/index.ts +11 -7
  22. package/src/modes/components/bash-execution.ts +16 -9
  23. package/src/modes/components/custom-editor.ts +8 -0
  24. package/src/modes/components/python-execution.ts +16 -7
  25. package/src/modes/components/settings-selector.ts +29 -14
  26. package/src/modes/components/tool-execution.ts +2 -1
  27. package/src/modes/controllers/command-controller.ts +3 -1
  28. package/src/modes/controllers/event-controller.ts +7 -0
  29. package/src/modes/controllers/input-controller.ts +23 -2
  30. package/src/modes/controllers/selector-controller.ts +9 -7
  31. package/src/modes/interactive-mode.ts +84 -1
  32. package/src/modes/rpc/rpc-client.ts +7 -0
  33. package/src/modes/rpc/rpc-mode.ts +8 -0
  34. package/src/modes/rpc/rpc-types.ts +2 -0
  35. package/src/modes/theme/theme.ts +163 -7
  36. package/src/modes/types.ts +1 -0
  37. package/src/patch/hashline.ts +2 -1
  38. package/src/patch/shared.ts +44 -13
  39. package/src/prompts/system/plan-mode-approved.md +5 -0
  40. package/src/prompts/system/subagent-system-prompt.md +1 -0
  41. package/src/prompts/system/system-prompt.md +10 -0
  42. package/src/prompts/tools/todo-write.md +3 -1
  43. package/src/sdk.ts +82 -9
  44. package/src/session/agent-session.ts +137 -29
  45. package/src/session/streaming-output.ts +1 -1
  46. package/src/stt/downloader.ts +71 -0
  47. package/src/stt/index.ts +3 -0
  48. package/src/stt/recorder.ts +351 -0
  49. package/src/stt/setup.ts +52 -0
  50. package/src/stt/stt-controller.ts +160 -0
  51. package/src/stt/transcribe.py +70 -0
  52. package/src/stt/transcriber.ts +91 -0
  53. package/src/task/executor.ts +10 -2
  54. package/src/tools/bash-interactive.ts +10 -6
  55. package/src/tools/fetch.ts +1 -1
  56. package/src/tools/output-meta.ts +6 -2
  57. package/src/web/scrapers/types.ts +1 -0
@@ -15,7 +15,7 @@ import {
15
15
  Text,
16
16
  TUI,
17
17
  } from "@oh-my-pi/pi-tui";
18
- import { $env, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
18
+ import { $env, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
19
19
  import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
20
20
  import chalk from "chalk";
21
21
  import { KeybindingsManager } from "../config/keybindings";
@@ -30,6 +30,7 @@ import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
30
30
  import { HistoryStorage } from "../session/history-storage";
31
31
  import type { SessionContext, SessionManager } from "../session/session-manager";
32
32
  import { getRecentSessions } from "../session/session-manager";
33
+ import { STTController, type SttState } from "../stt";
33
34
  import type { ExitPlanModeDetails } from "../tools";
34
35
  import { setTerminalTitle } from "../utils/title-generator";
35
36
  import type { AssistantMessageComponent } from "./components/assistant-message";
@@ -152,6 +153,11 @@ export class InteractiveMode implements InteractiveModeContext {
152
153
  readonly #inputController: InputController;
153
154
  readonly #selectorController: SelectorController;
154
155
  readonly #uiHelpers: UiHelpers;
156
+ #sttController: STTController | undefined;
157
+ #voiceAnimationInterval: NodeJS.Timeout | undefined;
158
+ #voiceHue = 0;
159
+ #voicePreviousShowHardwareCursor: boolean | null = null;
160
+ #voicePreviousUseTerminalCursor: boolean | null = null;
155
161
 
156
162
  constructor(
157
163
  session: AgentSession,
@@ -709,6 +715,11 @@ export class InteractiveMode implements InteractiveModeContext {
709
715
  this.loadingAnimation.stop();
710
716
  this.loadingAnimation = undefined;
711
717
  }
718
+ this.#cleanupMicAnimation();
719
+ if (this.#sttController) {
720
+ this.#sttController.dispose();
721
+ this.#sttController = undefined;
722
+ }
712
723
  this.statusLine.dispose();
713
724
  if (this.unsubscribe) {
714
725
  this.unsubscribe();
@@ -923,6 +934,78 @@ export class InteractiveMode implements InteractiveModeContext {
923
934
  return this.#commandController.handleMemoryCommand(text);
924
935
  }
925
936
 
937
+ async handleSTTToggle(): Promise<void> {
938
+ if (!settings.get("stt.enabled")) {
939
+ this.showWarning("Speech-to-text is disabled. Enable it in settings: stt.enabled");
940
+ return;
941
+ }
942
+ if (!this.#sttController) {
943
+ this.#sttController = new STTController();
944
+ }
945
+ await this.#sttController.toggle(this.editor, {
946
+ showWarning: (msg: string) => this.showWarning(msg),
947
+ showStatus: (msg: string) => this.showStatus(msg),
948
+ onStateChange: (state: SttState) => {
949
+ if (state === "recording") {
950
+ this.#voicePreviousShowHardwareCursor = this.ui.getShowHardwareCursor();
951
+ this.#voicePreviousUseTerminalCursor = this.editor.getUseTerminalCursor();
952
+ this.ui.setShowHardwareCursor(false);
953
+ this.editor.setUseTerminalCursor(false);
954
+ this.#startMicAnimation();
955
+ } else if (state === "transcribing") {
956
+ this.#stopMicAnimation();
957
+ this.editor.cursorOverride = `\x1b[38;2;200;200;200m${theme.icon.mic}\x1b[0m`;
958
+ this.editor.cursorOverrideWidth = 1;
959
+ } else {
960
+ this.#cleanupMicAnimation();
961
+ }
962
+ this.updateEditorTopBorder();
963
+ this.ui.requestRender();
964
+ },
965
+ });
966
+ }
967
+
968
+ #updateMicIcon(): void {
969
+ const { r, g, b } = hsvToRgb({ h: this.#voiceHue, s: 0.9, v: 1.0 });
970
+ this.editor.cursorOverride = `\x1b[38;2;${r};${g};${b}m${theme.icon.mic}\x1b[0m`;
971
+ this.editor.cursorOverrideWidth = 1;
972
+ }
973
+
974
+ #startMicAnimation(): void {
975
+ if (this.#voiceAnimationInterval) return;
976
+ this.#voiceHue = 0;
977
+ this.#updateMicIcon();
978
+ this.#voiceAnimationInterval = setInterval(() => {
979
+ this.#voiceHue = (this.#voiceHue + 8) % 360;
980
+ this.#updateMicIcon();
981
+ this.ui.requestRender();
982
+ }, 60);
983
+ }
984
+
985
+ #stopMicAnimation(): void {
986
+ if (this.#voiceAnimationInterval) {
987
+ clearInterval(this.#voiceAnimationInterval);
988
+ this.#voiceAnimationInterval = undefined;
989
+ }
990
+ }
991
+
992
+ #cleanupMicAnimation(): void {
993
+ if (this.#voiceAnimationInterval) {
994
+ clearInterval(this.#voiceAnimationInterval);
995
+ this.#voiceAnimationInterval = undefined;
996
+ }
997
+ this.editor.cursorOverride = undefined;
998
+ this.editor.cursorOverrideWidth = undefined;
999
+ if (this.#voicePreviousShowHardwareCursor !== null) {
1000
+ this.ui.setShowHardwareCursor(this.#voicePreviousShowHardwareCursor);
1001
+ this.#voicePreviousShowHardwareCursor = null;
1002
+ }
1003
+ if (this.#voicePreviousUseTerminalCursor !== null) {
1004
+ this.editor.setUseTerminalCursor(this.#voicePreviousUseTerminalCursor);
1005
+ this.#voicePreviousUseTerminalCursor = null;
1006
+ }
1007
+ }
1008
+
926
1009
  showDebugSelector(): void {
927
1010
  this.#selectorController.showDebugSelector();
928
1011
  }
@@ -207,6 +207,13 @@ export class RpcClient {
207
207
  await this.#send({ type: "abort" });
208
208
  }
209
209
 
210
+ /**
211
+ * Abort current operation and immediately start a new turn with the given message.
212
+ */
213
+ async abortAndPrompt(message: string, images?: ImageContent[]): Promise<void> {
214
+ await this.#send({ type: "abort_and_prompt", message, images });
215
+ }
216
+
210
217
  /**
211
218
  * Start a new session, optionally with parent tracking.
212
219
  * @param parentSession - Optional parent session path for lineage tracking
@@ -449,6 +449,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
449
449
  return success(id, "abort");
450
450
  }
451
451
 
452
+ case "abort_and_prompt": {
453
+ await session.abort();
454
+ session
455
+ .prompt(command.message, { images: command.images })
456
+ .catch(e => output(error(id, "abort_and_prompt", e.message)));
457
+ return success(id, "abort_and_prompt");
458
+ }
459
+
452
460
  case "new_session": {
453
461
  const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
454
462
  const cancelled = !(await session.newSession(options));
@@ -20,6 +20,7 @@ export type RpcCommand =
20
20
  | { id?: string; type: "steer"; message: string; images?: ImageContent[] }
21
21
  | { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
22
22
  | { id?: string; type: "abort" }
23
+ | { id?: string; type: "abort_and_prompt"; message: string; images?: ImageContent[] }
23
24
  | { id?: string; type: "new_session"; parentSession?: string }
24
25
 
25
26
  // State
@@ -94,6 +95,7 @@ export type RpcResponse =
94
95
  | { id?: string; type: "response"; command: "steer"; success: true }
95
96
  | { id?: string; type: "response"; command: "follow_up"; success: true }
96
97
  | { id?: string; type: "response"; command: "abort"; success: true }
98
+ | { id?: string; type: "response"; command: "abort_and_prompt"; success: true }
97
99
  | { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } }
98
100
 
99
101
  // State
@@ -114,6 +114,8 @@ export type SymbolKey =
114
114
  | "icon.extensionPrompt"
115
115
  | "icon.extensionContextFile"
116
116
  | "icon.extensionInstruction"
117
+ // STT
118
+ | "icon.mic"
117
119
  // Thinking Levels
118
120
  | "thinking.minimal"
119
121
  | "thinking.low"
@@ -271,6 +273,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
271
273
  "icon.extensionPrompt": "✎",
272
274
  "icon.extensionContextFile": "📎",
273
275
  "icon.extensionInstruction": "📘",
276
+ // STT
277
+ "icon.mic": "🎤",
274
278
  // Thinking levels
275
279
  "thinking.minimal": "◔ min",
276
280
  "thinking.low": "◑ low",
@@ -507,6 +511,8 @@ const NERD_SYMBOLS: SymbolMap = {
507
511
  "icon.extensionContextFile": "\uf0f6",
508
512
  // pick:  | alt:  
509
513
  "icon.extensionInstruction": "\uf02d",
514
+ // STT - fa-microphone
515
+ "icon.mic": "\uf130",
510
516
  // Thinking Levels - emoji labels
511
517
  // pick: 🤨 min | alt:  min  min
512
518
  "thinking.minimal": "\u{F0E7} min",
@@ -676,6 +682,8 @@ const ASCII_SYMBOLS: SymbolMap = {
676
682
  "icon.extensionPrompt": "PR",
677
683
  "icon.extensionContextFile": "CF",
678
684
  "icon.extensionInstruction": "IN",
685
+ // STT
686
+ "icon.mic": "MIC",
679
687
  // Thinking Levels
680
688
  "thinking.minimal": "[min]",
681
689
  "thinking.low": "[low]",
@@ -1374,6 +1382,7 @@ export class Theme {
1374
1382
  extensionPrompt: this.#symbols["icon.extensionPrompt"],
1375
1383
  extensionContextFile: this.#symbols["icon.extensionContextFile"],
1376
1384
  extensionInstruction: this.#symbols["icon.extensionInstruction"],
1385
+ mic: this.#symbols["icon.mic"],
1377
1386
  };
1378
1387
  }
1379
1388
 
@@ -1624,7 +1633,8 @@ function detectTerminalBackground(): "dark" | "light" {
1624
1633
  }
1625
1634
 
1626
1635
  function getDefaultTheme(): string {
1627
- return detectTerminalBackground();
1636
+ const bg = detectTerminalBackground();
1637
+ return bg === "light" ? autoLightTheme : autoDarkTheme;
1628
1638
  }
1629
1639
 
1630
1640
  // ============================================================================
@@ -1633,10 +1643,20 @@ function getDefaultTheme(): string {
1633
1643
 
1634
1644
  export var theme: Theme;
1635
1645
  var currentThemeName: string | undefined;
1646
+
1647
+ /** Get the name of the currently active theme. */
1648
+ export function getCurrentThemeName(): string | undefined {
1649
+ return currentThemeName;
1650
+ }
1636
1651
  var currentSymbolPresetOverride: SymbolPreset | undefined;
1637
1652
  var currentColorBlindMode: boolean = false;
1638
1653
  var themeWatcher: fs.FSWatcher | undefined;
1654
+ var sigwinchHandler: (() => void) | undefined;
1655
+ var autoDetectedTheme: boolean = false;
1656
+ var autoDarkTheme: string = "dark";
1657
+ var autoLightTheme: string = "light";
1639
1658
  var onThemeChangeCallback: (() => void) | undefined;
1659
+ var themeLoadRequestId: number = 0;
1640
1660
 
1641
1661
  function getCurrentThemeOptions(): CreateThemeOptions {
1642
1662
  return {
@@ -1646,12 +1666,16 @@ function getCurrentThemeOptions(): CreateThemeOptions {
1646
1666
  }
1647
1667
 
1648
1668
  export async function initTheme(
1649
- themeName?: string,
1650
1669
  enableWatcher: boolean = false,
1651
1670
  symbolPreset?: SymbolPreset,
1652
1671
  colorBlindMode?: boolean,
1672
+ darkTheme?: string,
1673
+ lightTheme?: string,
1653
1674
  ): Promise<void> {
1654
- const name = themeName ?? getDefaultTheme();
1675
+ autoDetectedTheme = true;
1676
+ autoDarkTheme = darkTheme ?? "dark";
1677
+ autoLightTheme = lightTheme ?? "light";
1678
+ const name = getDefaultTheme();
1655
1679
  currentThemeName = name;
1656
1680
  currentSymbolPresetOverride = symbolPreset;
1657
1681
  currentColorBlindMode = colorBlindMode ?? false;
@@ -1659,6 +1683,7 @@ export async function initTheme(
1659
1683
  theme = await loadTheme(name, getCurrentThemeOptions());
1660
1684
  if (enableWatcher) {
1661
1685
  await startThemeWatcher();
1686
+ startSigwinchListener();
1662
1687
  }
1663
1688
  } catch (err) {
1664
1689
  logger.debug("Theme loading failed, falling back to dark theme", { error: String(err) });
@@ -1672,9 +1697,15 @@ export async function setTheme(
1672
1697
  name: string,
1673
1698
  enableWatcher: boolean = false,
1674
1699
  ): Promise<{ success: boolean; error?: string }> {
1700
+ autoDetectedTheme = false;
1675
1701
  currentThemeName = name;
1702
+ const requestId = ++themeLoadRequestId;
1676
1703
  try {
1677
- theme = await loadTheme(name, getCurrentThemeOptions());
1704
+ const loadedTheme = await loadTheme(name, getCurrentThemeOptions());
1705
+ if (requestId !== themeLoadRequestId) {
1706
+ return { success: false, error: "Theme change superseded by a newer request" };
1707
+ }
1708
+ theme = loadedTheme;
1678
1709
  if (enableWatcher) {
1679
1710
  await startThemeWatcher();
1680
1711
  }
@@ -1683,6 +1714,9 @@ export async function setTheme(
1683
1714
  }
1684
1715
  return { success: true };
1685
1716
  } catch (error) {
1717
+ if (requestId !== themeLoadRequestId) {
1718
+ return { success: false, error: "Theme change superseded by a newer request" };
1719
+ }
1686
1720
  // Theme is invalid - fall back to dark theme
1687
1721
  currentThemeName = "dark";
1688
1722
  theme = await loadTheme("dark", getCurrentThemeOptions());
@@ -1694,7 +1728,74 @@ export async function setTheme(
1694
1728
  }
1695
1729
  }
1696
1730
 
1731
+ export async function previewTheme(name: string): Promise<{ success: boolean; error?: string }> {
1732
+ const requestId = ++themeLoadRequestId;
1733
+ try {
1734
+ const loadedTheme = await loadTheme(name, getCurrentThemeOptions());
1735
+ if (requestId !== themeLoadRequestId) {
1736
+ return { success: false, error: "Theme preview superseded by a newer request" };
1737
+ }
1738
+ theme = loadedTheme;
1739
+ if (onThemeChangeCallback) {
1740
+ onThemeChangeCallback();
1741
+ }
1742
+ return { success: true };
1743
+ } catch (error) {
1744
+ if (requestId !== themeLoadRequestId) {
1745
+ return { success: false, error: "Theme preview superseded by a newer request" };
1746
+ }
1747
+ return {
1748
+ success: false,
1749
+ error: error instanceof Error ? error.message : String(error),
1750
+ };
1751
+ }
1752
+ }
1753
+
1754
+ /**
1755
+ * Enable auto-detection mode, switching to the appropriate dark/light theme.
1756
+ */
1757
+ export function enableAutoTheme(): void {
1758
+ autoDetectedTheme = true;
1759
+ const resolved = getDefaultTheme();
1760
+ if (resolved === currentThemeName) return;
1761
+ currentThemeName = resolved;
1762
+ loadTheme(resolved, getCurrentThemeOptions())
1763
+ .then(loadedTheme => {
1764
+ theme = loadedTheme;
1765
+ if (onThemeChangeCallback) {
1766
+ onThemeChangeCallback();
1767
+ }
1768
+ })
1769
+ .catch(err => {
1770
+ logger.debug("Auto theme switch failed", { error: String(err) });
1771
+ });
1772
+ }
1773
+
1774
+ /**
1775
+ * Update the theme mappings for auto-detection mode.
1776
+ * When a dark/light mapping changes and auto-detection is active, re-evaluate the theme.
1777
+ */
1778
+ export function setAutoThemeMapping(mode: "dark" | "light", themeName: string): void {
1779
+ if (mode === "dark") autoDarkTheme = themeName;
1780
+ else autoLightTheme = themeName;
1781
+ if (!autoDetectedTheme) return;
1782
+ const resolved = getDefaultTheme();
1783
+ if (resolved === currentThemeName) return;
1784
+ currentThemeName = resolved;
1785
+ loadTheme(resolved, getCurrentThemeOptions())
1786
+ .then(loadedTheme => {
1787
+ theme = loadedTheme;
1788
+ if (onThemeChangeCallback) {
1789
+ onThemeChangeCallback();
1790
+ }
1791
+ })
1792
+ .catch(err => {
1793
+ logger.debug("Auto theme mapping switch failed", { error: String(err) });
1794
+ });
1795
+ }
1796
+
1697
1797
  export function setThemeInstance(themeInstance: Theme): void {
1798
+ autoDetectedTheme = false;
1698
1799
  theme = themeInstance;
1699
1800
  currentThemeName = "<in-memory>";
1700
1801
  stopThemeWatcher();
@@ -1836,11 +1937,41 @@ async function startThemeWatcher(): Promise<void> {
1836
1937
  }
1837
1938
  }
1838
1939
 
1940
+ /** Re-check COLORFGBG on SIGWINCH and switch dark/light when using auto-detected theme. */
1941
+ function startSigwinchListener(): void {
1942
+ stopSigwinchListener();
1943
+ sigwinchHandler = () => {
1944
+ if (!autoDetectedTheme) return;
1945
+ const resolved = getDefaultTheme();
1946
+ if (resolved === currentThemeName) return;
1947
+ currentThemeName = resolved;
1948
+ loadTheme(resolved, getCurrentThemeOptions())
1949
+ .then(loadedTheme => {
1950
+ theme = loadedTheme;
1951
+ if (onThemeChangeCallback) {
1952
+ onThemeChangeCallback();
1953
+ }
1954
+ })
1955
+ .catch(err => {
1956
+ logger.debug("Theme switch on SIGWINCH failed", { error: String(err) });
1957
+ });
1958
+ };
1959
+ process.on("SIGWINCH", sigwinchHandler);
1960
+ }
1961
+
1962
+ function stopSigwinchListener(): void {
1963
+ if (sigwinchHandler) {
1964
+ process.removeListener("SIGWINCH", sigwinchHandler);
1965
+ sigwinchHandler = undefined;
1966
+ }
1967
+ }
1968
+
1839
1969
  export function stopThemeWatcher(): void {
1840
1970
  if (themeWatcher) {
1841
1971
  themeWatcher.close();
1842
1972
  themeWatcher = undefined;
1843
1973
  }
1974
+ stopSigwinchListener();
1844
1975
  }
1845
1976
 
1846
1977
  // ============================================================================
@@ -1921,11 +2052,36 @@ export async function getResolvedThemeColors(themeName?: string): Promise<Record
1921
2052
  }
1922
2053
 
1923
2054
  /**
1924
- * Check if a theme is a "light" theme (for CSS that needs light/dark variants).
2055
+ * Check if a theme is a "light" theme by analyzing its background color luminance.
2056
+ * Loads theme JSON synchronously (built-in or custom file) and resolves userMessageBg.
1925
2057
  */
1926
2058
  export function isLightTheme(themeName?: string): boolean {
1927
- // Currently just check the name - could be extended to analyze colors
1928
- return themeName === "light";
2059
+ const name = themeName ?? "dark";
2060
+ const builtinThemes = getBuiltinThemes();
2061
+ let themeJson: ThemeJson | undefined;
2062
+ if (name in builtinThemes) {
2063
+ themeJson = builtinThemes[name];
2064
+ } else {
2065
+ try {
2066
+ const customPath = path.join(getCustomThemesDir(), `${name}.json`);
2067
+ const content = fs.readFileSync(customPath, "utf-8");
2068
+ themeJson = JSON.parse(content) as ThemeJson;
2069
+ } catch {
2070
+ return false;
2071
+ }
2072
+ }
2073
+ try {
2074
+ const resolved = resolveVarRefs(themeJson.colors.userMessageBg, themeJson.vars ?? {});
2075
+ if (typeof resolved !== "string" || !resolved.startsWith("#") || resolved.length !== 7) return false;
2076
+ const r = parseInt(resolved.slice(1, 3), 16) / 255;
2077
+ const g = parseInt(resolved.slice(3, 5), 16) / 255;
2078
+ const b = parseInt(resolved.slice(5, 7), 16) / 255;
2079
+ // Relative luminance (ITU-R BT.709)
2080
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
2081
+ return luminance > 0.5;
2082
+ } catch {
2083
+ return false;
2084
+ }
1929
2085
  }
1930
2086
 
1931
2087
  /**
@@ -152,6 +152,7 @@ export interface InteractiveModeContext {
152
152
  handleHandoffCommand(customInstructions?: string): Promise<void>;
153
153
  handleMoveCommand(targetPath: string): Promise<void>;
154
154
  handleMemoryCommand(text: string): Promise<void>;
155
+ handleSTTToggle(): Promise<void>;
155
156
  executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;
156
157
  openInBrowser(urlOrPath: string): void;
157
158
  refreshSlashCommandState(cwd?: string): Promise<void>;
@@ -56,7 +56,7 @@ function splitDstLines(dst: string): string[] {
56
56
  }
57
57
 
58
58
  /** Pattern matching hashline display format: `LINE:HASH|CONTENT` */
59
- const HASHLINE_PREFIX_RE = /^\d+:[0-9a-zA-Z]{1,16}\|/;
59
+ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+:[0-9a-zA-Z]{1,16}\|/;
60
60
 
61
61
  /** Pattern matching a unified-diff `+` prefix (but not `++`) */
62
62
  const DIFF_PLUS_RE = /^\+(?!\+)/;
@@ -508,6 +508,7 @@ export function parseLineRef(ref: string): { line: number; hash: string } {
508
508
  const cleaned = ref
509
509
  .replace(/\|.*$/, "")
510
510
  .replace(/ {2}.*$/, "")
511
+ .replace(/^>+\s*/, "")
511
512
  .trim();
512
513
  const normalized = cleaned.replace(/\s*:\s*/, ":");
513
514
  const strictMatch = normalized.match(/^(\d+):([0-9a-zA-Z]{1,16})$/);
@@ -121,7 +121,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
121
121
  return text;
122
122
  }
123
123
 
124
- function formatStreamingHashlineEdits(edits: HashlineEditPreview[], uiTheme: Theme, ui: ToolUIKit): string {
124
+ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme, ui: ToolUIKit): string {
125
125
  const MAX_EDITS = 4;
126
126
  const MAX_DST_LINES = 8;
127
127
  let text = "\n\n";
@@ -156,28 +156,59 @@ function formatStreamingHashlineEdits(edits: HashlineEditPreview[], uiTheme: The
156
156
  }
157
157
 
158
158
  return text.trimEnd();
159
- function formatHashlineEdit(edit: HashlineEditPreview): { srcLabel: string; dst: string } {
160
- if ("set_line" in edit) {
159
+ function formatHashlineEdit(edit: unknown): { srcLabel: string; dst: string } {
160
+ const asRecord = (value: unknown): Record<string, unknown> | undefined => {
161
+ if (typeof value === "object" && value !== null) return value as Record<string, unknown>;
162
+ return undefined;
163
+ };
164
+ const editRecord = asRecord(edit);
165
+ if (!editRecord) {
166
+ return {
167
+ srcLabel: "• (incomplete edit)",
168
+ dst: "",
169
+ };
170
+ }
171
+ if ("set_line" in editRecord) {
172
+ const setLine = asRecord(editRecord.set_line);
173
+ return {
174
+ srcLabel: `• set_line ${typeof setLine?.anchor === "string" ? setLine.anchor : "…"}`,
175
+ dst: typeof setLine?.new_text === "string" ? setLine.new_text : "",
176
+ };
177
+ }
178
+ if ("replace_lines" in editRecord) {
179
+ const replaceLines = asRecord(editRecord.replace_lines);
180
+ const start = typeof replaceLines?.start_anchor === "string" ? replaceLines.start_anchor : "…";
181
+ const end = typeof replaceLines?.end_anchor === "string" ? replaceLines.end_anchor : "…";
161
182
  return {
162
- srcLabel: `• set_line ${edit.set_line.anchor}`,
163
- dst: edit.set_line.new_text,
183
+ srcLabel: `• replace_lines ${start}..${end}`,
184
+ dst: typeof replaceLines?.new_text === "string" ? replaceLines.new_text : "",
164
185
  };
165
186
  }
166
- if ("replace_lines" in edit) {
187
+ if ("replace" in editRecord) {
188
+ const replace = asRecord(editRecord.replace);
189
+ const all = typeof replace?.all === "boolean" ? replace.all : false;
167
190
  return {
168
- srcLabel: `• replace_lines ${edit.replace_lines.start_anchor}..${edit.replace_lines.end_anchor}`,
169
- dst: edit.replace_lines.new_text,
191
+ srcLabel: `• replace old_text→new_text${all ? " (all)" : ""}`,
192
+ dst: typeof replace?.new_text === "string" ? replace.new_text : "",
170
193
  };
171
194
  }
172
- if ("replace" in edit) {
195
+ if ("insert_after" in editRecord) {
196
+ const insertAfter = asRecord(editRecord.insert_after);
197
+ const anchor = typeof insertAfter?.anchor === "string" ? insertAfter.anchor : "…";
198
+ const text =
199
+ typeof insertAfter?.text === "string"
200
+ ? insertAfter.text
201
+ : typeof insertAfter?.content === "string"
202
+ ? insertAfter.content
203
+ : "";
173
204
  return {
174
- srcLabel: `• replace old_text→new_text${edit.replace.all ? " (all)" : ""}`,
175
- dst: edit.replace.new_text,
205
+ srcLabel: `• insert_after ${anchor}..`,
206
+ dst: text,
176
207
  };
177
208
  }
178
209
  return {
179
- srcLabel: `• insert_after ${edit.insert_after.anchor}..`,
180
- dst: edit.insert_after.text,
210
+ srcLabel: "• (incomplete edit)",
211
+ dst: "",
181
212
  };
182
213
  }
183
214
  }
@@ -9,6 +9,11 @@ Plan approved. Execute it now.
9
9
  <instruction>
10
10
  Execute this plan step by step. You have full tool access.
11
11
  Verify each step before proceeding to the next.
12
+ {{#has tools "todo_write"}}
13
+ Before execution, initialize todo tracking for this plan with `todo_write`.
14
+ After each completed step, immediately update `todo_write` so progress stays visible.
15
+ If a `todo_write` call fails, fix the todo payload and retry before continuing silently.
16
+ {{/has}}
12
17
  </instruction>
13
18
 
14
19
  <critical>
@@ -15,6 +15,7 @@ For additional parent conversation context, check {{contextFile}} (`tail -100` o
15
15
  - MUST work under working tree: {{worktree}}. Do not modify original repository.
16
16
  {{/if}}
17
17
  - MUST call `submit_result` exactly once when finished. No JSON in text. No plain-text summary. Pass result via `data` parameter.
18
+ - Todo tracking is parent-owned. Do not create or maintain a separate todo list in this subagent.
18
19
  {{#if outputSchema}}
19
20
  - If cannot complete, call `submit_result` with `status="aborted"` and error message. Do not provide success result or pretend completion.
20
21
  {{else}}
@@ -123,6 +123,16 @@ Don't open a file hoping. Hope is not a strategy.
123
123
  **If blocked**: exhaust tools/context/files first. Only then ask — minimum viable question.
124
124
  **If requested change includes refactor**: remove now-unused elements. Note removals.
125
125
 
126
+ {{#has tools "todo_write"}}
127
+ ### Task Tracking
128
+ - Use `todo_write` proactively for non-trivial, multi-step work so progress stays visible.
129
+ - Initialize todos before implementation for complex tasks, then keep them current while working.
130
+ - Mark todo items complete immediately after finishing them; do not batch completion updates.
131
+ - Keep todo items as focused logical units (one coherent outcome per item); split broad work into smaller items.
132
+ - Keep exactly one item `in_progress` at a time and complete in order unless requirements change.
133
+ - Skip `todo_write` for single trivial or purely informational requests.
134
+ {{/has}}
135
+
126
136
  ### Verification
127
137
  - Prefer external proof: tests, linters, type checks, repro steps.
128
138
  - If unverified: state what to run and expected result.
@@ -20,14 +20,16 @@ Use proactively:
20
20
  2. **Task Management**:
21
21
  - Update status in real time
22
22
  - Mark complete IMMEDIATELY after finishing (no batching)
23
- - Multiple tasks may be in_progress in parallel
23
+ - Keep exactly ONE task in_progress at a time
24
24
  - Remove tasks no longer relevant
25
+ - Complete tasks in list order (do not mark later tasks completed while earlier tasks remain incomplete)
25
26
  3. **Task Completion Requirements**:
26
27
  - ONLY mark completed when FULLY accomplished
27
28
  - On errors/blockers/inability to finish, keep in_progress
28
29
  - When blocked, create task describing what needs resolving
29
30
  4. **Task Breakdown**:
30
31
  - Create specific, actionable items
32
+ - Keep each todo scoped to one logical unit of work; split unrelated work into separate items
31
33
  - Break complex tasks into smaller steps
32
34
  - Use clear, descriptive names
33
35
  </protocol>