@oh-my-pi/pi-coding-agent 16.3.12 → 16.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli.js +3244 -3246
  3. package/dist/types/config/keybindings.d.ts +9 -4
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  6. package/dist/types/mnemopi/state.d.ts +7 -3
  7. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  8. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  9. package/dist/types/modes/interactive-mode.d.ts +3 -1
  10. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  11. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  12. package/dist/types/modes/types.d.ts +3 -1
  13. package/dist/types/session/agent-session.d.ts +3 -4
  14. package/dist/types/tools/read.d.ts +1 -0
  15. package/dist/types/tools/renderers.d.ts +12 -5
  16. package/dist/types/tools/ssh.d.ts +4 -1
  17. package/dist/types/tools/write.d.ts +1 -0
  18. package/package.json +12 -12
  19. package/src/config/keybindings.ts +62 -10
  20. package/src/config/model-registry.ts +85 -20
  21. package/src/config/settings.ts +48 -21
  22. package/src/extensibility/extensions/runner.ts +1 -0
  23. package/src/extensibility/extensions/types.ts +13 -2
  24. package/src/internal-urls/docs-index.generated.txt +1 -1
  25. package/src/mnemopi/state.ts +19 -5
  26. package/src/modes/acp/acp-agent.ts +69 -8
  27. package/src/modes/acp/acp-event-mapper.ts +1 -1
  28. package/src/modes/components/read-tool-group.ts +5 -1
  29. package/src/modes/components/tool-execution.ts +28 -24
  30. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  31. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  32. package/src/modes/controllers/input-controller.ts +5 -55
  33. package/src/modes/interactive-mode.ts +43 -2
  34. package/src/modes/rpc/rpc-client.ts +42 -13
  35. package/src/modes/rpc/rpc-mode.ts +21 -19
  36. package/src/modes/types.ts +3 -0
  37. package/src/prompts/agents/plan.md +0 -1
  38. package/src/prompts/agents/reviewer.md +0 -1
  39. package/src/prompts/tools/grep.md +2 -1
  40. package/src/prompts/tools/memory-edit.md +2 -0
  41. package/src/session/agent-session.ts +8 -5
  42. package/src/tools/grep.ts +58 -13
  43. package/src/tools/image-gen.ts +1 -1
  44. package/src/tools/memory-edit.ts +3 -1
  45. package/src/tools/read.ts +33 -13
  46. package/src/tools/renderers.ts +13 -5
  47. package/src/tools/ssh.ts +10 -3
  48. package/src/tools/tts.ts +1 -1
  49. package/src/tools/write.ts +26 -0
@@ -88,6 +88,7 @@ export type RpcSkillCommandResult = { agentInvoked: true };
88
88
  export async function tryRunRpcSkillCommand(
89
89
  session: RpcSkillCommandSession,
90
90
  text: string,
91
+ streamingBehavior: "steer" | "followUp" = "steer",
91
92
  ): Promise<RpcSkillCommandResult | false> {
92
93
  if (!session.skillsSettings?.enableSkillCommands) return false;
93
94
  const parsed = parseSkillInvocation(text);
@@ -95,13 +96,16 @@ export async function tryRunRpcSkillCommand(
95
96
  const skill = session.skills.find(candidate => candidate.name === parsed.name);
96
97
  if (!skill) return false;
97
98
  const built = await buildSkillPromptMessage(skill, parsed.args, "user");
98
- await session.promptCustomMessage({
99
- customType: SKILL_PROMPT_MESSAGE_TYPE,
100
- content: built.message,
101
- display: true,
102
- details: built.details,
103
- attribution: "user",
104
- });
99
+ await session.promptCustomMessage(
100
+ {
101
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
102
+ content: built.message,
103
+ display: true,
104
+ details: built.details,
105
+ attribution: "user",
106
+ },
107
+ { streamingBehavior },
108
+ );
105
109
  return { agentInvoked: true };
106
110
  }
107
111
 
@@ -755,6 +759,10 @@ export async function runRpcMode(
755
759
  return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions);
756
760
  }
757
761
 
