pi-my-compact-ui 0.2.2 → 0.3.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/src/helpers.ts ADDED
@@ -0,0 +1,130 @@
1
+ /** 无运行状态依赖的格式化与流式工具参数解析。 */
2
+ import { homedir } from "os";
3
+ import type { ThinkingEntry, ToolStatus } from "./types.js";
4
+ import { isKeyRelease, parseKey, matchesKey } from "@earendil-works/pi-tui";
5
+
6
+ export function shortenPath(path: string): string {
7
+ const home = homedir();
8
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
9
+ }
10
+
11
+ export function oneLine(value: unknown, max?: number): string {
12
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
13
+ if (typeof max === "number" && text.length > max) {
14
+ return max <= 3 ? "...".slice(0, max) : `${text.slice(0, max - 3)}...`;
15
+ }
16
+ return text;
17
+ }
18
+
19
+ export function estimateTextTokens(text: string): number {
20
+ return Math.ceil(text.length / 4);
21
+ }
22
+
23
+ export function formatTokenK(tokens: number): string {
24
+ if (tokens <= 0) return "0.0K";
25
+ if (tokens < 100) return "<0.1K";
26
+ const value = tokens / 1000;
27
+ return value < 100 ? `${value.toFixed(1)}K` : `${Math.round(value)}K`;
28
+ }
29
+
30
+ export function thinkingTokenLabel(entry: ThinkingEntry): string {
31
+ const tokens = Math.max(0, Math.round(Number(entry?.tokens) || 0));
32
+ if (tokens < 1000) {
33
+ return `(${tokens}t)`;
34
+ }
35
+ const k = tokens / 1000;
36
+ const formatted = k < 100 ? k.toFixed(1) : Math.round(k).toString();
37
+ return `(${formatted}k)`;
38
+ }
39
+
40
+ export function toolSummary(name: string, args: any): { name: string; content: string } {
41
+ switch (name) {
42
+ case "bash":
43
+ case "powershell":
44
+ return { name, content: oneLine(args?.command || "...") };
45
+ case "read":
46
+ return { name: "read", content: shortenPath(args?.path || "...") };
47
+ case "write":
48
+ case "edit":
49
+ return { name, content: shortenPath(args?.path || "...") };
50
+ case "find":
51
+ return { name: "find", content: `${oneLine(args?.pattern || "")} in ${shortenPath(args?.path || ".")}` };
52
+ case "grep":
53
+ return { name: "grep", content: `${oneLine(args?.pattern || "")} in ${shortenPath(args?.path || ".")}` };
54
+ case "ls":
55
+ return { name: "ls", content: shortenPath(args?.path || ".") };
56
+ case "web_search":
57
+ return { name: "web_search", content: oneLine(args?.query || "...") };
58
+ case "subagent":
59
+ return { name: "subagent", content: oneLine(args?.agent || args?.task || "...") };
60
+ default: {
61
+ const preferred = args?.path ?? args?.query ?? args?.name ?? args?.description ?? args?.url;
62
+ return { name, content: oneLine(preferred ?? "...") };
63
+ }
64
+ }
65
+ }
66
+
67
+ export function toolStatus(tool: any): ToolStatus {
68
+ if (tool?.result?.isError) return "error";
69
+ if (tool?.isPartial === true || (tool?.executionStarted && !tool?.result)) return "pending";
70
+ return tool?.result ? "success" : "pending";
71
+ }
72
+
73
+ export function streamingToolArgs(block: any): any {
74
+ const args = block?.arguments && typeof block.arguments === "object" ? block.arguments : {};
75
+ if (Object.keys(args).length > 0) return args;
76
+ const partial = String(block?.partialJson || block?.partialArgs || "").trim();
77
+ if (!partial) return args;
78
+ const name = String(block?.name || "");
79
+ if (name === "bash" || name === "powershell") return { command: partial };
80
+ if (name === "read" || name === "write" || name === "edit" || name === "ls") return { path: partial };
81
+ return { query: partial };
82
+ }
83
+
84
+ export function locateStreamingToolCall(
85
+ content: any[],
86
+ streamEvent: any,
87
+ ): { index: number; block: any } | undefined {
88
+ const index = Number(streamEvent?.contentIndex);
89
+ if (Number.isInteger(index) && content[index]?.type === "toolCall") {
90
+ return { index, block: content[index] };
91
+ }
92
+ const fromEvent = streamEvent?.toolCall;
93
+ if (fromEvent && (fromEvent.type === "toolCall" || fromEvent.name)) {
94
+ const found = content.findIndex(
95
+ (item) => item?.type === "toolCall" && fromEvent.id && item.id === fromEvent.id,
96
+ );
97
+ if (found >= 0) return { index: found, block: content[found] };
98
+ return { index: Number.isInteger(index) ? index : content.length, block: fromEvent };
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ export function toolResultText(tool: any): string {
104
+ return (tool?.result?.content ?? [])
105
+ .filter((c: any) => c.type === "text")
106
+ .map((c: any) => String(c.text))
107
+ .join("\n")
108
+ .trim();
109
+ }
110
+
111
+ export function formatWorkedTime(elapsedMs: number): string {
112
+ const totalSec = Math.max(1, Math.round(elapsedMs / 1000));
113
+ const hours = Math.floor(totalSec / 3600);
114
+ const minutes = Math.floor((totalSec % 3600) / 60);
115
+ const seconds = totalSec % 60;
116
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
117
+ if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
118
+ return `${seconds}s`;
119
+ }
120
+
121
+ export function isCtrlI(data: string): boolean {
122
+ // Ignore key release events to prevent accidental toggling on key up
123
+ if (isKeyRelease(data)) {
124
+ return false;
125
+ }
126
+ // In legacy terminals, Ctrl+I sends byte 0x09 ("\t"), identical to Tab.
127
+ // To keep Tab completion working, exclude plain "\t" and only recognize
128
+ // Ctrl+I under extended keyboard protocols (Kitty keyboard, modifyOtherKeys).
129
+ return data !== "\t" && (parseKey(data) === "ctrl+i" || matchesKey(data, "ctrl+i"));
130
+ }
@@ -0,0 +1,98 @@
1
+ /** 将内部代码围栏标记转换为保留 ANSI 高亮的背景面板。 */
2
+ import { truncateToWidth, visibleWidth, stripTerminalSequences, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import type { MarkdownTheme, Markdown } from "@earendil-works/pi-tui";
4
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
5
+ import { CODE_BLOCK_PADDING_X, MARKDOWN_RENDER_PATCH_KEY } from "./constants.js";
6
+ import { runtime } from "./state.js";
7
+
8
+ export function isCompactCodeBlockOpen(visible: string): boolean {
9
+ return visible === "┌─" || visible.startsWith("┌─ ");
10
+ }
11
+
12
+ export function isCompactCodeBlockClose(visible: string): boolean {
13
+ return visible === "└─";
14
+ }
15
+
16
+ export function getCompactMarkdownTheme(): MarkdownTheme {
17
+ const base = getMarkdownTheme();
18
+ let insideCodeBlock = false;
19
+ const theme: MarkdownTheme = {
20
+ ...base,
21
+ codeBlockIndent: "",
22
+ codeBlockBorder(text: string): string {
23
+ const opening = !insideCodeBlock;
24
+ insideCodeBlock = !insideCodeBlock;
25
+ const language = opening ? text.replace(/^```/, "").trim() : "";
26
+ if (opening) {
27
+ theme.codeBlockIndent = "";
28
+ }
29
+ const border = opening ? `┌─${language ? ` ${language}` : ""}` : "└─";
30
+ return base.codeBlockBorder(border);
31
+ },
32
+ };
33
+ return theme;
34
+ }
35
+
36
+ export function renderCodeBlockBackgroundRow(content: string, width: number): string {
37
+ const safeWidth = Math.max(1, width);
38
+ const horizontalPadding = Math.min(CODE_BLOCK_PADDING_X, Math.floor((safeWidth - 1) / 2));
39
+ const innerWidth = Math.max(1, safeWidth - horizontalPadding * 2);
40
+ const clipped = truncateToWidth(content, innerWidth, "...");
41
+ const rightFill = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
42
+ const row = `${" ".repeat(horizontalPadding)}${clipped}${rightFill}${" ".repeat(horizontalPadding)}`;
43
+ return runtime.currentTheme?.bg?.("toolPendingBg", row) ?? row;
44
+ }
45
+
46
+ export function normalizeCompactCodeBlockLines(lines: string[], width: number, paddingX = 0): string[] {
47
+ const safeWidth = Math.max(1, width);
48
+ const horizontalPadding = Math.max(0, Math.floor(paddingX));
49
+ const leftPadding = " ".repeat(horizontalPadding);
50
+ const contentWidth = Math.max(1, safeWidth - horizontalPadding * 2);
51
+ const continuationWidth = contentWidth;
52
+ const normalized: string[] = [];
53
+ let codeBlockMode: "none" | "background" = "none";
54
+
55
+ for (const originalLine of lines) {
56
+ const withoutLeftPadding =
57
+ leftPadding.length > 0 && originalLine.startsWith(leftPadding)
58
+ ? originalLine.slice(leftPadding.length)
59
+ : originalLine;
60
+ const content = withoutLeftPadding.trimEnd();
61
+ const visible = stripTerminalSequences(content).trimStart();
62
+ if (isCompactCodeBlockOpen(visible)) {
63
+ codeBlockMode = "background";
64
+ const language = visible.replace(/^┌─/, "").trim();
65
+ if (language) {
66
+ const label = runtime.currentTheme?.fg?.("muted", language) ?? language;
67
+ normalized.push(`${leftPadding}${renderCodeBlockBackgroundRow(label, contentWidth)}`);
68
+ }
69
+ continue;
70
+ }
71
+ if (isCompactCodeBlockClose(visible)) {
72
+ codeBlockMode = "none";
73
+ continue;
74
+ }
75
+ if (codeBlockMode === "background") {
76
+ const codeWidth = Math.max(1, continuationWidth - CODE_BLOCK_PADDING_X * 2);
77
+ const wrappedRows = wrapTextWithAnsi(content, codeWidth);
78
+ for (const wrapped of wrappedRows.length > 0 ? wrappedRows : [""]) {
79
+ normalized.push(`${leftPadding}${renderCodeBlockBackgroundRow(wrapped, contentWidth)}`);
80
+ }
81
+ continue;
82
+ }
83
+ normalized.push(truncateToWidth(originalLine, safeWidth, "..."));
84
+ }
85
+
86
+ return normalized;
87
+ }
88
+
89
+ export function installVisibleAssistantMarkdownRendering(component: Markdown): void {
90
+ const markdown = component as any;
91
+ if (markdown[MARKDOWN_RENDER_PATCH_KEY]) return;
92
+ markdown.theme = getCompactMarkdownTheme();
93
+ const originalRender = markdown.render.bind(markdown);
94
+ markdown.render = (width: number): string[] =>
95
+ normalizeCompactCodeBlockLines(originalRender(width), width, Number(markdown.paddingX) || 0);
96
+ markdown[MARKDOWN_RENDER_PATCH_KEY] = { originalRender };
97
+ markdown.invalidate();
98
+ }