@oh-my-pi/pi-coding-agent 14.0.5 → 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 (101) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/package.json +8 -8
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +43 -10
  5. package/src/async/support.ts +5 -0
  6. package/src/cli/list-models.ts +96 -57
  7. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  8. package/src/commit/model-selection.ts +16 -13
  9. package/src/config/mcp-schema.json +1 -1
  10. package/src/config/model-equivalence.ts +675 -0
  11. package/src/config/model-registry.ts +242 -45
  12. package/src/config/model-resolver.ts +282 -65
  13. package/src/config/settings-schema.ts +27 -3
  14. package/src/config/settings.ts +1 -1
  15. package/src/cursor.ts +64 -23
  16. package/src/edit/index.ts +254 -89
  17. package/src/edit/modes/chunk.ts +336 -57
  18. package/src/edit/modes/hashline.ts +51 -26
  19. package/src/edit/modes/patch.ts +16 -10
  20. package/src/edit/modes/replace.ts +15 -7
  21. package/src/edit/renderer.ts +248 -94
  22. package/src/export/html/template.css +82 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +614 -97
  25. package/src/extensibility/custom-tools/types.ts +0 -3
  26. package/src/extensibility/extensions/loader.ts +16 -0
  27. package/src/extensibility/extensions/runner.ts +2 -7
  28. package/src/extensibility/extensions/types.ts +8 -4
  29. package/src/internal-urls/docs-index.generated.ts +4 -4
  30. package/src/internal-urls/jobs-protocol.ts +2 -1
  31. package/src/ipy/executor.ts +447 -52
  32. package/src/ipy/kernel.ts +39 -13
  33. package/src/lsp/client.ts +55 -1
  34. package/src/lsp/index.ts +8 -0
  35. package/src/lsp/types.ts +6 -0
  36. package/src/main.ts +6 -2
  37. package/src/memories/index.ts +7 -6
  38. package/src/modes/acp/acp-agent.ts +4 -1
  39. package/src/modes/components/bash-execution.ts +16 -4
  40. package/src/modes/components/model-selector.ts +221 -64
  41. package/src/modes/components/status-line/presets.ts +17 -6
  42. package/src/modes/components/status-line/segments.ts +15 -0
  43. package/src/modes/components/status-line-segment-editor.ts +1 -0
  44. package/src/modes/components/status-line.ts +7 -1
  45. package/src/modes/components/tool-execution.ts +145 -75
  46. package/src/modes/controllers/command-controller.ts +42 -1
  47. package/src/modes/controllers/event-controller.ts +4 -1
  48. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  49. package/src/modes/controllers/input-controller.ts +9 -3
  50. package/src/modes/controllers/selector-controller.ts +17 -6
  51. package/src/modes/interactive-mode.ts +19 -3
  52. package/src/modes/print-mode.ts +13 -4
  53. package/src/modes/prompt-action-autocomplete.ts +3 -5
  54. package/src/modes/rpc/rpc-mode.ts +8 -2
  55. package/src/modes/shared.ts +2 -2
  56. package/src/modes/types.ts +1 -0
  57. package/src/modes/utils/ui-helpers.ts +1 -0
  58. package/src/prompts/system/system-prompt.md +5 -1
  59. package/src/prompts/tools/bash.md +16 -1
  60. package/src/prompts/tools/cancel-job.md +1 -1
  61. package/src/prompts/tools/chunk-edit.md +191 -163
  62. package/src/prompts/tools/hashline.md +11 -11
  63. package/src/prompts/tools/patch.md +10 -5
  64. package/src/prompts/tools/{await.md → poll.md} +1 -1
  65. package/src/prompts/tools/read-chunk.md +12 -3
  66. package/src/prompts/tools/read.md +9 -0
  67. package/src/prompts/tools/task.md +2 -2
  68. package/src/prompts/tools/vim.md +98 -0
  69. package/src/prompts/tools/write.md +1 -0
  70. package/src/sdk.ts +758 -725
  71. package/src/session/agent-session.ts +187 -40
  72. package/src/session/session-manager.ts +50 -4
  73. package/src/slash-commands/builtin-registry.ts +17 -0
  74. package/src/task/executor.ts +9 -5
  75. package/src/task/index.ts +3 -5
  76. package/src/task/types.ts +2 -2
  77. package/src/tools/bash.ts +240 -57
  78. package/src/tools/cancel-job.ts +2 -1
  79. package/src/tools/find.ts +5 -2
  80. package/src/tools/grep.ts +77 -8
  81. package/src/tools/index.ts +48 -19
  82. package/src/tools/inspect-image.ts +1 -1
  83. package/src/tools/{await-tool.ts → poll-tool.ts} +38 -31
  84. package/src/tools/python.ts +293 -278
  85. package/src/tools/read.ts +218 -1
  86. package/src/tools/sqlite-reader.ts +623 -0
  87. package/src/tools/submit-result.ts +5 -2
  88. package/src/tools/todo-write.ts +8 -2
  89. package/src/tools/vim.ts +966 -0
  90. package/src/tools/write.ts +187 -1
  91. package/src/utils/commit-message-generator.ts +1 -0
  92. package/src/utils/edit-mode.ts +2 -1
  93. package/src/utils/git.ts +24 -1
  94. package/src/utils/session-color.ts +55 -0
  95. package/src/utils/title-generator.ts +16 -7
  96. package/src/vim/buffer.ts +309 -0
  97. package/src/vim/commands.ts +382 -0
  98. package/src/vim/engine.ts +2426 -0
  99. package/src/vim/parser.ts +151 -0
  100. package/src/vim/render.ts +252 -0
  101. package/src/vim/types.ts +197 -0
