thincoder 0.12.35 → 0.12.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.35",
3
+ "version": "0.12.37",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -185,13 +185,27 @@ export function buildAdvisorUserMessage(agent, prior, reviewType, designToken =
185
185
  } catch { /* file doesn't exist — skip */ }
186
186
  }
187
187
 
188
+ // Document map (docs/design/README.md) — inject when the discovered
189
+ // project root has one: the reviewer checks document ownership against it
190
+ // (a change for an existing section must amend that section's document,
191
+ // not spawn a new file for it). Absent map → skip (nothing to check against).
192
+ try {
193
+ const mapPath = resolve(guideRoot ?? agent.cwd, "docs", "design", "README.md")
194
+ if (existsSync(mapPath)) {
195
+ parts.push("## Document Map")
196
+ parts.push("The document map below registers which document files exist per section. Use it for the Document ownership criterion: a change for an existing section must amend that section's document, not create a new file.")
197
+ parts.push(readFileSync(mapPath, "utf8"))
198
+ parts.push("")
199
+ }
200
+ } catch { /* file doesn't exist or is unreadable — skip */ }
201
+
188
202
  parts.push("## Instructions")
189
203
  if (docList.length > 0) {
190
204
  parts.push("1. Read every document in the Documents to Review list in full — review ONLY those files. Read METHODOLOGY.md to understand the project's standards.")
191
205
  } else {
192
206
  parts.push("1. Read the design document fully. Read METHODOLOGY.md to understand the project's standards.")
193
207
  }
194
- parts.push("2. Review against: completeness (all requirements covered?), feasibility (can this be built?), clarity (specific enough?), acceptance criteria (verifiable?), scope (appropriate?).")
208
+ parts.push("2. Review against: completeness (all requirements covered?), feasibility (can this be built?), methodology compliance (does it follow the project's METHODOLOGY.md?), clarity (specific enough?), acceptance criteria (verifiable?), scope (appropriate?).")
195
209
  parts.push("3. If the ## Project Guide (AGENTS.md) section above is present, also check requirement fit: does the design match what the requirements documents it points to actually ask for?")
196
210
  parts.push("4. Do NOT run git diff or look for code changes — there are none at this stage.")
197
211
  parts.push("5. If you find issues, produce your review table with the format: | # | Category | Severity | Issue | Suggestion |. If the design passes, no table is needed.")
package/src/advisor.mjs CHANGED
@@ -47,6 +47,7 @@ import { fileURLToPath } from "node:url"
47
47
  import { extractAgentResponseTable } from "./advisor/history.mjs"
48
48
  import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
49
49
  import { buildConvergenceBody } from "./advisor/convergence.mjs"
50
+ import { escapeLiteralEscapes } from "./escape.mjs"
50
51
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
51
52
  export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
52
53
  export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
@@ -72,14 +73,11 @@ const ADVISOR_ROUND1 = loadPrompt("advisor-round1.md", "advisor-round1.md")
72
73
  // buildAdvisorSystemPrompt when _advisorRound > 0.
73
74
  const ADVISOR_ROUND2 = loadPrompt("advisor-round2.md", "advisor-round2.md")
74
75
  const ADVISOR_ROUND3 = loadPrompt("advisor-round3.md", "advisor-round3.md")
75
- // Fallback when advisor-design.md is missing keep in sync with the real
76
- // file (table format + workflow steps).
77
- const ADVISOR_DESIGN_FALLBACK = `You are an independent design reviewer for an engineering-mode project. Review the design document in the changes below. Evaluate: completeness, feasibility, clarity, scope, acceptance criteria. Read METHODOLOGY.md if provided. Produce a review table with | # | Category | Severity | Issue | Suggestion | format.`
78
- let ADVISOR_DESIGN = ""
79
- // Design review is OPTIONAL (engineering mode only) — silent fallback to the
80
- // in-code constant is intentional, unlike the mandatory round prompts which
81
- // must exist for every review (loadPrompt throws a descriptive error there).
82
- try { ADVISOR_DESIGN = readFileSync(join(__dirname, "prompts", "advisor-design.md"), "utf8") } catch { /* fallback below */ }
76
+ // Design-review prompthard-loaded like the round prompts (decision
77
+ // 2026-08-21): a missing file means a broken installation, and silently
78
+ // degrading to a lesser in-code prompt would quietly strip the approval-signal
79
+ // and citation rules, disabling design approval entirely. loadPrompt throws.
80
+ const ADVISOR_DESIGN = loadPrompt("advisor-design.md", "advisor-design.md")
83
81
 
84
82
  // ────────────────────────────────────────
85
83
  // System prompt building
@@ -109,7 +107,7 @@ export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
109
107
  // approval token); rounds 2+ converge like code reviews (verify agent fix claims).
110
108
  if (reviewType === "design") {
111
109
  if (!hasPrior) {
112
- return ADVISOR_DESIGN || ADVISOR_DESIGN_FALLBACK
110
+ return ADVISOR_DESIGN
113
111
  }
114
112
  const round = (agent._advisorRound || 0) + 1
115
113
  if (round === 2) return ADVISOR_ROUND2
@@ -174,28 +172,9 @@ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
174
172
  * messages.mjs so the legacy path shares it (see there).
175
173
  */
176
174
 
177
- /**
178
- * Neutralize literal backslash escape sequences ("\x", "\u") that some
179
- * OpenAI-compatible servers interpret inside message content ("unexpected end
180
- * of hex escape" → 400 — observed 2026-08-06 when the conversation background
181
- * quoted "\x" literals). Only sequences that would be INVALID when expanded
182
- * are doubled ("\\x" → literal "\x" after server expansion); well-formed
183
- * "\xNN" / "\uNNNN" pass through untouched (they expand to a byte/codepoint).
184
- */
185
- export function escapeLiteralEscapes(text) {
186
- // (?<!\\) — only a SINGLE backslash counts ("\\x" already doubles the
187
- // escape and must pass through untouched); lookbehind is fine on Node 24.
188
- // Known limitation (documented, accepted): an ODD backslash run of 3+ (e.g.
189
- // "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare in real
190
- // conversation text, and the sequence is still valid JSON either way.
191
- // The lookahead treats "\x" followed by AT LEAST 2 hex as valid (servers
192
- // expand only the first two: "\x1b3" → ESC + "3"); only truncated runs
193
- // ("\x" + <2 hex) are doubled.
194
- text = String(text ?? "")
195
- return text
196
- .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
197
- .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
198
- }
175
+ // escapeLiteralEscapes 已抽到 ./escape.mjs(advisor 与主 agent 发送路径共用),
176
+ // 这里 re-export 保持既有 import 稳定。
177
+ export { escapeLiteralEscapes }
199
178
 
200
179
 
201
180
  /**
package/src/config.mjs CHANGED
@@ -36,7 +36,7 @@ export const PROVIDER_PRESETS = {
36
36
  groq: { baseURL: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile", maxTokens: 32768, desc: "Groq" },
37
37
  }
38
38
 
39
- const DEFAULTS = {
39
+ export const DEFAULTS = {
40
40
  activeModel: null, // optional: override provider.model (set via /model picker or /model provider:model)
41
41
  agent: {
42
42
  maxTurns: 100,
package/src/context.mjs CHANGED
@@ -214,7 +214,7 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
214
214
  const middle = history.slice(split.headEnd, split.tailStart)
215
215
  const serialized = middle
216
216
  .map((m) => {
217
- const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function.name).join(", ")}]` : ""
217
+ const toolNote = m.tool_calls ? ` [called tools: ${m.tool_calls.map((t) => t.function?.name).join(", ")}]` : ""
218
218
  // user messages get a wider cap (8000): cutting off a long user-pasted requirement loses original intent; tool/assistant capped at 2000 is enough
219
219
  const cap = m.role === "user" ? 8000 : 2000
220
220
  // Multimodal messages (array content): extract the TEXT parts — the image itself can't be
package/src/escape.mjs ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析。
3
+ *
4
+ * 某些服务端(Kimi 等)会把 content 里的字面 "\x" / "\u" 当作 hex escape 再解释一遍,
5
+ * 遇到 "\x" 后不足 2 个 hex(或 "\u" 后不足 4 个 hex)时服务端报
6
+ * "unexpected end of hex escape" → 400(首次观察于 2026-08-06,见 advisor.mjs 历史)。
7
+ *
8
+ * 对策:把这类"一旦被服务端二次展开就会非法"的字面序列提前 double 成 "\\x" / "\\u",
9
+ * 服务端二次解析后还原为字面量;合法完整的 "\xNN" / "\uNNNN" 原样放过(它们能展开成
10
+ * 一个字节/码点)。JSON.stringify 层面的反斜杠转义由发送方负责,本模块不碰。
11
+ */
12
+
13
+ /** 中和单段文本里的非法字面转义序列。 */
14
+ export function escapeLiteralEscapes(text) {
15
+ text = String(text ?? "")
16
+ return text
17
+ // (?<!\\) — 只有单个反斜杠才处理("\\x" 已经是 double 的,必须原样放过)
18
+ // 前瞻:\x 后至少 2 个 hex 视为合法(服务端只展开前两个),只有不足 2 个的才 double
19
+ .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
20
+ .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
21
+ }
22
+
23
+ /** 对单条消息的 content 应用 escapeLiteralEscapes(支持字符串或 OpenAI 多模态 part 数组)。 */
24
+ export function escapeMessageContent(message) {
25
+ const content = message?.content
26
+ if (typeof content === "string") {
27
+ return { ...message, content: escapeLiteralEscapes(content) }
28
+ }
29
+ if (Array.isArray(content)) {
30
+ let changed = false
31
+ const parts = content.map((p) => {
32
+ if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
33
+ const escaped = escapeLiteralEscapes(p.text)
34
+ if (escaped !== p.text) {
35
+ changed = true
36
+ return { ...p, text: escaped }
37
+ }
38
+ }
39
+ return p
40
+ })
41
+ return changed ? { ...message, content: parts } : message
42
+ }
43
+ return message
44
+ }
45
+
46
+ /** 对整个 messages 数组逐条应用 escapeMessageContent。 */
47
+ export function escapeMessages(messages) {
48
+ return messages.map(escapeMessageContent)
49
+ }
@@ -2,8 +2,13 @@
2
2
  * generate-title.mjs — LLM-generated session titles (CLI side)
