thincoder 0.12.19 → 0.12.20
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 +1 -1
- package/src/memory/docs.mjs +1 -1
- package/src/prompts/engineering.md +47 -21
- package/src/session.mjs +6 -7
- package/src/tools/codemode.mjs +20 -23
- package/src/tools/execute.md +2 -2
- package/src/tui/cmd-session.mjs +4 -1
- package/src/tui/index.mjs +42 -3
- package/src/tui/interaction.mjs +9 -2
- package/src/tui/key-handler.mjs +27 -2
- package/src/tui/layout.mjs +3 -1
- package/src/tui/startup.mjs +58 -27
package/package.json
CHANGED
package/src/memory/docs.mjs
CHANGED
|
@@ -227,7 +227,7 @@ export function memoryTools(memory, opts = {}) {
|
|
|
227
227
|
tags: (args.tags ?? "").split(/\s+/).filter(Boolean),
|
|
228
228
|
author: opts.author ?? "unknown",
|
|
229
229
|
})
|
|
230
|
-
return `Saved to project memory (${filename}): [${args.type}] ${args.title}
|
|
230
|
+
return `Saved to project memory (${filename}): [${args.type}] ${args.title}`
|
|
231
231
|
}
|
|
232
232
|
if (!opts.team?.dir) {
|
|
233
233
|
throw new Error("team scope not configured: set memory.team in ~/.thincoder/config.json")
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## Your Role: Designer, not Implementer
|
|
4
4
|
|
|
5
5
|
You are the ARCHITECT. In this mode your deliverables are:
|
|
6
|
-
1. the design
|
|
6
|
+
1. the requirements + design documents (docs/),
|
|
7
7
|
2. the design review (via `advisor` with `type="design"`),
|
|
8
8
|
3. the approved implementation plan handed to an eng-coder.
|
|
9
9
|
|
|
@@ -13,29 +13,39 @@ subagents only.
|
|
|
13
13
|
|
|
14
14
|
## Mandatory Flow (every task, no skipping)
|
|
15
15
|
|
|
16
|
-
1. **
|
|
16
|
+
1. **Clarify requirements.** Ask open-ended questions (see Questioning Style)
|
|
17
|
+
until who/what/why are unambiguous, then write the REQUIREMENTS doc — three
|
|
18
|
+
layers per METHODOLOGY: overall goal / functional user stories /
|
|
19
|
+
non-functional standards. Clarification is DONE when each layer is concrete
|
|
20
|
+
enough to design against (the user confirms, or the answers stop changing
|
|
21
|
+
the requirement). Do NOT start the design before this.
|
|
22
|
+
2. **Design.** Write the design document in `docs/` (problem statement,
|
|
17
23
|
solution approach, full affected-file list, verifiable acceptance criteria).
|
|
18
24
|
Do NOT open any code file for editing before this document exists.
|
|
19
|
-
|
|
25
|
+
3. **Design review.** Call `advisor` with `type="design"`, passing
|
|
20
26
|
`documents=[...]` — the explicit list of doc paths to review (requirements +
|
|
21
|
-
design + referenced docs
|
|
22
|
-
|
|
23
|
-
- If advisor finds issues: fix the design, re-submit.
|
|
27
|
+
design + referenced docs; METHODOLOGY.md is read by the advisor itself).
|
|
28
|
+
This runs a dedicated design review in an isolated context.
|
|
29
|
+
- If advisor finds issues: fix the design, re-submit.
|
|
24
30
|
- If advisor approves: it returns a design token in plain text in its response.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
4. **
|
|
31
|
+
- If the advisor keeps rejecting after 3 rounds, STOP and report the open
|
|
32
|
+
issues to the user — do not loop silently.
|
|
33
|
+
4. **User sign-off.** Present the design summary AND the advisor's findings
|
|
34
|
+
(any remaining 🟡 advisories the user should know about) and WAIT for
|
|
35
|
+
explicit approval before any implementation step.
|
|
36
|
+
5. **Implement via eng-coder.** Spawn a subagent with `role="eng-coder"`,
|
|
28
37
|
providing the METHODOLOGY task structure: the **Docs involved** list (design
|
|
29
38
|
doc + requirements + referenced docs), the file list, the acceptance
|
|
30
|
-
criteria
|
|
31
|
-
|
|
39
|
+
criteria. Pass the designToken via the `designToken` PARAMETER — never in
|
|
40
|
+
the task text. The token is required — eng-coder cannot modify files
|
|
32
41
|
without it.
|
|
33
|
-
|
|
34
|
-
the acceptance criteria from the design
|
|
35
|
-
the
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
6. **Delivery review.** After eng-coder returns, verify the delivery against
|
|
43
|
+
the acceptance criteria from the design (run the tests it claims pass, read
|
|
44
|
+
the changed files). The eng-coder self-reviewed inside the subagent — its
|
|
45
|
+
advisor(code) call happens there. Re-review with the `advisor` tool
|
|
46
|
+
(`type="code"`, `documents=[...]` = the task's Docs involved list) only when
|
|
47
|
+
the user asks or the delivery looks wrong.
|
|
48
|
+
7. **Verify.** Run `verify` — it must pass before you claim the task complete.
|
|
39
49
|
|
|
40
50
|
## Work Loop (every user message)
|
|
41
51
|
|
|
@@ -45,9 +55,9 @@ passed?
|
|
|
45
55
|
|
|
46
56
|
| State | Default action |
|
|
47
57
|
|---|---|
|
|
48
|
-
| Requirements exploration | Clarify (who/what/why — never how), explore the current state, then write the REQUIREMENTS doc — three layers per METHODOLOGY: overall goal / functional user stories / non-functional standards |
|
|
49
|
-
| Design | Write or refine the DESIGN doc (approach + rationale, architecture/interface, affected files, key decisions), organized by business domain per METHODOLOGY, ask for confirmation |
|
|
50
|
-
| Awaiting approval | Present design summary, WAIT for explicit approval |
|
|
58
|
+
| Requirements exploration | Clarify (who/what/why — never how), explore the current state, then write the REQUIREMENTS doc — three layers per METHODOLOGY: overall goal / functional user stories / non-functional standards (flow step 1) |
|
|
59
|
+
| Design | Write or refine the DESIGN doc (approach + rationale, architecture/interface, affected files, key decisions), organized by business domain per METHODOLOGY, ask for confirmation (flow steps 2-3) |
|
|
60
|
+
| Awaiting approval | Present design summary + advisor findings, WAIT for explicit approval (flow step 4) |
|
|
51
61
|
| Implementation | eng-coder is working — do not redesign in parallel |
|
|
52
62
|
| Delivery review | Verify the delivery against the acceptance criteria (the eng-coder self-reviewed inside the subagent); re-review with advisor (type="code", documents = Docs involved) only when the user asks or the delivery looks wrong; report |
|
|
53
63
|
| Wrapped up | Report, wait for next instruction |
|
|
@@ -68,7 +78,23 @@ Then handle the message:
|
|
|
68
78
|
|
|
69
79
|
End every turn with three checks: ① decisions written to docs? ② current state
|
|
70
80
|
named and next step stated? ③ what the user must do (approve / clarify / continue)?
|
|
71
|
-
No code edits outside approved minor fixes
|
|
81
|
+
No code edits outside approved minor fixes (typos in docs you own, etc. —
|
|
82
|
+
never implementation code). No unprompted advisor calls.
|
|
83
|
+
|
|
84
|
+
## Questioning Style (requirement clarification)
|
|
85
|
+
|
|
86
|
+
Clarify with OPEN-ENDED questions — the user's own words carry constraints you
|
|
87
|
+
cannot enumerate. When using the `question` tool:
|
|
88
|
+
|
|
89
|
+
- Default to free text (no `options`). "What should X do when…?" invites the
|
|
90
|
+
real answer; a preset list can only contain what you already guessed.
|
|
91
|
+
- Use `options` ONLY for finite enumerations: choose a tech stack, pick A/B/C,
|
|
92
|
+
select from a closed set. (The UI always offers a custom-answer channel, so
|
|
93
|
+
a preset list never blocks a written answer.)
|
|
94
|
+
- Ask ONE question per tool call; wait for the answer before asking the next.
|
|
95
|
+
Chain questions in sequence: each answer drives the next question.
|
|
96
|
+
- Never make the user fight the UI: if a question needs explanation or nuance,
|
|
97
|
+
free text, not a multiple-choice guess.
|
|
72
98
|
|
|
73
99
|
## Hard Rules
|
|
74
100
|
|
package/src/session.mjs
CHANGED
|
@@ -317,8 +317,11 @@ export { isLegacyTransient }
|
|
|
317
317
|
|
|
318
318
|
// ========== core read/write ==========
|
|
319
319
|
|
|
320
|
-
/** Save agent state
|
|
321
|
-
|
|
320
|
+
/** Save agent state to the active slot file (atomic write). `display` (the old
|
|
321
|
+
* WYSIWYG render snapshot) is DEPRECATED — it drifted out of sync with history
|
|
322
|
+
* whenever VS Code wrote the slot, and the TUI resumed from a stale snapshot.
|
|
323
|
+
* Restore now always rebuilds from history (lazy, see startup.mjs). */
|
|
324
|
+
export function saveSession(agent) {
|
|
322
325
|
// _fullHistory is written at the source via pushReal — no flush needed here.
|
|
323
326
|
// history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
|
|
324
327
|
// contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
|
|
@@ -333,7 +336,6 @@ export function saveSession(agent, display) {
|
|
|
333
336
|
updatedAt: Date.now(),
|
|
334
337
|
history,
|
|
335
338
|
contextHistory,
|
|
336
|
-
display: display ?? [],
|
|
337
339
|
tasks: agent.tasks ?? [],
|
|
338
340
|
planMode: agent.planMode ?? false,
|
|
339
341
|
autoApprove: agent.autoApprove ?? false,
|
|
@@ -367,9 +369,6 @@ export function loadSession(cwd) {
|
|
|
367
369
|
if (!Array.isArray(data.history)) return null
|
|
368
370
|
if (data.cwd && data.cwd.toLowerCase() !== cwd.toLowerCase()) return null
|
|
369
371
|
data.history = data.history.filter((m) => !isLegacyTransient(m))
|
|
370
|
-
data.display = Array.isArray(data.display)
|
|
371
|
-
? data.display.filter((l) => l && typeof l.text === "string").map((l) => ({ text: l.text, color: l.color }))
|
|
372
|
-
: []
|
|
373
372
|
data._recovered = false
|
|
374
373
|
return data
|
|
375
374
|
} catch (e) {
|
|
@@ -473,7 +472,7 @@ export function newSession(cwd) {
|
|
|
473
472
|
while (m.slots[slot]) slot++
|
|
474
473
|
|
|
475
474
|
// Write empty session
|
|
476
|
-
const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], tasks: [],
|
|
475
|
+
const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], tasks: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
|
|
477
476
|
writeSessionFile(slotPath(cwd, slot), data)
|
|
478
477
|
m.slots[slot] = slotDigest(data)
|
|
479
478
|
m.active = slot
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -1,36 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tools/codemode.mjs — CodeMode:
|
|
2
|
+
* tools/codemode.mjs — CodeMode: JavaScript execution tool
|
|
3
3
|
*
|
|
4
4
|
* Gives the model an `execute` tool backed by Node.js vm.Script.runInNewContext.
|
|
5
5
|
* Multiple tool calls can be composed into a single script, reducing API round-trips
|
|
6
6
|
* and keeping large intermediate results out of context.
|
|
7
7
|
*
|
|
8
|
-
* Sandbox API
|
|
8
|
+
* Sandbox API:
|
|
9
9
|
* readFile(path) — read a file relative to cwd, return string
|
|
10
10
|
* writeFile(path, c) — write content to a file (auto-creates parent dirs)
|
|
11
11
|
* glob(pattern) — return array of matching paths
|
|
12
12
|
* grep(pattern, file) — return array of matching lines
|
|
13
13
|
* log(...args) — append to output buffer
|
|
14
|
-
* fetch(url) — HTTP GET, return string
|
|
14
|
+
* fetch(url) — HTTP GET, return string
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* Full Node access via require()/process — no fake sandbox. The bash tool can
|
|
17
|
+
* already reach any Node API, so blocking require here only misled the model
|
|
18
|
+
* about its real capability boundary (project philosophy: no command-level
|
|
19
|
+
* sandbox; transparency + trust + audit).
|
|
17
20
|
*
|
|
18
|
-
* Limits:
|
|
21
|
+
* Limits (engineering guards, not security):
|
|
19
22
|
* timeout: 30s (configurable via timeoutMs param)
|
|
20
23
|
* maxOutput: 50000 bytes
|
|
21
24
|
* maxScriptSize: 50000 bytes
|
|
25
|
+
* file paths confined to cwd (accidental out-of-workspace writes)
|
|
22
26
|
*/
|
|
23
27
|
|
|
24
28
|
import { Script, createContext } from "node:vm"
|
|
29
|
+
import { createRequire } from "node:module"
|
|
25
30
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
26
31
|
import { join, dirname, relative, resolve } from "node:path"
|
|
27
|
-
import { DESC, globToRegex, normalizeEOL
|
|
32
|
+
import { DESC, globToRegex, normalizeEOL } from "./shared.mjs"
|
|
28
33
|
|
|
29
34
|
const MAX_OUTPUT = 50_000
|
|
30
35
|
const MAX_SCRIPT = 50_000
|
|
31
36
|
const DEFAULT_TIMEOUT = 30_000
|
|
32
37
|
|
|
33
|
-
/**
|
|
38
|
+
/** fetch: only http/https (protocol guard kept; no private-host rejection — the
|
|
39
|
+
* bash tool can reach anything anyway, so the SSRF check was a fake boundary).
|
|
34
40
|
* Validation throws SYNCHRONOUSLY so the vm sandbox's try/catch can catch it —
|
|
35
41
|
* an async throw here would become an unhandled rejection and crash the host process,
|
|
36
42
|
* and the rejection message would never reach the model. */
|
|
@@ -39,10 +45,6 @@ function sandboxFetch(url) {
|
|
|
39
45
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
40
46
|
throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
|
|
41
47
|
}
|
|
42
|
-
|
|
43
|
-
if (isPrivateHost(parsed.hostname)) {
|
|
44
|
-
throw new Error(`CodeMode fetch: private/internal host not allowed: ${parsed.hostname}`)
|
|
45
|
-
}
|
|
46
48
|
return doFetch(url)
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -66,7 +68,7 @@ export const codeModeTool = {
|
|
|
66
68
|
properties: {
|
|
67
69
|
code: {
|
|
68
70
|
type: "string",
|
|
69
|
-
description: "JavaScript code to execute
|
|
71
|
+
description: "JavaScript code to execute. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args). require()/process/Node modules are available.",
|
|
70
72
|
},
|
|
71
73
|
timeoutMs: {
|
|
72
74
|
type: "integer",
|
|
@@ -88,12 +90,10 @@ export const codeModeTool = {
|
|
|
88
90
|
const output = []
|
|
89
91
|
const timeoutMs = Math.min(args.timeoutMs ?? DEFAULT_TIMEOUT, 60_000)
|
|
90
92
|
|
|
91
|
-
// File path guard: ensure paths are within cwd
|
|
93
|
+
// File path guard: ensure paths are within cwd (accidental out-of-workspace writes)
|
|
92
94
|
function safePath(p) {
|
|
93
95
|
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
94
|
-
// Normalize and resolve
|
|
95
96
|
const abs = resolve(cwd, p)
|
|
96
|
-
// Check containment
|
|
97
97
|
const rel = relative(cwd, abs)
|
|
98
98
|
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
99
99
|
throw new Error(`Path traversal denied: ${p}`)
|
|
@@ -101,11 +101,6 @@ export const codeModeTool = {
|
|
|
101
101
|
return abs
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// Block dynamic imports: check for import() syntax before execution
|
|
105
|
-
if (/\bimport\s*\(/.test(code)) {
|
|
106
|
-
return "Error: dynamic import() is not allowed in CodeMode sandbox. Use the provided readFile/writeFile/glob/grep/fetch functions instead."
|
|
107
|
-
}
|
|
108
|
-
|
|
109
104
|
const sandbox = createContext({
|
|
110
105
|
readFile: (p) => {
|
|
111
106
|
const abs = safePath(p)
|
|
@@ -159,9 +154,11 @@ export const codeModeTool = {
|
|
|
159
154
|
}
|
|
160
155
|
},
|
|
161
156
|
fetch: sandboxFetch,
|
|
162
|
-
//
|
|
163
|
-
require: (
|
|
164
|
-
process
|
|
157
|
+
// Full Node access — no fake sandbox (bash can reach it anyway).
|
|
158
|
+
require: createRequire(join(cwd, "__codemode__.js")),
|
|
159
|
+
process,
|
|
160
|
+
setTimeout,
|
|
161
|
+
clearTimeout,
|
|
165
162
|
})
|
|
166
163
|
|
|
167
164
|
try {
|
package/src/tools/execute.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
Execute
|
|
1
|
+
Execute JavaScript code with full Node access. Use this to compose multiple file operations into one call — read, write, glob, grep, log, or require() any module. Max 30s timeout, 50KB output.
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
|
-
- code (required): JavaScript code to execute
|
|
4
|
+
- code (required): JavaScript code to execute. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args). require()/process/Node modules are available.
|
|
5
5
|
- timeoutMs: Timeout in milliseconds (default 30000, max 60000)
|
package/src/tui/cmd-session.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { listSlots, switchToSlot, applySession } from "../session.mjs"
|
|
2
2
|
import { ansi, C } from "./ansi.mjs"
|
|
3
|
+
import { restoreLines } from "./startup.mjs"
|
|
3
4
|
|
|
4
5
|
/** /session command: list/switch session slots.
|
|
5
6
|
* ctx: { agent, state, showPicker, pushLine, pushLabel, render } */
|
|
@@ -38,7 +39,9 @@ export async function handleSessionCommand(ctx) {
|
|
|
38
39
|
return
|
|
39
40
|
}
|
|
40
41
|
applySession(agent, data)
|
|
41
|
-
|
|
42
|
+
// Rebuild from history (lazy) — the display snapshot is deprecated.
|
|
43
|
+
state.lines = []
|
|
44
|
+
restoreLines(state, data.history)
|
|
42
45
|
state.tasks = agent.tasks ?? []
|
|
43
46
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
44
47
|
state.tasks = []
|
package/src/tui/index.mjs
CHANGED
|
@@ -31,7 +31,8 @@ import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, trans
|
|
|
31
31
|
import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
|
|
32
32
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
33
33
|
import { createKeyHandler } from "./key-handler.mjs"
|
|
34
|
-
import { showStartup, backgroundIndex } from "./startup.mjs"
|
|
34
|
+
import { showStartup, backgroundIndex, historyToLines, HISTORY_PAGE_MESSAGES } from "./startup.mjs"
|
|
35
|
+
import { countConvLines } from "./render-conversation.mjs"
|
|
35
36
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
36
37
|
|
|
37
38
|
/** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
|
|
@@ -92,6 +93,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
92
93
|
expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
|
|
93
94
|
foldEnabled: true, // global fold toggle — /fold on|off
|
|
94
95
|
exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
|
|
96
|
+
// Lazy history window (parity with VS Code): only the latest messages are
|
|
97
|
+
// materialized on restore; PgUp-at-top loads earlier pages via loadOlder.
|
|
98
|
+
_historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
|
|
99
|
+
_historyTotal: 0, // total messages in the restored session
|
|
100
|
+
_hasOlder: false, // more earlier messages remain unloaded
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
|
@@ -221,7 +227,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
221
227
|
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
222
228
|
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
223
229
|
try {
|
|
224
|
-
saveSession(agent
|
|
230
|
+
saveSession(agent)
|
|
225
231
|
} catch {
|
|
226
232
|
// Save failure shouldn't block exit
|
|
227
233
|
}
|
|
@@ -252,6 +258,39 @@ export async function startTUI(agent, opts = {}) {
|
|
|
252
258
|
render()
|
|
253
259
|
}
|
|
254
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Load the next earlier page of restored history (lazy, parity with VS Code
|
|
263
|
+
* loadOlder). Prepends earlier source lines and compensates scroll so the
|
|
264
|
+
* visible content does not jump. Called from key-handler when PgUp hits the top.
|
|
265
|
+
*/
|
|
266
|
+
const loadOlder = () => {
|
|
267
|
+
if (!state._hasOlder) return
|
|
268
|
+
const full = agent._fullHistory ?? []
|
|
269
|
+
const loaded = state._historyLoaded
|
|
270
|
+
const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
|
|
271
|
+
const end = full.length - loaded
|
|
272
|
+
if (start >= end) return
|
|
273
|
+
|
|
274
|
+
const cols = process.stdout.columns || 80
|
|
275
|
+
const before = countConvLines(state, cols)
|
|
276
|
+
|
|
277
|
+
// Drop the old placeholder, prepend the older page, re-add the placeholder
|
|
278
|
+
// (with an updated count) only if more remain.
|
|
279
|
+
if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
|
|
280
|
+
state.lines.unshift(...historyToLines(full, start, end))
|
|
281
|
+
state._historyLoaded += end - start
|
|
282
|
+
state._hasOlder = start > 0
|
|
283
|
+
if (state._hasOlder) {
|
|
284
|
+
state.lines.unshift({ text: `… ${start} more earlier messages (PgUp at top to load)`, color: C.dim })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Scroll compensation: prepending N display rows must move scroll by N to
|
|
288
|
+
// keep the previously-visible bottom-anchored content in place.
|
|
289
|
+
const after = countConvLines(state, cols)
|
|
290
|
+
state.scroll += Math.max(0, after - before)
|
|
291
|
+
render()
|
|
292
|
+
}
|
|
293
|
+
|
|
255
294
|
// Only emit the assistant label once per turn (on first token or first tool call)
|
|
256
295
|
let assistantLabeled = false
|
|
257
296
|
const ensureAssistantLabel = () => {
|
|
@@ -380,7 +419,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
380
419
|
agent, state, render, popPicker, renderPickerLines,
|
|
381
420
|
handleSlash, handleTab, submit, pasteClipboardImage,
|
|
382
421
|
wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
383
|
-
renderWizard, pushLine, cleanup, showPicker,
|
|
422
|
+
renderWizard, pushLine, cleanup, showPicker, loadOlder,
|
|
384
423
|
})
|
|
385
424
|
keyStream.on("keypress", (str, key) => {
|
|
386
425
|
try {
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -4,6 +4,11 @@ import { detectDanger } from "../tools/shared.mjs"
|
|
|
4
4
|
/** Interaction primitives: permission approval + question input.
|
|
5
5
|
* Extracted from index.mjs, receives closure dependencies via createInteraction(ctx).
|
|
6
6
|
* ctx: { agent, state, pushLine, pushLabel, render, summarize } */
|
|
7
|
+
|
|
8
|
+
/** Sentinel appended to question option lists — selecting it switches to free-text
|
|
9
|
+
* answer mode (the user supplements/corrects the AI's preset choices). */
|
|
10
|
+
export const QUESTION_CUSTOM = "\u0001custom-answer"
|
|
11
|
+
|
|
7
12
|
export function createInteraction(ctx) {
|
|
8
13
|
const { agent, state, pushLine, pushLabel, render, summarize } = ctx
|
|
9
14
|
|
|
@@ -74,11 +79,13 @@ export function createInteraction(ctx) {
|
|
|
74
79
|
render()
|
|
75
80
|
})
|
|
76
81
|
}
|
|
77
|
-
// options mode: show list in input box, arrow keys to select, Enter to confirm
|
|
82
|
+
// options mode: show list in input box, arrow keys to select, Enter to confirm.
|
|
83
|
+
// A "custom answer" sentinel is appended so the user can always type their own
|
|
84
|
+
// answer instead of picking a preset — the AI's options are never exhaustive.
|
|
78
85
|
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
79
86
|
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
80
87
|
return new Promise((resolve) => {
|
|
81
|
-
state.question = { text, options, selected: 0, resolve }
|
|
88
|
+
state.question = { text, options: [...options, QUESTION_CUSTOM], selected: 0, resolve }
|
|
82
89
|
state.status = "Waiting for choice..."
|
|
83
90
|
render()
|
|
84
91
|
})
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -2,6 +2,16 @@ import { ansi, C } from "./ansi.mjs"
|
|
|
2
2
|
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
3
|
import { computeLayout } from "./layout.mjs"
|
|
4
4
|
import { handleSearchKey } from "./key-handler-search.mjs"
|
|
5
|
+
import { countConvLines } from "./render-conversation.mjs"
|
|
6
|
+
import { QUESTION_CUSTOM } from "./interaction.mjs"
|
|
7
|
+
|
|
8
|
+
/** Current conversation max scroll offset (display lines beyond the visible panel). */
|
|
9
|
+
function convMaxScroll(state) {
|
|
10
|
+
const cols = process.stdout.columns || 80
|
|
11
|
+
const rows = process.stdout.rows || 24
|
|
12
|
+
const layout = computeLayout(state, { cols, rows })
|
|
13
|
+
return Math.max(0, countConvLines(state, cols) - layout.panels.conversation.h)
|
|
14
|
+
}
|
|
5
15
|
|
|
6
16
|
/** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
|
|
7
17
|
* Extracted from index.mjs.
|
|
@@ -10,7 +20,7 @@ import { handleSearchKey } from "./key-handler-search.mjs"
|
|
|
10
20
|
* wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
11
21
|
* renderWizard, pushLine, cleanup, showPicker } */
|
|
12
22
|
export function createKeyHandler(ctx) {
|
|
13
|
-
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker } = ctx
|
|
23
|
+
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker, loadOlder } = ctx
|
|
14
24
|
|
|
15
25
|
return function onKeypress(str, key = {}) {
|
|
16
26
|
// permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
|
|
@@ -58,6 +68,15 @@ export function createKeyHandler(ctx) {
|
|
|
58
68
|
render()
|
|
59
69
|
} else if (key.name === "return") {
|
|
60
70
|
const answer = q.options[q.selected ?? 0]
|
|
71
|
+
if (answer === QUESTION_CUSTOM) {
|
|
72
|
+
// Switch to free-text mode — the user wants to type their own answer.
|
|
73
|
+
q.options = []
|
|
74
|
+
q.answer = ""
|
|
75
|
+
q.selected = undefined
|
|
76
|
+
state.status = "Waiting for answer..."
|
|
77
|
+
render()
|
|
78
|
+
return
|
|
79
|
+
}
|
|
61
80
|
q.resolve(answer)
|
|
62
81
|
state.question = null
|
|
63
82
|
state.status = "Processing..."
|
|
@@ -268,7 +287,13 @@ export function createKeyHandler(ctx) {
|
|
|
268
287
|
|
|
269
288
|
// page scroll
|
|
270
289
|
if (key.name === "pageup") {
|
|
271
|
-
|
|
290
|
+
// At the top of the conversation → load the next earlier history page
|
|
291
|
+
// (lazy restore, parity with VS Code's scroll-back loadOlder).
|
|
292
|
+
if (state._hasOlder && loadOlder && state.scroll >= convMaxScroll(state)) {
|
|
293
|
+
loadOlder()
|
|
294
|
+
} else {
|
|
295
|
+
state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
|
|
296
|
+
}
|
|
272
297
|
render()
|
|
273
298
|
return
|
|
274
299
|
}
|
package/src/tui/layout.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
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
|
+
import { QUESTION_CUSTOM } from "./interaction.mjs"
|
|
11
12
|
|
|
12
13
|
const MAX_INPUT_LINES = 5
|
|
13
14
|
const MAX_TASK_LINES = 5
|
|
@@ -36,7 +37,8 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
36
37
|
if (q.options.length > 0) {
|
|
37
38
|
const sel = q.selected ?? 0
|
|
38
39
|
const start = Math.max(0, Math.min(sel - 2, q.options.length - QWIN))
|
|
39
|
-
boxLines = q.options.slice(start, start + QWIN).map((opt, i) =>
|
|
40
|
+
boxLines = q.options.slice(start, start + QWIN).map((opt, i) =>
|
|
41
|
+
(start + i === sel ? "▸ " : " ") + (opt === QUESTION_CUSTOM ? "✍ Custom answer…" : opt))
|
|
40
42
|
} else {
|
|
41
43
|
boxLines = ["▸ " + (q.answer ?? "")]
|
|
42
44
|
}
|
package/src/tui/startup.mjs
CHANGED
|
@@ -2,6 +2,60 @@ import { listSlots } from "../session.mjs"
|
|
|
2
2
|
import { sliceByWidth } from "./render.mjs"
|
|
3
3
|
import { ansi, C } from "./ansi.mjs"
|
|
4
4
|
|
|
5
|
+
/** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
|
|
6
|
+
* the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
|
|
7
|
+
* more. Rebuilding an 8000-message session eagerly froze startup + first render. */
|
|
8
|
+
export const INITIAL_HISTORY_MESSAGES = 200
|
|
9
|
+
export const HISTORY_PAGE_MESSAGES = 50
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert history[startIdx, endIdx) into conversation source lines (label lines
|
|
13
|
+
* with a leading blank separator, content lines, tool summaries). `history` is the
|
|
14
|
+
* FULL array so the tool-result lookahead (history[i+1]) works across the page edge.
|
|
15
|
+
*/
|
|
16
|
+
export function historyToLines(history, startIdx, endIdx) {
|
|
17
|
+
const lines = []
|
|
18
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
19
|
+
const m = history[i]
|
|
20
|
+
if (m.role === "user") {
|
|
21
|
+
if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
|
|
22
|
+
if (lines.length > 0) lines.push({ text: "", color: C.dim })
|
|
23
|
+
lines.push({ text: "❯ You:", color: ansi.bold + C.user })
|
|
24
|
+
if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
|
|
25
|
+
} else if (m.role === "assistant") {
|
|
26
|
+
if (lines.length > 0) lines.push({ text: "", color: C.dim })
|
|
27
|
+
lines.push({ text: "❯ ThinCoder:", color: ansi.bold + C.assistant })
|
|
28
|
+
if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
|
|
29
|
+
for (const tc of m.tool_calls ?? []) {
|
|
30
|
+
const toolResult = history[i + 1]
|
|
31
|
+
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
32
|
+
const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
|
|
33
|
+
lines.push({ text: ` [tool] ${tc.function?.name ?? "?"}${summary}`, color: C.tool })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return lines
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lazy-restore a session into state.lines: materialize only the latest
|
|
43
|
+
* INITIAL_HISTORY_MESSAGES, set the _history* counters the loadOlder closure
|
|
44
|
+
* reads, and prepend the "… N more earlier messages" placeholder. Shared by
|
|
45
|
+
* startup restore and /session switching (the display snapshot is deprecated).
|
|
46
|
+
*/
|
|
47
|
+
export function restoreLines(state, history) {
|
|
48
|
+
const total = Array.isArray(history) ? history.length : 0
|
|
49
|
+
if (total === 0) return
|
|
50
|
+
const start = Math.max(0, total - INITIAL_HISTORY_MESSAGES)
|
|
51
|
+
state.lines.push(...historyToLines(history, start, total))
|
|
52
|
+
state._historyLoaded = total - start
|
|
53
|
+
state._historyTotal = total
|
|
54
|
+
state._hasOlder = start > 0
|
|
55
|
+
if (state._hasOlder) {
|
|
56
|
+
state.lines.unshift({ text: `… ${start} more earlier messages (PgUp at top to load)`, color: C.dim })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
5
59
|
/** Startup screen + session recovery + background indexing.
|
|
6
60
|
* Extracted from index.mjs.
|
|
7
61
|
* ctx: { agent, state, opts, pushLine, pushLabel, render, startWizard } */
|
|
@@ -18,33 +72,10 @@ export function showStartup(ctx) {
|
|
|
18
72
|
}
|
|
19
73
|
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
20
74
|
|
|
21
|
-
// Recover previous session: rebuild
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
state
|
|
25
|
-
const recoveryNote = opts.restored._recovered ? " (recovered from backup)" : ""
|
|
26
|
-
pushLabel(`── Restored previous session${recoveryNote}; /new for a fresh session ──`, C.warn)
|
|
27
|
-
} else if (opts.restored?.history?.length) {
|
|
28
|
-
// Rebuild conversation: user/assistant messages shown one by one, tool result lines show only first-line summary
|
|
29
|
-
for (let i = 0; i < opts.restored.history.length; i++) {
|
|
30
|
-
const m = opts.restored.history[i]
|
|
31
|
-
if (m.role === "user") {
|
|
32
|
-
if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
|
|
33
|
-
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
34
|
-
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
35
|
-
} else if (m.role === "assistant") {
|
|
36
|
-
pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
|
|
37
|
-
if (typeof m.content === "string" && m.content) pushLine(m.content, C.text)
|
|
38
|
-
for (const tc of m.tool_calls ?? []) {
|
|
39
|
-
// Find the next corresponding tool result, show first-line summary
|
|
40
|
-
const toolResult = opts.restored.history[i + 1]
|
|
41
|
-
const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
|
|
42
|
-
const summary = hasResult ? " → " + sliceByWidth(String(toolResult.content).split("\n")[0], 80) : ""
|
|
43
|
-
pushLine(` [tool] ${tc.function?.name ?? "?"}${summary}`, C.tool)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
// Tool messages aren't rendered separately — already shown as summary after assistant's tool_calls
|
|
47
|
-
}
|
|
75
|
+
// Recover previous session: rebuild from history (lazy — display snapshot is
|
|
76
|
+
// deprecated; it drifted out of sync with history on VS Code writes).
|
|
77
|
+
if (opts.restored?.history?.length) {
|
|
78
|
+
restoreLines(state, opts.restored.history)
|
|
48
79
|
pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
|
|
49
80
|
}
|
|
50
81
|
|