@giladbarnea/pi-user-agents 0.0.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/INTERNALS.md +65 -0
- package/PARSER_SPEC.md +264 -0
- package/README.md +127 -0
- package/agent-viewer.ts +653 -0
- package/autocomplete.ts +576 -0
- package/command-line.ts +746 -0
- package/context-meter.ts +41 -0
- package/editor-coloring.ts +160 -0
- package/index.ts +74 -0
- package/package.json +44 -0
- package/runner.ts +591 -0
- package/shared.ts +214 -0
- package/tool-render/FORK.md +108 -0
- package/tool-render/ansi.ts +77 -0
- package/tool-render/constants.ts +48 -0
- package/tool-render/diff.ts +957 -0
- package/tool-render/index.ts +137 -0
- package/tool-render/text.ts +212 -0
- package/tool-render/theme.ts +71 -0
- package/tool-render/tools.ts +502 -0
- package/transcript.ts +303 -0
- package/widget.ts +523 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { truncateToWidth } from "./ansi.js";
|
|
2
|
+
import { CAPS } from "./constants.js";
|
|
3
|
+
import type { RenderTheme } from "./theme.js";
|
|
4
|
+
import {
|
|
5
|
+
formatArguments,
|
|
6
|
+
shortenPath,
|
|
7
|
+
splitTerminalLines,
|
|
8
|
+
type ToolResultLike,
|
|
9
|
+
truncateText,
|
|
10
|
+
} from "./text.js";
|
|
11
|
+
import { isFirstClassTool, renderFirstClassTool, renderGenericTool } from "./tools.js";
|
|
12
|
+
|
|
13
|
+
export type { RenderTheme } from "./theme.js";
|
|
14
|
+
export type { ToolResultLike } from "./text.js";
|
|
15
|
+
|
|
16
|
+
export type ToolCallPart = {
|
|
17
|
+
arguments: Record<string, unknown>;
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
type: "toolCall";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The persisted transcript shape, narrowed structurally so this module stays standalone. */
|
|
24
|
+
type TranscriptMessage = {
|
|
25
|
+
content?: unknown;
|
|
26
|
+
details?: unknown;
|
|
27
|
+
isError?: boolean;
|
|
28
|
+
role: string;
|
|
29
|
+
toolCallId?: string;
|
|
30
|
+
toolName?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ToolIndex = {
|
|
34
|
+
/** Every tool-call id present in the same slice, so already-rendered results can be dropped. */
|
|
35
|
+
callIds: Set<string>;
|
|
36
|
+
resultByCallId: Map<string, ToolResultLike>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function isToolCallPart(part: unknown): part is ToolCallPart {
|
|
40
|
+
const candidate = part as ToolCallPart | undefined;
|
|
41
|
+
return candidate?.type === "toolCall" && typeof candidate.id === "string";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pair calls with results by their stable identity in one pass.
|
|
46
|
+
*
|
|
47
|
+
* Order is never used as a fallback: parallel tool calls make it ambiguous. A call with no
|
|
48
|
+
* entry in `resultByCallId` genuinely has no stored result.
|
|
49
|
+
*/
|
|
50
|
+
export function indexToolMessages(messages: readonly TranscriptMessage[]): ToolIndex {
|
|
51
|
+
const index: ToolIndex = { callIds: new Set(), resultByCallId: new Map() };
|
|
52
|
+
for (const message of messages) {
|
|
53
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
54
|
+
for (const part of message.content) if (isToolCallPart(part)) index.callIds.add(part.id);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (message.role === "toolResult" && typeof message.toolCallId === "string") {
|
|
58
|
+
index.resultByCallId.set(message.toolCallId, {
|
|
59
|
+
content: message.content as ToolResultLike["content"],
|
|
60
|
+
details: message.details,
|
|
61
|
+
isError: message.isError === true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return index;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render one historical tool call, with its stored result when there is one.
|
|
70
|
+
*
|
|
71
|
+
* Every returned line fits `width` and contains no newline, because the overlay hard-cuts
|
|
72
|
+
* lines instead of wrapping them.
|
|
73
|
+
*/
|
|
74
|
+
export function renderToolCall(
|
|
75
|
+
call: ToolCallPart,
|
|
76
|
+
result: ToolResultLike | undefined,
|
|
77
|
+
width: number,
|
|
78
|
+
theme: RenderTheme,
|
|
79
|
+
): string[] {
|
|
80
|
+
if (width <= 0) return [];
|
|
81
|
+
const args = call.arguments ?? {};
|
|
82
|
+
const lines = isFirstClassTool(call.name)
|
|
83
|
+
? renderFirstClassTool(call.name, args, result, theme, width)
|
|
84
|
+
: renderGenericTool(call.name, args, result, theme, width);
|
|
85
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Render a stored result whose call is not in the visible slice.
|
|
90
|
+
*
|
|
91
|
+
* This happens at the inherited-message boundary, where the assistant message holding the
|
|
92
|
+
* call was cloned from the parent session and skipped by the transcript view.
|
|
93
|
+
*/
|
|
94
|
+
export function renderOrphanToolResult(
|
|
95
|
+
toolName: string,
|
|
96
|
+
result: ToolResultLike,
|
|
97
|
+
width: number,
|
|
98
|
+
theme: RenderTheme,
|
|
99
|
+
): string[] {
|
|
100
|
+
if (width <= 0) return [];
|
|
101
|
+
return renderGenericTool(toolName, {}, result, theme, width).map((line) =>
|
|
102
|
+
truncateToWidth(line, width),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function argumentString(args: Record<string, unknown>, key: string): string {
|
|
107
|
+
const value = args[key];
|
|
108
|
+
return typeof value === "string" ? value : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A single plain-text line for the activity preview under the editor.
|
|
113
|
+
*
|
|
114
|
+
* The preview runs this through Pi's Markdown renderer, so it carries no escape codes. The
|
|
115
|
+
* tool name keeps the backtick convention already used for tool results.
|
|
116
|
+
*
|
|
117
|
+
* @example describeToolCall({ type: "toolCall", id: "1", name: "ls", arguments: {} })
|
|
118
|
+
*/
|
|
119
|
+
export function describeToolCall(call: ToolCallPart): string {
|
|
120
|
+
const args = call.arguments ?? {};
|
|
121
|
+
const name = `\`${call.name}\``;
|
|
122
|
+
if (call.name === "bash") {
|
|
123
|
+
const firstLine = splitTerminalLines(argumentString(args, "command"))[0]?.trim() ?? "";
|
|
124
|
+
return firstLine ? `${name} $ ${truncateText(firstLine, CAPS.commandPreviewChars)}` : name;
|
|
125
|
+
}
|
|
126
|
+
if (call.name === "read" || call.name === "edit" || call.name === "write" || call.name === "ls") {
|
|
127
|
+
const path = argumentString(args, "path") || argumentString(args, "file_path");
|
|
128
|
+
return path ? `${name} ${shortenPath(path)}` : name;
|
|
129
|
+
}
|
|
130
|
+
if (call.name === "grep" || call.name === "find") {
|
|
131
|
+
const query =
|
|
132
|
+
argumentString(args, "pattern") || argumentString(args, "glob") || argumentString(args, "path");
|
|
133
|
+
return query ? `${name} ${truncateText(query, CAPS.genericArgumentChars)}` : name;
|
|
134
|
+
}
|
|
135
|
+
const readableArguments = formatArguments(args, CAPS.genericArgumentChars);
|
|
136
|
+
return readableArguments ? `${name} ${readableArguments}` : name;
|
|
137
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
3
|
+
import { stripAnsi } from "./ansi.js";
|
|
4
|
+
import { CAPS, GLYPHS } from "./constants.js";
|
|
5
|
+
import { type RenderTheme, toolLabel, treeConnector } from "./theme.js";
|
|
6
|
+
|
|
7
|
+
/** A stored tool result, as persisted in the child session transcript. */
|
|
8
|
+
export type ToolResultLike = {
|
|
9
|
+
content?: Array<{ type: string; text?: string }>;
|
|
10
|
+
details?: unknown;
|
|
11
|
+
isError?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function normalizeTerminalText(text: string): string {
|
|
15
|
+
return text.replace(/\r\n|\r/g, "\n").replace(/\t/g, " ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function splitTerminalLines(text: string): string[] {
|
|
19
|
+
return normalizeTerminalText(text).split("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @example lineCount("a\nb\n") // 2 */
|
|
23
|
+
export function lineCount(text: string): number {
|
|
24
|
+
if (!text) return 0;
|
|
25
|
+
const normalized = normalizeTerminalText(text).replace(/\n$/, "");
|
|
26
|
+
return normalized ? normalized.split("\n").length : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function textContent(result: ToolResultLike | undefined): string {
|
|
30
|
+
const part = result?.content?.find(
|
|
31
|
+
(candidate) => candidate?.type === "text" && typeof candidate.text === "string",
|
|
32
|
+
);
|
|
33
|
+
return part?.text ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @example truncateText("abcdef", 4) // "abc…" */
|
|
37
|
+
export function truncateText(text: string, maxChars: number): string {
|
|
38
|
+
if (text.length <= maxChars) return text;
|
|
39
|
+
return `${text.slice(0, Math.max(0, maxChars - GLYPHS.ellipsis.length))}${GLYPHS.ellipsis}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clipLine(line: string): string {
|
|
43
|
+
return truncateText(line, CAPS.maxLineWidth);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function preview(text: string, count: number): string {
|
|
47
|
+
return splitTerminalLines(text).slice(0, count).map(clipLine).join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function plural(count: number, singular: string, pluralText = `${singular}s`): string {
|
|
51
|
+
return `${count} ${count === 1 ? singular : pluralText}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function truncatedMarker(text: string): boolean {
|
|
55
|
+
return /^\s*\[(?:Output|Full output|Read output|Search output|Bash output)[^\n\]]*truncated|^\s*\[[^\n\]]*Full output saved to:/im.test(
|
|
56
|
+
text,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type TruncationDetails = {
|
|
61
|
+
firstLineExceedsLimit?: boolean;
|
|
62
|
+
outputLines?: number;
|
|
63
|
+
totalLines?: number;
|
|
64
|
+
truncated?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function truncationDetails(result: ToolResultLike | undefined): TruncationDetails | undefined {
|
|
68
|
+
const details = result?.details as { truncation?: TruncationDetails } | undefined;
|
|
69
|
+
return details?.truncation;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resultTruncated(result: ToolResultLike | undefined): boolean {
|
|
73
|
+
const truncation = truncationDetails(result);
|
|
74
|
+
if (typeof truncation?.truncated === "boolean") return truncation.truncated;
|
|
75
|
+
return truncatedMarker(textContent(result));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Prefer Pi's persisted line counts; fall back to counting the stored output. */
|
|
79
|
+
export function readResultSummary(
|
|
80
|
+
result: ToolResultLike | undefined,
|
|
81
|
+
args: Record<string, unknown>,
|
|
82
|
+
theme: RenderTheme,
|
|
83
|
+
): string {
|
|
84
|
+
const truncation = truncationDetails(result);
|
|
85
|
+
if (
|
|
86
|
+
truncation?.truncated &&
|
|
87
|
+
typeof truncation.outputLines === "number" &&
|
|
88
|
+
typeof truncation.totalLines === "number"
|
|
89
|
+
) {
|
|
90
|
+
let summary =
|
|
91
|
+
theme.fg("success", `${truncation.outputLines}/${truncation.totalLines} lines`) +
|
|
92
|
+
theme.fg("warning", " · truncated");
|
|
93
|
+
if (!truncation.firstLineExceedsLimit && truncation.outputLines > 0) {
|
|
94
|
+
const startLine = Math.max(1, Math.floor(Number(args.offset) || 1));
|
|
95
|
+
summary += theme.fg("dim", ` · continue offset=${startLine + truncation.outputLines}`);
|
|
96
|
+
}
|
|
97
|
+
return summary;
|
|
98
|
+
}
|
|
99
|
+
const count = lineCount(textContent(result));
|
|
100
|
+
let summary = theme.fg("success", plural(count, "line"));
|
|
101
|
+
if (resultTruncated(result)) summary += theme.fg("warning", " · truncated");
|
|
102
|
+
return summary;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @example shortenPath("/nowhere/x.ts") // "/nowhere/x.ts" */
|
|
106
|
+
export function shortenPath(pathText: string): string {
|
|
107
|
+
const home = homedir();
|
|
108
|
+
if (pathText === home) return "~";
|
|
109
|
+
if (pathText.startsWith(`${home}/`)) return `~${pathText.slice(home.length)}`;
|
|
110
|
+
return pathText;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderToolPathText(
|
|
114
|
+
rawPath: unknown,
|
|
115
|
+
theme: RenderTheme,
|
|
116
|
+
emptyFallback?: string,
|
|
117
|
+
): string {
|
|
118
|
+
const value = typeof rawPath === "string" ? rawPath : rawPath == null ? "" : String(rawPath);
|
|
119
|
+
const displayPath = value || emptyFallback;
|
|
120
|
+
if (!displayPath) return "";
|
|
121
|
+
return theme.fg("accent", shortenPath(displayPath));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function argumentPath(args: Record<string, unknown>): unknown {
|
|
125
|
+
return args.path ?? args.file_path ?? "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readCallText(args: Record<string, unknown>, theme: RenderTheme): string {
|
|
129
|
+
const offset = args.offset;
|
|
130
|
+
const limit = args.limit;
|
|
131
|
+
const range = offset || limit
|
|
132
|
+
? `:${offset ?? 1}${limit ? `-${Number(offset ?? 1) + Number(limit) - 1}` : ""}`
|
|
133
|
+
: "";
|
|
134
|
+
return `${toolLabel(theme, "Read ")}${renderToolPathText(argumentPath(args), theme)}${
|
|
135
|
+
range ? theme.fg("accent", range) : ""
|
|
136
|
+
}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readOnlyCallText(
|
|
140
|
+
toolName: "grep" | "find" | "ls",
|
|
141
|
+
args: Record<string, unknown>,
|
|
142
|
+
theme: RenderTheme,
|
|
143
|
+
): string {
|
|
144
|
+
if (toolName === "ls")
|
|
145
|
+
return `${toolLabel(theme, "ls ")}${renderToolPathText(args.path ?? ".", theme)}`;
|
|
146
|
+
const usesPathOnly =
|
|
147
|
+
args.pattern === undefined && args.glob === undefined && typeof args.path === "string";
|
|
148
|
+
const query = args.pattern ?? args.glob ?? args.path ?? args.query ?? "";
|
|
149
|
+
const rendered = usesPathOnly
|
|
150
|
+
? renderToolPathText(args.path, theme)
|
|
151
|
+
: theme.fg("accent", clipLine(String(query)));
|
|
152
|
+
return `${toolLabel(theme, `${toolName} `)}${rendered}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const ARGUMENT_VALUE_CHARS = 60;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One argument value, readable at a glance.
|
|
159
|
+
*
|
|
160
|
+
* Nested containers are summarized rather than serialized: a JSON blob is exactly what this
|
|
161
|
+
* renderer exists to remove, and a nested object tells the reader nothing at overlay width.
|
|
162
|
+
*
|
|
163
|
+
* @example formatArgumentValue(3) // "3"
|
|
164
|
+
* @example formatArgumentValue({ a: 1, b: 2 }) // "{2 keys}"
|
|
165
|
+
*/
|
|
166
|
+
export function formatArgumentValue(value: unknown): string {
|
|
167
|
+
if (value === null) return "null";
|
|
168
|
+
if (Array.isArray(value)) return `[${plural(value.length, "item")}]`;
|
|
169
|
+
if (typeof value === "object") return `{${plural(Object.keys(value).length, "key")}}`;
|
|
170
|
+
if (typeof value !== "string") return String(value);
|
|
171
|
+
const clipped = truncateText(value.replace(/\s+/g, " ").trim(), ARGUMENT_VALUE_CHARS);
|
|
172
|
+
return clipped.includes(" ") ? `"${clipped}"` : clipped;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @example formatArguments({ limit: 5 }) // "limit=5" */
|
|
176
|
+
export function formatArguments(args: Record<string, unknown>, maxChars: number): string {
|
|
177
|
+
const pairs = Object.entries(args).map(([key, value]) => `${key}=${formatArgumentValue(value)}`);
|
|
178
|
+
return truncateText(pairs.join(" "), maxChars);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function renderPathListPreview(
|
|
182
|
+
output: string,
|
|
183
|
+
toolName: "find" | "ls",
|
|
184
|
+
theme: RenderTheme,
|
|
185
|
+
): string {
|
|
186
|
+
const items = splitTerminalLines(output).filter((line) => line.trim().length > 0);
|
|
187
|
+
if (items.length === 0)
|
|
188
|
+
return theme.fg("muted", toolName === "ls" ? "empty directory" : "no files found");
|
|
189
|
+
const shown = items.slice(0, CAPS.searchPreviewLines);
|
|
190
|
+
const lines = shown.map((item, index) => {
|
|
191
|
+
const clean = stripAnsi(item).trim();
|
|
192
|
+
const isDirectory = clean.endsWith("/");
|
|
193
|
+
const isLast = index === shown.length - 1 && shown.length === items.length;
|
|
194
|
+
const label = isDirectory ? theme.fg("accent", theme.bold(clean)) : theme.fg("dim", clean);
|
|
195
|
+
return `${treeConnector(theme, isLast ? "└" : "├")}${label}`;
|
|
196
|
+
});
|
|
197
|
+
const remaining = items.length - shown.length;
|
|
198
|
+
if (remaining > 0) {
|
|
199
|
+
const noun =
|
|
200
|
+
toolName === "ls"
|
|
201
|
+
? remaining === 1
|
|
202
|
+
? "entry"
|
|
203
|
+
: "entries"
|
|
204
|
+
: remaining === 1
|
|
205
|
+
? "file"
|
|
206
|
+
: "files";
|
|
207
|
+
lines.push(
|
|
208
|
+
`${treeConnector(theme, "└")}${theme.fg("muted", `${GLYPHS.ellipsis} ${remaining} more ${noun}`)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { GLYPHS } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The overlay only guarantees `fg` and `bold`. Everything else is probed defensively.
|
|
7
|
+
*
|
|
8
|
+
* These are written as methods, not function properties, so Pi's `Theme` — whose tokens are
|
|
9
|
+
* narrow string unions — stays assignable to this wider structural type.
|
|
10
|
+
*/
|
|
11
|
+
export type RenderTheme = {
|
|
12
|
+
bg?(token: string, text: string): string;
|
|
13
|
+
bold(text: string): string;
|
|
14
|
+
fg(token: ThemeColor, text: string): string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TreeBranch = "├" | "└" | "│";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return the styled text only when the theme actually knows the token.
|
|
21
|
+
*
|
|
22
|
+
* A theme that does not recognize a token returns the text unchanged, and some themes alias
|
|
23
|
+
* unknown tokens to plain body text. Both cases must fall through to the next candidate.
|
|
24
|
+
*/
|
|
25
|
+
export function fgToken(
|
|
26
|
+
theme: RenderTheme,
|
|
27
|
+
token: ThemeColor,
|
|
28
|
+
text: string,
|
|
29
|
+
rejectTextFallback = false,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
const styled = theme.fg(token, text);
|
|
32
|
+
if (typeof styled !== "string" || styled === text) return undefined;
|
|
33
|
+
if (!rejectTextFallback || token === "text") return styled;
|
|
34
|
+
const textStyled = theme.fg("text", text);
|
|
35
|
+
if (typeof textStyled === "string" && textStyled !== text && styled === textStyled) return undefined;
|
|
36
|
+
return styled;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function subtleRule(theme: RenderTheme, text: string): string {
|
|
40
|
+
return (
|
|
41
|
+
fgToken(theme, "borderMuted", text, true) ??
|
|
42
|
+
fgToken(theme, "muted", text, true) ??
|
|
43
|
+
fgToken(theme, "dim", text, true) ??
|
|
44
|
+
text
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toolRule(theme: RenderTheme, text: string): string {
|
|
49
|
+
return fgToken(theme, "muted", text, true) ?? fgToken(theme, "dim", text, true) ?? text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function borderMuted(theme: RenderTheme, text: string): string {
|
|
53
|
+
return subtleRule(theme, text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function treeGlyph(branch: TreeBranch): string {
|
|
57
|
+
if (branch === "│") return ` ${GLYPHS.tree.stem}`;
|
|
58
|
+
return branch === "└" ? ` ${GLYPHS.tree.last}` : ` ${GLYPHS.tree.mid}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function treeConnector(theme: RenderTheme, branch: TreeBranch = "├"): string {
|
|
62
|
+
return toolRule(theme, treeGlyph(branch));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function toolLabel(theme: RenderTheme, label: string): string {
|
|
66
|
+
return theme.fg("text", theme.bold(label));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function stackPrefix(theme: RenderTheme): string {
|
|
70
|
+
return fgToken(theme, "accent", GLYPHS.bullet, true) ?? GLYPHS.bullet;
|
|
71
|
+
}
|