3
3
  * Called after the first user message to auto-title the session.
4
4
  * Mirrors thincoder-vscode/src/extension/generate-title.mjs but uses the CLI provider shape.
5
+ *
6
+ * CLI is OpenAI-compatible ONLY: a single direct fetch (no anthropic/google format dispatch) —
7
+ * see docs/design/SESSION.md §IK9UZ8-D.
5
8
  */
6
9
 
10
+ const MAX_TITLE_TOKENS = 100
11
+
7
12
  /** Generate a session title from the first user message using an LLM. Returns title string or null. */
8
13
  export async function generateTitle(userContent, provider) {
9
14
  // Extract text even from multimodal content (array of parts)
@@ -20,7 +25,12 @@ export async function generateTitle(userContent, provider) {
20
25
  { role: "system", content: "Generate a concise title (max 40 chars, no quotes) for this conversation. Reply ONLY with the title." },
21
26
  { role: "user", content: userText.slice(0, 200) },
22
27
  ],
23
- max_tokens: 30,
28
+ // Disable thinking so reasoning_content doesn't consume the whole output budget and
29
+ // leave content empty (IK9UZ8). Providers that don't accept the field ignore it
30
+ // (OpenAI-compatible convention). A 40-char title wants ~60–80 tokens, so 100 is
31
+ // ~2.5x headroom (design decision — docs/design/SESSION.md §IK9UZ8-D).
32
+ thinking: { type: "disabled" },
33
+ max_tokens: MAX_TITLE_TOKENS,
24
34
  stream: false,
25
35
  })
26
36
  const chatPath = provider.chatPath ?? "/chat/completions"
@@ -41,4 +51,4 @@ export async function generateTitle(userContent, provider) {
41
51
  } catch {
42
52
  return null
43
53
  }
44
- }
54
+ }
@@ -12,6 +12,7 @@ Evaluate the design against these dimensions:
12
12
  4. **Clarity** — Is the design specific enough to implement? Are the affected files identified?
13
13
  5. **Acceptance criteria** — Are they verifiable? Do they cover normal paths, edge cases, and error conditions?
14
14
  6. **Scope** — Is the scope appropriate? Are there opportunities to simplify? Is there scope creep?
15
+ 7. **Document ownership** — Does the change amend the design document that already owns its topic (per the document map in `docs/design/README.md`), or does it fragment by creating a new file for an existing section? Does the wording duplicate or contradict existing documents?
15
16
 
16
17
  ## Output Format
17
18
 
@@ -27,6 +28,14 @@ Severity levels:
27
28
  - 🟡 Advisory — design could be improved; NOT a blocker for approval
28
29
  - 🔵 Note — optional observation; NOT a blocker
29
30
 
31
+ Document ownership severity:
32
+ - Wording that CONTRADICTS an existing document (same mechanism described differently in two places) → 🔴
33
+ - Creating a new file for an existing section, or duplicating a description that already exists elsewhere → 🟡
34
+
35
+ ## Citation Discipline
36
+
37
+ When you cite design-document text, use the exact `file:line` format (e.g. `docs/design/AGENT-LOOP.md:180`) — host-side verification will check the citation against the current disk state. If you have not read/verified the cited content, mark it `unverified` instead of presenting it as fact.
38
+
30
39
  ## Approval Signal
31
40
 
32
41
  The user message contains an exact token in an `## Approval Signal` section (format `[DESIGN-TOKEN:...]`).
@@ -17,4 +17,11 @@ UI & interface design:
17
17
  - Free-text is correct ONLY when the input is genuinely open-ended (a name, a path, a message).
18
18
 
19
19
  Review discipline (standard mode only — engineering mode has its own review timing rules):
20
- - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context). Response table: `| # | Action | Detail |`. Round 2 verifies the prior issue table + flags obvious new issues; round 3+ strictly verifies only the prior issue table (no new-issue hunting). Max 5 rounds total.
20
+ - **Advisor:** call after changing code. Must provide scope: `paths` (files/dirs to review) or `documents` (context).
21
+ - **After each advisor review, reply with a response table** — exact header `| # | Action | Detail |` (the runtime extracts this header; keep it verbatim). One row per issue; `#` = the advisor's issue number (`Orig#` on rounds 2+).
22
+ - `Action` is one of exactly three values: `Fixed` (you edited the code), `Not an issue` (technical rebuttal with evidence), `Deferred` (admitted, not fixed now — with a reason).
23
+ - `Detail` = what changed and where (file:line), or your evidence/reason.
24
+ - **No "pre-existing" cop-out.** You own the whole code. "It was already broken" / "I didn't introduce it" is never a reason to skip a fix — when a defect appeared does not decide whether it should be fixed, and earlier agent turns created it. Rebut only on technical grounds, otherwise fix it.
25
+ - **Do not bury 🔴.** A 🔴 you neither fix nor rebut blocks convergence. `Deferred` fits 🟡/🔵 improvements or a 🔴 needing a user decision first — never a way to silently drop a real defect; surface any unresolved 🔴 to the user.
26
+ - Round 2 verifies the prior table + flags obvious new issues; round 3+ strictly verifies only the prior table (no new-issue hunting). Max 5 rounds total.
27
+ - When the advisor reports all clear (no 🔴 remaining), run `verify`.
@@ -122,6 +122,19 @@ cannot enumerate. When using the `question` tool:
122
122
  `/advisor` toggle state. Use `advisor`'s configured model if set; otherwise
123
123
  the main model is used automatically. The key property is independent
124
124
  context — every review runs in a fresh isolated session.
