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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.19",
3
+ "version": "0.12.20",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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}\nNote: file written to the repo; commit it yourself when ready.`
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 document (docs/),
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. **Design first.** Write the design document in `docs/` (problem statement,
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
- 2. **Design review.** Call `advisor` with `type="design"` to review it, passing
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). The advisor reviews ONLY those docs; it does not
22
- scan git diff. This runs a dedicated design review in an isolated context.
23
- - If advisor finds issues: fix the design, re-submit. Repeat until advisor approves.
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
- 3. **User sign-off.** Present the design to the user and WAIT for explicit
26
- approval before any implementation step.
27
- 4. **Implement via eng-coder.** Spawn a subagent with `role="eng-coder"`,
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 AND the designToken verbatim (the exact token string from the
31
- advisor output). The token is required — eng-coder cannot modify files
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
- 5. **Delivery review.** After eng-coder returns, verify the delivery against
34
- the acceptance criteria from the design. The eng-coder self-reviewed inside
35
- the subagent its advisor(code) call happens there. Re-review with the
36
- `advisor` tool (`type="code"`, `documents=[...]` = the task's Docs involved
37
- list) only when the user asks or the delivery looks wrong.
38
- 6. **Verify.** Run `verify` it must pass before you claim the task complete.
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. No unprompted advisor calls.
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 and display lines to the active slot file (atomic write) */
321
- export function saveSession(agent, display) {
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: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
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
@@ -1,36 +1,42 @@
1
1
  /**
2
- * tools/codemode.mjs — CodeMode: sandboxed JS execution tool
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 (all sync, no callbacks):
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 (SSRF-protected)
14
+ * fetch(url) — HTTP GET, return string
15
15
  *
16
- * Not available: require, import, process, child_process, setTimeout, any Node API.
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, isPrivateHost } from "./shared.mjs"
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
- /** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout.
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 in the sandbox. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args).",
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
- // Block process and require access
163
- require: () => { throw new Error("require() is not available in CodeMode sandbox") },
164
- process: undefined,
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 {
@@ -1,5 +1,5 @@
1
- Execute sandboxed JavaScript code. Use this to compose multiple file operations into one call — read, write, glob, grep, and log results. No network or system access. Max 30s timeout, 50KB output.
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 in the sandbox. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args).
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)
@@ -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
- state.lines = data.display.length ? [...data.display] : []
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, state.lines)
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 {
@@ -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
  })
@@ -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
- state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
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
  }
@@ -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) => (start + i === sel ? "▸ " : " ") + opt)
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
  }
@@ -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 conversation display (tool result lines omitted, keep it clean)
22
- if (opts.restored?.display?.length) {
23
- // User-facing recovery: display is a WYSIWYG snapshot of the conversation before exit
24
- state.lines = [...opts.restored.display.map((l) => ({ text: l.text, color: l.color })), ...state.lines]
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