pi-compact-tools 0.6.0 → 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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Dong Hyeong Lee
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dong Hyeong Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -30,7 +30,7 @@ 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
- - Three-state `Ctrl+T` thinking view: summary, detail, then hidden
33
+ - Four-step `Ctrl+T` thinking cycle: summary, detail, summary, then hidden
34
34
  - Millisecond-precision duration with neutral status text
35
35
  - Per-tool initial compaction and global-toggle participation with `auto_compact: true` or `false`
36
36
  - Tool invocation details are always fully visible
@@ -140,12 +140,12 @@ Remove `"powershell"` on systems where it is unavailable.
140
140
  `Ctrl+T` cycles every thinking block through:
141
141
 
142
142
  ```text
143
- one-line summary + controls → summary + │-indented detail + controls → Thinking...
143
+ one-line summary → summary + │-indented detail → one-line summary → Thinking...
144
144
  ```
145
145
 
146
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
147
 
148
- 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.
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.
149
149
 
150
150
  A mouse click toggles one row's result:
151
151
 
@@ -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) {
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import {
5
5
  isKeyRelease,
6
+ isKeyRepeat,
6
7
  Key,
7
8
  matchesKey,
8
9
  stripTerminalSequences,
@@ -11,6 +12,7 @@ import {
11
12
  } from "@earendil-works/pi-tui";
12
13
 
13
14
  export type ThinkingView = "summary" | "detail" | "hidden";
15
+ export type ThinkingPhase = "summary" | "detail" | "summary-after-detail" | "hidden";
14
16
 
15
17
  type ThinkingRenderOptions = {
16
18
  styleSummary?: (summary: string, sectionIndex: number) => string;
@@ -19,12 +21,29 @@ type ThinkingRenderOptions = {
19
21
  styleControlPrefix?: (prefix: string) => string;
20
22
  };
21
23
 
22
- const NEXT_VIEW: Record<ThinkingView, ThinkingView> = {
24
+ const NEXT_PHASE: Record<ThinkingPhase, ThinkingPhase> = {
23
25
  summary: "detail",
24
- detail: "hidden",
26
+ detail: "summary-after-detail",
27
+ "summary-after-detail": "hidden",
25
28
  hidden: "summary",
26
29
  };
27
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
+
28
47
  function plainSummary(line: string): string {
29
48
  return line
30
49
  .replace(/^\s{0,3}#{1,6}\s+/u, "")
@@ -145,7 +164,7 @@ export function renderThinkingView(
145
164
  .join("\n\n");
146
165
  }
147
166
 
148
- function getInitialThinkingView(): ThinkingView {
167
+ function getInitialThinkingPhase(): ThinkingPhase {
149
168
  try {
150
169
  const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as {
151
170
  hideThinkingBlock?: unknown;
@@ -160,6 +179,8 @@ export function isThinkingStreamEvent(eventType: string): boolean {
160
179
  return eventType === "thinking_start" || eventType === "thinking_delta";
161
180
  }
162
181
 
182
+ const SHIMMER_FRAME_INTERVAL_MS = 80;
183
+
163
184
  function shimmer(summary: string, theme: Theme, startedAt: number): string {
164
185
  const characters = [...summary];
165
186
  if (characters.length === 0) return summary;
@@ -168,7 +189,7 @@ function shimmer(summary: string, theme: Theme, startedAt: number): string {
168
189
  // enters at its first character instead of inheriting another section's
169
190
  // wall-clock phase.
170
191
  const elapsed = Math.max(0, Date.now() - startedAt);
171
- const center = Math.floor(elapsed / 80) % (characters.length + sweepTail);
192
+ const center = Math.floor(elapsed / SHIMMER_FRAME_INTERVAL_MS) % (characters.length + sweepTail);
172
193
  let currentColor: Parameters<Theme["fg"]>[0] | undefined;
173
194
  let chunk = "";
174
195
  let output = "";
@@ -194,17 +215,15 @@ function shimmer(summary: string, theme: Theme, startedAt: number): string {
194
215
  }
195
216
 
196
217
  export class ThinkingCycleController {
197
- private view: ThinkingView = "summary";
218
+ private phase: ThinkingPhase = "summary";
198
219
  private unsubscribe: (() => void) | undefined;
199
220
  private theme: Theme | undefined;
200
221
  private thinkingStreamActive = false;
222
+ private refreshThinking: (() => void) | undefined;
201
223
  private readonly summarySweepStartedAt = new Map<number, number>();
202
224
 
203
225
  constructor(pi: ExtensionAPI) {
204
- pi.on("message_start", () => {
205
- this.thinkingStreamActive = false;
206
- this.summarySweepStartedAt.clear();
207
- });
226
+ pi.on("message_start", () => this.resetSweep());
208
227
  pi.on("message_update", (event) => {
209
228
  // `context.isStreaming` describes the whole assistant message. Tool-call
210
229
  // argument streaming therefore remains true after thinking has ended.
@@ -213,20 +232,14 @@ export class ThinkingCycleController {
213
232
  if (eventType === "thinking_start") this.summarySweepStartedAt.clear();
214
233
  this.thinkingStreamActive = isThinkingStreamEvent(eventType);
215
234
  });
216
- pi.on("message_end", () => {
217
- this.thinkingStreamActive = false;
218
- this.summarySweepStartedAt.clear();
219
- });
220
- pi.on("tool_call", () => {
221
- this.thinkingStreamActive = false;
222
- this.summarySweepStartedAt.clear();
223
- });
235
+ pi.on("message_end", () => this.resetSweep());
236
+ pi.on("tool_call", () => this.resetSweep());
224
237
 
225
238
  pi.registerMarkdownTransformer((markdown, context) => {
226
239
  if (context.messageType !== "assistant-thinking") return markdown;
227
240
  const theme = this.theme;
228
241
  const detailTextPrefix = theme?.getFgAnsi("thinkingText");
229
- return renderThinkingView(markdown, this.view, context.availableWidth, {
242
+ return renderThinkingView(markdown, thinkingViewForPhase(this.phase), context.availableWidth, {
230
243
  styleSummary: theme
231
244
  ? context.isStreaming && this.thinkingStreamActive
232
245
  ? (summary, sectionIndex) => {
@@ -254,6 +267,11 @@ export class ThinkingCycleController {
254
267
  });
255
268
  }
256
269
 
270
+ private resetSweep(): void {
271
+ this.thinkingStreamActive = false;
272
+ this.summarySweepStartedAt.clear();
273
+ }
274
+
257
275
  bind(ctx: ExtensionContext): void {
258
276
  this.unsubscribe?.();
259
277
  this.theme = ctx.ui.theme;
@@ -263,21 +281,29 @@ export class ThinkingCycleController {
263
281
  // host is hidden, Markdown transformers are not invoked at all. Pi wraps
264
282
  // this label in thinkingText, so an inner thinkingMax span is required to
265
283
  // keep it consistent with completed summary titles.
266
- this.view = getInitialThinkingView();
284
+ this.phase = getInitialThinkingPhase();
267
285
  const hiddenLabel = this.theme.fg("thinkingMax", this.theme.bold("Thinking..."));
268
- ctx.ui.setHiddenThinkingLabel(hiddenLabel);
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();
269
291
  this.unsubscribe = ctx.ui.onTerminalInput((data) => {
270
- if (!matchesKey(data, Key.ctrl("t"))) return undefined;
271
- if (isKeyRelease(data)) return { consume: true };
272
- this.view = NEXT_VIEW[this.view];
273
- if (this.view === "detail") {
274
- // summary -> detail stays host-visible; rebuild to apply the transformer.
275
- ctx.ui.setHiddenThinkingLabel(hiddenLabel);
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?.();
276
302
  return { consume: true };
277
303
  }
278
- // detail -> hidden and hidden -> summary intentionally reach Pi's existing
279
- // two-state handler. This keeps host visibility/settings synchronized and
280
- // clears built-in per-block click overrides without modifying Pi itself.
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.
281
307
  return undefined;
282
308
  });
283
309
  }
@@ -285,6 +311,7 @@ export class ThinkingCycleController {
285
311
  dispose(): void {
286
312
  this.unsubscribe?.();
287
313
  this.unsubscribe = undefined;
314
+ this.refreshThinking = undefined;
288
315
  this.thinkingStreamActive = false;
289
316
  this.summarySweepStartedAt.clear();
290
317
  this.theme = undefined;
@@ -28,6 +28,7 @@ import {
28
28
  import { isFullscreenMode, loadConfig } from "./compact-tools-config.ts";
29
29
  import { classifyToggleInput } from "./compact-tools-input.ts";
30
30
  import {
31
+ formatArgumentSummary,
31
32
  formatReadResultSummary,
32
33
  getArgumentDetails,
33
34
  getCallDetails,
@@ -100,12 +101,14 @@ function renderControls(
100
101
  running: boolean,
101
102
  isError: boolean,
102
103
  summary?: string,
104
+ argumentSummary?: string,
103
105
  ): Component {
104
106
  const duration = running && !runtime.animatesRows ? undefined : formatDuration(state);
105
107
  const status = running
106
108
  ? (duration ?? "Running")
107
109
  : `${isError ? "Failed" : "Done"}${duration ? ` in ${duration}` : ""}`;
108
110
  let details = theme.fg("borderAccent", status);
111
+ if (argumentSummary && !running) details += theme.fg("borderAccent", ` (${argumentSummary})`);
109
112
  if (summary) details += theme.fg("borderAccent", ` • ${summary}`);
110
113
  if (state.hasResult) {
111
114
  const clickAction = state.expanded ? "to hide" : "for result";
@@ -192,7 +195,8 @@ function renderFileResult(
192
195
  const container = new CachedContainer();
193
196
  appendFileResult(container, definition, state, output, theme, ctx.isError);
194
197
  const summary = name === "read" && !ctx.isError ? formatReadResultSummary(result) : undefined;
195
- 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));
196
200
  return container;
197
201
  }
198
202
 
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "pi-compact-tools",
3
- "version": "0.6.0",
4
- "description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "Dong Hyeong Lee",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/nedleeds/pi-compact-tools.git"
11
- },
12
- "homepage": "https://github.com/nedleeds/pi-compact-tools#readme",
13
- "bugs": {
14
- "url": "https://github.com/nedleeds/pi-compact-tools/issues"
15
- },
16
- "keywords": [
17
- "pi-package",
18
- "pi-coding-agent",
19
- "pi-extension",
20
- "terminal-ui",
21
- "theme"
22
- ],
23
- "files": [
24
- "extensions",
25
- "themes",
26
- "examples",
27
- "scripts",
28
- "schemas",
29
- "README.md",
30
- "LICENSE"
31
- ],
32
- "scripts": {
33
- "test": "tsx --test tests/*.test.ts",
34
- "check": "tsc --noEmit && npm test && npm run check:resources",
35
- "check:resources": "node scripts/check-resources.mjs",
36
- "prepublishOnly": "npm run check"
37
- },
38
- "pi": {
39
- "extensions": [
40
- "./extensions/compact-tools.ts"
41
- ],
42
- "themes": [
43
- "./themes/github-dark-pro.json"
44
- ],
45
- "image": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif"
46
- },
47
- "peerDependencies": {
48
- "@earendil-works/pi-coding-agent": "*",
49
- "@earendil-works/pi-tui": "*"
50
- },
51
- "devDependencies": {
52
- "@earendil-works/pi-coding-agent": "0.85.1",
53
- "@earendil-works/pi-tui": "0.85.1",
54
- "@types/node": "^24.0.0",
55
- "tsx": "^4.23.13",
56
- "typescript": "^5.9.0"
57
- },
58
- "engines": {
59
- "node": ">=22.19.0"
60
- },
61
- "publishConfig": {
62
- "access": "public"
63
- }
64
- }
1
+ {
2
+ "name": "pi-compact-tools",
3
+ "version": "0.6.1",
4
+ "description": "Compact, expandable UI for pi's built-in tools, bundled with a polished GitHub Dark theme.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Dong Hyeong Lee",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/nedleeds/pi-compact-tools.git"
11
+ },
12
+ "homepage": "https://github.com/nedleeds/pi-compact-tools#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/nedleeds/pi-compact-tools/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-coding-agent",
19
+ "pi-extension",
20
+ "terminal-ui",
21
+ "theme"
22
+ ],
23
+ "files": [
24
+ "extensions",
25
+ "themes",
26
+ "examples",
27
+ "scripts",
28
+ "schemas",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "test": "tsx --test tests/*.test.ts",
34
+ "check": "tsc --noEmit && npm test && npm run check:resources",
35
+ "check:resources": "node scripts/check-resources.mjs",
36
+ "prepublishOnly": "npm run check"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./extensions/compact-tools.ts"
41
+ ],
42
+ "themes": [
43
+ "./themes/github-dark-pro.json"
44
+ ],
45
+ "image": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/nerd-font-workflow-optimized.gif"
46
+ },
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-coding-agent": "*",
49
+ "@earendil-works/pi-tui": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-coding-agent": "0.85.1",
53
+ "@earendil-works/pi-tui": "0.85.1",
54
+ "@types/node": "^24.0.0",
55
+ "tsx": "^4.23.13",
56
+ "typescript": "^5.9.0"
57
+ },
58
+ "engines": {
59
+ "node": ">=22.19.0"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }