@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.1

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 (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. package/src/vim/types.ts +197 -0
@@ -81,6 +81,7 @@ export interface ToolExecutionHandle {
81
81
  export class ToolExecutionComponent extends Container {
82
82
  #contentBox: Box; // Used for custom tools and bash visual truncation
83
83
  #contentText: Text; // For built-in tools (with its own padding/bg)
84
+ #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
84
85
  #imageComponents: Image[] = [];
85
86
  #imageSpacers: Spacer[] = [];
86
87
  #toolName: string;
@@ -126,17 +127,18 @@ export class ToolExecutionComponent extends Container {
126
127
  tool: AgentTool | undefined,
127
128
  ui: TUI,
128
129
  cwd: string = getProjectDir(),
130
+ _toolCallId?: string,
129
131
  ) {
130
132
  super();
131
133
  this.#toolName = toolName;
132
134
  this.#toolLabel = tool?.label ?? toolName;
133
- this.#args = cloneToolArgs(args);
134
135
  this.#showImages = options.showImages ?? true;
135
136
  this.#editFuzzyThreshold = options.editFuzzyThreshold;
136
137
  this.#editAllowFuzzy = options.editAllowFuzzy;
137
138
  this.#tool = tool;
138
139
  this.#ui = ui;
139
140
  this.#cwd = cwd;
141
+ this.#args = cloneToolArgs(args);
140
142
 
141
143
  this.addChild(new Spacer(1));
142
144
 
@@ -179,12 +181,32 @@ export class ToolExecutionComponent extends Container {
179
181
  #maybeComputeEditDiff(): void {
180
182
  if (this.#toolName !== "edit") return;
181
183
 
182
- const path = this.#args?.path;
183
- const op = this.#args?.op;
184
+ const edits = this.#args?.edits;
185
+ if (!Array.isArray(edits) || edits.length === 0) return;
186
+
187
+ const first = edits[0];
188
+ if (!first || typeof first !== "object") return;
189
+
190
+ // Detect mode from first edit entry shape and compute preview for first file
191
+ if ("old_text" in first && "new_text" in first) {
192
+ // Replace mode
193
+ const { path, old_text: oldText, new_text: newText, all } = first;
194
+ if (!path || oldText === undefined || newText === undefined) return;
184
195
 
185
- if (op) {
186
- const diff = this.#args?.diff;
187
- const rename = this.#args?.rename;
196
+ const argsKey = JSON.stringify({ path, oldText, newText, all });
197
+ if (this.#editDiffArgsKey === argsKey) return;
198
+ this.#editDiffArgsKey = argsKey;
199
+
200
+ computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result => {
201
+ if (this.#editDiffArgsKey === argsKey) {
202
+ this.#editDiffPreview = result;
203
+ this.#updateDisplay();
204
+ this.#ui.requestRender();
205
+ }
206
+ });
207
+ } else if ("path" in first && ("diff" in first || ("op" in first && !("content" in first)))) {
208
+ // Patch mode (has diff or op without content — chunk edits always have content)
209
+ const { path, op, rename, diff } = first;
188
210
  if (!path) return;
189
211
 
190
212
  const argsKey = JSON.stringify({ path, op, rename, diff });
@@ -201,49 +223,26 @@ export class ToolExecutionComponent extends Container {
201
223
  this.#ui.requestRender();
202
224
  }
203
225
  });
204
- return;
205
- }
206
- const edits = this.#args?.edits;
207
- const move = this.#args?.move;
208
- if (path && Array.isArray(edits)) {
209
- const argsKey = JSON.stringify({ path, edits, move });
226
+ } else if ("loc" in first && "path" in first) {
227
+ // Hashline mode — group edits by path, preview first file
228
+ const path = first.path;
229
+ if (!path) return;
230
+ const fileEdits = edits.filter((e: any) => e.path === path);
231
+ const move = this.#args?.move;
232
+
233
+ const argsKey = JSON.stringify({ path, edits: fileEdits, move });
210
234
  if (this.#editDiffArgsKey === argsKey) return;
211
235
  this.#editDiffArgsKey = argsKey;
212
236
 
213
- computeHashlineDiff({ path, edits, move }, this.#cwd).then(result => {
237
+ computeHashlineDiff({ path, edits: fileEdits, move }, this.#cwd).then(result => {
214
238
  if (this.#editDiffArgsKey === argsKey) {
215
239
  this.#editDiffPreview = result;
216
240
  this.#updateDisplay();
217
241
  this.#ui.requestRender();
218
242
  }
219
243
  });
220
- return;
221
244
  }
222
-
223
- const oldText = this.#args?.old_text;
224
- const newText = this.#args?.new_text;
225
- const all = this.#args?.all;
226
-
227
- // Need all three params to compute diff
228
- if (!path || oldText === undefined || newText === undefined) return;
229
-
230
- // Create a key to track which args this computation is for
231
- const argsKey = JSON.stringify({ path, oldText, newText, all });
232
-
233
- // Skip if we already computed for these exact args
234
- if (this.#editDiffArgsKey === argsKey) return;
235
-
236
- this.#editDiffArgsKey = argsKey;
237
-
238
- // Compute diff async
239
- computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result => {
240
- // Only update if args haven't changed since we started
241
- if (this.#editDiffArgsKey === argsKey) {
242
- this.#editDiffPreview = result;
243
- this.#updateDisplay();
244
- this.#ui.requestRender();
245
- }
246
- });
245
+ // Chunk mode edits don't have a pre-execution diff preview
247
246
  }
248
247
 
249
248
  updateResult(
@@ -443,51 +442,122 @@ export class ToolExecutionComponent extends Container {
443
442
  } else if (this.#toolName in toolRenderers) {
444
443
  // Built-in tools with renderers
445
444
  const renderer = toolRenderers[this.#toolName];
446
- // Inline renderers skip background styling
447
- this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
448
- this.#contentBox.clear();
449
445
 
450
- const shouldRenderCall = !this.#result || !renderer.mergeCallAndResult;
451
- if (shouldRenderCall) {
452
- // Render call component
453
- try {
454
- const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
455
- if (callComponent) {
456
- this.#contentBox.addChild(ensureInvalidate(callComponent));
446
+ // Clean up previous multi-file boxes
447
+ for (const box of this.#multiFileBoxes) {
448
+ this.removeChild(box);
449
+ }
450
+ this.#multiFileBoxes = [];
451
+
452
+ // Check for multi-file edit results
453
+ const perFileResults = this.#result?.details?.perFileResults as
454
+ | Array<{ path: string; isError?: boolean }>
455
+ | undefined;
456
+ if (perFileResults && perFileResults.length > 1) {
457
+ // Multi-file: render each file as its own Box (identical to separate tool calls)
458
+ this.#contentBox.setBgFn(undefined);
459
+ this.#contentBox.clear();
460
+
461
+ const renderContext = this.#buildRenderContext();
462
+ this.#renderState.renderContext = renderContext;
463
+
464
+ for (let i = 0; i < perFileResults.length; i++) {
465
+ const fileResult = perFileResults[i];
466
+ if (i > 0) {
467
+ const spacer = new Spacer(1);
468
+ this.#multiFileBoxes.push(spacer);
469
+ this.addChild(spacer);
457
470
  }
458
- } catch (err) {
459
- logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
460
- // Fall back to default on error
461
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
471
+ const fileBgFn = fileResult.isError
472
+ ? (text: string) => theme.bg("toolErrorBg", text)
473
+ : (text: string) => theme.bg("toolSuccessBg", text);
474
+ const fileBox = new Box(1, 1, fileBgFn);
475
+ try {
476
+ const resultComponent = renderer.renderResult(
477
+ { content: [], details: fileResult, isError: fileResult.isError },
478
+ this.#renderState,
479
+ theme,
480
+ );
481
+ if (resultComponent) {
482
+ fileBox.addChild(ensureInvalidate(resultComponent));
483
+ }
484
+ } catch (err) {
485
+ logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
486
+ }
487
+ this.#multiFileBoxes.push(fileBox);
488
+ this.addChild(fileBox);
462
489
  }
463
- }
464
490
 
465
- // Render result component if we have a result
466
- if (this.#result) {
467
- try {
468
- // Build render context for tools that need extra state
469
- const renderContext = this.#buildRenderContext();
470
- this.#renderState.renderContext = renderContext;
471
-
472
- const resultComponent = renderer.renderResult(
491
+ // Show pending indicator for remaining files
492
+ const totalFiles = this.#args?.edits
493
+ ? new Set((this.#args.edits as any[]).map((e: any) => e?.path).filter(Boolean)).size
494
+ : 0;
495
+ const remaining = Math.max(0, totalFiles - perFileResults.length);
496
+ if (remaining > 0 && this.#isPartial) {
497
+ const pendingSpacer = new Spacer(1);
498
+ this.#multiFileBoxes.push(pendingSpacer);
499
+ this.addChild(pendingSpacer);
500
+ const pendingBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
501
+ const pendingText = renderStatusLine(
473
502
  {
474
- content: this.#result.content as any,
475
- details: this.#result.details,
476
- isError: this.#result.isError,
503
+ icon: "pending",
504
+ title: "Edit",
505
+ description: theme.fg("dim", `${remaining} more file${remaining > 1 ? "s" : ""} pending…`),
477
506
  },
478
- this.#renderState,
479
507
  theme,
480
- this.#getCallArgsForRender(),
481
508
  );
482
- if (resultComponent) {
483
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
509
+ pendingBox.addChild(new Text(pendingText, 0, 0));
510
+ this.#multiFileBoxes.push(pendingBox);
511
+ this.addChild(pendingBox);
512
+ }
513
+ } else {
514
+ // Single-file or no result: standard rendering
515
+ // Inline renderers skip background styling
516
+ this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
517
+ this.#contentBox.clear();
518
+
519
+ const shouldRenderCall = !this.#result || !renderer.mergeCallAndResult;
520
+ if (shouldRenderCall) {
521
+ // Render call component
522
+ try {
523
+ const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
524
+ if (callComponent) {
525
+ this.#contentBox.addChild(ensureInvalidate(callComponent));
526
+ }
527
+ } catch (err) {
528
+ logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
529
+ // Fall back to default on error
530
+ this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
484
531
  }
485
- } catch (err) {
486
- logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
487
- // Fall back to showing raw output on error
488
- const output = this.#getTextOutput();
489
- if (output) {
490
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
532
+ }
533
+
534
+ // Render result component if we have a result
535
+ if (this.#result) {
536
+ try {
537
+ // Build render context for tools that need extra state
538
+ const renderContext = this.#buildRenderContext();
539
+ this.#renderState.renderContext = renderContext;
540
+
541
+ const resultComponent = renderer.renderResult(
542
+ {
543
+ content: this.#result.content as any,
544
+ details: this.#result.details,
545
+ isError: this.#result.isError,
546
+ },
547
+ this.#renderState,
548
+ theme,
549
+ this.#getCallArgsForRender(),
550
+ );
551
+ if (resultComponent) {
552
+ this.#contentBox.addChild(ensureInvalidate(resultComponent));
553
+ }
554
+ } catch (err) {
555
+ logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
556
+ // Fall back to showing raw output on error
557
+ const output = this.#getTextOutput();
558
+ if (output) {
559
+ this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
560
+ }
491
561
  }
492
562
  }
493
563
  }
@@ -588,11 +588,16 @@ export class CommandController {
588
588
  }
589
589
  await this.ctx.session.newSession();
590
590
  this.ctx.resetObserverRegistry();
591
- setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
591
+ setSessionTerminalTitle(
592
+ this.ctx.sessionManager.getSessionName(),
593
+ this.ctx.sessionManager.getCwd(),
594
+ this.ctx.sessionManager.titleSource,
595
+ );
592
596
 
593
597
  this.ctx.statusLine.invalidate();
594
598
  this.ctx.statusLine.setSessionStartTime(Date.now());
595
599
  this.ctx.updateEditorTopBorder();
600
+ this.ctx.updateEditorBorderColor();
596
601
  this.ctx.ui.requestRender();
597
602
 
598
603
  this.ctx.chatContainer.clear();
@@ -686,6 +691,23 @@ export class CommandController {
686
691
  }
687
692
  }
688
693
 
694
+ async handleRenameCommand(title: string): Promise<void> {
695
+ try {
696
+ const stored = await this.ctx.sessionManager.setSessionName(title, "user");
697
+ if (!stored) {
698
+ this.ctx.showError("Session name cannot be empty.");
699
+ return;
700
+ }
701
+ const name = this.ctx.sessionManager.getSessionName()!;
702
+ setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.titleSource);
703
+ this.ctx.statusLine.invalidate();
704
+ this.ctx.updateEditorBorderColor();
705
+ this.ctx.showStatus(`Session renamed to "${name}".`);
706
+ } catch (err) {
707
+ this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
708
+ }
709
+ }
710
+
689
711
  async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
690
712
  const isDeferred = this.ctx.session.isStreaming;
691
713
  this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
@@ -869,6 +891,7 @@ export class CommandController {
869
891
 
870
892
  this.ctx.statusLine.invalidate();
871
893
  this.ctx.updateEditorTopBorder();
894
+ this.ctx.updateEditorBorderColor();
872
895
  await this.ctx.reloadTodos();
873
896
 
874
897
  this.ctx.chatContainer.addChild(new Spacer(1));
@@ -244,6 +244,7 @@ export class EventController {
244
244
  tool,
245
245
  this.ctx.ui,
246
246
  this.ctx.sessionManager.getCwd(),
247
+ content.id,
247
248
  );
248
249
  component.setExpanded(this.ctx.toolOutputExpanded);
249
250
  this.ctx.chatContainer.addChild(component);
@@ -333,6 +334,7 @@ export class EventController {
333
334
  tool,
334
335
  this.ctx.ui,
335
336
  this.ctx.sessionManager.getCwd(),
337
+ event.toolCallId,
336
338
  );
337
339
  component.setExpanded(this.ctx.toolOutputExpanded);
338
340
  this.ctx.chatContainer.addChild(component);
@@ -638,7 +640,8 @@ export class EventController {
638
640
  if (this.ctx.isBackgrounded === false) return;
639
641
  const notify = settings.get("completion.notify");
640
642
  if (notify === "off") return;
641
- const title = this.ctx.sessionManager.getSessionName();
643
+ const title =
644
+ this.ctx.sessionManager.titleSource === "auto" ? undefined : this.ctx.sessionManager.getSessionName();
642
645
  const message = title ? `${title}: Complete` : "Complete";
643
646
  TERMINAL.sendNotification(message);
644
647
  }
@@ -120,10 +120,18 @@ export class ExtensionUiController {
120
120
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
121
121
  setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
122
122
  getCommands: () => [],
123
+ getSessionName: () => this.ctx.sessionManager.getSessionName(),
124
+ setSessionName: async name => {
125
+ await this.ctx.sessionManager.setSessionName(name, "user");
126
+ setSessionTerminalTitle(
127
+ this.ctx.sessionManager.getSessionName(),
128
+ this.ctx.sessionManager.getCwd(),
129
+ this.ctx.sessionManager.titleSource,
130
+ );
131
+ },
123
132
  };
124
133
  const contextActions: ExtensionContextActions = {
125
134
  getModel: () => this.ctx.session.model,
126
- getSearchDb: () => this.ctx.session.searchDb,
127
135
  isIdle: () => !this.ctx.session.isStreaming,
128
136
  abort: () => this.ctx.session.abort(),
129
137
  hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
@@ -164,7 +172,11 @@ export class ExtensionUiController {
164
172
  if (!success) {
165
173
  return { cancelled: true };
166
174
  }
167
- setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
175
+ setSessionTerminalTitle(
176
+ this.ctx.sessionManager.getSessionName(),
177
+ this.ctx.sessionManager.getCwd(),
178
+ this.ctx.sessionManager.titleSource,
179
+ );
168
180
 
169
181
  // Call setup callback if provided
170
182
  if (options?.setup) {
@@ -242,7 +254,11 @@ export class ExtensionUiController {
242
254
  if (!result) {
243
255
  return { cancelled: true };
244
256
  }
245
- setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
257
+ setSessionTerminalTitle(
258
+ this.ctx.sessionManager.getSessionName(),
259
+ this.ctx.sessionManager.getCwd(),
260
+ this.ctx.sessionManager.titleSource,
261
+ );
246
262
  this.ctx.chatContainer.clear();
247
263
  this.ctx.renderInitialMessages();
248
264
  await this.ctx.reloadTodos();
@@ -382,10 +398,18 @@ export class ExtensionUiController {
382
398
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
383
399
  setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
384
400
  getCommands: () => [],
401
+ getSessionName: () => this.ctx.sessionManager.getSessionName(),
402
+ setSessionName: async name => {
403
+ await this.ctx.sessionManager.setSessionName(name, "user");
404
+ setSessionTerminalTitle(
405
+ this.ctx.sessionManager.getSessionName(),
406
+ this.ctx.sessionManager.getCwd(),
407
+ this.ctx.sessionManager.titleSource,
408
+ );
409
+ },
385
410
  };
386
411
  const contextActions: ExtensionContextActions = {
387
412
  getModel: () => this.ctx.session.model,
388
- getSearchDb: () => this.ctx.session.searchDb,
389
413
  isIdle: () => !this.ctx.session.isStreaming,
390
414
  abort: () => this.ctx.session.abort(),
391
415
  hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
@@ -583,7 +607,6 @@ export class ExtensionUiController {
583
607
  sessionManager: this.ctx.session.sessionManager,
584
608
  modelRegistry: this.ctx.session.modelRegistry,
585
609
  model: this.ctx.session.model,
586
- searchDb: this.ctx.session.searchDb,
587
610
  isIdle: () => !this.ctx.session.isStreaming,
588
611
  hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
589
612
  hasQueuedMessages: () => this.ctx.session.queuedMessageCount > 0,
@@ -342,8 +342,15 @@ export class InputController {
342
342
  generateSessionTitle(text, registry, this.ctx.settings, this.ctx.session.sessionId, this.ctx.session.model)
343
343
  .then(async title => {
344
344
  if (title) {
345
- await this.ctx.sessionManager.setSessionName(title);
346
- setSessionTerminalTitle(title, this.ctx.sessionManager.getCwd());
345
+ const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
346
+ if (applied) {
347
+ setSessionTerminalTitle(
348
+ this.ctx.sessionManager.getSessionName()!,
349
+ this.ctx.sessionManager.getCwd(),
350
+ this.ctx.sessionManager.titleSource,
351
+ );
352
+ this.ctx.updateEditorBorderColor();
353
+ }
347
354
  }
348
355
  })
349
356
  .catch(() => {});
@@ -557,7 +564,6 @@ export class InputController {
557
564
  return createPromptActionAutocompleteProvider({
558
565
  commands,
559
566
  basePath,
560
- searchDb: this.ctx.session.searchDb,
561
567
  keybindings: this.ctx.keybindings,
562
568
  copyCurrentLine: () => this.handleCopyCurrentLine(),
563
569
  copyPrompt: () => this.handleCopyPrompt(),
@@ -747,8 +747,9 @@ export class SelectorController {
747
747
  const sessionManager = this.ctx.sessionManager as {
748
748
  getSessionName?: () => string | undefined;
749
749
  getCwd: () => string;
750
+ titleSource?: "auto" | "user" | undefined;
750
751
  };
751
- setSessionTerminalTitle(sessionManager.getSessionName?.(), sessionManager.getCwd());
752
+ setSessionTerminalTitle(sessionManager.getSessionName?.(), sessionManager.getCwd(), sessionManager.titleSource);
752
753
  }
753
754
 
754
755
  async #detachActiveSessionBeforeDeletion(sessionPath: string): Promise<boolean> {
@@ -767,6 +768,7 @@ export class SelectorController {
767
768
  this.ctx.statusLine.invalidate();
768
769
  this.ctx.statusLine.setSessionStartTime(Date.now());
769
770
  this.ctx.updateEditorTopBorder();
771
+ this.ctx.updateEditorBorderColor();
770
772
  this.ctx.renderInitialMessages();
771
773
  await this.ctx.reloadTodos();
772
774
  this.ctx.ui.requestRender();
@@ -779,6 +781,7 @@ export class SelectorController {
779
781
  // Switch session via AgentSession (emits hook and tool session events)
780
782
  await this.ctx.session.switchSession(sessionPath);
781
783
  this.#refreshSessionTerminalTitle();
784
+ this.ctx.updateEditorBorderColor();
782
785
 
783
786
  // Clear and re-render the chat
784
787
  this.ctx.chatContainer.clear();
@@ -39,6 +39,7 @@ import { STTController, type SttState } from "../stt";
39
39
  import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
40
40
  import type { EventBus } from "../utils/event-bus";
41
41
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
42
+ import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../utils/session-color";
42
43
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
43
44
  import type { AssistantMessageComponent } from "./components/assistant-message";
44
45
  import type { BashExecutionComponent } from "./components/bash-execution";
@@ -392,7 +393,12 @@ export class InteractiveMode implements InteractiveModeContext {
392
393
  // Start the UI
393
394
  this.ui.start();
394
395
  pushTerminalTitle();
395
- setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
396
+ setSessionTerminalTitle(
397
+ this.sessionManager.getSessionName(),
398
+ this.sessionManager.getCwd(),
399
+ this.sessionManager.titleSource,
400
+ );
401
+ this.updateEditorBorderColor();
396
402
  this.#syncEditorMaxHeight();
397
403
  this.isInitialized = true;
398
404
  this.ui.requestRender(true);
@@ -531,8 +537,14 @@ export class InteractiveMode implements InteractiveModeContext {
531
537
  } else if (this.isPythonMode) {
532
538
  this.editor.borderColor = theme.getPythonModeBorderColor();
533
539
  } else {
534
- const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
535
- this.editor.borderColor = theme.getThinkingBorderColor(level);
540
+ const hex = getSessionAccentHexForTitle(this.sessionManager.getSessionName(), this.sessionManager.titleSource);
541
+ const ansi = getSessionAccentAnsi(hex);
542
+ if (ansi) {
543
+ this.editor.borderColor = (str: string) => `${ansi}${str}\x1b[39m`;
544
+ } else {
545
+ const level = this.session.thinkingLevel ?? ThinkingLevel.Off;
546
+ this.editor.borderColor = theme.getThinkingBorderColor(level);
547
+ }
536
548
  }
537
549
  this.updateEditorTopBorder();
538
550
  this.ui.requestRender();
@@ -1302,6 +1314,10 @@ export class InteractiveMode implements InteractiveModeContext {
1302
1314
  return this.#commandController.handleMoveCommand(targetPath);
1303
1315
  }
1304
1316
 
1317
+ handleRenameCommand(title: string): Promise<void> {
1318
+ return this.#commandController.handleRenameCommand(title);
1319
+ }
1320
+
1305
1321
  handleMemoryCommand(text: string): Promise<void> {
1306
1322
  return this.#commandController.handleMemoryCommand(text);
1307
1323
  }
@@ -6,6 +6,7 @@
6
6
  * - `omp --mode json "prompt"` - JSON event stream
7
7
  */
8
8
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
9
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
9
10
  import type { AgentSession } from "../session/agent-session";
10
11
 
11
12
  /**
@@ -72,11 +73,14 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
72
73
  },
73
74
  getThinkingLevel: () => session.thinkingLevel,
74
75
  setThinkingLevel: level => session.setThinkingLevel(level),
76
+ getSessionName: () => session.sessionManager.getSessionName(),
77
+ setSessionName: async name => {
78
+ await session.sessionManager.setSessionName(name, "user");
79
+ },
75
80
  },
76
81
  // ExtensionContextActions
77
82
  {
78
83
  getModel: () => session.model,
79
- getSearchDb: () => session.searchDb,
80
84
  isIdle: () => !session.isStreaming,
81
85
  abort: () => session.abort(),
82
86
  hasPendingMessages: () => session.queuedMessageCount > 0,
@@ -166,14 +170,19 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
166
170
 
167
171
  // Check for error/aborted
168
172
  if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
169
- process.stderr.write(`${assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`}\n`);
170
- process.exit(1);
173
+ const errorLine = sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
174
+ const flushed = process.stderr.write(`${errorLine}\n`);
175
+ if (flushed) {
176
+ process.exit(1);
177
+ } else {
178
+ process.stderr.once("drain", () => process.exit(1));
179
+ }
171
180
  }
172
181
 
173
182
  // Output text content
174
183
  for (const content of assistantMsg.content) {
175
184
  if (content.type === "text") {
176
- process.stdout.write(`${content.text}\n`);
185
+ process.stdout.write(`${sanitizeText(content.text)}\n`);
177
186
  }
178
187
  }
179
188
  }
@@ -1,4 +1,3 @@
1
- import type { SearchDb } from "@oh-my-pi/pi-natives";
2
1
  import {
3
2
  type AutocompleteItem,
4
3
  type AutocompleteProvider,
@@ -24,7 +23,6 @@ interface PromptActionAutocompleteItem extends AutocompleteItem {
24
23
  interface PromptActionAutocompleteOptions {
25
24
  commands: SlashCommand[];
26
25
  basePath: string;
27
- searchDb?: SearchDb;
28
26
  keybindings: KeybindingsManager;
29
27
  copyCurrentLine: () => void;
30
28
  copyPrompt: () => void;
@@ -92,8 +90,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
92
90
  #baseProvider: CombinedAutocompleteProvider;
93
91
  #actions: PromptActionDefinition[];
94
92
 
95
- constructor(commands: SlashCommand[], basePath: string, actions: PromptActionDefinition[], searchDb?: SearchDb) {
96
- this.#baseProvider = new CombinedAutocompleteProvider(commands, basePath, searchDb);
93
+ constructor(commands: SlashCommand[], basePath: string, actions: PromptActionDefinition[]) {
94
+ this.#baseProvider = new CombinedAutocompleteProvider(commands, basePath);
97
95
  this.#actions = actions;
98
96
  }
99
97
 
@@ -229,5 +227,5 @@ export function createPromptActionAutocompleteProvider(
229
227
  },
230
228
  ];
231
229
 
232
- return new PromptActionAutocompleteProvider(options.commands, options.basePath, actions, options.searchDb);
230
+ return new PromptActionAutocompleteProvider(options.commands, options.basePath, actions);
233
231
  }
@@ -440,11 +440,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
440
440
  },
441
441
  getThinkingLevel: () => session.thinkingLevel,
442
442
  setThinkingLevel: level => session.setThinkingLevel(level),
443
+ getSessionName: () => session.sessionManager.getSessionName(),
444
+ setSessionName: async name => {
445
+ await session.sessionManager.setSessionName(name, "user");
446
+ },
443
447
  },
444
448
  // ExtensionContextActions
445
449
  {
446
450
  getModel: () => session.agent.state.model,
447
- getSearchDb: () => session.searchDb,
448
451
  isIdle: () => !session.isStreaming,
449
452
  abort: () => session.abort(),
450
453
  hasPendingMessages: () => session.queuedMessageCount > 0,
@@ -751,7 +754,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
751
754
  if (!name) {
752
755
  return error(id, "set_session_name", "Session name cannot be empty");
753
756
  }
754
- session.setSessionName(name);
757
+ const applied = await session.setSessionName(name, "user");
758
+ if (!applied) {
759
+ return error(id, "set_session_name", "Session name cannot be empty");
760
+ }
755
761
  return success(id, "set_session_name");
756
762
  }
757
763
 
@@ -5,10 +5,10 @@ import { theme } from "./theme/theme";
5
5
  // Text Sanitization
6
6
  // ═══════════════════════════════════════════════════════════════════════════
7
7
 
8
- /** Sanitize text for display in a single-line status. Replaces newlines/tabs with space, collapses runs, trims. */
8
+ /** Sanitize text for display in a single-line status. Strips C0/C1 control characters (including ANSI ESC), collapses whitespace, trims. */
9
9
  export function sanitizeStatusText(text: string): string {
10
10
  return text
11
- .replace(/[\r\n\t]/g, " ")
11
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
12
12
  .replace(/ +/g, " ")
13
13
  .trim();
14
14
  }
@@ -187,6 +187,7 @@ export interface InteractiveModeContext {
187
187
  handleCompactCommand(customInstructions?: string): Promise<void>;
188
188
  handleHandoffCommand(customInstructions?: string): Promise<void>;
189
189
  handleMoveCommand(targetPath: string): Promise<void>;
190
+ handleRenameCommand(title: string): Promise<void>;
190
191
  handleMemoryCommand(text: string): Promise<void>;
191
192
  handleSTTToggle(): Promise<void>;
192
193
  executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void>;