thincoder 0.12.3 → 0.12.4
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 +11 -1
- package/package.json +3 -3
- package/src/agent/completion.mjs +22 -7
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +44 -13
- package/src/config.mjs +13 -1
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- package/src/prompts/system.md +1 -1
- package/src/provider/core.mjs +58 -2
- package/src/session.mjs +90 -74
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +13 -7
- package/src/tui/agent-turn.mjs +15 -0
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +2 -26
- package/src/tui/cmd-session.mjs +2 -2
- package/src/tui/index.mjs +13 -3
- package/src/tui/key-handler.mjs +54 -14
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +6 -1
- package/src/tui/render-conversation.mjs +5 -2
- package/src/tui/render-frame.mjs +2 -1
- package/src/tui/render.mjs +13 -7
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
|
|
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.
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
package/src/tools/checklist.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/tools/codemode.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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")
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -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`,
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -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 } */
|
package/src/tui/cmd-config.mjs
CHANGED
|
@@ -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
|
-
|
|
205
|
-
|
|
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
|
package/src/tui/cmd-session.mjs
CHANGED
|
@@ -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
|
|
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} │ ${
|
|
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
|
-
|
|
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
|