@oh-my-pi/pi-coding-agent 13.15.2 → 13.16.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 (41) hide show
  1. package/CHANGELOG.md +26 -16
  2. package/package.json +7 -7
  3. package/src/config/keybindings.ts +6 -0
  4. package/src/config/model-registry.ts +215 -57
  5. package/src/config/settings-schema.ts +27 -0
  6. package/src/extensibility/extensions/types.ts +6 -1
  7. package/src/extensibility/hooks/types.ts +1 -1
  8. package/src/internal-urls/docs-index.generated.ts +1 -1
  9. package/src/modes/components/custom-editor.ts +6 -4
  10. package/src/modes/components/hook-editor.ts +57 -8
  11. package/src/modes/components/model-selector.ts +48 -29
  12. package/src/modes/components/settings-defs.ts +10 -1
  13. package/src/modes/components/settings-selector.ts +92 -5
  14. package/src/modes/controllers/extension-ui-controller.ts +32 -4
  15. package/src/modes/controllers/input-controller.ts +22 -9
  16. package/src/modes/controllers/selector-controller.ts +2 -2
  17. package/src/modes/interactive-mode.ts +7 -2
  18. package/src/modes/rpc/rpc-mode.ts +78 -30
  19. package/src/modes/rpc/rpc-types.ts +9 -1
  20. package/src/modes/theme/theme.ts +70 -0
  21. package/src/modes/types.ts +6 -1
  22. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  23. package/src/prompts/system/custom-system-prompt.md +5 -0
  24. package/src/prompts/system/system-prompt.md +6 -0
  25. package/src/prompts/tools/ask.md +1 -0
  26. package/src/prompts/tools/hashline.md +20 -5
  27. package/src/sdk.ts +9 -1
  28. package/src/session/agent-session.ts +338 -80
  29. package/src/session/messages.ts +23 -0
  30. package/src/session/session-manager.ts +65 -0
  31. package/src/system-prompt.ts +63 -2
  32. package/src/tools/ask.ts +109 -61
  33. package/src/tools/ast-edit.ts +2 -16
  34. package/src/tools/ast-grep.ts +2 -17
  35. package/src/tools/browser.ts +35 -17
  36. package/src/tools/grep.ts +4 -17
  37. package/src/tools/path-utils.ts +7 -0
  38. package/src/tools/render-utils.ts +27 -0
  39. package/src/tui/tree-list.ts +51 -22
  40. package/src/utils/image-input.ts +11 -1
  41. package/src/web/search/providers/codex.ts +10 -3
