@oh-my-pi/pi-coding-agent 16.1.9 → 16.1.11

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 (81) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cli.js +3455 -3430
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/edit/streaming.d.ts +5 -5
  5. package/dist/types/export/html/index.d.ts +31 -2
  6. package/dist/types/export/html/web-palette.d.ts +117 -0
  7. package/dist/types/hindsight/content.d.ts +7 -0
  8. package/dist/types/hindsight/transcript.d.ts +1 -1
  9. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  10. package/dist/types/modes/controllers/selector-controller.d.ts +0 -1
  11. package/dist/types/modes/interactive-mode.d.ts +5 -0
  12. package/dist/types/modes/types.d.ts +17 -0
  13. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  14. package/dist/types/sdk.d.ts +1 -0
  15. package/dist/types/session/agent-session.d.ts +4 -0
  16. package/dist/types/session/session-context.d.ts +2 -0
  17. package/dist/types/session/session-entries.d.ts +6 -0
  18. package/dist/types/session/session-manager.d.ts +2 -1
  19. package/dist/types/system-prompt.d.ts +2 -0
  20. package/dist/types/tools/acp-bridge.d.ts +29 -0
  21. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +39 -0
  22. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +10 -0
  23. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  24. package/dist/types/tools/browser.d.ts +1 -0
  25. package/dist/types/tools/find.d.ts +1 -2
  26. package/dist/types/tools/write.d.ts +1 -2
  27. package/dist/types/utils/image-loading.d.ts +4 -3
  28. package/dist/types/utils/jj.d.ts +25 -0
  29. package/dist/types/utils/title-generator.d.ts +0 -2
  30. package/package.json +12 -12
  31. package/scripts/generate-aria-snapshot.ts +134 -0
  32. package/scripts/generate-share-viewer.ts +4 -2
  33. package/src/autoresearch/git.ts +12 -0
  34. package/src/config/settings-schema.ts +12 -0
  35. package/src/discovery/opencode.ts +47 -4
  36. package/src/edit/hashline/filesystem.ts +8 -0
  37. package/src/edit/modes/patch.ts +18 -2
  38. package/src/edit/modes/replace.ts +13 -10
  39. package/src/edit/streaming.ts +5 -5
  40. package/src/export/html/index.ts +50 -8
  41. package/src/export/html/web-palette.ts +142 -0
  42. package/src/hindsight/backend.ts +4 -4
  43. package/src/hindsight/content.ts +17 -1
  44. package/src/hindsight/transcript.ts +2 -2
  45. package/src/internal-urls/docs-index.generated.txt +1 -1
  46. package/src/modes/components/agent-dashboard.ts +8 -8
  47. package/src/modes/components/hook-editor.ts +13 -10
  48. package/src/modes/components/session-selector.ts +3 -0
  49. package/src/modes/controllers/event-controller.ts +29 -5
  50. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  51. package/src/modes/controllers/selector-controller.ts +3 -5
  52. package/src/modes/interactive-mode.ts +69 -29
  53. package/src/modes/theme/dark.json +1 -1
  54. package/src/modes/types.ts +18 -0
  55. package/src/modes/utils/keybinding-matchers.ts +36 -1
  56. package/src/modes/utils/ui-helpers.ts +1 -0
  57. package/src/prompts/system/project-prompt.md +2 -0
  58. package/src/prompts/system/system-prompt.md +0 -1
  59. package/src/prompts/tools/browser.md +5 -2
  60. package/src/sdk.ts +20 -2
  61. package/src/session/agent-session.ts +38 -14
  62. package/src/session/session-context.ts +5 -0
  63. package/src/session/session-entries.ts +6 -0
  64. package/src/session/session-manager.ts +3 -1
  65. package/src/system-prompt.ts +15 -3
  66. package/src/task/worktree.ts +12 -4
  67. package/src/thinking.ts +1 -1
  68. package/src/tools/acp-bridge.ts +66 -0
  69. package/src/tools/bash.ts +3 -3
  70. package/src/tools/browser/aria/aria-snapshot.bundle.txt +7 -0
  71. package/src/tools/browser/aria/aria-snapshot.ts +103 -0
  72. package/src/tools/browser/cmux/cmux-tab.ts +73 -1
  73. package/src/tools/browser/tab-worker.ts +242 -47
  74. package/src/tools/browser.ts +5 -0
  75. package/src/tools/find.ts +1 -2
  76. package/src/tools/index.ts +1 -1
  77. package/src/tools/write.ts +3 -27
  78. package/src/utils/git.ts +3 -2
  79. package/src/utils/image-loading.ts +6 -3
  80. package/src/utils/jj.ts +47 -0
  81. package/src/utils/title-generator.ts +31 -99
@@ -52,7 +52,12 @@ import { discoverAgents } from "../../task/discovery";
52
52
  import type { AgentDefinition, AgentSource } from "../../task/types";
