@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.8

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/stats-cli.d.ts +1 -6
  9. package/dist/types/cli/update-cli.d.ts +8 -0
  10. package/dist/types/cli-commands.d.ts +10 -2
  11. package/dist/types/commands/stats.d.ts +4 -0
  12. package/dist/types/config/settings-schema.d.ts +28 -0
  13. package/dist/types/config/settings.d.ts +9 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +23 -4
  15. package/dist/types/extensibility/extensions/types.d.ts +58 -0
  16. package/dist/types/launch/presence.d.ts +4 -1
  17. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  18. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  19. package/dist/types/mnemopi/backend.d.ts +12 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  21. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  22. package/dist/types/modes/interactive-mode.d.ts +25 -3
  23. package/dist/types/modes/types.d.ts +10 -0
  24. package/dist/types/session/agent-session.d.ts +3 -0
  25. package/dist/types/session/prewalk.d.ts +4 -0
  26. package/dist/types/session/session-entries.d.ts +0 -1
  27. package/dist/types/session/session-manager.d.ts +12 -0
  28. package/dist/types/session/session-stats.d.ts +13 -1
  29. package/dist/types/session/skill-title-input.d.ts +13 -0
  30. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  31. package/dist/types/subprocess/worker-client.d.ts +7 -4
  32. package/dist/types/task/label.d.ts +2 -0
  33. package/dist/types/task/render.d.ts +2 -0
  34. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  35. package/dist/types/tiny/title-client.d.ts +6 -4
  36. package/dist/types/tiny/title-protocol.d.ts +1 -0
  37. package/dist/types/tiny/worker.d.ts +27 -0
  38. package/dist/types/tools/bash.d.ts +1 -1
  39. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/tools/read-format.d.ts +6 -0
  43. package/dist/types/tools/read-summary.d.ts +7 -1
  44. package/dist/types/utils/block-context.d.ts +14 -0
  45. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  46. package/dist/types/utils/git.d.ts +25 -1
  47. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  48. package/package.json +13 -13
  49. package/src/advisor/advise-tool.ts +5 -3
  50. package/src/cli/auth-broker-cli.ts +36 -1
  51. package/src/cli/profile-bootstrap.ts +2 -6
  52. package/src/cli/stats-cli.ts +6 -72
  53. package/src/cli/update-cli.ts +63 -11
  54. package/src/cli-commands.ts +61 -7
  55. package/src/commands/completions.ts +2 -1
  56. package/src/commands/stats.ts +7 -4
  57. package/src/commit/agentic/index.ts +15 -2
  58. package/src/commit/git/diff.ts +6 -2
  59. package/src/config/model-resolver.ts +52 -6
  60. package/src/config/models-config.ts +2 -2
  61. package/src/config/settings-schema.ts +33 -0
  62. package/src/config/settings.ts +159 -30
  63. package/src/discovery/helpers.ts +45 -2
  64. package/src/discovery/omp-plugins.ts +2 -1
  65. package/src/discovery/opencode.ts +56 -3
  66. package/src/edit/hashline/filesystem.ts +9 -3
  67. package/src/edit/modes/patch.ts +31 -5
  68. package/src/eval/js/process-entry.ts +4 -4
  69. package/src/export/html/tool-views.generated.js +19 -19
  70. package/src/extensibility/extensions/loader.ts +11 -0
  71. package/src/extensibility/extensions/runner.ts +118 -5
  72. package/src/extensibility/extensions/types.ts +60 -0
  73. package/src/extensibility/extensions/wrapper.ts +10 -1
  74. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  75. package/src/launch/client.ts +9 -4
  76. package/src/launch/presence.ts +19 -4
  77. package/src/lsp/defaults.json +1 -1
  78. package/src/lsp/writethrough.ts +20 -10
  79. package/src/mcp/manager.ts +41 -20
  80. package/src/mcp/oauth-credentials.ts +38 -0
  81. package/src/mcp/oauth-flow.ts +21 -0
  82. package/src/mcp/tool-bridge.ts +32 -16
  83. package/src/mnemopi/backend.ts +35 -3
  84. package/src/modes/components/model-hub.ts +37 -4
  85. package/src/modes/components/settings-selector.ts +17 -11
  86. package/src/modes/components/tool-execution.ts +97 -29
  87. package/src/modes/components/tree-selector.ts +7 -2
  88. package/src/modes/controllers/event-controller.ts +12 -2
  89. package/src/modes/controllers/input-controller.ts +64 -27
  90. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  91. package/src/modes/interactive-mode.ts +79 -11
  92. package/src/modes/types.ts +11 -0
  93. package/src/prompts/system/memory-extraction-system.md +5 -22
  94. package/src/prompts/system/system-prompt.md +1 -1
  95. package/src/session/agent-session.ts +52 -6
  96. package/src/session/messages.ts +6 -0
  97. package/src/session/prewalk.ts +25 -7
  98. package/src/session/session-entries.ts +0 -1
  99. package/src/session/session-maintenance.ts +10 -1
  100. package/src/session/session-manager.ts +15 -0
  101. package/src/session/session-stats.ts +24 -3
  102. package/src/session/settings-stream-fn.ts +7 -0
  103. package/src/session/skill-title-input.ts +32 -0
  104. package/src/session/turn-recovery.ts +23 -18
  105. package/src/slash-commands/builtin-session.ts +1 -1
  106. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  107. package/src/subprocess/worker-client.ts +8 -5
  108. package/src/task/executor.ts +11 -0
  109. package/src/task/index.ts +2 -0
  110. package/src/task/label.ts +14 -1
  111. package/src/task/persisted-revive.ts +13 -0
  112. package/src/task/render.ts +1 -1
  113. package/src/task/structured-subagent.ts +5 -2
  114. package/src/tiny/completion-prompt.ts +16 -0
  115. package/src/tiny/title-client.ts +15 -6
  116. package/src/tiny/title-protocol.ts +8 -1
  117. package/src/tiny/worker.ts +21 -19
  118. package/src/tools/bash.ts +7 -1
  119. package/src/tools/file-write-fallback.ts +467 -0
  120. package/src/tools/index.ts +1 -0
  121. package/src/tools/path-utils.ts +79 -0
  122. package/src/tools/read-format.ts +16 -2
  123. package/src/tools/read-summary.ts +9 -4
  124. package/src/tools/read.ts +306 -72
  125. package/src/utils/block-context.ts +15 -1
  126. package/src/utils/fetch-timeout.ts +33 -0
  127. package/src/utils/git.ts +54 -11
  128. package/src/web/search/providers/browser-page.ts +21 -3
  129. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -1,3 +1,4 @@
