@zhijiewang/openharness 2.24.0 → 2.26.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/dist/Tool.d.ts CHANGED
@@ -8,6 +8,7 @@ import type { PermissionMode, RiskLevel } from "./types/permissions.js";
8
8
  export type ToolResult = {
9
9
  output: string;
10
10
  isError: boolean;
11
+ outputType?: "json" | "markdown" | "image" | "plain";
11
12
  };
12
13
  export type ToolContext = {
13
14
  workingDir: string;
@@ -4,6 +4,7 @@
4
4
  * right after the scrollback content each frame (no absolute positioning gap).
5
5
  */
6
6
  import { recordApproval } from "../harness/approvals.js";
7
+ import { appendToolPermission } from "../harness/config.js";
7
8
  import { getTheme } from "../utils/theme-data.js";
8
9
  import { summarizeToolArgs } from "../utils/tool-summary.js";
9
10
  import { CellGrid } from "./cells.js";
@@ -396,9 +397,6 @@ export class TerminalRenderer {
396
397
  // exists (we don't auto-create on first interaction).
397
398
  if (k === "a" && toolName) {
398
399
  try {
399
- // Lazy import to avoid pulling config into the renderer bundle
400
- // for callers that don't trip the permission path.
401
- const { appendToolPermission } = require("../harness/config.js");
402
400
  appendToolPermission(toolName);
403
401
  }
404
402
  catch {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Static JSON tree renderer for tool output.
3
+ * Theme-colored, indented, depth-truncated, line-truncated.
4
+ */
5
+ import type { CellGrid } from "./cells.js";
6
+ export declare function resetJsonStyleCache(): void;
7
+ export declare function renderJsonTree(grid: CellGrid, row: number, col: number, value: unknown, width: number, opts: {
8
+ maxLines: number;
9
+ limit: number;
10
+ }): number;
11
+ //# sourceMappingURL=json-tree.d.ts.map
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Static JSON tree renderer for tool output.
3
+ * Theme-colored, indented, depth-truncated, line-truncated.
4
+ */
5
+ import { getTheme } from "../utils/theme-data.js";
6
+ const s = (fg, bold = false, dim = false) => ({ fg, bg: null, bold, dim, underline: false });
7
+ const MAX_DEPTH = 3;
8
+ let S_KEY;
9
+ let S_STRING;
10
+ let S_NUMBER;
11
+ let S_PUNCT;
12
+ let S_TRUNC;
13
+ let _stylesInit = false;
14
+ export function resetJsonStyleCache() {
15
+ _stylesInit = false;
16
+ }
17
+ function ensureStyles() {
18
+ if (_stylesInit)
19
+ return;
20
+ _stylesInit = true;
21
+ const t = getTheme();
22
+ S_KEY = s(t.user);
23
+ S_STRING = s(t.success);
24
+ S_NUMBER = s(t.tool);
25
+ S_PUNCT = s(null, false, true);
26
+ S_TRUNC = s(null, false, true);
27
+ }
28
+ export function renderJsonTree(grid, row, col, value, width, opts) {
29
+ ensureStyles();
30
+ const lines = [];
31
+ const seen = new Set();
32
+ emitValue(lines, value, 0, 0, seen);
33
+ const maxRows = Math.min(opts.limit - row, opts.maxLines);
34
+ if (maxRows <= 0)
35
+ return 0;
36
+ const truncated = lines.length > maxRows;
37
+ const visible = truncated ? lines.slice(0, maxRows - 1) : lines;
38
+ let r = row;
39
+ for (const line of visible) {
40
+ if (r >= opts.limit)
41
+ break;
42
+ let c = col + line.indent;
43
+ for (const tok of line.tokens) {
44
+ for (let i = 0; i < tok.text.length; i++) {
45
+ if (c >= col + width)
46
+ break;
47
+ grid.setCell(r, c, tok.text[i], tok.style);
48
+ c++;
49
+ }
50
+ }
51
+ r++;
52
+ }
53
+ if (truncated && r < opts.limit) {
54
+ const footer = `… (${lines.length} lines total)`;
55
+ grid.writeText(r, col, footer.slice(0, width), S_TRUNC);
56
+ r++;
57
+ }
58
+ return r - row;
59
+ }
60
+ function emitValue(out, value, indent, depth, seen) {
61
+ if (value === null) {
62
+ out.push({ indent, tokens: [{ text: "null", style: S_NUMBER }] });
63
+ return;
64
+ }
65
+ if (typeof value === "string") {
66
+ out.push({ indent, tokens: [{ text: JSON.stringify(value), style: S_STRING }] });
67
+ return;
68
+ }
69
+ if (typeof value === "number" || typeof value === "boolean") {
70
+ out.push({ indent, tokens: [{ text: String(value), style: S_NUMBER }] });
71
+ return;
72
+ }
73
+ if (Array.isArray(value)) {
74
+ if (value.length === 0) {
75
+ out.push({ indent, tokens: [{ text: "[]", style: S_PUNCT }] });
76
+ return;
77
+ }
78
+ if (depth >= MAX_DEPTH) {
79
+ out.push({ indent, tokens: [{ text: `[${value.length} items]`, style: S_TRUNC }] });
80
+ return;
81
+ }
82
+ if (seen.has(value)) {
83
+ out.push({ indent, tokens: [{ text: "[Circular]", style: S_TRUNC }] });
84
+ return;
85
+ }
86
+ seen.add(value);
87
+ out.push({ indent, tokens: [{ text: "[", style: S_PUNCT }] });
88
+ for (let i = 0; i < value.length; i++) {
89
+ emitValueAsItem(out, value[i], indent + 2, depth + 1, seen, i < value.length - 1);
90
+ }
91
+ out.push({ indent, tokens: [{ text: "]", style: S_PUNCT }] });
92
+ seen.delete(value);
93
+ return;
94
+ }
95
+ if (typeof value === "object") {
96
+ const entries = Object.entries(value);
97
+ if (entries.length === 0) {
98
+ out.push({ indent, tokens: [{ text: "{}", style: S_PUNCT }] });
99
+ return;
100
+ }
101
+ if (depth >= MAX_DEPTH) {
102
+ out.push({ indent, tokens: [{ text: "{…}", style: S_TRUNC }] });
103
+ return;
104
+ }
105
+ if (seen.has(value)) {
106
+ out.push({ indent, tokens: [{ text: "[Circular]", style: S_TRUNC }] });
107
+ return;
108
+ }
109
+ seen.add(value);
110
+ out.push({ indent, tokens: [{ text: "{", style: S_PUNCT }] });
111
+ for (let i = 0; i < entries.length; i++) {
112
+ const [k, v] = entries[i];
113
+ emitObjectEntry(out, k, v, indent + 2, depth + 1, seen, i < entries.length - 1);
114
+ }
115
+ out.push({ indent, tokens: [{ text: "}", style: S_PUNCT }] });
116
+ seen.delete(value);
117
+ return;
118
+ }
119
+ out.push({ indent, tokens: [{ text: String(value), style: S_TRUNC }] });
120
+ }
121
+ function emitValueAsItem(out, value, indent, depth, seen, trailingComma) {
122
+ const before = out.length;
123
+ emitValue(out, value, indent, depth, seen);
124
+ if (trailingComma && out.length > before) {
125
+ const last = out[out.length - 1];
126
+ last.tokens.push({ text: ",", style: S_PUNCT });
127
+ }
128
+ }
129
+ function emitObjectEntry(out, key, value, indent, depth, seen, trailingComma) {
130
+ const keyTok = { text: JSON.stringify(key), style: S_KEY };
131
+ const colonTok = { text: ": ", style: S_PUNCT };
132
+ // Inline primitives onto the same line as the key.
133
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
134
+ const valStyle = typeof value === "string" ? S_STRING : S_NUMBER;
135
+ const valText = typeof value === "string" ? JSON.stringify(value) : String(value);
136
+ const tokens = [keyTok, colonTok, { text: valText, style: valStyle }];
137
+ if (trailingComma)
138
+ tokens.push({ text: ",", style: S_PUNCT });
139
+ out.push({ indent, tokens });
140
+ return;
141
+ }
142
+ // Empty container inlines too: "key": {} / "key": []
143
+ if (Array.isArray(value) && value.length === 0) {
144
+ const tokens = [keyTok, colonTok, { text: "[]", style: S_PUNCT }];
145
+ if (trailingComma)
146
+ tokens.push({ text: ",", style: S_PUNCT });
147
+ out.push({ indent, tokens });
148
+ return;
149
+ }
150
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) {
151
+ const tokens = [keyTok, colonTok, { text: "{}", style: S_PUNCT }];
152
+ if (trailingComma)
153
+ tokens.push({ text: ",", style: S_PUNCT });
154
+ out.push({ indent, tokens });
155
+ return;
156
+ }
157
+ // Depth-collapsed container also inlines.
158
+ if (depth >= MAX_DEPTH) {
159
+ const collapsed = Array.isArray(value) ? `[${value.length} items]` : "{…}";
160
+ out.push({ indent, tokens: [keyTok, colonTok, { text: collapsed, style: S_TRUNC }] });
161
+ return;
162
+ }
163
+ // Circular reference: render inline, no descent.
164
+ if (seen.has(value)) {
165
+ const circ = [keyTok, colonTok, { text: "[Circular]", style: S_TRUNC }];
166
+ if (trailingComma)
167
+ circ.push({ text: ",", style: S_PUNCT });
168
+ out.push({ indent, tokens: circ });
169
+ return;
170
+ }
171
+ // Non-empty container: open bracket on key line, body indented, close bracket on its own line.
172
+ const opener = Array.isArray(value) ? "[" : "{";
173
+ const closer = Array.isArray(value) ? "]" : "}";
174
+ out.push({ indent, tokens: [keyTok, colonTok, { text: opener, style: S_PUNCT }] });
175
+ seen.add(value);
176
+ if (Array.isArray(value)) {
177
+ for (let i = 0; i < value.length; i++) {
178
+ emitValueAsItem(out, value[i], indent + 2, depth + 1, seen, i < value.length - 1);
179
+ }
180
+ }
181
+ else {
182
+ const entries = Object.entries(value);
183
+ for (let i = 0; i < entries.length; i++) {
184
+ const [ck, cv] = entries[i];
185
+ emitObjectEntry(out, ck, cv, indent + 2, depth + 1, seen, i < entries.length - 1);
186
+ }
187
+ }
188
+ seen.delete(value);
189
+ const closerTokens = [{ text: closer, style: S_PUNCT }];
190
+ if (trailingComma)
191
+ closerTokens.push({ text: ",", style: S_PUNCT });
192
+ out.push({ indent, tokens: closerTokens });
193
+ }
194
+ //# sourceMappingURL=json-tree.js.map
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import { getTheme } from "../utils/theme-data.js";
6
6
  import { renderDiff } from "./diff.js";
7
- import { isImageOutput, renderImageInline } from "./image.js";
7
+ import { renderToolOutput } from "./output-renderer.js";
8
+ import { deriveSpinnerLabel } from "./spinner-label.js";
9
+ import { toolColor } from "./tool-color.js";
8
10
  // ── Style constants ──
9
11
  const s = (fg, bold = false, dim = false) => ({ fg, bg: null, bold, dim, underline: false });
10
12
  export const S_TEXT = s(null);
@@ -93,14 +95,14 @@ export function renderThinkingSummarySection(state, grid, r, limit) {
93
95
  export function renderSpinnerSection(state, grid, r, limit) {
94
96
  if (!state.loading || state.streamingText || state.thinkingText || r >= limit)
95
97
  return r;
96
- const thinkText = "Thinking";
98
+ const thinkText = deriveSpinnerLabel(state.toolCalls);
97
99
  const elapsed = state.thinkingStartedAt ? Math.floor((Date.now() - state.thinkingStartedAt) / 1000) : 0;
98
100
  const t = getTheme();
99
101
  const baseColor = elapsed > 60 ? t.error : elapsed > 30 ? t.stall : t.primary;
100
102
  const shimmerColor = elapsed > 60 ? t.stallShimmer : elapsed > 30 ? t.warning : t.primaryShimmer;
101
103
  const baseStyle = { fg: baseColor, bg: null, bold: false, dim: false, underline: false };
102
104
  grid.writeText(r, 0, "◆ ", { ...baseStyle, bold: true });
103
- const shimmerPos = state.spinnerFrame % (thinkText.length + 6);
105
+ const shimmerPos = state.spinnerFrame % 20;
104
106
  const shimmerStyle = { fg: shimmerColor, bg: null, bold: true, dim: false, underline: false };
105
107
  for (let ci = 0; ci < thinkText.length; ci++) {
106
108
  grid.setCell(r, 2 + ci, thinkText[ci], Math.abs(ci - shimmerPos) <= 1 ? shimmerStyle : baseStyle);
@@ -141,8 +143,9 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
141
143
  : tc.status === "done"
142
144
  ? "✓"
143
145
  : "✗";
144
- const statusStyle = tc.status === "error" ? S_ERROR : tc.status === "done" ? S_GREEN : isAgent ? S_AGENT : S_YELLOW;
145
- const nameStyle = isAgent ? S_AGENT : { ...S_YELLOW, bold: true };
146
+ const toolStyle = { fg: toolColor(tc.toolName), bg: null, bold: false, dim: false, underline: false };
147
+ const statusStyle = tc.status === "error" ? S_ERROR : tc.status === "done" ? S_GREEN : isAgent ? S_AGENT : toolStyle;
148
+ const nameStyle = isAgent ? S_AGENT : { ...toolStyle, bold: true };
146
149
  const isExpanded = state.expandedToolCalls.has(callId);
147
150
  const canExpand = tc.status !== "running" && tc.output;
148
151
  if (canExpand) {
@@ -192,26 +195,12 @@ export function renderToolCallsSection(state, grid, r, limit, opts) {
192
195
  }
193
196
  }
194
197
  if (tc.output && tc.status !== "running" && isExpanded && r < limit) {
195
- if (isImageOutput(tc.output)) {
196
- const label = renderImageInline(tc.output);
197
- grid.writeText(r, 6, label.slice(0, w - 8), S_DIM);
198
- r++;
199
- continue;
200
- }
201
- const outLines = tc.output.split("\n");
202
- const maxOut = 20;
203
- const showLines = outLines.slice(0, maxOut);
204
- for (const line of showLines) {
205
- if (r >= limit)
206
- break;
207
- const lineStyle = tc.status === "error" ? S_ERROR : S_DIM;
208
- grid.writeTextWithLinks(r, 6, line.slice(0, w - 8), lineStyle, w - 2);
209
- r++;
210
- }
211
- if (outLines.length > maxOut && r < limit) {
212
- grid.writeText(r, 6, `… (${outLines.length} lines total)`, S_DIM);
213
- r++;
214
- }
198
+ const consumed = renderToolOutput(grid, r, 6, tc.output, tc.outputType, w - 8, {
199
+ status: tc.status,
200
+ maxLines: 20,
201
+ limit,
202
+ });
203
+ r += consumed;
215
204
  }
216
205
  }
217
206
  return r;
@@ -427,21 +416,33 @@ export function renderInputSection(state, grid, inputRow, limit, promptText, pro
427
416
  const inputStart = promptWidth;
428
417
  const inputLines = state.inputText.split("\n");
429
418
  const maxInputLines = Math.min(inputLines.length, 5);
419
+ // Pre-compute the [N lines] suffix column on row 0 (if multi-line) so the
420
+ // wrap glyph for line 0 can yield to the suffix when they would collide.
421
+ // The +1 reserves the glyph column so the suffix starts one column after
422
+ // it (produces "text↵ [N lines]"); a +0 would let the suffix overwrite the
423
+ // glyph on narrow terminals.
424
+ let lineCountCol = -1;
425
+ if (inputLines.length > 1) {
426
+ const lineCountStr = ` [${inputLines.length} lines]`;
427
+ lineCountCol = Math.min(inputStart + (inputLines[0]?.length ?? 0) + 1, grid.width - lineCountStr.length - 1);
428
+ }
430
429
  for (let li = 0; li < maxInputLines; li++) {
431
430
  if (inputRow + li >= limit)
432
431
  break;
433
- if (li === 0) {
434
- grid.writeText(inputRow, inputStart, inputLines[0], S_TEXT);
435
- }
436
- else {
437
- grid.writeText(inputRow + li, inputStart, inputLines[li], S_TEXT);
432
+ const lineText = inputLines[li];
433
+ grid.writeText(inputRow + li, inputStart, lineText, S_TEXT);
434
+ // Audit U-C2: append a dim ↵ continuation glyph to every non-last line.
435
+ if (li < inputLines.length - 1) {
436
+ const glyphCol = inputStart + lineText.length;
437
+ const wouldCollideWithLineCount = li === 0 && lineCountCol > inputStart && glyphCol >= lineCountCol;
438
+ if (glyphCol < grid.width && !wouldCollideWithLineCount) {
439
+ grid.writeText(inputRow + li, glyphCol, "↵", S_DIM);
440
+ }
438
441
  }
439
442
  }
440
- if (inputLines.length > 1) {
443
+ if (inputLines.length > 1 && lineCountCol > inputStart) {
441
444
  const lineCountStr = ` [${inputLines.length} lines]`;
442
- const lineCountCol = Math.min(inputStart + (inputLines[0]?.length ?? 0) + 1, grid.width - lineCountStr.length - 1);
443
- if (lineCountCol > inputStart)
444
- grid.writeText(inputRow, lineCountCol, lineCountStr, S_DIM);
445
+ grid.writeText(inputRow, lineCountCol, lineCountStr, S_DIM);
445
446
  }
446
447
  const hintsRow = inputRow + maxInputLines;
447
448
  if (hintsRow < limit) {
@@ -12,6 +12,7 @@ export type ToolCallInfo = {
12
12
  toolName: string;
13
13
  status: "running" | "done" | "error";
14
14
  output?: string;
15
+ outputType?: "json" | "markdown" | "image" | "plain";
15
16
  args?: string;
16
17
  isAgent?: boolean;
17
18
  agentDescription?: string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Tool output dispatcher.
3
+ *
4
+ * Detection chain (stops at first hit):
5
+ * 1. __IMAGE__: sentinel -> renderImageInline
6
+ * 2. outputType="json" -> renderJsonTree (fallback to plain on parse fail)
7
+ * 3. outputType="markdown" -> renderMarkdown
8
+ * 4. outputType="plain"|"image" -> renderPlain (image without sentinel is malformed)
9
+ * 5. heuristic JSON parse -> renderJsonTree
10
+ * 6. heuristic markdown -> renderMarkdown
11
+ * 7. fallback -> renderPlain
12
+ */
13
+ import type { CellGrid } from "./cells.js";
14
+ export type OutputType = "json" | "markdown" | "image" | "plain";
15
+ export declare function renderToolOutput(grid: CellGrid, row: number, col: number, output: string, outputType: OutputType | undefined, width: number, opts: {
16
+ status: "running" | "done" | "error";
17
+ maxLines: number;
18
+ limit: number;
19
+ }): number;
20
+ export declare function tryParseJson(s: string): {
21
+ ok: true;
22
+ value: unknown;
23
+ } | {
24
+ ok: false;
25
+ };
26
+ export declare function looksLikeMarkdown(s: string): boolean;
27
+ //# sourceMappingURL=output-renderer.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tool output dispatcher.
3
+ *
4
+ * Detection chain (stops at first hit):
5
+ * 1. __IMAGE__: sentinel -> renderImageInline
6
+ * 2. outputType="json" -> renderJsonTree (fallback to plain on parse fail)
7
+ * 3. outputType="markdown" -> renderMarkdown
8
+ * 4. outputType="plain"|"image" -> renderPlain (image without sentinel is malformed)
9
+ * 5. heuristic JSON parse -> renderJsonTree
10
+ * 6. heuristic markdown -> renderMarkdown
11
+ * 7. fallback -> renderPlain
12
+ */
13
+ import { isImageOutput, renderImageInline } from "./image.js";
14
+ import { renderJsonTree } from "./json-tree.js";
15
+ import { renderMarkdown } from "./markdown.js";
16
+ const S_DIM = { fg: null, bg: null, bold: false, dim: true, underline: false };
17
+ const S_ERROR = { fg: "red", bg: null, bold: false, dim: false, underline: false };
18
+ export function renderToolOutput(grid, row, col, output, outputType, width, opts) {
19
+ // 1. Image sentinel always wins.
20
+ if (isImageOutput(output)) {
21
+ if (row >= opts.limit)
22
+ return 0;
23
+ const label = renderImageInline(output);
24
+ grid.writeText(row, col, label.slice(0, width), S_DIM);
25
+ return 1;
26
+ }
27
+ // 2-4. Typed dispatch.
28
+ if (outputType === "json") {
29
+ const parsed = tryParseJson(output);
30
+ if (parsed.ok)
31
+ return renderJsonTree(grid, row, col, parsed.value, width, { maxLines: opts.maxLines, limit: opts.limit });
32
+ return renderPlain(grid, row, col, output, width, opts);
33
+ }
34
+ if (outputType === "markdown") {
35
+ return renderMarkdown(grid, row, col, output, width, false, opts.limit);
36
+ }
37
+ if (outputType === "plain" || outputType === "image") {
38
+ return renderPlain(grid, row, col, output, width, opts);
39
+ }
40
+ // 5-7. Heuristic fallback (outputType undefined).
41
+ const json = tryParseJson(output);
42
+ if (json.ok)
43
+ return renderJsonTree(grid, row, col, json.value, width, { maxLines: opts.maxLines, limit: opts.limit });
44
+ if (looksLikeMarkdown(output))
45
+ return renderMarkdown(grid, row, col, output, width, false, opts.limit);
46
+ return renderPlain(grid, row, col, output, width, opts);
47
+ }
48
+ export function tryParseJson(s) {
49
+ const t = s.trimStart();
50
+ if (t[0] !== "{" && t[0] !== "[")
51
+ return { ok: false };
52
+ try {
53
+ return { ok: true, value: JSON.parse(t) };
54
+ }
55
+ catch {
56
+ return { ok: false };
57
+ }
58
+ }
59
+ const FENCED_RE = /```[\w]*\r?\n/;
60
+ const TABLE_RE = /^\|.+\|\s*\n\|[\s:|-]+\|/m;
61
+ const HEADING_RE = /^#{1,6}\s+\S/gm;
62
+ export function looksLikeMarkdown(s) {
63
+ if (FENCED_RE.test(s))
64
+ return true;
65
+ if (TABLE_RE.test(s))
66
+ return true;
67
+ const headings = s.match(HEADING_RE);
68
+ if (headings && headings.length >= 2)
69
+ return true;
70
+ return false;
71
+ }
72
+ function renderPlain(grid, row, col, output, width, opts) {
73
+ const outLines = output.split("\n");
74
+ const showLines = outLines.slice(0, opts.maxLines);
75
+ const lineStyle = opts.status === "error" ? S_ERROR : S_DIM;
76
+ let r = row;
77
+ for (const line of showLines) {
78
+ if (r >= opts.limit)
79
+ break;
80
+ grid.writeTextWithLinks(r, col, line.slice(0, width), lineStyle, col + width);
81
+ r++;
82
+ }
83
+ if (outLines.length > opts.maxLines && r < opts.limit) {
84
+ grid.writeText(r, col, `… (${outLines.length} lines total)`.slice(0, width), S_DIM);
85
+ r++;
86
+ }
87
+ return r - row;
88
+ }
89
+ //# sourceMappingURL=output-renderer.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Derives the spinner section label from the live tool-call map.
3
+ */
4
+ import type { ToolCallInfo } from "./layout.js";
5
+ export declare function deriveSpinnerLabel(toolCalls: Map<string, ToolCallInfo>): string;
6
+ //# sourceMappingURL=spinner-label.d.ts.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Derives the spinner section label from the live tool-call map.
3
+ */
4
+ export function deriveSpinnerLabel(toolCalls) {
5
+ const running = [];
6
+ for (const tc of toolCalls.values()) {
7
+ if (tc.status === "running")
8
+ running.push(tc);
9
+ }
10
+ if (running.length === 0)
11
+ return "Thinking";
12
+ if (running.length === 1) {
13
+ const name = running[0].toolName;
14
+ if (name.startsWith("mcp__")) {
15
+ const rest = name.slice("mcp__".length);
16
+ const idx = rest.indexOf("__");
17
+ if (idx > 0)
18
+ return `Calling ${rest.slice(0, idx)}:${rest.slice(idx + 2)}`;
19
+ }
20
+ return `Running ${name}`;
21
+ }
22
+ return `Running ${running.length} tools`;
23
+ }
24
+ //# sourceMappingURL=spinner-label.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Maps a tool name to a display color for the tool-call section.
3
+ * Read-class tools → cyan, Mutate-class → yellow, Exec-class → magenta,
4
+ * MCP tools (mcp__ prefix) → green, everything else → yellow fallback.
5
+ */
6
+ export type ToolColor = "cyan" | "yellow" | "magenta" | "green";
7
+ export declare function toolColor(toolName: string): ToolColor;
8
+ //# sourceMappingURL=tool-color.d.ts.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Maps a tool name to a display color for the tool-call section.
3
+ * Read-class tools → cyan, Mutate-class → yellow, Exec-class → magenta,
4
+ * MCP tools (mcp__ prefix) → green, everything else → yellow fallback.
5
+ */
6
+ const READ = new Set(["Read", "Glob", "Grep", "WebFetch", "WebSearch", "ExaSearch"]);
7
+ const MUTATE = new Set(["Edit", "Write", "NotebookEdit"]);
8
+ const EXEC = new Set(["Bash", "PowerShell"]);
9
+ export function toolColor(toolName) {
10
+ if (READ.has(toolName))
11
+ return "cyan";
12
+ if (MUTATE.has(toolName))
13
+ return "yellow";
14
+ if (EXEC.has(toolName))
15
+ return "magenta";
16
+ if (toolName.startsWith("mcp__"))
17
+ return "green";
18
+ return "yellow";
19
+ }
20
+ //# sourceMappingURL=tool-color.js.map
package/dist/repl.js CHANGED
@@ -21,6 +21,7 @@ import { isTrusted, trustSystemActive } from "./harness/trust.js";
21
21
  import { query } from "./query/index.js";
22
22
  import { resetDiffStyleCache } from "./renderer/diff.js";
23
23
  import { TerminalRenderer } from "./renderer/index.js";
24
+ import { resetJsonStyleCache } from "./renderer/json-tree.js";
24
25
  import { resetStyleCache } from "./renderer/layout.js";
25
26
  import { resetMdStyleCache } from "./renderer/markdown.js";
26
27
  import { createAssistantMessage, createInfoMessage, createMessage } from "./types/message.js";
@@ -28,6 +29,8 @@ import { formatTokenCount } from "./utils/format.js";
28
29
  import { fuzzyFilter } from "./utils/fuzzy.js";
29
30
  import { setActiveTheme } from "./utils/theme-data.js";
30
31
  import { formatToolArgs, summarizeToolOutput } from "./utils/tool-summary.js";
32
+ /** Per-call cap on rendered tool output in renderer state. Sized to fit typical JSON/markdown files (16 KiB) so JSON.parse / markdown detection works on real content; larger outputs render truncated. */
33
+ const TOOL_OUTPUT_RENDER_CAP = 16384;
31
34
  export async function startREPL(config) {
32
35
  if (config.theme)
33
36
  setActiveTheme(config.theme);
@@ -814,6 +817,7 @@ export async function startREPL(config) {
814
817
  resetStyleCache();
815
818
  resetMdStyleCache();
816
819
  resetDiffStyleCache();
820
+ resetJsonStyleCache();
817
821
  // Persist theme to config
818
822
  try {
819
823
  const cfg = cachedConfig ?? {
@@ -977,11 +981,11 @@ export async function startREPL(config) {
977
981
  case "tool_call_end": {
978
982
  const toolName = callIdToToolName.get(event.callId) ?? event.callId;
979
983
  const prevTc = renderer.getToolCall(event.callId);
980
- const _elapsed = prevTc?.startedAt ? Math.floor((Date.now() - prevTc.startedAt) / 1000) : 0;
981
984
  renderer.setToolCall(event.callId, {
982
985
  toolName,
983
986
  status: event.isError ? "error" : "done",
984
- output: event.output?.slice(0, 500),
987
+ output: event.output?.slice(0, TOOL_OUTPUT_RENDER_CAP),
988
+ outputType: event.outputType,
985
989
  args: prevTc?.args,
986
990
  resultSummary: event.output ? summarizeToolOutput(event.output) : undefined,
987
991
  startedAt: prevTc?.startedAt,
@@ -10,6 +10,15 @@ const inputSchema = z.object({
10
10
  const DEFAULT_LIMIT = 2000;
11
11
  const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
12
12
  const NOTEBOOK_EXTENSION = ".ipynb";
13
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
14
+ const JSON_EXTENSIONS = new Set([".json"]);
15
+ function outputTypeFromExt(ext) {
16
+ if (JSON_EXTENSIONS.has(ext))
17
+ return "json";
18
+ if (MARKDOWN_EXTENSIONS.has(ext))
19
+ return "markdown";
20
+ return "plain";
21
+ }
13
22
  function parsePageRange(pages) {
14
23
  const result = [];
15
24
  for (const part of pages.split(",")) {
@@ -101,7 +110,7 @@ export const FileReadTool = {
101
110
  }
102
111
  }
103
112
  if (pageTexts.length > 0) {
104
- return { output: pageTexts.join("\n\n"), isError: false };
113
+ return { output: pageTexts.join("\n\n"), isError: false, outputType: "plain" };
105
114
  }
106
115
  // Fallback: return as base64 for multimodal analysis
107
116
  const base64 = buffer.toString("base64");
@@ -128,7 +137,7 @@ export const FileReadTool = {
128
137
  }
129
138
  }
130
139
  }
131
- return { output: parts.join("\n\n"), isError: false };
140
+ return { output: parts.join("\n\n"), isError: false, outputType: "plain" };
132
141
  }
133
142
  // Default: text file
134
143
  const content = await fs.readFile(filePath, "utf-8");
@@ -143,7 +152,7 @@ export const FileReadTool = {
143
152
  if (shown < total) {
144
153
  result += `\n\n(Showing lines ${offset + 1}-${offset + shown} of ${total})`;
145
154
  }
146
- return { output: result, isError: false };
155
+ return { output: result, isError: false, outputType: outputTypeFromExt(ext) };
147
156
  }
148
157
  catch (err) {
149
158
  if (err.code === "ENOENT") {
@@ -70,7 +70,7 @@ export const WebFetchTool = {
70
70
  signal: AbortSignal.timeout(30_000),
71
71
  });
72
72
  // Re-check host after redirect to prevent SSRF via open redirects
73
- const finalUrl = new URL(response.url);
73
+ const finalUrl = new URL(response.url || input.url);
74
74
  if (isBlockedHost(finalUrl.hostname)) {
75
75
  return { output: "Error: Redirect to private/internal host blocked.", isError: true };
76
76
  }
@@ -88,7 +88,10 @@ export const WebFetchTool = {
88
88
  if (text.length > MAX_OUTPUT) {
89
89
  text = `${text.slice(0, MAX_OUTPUT)}\n... [truncated]`;
90
90
  }
91
- return { output: text, isError: false };
91
+ const ct = contentType.toLowerCase();
92
+ const isJson = /^application\/(?:[a-z0-9.+-]+\+)?json\b/.test(ct);
93
+ const outputType = isJson ? "json" : ct.includes("text/markdown") ? "markdown" : "plain";
94
+ return { output: text, isError: false, outputType };
92
95
  }
93
96
  catch (err) {
94
97
  return { output: `Error fetching URL: ${err.message}`, isError: true };
@@ -20,6 +20,7 @@ export type ToolCallEnd = {
20
20
  readonly type: "tool_call_end";
21
21
  readonly callId: string;
22
22
  readonly output: string;
23
+ readonly outputType?: "json" | "markdown" | "image" | "plain";
23
24
  readonly isError: boolean;
24
25
  };
25
26
  export type PermissionRequest = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.24.0",
3
+ "version": "2.26.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {