@oh-my-pi/pi-coding-agent 13.3.7 → 13.3.8

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 (49) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +9 -18
  3. package/scripts/format-prompts.ts +7 -172
  4. package/src/config/prompt-templates.ts +2 -54
  5. package/src/config/settings-schema.ts +24 -0
  6. package/src/discovery/codex.ts +1 -2
  7. package/src/discovery/helpers.ts +0 -5
  8. package/src/lsp/client.ts +8 -0
  9. package/src/lsp/config.ts +2 -3
  10. package/src/lsp/index.ts +379 -99
  11. package/src/lsp/render.ts +21 -31
  12. package/src/lsp/types.ts +21 -8
  13. package/src/lsp/utils.ts +193 -1
  14. package/src/mcp/config-writer.ts +3 -0
  15. package/src/modes/components/settings-defs.ts +9 -0
  16. package/src/modes/interactive-mode.ts +8 -1
  17. package/src/modes/theme/mermaid-cache.ts +4 -4
  18. package/src/modes/theme/theme.ts +33 -0
  19. package/src/prompts/system/subagent-user-prompt.md +2 -0
  20. package/src/prompts/system/system-prompt.md +12 -1
  21. package/src/prompts/tools/ast-find.md +20 -0
  22. package/src/prompts/tools/ast-replace.md +21 -0
  23. package/src/prompts/tools/bash.md +2 -0
  24. package/src/prompts/tools/hashline.md +26 -8
  25. package/src/prompts/tools/lsp.md +22 -5
  26. package/src/sdk.ts +11 -1
  27. package/src/session/agent-session.ts +261 -82
  28. package/src/task/executor.ts +8 -5
  29. package/src/tools/ast-find.ts +316 -0
  30. package/src/tools/ast-replace.ts +294 -0
  31. package/src/tools/bash.ts +2 -1
  32. package/src/tools/browser.ts +2 -8
  33. package/src/tools/fetch.ts +55 -18
  34. package/src/tools/index.ts +8 -0
  35. package/src/tools/path-utils.ts +34 -0
  36. package/src/tools/python.ts +2 -1
  37. package/src/tools/renderers.ts +4 -0
  38. package/src/tools/ssh.ts +2 -1
  39. package/src/tools/todo-write.ts +34 -0
  40. package/src/tools/tool-timeouts.ts +29 -0
  41. package/src/utils/mime.ts +37 -14
  42. package/src/utils/prompt-format.ts +172 -0
  43. package/src/web/scrapers/arxiv.ts +12 -12
  44. package/src/web/scrapers/go-pkg.ts +2 -2
  45. package/src/web/scrapers/iacr.ts +17 -9
  46. package/src/web/scrapers/readthedocs.ts +3 -3
  47. package/src/web/scrapers/twitter.ts +11 -11
  48. package/src/web/scrapers/wikipedia.ts +4 -5
  49. package/src/utils/ignore-files.ts +0 -119
package/src/lsp/render.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  formatExpandHint,
16
16
  formatMoreItems,
17
17
  formatStatusIcon,
18
+ replaceTabs,
18
19
  shortenPath,
19
20
  TRUNCATE_LENGTHS,
20
21
  truncateToWidth,
@@ -31,9 +32,16 @@ import type { LspParams, LspToolDetails } from "./types";
31
32
  * Render the LSP tool call in the TUI.
32
33
  * Shows: "lsp <operation> <file/filecount>"
33
34
  */
