thincoder 0.10.0 → 0.11.1

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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +360 -72
  4. package/src/agent/helpers.mjs +7 -3
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +73 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +7 -0
  15. package/src/config.mjs +47 -20
  16. package/src/prompts/advisor-round1.md +23 -0
  17. package/src/prompts/advisor-round2.md +26 -0
  18. package/src/prompts/advisor-round3.md +24 -0
  19. package/src/prompts/coder.md +6 -2
  20. package/src/prompts/discipline.md +23 -6
  21. package/src/prompts/explore.md +2 -0
  22. package/src/prompts/plan.md +2 -0
  23. package/src/prompts/system.md +13 -6
  24. package/src/provider/anthropic.mjs +190 -0
  25. package/src/provider/core.mjs +42 -130
  26. package/src/provider/google.mjs +199 -0
  27. package/src/provider/sse.mjs +112 -0
  28. package/src/proxy.mjs +236 -0
  29. package/src/tools/bash.md +8 -0
  30. package/src/tools/codemode.mjs +5 -16
  31. package/src/tools/edit.md +8 -0
  32. package/src/tools/fetch.md +2 -1
  33. package/src/tools/git.mjs +125 -156
  34. package/src/tools/index.mjs +9 -9
  35. package/src/tools/linter.mjs +46 -32
  36. package/src/tools/read.md +7 -0
  37. package/src/tools/shared.mjs +16 -0
  38. package/src/tools/system.mjs +14 -10
  39. package/src/tools/web.mjs +115 -89
  40. package/src/tools/websearch.md +5 -3
  41. package/src/tui/agent-turn.mjs +86 -75
  42. package/src/tui/cmd-advisor.mjs +138 -49
  43. package/src/tui/cmd-clear.mjs +11 -17
  44. package/src/tui/cmd-config.mjs +226 -142
  45. package/src/tui/cmd-extract.mjs +1 -1
  46. package/src/tui/cmd-fold.mjs +2 -3
  47. package/src/tui/cmd-goal.mjs +58 -27
  48. package/src/tui/cmd-help.mjs +3 -1
  49. package/src/tui/cmd-mcp.mjs +178 -142
  50. package/src/tui/cmd-model.mjs +15 -4
  51. package/src/tui/cmd-new.mjs +7 -13
  52. package/src/tui/cmd-restore.mjs +12 -16
  53. package/src/tui/cmd-session.mjs +28 -32
  54. package/src/tui/cmd-think.mjs +75 -50
  55. package/src/tui/cmd-undo.mjs +19 -23
  56. package/src/tui/cmd-upgrade.mjs +22 -26
  57. package/src/tui/index.mjs +61 -215
  58. package/src/tui/key-handler.mjs +56 -20
  59. package/src/tui/layout.mjs +13 -3
  60. package/src/tui/pickers.mjs +151 -182
  61. package/src/tui/render-conversation.mjs +92 -0
  62. package/src/tui/render-frame.mjs +56 -114
  63. package/src/tui/render-loop.mjs +181 -0
  64. package/src/tui/slash-commands.mjs +26 -16
