@oh-my-pi/pi-coding-agent 13.3.6 → 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 (68) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/package.json +9 -18
  3. package/scripts/format-prompts.ts +7 -172
  4. package/src/capability/mcp.ts +5 -0
  5. package/src/cli/args.ts +1 -0
  6. package/src/config/prompt-templates.ts +9 -55
  7. package/src/config/settings-schema.ts +24 -0
  8. package/src/discovery/builtin.ts +1 -0
  9. package/src/discovery/codex.ts +1 -2
  10. package/src/discovery/helpers.ts +0 -5
  11. package/src/discovery/mcp-json.ts +2 -0
  12. package/src/internal-urls/docs-index.generated.ts +1 -1
  13. package/src/lsp/client.ts +8 -0
  14. package/src/lsp/config.ts +2 -3
  15. package/src/lsp/index.ts +379 -99
  16. package/src/lsp/render.ts +21 -31
  17. package/src/lsp/types.ts +21 -8
  18. package/src/lsp/utils.ts +193 -1
  19. package/src/mcp/config-writer.ts +3 -0
  20. package/src/mcp/config.ts +1 -0
  21. package/src/mcp/oauth-flow.ts +3 -1
  22. package/src/mcp/types.ts +5 -0
  23. package/src/modes/components/settings-defs.ts +9 -0
  24. package/src/modes/components/status-line.ts +1 -1
  25. package/src/modes/controllers/mcp-command-controller.ts +6 -2
  26. package/src/modes/interactive-mode.ts +8 -1
  27. package/src/modes/theme/mermaid-cache.ts +4 -4
  28. package/src/modes/theme/theme.ts +33 -0
  29. package/src/prompts/system/custom-system-prompt.md +0 -10
  30. package/src/prompts/system/subagent-user-prompt.md +2 -0
  31. package/src/prompts/system/system-prompt.md +12 -9
  32. package/src/prompts/tools/ast-find.md +20 -0
  33. package/src/prompts/tools/ast-replace.md +21 -0
  34. package/src/prompts/tools/bash.md +2 -0
  35. package/src/prompts/tools/hashline.md +26 -8
  36. package/src/prompts/tools/lsp.md +22 -5
  37. package/src/prompts/tools/task.md +0 -1
  38. package/src/sdk.ts +11 -5
  39. package/src/session/agent-session.ts +293 -83
  40. package/src/system-prompt.ts +3 -34
  41. package/src/task/executor.ts +8 -7
  42. package/src/task/index.ts +8 -55
  43. package/src/task/template.ts +2 -4
  44. package/src/task/types.ts +0 -5
  45. package/src/task/worktree.ts +6 -2
  46. package/src/tools/ast-find.ts +316 -0
  47. package/src/tools/ast-replace.ts +294 -0
  48. package/src/tools/bash.ts +2 -1
  49. package/src/tools/browser.ts +2 -8
  50. package/src/tools/fetch.ts +55 -18
  51. package/src/tools/index.ts +8 -0
  52. package/src/tools/jtd-to-json-schema.ts +29 -13
  53. package/src/tools/path-utils.ts +34 -0
  54. package/src/tools/python.ts +2 -1
  55. package/src/tools/renderers.ts +4 -0
  56. package/src/tools/ssh.ts +2 -1
  57. package/src/tools/submit-result.ts +143 -44
  58. package/src/tools/todo-write.ts +34 -0
  59. package/src/tools/tool-timeouts.ts +29 -0
  60. package/src/utils/mime.ts +37 -14
  61. package/src/utils/prompt-format.ts +172 -0
  62. package/src/web/scrapers/arxiv.ts +12 -12
  63. package/src/web/scrapers/go-pkg.ts +2 -2
  64. package/src/web/scrapers/iacr.ts +17 -9
  65. package/src/web/scrapers/readthedocs.ts +3 -3
  66. package/src/web/scrapers/twitter.ts +11 -11
  67. package/src/web/scrapers/wikipedia.ts +4 -5
  68. 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
  /**
package/src/mcp/config.ts CHANGED
@@ -42,6 +42,7 @@ function convertToLegacyConfig(server: MCPServer): MCPServerConfig {
42
42
  enabled: server.enabled,
43
43
  timeout: server.timeout,
44
44
  auth: server.auth,
45
+ oauth: server.oauth,
45
46
  };
46
47
 
47
48
  if (transport === "stdio") {
@@ -22,6 +22,8 @@ export interface MCPOAuthConfig {
22
22
  clientSecret?: string;
23
23
  /** OAuth scopes (space-separated) */
24
24
  scopes?: string;
25
+ /** Custom callback port (default: 3000) */
26
+ callbackPort?: number;
25
27
  }
26
28
 
27
29
  /**
@@ -37,7 +39,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
37
39
  private config: MCPOAuthConfig,
38
40
  ctrl: OAuthController,
39
41
  ) {
40
- super(ctrl, DEFAULT_PORT, CALLBACK_PATH);
42
+ super(ctrl, config.callbackPort ?? DEFAULT_PORT, CALLBACK_PATH);
41
43
  this.#resolvedClientId = this.#resolveClientId(config);
42
44
  }
43
45
 
package/src/mcp/types.ts CHANGED
@@ -59,6 +59,11 @@ interface MCPServerConfigBase {
59
59
  timeout?: number;
60
60
  /** Authentication configuration (optional) */