1
+ import type { OAuthCredentials } from "@oh-my-pi/pi-ai/oauth/types";
1
2
  import { getActiveProfile } from "@oh-my-pi/pi-utils/dirs";
2
3
  import { expandEnvVarsDeep } from "../discovery/helpers";
3
4
  import type { AuthStorage } from "../session/auth-storage";
@@ -6,6 +7,7 @@ import {
6
7
  type MCPStoredOAuthCredential,
7
8
  mcpOAuthCredentialId,
8
9
  mcpOAuthCredentialProfile,
10
+ refreshMCPOAuthToken,
9
11
  } from "./oauth-flow";
10
12
  import type { MCPAuthConfig, MCPServerConfig } from "./types";
11
13
 
@@ -80,6 +82,42 @@ export function selectMcpOAuthRefreshMaterial(
80
82
  return credential.tokenUrl ? credential : auth;
81
83
  }
82
84
 
85
+ /**
86
+ * Refresh a stored MCP OAuth credential via the standard `refresh_token` grant.
87
+ *
88
+ * Refresh material is taken from the credential itself (self-contained modern
89
+ * credentials embed `tokenUrl`/`clientId`/`clientSecret`/`resource`) or, for
90
+ * legacy credentials that carry none, the server's `auth` block. Shared by the
91
+ * local MCP manager and the `omp auth-broker serve` refresh path so a broker
92
+ * with no access to the MCP config can still refresh `mcp_oauth:*` credentials
93
+ * from the vault.
94
+ *
95
+ * `serverUrl` supplies the RFC 8707 fallback resource indicator when neither
96
+ * the credential nor the auth block advertised one; the manager passes the
97
+ * configured server URL, the broker recovers it from the credential id via
98
+ * {@link mcpOAuthServerUrlFromCredentialId}.
99
+ *
100
+ * @throws when no usable refresh token or token endpoint is available.
101
+ */
102
+ export function refreshManagedMcpOAuthCredential(
103
+ credential: MCPStoredOAuthCredential,
104
+ opts: { serverUrl?: string; auth?: MCPAuthConfig; signal?: AbortSignal } = {},
105
+ ): Promise<OAuthCredentials> {
106
+ const material = selectMcpOAuthRefreshMaterial(credential, opts.auth);
107
+ const tokenUrl = material?.tokenUrl;
108
+ if (!credential.refresh || !tokenUrl) {
109
+ throw new Error("MCP OAuth credential is missing refresh material");
110
+ }
111
+ const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
112
+ const resourceIsFallback = !material?.resource && Boolean(opts.serverUrl);
113
+ const resource = material?.resource ?? (resourceIsFallback ? opts.serverUrl : undefined);
114
+ return refreshMCPOAuthToken(tokenUrl, credential.refresh, material?.clientId, material?.clientSecret, resource, {
115
+ authorizationUrl,
116
+ stripSameOriginResource: resourceIsFallback,
117
+ signal: opts.signal,
118
+ });
119
+ }
120
+
83
121
  export async function removeManagedMcpOAuthCredential(
84
122
  authStorage: AuthStorage,
85
123
  credentialId: string | undefined,
@@ -53,6 +53,27 @@ export function mcpOAuthCredentialProfile(credentialId: string): string | undefi
53
53
  return separator === -1 ? undefined : credentialId.slice(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length, separator);
54
54
  }
