@xzzpig/pi-tidy 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1104 -0
- package/FORK-CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +416 -2
- package/fork/defaults.ts +77 -0
- package/fork/index.ts +2 -0
- package/fork/install.ts +58 -0
- package/fork/thinking-row.ts +221 -0
- package/package.json +68 -3
- package/src/clean-copy.ts +313 -0
- package/src/clipboard.ts +29 -0
- package/src/config.ts +292 -0
- package/src/draw/frame-draw.ts +307 -0
- package/src/draw/icons.ts +2014 -0
- package/src/draw/paint.ts +57 -0
- package/src/frame.ts +20 -0
- package/src/hover.ts +185 -0
- package/src/index.ts +322 -0
- package/src/live.ts +91 -0
- package/src/messages/bash-mode-frame.ts +112 -0
- package/src/messages/error-lines.ts +331 -0
- package/src/messages/extension-notice.ts +204 -0
- package/src/messages/hex-swatches.ts +245 -0
- package/src/messages/message-frame.ts +515 -0
- package/src/messages/user-message.ts +207 -0
- package/src/theme-access.ts +26 -0
- package/src/tools/background-jobs.ts +617 -0
- package/src/tools/bash-highlight.ts +330 -0
- package/src/tools/codemode-body.ts +602 -0
- package/src/tools/result-preview.ts +56 -0
- package/src/tools/split-diff.ts +606 -0
- package/src/tools/tool-frame.ts +1019 -0
- package/src/tools/tool-group-view.ts +1160 -0
- package/src/tools/tool-groups.ts +706 -0
- package/src/tools/tool-lines.ts +621 -0
- package/src/tools/write-preview.ts +81 -0
- package/src/turns/code-blocks.ts +273 -0
- package/src/turns/turn-lifecycle.ts +43 -0
- package/src/turns/turn-state.ts +257 -0
- package/src/turns/turn-view.ts +504 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { HeaderStyle } from "./draw/frame-draw.ts";
|
|
5
|
+
import type { UserMessageStyle } from "./messages/user-message.ts";
|
|
6
|
+
import type { CollapseTurns, FinalReplyStyle } from "./turns/turn-state.ts";
|
|
7
|
+
|
|
8
|
+
export interface MessageBorderColors {
|
|
9
|
+
/** The final reply's frame, an open run's rail and its `▾` marker. Also `#rrggbb`. */
|
|
10
|
+
assistant: string;
|
|
11
|
+
user: string;
|
|
12
|
+
compaction: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
skill: string;
|
|
15
|
+
custom: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TidyConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
highlightBash: boolean;
|
|
21
|
+
/** Give message boxes the same rounded transparent frame as tool boxes. */
|
|
22
|
+
frameMessages: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* How user messages look: "frame" (the rounded message frame, hugging the
|
|
25
|
+
* text, the user icon in its top border), "rail" (rules above and below, an
|
|
26
|
+
* accent `│` in front of the text), "rules" (only the rules) or "off" (pi's own).
|
|
27
|
+
*/
|
|
28
|
+
userMessageStyle: UserMessageStyle;
|
|
29
|
+
/** Alignment of a framed user message and its label; a rail's label only. */
|
|
30
|
+
userMessageAlign: "left" | "right";
|
|
31
|
+
/**
|
|
32
|
+
* The widest a framed user message's text renders before it wraps: `"auto"`
|
|
33
|
+
* (the whole width), a fraction of the width (`0.7`), or a column count
|
|
34
|
+
* (`88`). The frame hugs the narrower text.
|
|
35
|
+
*/
|
|
36
|
+
userMessageMaxWidth: number | "auto";
|
|
37
|
+
/** Show a user message's time next to its icon. */
|
|
38
|
+
userMessageTime: boolean;
|
|
39
|
+
/** Fold a whole run live or only after settlement. */
|
|
40
|
+
collapseTurns: CollapseTurns;
|
|
41
|
+
/**
|
|
42
|
+
* The settled final assistant reply, independently of folding: "frame" (a
|
|
43
|
+
* full-width rounded frame), "rail" (rules above and below, a `│` in front of
|
|
44
|
+
* the text), "rules" (only the rules) or "off" (pi's own).
|
|
45
|
+
*/
|
|
46
|
+
finalReplyStyle: FinalReplyStyle;
|
|
47
|
+
/** `auto`, or a pattern of `YYYY M MM D DD H HH mm ss` and literal text. */
|
|
48
|
+
messageTimeFormat: string;
|
|
49
|
+
/** What a message's top edge looks like: `inline` or `split`. */
|
|
50
|
+
messageHeaderStyle: HeaderStyle;
|
|
51
|
+
/** Rows between each rule and the text of a "rail" or "rules" user message. */
|
|
52
|
+
userMessagePaddingY: number;
|
|
53
|
+
/** "rail" and "rules" user message colors: theme color names or `#rrggbb`. */
|
|
54
|
+
userMessageColors: { rail: string; rule: string };
|
|
55
|
+
/** Prepend Nerd Font icons (tool kind / file type / message kind). */
|
|
56
|
+
icons: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Collapsed bash result preview height, in visual lines (default 1). pi's own
|
|
59
|
+
* bash renderer hard-codes 5 and has no setting; `ctrl+o` still shows everything.
|
|
60
|
+
*/
|
|
61
|
+
bashResultLines: number;
|
|
62
|
+
/**
|
|
63
|
+
* Collapsed write preview height, in visual lines (default 10, pi's own
|
|
64
|
+
* number). `0` hides the preview until the box is expanded.
|
|
65
|
+
*/
|
|
66
|
+
writeLines: number;
|
|
67
|
+
/**
|
|
68
|
+
* Collapse one assistant segment's tool calls into a single summary line.
|
|
69
|
+
* Click the line for one row per call, click a row for the full box.
|
|
70
|
+
*/
|
|
71
|
+
groupTools: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Start each new tool group open (`▾` header, one row per call) instead of
|
|
74
|
+
* folded to its summary line. Only the group's first level: clicking the
|
|
75
|
+
* header or `ctrl+o`'s collapse-all stays folded.
|
|
76
|
+
*/
|
|
77
|
+
groupOpen: boolean;
|
|
78
|
+
/** Rows a group's call may take: 2 lets one that does not fit go on to a second. */
|
|
79
|
+
toolRowLines: 1 | 2;
|
|
80
|
+
/**
|
|
81
|
+
* Tools that never fold into a group and keep pi's own box (edit/write by
|
|
82
|
+
* default: the diff is the point). They also end the group before them.
|
|
83
|
+
*/
|
|
84
|
+
alwaysShowTools: string[];
|
|
85
|
+
/** Track background jobs (pi-processes) on the row of the call that started them. */
|
|
86
|
+
backgroundJobs: boolean;
|
|
87
|
+
/** Hide a job's notification messages when its row already shows them. */
|
|
88
|
+
hideJobNotifications: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* pi reports a failed request once per retry attempt, as a plain row with the
|
|
91
|
+
* provider's raw body. Fold the repeats into the first row as `×N` and frame
|
|
92
|
+
* it like any other box. Only error rows: warnings and notes stay as pi
|
|
93
|
+
* drew them.
|
|
94
|
+
*/
|
|
95
|
+
dedupeErrors: boolean;
|
|
96
|
+
/** Take tidy's frames and rails out of what pi's fullscreen selection copies. */
|
|
97
|
+
cleanCopy: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Render an edit's diff as two panes (old | new) with line numbers, a
|
|
100
|
+
* coloured bar and a tinted row per changed line. Also decides whether a
|
|
101
|
+
* reply's `diff` code block gets panes. Falls back to pi's unified diff when
|
|
102
|
+
* the box is too narrow (see `splitDiffMinWidth`).
|
|
103
|
+
*/
|
|
104
|
+
splitDiff: boolean;
|
|
105
|
+
/** Box width below which the split becomes unreadable; pi's diff is used. */
|
|
106
|
+
splitDiffMinWidth: number;
|
|
107
|
+
/**
|
|
108
|
+
* Diff body rows shown while the box is collapsed (default 20). The window
|
|
109
|
+
* follows the first changed line, not the top of the file; `ctrl+o` shows all.
|
|
110
|
+
* edit has no such budget of its own — pi draws the whole diff.
|
|
111
|
+
*/
|
|
112
|
+
diffLines: number;
|
|
113
|
+
/**
|
|
114
|
+
* Put a `copy` chip on a reply's fenced code blocks, and give a language pi's
|
|
115
|
+
* highlighter has no grammar for (fish) the closest one it does. A `diff` block
|
|
116
|
+
* goes side by side instead, per `splitDiff`.
|
|
117
|
+
*/
|
|
118
|
+
codeBlocks: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Paint a dot in front of every hex color a message names (`● #7f849c`).
|
|
121
|
+
* Fenced code blocks and inline code are skipped.
|
|
122
|
+
*/
|
|
123
|
+
hexSwatches: boolean;
|
|
124
|
+
/** Per-kind theme fg colors for message-box borders (any ThemeColor name). */
|
|
125
|
+
messageBorderColors: MessageBorderColors;
|
|
126
|
+
/**
|
|
127
|
+
* Theme color for thinking: the `🧠` row, the counting entry in the summary
|
|
128
|
+
* line, and the border of an expanded thinking box. Any theme color name or
|
|
129
|
+
* `#rrggbb`.
|
|
130
|
+
*/
|
|
131
|
+
thinkingColor: string;
|
|
132
|
+
/**
|
|
133
|
+
* Per-tool border color for successful executions (theme fg color names;
|
|
134
|
+
* pending stays grey, error stays red). When set in settings, the map
|
|
135
|
+
* REPLACES the defaults entirely — `{}` disables per-tool colors.
|
|
136
|
+
*/
|
|
137
|
+
toolColors: Record<string, string>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** pi's own collapsed write preview height; `writeLines` defaults to it. */
|
|
141
|
+
export const PI_WRITE_PREVIEW_LINES = 10;
|
|
142
|
+
export const DEFAULT_BASH_RESULT_LINES = 1;
|
|
143
|
+
|
|
144
|
+
const DEFAULT_TOOL_COLORS: Record<string, string> = {
|
|
145
|
+
bash: "bashMode",
|
|
146
|
+
read: "toolTitle",
|
|
147
|
+
edit: "syntaxVariable",
|
|
148
|
+
write: "syntaxVariable",
|
|
149
|
+
grep: "syntaxOperator",
|
|
150
|
+
find: "syntaxOperator",
|
|
151
|
+
ls: "syntaxOperator",
|
|
152
|
+
mcp: "customMessageLabel",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const DEFAULTS: TidyConfig = {
|
|
156
|
+
enabled: true,
|
|
157
|
+
highlightBash: true,
|
|
158
|
+
frameMessages: true,
|
|
159
|
+
userMessageStyle: "frame",
|
|
160
|
+
userMessageAlign: "left",
|
|
161
|
+
userMessageMaxWidth: "auto",
|
|
162
|
+
userMessageTime: true,
|
|
163
|
+
collapseTurns: "afterRun",
|
|
164
|
+
finalReplyStyle: "frame",
|
|
165
|
+
messageTimeFormat: "auto",
|
|
166
|
+
messageHeaderStyle: "inline",
|
|
167
|
+
userMessagePaddingY: 1,
|
|
168
|
+
userMessageColors: { rail: "accent", rule: "borderMuted" },
|
|
169
|
+
icons: true,
|
|
170
|
+
bashResultLines: DEFAULT_BASH_RESULT_LINES,
|
|
171
|
+
writeLines: PI_WRITE_PREVIEW_LINES,
|
|
172
|
+
groupTools: true,
|
|
173
|
+
groupOpen: false,
|
|
174
|
+
toolRowLines: 1,
|
|
175
|
+
alwaysShowTools: ["edit", "write"],
|
|
176
|
+
backgroundJobs: true,
|
|
177
|
+
hideJobNotifications: true,
|
|
178
|
+
dedupeErrors: true,
|
|
179
|
+
cleanCopy: true,
|
|
180
|
+
splitDiff: true,
|
|
181
|
+
splitDiffMinWidth: 72,
|
|
182
|
+
diffLines: 20,
|
|
183
|
+
codeBlocks: true,
|
|
184
|
+
hexSwatches: true,
|
|
185
|
+
thinkingColor: "warning",
|
|
186
|
+
messageBorderColors: {
|
|
187
|
+
assistant: "accent",
|
|
188
|
+
user: "toolTitle",
|
|
189
|
+
compaction: "customMessageLabel",
|
|
190
|
+
branch: "mdCode",
|
|
191
|
+
skill: "accent",
|
|
192
|
+
custom: "warning",
|
|
193
|
+
},
|
|
194
|
+
toolColors: DEFAULT_TOOL_COLORS,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const USER_MESSAGE_STYLES: readonly UserMessageStyle[] = ["rail", "rules", "frame", "off"];
|
|
198
|
+
|
|
199
|
+
// The pre-0.5 `frameUserMessages` needs no reading: `true` asked for the frame,
|
|
200
|
+
// which is the default now, and `false` meant "leave them to pi-starline",
|
|
201
|
+
// which no longer draws them.
|
|
202
|
+
function userMessageStyle(toolbox: any): UserMessageStyle {
|
|
203
|
+
const style = toolbox?.userMessageStyle;
|
|
204
|
+
return USER_MESSAGE_STYLES.includes(style) ? style : DEFAULTS.userMessageStyle;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A fraction of the width (0 < r ≤ 1) or a column count. Anything else — zero, a
|
|
208
|
+
// negative, a non-finite number, a string — is the whole width: a typo in a
|
|
209
|
+
// setting should not hide the message it was meant to narrow.
|
|
210
|
+
function userMaxWidth(value: unknown): number | "auto" {
|
|
211
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : DEFAULTS.userMessageMaxWidth;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const FINAL_REPLY_STYLES: readonly FinalReplyStyle[] = ["frame", "rail", "rules", "off"];
|
|
215
|
+
|
|
216
|
+
// `frameFinalReply` from before 0.9 keeps working: `true` is "frame", `false` is "off".
|
|
217
|
+
function finalReplyStyle(toolbox: any): FinalReplyStyle {
|
|
218
|
+
if (FINAL_REPLY_STYLES.includes(toolbox?.finalReplyStyle)) return toolbox.finalReplyStyle;
|
|
219
|
+
return toolbox?.frameFinalReply === false ? "off" : DEFAULTS.finalReplyStyle;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stringEntries(value: unknown): Record<string, string> {
|
|
223
|
+
if (typeof value !== "object" || value === null) return {};
|
|
224
|
+
return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readSettings(): any {
|
|
228
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
229
|
+
return JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function loadConfig(): TidyConfig {
|
|
233
|
+
try {
|
|
234
|
+
// `tidy` since the rename from pi-toolbox; the old key keeps working.
|
|
235
|
+
const settings = readSettings();
|
|
236
|
+
const toolbox = settings?.tidy ?? settings?.toolbox;
|
|
237
|
+
return {
|
|
238
|
+
enabled: toolbox?.enabled ?? DEFAULTS.enabled,
|
|
239
|
+
highlightBash: toolbox?.highlightBash ?? DEFAULTS.highlightBash,
|
|
240
|
+
frameMessages: toolbox?.frameMessages ?? DEFAULTS.frameMessages,
|
|
241
|
+
userMessageStyle: userMessageStyle(toolbox),
|
|
242
|
+
userMessageAlign: toolbox?.userMessageAlign === "right" ? "right" : "left",
|
|
243
|
+
userMessageMaxWidth: userMaxWidth(toolbox?.userMessageMaxWidth),
|
|
244
|
+
userMessageTime: typeof toolbox?.userMessageTime === "boolean" ? toolbox.userMessageTime : DEFAULTS.userMessageTime,
|
|
245
|
+
collapseTurns: ["off", "afterRun", "live"].includes(toolbox?.collapseTurns) ? toolbox.collapseTurns : DEFAULTS.collapseTurns,
|
|
246
|
+
finalReplyStyle: finalReplyStyle(toolbox),
|
|
247
|
+
messageTimeFormat:
|
|
248
|
+
typeof toolbox?.messageTimeFormat === "string" && toolbox.messageTimeFormat.trim()
|
|
249
|
+
? toolbox.messageTimeFormat
|
|
250
|
+
: DEFAULTS.messageTimeFormat,
|
|
251
|
+
messageHeaderStyle: toolbox?.messageHeaderStyle === "split" ? "split" : DEFAULTS.messageHeaderStyle,
|
|
252
|
+
userMessagePaddingY:
|
|
253
|
+
Number.isInteger(toolbox?.userMessagePaddingY) && toolbox.userMessagePaddingY >= 0
|
|
254
|
+
? Math.min(3, toolbox.userMessagePaddingY)
|
|
255
|
+
: DEFAULTS.userMessagePaddingY,
|
|
256
|
+
userMessageColors: {
|
|
257
|
+
...DEFAULTS.userMessageColors,
|
|
258
|
+
...stringEntries(toolbox?.userMessageColors),
|
|
259
|
+
},
|
|
260
|
+
icons: toolbox?.icons ?? DEFAULTS.icons,
|
|
261
|
+
bashResultLines: typeof toolbox?.bashResultLines === "number" ? toolbox.bashResultLines : DEFAULTS.bashResultLines,
|
|
262
|
+
writeLines: typeof toolbox?.writeLines === "number" ? toolbox.writeLines : DEFAULTS.writeLines,
|
|
263
|
+
groupTools: typeof toolbox?.groupTools === "boolean" ? toolbox.groupTools : DEFAULTS.groupTools,
|
|
264
|
+
groupOpen: typeof toolbox?.groupOpen === "boolean" ? toolbox.groupOpen : DEFAULTS.groupOpen,
|
|
265
|
+
toolRowLines: toolbox?.toolRowLines === 2 ? 2 : DEFAULTS.toolRowLines,
|
|
266
|
+
alwaysShowTools: Array.isArray(toolbox?.alwaysShowTools)
|
|
267
|
+
? toolbox.alwaysShowTools.filter((name: unknown): name is string => typeof name === "string")
|
|
268
|
+
: DEFAULTS.alwaysShowTools,
|
|
269
|
+
backgroundJobs: typeof toolbox?.backgroundJobs === "boolean" ? toolbox.backgroundJobs : DEFAULTS.backgroundJobs,
|
|
270
|
+
hideJobNotifications:
|
|
271
|
+
typeof toolbox?.hideJobNotifications === "boolean" ? toolbox.hideJobNotifications : DEFAULTS.hideJobNotifications,
|
|
272
|
+
dedupeErrors: typeof toolbox?.dedupeErrors === "boolean" ? toolbox.dedupeErrors : DEFAULTS.dedupeErrors,
|
|
273
|
+
cleanCopy: typeof toolbox?.cleanCopy === "boolean" ? toolbox.cleanCopy : DEFAULTS.cleanCopy,
|
|
274
|
+
splitDiff: typeof toolbox?.splitDiff === "boolean" ? toolbox.splitDiff : DEFAULTS.splitDiff,
|
|
275
|
+
splitDiffMinWidth:
|
|
276
|
+
typeof toolbox?.splitDiffMinWidth === "number" ? toolbox.splitDiffMinWidth : DEFAULTS.splitDiffMinWidth,
|
|
277
|
+
diffLines: typeof toolbox?.diffLines === "number" ? toolbox.diffLines : DEFAULTS.diffLines,
|
|
278
|
+
codeBlocks: typeof toolbox?.codeBlocks === "boolean" ? toolbox.codeBlocks : DEFAULTS.codeBlocks,
|
|
279
|
+
hexSwatches: typeof toolbox?.hexSwatches === "boolean" ? toolbox.hexSwatches : DEFAULTS.hexSwatches,
|
|
280
|
+
thinkingColor: typeof toolbox?.thinkingColor === "string" ? toolbox.thinkingColor : DEFAULTS.thinkingColor,
|
|
281
|
+
messageBorderColors: {
|
|
282
|
+
...DEFAULTS.messageBorderColors,
|
|
283
|
+
...toolbox?.messageBorderColors,
|
|
284
|
+
},
|
|
285
|
+
// Replace, not merge: removing a tool's color must be expressible.
|
|
286
|
+
toolColors: toolbox?.toolColors ?? DEFAULTS.toolColors,
|
|
287
|
+
};
|
|
288
|
+
} catch {
|
|
289
|
+
return { ...DEFAULTS };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing helpers shared by the tool and message frames: background-fill
|
|
3
|
+
* stripping, visible-width padding, escape-aware text search, and the rounded
|
|
4
|
+
* border itself. Pure string work — no pi components.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { ThemeLike } from "../theme-access.ts";
|
|
9
|
+
import { paint } from "./paint.ts";
|
|
10
|
+
|
|
11
|
+
// Solid background fills: truecolor/256 "48;…" sequences and the standard
|
|
12
|
+
// 40-47 / 100-107 bg colors. pi's shell and some built-in renderers (edit)
|
|
13
|
+
// paint toolPendingBg/toolSuccessBg/toolErrorBg into their lines; stripping
|
|
14
|
+
// the fills keeps the whole frame transparent. \x1b[49m (default bg) is kept.
|
|
15
|
+
const BG_FILL_RE = /\x1b\[48;[0-9;]*m|\x1b\[(?:4[0-7]|10[0-7])m/g;
|
|
16
|
+
|
|
17
|
+
export function stripBackgroundFills(line: string): string {
|
|
18
|
+
return line.replace(BG_FILL_RE, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TIME_TOKENS = /YYYY|MM?|DD?|HH?|mm|ss/g;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Local time. `auto` is the clock, with the date only when the message is not from
|
|
25
|
+
* today; anything else is a pattern of `YYYY M MM D DD H HH mm ss` and literal text.
|
|
26
|
+
*/
|
|
27
|
+
export function formatMessageTime(timestamp: number, format = "auto", now = Date.now()): string {
|
|
28
|
+
const date = new Date(timestamp);
|
|
29
|
+
if (!Number.isFinite(date.getTime())) return "";
|
|
30
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
31
|
+
if (format !== "auto") {
|
|
32
|
+
const parts: Record<string, string> = {
|
|
33
|
+
YYYY: String(date.getFullYear()),
|
|
34
|
+
M: String(date.getMonth() + 1),
|
|
35
|
+
MM: pad(date.getMonth() + 1),
|
|
36
|
+
D: String(date.getDate()),
|
|
37
|
+
DD: pad(date.getDate()),
|
|
38
|
+
H: String(date.getHours()),
|
|
39
|
+
HH: pad(date.getHours()),
|
|
40
|
+
mm: pad(date.getMinutes()),
|
|
41
|
+
ss: pad(date.getSeconds()),
|
|
42
|
+
};
|
|
43
|
+
return format.replace(TIME_TOKENS, (token) => parts[token] as string);
|
|
44
|
+
}
|
|
45
|
+
const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
46
|
+
return date.toDateString() === new Date(now).toDateString()
|
|
47
|
+
? clock
|
|
48
|
+
: `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${clock}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Muted and italic: the timestamp belongs to the border, not to the message. */
|
|
52
|
+
export function italicDim(theme: ThemeLike, text: string): string {
|
|
53
|
+
const dim = theme.fg("dim", text);
|
|
54
|
+
return theme.italic ? theme.italic(dim) : dim;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const TRAILING_SGR_RE = /\x1b\[[0-9;:]*m$/;
|
|
58
|
+
|
|
59
|
+
/** Drop the padding a row was filled out with, keeping the escapes that close its styles. */
|
|
60
|
+
export function trimTrailingSpaces(line: string): string {
|
|
61
|
+
let body = line;
|
|
62
|
+
let tail = "";
|
|
63
|
+
for (;;) {
|
|
64
|
+
const sgr = TRAILING_SGR_RE.exec(body);
|
|
65
|
+
if (sgr) {
|
|
66
|
+
tail = sgr[0] + tail;
|
|
67
|
+
body = body.slice(0, -sgr[0].length);
|
|
68
|
+
} else if (body.endsWith(" ")) {
|
|
69
|
+
body = body.replace(/ +$/, "");
|
|
70
|
+
} else {
|
|
71
|
+
return body + tail;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `truncateToWidth`, but only for lines that are actually too wide.
|
|
78
|
+
*
|
|
79
|
+
* pi-tui's truncateToWidth runs an `Intl.Segmenter` over the whole string
|
|
80
|
+
* whether or not anything is cut: ~17us for a 100-cell styled row, against
|
|
81
|
+
* ~90ns for `visibleWidth` on the same line. Rows that already fit are the
|
|
82
|
+
* common case, and this guard keeps their bytes exactly as they were.
|
|
83
|
+
*/
|
|
84
|
+
export function clipToWidth(line: string, width: number, ellipsis = "…"): string {
|
|
85
|
+
return visibleWidth(line) <= width ? line : truncateToWidth(line, width, ellipsis);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padToWidth(line: string, width: number): string {
|
|
89
|
+
const w = visibleWidth(line);
|
|
90
|
+
if (w === width) return line;
|
|
91
|
+
if (w < width) return line + " ".repeat(width - w);
|
|
92
|
+
return truncateToWidth(line, width);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Cache key for a rendered frame: the exact lines that were framed.
|
|
97
|
+
*
|
|
98
|
+
* A digest of (count, first line, last line) is not enough. Tool boxes are
|
|
99
|
+
* bordered by constant frame and padding rows, so a change confined to the
|
|
100
|
+
* middle — the elapsed counter ticking from `Elapsed 0.0s` to `Elapsed 1.0s` —
|
|
101
|
+
* left the digest untouched, and the box re-served the stale frame until
|
|
102
|
+
* something else (a click toggling `expanded`) invalidated the cache.
|
|
103
|
+
*
|
|
104
|
+
* `Box` and `Text` hand back the same cached line array while their content is
|
|
105
|
+
* unchanged, so in the common case this is a pointer comparison per row and
|
|
106
|
+
* allocates nothing.
|
|
107
|
+
*/
|
|
108
|
+
export function sameLines(cached: readonly string[] | undefined, lines: readonly string[]): boolean {
|
|
109
|
+
if (!cached || cached.length !== lines.length) return false;
|
|
110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
111
|
+
if (cached[i] !== lines[i]) return false;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ANSI_RE = /\x1b\[[0-9;:?]*[ -/]*[@-~]/g;
|
|
117
|
+
|
|
118
|
+
/** CSI and OSC (BEL- or ST-terminated) escape sequences. */
|
|
119
|
+
const ESCAPE_RE = /\x1b(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)/g;
|
|
120
|
+
|
|
121
|
+
/** Map a row's visible characters to their raw string indices, skipping escapes. */
|
|
122
|
+
function visibleCharMap(row: string): { plain: string; map: number[] } {
|
|
123
|
+
let plain = "";
|
|
124
|
+
const map: number[] = [];
|
|
125
|
+
let last = 0;
|
|
126
|
+
for (const m of row.matchAll(ESCAPE_RE)) {
|
|
127
|
+
for (let j = last; j < m.index; j++) {
|
|
128
|
+
map.push(j);
|
|
129
|
+
plain += row[j];
|
|
130
|
+
}
|
|
131
|
+
last = m.index + m[0].length;
|
|
132
|
+
}
|
|
133
|
+
for (let j = last; j < row.length; j++) {
|
|
134
|
+
map.push(j);
|
|
135
|
+
plain += row[j];
|
|
136
|
+
}
|
|
137
|
+
return { plain, map };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function visibleIndexOf(row: string, needle: string): number {
|
|
141
|
+
const { plain, map } = visibleCharMap(row);
|
|
142
|
+
const pi = plain.lastIndexOf(needle);
|
|
143
|
+
return pi >= 0 ? map[pi] : -1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A row's text with every escape sequence removed. */
|
|
147
|
+
export function visibleText(row: string): string {
|
|
148
|
+
return visibleCharMap(row).plain;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The raw index of the visible character at `plainIndex`, or -1. */
|
|
152
|
+
export function rawIndexOfVisible(row: string, plainIndex: number): number {
|
|
153
|
+
const { map } = visibleCharMap(row);
|
|
154
|
+
return plainIndex >= 0 && plainIndex < map.length ? map[plainIndex] : -1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Remove the leading tool name from a call row — with the kind icon in
|
|
159
|
+
* front, the word is redundant (` edit ~/x.ts` reads choppier than
|
|
160
|
+
* ` ~/x.ts`). No-op when the row doesn't start with the word.
|
|
161
|
+
* Removes a *visible-text* range (word + one following space) so escape
|
|
162
|
+
* sequences interleaved between them (the name's style closers) can't
|
|
163
|
+
* strand a double space.
|
|
164
|
+
*/
|
|
165
|
+
export function removeLeadingWord(line: string, word: string): string {
|
|
166
|
+
const { plain, map } = visibleCharMap(line);
|
|
167
|
+
const start = plain.search(/\S/);
|
|
168
|
+
if (start < 0 || !plain.startsWith(word, start)) return line;
|
|
169
|
+
const after = plain[start + word.length];
|
|
170
|
+
if (after !== undefined && after !== " ") return line;
|
|
171
|
+
const endPlain = after === " " ? start + word.length + 1 : start + word.length;
|
|
172
|
+
const rawStart = map[start];
|
|
173
|
+
const rawEnd = map[endPlain - 1] + 1;
|
|
174
|
+
return line.slice(0, rawStart) + line.slice(rawEnd);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* When `idx` sits inside an OSC 8 hyperlink (opener before it, no closer in
|
|
179
|
+
* between), return the opener's start so the icon lands outside the link.
|
|
180
|
+
* Otherwise return `idx` unchanged.
|
|
181
|
+
*/
|
|
182
|
+
export function outsideHyperlink(row: string, idx: number): number {
|
|
183
|
+
const opener = row.lastIndexOf("\x1b]8;;", idx);
|
|
184
|
+
if (opener < 0) return idx;
|
|
185
|
+
const after = row[opener + 6];
|
|
186
|
+
if (after === "\x07" || after === "\x1b") return idx; // that's a closer
|
|
187
|
+
const closer = Math.max(row.lastIndexOf("\x1b]8;;\x07", idx), row.lastIndexOf("\x1b]8;;\x1b\\", idx));
|
|
188
|
+
return opener > closer ? opener : idx;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A padding row from pi's default shell: spaces plus background escapes. */
|
|
192
|
+
function isBlankRow(line: string): boolean {
|
|
193
|
+
return line.replace(ANSI_RE, "").trim() === "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The index range left after dropping blank rows at both ends. */
|
|
197
|
+
export function blankEdges(lines: readonly string[]): { start: number; end: number } {
|
|
198
|
+
let start = 0;
|
|
199
|
+
let end = lines.length;
|
|
200
|
+
while (start < end && isBlankRow(lines[start])) start++;
|
|
201
|
+
while (end > start && isBlankRow(lines[end - 1])) end--;
|
|
202
|
+
return { start, end };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function trimBlankEdges(lines: string[]): string[] {
|
|
206
|
+
const { start, end } = blankEdges(lines);
|
|
207
|
+
return start === 0 && end === lines.length ? lines : lines.slice(start, end);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type HeaderStyle = "inline" | "split";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* What a message's top edge says: who it is from and when. `split` sets a rule between
|
|
214
|
+
* the two and a longer one in front (`── model ── 13:09 ───`), and goes back to `inline`
|
|
215
|
+
* on an edge too narrow for that. Both parts arrive painted; `lead` is the rule's length
|
|
216
|
+
* in front of the label.
|
|
217
|
+
*/
|
|
218
|
+
export function messageHeader(
|
|
219
|
+
theme: ThemeLike,
|
|
220
|
+
color: string,
|
|
221
|
+
identity: string,
|
|
222
|
+
time: string,
|
|
223
|
+
style: HeaderStyle,
|
|
224
|
+
width: number,
|
|
225
|
+
): { label: string; lead: number } {
|
|
226
|
+
const inline = { label: time ? `${identity} ${time}` : identity, lead: 1 };
|
|
227
|
+
if (style !== "split") return inline;
|
|
228
|
+
const label = time ? `${identity} ${paint(theme, color, "──")} ${time}` : identity;
|
|
229
|
+
// `╭── ` and ` ──╮`.
|
|
230
|
+
return visibleWidth(label) + 8 <= width ? { label, lead: 2 } : inline;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A rounded top border with a label set into it: `╭─ label ─────╮`. Falls
|
|
235
|
+
* back to the plain border when the label does not fit. `label` arrives
|
|
236
|
+
* painted; only the rule is colored here.
|
|
237
|
+
*/
|
|
238
|
+
export function labeledTopBorder(
|
|
239
|
+
width: number,
|
|
240
|
+
theme: ThemeLike,
|
|
241
|
+
color: string,
|
|
242
|
+
label: string,
|
|
243
|
+
align: "left" | "right" = "left",
|
|
244
|
+
lead = 1,
|
|
245
|
+
/**
|
|
246
|
+
* Cells the label really takes. A Nerd Font glyph draws one cell wider than
|
|
247
|
+
* `visibleWidth` measures it, and a border that trusts the table alone loses
|
|
248
|
+
* its right corner to the truncation.
|
|
249
|
+
*/
|
|
250
|
+
labelCells = visibleWidth(label),
|
|
251
|
+
): string {
|
|
252
|
+
const inner = Math.max(2, width - 2);
|
|
253
|
+
const bc = (s: string) => paint(theme, color, s);
|
|
254
|
+
const rest = inner - 2 - lead - labelCells;
|
|
255
|
+
if (rest < 1) return bc(`╭${"─".repeat(inner)}╮`);
|
|
256
|
+
const start = "─".repeat(lead);
|
|
257
|
+
return align === "right"
|
|
258
|
+
? `${bc(`╭${"─".repeat(rest)} `)}${label}${bc(` ${start}╮`)}`
|
|
259
|
+
: `${bc(`╭${start} `)}${label}${bc(` ${"─".repeat(rest)}╮`)}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A rule with a label set into it: `─ label ─────`. Plain when the label does not fit. */
|
|
263
|
+
export function labeledRule(
|
|
264
|
+
width: number,
|
|
265
|
+
theme: ThemeLike,
|
|
266
|
+
color: string,
|
|
267
|
+
label: string,
|
|
268
|
+
align: "left" | "right" = "left",
|
|
269
|
+
lead = 1,
|
|
270
|
+
): string {
|
|
271
|
+
const rest = width - 2 - lead - visibleWidth(label);
|
|
272
|
+
if (!label || rest < 1) return paint(theme, color, "─".repeat(width));
|
|
273
|
+
const start = "─".repeat(lead);
|
|
274
|
+
return align === "right"
|
|
275
|
+
? `${paint(theme, color, `${"─".repeat(rest)} `)}${label}${paint(theme, color, ` ${start}`)}`
|
|
276
|
+
: `${paint(theme, color, `${start} `)}${label}${paint(theme, color, ` ${"─".repeat(rest)}`)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Draw the rounded frame. Border color tracks tool state:
|
|
281
|
+
* pending (streaming/executing) → borderMuted (grey)
|
|
282
|
+
* success → success (green)
|
|
283
|
+
* error → error (red)
|
|
284
|
+
*/
|
|
285
|
+
export function drawFrame(
|
|
286
|
+
lines: string[],
|
|
287
|
+
width: number,
|
|
288
|
+
theme: ThemeLike,
|
|
289
|
+
color: string,
|
|
290
|
+
/**
|
|
291
|
+
* Rows whose background fill belongs to the content (the split diff's tinted
|
|
292
|
+
* rows). Every other row is stripped — the frame is transparent by design.
|
|
293
|
+
*/
|
|
294
|
+
keepBg?: ReadonlySet<number>,
|
|
295
|
+
): string[] {
|
|
296
|
+
const inner = Math.max(2, width - 2);
|
|
297
|
+
const bc = (s: string) => paint(theme, color, s);
|
|
298
|
+
const out: string[] = [bc(`╭${"─".repeat(inner)}╮`)];
|
|
299
|
+
for (let i = 0; i < lines.length; i++) {
|
|
300
|
+
const line = lines[i] as string;
|
|
301
|
+
const kept = keepBg?.has(i) ? line : stripBackgroundFills(line);
|
|
302
|
+
out.push(`${bc("│")}${padToWidth(kept, inner)}${bc("│")}`);
|
|
303
|
+
}
|
|
304
|
+
out.push(bc(`╰${"─".repeat(inner)}╯`));
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|