@@ -9,6 +9,7 @@ import { theme } from "../../modes/theme/theme";
9
9
  import type { AgentSession } from "../../session/agent-session";
10
10
  import { calculatePromptTokens } from "../../session/compaction/compaction";
11
11
  import * as git from "../../utils/git";
12
+ import { getSessionAccentAnsi, getSessionAccentHexForTitle } from "../../utils/session-color";
12
13
  import { sanitizeStatusText } from "../shared";
13
14
  import {
14
15
  canReuseCachedPr,
@@ -471,7 +472,12 @@ export class StatusLineComponent implements Component {
471
472
  leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
472
473
  rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
473
474
  const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
474
- const gapFill = theme.fg("border", theme.boxRound.horizontal.repeat(gapWidth));
475
+ const accentHex = getSessionAccentHexForTitle(
476
+ this.session.sessionManager?.getSessionName(),
477
+ this.session.sessionManager?.titleSource,
478
+ );
479
+ const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
480
+ const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
475
481
  return leftGroup + gapFill + rightGroup;
476
482
  }
477
483
 
@@ -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));
@@ -1002,6 +1025,14 @@ function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: numbe
1002
1025
  return `account ${index + 1}`;
1003
1026
  }
1004
1027
 
1028
+ function formatUnlimitedReportLabel(report: UsageReport, index: number): string {
1029
+ const email = report.metadata?.email as string | undefined;
1030
+ if (email) return email;
1031
+ const accountId = report.metadata?.accountId as string | undefined;
1032
+ if (accountId) return accountId;
1033
+ return `account ${index + 1}`;
1034
+ }
1035
+
1005
1036
  function formatResetShort(limit: UsageLimit, nowMs: number): string | undefined {
1006
1037
  if (limit.window?.resetsAt !== undefined) {
1007
1038
  return formatDuration(limit.window.resetsAt - nowMs);
@@ -1188,6 +1219,16 @@ function renderUsageReports(reports: UsageReport[], uiTheme: typeof theme, nowMs
1188
1219
  }
1189
1220
  }
1190
1221
 
1222
+ // Render accounts with no rate limits (e.g. business/enterprise plans).
1223
+ const unlimitedReports = providerReports.filter(report => report.limits.length === 0);
1224
+ for (const report of unlimitedReports) {
1225
+ const label = formatUnlimitedReportLabel(report, 0);
1226
+ const tier = report.metadata?.planType as string | undefined;
1227
+ const tierSuffix = tier ? ` ${uiTheme.fg("dim", `(${tier})`)}` : "";
1228
+ lines.push(
1229
+ `${uiTheme.fg("success", uiTheme.status.success)} ${label}${tierSuffix} ${uiTheme.fg("dim", "-- no limits")}`,
1230
+ );
1231
+ }
1191
1232
  // No per-provider footer; global header shows last check.
1192
1233
  }
1193
1234
 