762
+ addAutocompleteProvider(): void {
763
+ // Autocomplete provider composition is not supported in RPC mode
764
+ }
765
+
758
766
  get theme(): Theme {
759
767
  return theme;
760
768
  }
@@ -842,7 +850,7 @@ export async function runRpcMode(
842
850
  // =================================================================
843
851
 
844
852
  case "prompt": {
845
- const skillResult = await tryRunRpcSkillCommand(session, command.message);
853
+ const skillResult = await tryRunRpcSkillCommand(session, command.message, command.streamingBehavior);
846
854
  if (skillResult) {
847
855
  return success(id, "prompt", skillResult);
848
856
  }
@@ -1198,11 +1206,9 @@ export async function runRpcMode(
1198
1206
  return error(id, "login", `Unknown OAuth provider: ${command.providerId}`);
1199
1207
  }
1200
1208
  const uiCtx = new RpcExtensionUIContext(pendingExtensionRequests, output);
1201
- // Track whether onAuth has fired. Providers that use OAuthCallbackFlow
1202
- // always call onAuth first (emit browser URL), then onManualCodeInput as
1203
- // a fallback. Providers that require interactive input (API-key paste,
1204
- // GitHub Enterprise URL, device-code entry) call onPrompt before onAuth.
1205
- // We use this ordering to self-classify at runtime — no static allowlist.
1209
+ // Track whether onAuth has fired. Providers that require interactive
1210
+ // input before a browser URL cannot be satisfied headlessly; after
1211
+ // onAuth, prompt input is the pasted OAuth code/redirect URL path.
1206
1212
  let authEmitted = false;
1207
1213
  try {
1208
1214
  await session.modelRegistry.authStorage.login(command.providerId, {
@@ -1220,7 +1226,7 @@ export async function runRpcMode(
1220
1226
  onProgress: message => {
1221
1227
  uiCtx.notify(message, "info");
1222
1228
  },
1223
- onPrompt: () => {
1229
+ onPrompt: async prompt => {
1224
1230
  if (!authEmitted) {
1225
1231
  // onPrompt called before any auth URL — provider requires
1226
1232
  // interactive input that cannot be satisfied headlessly.
@@ -1231,11 +1237,7 @@ export async function runRpcMode(
1231
1237
  ),
1232
1238
  );
1233
1239
  }
1234
- // onAuth has already fired we are inside OAuthCallbackFlow's
1235
- // manual-redirect fallback race. Returning a never-settling promise
1236
- // lets the race block until the callback server wins; a rejection
1237
- // would be caught as null and spin the while(true) loop.
1238
- return new Promise<string>(() => {});
1240
+ return (await uiCtx.input(prompt.message, prompt.placeholder, { timeout: 600_000 })) ?? "";
1239
1241
  },
1240
1242
  });
1241
1243
  await session.modelRegistry.refresh();
@@ -7,6 +7,7 @@ import type { CollabHost } from "../collab/host";
7
7
  import type { KeybindingsManager } from "../config/keybindings";
8
8
  import type { Settings } from "../config/settings";
9
9
  import type {
10
+ AutocompleteProviderFactory,
10
11
  ExtensionUIContext,
11
12
  ExtensionUIDialogOptions,
12
13
  ExtensionUISelectItem,
@@ -218,6 +219,8 @@ export interface InteractiveModeContext {
218
219
  // Extension UI integration
219
220
  setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
220
221
  initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void;
222
+ /** Stack extension autocomplete behavior on top of the built-in editor provider. */
223
+ addAutocompleteProvider(factory: AutocompleteProviderFactory): void;
221
224
  setEditorComponent(
222
225
  factory: ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => CustomEditor) | undefined,
223
226
  ): void;
@@ -4,7 +4,6 @@ description: Software architect for complex multi-file architectural decisions.
4
4
  tools: read, grep, glob, bash, lsp, web_search, ast_grep
5
5
  spawns: explore
6
6
  model: pi/plan, pi/slow
7
- thinking-level: high
8
7
  ---
9
8
 
10
9
  Analyze the codebase and the user's request. Produce a detailed implementation plan.
@@ -4,7 +4,6 @@ description: "Code review specialist for quality/security analysis"
4
4
  tools: read, grep, glob, bash, lsp, web_search, ast_grep
5
5
  spawns: explore
6
6
  model: pi/slow
7
- thinking-level: high
8
7
  output:
9
8
  properties:
10
9
  overall_correctness:
@@ -2,7 +2,8 @@ Greps files using regex.
2
2
 
3
3
  <instruction>
4
4
  - Rust regex (RE2-style): alternation is `foo|bar`, not GNU BRE-style `foo\|bar`; Rust word boundaries like `\bword\b` are supported. Use line anchors or post-filters instead of lookaround/backreferences.
5
- - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
5
+ - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Use `selector` only for line-number filtering, never path/root selection (`"/"` belongs in `path`).
6
+ - Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
6
7
  - Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
7
8
  </instruction>
8
9
 
@@ -5,6 +5,8 @@ Use only with ids returned by the `recall` tool. Operations:
5
5
  - `forget`: permanently delete a working memory.
6
6
  - `invalidate`: softly supersede a working or episodic memory, optionally pointing at `replacement_id`.
7
7
 
8
+ Fact ids (recall results marked `[facts]`) are read-only: inspect them with `read memory://<id>`; every edit op on a fact id returns `not_editable`.
9
+
8
10
  Prefer `invalidate` when a memory became stale but its history may still be useful. Use `forget` only for content that should be hard-deleted.
9
11
 
10
12
  **Always read the full memory before `update`.** Recall results are clipped previews (the trailing `…` marks a truncation and `full_length` reports the original size); `update` replaces content wholesale, so overwriting the preview would delete the unseen tail. Fetch the row first with `read memory://<id>`, then pass the merged content in `content`.
@@ -1180,6 +1180,7 @@ const noOpUIContext: ExtensionUIContext = {
1180
1180
  pasteToEditor: () => {},
1181
1181
  getEditorText: () => "",
1182
1182
  editor: async () => undefined,
1183
+ addAutocompleteProvider: () => {},
1183
1184
  get theme() {
1184
1185
  return theme;
1185
1186
  },
@@ -8309,11 +8310,10 @@ export class AgentSession {
8309
8310
  }
8310
8311
 
8311
8312
  /**
8312
- * Send a user message to the agent.
8313
- * When deliverAs is set, queue the message instead of starting a new turn.
8313
+ * Send a user message through the prompt flow.
8314
8314
  *
8315
- * @param content User message content (string or content array)
8316
- * @param options.deliverAs Delivery mode: "steer" or "followUp"
8315
+ * Omitted `deliverAs` starts a turn when idle and queues as a steer while streaming.
8316
+ * Explicit `deliverAs` queues without starting a turn in either state.
8317
8317
  */
8318
8318
  async sendUserMessage(
8319
8319
  content: string | (TextContent | ImageContent)[],
@@ -8348,10 +8348,13 @@ export class AgentSession {
8348
8348
  return;
8349
8349
  }
8350
8350
 
8351
- // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
8351
+ // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion.
8352
+ // `streamingBehavior: "steer"` preserves prompt-flow side effects during streaming while
8353
+ // covering the narrow race where a stream starts before prompt() acquires the turn.
8352
8354
  await this.prompt(text, {
8353
8355
  expandPromptTemplates: false,
8354
8356
  images,
8357
+ streamingBehavior: "steer",
8355
8358
  });
8356
8359
  }
8357
8360
 
package/src/tools/grep.ts CHANGED
@@ -80,7 +80,7 @@ const searchSchema = type({
80
80
  'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
81
81
  ),
82
82
  "selector?": type("string").describe(
83
- 'line selector without a leading colon (e.g. "50-100", "50+10", "50-100,200-300"); keeps `path` literal when filenames contain colons',
83
+ 'line selector applied to every searched file (e.g. "50-100", "50+10", "50-100,200-300"); never a path like "/"',
84
84
  ),
85
85
  "case?": type("boolean").describe("case-sensitive search"),
86
86
  "gitignore?": type("boolean").describe("respect gitignore"),
@@ -126,6 +126,7 @@ interface GrepPathSpec {
126
126
  clean: string;
127
127
  literalFilesystemMatch?: boolean;
128
128
  ranges?: [LineRange, ...LineRange[]];
129
+ rangeSource?: "explicit" | "path";
129
130
  }
130
131
 
131
132
  /**
@@ -158,11 +159,11 @@ async function parsePathSpecs(
158
159
  cwd: string,
159
160
  explicitSelector?: string,
160
161
  ): Promise<GrepPathSpec[]> {
161
- const explicitRanges =
162
- explicitSelector === undefined || explicitSelector.length === 0 ? undefined : parseLineRanges(explicitSelector);
163
- if (explicitSelector !== undefined && !explicitRanges) {
162
+ const normalizedSelector = explicitSelector?.trim() || undefined;
163
+ const explicitRanges = normalizedSelector === undefined ? undefined : parseLineRanges(normalizedSelector);
164
+ if (normalizedSelector !== undefined && !explicitRanges) {
164
165
  throw new ToolError(
165
- `selector "${explicitSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
166
+ `selector "${normalizedSelector}" is invalid — use line ranges like "50-100", "50+10", or "50-100,200-300" without a leading colon`,
166
167
  );
167
168
  }
168
169
  const specs: GrepPathSpec[] = [];
@@ -182,6 +183,7 @@ async function parsePathSpecs(
182
183
  clean: literalMatch && !rawPathHasScheme ? resolveReadPath(entry, cwd) : entry,
183
184
  literalFilesystemMatch: literalMatch,
184
185
  ranges: explicitRanges,
186
+ rangeSource: "explicit",
185
187
  });
186
188
  continue;
187
189
  }
@@ -210,6 +212,7 @@ async function parsePathSpecs(
210
212
  const literalFilesystemMatch = strictSplit.sel !== undefined && split.sel === undefined;
211
213
  let clean = literalFilesystemMatch ? resolveReadPath(entry, cwd) : entry;
212
214
  let ranges: [LineRange, ...LineRange[]] | undefined;
215
+ let rangeSource: "path" | undefined;
213
216
  if (!literalFilesystemMatch && split.sel) {
214
217
  const parsed = parseLineRanges(split.sel);
215
218
  if (!parsed) {
@@ -222,8 +225,15 @@ async function parsePathSpecs(
222
225
  }
223
226
  clean = split.path;
224
227
  ranges = parsed;
228
+ rangeSource = "path";
225
229
  }
226
- specs.push({ original: entry, clean, literalFilesystemMatch, ranges });
230
+ specs.push({
231
+ original: entry,
232
+ clean,
233
+ literalFilesystemMatch,
234
+ ranges,
235
+ rangeSource: ranges ? rangeSource : undefined,
236
+ });
227
237
  }
228
238
  return specs;
229
239
  }
@@ -400,6 +410,27 @@ function lineAllowed(lineNumber: number, ranges: readonly LineRange[] | undefine
400
410
  return !ranges || isLineInRanges(lineNumber, ranges);
401
411
  }
402
412
 
413
+ /**
414
+ * Per-file native fetch budget that guarantees the JS range filter can still
415
+ * surface `perFileKeep` in-range hits. Matches arrive one entry per matched
416
+ * line in line order, so a bounded range's hits all sit within the first
417
+ * `endLine` entries, and an open-ended range starting at S is preceded by at
418
+ * most S-1 out-of-range entries — S-1+perFileKeep entries cover the kept
419
+ * window or exhaust the file. Clamped to the native file-size ceiling (a
420
+ * ≤4 MiB file cannot have more matched lines than bytes), which also keeps
421
+ * the scaled global budget inside the native layer's u32 bounds.
422
+ */
423
+ function lineRangeFetchCap(pathSpecs: readonly GrepPathSpec[], perFileKeep: number): number {
424
+ let cap = 0;
425
+ for (const spec of pathSpecs) {
426
+ if (!spec.ranges) continue;
427
+ for (const range of spec.ranges) {
428
+ cap = Math.max(cap, range.endLine ?? range.startLine - 1 + perFileKeep);
429
+ }
430
+ }
431
+ return Math.min(cap, NATIVE_GREP_MAX_FILE_BYTES);
432
+ }
433
+
403
434
  /** Binary search for the index of the line containing byte `offset`. */
404
435
  function findLineIndex(starts: readonly number[], offset: number): number {
405
436
  if (starts.length === 0) return -1;
@@ -971,6 +1002,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
971
1002
  const searchablePaths = internalResolution.paths;
972
1003
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
973
1004
  const rangesByAbsPath = new Map<string, LineRange[]>();
1005
+ const globalRanges = pathSpecs.find(spec => spec.rangeSource === "explicit")?.ranges;
974
1006
 
975
1007
  if (
976
1008
  archiveUnreadable.length > 0 &&
@@ -1030,6 +1062,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1030
1062
  for (let idx = 0; idx < pathSpecs.length; idx++) {
1031
1063
  const spec = pathSpecs[idx];
1032
1064
  if (!spec.ranges) continue;
1065
+ if (spec.rangeSource === "explicit") continue;
1033
1066
  if (virtualInputIndexes.has(idx)) continue;
1034
1067
  const resolved = internalResolution.resolvedPathsByInput[idx];
1035
1068
  if (!resolved) continue;
@@ -1095,6 +1128,18 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1095
1128
  Boolean(multiTargets) ||
1096
1129
  (virtualResources.length > 0 && (virtualResources.length > 1 || searchablePaths.length > 0));
1097
1130
  const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
1131
+ // Range filtering happens in JS after the native fetch, so out-of-range
1132
+ // matches consume fetch budget. Widen the per-file budget just enough
1133
+ // that filtering can still yield `perFileMatchCap` in-range hits, and
1134
+ // scale the global safety ceiling by the same amplification so ranged
1135
+ // searches keep the baseline file coverage while staying finite.
1136
+ const hasLineRangeFilters = pathSpecs.some(spec => spec.ranges);
1137
+ const nativeMaxCountPerFile = hasLineRangeFilters
1138
+ ? Math.max(perFileMatchCap + 1, lineRangeFetchCap(pathSpecs, perFileMatchCap + 1))
1139
+ : perFileMatchCap + 1;
1140
+ const nativeMaxCount = hasLineRangeFilters
1141
+ ? Math.ceil(INTERNAL_TOTAL_CAP / (perFileMatchCap + 1)) * nativeMaxCountPerFile
1142
+ : INTERNAL_TOTAL_CAP;
1098
1143
 
1099
1144
  // Run grep
1100
1145
  let result: GrepResult = {
@@ -1129,12 +1174,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1129
1174
  multiline: effectiveMultiline,
1130
1175
  hidden: true,
1131
1176
  gitignore: useGitignore,
1132
- maxCount: INTERNAL_TOTAL_CAP,
1177
+ maxCount: nativeMaxCount,
1133
1178
  contextBefore: normalizedContextBefore,
1134
1179
  contextAfter: normalizedContextAfter,
1135
1180
  maxColumns: DEFAULT_MAX_COLUMN,
1136
1181
  mode: effectiveOutputMode,
1137
- maxCountPerFile: perFileMatchCap + 1,
1182
+ maxCountPerFile: nativeMaxCountPerFile,
1138
1183
  signal,
1139
1184
  timeoutMs: SEARCH_GREP_TIMEOUT_MS,
1140
1185
  },
@@ -1176,12 +1221,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1176
1221
  multiline: effectiveMultiline,
1177
1222
  hidden: true,
1178
1223
  gitignore: useGitignore,
1179
- maxCount: INTERNAL_TOTAL_CAP,
1224
+ maxCount: nativeMaxCount,
1180
1225
  contextBefore: normalizedContextBefore,
1181
1226
  contextAfter: normalizedContextAfter,
1182
1227
  maxColumns: DEFAULT_MAX_COLUMN,
1183
1228
  mode: effectiveOutputMode,
1184
- maxCountPerFile: perFileMatchCap + 1,
1229
+ maxCountPerFile: nativeMaxCountPerFile,
1185
1230
  signal,
1186
1231
  timeoutMs: SEARCH_GREP_TIMEOUT_MS,
1187
1232
  },
@@ -1222,12 +1267,12 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1222
1267
  }
1223
1268
  throw err;
1224
1269
  }
1225
- result = mergeGrepResults(result, virtualResult, INTERNAL_TOTAL_CAP);
1226
- if (rangesByAbsPath.size > 0) {
1270
+ result = mergeGrepResults(result, virtualResult, nativeMaxCount);
1271
+ if (rangesByAbsPath.size > 0 || globalRanges) {
1227
1272
  const filteredMatches: GrepMatch[] = [];
1228
1273
  for (const match of result.matches) {
1229
1274
  const abs = matchAbsolutePath(match.path, searchPath);
1230
- const ranges = rangesByAbsPath.get(abs);
1275
+ const ranges = rangesByAbsPath.get(abs) ?? globalRanges;
1231
1276
  if (!ranges) {
1232
1277
  // Path has no line-range constraint (e.g. a peer entry without `:N-M`).
1233
1278
  filteredMatches.push(match);
@@ -1276,7 +1276,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1276
1276
  const xaiCreds = await resolveXAIHttpCredentials(ctx.modelRegistry, resolvedModel);
1277
1277
  if (!xaiCreds) {
1278
1278
  throw new Error(
1279
- "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
1279
+ "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY.",
1280
1280
  );
1281
1281
  }
1282
1282
 
@@ -50,7 +50,9 @@ export class MemoryEditTool implements AgentTool<typeof memoryEditSchema> {
50
50
  const text =
51
51
  result.status === "not_found"
52
52
  ? `Memory ${params.id} was not found${location}.`
53
- : `Memory ${params.id} ${result.status}${location}.`;
53
+ : result.status === "not_editable"
54
+ ? `Memory ${params.id} is a read-only fact${location}; it cannot be edited. Read it with memory://${params.id}.`
55
+ : `Memory ${params.id} ${result.status}${location}.`;
54
56
  return {
55
57
  content: [{ type: "text", text }],
56
58
  details: result,
package/src/tools/read.ts CHANGED
@@ -2118,13 +2118,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2118
2118
  _toolContext?: AgentToolContext,
2119
2119
  ): Promise<AgentToolResult<ReadToolDetails>> {
2120
2120
  let { path: readPath } = params;
2121
- let explicitSelector = params.selector?.trim();
2121
+ let explicitSelector = params.selector?.trim() || undefined;
2122
2122
  let explicitParsedSelector = explicitSelector === undefined ? undefined : parseSel(explicitSelector);
2123
- if (
2124
- params.selector !== undefined &&
2125
- (explicitSelector === undefined || explicitSelector.length === 0 || explicitParsedSelector?.kind === "none")
2126
- ) {
2127
- throw invalidSelector(params.selector);
2123
+ if (explicitSelector !== undefined && explicitParsedSelector?.kind === "none") {
2124
+ throw invalidSelector(explicitSelector);
2128
2125
  }
2129
2126
  if (readPath.startsWith("file://")) {
2130
2127
  readPath = expandPath(readPath);
@@ -3314,6 +3311,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3314
3311
  interface ReadRenderArgs {
3315
3312
  path?: unknown;
3316
3313
  file_path?: unknown;
3314
+ selector?: unknown;
3317
3315
  sel?: string;
3318
3316
  // Legacy fields from old schema — tolerated for in-flight tool calls during transition
3319
3317
  offset?: number;
@@ -3378,10 +3376,20 @@ function formatReadPathLink(
3378
3376
 
3379
3377
  export const readToolRenderer = {
3380
3378
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
3381
- const rawPath =
3379
+ const baseRawPath =
3382
3380
  typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
3383
- if (isReadableUrlPath(rawPath)) {
3384
- return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
3381
+ const explicitSelector =
3382
+ typeof args.selector === "string"
3383
+ ? args.selector.trim().replace(/^:+/, "")
3384
+ : args.sel?.trim().replace(/^:+/, "");
3385
+ const rawPath =
3386
+ explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3387
+ if (isReadableUrlPath(baseRawPath)) {
3388
+ return renderReadUrlCall(
3389
+ { path: rawPath, raw: args.raw || explicitSelector?.toLowerCase() === "raw" },
3390
+ _options,
3391
+ uiTheme,
3392
+ );
3385
3393
  }
3386
3394
 
3387
3395
  const offset = args.offset;
@@ -3405,9 +3413,9 @@ export const readToolRenderer = {
3405
3413
  args?: ReadRenderArgs,
3406
3414
  ): Component {
3407
3415
  const urlDetails = result.details as ReadUrlToolDetails | undefined;
3408
- const rawPathForKind =
3416
+ const baseRawPathForKind =
3409
3417
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3410
- if (urlDetails?.kind === "url" || isReadableUrlPath(rawPathForKind)) {
3418
+ if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
3411
3419
  return renderReadUrlResult(
3412
3420
  result as {
3413
3421
  content: Array<{ type: string; text?: string }>;
@@ -3422,8 +3430,14 @@ export const readToolRenderer = {
3422
3430
  if (result.isError) {
3423
3431
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
3424
3432
  const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
3425
- const rawPath =
3433
+ const baseRawPath =
3426
3434
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3435
+ const explicitSelector =
3436
+ typeof args?.selector === "string"
3437
+ ? args.selector.trim().replace(/^:+/, "")
3438
+ : args?.sel?.trim().replace(/^:+/, "");
3439
+ const rawPath =
3440
+ explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3427
3441
  const filePath =
3428
3442
  formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
3429
3443
  shortenPath(rawPath);
@@ -3450,8 +3464,14 @@ export const readToolRenderer = {
3450
3464
  // echo next to the styled warning line below.
3451
3465
  const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
3452
3466
  const imageContent = result.content?.find(c => c.type === "image");
3453
- const rawPath =
3467
+ const baseRawPath =
3454
3468
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3469
+ const explicitSelector =
3470
+ typeof args?.selector === "string"
3471
+ ? args.selector.trim().replace(/^:+/, "")
3472
+ : args?.sel?.trim().replace(/^:+/, "");
3473
+ const rawPath =
3474
+ explicitSelector && explicitSelector.length > 0 ? `${baseRawPath}:${explicitSelector}` : baseRawPath;
3455
3475
  const renderPath = splitReadRenderPath(rawPath);
3456
3476
  const lang = getLanguageFromPath(renderPath.path);
3457
3477
 
@@ -32,6 +32,15 @@ import { sshToolRenderer } from "./ssh";
32
32
  import { todoToolRenderer } from "./todo";
33
33
  import { writeToolRenderer } from "./write";
34
34
 
35
+ /**
36
+ * Per-renderer opt-in for a full viewport replay when the first result
37
+ * replaces a painted pending-call render. A predicate receives the painted
38
+ * call args and render options so the repaint stays scoped to the pending
39
+ * shapes that actually re-anchor (an over-eager replay wipes native
40
+ * scrollback on direct terminals).
41
+ */
42
+ export type FirstResultViewportRepaint = boolean | ((args: unknown, options: RenderResultOptions) => boolean);
43
+
35
44
  export type ToolRenderer = {
36
45
  renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
37
46
  renderResult: (
@@ -55,12 +64,11 @@ export type ToolRenderer = {
55
64
  */
56
65
  animatedPartialResult?: boolean | ((args: unknown) => boolean);
57
66
  /**
58
- * Whether replacing a streamed pending placeholder with the first result
59
- * requires a full viewport repaint. Use for merged renderers whose pending
60
- * streamed args may have committed placeholder rows that the result render
61
- * re-anchors instead of preserving.
67
+ * Whether replacing a pending call render with the first result requires a
68
+ * full viewport repaint. Use for merged renderers whose pending rows can be
69
+ * re-anchored instead of preserved by the result render.
62
70
  */
63
- forceFirstResultViewportRepaint?: boolean;
71
+ forceFirstResultViewportRepaint?: FirstResultViewportRepaint;
64
72
  /**
65
73
  * Whether settling a provisional partial result into the final render requires
66
74
  * a full viewport repaint. Use when the result renderer changes chrome or
package/src/tools/ssh.ts CHANGED
@@ -244,6 +244,13 @@ interface SshRenderArgs {
244
244
  timeout?: number;
245
245
  }
246
246
 
247
+ /** Whether the painted call args still carry the streamed raw-JSON buffer —
248
+ * the shape that renders the `⏳ SSH: […]` / `$ …` placeholder. */
249
+ function hasStreamedRenderArgs(args: unknown): boolean {
250
+ if (args == null || typeof args !== "object" || !("__partialJson" in args)) return false;
251
+ return typeof args.__partialJson === "string";
252
+ }
253
+
247
254
  interface SshRenderContext {
248
255
  /** Visual lines for truncated output (pre-computed by tool-execution) */
249
256
  visualLines?: string[];
@@ -388,9 +395,9 @@ export const sshToolRenderer = {
388
395
  mergeCallAndResult: true,
389
396
  // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
390
397
  // `$ …`), then the first partial result inserts the `Output` section and
391
- // re-anchors the frame. Force a full repaint at that seam so placeholder rows
392
- // do not survive in viewport/native scrollback.
393
- forceFirstResultViewportRepaint: true,
398
+ // re-anchors the frame. Force a full repaint only at that streamed-placeholder
399
+ // seam so placeholder rows do not survive in viewport/native scrollback.
400
+ forceFirstResultViewportRepaint: hasStreamedRenderArgs,
394
401
  // The provisional pending-result frame settles into the final `⇄ SSH: [host]`
395
402
  // frame, so clear/replay the viewport at that topology flip too.
396
403
  forceResultViewportRepaintOnSettle: true,
package/src/tools/tts.ts CHANGED
@@ -103,7 +103,7 @@ async function synthesizeXai(
103
103
  content: [
104
104
  {
105
105
  type: "text",
106
- text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
106
+ text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok or X Premium+) or set XAI_API_KEY.",
107
107
  },
108
108
  ],
109
109
  };
@@ -985,6 +985,24 @@ function countLines(text: string): number {
985
985
  return text.split("\n").length;
986
986
  }
987
987
 
988
+ /** Bounded newline scan: whether `text` spans more than `maxLines` lines.
989
+ * Runs on every live compose (the repaint predicate below), so it must not
990
+ * materialize the split the way `countLines` does. */
991
+ function exceedsLineCount(text: string, maxLines: number): boolean {
992
+ if (!text) return false;
993
+ let lines = 1;
994
+ for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) {
995
+ if (++lines > maxLines) return true;
996
+ }
997
+ return false;
998
+ }
999
+
1000
+ function writeContentOf(args: unknown): string {
1001
+ if (args == null || typeof args !== "object" || !("content" in args)) return "";
1002
+ const content = args.content;
1003
+ return typeof content === "string" ? content : "";
1004
+ }
1005
+
988
1006
  function formatLineCountSuffix(lineCount: number, uiTheme: Theme): string {
989
1007
  if (lineCount <= 0) return "";
990
1008
  return uiTheme.fg("dim", ` · ${lineCount} line${lineCount === 1 ? "" : "s"}`);
@@ -1218,4 +1236,12 @@ export const writeToolRenderer = {
1218
1236
  });
1219
1237
  },
1220
1238
  mergeCallAndResult: true,
1239
+ // The collapsed pending preview follows the streaming edge with a tail
1240
+ // window once the content outgrows it (`… (N earlier lines)` + last rows);
1241
+ // the first partial result re-anchors the frame to the top of the file, so
1242
+ // tail rows already committed to viewport/native scrollback would survive
1243
+ // as stale content above the new frame without a full replay. Expanded and
1244
+ // short previews stay top-anchored and skip the (scrollback-wiping) reset.
1245
+ forceFirstResultViewportRepaint: (args: unknown, options: RenderResultOptions) =>
1246
+ !options.expanded && exceedsLineCount(writeContentOf(args), WRITE_STREAMING_PREVIEW_LINES),
1221
1247
  };