53
53
  import { shortenPath } from "../../tools/render-utils";
54
54
  import { getEditorTheme, theme } from "../theme/theme";
55
- import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
55
+ import {
56
+ matchesAppFollowUp,
57
+ matchesAppInterrupt,
58
+ matchesSelectDown,
59
+ matchesSelectUp,
60
+ } from "../utils/keybinding-matchers";
56
61
  import { DynamicBorder } from "./dynamic-border";
57
62
 
58
63
  type SourceTabId = "all" | AgentSource;
@@ -652,11 +657,6 @@ export class AgentDashboard extends Container {
652
657
  this.#buildLayout();
653
658
  }
654
659
 
655
- #shouldSubmitCreateDescription(data: string): boolean {
656
- if (matchesKey(data, "ctrl+enter")) return true;
657
- return process.platform === "win32" && data === "\n" && this.#createDescription.trim().length > 0;
658
- }
659
-
660
660
  async #generateAgentFromDescription(rawDescription: string): Promise<void> {
661
661
  const description = rawDescription.trim();
662
662
  this.#createDescription = description;
@@ -911,7 +911,7 @@ export class AgentDashboard extends Container {
911
911
  this.addChild(new Spacer(1));
912
912
  const hints = this.#createGenerating
913
913
  ? " Generating..."
914
- : " Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel";
914
+ : " Ctrl+Q/Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel";
915
915
  this.addChild(new Text(theme.fg("dim", hints), 0, 0));
916
916
  }
917
917
 
@@ -1102,7 +1102,7 @@ export class AgentDashboard extends Container {
1102
1102
  }
1103
1103
  return;
1104
1104
  }
