@oh-my-pi/pi-coding-agent 16.1.21 → 16.1.23
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 +36 -1
- package/dist/cli.js +3659 -3517
- package/dist/types/cli/gc-cli.d.ts +58 -0
- package/dist/types/commands/gc.d.ts +37 -0
- package/dist/types/config/settings-schema.d.ts +54 -0
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/mcp/transports/stdio.d.ts +25 -1
- package/dist/types/mcp/transports/stdio.test.d.ts +1 -0
- package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/session/session-listing.d.ts +10 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tools/plan-mode-guard.d.ts +7 -0
- package/package.json +12 -12
- package/scripts/bench-guard.ts +1 -1
- package/src/cli/gc-cli.ts +939 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/gc.ts +46 -0
- package/src/config/settings-schema.ts +45 -0
- package/src/config/settings.ts +44 -6
- package/src/edit/hashline/filesystem.ts +11 -6
- package/src/eval/__tests__/julia-prelude.test.ts +18 -0
- package/src/eval/jl/runner.jl +7 -1
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +6 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/lsp/index.ts +8 -1
- package/src/mcp/oauth-discovery.ts +62 -15
- package/src/mcp/transports/stdio.test.ts +45 -0
- package/src/mcp/transports/stdio.ts +46 -12
- package/src/modes/controllers/input-controller.ts +2 -2
- package/src/modes/controllers/selector-controller.ts +10 -0
- package/src/modes/interactive-mode.ts +26 -9
- package/src/modes/theme/mermaid-rendering.test.ts +53 -0
- package/src/modes/theme/theme.ts +33 -14
- package/src/prompts/system/plan-mode-active.md +9 -0
- package/src/prompts/system/system-prompt.md +2 -0
- package/src/sdk.ts +43 -4
- package/src/session/agent-session.ts +323 -62
- package/src/session/session-listing.ts +35 -2
- package/src/slash-commands/builtin-registry.ts +20 -2
- package/src/system-prompt.ts +4 -0
- package/src/tools/acp-bridge.ts +6 -1
- package/src/tools/plan-mode-guard.ts +26 -13
- package/src/utils/edit-mode.ts +19 -2
- package/src/utils/shell-snapshot.ts +1 -1
package/src/lsp/index.ts
CHANGED
|
@@ -2054,7 +2054,14 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
|
|
2054
2054
|
}
|
|
2055
2055
|
|
|
2056
2056
|
if (action === "reload" && (isWorkspace || !resolvedFile)) {
|
|
2057
|
-
|
|
2057
|
+
// `reload *` is the user's explicit request to re-read config from
|
|
2058
|
+
// disk. Drop the per-cwd cache entry so `.omp/lsp.json`, root markers,
|
|
2059
|
+
// and plugin configs added after the first LSP call become visible —
|
|
2060
|
+
// otherwise `getConfig` returns the first observation for the rest of
|
|
2061
|
+
// the process lifetime (#3546).
|
|
2062
|
+
configCache.delete(this.session.cwd);
|
|
2063
|
+
const refreshedConfig = getConfig(this.session.cwd);
|
|
2064
|
+
const servers = getLspServers(refreshedConfig);
|
|
2058
2065
|
if (servers.length === 0) {
|
|
2059
2066
|
return {
|
|
2060
2067
|
content: [{ type: "text", text: "No language server found for this action" }],
|
|
@@ -250,6 +250,45 @@ export function analyzeAuthError(error: Error, serverUrl?: string): AuthDetectio
|
|
|
250
250
|
};
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Normalize an OAuth issuer URL for RFC 8414 §3.3 comparison: lowercase
|
|
255
|
+
* scheme/host (URL parser already does this), drop fragment/query, strip a
|
|
256
|
+
* trailing slash on the path. The path is otherwise case-sensitive.
|
|
257
|
+
*/
|
|
258
|
+
function normalizeIssuerUrl(value: string): string | undefined {
|
|
259
|
+
try {
|
|
260
|
+
const u = new URL(value);
|
|
261
|
+
const path = u.pathname.replace(/\/+$/, "");
|
|
262
|
+
return `${u.protocol}//${u.host}${path}`;
|
|
263
|
+
} catch {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* RFC 8414 §3.3: an authorization-server metadata document's `issuer` MUST
|
|
270
|
+
* equal the URL the client used to construct the metadata URL. When a server
|
|
271
|
+
* hosts metadata for several issuers under one origin (Plane serves a root
|
|
272
|
+
* issuer `https://mcp.plane.so/` at `/.well-known/oauth-authorization-server`
|
|
273
|
+
* *and* a path-scoped issuer `https://mcp.plane.so/http` at the path-prefixed
|
|
274
|
+
* well-known URL), accepting the first hit silently routes the grant to the
|
|
275
|
+
* wrong `/authorize` endpoint and produces opaque `server_error` redirects.
|
|
276
|
+
*
|
|
277
|
+
* Returns true when the metadata is safe to use:
|
|
278
|
+
* - the document has no `issuer` field (nonstandard / legacy servers — keep
|
|
279
|
+
* today's permissive behavior), or
|
|
280
|
+
* - the issuer matches `baseUrl` after trailing-slash normalization.
|
|
281
|
+
*/
|
|
282
|
+
function issuerMatchesBase(metadataIssuer: unknown, baseUrl: string): boolean {
|
|
283
|
+
if (typeof metadataIssuer !== "string" || !metadataIssuer.trim()) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
const normalizedIssuer = normalizeIssuerUrl(metadataIssuer);
|
|
287
|
+
const normalizedBase = normalizeIssuerUrl(baseUrl);
|
|
288
|
+
if (!normalizedIssuer || !normalizedBase) return true;
|
|
289
|
+
return normalizedIssuer === normalizedBase;
|
|
290
|
+
}
|
|
291
|
+
|
|
253
292
|
/**
|
|
254
293
|
* Try to discover OAuth endpoints by querying the server's well-known endpoints.
|
|
255
294
|
* This is a fallback when error responses don't include OAuth metadata.
|
|
@@ -269,10 +308,15 @@ export async function discoverOAuthEndpoints(
|
|
|
269
308
|
"/.mcp/auth",
|
|
270
309
|
"/authorize", // Some MCP servers expose OAuth config here
|
|
271
310
|
];
|
|
272
|
-
const urlsToQuery: string
|
|
311
|
+
const urlsToQuery: Array<{ url: string; issuerCandidate: boolean }> = [];
|
|
273
312
|
const visitedAuthServers = new Set<string>();
|
|
274
313
|
|
|
275
314
|
let protectedResource = opts?.protectedResource;
|
|
315
|
+
const addDiscoveryBase = (url: string | undefined, issuerCandidate: boolean): void => {
|
|
316
|
+
if (!url || visitedAuthServers.has(url)) return;
|
|
317
|
+
urlsToQuery.push({ url, issuerCandidate });
|
|
318
|
+
visitedAuthServers.add(url);
|
|
319
|
+
};
|
|
276
320
|
|
|
277
321
|
// Step 1: If a resource_metadata URL was provided, fetch it to discover auth servers.
|
|
278
322
|
// This follows the RFC 9728 chain: resource_metadata → authorization_servers.
|
|
@@ -293,10 +337,7 @@ export async function discoverOAuthEndpoints(
|
|
|
293
337
|
? meta.authorization_servers.filter((entry): entry is string => typeof entry === "string")
|
|
294
338
|
: [];
|
|
295
339
|
for (const s of authServers) {
|
|
296
|
-
|
|
297
|
-
urlsToQuery.push(s);
|
|
298
|
-
visitedAuthServers.add(s);
|
|
299
|
-
}
|
|
340
|
+
addDiscoveryBase(s, true);
|
|
300
341
|
}
|
|
301
342
|
}
|
|
302
343
|
} catch {
|
|
@@ -304,13 +345,9 @@ export async function discoverOAuthEndpoints(
|
|
|
304
345
|
}
|
|
305
346
|
}
|
|
306
347
|
|
|
307
|
-
// Step 2: Add explicit authServerUrl
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
urlsToQuery.push(url);
|
|
311
|
-
visitedAuthServers.add(url);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
348
|
+
// Step 2: Add explicit authServerUrl as an issuer candidate, then the resource server fallback.
|
|
349
|
+
addDiscoveryBase(authServerUrl, true);
|
|
350
|
+
addDiscoveryBase(serverUrl, false);
|
|
314
351
|
|
|
315
352
|
const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
|
|
316
353
|
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
@@ -375,10 +412,10 @@ export async function discoverOAuthEndpoints(
|
|
|
375
412
|
return null;
|
|
376
413
|
};
|
|
377
414
|
|
|
378
|
-
for (const
|
|
415
|
+
for (const base of urlsToQuery) {
|
|
379
416
|
for (const path of wellKnownPaths) {
|
|
380
417
|
// Try each well-known path at both the absolute origin and relative
|
|
381
|
-
const urlsToTry = buildWellKnownUrls(path,
|
|
418
|
+
const urlsToTry = buildWellKnownUrls(path, base.url);
|
|
382
419
|
for (const url of urlsToTry) {
|
|
383
420
|
try {
|
|
384
421
|
const response = await fetchImpl(url.toString(), {
|
|
@@ -389,7 +426,17 @@ export async function discoverOAuthEndpoints(
|
|
|
389
426
|
|
|
390
427
|
if (response.ok) {
|
|
391
428
|
const metadata = (await response.json()) as Record<string, unknown>;
|
|
392
|
-
|
|
429
|
+
// Authorization-server / OpenID Connect metadata documents carry an
|
|
430
|
+
// `issuer` field that MUST equal the queried base URL only when that
|
|
431
|
+
// URL came from an auth-server source (RFC 8414 §3.3, OIDC Discovery
|
|
432
|
+
// §4.3). Resource-server fallback probes can legitimately return
|
|
433
|
+
// cross-host issuer metadata.
|
|
434
|
+
const requireIssuerMatch =
|
|
435
|
+
base.issuerCandidate &&
|
|
436
|
+
(path === "/.well-known/oauth-authorization-server" ||
|
|
437
|
+
path === "/.well-known/openid-configuration");
|
|
438
|
+
const issuerOk = requireIssuerMatch ? issuerMatchesBase(metadata.issuer, base.url) : true;
|
|
439
|
+
const endpoints = issuerOk ? findEndpoints(metadata) : null;
|
|
393
440
|
if (endpoints) return endpoints;
|
|
394
441
|
|
|
395
442
|
if (path === "/.well-known/oauth-protected-resource") {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { resolveStdioSpawnCommand } from "./stdio";
|
|
4
|
+
|
|
5
|
+
describe("resolveStdioSpawnCommand", () => {
|
|
6
|
+
it("hides Windows executable MCP servers when the host has no console", async () => {
|
|
7
|
+
// Hidden so a console-app child does not allocate a visible window when
|
|
8
|
+
// OMP is launched without a terminal console (#3536).
|
|
9
|
+
await expect(
|
|
10
|
+
resolveStdioSpawnCommand(
|
|
11
|
+
{ command: "server.exe", args: ["--stdio"] },
|
|
12
|
+
{ cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: false },
|
|
13
|
+
),
|
|
14
|
+
).resolves.toEqual({
|
|
15
|
+
cmd: ["server.exe", "--stdio"],
|
|
16
|
+
windowsHide: true,
|
|
17
|
+
detached: false,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("inherits an attached Windows console instead of forcing CREATE_NO_WINDOW", async () => {
|
|
22
|
+
await expect(
|
|
23
|
+
resolveStdioSpawnCommand(
|
|
24
|
+
{ command: "server.exe", args: ["--stdio"] },
|
|
25
|
+
{ cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: true },
|
|
26
|
+
),
|
|
27
|
+
).resolves.toEqual({
|
|
28
|
+
cmd: ["server.exe", "--stdio"],
|
|
29
|
+
windowsHide: false,
|
|
30
|
+
detached: false,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("detaches off-Windows MCP servers so terminal job-control signals cannot stop them", async () => {
|
|
35
|
+
await expect(
|
|
36
|
+
resolveStdioSpawnCommand(
|
|
37
|
+
{ command: "server.exe", args: ["--stdio"] },
|
|
38
|
+
{ cwd: process.cwd(), env: {}, platform: "linux" },
|
|
39
|
+
),
|
|
40
|
+
).resolves.toEqual({
|
|
41
|
+
cmd: ["server.exe", "--stdio"],
|
|
42
|
+
detached: true,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import * as fs from "node:fs/promises";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
|
|
11
10
|
import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
|
12
11
|
import { type Subprocess, spawn } from "bun";
|
|
12
|
+
import { hostHasInheritableConsole } from "../../eval/py/spawn-options";
|
|
13
13
|
import type {
|
|
14
14
|
JsonRpcError,
|
|
15
15
|
JsonRpcMessage,
|
|
@@ -22,16 +22,40 @@ import type {
|
|
|
22
22
|
import { toJsonRpcError } from "../../mcp/types";
|
|
23
23
|
import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
|
|
24
24
|
|
|
25
|
-
/** Subprocess argv for
|
|
25
|
+
/** Subprocess argv and platform-derived spawn flags for an MCP stdio server. */
|
|
26
26
|
export interface StdioSpawnCommand {
|
|
27
27
|
cmd: string[];
|
|
28
|
+
/**
|
|
29
|
+
* Hide the Windows console window for the direct child.
|
|
30
|
+
*
|
|
31
|
+
* Windows uses this only when the OMP host has no console to share. When
|
|
32
|
+
* the host is running inside a terminal, `windowsHide: true` maps to
|
|
33
|
+
* `CREATE_NO_WINDOW`, which strips that inheritable console from hidden
|
|
34
|
+
* `cmd.exe` / PowerShell wrapper chains. Their console grandchildren then
|
|
35
|
+
* allocate fresh visible conhost windows during startup or reconnects
|
|
36
|
+
* (#3567).
|
|
37
|
+
*/
|
|
28
38
|
windowsHide?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Run the subprocess in its own session.
|
|
41
|
+
*
|
|
42
|
+
* POSIX: `true`. Detach → `setsid`, so the MCP process tree has no
|
|
43
|
+
* controlling terminal and terminal job-control signals (Ctrl+Z SIGTSTP,
|
|
44
|
+
* background-read SIGTTIN) cannot stop stdio servers such as
|
|
45
|
+
* `chrome-devtools-mcp` and leave our read loop blocked on silent pipes.
|
|
46
|
+
*
|
|
47
|
+
* Windows: `false`. There is no SIGTSTP/SIGTTIN to escape, and Windows
|
|
48
|
+
* wrapper chains must stay in the OMP console session so nested console
|
|
49
|
+
* grandchildren keep stdout routed through our pipe (#3544).
|
|
50
|
+
*/
|
|
51
|
+
detached: boolean;
|
|
29
52
|
}
|
|
30
53
|
|
|
31
54
|
/** Inputs used to resolve platform-specific stdio spawn behavior. */
|
|
32
55
|
export interface ResolveStdioSpawnOptions {
|
|
33
56
|
cwd: string;
|
|
34
57
|
env: Record<string, string | undefined>;
|
|
58
|
+
hostHasInheritableConsole?: boolean;
|
|
35
59
|
platform?: NodeJS.Platform;
|
|
36
60
|
}
|
|
37
61
|
|
|
@@ -142,6 +166,7 @@ async function resolveWindowsNpmShimCommand(
|
|
|
142
166
|
command: string,
|
|
143
167
|
args: readonly string[],
|
|
144
168
|
cwd: string,
|
|
169
|
+
windowsHide: boolean,
|
|
145
170
|
): Promise<StdioSpawnCommand | null> {
|
|
146
171
|
if (!isWindowsBatchCommand(command)) return null;
|
|
147
172
|
if (!hasPathSegment(command)) return null;
|
|
@@ -176,7 +201,8 @@ async function resolveWindowsNpmShimCommand(
|
|
|
176
201
|
const nodeCommand = (await fileExists(siblingNode)) ? siblingNode : "node";
|
|
177
202
|
return {
|
|
178
203
|
cmd: [nodeCommand, target, ...args],
|
|
179
|
-
windowsHide
|
|
204
|
+
windowsHide,
|
|
205
|
+
detached: false,
|
|
180
206
|
};
|
|
181
207
|
}
|
|
182
208
|
|
|
@@ -229,22 +255,28 @@ export async function resolveStdioSpawnCommand(
|
|
|
229
255
|
options: ResolveStdioSpawnOptions,
|
|
230
256
|
): Promise<StdioSpawnCommand> {
|
|
231
257
|
const args = config.args ?? [];
|
|
232
|
-
if (options.platform !== "win32") return { cmd: [config.command, ...args] };
|
|
258
|
+
if (options.platform !== "win32") return { cmd: [config.command, ...args], detached: true };
|
|
233
259
|
|
|
260
|
+
const windowsHide = options.hostHasInheritableConsole === undefined ? true : !options.hostHasInheritableConsole;
|
|
234
261
|
const resolved = await resolveWindowsCommandPath(config.command, options.cwd, options.env);
|
|
235
262
|
const resolvedCommand = resolved ?? config.command;
|
|
236
|
-
const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd);
|
|
263
|
+
const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd, windowsHide);
|
|
237
264
|
if (npmShimCommand) return npmShimCommand;
|
|
238
265
|
|
|
239
266
|
// Direct-spawn only when we resolved to a concrete file AND its extension
|
|
240
267
|
// is not a batch script. Everything else (resolved .cmd/.bat, or an
|
|
241
268
|
// unresolved extensionless command) goes through cmd.exe so PATHEXT runs.
|
|
269
|
+
// Windows stdio servers stay attached so wrapper grandchildren inherit the
|
|
270
|
+
// same console session. Only hide the child when OMP itself has no console
|
|
271
|
+
// to share; CREATE_NO_WINDOW breaks console inheritance for nested wrappers.
|
|
272
|
+
const detached = false;
|
|
242
273
|
const needsCmdExe = resolved === null || isWindowsBatchCommand(resolvedCommand);
|
|
243
|
-
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args] };
|
|
274
|
+
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
|
|
244
275
|
|
|
245
276
|
return {
|
|
246
277
|
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
|
|
247
|
-
windowsHide
|
|
278
|
+
windowsHide,
|
|
279
|
+
detached,
|
|
248
280
|
};
|
|
249
281
|
}
|
|
250
282
|
|
|
@@ -338,12 +370,14 @@ export class StdioTransport implements MCPTransport {
|
|
|
338
370
|
cwd,
|
|
339
371
|
env,
|
|
340
372
|
platform: process.platform,
|
|
373
|
+
hostHasInheritableConsole: hostHasInheritableConsole(),
|
|
341
374
|
});
|
|
342
375
|
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// SIGTSTP,
|
|
346
|
-
//
|
|
376
|
+
// Platform-derived session and console-window handling come from
|
|
377
|
+
// `resolveStdioSpawnCommand`: POSIX detaches into its own session to
|
|
378
|
+
// escape terminal job-control signals (SIGTSTP, SIGTTIN); Windows stays
|
|
379
|
+
// attached, and only hides the child when the host has no console to
|
|
380
|
+
// share. See `StdioSpawnCommand`.
|
|
347
381
|
this.#process = spawn({
|
|
348
382
|
cmd: spawnCommand.cmd,
|
|
349
383
|
cwd,
|
|
@@ -352,7 +386,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
352
386
|
stdout: "pipe",
|
|
353
387
|
stderr: "pipe",
|
|
354
388
|
windowsHide: spawnCommand.windowsHide,
|
|
355
|
-
detached:
|
|
389
|
+
detached: spawnCommand.detached,
|
|
356
390
|
});
|
|
357
391
|
|
|
358
392
|
this.#connected = true;
|
|
@@ -969,7 +969,7 @@ export class InputController {
|
|
|
969
969
|
//
|
|
970
970
|
// SIGTSTP: brush-core (the embedded shell behind every bash tool call)
|
|
971
971
|
// installs a tokio SIGTSTP listener on `Process::wait` to detect when
|
|
972
|
-
// its children have been stopped (`crates/brush-core
|
|
972
|
+
// its children have been stopped (`crates/vendor/brush-core/src/sys/
|
|
973
973
|
// unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::
|
|
974
974
|
// signal(SIGTSTP)`). Per tokio's documented contract, the first call
|
|
975
975
|
// for a given SignalKind permanently replaces the kernel-default
|
|
@@ -993,7 +993,7 @@ export class InputController {
|
|
|
993
993
|
// children that must survive the suspend (MCP stdio servers via
|
|
994
994
|
// the `detached: true` spawn in `mcp/transports/stdio.ts`, every
|
|
995
995
|
// brush external command via brush's per-child `setsid` in
|
|
996
|
-
// `crates/brush-core
|
|
996
|
+
// `crates/vendor/brush-core/src/commands.rs`) are already in
|
|
997
997
|
// their own sessions, so pgid=0 does not reach them.
|
|
998
998
|
process.kill(0, "SIGSTOP");
|
|
999
999
|
} catch (err) {
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getSymbolTheme,
|
|
23
23
|
previewTheme,
|
|
24
24
|
setColorBlindMode,
|
|
25
|
+
setMarkdownMermaidRendering,
|
|
25
26
|
setSymbolPreset,
|
|
26
27
|
setTheme,
|
|
27
28
|
theme,
|
|
@@ -374,6 +375,15 @@ export class SelectorController {
|
|
|
374
375
|
this.ctx.ui.requestRender();
|
|
375
376
|
break;
|
|
376
377
|
|
|
378
|
+
case "tui.renderMermaid":
|
|
379
|
+
setMarkdownMermaidRendering(value as boolean);
|
|
380
|
+
this.ctx.session.refreshBaseSystemPrompt().catch(err => {
|
|
381
|
+
this.ctx.showError(`Failed to apply Mermaid rendering setting: ${err}`);
|
|
382
|
+
});
|
|
383
|
+
this.ctx.rebuildChatFromMessages();
|
|
384
|
+
this.ctx.ui.resetDisplay();
|
|
385
|
+
break;
|
|
386
|
+
|
|
377
387
|
case "theme": {
|
|
378
388
|
setTheme(value as string, true).then(result => {
|
|
379
389
|
this.ctx.statusLine.invalidate();
|
|
@@ -167,6 +167,7 @@ import {
|
|
|
167
167
|
getSymbolTheme,
|
|
168
168
|
onTerminalAppearanceChange,
|
|
169
169
|
onThemeChange,
|
|
170
|
+
setMarkdownMermaidRendering,
|
|
170
171
|
theme,
|
|
171
172
|
} from "./theme/theme";
|
|
172
173
|
import type {
|
|
@@ -588,6 +589,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
588
589
|
}
|
|
589
590
|
|
|
590
591
|
setTuiTight(settings.get("tui.tight"));
|
|
592
|
+
setMarkdownMermaidRendering(settings.get("tui.renderMermaid"));
|
|
591
593
|
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
|
|
592
594
|
this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
|
|
593
595
|
// OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
|
|
@@ -3021,16 +3023,31 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
3021
3023
|
// Capture the operator's tier choice and hand it to #approvePlan, which
|
|
3022
3024
|
// applies it AFTER #exitPlanMode. #exitPlanMode normally restores
|
|
3023
3025
|
// #planModePreviousModelState (the model from before plan mode), so
|
|
3024
|
-
// applying the slider choice any earlier would be silently reverted
|
|
3025
|
-
//
|
|
3026
|
-
//
|
|
3027
|
-
//
|
|
3028
|
-
//
|
|
3029
|
-
//
|
|
3030
|
-
//
|
|
3031
|
-
//
|
|
3026
|
+
// applying the slider choice any earlier would be silently reverted.
|
|
3027
|
+
// Pass executionModel only when the slider was actually shown — a
|
|
3028
|
+
// singleton cycle (e.g. only modelRoles.plan is configured, so
|
|
3029
|
+
// getRoleModelCycle synthesizes a lone `default` entry from the
|
|
3030
|
+
// currently active plan model) hides the slider, the operator made
|
|
3031
|
+
// no selection, and the pre-plan model is not in the cycle. Pinning
|
|
3032
|
+
// that singleton would silently switch the session back to the plan
|
|
3033
|
+
// model after #exitPlanMode restored the pre-plan model.
|
|
3034
|
+
// Treat the choice as implicit only when applying the selected role
|
|
3035
|
+
// would land on the same end state as the restore — same model AND
|
|
3036
|
+
// the same effective thinking level. A role with an explicit thinking
|
|
3037
|
+
// suffix that differs from the restored thinking level must still go
|
|
3038
|
+
// through applyRoleModel, otherwise approving on the same model with a
|
|
3039
|
+
// different configured thinking level silently keeps the pre-plan level.
|
|
3040
|
+
const restoredState = this.#planModePreviousModelState;
|
|
3041
|
+
const restoredIndex =
|
|
3042
|
+
cycle && restoredState
|
|
3043
|
+
? cycle.models.findIndex(entry => {
|
|
3044
|
+
if (!modelsAreEqual(entry.model, restoredState.model)) return false;
|
|
3045
|
+
if (!entry.explicitThinkingLevel) return true;
|
|
3046
|
+
return entry.thinkingLevel === restoredState.thinkingLevel;
|
|
3047
|
+
})
|
|
3048
|
+
: -1;
|
|
3032
3049
|
const executionModel =
|
|
3033
|
-
cycle && selectedTierIndex !==
|
|
3050
|
+
slider && cycle && selectedTierIndex !== restoredIndex ? cycle.models[selectedTierIndex] : undefined;
|
|
3034
3051
|
await this.#approvePlan(latestPlanContent, {
|
|
3035
3052
|
planFilePath,
|
|
3036
3053
|
title: details.title,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { Markdown } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { Settings } from "../../config/settings";
|
|
4
|
+
import { buildSystemPrompt } from "../../system-prompt";
|
|
5
|
+
import { getMarkdownTheme, getThemeByName, setMarkdownMermaidRendering, setThemeInstance } from "./theme";
|
|
6
|
+
|
|
7
|
+
const workspaceTree = {
|
|
8
|
+
rootPath: "/tmp/project",
|
|
9
|
+
rendered: "",
|
|
10
|
+
truncated: false,
|
|
11
|
+
totalLines: 0,
|
|
12
|
+
agentsMdFiles: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function stripAnsi(text: string): string {
|
|
16
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
await Settings.init({ inMemory: true });
|
|
21
|
+
const theme = await getThemeByName("dark");
|
|
22
|
+
if (!theme) throw new Error("theme unavailable");
|
|
23
|
+
setThemeInstance(theme);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
setMarkdownMermaidRendering(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("Mermaid rendering setting", () => {
|
|
31
|
+
it("removes the Mermaid prompt note when rendering is disabled", async () => {
|
|
32
|
+
const { systemPrompt } = await buildSystemPrompt({
|
|
33
|
+
renderMermaid: false,
|
|
34
|
+
contextFiles: [],
|
|
35
|
+
skills: [],
|
|
36
|
+
toolNames: [],
|
|
37
|
+
workspaceTree,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(systemPrompt.join("\n")).not.toContain("```mermaid");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to a highlighted code fence when rendering is disabled", () => {
|
|
44
|
+
setMarkdownMermaidRendering(false);
|
|
45
|
+
|
|
46
|
+
const markdown = new Markdown("```mermaid\ngraph TD\n A --> B\n```", 0, 0, getMarkdownTheme());
|
|
47
|
+
const lines = stripAnsi(markdown.render(80).join("\n"));
|
|
48
|
+
|
|
49
|
+
expect(lines).toContain("```mermaid");
|
|
50
|
+
expect(lines).toContain("graph TD");
|
|
51
|
+
expect(lines).toContain("-->");
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -2824,23 +2824,36 @@ export function getSymbolTheme(): SymbolTheme {
|
|
|
2824
2824
|
|
|
2825
2825
|
let cachedMarkdownTheme: MarkdownTheme | undefined;
|
|
2826
2826
|
let cachedMarkdownThemeRef: Theme | undefined;
|
|
2827
|
+
let markdownMermaidRendering = true;
|
|
2828
|
+
|
|
2829
|
+
export function setMarkdownMermaidRendering(enabled: boolean): void {
|
|
2830
|
+
if (markdownMermaidRendering === enabled) return;
|
|
2831
|
+
markdownMermaidRendering = enabled;
|
|
2832
|
+
cachedMarkdownTheme = undefined;
|
|
2833
|
+
}
|
|
2827
2834
|
|
|
2828
2835
|
export function getMarkdownTheme(): MarkdownTheme {
|
|
2829
2836
|
if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
|
|
2830
2837
|
return cachedMarkdownTheme;
|
|
2831
2838
|
}
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2839
|
+
const mermaid = markdownMermaidRendering
|
|
2840
|
+
? (() => {
|
|
2841
|
+
// Mermaid ASCII diagrams render with the active palette so they read as
|
|
2842
|
+
// content rather than raw monochrome. Roles mirror the SVG renderer's
|
|
2843
|
+
// mapping; `text`/`muted`/`border`/`borderMuted`/`accent` exist in every theme.
|
|
2844
|
+
const mermaidColorMode =
|
|
2845
|
+
theme.getColorMode() === "truecolor" ? ("truecolor" as const) : ("ansi256" as const);
|
|
2846
|
+
const mermaidTheme = {
|
|
2847
|
+
fg: theme.getColorHex("text"),
|
|
2848
|
+
border: theme.getColorHex("border"),
|
|
2849
|
+
line: theme.getColorHex("muted"),
|
|
2850
|
+
arrow: theme.getColorHex("accent"),
|
|
2851
|
+
corner: theme.getColorHex("muted"),
|
|
2852
|
+
junction: theme.getColorHex("borderMuted"),
|
|
2853
|
+
};
|
|
2854
|
+
return { mermaidColorMode, mermaidTheme };
|
|
2855
|
+
})()
|
|
2856
|
+
: undefined;
|
|
2844
2857
|
const markdownTheme: MarkdownTheme = {
|
|
2845
2858
|
heading: (text: string) => theme.fg("mdHeading", text),
|
|
2846
2859
|
link: (text: string) => theme.fg("mdLink", text),
|
|
@@ -2857,8 +2870,14 @@ export function getMarkdownTheme(): MarkdownTheme {
|
|
|
2857
2870
|
underline: (text: string) => theme.underline(text),
|
|
2858
2871
|
strikethrough: (text: string) => chalk.strikethrough(text),
|
|
2859
2872
|
symbols: getSymbolTheme(),
|
|
2860
|
-
resolveMermaidAscii:
|
|
2861
|
-
|
|
2873
|
+
resolveMermaidAscii: mermaid
|
|
2874
|
+
? (source, maxWidth) =>
|
|
2875
|
+
resolveMermaidAscii(source, {
|
|
2876
|
+
maxWidth,
|
|
2877
|
+
theme: mermaid.mermaidTheme,
|
|
2878
|
+
colorMode: mermaid.mermaidColorMode,
|
|
2879
|
+
})
|
|
2880
|
+
: undefined,
|
|
2862
2881
|
highlightCode: (code: string, lang?: string): string[] => {
|
|
2863
2882
|
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
|
|
2864
2883
|
const highlighted = highlightCached(code, validLang, theme);
|
|
@@ -24,6 +24,15 @@ Choose a short kebab-case `<slug>` naming this task and write the plan to `local
|
|
|
24
24
|
|
|
25
25
|
Use `{{editToolName}}` for incremental edits and `{{writeToolName}}` only to create or fully replace the file. You MUST write findings into the plan as you learn them — you NEVER batch all writing to the end.
|
|
26
26
|
|
|
27
|
+
{{#if isHashlineEditMode}}
|
|
28
|
+
Structure the plan as `##`/`###` markdown sections so you can revise it section-by-section: with `{{editToolName}}`, a heading anchors its WHOLE section (through every nested deeper heading, up to the next same-or-higher heading). Rely on the block ops to grow the plan without rewriting the file:
|
|
29
|
+
- `SWAP.BLK N:` on a heading line — rewrite that entire section in place.
|
|
30
|
+
- `DEL.BLK N` on a heading line — drop the whole section.
|
|
31
|
+
- `INS.BLK.POST N:` on a heading line — add a new section AFTER that one (end the inserted body with a blank line so the next heading stays separated).
|
|
32
|
+
|
|
33
|
+
Write each section together with its body — block ops need a multi-line section; a bare heading with no body falls back to plain `INS.POST`/`DEL`/`SWAP`.
|
|
34
|
+
{{/if}}
|
|
35
|
+
|
|
27
36
|
## Ground every claim
|
|
28
37
|
|
|
29
38
|
You eliminate unknowns by discovering facts, not by asking.
|
|
@@ -16,7 +16,9 @@ You are a helpful assistant the team trusts with load-bearing changes, operating
|
|
|
16
16
|
- Consider what code compiles to. NEVER allocate avoidably; no needless copies or computation.
|
|
17
17
|
- You are not alone in this repo. Treat unexpected changes as the user's work and adapt.
|
|
18
18
|
- In terminal prose and final chat, you MAY use LaTeX math (`$`, `$$`, `\text`, `\times`) and color (`\textcolor`, `\colorbox`, `\fcolorbox`).
|
|
19
|
+
{{#if renderMermaid}}
|
|
19
20
|
- To show a diagram, you MAY emit a ` ```mermaid ` block — the terminal renders it as ASCII. Use it for genuine structure or flow, not trivia.
|
|
21
|
+
{{/if}}
|
|
20
22
|
|
|
21
23
|
RUNTIME
|
|
22
24
|
==============
|
package/src/sdk.ts
CHANGED
|
@@ -1223,7 +1223,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1223
1223
|
const allowedModels = await logger.time("resolveAllowedModels", () =>
|
|
1224
1224
|
resolveAllowedModels(modelRegistry, settings, modelMatchPreferences),
|
|
1225
1225
|
);
|
|
1226
|
-
|
|
1226
|
+
let defaultRoleSpec = logger.time("resolveDefaultModelRole", () =>
|
|
1227
1227
|
resolveModelRoleValue(settings.getModelRole("default"), allowedModels, {
|
|
1228
1228
|
settings,
|
|
1229
1229
|
matchPreferences: modelMatchPreferences,
|
|
@@ -1946,9 +1946,47 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1946
1946
|
// Re-resolve the allowed set: extension factories above may have
|
|
1947
1947
|
// registered providers/models that weren't visible at startup.
|
|
1948
1948
|
const fallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences);
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1949
|
+
|
|
1950
|
+
// Retry the default-role lookup against the post-extension allowed
|
|
1951
|
+
// set. Extension factories register providers AFTER the early
|
|
1952
|
+
// `defaultRoleSpec` resolution, so a role pointing at an extension
|
|
1953
|
+
// model (e.g. an openai-compat plugin's `posthog/claude-opus-4-8`)
|
|
1954
|
+
// returned `undefined` there. Without this retry the next step's
|
|
1955
|
+
// `pickDefaultAvailableModel` happily replaces the user's configured
|
|
1956
|
+
// default with a bundled provider's default whenever a stray
|
|
1957
|
+
// `OPENAI_API_KEY`/`ANTHROPIC_API_KEY` is in the environment.
|
|
1958
|
+
// (issue #3569)
|
|
1959
|
+
if (!hasExplicitModel && !defaultRoleSpec.model) {
|
|
1960
|
+
const reResolvedRoleSpec = resolveModelRoleValue(settings.getModelRole("default"), fallbackCandidates, {
|
|
1961
|
+
settings,
|
|
1962
|
+
matchPreferences: modelMatchPreferences,
|
|
1963
|
+
modelRegistry,
|
|
1964
|
+
});
|
|
1965
|
+
if (reResolvedRoleSpec.model) {
|
|
1966
|
+
defaultRoleSpec = reResolvedRoleSpec;
|
|
1967
|
+
const resolvedDefaultModel = reResolvedRoleSpec.model;
|
|
1968
|
+
model = resolvedDefaultModel;
|
|
1969
|
+
modelFallbackMessage = undefined;
|
|
1970
|
+
// Recompute the thinking level against the now-real model.
|
|
1971
|
+
// `pickInitialThinkingLevel` closes over `defaultRoleSpec`,
|
|
1972
|
+
// so the role's explicit selector (e.g. `:max`) now applies.
|
|
1973
|
+
thinkingLevel = pickInitialThinkingLevel(resolvedDefaultModel);
|
|
1974
|
+
autoThinking = thinkingLevel === AUTO_THINKING;
|
|
1975
|
+
effectiveThinkingLevel = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel;
|
|
1976
|
+
effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () =>
|
|
1977
|
+
autoThinking
|
|
1978
|
+
? resolveProvisionalAutoLevel(resolvedDefaultModel)
|
|
1979
|
+
: resolveThinkingLevelForModel(resolvedDefaultModel, effectiveThinkingLevel),
|
|
1980
|
+
);
|
|
1981
|
+
preconnectModelHost(resolvedDefaultModel.baseUrl);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
if (!model) {
|
|
1986
|
+
const defaultModel = pickDefaultAvailableModel(fallbackCandidates.filter(hasModelAuth));
|
|
1987
|
+
if (defaultModel) {
|
|
1988
|
+
model = defaultModel;
|
|
1989
|
+
}
|
|
1952
1990
|
}
|
|
1953
1991
|
if (model) {
|
|
1954
1992
|
if (modelFallbackMessage) {
|
|
@@ -2221,6 +2259,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2221
2259
|
memoryRootEnabled: memoryBackend.id === "local",
|
|
2222
2260
|
model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
|
|
2223
2261
|
personality: agentKind === "sub" ? "none" : settings.get("personality"),
|
|
2262
|
+
renderMermaid: settings.get("tui.renderMermaid"),
|
|
2224
2263
|
});
|
|
2225
2264
|
|
|
2226
2265
|
if (options.systemPrompt === undefined) {
|