35
+ function sanitizeInlineText(value: string): string {
36
+ return replaceTabs(value).replaceAll(/\r?\n/g, " ");
37
+ }
38
+
34
39
  export function renderCall(args: LspParams, _options: RenderResultOptions, theme: Theme): Text {
35
40
  const actionLabel = (args.action ?? "request").replace(/_/g, " ");
36
41
  const queryPreview = args.query ? truncateToWidth(args.query, TRUNCATE_LENGTHS.SHORT) : undefined;
42
+ const symbolPreview = args.symbol
43
+ ? truncateToWidth(sanitizeInlineText(args.symbol), TRUNCATE_LENGTHS.SHORT)
44
+ : undefined;
37
45
 
38
46
  let target: string | undefined;
39
47
  let hasFileTarget = false;
@@ -41,26 +49,17 @@ export function renderCall(args: LspParams, _options: RenderResultOptions, theme
41
49
  if (args.file) {
42
50
  target = shortenPath(args.file);
43
51
  hasFileTarget = true;
44
- } else if (args.files?.length === 1) {
45
- target = shortenPath(args.files[0]);
46
- hasFileTarget = true;
47
- } else if (args.files?.length) {
48
- target = `${args.files.length} files`;
49
52
  }
50
53
 
51
54
  if (hasFileTarget && args.line !== undefined) {
52
- const col = args.column !== undefined ? `:${args.column}` : "";
53
- target += `:${args.line}${col}`;
54
- if (args.end_line !== undefined) {
55
- const endCol = args.end_character !== undefined ? `:${args.end_character}` : "";
56
- target += `-${args.end_line}${endCol}`;
55
+ target += `:${args.line}`;
56
+ if (symbolPreview) {
57
+ target += ` (${symbolPreview})`;
57
58
  }
58
59
  } else if (!target && args.line !== undefined) {
59
- const col = args.column !== undefined ? `:${args.column}` : "";
60
- target = `line ${args.line}${col}`;
61
- if (args.end_line !== undefined) {
62
- const endCol = args.end_character !== undefined ? `:${args.end_character}` : "";
63
- target += `-${args.end_line}${endCol}`;
60
+ target = `line ${args.line}`;
61
+ if (symbolPreview) {
62
+ target += ` (${symbolPreview})`;
64
63
  }
65
64
  }
66
65
 
@@ -68,9 +67,6 @@ export function renderCall(args: LspParams, _options: RenderResultOptions, theme
68
67
  if (queryPreview && target) meta.push(`query:${queryPreview}`);
69
68
  if (args.new_name) meta.push(`new:${args.new_name}`);
70
69
  if (args.apply !== undefined) meta.push(`apply:${args.apply ? "true" : "false"}`);
71
- if (args.include_declaration !== undefined) {
72
- meta.push(`include_decl:${args.include_declaration ? "true" : "false"}`);
73
- }
74
70
 
75
71
  const descriptionParts = [actionLabel];
76
72
  if (target) {
@@ -104,7 +100,7 @@ export function renderResult(
104
100
  result: { content: Array<{ type: string; text?: string }>; details?: LspToolDetails; isError?: boolean },
105
101
  options: RenderResultOptions,
106
102
  theme: Theme,
107
- args?: LspParams & { file?: string; files?: string[] },
103
+ args?: LspParams,
108
104
  ): Component {
109
105
  const content = result.content?.[0];
110
106
  if (!content || content.type !== "text" || !("text" in content) || !content.text) {
@@ -129,25 +125,19 @@ export function renderResult(
129
125
  const requestLines: string[] = [];
130
126
  if (request?.file) {
131
127
  requestLines.push(theme.fg("toolOutput", request.file));
132
- } else if (request?.files?.length === 1) {
133
- requestLines.push(theme.fg("toolOutput", request.files[0]));
134
- } else if (request?.files?.length) {
135
- requestLines.push(theme.fg("dim", `${request.files.length} file(s)`));
136
128
  }
137
129
  if (request?.line !== undefined) {
138
- const col = request.column !== undefined ? `:${request.column}` : "";
139
- requestLines.push(theme.fg("dim", `line ${request.line}${col}`));
130
+ requestLines.push(theme.fg("dim", `line ${request.line}`));
140
131
  }
141
- if (request?.end_line !== undefined) {
142
- const endCol = request.end_character !== undefined ? `:${request.end_character}` : "";
143
- requestLines.push(theme.fg("dim", `end ${request.end_line}${endCol}`));
132
+ if (request?.symbol) {
133
+ requestLines.push(theme.fg("dim", `symbol: ${sanitizeInlineText(request.symbol)}`));
134
+ if (request.occurrence !== undefined) {
135
+ requestLines.push(theme.fg("dim", `occurrence: ${request.occurrence}`));
136
+ }
144
137
  }
145
138
  if (request?.query) requestLines.push(theme.fg("dim", `query: ${request.query}`));
146
139
  if (request?.new_name) requestLines.push(theme.fg("dim", `new name: ${request.new_name}`));
147
140
  if (request?.apply !== undefined) requestLines.push(theme.fg("dim", `apply: ${request.apply ? "true" : "false"}`));
148
- if (request?.include_declaration !== undefined) {
149
- requestLines.push(theme.fg("dim", `include declaration: ${request.include_declaration ? "true" : "false"}`));
150
- }
151
141
 
152
142
  const outputBlock = new CachedOutputBlock();
153
143
 
package/src/lsp/types.ts CHANGED
@@ -7,19 +7,32 @@ import { type Static, Type } from "@sinclair/typebox";
7
7
  // =============================================================================
8
8
 
9
9
  export const lspSchema = Type.Object({
10
- action: StringEnum(["diagnostics", "definition", "references", "hover", "symbols", "rename", "status", "reload"], {
11
- description: "LSP operation",
12
- }),
13
- files: Type.Optional(Type.Array(Type.String({ description: "File path" }))),
10
+ action: StringEnum(
11
+ [
12
+ "diagnostics",
13
+ "definition",
14
+ "references",
15
+ "hover",
16
+ "symbols",
17
+ "rename",
18
+ "code_actions",
19
+ "type_definition",
20
+ "implementation",
21
+ "status",
22
+ "reload",
23
+ ],
24
+ { description: "LSP operation" },
25
+ ),
14
26
  file: Type.Optional(Type.String({ description: "File path" })),
15
27
  line: Type.Optional(Type.Number({ description: "Line number (1-indexed)" })),
16
- column: Type.Optional(Type.Number({ description: "Column number (1-indexed)" })),
17
- end_line: Type.Optional(Type.Number({ description: "End line for range (1-indexed)" })),
18
- end_character: Type.Optional(Type.Number({ description: "End column for range (1-indexed)" })),
28
+ symbol: Type.Optional(
29
+ Type.String({ description: "Symbol/substring to locate on the line (used to compute column)" }),
30
+ ),
31
+ occurrence: Type.Optional(Type.Number({ description: "Symbol occurrence on line (1-indexed, default: 1)" })),
19
32
  query: Type.Optional(Type.String({ description: "Search query or SSR pattern" })),
20
33
  new_name: Type.Optional(Type.String({ description: "New name for rename" })),
21
34
  apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true)" })),
22
- include_declaration: Type.Optional(Type.Boolean({ description: "Include declaration in refs (default: true)" })),
35
+ timeout: Type.Optional(Type.Number({ description: "Request timeout in seconds" })),
23
36
  });
24
37
 
25
38
  export type LspParams = Static<typeof lspSchema>;
package/src/lsp/utils.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export { truncate } from "@oh-my-pi/pi-utils";
2
2
 
3
3
  import path from "node:path";
4
+ import { isEnoent } from "@oh-my-pi/pi-utils";
4
5
  import { type Theme, theme } from "../modes/theme/theme";
5
6
  import type {
7
+ CodeAction,
8
+ Command,
6
9
  Diagnostic,
7
10
  DiagnosticSeverity,
8
11
  DocumentSymbol,
@@ -475,7 +478,8 @@ export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string
475
478
  const prefix = " ".repeat(indent);
476
479
  const icon = symbolKindToIcon(symbol.kind);
477
480
  const line = symbol.range.start.line + 1;
478
- const results = [`${prefix}${icon} ${symbol.name} @ line ${line}`];
481
+ const detail = symbol.detail ? ` ${symbol.detail}` : "";
482
+ const results = [`${prefix}${icon} ${symbol.name}${detail} @ line ${line}`];
479
483
 
480
484
  if (symbol.children) {
481
485
  for (const child of symbol.children) {
@@ -496,6 +500,110 @@ export function formatSymbolInformation(symbol: SymbolInformation, cwd: string):
496
500
  return `${icon} ${symbol.name}${container} @ ${location}`;
497
501
  }
498
502
 
503
+ export function filterWorkspaceSymbols(symbols: SymbolInformation[], query: string): SymbolInformation[] {
504
+ const needle = query.trim().toLowerCase();
505
+ if (!needle) return symbols;
506
+ return symbols.filter(symbol => {
507
+ const fields = [symbol.name, symbol.containerName ?? "", uriToFile(symbol.location.uri)];
508
+ return fields.some(field => field.toLowerCase().includes(needle));
509
+ });
510
+ }
511
+
512
+ export function dedupeWorkspaceSymbols(symbols: SymbolInformation[]): SymbolInformation[] {
513
+ const seen = new Set<string>();
514
+ const unique: SymbolInformation[] = [];
515
+ for (const symbol of symbols) {
516
+ const key = [
517
+ symbol.name,
518
+ symbol.containerName ?? "",
519
+ symbol.kind,
520
+ symbol.location.uri,
521
+ symbol.location.range.start.line,
522
+ symbol.location.range.start.character,
523
+ ].join(":");
524
+ if (seen.has(key)) continue;
525
+ seen.add(key);
526
+ unique.push(symbol);
527
+ }
528
+ return unique;
529
+ }
530
+
531
+ export function formatCodeAction(action: CodeAction | Command, index: number): string {
532
+ const kind = "kind" in action && action.kind ? action.kind : "action";
533
+ const preferred = "isPreferred" in action && action.isPreferred ? " (preferred)" : "";
534
+ const disabled = "disabled" in action && action.disabled ? ` (disabled: ${action.disabled.reason})` : "";
535
+ return `${index}: [${kind}] ${action.title}${preferred}${disabled}`;
536
+ }
537
+
538
+ export interface CodeActionApplyDependencies {
539
+ resolveCodeAction?: (action: CodeAction) => Promise<CodeAction>;
540
+ applyWorkspaceEdit: (edit: WorkspaceEdit) => Promise<string[]>;
541
+ executeCommand: (command: Command) => Promise<void>;
542
+ }
543
+
544
+ export interface AppliedCodeActionResult {
545
+ title: string;
546
+ edits: string[];
547
+ executedCommands: string[];
548
+ }
549
+
550
+ function isCommandItem(action: CodeAction | Command): action is Command {
551
+ return typeof action.command === "string";
552
+ }
553
+
554
+ export async function applyCodeAction(
555
+ action: CodeAction | Command,
556
+ dependencies: CodeActionApplyDependencies,
557
+ ): Promise<AppliedCodeActionResult | null> {
558
+ if (isCommandItem(action)) {
559
+ await dependencies.executeCommand(action);
560
+ return { title: action.title, edits: [], executedCommands: [action.command] };
561
+ }
562
+
563
+ let resolvedAction = action;
564
+ if (!resolvedAction.edit && dependencies.resolveCodeAction) {
565
+ try {
566
+ resolvedAction = await dependencies.resolveCodeAction(resolvedAction);
567
+ } catch {
568
+ // Resolve is optional; continue with unresolved action.
569
+ }
570
+ }
571
+
572
+ const edits = resolvedAction.edit ? await dependencies.applyWorkspaceEdit(resolvedAction.edit) : [];
573
+ const executedCommands: string[] = [];
574
+ if (resolvedAction.command) {
575
+ await dependencies.executeCommand(resolvedAction.command);
576
+ executedCommands.push(resolvedAction.command.command);
577
+ }
578
+
579
+ if (edits.length === 0 && executedCommands.length === 0) {
580
+ return null;
581
+ }
582
+
583
+ return { title: resolvedAction.title, edits, executedCommands };
584
+ }
585
+
586
+ const GLOB_PATTERN_CHARS = /[*?[{]/;
587
+
588
+ export function hasGlobPattern(value: string): boolean {
589
+ return GLOB_PATTERN_CHARS.test(value);
590
+ }
591
+
592
+ export async function collectGlobMatches(
593
+ pattern: string,
594
+ cwd: string,
595
+ maxMatches: number,
596
+ ): Promise<{ matches: string[]; truncated: boolean }> {
597
+ const normalizedLimit = Number.isFinite(maxMatches) ? Math.max(1, Math.trunc(maxMatches)) : 1;
598
+ const matches: string[] = [];
599
+ for await (const match of new Bun.Glob(pattern).scan({ cwd })) {
600
+ if (matches.length >= normalizedLimit) {
601
+ return { matches, truncated: true };
602
+ }
603
+ matches.push(match);
604
+ }
605
+ return { matches, truncated: false };
606
+ }
499
607
  // =============================================================================
500
608
  // Hover Content Extraction
501
609
  // =============================================================================
@@ -525,3 +633,87 @@ export function extractHoverText(
525
633
 
526
634
  // =============================================================================
527
635
  // General Utilities
636
+
637
+ function firstNonWhitespaceColumn(lineText: string): number {
638
+ const match = lineText.match(/\S/);
639
+ return match ? (match.index ?? 0) : 0;
640
+ }
641
+
642
+ function findSymbolMatchIndexes(lineText: string, symbol: string, caseInsensitive = false): number[] {
643
+ if (symbol.length === 0) return [];
644
+ const haystack = caseInsensitive ? lineText.toLowerCase() : lineText;
645
+ const needle = caseInsensitive ? symbol.toLowerCase() : symbol;
646
+ const indexes: number[] = [];
647
+ let fromIndex = 0;
648
+ while (fromIndex <= haystack.length - needle.length) {
649
+ const matchIndex = haystack.indexOf(needle, fromIndex);
650
+ if (matchIndex === -1) break;
651
+ indexes.push(matchIndex);
652
+ fromIndex = matchIndex + needle.length;
653
+ }
654
+ return indexes;
655
+ }
656
+
657
+ function normalizeOccurrence(occurrence?: number): number {
658
+ if (occurrence === undefined || !Number.isFinite(occurrence)) return 1;
659
+ return Math.max(1, Math.trunc(occurrence));
660
+ }
661
+
662
+ export async function resolveSymbolColumn(
663
+ filePath: string,
664
+ line: number,
665
+ symbol?: string,
666
+ occurrence?: number,
667
+ ): Promise<number> {
668
+ const lineNumber = Math.max(1, line);
669
+ const matchOccurrence = normalizeOccurrence(occurrence);
670
+ try {
671
+ const fileText = await Bun.file(filePath).text();
672
+ const lines = fileText.split("\n");
673
+ const targetLine = lines[lineNumber - 1] ?? "";
674
+ if (!symbol) {
675
+ return firstNonWhitespaceColumn(targetLine);
676
+ }
677
+
678
+ const exactIndexes = findSymbolMatchIndexes(targetLine, symbol);
679
+ const fallbackIndexes = exactIndexes.length > 0 ? exactIndexes : findSymbolMatchIndexes(targetLine, symbol, true);
680
+ if (fallbackIndexes.length === 0) {
681
+ throw new Error(`Symbol "${symbol}" not found on line ${lineNumber}`);
682
+ }
683
+ if (matchOccurrence > fallbackIndexes.length) {
684
+ throw new Error(
685
+ `Symbol "${symbol}" occurrence ${matchOccurrence} is out of bounds on line ${lineNumber} (found ${fallbackIndexes.length})`,
686
+ );
687
+ }
688
+ return fallbackIndexes[matchOccurrence - 1];
689
+ } catch (error) {
690
+ if (isEnoent(error)) {
691
+ throw new Error(`File not found: ${filePath}`);
692
+ }
693
+ throw error;
694
+ }
695
+ }
696
+
697
+ export async function readLocationContext(filePath: string, line: number, contextLines = 1): Promise<string[]> {
698
+ const targetLine = Math.max(1, line);
699
+ const surrounding = Math.max(0, contextLines);
700
+ try {
701
+ const fileText = await Bun.file(filePath).text();
702
+ const lines = fileText.split("\n");
703
+ if (lines.length === 0) return [];
704
+
705
+ const startLine = Math.max(1, targetLine - surrounding);
706
+ const endLine = Math.min(lines.length, targetLine + surrounding);
707
+ const context: string[] = [];
708
+ for (let currentLine = startLine; currentLine <= endLine; currentLine++) {
709
+ const content = lines[currentLine - 1] ?? "";
710
+ context.push(`${currentLine}: ${content}`);
711
+ }
712
+ return context;
713
+ } catch (error) {
714
+ if (isEnoent(error)) {
715
+ return [];
716
+ }
717
+ throw error;
718
+ }
719
+ }
@@ -6,6 +6,7 @@
6
6
  import * as fs from "node:fs";
7
7
  import * as path from "node:path";
8
8
  import { isEnoent } from "@oh-my-pi/pi-utils";
9
+ import { invalidate as invalidateFsCache } from "../capability/fs";
9
10
 
10
11
  import { validateServerConfig } from "./config";
11
12
  import type { MCPConfigFile, MCPServerConfig } from "./types";
@@ -44,6 +45,8 @@ export async function writeMCPConfigFile(filePath: string, config: MCPConfigFile
44
45
 
45
46
  // Rename to final path (atomic on most systems)
46
47
  await fs.promises.rename(tmpPath, filePath);
48
+ // Invalidate the capability fs cache so subsequent reads see the new content
49
+ invalidateFsCache(filePath);
47
50
  }
48
51
 
49
52
  /**
@@ -149,6 +149,15 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
149
149
  { value: "60", label: "60 seconds" },
150
150
  { value: "120", label: "120 seconds" },
151
151
  ],
152
+ // Global tool timeout ceiling
153
+ "tools.maxTimeout": [
154
+ { value: "0", label: "No limit" },
155
+ { value: "30", label: "30 seconds" },
156
+ { value: "60", label: "60 seconds" },
157
+ { value: "120", label: "120 seconds" },
158
+ { value: "300", label: "5 minutes" },
159
+ { value: "600", label: "10 minutes" },
160
+ ],
152
161
  // Edit fuzzy threshold
153
162
  "edit.fuzzyThreshold": [
154
163
  { value: "0.85", label: "0.85", description: "Lenient" },
@@ -54,7 +54,7 @@ import { SSHCommandController } from "./controllers/ssh-command-controller";
54
54
  import { OAuthManualInputManager } from "./oauth-manual-input";
55
55
  import { setMermaidRenderCallback } from "./theme/mermaid-cache";
56
56
  import type { Theme } from "./theme/theme";
57
- import { getEditorTheme, getMarkdownTheme, onThemeChange, theme } from "./theme/theme";
57
+ import { getEditorTheme, getMarkdownTheme, onTerminalAppearanceChange, onThemeChange, theme } from "./theme/theme";
58
58
  import type { CompactionQueuedMessage, InteractiveModeContext, TodoItem, TodoPhase } from "./types";
59
59
  import { UiHelpers } from "./utils/ui-helpers";
60
60
 
@@ -363,6 +363,13 @@ export class InteractiveMode implements InteractiveModeContext {
363
363
  this.ui.requestRender();
364
364
  });
365
365
 
366
+ // Subscribe to terminal Mode 2031 dark/light appearance change notifications.
367
+ // When the OS or terminal switches between dark and light mode, the terminal
368
+ // sends a DSR and we re-evaluate which theme to use.
369
+ this.ui.terminal.onAppearanceChange(mode => {
370
+ onTerminalAppearanceChange(mode);
371
+ });
372
+
366
373
  // Set up git branch watcher
367
374
  this.statusLine.watchBranch(() => {
368
375
  this.updateEditorTopBorder();
@@ -6,9 +6,9 @@ import {
6
6
  } from "@oh-my-pi/pi-tui";
7
7
  import { logger } from "@oh-my-pi/pi-utils";
8
8
 
9
- const cache = new Map<string, MermaidImage>();
10
- const pending = new Map<string, Promise<MermaidImage | null>>();
11
- const failed = new Set<string>();
9
+ const cache = new Map<bigint, MermaidImage>();
10
+ const pending = new Map<bigint, Promise<MermaidImage | null>>();
11
+ const failed = new Set<bigint>();
12
12
 
13
13
  const defaultOptions: MermaidRenderOptions = {
14
14
  theme: "dark",
@@ -28,7 +28,7 @@ export function setMermaidRenderCallback(callback: (() => void) | null): void {
28
28
  * Get a pre-rendered mermaid image by hash.
29
29
  * Returns null if not cached or rendering failed.
30
30
  */
31
- export function getMermaidImage(hash: string): MermaidImage | null {
31
+ export function getMermaidImage(hash: bigint): MermaidImage | null {
32
32
  return cache.get(hash) ?? null;
33
33
  }
34
34
 
@@ -1616,7 +1616,15 @@ export async function getThemeByName(name: string): Promise<Theme | undefined> {
1616
1616
  }
1617
1617
  }
1618
1618
 
1619
+ /** Appearance reported by Mode 2031 (terminal DSR), or undefined if not (yet) available. */
1620
+ var terminalReportedAppearance: "dark" | "light" | undefined;
1621
+
1619
1622
  function detectTerminalBackground(): "dark" | "light" {
1623
+ // Prefer terminal-reported appearance from Mode 2031 (CSI ? 997 ; {1,2} n)
1624
+ if (terminalReportedAppearance) {
1625
+ return terminalReportedAppearance;
1626
+ }
1627
+ // Fallback: COLORFGBG environment variable (static, set once at terminal launch)
1620
1628
  const colorfgbg = Bun.env.COLORFGBG || "";
1621
1629
  if (colorfgbg) {
1622
1630
  const parts = colorfgbg.split(";");
@@ -1793,6 +1801,31 @@ export function setAutoThemeMapping(mode: "dark" | "light", themeName: string):
1793
1801
  });
1794
1802
  }
1795
1803
 
1804
+ /**
1805
+ * Called when the terminal reports a dark/light appearance change via Mode 2031.
1806
+ * Updates the cached appearance and triggers auto-theme re-evaluation.
1807
+ * This is the cross-platform mechanism supported by Ghostty, Kitty, Contour,
1808
+ * VTE (GNOME Terminal), and tmux 3.6+.
1809
+ */
1810
+ export function onTerminalAppearanceChange(mode: "dark" | "light"): void {
1811
+ if (terminalReportedAppearance === mode) return;
1812
+ terminalReportedAppearance = mode;
1813
+ if (!autoDetectedTheme) return;
1814
+ const resolved = getDefaultTheme();
1815
+ if (resolved === currentThemeName) return;
1816
+ currentThemeName = resolved;
1817
+ loadTheme(resolved, getCurrentThemeOptions())
1818
+ .then(loadedTheme => {
1819
+ theme = loadedTheme;
1820
+ if (onThemeChangeCallback) {
1821
+ onThemeChangeCallback();
1822
+ }
1823
+ })
1824
+ .catch(err => {
1825
+ logger.debug("Mode 2031 appearance switch failed", { error: String(err) });
1826
+ });
1827
+ }
1828
+
1796
1829
  export function setThemeInstance(themeInstance: Theme): void {
1797
1830
  autoDetectedTheme = false;
1798
1831
  theme = themeInstance;
@@ -1,6 +1,8 @@
1
1
  {{#if context}}
2
2
  {{SECTION_SEPERATOR "Background"}}
3
+ <context>
3
4
  {{context}}
5
+ </context>
4
6
  {{/if}}
5
7
 
6
8
  {{SECTION_SEPERATOR "Task"}}
@@ -106,7 +106,7 @@ Domain-specific rules from past experience. **MUST** read `rule://<name>` when w
106
106
  You **MUST** use tools to complete the task.
107
107
 
108
108
  {{#if intentTracing}}
109
- Every tool call **MUST** include the `{{intentField}}` parameter: one sentence in present participle form (e.g., Inserting comment before the function), no trailing period. This is a contract-level requirement, not optional metadata.
109
+ Every tool call **MUST** include the `{{intentField}}` parameter: one concise sentence in present participle form (e.g., Updating imports), ideally 2-6 words, with no trailing period. This is a contract-level requirement, not optional metadata.
110
110
  {{/if}}
111
111
 
112
112
  You **MUST** use the following tools, as effectively as possible, to complete the task:
@@ -147,10 +147,21 @@ You **MUST NOT** use Python or Bash when a specialized tool exists.
147
147
 
148
148
  Semantic questions **MUST** be answered with semantic tools.
149
149
  - Where is this thing defined? → `lsp definition`
150
+ - What type does this thing resolve to? → `lsp type_definition`
151
+ - What concrete implementations exist? → `lsp implementation`
150
152
  - What uses this thing I'm about to change? → `lsp references`
151
153
  - What is this thing? → `lsp hover`
154
+ - Can the server propose fixes/imports/refactors? → `lsp code_actions` (list first, then apply with `apply: true` + `query`)
152
155
  {{/has}}
153
156
 
157
+ {{#ifAny (includes tools "ast_find") (includes tools "ast_replace")}}
158
+ ### AST tools for structural code work
159
+
160
+ When AST tools are available, syntax-aware operations take priority over text hacks.
161
+ {{#has tools "ast_find"}}- Use `ast_find` for structural discovery (call shapes, declarations, syntax patterns) before text grep when code structure matters{{/has}}
162
+ {{#has tools "ast_replace"}}- Use `ast_replace` for structural codemods/replacements; do not use bash `sed`/`perl`/`awk` for syntax-level rewrites{{/has}}
163
+ - Use `grep` for plain text/regex lookup only when AST shape is irrelevant
164
+ {{/ifAny}}
154
165
  {{#if eagerTasks}}
155
166
  <eager-tasks>
156
167
  You **SHOULD** delegate work to subagents by default. Working alone is the exception, not the rule.
@@ -0,0 +1,20 @@
1
+ Performs structural code search using AST matching via native ast-grep.
2
+
3
+ <instruction>
4
+ - Use this when syntax shape matters more than raw text (calls, declarations, specific language constructs)
5
+ - Prefer a precise `path` scope to keep results targeted and deterministic (`path` accepts files, directories, or glob patterns)
6
+ - `pattern` is required; `lang` is optional (`lang` is inferred per file extension when omitted)
7
+ - Use `selector` only for contextual pattern mode; otherwise provide a direct pattern
8
+ - Enable `include_meta` when metavariable captures are needed in output
9
+ </instruction>
10
+
11
+ <output>
12
+ - Returns grouped matches with file path, byte range, and line/column ranges
13
+ - Includes summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
14
+ </output>
15
+
16
+ <critical>
17
+ - `pattern` is required
18
+ - Set `lang` explicitly to constrain matching when path pattern spans mixed-language trees
19
+ - If exploration is broad/open-ended across subsystems, use Task tool with explore subagent first
20
+ </critical>
@@ -0,0 +1,21 @@
1
+ Performs structural AST-aware rewrites via native ast-grep.
2
+
3
+ <instruction>
4
+ - Use for codemods and structural rewrites where plain text replace is unsafe
5
+ - Narrow scope with `path` before replacing (`path` accepts files, directories, or glob patterns)
6
+ - `pattern` + `rewrite` are required; `lang` is optional only when all matched files resolve to a single language
7
+ - Keep `dry_run` enabled unless explicit apply intent is clear
8
+ - Use `max_files` and `max_replacements` as safety caps on broad rewrites
9
+ </instruction>
10
+
11
+ <output>
12
+ - Returns replacement summary, per-file replacement counts, and change previews
13
+ - Reports whether changes were applied or only previewed
14
+ - Includes parse issues when files cannot be processed
15
+ </output>
16
+
17
+ <critical>
18
+ - `pattern` + `rewrite` are required
19
+ - If the path pattern spans multiple languages, set `lang` explicitly for deterministic rewrites
20
+ - For one-off local text edits, prefer the Edit tool instead of AST replace
21
+ </critical>
@@ -35,6 +35,8 @@ You **MUST** use specialized tools instead of bash for ALL file operations:
35
35
  |`ls dir/`|`read(path="dir/")`|
36
36
  |`cat <<'EOF' > file`|`write(path="file", content="...")`|
37
37
  |`sed -i 's/old/new/' file`|`edit(path="file", edits=[...])`|
38
+ - If `ast_find` / `ast_replace` tools are available in the session, you **MUST** use them for structural code search/rewrites instead of bash `grep`/`sed`/`awk`/`perl` pipelines
39
+ - Bash is for command execution, not syntax-aware code transformation; prefer `ast_find` for discovery and `ast_replace` for codemods
38
40
  - You **MUST NOT** use Bash for these operations like read, grep, find, edit, write, where specialized tools exist.
39
41
  - You **MUST NOT** use `2>&1` | `2>/dev/null` pattern, stdout and stderr are already merged.
40
42
  - You **MUST NOT** use `| head -n 50` or `| tail -n 100` pattern, use `head` and `tail` parameters instead.
@@ -175,26 +175,44 @@ Good — include original `}` in the replaced range when replacement keeps `}`:
175
175
  Also apply the same rule to `);`, `],`, and `},` closers: if replacement includes the closer token, `end` must include the original closer line.
176
176
  </example>
177
177
 
178
- <example name="insert between siblings">
178
+ <example name="insert between sibling declarations">
179
179
  ```ts
180
- {{hlinefull 44 " \"build\": \"bun run compile\","}}
181
- {{hlinefull 45 " \"test\": \"bun test\""}}
180
+ {{hlinefull 44 "function x() {"}}
181
+ {{hlinefull 45 " runX();"}}
182
+ {{hlinefull 46 "}"}}
183
+ {{hlinefull 47 ""}}
184
+ {{hlinefull 48 "function y() {"}}
185
+ {{hlinefull 49 " runY();"}}
186
+ {{hlinefull 50 "}"}}
182
187
  ```
183
188
  ```
184
189
  {
185
190
  path: "…",
186
191
  edits: [{
187
192
  op: "prepend",
188
- pos: "{{hlineref 45 " \"test\": \"bun test\""}}",
189
- lines: [" \"lint\": \"biome check\","]
193
+ pos: "{{hlineref 48 "function y() {"}}",
194
+ lines: [
195
+ "function z() {",
196
+ " runZ();",
197
+ "}",
198
+ ""
199
+ ]
190
200
  }]
191
201
  }
192
202
  ```
193
203
  Result:
194
204
  ```ts
195
- {{hlinefull 44 " \"build\": \"bun run compile\","}}
196
- {{hlinefull 45 " \"lint\": \"biome check\","}}
197
- {{hlinefull 46 " \"test\": \"bun test\""}}
205
+ {{hlinefull 44 "function x() {"}}
206
+ {{hlinefull 45 " runX();"}}
207
+ {{hlinefull 46 "}"}}
208
+ {{hlinefull 47 ""}}
209
+ {{hlinefull 48 "function z() {"}}
210
+ {{hlinefull 49 " runZ();"}}
211
+ {{hlinefull 50 "}"}}
212
+ {{hlinefull 51 ""}}
213
+ {{hlinefull 52 "function y() {"}}
214
+ {{hlinefull 53 " runY();"}}
215
+ {{hlinefull 54 "}"}}
198
216
  ```
199
217
  </example>
200
218