55
55
 
56
+ /**
57
+ * Server URL embedded in a managed MCP OAuth credential id, or `undefined`
58
+ * for legacy random ids (`mcp_oauth_<rand>`) minted before URL-keyed ids.
59
+ *
60
+ * Inverse of {@link mcpOAuthCredentialId}. Mirrors {@link mcpOAuthCredentialProfile}:
61
+ * the URL contains `:` and `/`, so for profile-scoped ids the URL is everything
62
+ * after the profile segment; for legacy url-keyed ids (`mcp_oauth:<url>`) it is
63
+ * everything after the prefix. Lets the auth-broker — which never sees the MCP
64
+ * config — recover the server URL for the RFC 8707 fallback resource on refresh.
65
+ */
66
+ export function mcpOAuthServerUrlFromCredentialId(credentialId: string): string | undefined {
67
+ if (credentialId.startsWith(MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX)) {
68
+ const separator = credentialId.indexOf(":", MCP_OAUTH_PROFILE_CREDENTIAL_PREFIX.length);
69
+ return separator === -1 ? undefined : credentialId.slice(separator + 1) || undefined;
70
+ }
71
+ if (credentialId.startsWith(MCP_OAUTH_URL_CREDENTIAL_PREFIX)) {
72
+ return credentialId.slice(MCP_OAUTH_URL_CREDENTIAL_PREFIX.length) || undefined;
73
+ }
74
+ return undefined;
75
+ }
76
+
56
77
  /**
57
78
  * Stored MCP OAuth credential. Refresh material is embedded so token refresh
58
79
  * works without any `auth` block persisted in (possibly shared) config files.
@@ -4,7 +4,7 @@
4
4
  * Converts MCP tool definitions to CustomTool format for the agent.
5
5
  */
6
6
  import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
- import type { TSchema } from "@oh-my-pi/pi-ai";
7
+ import type { ImageContent, TextContent, TSchema } from "@oh-my-pi/pi-ai";
8
8
  import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
9
9
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
@@ -191,30 +191,40 @@ export interface MCPToolDetails {
191
191
  meta?: OutputMeta;
192
192
  }
193
193
  /**
194
- * Format MCP content for LLM consumption.
194
+ * Convert MCP content to agent content while retaining image payloads.
195
195
  */