125
+ - **Advisor response table.** After each advisor review you run, reply with a
126
+ response table — exact header `| # | Action | Detail |`, one row per issue;
127
+ `#` = the advisor's issue number (`Orig#` on rounds 2+).
128
+ - `Action` is one of exactly three values: `Fixed` (you edited the code), `Not an issue` (technical rebuttal with evidence), `Deferred` (admitted, not fixed now — with a reason).
129
+ - `Detail` = what changed and where (file:line), or your evidence/reason.
130
+ - No "pre-existing" cop-out: "it was already broken" is never a reason to drop
131
+ a finding — you own the whole design/code, and when a defect appeared does
132
+ not decide whether it should be fixed. If a finding is outside the approved
133
+ design's scope, surface it or propose a design update — do not silently
134
+ ignore it.
135
+ - A 🔴 you neither fix nor surface blocks convergence. `Deferred` fits 🟡/🔵
136
+ improvements or a 🔴 needing a user decision first — never a way to silently
137
+ drop a real defect; surface any unresolved 🔴 to the user.
125
138
  - **Review timing**: do NOT call advisor unprompted or repeatedly. Reviews
126
139
  happen only when: the user explicitly asks, the system pushes back, or a
127
140
  mandatory flow node requires it (the eng-coder self-reviews before delivery —
@@ -8,10 +8,12 @@ Programming is collaborative labor between you and the human. The human decides
8
8
 
9
9
  **How you work — before you write any code:**
10
10
  - **Read design docs first.** Use `doc_search` to find relevant design docs, AGENTS.md, and architecture decisions. Code without design context is guesswork. If docs conflict with code, docs are right. If the user's instruction conflicts with the docs, tell the user first — discuss, update the docs, then code.
11
+ - **Document ownership — find the doc that owns the topic before writing.** Before writing to `docs/design/`, check the `docs/design/README.md` document map (no map → check AGENTS.md and the docs directory) to locate the document that owns the topic — if it exists, update it; never create a new file for an existing section. Create a new file only when no section owns the topic, and register it in the map. Describe each mechanism in detail in exactly ONE place (the authoritative source); other documents reference it, never copy it.
11
12
  - **Check existing code.** Search for existing functions, helpers, patterns before writing new ones. Duplicates are technical debt.
12
13
  - **Understand intent.** Ask why this change is needed — the "why" reveals scope the literal request hides.
13
14
  - **Confirm understanding.** State what you believe the user asked for and what you plan to deliver, including the most important acceptance criteria. Wait for confirmation. No task is too small — a wrong assumption always costs more than the round-trip. Once confirmed, deliver exactly what was agreed — no simplifying, no substituting, no taking shortcuts after the fact. Simplifying a confirmed requirement frustrates the user and wastes time; they will just tell you to do it right anyway.
14
- - **Confirm before any file-writing action — no exemptions.** Before ANY file-writing action (write / edit / apply_patch / insert_after / delete / hashline_edit, or any bash that writes files), restate in plain text your understanding of the task plus the key points of your plan, and WAIT for the user's explicit confirmation (an "OK / 可以 / continue"-type reply) before executing. No confirmation, silence, or the user answering with a new question or a new requirement → do not touch anything, no matter how small or obvious the change seems. Even after rounds of clarification, when you are completely sure you understand, you must still write the plan out and wait — "this is obvious enough to skip asking" is never a valid reason to skip, and a new question from the user is not a confirmation; it means the understanding has changed.
15
+ - **Confirm before any file-writing action.** Before ANY file-writing action (write / edit / apply_patch / insert_after / delete / hashline_edit, or any bash that writes files), restate in plain text your understanding of the task plus the key points of your plan, and WAIT for the user's explicit confirmation (an "OK / 可以 / continue"-type reply) before executing. For the changes you propose, there are no exemptions: no confirmation, silence, or the user answering with a new question or a new requirement → do not touch anything, no matter how small or obvious the change seems. Even after rounds of clarification, when you are completely sure you understand, you must still write the plan out and wait — "this is obvious enough to skip asking" is never a valid reason to skip, and a new question from the user is not a confirmation; it means the understanding has changed.
16
+ - **Doc/code consistency outranks this gate (the one carve-out).** The gate above governs the changes you PROPOSE for the task — a new deliverable, a change of scope or approach. It does NOT govern standing obligations you already owe: (a) updating the document that already owns the topic (per the document map) so it stays consistent with code/logic the user already confirmed; (b) recording a decision the user just made ("Discussion → docs"); (c) closing an advisor-flagged doc-code gap. These complete the SAME confirmed task — do them in the same turn, without re-asking.
15
17
  - **Re-confirm when the requirement changes.** If what was confirmed is later changed by a new requirement in the conversation, restate your understanding and plan and wait for fresh confirmation before touching files.
16
18
 
17
19
  **How you work — while coding:**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { specForModel } from "../config.mjs"
8
8
  import { proxyFetch } from "../proxy.mjs"
9
+ import { escapeMessages } from "../escape.mjs"
9
10
  import { readSSE } from "./sse.mjs"
10
11
  export { readSSE } from "./sse.mjs"
11
12
  import {
@@ -68,6 +69,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
68
69
  }
69
70
 
70
71
  messages = normalizeToolPairing(messages)
72
+ // 中和服务端的非标二次转义:会话里若出现字面 "\x"/"\u"(如讨论转义、grep 到含
73
+ // 转义的代码),Kimi 等会把它们当 hex escape 再解析 → "unexpected end of hex escape" 400。
74
+ // 发送前统一 double 掉会形成非法转义的序列(合法 \xNN/\uNNNN 不受影响)。
75
+ messages = escapeMessages(messages)
71
76
  // Compile string-pattern rules to RegExp at call time
72
77
  const rules = compileStreamRules(streamRules)
73
78
  const body = {
@@ -19,6 +19,56 @@ export function normalizeUsageCache(u) {
19
19
  }
20
20
  return u
21
21
  }
22
+ /** Defensive tool-call merge (PROVIDER.md §10): skip null/malformed elements and count them;
23
+ * merge slots by index / id / name / tail, accumulate arguments. */
24
+ function mergeToolCalls(result, delta) {
25
+ for (const tc of delta.tool_calls ?? []) {
26
+ if (!tc || typeof tc !== "object") { result.droppedToolCalls++; continue }
27
+ let slot
28
+ if (Number.isInteger(tc.index) && tc.index >= 0) {
29
+ slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
30
+ } else if (tc.id) {
31
+ slot = result.toolCalls.find((s) => s && s.id === tc.id)
32
+ if (!slot) { slot = { id: tc.id, name: "", arguments: "" }; result.toolCalls.push(slot) }
33
+ } else if (tc.function?.name) {
34
+ slot = { id: "", name: "", arguments: "" }
35
+ result.toolCalls.push(slot)
36
+ } else {
37
+ slot = result.toolCalls[result.toolCalls.length - 1]
38
+ if (!slot) { result.droppedToolCalls++; continue }
39
+ }
40
+ if (tc.id && !slot.id) slot.id = tc.id
41
+ if (tc.function?.name && !slot.name) slot.name = tc.function.name
42
+ const arg = tc.function?.arguments
43
+ if (typeof arg === "string") slot.arguments += arg
44
+ else if (arg != null) slot.arguments += JSON.stringify(arg)
45
+ }
46
+ }
47
+
48
+ /** Finalize tool calls (PROVIDER.md §10): drop nameless slots, synthesize missing ids,
49
+ * count drops, and surface a machine-line warning via the existing `_warnings` channel. */
50
+ function finalizeToolCalls(result) {
51
+ const entries = result.toolCalls.filter((tc) => tc) // drop sparse holes (rule-1 index jumps)
52
+ const kept = entries.filter((tc) => tc.name) // drop nameless slots
53
+ result.droppedToolCalls = (result.droppedToolCalls ?? 0) + (entries.length - kept.length)
54
+ result.toolCalls = kept
55
+ const used = new Set(kept.map((tc) => tc.id).filter(Boolean))
56
+ let seq = 0
57
+ for (const tc of kept) {
58
+ if (!tc.id) {
59
+ let id
60
+ do { id = `call_${seq++}` } while (used.has(id))
61
+ tc.id = id
62
+ used.add(id)
63
+ }
64
+ }
65
+ if (result.droppedToolCalls > 0) {
66
+ const existing = (result._warnings ??= [])
67
+ if (!existing.some((w) => w.name === "malformed-tool-calls")) {
68
+ existing.push({ name: "malformed-tool-calls", message: `${result.droppedToolCalls} malformed tool_calls dropped from provider response` })
69
+ }
70
+ }
71
+ }
22
72
 
23
73
  export async function readSSE(response, { onToken, onReasoning, rules, signal, firedPatterns: sharedFired }) {
24
74
  // Early intercept: non-SSE responses — either error bodies (HTTP >= 400) or
@@ -46,19 +96,15 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
46
96
  const parsed = JSON.parse(body)
47
97
  const choice = parsed.choices?.[0]
48
98
  if (choice) {
49
- const result = { content: "", reasoning: "", toolCalls: [], usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
99
+ const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: normalizeUsageCache(parsed.usage ?? null), finishReason: null }
50
100
  const delta = choice.delta ?? {}
51
101
  result.content = delta.content ?? ""
52
102
  result.reasoning = delta.reasoning_content ?? ""
53
103
  result.finishReason = choice.finish_reason ?? null
54
- for (const tc of delta.tool_calls ?? []) {
55
- const slot = (result.toolCalls[tc.index ?? result.toolCalls.length] ??= { id: "", name: "", arguments: "" })
56
- if (tc.id) slot.id = tc.id
57
- if (tc.function?.name && !slot.name) slot.name = tc.function.name
58
- if (tc.function?.arguments) slot.arguments += tc.function.arguments
59
- }
104
+ mergeToolCalls(result, delta)
60
105
  if (result.content) onToken?.(result.content)
61
106
  if (result.reasoning) onReasoning?.(result.reasoning)
107
+ finalizeToolCalls(result)
62
108
  return result
63
109
  }
64
110
  } catch { /* not parseable JSON */ }
@@ -66,7 +112,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
66
112
  throw new Error(`API error: HTTP ${response.status} — unexpected non-SSE response: ${body.slice(0, 200)}`)
67
113
  }