@@ -2,6 +2,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Effort } from "@oh-my-pi/pi-ai";
3
3
  import {
4
4
  Container,
5
+ Input,
5
6
  matchesKey,
6
7
  type SelectItem,
7
8
  SelectList,
@@ -31,6 +32,52 @@ import { getPreset } from "./status-line/presets";
31
32
  /**
32
33
  * A submenu component for selecting from a list of options.
33
34
  */
35
+ /**
36
+ * Submenu component for free-text string settings.
37
+ * Mirrors the ConfigInputSubmenu pattern from plugin-settings.ts.
38
+ */
39
+ class TextInputSubmenu extends Container {
40
+ #input: Input;
41
+
42
+ constructor(
43
+ label: string,
44
+ description: string,
45
+ currentValue: string,
46
+ private readonly onSubmit: (value: string) => void,
47
+ private readonly onCancel: () => void,
48
+ ) {
49
+ super();
50
+
51
+ this.addChild(new Text(theme.bold(theme.fg("accent", label)), 0, 0));
52
+ if (description) {
53
+ this.addChild(new Spacer(1));
54
+ this.addChild(new Text(theme.fg("muted", description), 0, 0));
55
+ }
56
+ this.addChild(new Spacer(1));
57
+
58
+ this.#input = new Input();
59
+ if (currentValue) {
60
+ this.#input.setValue(currentValue);
61
+ // Move cursor to end of pre-filled value (ctrl+e = cursorLineEnd).
62
+ this.#input.handleInput("\x05");
63
+ }
64
+ this.#input.onSubmit = value => {
65
+ this.onSubmit(value); // empty string clears the setting
66
+ };
67
+ this.addChild(this.#input);
68
+ this.addChild(new Spacer(1));
69
+ this.addChild(new Text(theme.fg("dim", " Enter to save · Esc to cancel · Clear field to unset"), 0, 0));
70
+ }
71
+
72
+ handleInput(data: string): void {
73
+ if (data === "\x1b" || data === "\x1b\x1b") {
74
+ this.onCancel();
75
+ return;
76
+ }
77
+ this.#input.handleInput(data);
78
+ }
79
+ }
80
+
34
81
  class SelectSubmenu extends Container {
35
82
  #selectList: SelectList;
36
83
  #previewText: Text | null = null;
@@ -180,6 +227,7 @@ export class SettingsSelectorComponent extends Container {
180
227
  #statusPreviewContainer: Container | null = null;
181
228
  #statusPreviewText: Text | null = null;
182
229
  #currentTabId: SettingTab | "plugins" = "appearance";
230
+ #textInputActive = false;
183
231
 
184
232
  constructor(
185
233
  private readonly context: SettingsRuntimeContext,
@@ -277,6 +325,15 @@ export class SettingsSelectorComponent extends Container {
277
325
  currentValue: this.#getSubmenuCurrentValue(def.path, currentValue),
278
326
  submenu: (cv, done) => this.#createSubmenu(def, cv, done),
279
327
  };
328
+
329
+ case "text":
330
+ return {
331
+ id: def.path,
332
+ label: def.label,
333
+ description: def.description,
334
+ currentValue: (currentValue as string) ?? "",
335
+ submenu: (cv, done) => this.#createTextInput(def, cv, done),
336
+ };
280
337
  }
281
338
  }
282
339
 
@@ -389,6 +446,34 @@ export class SettingsSelectorComponent extends Container {
389
446
  );
390
447
  }
391
448
 
449
+ /**
450
+ * Create a text input submenu for a plain string setting.
451
+ */
452
+ #createTextInput(
453
+ def: SettingDef & { type: "text" },
454
+ currentValue: string,
455
+ done: (value?: string) => void,
456
+ ): Container {
457
+ this.#textInputActive = true;
458
+ const wrappedDone = (value?: string) => {
459
+ this.#textInputActive = false;
460
+ done(value);
461
+ };
462
+ return new TextInputSubmenu(
463
+ def.label,
464
+ def.description,
465
+ currentValue,
466
+ value => {
467
+ // Empty string clears the setting; undefined-typed string settings
468
+ // store "" which the browser.ts expandPath ignores (no-op fallback).
469
+ this.#setSettingValue(def.path, value);
470
+ this.callbacks.onChange(def.path, value);
471
+ wrappedDone(value);
472
+ },
473
+ () => wrappedDone(),
474
+ );
475
+ }
476
+
392
477
  /**
393
478
  * Set a setting value, handling type conversion.
394
479
  */
@@ -510,12 +595,14 @@ export class SettingsSelectorComponent extends Container {
510
595
  }
511
596
 
