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.
@@ -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
- advisorStreaming: "", // advisor streaming buffer (formatted like main response)
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
  })
@@ -5,9 +5,11 @@
5
5
  * model replies stop showing literal `**`, `##`, backtick markers (IK5VW3).
6
6
  *
7
7
  * Design constraints:
8
- * - Operates on ALREADY-WRAPPED single lines (call after wrapText): inserting ANSI
9
- * here cannot break width math, because wrapping already happened.
10
- * - Uses narrow-scope SGR resets (22 = bold off, 27 = reverse off, 29 = strikethrough off)
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
- if (!line || line.indexOf("*") === -1 && line.indexOf("`") === -1 && line.indexOf("_") === -1 && line.indexOf("~") === -1) {
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 a heading line: strip leading `#` markers and bold the whole line. Returns original when not a heading. */
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
- const m = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line)
50
- if (!m || !m[2]) return line
51
- return `${BOLD}${m[2]}${BOLD_OFF}`
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
  }
@@ -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
- return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey }
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
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${state.advisorStreaming?.length ?? 0}|${state._advisorThink?.length ?? 0}|${state.foldEnabled !== false ? "f" : "u"}|${exp}`
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 [blank, ▶, first 4, last] 5 content lines. Main output and
79
- // thinking are the REAL long content; bidirectional folding (collapse markers
80
- // + click toggle) keeps them readable — the 0.12.7 dim-only restriction was a
81
- // temporary fix for the single-direction era and is now reverted. Keyed by the
82
- // source-line index (`long-${i}`) so the toggle survives re-renders.
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
- for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
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
- // Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
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
- // EXPANDED long block: blank line + ▼ control line at the HEAD, directly
103
- // before the content. DIM blocks must not re-trigger the consecutive-dim
104
- // folding below (folding stacked on folding reported regression).
105
- if (l.color === C.dim) {
106
- for (const line of block) line._skipDimFold = true
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
- if (state._advisorThink || state.advisorStreaming) {
121
- const thinkLines = state._advisorThink ? sanitizeDisplay(state._advisorThink).split("\n") : []
122
- const mainLines = state.advisorStreaming
123
- ? formatTables(sanitizeDisplay(state.advisorStreaming), cols - 3)
124
- : []
125
- const allLines = [...thinkLines.map(l => ({ text: l, color: C.reason })), ...mainLines.map(l => ({ text: l, color: C.text }))]
126
- const truncated = allLines.length > 5
127
- if (truncated) convLines.push({ text: "│ …", color: C.dim })
128
- const shown = truncated ? allLines.slice(-5) : allLines
129
- for (const { text, color } of shown) {
130
- for (const wrapped of wrapText(text, cols - 3)) {
131
- convLines.push({ text: `│ ${wrapped}`, color })
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
- for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
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: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: C.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
- folded.push(blankLine())
167
- folded.push(foldHintLine(`▼ ${blockLen} lines — click to collapse`, foldKey))
168
- for (let k = i; k < j; k++) folded.push(convLines[k])
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
  }
@@ -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 ch of text) w += charWidth(ch.codePointAt(0))
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
- for (const ch of text) {
44
- const cw = charWidth(ch.codePointAt(0))
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 ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
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") {