@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.17

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 (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli.js +2749 -2726
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/config/model-discovery.d.ts +1 -0
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  7. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  8. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  9. package/dist/types/hindsight/content.d.ts +2 -10
  10. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  15. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  16. package/dist/types/session/agent-session.d.ts +6 -6
  17. package/dist/types/session/provider-image-budget.d.ts +3 -0
  18. package/dist/types/session/session-context.d.ts +6 -5
  19. package/dist/types/task/parallel.d.ts +4 -0
  20. package/dist/types/thinking.d.ts +8 -1
  21. package/dist/types/tiny/title-client.d.ts +2 -2
  22. package/dist/types/utils/tools-manager.d.ts +2 -0
  23. package/package.json +12 -12
  24. package/src/auto-thinking/classifier.ts +7 -2
  25. package/src/cli/profile-alias.ts +38 -7
  26. package/src/cli/usage-cli.ts +5 -1
  27. package/src/config/model-discovery.ts +59 -8
  28. package/src/config/model-registry.ts +74 -3
  29. package/src/discovery/omp-extension-roots.ts +1 -3
  30. package/src/extensibility/extensions/wrapper.ts +3 -2
  31. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  33. package/src/extensibility/plugins/loader.ts +71 -23
  34. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  35. package/src/extensibility/tool-event-input.ts +57 -0
  36. package/src/hindsight/content.ts +21 -12
  37. package/src/mcp/tool-bridge.ts +27 -2
  38. package/src/mnemopi/state.ts +5 -2
  39. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  40. package/src/modes/components/chat-transcript-builder.ts +6 -0
  41. package/src/modes/components/custom-editor.test.ts +18 -1
  42. package/src/modes/components/custom-editor.ts +77 -45
  43. package/src/modes/components/hook-editor.ts +15 -2
  44. package/src/modes/components/settings-selector.ts +2 -2
  45. package/src/modes/components/status-line/component.ts +52 -8
  46. package/src/modes/components/status-line/segments.ts +5 -1
  47. package/src/modes/components/status-line/types.ts +1 -0
  48. package/src/modes/components/welcome.ts +12 -14
  49. package/src/modes/controllers/command-controller.ts +16 -5
  50. package/src/modes/controllers/input-controller.ts +115 -3
  51. package/src/modes/controllers/selector-controller.ts +19 -1
  52. package/src/modes/interactive-mode.ts +3 -3
  53. package/src/modes/utils/ui-helpers.ts +3 -3
  54. package/src/sdk.ts +8 -10
  55. package/src/session/agent-session.ts +193 -49
  56. package/src/session/provider-image-budget.ts +86 -0
  57. package/src/session/session-context.ts +14 -7
  58. package/src/session/session-storage.ts +24 -2
  59. package/src/session/snapcompact-inline.ts +19 -3
  60. package/src/slash-commands/builtin-registry.ts +0 -22
  61. package/src/slash-commands/helpers/usage-report.ts +9 -1
  62. package/src/task/parallel.ts +6 -1
  63. package/src/thinking.ts +9 -2
  64. package/src/tiny/title-client.ts +75 -21
  65. package/src/utils/tools-manager.ts +67 -10
@@ -189,6 +189,22 @@ export interface RetentionTranscript {
189
189
  * Messages are tag-stripped before framing to break the recall→retain loop.
190
190
  * Returns `{ transcript: null }` when nothing meaningful survives.
191
191
  */
192
+ function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
193
+ const parts: string[] = [];
194
+ for (const msg of messages) {
195
+ const content = stripMemoryTags(msg.content).trim();
196
+ if (!hasSubstantiveContent(content)) continue;
197
+ parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
198
+ }
199
+
200
+ if (parts.length === 0) return { transcript: null, messageCount: 0 };
201
+
202
+ const transcript = parts.join("\n\n");
203
+ if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
204
+
205
+ return { transcript, messageCount: parts.length };
206
+ }
207
+
192
208
  export function prepareRetentionTranscript(
193
209
  messages: HindsightMessage[],
194
210
  retainFullWindow = false,
@@ -210,17 +226,10 @@ export function prepareRetentionTranscript(
210
226
  targetMessages = messages.slice(lastUserIdx);
211
227
  }
212
228
 
213
- const parts: string[] = [];
214
- for (const msg of targetMessages) {
215
- const content = stripMemoryTags(msg.content).trim();
216
- if (!hasSubstantiveContent(content)) continue;
217
- parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
218
- }
219
-
220
- if (parts.length === 0) return { transcript: null, messageCount: 0 };
221
-
222
- const transcript = parts.join("\n\n");
223
- if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
229
+ return formatRetentionMessages(targetMessages);
230
+ }
224
231
 
225
- return { transcript, messageCount: parts.length };
232
+ /** Format only user-authored messages for memory fact/entity extraction. */
233
+ export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
234
+ return formatRetentionMessages(messages.filter(message => message.role === "user"));
226
235
  }