1105
- if (!this.#createGenerating && this.#shouldSubmitCreateDescription(data)) {
1105
+ if (!this.#createGenerating && matchesAppFollowUp(data)) {
1106
1106
  this.#submitCreateDescription();
1107
1107
  return;
1108
1108
  }
@@ -3,12 +3,17 @@
3
3
  * Supports Ctrl+G for external editor.
4
4
  *
5
5
  * Two modes:
6
- * - Default (hook): Enter inserts newline, Ctrl+Enter submits, bordered popup
6
+ * - Default (hook): Enter inserts newline, the `app.message.followUp` chord
7
+ * (Ctrl+Q / Ctrl+Enter) submits, bordered popup
7
8
  * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome
8
9
  */
9
10
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
11
  import { getEditorTheme, theme } from "../../modes/theme/theme";
11
- import { matchesAppExternalEditor, matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
12
+ import {
13
+ matchesAppExternalEditor,
14
+ matchesAppFollowUp,
15
+ matchesAppInterrupt,
16
+ } from "../../modes/utils/keybinding-matchers";
12
17
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
13
18
  import { DynamicBorder } from "./dynamic-border";
14
19
 
@@ -17,10 +22,6 @@ export interface HookEditorOptions {
17
22
  promptStyle?: boolean;
18
23
  }
19
24
 
20
- function isCtrlEnterSubmit(keyData: string): boolean {
21
- return matchesKey(keyData, "ctrl+enter") || (keyData.charCodeAt(0) === 10 && keyData.length > 1);
22
- }
23
-
24
25
  export class HookEditorComponent extends Container {
25
26
  #editor: Editor;
26
27
  #onSubmitCallback: (value: string) => void;
@@ -67,7 +68,7 @@ export class HookEditorComponent extends Container {
67
68
  // Hint
68
69
  const hint = this.#promptStyle
69
70
  ? "enter submit esc cancel ctrl+g external editor"
70
- : "ctrl+enter submit esc cancel ctrl+g external editor";
71
+ : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
71
72
  this.addChild(new Text(theme.fg("dim", hint), 1, 0));
72
73
 
73
74
  this.addChild(new Spacer(1));
@@ -118,10 +119,12 @@ export class HookEditorComponent extends Container {
118
119
  this.#editor.handleInput(keyData);
119
120
  }
120
121
 
121
- /** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */
122
+ /** Hook-style: Enter=newline, app.message.followUp chord (Ctrl+Q/Ctrl+Enter) submits. */
122
123
  #handleHookStyleInput(keyData: string): void {
123
- // Ctrl+Enter to submit. Use key matching so lock-key and keypad Enter variants work.
124
- if (isCtrlEnterSubmit(keyData)) {
124
+ // Submit on the follow-up chord. Uses the shared keybinding so Ctrl+Q works
125
+ // on Windows Terminal (#1903) and any user remap of `app.message.followUp`
126
+ // applies here too.
127
+ if (matchesAppFollowUp(keyData)) {
125
128
  this.#submitCurrentText();
126
129
  return;
127
130
  }
@@ -353,6 +353,9 @@ class SessionList implements Component {
353
353
  if (status) {
354
354
  metadata += ` ${dot} ${status}`;
355
355
  }
356
+ if (session.parentSessionPath) {
357
+ metadata += ` ${dot} ${dim(`${theme.icon.branch} fork`)}`;
358
+ }
356
359
  if (this.#showCwd && session.cwd) {
357
360
  metadata += ` ${dot} ${dim(shortenPath(session.cwd))}`;
358
361
  }
@@ -296,8 +296,15 @@ export class EventController {
296
296
  this.#resetReadGroup();
297
297
  this.#resolveDisplaceablePoll();
298
298
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
299
- const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
300
- if (!wasOptimistic) {
299
+ const matchedLocalSubmission = this.ctx.locallySubmittedUserSignatures.delete(signature);
300
+ const replacesOptimistic =
301
+ this.ctx.optimisticUserMessageSignature !== undefined && !wasOptimistic && !matchedLocalSubmission;
302
+ const wasLocallySubmitted = matchedLocalSubmission || wasOptimistic || replacesOptimistic;
303
+ if (wasOptimistic) {
304
+ this.ctx.clearOptimisticUserMessage();
305
+ } else if (replacesOptimistic) {
306
+ this.ctx.replaceOptimisticUserMessage(event.message);
307
+ } else {
301
308
  // Append synchronously: #emit dispatches to this listener fire-and-forget
302
309
  // (see AgentSession.#emit), so any await between the user message_start and
303
310
  // addMessageToChat lets later events (assistant message_start, tool execution
@@ -306,9 +313,6 @@ export class EventController {
306
313
  // links via the synchronous putBlobSync fallback, so no await is needed here.
307
314
  this.ctx.addMessageToChat(event.message);
308
315
  }
309
- if (wasOptimistic) {
310
- this.ctx.optimisticUserMessageSignature = undefined;
311
- }
312
316
 
313
317
  // Clear the editor only when the submission did not originate from a
314
318
  // local submission (optimistic or queued-while-streaming). Both local
@@ -738,6 +742,26 @@ export class EventController {
738
742
  this.ctx.chatContainer.addChild(component);
739
743
  this.ctx.pendingTools.set(event.toolCallId, component);
740
744
  this.ctx.ui.requestRender();
745
+ } else {
746
+ // The tool is about to run, so its arguments are final and validated.
747
+ // A pending component created while args streamed (message_update) may
748
+ // still show a mid-reveal prefix — or, when the closing full-args
749
+ // `message_update` never lands (smooth-streaming off leaving the
750
+ // throttled `arguments` stale, an owned-dialect projector, or a
751
+ // superseded/aborted turn that still executes the call), a stale body
752
+ // the result render then freezes at its `…` placeholder. Reconcile the
753
+ // authoritative args here and drop any live reveal so a late tick can't
754
+ // re-truncate them: tool_execution_start is the one event every
755
+ // execution path emits with the full args immediately before the result.
756
+ this.#toolArgsReveal.finish(event.toolCallId);
757
+ const component = this.ctx.pendingTools.get(event.toolCallId);
758
+ if (component && typeof component.updateArgs === "function") {
759
+ component.updateArgs(event.args, event.toolCallId);
760
+ if (typeof component.setArgsComplete === "function") {
761
+ component.setArgsComplete(event.toolCallId);
762
+ }
763
+ this.ctx.ui.requestRender();
764
+ }
741
765
  }
742
766
  }
743
767
 
@@ -810,8 +810,12 @@ export class ExtensionUiController {
810
810
 
811
811
  #applyCustomMessageDisplay(wasStreaming: boolean, shouldDisplay: boolean | undefined): void {
812
812
  // For non-streaming cases with display=true, update UI
813
- // (streaming cases update via message_end event)
814
- if (!wasStreaming && shouldDisplay) {
813
+ // (streaming cases update via message_end event).
814
+ // Gate on initialChatRendered (#1955): an extension's session_start
815
+ // sendMessage({display:true}) runs before renderInitialMessages, which would
816
+ // re-render from session entries AND re-append via preserveExistingChat,
817
+ // duplicating the message. After the initial render the rebuild must run.
818
+ if (!wasStreaming && shouldDisplay && this.ctx.initialChatRendered) {
815
819
  this.ctx.rebuildChatFromMessages();
816
820
  }
817
821
  }
@@ -71,10 +71,6 @@ import { buildCopyTargets } from "../utils/copy-targets";
71
71
 
72
72
  const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
73
73
 
74
- export function formatTemporaryModelStatus(modelLabel: string, roleSelectorHint = "Alt+M"): string {
75
- return `Temporary model selection is session-only: ${modelLabel}. Use ${roleSelectorHint} or /model for role models (default/smol/plan/task/slow/custom roles).`;
76
- }
77
-
78
74
  export class SelectorController {
79
75
  constructor(private ctx: InteractiveModeContext) {}
80
76
 
@@ -499,7 +495,9 @@ export class SelectorController {
499
495
  this.ctx.statusLine.invalidate();
500
496
  this.ctx.updateEditorBorderColor();
501
497
  const roleSelectorHint = this.ctx.keybindings.getKeys("app.model.select")[0] ?? "Alt+M";
502
- this.ctx.showStatus(formatTemporaryModelStatus(selector ?? model.id, roleSelectorHint));
498
+ this.ctx.showStatus(
499
+ `Session-only model: ${selector ?? model.id}. Use ${roleSelectorHint} or /model for roles.`,
500
+ );
503
501
  done();
504
502
  this.ctx.ui.requestRender();
505
503
  } else if (role === "default") {
@@ -390,6 +390,7 @@ export class InteractiveMode implements InteractiveModeContext {
390
390
  statusLine: StatusLineComponent;
391
391
 
392
392
  isInitialized = false;
393
+ initialChatRendered = false;
393
394
  isBashMode = false;
394
395
  toolOutputExpanded = false;
395
396
  todoExpanded = false;
@@ -435,6 +436,7 @@ export class InteractiveMode implements InteractiveModeContext {
435
436
  locallySubmittedUserSignatures: Set<string> = new Set();
436
437
  #pendingSubmittedInput: SubmittedUserInput | undefined;
437
438
  #pendingSubmissionDispose: (() => void) | undefined;
439
+ #optimisticUserMessageComponents: Component[] = [];
438
440
  lastSigintTime = 0;
439
441
  lastEscapeTime = 0;
440
442
  lastLeftTapTime = 0;
@@ -1247,6 +1249,32 @@ export class InteractiveMode implements InteractiveModeContext {
1247
1249
  throw err;
1248
1250
  }
1249
1251
  }
1252
+ #captureAddedChatComponents(render: () => void): Component[] {
1253
+ const start = this.chatContainer.children.length;
1254
+ render();
1255
+ return this.chatContainer.children.slice(start);
1256
+ }
1257
+
1258
+ clearOptimisticUserMessage(): void {
1259
+ this.optimisticUserMessageSignature = undefined;
1260
+ this.#pendingSubmissionDispose?.();
1261
+ this.#pendingSubmissionDispose = undefined;
1262
+ this.#optimisticUserMessageComponents = [];
1263
+ }
1264
+
1265
+ replaceOptimisticUserMessage(
1266
+ message: AgentMessage,
1267
+ options?: { imageLinks?: readonly (string | undefined)[] },
1268
+ ): void {
1269
+ this.optimisticUserMessageSignature = undefined;
1270
+ this.#pendingSubmissionDispose?.();
1271
+ this.#pendingSubmissionDispose = undefined;
1272
+ for (const component of this.#optimisticUserMessageComponents) {
1273
+ this.chatContainer.removeChild(component);
1274
+ }
1275
+ this.#optimisticUserMessageComponents = [];
1276
+ this.addMessageToChat(message, options);
1277
+ }
1250
1278
 
1251
1279
  startPendingSubmission(input: {
1252
1280
  text: string;
@@ -1272,18 +1300,19 @@ export class InteractiveMode implements InteractiveModeContext {
1272
1300
  const imageCount = submission.images?.length ?? 0;
1273
1301
  this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
1274
1302
  this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
1275
- this.addMessageToChat(
1276
- {
1277
- role: "user",
1278
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1279
- attribution: "user",
1280
- timestamp: Date.now(),
1281
- },
1282
- { imageLinks: input.imageLinks },
1283
- );
1303
+ this.#optimisticUserMessageComponents = this.#captureAddedChatComponents(() => {
1304
+ this.addMessageToChat(
1305
+ {
1306
+ role: "user",
1307
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1308
+ attribution: "user",
1309
+ timestamp: Date.now(),
1310
+ },
1311
+ { imageLinks: input.imageLinks },
1312
+ );
1313
+ });
1284
1314
  } else {
1285
- this.optimisticUserMessageSignature = undefined;
1286
- this.#pendingSubmissionDispose = undefined;
1315
+ this.clearOptimisticUserMessage();
1287
1316
  }
1288
1317
  this.editor.setText("");
1289
1318
  this.editor.imageLinks = undefined;
@@ -1300,9 +1329,7 @@ export class InteractiveMode implements InteractiveModeContext {
1300
1329
 
1301
1330
  submission.cancelled = true;
1302
1331
  this.#pendingSubmittedInput = undefined;
1303
- this.optimisticUserMessageSignature = undefined;
1304
- this.#pendingSubmissionDispose?.();
1305
- this.#pendingSubmissionDispose = undefined;
1332
+ this.clearOptimisticUserMessage();
1306
1333
  this.#pendingWorkingMessage = undefined;
1307
1334
  if (submission.customType === "goal-continuation") {
1308
1335
  this.#goalContinuationTurnInFlight = false;
@@ -1344,6 +1371,7 @@ export class InteractiveMode implements InteractiveModeContext {
1344
1371
  if (wasPendingSubmission && !this.session.isStreaming && !this.streamingComponent) {
1345
1372
  this.optimisticUserMessageSignature = undefined;
1346
1373
  pendingSubmissionDispose?.();
1374
+ this.#optimisticUserMessageComponents = [];
1347
1375
  this.#pendingWorkingMessage = undefined;
1348
1376
  if (this.loadingAnimation) {
1349
1377
  this.#stopLoadingAnimation(true);
@@ -1434,15 +1462,17 @@ export class InteractiveMode implements InteractiveModeContext {
1434
1462
  if (!this.optimisticUserMessageSignature) return;
1435
1463
  const submission = this.#pendingSubmittedInput;
1436
1464
  if (!submission || submission.cancelled || submission.customType) return;
1437
- this.addMessageToChat(
1438
- {
1439
- role: "user",
1440
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1441
- attribution: "user",
1442
- timestamp: Date.now(),
1443
- },
1444
- { imageLinks: submission.imageLinks },
1445
- );
1465
+ this.#optimisticUserMessageComponents = this.#captureAddedChatComponents(() => {
1466
+ this.addMessageToChat(
1467
+ {
1468
+ role: "user",
1469
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1470
+ attribution: "user",
1471
+ timestamp: Date.now(),
1472
+ },
1473
+ { imageLinks: submission.imageLinks },
1474
+ );
1475
+ });
1446
1476
  }
1447
1477
 
1448
1478
  #formatTodoLine(todo: TodoItem, prefix: string, matched: boolean): string {
@@ -1926,9 +1956,21 @@ export class InteractiveMode implements InteractiveModeContext {
1926
1956
 
1927
1957
  const planFilePath = options?.planFilePath ?? (await this.#getPlanFilePath());
1928
1958
  const previousTools = this.session.getActiveToolNames();
1929
- const hasResolveTool = this.session.getToolByName("resolve") !== undefined;
1930
- const planTools = hasResolveTool ? [...previousTools, "resolve"] : previousTools;
1931
- const uniquePlanTools = [...new Set(planTools)];
1959
+ // `plan-mode-active.md` instructs the agent to draft the plan file with
1960
+ // `write` and refine it with `edit`. Both must be in the active set or the
1961
+ // agent falls back to `edit` on a non-existent file and stalls. `edit` is an
1962
+ // essential built-in so it survives `tools.discoveryMode === "all"`, but
1963
+ // `write` has `loadMode: "discoverable"` and is hidden behind
1964
+ // `search_tool_bm25` — re-activate it here only when the current registry
1965
+ // entry is the built-in write tool (issue #3165). A shadowing extension
1966
+ // tool named `write` must stay inactive because plan mode's read-only
1967
+ // guarantee relies on the built-in write/edit guard. `resolve` is hidden
1968
+ // too; the standing handler below consumes plan-approval calls through it.
1969
+ const planAugmentations = ["resolve"];
1970
+ if (this.session.hasBuiltInTool("write")) {
1971
+ planAugmentations.push("write");
1972
+ }
1973
+ const uniquePlanTools = [...new Set([...previousTools, ...planAugmentations])];
1932
1974
 
1933
1975
  this.#planModePreviousTools = previousTools;
1934
1976
  this.planModePlanFilePath = planFilePath;
@@ -3244,9 +3286,7 @@ export class InteractiveMode implements InteractiveModeContext {
3244
3286
 
3245
3287
  showError(message: string): void {
3246
3288
  this.#pendingSubmittedInput = undefined;
3247
- this.optimisticUserMessageSignature = undefined;
3248
- this.#pendingSubmissionDispose?.();
3249
- this.#pendingSubmissionDispose = undefined;
3289
+ this.clearOptimisticUserMessage();
3250
3290
  this.#pendingWorkingMessage = undefined;
3251
3291
  if (this.loadingAnimation) {
3252
3292
  this.#stopLoadingAnimation(true);
@@ -90,6 +90,6 @@
90
90
  "export": {
91
91
  "pageBg": "#18181e",
92
92
  "cardBg": "#1e1e24",
93
- "infoBg": "#3c3728"
93
+ "infoBg": "#26262e"
94
94
  }
95
95
  }
@@ -137,6 +137,17 @@ export interface InteractiveModeContext {
137
137
 
138
138
  // State
139
139
  isInitialized: boolean;
140
+ /**
141
+ * `true` once `renderInitialMessages` has rendered the session transcript
142
+ * into `chatContainer` at least once.
143
+ *
144
+ * Extension chat-rebuilds (`ExtensionUiController.#applyCustomMessageDisplay`)
145
+ * are gated on this: rebuilding before the initial render would plant a
146
+ * session-derived component into the chat that `renderInitialMessages` then
147
+ * both re-renders from session entries AND re-appends via
148
+ * `preserveExistingChat`, duplicating the message (issue #1955).
149
+ */
150
+ initialChatRendered: boolean;
140
151
  isBashMode: boolean;
141
152
  toolOutputExpanded: boolean;
142
153
  todoExpanded: boolean;
@@ -258,6 +269,13 @@ export interface InteractiveModeContext {
258
269
  * delivery error should leave the signature set untouched.
259
270
  */
260
271
  withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T>;
272
+ /** Clears bookkeeping for an optimistic local user message once the matching session event arrives. */
273
+ clearOptimisticUserMessage(): void;
274
+ /** Replaces the raw optimistic user render with the canonical message emitted by the session. */
275
+ replaceOptimisticUserMessage(
276
+ message: AgentMessage,
277
+ options?: { imageLinks?: readonly (string | undefined)[] },
278
+ ): void;
261
279
  isKnownSlashCommand(text: string): boolean;
262
280
  addMessageToChat(
263
281
  message: AgentMessage,
@@ -1,4 +1,4 @@
1
- import { getKeybindings, matchesKey } from "@oh-my-pi/pi-tui";
1
+ import { getKeybindings, type KeyId, matchesKey } from "@oh-my-pi/pi-tui";
2
2
 
3
3
  /**
4
4
  * Match the coding-agent interrupt key.
@@ -49,3 +49,38 @@ export function matchesAppExternalEditor(data: string): boolean {
49
49
  }
50
50
  return matchesKey(data, "ctrl+g");
51
51
  }
52
+
53
+ function matchesEffectiveKey(data: string, key: KeyId): boolean {
54
+ if ((key === "ctrl+enter" || key === "ctrl+return") && data.charCodeAt(0) === 10 && data.length > 1) {
55
+ return true;
56
+ }
57
+ return matchesKey(data, key);
58
+ }
59
+
60
+ function matchesEffectiveKeys(data: string, keys: readonly KeyId[]): boolean {
61
+ for (const key of keys) {
62
+ if (matchesEffectiveKey(data, key)) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Match the "submit multi-line text input" keybinding (`app.message.followUp`).
69
+ *
70
+ * Used by forms where plain Enter inserts a newline and a modified-Enter chord
71
+ * submits — the main editor's follow-up handler, the agent dashboard's new-agent
72
+ * description, and the hook editor's hook-style mode. The keybinding defaults to
73
+ * `["ctrl+q", "ctrl+enter"]` so Windows Terminal (which can't deliver a distinct
74
+ * Ctrl+Enter event; #1903) still has a working chord without user remapping.
75
+ *
76
+ * Also recognizes modifier-tagged LF as Ctrl+Enter only when Ctrl+Enter is an
77
+ * effective follow-up binding.
78
+ */
79
+ export function matchesAppFollowUp(data: string): boolean {
80
+ const keybindings = getKeybindings();
81
+ const keys = keybindings.getKeys("app.message.followUp");
82
+ if (keys.length > 0) {
83
+ return matchesEffectiveKeys(data, keys);
84
+ }
85
+ return matchesEffectiveKeys(data, ["ctrl+enter", "ctrl+q"]);
86
+ }
@@ -605,6 +605,7 @@ export class UiHelpers {
605
605
  // dispose them (stopping any live timers/subscriptions) before clearing. When
606
606
  // preserving, the same instances are re-added below, so detach without dispose.
607
607
  const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
608
+ this.ctx.initialChatRendered = true;
608
609
  if (preservedChatChildren) {
609
610
  this.ctx.chatContainer.clear();
610
611
  } else {
@@ -29,6 +29,7 @@ Before making changes within these directories, you MUST read:
29
29
  The context files above are loaded automatically. You NEVER `search`/`find` for `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, or similar agent/context files — the relevant ones are already in your context; any others are noise.
30
30
  {{/ifAny}}
31
31
 
32
+ {{#if includeWorkspaceTree}}
32
33
  {{#if workspaceTree.rendered}}
33
34
  <workspace-tree>
34
35
  Working directory layout (sorted by mtime, recent first; depth ≤ 3):
@@ -38,6 +39,7 @@ Working directory layout (sorted by mtime, recent first; depth ≤ 3):
38
39
  {{/if}}
39
40
  </workspace-tree>
40
41
  {{/if}}
42
+ {{/if}}
41
43
 
42
44
  Today is {{date}}, and the current working directory is '{{cwd}}'.
43
45
 
@@ -17,7 +17,6 @@ You are a helpful assistant the team trusts with load-bearing changes, operating
17
17
  - You are not alone in this repo. Treat unexpected changes as the user's work and adapt.
18
18
  - In terminal prose and final chat, you MAY use LaTeX math (`$`, `$$`, `\text`, `\times`) and color (`\textcolor`, `\colorbox`, `\fcolorbox`).
19
19
  - To show a diagram, you MAY emit a ` ```mermaid ` block — the terminal renders it as ASCII. Use it for genuine structure or flow, not trivia.
20
- - For a visual separator between sections, use `─` (U+2500).
21
20
 
22
21
  RUNTIME
23
22
  ==============
@@ -15,19 +15,22 @@ Drives real Chromium tab; full puppeteer access via JS.
15
15
  - `tab` helpers; drop to raw puppeteer `page` for anything uncovered:
16
16
  - `tab.goto(url, { waitUntil? })` — navigate.
17
17
  - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot: `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Ids stable until next observe/goto.
18
+ - `tab.ariaSnapshot(selector?, { depth?, boxes? })` — Playwright-format ARIA-tree YAML (nested roles + accessible names + `/url`/`/placeholder`), scoped to `selector` or the whole document. Every node carries a `[ref=eN]` id; `[cursor=pointer]` flags clickables. Captures dense, hierarchical structure/text that `observe()`'s flat list flattens away. Refs renumber from e1 each call and stay valid until the next `ariaSnapshot()`.
19
+ - `tab.ref("e5")` — `[ref=eN]` from the last ariaSnapshot → element handle with the common action methods (`.click()`, `.type()`, `.fill()`, `.hover()`, `.evaluate()`, …); the primary way to act on a ref. For convenience `aria-ref=e5` also works inline in `tab.click`/`type`/`fill`/`waitFor`/`scrollIntoView` (e.g. `tab.click("aria-ref=e5")`).
18
20
  - `tab.id(n)` — id from last observe → `ElementHandle` (`.click()`, `.type()`, …).
19
21
  - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)`.
20
- - `tab.waitFor(selector)` — wait until attached; returns `ElementHandle`.
22
+ - `tab.waitFor(selector, { timeout? })` / `tab.waitForSelector(selector, { timeout?, visible?, hidden? })` — wait until attached (optionally visible/hidden); returns the `ElementHandle`.
21
23
  - `tab.drag(from, to)` — endpoints: selector (center-to-center) or `{ x, y }` viewport point (canvases, sliders).
22
24
  - `tab.scrollIntoView(selector)` — center in viewport; before clicking off-screen elements.
23
25
  - `tab.select(selector, …values)` — set `<select>` option(s); returns selection. `tab.fill` NEVER works for selects.
24
26
  - `tab.uploadFile(selector, …filePaths)` — attach files to `<input type="file">`; paths relative to cwd.
25
27
  - `tab.waitForUrl(pattern, { timeout? })` — substring or `RegExp` (matches SPA pushState nav); returns matched URL.
26
28
  - `tab.waitForResponse(pattern, { timeout? })` — substring, `RegExp`, or `(response) => boolean`; returns puppeteer `HTTPResponse` (`.text()`/`.json()`/`.status()`/`.headers()`).
29
+ - `tab.waitForNavigation({ waitUntil?, timeout? })` — resolves on the next navigation. Start it BEFORE the click/submit that triggers it; after `tab.goto` (which already waits) use `tab.waitForUrl`/`tab.waitForSelector` instead.
27
30
  - `tab.evaluate(fn, …args)` — `page.evaluate` for ad-hoc DOM reads.
28
31
  - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file.
29
32
  - `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable.
30
- - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`.
33
+ - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.<op> timed out` error, never the whole-cell timeout.
31
34
  </instruction>
32
35
 
33
36
  <critical>
package/src/sdk.ts CHANGED
@@ -796,6 +796,7 @@ export interface BuildSystemPromptOptions {
796
796
  customPrompt?: string;
797
797
  appendPrompt?: string;
798
798
  inlineToolDescriptors?: boolean;
799
+ includeWorkspaceTree?: boolean;
799
800
  }
800
801
 
801
802
  /**
@@ -813,6 +814,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
813
814
  contextFiles: options.contextFiles,
814
815
  appendSystemPrompt: options.appendPrompt,
815
816
  inlineToolDescriptors: options.inlineToolDescriptors,
817
+ includeWorkspaceTree: options.includeWorkspaceTree,
816
818
  toolNames: options.tools?.map(tool => tool.name),
817
819
  tools: toolMap ? buildSystemPromptToolMetadata(toolMap) : undefined,
818
820
  });
@@ -1120,9 +1122,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1120
1122
  // startup does not perform a second recursive filesystem search. Subagents
1121
1123
  // inherit the parent's resolved values via options.
1122
1124
  const STARTUP_SCAN_DEADLINE_MS = 5000;
1125
+ const includeWorkspaceTree = settings.get("includeWorkspaceTree") ?? false;
1123
1126
  const workspaceTreePromise: Promise<WorkspaceTree> = options.workspaceTree
1124
1127
  ? Promise.resolve(options.workspaceTree)
1125
- : logger.time("buildWorkspaceTree", () => buildWorkspaceTree(cwd, { timeoutMs: STARTUP_SCAN_DEADLINE_MS }));
1128
+ : includeWorkspaceTree
1129
+ ? logger.time("buildWorkspaceTree", () => buildWorkspaceTree(cwd, { timeoutMs: STARTUP_SCAN_DEADLINE_MS }))
1130
+ : Promise.resolve({ rootPath: cwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] });
1126
1131
  workspaceTreePromise.catch(() => {});
1127
1132
 
1128
1133
  // Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited
@@ -1283,7 +1288,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1283
1288
  const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => {
1284
1289
  let level = options.thinkingLevel;
1285
1290
  if (level === undefined && hasExistingSession && hasThinkingEntry) {
1286
- level = parseThinkingLevel(existingSession.thinkingLevel);
1291
+ level =
1292
+ parseConfiguredThinkingLevel(existingSession.configuredThinkingLevel) ??
1293
+ parseThinkingLevel(existingSession.thinkingLevel);
1287
1294
  }
1288
1295
  if (level === undefined && !hasThinkingEntry && restoredSessionThinkingLevel !== undefined) {
1289
1296
  level = restoredSessionThinkingLevel;
@@ -2014,18 +2021,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2014
2021
  );
2015
2022
 
2016
2023
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
2024
+ const builtInRegistryToolNames = new Set<string>();
2017
2025
  const toolRegistry = new Map<string, Tool>();
2018
2026
  for (const tool of builtinTools) {
2019
2027
  toolRegistry.set(tool.name, tool);
2028
+ builtInRegistryToolNames.add(tool.name);
2020
2029
  }
2021
2030
  if (!toolRegistry.has("goal") && settings.get("goal.enabled")) {
2022
2031
  const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
2023
2032
  if (goalTool) {
2024
2033
  toolRegistry.set(goalTool.name, wrapToolWithMetaNotice(goalTool));
2034
+ builtInRegistryToolNames.add(goalTool.name);
2025
2035
  }
2026
2036
  }
2027
2037
  for (const tool of wrappedExtensionTools) {
2028
2038
  toolRegistry.set(tool.name, tool);
2039
+ builtInRegistryToolNames.delete(tool.name);
2029
2040
  }
2030
2041
  if (deferMCPDiscoveryForUI && mcpManager) {
2031
2042
  for (const name of collectPendingMCPToolNames(options.toolNames, existingSession.selectedMCPToolNames)) {
@@ -2043,6 +2054,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2043
2054
  }
2044
2055
  if (model?.provider === "cursor") {
2045
2056
  toolRegistry.delete("edit");
2057
+ builtInRegistryToolNames.delete("edit");
2046
2058
  }
2047
2059
 
2048
2060
  // `resolve` is hidden but must stay in the registry whenever any code path can invoke it:
@@ -2055,10 +2067,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2055
2067
  const needsResolveTool = hasDeferrableTools || planModeAvailable;
2056
2068
  if (!needsResolveTool) {
2057
2069
  toolRegistry.delete("resolve");
2070
+ builtInRegistryToolNames.delete("resolve");
2058
2071
  } else if (!toolRegistry.has("resolve")) {
2059
2072
  const resolveTool = await logger.time("createTools:resolve:session", HIDDEN_TOOLS.resolve, toolSession);
2060
2073
  if (resolveTool) {
2061
2074
  toolRegistry.set(resolveTool.name, wrapToolWithMetaNotice(resolveTool));
2075
+ builtInRegistryToolNames.add(resolveTool.name);
2062
2076
  }
2063
2077
  }
2064
2078
 
@@ -2075,6 +2089,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2075
2089
  searchTool.name,
2076
2090
  new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
2077
2091
  );
2092
+ builtInRegistryToolNames.add(searchTool.name);
2078
2093
  }
2079
2094
  let mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
2080
2095
 
@@ -2101,6 +2116,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2101
2116
  const eagerTasks = settings.get("task.eager") !== "default";
2102
2117
  const eagerTasksAlways = settings.get("task.eager") === "always";
2103
2118
  const intentField = $flag("PI_INTENT_TRACING", settings.get("tools.intentTracing")) ? INTENT_FIELD : undefined;
2119
+ const includeWorkspaceTree = settings.get("includeWorkspaceTree") ?? false;
2104
2120
  const rebuildSystemPrompt = async (
2105
2121
  toolNames: string[],
2106
2122
  tools: Map<string, AgentTool>,
@@ -2194,6 +2210,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2194
2210
  taskBatch: settings.get("task.batch"),
2195
2211
  secretsEnabled,
2196
2212
  workspaceTree: workspaceTreePromise,
2213
+ includeWorkspaceTree,
2197
2214
  memoryRootEnabled: memoryBackend.id === "local",
2198
2215
  model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
2199
2216
  personality: agentKind === "sub" ? "none" : settings.get("personality"),
@@ -2616,6 +2633,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2616
2633
  skillsSettings: settings.getGroup("skills"),
2617
2634
  modelRegistry,
2618
2635
  toolRegistry,
2636
+ builtInToolNames: builtInRegistryToolNames,
2619
2637
  transformContext,
2620
2638
  onPayload,
2621
2639
  onResponse,