pi-compact-tools 0.5.0 → 0.5.7

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
@@ -22,7 +22,7 @@ The spinner animates during execution, then changes to a duration-aware completi
22
22
 
23
23
  ### Initially visible edit diffs
24
24
 
25
- Completed `edit` rows show their full diff initially by default. The per-tool `auto_compact` setting controls which tools start in their compact summary state without disabling click or `Ctrl+O` interaction.
25
+ Completed `edit` rows show their full diff initially by default. The per-tool `auto_compact` setting controls which tools start with their result hidden without disabling click or `Ctrl+O` interaction.
26
26
 
27
27
  ![Edit diff](https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/assets/edit-diff.gif)
28
28
 
@@ -32,8 +32,9 @@ Completed `edit` rows show their full diff initially by default. The per-tool `a
32
32
  - Animated configurable spinner in fullscreen TUI mode
33
33
  - Millisecond-precision duration and configurable completion icons
34
34
  - Per-tool initial compaction and global-toggle participation with `auto_compact: true` or `false`
35
- - Click cycles one row through meaningful detail levels; `Ctrl+O` expands or collapses all participating rows
36
- - Short previews, full output, and initially visible edit diffs by default
35
+ - Tool invocation details are always fully visible
36
+ - Click toggles one result between hidden and fully visible; `Ctrl+O` toggles all participating rows
37
+ - Full output on expansion and initially visible edit diffs by default
37
38
  - Dark `│` and `└─` visual grouping
38
39
  - Distinct cool-blue thinking text
39
40
  - Windows, Unix, and classic Mac line-ending support
@@ -86,7 +87,6 @@ Default configuration:
86
87
  "edit": false,
87
88
  "bash": true
88
89
  },
89
- "previewLines": 10,
90
90
  "spinner": {
91
91
  "frames": ["◐", "◓", "◑", "◒"],
92
92
  "intervalMs": 120
@@ -106,9 +106,9 @@ For a minimal blinking spinner that reuses the successful-tool circle, copy [`ex
106
106
 
107
107
  `auto_compact` controls each tool's initial state and whether it participates in the global `Ctrl+O` toggle:
108
108
 
109
- - `true` starts at the compact summary and participates in `Ctrl+O`.
110
- - `false` starts at the most detailed available level and is unaffected by `Ctrl+O`.
111
- - Clicking any compact-rendered row still cycles that row through its available levels.
109
+ - `true` starts with the result hidden and participates in `Ctrl+O`.
110
+ - `false` starts with the full result visible and is unaffected by `Ctrl+O`.
111
+ - Clicking any compact-rendered row still toggles that row's result.
112
112
 
113
113
  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
114
 