68
114
 
69
- const result = { content: "", reasoning: "", toolCalls: [], usage: null, finishReason: null }
115
+ const result = { content: "", reasoning: "", toolCalls: [], droppedToolCalls: 0, usage: null, finishReason: null }
70
116
  const decoder = new TextDecoder()
71
117
  let buffer = ""
72
118
  let hasChoices = false
@@ -96,12 +142,7 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
96
142
  result.content += delta.content
97
143
  onToken?.(delta.content)
98
144
  }
99
- for (const tc of delta.tool_calls ?? []) {
100
- const slot = (result.toolCalls[tc.index] ??= { id: "", name: "", arguments: "" })
101
- if (tc.id) slot.id = tc.id
102
- if (tc.function?.name && !slot.name) slot.name = tc.function.name
103
- if (tc.function?.arguments) slot.arguments += tc.function.arguments
104
- }
145
+ mergeToolCalls(result, delta)
105
146
  }
106
147
  }
107
148
 
@@ -165,5 +206,6 @@ export async function readSSE(response, { onToken, onReasoning, rules, signal, f
165
206
  throw new Error(`API error: ${errorMsg}`)
166
207
  }
167
208
 
209
+ finalizeToolCalls(result)
168
210
  return result
169
211
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * session-migrate.mjs — one-time migration of legacy short-hash session files to the
3
+ * full 40-char sha1 hash. Extracted from session.mjs (file-size split).
4
+ */
5
+ import { createHash } from "node:crypto"
6
+ import { renameSync, existsSync } from "node:fs"
7
+ import { join } from "node:path"
8
+ import { configDir } from "./config.mjs"
9
+
10
+ /** One-time migration: rename legacy short-hash session files to the full 40-char hash.
11
+ * Idempotent; runs on first access per cwd.
12
+ * Historical hash algorithms (all sha1, none normalized the drive letter):
13
+ * - CLI: sha1(cwd).slice(0, 12) — cwd comes from process.cwd() (uppercase drive on Windows)
14
+ * - VS Code: sha1(cwd).slice(0, 16) — cwd comes from uri.fsPath (LOWERCASE drive on Windows)
15
+ * Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
16
+ * Every combination is tried — a migration that only checks one candidate misses real
17
+ * legacy files (drive-letter case differs between CLI and VS Code historical paths). */
18
+ export function migrateHashLength(cwd, fullHash) {
19
+ const dir = join(configDir, "sessions")
20
+ const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
21
+ const candidates = [
22
+ createHash("sha1").update(cwd).digest("hex").slice(0, 12),
23
+ createHash("sha1").update(cwd).digest("hex").slice(0, 16),
24
+ createHash("sha1").update(lower).digest("hex").slice(0, 12),
25
+ createHash("sha1").update(lower).digest("hex").slice(0, 16),
26
+ fullHash.slice(0, 12),
27
+ ]
28
+ const newBase = join(dir, `${fullHash}.json`)
29
+ let migrated = false
30
+ for (const short of new Set(candidates)) {
31
+ const legacyBase = join(dir, `${short}.json`)
32
+ if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) continue
33
+ migrated = true
34
+ try {
35
+ for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
36
+ const from = legacyBase + suffix
37
+ if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
38
+ }
39
+ } catch { /* best-effort; leave files in place on failure */ }
40
+ }
41
+ return migrated
42
+ }
package/src/session.mjs CHANGED
@@ -13,6 +13,7 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsS
13
13
  import { join, dirname } from "node:path"
14
14
  import { execSync } from "node:child_process"
15
15
  import { configDir } from "./config.mjs"
16
+ import { migrateHashLength } from "./session-migrate.mjs"
16
17
 
17
18
  let currentSessionId = null
18
19
 
@@ -35,40 +36,6 @@ function cwdHash(cwd) {
35
36
  return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
36
37
  }
37
38
 
38
- /** One-time migration: rename legacy short-hash session files to the full 40-char hash.
39
- * Idempotent; runs on first access per cwd.
40
- * Historical hash algorithms (all sha1, none normalized the drive letter):
41
- * - CLI: sha1(cwd).slice(0, 12) — cwd comes from process.cwd() (uppercase drive on Windows)
42
- * - VS Code: sha1(cwd).slice(0, 16) — cwd comes from uri.fsPath (LOWERCASE drive on Windows)
43
- * Plus the previous migration attempt's assumption (normalized 12 = first 12 of the full hash).
44
- * Every combination is tried — a migration that only checks one candidate misses real
45
- * legacy files (drive-letter case differs between CLI and VS Code historical paths). */
46
- function migrateHashLength(cwd, fullHash) {
47
- const dir = join(configDir, "sessions")
48
- const lower = cwd.replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + ":")
49
- const candidates = [
50
- createHash("sha1").update(cwd).digest("hex").slice(0, 12),
51
- createHash("sha1").update(cwd).digest("hex").slice(0, 16),
52
- createHash("sha1").update(lower).digest("hex").slice(0, 12),
53
- createHash("sha1").update(lower).digest("hex").slice(0, 16),
54
- fullHash.slice(0, 12),
55
- ]
56
- const newBase = join(dir, `${fullHash}.json`)
57
- let migrated = false
58
- for (const short of new Set(candidates)) {
59
- const legacyBase = join(dir, `${short}.json`)
60
- if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) continue
61
- migrated = true
62
- try {
63
- for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
64
- const from = legacyBase + suffix
65
- if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
66
- }
67
- } catch { /* best-effort; leave files in place on failure */ }
68
- }
69
- return migrated
70
- }
71
-
72
39
  /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