@@ -58,6 +58,31 @@ function normalizeToolArgs(value: unknown): MCPToolArgs {
58
58
  return value as MCPToolArgs;
59
59
  }
60
60
 
61
+ function isUnusedOptionalPlaceholder(value: unknown): boolean {
62
+ return (
63
+ value === undefined ||
64
+ value === "" ||
65
+ (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
66
+ );
67
+ }
68
+
69
+ function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
70
+ const properties = inputSchema.properties;
71
+ if (!properties) return args;
72
+
73
+ let cleaned: MCPToolArgs | undefined;
74
+ const required = new Set(inputSchema.required ?? []);
75
+ for (const [key, value] of Object.entries(args)) {
76
+ if (required.has(key) || !Object.hasOwn(properties, key) || !isUnusedOptionalPlaceholder(value)) {
77
+ continue;
78
+ }
79
+ cleaned ??= { ...args };
80
+ delete cleaned[key];
81
+ }
82
+
83
+ return cleaned ?? args;
84
+ }
85
+
61
86
  /** Details included in MCP tool results for rendering */
62
87
  export interface MCPToolDetails {
63
88
  /** Server name */
@@ -261,7 +286,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
261
286
  signal?: AbortSignal,
262
287
  ): Promise<CustomToolResult<MCPToolDetails>> {
263
288
  throwIfAborted(signal);
264
- const args = normalizeToolArgs(params);
289
+ const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
265
290
  const provider = this.connection._source?.provider;
266
291
  const providerName = this.connection._source?.providerName;
267
292
 
@@ -360,7 +385,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
360
385
  signal?: AbortSignal,
361
386
  ): Promise<CustomToolResult<MCPToolDetails>> {
362
387
  throwIfAborted(signal);
363
- const args = normalizeToolArgs(params);
388
+ const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
364
389
  const provider = this.#fallbackProvider;
365
390
  const providerName = this.#fallbackProviderName;
366
391
 
@@ -9,6 +9,7 @@ import {
9
9
  composeRecallQuery,
10
10
  formatCurrentTime,
11
11
  prepareRetentionTranscript,
12
+ prepareUserRetentionTranscript,
12
13
  truncateRecallQuery,
13
14
  } from "../hindsight/content";
14
15
  import { extractMessages } from "../hindsight/transcript";
@@ -352,6 +353,7 @@ export class MnemopiSessionState {
352
353
  async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
353
354
  const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
354
355
  if (!transcript) return;
356
+ const { transcript: extractText } = prepareUserRetentionTranscript(messages);
355
357
  this.rememberInScope(transcript, {
356
358
  source: "coding-agent-transcript",
357
359
  importance: 0.65,
@@ -362,8 +364,9 @@ export class MnemopiSessionState {
362
364
  cwd: this.session.sessionManager.getCwd(),
363
365
  },
364
366
  scope: "bank",
365
- extract: true,
366
- extractEntities: true,
367
+ extract: extractText !== null,
368
+ extractEntities: extractText !== null,
369
+ extractText,
367
370
  veracity: "unknown",
368
371
  memoryType: "episode",
369
372
  });
@@ -7,16 +7,11 @@
7
7
  * compositing into the live transcript's scrollback. It renders a parked
8
8
  * subagent / advisor / collab-guest transcript that has no live in-view session.
9
9
  *
10
- * The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild})
11
- * rather than synced incrementally, so a growing file-backed transcript (the
12
- * advisor appends while you watch) can never duplicate or misorder rows. Scroll
13
- * is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail
14
- * until the reader scrolls up.
15
- *
16
- * Local agents re-read the whole session file whenever its size or mtime changes
17
- * (covering SessionManager's in-place rewrites, not just appends). Collab guests
18
- * keep the incremental byte cursor the host's capped `readTranscript` requires
19
- * and rebuild components from the accumulated entries.
10
+ * Local transcripts tail append-only growth: unchanged file identity plus stable
11
+ * sentinels means only newly appended JSONL is parsed and rendered. Rewrites,
12
+ * truncation, rotation, or sentinel drift fall back to a full rebuild so changed
13
+ * historical entries cannot leave stale components behind. Collab guests use the
14
+ * same append path over the host's byte-capped transcript reads.
20
15
  */
21
16
  import * as fs from "node:fs";
22
17
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
@@ -64,6 +59,56 @@ export interface AgentTranscriptViewerDeps {
64
59
  /** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
65
60
  const POLL_MS = 250;
66
61
 
62
+ const SENTINEL_BYTES = 4096;
63
+
64
+ interface LocalTranscriptSentinel {
65
+ offset: number;
66
+ bytes: Buffer;
67
+ }
68
+
69
+ interface LocalTranscriptState {
70
+ path: string;
71
+ dev: number;
72
+ ino: number;
73
+ size: number;
74
+ mtimeMs: number;
75
+ offset: number;
76
+ pending: string;
77
+ sentinels: LocalTranscriptSentinel[];
78
+ }
79
+
80
+ function readFileRangeSync(file: string, offset: number, length: number): Buffer {
81
+ if (length <= 0) return Buffer.alloc(0);
82
+ const fd = fs.openSync(file, "r");
83
+ try {
84
+ const buffer = Buffer.alloc(length);
85
+ const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
86
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
87
+ } finally {
88
+ fs.closeSync(fd);
89
+ }
90
+ }
91
+
92
+ function sentinelOffsets(size: number): number[] {
93
+ if (size <= 0) return [];
94
+ const length = Math.min(SENTINEL_BYTES, size);
95
+ return [...new Set([0, Math.max(0, Math.floor((size - length) / 2)), Math.max(0, size - length)])];
96
+ }
97
+
98
+ function sentinelsFromBuffer(buffer: Buffer): LocalTranscriptSentinel[] {
99
+ const size = buffer.byteLength;
100
+ const length = Math.min(SENTINEL_BYTES, size);
101
+ return sentinelOffsets(size).map(offset => ({
102
+ offset,
103
+ bytes: Buffer.from(buffer.subarray(offset, offset + length)),
104
+ }));
105
+ }
106
+
107
+ function sentinelsFromFile(file: string, size: number): LocalTranscriptSentinel[] {
108
+ const length = Math.min(SENTINEL_BYTES, size);
109
+ return sentinelOffsets(size).map(offset => ({ offset, bytes: readFileRangeSync(file, offset, length) }));
110
+ }
111
+
67
112
  function statusBadge(status: AgentStatus): string {
68
113
  switch (status) {
69
114
  case "running":
@@ -85,10 +130,9 @@ export class AgentTranscriptViewer implements Component {
85
130
  #notice: string | undefined;
86
131
  #expanded = false;
87
132
 
88
- // Local file transcript state: re-read when the file size or mtime changes.
89
- #lastSignature = "";
133
+ #localState: LocalTranscriptState | undefined;
134
+ #localUnavailable = "";
90
135
  // Remote transcript state (incremental; the host caps each read).
91
- #remoteEntries: SessionMessageEntry[] = [];
92
136
  #remoteBytes = 0;
93
137
  #remoteFetchInFlight = false;
94
138
  #remoteToken = 0;
@@ -145,7 +189,7 @@ export class AgentTranscriptViewer implements Component {
145
189
  // Transcript loading
146
190
  // ========================================================================
147
191
 
148
- /** Re-read the transcript and rebuild components when it changed. */
192
+ /** Refresh the transcript from a local file or remote host. */
149
193
  #refresh(): void {
150
194
  if (this.#disposed) return;
151
195
  if (this.deps.remote) {
@@ -154,39 +198,132 @@ export class AgentTranscriptViewer implements Component {
154
198
  }
155
199
  const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
156
200
  if (!sessionFile) {
157
- if (this.#lastSignature !== "none") {
158
- this.#lastSignature = "none";
159
- this.#rebuild([]);
160
- }
201
+ this.#clearLocal("none");
161
202
  return;
162
203
  }
163
- let signature: string;
204
+ let stat: fs.Stats;
164
205
  try {
165
- const stat = fs.statSync(sessionFile);
166
- // Include the path: a different file with the same size/mtime must not alias.
167
- signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
206
+ stat = fs.statSync(sessionFile);
168
207
  } catch {
169
- // File deleted/rotated while open (e.g. the owning session was dropped):
170
- // clear stale content once instead of freezing on it forever.
171
- if (this.#lastSignature !== "missing") {
172
- this.#lastSignature = "missing";
173
- this.#model = undefined;
174
- this.#rebuild([]);
175
- }
208
+ this.#clearLocal("missing");
176
209
  return;
177
210
  }
178
- if (signature === this.#lastSignature) return;
179
- let text: string;
211
+ const state = this.#localState;
212
+ if (state && this.#canAppendLocal(sessionFile, stat, state)) {
213
+ if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return;
214
+ if (stat.size > state.size) {
215
+ this.#appendLocal(sessionFile, stat, state);
216
+ return;
217
+ }
218
+ }
219
+ this.#loadLocalFull(sessionFile, stat);
220
+ }
221
+
222
+ #clearLocal(reason: string): void {
223
+ if (!this.#localState && this.#localUnavailable === reason) return;
224
+ this.#localState = undefined;
225
+ this.#localUnavailable = reason;
226
+ this.#model = undefined;
227
+ this.#rebuild([]);
228
+ }
229
+
230
+ #canAppendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): boolean {
231
+ if (state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.size)
232
+ return false;
233
+ for (const sentinel of state.sentinels) {
234
+ let current: Buffer;
235
+ try {
236
+ current = readFileRangeSync(sessionFile, sentinel.offset, sentinel.bytes.byteLength);
237
+ } catch (err) {
238
+ // The file can be unlinked/rotated between statSync and this read.
239
+ // Treat as not-appendable so #refresh falls back to a guarded full load.
240
+ logger.debug("transcript viewer: sentinel read failed", { err: String(err) });
241
+ return false;
242
+ }
243
+ if (!current.equals(sentinel.bytes)) return false;
244
+ }
245
+ return true;
246
+ }
247
+
248
+ #loadLocalFull(sessionFile: string, stat: fs.Stats): void {
249
+ let data: Buffer;
180
250
  try {
181
- text = fs.readFileSync(sessionFile, "utf-8");
251
+ data = fs.readFileSync(sessionFile);
182
252
  } catch (err) {
183
- // Leave #lastSignature unchanged so a transient read error retries next poll.
253
+ // Leave #localState unchanged so a transient read error retries next poll.
184
254
  logger.debug("transcript viewer: read failed", { err: String(err) });
185
255
  return;
186
256
  }
187
- this.#lastSignature = signature;
257
+ // The file may have grown between the earlier `statSync` and this read.
258
+ // Anchor the tail cursor to what we actually consumed so the next poll's
259
+ // `#appendLocal` never re-renders bytes already in the rebuilt transcript;
260
+ // re-stat for mtime/identity so the post-read clock matches what's on disk.
261
+ let post: fs.Stats;
262
+ try {
263
+ post = fs.statSync(sessionFile);
264
+ } catch {
265
+ post = stat;
266
+ }
267
+ // A reader that opens the file mid-append sees a trailing partial line
268
+ // (no terminating newline). Carry those bytes as `pending` so the next
269
+ // poll's `#appendLocal` joins them with the completion bytes instead of
270
+ // parsing a headless line fragment and dropping the entry.
271
+ const text = data.toString("utf-8");
272
+ const lastNewline = text.lastIndexOf("\n");
273
+ const complete = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
274
+ const pending = lastNewline >= 0 ? text.slice(lastNewline + 1) : text;
275
+ this.#localUnavailable = "";
276
+ this.#localState = {
277
+ path: sessionFile,
278
+ dev: post.dev,
279
+ ino: post.ino,
280
+ size: data.byteLength,
281
+ mtimeMs: post.mtimeMs,
282
+ offset: data.byteLength,
283
+ pending,
284
+ sentinels: sentinelsFromBuffer(data),
285
+ };
188
286
  this.#model = undefined;
189
- this.#rebuild(this.#extractMessages(parseSessionEntries(text)));
287
+ this.#rebuild(this.#extractMessages(parseSessionEntries(complete)));
288
+ }
289
+
290
+ #appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
291
+ let chunk: string;
292
+ try {
293
+ chunk = readFileRangeSync(sessionFile, state.offset, stat.size - state.offset).toString("utf-8");
294
+ } catch (err) {
295
+ logger.debug("transcript viewer: tail read failed", { err: String(err) });
296
+ this.#loadLocalFull(sessionFile, stat);
297
+ return;
298
+ }
299
+ const combined = state.pending + chunk;
300
+ const lastNewline = combined.lastIndexOf("\n");
301
+ const complete = lastNewline >= 0 ? combined.slice(0, lastNewline + 1) : "";
302
+ const previousModel = this.#model;
303
+ const parsed = complete ? this.#extractMessages(parseSessionEntries(complete)) : [];
304
+ let sentinels: LocalTranscriptSentinel[];
305
+ try {
306
+ sentinels = sentinelsFromFile(sessionFile, stat.size);
307
+ } catch (err) {
308
+ // File unlinked/rotated mid-poll: fall back to a guarded full reload
309
+ // instead of letting the open escape the poll timer.
310
+ logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
311
+ this.#loadLocalFull(sessionFile, stat);
312
+ return;
313
+ }
314
+ this.#localState = {
315
+ ...state,
316
+ size: stat.size,
317
+ mtimeMs: stat.mtimeMs,
318
+ offset: stat.size,
319
+ pending: lastNewline >= 0 ? combined.slice(lastNewline + 1) : combined,
320
+ sentinels,
321
+ };
322
+ if (parsed.length > 0) {
323
+ this.#append(parsed);
324
+ } else if (this.#model !== previousModel) {
325
+ this.deps.requestRender();
326
+ }
190
327
  }