@@ -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(),
@@ -7,6 +7,7 @@ import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
7
7
  import { getAgentDbPath, getConfigDirName, getProjectDir } from "@oh-my-pi/pi-utils";
8
8
  import { invalidate as invalidateFsCache } from "../../capability/fs";
9
9
  import { getRoleInfo } from "../../config/model-registry";
10
+ import { formatModelSelectorValue } from "../../config/model-resolver";
10
11
  import { settings } from "../../config/settings";
11
12
  import { DebugSelectorComponent } from "../../debug";
12
13
  import { disableProvider, enableProvider } from "../../discovery";
@@ -387,31 +388,38 @@ export class SelectorController {
387
388
  this.ctx.settings,
388
389
  this.ctx.session.modelRegistry,
389
390
  this.ctx.session.scopedModels,
390
- async (model, role, thinkingLevel) => {
391
+ async (model, role, thinkingLevel, selector) => {
391
392
  try {
392
393
  if (role === null) {
393
394
  // Temporary: update agent state but don't persist to settings
394
395
  await this.ctx.session.setModelTemporary(model);
395
396
  this.ctx.statusLine.invalidate();
396
397
  this.ctx.updateEditorBorderColor();
397
- this.ctx.showStatus(`Temporary model: ${model.id}`);
398
+ this.ctx.showStatus(`Temporary model: ${selector ?? model.id}`);
398
399
  done();
399
400
  this.ctx.ui.requestRender();
400
401
  } else if (role === "default") {
401
402
  // Default: update agent state and persist
402
- await this.ctx.session.setModel(model, role);
403
+ await this.ctx.session.setModel(model, role, {
404
+ selector,
405
+ thinkingLevel,
406
+ });
403
407
  if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
404
408
  this.ctx.session.setThinkingLevel(thinkingLevel);
405
409
  }
406
410
  this.ctx.statusLine.invalidate();
407
411
  this.ctx.updateEditorBorderColor();
408
- this.ctx.showStatus(`Default model: ${model.id}`);
412
+ this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
409
413
  // Don't call done() - selector stays open for role assignment
410
414
  } else {
411
415
  // Other roles (smol, slow): just update settings, not current model
416
+ this.ctx.settings.setModelRole(
417
+ role,
418
+ formatModelSelectorValue(selector ?? `${model.provider}/${model.id}`, thinkingLevel),
419
+ );
412
420
  const roleInfo = getRoleInfo(role, settings);
413
421
  const roleLabel = roleInfo?.name ?? role;
414
- this.ctx.showStatus(`${roleLabel} model: ${model.id}`);
422
+ this.ctx.showStatus(`${roleLabel} model: ${selector ?? model.id}`);
415
423
  // Don't call done() - selector stays open
416
424
  }
417
425
  } catch (error) {
@@ -739,8 +747,9 @@ export class SelectorController {
739
747
  const sessionManager = this.ctx.sessionManager as {
740
748
  getSessionName?: () => string | undefined;
741
749
  getCwd: () => string;
750
+ titleSource?: "auto" | "user" | undefined;
742
751
  };
743
- setSessionTerminalTitle(sessionManager.getSessionName?.(), sessionManager.getCwd());
752
+ setSessionTerminalTitle(sessionManager.getSessionName?.(), sessionManager.getCwd(), sessionManager.titleSource);
744
753
  }
745
754
 
746
755
  async #detachActiveSessionBeforeDeletion(sessionPath: string): Promise<boolean> {
@@ -759,6 +768,7 @@ export class SelectorController {
759
768
  this.ctx.statusLine.invalidate();
760
769
  this.ctx.statusLine.setSessionStartTime(Date.now());
761
770
  this.ctx.updateEditorTopBorder();
771
+ this.ctx.updateEditorBorderColor();
762
772
  this.ctx.renderInitialMessages();
763
773
  await this.ctx.reloadTodos();
764
774
  this.ctx.ui.requestRender();
@@ -771,6 +781,7 @@ export class SelectorController {
771
781
  // Switch session via AgentSession (emits hook and tool session events)
772
782
  await this.ctx.session.switchSession(sessionPath);
773
783
  this.#refreshSessionTerminalTitle();
784
+ this.ctx.updateEditorBorderColor();
774
785
 
775
786
  // Clear and re-render the chat
776
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
  }