@oh-my-pi/pi-coding-agent 16.0.11 → 16.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/cli.js +3166 -3202
- package/dist/types/config/settings-schema.d.ts +40 -39
- package/dist/types/lsp/types.d.ts +5 -3
- package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
- package/dist/types/modes/components/assistant-message.d.ts +8 -0
- package/dist/types/modes/components/cache-invalidation-marker.d.ts +39 -0
- package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
- package/dist/types/modes/components/index.d.ts +0 -1
- package/dist/types/modes/components/message-frame.d.ts +6 -4
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/theme/theme.d.ts +7 -1
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +20 -1
- package/dist/types/session/session-context.d.ts +7 -0
- package/dist/types/session/session-dump-format.d.ts +1 -0
- package/dist/types/session/tool-choice-queue.d.ts +14 -0
- package/dist/types/system-prompt.d.ts +3 -3
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/resolve.d.ts +15 -5
- package/package.json +12 -12
- package/src/config/settings-schema.ts +48 -39
- package/src/config/settings.ts +40 -0
- package/src/debug/log-viewer.ts +4 -4
- package/src/debug/raw-sse.ts +4 -4
- package/src/edit/renderer.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/lsp/client.ts +9 -9
- package/src/lsp/render.ts +7 -7
- package/src/lsp/types.ts +6 -3
- package/src/modes/components/__tests__/skill-message.test.ts +92 -0
- package/src/modes/components/agent-dashboard.ts +1 -1
- package/src/modes/components/assistant-message.ts +21 -0
- package/src/modes/components/cache-invalidation-marker.ts +94 -0
- package/src/modes/components/chat-transcript-builder.ts +16 -2
- package/src/modes/components/compaction-summary-message.ts +29 -1
- package/src/modes/components/custom-message.ts +4 -1
- package/src/modes/components/dynamic-border.ts +1 -1
- package/src/modes/components/extensions/extension-dashboard.ts +1 -1
- package/src/modes/components/extensions/inspector-panel.ts +5 -5
- package/src/modes/components/hook-selector.ts +2 -2
- package/src/modes/components/index.ts +0 -1
- package/src/modes/components/message-frame.ts +10 -6
- package/src/modes/components/model-selector.ts +2 -2
- package/src/modes/components/overlay-box.ts +10 -9
- package/src/modes/components/settings-defs.ts +7 -0
- package/src/modes/components/skill-message.ts +39 -19
- package/src/modes/components/tiny-title-download-progress.ts +1 -1
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/event-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +7 -0
- package/src/modes/interactive-mode.ts +9 -1
- package/src/modes/theme/theme.ts +14 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/utils/ui-helpers.ts +20 -2
- package/src/prompts/steering/user-interjection.md +3 -4
- package/src/sdk.ts +8 -6
- package/src/session/agent-session.ts +96 -23
- package/src/session/messages.ts +7 -9
- package/src/session/session-context.ts +54 -7
- package/src/session/session-dump-format.ts +3 -1
- package/src/session/snapcompact-inline.ts +2 -2
- package/src/session/tool-choice-queue.ts +59 -0
- package/src/system-prompt.ts +10 -9
- package/src/tools/bash-interactive.ts +4 -4
- package/src/tools/index.ts +4 -0
- package/src/tools/resolve.ts +66 -41
- package/src/tui/output-block.ts +9 -9
- package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
- package/src/modes/components/branch-summary-message.ts +0 -46
package/src/lsp/client.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits";
|
|
|
5
5
|
import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
|
|
6
6
|
import type {
|
|
7
7
|
LspClient,
|
|
8
|
+
LspJsonRpcId,
|
|
8
9
|
LspJsonRpcNotification,
|
|
9
10
|
LspJsonRpcRequest,
|
|
10
11
|
LspJsonRpcResponse,
|
|
@@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name:
|
|
|
416
417
|
* Handle workspace/workspaceFolders requests from the server.
|
|
417
418
|
*/
|
|
418
419
|
async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
|
|
419
|
-
if (typeof message.id !== "number") return;
|
|
420
420
|
await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
|
|
421
421
|
}
|
|
422
422
|
|
|
@@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson
|
|
|
424
424
|
* Handle workspace/configuration requests from the server.
|
|
425
425
|
*/
|
|
426
426
|
async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
|
|
427
|
-
if (typeof message.id !== "number") return;
|
|
428
427
|
const params = message.params as { items?: Array<{ section?: string }> };
|
|
429
428
|
const items = params?.items ?? [];
|
|
430
429
|
const result = items.map(item => {
|
|
@@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc
|
|
|
438
437
|
* Handle workspace/applyEdit requests from the server.
|
|
439
438
|
*/
|
|
440
439
|
async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
|
|
441
|
-
if (typeof message.id !== "number") return;
|
|
442
440
|
const params = message.params as { edit?: WorkspaceEdit };
|
|
443
441
|
if (!params?.edit) {
|
|
444
442
|
await sendResponse(
|
|
@@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
|
|
|
475
473
|
return;
|
|
476
474
|
}
|
|
477
475
|
if (message.method === "window/workDoneProgress/create") {
|
|
478
|
-
// Accept progress token registration from the server
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
476
|
+
// Accept progress token registration from the server.
|
|
477
|
+
await sendResponse(client, message.id, null, message.method);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") {
|
|
481
|
+
// Some servers block semantic requests until dynamic registration succeeds.
|
|
482
|
+
await sendResponse(client, message.id, null, message.method);
|
|
482
483
|
return;
|
|
483
484
|
}
|
|
484
|
-
if (typeof message.id !== "number") return;
|
|
485
485
|
await sendResponse(client, message.id, null, message.method, {
|
|
486
486
|
code: -32601,
|
|
487
487
|
message: `Method not found: ${message.method}`,
|
|
@@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
|
|
|
493
493
|
*/
|
|
494
494
|
async function sendResponse(
|
|
495
495
|
client: LspClient,
|
|
496
|
-
id:
|
|
496
|
+
id: LspJsonRpcId,
|
|
497
497
|
result: unknown,
|
|
498
498
|
method: string,
|
|
499
499
|
error?: { code: number; message: string; data?: unknown },
|
package/src/lsp/render.ts
CHANGED
|
@@ -223,10 +223,10 @@ function renderHover(
|
|
|
223
223
|
const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : "";
|
|
224
224
|
|
|
225
225
|
if (expanded) {
|
|
226
|
-
const h = theme.
|
|
227
|
-
const v = theme.
|
|
228
|
-
const top = `${theme.
|
|
229
|
-
const bottom = `${theme.
|
|
226
|
+
const h = theme.boxRound.horizontal;
|
|
227
|
+
const v = theme.boxRound.vertical;
|
|
228
|
+
const top = `${theme.boxRound.topLeft}${h.repeat(3)}`;
|
|
229
|
+
const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`;
|
|
230
230
|
let output = `${icon}${langLabel}`;
|
|
231
231
|
if (beforeCode) {
|
|
232
232
|
for (const line of beforeCode.split("\n")) {
|
|
@@ -254,9 +254,9 @@ function renderHover(
|
|
|
254
254
|
const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE);
|
|
255
255
|
output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`;
|
|
256
256
|
}
|
|
257
|
-
const h = theme.
|
|
258
|
-
const v = theme.
|
|
259
|
-
const bottom = `${theme.
|
|
257
|
+
const h = theme.boxRound.horizontal;
|
|
258
|
+
const v = theme.boxRound.vertical;
|
|
259
|
+
const bottom = `${theme.boxRound.bottomLeft}${h.repeat(3)}`;
|
|
260
260
|
output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`;
|
|
261
261
|
|
|
262
262
|
if (codeLines.length > 1) {
|
package/src/lsp/types.ts
CHANGED
|
@@ -399,7 +399,7 @@ export interface LspClient {
|
|
|
399
399
|
diagnostics: Map<string, PublishedDiagnostics>;
|
|
400
400
|
diagnosticsVersion: number;
|
|
401
401
|
openFiles: Map<string, OpenFile>;
|
|
402
|
-
pendingRequests: Map<number, PendingRequest>;
|
|
402
|
+
pendingRequests: Map<number | string, PendingRequest>;
|
|
403
403
|
messageBuffer: Uint8Array;
|
|
404
404
|
isReading: boolean;
|
|
405
405
|
/** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */
|
|
@@ -420,16 +420,19 @@ export interface LspClient {
|
|
|
420
420
|
// JSON-RPC Protocol Types
|
|
421
421
|
// =============================================================================
|
|
422
422
|
|
|
423
|
+
/** JSON-RPC request/response identifier accepted by LSP peers. */
|
|
424
|
+
export type LspJsonRpcId = number | string;
|
|
425
|
+
|
|
423
426
|
export interface LspJsonRpcRequest {
|
|
424
427
|
jsonrpc: "2.0";
|
|
425
|
-
id:
|
|
428
|
+
id: LspJsonRpcId;
|
|
426
429
|
method: string;
|
|
427
430
|
params: unknown;
|
|
428
431
|
}
|
|
429
432
|
|
|
430
433
|
export interface LspJsonRpcResponse {
|
|
431
434
|
jsonrpc: "2.0";
|
|
432
|
-
id?:
|
|
435
|
+
id?: LspJsonRpcId;
|
|
433
436
|
result?: unknown;
|
|
434
437
|
error?: { code: number; message: string; data?: unknown };
|
|
435
438
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { Settings } from "../../../config/settings";
|
|
5
|
+
import type { CustomMessage, SkillPromptDetails } from "../../../session/messages";
|
|
6
|
+
import { getThemeByName, setThemeInstance, type Theme } from "../../theme/theme";
|
|
7
|
+
import { SkillMessageComponent } from "../skill-message";
|
|
8
|
+
|
|
9
|
+
// Drop SGR colors and OSC 8 hyperlink wrappers so assertions see the visible text only.
|
|
10
|
+
const strip = (lines: readonly string[]): string =>
|
|
11
|
+
lines
|
|
12
|
+
.join("\n")
|
|
13
|
+
.replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "")
|
|
14
|
+
.replace(/\x1b\[[0-9;]*m/g, "");
|
|
15
|
+
|
|
16
|
+
function makeMessage(
|
|
17
|
+
details: SkillPromptDetails,
|
|
18
|
+
content = "Use the atomic-commit workflow.",
|
|
19
|
+
): CustomMessage<SkillPromptDetails> {
|
|
20
|
+
return { role: "custom", customType: "skill-prompt", content, display: true, details, timestamp: Date.now() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("SkillMessageComponent", () => {
|
|
24
|
+
let uiTheme: Theme;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
await Settings.init({ inMemory: true });
|
|
28
|
+
const loaded = await getThemeByName("dark");
|
|
29
|
+
if (!loaded) throw new Error("theme unavailable");
|
|
30
|
+
uiTheme = loaded;
|
|
31
|
+
setThemeInstance(uiTheme);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const skillPath = path.join(os.homedir(), ".agent/skills/atomic-commit/SKILL.md");
|
|
35
|
+
|
|
36
|
+
it("renders a compact, outlined card instead of the archaic key:value dump", () => {
|
|
37
|
+
const component = new SkillMessageComponent(
|
|
38
|
+
makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88 }),
|
|
39
|
+
);
|
|
40
|
+
const text = strip(component.render(80));
|
|
41
|
+
|
|
42
|
+
// New look: an icon-tagged "skill" header with the name and a single meta line.
|
|
43
|
+
expect(text).toContain("skill");
|
|
44
|
+
expect(text).toContain("atomic-commit");
|
|
45
|
+
expect(text).toContain("88 lines");
|
|
46
|
+
|
|
47
|
+
// The card is drawn with an outline.
|
|
48
|
+
expect(text).toContain(uiTheme.boxRound.topLeft);
|
|
49
|
+
expect(text).toContain(uiTheme.boxRound.bottomRight);
|
|
50
|
+
|
|
51
|
+
// Path is home-shortened and never leaks the absolute home dir.
|
|
52
|
+
expect(text).toContain("~/.agent/skills/atomic-commit/SKILL.md");
|
|
53
|
+
expect(text).not.toContain(os.homedir());
|
|
54
|
+
|
|
55
|
+
// The old archaic framing is gone.
|
|
56
|
+
expect(text).not.toContain("[skill]");
|
|
57
|
+
expect(text).not.toContain("Skill:");
|
|
58
|
+
expect(text).not.toContain("Path:");
|
|
59
|
+
expect(text).not.toContain("Prompt:");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("flattens multi-line args onto the single-line header", () => {
|
|
63
|
+
const component = new SkillMessageComponent(
|
|
64
|
+
makeMessage({ name: "atomic-commit", path: skillPath, lineCount: 88, args: "stage all\nthen split" }),
|
|
65
|
+
);
|
|
66
|
+
const text = strip(component.render(80));
|
|
67
|
+
// Whitespace (including the newline) collapsed to single spaces so the header can't break.
|
|
68
|
+
expect(text).toContain("stage all then split");
|
|
69
|
+
expect(text).not.toContain("stage all\nthen split");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("uses a singular unit for a one-line prompt", () => {
|
|
73
|
+
const component = new SkillMessageComponent(makeMessage({ name: "tiny", path: skillPath, lineCount: 1 }));
|
|
74
|
+
const text = strip(component.render(80));
|
|
75
|
+
expect(text).toContain("1 line");
|
|
76
|
+
expect(text).not.toContain("1 lines");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("reveals the prompt body under a calm subheader only when expanded", () => {
|
|
80
|
+
const details: SkillPromptDetails = { name: "atomic-commit", path: skillPath, lineCount: 88 };
|
|
81
|
+
const body = "Step one: stage hunks.";
|
|
82
|
+
|
|
83
|
+
const collapsed = new SkillMessageComponent(makeMessage(details, body));
|
|
84
|
+
expect(strip(collapsed.render(80))).not.toContain(body);
|
|
85
|
+
|
|
86
|
+
const expanded = new SkillMessageComponent(makeMessage(details, body));
|
|
87
|
+
expanded.setExpanded(true);
|
|
88
|
+
const text = strip(expanded.render(80));
|
|
89
|
+
expect(text).toContain("prompt");
|
|
90
|
+
expect(text).toContain(body);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -321,7 +321,7 @@ class TwoColumnBody implements Component {
|
|
|
321
321
|
const rightLines = this.rightPane.render(rightWidth);
|
|
322
322
|
const lineCount = this.maxHeight;
|
|
323
323
|
const out: string[] = [];
|
|
324
|
-
const separator = theme.fg("dim", ` ${theme.
|
|
324
|
+
const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
|
|
325
325
|
|
|
326
326
|
for (let i = 0; i < lineCount; i++) {
|
|
327
327
|
const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
|
|
@@ -5,6 +5,7 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
|
5
5
|
import { resolveAbortLabel, shouldRenderAbortReason } from "../../session/messages";
|
|
6
6
|
import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
|
7
7
|
import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
8
|
+
import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Max lines of a turn-ending provider error rendered inline in the transcript.
|
|
@@ -29,6 +30,7 @@ const THINKING_DOTS_FRAME_MS = 320;
|
|
|
29
30
|
*/
|
|
30
31
|
export class AssistantMessageComponent extends Container {
|
|
31
32
|
#contentContainer: Container;
|
|
33
|
+
#markerSlot: Container;
|
|
32
34
|
#lastMessage?: AssistantMessage;
|
|
33
35
|
#toolImagesByCallId = new Map<string, ImageContent[]>();
|
|
34
36
|
#convertedKittyImages = new Map<string, ImageContent>();
|
|
@@ -75,6 +77,11 @@ export class AssistantMessageComponent extends Container {
|
|
|
75
77
|
super();
|
|
76
78
|
this.#transcriptBlockFinalized = message !== undefined;
|
|
77
79
|
|
|
80
|
+
// Slim cache-invalidation divider, populated above the content when this
|
|
81
|
+
// turn's request lost the prompt cache (see setCacheInvalidation).
|
|
82
|
+
this.#markerSlot = new Container();
|
|
83
|
+
this.addChild(this.#markerSlot);
|
|
84
|
+
|
|
78
85
|
// Container for text/thinking content
|
|
79
86
|
this.#contentContainer = new Container();
|
|
80
87
|
this.addChild(this.#contentContainer);
|
|
@@ -84,6 +91,20 @@ export class AssistantMessageComponent extends Container {
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Show or clear the slim cache-invalidation divider above this turn. Set at
|
|
96
|
+
* `message_end` (live) or during rebuild, once the turn's usage is known and
|
|
97
|
+
* compared against the previous turn's cache footprint. Bumps the transcript
|
|
98
|
+
* block version so the change repaints even after content finalized.
|
|
99
|
+
*/
|
|
100
|
+
setCacheInvalidation(info: CacheInvalidation | undefined): void {
|
|
101
|
+
this.#markerSlot.clear();
|
|
102
|
+
if (info) {
|
|
103
|
+
this.#markerSlot.addChild(new CacheInvalidationMarkerComponent(info));
|
|
104
|
+
}
|
|
105
|
+
this.#blockVersion++;
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
override invalidate(): void {
|
|
88
109
|
super.invalidate();
|
|
89
110
|
// Theme/symbol changes arrive via invalidate(). Fast-path children captured
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Usage } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { formatNumber } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { theme } from "../../modes/theme/theme";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimum cached prefix (read + write) the previous turn must have established
|
|
8
|
+
* before a collapse on the current turn counts as an invalidation. Filters out
|
|
9
|
+
* tiny contexts and providers below the cacheable-prefix floor, where a zero
|
|
10
|
+
* `cacheRead` is expected rather than a reset.
|
|
11
|
+
*/
|
|
12
|
+
const MIN_CACHE_FOOTPRINT = 2048;
|
|
13
|
+
|
|
14
|
+
/** A prompt-cache invalidation detected from a turn's usage. */
|
|
15
|
+
export interface CacheInvalidation {
|
|
16
|
+
/** Prompt tokens the cold turn had to (re)process instead of reading from cache. */
|
|
17
|
+
reprocessedTokens: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decide whether `current` turn lost the prompt cache that `prev` established.
|
|
22
|
+
*
|
|
23
|
+
* The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/
|
|
24
|
+
* system-prompt change (or a history rewrite) breaks the prefix, so the next
|
|
25
|
+
* request reads nothing from cache and re-pays for the whole prompt. We detect
|
|
26
|
+
* that as: the previous turn cached a meaningful prefix, yet this turn's
|
|
27
|
+
* `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt.
|
|
28
|
+
* Returns `undefined` (no marker) for the first turn, tiny contexts, turns
|
|
29
|
+
* that reused any cache, and — crucially — turns on providers with *implicit*
|
|
30
|
+
* best-effort caching. Only an explicit, prefix-controlled cache (Anthropic /
|
|
31
|
+
* Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite >
|
|
32
|
+
* 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and
|
|
33
|
+
* drop `cacheRead` to zero intermittently as routine propagation noise that
|
|
34
|
+
* self-heals the next turn, so flagging it would be a false positive.
|
|
35
|
+
*/
|
|
36
|
+
export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
|
|
37
|
+
if (!prev) return undefined;
|
|
38
|
+
const prevFootprint = prev.cacheRead + prev.cacheWrite;
|
|
39
|
+
if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined;
|
|
40
|
+
// Any cache reuse this turn means the prefix survived (at least partly).
|
|
41
|
+
if (current.cacheRead > 0) return undefined;
|
|
42
|
+
// Only an explicit, prefix-controlled cache re-creates the prefix on a cold
|
|
43
|
+
// turn — Anthropic/Bedrock report that as `cacheWrite`. Implicit best-effort
|
|
44
|
+
// caches (Google/OpenAI/Fireworks) report `cacheWrite: 0` and drop `cacheRead`
|
|
45
|
+
// to zero intermittently as propagation noise, not a real invalidation.
|
|
46
|
+
if (current.cacheWrite <= 0) return undefined;
|
|
47
|
+
const reprocessedTokens = current.cacheWrite + current.input;
|
|
48
|
+
if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined;
|
|
49
|
+
return { reprocessedTokens };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CACHE_INVALIDATION_RULE_WIDTH = 10;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Slim left-aligned divider rendered above an assistant turn whose request lost
|
|
56
|
+
* the prompt cache. Mirrors the compaction divider's banner styling but spans
|
|
57
|
+
* only a short rule plus label (not the full width) and carries no expandable
|
|
58
|
+
* detail:
|
|
59
|
+
*
|
|
60
|
+
* ────────── ⊘ cache miss · 50.9k tokens
|
|
61
|
+
*/
|
|
62
|
+
export class CacheInvalidationMarkerComponent implements Component {
|
|
63
|
+
#cache?: { width: number; lines: string[] };
|
|
64
|
+
|
|
65
|
+
constructor(private readonly info: CacheInvalidation) {}
|
|
66
|
+
|
|
67
|
+
invalidate(): void {
|
|
68
|
+
this.#cache = undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render(width: number): readonly string[] {
|
|
72
|
+
width = Math.max(1, width);
|
|
73
|
+
if (this.#cache?.width === width) {
|
|
74
|
+
return this.#cache.lines;
|
|
75
|
+
}
|
|
76
|
+
const lines = ["", this.#divider(width), ""];
|
|
77
|
+
this.#cache = { width, lines };
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#divider(width: number): string {
|
|
82
|
+
const icon = theme.icon.cacheMiss;
|
|
83
|
+
const head = icon ? `${icon} cache miss` : "cache miss";
|
|
84
|
+
const tokens = this.info.reprocessedTokens;
|
|
85
|
+
const label = tokens > 0 ? `${head} ${theme.sep.dot.trim()} ${formatNumber(tokens)} tokens` : head;
|
|
86
|
+
const labelWidth = Bun.stringWidth(label, { countAnsiEscapeCodes: false });
|
|
87
|
+
const ruleWidth = Math.min(CACHE_INVALIDATION_RULE_WIDTH, width - labelWidth - 1);
|
|
88
|
+
if (ruleWidth < 1) {
|
|
89
|
+
// Too narrow to frame — emit the bare label.
|
|
90
|
+
return theme.fg("muted", label);
|
|
91
|
+
}
|
|
92
|
+
return `${theme.fg("dim", theme.tree.horizontal.repeat(ruleWidth))} ${theme.fg("muted", label)}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -36,9 +36,13 @@ import { createAdvisorMessageCard } from "./advisor-message";
|
|
|
36
36
|
import { AssistantMessageComponent } from "./assistant-message";
|
|
37
37
|
import { createBackgroundTanDispatchBlock } from "./background-tan-message";
|
|
38
38
|
import { BashExecutionComponent } from "./bash-execution";
|
|
39
|
-
import {
|
|
39
|
+
import { detectCacheInvalidation } from "./cache-invalidation-marker";
|
|
40
40
|
import { CollabPromptMessageComponent } from "./collab-prompt-message";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
BranchSummaryMessageComponent,
|
|
43
|
+
CompactionSummaryMessageComponent,
|
|
44
|
+
createHandoffSummaryMessageComponent,
|
|
45
|
+
} from "./compaction-summary-message";
|
|
42
46
|
import { CustomMessageComponent } from "./custom-message";
|
|
43
47
|
import { EvalExecutionComponent } from "./eval-execution";
|
|
44
48
|
import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
|
|
@@ -73,6 +77,7 @@ export class ChatTranscriptBuilder {
|
|
|
73
77
|
#readArgs = new Map<string, Record<string, unknown>>();
|
|
74
78
|
#readGroup: ReadToolGroupComponent | null = null;
|
|
75
79
|
#pendingUsage: Usage | undefined;
|
|
80
|
+
#lastAssistantUsage: Usage | undefined;
|
|
76
81
|
#waitingPoll: ToolExecutionComponent | null = null;
|
|
77
82
|
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
|
|
78
83
|
#expanded = false;
|
|
@@ -111,6 +116,7 @@ export class ChatTranscriptBuilder {
|
|
|
111
116
|
this.#readArgs.clear();
|
|
112
117
|
this.#readGroup = null;
|
|
113
118
|
this.#pendingUsage = undefined;
|
|
119
|
+
this.#lastAssistantUsage = undefined;
|
|
114
120
|
this.#waitingPoll = null;
|
|
115
121
|
this.#expandables = [];
|
|
116
122
|
this.container.dispose();
|
|
@@ -246,6 +252,14 @@ export class ChatTranscriptBuilder {
|
|
|
246
252
|
);
|
|
247
253
|
this.container.addChild(assistantComponent);
|
|
248
254
|
|
|
255
|
+
if (settings.get("display.cacheMissMarker")) {
|
|
256
|
+
const invalidation = detectCacheInvalidation(this.#lastAssistantUsage, message.usage);
|
|
257
|
+
if (invalidation) assistantComponent.setCacheInvalidation(invalidation);
|
|
258
|
+
}
|
|
259
|
+
if (message.usage.cacheRead + message.usage.cacheWrite + message.usage.input > 0) {
|
|
260
|
+
this.#lastAssistantUsage = message.usage;
|
|
261
|
+
}
|
|
262
|
+
|
|
249
263
|
const hasVisibleAssistantContent = message.content.some(
|
|
250
264
|
content =>
|
|
251
265
|
(content.type === "text" && canonicalizeMessage(content.text)) ||
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
3
|
-
import type { CompactionSummaryMessage, CustomMessage } from "../../session/messages";
|
|
3
|
+
import type { BranchSummaryMessage, CompactionSummaryMessage, CustomMessage } from "../../session/messages";
|
|
4
4
|
|
|
5
5
|
interface SummaryDividerOptions {
|
|
6
6
|
label: () => string;
|
|
@@ -156,6 +156,34 @@ export function createHandoffSummaryMessageComponent(
|
|
|
156
156
|
return component;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* A branch summary collapses a side branch back into the main line. Render it
|
|
161
|
+
* with the same slim divider as `/compact` and handoff rather than a `[branch]`
|
|
162
|
+
* box, so every history-collapse point reads as one consistent banner.
|
|
163
|
+
*/
|
|
164
|
+
export class BranchSummaryMessageComponent implements Component {
|
|
165
|
+
#divider: SummaryDividerComponent;
|
|
166
|
+
|
|
167
|
+
constructor(private readonly message: BranchSummaryMessage) {
|
|
168
|
+
this.#divider = new SummaryDividerComponent({
|
|
169
|
+
label: () => `${theme.icon.branch} branch`,
|
|
170
|
+
detailMarkdown: () => `**Branch summary**\n\n${this.message.summary}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setExpanded(expanded: boolean): void {
|
|
175
|
+
this.#divider.setExpanded(expanded);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
invalidate(): void {
|
|
179
|
+
this.#divider.invalidate();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
render(width: number): readonly string[] {
|
|
183
|
+
return this.#divider.render(width);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
159
187
|
function getCustomMessageText(message: CustomMessage<unknown>): string {
|
|
160
188
|
if (typeof message.content === "string") return message.content;
|
|
161
189
|
let firstText: string | undefined;
|
|
@@ -46,12 +46,15 @@ export class CustomMessageComponent extends Container {
|
|
|
46
46
|
}
|
|
47
47
|
this.removeChild(this.#box);
|
|
48
48
|
|
|
49
|
+
// The transcript dispatch routes both `custom` and legacy `hookMessage` roles here:
|
|
50
|
+
// tag hooks with the hook glyph, other injected messages with a neutral package.
|
|
51
|
+
const isHook = (this.message.role as string) === "hookMessage";
|
|
49
52
|
const custom = renderFramedMessage({
|
|
50
53
|
message: this.message,
|
|
51
54
|
box: this.#box,
|
|
52
55
|
expanded: this.#expanded,
|
|
53
56
|
customRenderer: this.customRenderer,
|
|
54
|
-
|
|
57
|
+
icon: isHook ? theme.icon.extensionHook : theme.icon.package,
|
|
55
58
|
});
|
|
56
59
|
|
|
57
60
|
if (custom) {
|
|
@@ -26,7 +26,7 @@ export class DynamicBorder implements Component {
|
|
|
26
26
|
if (this.#cachedLines && this.#cachedWidth === width) {
|
|
27
27
|
return this.#cachedLines;
|
|
28
28
|
}
|
|
29
|
-
const lines = [this.#color(theme.
|
|
29
|
+
const lines = [this.#color(theme.boxRound.horizontal.repeat(Math.max(1, width)))];
|
|
30
30
|
this.#cachedWidth = width;
|
|
31
31
|
this.#cachedLines = lines;
|
|
32
32
|
return lines;
|
|
@@ -380,7 +380,7 @@ class TwoColumnBody implements Component {
|
|
|
380
380
|
// Fill the full body height so the dashboard reads as a full-screen view.
|
|
381
381
|
const numLines = this.maxHeight;
|
|
382
382
|
const combined: string[] = [];
|
|
383
|
-
const separator = theme.fg("dim", ` ${theme.
|
|
383
|
+
const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `);
|
|
384
384
|
|
|
385
385
|
for (let i = 0; i < numLines; i++) {
|
|
386
386
|
const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
|
|
@@ -107,7 +107,7 @@ export class InspectorPanel implements Component {
|
|
|
107
107
|
#renderFilePreview(raw: unknown, width: number): string[] {
|
|
108
108
|
const lines: string[] = [];
|
|
109
109
|
lines.push(theme.fg("muted", "Preview:"));
|
|
110
|
-
lines.push(theme.fg("dim", theme.
|
|
110
|
+
lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
|
|
111
111
|
|
|
112
112
|
const content = this.#getContextFileContent(raw);
|
|
113
113
|
if (!content) {
|
|
@@ -165,7 +165,7 @@ export class InspectorPanel implements Component {
|
|
|
165
165
|
#renderToolArgs(raw: unknown, width: number): string[] {
|
|
166
166
|
const lines: string[] = [];
|
|
167
167
|
lines.push(theme.fg("muted", "Arguments:"));
|
|
168
|
-
lines.push(theme.fg("dim", theme.
|
|
168
|
+
lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
|
|
169
169
|
|
|
170
170
|
try {
|
|
171
171
|
const tool = raw as any;
|
|
@@ -207,7 +207,7 @@ export class InspectorPanel implements Component {
|
|
|
207
207
|
#renderSkillContent(raw: unknown, width: number): string[] {
|
|
208
208
|
const lines: string[] = [];
|
|
209
209
|
lines.push(theme.fg("muted", "Instruction:"));
|
|
210
|
-
lines.push(theme.fg("dim", theme.
|
|
210
|
+
lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
|
|
211
211
|
|
|
212
212
|
try {
|
|
213
213
|
const skill = raw as any;
|
|
@@ -236,7 +236,7 @@ export class InspectorPanel implements Component {
|
|
|
236
236
|
#renderMcpDetails(raw: unknown, width: number): string[] {
|
|
237
237
|
const lines: string[] = [];
|
|
238
238
|
lines.push(theme.fg("muted", "Connection:"));
|
|
239
|
-
lines.push(theme.fg("dim", theme.
|
|
239
|
+
lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
|
|
240
240
|
|
|
241
241
|
try {
|
|
242
242
|
const mcp = raw as any;
|
|
@@ -275,7 +275,7 @@ export class InspectorPanel implements Component {
|
|
|
275
275
|
// Show trigger pattern if present
|
|
276
276
|
if (ext.trigger) {
|
|
277
277
|
lines.push(theme.fg("muted", "Trigger:"));
|
|
278
|
-
lines.push(theme.fg("dim", theme.
|
|
278
|
+
lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
|
|
279
279
|
lines.push(` ${theme.fg("accent", ext.trigger)}`);
|
|
280
280
|
lines.push("");
|
|
281
281
|
}
|
|
@@ -123,7 +123,7 @@ class OutlinedList extends Container {
|
|
|
123
123
|
|
|
124
124
|
render(width: number): readonly string[] {
|
|
125
125
|
const borderColor = (text: string) => theme.fg("border", text);
|
|
126
|
-
const horizontal = borderColor(theme.
|
|
126
|
+
const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width)));
|
|
127
127
|
const innerWidth = Math.max(1, width - 2);
|
|
128
128
|
const content: string[] = [];
|
|
129
129
|
for (const line of this.#lines) {
|
|
@@ -134,7 +134,7 @@ class OutlinedList extends Container {
|
|
|
134
134
|
const wrappedLine = `${indent}${wrappedBody}`;
|
|
135
135
|
const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
|
|
136
136
|
content.push(
|
|
137
|
-
`${borderColor(theme.
|
|
137
|
+
`${borderColor(theme.boxRound.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxRound.vertical)}`,
|
|
138
138
|
);
|
|
139
139
|
}
|
|
140
140
|
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
export * from "./assistant-message";
|
|
3
3
|
export * from "./bash-execution";
|
|
4
4
|
export * from "./bordered-loader";
|
|
5
|
-
export * from "./branch-summary-message";
|
|
6
5
|
export * from "./compaction-summary-message";
|
|
7
6
|
export * from "./countdown-timer";
|
|
8
7
|
export * from "./custom-editor";
|
|
@@ -34,16 +34,18 @@ export interface RebuildFrameOptions<M extends FramedMessage> {
|
|
|
34
34
|
message: M;
|
|
35
35
|
box: Box;
|
|
36
36
|
expanded: boolean;
|
|
37
|
+
/** Icon glyph shown before the customType in the default header (e.g. a hook/extension icon). */
|
|
38
|
+
icon?: string;
|
|
37
39
|
/** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
|
|
38
40
|
collapseAfterLines?: number;
|
|
39
41
|
customRenderer?: FramedRenderer<M>;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
|
-
* Attempt the custom renderer; on failure or undefined return, populate
|
|
44
|
-
*
|
|
45
|
-
* undefined. When the custom renderer succeeds, return its Component
|
|
46
|
-
* caller can mount it and skip the default box.
|
|
45
|
+
* Attempt the custom renderer; on failure or undefined return, populate `box`
|
|
46
|
+
* with the default outlined card — an `icon customType` header + markdown body —
|
|
47
|
+
* and return undefined. When the custom renderer succeeds, return its Component
|
|
48
|
+
* so the caller can mount it and skip the default box.
|
|
47
49
|
*/
|
|
48
50
|
export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
|
|
49
51
|
if (opts.customRenderer) {
|
|
@@ -56,9 +58,11 @@ export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameO
|
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
opts.box.clear();
|
|
61
|
+
// Match the skill card: a subtle rounded outline so injected messages read as cards.
|
|
62
|
+
opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) });
|
|
59
63
|
|
|
60
|
-
const
|
|
61
|
-
opts.box.addChild(new Text(
|
|
64
|
+
const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType;
|
|
65
|
+
opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0));
|
|
62
66
|
opts.box.addChild(new Spacer(1));
|
|
63
67
|
|
|
64
68
|
let text: string;
|
|
@@ -1112,7 +1112,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
1112
1112
|
const menuWidth = contentWidth + (needsScroll ? 1 : 0);
|
|
1113
1113
|
|
|
1114
1114
|
this.#menuContainer.addChild(new Spacer(1));
|
|
1115
|
-
this.#menuContainer.addChild(new Text(theme.fg("border", theme.
|
|
1115
|
+
this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0));
|
|
1116
1116
|
if (showingThinking && this.#menuSelectedRole) {
|
|
1117
1117
|
this.#menuContainer.addChild(
|
|
1118
1118
|
new Text(
|
|
@@ -1152,7 +1152,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
1152
1152
|
|
|
1153
1153
|
this.#menuContainer.addChild(new Spacer(1));
|
|
1154
1154
|
this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0));
|
|
1155
|
-
this.#menuContainer.addChild(new Text(theme.fg("border", theme.
|
|
1155
|
+
this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxRound.horizontal.repeat(menuWidth)), 0, 0));
|
|
1156
1156
|
}
|
|
1157
1157
|
|
|
1158
1158
|
#getMenuVisibleCount(optionCount: number): number {
|