191
328
 
192
329
  #fetchRemote(): void {
@@ -209,9 +346,13 @@ export class AgentTranscriptViewer implements Component {
209
346
  return;
210
347
  }
211
348
  if (result.newSize < fromByte) {
212
- // Host transcript rotated/truncated — restart from 0.
349
+ // Host transcript rotated/truncated — drop the stale rendered rows
350
+ // before restarting; otherwise the post-rotation fetch would stack
351
+ // new content under the pre-rotation history.
213
352
  this.#remoteBytes = 0;
214
- this.#remoteEntries = [];
353
+ this.#hasRemoteData = false;
354
+ this.#model = undefined;
355
+ this.#rebuild([]);
215
356
  this.#fetchRemote();
216
357
  return;
217
358
  }
@@ -222,10 +363,14 @@ export class AgentTranscriptViewer implements Component {
222
363
  if (lastNewline >= 0) {
223
364
  const completeChunk = result.text.slice(0, lastNewline + 1);
224
365
  this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
366
+ const previousModel = this.#model;
225
367
  const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
226
368
  if (parsed.length > 0) {
227
- this.#remoteEntries.push(...parsed);
228
- this.#rebuild(this.#remoteEntries);
369
+ this.#append(parsed);
370
+ return;
371
+ }
372
+ if (this.#model !== previousModel) {
373
+ this.deps.requestRender();
229
374
  return;
230
375
  }
231
376
  }
@@ -257,6 +402,11 @@ export class AgentTranscriptViewer implements Component {
257
402
  this.deps.requestRender();
258
403
  }
259
404
 
405
+ #append(entries: SessionMessageEntry[]): void {
406
+ this.#builder.append(entries);
407
+ this.deps.requestRender();
408
+ }
409
+
260
410
  // ========================================================================
