thincoder 0.12.10 → 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 +19 -2
- 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 +209 -87
- package/src/agent/completion.mjs +9 -2
- package/src/agent/dispatch.mjs +14 -0
- package/src/agent-tools/advisor.mjs +12 -11
- package/src/agent-tools/subagent.mjs +73 -9
- package/src/agent.mjs +5 -5
- package/src/config.mjs +3 -0
- 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/tools/system.mjs +13 -5
- package/src/tui/agent-turn.mjs +73 -121
- package/src/tui/cmd-shell.mjs +104 -0
- package/src/tui/cmd-submodel.mjs +151 -0
- package/src/tui/index.mjs +4 -3
- package/src/tui/markdown.mjs +26 -8
- package/src/tui/pickers.mjs +32 -1
- package/src/tui/render-conversation.mjs +103 -37
- package/src/tui/render.mjs +24 -5
- package/src/tui/slash-commands.mjs +10 -0
- package/src/tui/tool-summaries.mjs +113 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
|
|
3
|
+
/** Subagent role slots — each has an independent model override slot */
|
|
4
|
+
export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder"]
|
|
5
|
+
|
|
6
|
+
/** Human-readable effective display with inheritance source. role=null → global slot.
|
|
7
|
+
* source semantics: "type" = role-specific override (subagentModels[role]),
|
|
8
|
+
* "global" = falls back to subagentModel, "parent" = no override, inherits the
|
|
9
|
+
* parent agent's provider. */
|
|
10
|
+
function slotDisplay(agent, role) {
|
|
11
|
+
const cfg = agent.config?.agent ?? {}
|
|
12
|
+
if (role === null) {
|
|
13
|
+
return cfg.subagentModel ? { value: cfg.subagentModel, source: "global" } : { value: null, source: "parent" }
|
|
14
|
+
}
|
|
15
|
+
const type = cfg.subagentModels?.[role]
|
|
16
|
+
const global = cfg.subagentModel
|
|
17
|
+
if (type) return { value: type, source: "type" }
|
|
18
|
+
if (global) return { value: global, source: "global" }
|
|
19
|
+
return { value: null, source: "parent" }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** /submodel command: subagent model config — picker menu (default) or direct args.
|
|
23
|
+
* ctx: { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot }
|
|
24
|
+
* /submodel → picker: global + 4 role slots
|
|
25
|
+
* /submodel <value> → set global default
|
|
26
|
+
* /submodel <type> <value> → set a role slot
|
|
27
|
+
* /submodel <type> → show a slot
|
|
28
|
+
* /submodel reset [type] → clear global (or a slot)
|
|
29
|
+
* value forms: provider:model | provider name | model name (same as subagent tool model arg) */
|
|
30
|
+
export async function handleSubmodelCommand(ctx, args = []) {
|
|
31
|
+
const { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot } = ctx
|
|
32
|
+
const input = args.join(" ").trim()
|
|
33
|
+
|
|
34
|
+
// Single write channel: mutate BOTH in-memory agent.config.agent and the disk raw
|
|
35
|
+
// with the same function (no divergent double-writes). Agent config is created
|
|
36
|
+
// lazily here — an Esc-cancelled picker never writes anything.
|
|
37
|
+
const persist = async (mutate) => {
|
|
38
|
+
agent.config.agent ??= {}
|
|
39
|
+
const a = agent.config.agent
|
|
40
|
+
mutate(a)
|
|
41
|
+
await persistRaw((raw) => {
|
|
42
|
+
raw.agent ??= {}
|
|
43
|
+
mutate(raw.agent)
|
|
44
|
+
}).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Direct args ──
|
|
48
|
+
if (input) {
|
|
49
|
+
const parts = input.split(/\s+/)
|
|
50
|
+
const first = parts[0].toLowerCase()
|
|
51
|
+
if (first === "reset") {
|
|
52
|
+
const type = parts[1]?.toLowerCase()
|
|
53
|
+
if (type) {
|
|
54
|
+
if (!SUBMODEL_SLOTS.includes(type)) {
|
|
55
|
+
pushLine(`Unknown subagent type: ${type} (available: ${SUBMODEL_SLOTS.join(", ")})`, C.error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
await persist((a) => { a.subagentModels ??= {}; delete a.subagentModels[type]; if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels })
|
|
59
|
+
pushLine(`Subagent type ${type}: reset to inherit (${slotDisplay(agent, type).source === "global" ? `global: ${slotDisplay(agent, type).value}` : "parent provider"}).`, C.text)
|
|
60
|
+
} else {
|
|
61
|
+
await persist((a) => { a.subagentModel = null })
|
|
62
|
+
pushLine("Subagent global model: reset to inherit parent provider.", C.text)
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (SUBMODEL_SLOTS.includes(first)) {
|
|
67
|
+
const value = parts.slice(1).join(" ")
|
|
68
|
+
if (!value) {
|
|
69
|
+
const d = slotDisplay(agent, first)
|
|
70
|
+
pushLine(`Subagent ${first}: ${d.value ? `\`${d.value}\` (${d.source} config)` : "(inherit: parent provider)"}`, C.text)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[first] = value })
|
|
74
|
+
pushLine(`Subagent ${first} model set to \`${value}\`.`, C.text)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (parts.length >= 2) {
|
|
78
|
+
// Two+ tokens and the first is not a known slot → user probably meant a type
|
|
79
|
+
pushLine(`Unknown subagent type: ${first} (available: ${SUBMODEL_SLOTS.join(", ")})`, C.error)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
// Global value (provider:model | provider | model)
|
|
83
|
+
await persist((a) => { a.subagentModel = input })
|
|
84
|
+
pushLine(`Subagent global model set to \`${input}\`.`, C.text)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Picker menu: global + 4 role slots ──
|
|
89
|
+
// for(;;) loop relies on showPicker's async/Promise suspension — every iteration
|
|
90
|
+
// awaits a user choice; Esc (null) exits. Mirrors openModelPicker's menu-loop pattern.
|
|
91
|
+
for (;;) {
|
|
92
|
+
const g = slotDisplay(agent, null)
|
|
93
|
+
const entries = [
|
|
94
|
+
{ type: "header", text: "Subagent models — pick a slot to edit (Esc exits)" },
|
|
95
|
+
{ type: "item", text: `${g.value ? `Global default: ${g.value}` : "Global default: (inherit parent)"}`, action: "slot", slot: "global", marker: g.value ? "●" : "○" },
|
|
96
|
+
...SUBMODEL_SLOTS.map((role) => {
|
|
97
|
+
const d = slotDisplay(agent, role)
|
|
98
|
+
const label = d.value ? `${role}: ${d.value}${d.source === "global" ? " (←global)" : ""}` : `${role}: (inherit: ${d.source === "global" ? "global" : "parent"})`
|
|
99
|
+
return { type: "item", text: label, action: "slot", slot: role, marker: d.source === "type" ? "●" : "○" }
|
|
100
|
+
}),
|
|
101
|
+
{ type: "header", text: "Actions" },
|
|
102
|
+
{ type: "item", text: "Reset all (inherit parent)", action: "resetall" },
|
|
103
|
+
]
|
|
104
|
+
const picked = await showPicker("Subagent Models", entries)
|
|
105
|
+
if (!picked) return // Esc
|
|
106
|
+
|
|
107
|
+
if (picked.action === "resetall") {
|
|
108
|
+
await persist((a) => { a.subagentModel = null; delete a.subagentModels })
|
|
109
|
+
pushLine("All subagent model overrides cleared — inherit parent provider.", C.text)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const role = picked.slot === "global" ? null : picked.slot
|
|
114
|
+
const cur = slotDisplay(agent, role)
|
|
115
|
+
const sub = await showPicker(`Subagent ${picked.slot}`, [
|
|
116
|
+
{ type: "header", text: `Current: ${cur.value ? `\`${cur.value}\` (${cur.source})` : "(inherit)"}` },
|
|
117
|
+
{ type: "item", text: "Set model… (provider → model picker)", action: "set" },
|
|
118
|
+
{ type: "item", text: "Set to parent provider model", action: "parent" },
|
|
119
|
+
{ type: "item", text: "Reset (inherit)", action: "reset" },
|
|
120
|
+
])
|
|
121
|
+
if (!sub) continue // Esc → back to slots
|
|
122
|
+
|
|
123
|
+
if (sub.action === "set") {
|
|
124
|
+
const sel = await pickModelForSlot()
|
|
125
|
+
if (!sel) continue
|
|
126
|
+
const value = `${sel.provider}:${sel.model}`
|
|
127
|
+
if (role) {
|
|
128
|
+
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
|
|
129
|
+
pushLine(`Subagent ${role} model set to \`${value}\`.`, C.text)
|
|
130
|
+
} else {
|
|
131
|
+
await persist((a) => { a.subagentModel = value })
|
|
132
|
+
pushLine(`Subagent global model set to \`${value}\`.`, C.text)
|
|
133
|
+
}
|
|
134
|
+
} else if (sub.action === "parent") {
|
|
135
|
+
const value = `${agent.activeProvider}:${agent.activeModel ?? agent.provider?.model}`
|
|
136
|
+
if (role) {
|
|
137
|
+
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
|
|
138
|
+
} else {
|
|
139
|
+
await persist((a) => { a.subagentModel = value })
|
|
140
|
+
}
|
|
141
|
+
pushLine(`Subagent ${picked.slot} set to parent model \`${value}\`.`, C.text)
|
|
142
|
+
} else if (sub.action === "reset") {
|
|
143
|
+
if (role) {
|
|
144
|
+
await persist((a) => { a.subagentModels ??= {}; delete a.subagentModels[role]; if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels })
|
|
145
|
+
} else {
|
|
146
|
+
await persist((a) => { a.subagentModel = null })
|
|
147
|
+
}
|
|
148
|
+
pushLine(`Subagent ${picked.slot} reset to inherit.`, C.text)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
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: [],
|
|
@@ -291,7 +291,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
291
291
|
// local TUI/agent config, never the in-flight turn); the rest are queued
|
|
292
292
|
const cmd0 = text.split(/\s+/)[0].toLowerCase()
|
|
293
293
|
const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
|
|
294
|
-
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
294
|
+
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/submodel", "/shell", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
295
295
|
if (safeDuringProcessing.has(resolved0)) {
|
|
296
296
|
await handleSlash(text)
|
|
297
297
|
render()
|
|
@@ -341,7 +341,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
341
341
|
const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
|
|
342
342
|
|
|
343
343
|
// Model picker + generic picker: implemented in pickers.mjs
|
|
344
|
-
const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
|
|
344
|
+
const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot } = createPickers({
|
|
345
345
|
agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
|
|
346
346
|
})
|
|
347
347
|
|
|
@@ -363,6 +363,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
363
363
|
openModelPicker: () => openModelPicker(),
|
|
364
364
|
selectModel,
|
|
365
365
|
setProviderKey,
|
|
366
|
+
pickModelForSlot,
|
|
366
367
|
runDistill,
|
|
367
368
|
exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
|
|
368
369
|
})
|
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
|
}
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -40,6 +40,8 @@ export function createPickers(ctx) {
|
|
|
40
40
|
closePicker()
|
|
41
41
|
return new Promise((resolve) => {
|
|
42
42
|
const itemCount = entries.filter((e) => e.type === "item").length
|
|
43
|
+
// No selectable items — resolve immediately instead of showing an empty picker
|
|
44
|
+
if (itemCount === 0) { resolve(null); return }
|
|
43
45
|
const index = Math.max(0, Math.min(defaultIndex, Math.max(0, itemCount - 1)))
|
|
44
46
|
state.picker = { title, entries, lines: [], index, scroll: 0, selectedLine: 0, filter: "", resolve }
|
|
45
47
|
state.pickerStack.push(state.picker)
|
|
@@ -389,5 +391,34 @@ export function createPickers(ctx) {
|
|
|
389
391
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
390
392
|
}
|
|
391
393
|
|
|
392
|
-
|
|
394
|
+
|
|
395
|
+
/** Slot-bound model picker: two-level provider → model selection that RETURNS
|
|
396
|
+
* { provider, model } instead of writing main-session state — used by /submodel
|
|
397
|
+
* to write into a subagent slot (global or per-role). Esc from the model list
|
|
398
|
+
* returns to the provider list (openModelPicker parity); Esc from the provider
|
|
399
|
+
* list exits → null. */
|
|
400
|
+
async function pickModelForSlot() {
|
|
401
|
+
for (;;) {
|
|
402
|
+
const providers = agent.providers
|
|
403
|
+
if (!providers.length) return null
|
|
404
|
+
const e = await showPicker("Select provider", providers.map((p) => ({
|
|
405
|
+
type: "item",
|
|
406
|
+
text: `${p.name.padEnd(12)} ${p.model}`,
|
|
407
|
+
action: "open-models",
|
|
408
|
+
provider: p.name,
|
|
409
|
+
})))
|
|
410
|
+
if (!e?.provider) return null
|
|
411
|
+
const providerConfig = providers.find((p) => p.name === e.provider)
|
|
412
|
+
if (!providerConfig) return null
|
|
413
|
+
const entries = buildModelEntriesForProvider(e.provider, providerConfig)
|
|
414
|
+
fetchModelsForProvider(e.provider, entries).catch((err) => {
|
|
415
|
+
pushLine(`[model] fetch models failed: ${err.message}`, C.error)
|
|
416
|
+
})
|
|
417
|
+
const me = await showPicker(`${e.provider} models`, entries)
|
|
418
|
+
if (!me?.model) continue // Esc from model list → back to provider list
|
|
419
|
+
return { provider: e.provider, model: me.model }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot }
|
|
393
424
|
}
|
|
@@ -3,16 +3,43 @@
|
|
|
3
3
|
* Extracted from render-frame.mjs.
|
|
4
4
|
*/
|
|
5
5
|
import { ansi, C } from "./ansi.mjs"
|
|
6
|
-
import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
|
|
6
|
+
import { formatTables, sanitizeDisplay, stringWidth, wrapText } from "./render.mjs"
|
|
7
7
|
import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
|
|
8
8
|
|
|
9
9
|
let _convCache = { key: "", cols: 0, lines: [] }
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Render markdown markers to ANSI, then pad the line tail back to the pre-render
|
|
13
|
+
* display width. Markers (`` ` ``, `**`, `~~`) vanish on render — without the
|
|
14
|
+
* compensation, table rows containing them display shorter than the column widths
|
|
15
|
+
* computed by formatTables and the borders misalign (reported regression).
|
|
16
|
+
* @param {string} text — plain text line (no ANSI yet), already wrapped
|
|
17
|
+
* @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
|
|
18
|
+
*/
|
|
19
|
+
function renderMarkdownPreservingWidth(text) {
|
|
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")
|
|
30
|
+
}
|
|
31
|
+
// Test seam (mirrors the _-prefixed seams in run.mjs).
|
|
32
|
+
export { renderMarkdownPreservingWidth as _renderMarkdownPreservingWidth }
|
|
33
|
+
|
|
34
|
+
|
|
11
35
|
export function convCacheKey(state) {
|
|
12
36
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
13
37
|
// expandedBlocks participates: expanding/folding a block must invalidate the cache
|
|
14
38
|
const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
|
|
15
|
-
|
|
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}`
|
|
16
43
|
}
|
|
17
44
|
|
|
18
45
|
/** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
|
|
@@ -55,6 +82,13 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
|
|
|
55
82
|
return result
|
|
56
83
|
}
|
|
57
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
|
+
*/
|
|
58
92
|
function buildConvLines(state, cols) {
|
|
59
93
|
const key = convCacheKey(state)
|
|
60
94
|
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
@@ -75,19 +109,25 @@ function buildConvLines(state, cols) {
|
|
|
75
109
|
|
|
76
110
|
// Long-message folding: ANY single line (main output C.text, thinking C.reason,
|
|
77
111
|
// tool summaries C.dim — whatever wraps beyond LONG_FOLD_LINES display rows)
|
|
78
|
-
// collapses to [
|
|
79
|
-
// thinking are the REAL long
|
|
80
|
-
// + click toggle) keeps
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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.
|
|
83
118
|
const longKey = `long-${i}`
|
|
84
119
|
const folded = state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
|
|
85
120
|
const block = []
|
|
86
|
-
|
|
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)) {
|
|
87
129
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
88
|
-
|
|
89
|
-
// Runs AFTER wrapping so the ANSI it inserts never skews width math.
|
|
90
|
-
block.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId, _src: i })
|
|
130
|
+
block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
|
|
91
131
|
}
|
|
92
132
|
}
|
|
93
133
|
if (folded && block.length > LONG_FOLD_LINES) {
|
|
@@ -99,15 +139,21 @@ function buildConvLines(state, cols) {
|
|
|
99
139
|
convLines.push(foldHintLine(`▶ … ${block.length - FOLD_KEEP} more lines — click to expand`, longKey, i))
|
|
100
140
|
convLines.push(block[block.length - 1])
|
|
101
141
|
} else if (block.length > LONG_FOLD_LINES) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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)
|
|
107
156
|
}
|
|
108
|
-
convLines.push(blankLine())
|
|
109
|
-
convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
|
|
110
|
-
convLines.push(...block)
|
|
111
157
|
} else {
|
|
112
158
|
convLines.push(...block)
|
|
113
159
|
}
|
|
@@ -117,25 +163,40 @@ function buildConvLines(state, cols) {
|
|
|
117
163
|
convLines.push({ text: wrapped, color: C.reason })
|
|
118
164
|
}
|
|
119
165
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
191
|
}
|
|
133
192
|
}
|
|
134
193
|
}
|
|
135
194
|
if (state.streaming) {
|
|
136
|
-
|
|
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)) {
|
|
137
198
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
138
|
-
convLines.push({ text:
|
|
199
|
+
convLines.push({ text: wrapped, color: C.text })
|
|
139
200
|
}
|
|
140
201
|
}
|
|
141
202
|
}
|
|
@@ -162,10 +223,15 @@ function buildConvLines(state, cols) {
|
|
|
162
223
|
i = j
|
|
163
224
|
continue
|
|
164
225
|
}
|
|
165
|
-
// EXPANDED consecutive-dim block: blank + ▼ at the HEAD, then every line
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
235
|
i = j
|
|
170
236
|
continue
|
|
171
237
|
}
|
package/src/tui/render.mjs
CHANGED
|
@@ -31,20 +31,37 @@ export function charWidth(cp) {
|
|
|
31
31
|
|
|
32
32
|
/** Compute the display width of a string (CJK characters count as 2) */
|
|
33
33
|
export function stringWidth(text) {
|
|
34
|
+
// ANSI escape sequences occupy zero display width — strip them before counting.
|
|
35
|
+
// Without this, markdown-rendered text (ANSI inserted) was measured wider than it
|
|
36
|
+
// displays, and table lines with inline markers ended up shorter than the computed
|
|
37
|
+
// column widths (reported: table borders misaligned after `code`/`**bold**` cells).
|
|
34
38
|
let w = 0
|
|
35
|
-
for (const
|
|
39
|
+
for (const part of text.split(ANSI_SEQUENCE_RE)) {
|
|
40
|
+
for (const ch of part) w += charWidth(ch.codePointAt(0))
|
|
41
|
+
}
|
|
36
42
|
return w
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
/** Slice by display width */
|
|
45
|
+
/** Slice by display width — ANSI sequences count as zero width and are kept whole. */
|
|
40
46
|
export function sliceByWidth(text, maxWidth) {
|
|
41
47
|
let w = 0
|
|
42
48
|
let out = ""
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
let i = 0
|
|
50
|
+
while (i < text.length) {
|
|
51
|
+
// Copy any ANSI sequence verbatim (zero display width, never sliced mid-sequence)
|
|
52
|
+
const m = text.slice(i).match(ANSI_SEQUENCE)
|
|
53
|
+
if (m && m.index === 0) {
|
|
54
|
+
out += m[0]
|
|
55
|
+
i += m[0].length
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const cp = text.codePointAt(i)
|
|
59
|
+
const ch = String.fromCodePoint(cp)
|
|
60
|
+
const cw = charWidth(cp)
|
|
45
61
|
if (w + cw > maxWidth) break
|
|
46
62
|
w += cw
|
|
47
63
|
out += ch
|
|
64
|
+
i += ch.length
|
|
48
65
|
}
|
|
49
66
|
return out
|
|
50
67
|
}
|
|
@@ -186,7 +203,9 @@ export function layoutInput(chars, cursor, width) {
|
|
|
186
203
|
* Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
|
|
187
204
|
* are also cleaned during replay.
|
|
188
205
|
*/
|
|
189
|
-
const
|
|
206
|
+
const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
|
|
207
|
+
// Global variant for replace()/split(); the non-global one keeps match.index for slicing
|
|
208
|
+
const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
|
|
190
209
|
export function sanitizeDisplay(s) {
|
|
191
210
|
return s
|
|
192
211
|
.replace(ANSI_SEQUENCE_RE, "")
|
|
@@ -25,6 +25,8 @@ import { handleAutoCommand } from "./cmd-auto.mjs"
|
|
|
25
25
|
import { handleAdvisorCommand } from "./cmd-advisor.mjs"
|
|
26
26
|
import { handleThinkCommand } from "./cmd-think.mjs"
|
|
27
27
|
import { handleModelCommand } from "./cmd-model.mjs"
|
|
28
|
+
import { handleSubmodelCommand } from "./cmd-submodel.mjs"
|
|
29
|
+
import { handleShellCommand } from "./cmd-shell.mjs"
|
|
28
30
|
import { handleConfigCommand } from "./cmd-config.mjs"
|
|
29
31
|
import { handleExtractCommand } from "./cmd-extract.mjs"
|
|
30
32
|
import { handleHelpCommand } from "./cmd-help.mjs"
|
|
@@ -39,6 +41,8 @@ export const SLASH_COMMANDS = [
|
|
|
39
41
|
{ name: "/eng", group: "Agent", desc: "toggle engineering mode — strict methodology enforcement" },
|
|
40
42
|
{ name: "/advisor", group: "Agent", desc: "advisor settings (toggle, model, thinking, guard)" },
|
|
41
43
|
{ name: "/model", group: "Agent", desc: "select model & manage providers" },
|
|
44
|
+
{ name: "/submodel", group: "Agent", desc: "subagent model per type (explore/plan/coder/eng-coder)" },
|
|
45
|
+
{ name: "/shell", group: "System", desc: "bash tool shell (git-bash/pwsh path; win11 cmd encoding fix)" },
|
|
42
46
|
{ name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
|
|
43
47
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
44
48
|
{ name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
|
|
@@ -78,6 +82,8 @@ export const HANDLERS = {
|
|
|
78
82
|
"/advisor": handleAdvisorCommand,
|
|
79
83
|
"/think": handleThinkCommand,
|
|
80
84
|
"/model": handleModelCommand,
|
|
85
|
+
"/submodel": handleSubmodelCommand,
|
|
86
|
+
"/shell": handleShellCommand,
|
|
81
87
|
"/config": handleConfigCommand,
|
|
82
88
|
"/upgrade": handleUpgradeCommand,
|
|
83
89
|
"/fold": handleFoldCommand,
|
|
@@ -125,6 +131,10 @@ export function createSlashCommands(ctx) {
|
|
|
125
131
|
const argIndex = parts.length - 2 // which parameter is being typed (0-based)
|
|
126
132
|
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
127
133
|
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
134
|
+
if (cmd === "/submodel") {
|
|
135
|
+
if (argIndex === 0) return match(["explore", "plan", "coder", "eng-coder", "reset"])
|
|
136
|
+
if (parts[1] && !["reset"].includes(parts[1]) && argIndex === 1) return match(agent.providers.map((p) => p.name))
|
|
137
|
+
}
|
|
128
138
|
if (cmd === "/think") {
|
|
129
139
|
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
130
140
|
if (argIndex === 1 && parts[1].toLowerCase() === "effort") {
|