@code-yeongyu/senpi-codemode 2026.7.25-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/CHANGELOG.md +250 -0
- package/LICENSE +22 -0
- package/README.md +161 -0
- package/package.json +58 -0
- package/src/bridge/http-server.ts +236 -0
- package/src/bridge/protocol.ts +198 -0
- package/src/bridge/reserved.ts +9 -0
- package/src/bridges/agent-bridge.ts +197 -0
- package/src/bridges/output-bridge.ts +96 -0
- package/src/bridges/schema-injection.ts +3 -0
- package/src/codemode/runtime.ts +258 -0
- package/src/codemode/tools.ts +106 -0
- package/src/completion/handler.ts +192 -0
- package/src/completion/tool-bridge.ts +55 -0
- package/src/config/settings.ts +215 -0
- package/src/extension/runtime-factory.ts +114 -0
- package/src/extension/session-manager-proxy.ts +116 -0
- package/src/extension/session-manager.ts +215 -0
- package/src/host-sdk.ts +1 -0
- package/src/index.ts +181 -0
- package/src/interpreters/detect.ts +161 -0
- package/src/kernels/jl/kernel.ts +37 -0
- package/src/kernels/jl/prelude.jl +283 -0
- package/src/kernels/jl/runner.jl +327 -0
- package/src/kernels/js/context-manager.ts +296 -0
- package/src/kernels/js/inline-worker-entry.js +23 -0
- package/src/kernels/js/inline-worker.ts +15 -0
- package/src/kernels/js/kernel-contract.ts +38 -0
- package/src/kernels/js/local-module-loader.ts +108 -0
- package/src/kernels/js/prelude.ts +15 -0
- package/src/kernels/js/rewrite-imports.ts +164 -0
- package/src/kernels/js/run-queue.ts +82 -0
- package/src/kernels/js/worker-core.d.ts +18 -0
- package/src/kernels/js/worker-core.js +94 -0
- package/src/kernels/js/worker-entry.js +23 -0
- package/src/kernels/js/worker-host.ts +117 -0
- package/src/kernels/js/worker-indirect-eval.js +88 -0
- package/src/kernels/js/worker-runtime.js +401 -0
- package/src/kernels/py/kernel-contract.ts +32 -0
- package/src/kernels/py/kernel.ts +290 -0
- package/src/kernels/py/prelude.py +954 -0
- package/src/kernels/py/process.ts +119 -0
- package/src/kernels/py/transport.ts +237 -0
- package/src/kernels/rb/kernel.ts +26 -0
- package/src/kernels/rb/prelude.rb +270 -0
- package/src/kernels/rb/runner.rb +204 -0
- package/src/kernels/shared/subprocess-contract.ts +22 -0
- package/src/kernels/shared/subprocess-kernel.ts +266 -0
- package/src/kernels/shared/subprocess-process.ts +174 -0
- package/src/kernels/shared/subprocess-queue.ts +101 -0
- package/src/kernels/shared/subprocess-run.ts +98 -0
- package/src/output/output-meta.ts +89 -0
- package/src/output/streaming-output.ts +296 -0
- package/src/prompt/eval-prompt.ts +319 -0
- package/src/timeouts/bridge-timeout.ts +16 -0
- package/src/timeouts/idle-timeout.ts +84 -0
- package/src/tool/cell-handler.ts +279 -0
- package/src/tool/eval-tool.ts +285 -0
- package/src/tool/image.ts +274 -0
- package/src/tool/json-tree.ts +247 -0
- package/src/tool/render.ts +876 -0
- package/src/tool/status-events.ts +12 -0
- package/src/tool/types.ts +114 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import type { AgentToolResult, ExtensionContext } from "@code-yeongyu/senpi";
|
|
3
|
+
import { convertToPng, formatDimensionNote, resizeImage } from "@code-yeongyu/senpi";
|
|
4
|
+
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
|
+
import type { TruncationMeta } from "../output/output-meta.ts";
|
|
6
|
+
import {
|
|
7
|
+
artifactNotice,
|
|
8
|
+
DEFAULT_MAX_BYTES,
|
|
9
|
+
DEFAULT_MAX_LINES,
|
|
10
|
+
OutputSink,
|
|
11
|
+
type OutputSummary,
|
|
12
|
+
TailBuffer,
|
|
13
|
+
truncateTail,
|
|
14
|
+
} from "../output/streaming-output.ts";
|
|
15
|
+
|
|
16
|
+
const MAX_DISPLAY_TEXT_BYTES = 8_000;
|
|
17
|
+
|
|
18
|
+
export interface EvalImageContent {
|
|
19
|
+
readonly type: "image";
|
|
20
|
+
readonly data: string;
|
|
21
|
+
readonly mimeType: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EvalImageResizeResult {
|
|
25
|
+
readonly image: EvalImageContent;
|
|
26
|
+
readonly dimensionNote?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type EvalImageResizer = (
|
|
30
|
+
image: EvalImageContent,
|
|
31
|
+
model: ExtensionContext["model"],
|
|
32
|
+
) => Promise<EvalImageResizeResult>;
|
|
33
|
+
|
|
34
|
+
export interface EvalOutputOptions {
|
|
35
|
+
readonly artifactPath?: string;
|
|
36
|
+
readonly headBytes: number;
|
|
37
|
+
readonly maxColumns: number;
|
|
38
|
+
readonly model: ExtensionContext["model"];
|
|
39
|
+
readonly imageResizer?: EvalImageResizer;
|
|
40
|
+
readonly onChunk: (aggregateText: string, cellText: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EvalOutputResult {
|
|
44
|
+
readonly output: string;
|
|
45
|
+
readonly images: readonly EvalImageContent[];
|
|
46
|
+
readonly jsonOutputs: readonly unknown[];
|
|
47
|
+
readonly hasMarkdown: boolean;
|
|
48
|
+
readonly truncated: boolean;
|
|
49
|
+
readonly notice?: string;
|
|
50
|
+
readonly meta?: TruncationMeta;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
|
|
54
|
+
type WebpModel = { readonly provider: string; readonly api: string } | undefined;
|
|
55
|
+
|
|
56
|
+
class DisplayPayloadError extends Error {
|
|
57
|
+
readonly name = "DisplayPayloadError";
|
|
58
|
+
|
|
59
|
+
constructor(mimeType: string, cause: SyntaxError) {
|
|
60
|
+
super(`Invalid ${mimeType} display payload: ${cause.message}`, { cause });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class EvalOutputCollector {
|
|
65
|
+
readonly #options: EvalOutputOptions;
|
|
66
|
+
readonly #sink: OutputSink;
|
|
67
|
+
readonly #aggregateTail = new TailBuffer(DEFAULT_MAX_BYTES * 2);
|
|
68
|
+
readonly #cellTail = new TailBuffer(DEFAULT_MAX_BYTES * 2);
|
|
69
|
+
readonly #displayImages: EvalImageContent[] = [];
|
|
70
|
+
readonly #images: EvalImageContent[] = [];
|
|
71
|
+
readonly #jsonOutputs: unknown[] = [];
|
|
72
|
+
#hasMarkdown = false;
|
|
73
|
+
#imagesProcessed = false;
|
|
74
|
+
|
|
75
|
+
constructor(options: EvalOutputOptions) {
|
|
76
|
+
this.#options = options;
|
|
77
|
+
this.#sink = new OutputSink({
|
|
78
|
+
artifactPath: options.artifactPath,
|
|
79
|
+
headBytes: options.headBytes,
|
|
80
|
+
maxColumns: options.maxColumns,
|
|
81
|
+
onChunk: (chunk) => {
|
|
82
|
+
this.#aggregateTail.append(chunk);
|
|
83
|
+
this.#cellTail.append(chunk);
|
|
84
|
+
options.onChunk(this.#aggregateTail.text(), this.#cellTail.text());
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
push(text: string): void {
|
|
90
|
+
this.#sink.push(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
display(message: DisplayMessage): void {
|
|
94
|
+
if (message.mimeType.startsWith("image/")) {
|
|
95
|
+
this.#displayImages.push({ type: "image", mimeType: message.mimeType, data: message.dataBase64 });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const text = Buffer.from(message.dataBase64, "base64").toString("utf8");
|
|
99
|
+
if (message.mimeType === "application/json") {
|
|
100
|
+
let value: unknown;
|
|
101
|
+
try {
|
|
102
|
+
value = JSON.parse(text);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof SyntaxError) throw new DisplayPayloadError(message.mimeType, error);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
this.#jsonOutputs.push(value);
|
|
108
|
+
this.#sink.push(`display[${this.#jsonOutputs.length}]:\n${formatDisplayJson(value)}\n`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (message.mimeType === "text/markdown") this.#hasMarkdown = true;
|
|
112
|
+
this.#sink.push(text.endsWith("\n") ? text : `${text}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
aggregateText(): string {
|
|
116
|
+
return this.#aggregateTail.text();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async finish(): Promise<EvalOutputResult> {
|
|
120
|
+
await this.#processImages();
|
|
121
|
+
const summary = await this.#finalSummary();
|
|
122
|
+
const meta = truncationMetaFromSummary(summary);
|
|
123
|
+
const notice = summary.artifactId === undefined ? undefined : artifactNotice(summary.artifactId);
|
|
124
|
+
return {
|
|
125
|
+
output: summary.output.trimEnd(),
|
|
126
|
+
images: [...this.#images],
|
|
127
|
+
jsonOutputs: [...this.#jsonOutputs],
|
|
128
|
+
hasMarkdown: this.#hasMarkdown,
|
|
129
|
+
truncated: summary.truncated,
|
|
130
|
+
...(notice === undefined ? {} : { notice }),
|
|
131
|
+
...(meta === undefined ? {} : { meta }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async flush(): Promise<void> {
|
|
136
|
+
await this.#sink.dump();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async #processImages(): Promise<void> {
|
|
140
|
+
if (this.#imagesProcessed) return;
|
|
141
|
+
this.#imagesProcessed = true;
|
|
142
|
+
const resize = this.#options.imageResizer ?? resizeEvalImage;
|
|
143
|
+
for (const source of this.#displayImages) {
|
|
144
|
+
const resized = await resize(source, this.#options.model);
|
|
145
|
+
this.#images.push(resized.image);
|
|
146
|
+
const description = resized.dimensionNote ?? `[${resized.image.mimeType}]`;
|
|
147
|
+
this.#sink.push(`display image ${this.#images.length}: ${description}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async #finalSummary(): Promise<OutputSummary> {
|
|
152
|
+
const summary = await this.#sink.dump();
|
|
153
|
+
if (summary.truncated || summary.totalLines <= DEFAULT_MAX_LINES) return summary;
|
|
154
|
+
const truncated = truncateTail(summary.output, {
|
|
155
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
156
|
+
maxBytes: Number.MAX_SAFE_INTEGER,
|
|
157
|
+
});
|
|
158
|
+
let artifactId = summary.artifactId;
|
|
159
|
+
if (artifactId === undefined && this.#options.artifactPath !== undefined) {
|
|
160
|
+
await writeFile(this.#options.artifactPath, this.#aggregateTail.text(), "utf8");
|
|
161
|
+
artifactId = this.#options.artifactPath;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
...summary,
|
|
165
|
+
output: truncated.content,
|
|
166
|
+
truncated: true,
|
|
167
|
+
outputLines: truncated.outputLines,
|
|
168
|
+
outputBytes: truncated.outputBytes,
|
|
169
|
+
...(artifactId === undefined ? {} : { artifactId }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function webpExclusionForModel(model: WebpModel): true | undefined {
|
|
175
|
+
if (model === undefined) return undefined;
|
|
176
|
+
return model.provider === "ollama" ||
|
|
177
|
+
model.provider === "ollama-cloud" ||
|
|
178
|
+
model.provider === "llama.cpp" ||
|
|
179
|
+
model.provider === "lm-studio" ||
|
|
180
|
+
model.provider === "local-server" ||
|
|
181
|
+
model.api === "ollama-chat"
|
|
182
|
+
? true
|
|
183
|
+
: undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const resizeEvalImage: EvalImageResizer = async (image, model) => {
|
|
187
|
+
const excludeWebP = webpExclusionForModel(model);
|
|
188
|
+
const forceWebpConversion = excludeWebP === true && image.mimeType === "image/webp";
|
|
189
|
+
const resized = await resizeImage(
|
|
190
|
+
Buffer.from(image.data, "base64"),
|
|
191
|
+
image.mimeType,
|
|
192
|
+
forceWebpConversion ? { maxBytes: Buffer.byteLength(image.data, "utf8") } : undefined,
|
|
193
|
+
);
|
|
194
|
+
let output: EvalImageContent =
|
|
195
|
+
resized === null ? image : { type: "image", data: resized.data, mimeType: resized.mimeType };
|
|
196
|
+
if (excludeWebP === true && output.mimeType === "image/webp") {
|
|
197
|
+
const converted = await convertToPng(output.data, output.mimeType);
|
|
198
|
+
if (converted === null)
|
|
199
|
+
throw new TypeError(`Unable to convert ${output.mimeType} display output for the active model`);
|
|
200
|
+
output = { type: "image", data: converted.data, mimeType: converted.mimeType };
|
|
201
|
+
}
|
|
202
|
+
const dimensionNote = resized === null ? undefined : formatDimensionNote(resized);
|
|
203
|
+
return { image: output, ...(dimensionNote === undefined ? {} : { dimensionNote }) };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
function formatDisplayJson(value: unknown): string {
|
|
207
|
+
let text: string;
|
|
208
|
+
try {
|
|
209
|
+
text = JSON.stringify(value, null, 2) ?? String(value);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!(error instanceof TypeError)) throw error;
|
|
212
|
+
text = String(value);
|
|
213
|
+
}
|
|
214
|
+
if (text.length <= MAX_DISPLAY_TEXT_BYTES) return text;
|
|
215
|
+
return `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n[…${text.length - MAX_DISPLAY_TEXT_BYTES}ch elided…]`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function truncationMetaFromSummary(summary: OutputSummary): TruncationMeta | undefined {
|
|
219
|
+
if (!summary.truncated) return undefined;
|
|
220
|
+
const artifact = summary.artifactId === undefined ? {} : { artifactId: summary.artifactId };
|
|
221
|
+
if (summary.elidedBytes !== undefined && summary.elidedBytes > 0) {
|
|
222
|
+
const elidedLines = summary.elidedLines ?? Math.max(0, summary.totalLines - summary.outputLines);
|
|
223
|
+
const keptLines = Math.max(0, summary.outputLines - 1);
|
|
224
|
+
const headLines = Math.ceil(keptLines / 2);
|
|
225
|
+
const tailLines = keptLines - headLines;
|
|
226
|
+
return {
|
|
227
|
+
direction: "middle",
|
|
228
|
+
truncatedBy: "middle",
|
|
229
|
+
totalLines: summary.totalLines,
|
|
230
|
+
totalBytes: summary.totalBytes,
|
|
231
|
+
outputLines: summary.outputLines,
|
|
232
|
+
outputBytes: summary.outputBytes,
|
|
233
|
+
...(headLines > 0 ? { headRange: { start: 1, end: headLines } } : {}),
|
|
234
|
+
...(tailLines > 0
|
|
235
|
+
? { tailRange: { start: summary.totalLines - tailLines + 1, end: summary.totalLines } }
|
|
236
|
+
: {}),
|
|
237
|
+
elidedBytes: summary.elidedBytes,
|
|
238
|
+
elidedLines,
|
|
239
|
+
...artifact,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
direction: "tail",
|
|
244
|
+
truncatedBy: summary.outputBytes < summary.totalBytes ? "bytes" : "lines",
|
|
245
|
+
totalLines: summary.totalLines,
|
|
246
|
+
totalBytes: summary.totalBytes,
|
|
247
|
+
outputLines: summary.outputLines,
|
|
248
|
+
outputBytes: summary.outputBytes,
|
|
249
|
+
shownRange: { start: Math.max(1, summary.totalLines - summary.outputLines + 1), end: summary.totalLines },
|
|
250
|
+
...artifact,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function marshalToolResult(result: AgentToolResult<unknown>) {
|
|
255
|
+
const texts = result.content.filter((part) => part.type === "text").map((part) => part.text);
|
|
256
|
+
const images = result.content
|
|
257
|
+
.filter((part) => part.type === "image")
|
|
258
|
+
.map((part) => ({ mimeType: part.mimeType, dataBase64: part.data }));
|
|
259
|
+
const details =
|
|
260
|
+
typeof result.details === "object" &&
|
|
261
|
+
result.details !== null &&
|
|
262
|
+
!Array.isArray(result.details) &&
|
|
263
|
+
Object.keys(result.details).length === 0
|
|
264
|
+
? undefined
|
|
265
|
+
: result.details;
|
|
266
|
+
const hasError = toolResultIsError(result);
|
|
267
|
+
const text = texts.join("\n");
|
|
268
|
+
return images.length === 0 && details === undefined && !hasError ? { text } : { text, details, images, hasError };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function toolResultIsError(result: AgentToolResult<unknown>): boolean {
|
|
272
|
+
const details = result.details;
|
|
273
|
+
return typeof details === "object" && details !== null && "isError" in details && details.isError === true;
|
|
274
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@code-yeongyu/senpi";
|
|
2
|
+
|
|
3
|
+
export const JSON_TREE_MAX_DEPTH_COLLAPSED = 2;
|
|
4
|
+
export const JSON_TREE_MAX_DEPTH_EXPANDED = 6;
|
|
5
|
+
export const JSON_TREE_MAX_LINES_COLLAPSED = 6;
|
|
6
|
+
export const JSON_TREE_MAX_LINES_EXPANDED = 200;
|
|
7
|
+
export const JSON_TREE_SCALAR_LEN_COLLAPSED = 60;
|
|
8
|
+
export const JSON_TREE_SCALAR_LEN_EXPANDED = 2000;
|
|
9
|
+
|
|
10
|
+
const HIDDEN_KEYS = { i: true, __partialJson: true } as const;
|
|
11
|
+
const TREE_VERTICAL = "│ ";
|
|
12
|
+
const TREE_BRANCH = "├─";
|
|
13
|
+
const TREE_LAST = "└─";
|
|
14
|
+
// Senpi's Theme exposes fg() but not omp's tree/icon helpers, so fixed glyphs
|
|
15
|
+
// replace styledSymbol() while the existing dim/muted color names map directly.
|
|
16
|
+
const OBJECT_GLYPH = "◆";
|
|
17
|
+
const ARRAY_GLYPH = "◇";
|
|
18
|
+
const SCALAR_GLYPH = "•";
|
|
19
|
+
|
|
20
|
+
type JsonTreeResult = { readonly lines: string[]; readonly truncated: boolean };
|
|
21
|
+
type RenderNode = readonly [
|
|
22
|
+
value: unknown,
|
|
23
|
+
key: string | undefined,
|
|
24
|
+
ancestors: boolean[],
|
|
25
|
+
isLast: boolean,
|
|
26
|
+
depth: number,
|
|
27
|
+
];
|
|
28
|
+
type RenderState = {
|
|
29
|
+
readonly lines: string[];
|
|
30
|
+
readonly theme: Theme | undefined;
|
|
31
|
+
readonly maxDepth: number;
|
|
32
|
+
readonly maxLines: number;
|
|
33
|
+
readonly maxScalarLen: number;
|
|
34
|
+
readonly activeObjects: Set<object>;
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function style(theme: Theme | undefined, color: ThemeColor, text: string): string {
|
|
43
|
+
return theme === undefined ? text : theme.fg(color, text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function textWidth(text: string): number {
|
|
47
|
+
let width = 0;
|
|
48
|
+
for (const character of text) width += character === "\t" ? 4 : 1;
|
|
49
|
+
return width;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function truncateToColumns(text: string, maxColumns: number): string {
|
|
53
|
+
const limit = Math.max(0, Math.floor(maxColumns));
|
|
54
|
+
if (textWidth(text) <= limit) return text;
|
|
55
|
+
if (limit === 0) return "";
|
|
56
|
+
const marker = "…";
|
|
57
|
+
const prefixLimit = Math.max(0, limit - textWidth(marker));
|
|
58
|
+
let prefix = "";
|
|
59
|
+
let width = 0;
|
|
60
|
+
for (const character of text) {
|
|
61
|
+
const characterWidth = character === "\t" ? 4 : 1;
|
|
62
|
+
if (width + characterWidth > prefixLimit) break;
|
|
63
|
+
prefix += character;
|
|
64
|
+
width += characterWidth;
|
|
65
|
+
}
|
|
66
|
+
return `${prefix}${marker}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatScalar(value: unknown, maxLen: number): string {
|
|
70
|
+
if (value === null) return "null";
|
|
71
|
+
if (value === undefined) return "undefined";
|
|
72
|
+
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
const escaped = value.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
75
|
+
return `"${truncateToColumns(escaped, maxLen)}"`;
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) return `[${value.length} items]`;
|
|
78
|
+
if (typeof value === "object") return `{${Object.keys(value).length} keys}`;
|
|
79
|
+
return String(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildTreePrefix(theme: Theme | undefined, ancestors: readonly boolean[]): string {
|
|
83
|
+
return ancestors.map((hasNext) => style(theme, "dim", hasNext ? TREE_VERTICAL : " ")).join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderJsonTreeLines(
|
|
87
|
+
value: unknown,
|
|
88
|
+
theme: Theme | undefined,
|
|
89
|
+
maxDepth: number,
|
|
90
|
+
maxLines: number,
|
|
91
|
+
maxScalarLen: number,
|
|
92
|
+
): JsonTreeResult {
|
|
93
|
+
const state: RenderState = {
|
|
94
|
+
lines: [],
|
|
95
|
+
theme,
|
|
96
|
+
maxDepth: Math.max(0, Math.floor(maxDepth)),
|
|
97
|
+
maxLines: Math.max(0, Math.floor(maxLines)),
|
|
98
|
+
maxScalarLen: Math.max(0, Math.floor(maxScalarLen)),
|
|
99
|
+
activeObjects: new Set<object>(),
|
|
100
|
+
truncated: false,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const pushLine = (line: string): boolean => {
|
|
104
|
+
if (state.lines.length >= state.maxLines) {
|
|
105
|
+
state.truncated = true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
state.lines.push(line);
|
|
109
|
+
return true;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const renderNode = (node: RenderNode): void => {
|
|
113
|
+
const [value, key, ancestors, isLast, depth] = node;
|
|
114
|
+
if (state.lines.length >= state.maxLines) {
|
|
115
|
+
state.truncated = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const connector = isLast ? TREE_LAST : TREE_BRANCH;
|
|
119
|
+
const prefix = `${buildTreePrefix(state.theme, ancestors)}${style(state.theme, "dim", connector)} `;
|
|
120
|
+
const objectValue = value !== null && typeof value === "object" ? value : undefined;
|
|
121
|
+
if (objectValue !== undefined && state.activeObjects.has(objectValue)) {
|
|
122
|
+
pushLine(`${prefix}${style(state.theme, "dim", "… (circular)")}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (objectValue !== undefined) state.activeObjects.add(objectValue);
|
|
126
|
+
ancestors.push(!isLast);
|
|
127
|
+
try {
|
|
128
|
+
if (value === null || value === undefined || typeof value !== "object") {
|
|
129
|
+
const label = key ? style(state.theme, "muted", key) : style(state.theme, "muted", "value");
|
|
130
|
+
if (typeof value === "string" && value.includes("\n")) {
|
|
131
|
+
const stringLines = value.split("\n");
|
|
132
|
+
const visibleLines = Math.min(stringLines.length, Math.max(1, state.maxLines - state.lines.length - 1));
|
|
133
|
+
const first = truncateToColumns(stringLines[0] ?? "", state.maxScalarLen);
|
|
134
|
+
if (
|
|
135
|
+
!pushLine(
|
|
136
|
+
`${prefix}${style(state.theme, "muted", SCALAR_GLYPH)} ${label}: ${style(state.theme, "dim", `"${first}`)}`,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return;
|
|
140
|
+
const continuePrefix = buildTreePrefix(state.theme, ancestors);
|
|
141
|
+
for (let index = 1; index < visibleLines; index += 1) {
|
|
142
|
+
const line = truncateToColumns(stringLines[index] ?? "", state.maxScalarLen);
|
|
143
|
+
if (!pushLine(`${continuePrefix} ${style(state.theme, "dim", ` ${line}`)}`)) return;
|
|
144
|
+
}
|
|
145
|
+
if (stringLines.length > visibleLines) {
|
|
146
|
+
state.truncated = true;
|
|
147
|
+
pushLine(
|
|
148
|
+
`${continuePrefix} ${style(state.theme, "dim", ` …(${stringLines.length - visibleLines} more lines)"`)}`,
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
const lastIndex = state.lines.length - 1;
|
|
152
|
+
const lastLine = state.lines[lastIndex];
|
|
153
|
+
if (lastLine !== undefined) state.lines[lastIndex] = `${lastLine}${style(state.theme, "dim", '"')}`;
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
pushLine(
|
|
158
|
+
`${prefix}${style(state.theme, "muted", SCALAR_GLYPH)} ${label}: ${style(state.theme, "dim", formatScalar(value, state.maxScalarLen))}`,
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
const label = key ? key : "array";
|
|
165
|
+
if (!pushLine(`${prefix}${style(state.theme, "muted", ARRAY_GLYPH)} ${style(state.theme, "muted", label)}`))
|
|
166
|
+
return;
|
|
167
|
+
if (value.length === 0) {
|
|
168
|
+
pushLine(
|
|
169
|
+
`${buildTreePrefix(state.theme, ancestors)}${style(state.theme, "dim", TREE_LAST)} ${style(state.theme, "dim", "[]")}`,
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (depth >= state.maxDepth) {
|
|
174
|
+
pushLine(
|
|
175
|
+
`${buildTreePrefix(state.theme, ancestors)}${style(state.theme, "dim", TREE_LAST)} ${style(state.theme, "dim", "…")}`,
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const [index, child] of value.entries()) {
|
|
180
|
+
renderNode([child, `[${index}]`, ancestors, index === value.length - 1, depth + 1]);
|
|
181
|
+
if (state.lines.length >= state.maxLines) {
|
|
182
|
+
state.truncated = true;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!isRecord(value)) return;
|
|
190
|
+
const label = key ? key : "object";
|
|
191
|
+
if (!pushLine(`${prefix}${style(state.theme, "muted", OBJECT_GLYPH)} ${style(state.theme, "muted", label)}`))
|
|
192
|
+
return;
|
|
193
|
+
if (depth >= state.maxDepth) {
|
|
194
|
+
pushLine(
|
|
195
|
+
`${buildTreePrefix(state.theme, ancestors)}${style(state.theme, "dim", TREE_LAST)} ${style(state.theme, "dim", "…")}`,
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const keys = Object.keys(value);
|
|
200
|
+
if (keys.length === 0) {
|
|
201
|
+
pushLine(
|
|
202
|
+
`${buildTreePrefix(state.theme, ancestors)}${style(state.theme, "dim", TREE_LAST)} ${style(state.theme, "dim", "{}")}`,
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const [index, key] of keys.entries()) {
|
|
207
|
+
renderNode([value[key], key, ancestors, index === keys.length - 1, depth + 1]);
|
|
208
|
+
if (state.lines.length >= state.maxLines) {
|
|
209
|
+
state.truncated = true;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
ancestors.pop();
|
|
215
|
+
if (objectValue !== undefined) state.activeObjects.delete(objectValue);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const rootObject = value !== null && typeof value === "object" ? value : undefined;
|
|
220
|
+
if (rootObject !== undefined) state.activeObjects.add(rootObject);
|
|
221
|
+
try {
|
|
222
|
+
if (isRecord(value)) {
|
|
223
|
+
const keys = Object.keys(value).filter((key) => !Object.hasOwn(HIDDEN_KEYS, key));
|
|
224
|
+
for (const [index, key] of keys.entries()) {
|
|
225
|
+
renderNode([value[key], key, [], index === keys.length - 1, 1]);
|
|
226
|
+
if (state.lines.length >= state.maxLines) {
|
|
227
|
+
state.truncated = true;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} else if (Array.isArray(value)) {
|
|
232
|
+
for (const [index, child] of value.entries()) {
|
|
233
|
+
renderNode([child, `[${index}]`, [], index === value.length - 1, 1]);
|
|
234
|
+
if (state.lines.length >= state.maxLines) {
|
|
235
|
+
state.truncated = true;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
renderNode([value, undefined, [], true, 0]);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
if (rootObject !== undefined) state.activeObjects.delete(rootObject);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { lines: state.lines, truncated: state.truncated };
|
|
247
|
+
}
|