261
411
  // Input
262
412
  // ========================================================================
@@ -455,8 +605,10 @@ export class AgentTranscriptViewer implements Component {
455
605
  }
456
606
 
457
607
  #placeholder(): string {
458
- if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available.";
459
- if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host";
608
+ if (this.deps.remote) {
609
+ if (this.#remoteUnavailable) return "Transcript lives on the host — not available.";
610
+ return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…";
611
+ }
460
612
  if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
461
613
  return "No messages yet.";
462
614
  }
@@ -103,6 +103,12 @@ export class ChatTranscriptBuilder {
103
103
  if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
104
104
  }
105
105
 
106
+ /** Append newly persisted entries without rebuilding already rendered rows. */
107
+ append(entries: SessionMessageEntry[]): void {
108
+ for (const entry of entries) this.#appendChatMessage(entry.message);
109
+ if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
110
+ }
111
+
106
112
  /** Toggle tool-output expansion across every expandable component. */
107
113
  setExpanded(expanded: boolean): void {
108
114
  this.#expanded = expanded;
@@ -4,6 +4,7 @@ import { getEditorTheme, initTheme } from "../theme/theme";
4
4
  import {
5
5
  CustomEditor,
6
6
  extractBracketedImagePastePaths,
7
+ extractBracketedPastePaths,
7
8
  SPACE_HOLD_MECHANICAL_RUN,
8
9
  SPACE_HOLD_RELEASE_MS,
9
10
  SPACE_REPEAT_MAX_GAP_MS,
@@ -73,7 +74,7 @@ describe("CustomEditor placeholder decoration", () => {
73
74
  });
74
75
  });