@@ -0,0 +1,92 @@
1
+ /**
2
+ * render-conversation.mjs — conversation panel line builder
3
+ * Extracted from render-frame.mjs.
4
+ */
5
+ import { ansi, C } from "./ansi.mjs"
6
+ import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
7
+
8
+ let _convCache = { key: "", cols: 0, lines: [] }
9
+
10
+ export function convCacheKey(state) {
11
+ const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
12
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
13
+ }
14
+
15
+ function buildConvLines(state, cols) {
16
+ const key = convCacheKey(state)
17
+ if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
18
+
19
+ const convLines = []
20
+ for (const l of state.lines) {
21
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
22
+ for (const wrapped of wrapText(line, cols - 1)) {
23
+ convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
24
+ }
25
+ }
26
+ }
27
+ if (state.reasoning) {
28
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
29
+ convLines.push({ text: wrapped, color: C.reason })
30
+ }
31
+ }
32
+ if (state.streaming) {
33
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
34
+ for (const wrapped of wrapText(line, cols - 1)) {
35
+ convLines.push({ text: wrapped, color: C.text })
36
+ }
37
+ }
38
+ }
39
+ const allStreams = Object.values(state.toolStreams).join("")
40
+ if (allStreams) {
41
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
42
+ for (const wrapped of wrapText(tail, cols - 1)) {
43
+ convLines.push({ text: wrapped, color: C.dim })
44
+ }
45
+ }
46
+
47
+ // Fold long blocks (> 8 consecutive dim lines)
48
+ const FOLD_LINES = 8
49
+ let foldCounter = 0
50
+ const folded = []
51
+ let i = 0
52
+ while (i < convLines.length) {
53
+ const line = convLines[i]
54
+ if (line.color === C.dim) {
55
+ let j = i
56
+ while (j < convLines.length && convLines[j].color === C.dim) j++
57
+ const blockLen = j - i
58
+ if (blockLen > FOLD_LINES) {
59
+ const foldKey = `fold-${foldCounter++}`
60
+ if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
61
+ folded.push(convLines[i])
62
+ if (blockLen > 2) folded.push(convLines[i + 1])
63
+ folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
64
+ i = j
65
+ continue
66
+ }
67
+ }
68
+ }
69
+ folded.push(line)
70
+ i++
71
+ }
72
+
73
+ _convCache = { key, cols, lines: folded }
74
+ return folded
75
+ }
76
+
77
+ export function countConvLines(state, cols) {
78
+ return buildConvLines(state, cols).length
79
+ }
80
+
81
+ export function renderConversation(state, cols, visibleH, scroll) {
82
+ const convLines = buildConvLines(state, cols)
83
+ const maxScroll = Math.max(0, convLines.length - visibleH)
84
+ const clamped = Math.min(scroll, maxScroll)
85
+ const end = convLines.length - clamped
86
+ const visible = convLines.slice(Math.max(0, end - visibleH), end)
87
+ const pad = visibleH - visible.length
88
+ const out = []
89
+ for (let p = 0; p < pad; p++) out.push("")
90
+ for (const l of visible) out.push(`${l.color ?? ""}${l.text}${ansi.reset}`)
91
+ return out
92
+ }
@@ -7,13 +7,14 @@
7
7
  * The legacy renderFrame() wrapper is kept for compatibility.
8
8
  */
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
- import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
11
- import {
12
- sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay,
13
- } from "./render.mjs"
10
+ import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
+ import { sliceByWidth, stringWidth, wrapText, formatTables, sanitizeDisplay } from "./render.mjs"
14
12
  import { specForModel } from "../config.mjs"
13
+ import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
15
14
  import { basename } from "node:path"
16
15
 
16
+ export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
17
+
17
18
  // ---------- status bar slash-command hints ----------
