thincoder 0.12.3 → 0.12.5

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/src/session.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * session.mjs — session persistence (slot-based model)
3
- * Each project (keyed by cwd hash) keeps up to 5 session slots.
3
+ * Each project (keyed by cwd hash) keeps unlimited session slots.
4
4
  * Every session lives in a numbered slot; the manifest tracks which slot is active.
5
5
  * There is no separate "current" file — the active slot IS the current session.
6
6
  *
7
- * File layout: {hash}.json.1~5 (slots), {hash}.json.manifest (slot metadata + active pointer).
7
+ * File layout: {hash}.json.N (slots), {hash}.json.manifest (slot metadata + active pointer).
8
8
  * Legacy {hash}.json is migrated to a slot on first access.
9
9
  */
10
10
 
@@ -14,9 +14,6 @@ import { join, dirname } from "node:path"
14
14
  import { execSync } from "node:child_process"
15
15
  import { configDir } from "./config.mjs"
16
16
 
17
- const MAX_SLOTS = 5
18
- const CWD_HASH_LEN = 12
19
-
20
17
  let currentSessionId = null
21
18
 
22
19
  /** Generate unique session ID for this process */
@@ -27,9 +24,36 @@ export function getSessionId() {
27
24
  return currentSessionId
28
25
  }
29
26
 
30
- /** Derive base session path from cwd hash (legacy, kept for migration and tests) */
27
+ /** Normalize cwd for hashing: uppercase Windows drive letter so both ends
28
+ * (CLI's process.cwd() vs VS Code's uri.fsPath, which lowercases it) agree. */
29
+ export function normalizeCwd(cwd) {
30
+ return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
31
+ }
32
+
33
+ /** Full sha1 hex (40 chars), not truncated. Shared contract with the VS Code extension. */
34
+ function cwdHash(cwd) {
35
+ return createHash("sha1").update(normalizeCwd(cwd)).digest("hex")
36
+ }
37
+
38
+ /** One-time migration: rename legacy 12-char-hash session files to the full 40-char hash.
39
+ * Idempotent; runs on first access per cwd. */
40
+ function migrateHashLength(cwd, fullHash) {
41
+ const dir = join(configDir, "sessions")
42
+ const legacyBase = join(dir, `${fullHash.slice(0, 12)}.json`)
43
+ if (!existsSync(legacyBase) && !existsSync(`${legacyBase}.manifest`) && !existsSync(`${legacyBase}.1`)) return
44
+ const newBase = join(dir, `${fullHash}.json`)
45
+ try {
46
+ for (const suffix of ["", ".manifest", ...Array.from({ length: 64 }, (_, i) => `.${i + 1}`)]) {
47
+ const from = legacyBase + suffix
48
+ if (existsSync(from) && !existsSync(newBase + suffix)) renameSync(from, newBase + suffix)
49
+ }
50
+ } catch { /* best-effort; leave files in place on failure */ }
51
+ }
52
+
53
+ /** Derive base session path from cwd hash. Migrates legacy short-hash files on first access. */
31
54
  export function sessionPath(cwd) {
32
- const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
55
+ const hash = cwdHash(cwd)
56
+ migrateHashLength(cwd, hash)
33
57
  return join(configDir, "sessions", `${hash}.json`)
34
58
  }
35
59
 
@@ -72,7 +96,7 @@ function isRealUserMsg(m) {
72
96
  }
73
97
 
74
98
  /** Extract slot metadata from history (shared by slotDigest and loadSlotMeta) */
