thincoder 0.12.11 → 0.12.13
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/convergence.mjs +80 -0
- package/src/advisor/history.mjs +20 -55
- package/src/advisor/messages.mjs +182 -36
- package/src/advisor/repos.mjs +40 -0
- package/src/advisor/run.mjs +201 -95
- package/src/advisor.mjs +192 -99
- package/src/agent/completion.mjs +10 -10
- 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 +25 -32
- package/src/config.mjs +13 -5
- package/src/context.mjs +32 -9
- package/src/prompts/advisor-round1.md +9 -3
- package/src/prompts/advisor-round2.md +10 -8
- package/src/prompts/advisor-round3.md +14 -14
- 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/tools/shared.mjs
CHANGED
|
@@ -17,19 +17,31 @@ export const MAX_OUTPUT_CHARS = 200_000
|
|
|
17
17
|
const ENCODING_DETECT_MAX_TRIM = 3
|
|
18
18
|
const SYNTAX_CHECK_TIMEOUT = 10000
|
|
19
19
|
export const BASH_TIMEOUT_MS = 120_000
|
|
20
|
-
export const MAX_RESPONSE_BODY_BYTES = 5_000_000
|
|
21
20
|
export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
|
|
22
21
|
|
|
23
|
-
/** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
|
|
22
|
+
/** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
|
|
23
|
+
* Returns TRUE for private hosts — callers block them. */
|
|
24
24
|
export function isPrivateHost(hostname) {
|
|
25
25
|
const h = hostname.toLowerCase()
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Local loopback + link-local names: BLOCK (true). These returned false
|
|
27
|
+
// before — the guard was inverted for the most common SSRF targets
|
|
28
|
+
// (localhost/127.x reach internal services unchecked).
|
|
29
|
+
if (h === "localhost" || h === "0.0.0.0" || h.endsWith(".localhost")) return true
|
|
30
|
+
if (h === "127.0.0.1" || h.startsWith("127.")) return true
|
|
28
31
|
if (h === "169.254.169.254" || h === "metadata.google.internal") return true
|
|
29
32
|
// IPv6 private ranges — only check if host contains ":"
|
|
30
|
-
|
|
33
|
+
// fe80::/10 link-local covers fe80:…febf:… — startsWith("fe80:") is the
|
|
34
|
+
// practical subset (fe8/fe9/fea/feb all begin fe8/feb — full range regex
|
|
35
|
+
// would be /^fe[89ab][0-9a-f]:/; startsWith fe8 + fe9 + fea + feb covers it).
|
|
36
|
+
if (h.includes(":")) {
|
|
37
|
+
if (h === "::1" || h.startsWith("fc") || h.startsWith("fd")) return true
|
|
38
|
+
if (/^fe[89ab][0-9a-f]:/.test(h)) return true
|
|
39
|
+
}
|
|
31
40
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
32
41
|
if (m) {
|
|
42
|
+
// Octet range is NOT validated (999.10.0.1 parses but matches no private
|
|
43
|
+
// prefix → treated as public). Intentional: the guard checks known-private
|
|
44
|
+
// prefixes; invalid IPs are harmless false-negatives for SSRF purposes.
|
|
33
45
|
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
34
46
|
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || a === 192 && b === 168 || a === 169 && b === 254 || a === 0) return true
|
|
35
47
|
}
|
|
@@ -69,24 +81,10 @@ export function truncate(text, max = MAX_OUTPUT_CHARS) {
|
|
|
69
81
|
return text.slice(0, max) + `\n[... truncated: ${text.length - max} chars omitted — redirect to a file if you need the full output]`
|
|
70
82
|
}
|
|
71
83
|
|
|
72
|
-
/** Read response body with a byte limit */
|
|
73
|
-
export async function readBodyText(response, limit = MAX_RESPONSE_BODY_BYTES) {
|
|
74
|
-
if (!response.body) return ""
|
|
75
|
-
const reader = response.body.getReader()
|
|
76
|
-
const chunks = []
|
|
77
|
-
let total = 0
|
|
78
|
-
try {
|
|
79
|
-
for (;;) {
|
|
80
|
-
const { done, value } = await reader.read()
|
|
81
|
-
if (done) break
|
|
82
|
-
if (value) { chunks.push(value); total += value.length }
|
|
83
|
-
if (total >= limit) { await reader.cancel(); break }
|
|
84
|
-
}
|
|
85
|
-
} finally { reader.releaseLock() }
|
|
86
|
-
return new TextDecoder("utf-8").decode(Buffer.concat(chunks))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
84
|
/** Streaming decoder: encoding sniffing ASCII→UTF-8→GBK.
|
|
85
|
+
* KNOWN LIMITATION (accepted): the fallback is hardcoded to GBK (Chinese) —
|
|
86
|
+
* Shift-JIS/EUC-KR pages decode as mojibake. Real-world usage is dominated
|
|
87
|
+
* by UTF-8; a charset-aware variant would need the Content-Type header.
|
|
90
88
|
* Each call creates an independent decoder instance — must not be shared across parallel streams (internal decoder state accumulates). */
|
|
91
89
|
export function makeDecoder() {
|
|
92
90
|
let decoder = null
|
|
@@ -154,7 +152,7 @@ export async function autoSyntaxCheck(abs) {
|
|
|
154
152
|
}
|
|
155
153
|
|
|
156
154
|
/** Resolve realpath by walking up the directory tree */
|
|
157
|
-
|
|
155
|
+
function realpathNearest(abs) {
|
|
158
156
|
let cur = abs
|
|
159
157
|
const tail = []
|
|
160
158
|
while (!existsSync(cur)) {
|
|
@@ -167,15 +165,20 @@ export function realpathNearest(abs) {
|
|
|
167
165
|
catch { return abs }
|
|
168
166
|
}
|
|
169
167
|
|
|
168
|
+
// cwd is effectively constant per CLI session — the cache never grows in
|
|
169
|
+
// practice. A long-running server with rotating cwds would leak; revisit with
|
|
170
|
+
// an LRU if that usage ever appears.
|
|
170
171
|
const realCwdCache = new Map()
|
|
171
172
|
/** Resolve cwd to realpath, cached */
|
|
172
|
-
|
|
173
|
+
function realCwd(cwd) {
|
|
173
174
|
if (!realCwdCache.has(cwd)) realCwdCache.set(cwd, realpathNearest(resolve(cwd)))
|
|
174
175
|
return realCwdCache.get(cwd)
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
/** Assert that a resolved path is inside cwd; throws on escape */
|
|
178
|
-
|
|
179
|
+
function assertInside(cwd, resolved, p) {
|
|
180
|
+
// relative() returns platform-native separators; ".." + sep therefore
|
|
181
|
+
// matches both / and \ traversal on the respective platform.
|
|
179
182
|
const rel = relative(cwd, resolved)
|
|
180
183
|
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
181
184
|
throw new Error(`Access denied outside working directory: ${p}`)
|
|
@@ -215,8 +218,11 @@ function blankQuoted(command) {
|
|
|
215
218
|
const ch = command[i]
|
|
216
219
|
if (quote) {
|
|
217
220
|
if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
|
|
218
|
-
if (ch === quote) quote = null
|
|
219
|
-
|
|
221
|
+
if (ch === quote) { quote = null; out += " "; continue }
|
|
222
|
+
// Backticks are COMMAND SUBSTITUTION — the content executes, so it must
|
|
223
|
+
// stay visible to the redirection check (echo `cat > /tmp/x` writes a
|
|
224
|
+
// file). Only ' and " are literal regions.
|
|
225
|
+
out += quote === "`" ? ch : " "
|
|
220
226
|
} else if (ch === "'" || ch === '"' || ch === "`") {
|
|
221
227
|
quote = ch
|
|
222
228
|
out += " "
|
|
@@ -227,17 +233,22 @@ function blankQuoted(command) {
|
|
|
227
233
|
return out
|
|
228
234
|
}
|
|
229
235
|
|
|
230
|
-
/** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions
|
|
236
|
+
/** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions.
|
|
237
|
+
* Backtick contents count (command substitution executes); fd-prefixed forms
|
|
238
|
+
* (2> file, 1>> file) count too. */
|
|
231
239
|
export function hasFileRedirection(command) {
|
|
232
240
|
const bare = blankQuoted(command)
|
|
233
|
-
return /(^|[\s;&|])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|])<\s*\S/.test(bare)
|
|
241
|
+
return /(^|[\s;&|0-9])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|0-9])<\s*\S/.test(bare)
|
|
234
242
|
}
|
|
235
243
|
|
|
236
244
|
/** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
|
|
237
245
|
export function isDestructiveCommand(seg) {
|
|
238
246
|
const s = seg
|
|
239
|
-
// rm with
|
|
240
|
-
|
|
247
|
+
// rm with recursive (-r/-R/--recursive): destructive WITH or WITHOUT -f
|
|
248
|
+
// (recursive delete removes trees non-interactively in many setups; -rf is
|
|
249
|
+
// the classic case). Conservative: prefer blocking. The \s before the flag
|
|
250
|
+
// requires a separator — "rm-rf" is not a valid command (no such program).
|
|
251
|
+
if (/\brm\b/.test(s) && (/\s-\S*r/i.test(s) || /\s--recursive\b/i.test(s))) return true
|
|
241
252
|
if (/\brmdir\b/i.test(s)) return true
|
|
242
253
|
if (/\bdel\b/i.test(s) && /\/f\b/i.test(s)) return true
|
|
243
254
|
if (/\brd\b/i.test(s) && /\/s\b/i.test(s)) return true
|
|
@@ -251,28 +262,11 @@ export function isDestructiveCommand(seg) {
|
|
|
251
262
|
return false
|
|
252
263
|
}
|
|
253
264
|
|
|
254
|
-
/** Whether a single command segment destroys uncommitted changes */
|
|
255
|
-
export function isDestructiveGitSegment(seg) {
|
|
256
|
-
if (!/^\s*git\s/.test(seg)) return false
|
|
257
|
-
if (/\scheckout\s+(?:--|\.(?:\s|$))/.test(seg)) return true
|
|
258
|
-
if (/\sreset\s+--hard\b/.test(seg)) return true
|
|
259
|
-
if (/\sclean\s+-\S*f/.test(seg)) return true
|
|
260
|
-
if (/\srestore\s/.test(seg) && (/--worktree/.test(seg) || !/--staged/.test(seg))) return true
|
|
261
|
-
return false
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Whether cwd is inside a git repository */
|
|
265
|
-
export function insideGitRepo(cwd) {
|
|
266
|
-
try {
|
|
267
|
-
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
268
|
-
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
269
|
-
})
|
|
270
|
-
return true
|
|
271
|
-
} catch { return false }
|
|
272
|
-
}
|
|
273
265
|
|
|
274
266
|
/** Convert glob pattern to regex */
|
|
275
267
|
export function globToRegex(pattern) {
|
|
268
|
+
// Sentinel chars: \u0001/\u0002 never appear in real glob patterns (they
|
|
269
|
+
// come from model output or the filesystem) — safe as **/ and ** placeholders.
|
|
276
270
|
const DS = "\u0001", DP = "\u0002"
|
|
277
271
|
const escaped = pattern
|
|
278
272
|
.replace(/\*\*\//g, DS).replace(/\*\*/g, DP)
|
|
@@ -283,12 +277,24 @@ export function globToRegex(pattern) {
|
|
|
283
277
|
return new RegExp(`^${escaped}$`)
|
|
284
278
|
}
|
|
285
279
|
|
|
286
|
-
/**
|
|
280
|
+
/** Decode a numeric HTML entity to its code point — invalid/out-of-range
|
|
281
|
+
* values (e.g. �) must not throw RangeError; keep the source
|
|
282
|
+
* text as-is (display-only residue is acceptable). */
|
|
283
|
+
function decodeNumericEntity(_, digits) {
|
|
284
|
+
const n = Number(digits)
|
|
285
|
+
return Number.isSafeInteger(n) && n <= 0x10ffff ? String.fromCodePoint(n) : _
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Strip HTML tags. KNOWN LIMITATION (accepted): the `/<[^>]+>/g` regex treats
|
|
289
|
+
* the first `>` (or a `<` inside an attribute value) as the tag boundary —
|
|
290
|
+
* `<img alt="a > b">` truncates the match and leaves text residue. A full
|
|
291
|
+
* HTML parser is out of scope; real-world HTML with angle brackets in
|
|
292
|
+
* attributes is rare and the residue is display-only (never parsed). */
|
|
287
293
|
export function stripTags(html) {
|
|
288
294
|
return html
|
|
289
295
|
.replace(/<[^>]+>/g, "")
|
|
290
|
-
.replace(/�*(\d+);/g, (
|
|
291
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (
|
|
296
|
+
.replace(/�*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
|
|
297
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
|
|
292
298
|
.replace(/ | /g, " ")
|
|
293
299
|
.replace(/</g, "<")
|
|
294
300
|
.replace(/>/g, ">")
|
|
@@ -308,8 +314,8 @@ export function htmlToText(html) {
|
|
|
308
314
|
.replace(/<br\s*\/?>/gi, "\n")
|
|
309
315
|
.replace(/<li[^>]*>/gi, "- ")
|
|
310
316
|
.replace(/<[^>]+>/g, "")
|
|
311
|
-
.replace(/�*(\d+);/g, (
|
|
312
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (
|
|
317
|
+
.replace(/�*(\d+);/g, (m, n) => decodeNumericEntity(m, n))
|
|
318
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => decodeNumericEntity(m, parseInt(h, 16)))
|
|
313
319
|
.replace(/ | /g, " ")
|
|
314
320
|
.replace(/</g, "<")
|
|
315
321
|
.replace(/>/g, ">")
|
|
@@ -325,8 +331,13 @@ export function runGit(cwd, cmdArgs) {
|
|
|
325
331
|
try {
|
|
326
332
|
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
327
333
|
} catch (e) {
|
|
328
|
-
//
|
|
329
|
-
|
|
334
|
+
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
335
|
+
// (callers show "(truncated)"-style tails). ALL OTHER errors (non-git repo,
|
|
336
|
+
// permission, bad command) return "" — matching gitDiffOne's pattern: a
|
|
337
|
+
// failed git call must not masquerade as partial success.
|
|
338
|
+
if (e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && e.stdout) {
|
|
339
|
+
return String(e.stdout).trim().replace(/\r/g, "").split("\n").slice(0, 200).join("\n")
|
|
340
|
+
}
|
|
330
341
|
return ""
|
|
331
342
|
}
|
|
332
343
|
}
|
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
|
}
|