pi-compact-tools 0.5.9 → 0.6.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/README.md CHANGED
@@ -6,7 +6,7 @@ Compact, expandable rendering for Pi's built-in tools, with a polished GitHub Da
6
6
 
7
7
  ## Demo
8
8
 
9
- The recordings below use the optional [Nerd Font](#nerd-font-preset) preset. **The default configuration does not require a Nerd Font**: it uses portable emoji and standard Unicode icons instead, with the same spinner, timing, and expansion behavior.
9
+ The recordings below use the optional [Nerd Font](#nerd-font-spinner-preset) spinner preset. **The default configuration does not require a Nerd Font**.
10
10
 
11
11
  ### Compact tool workflow
12
12
 
@@ -14,11 +14,11 @@ Active, successful, and failed tool calls stay compact while preserving useful s
14
14
 
15
15
  ![Compact tool workflow with Nerd Font](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif)
16
16
 
17
- ### Spinner and duration indicators
17
+ ### Spinner and duration display
18
18
 
19
- The spinner animates during execution, then changes to a duration-aware completion icon.
19
+ The spinner animates during execution, then returns to a green success circle or red failure circle. Elapsed time remains visible in the neutral control color.
20
20
 
21
- ![Spinner and duration indicators](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/spinner-duration-optimized.gif)
21
+ ![Spinner and elapsed-time display](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/spinner-duration-optimized.gif)
22
22
 
23
23
  ### Initially visible edit diffs
24
24
 
@@ -30,11 +30,13 @@ Completed `edit` rows show their full diff initially by default. The per-tool `a
30
30
 
31
31
  - Compact rendering for `read`, `write`, `edit`, `bash`, `powershell`, `grep`, `find`, and `ls`
32
32
  - Animated configurable spinner in fullscreen TUI mode
33
- - Millisecond-precision duration and configurable completion icons
33
+ - Four-step `Ctrl+T` thinking cycle: summary, detail, summary, then hidden
34
+ - Millisecond-precision duration with neutral status text
34
35
  - Per-tool initial compaction and global-toggle participation with `auto_compact: true` or `false`
35
36
  - Tool invocation details are always fully visible
36
37
  - Click toggles one result between hidden and fully visible; `Ctrl+O` toggles all participating rows
37
38
  - Full output on expansion and initially visible edit diffs by default
39
+ - Width-cached expanded results and edit-diff processing for responsive fullscreen scrolling
38
40
  - Dark `│` and `└─` visual grouping
39
41
  - Distinct cool-blue thinking text
40
42
  - Windows, Unix, and classic Mac line-ending support
@@ -90,19 +92,13 @@ Default configuration:
90
92
  "spinner": {
91
93
  "frames": ["◐", "◓", "◑", "◒"],
92
94
  "intervalMs": 120
93
- },
94
- "durationIndicators": [
95
- { "underMs": 1000, "icon": "⚡️", "color": "warning" },
96
- { "underMs": 10000, "icon": "🔥", "color": "#D95C3F" },
97
- { "underMs": 30000, "icon": "○", "color": "#79C0FF" },
98
- { "icon": "⏳", "color": "#D2A8FF" }
99
- ]
95
+ }
100
96
  }
101
97
  ```
102
98
 
103
- These defaults use emoji and standard Unicode, so they work without a Nerd Font. Fast tools finish immediately; no artificial spinner delay is added.
99
+ Successful call titles use a green `●`, while failed call titles use a red `●`. `Done`, `Failed`, and elapsed time use the same neutral control color. Fast tools finish immediately; no artificial spinner delay is added.
104
100
 
105
- For a minimal blinking spinner that reuses the successful-tool circle, copy [`examples/compact-tools-2.json`](examples/compact-tools-2.json). Its frames use `● ● (blank) ● ●` at 120 ms intervals. The renderer's built-in frame dimming turns this into a fade-out/fade-in animation without Nerd Font glyphs. Duration indicators use a small `•` whose color changes by elapsed time; the fast tier uses the theme's warning color value `#E0A052` directly. Rename it to `compact-tools.json` or copy its contents to the active configuration path.
101
+ For a minimal blinking spinner, copy [`examples/compact-tools-2.json`](examples/compact-tools-2.json). Its frames use `● ● (blank) ● ●` at 120 ms intervals. The renderer's built-in frame dimming turns this into a fade-out/fade-in animation. Rename it to `compact-tools.json` or copy its contents to the active configuration path.
106
102
 
107
103
  `auto_compact` controls each tool's initial state and whether it participates in the global `Ctrl+O` toggle:
108
104
 
@@ -112,8 +108,6 @@ For a minimal blinking spinner that reuses the successful-tool circle, copy [`ex
112
108
 
113
109
  Unspecified entries inherit the previous configuration layer. By default, `read`, `write`, and `bash` start compact and participate in the global toggle, while `edit` starts expanded and remains unaffected so code diffs stay visible.
114
110
 
115
- `durationIndicators` must have ascending `underMs` values, with a final fallback entry that omits `underMs`. `color` is optional and accepts a supported theme color or six-digit hex value. Existing icon-only configurations remain compatible.
116
-
117
111
  To enable all Unix-compatible tools:
118
112
 
119
113
  ```json
@@ -124,9 +118,9 @@ To enable all Unix-compatible tools:
124
118
 
125
119
  Add `"powershell"` on Windows if desired.
126
120
 
127
- ## Nerd Font preset
121
+ ## Nerd Font spinner preset
128
122
 
129
- If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset enables all eight tools and the icons shown in the demos:
123
+ If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset enables all eight tools and the spinner shown in the demos:
130
124
 
131
125
  ```json
132
126
  {
@@ -135,21 +129,23 @@ If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset ena
135
129
  "spinner": {
136
130
  "frames": ["✽", "✻", "✲", "✢", "✲", "✻"],
137
131
  "intervalMs": 80
138
- },
139
- "durationIndicators": [
140
- { "underMs": 1000, "icon": "\uf0e7", "color": "warning" },
141
- { "underMs": 10000, "icon": "\uf490", "color": "#D95C3F" },
142
- { "underMs": 30000, "icon": "\udb81\udde3", "color": "#79C0FF" },
143
- { "icon": "\udb81\udd1f", "color": "#D2A8FF" }
144
- ]
132
+ }
145
133
  }
146
134
  ```
147
135
 
148
- The escaped code points remain readable on GitHub and are decoded to Nerd Font icons when JSON is loaded. Remove `"powershell"` on systems where it is unavailable.
136
+ Remove `"powershell"` on systems where it is unavailable.
149
137
 
150
138
  ## Controls
151
139
 
152
- Relevant invocation details stay visible, including paths, patterns, shell commands, and auxiliary arguments. Read calls omit offset and limit noise, then report the number of lines read after completion. Large payloads represented as results, such as write content and edit diffs, follow the result toggle.
140
+ `Ctrl+T` cycles every thinking block through:
141
+
142
+ ```text
143
+ one-line summary → summary + │-indented detail → one-line summary → Thinking...
144
+ ```
145
+
146
+ Standalone bold lines and Markdown headings split one provider thinking run into logical sections. Each section gets its own summary, optional `│`-indented detail, and control row; `Ctrl+T` cycles all sections together. While thinking streams, a text-color highlight sweeps across summaries using streaming updates; no spinner glyph or title circle is shown. The transformation is display-only; complete thinking remains unchanged in the session and model context.
147
+
148
+ Relevant invocation details stay visible, including paths, patterns, shell commands, and auxiliary arguments. After `grep`, `find`, or `ls` completes, auxiliary arguments appear in parentheses beside the `Done in …s` status instead of in the invocation line or a JSON block. Read calls omit offset and limit noise, then report the number of lines read after completion. Large payloads represented as results, such as write content and edit diffs, follow the result toggle.
153
149
 
154
150
  A mouse click toggles one row's result:
155
151
 
@@ -10,11 +10,5 @@
10
10
  "spinner": {
11
11
  "frames": ["●", "●", " ", "●", "●"],
12
12
  "intervalMs": 120
13
- },
14
- "durationIndicators": [
15
- { "underMs": 1000, "icon": "•", "color": "#E0A052" },
16
- { "underMs": 10000, "icon": "•", "color": "#D95C3F" },
17
- { "underMs": 30000, "icon": "•", "color": "#79C0FF" },
18
- { "icon": "•", "color": "#D2A8FF" }
19
- ]
13
+ }
20
14
  }
@@ -10,11 +10,5 @@
10
10
  "spinner": {
11
11
  "frames": ["◐", "◓", "◑", "◒"],
12
12
  "intervalMs": 120
13
- },
14
- "durationIndicators": [
15
- { "underMs": 1000, "icon": "⚡️", "color": "warning" },
16
- { "underMs": 10000, "icon": "🔥", "color": "#D95C3F" },
17
- { "underMs": 30000, "icon": "○", "color": "#79C0FF" },
18
- { "icon": "⏳", "color": "#D2A8FF" }
19
- ]
13
+ }
20
14
  }
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
- import { parseDurationIndicators } from "./compact-tools-core.ts";
5
4
  import {
6
5
  SUPPORTED_TOOL_SET,
7
6
  type CompactToolName,
@@ -27,12 +26,6 @@ export const DEFAULT_CONFIG: CompactToolsConfig = {
27
26
  frames: ["◐", "◓", "◑", "◒"],
28
27
  intervalMs: 120,
29
28
  },
30
- durationIndicators: [
31
- { underMs: 1_000, icon: "⚡️", color: "warning" },
32
- { underMs: 10_000, icon: "🔥", color: "#D95C3F" },
33
- { underMs: 30_000, icon: "○", color: "#79C0FF" },
34
- { icon: "⏳", color: "#D2A8FF" },
35
- ],
36
29
  };