75
- function extractSlotMeta(history, activeProvider, updatedAt) {
99
+ function extractSlotMeta(history, activeProvider, updatedAt, title = "") {
76
100
  const userMsgs = history.filter(isRealUserMsg)
77
101
  const first = userMsgs[0]?.content ?? ""
78
102
  return {
@@ -81,12 +105,13 @@ function extractSlotMeta(history, activeProvider, updatedAt) {
81
105
  firstMessage: first.slice(0, 80),
82
106
  activeProvider: activeProvider ?? "",
83
107
  updatedAt: updatedAt ?? Date.now(),
108
+ title,
84
109
  }
85
110
  }
86
111
 
87
112
  /** Extract preview summary from session data for manifest storage (with current timestamp) */
88
113
  function slotDigest(data) {
89
- const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt)
114
+ const meta = extractSlotMeta(data.history ?? [], data.activeProvider, data.updatedAt, data.title ?? "")
90
115
  return { ts: Date.now(), ...meta }
91
116
  }
92
117
 
@@ -109,59 +134,50 @@ function saveManifest(cwd, m) {
109
134
  * Ensure an active slot exists in the manifest, migrating legacy data if needed.
110
135
  * Called by activeSlot() — idempotent, safe to call repeatedly.
111
136
  */
137
+ /**
138
+ * Claim a slot for this process and set it as active. Idempotent.
139
+ * Preference order:
140
+ * 1. The current active slot, if it is unowned / ours / its owner is dead — reuse it.
141
+ * 2. Any slot that is unowned or owned by a dead process (reclaim).
142
+ * 3. A brand-new slot when all are owned by live processes.
143
+ * The owner is recorded in m.slotSessions so other processes (CLI ↔ VS Code) can
144
+ * see which slots are taken and avoid them.
145
+ */
112
146
  function ensureActive(cwd, m) {
113
147
  const mySessionId = getSessionId()
114
-
115
- // Initialize slotSessions if not present
116
148
  if (!m.slotSessions) m.slotSessions = {}
117
-
118
- // Check if we already own the active slot
119
- if (m.active && m.slotSessions[m.active] === mySessionId) {
149
+
150
+ // Already own the active slot — nothing to do.
151
+ if (m.active && m.slotSessions[m.active] === mySessionId) return
152
+
153
+ const isFree = (slot) => {
154
+ const owner = m.slotSessions[slot]
155
+ if (!owner || owner === mySessionId) return true
156
+ return !isProcessAlive(parseInt(owner.split('-')[0]))
157
+ }
158
+
159
+ // 1. Prefer the current active slot if we can take it (preserves "resume where you left off").
160
+ if (m.active && m.slots[m.active] && isFree(m.active)) {
161
+ m.slotSessions[m.active] = mySessionId
162
+ saveManifest(cwd, m)
120
163
  return
121
164
  }
122
-
123
- // Try to find an available slot:
124
- // 1. Empty slots (not in slotSessions)
125
- // 2. Slots owned by dead processes (check if PID is still running)
126
- // 3. Oldest slot if all are busy
127
-
165
+
166
+ // 2. Otherwise claim the first slot that is free.
128
167
  const allSlots = Object.keys(m.slots).filter(n => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b)
129
-
130
- // Find first empty or dead slot
131
168
  for (const slot of allSlots) {
132
- const ownerSessionId = m.slotSessions[slot]
133
- if (!ownerSessionId) {
134
- // Empty slot - claim it
169
+ if (isFree(slot)) {
135
170
  m.active = slot
136
171
  m.slotSessions[slot] = mySessionId
137
172
  saveManifest(cwd, m)
138
173
  return
139
174
  }
140
- if (ownerSessionId !== mySessionId) {
141
- // Check if owner process is still alive
142
- const ownerPid = parseInt(ownerSessionId.split('-')[0])
143
- if (!isProcessAlive(ownerPid)) {
144
- // Dead process - reclaim slot
145
- m.active = slot
146
- m.slotSessions[slot] = mySessionId
147
- saveManifest(cwd, m)
148
- return
149
- }
150
- }
151
175
  }
152
-
153
- // All slots busy - create new slot if under limit, otherwise use oldest
154
- if (allSlots.length < MAX_SLOTS) {
155
- const newSlot = allSlots.length > 0 ? Math.max(...allSlots) + 1 : 1
156
- m.active = newSlot
157
- m.slotSessions[newSlot] = mySessionId
158
- saveManifest(cwd, m)
159
- return
160
- }
161
-
162
- // All slots busy and at limit - use oldest slot (smallest number)
163
- m.active = allSlots[0]
164
- m.slotSessions[m.active] = mySessionId
176
+
177
+ // 3. All slots owned by live processes allocate a new one (no limit).
178
+ const newSlot = allSlots.length > 0 ? Math.max(...allSlots) + 1 : 1
179
+ m.active = newSlot
180
+ m.slotSessions[newSlot] = mySessionId
165
181
  saveManifest(cwd, m)
166
182
  }
167
183
 
@@ -187,10 +203,10 @@ function isProcessAlive(pid) {
187
203
  }
188
204
  }
189
205
 
190
- /** Return the active slot number, migrating legacy data if necessary */
206
+ /** Return the active slot number for this process, claiming one if necessary */
191
207
  export function activeSlot(cwd) {
192
208
  const m = loadManifest(cwd)
193
- if (!m.active) ensureActive(cwd, m)
209
+ ensureActive(cwd, m)
194
210
  return m.active
195
211
  }
196
212
 
@@ -203,7 +219,7 @@ function loadSlotMeta(cwd, slot, v) {
203
219
  if (!existsSync(p)) return { ts }
204
220
  const data = JSON.parse(readFileSync(p, "utf8"))
205
221
  const history = data.history ?? []
206
- const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts)
222
+ const meta = extractSlotMeta(history, data.activeProvider, data.updatedAt ?? ts, data.title ?? "")
207
223
  return { ts, ...meta }
208
224
  } catch {
209
225
  return { ts }
@@ -229,6 +245,7 @@ export function listSlots(cwd) {
229
245
  activeProvider: meta.activeProvider ?? "",
230
246
  updatedAt: meta.updatedAt ?? meta.ts,
231
247
  updatedDate: new Date(meta.updatedAt ?? meta.ts).toLocaleString(),
248
+ title: meta.title ?? "",
232
249
  }
233
250
  })
234
251
  .sort((a, b) => b.updatedAt - a.updatedAt)
@@ -265,14 +282,20 @@ function isLegacyTransient(m) {
265
282
 
266
283
  /** Save agent state and display lines to the active slot file (atomic write) */
267
284
  export function saveSession(agent, display) {
268
- const history = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
285
+ // _fullHistory is written at the source via pushReal — no flush needed here.
286
+ // history = FULL, never-compacted (human-readable; VS Code panel & CLI resume read this)
287
+ // contextHistory = machine context (possibly compacted) so CLI resume keeps the token savings
288
+ const history = (agent._fullHistory ?? agent.history).filter((m) => !m.transient && !isLegacyTransient(m))
289
+ const contextHistory = agent.history.filter((m) => !m.transient && !isLegacyTransient(m))
269
290
  const data = {
270
291
  version: 2,
271
292
  cwd: agent.cwd,
293
+ title: agent.title ?? "",
272
294
  activeProvider: agent.activeProvider ?? agent.provider?.name,
273
295
  activeModel: agent.activeModel ?? null,
274
296
  updatedAt: Date.now(),
275
297
  history,
298
+ contextHistory,
276
299
  display: display ?? [],
277
300
  tasks: agent.tasks ?? [],
278
301
  planMode: agent.planMode ?? false,
@@ -350,7 +373,15 @@ export function loadSession(cwd) {
350
373
 
351
374
  /** Apply loaded session data onto an agent object; returns true if provider was switched */
352
375
  export function applySession(agent, data) {
353
- agent.history = data.history
376
+ // data.history is the FULL never-compacted record (human line); data.contextHistory is the
377
+ // (possibly compacted) machine line. Restore each line from its own source — the machine
378
+ // context keeps its compaction savings across resume. Legacy files without contextHistory
379
+ // fall back to seeding the machine line from the full history (it re-compacts when needed).
380
+ const full = Array.isArray(data.history) ? data.history : []
381
+ const machine = Array.isArray(data.contextHistory) ? data.contextHistory : full
382
+ agent._fullHistory = [...full]
383
+ agent.history = [...machine]
384
+ agent.title = data.title ?? ""
354
385
  agent.tasks = data.tasks ?? []
355
386
  agent.planMode = data.planMode ?? false
356
387
  agent.autoApprove = data.autoApprove ?? false
@@ -392,34 +423,19 @@ export function applySession(agent, data) {
392
423
  /**
393
424
  * Create a new session slot: allocate a free slot number,
394
425
  * write an empty session, and mark it as the active slot.
395
- * Evicts the oldest slot if MAX_SLOTS is reached.
426
+ * No limit on the number of sessions.
396
427
  */
397
428
  export function newSession(cwd) {
398
429
  const m = loadManifest(cwd)
399
430
  // Ensure active is set (migrate if needed)
400
431
  if (!m.active) ensureActive(cwd, m)
401
432
 
402
- let slot
403
- const entries = Object.entries(m.slots).filter(([n]) => /^\d+$/.test(n))
404
- if (entries.length < MAX_SLOTS) {
405
- slot = 1
406
- while (m.slots[slot]) slot++
407
- } else {
408
- // Full — evict oldest (but never evict the currently active slot)
409
- const candidates = entries.filter(([n]) => Number(n) !== m.active)
410
- if (candidates.length === 0) {
411
- // Should not happen with MAX_SLOTS >= 2 — manifest corruption or all slots are active
412
- console.error(`[session] newSession: all ${entries.length} slots are active, cannot evict. Overwriting oldest non-active slot skipped; reusing slot 1.`)
413
- slot = 1
414
- } else {
415
- slot = Number(candidates.sort(slotCmp)[0][0])
416
- }
417
- // Delete the evicted slot file
418
- try { unlinkSync(slotPath(cwd, slot)) } catch {}
419
- }
433
+ // Find next available slot number
434
+ let slot = 1
435
+ while (m.slots[slot]) slot++
420
436
 
421
437
  // Write empty session
422
- const data = { version: 2, cwd, updatedAt: Date.now(), history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
438
+ const data = { version: 2, cwd, title: "", updatedAt: Date.now(), history: [], tasks: [], display: [], goal: null, autoApprove: false, advisor: null, pendingReminders: [], sessionStart: null }
423
439
  writeSessionFile(slotPath(cwd, slot), data)
424
440
  m.slots[slot] = slotDigest(data)
425
441
  m.active = slot
@@ -31,14 +31,15 @@ function parse(filePath) {
31
31
  const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
32
  const text = m[3].trim()
33
33
 
34
- // Extract explicit ID if present (e.g. "T1:", "T1.1:")
35
- const idMatch = text.match(/^(T[\d.]+):/)
34
+ // Extract explicit ID if present (e.g. "T1:", "T1.1:") — strip it from the text
35
+ // so write() doesn't re-prepend it (round-trip would otherwise accumulate "T1: T1: ...")
36
+ const idMatch = text.match(/^(T[\d.]+):\s*/)
36
37
  const node = {
37
38
  id: idMatch ? idMatch[1] : null,
38
39
  index: flatIdx,
39
40
  depth,
40
41
  status,
41
- text,
42
+ text: idMatch ? text.slice(idMatch[0].length) : text,
42
43
  children: [],
43
44
  }
44
45
 
@@ -30,8 +30,11 @@ const MAX_OUTPUT = 50_000
30
30
  const MAX_SCRIPT = 50_000
31
31
  const DEFAULT_TIMEOUT = 30_000
32
32
 
33
- /** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout. */
34
- async function sandboxFetch(url) {
33
+ /** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout.
34
+ * Validation throws SYNCHRONOUSLY so the vm sandbox's try/catch can catch it —
35
+ * an async throw here would become an unhandled rejection and crash the host process,
36
+ * and the rejection message would never reach the model. */
37
+ function sandboxFetch(url) {
35
38
  const parsed = new URL(url)
36
39
  if (!["http:", "https:"].includes(parsed.protocol)) {
37
40
  throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
@@ -40,6 +43,10 @@ async function sandboxFetch(url) {
40
43
  if (isPrivateHost(parsed.hostname)) {
41
44
  throw new Error(`CodeMode fetch: private/internal host not allowed: ${parsed.hostname}`)
42
45
  }
46
+ return doFetch(url)
47
+ }
48
+
49
+ async function doFetch(url) {
43
50
  const ctrl = new AbortController()
44
51
  const timer = setTimeout(() => ctrl.abort(), 10_000)
45
52
  try {
@@ -158,11 +165,10 @@ export const codeModeTool = {
158
165
  })
159
166
 
160
167
  try {
161
- const script = new Script(code, {
162
- filename: "codemode.js",
163
- timeout: timeoutMs,
164
- })
165
- script.runInContext(sandbox)
168
+ const script = new Script(code, { filename: "codemode.js" })
169
+ // timeout belongs on runInContext — the Script constructor ignores it,
170
+ // so passing it there let runaway scripts (while(true)) hang the process forever.
171
+ script.runInContext(sandbox, { timeout: timeoutMs })
166
172
  return output.join("\n") || "(no output)"
167
173
  } catch (err) {
168
174
  const out = output.join("\n")
@@ -355,6 +355,21 @@ export async function runAgentTurn(ctx, text) {
355
355
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
356
356
  state.tasks = []
357
357
  }
358
+ // Auto-generate session title from the first user message (once per session)
359
+ if (!agent.title) {
360
+ try {
361
+ const { generateTitle } = await import("../generate-title.mjs")
362
+ const firstUser = (agent._fullHistory ?? agent.history).find(
363
+ (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
364
+ )
365
+ if (firstUser) {
366
+ const title = await generateTitle(firstUser.content, agent.provider)
367
+ if (title) agent.title = title
368
+ }
369
+ } catch {
370
+ // Title generation failure is non-fatal
371
+ }
372
+ }
358
373
  // Save session after every turn (survives crashes)
359
374
  try {
360
375
  saveSessionImpl(agent, state.lines)
package/src/tui/ansi.mjs CHANGED
@@ -13,6 +13,10 @@ export const ansi = {
13
13
  mouseOff: `${ESC}[?1000l${ESC}[?1006l`,
14
14
  bracketedPasteOn: `${ESC}[?2004h`,
15
15
  bracketedPasteOff: `${ESC}[?2004l`,
16
+ keyboardPush: `${ESC}[>1u`, // kitty keyboard protocol: push disambiguate mode (Shift+Enter → CSI-u)
17
+ keyboardPop: `${ESC}[<u`, // pop keyboard mode (restore terminal defaults on exit)
18
+ modifyOtherKeysOn: `${ESC}[>4;2m`, // xterm modifyOtherKeys level 2 (Shift+Enter → \x1b[27;2;13~), mintty/Git Bash path
19
+ modifyOtherKeysOff: `${ESC}[>4m`, // reset modifyOtherKeys
16
20
  home: `${ESC}[H`,
17
21
  clearLine: `${ESC}[K`,
18
22
  clearToEnd: `${ESC}[J`,
@@ -38,6 +38,15 @@ export function insertPastedText(state, rawText) {
38
38
  state.cursor += chars.length
39
39
  }
40
40
 
41
+ /** Translate Shift+Enter sequences from keyboard-enhanced terminals into the Alt+Enter path.
42
+ * kitty/CSI-u: \x1b[13;2u; xterm modifyOtherKeys: \x1b[27;2;13~. Both become \x1b\r,
43
+ * which readline parses reliably as meta+return (the multiline branch in key-handler).
44
+ * Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
45
+ * (degrades to a normal submit; Alt+Enter remains the fallback). */
46
+ export function translateShiftEnter(text) {
47
+ return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
48
+ }
49
+
41
50
  /** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
42
51
  * Extracted from index.mjs.
43
52
  * ctx: { agent, state, pushLine, render } */
@@ -153,7 +153,6 @@ export async function handleConfigCommand(ctx, args = []) {
153
153
  { type: "item", text: `agent.compactThreshold = ${ac.compactThreshold ?? 100000}${agent.config?.agent?.compactThresholdAuto ? " (auto)" : ""}`, action: "agent.compactThreshold" },
154
154
  { type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
155
155
  { type: "item", text: "Set embedding API key", action: "embedkey" },
156
- { type: "item", text: `embedding.model = ${ec.model ?? "BAAI/bge-m3"}`, action: "embedding.model" },
157
156
  { type: "item", text: `proxy = ${proxySummary()}`, action: "proxy" },
158
157
  { type: "item", text: "View full config", action: "view" },
159
158
  ]
@@ -201,31 +200,8 @@ export async function handleConfigCommand(ctx, args = []) {
201
200
  continue
202
201
  }
203
202
 
204
- if (choice.action === "embedding.model") {
205
- const models = [
206
- { label: "BAAI/bge-m3 (multilingual, 1024d)", value: "BAAI/bge-m3" },
207
- { label: "BAAI/bge-large-zh-v1.5 (Chinese, 1024d)", value: "BAAI/bge-large-zh-v1.5" },
208
- { label: "BAAI/bge-large-en-v1.5 (English, 1024d)", value: "BAAI/bge-large-en-v1.5" },
209
- { label: "text-embedding-3-small (OpenAI, 1536d)", value: "text-embedding-3-small" },
210
- { label: "text-embedding-3-large (OpenAI, 3072d)", value: "text-embedding-3-large" },
211
- ]
212
- const currentVal = ec.model ?? "BAAI/bge-m3"
213
- const modelChoice = await showPicker("Embedding Model", [
214
- { type: "header", text: `Current: ${currentVal}` },
215
- ...models.map(m => ({ type: "item", text: m.label, action: m.value })),
216
- ])
217
- if (!modelChoice) continue
218
- try {
219
- await saveProxy((raw) => {
220
- raw.embedding ??= {}
221
- raw.embedding.model = modelChoice.action
222
- })
223
- pushLabel("❯ Config", ansi.bold + C.tool)
224
- pushLine(`embedding.model = ${modelChoice.action}`, C.tool)
225
- running = false
226
- } catch (error) { pushLine(`Save failed: ${error.message}`, C.error) }
227
- continue
228
- }
203
+ // Embedding model is fixed (BAAI/bge-m3, SiliconFlow) — no picker; it's over-engineering
204
+ // to expose model choice when the vector index format assumes one embedding space.
229
205
 
230
206
  // Numeric config items
231
207
  const label = choice.action
@@ -18,14 +18,14 @@ export async function handleSessionCommand(ctx) {
18
18
  const entries = [
19
19
  { type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel)` },
20
20
  ...slots.map((s) => {
21
- const preview = s.firstMessage ? `"${truncate(s.firstMessage, 40)}"` : "(empty)"
21
+ const label = s.title || (s.firstMessage ? `"${truncate(s.firstMessage, 40)}"` : "(empty)")
22
22
  const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
23
23
  const when = shortDate(s.updatedAt)
24
24
  const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
25
25
  const marker = s.isActive ? " ●" : ""
26
26
  return {
27
27
  type: "item",
28
- text: `Slot ${s.slot} │ ${turns} │ ${when} │ ${preview}${model}${marker}`,
28
+ text: `Slot ${s.slot} │ ${turns} │ ${when} │ ${label}${model}${marker}`,
29
29
  slot: s.slot,
30
30
  }
31
31
  }),
package/src/tui/index.mjs CHANGED
@@ -27,7 +27,7 @@ import { createWizard } from "./wizard.mjs"
27
27
  import { createPickers } from "./pickers.mjs"
28
28
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
29
29
  import { createInteraction } from "./interaction.mjs"
30
- import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText } from "./clipboard.mjs"
30
+ import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter } from "./clipboard.mjs"
31
31
  import { runAgentTurn } from "./agent-turn.mjs"
32
32
  import { createKeyHandler } from "./key-handler.mjs"
33
33
  import { showStartup, backgroundIndex } from "./startup.mjs"
@@ -64,6 +64,7 @@ export async function startTUI(agent, opts = {}) {
64
64
  cursor: 0,
65
65
  history: [],
66
66
  historyIndex: -1,
67
+ _draft: null, // stashed unsent input while navigating history (restored on down past newest)
67
68
  scroll: 0, // scroll lines from bottom upward
68
69
  processing: false,
69
70
  controller: null, // AbortController for current agent run
@@ -88,6 +89,7 @@ export async function startTUI(agent, opts = {}) {
88
89
  search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
89
90
  expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
90
91
  foldEnabled: true, // global fold toggle — /fold on|off
92
+ exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
91
93
  }
92
94
 
93
95
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
@@ -108,7 +110,11 @@ export async function startTUI(agent, opts = {}) {
108
110
  const startupRows = process.stdout.rows || 24
109
111
  emitKeypressEvents(keyStream)
110
112
  process.stdin.setRawMode(true)
111
- process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
113
+ // Keyboard enhancement enable BOTH protocols (unsupported terminals ignore them):
114
+ // kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
115
+ // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
116
+ // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
117
+ process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn)
112
118
 
113
119
  const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
114
120
 
@@ -181,6 +187,9 @@ export async function startTUI(agent, opts = {}) {
181
187
  text = text.slice(0, -tail[0].length)
182
188
  }
183
189
 
190
+ // Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
191
+ text = translateShiftEnter(text)
192
+
184
193
  if (state.scroll !== lastRenderedScroll) {
185
194
  lastRenderedScroll = state.scroll
186
195
  render()
@@ -210,7 +219,7 @@ export async function startTUI(agent, opts = {}) {
210
219
  // Can't close? fine, process is exiting anyway
211
220
  }
212
221
  process.stdin.setRawMode(false)
213
- process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
222
+ process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
214
223
  }
215
224
  process.on("exit", cleanup)
216
225
 
@@ -260,6 +269,7 @@ export async function startTUI(agent, opts = {}) {
260
269
  state.cursor = 0
261
270
  state.history.push(text)
262
271
  state.historyIndex = -1
272
+ state._draft = null // submitted — the draft is now history
263
273
  state.scroll = 0
264
274
 
265
275
  // Slash commands: handled locally, don't enter agent loop