@oh-my-pi/pi-coding-agent 16.0.11 → 16.1.0
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 +22 -0
- package/dist/cli.js +2872 -2908
- package/dist/types/config/settings-schema.d.ts +14 -4
- 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 +34 -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 +16 -4
- 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/render.ts +7 -7
- 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 +84 -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/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 +90 -13
- 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/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) {
|
|
@@ -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,84 @@
|
|
|
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, and turns
|
|
29
|
+
* that reused any cache.
|
|
30
|
+
*/
|
|
31
|
+
export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
|
|
32
|
+
if (!prev) return undefined;
|
|
33
|
+
const prevFootprint = prev.cacheRead + prev.cacheWrite;
|
|
34
|
+
if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined;
|
|
35
|
+
// Any cache reuse this turn means the prefix survived (at least partly).
|
|
36
|
+
if (current.cacheRead > 0) return undefined;
|
|
37
|
+
const reprocessedTokens = current.cacheWrite + current.input;
|
|
38
|
+
if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined;
|
|
39
|
+
return { reprocessedTokens };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CACHE_INVALIDATION_RULE_WIDTH = 10;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Slim left-aligned divider rendered above an assistant turn whose request lost
|
|
46
|
+
* the prompt cache. Mirrors the compaction divider's banner styling but spans
|
|
47
|
+
* only a short rule plus label (not the full width) and carries no expandable
|
|
48
|
+
* detail:
|
|
49
|
+
*
|
|
50
|
+
* ────────── ⊘ cache miss · 50.9k tokens
|
|
51
|
+
*/
|
|
52
|
+
export class CacheInvalidationMarkerComponent implements Component {
|
|
53
|
+
#cache?: { width: number; lines: string[] };
|
|
54
|
+
|
|
55
|
+
constructor(private readonly info: CacheInvalidation) {}
|
|
56
|
+
|
|
57
|
+
invalidate(): void {
|
|
58
|
+
this.#cache = undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render(width: number): readonly string[] {
|
|
62
|
+
width = Math.max(1, width);
|
|
63
|
+
if (this.#cache?.width === width) {
|
|
64
|
+
return this.#cache.lines;
|
|
65
|
+
}
|
|
66
|
+
const lines = ["", this.#divider(width), ""];
|
|
67
|
+
this.#cache = { width, lines };
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#divider(width: number): string {
|
|
72
|
+
const icon = theme.icon.cacheMiss;
|
|
73
|
+
const head = icon ? `${icon} cache miss` : "cache miss";
|
|
74
|
+
const tokens = this.info.reprocessedTokens;
|
|
75
|
+
const label = tokens > 0 ? `${head} ${theme.sep.dot.trim()} ${formatNumber(tokens)} tokens` : head;
|
|
76
|
+
const labelWidth = Bun.stringWidth(label, { countAnsiEscapeCodes: false });
|
|
77
|
+
const ruleWidth = Math.min(CACHE_INVALIDATION_RULE_WIDTH, width - labelWidth - 1);
|
|
78
|
+
if (ruleWidth < 1) {
|
|
79
|
+
// Too narrow to frame — emit the bare label.
|
|
80
|
+
return theme.fg("muted", label);
|
|
81
|
+
}
|
|
82
|
+
return `${theme.fg("dim", theme.tree.horizontal.repeat(ruleWidth))} ${theme.fg("muted", label)}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -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 {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared box-drawing chrome for fullscreen overlays (the `/copy` picker, the
|
|
3
|
-
* plan-review overlay, …). Every helper paints with `theme.
|
|
4
|
-
* the `border`/`accent` theme
|
|
3
|
+
* plan-review overlay, …). Every helper paints with `theme.boxRound` glyphs
|
|
4
|
+
* (rounded corners, sharp tee/cross junctions) and the `border`/`accent` theme
|
|
5
|
+
* colors so all outlined overlays read identically.
|
|
5
6
|
*/
|
|
6
7
|
import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
7
8
|
import { theme } from "../theme/theme";
|
|
@@ -23,7 +24,7 @@ function paint(s: string): string {
|
|
|
23
24
|
|
|
24
25
|
/** Top border with an optional accent-colored title inset into the rule. */
|
|
25
26
|
export function topBorder(width: number, title: string): string {
|
|
26
|
-
const box = theme.
|
|
27
|
+
const box = theme.boxRound;
|
|
27
28
|
const inner = Math.max(0, width - 2);
|
|
28
29
|
if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
|
|
29
30
|
const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
|
|
@@ -37,18 +38,18 @@ export function topBorder(width: number, title: string): string {
|
|
|
37
38
|
|
|
38
39
|
/** A horizontal rule with left/right tees, splitting overlay sections. */
|
|
39
40
|
export function divider(width: number): string {
|
|
40
|
-
const box = theme.
|
|
41
|
+
const box = theme.boxRound;
|
|
41
42
|
return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
export function bottomBorder(width: number): string {
|
|
45
|
-
const box = theme.
|
|
46
|
+
const box = theme.boxRound;
|
|
46
47
|
return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** Wrap pre-styled content in vertical borders with single-column insets. */
|
|
50
51
|
export function row(content: string, width: number): string {
|
|
51
|
-
const box = theme.
|
|
52
|
+
const box = theme.boxRound;
|
|
52
53
|
return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -70,7 +71,7 @@ export function splitBodyWidth(width: number, sidebarWidth: number): number {
|
|
|
70
71
|
|
|
71
72
|
/** Top border carrying the title, split by a `┬` over the column divider. */
|
|
72
73
|
export function topBorderSplit(width: number, title: string, sidebarWidth: number): string {
|
|
73
|
-
const box = theme.
|
|
74
|
+
const box = theme.boxRound;
|
|
74
75
|
const dividerCol = splitDividerCol(sidebarWidth);
|
|
75
76
|
const leftLen = Math.max(0, dividerCol - 1);
|
|
76
77
|
const rightLen = Math.max(0, width - 2 - dividerCol);
|
|
@@ -90,7 +91,7 @@ export function topBorderSplit(width: number, title: string, sidebarWidth: numbe
|
|
|
90
91
|
|
|
91
92
|
/** Section rule that closes the sidebar column with a `┴` over the divider. */
|
|
92
93
|
export function dividerSplit(width: number, sidebarWidth: number): string {
|
|
93
|
-
const box = theme.
|
|
94
|
+
const box = theme.boxRound;
|
|
94
95
|
const dividerCol = splitDividerCol(sidebarWidth);
|
|
95
96
|
const leftLen = Math.max(0, dividerCol - 1);
|
|
96
97
|
const rightLen = Math.max(0, width - 2 - dividerCol);
|
|
@@ -101,7 +102,7 @@ export function dividerSplit(width: number, sidebarWidth: number): string {
|
|
|
101
102
|
|
|
102
103
|
/** A two-column content row: `│ sidebar │ body │`, each inset by one column. */
|
|
103
104
|
export function splitRow(sidebar: string, body: string, width: number, sidebarWidth: number): string {
|
|
104
|
-
const box = theme.
|
|
105
|
+
const box = theme.boxRound;
|
|
105
106
|
const bodyWidth = splitBodyWidth(width, sidebarWidth);
|
|
106
107
|
const bar = paint(box.vertical);
|
|
107
108
|
return `${bar} ${fit(sidebar, sidebarWidth)} ${bar} ${fit(body, bodyWidth)} ${bar}`;
|