thincoder 0.12.11 → 0.12.12
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/README.md +8 -0
- package/bin/thincoder.mjs +11 -1
- package/package.json +1 -1
- package/src/acp/bridge.mjs +229 -0
- package/src/acp/session.mjs +46 -0
- package/src/acp/transport.mjs +155 -0
- package/src/acp.mjs +335 -0
- package/src/advisor/citations.mjs +77 -0
- package/src/advisor/history.mjs +25 -6
- package/src/advisor/messages.mjs +76 -22
- package/src/advisor/run.mjs +185 -91
- package/src/advisor.mjs +205 -83
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +11 -10
- package/src/agent-tools/subagent.mjs +26 -8
- package/src/agent.mjs +5 -5
- package/src/prompts/advisor-round1.md +8 -2
- package/src/prompts/advisor-round2.md +6 -4
- package/src/prompts/advisor-round3.md +6 -4
- package/src/prompts/discipline.md +1 -1
- package/src/session.mjs +19 -0
- package/src/tools/file.mjs +30 -0
- package/src/tools/insert_after.md +1 -0
- package/src/tools/patch.mjs +4 -0
- package/src/tools/shared.mjs +68 -57
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/index.mjs +1 -1
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/render-conversation.mjs +90 -39
- package/src/tui/tool-summaries.mjs +113 -0
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -2,11 +2,17 @@ import { runAgent, ContinueError } from "../agent.mjs"
|
|
|
2
2
|
import { saveSession } from "../session.mjs"
|
|
3
3
|
import { sliceByWidth } from "./render.mjs"
|
|
4
4
|
import { ansi, C } from "./ansi.mjs"
|
|
5
|
+
import { formatToolSummary } from "./tool-summaries.mjs"
|
|
6
|
+
import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
|
|
5
7
|
|
|
6
8
|
/** Tool execution start timestamps (performance.now ms), keyed by tool name. */
|
|
7
9
|
const _toolTicks = Object.create(null)
|
|
8
10
|
|
|
9
|
-
/** Per-tool streaming preview line limits — tools with verbose output get more lines
|
|
11
|
+
/** Per-tool streaming preview line limits — tools with verbose output get more lines.
|
|
12
|
+
* NOTE: `advisor` is intentionally NOT pruned by the live-line mechanism: its
|
|
13
|
+
* streaming returns early (kind-split into _advisorThink/advisorStreaming) and
|
|
14
|
+
* is rendered full-length in render-conversation. The entry is kept for
|
|
15
|
+
* symmetry with the map's other tools. */
|
|
10
16
|
const LIVE_LINE_LIMITS = {
|
|
11
17
|
bash: 10,
|
|
12
18
|
advisor: 15,
|
|
@@ -43,8 +49,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
43
49
|
state.status = "Processing..."
|
|
44
50
|
state.streaming = ""
|
|
45
51
|
state.reasoning = ""
|
|
46
|
-
state.
|
|
47
|
-
state._advisorThink = ""
|
|
52
|
+
state._advisorBlocks = []
|
|
48
53
|
state.subTasks = {}
|
|
49
54
|
state.currentTool = null
|
|
50
55
|
state.processingStarted = Date.now()
|
|
@@ -56,6 +61,12 @@ export async function runAgentTurn(ctx, text) {
|
|
|
56
61
|
}, 1000)
|
|
57
62
|
render()
|
|
58
63
|
|
|
64
|
+
// NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
|
|
65
|
+
// Timing safety: onToolResult flushes _advisorThink into history and empties
|
|
66
|
+
// the buffers BEFORE onTurnEnd can call flushStream (tool result is
|
|
67
|
+
// dispatched inside executeToolCalls; onTurnEnd fires after the turn loop
|
|
68
|
+
// resumes). If a future change calls flushStream mid-advisor-execution the
|
|
69
|
+
// in-progress thinking WOULD be lost — keep the ordering, or flush here too.
|
|
59
70
|
const flushStream = () => {
|
|
60
71
|
if (state.reasoning) {
|
|
61
72
|
const idx = state.lines.length
|
|
@@ -77,8 +88,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
77
88
|
state._autoExpand.push(idx)
|
|
78
89
|
state.streaming = ""
|
|
79
90
|
}
|
|
80
|
-
state.
|
|
81
|
-
state._advisorThink = ""
|
|
91
|
+
state._advisorBlocks = []
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
const callbacks = {
|
|
@@ -132,7 +142,10 @@ export async function runAgentTurn(ctx, text) {
|
|
|
132
142
|
scheduleRender()
|
|
133
143
|
return
|
|
134
144
|
}
|
|
135
|
-
|
|
145
|
+
// Redundant with flushStream() below (it clears both buffers) — kept as
|
|
146
|
+
// defense-in-depth so a future flushStream change cannot leak advisor
|
|
147
|
+
// buffers into the next tool's view.
|
|
148
|
+
if (name === "advisor") { state._advisorBlocks = [] }
|
|
136
149
|
flushStream()
|
|
137
150
|
ensureAssistantLabel()
|
|
138
151
|
state.currentTool = name
|
|
@@ -197,7 +210,37 @@ export async function runAgentTurn(ctx, text) {
|
|
|
197
210
|
const summary = formatToolSummary(name, result)
|
|
198
211
|
if (summary) pushLine(` ${summary}`, C.dim)
|
|
199
212
|
}
|
|
200
|
-
if (name === "advisor") {
|
|
213
|
+
if (name === "advisor") {
|
|
214
|
+
// The review's thinking must survive into the conversation history like
|
|
215
|
+
// the main agent's reasoning (flushStream does for state.reasoning) —
|
|
216
|
+
// discarding it left the thought process visible only mid-review, then
|
|
217
|
+
// gone. Flush BEFORE the done line so the block sits above it.
|
|
218
|
+
// NOTE (rendering): the flushed block has NO "│ " gutter prefix while
|
|
219
|
+
// the live streaming view adds one — same convention as the main
|
|
220
|
+
// agent's reasoning (live gutter, history plain). Intentional.
|
|
221
|
+
const blocks = state._advisorBlocks ?? []
|
|
222
|
+
if (blocks.length > 0) {
|
|
223
|
+
// Flush the ordered blocks in sequence — thinking and tool progress
|
|
224
|
+
// alternate in history exactly as they were emitted. The live
|
|
225
|
+
// "[thinking…]" placeholders are stripped (wait indicators, not
|
|
226
|
+
// review content); literal replaceAll of the shared constant can
|
|
227
|
+
// never drift.
|
|
228
|
+
const text = blocks
|
|
229
|
+
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
|
|
230
|
+
.join("")
|
|
231
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
232
|
+
.trim()
|
|
233
|
+
if (text) {
|
|
234
|
+
const idx = state.lines.length
|
|
235
|
+
pushLine(text, C.reason)
|
|
236
|
+
// Completed review output stays expanded (user is reading it).
|
|
237
|
+
state.expandedBlocks ??= new Set()
|
|
238
|
+
state.expandedBlocks.add("long-" + idx)
|
|
239
|
+
state._autoExpand ??= []
|
|
240
|
+
state._autoExpand.push(idx)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
201
244
|
// Done line for ALL tools (panel area abolished — inline only).
|
|
202
245
|
if (!isSubagent) {
|
|
203
246
|
const elapsed = _toolTicks[name] ? ` (${Math.round(performance.now() - _toolTicks[name])}ms)` : ""
|
|
@@ -217,13 +260,23 @@ export async function runAgentTurn(ctx, text) {
|
|
|
217
260
|
if (name === "advisor") {
|
|
218
261
|
// Accumulate to buffer — formatTables + wrapText in render-conversation
|
|
219
262
|
// handles markdown formatting, same as main agent response.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
263
|
+
// NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
|
|
264
|
+
// emit() wrapper) — a raw string chunk is never think; if that ever
|
|
265
|
+
// changes, plain-string think would land in advisorStreaming.
|
|
266
|
+
// ORDERED block buffer — preserves the interleaved emission order
|
|
267
|
+
// (think → tool → think → … → final). Two separate buffers (_advisorThink
|
|
268
|
+
// vs advisorStreaming) rendered think-block-then-main-block, which
|
|
269
|
+
// regrouped ALL thinking above ALL tool progress — the alternating
|
|
270
|
+
// timeline was destroyed. Consecutive chunks of the same kind merge
|
|
271
|
+
// into one block; kind flips start a new block; render walks the
|
|
272
|
+
// blocks in order with per-kind colors.
|
|
273
|
+
const isString = typeof chunk === "string"
|
|
274
|
+
const raw = isString ? chunk : String(chunk?.text ?? "")
|
|
275
|
+
const kind = isString ? "text" : (chunk?.kind ?? "text")
|
|
276
|
+
const blocks = state._advisorBlocks ??= []
|
|
277
|
+
const last = blocks.at(-1)
|
|
278
|
+
if (last && last.kind === kind) last.text += raw
|
|
279
|
+
else blocks.push({ kind, text: raw })
|
|
227
280
|
scheduleRender()
|
|
228
281
|
return
|
|
229
282
|
}
|
|
@@ -295,7 +348,11 @@ export async function runAgentTurn(ctx, text) {
|
|
|
295
348
|
// pushback messages appear in the conversation at the right spot.
|
|
296
349
|
const last = agent.history.at(-1)
|
|
297
350
|
if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
|
|
298
|
-
|
|
351
|
+
// Reminders can embed long prior tables — show only the first lines
|
|
352
|
+
// (the full text is in agent.history); 3 lines + ellipsis.
|
|
353
|
+
const lines = last.content.split("\n")
|
|
354
|
+
const shown = lines.length > 3 ? lines.slice(0, 3).join("\n") + "\n…" : last.content
|
|
355
|
+
pushLine(shown, C.warn)
|
|
299
356
|
}
|
|
300
357
|
if (++n % 5 !== 0) return
|
|
301
358
|
try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
@@ -357,8 +414,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
357
414
|
clearInterval(ticker)
|
|
358
415
|
state.processing = false
|
|
359
416
|
state.subTasks = {}
|
|
360
|
-
state.
|
|
361
|
-
state._advisorThink = ""
|
|
417
|
+
state._advisorBlocks = []
|
|
362
418
|
state.controller = null
|
|
363
419
|
state.status = "Ready"
|
|
364
420
|
// Auto-collapse todo panel when all tasks done (matching kimi-code TUI; agent.tasks are preserved)
|
|
@@ -403,107 +459,3 @@ export async function runAgentTurn(ctx, text) {
|
|
|
403
459
|
return
|
|
404
460
|
}
|
|
405
461
|
}
|
|
406
|
-
|
|
407
|
-
/** Extract a one-line summary from tool output for the done line */
|
|
408
|
-
function formatToolSummary(name, result) {
|
|
409
|
-
if (name === "verify") return _verifySummary(result)
|
|
410
|
-
if (name === "bash") return _bashSummary(result)
|
|
411
|
-
if (name === "advisor") return _advisorSummary(result)
|
|
412
|
-
if (name === "read" || name === "read_file") return _readSummary(result)
|
|
413
|
-
if (name === "write" || name === "write_file") return _writeSummary(result)
|
|
414
|
-
if (name === "grep" || name === "search") return _grepSummary(result)
|
|
415
|
-
if (name === "glob") return _globSummary(result)
|
|
416
|
-
// Default: first non-empty line
|
|
417
|
-
const first = result.split("\n").find((l) => l.trim())
|
|
418
|
-
return first ? `${name}: ${first.slice(0, 100)}` : null
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function _readSummary(result) {
|
|
422
|
-
const lines = result.split("\n")
|
|
423
|
-
// Look for line count in result
|
|
424
|
-
const countMatch = result.match(/(\d+) lines?/)
|
|
425
|
-
if (countMatch) return `${countMatch[1]} lines`
|
|
426
|
-
// Fallback: count actual lines
|
|
427
|
-
return `${lines.length} lines`
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function _writeSummary(result) {
|
|
431
|
-
// Extract file size or confirmation
|
|
432
|
-
if (result.includes("wrote") || result.includes("created")) {
|
|
433
|
-
const sizeMatch = result.match(/(\d+)(?:\s*(?:bytes?|chars?))/i)
|
|
434
|
-
return sizeMatch ? `wrote ${sizeMatch[1]} bytes` : "wrote file"
|
|
435
|
-
}
|
|
436
|
-
const first = result.split("\n").find((l) => l.trim())
|
|
437
|
-
return first ? first.slice(0, 80) : "wrote"
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function _grepSummary(result) {
|
|
441
|
-
const lines = result.split("\n").filter((l) => l.trim())
|
|
442
|
-
const count = lines.length
|
|
443
|
-
if (count === 0) return "no matches"
|
|
444
|
-
if (count === 1) return "1 match"
|
|
445
|
-
return `${count} matches`
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function _globSummary(result) {
|
|
449
|
-
const lines = result.split("\n").filter((l) => l.trim())
|
|
450
|
-
const count = lines.length
|
|
451
|
-
if (count === 0) return "no files"
|
|
452
|
-
if (count === 1) return "1 file"
|
|
453
|
-
return `${count} files`
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
|
|
458
|
-
* The first non-empty line is always the "[stdout]:" marker — useless as a summary.
|
|
459
|
-
* Show the LAST output line (usually the meaningful tail) plus the exit status.
|
|
460
|
-
*/
|
|
461
|
-
function _bashSummary(result) {
|
|
462
|
-
const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
|
|
463
|
-
const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
|
|
464
|
-
const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
|
|
465
|
-
const parts = []
|
|
466
|
-
if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
|
|
467
|
-
if (status) parts.push(status)
|
|
468
|
-
return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function _advisorSummary(result) {
|
|
472
|
-
const text = String(result ?? "")
|
|
473
|
-
if (/no 🔴|all.*(?:resolved|fixed|pass)/im.test(text)) return "advisor: passed"
|
|
474
|
-
// Error / skip messages — extract the reason after "Advisor:"
|
|
475
|
-
const errMatch = text.trimStart().match(/^Advisor:\s*(.+)/)
|
|
476
|
-
if (errMatch) return `advisor: ${errMatch[1].split(".")[0]}`
|
|
477
|
-
const critical = (text.match(/\| \d+ \|.*\| 🔴/g) || []).length
|
|
478
|
-
const advisory = (text.match(/\| \d+ \|.*\| 🟡/g) || []).length
|
|
479
|
-
const style = (text.match(/\| \d+ \|.*\| 🔵/g) || []).length
|
|
480
|
-
const parts = []
|
|
481
|
-
if (critical) parts.push(`${critical} critical`)
|
|
482
|
-
if (advisory) parts.push(`${advisory} advisory`)
|
|
483
|
-
if (style) parts.push(`${style} style`)
|
|
484
|
-
if (parts.length === 0) return null
|
|
485
|
-
return `advisor: ${parts.join(", ")}`
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function _verifySummary(result) {
|
|
489
|
-
const lines = result.split("\n")
|
|
490
|
-
const summary = []
|
|
491
|
-
// Changed files count
|
|
492
|
-
const changed = lines.find((l) => l.startsWith("Changed files:"))
|
|
493
|
-
if (changed) {
|
|
494
|
-
const m = changed.match(/files changed/) ? changed.replace(/^Changed files \(.*?\)/, "Changed files") : changed
|
|
495
|
-
summary.push(m)
|
|
496
|
-
}
|
|
497
|
-
// Syntax check results
|
|
498
|
-
const syntax = lines.filter((l) => l.startsWith(" ✗"))
|
|
499
|
-
if (syntax.length > 0) {
|
|
500
|
-
summary.push(`${syntax.length} syntax error(s)`)
|
|
501
|
-
}
|
|
502
|
-
// Test results
|
|
503
|
-
const testLine = lines.find((l) => l.startsWith("✓ Tests passed.") || l.startsWith("✗ Tests FAILED"))
|
|
504
|
-
if (testLine) summary.push(testLine.trim())
|
|
505
|
-
// Task list
|
|
506
|
-
const taskLine = lines.find((l) => l.startsWith("Task list:"))
|
|
507
|
-
if (taskLine) summary.push(taskLine)
|
|
508
|
-
return summary.length > 0 ? `verify: ${summary.join(" — ")}` : ""
|
|
509
|
-
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -60,7 +60,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
60
60
|
const state = {
|
|
61
61
|
lines: [], // conversation lines: { text, color }
|
|
62
62
|
streaming: "", // current streaming buffer
|
|
63
|
-
|
|
63
|
+
_advisorBlocks: [], // advisor ordered blocks: [{ kind: "think"|"text", text }] — preserves emission order (think ↔ tool interleaving)
|
|
64
64
|
input: [], // input buffer (codepoint array)
|
|
65
65
|
cursor: 0,
|
|
66
66
|
history: [],
|
package/src/tui/markdown.mjs
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* model replies stop showing literal `**`, `##`, backtick markers (IK5VW3).
|
|
6
6
|
*
|
|
7
7
|
* Design constraints:
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - Display-only rendering. renderMarkdownHeading handles multi-line input
|
|
9
|
+
* (splits internally); renderMarkdownInline expects single lines (its
|
|
10
|
+
* regexes use [^*\n]+ — no cross-line matches). Callers pass pre-wrapped
|
|
11
|
+
* lines so the inserted ANSI never skews width math.
|
|
12
|
+
* - Uses narrow-scope SGR resets (22 = bold off, 24 = underline off, 29 = strikethrough off)
|
|
11
13
|
* instead of reset(0), so the line's base color (C.text etc.) survives.
|
|
12
14
|
* - Code spans are extracted FIRST: anything inside backticks is styled as code and
|
|
13
15
|
* its `**`/`__` markers are NOT interpreted (markdown semantics).
|
|
@@ -15,6 +17,10 @@
|
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
const BOLD = "\x1b[1m"
|
|
20
|
+
// NOTE: \x1b[22m resets BOTH bold and faint/dim (SGR 2). Today no C.reason
|
|
21
|
+
// (dim) line passes through markdown rendering (reasoning/think blocks skip
|
|
22
|
+
// it), so this is latent — if dim text ever gains markdown, bold segments
|
|
23
|
+
// would clear the dim effect after them.
|
|
18
24
|
const BOLD_OFF = "\x1b[22m"
|
|
19
25
|
const UNDERLINE = "\x1b[4m"
|
|
20
26
|
const UNDERLINE_OFF = "\x1b[24m"
|
|
@@ -23,7 +29,10 @@ const STRIKE_OFF = "\x1b[29m"
|
|
|
23
29
|
|
|
24
30
|
/** Render inline markers on a single text line: `code` spans, **bold**, __bold__, ~~strike~~. */
|
|
25
31
|
export function renderMarkdownInline(line) {
|
|
26
|
-
|
|
32
|
+
// Single underscore lines (snake_case identifiers) must short-circuit too —
|
|
33
|
+
// __bold__ needs a DOUBLE underscore; a lone "_" would otherwise run the
|
|
34
|
+
// whole split/replace pipeline for nothing.
|
|
35
|
+
if (!line || (line.indexOf("*") === -1 && line.indexOf("`") === -1 && line.indexOf("__") === -1 && line.indexOf("~") === -1)) {
|
|
27
36
|
return line
|
|
28
37
|
}
|
|
29
38
|
|
|
@@ -44,9 +53,18 @@ export function renderMarkdownInline(line) {
|
|
|
44
53
|
return out
|
|
45
54
|
}
|
|
46
55
|
|
|
47
|
-
/** Render
|
|
56
|
+
/** Render heading markers: strip leading `#` markers and bold the heading.
|
|
57
|
+
* Inline markers inside the heading are stripped too — the heading is already
|
|
58
|
+
* fully bold, so `**bold**` inside it would wrap another bold sequence whose
|
|
59
|
+
* `\x1b[22m` turns bold OFF for the rest of the heading text.
|
|
60
|
+
* Line-by-line (split on \n): without the m flag, `^`/`$` anchor the whole
|
|
61
|
+
* string, so a multi-line input never matched and headings stayed raw —
|
|
62
|
+
* the old call sites passed single wrapped lines and hid the defect.
|
|
63
|
+
* Returns the original text when no line is a heading. */
|
|
48
64
|
export function renderMarkdownHeading(line) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
return line.split("\n").map((l) => {
|
|
66
|
+
const m = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(l)
|
|
67
|
+
if (!m || !m[2]) return l
|
|
68
|
+
return `${BOLD}${m[2].replace(/\*\*|__|~~/g, "")}${BOLD_OFF}`
|
|
69
|
+
}).join("\n")
|
|
52
70
|
}
|
|
@@ -17,17 +17,29 @@ let _convCache = { key: "", cols: 0, lines: [] }
|
|
|
17
17
|
* @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
|
|
18
18
|
*/
|
|
19
19
|
function renderMarkdownPreservingWidth(text) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
// Line-by-line: render + compensate per line. The per-line padding serves
|
|
21
|
+
// NON-table text (so `**bold** text` next to plain text keeps its width).
|
|
22
|
+
// Table alignment is NOT provided by the padding — formatTables strips cell
|
|
23
|
+
// padding during trim and recomputes widths from the RENDERED text (that is
|
|
24
|
+
// the render-before-measure contract).
|
|
25
|
+
return text.split("\n").map((line) => {
|
|
26
|
+
const rendered = renderMarkdownInline(renderMarkdownHeading(line))
|
|
27
|
+
const diff = stringWidth(line) - stringWidth(rendered)
|
|
28
|
+
return diff > 0 ? rendered + " ".repeat(diff) : rendered
|
|
29
|
+
}).join("\n")
|
|
23
30
|
}
|
|
31
|
+
// Test seam (mirrors the _-prefixed seams in run.mjs).
|
|
32
|
+
export { renderMarkdownPreservingWidth as _renderMarkdownPreservingWidth }
|
|
24
33
|
|
|
25
34
|
|
|
26
35
|
export function convCacheKey(state) {
|
|
27
36
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
28
37
|
// expandedBlocks participates: expanding/folding a block must invalidate the cache
|
|
29
38
|
const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
|
|
30
|
-
|
|
39
|
+
// Content prefix in the signature: same kind+length with different content
|
|
40
|
+
// would otherwise collide (stale render); 8 chars disambiguate in practice.
|
|
41
|
+
const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
|
|
42
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
/** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
|
|
@@ -70,6 +82,13 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
|
|
|
70
82
|
return result
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Build the conversation lines for the given state.
|
|
87
|
+
* NOTE: module-level _convCache is read/written as a side effect (keyed by
|
|
88
|
+
* convCacheKey + cols) — the function is pure w.r.t. its input except for
|
|
89
|
+
* that cache; direct callers outside renderConversation/countConvLines
|
|
90
|
+
* should be aware the cache persists across calls.
|
|
91
|
+
*/
|
|
73
92
|
function buildConvLines(state, cols) {
|
|
74
93
|
const key = convCacheKey(state)
|
|
75
94
|
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
@@ -90,19 +109,25 @@ function buildConvLines(state, cols) {
|
|
|
90
109
|
|
|
91
110
|
// Long-message folding: ANY single line (main output C.text, thinking C.reason,
|
|
92
111
|
// tool summaries C.dim — whatever wraps beyond LONG_FOLD_LINES display rows)
|
|
93
|
-
// collapses to [
|
|
94
|
-
// thinking are the REAL long
|
|
95
|
-
// + click toggle) keeps
|
|
96
|
-
//
|
|
97
|
-
//
|
|
112
|
+
// collapses to [first 4, ▶, last]; expanded long blocks render as
|
|
113
|
+
// [blank, ▼, every line]. Main output and thinking are the REAL long
|
|
114
|
+
// content; bidirectional folding (collapse markers + click toggle) keeps
|
|
115
|
+
// them readable — the 0.12.7 dim-only restriction was a temporary fix for
|
|
116
|
+
// the single-direction era and is now reverted. Keyed by the source-line
|
|
117
|
+
// index (`long-${i}`) so the toggle survives re-renders.
|
|
98
118
|
const longKey = `long-${i}`
|
|
99
119
|
const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
|
|
100
120
|
const block = []
|
|
101
|
-
|
|
121
|
+
// Lightweight markdown display (IK5VW3): render BEFORE measuring — the
|
|
122
|
+
// table column math (formatTables) and wrapping must see the RENDERED
|
|
123
|
+
// text (ANSI consumes zero display width; the width functions are
|
|
124
|
+
// ANSI-aware). Rendering after wrapping measured raw markdown
|
|
125
|
+
// (`**bold**` = 8) against displayed text (4) and sliced markers
|
|
126
|
+
// mid-sequence — the table misalignment the user kept reporting.
|
|
127
|
+
const renderedText = renderMarkdownPreservingWidth(sanitizeDisplay(text))
|
|
128
|
+
for (const line of formatTables(renderedText, cols - 1)) {
|
|
102
129
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
103
|
-
|
|
104
|
-
// Runs AFTER wrapping so the ANSI it inserts never skews width math.
|
|
105
|
-
block.push({ text: renderMarkdownPreservingWidth(wrapped), color: l.color, _foldId: l._foldId, _src: i })
|
|
130
|
+
block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
if (folded && block.length > LONG_FOLD_LINES) {
|
|
@@ -114,15 +139,21 @@ function buildConvLines(state, cols) {
|
|
|
114
139
|
convLines.push(foldHintLine(`▶ … ${block.length - FOLD_KEEP} more lines — click to expand`, longKey, i))
|
|
115
140
|
convLines.push(block[block.length - 1])
|
|
116
141
|
} else if (block.length > LONG_FOLD_LINES) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
if (state.foldEnabled === false) {
|
|
143
|
+
// Folding fully off — content already fully visible; a "click to
|
|
144
|
+
// collapse" hint would be misleading (toggling has no effect).
|
|
145
|
+
convLines.push(...block)
|
|
146
|
+
} else {
|
|
147
|
+
// EXPANDED long block: blank line + ▼ control line at the HEAD, directly
|
|
148
|
+
// before the content. DIM blocks must not re-trigger the consecutive-dim
|
|
149
|
+
// folding below (folding stacked on folding — reported regression).
|
|
150
|
+
if (l.color === C.dim) {
|
|
151
|
+
for (const line of block) line._skipDimFold = true
|
|
152
|
+
}
|
|
153
|
+
convLines.push(blankLine())
|
|
154
|
+
convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
|
|
155
|
+
convLines.push(...block)
|
|
122
156
|
}
|
|
123
|
-
convLines.push(blankLine())
|
|
124
|
-
convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
|
|
125
|
-
convLines.push(...block)
|
|
126
157
|
} else {
|
|
127
158
|
convLines.push(...block)
|
|
128
159
|
}
|
|
@@ -132,25 +163,40 @@ function buildConvLines(state, cols) {
|
|
|
132
163
|
convLines.push({ text: wrapped, color: C.reason })
|
|
133
164
|
}
|
|
134
165
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
for (const
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
const advisorBlocks = state._advisorBlocks ?? []
|
|
167
|
+
if (advisorBlocks.length > 0) {
|
|
168
|
+
// ORDERED block display — the blocks preserve the emission order
|
|
169
|
+
// (think → tool → think → … → final) and render as one interleaved
|
|
170
|
+
// stream: thinking in reasoning color, tool progress/final in text color.
|
|
171
|
+
// Full-length, no preview truncation; long content scrolls via the
|
|
172
|
+
// conversation window like everything else.
|
|
173
|
+
// NOTE: formatTables returns an ARRAY of lines (not a string) — calling
|
|
174
|
+
// .split on it crashed the whole render (tools/final never displayed).
|
|
175
|
+
for (const block of advisorBlocks) {
|
|
176
|
+
const color = { think: C.reason, tool: C.tool, text: C.text }[block.kind] ?? C.text
|
|
177
|
+
const source = sanitizeDisplay(block.text)
|
|
178
|
+
// kind:"text" (the final review prose) gets the same lightweight markdown
|
|
179
|
+
// styling as the main agent response. Rendered BEFORE measuring: the
|
|
180
|
+
// width math (formatTables / wrapText) must see the RENDERED text —
|
|
181
|
+
// measuring raw markdown (`**bold**` = 8) against displayed text (4)
|
|
182
|
+
// misaligned table columns; wrapping raw markdown sliced markers
|
|
183
|
+
// mid-sequence (`**bo` + `ld**`) so the renderer never saw complete ones.
|
|
184
|
+
const rows = block.kind === "think"
|
|
185
|
+
? source.split("\n")
|
|
186
|
+
: formatTables(block.kind === "text" ? renderMarkdownPreservingWidth(source) : source, cols - 3)
|
|
187
|
+
for (const line of rows) {
|
|
188
|
+
for (const wrapped of wrapText(line, cols - 3)) {
|
|
189
|
+
convLines.push({ text: `│ ${wrapped}`, color })
|
|
190
|
+
}
|
|
147
191
|
}
|
|
148
192
|
}
|
|
149
193
|
}
|
|
150
194
|
if (state.streaming) {
|
|
151
|
-
|
|
195
|
+
// Rendered BEFORE formatTables — see the advisor-block comment above.
|
|
196
|
+
const rendered = renderMarkdownPreservingWidth(sanitizeDisplay(state.streaming))
|
|
197
|
+
for (const line of formatTables(rendered, cols - 1)) {
|
|
152
198
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
153
|
-
convLines.push({ text:
|
|
199
|
+
convLines.push({ text: wrapped, color: C.text })
|
|
154
200
|
}
|
|
155
201
|
}
|
|
156
202
|
}
|
|
@@ -177,10 +223,15 @@ function buildConvLines(state, cols) {
|
|
|
177
223
|
i = j
|
|
178
224
|
continue
|
|
179
225
|
}
|
|
180
|
-
// EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
226
|
+
// EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line.
|
|
227
|
+
// foldEnabled=false → raw block, no hint (toggling would be a no-op).
|
|
228
|
+
if (state.foldEnabled === false) {
|
|
229
|
+
for (let k = i; k < j; k++) folded.push(convLines[k])
|
|
230
|
+
} else {
|
|
231
|
+
folded.push(blankLine())
|
|
232
|
+
folded.push(foldHintLine(`▼ … ${blockLen} lines — click to collapse`, foldKey))
|
|
233
|
+
for (let k = i; k < j; k++) folded.push(convLines[k])
|
|
234
|
+
}
|
|
184
235
|
i = j
|
|
185
236
|
continue
|
|
186
237
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-summaries.mjs — one-line tool-result summaries for the TUI "done" lines.
|
|
3
|
+
* Extracted from agent-turn.mjs (file-size split): pure functions, no state.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Extract a one-line summary from tool output for the done line */
|
|
7
|
+
export function formatToolSummary(name, result) {
|
|
8
|
+
if (name === "verify") return _verifySummary(result)
|
|
9
|
+
if (name === "bash") return _bashSummary(result)
|
|
10
|
+
if (name === "advisor") return _advisorSummary(result)
|
|
11
|
+
if (name === "read" || name === "read_file") return _readSummary(result)
|
|
12
|
+
if (name === "write" || name === "write_file") return _writeSummary(result)
|
|
13
|
+
if (name === "grep" || name === "search") return _grepSummary(result)
|
|
14
|
+
if (name === "glob") return _globSummary(result)
|
|
15
|
+
// Default: first non-empty line
|
|
16
|
+
const first = result.split("\n").find((l) => l.trim())
|
|
17
|
+
return first ? `${name}: ${first.slice(0, 100)}` : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _readSummary(result) {
|
|
21
|
+
const lines = result.split("\n")
|
|
22
|
+
// Look for line count in result
|
|
23
|
+
const countMatch = result.match(/(\d+) lines?/)
|
|
24
|
+
if (countMatch) return `${countMatch[1]} lines`
|
|
25
|
+
// Fallback: count actual lines
|
|
26
|
+
return `${lines.length} lines`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _writeSummary(result) {
|
|
30
|
+
// Extract file size or confirmation
|
|
31
|
+
if (result.includes("wrote") || result.includes("created")) {
|
|
32
|
+
const sizeMatch = result.match(/(\d+)(?:\s*(?:bytes?|chars?))/i)
|
|
33
|
+
return sizeMatch ? `wrote ${sizeMatch[1]} bytes` : "wrote file"
|
|
34
|
+
}
|
|
35
|
+
const first = result.split("\n").find((l) => l.trim())
|
|
36
|
+
return first ? first.slice(0, 80) : "wrote"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _grepSummary(result) {
|
|
40
|
+
const lines = result.split("\n").filter((l) => l.trim())
|
|
41
|
+
const count = lines.length
|
|
42
|
+
if (count === 0) return "no matches"
|
|
43
|
+
if (count === 1) return "1 match"
|
|
44
|
+
return `${count} matches`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _globSummary(result) {
|
|
48
|
+
const lines = result.split("\n").filter((l) => l.trim())
|
|
49
|
+
const count = lines.length
|
|
50
|
+
if (count === 0) return "no files"
|
|
51
|
+
if (count === 1) return "1 file"
|
|
52
|
+
return `${count} files`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
|
|
57
|
+
* The first non-empty line is always the "[stdout]:" marker — useless as a summary.
|
|
58
|
+
* Show the LAST output line (usually the meaningful tail) plus the exit status.
|
|
59
|
+
*/
|
|
60
|
+
function _bashSummary(result) {
|
|
61
|
+
const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
|
|
62
|
+
const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
|
|
63
|
+
const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
|
|
64
|
+
const parts = []
|
|
65
|
+
if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
|
|
66
|
+
if (status) parts.push(status)
|
|
67
|
+
return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _advisorSummary(result) {
|
|
71
|
+
const text = String(result ?? "")
|
|
72
|
+
// Error / skip messages — extract the reason after "Advisor:"
|
|
73
|
+
const errMatch = text.trimStart().match(/^Advisor:\s*(.+)/)
|
|
74
|
+
if (errMatch) return `advisor: ${errMatch[1].split(".")[0]}`
|
|
75
|
+
const critical = (text.match(/\| \d+ \|.*\| 🔴/g) || []).length
|
|
76
|
+
const advisory = (text.match(/\| \d+ \|.*\| 🟡/g) || []).length
|
|
77
|
+
const style = (text.match(/\| \d+ \|.*\| 🔵/g) || []).length
|
|
78
|
+
// Protocol: zero 🔴 rows in the review table = pass (phrase fallback for
|
|
79
|
+
// table-free summaries like "No issues found").
|
|
80
|
+
if (critical === 0 && (/\| \d+ \|/.test(text)
|
|
81
|
+
|| /no\s+🔴|all.*(?:resolved|fixed|pass)|pass(?:es|ed)?\b|no\s+(?:critical\s+)?issues?/i.test(text))) {
|
|
82
|
+
return "advisor: passed"
|
|
83
|
+
}
|
|
84
|
+
const parts = []
|
|
85
|
+
if (critical) parts.push(`${critical} critical`)
|
|
86
|
+
if (advisory) parts.push(`${advisory} advisory`)
|
|
87
|
+
if (style) parts.push(`${style} style`)
|
|
88
|
+
if (parts.length === 0) return null
|
|
89
|
+
return `advisor: ${parts.join(", ")}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _verifySummary(result) {
|
|
93
|
+
const lines = result.split("\n")
|
|
94
|
+
const summary = []
|
|
95
|
+
// Changed files count
|
|
96
|
+
const changed = lines.find((l) => l.startsWith("Changed files:"))
|
|
97
|
+
if (changed) {
|
|
98
|
+
const m = changed.match(/files changed/) ? changed.replace(/^Changed files \(.*?\)/, "Changed files") : changed
|
|
99
|
+
summary.push(m)
|
|
100
|
+
}
|
|
101
|
+
// Syntax check results
|
|
102
|
+
const syntax = lines.filter((l) => l.startsWith(" ✗"))
|
|
103
|
+
if (syntax.length > 0) {
|
|
104
|
+
summary.push(`${syntax.length} syntax error(s)`)
|
|
105
|
+
}
|
|
106
|
+
// Test results
|
|
107
|
+
const testLine = lines.find((l) => l.startsWith("✓ Tests passed.") || l.startsWith("✗ Tests FAILED"))
|
|
108
|
+
if (testLine) summary.push(testLine.trim())
|
|
109
|
+
// Task list
|
|
110
|
+
const taskLine = lines.find((l) => l.startsWith("Task list:"))
|
|
111
|
+
if (taskLine) summary.push(taskLine)
|
|
112
|
+
return summary.length > 0 ? `verify: ${summary.join(" — ")}` : ""
|
|
113
|
+
}
|