37
30
 
38
31
  function isObject(value: unknown): value is JsonObject {
@@ -120,18 +113,7 @@ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: stri
120
113
  const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
121
114
  const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
122
115
  const spinner = parseSpinner(base.spinner, value.spinner, path);
123
- const parsedIndicators = value.durationIndicators === undefined
124
- ? undefined
125
- : parseDurationIndicators(value.durationIndicators);
126
- if (value.durationIndicators !== undefined && !parsedIndicators) {
127
- warn(path, "invalid durationIndicators; using previous values");
128
- }
129
- return {
130
- tools,
131
- auto_compact,
132
- spinner,
133
- durationIndicators: parsedIndicators ?? base.durationIndicators,
134
- };
116
+ return { tools, auto_compact, spinner };
135
117
  }
136
118
 
137
119
  function readJson(path: string): unknown {
@@ -1,58 +1,11 @@
1
- /** Pure configuration, timing, text, and color helpers for compact tool rendering. */
1
+ /** Pure timing and text helpers for compact tool rendering. */
2
2
 
3
- const DURATION_INDICATOR_COLORS = [
4
- "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning", "muted", "dim", "text",
5
- "thinkingText", "toolTitle", "toolOutput", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext", "bashMode",
6
- ] as const;
7
-
8
- export type ThemeDurationIndicatorColor = (typeof DURATION_INDICATOR_COLORS)[number];
9
- export type DurationIndicatorColor = ThemeDurationIndicatorColor | `#${string}`;
10
- export type DurationIndicatorConfig = {
11
- underMs?: number;
12
- icon: string;
13
- color?: DurationIndicatorColor;
14
- };
15
3
  export type RowStatus = "pending" | "running" | "success" | "error";
16
- const DURATION_INDICATOR_COLOR_SET = new Set<string>(DURATION_INDICATOR_COLORS);
17
- export const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
18
-
19
- type JsonObject = Record<string, unknown>;
20
-
21
- function isObject(value: unknown): value is JsonObject {
22
- return typeof value === "object" && value !== null && !Array.isArray(value);
23
- }
24
-
25
- function isPositiveInteger(value: unknown, minimum: number): value is number {
26
- return typeof value === "number" && Number.isInteger(value) && value >= minimum;
27
- }
28
-
29
- export function parseDurationIndicators(value: unknown): DurationIndicatorConfig[] | undefined {
30
- if (!Array.isArray(value) || value.length === 0) return undefined;
31
- const rules: DurationIndicatorConfig[] = [];
32
- let previousLimit = 0;
33
- for (const [index, item] of value.entries()) {
34
- if (!isObject(item) || typeof item.icon !== "string" || item.icon.length === 0) return undefined;
35
- if (
36
- item.color !== undefined &&
37
- (typeof item.color !== "string" ||
38
- (!DURATION_INDICATOR_COLOR_SET.has(item.color) && !HEX_COLOR_PATTERN.test(item.color)))
39
- ) return undefined;
40
- const color = item.color as DurationIndicatorColor | undefined;
41
- const isLast = index === value.length - 1;
42
- if (isLast && item.underMs === undefined) rules.push({ icon: item.icon, color });
43
- else if (isPositiveInteger(item.underMs, previousLimit + 1)) {
44
- previousLimit = item.underMs;
45
- rules.push({ underMs: item.underMs, icon: item.icon, color });
46
- } else return undefined;
47
- }
48
- return rules.at(-1)?.underMs === undefined ? rules : undefined;
49
- }
50
4
 
51
5
  export function normalizeLineEndings(value: string): string {
52
6
  return value.replace(/\r\n?|\n/g, "\n");
53
7
  }
54
8
 
55
-
56
9
  export function classifyCallStatus(isError: boolean, executionStarted: boolean, completed: boolean): RowStatus {
57
10
  if (isError) return "error";
58
11
  if (completed) return "success";
@@ -62,28 +15,3 @@ export function classifyCallStatus(isError: boolean, executionStarted: boolean,
62
15
  export function formatDurationMs(elapsedMs: number): string {
63
16
  return `${(elapsedMs / 1000).toFixed(3)}s`;
64
17
  }
65
-
66
- export function selectDurationIndicator(
67
- rules: readonly DurationIndicatorConfig[],
68
- elapsedMs: number | undefined,
69
- ): DurationIndicatorConfig | undefined {
70
- if (elapsedMs === undefined) return rules[0];
71
- return rules.find((rule) => rule.underMs === undefined || elapsedMs < rule.underMs);
72
- }
73
-
74
- function colorDistanceSquared(red: number, green: number, blue: number, candidate: readonly number[]): number {
75
- return (red - candidate[0]!) ** 2 + (green - candidate[1]!) ** 2 + (blue - candidate[2]!) ** 2;
76
- }
77
-
78
- export function rgbToAnsi256(red: number, green: number, blue: number): number {
79
- const cube = [red, green, blue].map((channel) => Math.round(channel / 51));
80
- const cubeRgb = cube.map((channel) => channel * 51);
81
- const cubeIndex = 16 + 36 * cube[0]! + 6 * cube[1]! + cube[2]!;
82
- const average = (red + green + blue) / 3;
83
- const grayStep = Math.max(0, Math.min(23, Math.round((average - 8) / 10)));
84
- const grayLevel = 8 + grayStep * 10;
85
- const grayRgb = [grayLevel, grayLevel, grayLevel];
86
- return colorDistanceSquared(red, green, blue, grayRgb) < colorDistanceSquared(red, green, blue, cubeRgb)
87
- ? 232 + grayStep
88
- : cubeIndex;
89
- }
@@ -7,13 +7,7 @@ function pathFrom(args: ToolArgs): string {
7
7
  return typeof value === "string" ? normalizeLineEndings(value) : "";
8
8
  }
9
9
 
10
- export function getCallDetails(name: string, args: ToolArgs): string {
11
- const path = pathFrom(args) || (name === "grep" || name === "find" || name === "ls" ? "." : "");
12
- if (name === "read") return path;
13
- if (name !== "grep" && name !== "find") return path;
14
- const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern) : "…";
15
- return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
16
- }
10
+ const INLINE_ARGUMENT_TOOLS = new Set(["grep", "find", "ls"]);
17
11
 
18
12
  function omitArgument(name: string, key: string): boolean {
19
13
  if (key === "path" || key === "file_path") return true;
@@ -22,8 +16,7 @@ function omitArgument(name: string, key: string): boolean {
22
16
  return (name === "grep" || name === "find") && key === "pattern";
23
17
  }
24
18
 
25
- export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
26
- if (name === "edit") return {};
19
+ function collectArgumentDetails(name: string, args: ToolArgs): ToolArgs {
27
20
  const details: ToolArgs = {};
28
21
  for (const [key, value] of Object.entries(args)) {
29
22
  if (value !== undefined && !omitArgument(name, key)) details[key] = value;
@@ -31,6 +24,37 @@ export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
31
24
  return details;
32
25
  }
33
26
 
27
+ function formatInlineValue(value: unknown): string {
28
+ if (typeof value === "string") {
29
+ const compact = normalizeLineEndings(value).replace(/\s+/gu, " ").trim();
30
+ return compact || '""';
31
+ }
32
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
33
+ return String(value);
34
+ }
35
+ return JSON.stringify(value) ?? String(value);
36
+ }
37
+
38
+ export function formatArgumentSummary(name: string, args: ToolArgs): string | undefined {
39
+ if (!INLINE_ARGUMENT_TOOLS.has(name)) return undefined;
40
+ const arguments_ = Object.entries(collectArgumentDetails(name, args));
41
+ if (arguments_.length === 0) return undefined;
42
+ return arguments_.map(([key, value]) => `${key} ${formatInlineValue(value)}`).join(" · ");
43
+ }
44
+
45
+ export function getCallDetails(name: string, args: ToolArgs): string {
46
+ const path = pathFrom(args) || (INLINE_ARGUMENT_TOOLS.has(name) ? "." : "");
47
+ if (name === "read") return path;
48
+ if (name !== "grep" && name !== "find") return path;
49
+ const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern) : "…";
50
+ return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
51
+ }
52
+
53
+ export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
54
+ if (name === "edit" || INLINE_ARGUMENT_TOOLS.has(name)) return {};
55
+ return collectArgumentDetails(name, args);
56
+ }
57
+
34
58
  export function getTextResult(result: AgentToolResult<unknown>): string {
35
59
  const parts: string[] = [];
36
60
  for (const item of result.content) {
@@ -1,6 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { Component } from "@earendil-works/pi-tui";
3
3
  import {
4
+ Container,
4
5
  sliceByColumn,
5
6
  stripTerminalSequences,
6
7
  visibleWidth,
@@ -13,7 +14,10 @@ class CachedComponent implements Component {
13
14
  private cachedWidth?: number;
14
15
  private cachedLines?: string[];
15
16
 
16
- constructor(private readonly renderLines: (width: number) => string[]) {}
17
+ constructor(
18
+ private readonly renderLines: (width: number) => string[],
19
+ private readonly invalidateSource?: () => void,
20
+ ) {}
17
21
 
18
22
  render(width: number): string[] {
19
23
  if (this.cachedWidth !== width || !this.cachedLines) {
@@ -24,11 +28,51 @@ class CachedComponent implements Component {
24
28
  }
25
29
 
26
30
  invalidate(): void {
31
+ this.invalidateSource?.();
27
32
  this.cachedWidth = undefined;
28
33
  this.cachedLines = undefined;
29
34
  }
30
35
  }
31
36
 
37
+ /** A Container that does not re-copy all child lines on every fullscreen scroll frame. */
38
+ export class CachedContainer extends Container {
39
+ private cachedWidth?: number;
40
+ private cachedLines?: string[];
41
+
42
+ private clearRenderCache(): void {
43
+ this.cachedWidth = undefined;
44
+ this.cachedLines = undefined;
45
+ }
46
+
47
+ override addChild(component: Component): void {
48
+ super.addChild(component);
49
+ this.clearRenderCache();
50
+ }
51
+
52
+ override removeChild(component: Component): void {
53
+ super.removeChild(component);
54
+ this.clearRenderCache();
55
+ }
56
+
57
+ override clear(): void {
58
+ super.clear();
59
+ this.clearRenderCache();
60
+ }
61
+
62
+ override render(width: number): string[] {
63
+ if (this.cachedWidth !== width || !this.cachedLines) {
64
+ this.cachedWidth = width;
65
+ this.cachedLines = super.render(width);
66
+ }
67
+ return this.cachedLines;
68
+ }
69
+
70
+ override invalidate(): void {
71
+ super.invalidate();
72
+ this.clearRenderCache();
73
+ }
74
+ }
75
+
32
76
  export function prefixedText(text: string, firstPrefix: string, continuationPrefix = firstPrefix): Component {
33
77
  const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
34
78
  const normalized = text.replace(/\t/g, " ");
@@ -74,45 +118,36 @@ export function renderToolCall(title: string, details: string | undefined, theme
74
118
  }
75
119
 
76
120
  export function wrapEditResult(component: Component, theme: Theme): Component {
77
- return {
78
- render(width: number) {
79
- const lines = component.render(Math.max(1, width - 3));
80
- const first = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
81
- if (first < 0) return [];
82
- const contentLines = lines.slice(first);
83
- const commonIndent = Math.min(
84
- ...contentLines
85
- .filter((line) => visibleWidth(line.trim()) > 0)
86
- .map((line) => stripTerminalSequences(line).match(/^ */)?.[0].length ?? 0),
87
- );
88
- const prefix = theme.fg("border", " │ ");
89
- return contentLines.map((line) => {
90
- const content = sliceByColumn(line, commonIndent, visibleWidth(line) - commonIndent, true);
91
- return `${prefix}${content}`;
92
- });
93
- },
94
- invalidate() {
95
- component.invalidate?.();
96
- },
97
- };
121
+ return new CachedComponent((width) => {
122
+ const lines = component.render(Math.max(1, width - 3));
123
+ const first = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
124
+ if (first < 0) return [];
125
+ const contentLines = lines.slice(first);
126
+ let commonIndent = Number.POSITIVE_INFINITY;
127
+ for (const line of contentLines) {
128
+ const plain = stripTerminalSequences(line);
129
+ if (plain.trim().length === 0) continue;
130
+ commonIndent = Math.min(commonIndent, plain.match(/^ */)?.[0].length ?? 0);
131
+ if (commonIndent === 0) break;
132
+ }
133
+ if (!Number.isFinite(commonIndent)) commonIndent = 0;
134
+ const prefix = theme.fg("border", " │ ");
135
+ return contentLines.map((line) => {
136
+ const content = sliceByColumn(line, commonIndent, Math.max(0, visibleWidth(line) - commonIndent), true);
137
+ return `${prefix}${content}`;
138
+ });
139
+ }, () => component.invalidate?.());
98
140
  }
99
141
 
100
142
  export function styleMultiline(text: string, style: (line: string) => string): string {
101
143
  return normalizeLineEndings(text).split("\n").map(style).join("\n");
102
144
  }
103
145
 
104
- function styleOutputLine(text: string, theme: Theme, isError: boolean): string {
105
- const color = isError ? "error" : "toolOutput";
106
- return text
107
- .split(/(⚡️?)/u)
108
- .map((part) => theme.fg(!isError && part.startsWith("⚡") ? "warning" : color, part))
109
- .join("");
110
- }
111
-
112
146
  export function renderOutput(output: string, theme: Theme, isError: boolean): Component | undefined {
113
147
  const normalized = normalizeLineEndings(output).trimEnd();
114
148
  if (!normalized) return undefined;
115
- const styled = normalized.split("\n").map((line) => styleOutputLine(line, theme, isError)).join("\n");
149
+ const color = isError ? "error" : "toolOutput";
150
+ const styled = normalized.split("\n").map((line) => theme.fg(color, line)).join("\n");
116
151
  return prefixedText(styled, theme.fg("border", " │ "));
117
152
  }
118
153
 
@@ -0,0 +1,319 @@
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ isKeyRelease,
6
+ isKeyRepeat,
7
+ Key,
8
+ matchesKey,
9
+ stripTerminalSequences,
10
+ truncateToWidth,
11
+ wrapTextWithAnsi,
12
+ } from "@earendil-works/pi-tui";
13
+
14
+ export type ThinkingView = "summary" | "detail" | "hidden";
15
+ export type ThinkingPhase = "summary" | "detail" | "summary-after-detail" | "hidden";
16
+
17
+ type ThinkingRenderOptions = {
18
+ styleSummary?: (summary: string, sectionIndex: number) => string;
19
+ styleDetailPrefix?: (prefix: string) => string;
20
+ controls?: string;
21
+ styleControlPrefix?: (prefix: string) => string;
22
+ };
23
+
24
+ const NEXT_PHASE: Record<ThinkingPhase, ThinkingPhase> = {
25
+ summary: "detail",
26
+ detail: "summary-after-detail",
27
+ "summary-after-detail": "hidden",
28
+ hidden: "summary",
29
+ };
30
+
31
+ export function thinkingViewForPhase(phase: ThinkingPhase): ThinkingView {
32
+ return phase === "summary-after-detail" ? "summary" : phase;
33
+ }
34
+
35
+ export function nextThinkingPhase(phase: ThinkingPhase): ThinkingPhase {
36
+ return NEXT_PHASE[phase];
37
+ }
38
+
39
+ export type ThinkingToggleInput = "toggle" | "repeat" | "release";
40
+
41
+ export function classifyThinkingToggleInput(data: string): ThinkingToggleInput | undefined {
42
+ if (!matchesKey(data, Key.ctrl("t"))) return undefined;
43
+ if (isKeyRelease(data)) return "release";
44
+ return isKeyRepeat(data) ? "repeat" : "toggle";
45
+ }
46
+
47
+ function plainSummary(line: string): string {
48
+ return line
49
+ .replace(/^\s{0,3}#{1,6}\s+/u, "")
50
+ .replace(/^\s*[-*+]\s+/u, "")
51
+ .replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
52
+ .replace(/[*_~`]/gu, "")
53
+ .replace(/\s+/gu, " ")
54
+ .trim();
55
+ }
56
+
57
+ type ThinkingSection = { summary: string; detail: string };
58
+
59
+ function splitThinkingSections(markdown: string, includeDetail: boolean): ThinkingSection[] {
60
+ const normalized = markdown.includes("\r") ? markdown.replace(/\r\n?/gu, "\n") : markdown;
61
+ const lines = normalized.split("\n");
62
+ let first = -1;
63
+ let summary = "";
64
+ for (let index = 0; index < lines.length; index++) {
65
+ summary = plainSummary(lines[index]!);
66
+ if (summary) {
67
+ first = index;
68
+ break;
69
+ }
70
+ }
71
+ if (first < 0) return [{ summary: "Thinking...", detail: "" }];
72
+ const sections: ThinkingSection[] = [];
73
+ let detail: string[] = [];
74
+ let inFence = false;
75
+ const flush = () => {
76
+ sections.push({ summary, detail: includeDetail ? detail.join("\n").trim() : "" });
77
+ detail = [];
78
+ };
79
+ for (let index = first + 1; index < lines.length; index++) {
80
+ const line = lines[index]!;
81
+ const fence = /^\s*(```|~~~)/u.test(line);
82
+ if (!inFence) {
83
+ const boldHeading = line.match(/^\s*\*\*(.+?)\*\*\s*$/u)?.[1];
84
+ const markdownHeading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/u)?.[1];
85
+ const heading = boldHeading ?? markdownHeading;
86
+ if (heading) {
87
+ flush();
88
+ summary = plainSummary(heading);
89
+ continue;
90
+ }
91
+ }
92
+ if (includeDetail) detail.push(line);
93
+ if (fence) inFence = !inFence;
94
+ }
95
+ flush();
96
+ return sections;
97
+ }
98
+
99
+ function compactDetail(
100
+ detail: string,
101
+ availableWidth: number,
102
+ stylePrefix: (prefix: string) => string,
103
+ ): string {
104
+ const output: string[] = [];
105
+ let inFence = false;
106
+ for (const logicalLine of detail.replace(/\t/gu, " ").split("\n")) {
107
+ const fence = /^\s*(```|~~~)/u.test(logicalLine);
108
+ if (!inFence && fence) {
109
+ // Fences need a real blockquote and blank delimiter to remain valid Markdown.
110
+ output.push("", `> ${logicalLine}`);
111
+ inFence = true;
112
+ continue;
113
+ }
114
+ if (inFence) {
115
+ output.push(logicalLine ? `> ${logicalLine}` : ">");
116
+ if (fence) {
117
+ inFence = false;
118
+ output.push("");
119
+ }
120
+ continue;
121
+ }
122
+ if (!logicalLine) {
123
+ output.push(`${stylePrefix("│")} `);
124
+ continue;
125
+ }
126
+ const wrapped = wrapTextWithAnsi(logicalLine, Math.max(1, availableWidth - 2));
127
+ for (const line of wrapped) {
128
+ output.push(`${stylePrefix("│ ")}${stripTerminalSequences(line)} `);
129
+ }
130
+ }
131
+ return output.join("\n");
132
+ }
133
+
134
+ function hardBreak(lines: string[]): string {
135
+ return lines.map((line, index) => (index < lines.length - 1 ? `${line} ` : line)).join("\n");
136
+ }
137
+
138
+ /** Display-only transformation; the original thinking remains unchanged in session/model context. */
139
+ export function renderThinkingView(
140
+ markdown: string,
141
+ view: ThinkingView,
142
+ availableWidth: number,
143
+ options: ThinkingRenderOptions = {},
144
+ ): string {
145
+ if (view === "hidden") return "Thinking...";
146
+ const controls = options.controls ?? "ctrl+t toggle • click to hide";
147
+ const styleControlPrefix = options.styleControlPrefix ?? ((prefix: string) => prefix);
148
+ return splitThinkingSections(markdown, view === "detail")
149
+ .map(({ summary, detail }, sectionIndex) => {
150
+ const fittedSummary = stripTerminalSequences(
151
+ truncateToWidth(summary, Math.max(1, availableWidth), "…"),
152
+ );
153
+ const title = options.styleSummary?.(fittedSummary, sectionIndex) ?? fittedSummary;
154
+ const controlLines = wrapTextWithAnsi(controls, Math.max(1, availableWidth - 3)).map(
155
+ (line, index) => `${index === 0 ? styleControlPrefix("└─ ") : " "}${line}`,
156
+ );
157
+ if (view === "detail" && detail) {
158
+ const styleDetailPrefix = options.styleDetailPrefix ?? ((prefix: string) => prefix);
159
+ const connector = `${styleDetailPrefix("│")} `;
160
+ return `${title} \n${connector}\n${compactDetail(detail, availableWidth, styleDetailPrefix)}\n${hardBreak(controlLines)}`;
161
+ }
162
+ return hardBreak([title, ...controlLines]);
163
+ })
164
+ .join("\n\n");
165
+ }
166
+
167
+ function getInitialThinkingPhase(): ThinkingPhase {
168
+ try {
169
+ const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as {
170
+ hideThinkingBlock?: unknown;
171
+ };
172
+ return settings.hideThinkingBlock === true ? "hidden" : "summary";
173
+ } catch {
174
+ return "summary";
175
+ }
176
+ }
177
+
178
+ export function isThinkingStreamEvent(eventType: string): boolean {
179
+ return eventType === "thinking_start" || eventType === "thinking_delta";
180
+ }
181
+
182
+ const SHIMMER_FRAME_INTERVAL_MS = 80;
183
+
184
+ function shimmer(summary: string, theme: Theme, startedAt: number): string {
185
+ const characters = [...summary];
186
+ if (characters.length === 0) return summary;
187
+ const sweepTail = 5;
188
+ // Each logical section receives its own start time. The highlight therefore
189
+ // enters at its first character instead of inheriting another section's
190
+ // wall-clock phase.
191
+ const elapsed = Math.max(0, Date.now() - startedAt);
192
+ const center = Math.floor(elapsed / SHIMMER_FRAME_INTERVAL_MS) % (characters.length + sweepTail);
193
+ let currentColor: Parameters<Theme["fg"]>[0] | undefined;
194
+ let chunk = "";
195
+ let output = "";
196
+ for (let index = 0; index < characters.length; index++) {
197
+ const distance = Math.abs(index - center);
198
+ // Stay entirely in the sky-blue family: the active baseline is blue,
199
+ // and the sweep fades through light blue into a near-white core.
200
+ const color = distance === 0
201
+ ? "userMessageText"
202
+ : distance === 1
203
+ ? "text"
204
+ : distance === 2
205
+ ? "thinkingMax"
206
+ : "thinkingXhigh";
207
+ if (currentColor !== undefined && color !== currentColor) {
208
+ output += theme.fg(currentColor, theme.bold(chunk));
209
+ chunk = "";
210
+ }
211
+ currentColor = color;
212
+ chunk += characters[index];
213
+ }
214
+ return currentColor === undefined ? output : output + theme.fg(currentColor, theme.bold(chunk));
215
+ }
216
+
217
+ export class ThinkingCycleController {
218
+ private phase: ThinkingPhase = "summary";
219
+ private unsubscribe: (() => void) | undefined;
220
+ private theme: Theme | undefined;
221
+ private thinkingStreamActive = false;
222
+ private refreshThinking: (() => void) | undefined;
223
+ private readonly summarySweepStartedAt = new Map<number, number>();
224
+
225
+ constructor(pi: ExtensionAPI) {
226
+ pi.on("message_start", () => this.resetSweep());
227
+ pi.on("message_update", (event) => {
228
+ // `context.isStreaming` describes the whole assistant message. Tool-call
229
+ // argument streaming therefore remains true after thinking has ended.
230
+ // Track the actual provider event so the sweep ends on thinking_end.
231
+ const eventType = event.assistantMessageEvent.type;
232
+ if (eventType === "thinking_start") this.summarySweepStartedAt.clear();
233
+ this.thinkingStreamActive = isThinkingStreamEvent(eventType);
234
+ });
235
+ pi.on("message_end", () => this.resetSweep());
236
+ pi.on("tool_call", () => this.resetSweep());
237
+
238
+ pi.registerMarkdownTransformer((markdown, context) => {
239
+ if (context.messageType !== "assistant-thinking") return markdown;
240
+ const theme = this.theme;
241
+ const detailTextPrefix = theme?.getFgAnsi("thinkingText");
242
+ return renderThinkingView(markdown, thinkingViewForPhase(this.phase), context.availableWidth, {
243
+ styleSummary: theme
244
+ ? context.isStreaming && this.thinkingStreamActive
245
+ ? (summary, sectionIndex) => {
246
+ let startedAt = this.summarySweepStartedAt.get(sectionIndex);
247
+ if (startedAt === undefined) {
248
+ startedAt = Date.now();
249
+ this.summarySweepStartedAt.set(sectionIndex, startedAt);
250
+ }
251
+ return shimmer(summary, theme, startedAt);
252
+ }
253
+ : (summary) => theme.fg("thinkingMax", theme.bold(summary))
254
+ : undefined,
255
+ // border styling resets the foreground; resume detail gray immediately.
256
+ styleDetailPrefix: theme && detailTextPrefix
257
+ ? (prefix) => theme.fg("border", prefix) + detailTextPrefix
258
+ : undefined,
259
+ controls: theme
260
+ ? theme.fg(
261
+ "borderAccent",
262
+ `${theme.italic("ctrl+t")} toggle • ${theme.italic("click")} to hide`,
263
+ )
264
+ : undefined,
265
+ styleControlPrefix: theme ? (prefix) => theme.fg("border", prefix) : undefined,
266
+ });
267
+ });
268
+ }
269
+
270
+ private resetSweep(): void {
271
+ this.thinkingStreamActive = false;
272
+ this.summarySweepStartedAt.clear();
273
+ }
274
+
275
+ bind(ctx: ExtensionContext): void {
276
+ this.unsubscribe?.();
277
+ this.theme = ctx.ui.theme;
278
+ this.thinkingStreamActive = false;
279
+ this.summarySweepStartedAt.clear();
280
+ // Match Pi's persisted host visibility after startup or /reload. When the
281
+ // host is hidden, Markdown transformers are not invoked at all. Pi wraps
282
+ // this label in thinkingText, so an inner thinkingMax span is required to
283
+ // keep it consistent with completed summary titles.
284
+ this.phase = getInitialThinkingPhase();
285
+ const hiddenLabel = this.theme.fg("thinkingMax", this.theme.bold("Thinking..."));
286
+ // Pi does not currently expose transcript invalidation directly. Updating
287
+ // this label rebuilds assistant Markdown and requests a render without
288
+ // changing the message or its hidden/visible state.
289
+ this.refreshThinking = () => ctx.ui.setHiddenThinkingLabel(hiddenLabel);
290
+ this.refreshThinking();
291
+ this.unsubscribe = ctx.ui.onTerminalInput((data) => {
292
+ const input = classifyThinkingToggleInput(data);
293
+ if (input === undefined) return undefined;
294
+ // Kitty-capable macOS terminals report held keys as repeat events. Treating
295
+ // those as presses can skip detail before the user sees it.
296
+ if (input === "release" || input === "repeat") return { consume: true };
297
+ this.phase = nextThinkingPhase(this.phase);
298
+ if (this.phase === "detail" || this.phase === "summary-after-detail") {
299
+ // Visible-to-visible transitions are owned by this extension. Rebuild the
300
+ // Markdown and prevent Pi's built-in two-state toggle from hiding it.
301
+ this.refreshThinking?.();
302
+ return { consume: true };
303
+ }
304
+ // summary-after-detail -> hidden and hidden -> summary intentionally reach
305
+ // Pi's existing two-state handler. This keeps visibility/settings in sync
306
+ // and clears built-in per-block click overrides.
307
+ return undefined;
308
+ });
309
+ }
310
+
311
+ dispose(): void {
312
+ this.unsubscribe?.();
313
+ this.unsubscribe = undefined;
314
+ this.refreshThinking = undefined;
315
+ this.thinkingStreamActive = false;
316
+ this.summarySweepStartedAt.clear();
317
+ this.theme = undefined;
318
+ }
319
+ }
@@ -1,6 +1,5 @@
1
1
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import type { Component } from "@earendil-works/pi-tui";
3
- import type { DurationIndicatorConfig } from "./compact-tools-core.ts";
4
3
 
5
4
  export const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"] as const;
6
5
  export const SUPPORTED_TOOL_SET = new Set<string>(SUPPORTED_TOOLS);
@@ -17,7 +16,6 @@ export interface CompactToolsConfig {
17
16
  frames: string[];
18
17
  intervalMs: number;
19
18
  };
20
- durationIndicators: DurationIndicatorConfig[];
21
19
  }
22
20
 
23
21
  export interface RowState {
@@ -18,22 +18,17 @@ import {
18
18
  createReadToolDefinition,
19
19
  createWriteToolDefinition,
20
20
  } from "@earendil-works/pi-coding-agent";
21
- import type { Component } from "@earendil-works/pi-tui";
22
- import { Container } from "@earendil-works/pi-tui";
21
+ import type { Component, Container } from "@earendil-works/pi-tui";
23
22
  import {
24
23
  classifyCallStatus,
25
24
  formatDurationMs,
26
- HEX_COLOR_PATTERN,
27
25
  normalizeLineEndings,
28
- rgbToAnsi256,
29
- selectDurationIndicator,
30
- type DurationIndicatorConfig,
31
26
  type RowStatus,
32
- type ThemeDurationIndicatorColor,
33
27
  } from "./compact-tools-core.ts";
34
28
  import { isFullscreenMode, loadConfig } from "./compact-tools-config.ts";
35
29
  import { classifyToggleInput } from "./compact-tools-input.ts";
36
30
  import {
31
+ formatArgumentSummary,
37
32
  formatReadResultSummary,
38
33
  getArgumentDetails,
39
34
  getCallDetails,
@@ -41,6 +36,7 @@ import {
41
36
  getTextResult,
42
37
  } from "./compact-tools-invocation.ts";
43
38
  import {
39
+ CachedContainer,
44
40
  prefixedText,
45
41
  renderArguments,
46
42
  renderOutput,
@@ -49,6 +45,7 @@ import {
49
45
  wrapEditResult,
50
46
  } from "./compact-tools-layout.ts";
51
47
  import { ToolRuntime } from "./compact-tools-runtime.ts";
48
+ import { ThinkingCycleController } from "./compact-tools-thinking.ts";
52
49
  import type {
53
50
  BuiltInDefinition,
54
51
  CompactToolName,
@@ -86,7 +83,7 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
86
83
  }
87
84
 
88
85
  function renderIndicator(theme: Theme, state: RowState, status: RowStatus): string {
89
- if (status === "error") return theme.fg("error", "⊗");
86
+ if (status === "error") return theme.fg("error", "●");
90
87
  if (status === "success") return theme.fg("success", "●");
91
88
  const frames = runtime.config.spinner.frames;
92
89
  const frameIndex = state.frame ?? 0;
@@ -98,42 +95,20 @@ function formatDuration(state: RowState): string | undefined {
98
95
  return formatDurationMs((state.endedAt ?? Date.now()) - state.startedAt);
99
96
  }
100
97
 
101
- function durationIndicator(state: RowState): DurationIndicatorConfig | undefined {
102
- const elapsed = state.startedAt !== undefined && state.endedAt !== undefined
103
- ? state.endedAt - state.startedAt
104
- : undefined;
105
- return selectDurationIndicator(runtime.config.durationIndicators, elapsed);
106
- }
107
-
108
- function styleDurationIcon(theme: Theme, indicator: DurationIndicatorConfig): string {
109
- const color = indicator.color ?? "dim";
110
- if (!HEX_COLOR_PATTERN.test(color)) {
111
- return theme.fg(color as ThemeDurationIndicatorColor, indicator.icon);
112
- }
113
- const red = Number.parseInt(color.slice(1, 3), 16);
114
- const green = Number.parseInt(color.slice(3, 5), 16);
115
- const blue = Number.parseInt(color.slice(5, 7), 16);
116
- const ansi = theme.getColorMode() === "truecolor"
117
- ? `\x1b[38;2;${red};${green};${blue}m`
118
- : `\x1b[38;5;${rgbToAnsi256(red, green, blue)}m`;
119
- return `${ansi}${indicator.icon}\x1b[39m`;
120
- }
121
-
122
98
  function renderControls(
123
99
  theme: Theme,
124
100
  state: RowState,
125
101
  running: boolean,
126
102
  isError: boolean,
127
103
  summary?: string,
104
+ argumentSummary?: string,
128
105
  ): Component {
129
106
  const duration = running && !runtime.animatesRows ? undefined : formatDuration(state);
130
- const indicator = !running && !isError ? durationIndicator(state) : undefined;
131
107
  const status = running
132
108
  ? (duration ?? "Running")
133
109
  : `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
134
- let details = indicator
135
- ? `${styleDurationIcon(theme, indicator)} ${theme.fg("borderAccent", status)}`
136
- : theme.fg("borderAccent", status);
110
+ let details = theme.fg("borderAccent", status);
111
+ if (argumentSummary && !running) details += theme.fg("borderAccent", ` (${argumentSummary})`);
137
112
  if (summary) details += theme.fg("borderAccent", ` • ${summary}`);
138
113
  if (state.hasResult) {
139
114
  const clickAction = state.expanded ? "to hide" : "for result";
@@ -163,7 +138,7 @@ function renderFileCall(
163
138
  const details = callDetails
164
139
  ? styleMultiline(callDetails, (line) => theme.fg("toolOutput", line))
165
140
  : undefined;
166
- const container = new Container();
141
+ const container = new CachedContainer();
167
142
  container.addChild(renderToolCall(renderCallTitle(name, theme, ctx, state), details, theme));
168
143
  const arguments_ = getArgumentDetails(name, args);
169
144
  if (Object.keys(arguments_).length > 0) container.addChild(renderArguments(arguments_, theme));
@@ -217,10 +192,11 @@ function renderFileResult(
217
192
  const output = getFileOutput(name, ctx.args, result, ctx.isError);
218
193
  runtime.setResultAvailable(state, name, name === "edit" || output.length > 0);
219
194
  updateEditResult(definition, result, options, theme, ctx, state);
220
- const container = new Container();
195
+ const container = new CachedContainer();
221
196
  appendFileResult(container, definition, state, output, theme, ctx.isError);
222
197
  const summary = name === "read" && !ctx.isError ? formatReadResultSummary(result) : undefined;
223
- container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, summary));
198
+ const argumentSummary = formatArgumentSummary(name, ctx.args);
199
+ container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, summary, argumentSummary));
224
200
  return container;
225
201
  }
226
202
 
@@ -250,7 +226,7 @@ function renderShellResult(
250
226
  const output = getTextResult(result);
251
227
  runtime.setResultAvailable(state, name, output.length > 0);
252
228
  if (!state.expanded) return renderControls(theme, state, options.isPartial, ctx.isError);
253
- const container = new Container();
229
+ const container = new CachedContainer();
254
230
  const component = renderOutput(output, theme, ctx.isError);
255
231
  if (component) container.addChild(component);
256
232
  container.addChild(renderControls(theme, state, options.isPartial, ctx.isError));
@@ -324,14 +300,17 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
324
300
  }
325
301
 
326
302
  export default function compactTools(pi: ExtensionAPI): void {
303
+ const thinkingCycle = new ThinkingCycleController(pi);
327
304
  configure(pi);
328
305
  pi.on("session_start", (event, ctx) => {
329
306
  if (event.reason !== "reload") runtime.clearTimings();
330
307
  configure(pi, ctx.cwd, ctx.isProjectTrusted());
331
308
  bindTerminalInput(ctx);
309
+ thinkingCycle.bind(ctx);
332
310
  });
333
311
  pi.on("resources_discover", (_event, ctx) => configure(pi, ctx.cwd, ctx.isProjectTrusted()));
334
312
  pi.on("session_shutdown", (event) => {
313
+ thinkingCycle.dispose();
335
314
  unsubscribeTerminalInput?.();
336
315
  unsubscribeTerminalInput = undefined;
337
316
  runtime.reset(event.reason !== "reload");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-compact-tools",
3
- "version": "0.5.9",
3
+ "version": "0.6.1",
4
4
  "description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,44 +61,6 @@
61
61
  "default": 120
62
62
  }
63
63
  }
64
- },
65
- "durationIndicators": {
66
- "description": "Ordered duration thresholds. The final item must omit underMs and acts as the fallback.",
67
- "type": "array",
68
- "minItems": 1,
69
- "items": {
70
- "type": "object",
71
- "additionalProperties": false,
72
- "required": ["icon"],
73
- "properties": {
74
- "underMs": {
75
- "type": "integer",
76
- "minimum": 1
77
- },
78
- "icon": {
79
- "type": "string",
80
- "minLength": 1
81
- },
82
- "color": {
83
- "description": "Optional semantic theme color or six-digit hex color used only for this duration icon.",
84
- "anyOf": [
85
- {
86
- "type": "string",
87
- "enum": [
88
- "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
89
- "muted", "dim", "text", "thinkingText", "toolTitle", "toolOutput", "toolDiffAdded",
90
- "toolDiffRemoved", "toolDiffContext", "bashMode"
91
- ]
92
- },
93
- {
94
- "type": "string",
95
- "pattern": "^#[0-9A-Fa-f]{6}$"
96
- }
97
- ],
98
- "default": "dim"
99
- }
100
- }
101
- }
102
64
  }
103
65
  }
104
66
  }
@@ -23,8 +23,11 @@ if (theme.name !== "github-dark-pro" || !theme.colors || typeof theme.colors !==
23
23
  if (!theme.colors.border || !theme.colors.dim || !theme.colors.muted) {
24
24
  throw new Error("github-dark-pro must define spinner and separator colors");
25
25
  }
26
- if (!theme.vars?.thinkingText) {
27
- throw new Error("github-dark-pro must define a distinct thinking text color");
26
+ if (!theme.vars?.thinkingText || !theme.colors.thinkingMax) {
27
+ throw new Error("github-dark-pro must define thinking text and summary colors");
28
+ }
29
+ if (!theme.colors.mdQuote || !theme.colors.mdCodeBlockBorder) {
30
+ throw new Error("github-dark-pro must define thinking detail and code fence colors");
28
31
  }
29
32
 
30
33
  const example = JSON.parse(
@@ -51,31 +54,17 @@ const blinkExample = JSON.parse(
51
54
  if (JSON.stringify(blinkExample.spinner?.frames) !== JSON.stringify(["●", "●", " ", "●", "●"])) {
52
55
  throw new Error("compact-tools-2 spinner must fade through repeated circles and a blank frame");
53
56
  }
54
- if (
55
- !Array.isArray(blinkExample.durationIndicators) ||
56
- blinkExample.durationIndicators.some((indicator) => indicator.icon !== "•") ||
57
- blinkExample.durationIndicators[0]?.color !== "#E0A052"
58
- ) {
59
- throw new Error("compact-tools-2 must use bullet duration indicators and the explicit warning hex color");
60
- }
61
- const indicators = example.durationIndicators;
62
- if (!Array.isArray(indicators) || indicators.length === 0 || indicators.at(-1)?.underMs !== undefined) {
63
- throw new Error("example durationIndicators must end with a fallback rule");
64
- }
65
- for (let index = 0; index < indicators.length - 1; index++) {
66
- if (!(indicators[index].underMs > (indicators[index - 1]?.underMs ?? 0))) {
67
- throw new Error("example durationIndicators thresholds must be ascending");
57
+ for (const [name, config] of [["compact-tools", example], ["compact-tools-2", blinkExample]]) {
58
+ if ("durationIndicators" in config) {
59
+ throw new Error(`${name} must not contain the removed durationIndicators setting`);
68
60
  }
69
61
  }
70
- if (indicators.some((indicator) => indicator.color !== undefined && typeof indicator.color !== "string")) {
71
- throw new Error("example duration indicator colors must be strings");
72
- }
73
62
 
74
63
  const schema = JSON.parse(
75
64
  await readFile(new URL("../schemas/compact-tools.schema.json", import.meta.url), "utf8"),
76
65
  );
77
- if (!schema.properties?.durationIndicators?.items?.properties?.color) {
78
- throw new Error("schema must describe durationIndicators.color");
66
+ if (schema.properties?.durationIndicators) {
67
+ throw new Error("schema must not expose the removed durationIndicators setting");
79
68
  }
80
69
  if (!schema.properties?.auto_compact?.properties?.edit) {
81
70
  throw new Error("schema must describe per-tool auto_compact policies");
@@ -36,7 +36,7 @@
36
36
  "muted": "muted",
37
37
  "dim": "dim",
38
38
  "text": "text",
39
- "thinkingText": "thinkingText",
39
+ "thinkingText": "toolText",
40
40
 
41
41
  "selectedBg": "surfaceMuted",
42
42
  "searchMatchBg": "#3B2E10",
@@ -59,8 +59,8 @@
59
59
  "mdLinkUrl": "dim",
60
60
  "mdCode": "inlineCode",
61
61
  "mdCodeBlock": "text",
62
- "mdCodeBlockBorder": "#484F58",
63
- "mdQuote": "quoteText",
62
+ "mdCodeBlockBorder": "#0D1117",
63
+ "mdQuote": "toolText",
64
64
  "mdQuoteBorder": "border",
65
65
  "mdHr": "borderSubtle",
66
66
  "mdListBullet": "muted",
@@ -85,7 +85,7 @@
85
85
  "thinkingMedium": "muted",
86
86
  "thinkingHigh": "blue",
87
87
  "thinkingXhigh": "blueSoft",
88
- "thinkingMax": "blueSoft",
88
+ "thinkingMax": "#9FCFF8",
89
89
  "bashMode": "green"
90
90
  },
91
91
  "export": {