pi-minimalist 0.2.0 → 0.2.2

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/src/tools.ts CHANGED
@@ -1,17 +1,14 @@
1
1
  /**
2
- * Tool vocabulary: how each tool describes itself on one line.
2
+ * Tool vocabulary: how each tool describes itself (a label and its details).
3
3
  *
4
4
  * Pure and deterministic — no components, no theme, no globals. Which tools get
5
5
  * compacted is `Config.compacts()`, not this file.
6
6
  *
7
7
  * `describeTool` returns the label and details SEPARATELY and must never join
8
- * them: the painter colors each part, and an earlier version that returned one
9
- * string forced the painter to split it back apart on the first space, silently
10
- * assuming no label ever contains one.
8
+ * them: the painter colors each part, and one joined string would
9
+ * force it to split on the first space, assuming no label contains one.
11
10
  */
12
11
 
13
- import type { Config } from "./config.ts";
14
-
15
12
  /** Native tools with a per-tool details extractor below. */
16
13
  // `as const` preserves literal names instead of widening every item to string.
17
14
  export const BUILT_INS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
@@ -19,7 +16,7 @@ export const BUILT_INS = ["read", "bash", "edit", "write", "grep", "find", "ls"]
19
16
  // `(typeof BUILT_INS)[number]` turns the tuple values into a union type.
20
17
  export type ToolName = (typeof BUILT_INS)[number];
21
18
 
22
- /** True for native tools with a hand-written one-line description. */
19
+ /** True for native tools with a hand-written description. */
23
20
  export function isBuiltIn(name: string): name is ToolName {
24
21
  return BUILT_INS.includes(name as ToolName);
25
22
  }
@@ -30,8 +27,8 @@ const mcpTools = new Set<string>();
30
27
  /**
31
28
  * Refresh after MCP adapter registration.
32
29
  *
33
- * No longer decides what gets COMPACTED (the blacklist does that). It is kept
34
- * because it decides what gets LABELED `mcp`, which is real information: a bare
30
+ * Decides what gets LABELED `mcp` (not what gets compacted; the blacklist does
31
+ * that). It is real information: a bare
35
32
  * `atlassian_getConfluencePage` row does not tell you it crossed an MCP server.
36
33
  */
