thincoder 0.12.36 → 0.12.38

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,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,