thincoder 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/thincoder.cjs +4 -0
- package/bin/thincoder.mjs +25 -25
- package/package.json +2 -2
- package/src/agent/dispatch.mjs +17 -12
- package/src/agent/helpers.mjs +22 -11
- package/src/agent/setup.mjs +32 -10
- package/src/agent-tools/goal.mjs +7 -6
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/recent-changes.mjs +3 -3
- package/src/agent-tools/skill.mjs +6 -6
- package/src/agent-tools/subagent.mjs +19 -18
- package/src/agent-tools/task.mjs +4 -4
- package/src/agent-tools/verify.mjs +21 -19
- package/src/agent-tools.mjs +3 -3
- package/src/agent.mjs +58 -37
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +9 -9
- package/src/cli/memory-command.mjs +1 -1
- package/src/cli/permission.mjs +3 -3
- package/src/cli/setup-wizard.mjs +15 -15
- package/src/config.mjs +55 -53
- package/src/context.mjs +59 -56
- package/src/distill.mjs +35 -35
- package/src/embedding.mjs +17 -17
- package/src/git/checkpoint.mjs +211 -38
- package/src/git/gitmem.mjs +21 -20
- package/src/markdown.mjs +13 -13
- package/src/mcp/helpers.mjs +6 -1
- package/src/mcp/transport-http.mjs +2 -1
- package/src/mcp/transport-stdio.mjs +3 -2
- package/src/mcp/transport-ws.mjs +3 -2
- package/src/mcp.mjs +5 -2
- package/src/memory/code-index.mjs +16 -14
- package/src/memory/code-sync.mjs +36 -26
- package/src/memory/core.mjs +42 -35
- package/src/memory/docs.mjs +24 -15
- package/src/memory/schema.mjs +28 -27
- package/src/memory.mjs +2 -2
- package/src/prompts/coder.md +1 -1
- package/src/prompts/discipline.md +3 -0
- package/src/prompts/main.md +1 -2
- package/src/prompts/system.md +21 -16
- package/src/provider/core.mjs +11 -6
- package/src/provider/index.mjs +2 -2
- package/src/provider/rate.mjs +11 -11
- package/src/session.mjs +73 -40
- package/src/skills.mjs +17 -17
- package/src/tools/checkpoint.md +6 -2
- package/src/tools/file.mjs +16 -12
- package/src/tools/git.mjs +83 -12
- package/src/tools/index.mjs +1 -1
- package/src/tools/patch.mjs +20 -18
- package/src/tools/repomap-parse.mjs +17 -17
- package/src/tools/repomap.mjs +29 -29
- package/src/tools/shared.mjs +49 -28
- package/src/tools/system.mjs +163 -118
- package/src/tools/web.mjs +6 -6
- package/src/tui/agent-turn.mjs +82 -26
- package/src/tui/ansi.mjs +4 -2
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-auto.mjs +1 -1
- package/src/tui/cmd-clear.mjs +1 -1
- package/src/tui/cmd-config.mjs +2 -2
- package/src/tui/cmd-exit.mjs +2 -2
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-goal.mjs +4 -4
- package/src/tui/cmd-help.mjs +1 -1
- package/src/tui/cmd-init.mjs +3 -3
- package/src/tui/cmd-mcp.mjs +5 -5
- package/src/tui/cmd-model.mjs +1 -1
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +1 -1
- package/src/tui/cmd-reindex.mjs +2 -2
- package/src/tui/cmd-restore.mjs +1 -1
- package/src/tui/cmd-session.mjs +1 -1
- package/src/tui/cmd-skills.mjs +1 -1
- package/src/tui/cmd-think.mjs +2 -2
- package/src/tui/config-helpers.mjs +6 -6
- package/src/tui/distill-cmd.mjs +3 -3
- package/src/tui/index.mjs +131 -72
- package/src/tui/interaction.mjs +13 -12
- package/src/tui/key-handler.mjs +31 -23
- package/src/tui/layout.mjs +22 -17
- package/src/tui/pickers.mjs +19 -19
- package/src/tui/render-frame.mjs +37 -11
- package/src/tui/render.mjs +25 -24
- package/src/tui/slash-commands.mjs +14 -14
- package/src/tui/startup.mjs +15 -14
- package/src/tui/wizard.mjs +11 -11
- package/src/tui.mjs +2 -2
- package/bin/thincoder.js +0 -4
- package/src/tools/bash.mjs +0 -144
- package/src/tools/glob.mjs +0 -51
- package/src/tools/grep.mjs +0 -100
- package/src/tools/ls.mjs +0 -36
- package/src/tools.mjs +0 -2
- package/src/tui-render.mjs +0 -4
package/src/tui/layout.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* layout.mjs — TUI
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* layout.mjs — TUI layout engine (pure function)
|
|
3
|
+
* Computes position and height of each panel from state + terminal dimensions.
|
|
4
|
+
* Does not modify state — side effects are performed by the caller before rendering.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* header → conversation →
|
|
8
|
-
*
|
|
6
|
+
* Panel layout (top to bottom):
|
|
7
|
+
* header → conversation → subagent → output → todo → permission → queue → input → status
|
|
8
|
+
* Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
|
|
9
9
|
*/
|
|
10
10
|
import { layoutInput, wrapText } from "./render.mjs"
|
|
11
11
|
|
|
@@ -15,13 +15,13 @@ export const MAX_SUB_LINES = 4
|
|
|
15
15
|
const QWIN = 5
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Computes layout. Returns panel coordinates + precomputed content (height-affecting parts).
|
|
19
|
+
* Pure function: does not modify state.
|
|
20
20
|
*/
|
|
21
21
|
export function computeLayout(state, { cols, rows }) {
|
|
22
22
|
const W = Math.max(20, cols - 1)
|
|
23
23
|
|
|
24
|
-
// ---
|
|
24
|
+
// --- input box ---
|
|
25
25
|
const inputLayout = layoutInput(state.input, state.cursor, W - 4)
|
|
26
26
|
let inputOffset = 0
|
|
27
27
|
if (inputLayout.lines.length > MAX_INPUT_LINES) {
|
|
@@ -41,11 +41,11 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
41
41
|
}
|
|
42
42
|
const inputBoxH = boxLines.length + 2
|
|
43
43
|
|
|
44
|
-
// ---
|
|
44
|
+
// --- fixed panels ---
|
|
45
45
|
const headerH = 1
|
|
46
46
|
const statusH = 1
|
|
47
47
|
|
|
48
|
-
// ---
|
|
48
|
+
// --- conditional panels ---
|
|
49
49
|
const overlay = state.picker ?? state.wizard
|
|
50
50
|
const pickerH = overlay ? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12)) : 0
|
|
51
51
|
|
|
@@ -67,6 +67,10 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
67
67
|
? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
|
|
68
68
|
: 0
|
|
69
69
|
|
|
70
|
+
// Tool output panels: max 8 lines per panel, capped at reasonable total
|
|
71
|
+
const panels = Object.values(state.outputPanels).filter((p) => !p.done)
|
|
72
|
+
const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
|
|
73
|
+
|
|
70
74
|
// Permission preview (height depends on wrapped content)
|
|
71
75
|
let permPreviewLines = []
|
|
72
76
|
let permPreviewH = 0
|
|
@@ -84,17 +88,18 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
84
88
|
// Queue preview (1 line when queue has items and processing)
|
|
85
89
|
const queueH = state.queue.length > 0 && state.processing ? 1 : 0
|
|
86
90
|
|
|
87
|
-
// ---
|
|
88
|
-
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + permPreviewH + queueH
|
|
91
|
+
// --- elastic panel: conversation takes remaining space ---
|
|
92
|
+
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
|
|
89
93
|
const convH = Math.max(1, rows - fixedH)
|
|
90
94
|
|
|
91
|
-
// --- Y
|
|
95
|
+
// --- Y coordinates (0-indexed, +1 when used with ANSI) ---
|
|
92
96
|
let y = 0
|
|
93
97
|
const header = { y, h: headerH }; y += headerH
|
|
94
98
|
const conversation = { y, h: convH }; y += convH
|
|
99
|
+
const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
|
|
100
|
+
const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
|
|
95
101
|
const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
|
|
96
102
|
const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
|
|
97
|
-
const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
|
|
98
103
|
const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
|
|
99
104
|
const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
|
|
100
105
|
const inputBox = { y, h: inputBoxH }; y += inputBoxH
|
|
@@ -102,8 +107,8 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
102
107
|
|
|
103
108
|
return {
|
|
104
109
|
W, cols, rows,
|
|
105
|
-
panels: { header, conversation, picker, todo, subagent, permission, queue, inputBox, status },
|
|
106
|
-
//
|
|
110
|
+
panels: { header, conversation, picker, todo, subagent, output, permission, queue, inputBox, status },
|
|
111
|
+
// precomputed content (affects height, reused during render)
|
|
107
112
|
inputLayout,
|
|
108
113
|
inputOffset,
|
|
109
114
|
boxLines,
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { sliceByWidth } from "./render.mjs"
|
|
2
2
|
import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
/** Generic list picker + unified model/provider management picker.
|
|
5
|
+
* Receives closure dependencies via createPickers(ctx),
|
|
6
|
+
* returns { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey }. */
|
|
7
7
|
export function createPickers(ctx) {
|
|
8
8
|
const { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey } = ctx
|
|
9
9
|
|
|
10
10
|
const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* onSelect
|
|
12
|
+
/** Open a generic list picker. entries contain { type: "header"|"item", text, note?, ...extra },
|
|
13
|
+
* onSelect receives the selected entry (with extra fields forwarded), onCancel called on Esc. */
|
|
14
14
|
function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
|
|
15
15
|
state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
|
|
16
16
|
renderPickerLines()
|
|
@@ -22,7 +22,7 @@ export function createPickers(ctx) {
|
|
|
22
22
|
render()
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/** Rebuild display lines from entries and refresh */
|
|
26
26
|
function renderPickerLines() {
|
|
27
27
|
const p = state.picker
|
|
28
28
|
if (!p) return
|
|
@@ -48,7 +48,7 @@ export function createPickers(ctx) {
|
|
|
48
48
|
render()
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// ==========
|
|
51
|
+
// ========== unified picker: model switch + provider management ==========
|
|
52
52
|
|
|
53
53
|
async function openModelPicker() {
|
|
54
54
|
const entries = buildModelEntries()
|
|
@@ -64,14 +64,14 @@ export function createPickers(ctx) {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
openPicker({ title: "Models & Providers", entries, onSelect })
|
|
67
|
-
//
|
|
67
|
+
// default select the currently active model
|
|
68
68
|
const current = pickerItems().findIndex(
|
|
69
69
|
(e) => e.action === "switch" && e.provider === agent.activeProvider && e.model === agent.provider.model,
|
|
70
70
|
)
|
|
71
71
|
if (current >= 0) state.picker.index = current
|
|
72
72
|
renderPickerLines()
|
|
73
73
|
|
|
74
|
-
//
|
|
74
|
+
// async fetch remote model list, append under each provider
|
|
75
75
|
const { listModels } = await import("../provider/index.mjs")
|
|
76
76
|
await Promise.all(
|
|
77
77
|
agent.providers.map(async (p) => {
|
|
@@ -84,7 +84,7 @@ export function createPickers(ctx) {
|
|
|
84
84
|
{ baseURL: p.baseURL, apiKey: apiKey ?? "" },
|
|
85
85
|
{ signal: AbortSignal.timeout(10000) },
|
|
86
86
|
)
|
|
87
|
-
//
|
|
87
|
+
// find the model item position for this provider in entries, insert new models after it
|
|
88
88
|
const at = entries.findLastIndex(
|
|
89
89
|
(e) => e.type === "item" && e.action === "switch" && e.provider === p.name,
|
|
90
90
|
)
|
|
@@ -97,7 +97,7 @@ export function createPickers(ctx) {
|
|
|
97
97
|
.map((m) => ({ type: "item", text: m, action: "switch", provider: p.name, model: m })),
|
|
98
98
|
)
|
|
99
99
|
}
|
|
100
|
-
//
|
|
100
|
+
// update header note to remove "loading..."
|
|
101
101
|
const header = entries.find((e) => e.type === "header" && e.text === p.name)
|
|
102
102
|
if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
|
|
103
103
|
} catch (error) {
|
|
@@ -109,7 +109,7 @@ export function createPickers(ctx) {
|
|
|
109
109
|
)
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/** Build picker entries: each provider gets a header + model list, management actions at the bottom */
|
|
113
113
|
function buildModelEntries() {
|
|
114
114
|
const entries = []
|
|
115
115
|
for (const p of agent.providers) {
|
|
@@ -128,7 +128,7 @@ export function createPickers(ctx) {
|
|
|
128
128
|
marker: active ? "●" : "",
|
|
129
129
|
})
|
|
130
130
|
}
|
|
131
|
-
//
|
|
131
|
+
// management actions
|
|
132
132
|
entries.push({ type: "header", text: "Provider Management" })
|
|
133
133
|
entries.push({ type: "item", text: "Add provider…", action: "add" })
|
|
134
134
|
if (agent.providers.length > 1) {
|
|
@@ -138,7 +138,7 @@ export function createPickers(ctx) {
|
|
|
138
138
|
return entries
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
141
|
+
/** Switch provider + model, persist, threshold follows model */
|
|
142
142
|
async function selectModel(item) {
|
|
143
143
|
closePicker()
|
|
144
144
|
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
@@ -167,7 +167,7 @@ export function createPickers(ctx) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
/**
|
|
170
|
+
/** Add provider: preset menu → input key → done, or custom step-by-step input */
|
|
171
171
|
async function addProviderFlow() {
|
|
172
172
|
const presetEntries = [
|
|
173
173
|
{ type: "header", text: "Select a preset provider" },
|
|
@@ -204,7 +204,7 @@ export function createPickers(ctx) {
|
|
|
204
204
|
openModelPicker().catch(() => {})
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
|
-
//
|
|
207
|
+
// preset
|
|
208
208
|
if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch(() => {}); return }
|
|
209
209
|
const preset = PRESETS[se.name]
|
|
210
210
|
const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
|
|
@@ -222,7 +222,7 @@ export function createPickers(ctx) {
|
|
|
222
222
|
})
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
/**
|
|
225
|
+
/** Remove provider (cannot remove the currently active one) */
|
|
226
226
|
async function removeProviderFlow() {
|
|
227
227
|
const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
|
|
228
228
|
if (candidates.length === 0) { openModelPicker().catch(() => {}); return }
|
|
@@ -243,7 +243,7 @@ export function createPickers(ctx) {
|
|
|
243
243
|
})
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
/**
|
|
246
|
+
/** Set/change API key: select provider → enter key */
|
|
247
247
|
async function setKeyFlow() {
|
|
248
248
|
const keyEntries = [
|
|
249
249
|
{ type: "header", text: "Select provider to configure key" },
|
|
@@ -266,7 +266,7 @@ export function createPickers(ctx) {
|
|
|
266
266
|
})
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
/**
|
|
269
|
+
/** Write key for a given provider (memory + config file); if it's the currently active one, sync runtime too */
|
|
270
270
|
async function setProviderKey(name, key) {
|
|
271
271
|
const target = agent.providers.find((p) => p.name === name)
|
|
272
272
|
if (!target) return
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* render-frame.mjs —
|
|
3
|
-
*
|
|
2
|
+
* render-frame.mjs — terminal frame renderer (pure computation, no side effects)
|
|
3
|
+
* Produces an ANSI frame string from state + agent + layout, returns cursor position.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Layout calculation lives in layout.mjs (computeLayout pure function).
|
|
6
|
+
* Side effects (scroll clamping, ctxCache update) are performed by the caller before rendering.
|
|
7
7
|
*/
|
|
8
8
|
import { ansi, C, ESC } from "./ansi.mjs"
|
|
9
9
|
import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { specForModel } from "../config.mjs"
|
|
14
14
|
import { basename } from "node:path"
|
|
15
15
|
|
|
16
|
-
// ---------- status bar slash-command hints (
|
|
16
|
+
// ---------- status bar slash-command hints (lookup table instead of if-else chain) ----------
|
|
17
17
|
const SLASH_HINTS = {
|
|
18
18
|
"/config": "open config menu",
|
|
19
19
|
"/model": "select model & manage providers",
|
|
@@ -25,8 +25,8 @@ const SLASH_HINTS = {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Render one frame, returns { frame, cursorRow, cursorCol }.
|
|
29
|
+
* Pure function: does not modify state/agent.
|
|
30
30
|
*/
|
|
31
31
|
export function renderFrame(state, agent, opts) {
|
|
32
32
|
const cols = opts.cols || 80
|
|
@@ -111,6 +111,29 @@ export function renderFrame(state, agent, opts) {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// ---- tool output panels (streaming output like tail -f, auto-clears when done) ----
|
|
115
|
+
if (panels.output) {
|
|
116
|
+
const active = Object.values(state.outputPanels).filter((p) => !p.done)
|
|
117
|
+
if (active.length > 0) {
|
|
118
|
+
const linesPerPanel = Math.max(1, Math.floor(panels.output.h / active.length))
|
|
119
|
+
for (const p of active) {
|
|
120
|
+
const textLines = (p.text ?? "").split("\n").filter((l) => l.trim())
|
|
121
|
+
const tail = textLines.slice(-linesPerPanel)
|
|
122
|
+
for (const line of tail) {
|
|
123
|
+
out.push(`${C.dim} │ ${sliceByWidth(sanitizeDisplay(line), W - 5)}${ansi.reset}${ansi.clearLine}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// fill remaining rows
|
|
127
|
+
const used = active.reduce((s, p) => {
|
|
128
|
+
const tl = (p.text ?? "").split("\n").filter((l) => l.trim()).slice(-linesPerPanel)
|
|
129
|
+
return s + tl.length
|
|
130
|
+
}, 0)
|
|
131
|
+
for (let i = used; i < panels.output.h; i++) {
|
|
132
|
+
out.push(ansi.clearLine)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
// ---- permission preview ----
|
|
115
138
|
if (panels.permission) {
|
|
116
139
|
out.push(`${ansi.bold}${C.warn}❯ Permission Request${ansi.reset}${ansi.clearLine}`)
|
|
@@ -122,7 +145,7 @@ export function renderFrame(state, agent, opts) {
|
|
|
122
145
|
// ---- queue preview ----
|
|
123
146
|
if (panels.queue) {
|
|
124
147
|
const preview = sliceByWidth(state.queue[0].text, W - 20)
|
|
125
|
-
out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D
|
|
148
|
+
out.push(`${C.dim}❯ Queue: ${state.queue.length} pending${state.queue.length > 1 ? ` (next: ${preview}…)` : ` (next: ${preview})`} — Ctrl+D delete${ansi.reset}${ansi.clearLine}`)
|
|
126
149
|
}
|
|
127
150
|
|
|
128
151
|
// ---- input box ----
|
|
@@ -131,6 +154,9 @@ export function renderFrame(state, agent, opts) {
|
|
|
131
154
|
if (title === " Input " && isMultimodal) {
|
|
132
155
|
const hint = platform === "win32" ? " Alt+V paste " : " Ctrl+V paste "
|
|
133
156
|
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
|
|
157
|
+
} else if (title === " Input ") {
|
|
158
|
+
const hint = " Ctrl+U clear "
|
|
159
|
+
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
|
|
134
160
|
} else {
|
|
135
161
|
topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
136
162
|
}
|
|
@@ -161,7 +187,7 @@ export function renderFrame(state, agent, opts) {
|
|
|
161
187
|
return { frame, cursorRow, cursorCol }
|
|
162
188
|
}
|
|
163
189
|
|
|
164
|
-
// ----------------------------------------------------------
|
|
190
|
+
// ---------------------------------------------------------- internal helpers
|
|
165
191
|
|
|
166
192
|
/** Count conversation lines after sanitize + wrap (for scroll clamping). Pure. */
|
|
167
193
|
export function countConvLines(state, cols) {
|
|
@@ -282,8 +308,8 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
282
308
|
const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
|
|
283
309
|
const ctxHint = ctxPct > 0
|
|
284
310
|
? ctxPct >= 80
|
|
285
|
-
? ` │ ${ansi.reset}${C.warn}
|
|
286
|
-
: ` │
|
|
311
|
+
? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}`
|
|
312
|
+
: ` │ context ${ctxPct}%`
|
|
287
313
|
: ""
|
|
288
314
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
289
315
|
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
|
package/src/tui/render.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tui-render.mjs —
|
|
3
|
-
*
|
|
2
|
+
* tui-render.mjs — terminal display utilities (pure functions, zero dependencies)
|
|
3
|
+
* Character width calculation, CJK/emoji typesetting, text wrapping, markdown table reformatting.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
6
|
+
/** Character display width: CJK/emoji count as 2, combining characters as 0, rest as 1 */
|
|
7
7
|
export function charWidth(cp) {
|
|
8
8
|
if (
|
|
9
|
-
(cp >= 0x300 && cp <= 0x36f) || //
|
|
10
|
-
(cp >= 0x200b && cp <= 0x200f) || //
|
|
11
|
-
cp === 0xfe0f // emoji
|
|
9
|
+
(cp >= 0x300 && cp <= 0x36f) || // combining diacritics
|
|
10
|
+
(cp >= 0x200b && cp <= 0x200f) || // zero-width
|
|
11
|
+
cp === 0xfe0f // emoji variation selector
|
|
12
12
|
) {
|
|
13
13
|
return 0
|
|
14
14
|
}
|
|
@@ -29,13 +29,14 @@ export function charWidth(cp) {
|
|
|
29
29
|
return 1
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Compute the display width of a string (CJK characters count as 2) */
|
|
32
33
|
export function stringWidth(text) {
|
|
33
34
|
let w = 0
|
|
34
35
|
for (const ch of text) w += charWidth(ch.codePointAt(0))
|
|
35
36
|
return w
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
/**
|
|
39
|
+
/** Slice by display width */
|
|
39
40
|
export function sliceByWidth(text, maxWidth) {
|
|
40
41
|
let w = 0
|
|
41
42
|
let out = ""
|
|
@@ -48,19 +49,19 @@ export function sliceByWidth(text, maxWidth) {
|
|
|
48
49
|
return out
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
+
/** Right-pad by display width */
|
|
52
53
|
function padByWidth(text, width) {
|
|
53
54
|
return text + " ".repeat(Math.max(0, width - stringWidth(text)))
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
// ---------------------------------------------------------------- markdown
|
|
57
|
+
// ---------------------------------------------------------------- markdown table reformatting
|
|
57
58
|
|
|
58
59
|
const isTableRow = (line) => (line.match(/\|/g) ?? []).length >= 2
|
|
59
60
|
const isTableSeparator = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-")
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
|
-
*
|
|
63
|
-
* width
|
|
63
|
+
* Identify markdown table blocks in text, reformat by display width (fix CJK misalignment).
|
|
64
|
+
* width is the available display width; over-wide tables shrink columns. Non-table lines are kept as-is.
|
|
64
65
|
*/
|
|
65
66
|
export function formatTables(text, width) {
|
|
66
67
|
const lines = text.split("\n")
|
|
@@ -94,31 +95,31 @@ function renderTable(block, width) {
|
|
|
94
95
|
const colCount = Math.max(...rows.map((r) => r.length))
|
|
95
96
|
for (const r of rows) while (r.length < colCount) r.push("")
|
|
96
97
|
|
|
97
|
-
//
|
|
98
|
+
// Column widths: content first; if too wide, shrink from widest column (down to at least 3)
|
|
98
99
|
const widths = Array.from({ length: colCount }, (_, c) =>
|
|
99
100
|
Math.max(3, ...rows.map((r) => stringWidth(r[c] ?? ""))),
|
|
100
101
|
)
|
|
101
|
-
const borders = colCount * 3 + 1 // " │ "
|
|
102
|
+
const borders = colCount * 3 + 1 // " │ " separators + leading/trailing |
|
|
102
103
|
while (widths.reduce((a, b) => a + b, 0) + borders > width && Math.max(...widths) > 3) {
|
|
103
104
|
const widest = widths.indexOf(Math.max(...widths))
|
|
104
105
|
widths[widest]--
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
//
|
|
108
|
+
// Cell rendering: sliceByWidth truncates (single-line for header), padByWidth pads
|
|
108
109
|
const fmtCell = (text, ci) => padByWidth(sliceByWidth(text, widths[ci]), widths[ci])
|
|
109
110
|
const fmtRow = (cells) => "│ " + cells.map((c, i) => fmtCell(c, i)).join(" │ ") + " │"
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
+
// separator line
|
|
112
113
|
const separator = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"
|
|
113
114
|
|
|
114
115
|
const out = []
|
|
115
|
-
//
|
|
116
|
+
// Header: single-line truncation (header labels are usually short, truncation beats wrapping)
|
|
116
117
|
out.push(fmtRow(rows[0]))
|
|
117
118
|
out.push(separator)
|
|
118
119
|
|
|
119
|
-
//
|
|
120
|
+
// Data rows: over-long cells wrap by column width; one logical row may produce multiple display lines
|
|
120
121
|
for (let r = 2; r < rows.length; r++) {
|
|
121
|
-
// wrapText
|
|
122
|
+
// wrapText returns array of lines wrapped by width, preserving internal \n
|
|
122
123
|
const wrapped = rows[r].map((cell, ci) => wrapText(cell, widths[ci]))
|
|
123
124
|
const height = Math.max(...wrapped.map((lines) => lines.length))
|
|
124
125
|
for (let lineIdx = 0; lineIdx < height; lineIdx++) {
|
|
@@ -129,7 +130,7 @@ function renderTable(block, width) {
|
|
|
129
130
|
return out
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
/**
|
|
133
|
+
/** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width) */
|
|
133
134
|
export function layoutInput(chars, cursor, width) {
|
|
134
135
|
const PROMPT = "\u25b8 "
|
|
135
136
|
const lines = []
|
|
@@ -147,8 +148,6 @@ export function layoutInput(chars, cursor, width) {
|
|
|
147
148
|
}
|
|
148
149
|
for (let i = 0; i <= chars.length; i++) {
|
|
149
150
|
const ch = chars[i]
|
|
150
|
-
// 先处理 flush:宽字符可能触发换行,cursorCol 必须在 flush 之后取,
|
|
151
|
-
// 否则光标会停在上一行的末尾而非下一行的起始
|
|
152
151
|
if (ch !== undefined && ch !== "\n") {
|
|
153
152
|
const w = charWidth(ch.codePointAt(0))
|
|
154
153
|
if (col + w > avail()) flush()
|
|
@@ -171,8 +170,10 @@ export function layoutInput(chars, cursor, width) {
|
|
|
171
170
|
}
|
|
172
171
|
|
|
173
172
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
173
|
+
* Display sanitization: control characters can break terminal grid math (\r carriage return overwrite,
|
|
174
|
+
* \t width misjudgment causing entire frame misalignment, ANSI/bell screen floods).
|
|
175
|
+
* Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
|
|
176
|
+
* are also cleaned during replay.
|
|
176
177
|
*/
|
|
177
178
|
const ANSI_SEQUENCE_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g
|
|
178
179
|
export function sanitizeDisplay(s) {
|
|
@@ -185,7 +186,7 @@ export function sanitizeDisplay(s) {
|
|
|
185
186
|
.replace(/\n+$/, "")
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
/**
|
|
189
|
+
/** Wrap text by display width (preserving \n), returns array of lines */
|
|
189
190
|
export function wrapText(text, width) {
|
|
190
191
|
const lines = []
|
|
191
192
|
for (const rawLine of text.split("\n")) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* slash-commands.mjs —
|
|
3
|
-
*
|
|
2
|
+
* slash-commands.mjs — slash command definitions, dispatch, Tab completion.
|
|
3
|
+
* Each subcommand's implementation lives in its own cmd-*.mjs file; this file only does dispatch.
|
|
4
4
|
*
|
|
5
|
-
* ctx
|
|
5
|
+
* ctx object is injected by index.mjs and forwarded to each handler:
|
|
6
6
|
* { agent, state, distillOpts, pushLine, pushLabel, render,
|
|
7
7
|
* openPicker, openModelPicker, setProviderKey, runDistill,
|
|
8
8
|
* persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
|
|
@@ -47,7 +47,7 @@ export const SLASH_COMMANDS = [
|
|
|
47
47
|
{ name: "/help", group: "", desc: "this list" },
|
|
48
48
|
]
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/** Command → handler mapping table */
|
|
51
51
|
const HANDLERS = {
|
|
52
52
|
"/clear": handleClearCommand,
|
|
53
53
|
"/new": handleNewCommand,
|
|
@@ -69,17 +69,17 @@ const HANDLERS = {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* Creates the slash command processor.
|
|
73
|
+
* Returns { handleSlash, completions, handleTab }.
|
|
74
74
|
*/
|
|
75
75
|
export function createSlashCommands(ctx) {
|
|
76
76
|
const { agent, state, render } = ctx
|
|
77
|
-
//
|
|
77
|
+
// forward SLASH_COMMANDS to /help
|
|
78
78
|
const handlerCtx = { ...ctx, SLASH_COMMANDS }
|
|
79
79
|
|
|
80
80
|
async function handleSlash(text) {
|
|
81
81
|
const [cmd] = text.split(/\s+/)
|
|
82
|
-
//
|
|
82
|
+
// high-frequency command aliases
|
|
83
83
|
const aliases = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
|
|
84
84
|
const resolved = aliases[cmd] ?? cmd
|
|
85
85
|
const handler = HANDLERS[resolved]
|
|
@@ -90,18 +90,18 @@ export function createSlashCommands(ctx) {
|
|
|
90
90
|
ctx.pushLine(`Unknown command: ${cmd} (/help for available commands)`, C.error)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/** Tab
|
|
93
|
+
/** Tab completion candidates: command names / subcommands / provider names / preset names / think params */
|
|
94
94
|
function completions(input) {
|
|
95
95
|
if (!input.startsWith("/")) return []
|
|
96
96
|
const parts = input.split(/\s+/)
|
|
97
|
-
//
|
|
97
|
+
// still typing the first token: complete command names
|
|
98
98
|
if (parts.length === 1) {
|
|
99
99
|
return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
|
|
100
100
|
}
|
|
101
101
|
const cmd = parts[0]
|
|
102
|
-
const last = parts.at(-1) //
|
|
102
|
+
const last = parts.at(-1) // when trailing space, list all candidates
|
|
103
103
|
const head = parts.slice(0, -1).join(" ")
|
|
104
|
-
const argIndex = parts.length - 2 //
|
|
104
|
+
const argIndex = parts.length - 2 // which parameter is being typed (0-based)
|
|
105
105
|
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
106
106
|
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
107
107
|
if (cmd === "/think") {
|
|
@@ -117,11 +117,11 @@ export function createSlashCommands(ctx) {
|
|
|
117
117
|
return []
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** Tab
|
|
120
|
+
/** Tab: compute candidates and cycle through replacement */
|
|
121
121
|
function handleTab() {
|
|
122
122
|
const input = state.input.join("")
|
|
123
123
|
if (state.completion && input === state.completion.candidates[state.completion.index]) {
|
|
124
|
-
//
|
|
124
|
+
// previous candidate still in input box: cycle to next
|
|
125
125
|
state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
|
|
126
126
|
} else {
|
|
127
127
|
const candidates = completions(input)
|
package/src/tui/startup.mjs
CHANGED
|
@@ -2,13 +2,13 @@ import { listSlots } from "../session.mjs"
|
|
|
2
2
|
import { sliceByWidth } from "./render.mjs"
|
|
3
3
|
import { ansi, C } from "./ansi.mjs"
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
5
|
+
/** Startup screen + session recovery + background indexing.
|
|
6
|
+
* Extracted from index.mjs.
|
|
7
7
|
* ctx: { agent, state, opts, pushLine, pushLabel, render, startWizard } */
|
|
8
8
|
export function showStartup(ctx) {
|
|
9
9
|
const { agent, state, opts, pushLine, pushLabel, render, startWizard } = ctx
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// Startup screen
|
|
12
12
|
if (!agent.provider.apiKey) {
|
|
13
13
|
pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
|
|
14
14
|
pushLine("No API key configured yet — entering initial setup (Esc to skip anytime)", C.text)
|
|
@@ -18,13 +18,14 @@ export function showStartup(ctx) {
|
|
|
18
18
|
}
|
|
19
19
|
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
20
20
|
|
|
21
|
-
//
|
|
21
|
+
// Recover previous session: rebuild conversation display (tool result lines omitted, keep it clean)
|
|
22
22
|
if (opts.restored?.display?.length) {
|
|
23
|
-
//
|
|
23
|
+
// User-facing recovery: display is a WYSIWYG snapshot of the conversation before exit
|
|
24
24
|
state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
|
|
25
|
-
|
|
25
|
+
const recoveryNote = opts.restored._recovered ? " (recovered from backup)" : ""
|
|
26
|
+
pushLabel(`── Restored previous session${recoveryNote}; /new for a fresh session ──`, C.warn)
|
|
26
27
|
} else if (opts.restored?.history?.length) {
|
|
27
|
-
//
|
|
28
|
+
// Rebuild conversation: user/assistant messages shown one by one, tool result lines show only first-line summary
|
|
28
29
|
for (let i = 0; i < opts.restored.history.length; i++) {
|
|
29
30
|
const m = opts.restored.history[i]
|
|
30
31
|
if (m.role === "user") {
|
|
@@ -35,27 +36,27 @@ export function showStartup(ctx) {
|
|
|
35
36
|
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
36
37
|
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
37
38
|
for (const tc of m.tool_calls ?? []) {
|
|
38
|
-
//
|
|
39
|
+
// Find the next corresponding tool result, show first-line summary
|
|
39
40
|
const toolResult = opts.restored.history[i + 1]
|
|
40
41
|
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
41
42
|
const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
|
|
42
43
|
pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
//
|
|
46
|
+
// Tool messages aren't rendered separately — already shown as summary after assistant's tool_calls
|
|
46
47
|
}
|
|
47
48
|
pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
//
|
|
51
|
+
// Hint when archived slots exist
|
|
51
52
|
if (listSlots(agent.cwd).length > 0) {
|
|
52
53
|
pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
|
|
53
54
|
}
|
|
54
55
|
render()
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
58
|
+
/** Background indexing (runs after startup screen, non-blocking); progress shown in status bar, not conversation.
|
|
59
|
+
* Prefers git diff incremental (fast); falls back to full scan when git is unavailable or on first run. */
|
|
59
60
|
export async function backgroundIndex(ctx) {
|
|
60
61
|
const { agent, state, render } = ctx
|
|
61
62
|
const { codeSync, docSync, gitSync } = await import("../memory.mjs")
|
|
@@ -75,11 +76,11 @@ export async function backgroundIndex(ctx) {
|
|
|
75
76
|
})
|
|
76
77
|
|
|
77
78
|
if (gitRes !== null) {
|
|
78
|
-
//
|
|
79
|
+
// Git incremental succeeded, count directly
|
|
79
80
|
codeFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM code_chunks`).get()?.n ?? 0
|
|
80
81
|
docFiles = agent.memory.db.prepare(`SELECT COUNT(DISTINCT path) AS n FROM doc_chunks`).get()?.n ?? 0
|
|
81
82
|
} else {
|
|
82
|
-
//
|
|
83
|
+
// Fall back to full scan (codeSync and docSync in parallel — read/write different tables, SQLite WAL supports this natively)
|
|
83
84
|
const [codeRes, docRes] = await Promise.allSettled([
|
|
84
85
|
codeSync(agent.memory, cwd, {
|
|
85
86
|
onProgress: (p) => {
|