73
40
  export function sessionPath(cwd) {
74
41
  const hash = cwdHash(cwd)
@@ -149,10 +116,6 @@ function saveManifest(cwd, m) {
149
116
  writeSessionFile(manifestPath(cwd), m)
150
117
  }
151
118
 
152
- /**
153
- * Ensure an active slot exists in the manifest, migrating legacy data if needed.
154
- * Called by activeSlot() — idempotent, safe to call repeatedly.
155
- */
156
119
  /**
157
120
  * Claim a slot for this process and set it as active. Idempotent.
158
121
  * Preference order:
@@ -1,5 +1,12 @@
1
1
  import { C } from "./ansi.mjs"
2
2
 
3
+ /** Windows clipboard-read command: force UTF-8 console output so Get-Clipboard's bytes
4
+ * are decoded by Node's default UTF-8 (not the OEM codepage / GBK) — IK9UWM. Exported
5
+ * for unit tests (TUI.md §9.2D). */
6
+ export function buildWindowsClipboardCommand() {
7
+ return ["-NoProfile", "-Command", "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard"]
8
+ }
9
+
3
10
  /** Read text from system clipboard. Returns empty string on failure. */
4
11
  export async function readClipboardText() {
5
12
  try {
@@ -7,7 +14,9 @@ export async function readClipboardText() {
7
14
  const isWin = process.platform === "win32"
8
15
  const isMac = process.platform === "darwin"
9
16
  if (isWin) {
10
- return await new Promise((resolve) => execFile("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
17
+ // Strip a leading \uFEFF PowerShell may prepend a UTF-8 BOM once OutputEncoding
18
+ // flips to UTF-8 (TUI.md §9.2D BOM defense).
19
+ return await new Promise((resolve) => execFile("powershell", buildWindowsClipboardCommand(), { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout).replace(/^\uFEFF/, ""))))
11
20
  } else if (isMac) {
12
21
  return await new Promise((resolve) => execFile("pbpaste", [], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
13
22
  } else {
@@ -18,6 +27,49 @@ export async function readClipboardText() {
18
27
  }
19
28
  }
20
29
 
30
+ /** Write text to the system clipboard. Returns true on success, false on failure. */
31
+ export async function writeClipboardText(text) {
32
+ if (typeof text !== "string" || text.length === 0) return false
33
+ try {
34
+ const { spawn } = await import("node:child_process")
35
+ const isWin = process.platform === "win32"
36
+ const isMac = process.platform === "darwin"
37
+
38
+ if (isWin) {
39
+ // -EncodedCommand is base64 UTF-16LE: PowerShell decodes the command (embedded text
40
+ // included) directly from UTF-16, so no console codepage (e.g. GBK) can garble
41
+ // non-ASCII characters — the same class of bug as the read path (IK9UWM).
42
+ const psCmd = `Set-Clipboard -Value '${text.replace(/'/g, "''")}'`
43
+ const encoded = Buffer.from(psCmd, "utf16le").toString("base64")
44
+ await spawnWait(spawn, "powershell", ["-NoProfile", "-EncodedCommand", encoded], null)
45
+ return true
46
+ }
47
+ if (isMac) {
48
+ await spawnWait(spawn, "pbcopy", [], text)
49
+ return true
50
+ }
51
+ // Linux: prefer wl-copy (Wayland), fall back to xclip (X11).
52
+ await spawnWait(spawn, "sh", ["-c", "command -v wl-copy >/dev/null 2>&1 && wl-copy || xclip -selection clipboard"], text)
53
+ return true
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ /** Spawn a process and wait for clean exit. When stdinText is provided it is piped to
60
+ * the child as UTF-8 (used by pbcopy / xclip / wl-copy); otherwise stdio is ignored. */
61
+ function spawnWait(spawn, cmd, args, stdinText) {
62
+ return new Promise((resolve, reject) => {
63
+ const child = spawn(cmd, args, { stdio: stdinText == null ? "ignore" : ["pipe", "ignore", "ignore"] })
64
+ child.once("error", reject)
65
+ child.once("close", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`))))
66
+ if (stdinText != null) {
67
+ child.stdin.on("error", () => {}) // swallow EPIPE when the tool exits without draining
68
+ child.stdin.end(stdinText, "utf8")
69
+ }
70
+ })
71
+ }
72
+
21
73
  /** Insert pasted text into the active text target.
22
74
  * Free-text question active → append to its answer (single-line field: newlines stripped).
23
75
  * Options question active → ignore (no text field; must not leak into the input box).
@@ -29,7 +29,7 @@ export async function handleAdvisorCommand(ctx) {
29
29
  : cfg.thinking?.type === "disabled" ? "off"
30
30
  : cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
31
31
  : cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
32
- return `Advisor: always available | Model: ${curModel} | Think: ${thinkInfo}`
32
+ return `Advisor | Model: ${curModel} | Think: ${thinkInfo}`
33
33
  }
34
34
 
35
35
  function headerLine() {
@@ -115,7 +115,7 @@ export async function handleAdvisorCommand(ctx) {
115
115
  { type: "header", text: headerLine() },
116
116
  { type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
117
117
  { type: "item", text: `Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, action: "thinking" },
118
- { type: "item", text: `Guard: ${guardInfo}`, action: "guard" },
118
+ { type: "item", text: `Advisor: ${guardInfo}`, action: "guard" },
119
119
  { type: "item", text: "View full config", action: "view" },
120
120
  ]
121
121
 
@@ -125,9 +125,8 @@ export async function handleAdvisorCommand(ctx) {
125
125
 
126
126
  if (choice.action === "view") {
127
127
  pushLabel("❯ Advisor", ansi.bold + C.tool)
128
- pushLine(`Status: always available`, C.dim)
129
128
  pushLine(`Model: ${curModel} (provider: ${curProvider})`, C.dim)
130
- pushLine(`Guard: ${guardInfo}`, C.dim)
129
+ pushLine(`Advisor: ${guardInfo}`, C.dim)
131
130
  pushLine(`Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, C.dim)
132
131
  continue
133
132
  }
@@ -136,7 +135,7 @@ export async function handleAdvisorCommand(ctx) {
136
135
  cfg.guard = !(cfg.guard === true)
137
136
  await persist().catch(err => pushLine(`[error] ${err.message}`, C.error))
138
137
  pushLabel("❯ Advisor", ansi.bold + C.tool)
139
- pushLine(`Guard: ${cfg.guard === true ? "on" : "off"}`, C.tool)
138
+ pushLine(`Advisor: ${cfg.guard === true ? "on" : "off"}`, C.tool)
140
139
  continue
141
140
  }
142
141
 
@@ -1,10 +1,22 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
+ /** Merge an embedding-key save into the raw config, backfilling baseURL/model from defaults.
4
+ * Keeps existing custom values (Ollama/local embedding); defaults are the single source
5
+ * (TUI.md §9.3D — NF1). Exported for unit tests. */
6
+ export function embeddingPatch(raw, embKey, defaults) {
7
+ const prev = raw?.embedding ?? {}
8
+ return {
9
+ ...prev,
10
+ apiKey: embKey,
11
+ baseURL: prev.baseURL ?? defaults.baseURL,
12
+ model: prev.model ?? defaults.model,
13
+ }
14
+ }
3
15
 
4
16
  /** /config command: view and set agent/embedding/proxy config. */
5
17
  export async function handleConfigCommand(ctx, args = []) {
6
18
  const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, maskKey, pickModelForSlot } = ctx
7
- const { configPath } = await import("../config.mjs")
19
+ const { configPath, DEFAULTS } = await import("../config.mjs")
8
20
  const ac = agent.config?.agent ?? {}
9
21
  const ec = agent.config?.embedding ?? {}
10
22
 
@@ -20,7 +32,7 @@ export async function handleConfigCommand(ctx, args = []) {
20
32
  if (!embKey) return false
21
33
  agent.config.embedding ??= {}
22
34
  agent.config.embedding.apiKey = embKey
23
- await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: embKey } })
35
+ await persistRaw((raw) => { raw.embedding = embeddingPatch(raw, embKey, DEFAULTS.embedding) })
24
36
  if (agent.memory) {
25
37
  const { createEmbedder } = await import("../embedding.mjs")
26
38
  agent.memory.embedder = createEmbedder(agent.config.embedding)
@@ -0,0 +1,29 @@
1
+ /**
2
+ * cmd-copy.mjs — /copy command: copy the last assistant response to the clipboard.
3
+ * Copies the RAW markdown reply (agent.history content), not the ANSI-styled display.
4
+ */
5
+ import { C } from "./ansi.mjs"
6
+ import { writeClipboardText } from "./clipboard.mjs"
7
+
8
+ /** Most recent assistant reply with non-empty text content (skips tool-calls-only / transient). */
9
+ export function lastAssistantContent(history) {
10
+ if (!Array.isArray(history)) return null
11
+ for (let i = history.length - 1; i >= 0; i--) {
12
+ const m = history[i]
13
+ if (m?.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0) {
14
+ return m.content
15
+ }
16
+ }
17
+ return null
18
+ }
19
+
20
+ export async function handleCopyCommand(ctx) {
21
+ const { agent, pushLine } = ctx
22
+ const text = lastAssistantContent(agent?.history)
23
+ if (!text) {
24
+ pushLine("No assistant response to copy yet", C.warn)
25
+ return
26
+ }
27
+ const ok = await writeClipboardText(text)
28
+ pushLine(ok ? `Copied last response (${text.length} chars) to clipboard` : "Clipboard write failed", ok ? C.tool : C.error)
29
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * math.mjs — LaTeX → Unicode approximation for the TUI display layer (IK9IXD).
3
+ *
4
+ * Zero dependencies, pure functions, no ANSI output. A table-driven subset
5
+ * converter turns closed `$...$` (inline) and `$$...$$` (block) formula spans
6
+ * into readable Unicode (x̂, σᵢ, ∑, (a)/(b), …). Unknown LaTeX commands stay
7
+ * as-is — display-only, semantics untouched. Streaming safety: unclosed
8
+ * `$`/`$$` spans are left untouched (markdown.mjs unclosed-marker parity).
9
+ *
10
+ * Pipeline contract (render-conversation.mjs): math runs BEFORE markdown and
11
+ * BEFORE wrapping — math treats `$...$`/`$$...$$` as opaque (no markdown
12
+ * inside), backtick code spans are opaque to math (code is literal).
13
+ */
14
+
15
+ // ─── token tables (design: TUI.md §9.1D) ────────────────────────────────
16
+
17
+ /** Command → literal text (operators, functions, spacing). */
18
+ const DIRECT = new Map([
19
+ // functions keep their name as text
20
+ ["min", "min"], ["max", "max"], ["log", "log"], ["ln", "ln"], ["exp", "exp"], ["lim", "lim"],
21
+ // operators
22
+ ["sum", "∑"], ["prod", "∏"], ["int", "∫"],
23
+ ["pm", "±"], ["mp", "∓"], ["times", "×"], ["cdot", "·"], ["div", "÷"],
24
+ ["le", "≤"], ["ge", "≥"], ["ne", "≠"], ["approx", "≈"], ["equiv", "≡"],
25
+ ["propto", "∝"], ["in", "∈"], ["notin", "∉"], ["infty", "∞"],
26
+ ["to", "→"], ["rightarrow", "→"], ["partial", "∂"], ["nabla", "∇"],
27
+ ["forall", "∀"], ["exists", "∃"], ["cdots", "⋯"], ["ldots", "…"],
28
+ // spacing
29
+ ["quad", " "], ["qquad", " "], [",", " "], [";", " "],
30
+ ])
31
+
32
+ /** Greek letters, lower + upper. */
33
+ const GREEK = new Map([
34
+ ["alpha", "α"], ["beta", "β"], ["gamma", "γ"], ["delta", "δ"], ["epsilon", "ε"],
35
+ ["zeta", "ζ"], ["eta", "η"], ["theta", "θ"], ["iota", "ι"], ["kappa", "κ"],
36
+ ["lambda", "λ"], ["mu", "μ"], ["nu", "ν"], ["xi", "ξ"], ["omicron", "ο"],
37
+ ["pi", "π"], ["rho", "ρ"], ["sigma", "σ"], ["tau", "τ"], ["upsilon", "υ"],
38
+ ["phi", "φ"], ["chi", "χ"], ["psi", "ψ"], ["omega", "ω"],
39
+ ["Alpha", "Α"], ["Beta", "Β"], ["Gamma", "Γ"], ["Delta", "Δ"], ["Epsilon", "Ε"],
40
+ ["Zeta", "Ζ"], ["Eta", "Η"], ["Theta", "Θ"], ["Iota", "Ι"], ["Kappa", "Κ"],
41
+ ["Lambda", "Λ"], ["Mu", "Μ"], ["Nu", "Ν"], ["Xi", "Ξ"], ["Omicron", "Ο"],
42
+ ["Pi", "Π"], ["Rho", "Ρ"], ["Sigma", "Σ"], ["Tau", "Τ"], ["Upsilon", "Υ"],
43
+ ["Phi", "Φ"], ["Chi", "Χ"], ["Psi", "Ψ"], ["Omega", "Ω"],
44
+ ])
45
+
46
+ /** One-arg accent commands: argument + combining mark. */
47
+ const ACCENTS = new Map([
48
+ ["hat", "\u0302"], // U+0302 combining circumflex
49
+ ["bar", "\u0304"], // U+0304 combining macron
50
+ ["vec", "\u20d7"], // U+20D7 combining right arrow above
51
+ ])
52
+
53
+ /** Single-char subscript Unicode mapping (common subset). */
54
+ const SUBSCRIPT = new Map([
55
+ ["0", "₀"], ["1", "₁"], ["2", "₂"], ["3", "₃"], ["4", "₄"],
56
+ ["5", "₅"], ["6", "₆"], ["7", "₇"], ["8", "₈"], ["9", "₉"],
57
+ ["a", "ₐ"], ["e", "ₑ"], ["h", "ₕ"], ["i", "ᵢ"], ["j", "ⱼ"], ["k", "ₖ"],
58
+ ["l", "ₗ"], ["m", "ₘ"], ["n", "ₙ"], ["o", "ₒ"], ["p", "ₚ"], ["r", "ᵣ"],
59
+ ["s", "ₛ"], ["t", "ₜ"], ["u", "ᵤ"], ["v", "ᵥ"], ["x", "ₓ"],
60
+ ])
61
+
62
+ /** Single-char superscript Unicode mapping (common subset). */
63
+ const SUPERSCRIPT = new Map([
64
+ ["0", "⁰"], ["1", "¹"], ["2", "²"], ["3", "³"], ["4", "⁴"],
65
+ ["5", "⁵"], ["6", "⁶"], ["7", "⁷"], ["8", "⁸"], ["9", "⁹"],
66
+ ["+", "⁺"], ["-", "⁻"], ["=", "⁼"], ["(", "⁽"], [")", "⁾"],
67
+ ["a", "ᵃ"], ["b", "ᵇ"], ["c", "ᶜ"], ["d", "ᵈ"], ["e", "ᵉ"], ["f", "ᶠ"],
68
+ ["g", "ᵍ"], ["h", "ʰ"], ["i", "ⁱ"], ["j", "ʲ"], ["k", "ᵏ"], ["l", "ˡ"],
69
+ ["m", "ᵐ"], ["n", "ⁿ"], ["o", "ᵒ"], ["p", "ᵖ"], ["r", "ʳ"], ["s", "ˢ"],
70
+ ["t", "ᵗ"], ["u", "ᵘ"], ["v", "ᵛ"], ["w", "ʷ"], ["x", "ˣ"], ["y", "ʸ"], ["z", "ᶻ"],
71
+ ])
72
+
73
+ const CMD_RE = /^\\[a-zA-Z]+/
74
+
75
+ /** Commands that produce explicit whitespace (LaTeX math mode: ordinary spaces
76
+ * around them collapse into the explicit spacing — T5 `\quad` → exactly 2 spaces). */
77
+ const SPACE_PRODUCING = new Set(["quad", "qquad", ",", ";", " "])
78
+
79
+ /** Non-letter commands after the backslash (control space `\ `, thin spaces `\,`/`\;`). */
80
+ const CHAR_COMMANDS = new Map([
81
+ [" ", " "], // LaTeX control space
82
+ [",", " "], [";", " "],
83
+ ])
84
+
85
+ /** Index after the matching `}` for src[i] === "{" (nested-aware); null when unclosed. */
86
+ function matchBraced(src, i) {
87
+ let depth = 0
88
+ for (let j = i; j < src.length; j++) {
89
+ if (src[j] === "{") depth++
90
+ else if (src[j] === "}") {
91
+ depth--
92
+ if (depth === 0) return j + 1
93
+ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ /** One LaTeX argument: a `{...}` group or a single char. Returns { text, next } or null. */
99
+ function parseArg(src, i) {
100
+ if (i >= src.length) return null
101
+ if (src[i] === "{") {
102
+ const end = matchBraced(src, i)
103
+ if (end == null) return null
104
+ return { text: src.slice(i + 1, end - 1), next: end }
105
+ }
106
+ return { text: src[i], next: i + 1 }
107
+ }
108
+
109
+ /**
110
+ * Convert one LaTeX command starting at src[i] === "\\".
111
+ * Returns { text, next } when handled; null → caller copies the backslash verbatim
112
+ * (unknown commands stay as-is, backslash + name + braced args included).
113
+ */
114
+ function convertCommand(src, i) {
115
+ const m = CMD_RE.exec(src.slice(i))
116
+ if (!m) {
117
+ // Non-letter command: `\,` / `\;` / control space `\ ` → single space.
118
+ const ch = src[i + 1]
119
+ if (ch !== undefined && CHAR_COMMANDS.has(ch)) return { text: CHAR_COMMANDS.get(ch), next: i + 2, space: true }
120
+ return null
121
+ }
122
+ const cmd = m[0].slice(1)
123
+ const next = i + 1 + cmd.length
124
+
125
+ if (DIRECT.has(cmd)) return { text: DIRECT.get(cmd), next, space: SPACE_PRODUCING.has(cmd) }
126
+ if (GREEK.has(cmd)) return { text: GREEK.get(cmd), next }
127
+ if (cmd === "frac") {
128
+ const a1 = parseArg(src, next)
129
+ if (!a1) return null
130
+ const a2 = parseArg(src, a1.next)
131
+ if (!a2) return null
132
+ return { text: `(${convertFormula(a1.text)})/(${convertFormula(a2.text)})`, next: a2.next }
133
+ }
134
+ if (cmd === "sqrt") {
135
+ const a = parseArg(src, next)
136
+ if (!a) return null
137
+ return { text: `√(${convertFormula(a.text)})`, next: a.next }
138
+ }
139
+ if (cmd === "text") {
140
+ const a = parseArg(src, next)
141
+ if (!a) return null
142
+ return { text: a.text, next: a.next } // content verbatim
143
+ }
144
+ if (ACCENTS.has(cmd)) {
145
+ const a = parseArg(src, next)
146
+ if (!a) return null
147
+ return { text: convertFormula(a.text) + ACCENTS.get(cmd), next: a.next }
148
+ }
149
+ if (cmd === "left" || cmd === "right") {
150
+ const nch = src[next]
151
+ if (nch === "(" || nch === ")") return { text: nch, next: next + 1 }
152
+ }
153
+ // Unknown command: keep verbatim — name + any braced args (design: unknown stays
154
+ // as-is including backslash and arguments).
155
+ let end = next
156
+ let text = "\\" + cmd
157
+ while (end < src.length && src[end] === "{") {
158
+ const close = matchBraced(src, end)
159
+ if (close == null) break
160
+ text += src.slice(end, close)
161
+ end = close
162
+ }
163
+ return { text, next: end }
164
+ }
165
+
166
+ /** Sub/superscript at src[i] ("_" or "^"). Returns { text, next } or null (keep char as-is). */
167
+ function convertScript(src, i) {
168
+ const ch = src[i]
169
+ const isSup = ch === "^"
170
+ const table = isSup ? SUPERSCRIPT : SUBSCRIPT
171
+ const wrap = ch
172
+ if (src[i + 1] === "{") {
173
+ const end = matchBraced(src, i + 1)
174
+ if (end != null) return { text: `${wrap}(${convertFormula(src.slice(i + 2, end - 1))})`, next: end }
175
+ return null
176
+ }
177
+ if (i + 1 < src.length) {
178
+ const nch = src[i + 1]
179
+ const mapped = table.get(nch)
180
+ // Single char with a Unicode script char → direct; without → keep paren form.
181
+ return mapped !== undefined ? { text: mapped, next: i + 2 } : { text: `${wrap}(${nch})`, next: i + 2 }
182
+ }
183
+ return null
184
+ }
185
+
186
+ /** Table-driven subset converter for one closed formula body (exported for unit tests). */
187
+ export function convertFormula(src) {
188
+ let out = ""
189
+ let i = 0
190
+ while (i < src.length) {
191
+ const ch = src[i]
192
+ if (ch === "\\") {
193
+ const handled = convertCommand(src, i)
194
+ if (handled) {
195
+ if (handled.space) {
196
+ // Explicit LaTeX spacing: ordinary spaces AROUND it collapse into it
197
+ // (math-mode semantics — `\quad` alone contributes exactly its width).
198
+ out = out.replace(/\s+$/, "")
199
+ out += handled.text
200
+ let j = handled.next
201
+ while (j < src.length && /\s/.test(src[j])) j++
202
+ i = j
203
+ continue
204
+ }
205
+ out += handled.text
206
+ i = handled.next
207
+ continue
208
+ }
209
+ out += "\\" // unknown escape — copy verbatim, following chars flow through
210
+ i++
211
+ continue
212
+ }
213
+ if (ch === "_" || ch === "^") {
214
+ const handled = convertScript(src, i)
215
+ if (handled) {
216
+ out += handled.text
217
+ i = handled.next
218
+ continue
219
+ }
220
+ out += ch
221
+ i++
222
+ continue
223
+ }
224
+ out += ch
225
+ i++
226
+ }
227
+ return out
228
+ }
229
+
230
+ // ─── span scanning ──────────────────────────────────────────────────────
231
+
232
+ /** Convert closed `$...$` spans in one line (no cross-line pairing). */
233
+ function convertInlineSegments(seg) {
234
+ let out = ""
235
+ let i = 0
236
+ while (i < seg.length) {
237
+ if (seg[i] !== "$") {
238
+ out += seg[i]
239
+ i++
240
+ continue
241
+ }
242
+ if (seg[i + 1] === "$") {
243
+ out += "$$" // leftover (unmatched) block markers — never inline delimiters
244
+ i += 2
245
+ continue
246
+ }
247
+ // Find a closing single $ (a $ adjacent to another $ is part of a $$ pair — skip).
248
+ let close = -1
249
+ for (let j = i + 1; j < seg.length; j++) {
250
+ if (seg[j] !== "$") continue
251
+ if (seg[j + 1] === "$" || seg[j - 1] === "$") continue
252
+ close = j
253
+ break
254
+ }
255
+ if (close === -1) {
256
+ out += seg.slice(i) // unclosed — keep verbatim (streaming safety)
257
+ break
258
+ }
259
+ out += convertFormula(seg.slice(i + 1, close))
260
+ i = close + 1
261
+ }
262
+ return out
263
+ }
264
+
265
+ /** Convert closed `$$...$$` spans (cross-line OK). `\\` inside a span keeps line semantics. */
266
+ function convertBlockSegments(seg) {
267
+ let out = ""
268
+ let i = 0
269
+ while (i < seg.length) {
270
+ if (seg[i] !== "$" || seg[i + 1] !== "$") {
271
+ out += seg[i]
272
+ i++
273
+ continue
274
+ }
275
+ const close = seg.indexOf("$$", i + 2)
276
+ if (close === -1) {
277
+ out += seg.slice(i) // unclosed — keep verbatim (streaming safety)
278
+ break
279
+ }
280
+ const inner = seg.slice(i + 2, close)
281
+ // `\\` → per-line approximation (multi-line preserved; single-line otherwise).
282
+ out += inner.split("\\\\").map(convertFormula).join("\n")
283
+ i = close + 2
284
+ }
285
+ return out
286
+ }
287
+
288
+ /** Backtick split helper (markdown.mjs code-span parity): odd segments are code — opaque. */
289
+ function convertEvenSegments(text, fn) {
290
+ const parts = text.split("`")
291
+ for (let p = 0; p < parts.length; p += 2) parts[p] = fn(parts[p])
292
+ return parts.join("`")
293
+ }
294
+
295
+ /**
296
+ * Convert closed block-level `$$...$$` spans (cross-line) to Unicode approximation.
297
+ * Backtick code spans are opaque (code is literal). Unclosed `$$` stays as-is.
298
+ */
299
+ export function renderMathBlock(text) {
300
+ return convertEvenSegments(text, convertBlockSegments)
301
+ }
302
+
303
+ /**
304
+ * Convert closed inline `$...$` spans (single line each) to Unicode approximation.
305
+ * Backtick code spans are opaque. Unclosed `$` stays as-is.
306
+ */
307
+ export function renderMathInline(text) {
308
+ return convertEvenSegments(text, (seg) => seg.split("\n").map(convertInlineSegments).join("\n"))
309
+ }
@@ -5,6 +5,7 @@
5
5
  import { ansi, C } from "./ansi.mjs"
6
6
  import { formatTables, sanitizeDisplay, stringWidth, wrapText } from "./render.mjs"
7
7
  import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
8
+ import { renderMathInline, renderMathBlock } from "./math.mjs"
8
9
 
9
10
  let _convCache = { key: "", cols: 0, lines: [] }
10
11
 
@@ -28,6 +29,13 @@ function renderMarkdownPreservingWidth(text) {
28
29
  return diff > 0 ? rendered + " ".repeat(diff) : rendered
29
30
  }).join("\n")
30
31
  }
32
+
33
+ // Math runs BEFORE markdown (TUI.md §9.1D): `$...$`/`$$...$$` are opaque to markdown
34
+ // (so `x**2` inside a formula isn't misread as bold), and the Unicode approximation
35
+ // is measured by renderMarkdownPreservingWidth's width-compensation math.
36
+ function renderMathAndMarkdown(text) {
37
+ return renderMarkdownPreservingWidth(renderMathInline(renderMathBlock(text)))
38
+ }
31
39
  // Test seam (mirrors the _-prefixed seams in run.mjs).
32
40
  export { renderMarkdownPreservingWidth as _renderMarkdownPreservingWidth }
33
41
 
@@ -124,7 +132,7 @@ function buildConvLines(state, cols) {
124
132
  // ANSI-aware). Rendering after wrapping measured raw markdown
125
133
  // (`**bold**` = 8) against displayed text (4) and sliced markers
126
134
  // mid-sequence — the table misalignment the user kept reporting.
127
- const renderedText = renderMarkdownPreservingWidth(sanitizeDisplay(text))
135
+ const renderedText = renderMathAndMarkdown(sanitizeDisplay(text))
128
136
  for (const line of formatTables(renderedText, cols - 1)) {
129
137
  for (const wrapped of wrapText(line, cols - 1)) {
130
138
  block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
@@ -183,7 +191,7 @@ function buildConvLines(state, cols) {
183
191
  // mid-sequence (`**bo` + `ld**`) so the renderer never saw complete ones.
184
192
  const rows = block.kind === "think"
185
193
  ? source.split("\n")
186
- : formatTables(block.kind === "text" ? renderMarkdownPreservingWidth(source) : source, cols - 3)
194
+ : formatTables(block.kind === "text" ? renderMathAndMarkdown(source) : source, cols - 3)
187
195
  for (const line of rows) {
188
196
  for (const wrapped of wrapText(line, cols - 3)) {
189
197
  convLines.push({ text: `│ ${wrapped}`, color })
@@ -193,7 +201,7 @@ function buildConvLines(state, cols) {
193
201
  }
194
202
  if (state.streaming) {
195
203
  // Rendered BEFORE formatTables — see the advisor-block comment above.
196
- const rendered = renderMarkdownPreservingWidth(sanitizeDisplay(state.streaming))
204
+ const rendered = renderMathAndMarkdown(sanitizeDisplay(state.streaming))
197
205
  for (const line of formatTables(rendered, cols - 1)) {
198
206
  for (const wrapped of wrapText(line, cols - 1)) {
199
207
  convLines.push({ text: wrapped, color: C.text })
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
- import { sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay } from "./render.mjs"
11
+ import { sliceByWidth, stringWidth, sanitizeDisplay } from "./render.mjs"
12
12
  import { specForModel } from "../config.mjs"
13
13
  import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
14
14
  import { basename } from "node:path"
@@ -268,9 +268,9 @@ export function renderStatus(state, agent, cols, slashCommands) {
268
268
  const statusLine = buildStatusLine(state, agent, { cols, slashCommands })
269
269
  const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
270
270
  const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
271
- const advisorBanner = agent.config?.advisor?.guard === true ? `${C.advisor} GUARD${ansi.reset}${ansi.dim}│` : ""
271
+ const advisorBanner = agent.config?.advisor?.guard === true ? `${C.advisor} ADVISOR${ansi.reset}${ansi.dim}│` : ""
272
272
  const engBanner = agent.config?.agent?.engineering ? `${C.advisor} ENG${ansi.reset}${ansi.dim}│` : ""
273
- const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "") + (agent.config?.advisor?.guard === true ? " GUARD│ " : "") + (agent.config?.agent?.engineering ? " ENG│ " : "")
273
+ const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "") + (agent.config?.advisor?.guard === true ? " ADVISOR│ " : "") + (agent.config?.agent?.engineering ? " ENG│ " : "")
274
274
  const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
275
275
  return `${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${engBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}`
276
276
  }
@@ -28,6 +28,7 @@ import { handleModelCommand } from "./cmd-model.mjs"
28
28
  import { handleSubmodelCommand } from "./cmd-submodel.mjs"
29
29
  import { handleShellCommand } from "./cmd-shell.mjs"
30
30
  import { handleConfigCommand } from "./cmd-config.mjs"
31
+ import { handleCopyCommand } from "./cmd-copy.mjs"
31
32
  import { handleExtractCommand } from "./cmd-extract.mjs"
32
33
  import { handleHelpCommand } from "./cmd-help.mjs"
33
34
  import { handleUpgradeCommand } from "./cmd-upgrade.mjs"
@@ -39,7 +40,7 @@ export const SLASH_COMMANDS = [
39
40
  { name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
40
41
  { name: "/auto", group: "Agent", desc: "toggle auto-approve" },
41
42
  { name: "/eng", group: "Agent", desc: "toggle engineering mode — strict methodology enforcement" },
42
- { name: "/advisor", group: "Agent", desc: "advisor settings (toggle, model, thinking, guard)" },
43
+ { name: "/advisor", group: "Agent", desc: "advisor settings (model, thinking, review gate)" },
43
44
  { name: "/model", group: "Agent", desc: "select model & manage providers" },
44
45
  { name: "/submodel", group: "Agent", desc: "subagent model per type (explore/plan/coder/eng-coder)" },
45
46
  { name: "/shell", group: "System", desc: "bash tool shell (git-bash/pwsh path; win11 cmd encoding fix)" },
@@ -51,6 +52,7 @@ export const SLASH_COMMANDS = [
51
52
  { name: "/session", group: "Session", desc: "list/switch archived sessions" },
52
53
  { name: "/rename", group: "Session", desc: "rename the active session" },
53
54
  { name: "/clear", group: "Session", desc: "clear screen" },
55
+ { name: "/copy", group: "Session", desc: "copy last assistant response to clipboard" },
54
56
  { name: "/fold", group: "Session", desc: "toggle result folding on/off" },
55
57
  { name: "/undo", group: "Session", desc: "undo recent file modifications" },
56
58
  { name: "/init", group: "Project", desc: "generate project AGENTS.md skeleton" },
@@ -87,6 +89,7 @@ export const HANDLERS = {
87
89
  "/submodel": handleSubmodelCommand,
88
90
  "/shell": handleShellCommand,
89
91
  "/config": handleConfigCommand,
92
+ "/copy": handleCopyCommand,
90
93
  "/upgrade": handleUpgradeCommand,
91
94
  "/fold": handleFoldCommand,
92
95
  "/undo": handleUndoCommand,