@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.
- package/CHANGELOG.md +80 -0
- package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
- package/dist/cli.js +2993 -3001
- package/dist/docs-index.generated.txt +1 -1
- package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
- package/dist/types/advisor/advise-tool.d.ts +4 -2
- package/dist/types/cli/auth-broker-cli.d.ts +15 -0
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/cli/update-cli.d.ts +8 -0
- package/dist/types/cli-commands.d.ts +10 -2
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +28 -0
- package/dist/types/config/settings.d.ts +9 -0
- package/dist/types/extensibility/extensions/runner.d.ts +23 -4
- package/dist/types/extensibility/extensions/types.d.ts +58 -0
- package/dist/types/launch/presence.d.ts +4 -1
- package/dist/types/mcp/oauth-credentials.d.ts +23 -0
- package/dist/types/mcp/oauth-flow.d.ts +11 -0
- package/dist/types/mnemopi/backend.d.ts +12 -0
- package/dist/types/modes/components/tool-execution.d.ts +12 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +25 -3
- package/dist/types/modes/types.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +3 -0
- package/dist/types/session/prewalk.d.ts +4 -0
- package/dist/types/session/session-entries.d.ts +0 -1
- package/dist/types/session/session-manager.d.ts +12 -0
- package/dist/types/session/session-stats.d.ts +13 -1
- package/dist/types/session/skill-title-input.d.ts +13 -0
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/subprocess/worker-client.d.ts +7 -4
- package/dist/types/task/label.d.ts +2 -0
- package/dist/types/task/render.d.ts +2 -0
- package/dist/types/tiny/completion-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +6 -4
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tiny/worker.d.ts +27 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/dist/types/tools/read-format.d.ts +6 -0
- package/dist/types/tools/read-summary.d.ts +7 -1
- package/dist/types/utils/block-context.d.ts +14 -0
- package/dist/types/utils/fetch-timeout.d.ts +15 -0
- package/dist/types/utils/git.d.ts +25 -1
- package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
- package/package.json +13 -13
- package/src/advisor/advise-tool.ts +5 -3
- package/src/cli/auth-broker-cli.ts +36 -1
- package/src/cli/profile-bootstrap.ts +2 -6
- package/src/cli/stats-cli.ts +6 -72
- package/src/cli/update-cli.ts +63 -11
- package/src/cli-commands.ts +61 -7
- package/src/commands/completions.ts +2 -1
- package/src/commands/stats.ts +7 -4
- package/src/commit/agentic/index.ts +15 -2
- package/src/commit/git/diff.ts +6 -2
- package/src/config/model-resolver.ts +52 -6
- package/src/config/models-config.ts +2 -2
- package/src/config/settings-schema.ts +33 -0
- package/src/config/settings.ts +159 -30
- package/src/discovery/helpers.ts +45 -2
- package/src/discovery/omp-plugins.ts +2 -1
- package/src/discovery/opencode.ts +56 -3
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/eval/js/process-entry.ts +4 -4
- package/src/export/html/tool-views.generated.js +19 -19
- package/src/extensibility/extensions/loader.ts +11 -0
- package/src/extensibility/extensions/runner.ts +118 -5
- package/src/extensibility/extensions/types.ts +60 -0
- package/src/extensibility/extensions/wrapper.ts +10 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
- package/src/launch/client.ts +9 -4
- package/src/launch/presence.ts +19 -4
- package/src/lsp/defaults.json +1 -1
- package/src/lsp/writethrough.ts +20 -10
- package/src/mcp/manager.ts +41 -20
- package/src/mcp/oauth-credentials.ts +38 -0
- package/src/mcp/oauth-flow.ts +21 -0
- package/src/mcp/tool-bridge.ts +32 -16
- package/src/mnemopi/backend.ts +35 -3
- package/src/modes/components/model-hub.ts +37 -4
- package/src/modes/components/settings-selector.ts +17 -11
- package/src/modes/components/tool-execution.ts +97 -29
- package/src/modes/components/tree-selector.ts +7 -2
- package/src/modes/controllers/event-controller.ts +12 -2
- package/src/modes/controllers/input-controller.ts +64 -27
- package/src/modes/controllers/mcp-command-controller.ts +13 -4
- package/src/modes/interactive-mode.ts +79 -11
- package/src/modes/types.ts +11 -0
- package/src/prompts/system/memory-extraction-system.md +5 -22
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/session/agent-session.ts +52 -6
- package/src/session/messages.ts +6 -0
- package/src/session/prewalk.ts +25 -7
- package/src/session/session-entries.ts +0 -1
- package/src/session/session-maintenance.ts +10 -1
- package/src/session/session-manager.ts +15 -0
- package/src/session/session-stats.ts +24 -3
- package/src/session/settings-stream-fn.ts +7 -0
- package/src/session/skill-title-input.ts +32 -0
- package/src/session/turn-recovery.ts +23 -18
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/subprocess/worker-client.ts +8 -5
- package/src/task/executor.ts +11 -0
- package/src/task/index.ts +2 -0
- package/src/task/label.ts +14 -1
- package/src/task/persisted-revive.ts +13 -0
- package/src/task/render.ts +1 -1
- package/src/task/structured-subagent.ts +5 -2
- package/src/tiny/completion-prompt.ts +16 -0
- package/src/tiny/title-client.ts +15 -6
- package/src/tiny/title-protocol.ts +8 -1
- package/src/tiny/worker.ts +21 -19
- package/src/tools/bash.ts +7 -1
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
- package/src/tools/read-format.ts +16 -2
- package/src/tools/read-summary.ts +9 -4
- package/src/tools/read.ts +306 -72
- package/src/utils/block-context.ts +15 -1
- package/src/utils/fetch-timeout.ts +33 -0
- package/src/utils/git.ts +54 -11
- package/src/web/search/providers/browser-page.ts +21 -3
- 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,
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -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.
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -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
|
-
*
|
|
194
|
+
* Convert MCP content to agent content while retaining image payloads.
|
|
195
195
|
*/
|
|
196
|
-
function formatMCPContent(content: MCPContent[]):
|
|
197
|
-
const
|
|
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
|
-
|
|
211
|
+
appendText(item.text);
|
|
203
212
|
break;
|
|
204
213
|
case "image":
|
|
205
|
-
|
|
214
|
+
flushText();
|
|
215
|
+
blocks.push(item);
|
|
206
216
|
break;
|
|
207
217
|
case "resource":
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
}
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -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) =>
|
|
510
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1512
|
-
if (
|
|
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
|
-
|
|
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
|
-
#
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
#
|
|
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.
|
|
621
|
-
* Some tools (like generate_image) store images in details to avoid bloating
|
|
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():
|
|
686
|
+
#getAllImageBlocks(): ToolImageBlock[] {
|
|
624
687
|
if (!this.#result) return [];
|
|
625
|
-
const contentImages = this.#result.content
|
|
626
|
-
const
|
|
627
|
-
|
|
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.#
|
|
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.#
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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.#
|
|
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.#
|
|
890
|
-
|
|
891
|
-
this
|
|
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 (
|
|
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")
|
|
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
|
-
|
|
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.
|