18
19
  const SLASH_HINTS = {
19
20
  "/config": "open config menu",
@@ -35,7 +36,7 @@ const SLASH_HINTS = {
35
36
  export function renderHeader(agent, cols) {
36
37
  const model = agent.provider.model
37
38
  const spec = specForModel(model)
38
- const thinkOnValue = spec.thinkOnValue ?? "enabled"
39
+ const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
39
40
  const t = agent.provider.thinking
40
41
  const effort = agent.provider.reasoningEffort
41
42
  const thinkBadge = t?.type === "disabled" ? "│ think: off"
@@ -44,30 +45,6 @@ export function renderHeader(agent, cols) {
44
45
  return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
45
46
  }
46
47
 
47
- /**
48
- * Compute a cheap cache key for the conversation panel.
49
- * Structural hints that change ONLY when the conversation actually changes.
50
- * Used by the incremental renderer to skip rebuilding convLines when nothing changed.
51
- */
52
- export function convCacheKey(state) {
53
- const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
54
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
55
- }
56
-
57
- /** Conversation panel (scrollable, variable height). Returns exactly `visibleH` lines. */
58
- export function renderConversation(state, cols, visibleH, scroll) {
59
- const convLines = buildConvLines(state, cols)
60
- const maxScroll = Math.max(0, convLines.length - visibleH)
61
- const clamped = Math.min(scroll, maxScroll)
62
- const end = convLines.length - clamped
63
- const visible = convLines.slice(Math.max(0, end - visibleH), end)
64
- const pad = visibleH - visible.length
65
- const out = []
66
- for (let i = 0; i < pad; i++) out.push("")
67
- for (const l of visible) out.push(`${l.color}${l.text}${ansi.reset}`)
68
- return out
69
- }
70
-
71
48
  /** Todo/task panel. Returns empty array when no tasks visible. */
72
49
  export function renderTodo(visibleTasks, cols) {
73
50
  return visibleTasks.map((t) => {
@@ -146,12 +123,29 @@ export function renderPicker(state, cols, panel, overlay) {
146
123
  if (!panel || !overlay) return []
147
124
  const out = []
148
125
  const winH = panel.h - 1
149
- const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
126
+ const total = overlay.lines.length
127
+ const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, total - winH)))
150
128
  const shown = overlay.lines.slice(start, start + winH)
151
- const title = state.picker ? ` ❯ ${state.picker.title} ` : " ❯ Setup "
152
- out.push(`${ansi.bold}${C.tool}${title}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ navigate, Enter confirm, Esc cancel)" : ""}${ansi.reset}`)
153
- for (const l of shown) {
154
- out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}`)
129
+ // 标题行:左侧标题 + filter(截断防撑破帧),右侧位置指示(按键提示在状态栏,不重复)
130
+ const p = state.picker
131
+ const right = p && p.filteredItems?.length ? `${p.index + 1}/${p.filteredItems.length} ` : ""
132
+ const rawLeft = p ? ` ❯ ${p.title}${p.filter ? ` filter: ${p.filter}` : ""} ` : " ❯ Setup "
133
+ const left = sliceByWidth(rawLeft, Math.max(1, cols - 2 - stringWidth(right)))
134
+ const titlePad = " ".repeat(Math.max(1, cols - 1 - stringWidth(left) - stringWidth(right)))
135
+ out.push(`${ansi.bold}${C.tool}${left}${ansi.reset}${ansi.dim}${titlePad}${right}${ansi.reset}`)
136
+ const hasMoreAbove = start > 0
137
+ const hasMoreBelow = start + winH < total
138
+ for (let i = 0; i < shown.length; i++) {
139
+ const l = shown[i]
140
+ // 可视窗上方/下方有更多内容时,在首行/末行右侧给 dim 提示;单行窗口两个方向都有则合并指示
141
+ const moreAbove = i === 0 && hasMoreAbove
142
+ const moreBelow = i === shown.length - 1 && hasMoreBelow
143
+ const ind = moreAbove && moreBelow ? "↑↓ more" : moreAbove ? "↑ more" : moreBelow ? "↓ more" : ""
144
+ const maxW = cols - 1 - (ind ? stringWidth(ind) + 1 : 0)
145
+ // 超宽行截断并加省略号
146
+ const text = stringWidth(l.text) > maxW ? sliceByWidth(l.text, Math.max(0, maxW - 1)) + "…" : l.text
147
+ const pad = ind ? " ".repeat(Math.max(1, cols - 1 - stringWidth(text) - stringWidth(ind))) : ""
148
+ out.push(`${l.color}${text}${ansi.reset}${ind ? `${ansi.dim}${pad}${ind}${ansi.reset}` : ""}`)
155
149
  }
156
150
  for (let i = shown.length; i < winH; i++) out.push("")
157
151
  return out
@@ -187,17 +181,36 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
187
181
 
188
182
  for (let li = 0; li < boxLines.length; li++) {
189
183
  const l = boxLines[li]
190
- let content = sliceByWidth(l, W - 4)
191
- const fill = " ".repeat(Math.max(0, W - 4 - stringWidth(content)))
184
+ const original = sliceByWidth(l, W - 4)
185
+ let content = original
186
+ const contentWidth = stringWidth(original)
187
+ let fillLen = W - 4 - contentWidth
192
188
 
193
189
  if (li === curLine && curCol >= 0) {
194
- const beforeWidth = Math.min(curCol, stringWidth(content))
190
+ const beforeWidth = Math.min(curCol, contentWidth)
195
191
  const before = sliceByWidth(content, beforeWidth)
196
- const atIdx = beforeWidth
192
+ const atIdx = before.length // character index (not display width — CJK chars diverge)
197
193
  const at = content[atIdx] ?? " "
198
194
  const after = content.slice(atIdx + 1)
199
195
  content = before + `${ansi.reset}\x1b[7m${at}\x1b[27m${ansi.reset}` + after
196
+ // Cursor at end of input: at=[\x20] adds one display column — compensate fill.
197
+ // When fill is already 0 (content fills the line), don't add the extra space at all
198
+ // — instead, move the cursor to the last real character.
199
+ if (atIdx >= original.length) {
200
+ if (fillLen > 0) {
201
+ fillLen = Math.max(0, fillLen - 1)
202
+ } else {
203
+ // No room for extra space: place cursor on last character instead
204
+ const lastIdx = original.length - 1
205
+ if (lastIdx >= 0) {
206
+ const before2 = sliceByWidth(original, stringWidth(original.slice(0, lastIdx)))
207
+ const at2 = original[lastIdx] ?? " "
208
+ content = before2 + `${ansi.reset}\x1b[7m${at2}\x1b[27m${ansi.reset}`
209
+ }
210
+ }
211
+ }
200
212
  }
213
+ const fill = " ".repeat(Math.max(0, fillLen))
201
214
 
202
215
  out.push(`${borderColor}│${ansi.reset} ${content}${fill} ${borderColor}│${ansi.reset}`)
203
216
  }
@@ -297,78 +310,6 @@ export function renderFrame(state, agent, opts) {
297
310
  // Internal helpers (unchanged from original)
298
311
  // ====================================================================
299
312
 
300
- export function countConvLines(state, cols) {
301
- return buildConvLines(state, cols).length
302
- }
303
-
304
- let _convCache = { key: "", cols: 0, lines: [] }
305
- function buildConvLines(state, cols) {
306
- const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
307
- const key = convCacheKey(state)
308
- if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
309
-
310
- const convLines = []
311
- for (const l of state.lines) {
312
- for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
313
- for (const wrapped of wrapText(line, cols - 1)) {
314
- convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
315
- }
316
- }
317
- }
318
- // Messages after the conversation (streaming / thinking / tool output):
319
- // appended after history lines so they appear at the bottom.
320
- if (state.reasoning) {
321
- for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
322
- convLines.push({ text: wrapped, color: C.reason })
323
- }
324
- }
325
- if (state.streaming) {
326
- for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
327
- for (const wrapped of wrapText(line, cols - 1)) {
328
- convLines.push({ text: wrapped, color: C.text })
329
- }
330
- }
331
- }
332
- const allStreams = Object.values(state.toolStreams).join("")
333
- if (allStreams) {
334
- const tail = sanitizeDisplay(allStreams.slice(-4000))
335
- for (const wrapped of wrapText(tail, cols - 1)) {
336
- convLines.push({ text: wrapped, color: C.dim })
337
- }
338
- }
339
-
340
- // ---- Fold long blocks (> 8 consecutive dim lines) ----
341
- const FOLD_LINES = 8
342
- let foldCounter = 0
343
- const folded = []
344
- let i = 0
345
- while (i < convLines.length) {
346
- const line = convLines[i]
347
- // Only fold dim-colored lines (tool results, subagent previews)
348
- if (line.color === C.dim) {
349
- let j = i
350
- while (j < convLines.length && convLines[j].color === C.dim) j++
351
- const blockLen = j - i
352
- if (blockLen > FOLD_LINES) {
353
- const foldKey = `fold-${foldCounter++}`
354
- if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
355
- // Show first 2 lines + fold hint
356
- folded.push(convLines[i])
357
- if (blockLen > 2) folded.push(convLines[i + 1])
358
- folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
359
- i = j
360
- continue
361
- }
362
- }
363
- }
364
- folded.push(line)
365
- i++
366
- }
367
-
368
- _convCache = { key, cols, lines: folded }
369
- return folded
370
- }
371
-
372
313
  function inputBoxStyle(state) {
373
314
  let borderColor = C.tool
374
315
  let title
@@ -406,7 +347,7 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
406
347
  ? " y: continue │ n: stop"
407
348
  : " y: approve │ n: deny │ a: approve all (AUTO)"
408
349
  }
409
- if (state.picker) return " ↑↓: select │ Enter: confirm │ Esc: cancel"
350
+ if (state.picker) return " type: filter │ ↑↓/PgUp/PgDn: select │ Enter: confirm │ Esc: cancel"
410
351
  if (state.wizard) {
411
352
  return state.wizard.step === "provider"
412
353
  ? " ↑↓: select │ Enter: confirm │ Esc: skip"
@@ -434,10 +375,11 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
434
375
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
435
376
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
436
377
  const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
437
- const ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
438
- const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
378
+ const modelContext = specForModel(agent.provider.model).context
379
+ const ctxPct = Math.round((state.ctxCache.tokens / modelContext) * 100)
380
+ const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
439
381
  const ctxHint = ctxPct > 0
440
- ? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%` : ""
382
+ ? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
441
383
  const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
442
384
  return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
443
385
  }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * render-loop.mjs — frame scheduler + incremental panel rendering
3
+ * Extracted from src/tui/index.mjs to keep the TUI entry point under 500 lines.
4
+ */
5
+ import { computeLayout } from "./layout.mjs"
6
+ import {
7
+ renderFrame, countConvLines, convCacheKey,
8
+ renderHeader, renderConversation, renderTodo, renderSubagent,
9
+ renderOutput, renderPermission, renderQueue, renderPicker,
10
+ renderInputBox, renderStatus,
11
+ } from "./render-frame.mjs"
12
+ import { estimateTokens } from "../context.mjs"
13
+ import { ansi, C } from "./ansi.mjs"
14
+
15
+ const MIN_RENDER_INTERVAL_MS = 16
16
+
17
+ /**
18
+ * Create the render loop closure. Returns { render, scheduleRender }.
19
+ *
20
+ * @param {object} state — TUI state
21
+ * @param {object} agent — agent instance
22
+ * @param {object} ctx — mutable context: { startupDims, SLASH_COMMANDS, showUpdateNotice }
23
+ * @param {Function} pushLine — for error logging
24
+ */
25
+ export function createRenderLoop(state, agent, ctx, pushLine) {
26
+ const { startupDims, SLASH_COMMANDS } = ctx
27
+ const panelCache = new Map()
28
+ let lastCols = 0, lastRows = 0
29
+ let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
30
+ const convLineCache = []
31
+ let renderRequested = false, renderTimer = null, lastRenderAt = 0
32
+
33
+ function scheduleRender() {
34
+ if (renderTimer) return
35
+ const elapsed = performance.now() - lastRenderAt
36
+ const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
37
+ renderTimer = setTimeout(() => {
38
+ renderTimer = null
39
+ if (!renderRequested) return
40
+ renderRequested = false
41
+ lastRenderAt = performance.now()
42
+ doRender()
43
+ if (renderRequested) scheduleRender()
44
+ }, delay)
45
+ }
46
+
47
+ function render() {
48
+ if (renderRequested) return
49
+ renderRequested = true
50
+ process.nextTick(() => scheduleRender())
51
+ }
52
+
53
+ function buildPanel(name, panelLayout, lines, cacheKey) {
54
+ if (!panelLayout) {
55
+ if (panelCache.has(name)) panelCache.delete(name)
56
+ return null
57
+ }
58
+ const content = lines.join("\r\n")
59
+ const cached = panelCache.get(name)
60
+ const effectiveKey = cacheKey ?? content
61
+ if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
62
+ const rows = []
63
+ for (let i = 0; i < panelLayout.h; i++) {
64
+ rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
65
+ }
66
+ panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
67
+ return rows.join("")
68
+ }
69
+
70
+ function layoutStructureChanged(layout) {
71
+ for (const [name, cached] of panelCache) {
72
+ const p = layout.panels[name] ?? null
73
+ if (p == null) return true
74
+ if (p.y !== cached.y || p.h !== cached.h) return true
75
+ }
76
+ return false
77
+ }
78
+
79
+ function doRender() {
80
+ try {
81
+ const startupCols = startupDims.cols, startupRows = startupDims.rows
82
+ const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
83
+ const layout = computeLayout(state, dims)
84
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
85
+
86
+ const convLines = countConvLines(state, dims.cols)
87
+ state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
88
+ if (overlay && panels.picker) {
89
+ const winH = panels.picker.h - 1
90
+ if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
91
+ if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
92
+ }
93
+ if (state.ctxCache.len !== agent.history.length) {
94
+ state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
95
+ }
96
+
97
+ // Deferred upgrade notice — pop when no overlay is active
98
+ if (ctx.pendingNoticeReady(state)) {
99
+ const notice = state.pendingNotice
100
+ state.pendingNotice = null
101
+ ctx.showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
102
+ }
103
+
104
+ // Terminal resize or panel layout shift → full redraw
105
+ if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
106
+ lastCols = dims.cols; lastRows = dims.rows
107
+ for (const [name, panelLayout] of Object.entries(panels)) {
108
+ if (!panelLayout) { panelCache.delete(name); continue }
109
+ const cached = panelCache.get(name)
110
+ if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
111
+ }
112
+ const isStreaming = state.processing && !state.permission && !state.question && !state.picker
113
+ const isWizard = state.wizard?.step === "provider"
114
+ const { frame, cursorRow, cursorCol } = renderFrame(state, agent, { cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
115
+ if (isStreaming) {
116
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
117
+ } else if (isWizard) {
118
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
119
+ } else {
120
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
121
+ }
122
+ return
123
+ }
124
+
125
+ // ---- Incremental rendering (layout stable) ----
126
+ const out = []
127
+ const push = (s) => { if (s != null) out.push(s) }
128
+
129
+ push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
130
+ push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
131
+ push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
132
+
133
+ const convKey = convCacheKey(state)
134
+ const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
135
+ if (convChanged) {
136
+ lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
137
+ const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
138
+ const y = panels.conversation.y + 1
139
+ for (let i = 0; i < lines.length; i++) {
140
+ if (lines[i] !== convLineCache[i]) {
141
+ out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
142
+ convLineCache[i] = lines[i]
143
+ }
144
+ }
145
+ if (convLineCache.length > lines.length) {
146
+ for (let i = lines.length; i < convLineCache.length; i++) {
147
+ out.push(`\x1b[${y + i};1H\x1b[K`)
148
+ }
149
+ }
150
+ convLineCache.length = lines.length
151
+ }
152
+
153
+ push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
154
+ push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
155
+ push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0),
156
+ // Key by append counter, not text length: panel text is capped at 4000 chars (agent-turn),
157
+ // so length stops changing once the cap is hit and the panel would freeze mid-stream.
158
+ Object.values(state.outputPanels).filter(p => !p.done).map(p => p.seq ?? 0).join(",")))
159
+ push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
160
+ if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
161
+ else panelCache.delete("queue")
162
+ if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
163
+ else panelCache.delete("picker")
164
+
165
+ for (const p of Object.values(state.outputPanels)) {
166
+ if (p._pendingDone) { p.done = true; delete p._pendingDone }
167
+ }
168
+
169
+ const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
170
+ const cc = 3 + inputLayout.cursorCol
171
+ const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
172
+ const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
173
+
174
+ if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
175
+ } catch (e) {
176
+ // Don't let a render error crash the TUI
177
+ }
178
+ }
179
+
180
+ return { render, scheduleRender }
181
+ }
@@ -4,11 +4,12 @@
4
4
  *
5
5
  * ctx object is injected by index.mjs and forwarded to each handler:
6
6
  * { agent, state, distillOpts, pushLine, pushLabel, render,
7
- * openPicker, openModelPicker, setProviderKey, runDistill,
7
+ * showPicker, closePicker, openModelPicker, setProviderKey, runDistill,
8
8
  * persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
9
9
  */
10
10
 
11
11
  import { C } from "./ansi.mjs"
12
+ import { specForModel } from "../config.mjs"
12
13
  import { handleClearCommand } from "./cmd-clear.mjs"
13
14
  import { handleNewCommand } from "./cmd-new.mjs"
14
15
  import { handleExitCommand } from "./cmd-exit.mjs"
@@ -55,8 +56,11 @@ export const SLASH_COMMANDS = [
55
56
  { name: "/help", group: "System", desc: "this list" },
56
57
  ]
57
58
 
58
- /** Command handler mapping table */
59
- const HANDLERS = {
59
+ /** High-frequency command aliases (single source of truth — also used by index.mjs and cmd-help.mjs) */
60
+ export const SLASH_ALIASES = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
61
+
62
+ /** Command → handler mapping table (exported for tests) */
63
+ export const HANDLERS = {
60
64
  "/clear": handleClearCommand,
61
65
  "/new": handleNewCommand,
62
66
  "/exit": handleExitCommand,
@@ -90,27 +94,29 @@ export function createSlashCommands(ctx) {
90
94
  const handlerCtx = { ...ctx, SLASH_COMMANDS }
91
95
 
92
96
  async function handleSlash(text) {
93
- const [cmd] = text.split(/\s+/)
94
- // high-frequency command aliases
95
- const aliases = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
96
- const resolved = aliases[cmd] ?? cmd
97
+ const [rawCmd, ...args] = text.split(/\s+/)
98
+ // case-insensitive matching + alias resolution
99
+ const cmd = rawCmd.toLowerCase()
100
+ const resolved = SLASH_ALIASES[cmd] ?? cmd
97
101
  const handler = HANDLERS[resolved]
98
102
  if (handler) {
99
- await handler(handlerCtx)
103
+ await handler(handlerCtx, args)
100
104
  return
101
105
  }
102
- ctx.pushLine(`Unknown command: ${cmd} (/help for available commands)`, C.error)
106
+ ctx.pushLine(`Unknown command: ${rawCmd} (/help for available commands)`, C.error)
103
107
  }
104
108
 
105
109
  /** Tab completion candidates: command names / subcommands / provider names / preset names / think params */
106
110
  function completions(input) {
107
111
  if (!input.startsWith("/")) return []
108
112
  const parts = input.split(/\s+/)
109
- // still typing the first token: complete command names
113
+ // still typing the first token: complete command names (case-insensitive)
110
114
  if (parts.length === 1) {
111
- return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
115
+ const prefix = parts[0].toLowerCase()
116
+ return SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)).map((c) => c.name)
112
117
  }
113
- const cmd = parts[0]
118
+ // aliases resolve to their target command, so `/m <Tab>` completes /model args
119
+ const cmd = SLASH_ALIASES[parts[0].toLowerCase()] ?? parts[0].toLowerCase()
114
120
  const last = parts.at(-1) // when trailing space, list all candidates
115
121
  const head = parts.slice(0, -1).join(" ")
116
122
  const argIndex = parts.length - 2 // which parameter is being typed (0-based)
@@ -118,13 +124,17 @@ export function createSlashCommands(ctx) {
118
124
  if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
119
125
  if (cmd === "/think") {
120
126
  if (argIndex === 0) return match(["on", "off", "effort"])
121
- if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
127
+ if (argIndex === 1 && parts[1].toLowerCase() === "effort") {
128
+ // effort enum is model-specific — take it from the current model's spec
129
+ const levels = specForModel(agent.provider?.model).reasoningEffortEnum ?? ["high", "max"]
130
+ return match(levels)
131
+ }
122
132
  }
123
- if (cmd === "/config" && argIndex === 0) return match(["embedkey", "set"])
133
+ if (cmd === "/config" && argIndex === 0) return match(["embedkey"])
124
134
  if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
125
135
  if (cmd === "/mcp") {
126
- if (argIndex === 0) return match(["add", "url", "ws", "remove", "connect", "list"])
127
- if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
136
+ if (argIndex === 0) return match(["add", "http", "ws", "stdio", "ai", "remove", "connect", "list"])
137
+ if (argIndex === 1 && (parts[1]?.toLowerCase() === "remove" || parts[1]?.toLowerCase() === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
128
138
  }
129
139
  return []
130
140
  }