61
61
  auth?: MCPAuthConfig;
62
+ /** OAuth configuration for servers requiring explicit client credentials */
63
+ oauth?: {
64
+ clientId?: string;
65
+ callbackPort?: number;
66
+ };
62
67
  }
63
68
 
64
69
  /** Stdio server configuration */
@@ -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" },
@@ -163,7 +163,7 @@ export class StatusLineComponent implements Component {
163
163
  // Fire async fetch, return cached value
164
164
  (async () => {
165
165
  try {
166
- const result = await $`git status --porcelain`.quiet().nothrow();
166
+ const result = await $`git --no-optional-locks status --porcelain`.quiet().nothrow();
167
167
 
168
168
  if (result.exitCode !== 0) {
169
169
  this.#cachedGitStatus = null;
@@ -282,9 +282,10 @@ export class MCPCommandController {
282
282
  const credentialId = await this.#handleOAuthFlow(
283
283
  oauth.authorizationUrl,
284
284
  oauth.tokenUrl,
285
- oauth.clientId ?? "",
285
+ oauth.clientId ?? finalConfig.oauth?.clientId ?? "",
286
286
  "",
287
287
  oauth.scopes ?? "",
288
+ finalConfig.oauth?.callbackPort,
288
289
  );
289
290
  finalConfig = {
290
291
  ...finalConfig,
@@ -352,6 +353,7 @@ export class MCPCommandController {
352
353
  clientId: string,
353
354
  clientSecret: string,
354
355
  scopes: string,
356
+ callbackPort?: number,
355
357
  ): Promise<string> {
356
358
  const authStorage = this.ctx.session.modelRegistry.authStorage;
357
359
  let parsedAuthUrl: URL;
@@ -377,6 +379,7 @@ export class MCPCommandController {
377
379
  clientId: resolvedClientId,
378
380
  clientSecret: clientSecret || undefined,
379
381
  scopes: scopes || undefined,
382
+ callbackPort,
380
383
  },
381
384
  {
382
385
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -1198,9 +1201,10 @@ export class MCPCommandController {
1198
1201
  const credentialId = await this.#handleOAuthFlow(
1199
1202
  oauth.authorizationUrl,
1200
1203
  oauth.tokenUrl,
1201
- oauth.clientId ?? "",
1204
+ oauth.clientId ?? found.config.oauth?.clientId ?? "",
1202
1205
  "",
1203
1206
  oauth.scopes ?? "",
1207
+ found.config.oauth?.callbackPort,
1204
1208
  );
1205
1209
 
1206
1210
  const updated: MCPServerConfig = {
@@ -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;
@@ -40,16 +40,6 @@ If a skill covers your output, you **MUST** read `skill://<name>` before proceed
40
40
  {{/list}}
41
41
  </skills>
42
42
  {{/if}}
43
- {{#if preloadedSkills.length}}
44
- Following skills are preloaded in full; you **MUST** apply instructions directly.
45
- <preloaded-skills>
46
- {{#list preloadedSkills join="\n"}}
47
- <skill name="{{name}}">
48
- {{content}}
49
- </skill>
50
- {{/list}}
51
- </preloaded-skills>
52
- {{/if}}
53
43
  {{#if rules.length}}
54
44
  Rules are local constraints.
55
45
  You **MUST** read `rule://<name>` when working in that domain.
@@ -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"}}
@@ -93,14 +93,6 @@ You **MUST** use the following skills, to save you time, when working in their d
93
93
  {{/each}}
94
94
  {{/if}}
95
95
 
96
- {{#if preloadedSkills.length}}
97
- Preloaded skills:
98
- {{#each preloadedSkills}}
99
- ## {{name}}
100
- {{content}}
101
- {{/each}}
102
- {{/if}}
103
-
104
96
  {{#if rules.length}}
105
97
  # Rules
106
98
  Domain-specific rules from past experience. **MUST** read `rule://<name>` when working in their territory.
@@ -114,7 +106,7 @@ Domain-specific rules from past experience. **MUST** read `rule://<name>` when w
114
106
  You **MUST** use tools to complete the task.
115
107
 
116
108
  {{#if intentTracing}}
117
- 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.
118
110
  {{/if}}
119
111
 
120
112
  You **MUST** use the following tools, as effectively as possible, to complete the task:
@@ -155,10 +147,21 @@ You **MUST NOT** use Python or Bash when a specialized tool exists.
155
147
 
156
148
  Semantic questions **MUST** be answered with semantic tools.
157
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`
158
152
  - What uses this thing I'm about to change? → `lsp references`
159
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`)
160
155
  {{/has}}
161
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}}
162
165
  {{#if eagerTasks}}
163
166
  <eager-tasks>
164
167
  You **SHOULD** delegate work to subagents by default. Working alone is the exception, not the rule.