196
- function formatMCPContent(content: MCPContent[]): string {
197
- const parts: string[] = [];
196
+ function formatMCPContent(content: MCPContent[]): Array<TextContent | ImageContent> {
197
+ const blocks: Array<TextContent | ImageContent> = [];
198
+ let text = "";
199
+ const flushText = () => {
200
+ if (!text) return;
201
+ blocks.push({ type: "text", text });
202
+ text = "";
203
+ };
204
+ const appendText = (value: string) => {
205
+ text += text ? `\n\n${value}` : value;
206
+ };
198
207
 
199
208
  for (const item of content) {
200
209
  switch (item.type) {
201
210
  case "text":
202
- parts.push(item.text);
211
+ appendText(item.text);
203
212
  break;
204
213
  case "image":
205
- parts.push(`[Image: ${item.mimeType}]`);
214
+ flushText();
215
+ blocks.push(item);
206
216
  break;
207
217
  case "resource":
208
- if (item.resource.text) {
209
- parts.push(`[Resource: ${item.resource.uri}]\n${item.resource.text}`);
210
- } else {
211
- parts.push(`[Resource: ${item.resource.uri}]`);
212
- }
218
+ appendText(
219
+ item.resource.text
220
+ ? `[Resource: ${item.resource.uri}]\n${item.resource.text}`
221
+ : `[Resource: ${item.resource.uri}]`,
222
+ );
213
223
  break;
214
224
  }
215
225
  }
216
-
217
- return parts.join("\n\n");
226
+ flushText();
227
+ return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
218
228
  }
219
229
 
220
230
  /** Build a CustomToolResult from a callTool response. */
@@ -225,7 +235,7 @@ function buildResult(
225
235
  provider?: string,
226
236
  providerName?: string,
227
237
  ): CustomToolResult<MCPToolDetails> {
228
- const text = formatMCPContent(result.content);
238
+ const content = formatMCPContent(result.content);
229
239
  const details: MCPToolDetails = {
230
240
  serverName,
231
241
  mcpToolName,
@@ -235,8 +245,14 @@ function buildResult(
235
245
  provider,
236
246
  providerName,
237
247
  };
238
- const contentText = result.isError ? `Error: ${text}` : text;
239
- const toolResult: CustomToolResult<MCPToolDetails> = { content: [{ type: "text", text: contentText }], details };
248
+ if (result.isError) {
249
+ if (content[0]?.type === "text") {
250
+ content[0] = { type: "text", text: `Error: ${content[0].text}` };
251
+ } else {
252
+ content.unshift({ type: "text", text: "Error:" });
253
+ }
254
+ }
255
+ const toolResult: CustomToolResult<MCPToolDetails> = { content, details };
240
256
  if (result.isError) {
241
257
  toolResult.isError = true;
242
258
  }
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { type ApiKeyResolver, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
4
4
  import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
5
5
  import type { Mnemopi } from "@oh-my-pi/pi-mnemopi";
6
+ import type { MnemopiLlmCompleteOptions } from "@oh-my-pi/pi-mnemopi/core/runtime-options";
6
7
  import type * as MnemopiDiagnoseNs from "@oh-my-pi/pi-mnemopi/diagnose";
7
8
  import type { DiagnosticSummary } from "@oh-my-pi/pi-mnemopi/diagnose";
8
9
  import { logger } from "@oh-my-pi/pi-utils";
@@ -62,6 +63,27 @@ const STATIC_INSTRUCTIONS = [
62
63
  "",
63
64
  ].join("\n");
64
65
 
66
+ /** Prompt turns for one Mnemopi completion. */
67
+ export interface MemoryCompletionInput {
68
+ prompt: string;
69
+ systemPrompt?: string;
70
+ }
71
+
72
+ /** Maps a Mnemopi completion into instruction and input turns.
73
+ *
74
+ * Extraction is the only task with its own instructions, and it always supplies
75
+ * the raw text, so the instructions become the system turn and the text becomes
76
+ * the user turn. Every other task keeps the prompt Mnemopi rendered. */
77
+ export function resolveMemoryCompletionInput(
78
+ prompt: string,
79
+ options?: MnemopiLlmCompleteOptions,
80
+ ): MemoryCompletionInput {
81
+ if (options?.task?.kind === "memory-extraction") {
82
+ return { prompt: options.task.input, systemPrompt: memoryExtractionPrompt };
83
+ }
84
+ return { prompt };
85
+ }
86
+
65
87
  async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise<MnemopiSessionState> {
66
88
  const state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
67
89
  const previous = setMnemopiSessionState(session, state);
@@ -506,8 +528,16 @@ async function resolveMnemopiProviderOptions(
506
528
  return {
507
529
  ...base,
508
530
  llm: {
509
- complete: (prompt, opts) => tinyModelClient.complete(memoryModel, prompt, { maxTokens: opts?.maxTokens }),
510
- extractionPrompt: memoryExtractionPrompt,
531
+ complete: (prompt, opts) => {
532
+ const request = resolveMemoryCompletionInput(prompt, opts);
533
+ return tinyModelClient.complete(memoryModel, request.prompt, {
534
+ maxTokens: opts?.maxTokens,
535
+ systemPrompt: request.systemPrompt,
536
+ });
537
+ },
538
+ // No `extractionPrompt`: resolveMemoryCompletionInput supplies the
539
+ // instructions as a system turn for every extraction call, so anything
540
+ // rendered here would be built in code and then discarded.
511
541
  consolidationPrompt: memoryConsolidationPrompt,
512
542
  },
513
543
  };
@@ -537,6 +567,7 @@ async function resolveMnemopiProviderOptions(
537
567
  return {
538
568
  ...base,
539
569
  llm: async (prompt, opts) => {
570
+ const request = resolveMemoryCompletionInput(prompt, opts);
540
571
  const hasApiKey = await modelRegistry.getApiKey(model, sessionId);
541
572
  if (!hasApiKey) {
542
573
  logger.warn("Mnemopi: smol completion requested but no current API key is available.", {
@@ -549,7 +580,8 @@ async function resolveMnemopiProviderOptions(
549
580
  completeSimple(
550
581
  model,
551
582
  {
552
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
583
+ ...(request.systemPrompt ? { systemPrompt: [request.systemPrompt] } : {}),
584
+ messages: [{ role: "user", content: request.prompt, timestamp: Date.now() }],
553
585
  },
554
586
  {
555
587
  apiKey: modelRegistry.resolver(model, sessionId),
@@ -207,6 +207,10 @@ export class ModelHubComponent implements Component {
207
207
  #rolesRows: RolesRow[] = [];
208
208
  #roleIndex = 0;
209
209
  #roleHover: number | null = null;
210
+ /** First roles row drawn in the scroll window; follows the cursor and clamps to the list. */
211
+ #roleScrollStart = 0;
212
+ /** Roles rows actually drawn this frame; bounds mouse hit-testing to the visible window. */
213
+ #rolesVisibleCount = 0;
210
214
 
211
215
  #assigning: AssignTarget | null = null;
212
216
  #strip: StripState | null = null;
@@ -1302,6 +1306,15 @@ export class ModelHubComponent implements Component {
1302
1306
  }
1303
1307
  }
1304
1308
 
1309
+ /** Scroll `#roleScrollStart` just enough to keep `#roleIndex` inside a window of `viewHeight` rows, clamped to the list. */
1310
+ #ensureRoleVisible(viewHeight: number, total: number): number {
1311
+ if (viewHeight <= 0) return 0;
1312
+ let start = this.#roleScrollStart;
1313
+ if (this.#roleIndex < start) start = this.#roleIndex;
1314
+ else if (this.#roleIndex >= start + viewHeight) start = this.#roleIndex - viewHeight + 1;
1315
+ return Math.max(0, Math.min(start, Math.max(0, total - viewHeight)));
1316
+ }
1317
+
1305
1318
  /** Step the roles cursor by one row, skipping separator rows. Wraps at the ends unless `wrap: false` (then the cursor stays put). */
1306
1319
  #stepRoleIndex(from: number, delta: -1 | 1, options: { wrap?: boolean } = {}): number {
1307
1320
  const wrap = options.wrap ?? true;
@@ -1473,7 +1486,8 @@ export class ModelHubComponent implements Component {
1473
1486
  this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null;
1474
1487
  if (overBody && entry.kind === "roles" && this.#assigning === null) {
1475
1488
  const roleLine = bodyLine - this.#rolesRowStart;
1476
- this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null;
1489
+ this.#roleHover =
1490
+ roleLine >= 0 && roleLine < this.#rolesVisibleCount ? roleLine + this.#roleScrollStart : null;
1477
1491
  } else {
1478
1492
  this.#roleHover = null;
1479
1493
  if (overBody && this.#isBrowserView(entry)) {
@@ -1508,8 +1522,9 @@ export class ModelHubComponent implements Component {
1508
1522
  if (overBody) {
1509
1523
  if (entry.kind === "roles" && this.#assigning === null) {
1510
1524
  this.#focus = "list";
1511
- const roleLine = bodyLine - this.#rolesRowStart;
1512
- if (roleLine >= 0 && roleLine < this.#rolesRowCount) {
1525
+ const listLine = bodyLine - this.#rolesRowStart;
1526
+ if (listLine >= 0 && listLine < this.#rolesVisibleCount) {
1527
+ const roleLine = listLine + this.#roleScrollStart;
1513
1528
  const rowDef = this.#rolesRows[roleLine];
1514
1529
  if (rowDef && rowDef.kind !== "separator") {
1515
1530
  if (roleLine === this.#roleIndex) {
@@ -1718,7 +1733,16 @@ export class ModelHubComponent implements Component {
1718
1733
 
1719
1734
  const cycleOrder = this.#cycleOrder();
1720
1735
  const listFocused = this.#focus === "list";
1721
- for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) {
1736
+ // Window the list around the cursor so entries past the panel height stay
1737
+ // reachable; the trailing indicator line steals one row when clipped.
1738
+ const total = this.#rolesRows.length;
1739
+ const capacity = Math.max(0, rows - 2 - this.#rolesRowStart);
1740
+ const overflow = total > capacity;
1741
+ const viewHeight = overflow ? Math.max(0, capacity - 1) : capacity;
1742
+ this.#roleScrollStart = this.#ensureRoleVisible(viewHeight, total);
1743
+ const endIndex = Math.min(this.#roleScrollStart + viewHeight, total);
1744
+ this.#rolesVisibleCount = Math.max(0, endIndex - this.#roleScrollStart);
1745
+ for (let i = this.#roleScrollStart; i < endIndex; i++) {
1722
1746
  const rowDef = this.#rolesRows[i];
1723
1747
  if (!rowDef) continue;
1724
1748
  const selected = i === this.#roleIndex;
@@ -1802,6 +1826,15 @@ export class ModelHubComponent implements Component {
1802
1826
  lines.push(line);
1803
1827
  }
1804
1828
 
1829
+ if (overflow) {
1830
+ const hiddenAbove = this.#roleScrollStart;
1831
+ const hiddenBelow = total - endIndex;
1832
+ const parts: string[] = [];
1833
+ if (hiddenAbove > 0) parts.push(`↑ ${hiddenAbove} more`);
1834
+ if (hiddenBelow > 0) parts.push(`↓ ${hiddenBelow} more`);
1835
+ lines.push(truncateToWidth(theme.fg("dim", ` ${parts.join(" ")}`), width));
1836
+ }
1837
+
1805
1838
  // Live preview of the quick-switch cycle, rendered with the exact
1806
1839
  // segment track the ctrl+p status uses; the selected role's chip fills.
1807
1840
  while (lines.length < rows - 1) lines.push("");
@@ -1168,15 +1168,15 @@ export class SettingsSelectorComponent implements Component {
1168
1168
  return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
1169
1169
  }
1170
1170
 
1171
- #createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
1172
- let options = def.options;
1173
- if (def.path === "providers.webSearchOrder") {
1174
- const excluded: unknown = settings.get("providers.webSearchExclude");
1175
- if (Array.isArray(excluded)) {
1176
- options = options.filter(option => !excluded.includes(option.value));
1177
- }
1178
- }
1171
+ #getMultiSelectOptions(def: SettingDef & { type: "multiselect" }) {
1172
+ if (def.path !== "providers.webSearchOrder") return def.options;
1173
+ const excluded: unknown = settings.get("providers.webSearchExclude");
1174
+ if (!Array.isArray(excluded)) return def.options;
1175
+ return def.options.filter(option => !excluded.includes(option.value));
1176
+ }
1179
1177
 
1178
+ #createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
1179
+ const options = this.#getMultiSelectOptions(def);
1180
1180
  const current: unknown = settings.get(def.path);
1181
1181
  const initial = Array.isArray(current)
1182
1182
  ? current.filter((entry): entry is string => typeof entry === "string")
@@ -1196,9 +1196,15 @@ export class SettingsSelectorComponent implements Component {
1196
1196
  }
1197
1197
 
1198
1198
  #formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string {
1199
- const ids = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
1200
- if (ids.length === 0) return def.ordered ? "default" : "none";
1201
- const labels = ids.map(id => def.options.find(option => option.value === id)?.label ?? id);
1199
+ const options = this.#getMultiSelectOptions(def);
1200
+ const labels = Array.isArray(value)
1201
+ ? value.flatMap(entry => {
1202
+ if (typeof entry !== "string") return [];
1203
+ const option = options.find(candidate => candidate.value === entry);
1204
+ return option ? [option.label] : [];
1205
+ })
1206
+ : [];
1207
+ if (labels.length === 0) return def.ordered ? "default" : "none";
1202
1208
  return def.ordered ? labels.join(" → ") : labels.join(", ");
1203
1209
  }
1204
1210
 
@@ -14,7 +14,7 @@ import {
14
14
  Text,
15
15
  type TUI,
16
16
  } from "@oh-my-pi/pi-tui";
17
- import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
+ import { getProjectDir, isRecord, logger, sanitizeText } from "@oh-my-pi/pi-utils";
18
18
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
19
19
  import type { Theme } from "../../modes/theme/theme";
20
20
  import { getThemeEpoch, theme } from "../../modes/theme/theme";
@@ -73,6 +73,21 @@ function isTodoToolDetails(details: unknown): details is TodoToolDetails {
73
73
  );
74
74
  }
75
75
 
76
+ interface ToolImageBlock {
77
+ data?: string;
78
+ mimeType?: string;
79
+ }
80
+
81
+ function imageBlocksFromDetails(details: unknown): ToolImageBlock[] {
82
+ if (!isRecord(details) || !Array.isArray(details.images)) return [];
83
+ return details.images.filter(
84
+ (image): image is ToolImageBlock =>
85
+ isRecord(image) &&
86
+ (image.data === undefined || typeof image.data === "string") &&
87
+ (image.mimeType === undefined || typeof image.mimeType === "string"),
88
+ );
89
+ }
90
+
76
91
  function displaceableToolName(
77
92
  toolName: string,
78
93
  result: { details?: unknown; isError?: boolean },
@@ -269,6 +284,52 @@ export function sharedSpinnerFrame(frameCount: number, now: number = performance
269
284
  return frameCount > 0 ? Math.floor(now / SPINNER_GLYPH_ADVANCE_MS) % frameCount : 0;
270
285
  }
271
286
 
287
+ /** Live tool blocks currently driving a spinner. A single shared ticker (below)
288
+ * advances and repaints every registered block per glyph step, so N concurrent
289
+ * live/streaming blocks — e.g. parallel `task` subagents — cost one 80ms timer
290
+ * and one coalesced render frame per tick instead of N unsynchronized timers
291
+ * each independently waking the render scheduler (issue #8731). */
292
+ const liveSpinnerBlocks = new Set<ToolExecutionComponent>();
293
+ let sharedSpinnerTimer: NodeJS.Timeout | undefined;
294
+
295
+ /** Arm the shared spinner ticker if it is not already running. */
296
+ function ensureSharedSpinnerTicker(): void {
297
+ if (sharedSpinnerTimer) return;
298
+ sharedSpinnerTimer = setInterval(() => {
299
+ const frame = sharedSpinnerFrame(theme.spinnerFrames.length);
300
+ // Deleting the current block mid-iteration (freeze path) is safe on a Set.
301
+ for (const block of liveSpinnerBlocks) block.tickSpinner(frame);
302
+ }, SPINNER_RENDER_INTERVAL_MS);
303
+ }
304
+
305
+ /** Register a live block with the shared ticker, starting it on first use. */
306
+ function registerSpinnerBlock(block: ToolExecutionComponent): void {
307
+ liveSpinnerBlocks.add(block);
308
+ ensureSharedSpinnerTicker();
309
+ }
310
+
311
+ /** Drop a block; stop the ticker once no live block remains. */
312
+ function unregisterSpinnerBlock(block: ToolExecutionComponent): void {
313
+ if (!liveSpinnerBlocks.delete(block)) return;
314
+ if (liveSpinnerBlocks.size === 0 && sharedSpinnerTimer) {
315
+ clearInterval(sharedSpinnerTimer);
316
+ sharedSpinnerTimer = undefined;
317
+ }
318
+ }
319
+
320
+ /** Stop the shared spinner ticker and drop every registered live block.
321
+ * Called on interactive-mode teardown so a stray live block cannot keep the
322
+ * process-wide 80ms interval alive past shutdown (lingering event-loop
323
+ * handles pin the process; cf. `postmortem.quit`). Test files that assert on
324
+ * ticker arming also use this to start from a clean slate. */
325
+ export function stopSharedSpinnerTicker(): void {
326
+ liveSpinnerBlocks.clear();
327
+ if (sharedSpinnerTimer) {
328
+ clearInterval(sharedSpinnerTimer);
329
+ sharedSpinnerTimer = undefined;
330
+ }
331
+ }
332
+
272
333
  // Stable per-instance counter so each tool execution's inline images get a
273
334
  // graphics id that survives child re-creation (the image budget keys off it).
274
335
  let toolExecutionInstanceSeq = 0;
@@ -337,7 +398,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
337
398
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
338
399
  // Spinner animation for partial task results
339
400
  #spinnerFrame?: number;
340
- #spinnerInterval?: NodeJS.Timeout;
401
+ #spinnerActive = false;
341
402
  // Todo write completion strikethrough reveal animation
342
403
  #todoStrikeInterval?: NodeJS.Timeout;
343
404
  // Track if args are still being streamed (for edit/write spinner)
@@ -617,14 +678,18 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
617
678
  }
618
679
 
619
680
  /**
620
- * Get all image blocks from result content and details.images.
621
- * Some tools (like generate_image) store images in details to avoid bloating model context.
681
+ * Get all image blocks from result content and details.
682
+ * Some tools (like generate_image) store images in details to avoid bloating
683
+ * model context. Xdev-dispatched tools preserve those details under
684
+ * details.xdev.inner.
622
685
  */
623
- #getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> {
686
+ #getAllImageBlocks(): ToolImageBlock[] {
624
687
  if (!this.#result) return [];
625
- const contentImages = this.#result.content?.filter((c: any) => c.type === "image") || [];
626
- const detailImages = this.#result.details?.images || [];
627
- return [...contentImages, ...detailImages];
688
+ const contentImages = this.#result.content.filter(block => block.type === "image");
689
+ const details = this.#result.details;
690
+ const detailImages = imageBlocksFromDetails(details);
691
+ const xdevImages = isRecord(details) && isRecord(details.xdev) ? imageBlocksFromDetails(details.xdev.inner) : [];
692
+ return [...contentImages, ...detailImages, ...xdevImages];
628
693
  }
629
694
 
630
695
  /**
@@ -697,27 +762,16 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
697
762
  !isBackgroundAsyncRunning &&
698
763
  (pendingCallConsumesSpinner || partialResultConsumesSpinner);
699
764
  const needsSpinner = isStreamingArgs || isLivePartialTool || this.#displaceableByToolName === "hub";
700
- if (needsSpinner && !this.#spinnerInterval) {
765
+ if (needsSpinner && !this.#spinnerActive) {
701
766
  const frameCount = theme.spinnerFrames.length;
702
767
  const frame = sharedSpinnerFrame(frameCount);
703
768
  this.#spinnerFrame = frame;
704
769
  this.#renderState.spinnerFrame = frame;
705
- this.#spinnerInterval = setInterval(() => {
706
- // If a detached task interval from an older render path is still live,
707
- // stop it the instant the block leaves the repaintable region.
708
- if (this.#maybeFreezeBackgroundTask()) return;
709
- const now = performance.now();
710
- const frameCount = theme.spinnerFrames.length;
711
- this.#spinnerFrame = sharedSpinnerFrame(frameCount, now);
712
- this.#renderState.spinnerFrame = this.#spinnerFrame;
713
- // Component-scoped: a spinner tick only changes this tool block, so
714
- // the TUI reuses every other root subtree instead of walking the
715
- // whole tree (issue #4377).
716
- this.#ui.requestComponentRender(this);
717
- }, SPINNER_RENDER_INTERVAL_MS);
718
- } else if (!needsSpinner && this.#spinnerInterval) {
719
- clearInterval(this.#spinnerInterval);
720
- this.#spinnerInterval = undefined;
770
+ this.#spinnerActive = true;
771
+ registerSpinnerBlock(this);
772
+ } else if (!needsSpinner && this.#spinnerActive) {
773
+ this.#spinnerActive = false;
774
+ unregisterSpinnerBlock(this);
721
775
  // Clear the last drawn frame so a non-live renderCall (e.g. a write whose
722
776
  // args just completed) stops showing a frozen spinner glyph. Skip when a
723
777
  // todo strike owns the frame — it sets its own value right after this.
@@ -728,6 +782,20 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
728
782
  }
729
783
  }
730
784
 
785
+ /**
786
+ * Advance to the shared spinner glyph and repaint just this block. Driven by
787
+ * the single shared spinner ticker (see `registerSpinnerBlock`); the tick is
788
+ * component-scoped so the TUI reuses every other root subtree (issue #4377).
789
+ */
790
+ tickSpinner(frame: number): void {
791
+ // A detached task block that scrolled into native scrollback stops the
792
+ // instant it leaves the repaintable region.
793
+ if (this.#maybeFreezeBackgroundTask()) return;
794
+ this.#spinnerFrame = frame;
795
+ this.#renderState.spinnerFrame = frame;
796
+ this.#ui.requestComponentRender(this);
797
+ }
798
+
731
799
  /**
732
800
  * Freeze a detached (`async.state === "running"`) task block once its rows
733
801
  * become native-scrollback history: the block left the transcript's live
@@ -790,7 +858,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
790
858
  clearInterval(this.#todoStrikeInterval);
791
859
  this.#todoStrikeInterval = undefined;
792
860
  }
793
- if (!this.#spinnerInterval) {
861
+ if (!this.#spinnerActive) {
794
862
  this.#spinnerFrame = undefined;
795
863
  this.#renderState.spinnerFrame = undefined;
796
864
  }
@@ -886,9 +954,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
886
954
  * Stop spinner animation and cleanup resources.
887
955
  */
888
956
  stopAnimation(): void {
889
- if (this.#spinnerInterval) {
890
- clearInterval(this.#spinnerInterval);
891
- this.#spinnerInterval = undefined;
957
+ if (this.#spinnerActive) {
958
+ this.#spinnerActive = false;
959
+ unregisterSpinnerBlock(this);
892
960
  this.#spinnerFrame = undefined;
893
961
  this.#renderState.spinnerFrame = undefined;
894
962
  }
@@ -849,13 +849,18 @@ class TreeList implements Component {
849
849
  this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines);
850
850
  } else if (matchesSelectPageDown(keyData) || matchesKey(keyData, "right")) {
851
851
  this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines);
852
- } else if (matchesKey(keyData, "shift+enter") || matchesKey(keyData, "shift+return")) {
852
+ } else if (
853
+ matchesKey(keyData, "shift+enter") ||
854
+ matchesKey(keyData, "shift+return") ||
855
+ keyData === "\n" || // Shift+Enter delivered as bare LF (iTerm2 legacy mapping) — matches the composer (issue #8821)
856
+ keyData === "\x1b[13;2~" // Shift+Enter legacy CSI ~ form — also accepted by the composer (editor.ts:1466)
857
+ ) {
853
858
  // Summarize-and-switch: fork with a branch summary without the extra prompt.
854
859
  const selected = this.#filteredNodes[this.#selectedIndex];
855
860
  if (selected && this.onSelect) {
856
861
  this.onSelect(selected.node.entry.id, { summarize: true });
857
862
  }
858
- } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
863
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
859
864
  const selected = this.#filteredNodes[this.#selectedIndex];
860
865
  if (selected && this.onSelect) {
861
866
  this.onSelect(selected.node.entry.id, { summarize: false });
@@ -24,7 +24,7 @@ import { getSymbolTheme, theme } from "../../modes/theme/theme";
24
24
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
25
25
  import idleRecapPrompt from "../../prompts/system/recap-user.md" with { type: "text" };
26
26
  import type { AgentSessionEvent } from "../../session/agent-session";
27
- import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
27
+ import { isSilentAbort, isUserInvokedSkillPrompt, readQueueChipText, resolveAbortLabel } from "../../session/messages";
28
28
  import { type ApprovalMode, resolveApproval } from "../../tools/approval";
29
29
  import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils";
30
30
  import { PROPOSE_DEVICE_NAME, writeDeviceDispatch } from "../../tools/resolve";
@@ -778,7 +778,17 @@ export class EventController {
778
778
  }
779
779
  this.#renderedCustomMessages.add(signature);
780
780
  this.#resetReadGroup();
781
- this.ctx.addMessageToChat(event.message);
781
+ if (
782
+ event.message.role === "custom" &&
783
+ this.ctx.optimisticSkillMessagePending &&
784
+ isUserInvokedSkillPrompt(event.message)
785
+ ) {
786
+ // The optimistic `/skill:` row painted at submit time (issue #8895):
787
+ // swap it for the canonical message instead of appending a duplicate.
788
+ this.ctx.reconcileOptimisticSkillMessage(event.message);
789
+ } else {
790
+ this.ctx.addMessageToChat(event.message);
791
+ }
782
792
  // Queued custom-message chips are derived from the agent queue; refresh the
783
793
  // pending bar when the queued custom is consumed so the chip disappears
784
794
  // immediately.