37
34
  export function refreshMcpTools(tools: { name: string; sourceInfo: { path: string } }[]): void {
@@ -48,60 +45,80 @@ export function isMcpTool(name: string): boolean {
48
45
  /**
49
46
  * Collapse whitespace and truncate for one-line display.
50
47
  *
51
- * `max` is a DISPLAY budget in characters; `hardCap` is a sanity limit that
52
- * exists only so a multi-megabyte heredoc is not whitespace-collapsed, colored
53
- * and measured on every repaint. Neither is the final word on width —
54
- * `CompactLine` truncates at the real viewport column count.
48
+ * `max` is a DISPLAY budget in characters. It is not the final word on width —
49
+ * `CompactLine` truncates at the real viewport column count. There is no other
50
+ * cap: with no budget the whole value is kept.
55
51
  */
56
- export function compact(value: unknown, max = Number.POSITIVE_INFINITY, hardCap = 4000): string {
57
- const limit = Math.min(max, hardCap);
58
- // Slice BEFORE the regex so a huge string is never fully scanned. The extra
59
- // headroom leaves room for whitespace runs that collapse away.
52
+ export function compact(value: unknown, max = Number.POSITIVE_INFINITY): string {
60
53
  const raw = String(value ?? "");
61
- const sliced = Number.isFinite(limit) ? raw.slice(0, limit * 2 + 16) : raw.slice(0, hardCap * 2 + 16);
54
+ // With a finite budget, slice BEFORE the regex so a huge string is never fully
55
+ // scanned for a collapsed row. The extra headroom leaves room for whitespace
56
+ // runs that collapse away.
57
+ const sliced = Number.isFinite(max) ? raw.slice(0, max * 2 + 16) : raw;
62
58
  // Replacing every whitespace run prevents multi-line call rows.
63
59
  const text = sliced.replace(/\s+/g, " ").trim();
64
- return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
60
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
61
+ }
62
+
63
+ /**
64
+ * The value for an EXPANDED row: complete, with its line structure kept.
65
+ * `\r\n` becomes `\n`, tabs become two spaces, trailing whitespace goes, and
66
+ * blank lines at the start and end of the whole value go. Each line keeps its
67
+ * own leading indentation; `CompactLine` wraps every line under the details.
68
+ */
69
+ export function block(value: unknown): string {
70
+ const lines = String(value ?? "")
71
+ // \v, \f, \u2028, \u2029 and \u0085 would reach the terminal raw (the
72
+ // collapsed path folds them away with \s+).
73
+ .replace(/[\v\f\u2028\u2029\u0085]/g, " ")
74
+ .replace(/\r\n?/g, "\n")
75
+ .split("\n")
76
+ .map((line) => line.replace(/\t/g, " ").trimEnd());
77
+ while (lines.length > 0 && lines[0] === "") lines.shift();
78
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
79
+ return lines.join("\n");
80
+ }
81
+
82
+ /** A budget means a collapsed one-line row; no budget means an expanded row. */
83
+ function field(value: unknown, budget: number): string {
84
+ return Number.isFinite(budget) ? compact(value, budget) : block(value);
65
85
  }
66
86
 
67
87
  /** Minimum columns a composed field keeps, so it never vanishes entirely. */
68
88
  const MIN_FIELD = 12;
69
89
 
70
90
  export type DescribeOptions = {
71
- /** Expanded rows keep full-length details (still on ONE physical line). */
72
- expanded?: boolean;
73
91
  /**
74
92
  * Columns available for the details segment.
75
93
  *
76
94
  * Approximate on purpose: `CompactLine` performs the exact, ANSI-aware,
77
95
  * wide-character-aware truncation. This budget only decides WHICH field is
78
96
  * sacrificed when a composed detail (`/pattern/ in path`) cannot fit.
97
+ * Omitted (expanded rows): full-length details, wrapped by `CompactLine`.
79
98
  */
80
99
  budget?: number;
81
- config?: Config;
82
100
  };
83
101
 
84
102
  /**
85
- * The one-line description of a call: `read` + `src/a.ts:1-50`, `bash` +
103
+ * The description of a call: `read` + `src/a.ts:1-50`, `bash` +
86
104
  * `go test ./...`, or a third-party tool's name plus its most identifying
87
105
  * argument.
88
106
  */
89
107
  export function describeTool(name: string, args: any, options: DescribeOptions = {}): { label: string; details: string } {
90
- const hardCap = options.config?.get("maxDetailChars") ?? 4000;
91
108
  const budget = options.budget ?? Number.POSITIVE_INFINITY;
92
109
  const a = args ?? {};
93
110
 
94
- if (name === "mcp") return { label: "mcp", details: mcpDetails(a, budget, hardCap) };
95
- if (name === "mcpScript") return { label: "mcpScript", details: compact(a.code, budget, hardCap) };
111
+ if (name === "mcp") return { label: "mcp", details: mcpDetails(a, budget) };
112
+ if (name === "mcpScript") return { label: "mcpScript", details: field(a.code, budget) };
96
113
  if (name.startsWith("mcp__")) {
97
114
  const server = name.slice("mcp__".length);
98
- const tool = compact(a.tool, budget, hardCap);
115
+ const tool = field(a.tool, budget);
99
116
  return { label: "mcp", details: tool ? `${tool} @ ${server}` : `@ ${server}` };
100
117
  }
101
118
  if (isMcpTool(name)) return { label: "mcp", details: name };
102
- if (isBuiltIn(name)) return { label: name, details: builtInDetails(name, a, options, hardCap) };
119
+ if (isBuiltIn(name)) return { label: name, details: builtInDetails(name, a, options) };
103
120
  // Any other tool: show the name plus whichever argument identifies the call.
104
- return { label: name, details: genericDetails(a, budget, hardCap) };
121
+ return { label: name, details: genericDetails(a, budget) };
105
122
  }
106
123
 
107
124
  /**
@@ -120,10 +137,10 @@ export function summaryName(name: string, args: any): string {
120
137
  return name;
121
138
  }
122
139
 
123
- function mcpDetails(args: any, budget: number, hardCap: number): string {
124
- if (args.tool) return compact(args.tool, budget, hardCap);
140
+ function mcpDetails(args: any, budget: number): string {
141
+ if (args.tool) return field(args.tool, budget);
125
142
  for (const key of ["search", "describe", "connect", "action"] as const) {
126
- if (args[key]) return `${key} ${compact(args[key], budget, hardCap)}`;
143
+ if (args[key]) return `${key} ${field(args[key], budget)}`;
127
144
  }
128
145
  return "";
129
146
  }
@@ -139,20 +156,19 @@ const GENERIC_KEYS = [
139
156
  "tool", "action", "id", "target", "message", "text", "prompt",
140
157
  ] as const;
141
158
 
142
- function genericDetails(args: any, budget: number, hardCap: number): string {
159
+ function genericDetails(args: any, budget: number): string {
143
160
  for (const key of GENERIC_KEYS) {
144
161
  const value = args[key];
145
- // Only scalars: an object would serialize into noise on a one-line row.
162
+ // Only scalars: an object would serialize into noise on a row.
146
163
  if (value !== undefined && value !== null && typeof value !== "object") {
147
- const text = compact(value, budget, hardCap);
164
+ const text = field(value, budget);
148
165
  if (text) return text;
149
166
  }
150
167
  }
151
168
  return "";
152
169
  }
153
170
 
154
- function builtInDetails(name: ToolName, args: any, options: DescribeOptions, hardCap: number): string {
155
- const expanded = options.expanded === true;
171
+ function builtInDetails(name: ToolName, args: any, options: DescribeOptions): string {
156
172
  const budget = options.budget ?? Number.POSITIVE_INFINITY;
157
173
  // Paths are shown verbatim (no ~/ home abbreviation — it was decorative and
158
174
  // cost a homedir() call per render).
@@ -161,10 +177,9 @@ function builtInDetails(name: ToolName, args: any, options: DescribeOptions, har
161
177
  switch (name) {
162
178
  case "bash":
163
179
  // ONE field, so no budget split is needed: CompactLine truncates at the
164
- // real viewport width. An earlier version also capped this at 100
165
- // characters, which threw away ~89 usable columns on a 200-column
166
- // terminal. Newlines still collapse so a heredoc cannot escape the row.
167
- return compact(args.command, expanded ? Number.POSITIVE_INFINITY : budget, hardCap);
180
+ // real viewport width (no character cap). A collapsed row folds newlines into spaces; an expanded one
181
+ // keeps its lines (see `field`).
182
+ return field(args.command, budget);
168
183
  case "read": {
169
184
  // Show the line range when offset/limit were used, mirroring the built-in
170
185
  // read tool's "path:start-end" notation.
@@ -186,7 +201,7 @@ function builtInDetails(name: ToolName, args: any, options: DescribeOptions, har
186
201
  const wrapper = name === "grep" ? 2 : 0; // the two slashes in /pattern/
187
202
  const overhead = where.length + " in ".length + wrapper;
188
203
  const room = Number.isFinite(budget) ? Math.max(MIN_FIELD, budget - overhead) : Number.POSITIVE_INFINITY;
189
- const pattern = compact(args.pattern, room, hardCap);
204
+ const pattern = field(args.pattern, room);
190
205
  return name === "grep" ? `/${pattern}/ in ${where}` : `${pattern} in ${where}`;
191
206
  }
192
207
  case "ls":