@@ -132,7 +132,6 @@ If your terminal uses a [Nerd Font](https://www.nerdfonts.com/), this preset ena
132
132
  {
133
133
  "$schema": "https://raw.githubusercontent.com/nedleeds/pi-compact-tools/main/schemas/compact-tools.schema.json",
134
134
  "tools": ["read", "write", "edit", "bash", "powershell", "grep", "find", "ls"],
135
- "previewLines": 10,
136
135
  "spinner": {
137
136
  "frames": ["✽", "✻", "✲", "✢", "✲", "✻"],
138
137
  "intervalMs": 80
@@ -150,10 +149,12 @@ The escaped code points remain readable on GitHub and are decoded to Nerd Font i
150
149
 
151
150
  ## Controls
152
151
 
153
- A mouse click cycles one row through its available content:
152
+ Every tool's complete invocation is always visible, including paths, patterns, shell commands, and auxiliary arguments. Large payloads represented as results, such as write content and edit diffs, follow the result toggle.
153
+
154
+ A mouse click toggles one row's result:
154
155
 
155
156
  ```text
156
- summary → arguments → output preview → full output → summary
157
+ result hidden ↔ full result
157
158
  ```
158
159
 
159
160
  `Ctrl+O` operates on all rows whose tool has `auto_compact: true`:
@@ -7,7 +7,6 @@
7
7
  "edit": false,
8
8
  "bash": true
9
9
  },
10
- "previewLines": 10,
11
10
  "spinner": {
12
11
  "frames": ["●", "●", " ", "●", "●"],
13
12
  "intervalMs": 120
@@ -7,7 +7,6 @@
7
7
  "edit": false,
8
8
  "bash": true
9
9
  },
10
- "previewLines": 10,
11
10
  "spinner": {
12
11
  "frames": ["◐", "◓", "◑", "◒"],
13
12
  "intervalMs": 120
@@ -0,0 +1,163 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { parseDurationIndicators } from "./compact-tools-core.ts";
5
+ import {
6
+ SUPPORTED_TOOL_SET,
7
+ type CompactToolName,
8
+ type CompactToolsConfig,
9
+ } from "./compact-tools-types.ts";
10
+
11
+ const CONFIG_FILE = "compact-tools.json";
12
+ type JsonObject = Record<string, unknown>;
13
+
14
+ export const DEFAULT_CONFIG: CompactToolsConfig = {
15
+ tools: ["read", "write", "edit", "bash"],
16
+ auto_compact: {
17
+ read: true,
18
+ write: true,
19
+ edit: false,
20
+ bash: true,
21
+ powershell: true,
22
+ grep: true,
23
+ find: true,
24
+ ls: true,
25
+ },
26
+ spinner: {
27
+ frames: ["◐", "◓", "◑", "◒"],
28
+ intervalMs: 120,
29
+ },
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
+ };
37
+
38
+ function isObject(value: unknown): value is JsonObject {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+
42
+ function warn(path: string, message: string): void {
43
+ console.error(`[compact-tools] ${path}: ${message}`);
44
+ }
45
+
46
+ function isIntegerInRange(value: unknown, minimum: number, maximum: number): value is number {
47
+ return typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
48
+ }
49
+
50
+ function isNonEmptyStringArray(value: unknown): value is string[] {
51
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
52
+ }
53
+
54
+ function parseTools(value: unknown, path: string): CompactToolName[] | undefined {
55
+ if (!Array.isArray(value)) {
56
+ warn(path, "tools must be an array; using previous values");
57
+ return undefined;
58
+ }
59
+ const tools: CompactToolName[] = [];
60
+ const invalid: unknown[] = [];
61
+ for (const item of value) {
62
+ if (typeof item === "string" && SUPPORTED_TOOL_SET.has(item)) tools.push(item as CompactToolName);
63
+ else invalid.push(item);
64
+ }
65
+ if (invalid.length > 0) warn(path, `ignoring unsupported tools: ${invalid.map(String).join(", ")}`);
66
+ return [...new Set(tools)];
67
+ }
68
+
69
+ function parseAutoCompact(
70
+ base: CompactToolsConfig["auto_compact"],
71
+ value: unknown,
72
+ path: string,
73
+ ): CompactToolsConfig["auto_compact"] {
74
+ if (value === undefined) return base;
75
+ if (!isObject(value)) {
76
+ warn(path, "auto_compact must be an object; using previous values");
77
+ return base;
78
+ }
79
+ const result = { ...base };
80
+ for (const [name, enabled] of Object.entries(value)) {
81
+ if (!SUPPORTED_TOOL_SET.has(name)) {
82
+ warn(path, `ignoring unsupported auto_compact tool: ${name}`);
83
+ } else if (typeof enabled !== "boolean") {
84
+ warn(path, `auto_compact.${name} must be true or false; using previous value`);
85
+ } else {
86
+ result[name as CompactToolName] = enabled;
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+
92
+ function parseSpinner(
93
+ base: CompactToolsConfig["spinner"],
94
+ value: unknown,
95
+ path: string,
96
+ ): CompactToolsConfig["spinner"] {
97
+ if (value === undefined) return base;
98
+ if (!isObject(value)) {
99
+ warn(path, "spinner must be an object; using previous values");
100
+ return base;
101
+ }
102
+ const frames = value.frames;
103
+ const intervalMs = value.intervalMs;
104
+ const validFrames = isNonEmptyStringArray(frames);
105
+ const validInterval = isIntegerInRange(intervalMs, 40, 5_000);
106
+ if (frames !== undefined && !validFrames) warn(path, "spinner.frames must be a non-empty string array");
107
+ if (intervalMs !== undefined && !validInterval) warn(path, "spinner.intervalMs must be 40–5000");
108
+ return {
109
+ frames: validFrames ? frames : base.frames,
110
+ intervalMs: validInterval ? intervalMs : base.intervalMs,
111
+ };
112
+ }
113
+
114
+ export function mergeConfig(base: CompactToolsConfig, value: unknown, path: string): CompactToolsConfig {
115
+ if (value === undefined) return base;
116
+ if (!isObject(value)) {
117
+ warn(path, "expected a JSON object; using previous values");
118
+ return base;
119
+ }
120
+ const tools = value.tools === undefined ? base.tools : (parseTools(value.tools, path) ?? base.tools);
121
+ const auto_compact = parseAutoCompact(base.auto_compact, value.auto_compact, path);
122
+ 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
+ };
135
+ }
136
+
137
+ function readJson(path: string): unknown {
138
+ if (!existsSync(path)) return undefined;
139
+ try {
140
+ return JSON.parse(readFileSync(path, "utf8"));
141
+ } catch (error) {
142
+ warn(path, error instanceof Error ? error.message : String(error));
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ export function loadConfig(cwd?: string, projectTrusted = false): CompactToolsConfig {
148
+ const globalPath = join(getAgentDir(), CONFIG_FILE);
149
+ const globalConfig = mergeConfig(DEFAULT_CONFIG, readJson(globalPath), globalPath);
150
+ if (!cwd || !projectTrusted) return globalConfig;
151
+ const projectPath = join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
152
+ return mergeConfig(globalConfig, readJson(projectPath), projectPath);
153
+ }
154
+
155
+ export function isFullscreenMode(argv = process.argv): boolean {
156
+ for (let index = 0; index < argv.length; index++) {
157
+ const argument = argv[index];
158
+ if (argument === "--tui-mode") return argv[index + 1] === "fullscreen";
159
+ if (argument?.startsWith("--tui-mode=")) return argument.slice("--tui-mode=".length) === "fullscreen";
160
+ }
161
+ const settings = readJson(join(getAgentDir(), "settings.json"));
162
+ return isObject(settings) && settings.tuiMode === "fullscreen";
163
+ }
@@ -13,15 +13,6 @@ export type DurationIndicatorConfig = {
13
13
  color?: DurationIndicatorColor;
14
14
  };
15
15
  export type RowStatus = "pending" | "running" | "success" | "error";
16
- export type ExpandableLevelState = {
17
- level?: number;
18
- levels: readonly number[];
19
- };
20
-
21
- export function shouldExpandAll(rows: readonly ExpandableLevelState[]): boolean {
22
- return rows.some(({ level, levels }) => level !== levels.at(-1));
23
- }
24
-
25
16
  const DURATION_INDICATOR_COLOR_SET = new Set<string>(DURATION_INDICATOR_COLORS);
26
17
  export const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
27
18
 
@@ -0,0 +1,8 @@
1
+ import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ export type ToggleInput = "toggle" | "release";
4
+
5
+ export function classifyToggleInput(data: string): ToggleInput | undefined {
6
+ if (!matchesKey(data, Key.ctrl("o"))) return undefined;
7
+ return isKeyRelease(data) ? "release" : "toggle";
8
+ }
@@ -0,0 +1,59 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeLineEndings } from "./compact-tools-core.ts";
3
+ import type { ToolArgs } from "./compact-tools-types.ts";
4
+
5
+ function pathFrom(args: ToolArgs): string {
6
+ const value = args.path ?? args.file_path;
7
+ return typeof value === "string" ? normalizeLineEndings(value) : "";
8
+ }
9
+
10
+ export function formatReadCallDetails(path: string, args: ToolArgs): string {
11
+ const options: string[] = [];
12
+ if (typeof args.offset === "number") options.push(`offset: ${args.offset}`);
13
+ if (typeof args.limit === "number") options.push(`limit: ${args.limit}`);
14
+ return options.length === 0 ? path : `${path} (${options.join(", ")})`;
15
+ }
16
+
17
+ export function getCallDetails(name: string, args: ToolArgs): string {
18
+ const path = pathFrom(args) || (name === "grep" || name === "find" || name === "ls" ? "." : "");
19
+ if (name === "read") return formatReadCallDetails(path, args);
20
+ if (name !== "grep" && name !== "find") return path;
21
+ const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern) : "…";
22
+ return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
23
+ }
24
+
25
+ function omitArgument(name: string, key: string): boolean {
26
+ if (key === "path" || key === "file_path") return true;
27
+ if (name === "read" && (key === "offset" || key === "limit")) return true;
28
+ if (name === "write" && key === "content") return true;
29
+ return (name === "grep" || name === "find") && key === "pattern";
30
+ }
31
+
32
+ export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
33
+ if (name === "edit") return {};
34
+ const details: ToolArgs = {};
35
+ for (const [key, value] of Object.entries(args)) {
36
+ if (value !== undefined && !omitArgument(name, key)) details[key] = value;
37
+ }
38
+ return details;
39
+ }
40
+
41
+ export function getTextResult(result: AgentToolResult<unknown>): string {
42
+ const parts: string[] = [];
43
+ for (const item of result.content) {
44
+ if (item.type === "text") parts.push(item.text);
45
+ }
46
+ return normalizeLineEndings(parts.join("\n")).trimEnd();
47
+ }
48
+
49
+ export function getFileOutput(
50
+ name: string,
51
+ args: ToolArgs,
52
+ result: AgentToolResult<unknown>,
53
+ isError: boolean,
54
+ ): string {
55
+ if (isError || name === "read" || name === "grep" || name === "find" || name === "ls") {
56
+ return getTextResult(result);
57
+ }
58
+ return name === "write" ? String(args.content ?? "") : "";
59
+ }
@@ -0,0 +1,123 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import {
4
+ sliceByColumn,
5
+ stripTerminalSequences,
6
+ visibleWidth,
7
+ wrapTextWithAnsi,
8
+ } from "@earendil-works/pi-tui";
9
+ import { normalizeLineEndings } from "./compact-tools-core.ts";
10
+ import type { ToolArgs } from "./compact-tools-types.ts";
11
+
12
+ class CachedComponent implements Component {
13
+ private cachedWidth?: number;
14
+ private cachedLines?: string[];
15
+
16
+ constructor(private readonly renderLines: (width: number) => string[]) {}
17
+
18
+ render(width: number): string[] {
19
+ if (this.cachedWidth !== width || !this.cachedLines) {
20
+ this.cachedWidth = width;
21
+ this.cachedLines = this.renderLines(width);
22
+ }
23
+ return this.cachedLines;
24
+ }
25
+
26
+ invalidate(): void {
27
+ this.cachedWidth = undefined;
28
+ this.cachedLines = undefined;
29
+ }
30
+ }
31
+
32
+ export function prefixedText(text: string, firstPrefix: string, continuationPrefix = firstPrefix): Component {
33
+ const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
34
+ const normalized = text.replace(/\t/g, " ");
35
+ return new CachedComponent((width) => {
36
+ const lines = wrapTextWithAnsi(normalized, Math.max(1, width - prefixWidth));
37
+ return lines.map((line, index) => `${index === 0 ? firstPrefix : continuationPrefix}${line}`);
38
+ });
39
+ }
40
+
41
+ export function hardWrapTextWithAnsi(text: string, width: number): string[] {
42
+ const safeWidth = Math.max(1, width);
43
+ const wrapped: string[] = [];
44
+ for (const logicalLine of text.replace(/\t/g, " ").split("\n")) {
45
+ const lineWidth = visibleWidth(logicalLine);
46
+ if (lineWidth === 0) {
47
+ wrapped.push("");
48
+ continue;
49
+ }
50
+ let offset = 0;
51
+ while (offset < lineWidth) {
52
+ if (offset > 0) {
53
+ const remainder = sliceByColumn(logicalLine, offset, lineWidth - offset, true);
54
+ const whitespace = stripTerminalSequences(remainder).match(/^\s+/u)?.[0] ?? "";
55
+ offset += visibleWidth(whitespace);
56
+ if (offset >= lineWidth) break;
57
+ }
58
+ wrapped.push(sliceByColumn(logicalLine, offset, safeWidth, true));
59
+ offset += safeWidth;
60
+ }
61
+ }
62
+ return wrapped;
63
+ }
64
+
65
+ export function renderToolCall(title: string, details: string | undefined, theme: Theme): Component {
66
+ const text = details ? `${title} ${details}` : title;
67
+ const firstPrefix = " ";
68
+ const continuationPrefix = theme.fg("border", " │ ");
69
+ const prefixWidth = visibleWidth(continuationPrefix);
70
+ return new CachedComponent((width) => {
71
+ const lines = hardWrapTextWithAnsi(text, width - prefixWidth);
72
+ return lines.map((line, index) => `${index === 0 ? firstPrefix : continuationPrefix}${line}`);
73
+ });
74
+ }
75
+
76
+ 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
+ };
98
+ }
99
+
100
+ export function styleMultiline(text: string, style: (line: string) => string): string {
101
+ return normalizeLineEndings(text).split("\n").map(style).join("\n");
102
+ }
103
+
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
+ export function renderOutput(output: string, theme: Theme, isError: boolean): Component | undefined {
113
+ const normalized = normalizeLineEndings(output).trimEnd();
114
+ if (!normalized) return undefined;
115
+ const styled = normalized.split("\n").map((line) => styleOutputLine(line, theme, isError)).join("\n");
116
+ return prefixedText(styled, theme.fg("border", " │ "));
117
+ }
118
+
119
+ export function renderArguments(args: ToolArgs, theme: Theme): Component {
120
+ const json = JSON.stringify(args, null, 2) ?? "{}";
121
+ const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
122
+ return prefixedText(styled, theme.fg("border", " │ "));
123
+ }