75
76
 
76
- describe("CustomEditor bracketed image-path paste", () => {
77
+ describe("CustomEditor bracketed path paste", () => {
77
78
  it("leaves a pasted bare .png filename on the normal text path", () => {
78
79
  expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
79
80
  });
@@ -86,6 +87,22 @@ describe("CustomEditor bracketed image-path paste", () => {
86
87
  "C:\\Users\\me\\icon-photo-default.png",
87
88
  ]);
88
89
  });
90
+
91
+ it("extracts explicit non-image paths without classifying them as image paths", () => {
92
+ expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
93
+ expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
94
+ });
95
+
96
+ it("routes non-image path pastes through the file-path hook", async () => {
97
+ const { editor } = makeEditor();
98
+ const pasted = Promise.withResolvers<string>();
99
+ editor.onPasteFilePath = path => pasted.resolve(path);
100
+
101
+ editor.handleInput(bracketedPaste("/tmp/report.csv"));
102
+
103
+ expect(await pasted.promise).toBe("/tmp/report.csv");
104
+ expect(editor.getText()).toBe("");
105
+ });
89
106
  });
90
107
 
91
108
  describe("CustomEditor space-hold push-to-talk", () => {
@@ -62,7 +62,6 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
62
62
  const BRACKETED_PASTE_START = "\x1b[200~";
63
63
  const BRACKETED_PASTE_END = "\x1b[201~";
64
64
  const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
65
- const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
66
65
  const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
67
66
  const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
68
67
  const FILE_URI_REGEX = /^file:\/\//i;
@@ -100,19 +99,7 @@ function isPastedPathSeparator(char: string | undefined): boolean {
100
99
  return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
101
100
  }
102
101
 
103
- function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined {
104
- const quote = payload[segmentStart];
105
- const afterExtension = payload[extensionEnd];
106
- if (quote === '"' || quote === "'") {
107
- return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
108
- ? extensionEnd + 1
109
- : undefined;
110
- }
111
- if (isPastedPathSeparator(afterExtension)) return extensionEnd;
112
- return undefined;
113
- }
114
-
115
- function normalizePastedImagePath(path: string): string {
102
+ function normalizePastedPath(path: string): string {
116
103
  const trimmed = path.trim();
117
104
  const first = trimmed[0];
118
105
  const last = trimmed[trimmed.length - 1];
@@ -121,13 +108,60 @@ function normalizePastedImagePath(path: string): string {
121
108
  return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
122
109
  }
123
110
 
124
- function isExplicitPastedImagePath(path: string): boolean {
111
+ function isExplicitPastedPath(path: string): boolean {
125
112
  if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
126
113
  if (URI_SCHEME_REGEX.test(path)) return false;
127
114
  return path.includes("/") || path.includes("\\");
128
115
  }
129
116
 
130
- export function extractBracketedImagePastePaths(data: string): string[] | undefined {
117
+ function isImagePath(path: string): boolean {
118
+ return BRACKETED_IMAGE_PATH_REGEX.test(path);
119
+ }
120
+
121
+ function splitPastedPathSegments(payload: string): string[] | undefined {
122
+ const segments: string[] = [];
123
+ let segment = "";
124
+ let quote: string | undefined;
125
+ let escaped = false;
126
+
127
+ for (let i = 0; i < payload.length; i++) {
128
+ const char = payload[i];
129
+ if (escaped) {
130
+ segment += char;
131
+ escaped = false;
132
+ continue;
133
+ }
134
+ if (char === "\\") {
135
+ segment += char;
136
+ escaped = true;
137
+ continue;
138
+ }
139
+ if (quote) {
140
+ segment += char;
141
+ if (char === quote) quote = undefined;
142
+ continue;
143
+ }
144
+ if (char === '"' || char === "'") {
145
+ segment += char;
146
+ quote = char;
147
+ continue;
148
+ }
149
+ if (isPastedPathSeparator(char)) {
150
+ if (segment) {
151
+ segments.push(segment);
152
+ segment = "";
153
+ }
154
+ continue;
155
+ }
156
+ segment += char;
157
+ }
158
+
159
+ if (escaped || quote) return undefined;
160
+ if (segment) segments.push(segment);
161
+ return segments.length > 0 ? segments : undefined;
162
+ }
163
+
164
+ export function extractBracketedPastePaths(data: string): string[] | undefined {
131
165
  if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
132
166
  const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
133
167
  if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
@@ -135,33 +169,23 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
135
169
  const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
136
170
  if (!pasted) return undefined;
137
171
 
172
+ const segments = splitPastedPathSegments(pasted);
173
+ if (!segments) return undefined;
174
+
138
175
  const paths: string[] = [];
139
- let segmentStart = 0;
140
- BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0;
141
- for (
142
- let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
143
- match;
144
- match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
145
- ) {
146
- const extensionEnd = match.index + match[0].length;
147
- const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
148
- if (boundaryEnd === undefined) continue;
149
-
150
- const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
151
- if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
176
+ for (const segment of segments) {
177
+ const path = normalizePastedPath(segment);
178
+ if (!path || !isExplicitPastedPath(path)) return undefined;
152
179
  paths.push(path);
153
-
154
- segmentStart = boundaryEnd;
155
- while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
156
- segmentStart++;
157
- }
158
- BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
159
180
  }
160
-
161
- if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
162
181
  return paths;
163
182
  }
164
183
 
184
+ export function extractBracketedImagePastePaths(data: string): string[] | undefined {
185
+ const paths = extractBracketedPastePaths(data);
186
+ return paths?.every(isImagePath) ? paths : undefined;
187
+ }
188
+
165
189
  export function extractBracketedImagePastePath(data: string): string | undefined {
166
190
  const paths = extractBracketedImagePastePaths(data);
167
191
  return paths?.length === 1 ? paths[0] : undefined;
@@ -294,6 +318,8 @@ export class CustomEditor extends Editor {
294
318
  onPasteImage?: () => Promise<boolean>;
295
319
  /** Called when a bracketed paste contains one or more image-file paths. */
296
320
  onPasteImagePath?: (path: string) => void | Promise<void>;
321
+ /** Called when a bracketed paste contains one or more non-image file paths. */
322
+ onPasteFilePath?: (path: string) => void | Promise<void>;
297
323
  /** Called when the configured raw text-paste shortcut is pressed. */
298
324
  onPasteTextRaw?: () => void;
299
325
  /** Called when the configured dequeue shortcut is pressed. */
@@ -479,14 +505,20 @@ export class CustomEditor extends Editor {
479
505
  return;
480
506
  }
481
507
 
482
- const pastedImagePaths = extractBracketedImagePastePaths(data);
483
- if (pastedImagePaths && this.onPasteImagePath) {
484
- void (async () => {
485
- for (const path of pastedImagePaths) {
486
- await this.onPasteImagePath?.(path);
487
- }
488
- })();
489
- return;
508
+ const pastedPaths = extractBracketedPastePaths(data);
509
+ if (pastedPaths) {
510
+ const canHandlePaths = pastedPaths.every(path =>
511
+ isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined,
512
+ );
513
+ if (canHandlePaths) {
514
+ void (async () => {
515
+ for (const path of pastedPaths) {
516
+ if (isImagePath(path)) await this.onPasteImagePath?.(path);
517
+ else await this.onPasteFilePath?.(path);
518
+ }
519
+ })();
520
+ return;
521
+ }
490
522
  }
491
523
 
492
524
  const parsedKey = parseKey(data);