512
597
  handleInput(data: string): void {
513
- // Handle tab switching first (tab, shift+tab, or left/right arrows)
598
+ // Handle tab switching but NOT when a text input is active, since
599
+ // arrow keys must reach the cursor and Tab must not switch tabs.
514
600
  if (
515
- matchesKey(data, "tab") ||
516
- matchesKey(data, "shift+tab") ||
517
- matchesKey(data, "left") ||
518
- matchesKey(data, "right")
601
+ !this.#textInputActive &&
602
+ (matchesKey(data, "tab") ||
603
+ matchesKey(data, "shift+tab") ||
604
+ matchesKey(data, "left") ||
605
+ matchesKey(data, "right"))
519
606
  ) {
520
607
  this.#tabBar.handleInput(data);
521
608
  return;
@@ -50,7 +50,8 @@ export class ExtensionUiController {
50
50
  this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`);
51
51
  },
52
52
  getEditorText: () => this.ctx.editor.getText(),
53
- editor: (title, prefill) => this.showHookEditor(title, prefill),
53
+ editor: (title, prefill, dialogOptions, editorOptions) =>
54
+ this.showHookEditor(title, prefill, dialogOptions, editorOptions),
54
55
  get theme() {
55
56
  return theme;
56
57
  },
@@ -783,26 +784,53 @@ export class ExtensionUiController {
783
784
  /**
784
785
  * Show a multi-line editor for hooks (with Ctrl+G support).
785
786
  */
786
- showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
787
+ showHookEditor(
788
+ title: string,
789
+ prefill?: string,
790
+ dialogOptions?: ExtensionUIDialogOptions,
791
+ editorOptions?: { promptStyle?: boolean },
792
+ ): Promise<string | undefined> {
787
793
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
794
+ let settled = false;
795
+ const onAbort = () => {
796
+ this.hideHookEditor();
797
+ if (!settled) {
798
+ settled = true;
799
+ resolve(undefined);
800
+ }
801
+ };
802
+ const finish = (value: string | undefined) => {
803
+ if (settled) return;
804
+ settled = true;
805
+ dialogOptions?.signal?.removeEventListener("abort", onAbort);
806
+ resolve(value);
807
+ };
788
808
  this.ctx.hookEditor = new HookEditorComponent(
789
809
  this.ctx.ui,
790
810
  title,
791
811
  prefill,
792
812
  value => {
793
813
  this.hideHookEditor();
794
- resolve(value);
814
+ finish(value);
795
815
  },
796
816
  () => {
797
817
  this.hideHookEditor();
798
- resolve(undefined);
818
+ finish(undefined);
799
819
  },
820
+ editorOptions,
800
821
  );
801
822
 
802
823
  this.ctx.editorContainer.clear();
803
824
  this.ctx.editorContainer.addChild(this.ctx.hookEditor);
804
825
  this.ctx.ui.setFocus(this.ctx.hookEditor);
805
826
  this.ctx.ui.requestRender();
827
+ if (dialogOptions?.signal) {
828
+ if (dialogOptions.signal.aborted) {
829
+ onAbort();
830
+ } else {
831
+ dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
832
+ }
833
+ }
806
834
  return promise;
807
835
  }
808
836
 
@@ -11,6 +11,7 @@ import type { AgentSessionEvent } from "../../session/agent-session";
11
11
  import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
12
12
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
13
13
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
14
+ import { ensureSupportedImageInput } from "../../utils/image-input";
14
15
  import { resizeImage } from "../../utils/image-resize";
15
16
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
16
17
 
@@ -95,7 +96,11 @@ export class InputController {
95
96
  this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
96
97
  this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
97
98
  this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
98
- this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
99
+ this.ctx.editor.setActionKeys(
100
+ "app.model.selectTemporary",
101
+ this.ctx.keybindings.getKeys("app.model.selectTemporary"),
102
+ );
103
+ this.ctx.editor.onSelectModelTemporary = () => this.ctx.showModelSelector({ temporaryOnly: true });
99
104
 
100
105
  // Global debug handler on TUI (works regardless of focus)
101
106
  this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
@@ -498,17 +503,25 @@ export class InputController {
498
503
  const image = await readImageFromClipboard();
499
504
  if (image) {
500
505
  const base64Data = image.data.toBase64();
501
- let imageData = { data: base64Data, mimeType: image.mimeType };
506
+ let imageData = await ensureSupportedImageInput({
507
+ type: "image",
508
+ data: base64Data,
509
+ mimeType: image.mimeType,
510
+ });
511
+ if (!imageData) {
512
+ this.ctx.showStatus(`Unsupported clipboard image format: ${image.mimeType}`);
513
+ return false;
514
+ }
502
515
  if (settings.get("images.autoResize")) {
503
516
  try {
504
517
  const resized = await resizeImage({
505
518
  type: "image",
506
- data: base64Data,
507
- mimeType: image.mimeType,
519
+ data: imageData.data,
520
+ mimeType: imageData.mimeType,
508
521
  });
509
- imageData = { data: resized.data, mimeType: resized.mimeType };
522
+ imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
510
523
  } catch {
511
- imageData = { data: base64Data, mimeType: image.mimeType };
524
+ // Keep the normalized image when resize fails.
512
525
  }
513
526
  }
514
527
 
@@ -595,8 +608,8 @@ export class InputController {
595
608
 
596
609
  async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
597
610
  try {
598
- const roleOrder = ["smol", "default", "slow"] as const;
599
- const result = await this.ctx.session.cycleRoleModels(roleOrder, options);
611
+ const cycleOrder = settings.get("cycleOrder");
612
+ const result = await this.ctx.session.cycleRoleModels(cycleOrder, options);
600
613
  if (!result) {
601
614
  this.ctx.showStatus("Only one role model available");
602
615
  return;
@@ -612,7 +625,7 @@ export class InputController {
612
625
  : "";
613
626
  const tempLabel = options?.temporary ? " (temporary)" : "";
614
627
  const cycleSeparator = theme.fg("dim", " > ");
615
- const cycleLabel = roleOrder
628
+ const cycleLabel = cycleOrder
616
629
  .map(role => {
617
630
  if (role === result.role) {
618
631
  return theme.bold(theme.fg("accent", role));
@@ -3,7 +3,7 @@ import { getOAuthProviders, type OAuthProvider } from "@oh-my-pi/pi-ai";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
5
5
  import { getAgentDbPath, getProjectDir } from "@oh-my-pi/pi-utils";
6
- import { MODEL_ROLES } from "../../config/model-registry";
6
+ import { getRoleInfo } from "../../config/model-registry";
7
7
  import { settings } from "../../config/settings";
8
8
  import { DebugSelectorComponent } from "../../debug";
9
9
  import { disableProvider, enableProvider } from "../../discovery";
@@ -406,7 +406,7 @@ export class SelectorController {
406
406
  // Don't call done() - selector stays open for role assignment
407
407
  } else {
408
408
  // Other roles (smol, slow): just update settings, not current model
409
- const roleInfo = MODEL_ROLES[role];
409
+ const roleInfo = getRoleInfo(role, settings);
410
410
  const roleLabel = roleInfo?.name ?? role;
411
411
  this.ctx.showStatus(`${roleLabel} model: ${model.id}`);
412
412
  // Don't call done() - selector stays open
@@ -1399,8 +1399,13 @@ export class InteractiveMode implements InteractiveModeContext {
1399
1399
  this.#extensionUiController.hideHookInput();
1400
1400
  }
1401
1401
 
1402
- showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
1403
- return this.#extensionUiController.showHookEditor(title, prefill);
1402
+ showHookEditor(
1403
+ title: string,
1404
+ prefill?: string,
1405
+ dialogOptions?: ExtensionUIDialogOptions,
1406
+ editorOptions?: { promptStyle?: boolean },
1407
+ ): Promise<string | undefined> {
1408
+ return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions, editorOptions);
1404
1409
  }
1405
1410
 
1406
1411
  hideHookEditor(): void {
@@ -29,6 +29,77 @@ import type {
29
29
  // Re-export types for consumers
30
30
  export type * from "./rpc-types";
31
31
 
32
+ export type PendingExtensionRequest = {
33
+ resolve: (response: RpcExtensionUIResponse) => void;
34
+ reject: (error: Error) => void;
35
+ };
36
+
37
+ type RpcOutput = (obj: RpcResponse | RpcExtensionUIRequest | object) => void;
38
+
39
+ export function requestRpcEditor(
40
+ pendingRequests: Map<string, PendingExtensionRequest>,
41
+ output: RpcOutput,
42
+ title: string,
43
+ prefill?: string,
44
+ dialogOptions?: ExtensionUIDialogOptions,
45
+ editorOptions?: { promptStyle?: boolean },
46
+ ): Promise<string | undefined> {
47
+ if (dialogOptions?.signal?.aborted) return Promise.resolve(undefined);
48
+
49
+ const id = Snowflake.next() as string;
50
+ const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
51
+ let settled = false;
52
+
53
+ const cleanup = () => {
54
+ dialogOptions?.signal?.removeEventListener("abort", onAbort);
55
+ pendingRequests.delete(id);
56
+ };
57
+ const finish = (value: string | undefined) => {
58
+ if (settled) return;
59
+ settled = true;
60
+ cleanup();
61
+ resolve(value);
62
+ };
63
+ const fail = (error: Error) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ cleanup();
67
+ reject(error);
68
+ };
69
+ const onAbort = () => {
70
+ output({
71
+ type: "extension_ui_request",
72
+ id: Snowflake.next() as string,
73
+ method: "cancel",
74
+ targetId: id,
75
+ } as RpcExtensionUIRequest);
76
+ finish(undefined);
77
+ };
78
+
79
+ dialogOptions?.signal?.addEventListener("abort", onAbort, { once: true });
80
+ pendingRequests.set(id, {
81
+ resolve: response => {
82
+ if ("cancelled" in response && response.cancelled) {
83
+ finish(undefined);
84
+ } else if ("value" in response) {
85
+ finish(response.value);
86
+ } else {
87
+ finish(undefined);
88
+ }
89
+ },
90
+ reject: fail,
91
+ });
92
+ output({
93
+ type: "extension_ui_request",
94
+ id,
95
+ method: "editor",
96
+ title,
97
+ prefill,
98
+ promptStyle: editorOptions?.promptStyle,
99
+ } as RpcExtensionUIRequest);
100
+ return promise;
101
+ }
102
+
32
103
  /**
33
104
  * Run in RPC mode.
34
105
  * Listens for JSON commands on stdin, outputs events and responses on stdout.
@@ -55,12 +126,6 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
55
126
  return { id, type: "response", command, success: false, error: message };
56
127
  };
57
128
 
58
- // Pending extension UI requests waiting for response
59
- type PendingExtensionRequest = {
60
- resolve: (response: RpcExtensionUIResponse) => void;
61
- reject: (error: Error) => void;
62
- };
63
-
64
129
  const pendingExtensionRequests = new Map<string, PendingExtensionRequest>();
65
130
 
66
131
  // Shutdown request flag (wrapped in object to allow mutation with const)
@@ -261,30 +326,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
261
326
  return "";
262
327
  }
263
328
 
264
- async editor(title: string, prefill?: string): Promise<string | undefined> {
265
- const id = Snowflake.next() as string;
266
- const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
267
- this.pendingRequests.set(id, {
268
- resolve: (response: RpcExtensionUIResponse) => {
269
- this.pendingRequests.delete(id);
270
- if ("cancelled" in response && response.cancelled) {
271
- resolve(undefined);
272
- } else if ("value" in response) {
273
- resolve(response.value);
274
- } else {
275
- resolve(undefined);
276
- }
277
- },
278
- reject,
279
- });
280
- this.output({
281
- type: "extension_ui_request",
282
- id,
283
- method: "editor",
284
- title,
285
- prefill,
286
- } as RpcExtensionUIRequest);
287
- return promise;
329
+ async editor(
330
+ title: string,
331
+ prefill?: string,
332
+ dialogOptions?: ExtensionUIDialogOptions,
333
+ editorOptions?: { promptStyle?: boolean },
334
+ ): Promise<string | undefined> {
335
+ return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions);
288
336
  }
289
337
 
290
338
  get theme(): Theme {
@@ -194,7 +194,15 @@ export type RpcExtensionUIRequest =
194
194
  placeholder?: string;
195
195
  timeout?: number;
196
196
  }
197
- | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
197
+ | {
198
+ type: "extension_ui_request";
199
+ id: string;
200
+ method: "editor";
201
+ title: string;
202
+ prefill?: string;
203
+ promptStyle?: boolean;
204
+ }
205
+ | { type: "extension_ui_request"; id: string; method: "cancel"; targetId: string }
198
206
  | {
199
207
  type: "extension_ui_request";
200
208
  id: string;
@@ -955,6 +955,76 @@ export type ThemeColor =
955
955
  | "statusLineCost"
956
956
  | "statusLineSubagents";
957
957
 
958
+ /** Set of all valid ThemeColor string values for runtime validation */
959
+ const THEME_COLOR_RECORD = {
960
+ accent: true,
961
+ border: true,
962
+ borderAccent: true,
963
+ borderMuted: true,
964
+ success: true,
965
+ error: true,
966
+ warning: true,
967
+ muted: true,
968
+ dim: true,
969
+ text: true,
970
+ thinkingText: true,
971
+ userMessageText: true,
972
+ customMessageText: true,
973
+ customMessageLabel: true,
974
+ toolTitle: true,
975
+ toolOutput: true,
976
+ mdHeading: true,
977
+ mdLink: true,
978
+ mdLinkUrl: true,
979
+ mdCode: true,
980
+ mdCodeBlock: true,
981
+ mdCodeBlockBorder: true,
982
+ mdQuote: true,
983
+ mdQuoteBorder: true,
984
+ mdHr: true,
985
+ mdListBullet: true,
986
+ toolDiffAdded: true,
987
+ toolDiffRemoved: true,
988
+ toolDiffContext: true,
989
+ syntaxComment: true,
990
+ syntaxKeyword: true,
991
+ syntaxFunction: true,
992
+ syntaxVariable: true,
993
+ syntaxString: true,
994
+ syntaxNumber: true,
995
+ syntaxType: true,
996
+ syntaxOperator: true,
997
+ syntaxPunctuation: true,
998
+ thinkingOff: true,
999
+ thinkingMinimal: true,
1000
+ thinkingLow: true,
1001
+ thinkingMedium: true,
1002
+ thinkingHigh: true,
1003
+ thinkingXhigh: true,
1004
+ bashMode: true,
1005
+ pythonMode: true,
1006
+ statusLineSep: true,
1007
+ statusLineModel: true,
1008
+ statusLinePath: true,
1009
+ statusLineGitClean: true,
1010
+ statusLineGitDirty: true,
1011
+ statusLineContext: true,
1012
+ statusLineSpend: true,
1013
+ statusLineStaged: true,
1014
+ statusLineDirty: true,
1015
+ statusLineUntracked: true,
1016
+ statusLineOutput: true,
1017
+ statusLineCost: true,
1018
+ statusLineSubagents: true,
1019
+ } satisfies Record<ThemeColor, true>;
1020
+
1021
+ const VALID_THEME_COLORS: ReadonlySet<string> = new Set(Object.keys(THEME_COLOR_RECORD));
1022
+
1023
+ /** Check if a string is a valid ThemeColor value */
1024
+ export function isValidThemeColor(color: string): color is ThemeColor {
1025
+ return VALID_THEME_COLORS.has(color);
1026
+ }
1027
+
958
1028
  export type ThemeBg =
959
1029
  | "selectedBg"
960
1030
  | "userMessageBg"
@@ -243,7 +243,12 @@ export interface InteractiveModeContext {
243
243
  hideHookSelector(): void;
244
244
  showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
245
245
  hideHookInput(): void;
246
- showHookEditor(title: string, prefill?: string): Promise<string | undefined>;
246
+ showHookEditor(
247
+ title: string,
248
+ prefill?: string,
249
+ dialogOptions?: ExtensionUIDialogOptions,
250
+ editorOptions?: { promptStyle?: boolean },
251
+ ): Promise<string | undefined>;
247
252
  hideHookEditor(): void;
248
253
  showHookNotify(message: string, type?: "info" | "warning" | "error"): void;
249
254
  showHookCustom<T>(
@@ -40,7 +40,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
40
40
  `| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
41
41
  `| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
42
42
  `| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
43
- "| `Alt+P` | Select model (temporary) |",
43
+ `| \`${appKey(bindings, "app.model.selectTemporary")}\` | Select model (temporary) |`,
44
44
  `| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
45
45
  `| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
46
46
  `| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
@@ -40,6 +40,11 @@ If a skill covers your output, you **MUST** read `skill://<name>` before proceed
40
40
  {{/list}}
41
41
  </skills>
42
42
  {{/if}}
43
+ {{#if alwaysApplyRules.length}}
44
+ {{#each alwaysApplyRules}}
45
+ {{content}}
46
+ {{/each}}
47
+ {{/if}}
43
48
  {{#if rules.length}}
44
49
  Rules are local constraints.
45
50
  You **MUST** read `rule://<name>` when working in that domain.
@@ -124,6 +124,12 @@ You **MUST** use the following skills, to save you time, when working in their d
124
124
  {{/each}}
125
125
  {{/if}}
126
126
 
127
+ {{#if alwaysApplyRules.length}}
128
+ {{#each alwaysApplyRules}}
129
+ {{content}}
130
+ {{/each}}
131
+ {{/if}}
132
+
127
133
  {{#if rules.length}}
128
134
  # Rules
129
135
  Domain-specific rules from past experience. **MUST** read `rule://<name>` when working in their territory.
@@ -8,6 +8,7 @@ Asks user when you need clarification or input during task execution.
8
8
  - Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
9
9
  - Use `questions` for multiple related questions instead of asking one at a time
10
10
  - Set `multi: true` on question to allow multiple selections
11
+ - `ask.timeout` only applies while choosing options; once the user selects "Other (type your own)", there is no timeout
11
12
  </instruction>
12
13
 
13
14
  <caution>
@@ -2,8 +2,6 @@ Applies precise file edits using `LINE#ID` anchors from `read` output.
2
2
 
3
3
  Read the file first. Copy anchors exactly from the latest `read` output. In one `edit` call, batch all edits for one file. After any successful edit, re-read before editing that file again.
4
4
 
5
- This matters: your output is checked against the real file state. Invalid anchors, duplicated boundary lines, or semantically equivalent rewrites will fail.
6
-
7
5
  <operations>
8
6
  **Top level**
9
7
  - `path` — file path
@@ -61,6 +59,24 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er
61
59
  ```
62
60
  </example>
63
61
 
62
+ <example name="replace whole block including closing brace">
63
+ Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{hlineref 7 "}"}} because `content` includes `}`.
64
+ ```
65
+ {
66
+ path: "util.ts",
67
+ edits: [{
68
+ loc: { block: { pos: {{hlineref 6 "\tlog();"}}, end: {{hlineref 7 "}"}} } },
69
+ content: [
70
+ "\tvalidate();",
71
+ "\tlog();",
72
+ "}"
73
+ ]
74
+ }]
75
+ }
76
+ ```
77
+ **Wrong**: using `end: {{hlineref 6 "\tlog();"}}` with the same content — line 7 (`}`) survives the replacement AND content emits `}`, producing two closing braces.
78
+ </example>
79
+
64
80
  <example name="replace one line">
65
81
  ```
66
82
  {
@@ -108,9 +124,8 @@ When adding a sibling declaration, prefer `prepend` on the next declaration.
108
124
  - Make the minimum exact edit. Do not rewrite nearby code unless the consumed range requires it.
109
125
  - Use anchors exactly as `N#ID` from the latest `read` output.
110
126
  - `block` requires both `pos` and `end`. Other anchored ops require one anchor.
111
- - Replace exactly the owned span. If `content` re-emits content beyond `end`, it will duplicate.
112
- - **Boundary duplication trap**: when replacing a block, `end` must be the **last line of the block** (e.g. the closing `}`), not the last *content* line before it. Otherwise the closing delimiter survives and your replacement adds a second copy.
113
- - Do not target shared boundary lines such as `} else {`, `} catch (…) {`, `}),`, or `},{`.
127
+ - When your replacement `content` ends with a closing delimiter (`}`, `*/`, `)`, `]`), verify `end` includes the original line carrying that delimiter. If `end` stops one line too early, the original delimiter survives and your content adds a second copy.
128
+ - **Self-check**: compare the last line of `content` with the line immediately after `end` in the file. If they match (e.g., both are `}`), extend `end` to include that line.
114
129
  - For a block, either replace only the body or replace the whole block. Do not split block boundaries.
115
130
  - `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs.
116
131
  - Do not use this tool to reformat or clean up unrelated code.
package/src/sdk.ts CHANGED
@@ -796,6 +796,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
796
796
  }),
797
797
  );
798
798
 
799
+ // collect alwaysApply rules — full content injected into system prompt
800
+ const alwaysApplyRules = rulesResult.items.filter((rule: Rule) => {
801
+ if (registeredTtsrRuleNames.has(rule.name)) return false;
802
+ return rule.alwaysApply === true;
803
+ });
804
+
799
805
  const contextFiles = await logger.timeAsync(
800
806
  "discoverContextFiles",
801
807
  async () => options.contextFiles ?? (await discoverContextFiles(cwd, agentDir)),
@@ -930,7 +936,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
930
936
  );
931
937
  internalRouter.register(
932
938
  new RuleProtocolHandler({
933
- getRules: () => rulebookRules,
939
+ getRules: () => [...rulebookRules, ...alwaysApplyRules],
934
940
  }),
935
941
  );
936
942
  internalRouter.register(new PiProtocolHandler());
@@ -1252,6 +1258,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1252
1258
  tools: promptTools,
1253
1259
  toolNames,
1254
1260
  rules: rulebookRules,
1261
+ alwaysApplyRules,
1255
1262
  skillsSettings: settings.getGroup("skills"),
1256
1263
  appendSystemPrompt: appendPrompt,
1257
1264
  repeatToolDescriptions,
@@ -1272,6 +1279,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1272
1279
  tools: promptTools,
1273
1280
  toolNames,
1274
1281
  rules: rulebookRules,
1282
+ alwaysApplyRules,
1275
1283
  skillsSettings: settings.getGroup("skills"),
1276
1284
  customPrompt: options.systemPrompt,
1277
1285
  appendSystemPrompt: appendPrompt,