@oh-my-pi/pi-coding-agent 16.1.22 → 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 +29 -1
- package/dist/cli.js +3441 -3300
- 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/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 +20 -20
- package/src/mcp/transports/stdio.test.ts +20 -3
- package/src/mcp/transports/stdio.ts +45 -14
- 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" }],
|
|
@@ -308,10 +308,15 @@ export async function discoverOAuthEndpoints(
|
|
|
308
308
|
"/.mcp/auth",
|
|
309
309
|
"/authorize", // Some MCP servers expose OAuth config here
|
|
310
310
|
];
|
|
311
|
-
const urlsToQuery: string
|
|
311
|
+
const urlsToQuery: Array<{ url: string; issuerCandidate: boolean }> = [];
|
|
312
312
|
const visitedAuthServers = new Set<string>();
|
|
313
313
|
|
|
314
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
|
+
};
|
|
315
320
|
|
|
316
321
|
// Step 1: If a resource_metadata URL was provided, fetch it to discover auth servers.
|
|
317
322
|
// This follows the RFC 9728 chain: resource_metadata → authorization_servers.
|
|
@@ -332,10 +337,7 @@ export async function discoverOAuthEndpoints(
|
|
|
332
337
|
? meta.authorization_servers.filter((entry): entry is string => typeof entry === "string")
|
|
333
338
|
: [];
|
|
334
339
|
for (const s of authServers) {
|
|
335
|
-
|
|
336
|
-
urlsToQuery.push(s);
|
|
337
|
-
visitedAuthServers.add(s);
|
|
338
|
-
}
|
|
340
|
+
addDiscoveryBase(s, true);
|
|
339
341
|
}
|
|
340
342
|
}
|
|
341
343
|
} catch {
|
|
@@ -343,13 +345,9 @@ export async function discoverOAuthEndpoints(
|
|
|
343
345
|
}
|
|
344
346
|
}
|
|
345
347
|
|
|
346
|
-
// Step 2: Add explicit authServerUrl
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
urlsToQuery.push(url);
|
|
350
|
-
visitedAuthServers.add(url);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
348
|
+
// Step 2: Add explicit authServerUrl as an issuer candidate, then the resource server fallback.
|
|
349
|
+
addDiscoveryBase(authServerUrl, true);
|
|
350
|
+
addDiscoveryBase(serverUrl, false);
|
|
353
351
|
|
|
354
352
|
const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
|
|
355
353
|
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
@@ -414,10 +412,10 @@ export async function discoverOAuthEndpoints(
|
|
|
414
412
|
return null;
|
|
415
413
|
};
|
|
416
414
|
|
|
417
|
-
for (const
|
|
415
|
+
for (const base of urlsToQuery) {
|
|
418
416
|
for (const path of wellKnownPaths) {
|
|
419
417
|
// Try each well-known path at both the absolute origin and relative
|
|
420
|
-
const urlsToTry = buildWellKnownUrls(path,
|
|
418
|
+
const urlsToTry = buildWellKnownUrls(path, base.url);
|
|
421
419
|
for (const url of urlsToTry) {
|
|
422
420
|
try {
|
|
423
421
|
const response = await fetchImpl(url.toString(), {
|
|
@@ -429,13 +427,15 @@ export async function discoverOAuthEndpoints(
|
|
|
429
427
|
if (response.ok) {
|
|
430
428
|
const metadata = (await response.json()) as Record<string, unknown>;
|
|
431
429
|
// Authorization-server / OpenID Connect metadata documents carry an
|
|
432
|
-
// `issuer` field that MUST equal the queried base URL
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
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.
|
|
436
434
|
const requireIssuerMatch =
|
|
437
|
-
|
|
438
|
-
|
|
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
439
|
const endpoints = issuerOk ? findEndpoints(metadata) : null;
|
|
440
440
|
if (endpoints) return endpoints;
|
|
441
441
|
|
|
@@ -3,19 +3,35 @@ import { describe, expect, it } from "bun:test";
|
|
|
3
3
|
import { resolveStdioSpawnCommand } from "./stdio";
|
|
4
4
|
|
|
5
5
|
describe("resolveStdioSpawnCommand", () => {
|
|
6
|
-
it("hides
|
|
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).
|
|
7
9
|
await expect(
|
|
8
10
|
resolveStdioSpawnCommand(
|
|
9
11
|
{ command: "server.exe", args: ["--stdio"] },
|
|
10
|
-
{ cwd: process.cwd(), env: {}, platform: "win32" },
|
|
12
|
+
{ cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: false },
|
|
11
13
|
),
|
|
12
14
|
).resolves.toEqual({
|
|
13
15
|
cmd: ["server.exe", "--stdio"],
|
|
14
16
|
windowsHide: true,
|
|
17
|
+
detached: false,
|
|
15
18
|
});
|
|
16
19
|
});
|
|
17
20
|
|
|
18
|
-
it("
|
|
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 () => {
|
|
19
35
|
await expect(
|
|
20
36
|
resolveStdioSpawnCommand(
|
|
21
37
|
{ command: "server.exe", args: ["--stdio"] },
|
|
@@ -23,6 +39,7 @@ describe("resolveStdioSpawnCommand", () => {
|
|
|
23
39
|
),
|
|
24
40
|
).resolves.toEqual({
|
|
25
41
|
cmd: ["server.exe", "--stdio"],
|
|
42
|
+
detached: true,
|
|
26
43
|
});
|
|
27
44
|
});
|
|
28
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,25 +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.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
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;
|
|
245
273
|
const needsCmdExe = resolved === null || isWindowsBatchCommand(resolvedCommand);
|
|
246
|
-
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide };
|
|
274
|
+
if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
|
|
247
275
|
|
|
248
276
|
return {
|
|
249
277
|
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
|
|
250
278
|
windowsHide,
|
|
279
|
+
detached,
|
|
251
280
|
};
|
|
252
281
|
}
|
|
253
282
|
|
|
@@ -341,12 +370,14 @@ export class StdioTransport implements MCPTransport {
|
|
|
341
370
|
cwd,
|
|
342
371
|
env,
|
|
343
372
|
platform: process.platform,
|
|
373
|
+
hostHasInheritableConsole: hostHasInheritableConsole(),
|
|
344
374
|
});
|
|
345
375
|
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
// SIGTSTP,
|
|
349
|
-
//
|
|
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`.
|
|
350
381
|
this.#process = spawn({
|
|
351
382
|
cmd: spawnCommand.cmd,
|
|
352
383
|
cwd,
|
|
@@ -355,7 +386,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
355
386
|
stdout: "pipe",
|
|
356
387
|
stderr: "pipe",
|
|
357
388
|
windowsHide: spawnCommand.windowsHide,
|
|
358
|
-
detached:
|
|
389
|
+
detached: spawnCommand.detached,
|
|
359
390
|
});
|
|
360
391
|
|
|
361
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) {
|