dsh-code 1.2.0 → 1.3.0
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.en.md +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +955 -233
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +20 -0
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/history.d.ts +10 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/locales/en.d.ts +49 -1
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/session-directory.d.ts +25 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/update-panel.d.ts +22 -1
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +288 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +5 -2
- package/src/fork.ts +11 -7
- package/src/history.ts +14 -0
- package/src/index.ts +92 -53
- package/src/input-split.ts +24 -4
- package/src/kernel-panels.ts +60 -27
- package/src/locales/en.ts +50 -1
- package/src/locales/zh.ts +50 -1
- package/src/rainbow.ts +13 -3
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +2 -1
- package/src/session-directory.ts +82 -3
- package/src/session-switch.ts +14 -0
- package/src/store.ts +19 -1
- package/src/update-panel.ts +112 -5
- package/src/version.ts +5 -0
package/src/attachments.ts
CHANGED
|
@@ -52,17 +52,41 @@ export function looksLikeImagePath(path: string): boolean {
|
|
|
52
52
|
return IMAGE_EXTENSIONS.has(extname(path).toLowerCase())
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Strip one layer of ASCII or Unicode quotes and shell-escaped spaces. */
|
|
56
|
+
export function unwrapDroppedPath(token: string): string {
|
|
57
|
+
const trimmed = token.trim()
|
|
58
|
+
const wrapped = /^[\u2018\u201C"'](.+)[\u2019\u201D"']$/u.exec(trimmed)
|
|
59
|
+
return (wrapped?.[1] ?? trimmed).replace(/\\ /gu, ' ')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Absolute/relative drop with a dotted leaf — including unquoted spaces. */
|
|
63
|
+
export function looksLikeFilesystemDrop(path: string): boolean {
|
|
64
|
+
if (path.startsWith('file://')) return true
|
|
65
|
+
if (!/^(?:\/|[A-Za-z]:[\\/]|\\\\|\.\.?\/)/u.test(path)) return false
|
|
66
|
+
return looksLikeImagePath(path) || /\.[A-Za-z0-9]{1,16}$/u.test(path)
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
* A composer draft that is a filesystem path, not a slash command.
|
|
71
|
+
* `/usage` stays a command; `/Users/foo.png` and `C:\temp\a.png` are drops.
|
|
72
|
+
*/
|
|
73
|
+
export function looksLikePathDraft(value: string): boolean {
|
|
74
|
+
const path = unwrapDroppedPath(value)
|
|
75
|
+
if (looksLikeFilesystemDrop(path)) return true
|
|
76
|
+
return /^\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes)\//u.test(path)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a paste or file-drop into image and file paths: image-suffixed
|
|
81
|
+
* tokens stay images, other path-shaped tokens ride as file attachments
|
|
82
|
+
* (0.1.5 file blocks), and anything that is neither leaves both empty —
|
|
83
|
+
* the caller then treats the paste as plain text.
|
|
60
84
|
*
|
|
61
85
|
* File tokens are held to an absolute-path-with-shape bar (drive/backslash
|
|
62
|
-
* or a
|
|
86
|
+
* or a dotted leaf after a separator): a dropped terminal path always
|
|
63
87
|
* carries one of those, while prose, slash commands, and option flags never
|
|
64
|
-
* do. A POSIX absolute path without
|
|
65
|
-
*
|
|
88
|
+
* do. A POSIX absolute path without a dotted leaf falls through as text —
|
|
89
|
+
* the @ mention route still attaches such files deliberately.
|
|
66
90
|
*/
|
|
67
91
|
export function parsePastedAttachmentPaths(input: string): { readonly images: readonly string[]; readonly files: readonly string[] } {
|
|
68
92
|
const text = input.trim()
|
|
@@ -73,27 +97,49 @@ export function parsePastedAttachmentPaths(input: string): { readonly images: re
|
|
|
73
97
|
/^[A-Za-z]:[\\/]/u.test(path)
|
|
74
98
|
|| /^\\\\/u.test(path)
|
|
75
99
|
|| (/^\/|^\.\.?\//u.test(path) && /\.[A-Za-z0-9]{1,16}$/u.test(path))
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const token = match[1] ?? match[2] ?? match[3]
|
|
79
|
-
if (token === undefined) continue
|
|
80
|
-
let path = token
|
|
100
|
+
const classify = (raw: string): 'image' | 'file' | 'reject' => {
|
|
101
|
+
let path = unwrapDroppedPath(raw)
|
|
81
102
|
if (path.startsWith('file://')) {
|
|
82
103
|
try {
|
|
83
104
|
path = fileURLToPath(path)
|
|
84
105
|
} catch {
|
|
85
|
-
return
|
|
106
|
+
return 'reject'
|
|
107
|
+
}
|
|
108
|
+
} else if (/%[0-9A-Fa-f]{2}/u.test(path)) {
|
|
109
|
+
try {
|
|
110
|
+
path = decodeURIComponent(path)
|
|
111
|
+
} catch {
|
|
112
|
+
// Keep the raw token when it is not a valid percent-encoding.
|
|
86
113
|
}
|
|
87
|
-
if (looksLikeImagePath(path)) images.push(path)
|
|
88
|
-
else files.push(path)
|
|
89
|
-
continue
|
|
90
114
|
}
|
|
91
115
|
if (looksLikeImagePath(path)) {
|
|
92
116
|
images.push(path)
|
|
93
|
-
|
|
117
|
+
return 'image'
|
|
118
|
+
}
|
|
119
|
+
if (looksLikeDroppedFile(path) || looksLikeFilesystemDrop(path)) {
|
|
120
|
+
files.push(path)
|
|
121
|
+
return 'file'
|
|
94
122
|
}
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
return 'reject'
|
|
124
|
+
}
|
|
125
|
+
// A single dropped path with spaces often arrives unquoted (macOS, Windows
|
|
126
|
+
// Explorer, VS Code sendText). Use the whole paste only when there is one
|
|
127
|
+
// path start and the text actually contains spaces (or wrapping quotes) —
|
|
128
|
+
// two unquoted `C:\a.png D:\b.txt` tokens must still split.
|
|
129
|
+
const whole = unwrapDroppedPath(text)
|
|
130
|
+
const pathStarts = text.match(/(?:^|[\s"'])(?:\/|[A-Za-z]:[\\/]|\\\\|\.\.?\/|file:\/\/)/gu) ?? []
|
|
131
|
+
const spacedSingleton = pathStarts.length <= 1
|
|
132
|
+
&& looksLikeFilesystemDrop(whole)
|
|
133
|
+
&& (/\s/u.test(whole) || /^[\u2018\u201C"']/u.test(text))
|
|
134
|
+
if (spacedSingleton) {
|
|
135
|
+
classify(whole)
|
|
136
|
+
return { images, files }
|
|
137
|
+
}
|
|
138
|
+
const matcher = /"([^"]+)"|'([^']+)'|(\S+)/gu
|
|
139
|
+
for (const match of text.matchAll(matcher)) {
|
|
140
|
+
const token = match[1] ?? match[2] ?? match[3]
|
|
141
|
+
if (token === undefined) continue
|
|
142
|
+
if (classify(token) === 'reject') return { images: [], files: [] }
|
|
97
143
|
}
|
|
98
144
|
return { images, files }
|
|
99
145
|
}
|
|
@@ -75,14 +75,17 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
|
|
|
75
75
|
decline()
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
const propsRef = useRef(props)
|
|
79
|
+
propsRef.current = props
|
|
80
|
+
const rowKey = props.row.key
|
|
78
81
|
useEffect(() => () => {
|
|
79
82
|
controllerRef.current?.abort()
|
|
80
|
-
|
|
83
|
+
propsRef.current.cancel(rowKey)
|
|
81
84
|
const reply = replyRef.current
|
|
82
85
|
replyRef.current = undefined
|
|
83
86
|
reply?.detach()
|
|
84
87
|
reply?.reject(new AuthorizationDeclinedError())
|
|
85
|
-
}, [
|
|
88
|
+
}, [rowKey])
|
|
86
89
|
|
|
87
90
|
const start = (method: string): void => {
|
|
88
91
|
setPhase('running')
|
package/src/fork.ts
CHANGED
|
@@ -13,13 +13,17 @@ export function selectForkSeed(events: readonly SessionEvent[], atSeq?: number):
|
|
|
13
13
|
throw new Error('fork event sequence must be a non-negative integer')
|
|
14
14
|
}
|
|
15
15
|
const lastSeq = events.at(-1)?.seq ?? -1
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
let boundary: SessionEvent | undefined
|
|
17
|
+
if (atSeq === undefined || atSeq > lastSeq) {
|
|
18
|
+
boundary = events.findLast(event => event.type === 'turn/end')
|
|
19
|
+
} else {
|
|
20
|
+
// The last turn/start at or before the anchor. A seq in the gap after a
|
|
21
|
+
// turn/end (title, compaction) must not walk FORWARD to the next turn.
|
|
22
|
+
const start = events.findLast(event => event.type === 'turn/start' && event.seq <= atSeq)
|
|
23
|
+
boundary = start === undefined
|
|
24
|
+
? undefined
|
|
25
|
+
: events.find(event => event.type === 'turn/end' && event.seq >= start.seq)
|
|
26
|
+
}
|
|
23
27
|
if (boundary === undefined) {
|
|
24
28
|
throw new Error(atSeq !== undefined && atSeq <= lastSeq
|
|
25
29
|
? `the turn containing event ${atSeq} has not completed`
|
package/src/history.ts
CHANGED
|
@@ -122,6 +122,20 @@ export function beginRecall(entries: readonly string[], draft: string): RecallSt
|
|
|
122
122
|
return { entries, index: null, savedDraft: draft, lastRecalled: null }
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Join a panel-recalled entry onto the draft already in the composer: the
|
|
127
|
+
* draft is extended, never replaced, so picking a history row cannot discard
|
|
128
|
+
* work in progress. An empty draft takes the entry as-is; otherwise the entry
|
|
129
|
+
* starts on its own line unless the draft already ends one.
|
|
130
|
+
* @param draft - the composer text before the recall.
|
|
131
|
+
* @param entry - the sanitized text of the accepted row.
|
|
132
|
+
* @returns the text to place in the composer.
|
|
133
|
+
*/
|
|
134
|
+
export function appendRecall(draft: string, entry: string): string {
|
|
135
|
+
if (draft === '') return entry
|
|
136
|
+
return draft.endsWith('\n') ? draft + entry : `${draft}\n${entry}`
|
|
137
|
+
}
|
|
138
|
+
|
|
125
139
|
/** The outcome of one recall step. */
|
|
126
140
|
export interface RecallStep {
|
|
127
141
|
state: RecallState
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { createUserMessage, MessageId, type ContentBlock } from '@deepseek-ai/ds
|
|
|
25
25
|
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
|
26
26
|
import { SessionId, SessionLogOffset, type Session, type SessionEvent, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
27
27
|
import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
|
28
|
-
import type
|
|
28
|
+
import { SessionAlreadyOwnedError, type SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
|
29
29
|
// Type-only: carries the ctx.sessionTitle service merge for /title.
|
|
30
30
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
31
31
|
// Empty type imports carry the loader Context merge for the settlement await
|
|
@@ -96,14 +96,17 @@ import { parseAnimationsPref } from './render/animations.ts'
|
|
|
96
96
|
import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
|
|
97
97
|
import { parseLanguageName, setLanguage, t, type LanguageName } from './i18n.ts'
|
|
98
98
|
import {
|
|
99
|
+
acquireSessionDeletionLeases,
|
|
99
100
|
isSubagentSession,
|
|
100
101
|
matchSessionId,
|
|
101
102
|
mergeSessionTitles,
|
|
102
103
|
newestRootForCwd,
|
|
104
|
+
sessionRowMatchesQuery,
|
|
103
105
|
isSessionArtifactName,
|
|
104
106
|
jsonlSessionRoot,
|
|
105
107
|
planSessionDeletion,
|
|
106
108
|
projectSessionRows,
|
|
109
|
+
releaseSessionDeletionLeases,
|
|
107
110
|
sessionArtifactDirectory,
|
|
108
111
|
sessionDirectoryFor,
|
|
109
112
|
type SessionDirectoryOptions,
|
|
@@ -1234,7 +1237,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1234
1237
|
try {
|
|
1235
1238
|
parsed = currentMentions.parse(line)
|
|
1236
1239
|
} catch (error: unknown) {
|
|
1237
|
-
bridge.notify(
|
|
1240
|
+
bridge.notify(t('notice.invalidReference', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1238
1241
|
return
|
|
1239
1242
|
}
|
|
1240
1243
|
// Ordered delivery: the inbox order IS the user's message order. A line
|
|
@@ -1269,7 +1272,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1269
1272
|
if (mode === 'steer') currentAgent.steer(message)
|
|
1270
1273
|
else currentAgent.followup(message)
|
|
1271
1274
|
} catch (error: unknown) {
|
|
1272
|
-
bridge.notify(
|
|
1275
|
+
bridge.notify(t('notice.messageFailed', { kind: mode === 'steer' ? 'steering' : 'message', message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1273
1276
|
}
|
|
1274
1277
|
}
|
|
1275
1278
|
if (parsed.references.length === 0) {
|
|
@@ -1287,7 +1290,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1287
1290
|
}, (error: unknown) => {
|
|
1288
1291
|
pendingControllers.delete(controller)
|
|
1289
1292
|
if (controller.signal.aborted || epoch !== atEpoch) return
|
|
1290
|
-
bridge.notify(
|
|
1293
|
+
bridge.notify(t('notice.referenceFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1291
1294
|
})
|
|
1292
1295
|
})
|
|
1293
1296
|
}
|
|
@@ -1364,7 +1367,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1364
1367
|
}
|
|
1365
1368
|
await next.handle.dispose().catch(() => {})
|
|
1366
1369
|
if (!quitting) renderCurrent()
|
|
1367
|
-
bridge.notify(
|
|
1370
|
+
bridge.notify(t('notice.activationFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1368
1371
|
return
|
|
1369
1372
|
}
|
|
1370
1373
|
abortPendingControllers()
|
|
@@ -1383,7 +1386,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1383
1386
|
}
|
|
1384
1387
|
}).catch((error: unknown) => {
|
|
1385
1388
|
pendingInputs.length = 0
|
|
1386
|
-
bridge.notify(
|
|
1389
|
+
bridge.notify(t('notice.creationFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1387
1390
|
})
|
|
1388
1391
|
}
|
|
1389
1392
|
|
|
@@ -1393,19 +1396,27 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1393
1396
|
// exact whitespace unless the line is a syntactic slash command.
|
|
1394
1397
|
const line = submissionPayload(text)
|
|
1395
1398
|
if (line.trim() === '' && images.length === 0) return
|
|
1399
|
+
// A switch between the idle wait and the handoff keeps the OLD session
|
|
1400
|
+
// installed: a line delivered now would start a turn the handoff discards.
|
|
1401
|
+
// Refusing loudly beats losing it silently — the caller can retry after the
|
|
1402
|
+
// switch, and `switchQueue.cancel()` is the way out.
|
|
1403
|
+
if (switchQueue.activating) {
|
|
1404
|
+
bridge.notify(t('notice.switchInProgress'), 'warning')
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1396
1407
|
if (images.length === 0 && line.startsWith('/mode ')) {
|
|
1397
1408
|
void switchModeAction(line.slice(6).trim()).then(
|
|
1398
|
-
selected => bridge.notify(
|
|
1399
|
-
error => bridge.notify(
|
|
1409
|
+
selected => bridge.notify(t('notice.modeChanged', { value: selected })),
|
|
1410
|
+
error => bridge.notify(t('notice.modeChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1400
1411
|
)
|
|
1401
1412
|
return
|
|
1402
1413
|
}
|
|
1403
1414
|
if (images.length === 0 && line.startsWith('/permission ')) {
|
|
1404
1415
|
try {
|
|
1405
1416
|
const selected = setPermissionAction(line.slice(12).trim())
|
|
1406
|
-
bridge.notify(
|
|
1417
|
+
bridge.notify(t('notice.permissionChanged', { value: selected }))
|
|
1407
1418
|
} catch (error: unknown) {
|
|
1408
|
-
bridge.notify(
|
|
1419
|
+
bridge.notify(t('notice.permissionChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1409
1420
|
}
|
|
1410
1421
|
return
|
|
1411
1422
|
}
|
|
@@ -1458,7 +1469,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1458
1469
|
bridge.notify(t(preserved > 0 ? 'notice.turnCancelledKeepQueue' : 'notice.turnCancelled'))
|
|
1459
1470
|
return true
|
|
1460
1471
|
} catch (error: unknown) {
|
|
1461
|
-
bridge.notify(
|
|
1472
|
+
bridge.notify(t('notice.cancelFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1462
1473
|
return false
|
|
1463
1474
|
}
|
|
1464
1475
|
}
|
|
@@ -1491,7 +1502,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1491
1502
|
*/
|
|
1492
1503
|
const cycleMode = (): string => {
|
|
1493
1504
|
if (permissionPresets === undefined || permissionPresets.names.length === 0) {
|
|
1494
|
-
bridge.notify('
|
|
1505
|
+
bridge.notify(t('notice.permissionPresetsUnmounted'), 'warning')
|
|
1495
1506
|
return ''
|
|
1496
1507
|
}
|
|
1497
1508
|
try {
|
|
@@ -1542,7 +1553,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1542
1553
|
selectPermission(permissionPresets, session, decision.preset)
|
|
1543
1554
|
return `plan → off · permission → ${decision.preset}`
|
|
1544
1555
|
} catch (error: unknown) {
|
|
1545
|
-
bridge.notify(
|
|
1556
|
+
bridge.notify(t('notice.modeChangeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1546
1557
|
return ''
|
|
1547
1558
|
}
|
|
1548
1559
|
}
|
|
@@ -1568,7 +1579,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1568
1579
|
// it. Save failures degrade to a notice — the in-session switch already
|
|
1569
1580
|
// took effect and must not roll back (the web contract).
|
|
1570
1581
|
void defaultModel.saveSelection(selection).catch((error: unknown) => {
|
|
1571
|
-
bridge.notify(
|
|
1582
|
+
bridge.notify(t('notice.modelNotDefault', { message: error instanceof Error ? error.message : String(error) }), 'warning')
|
|
1572
1583
|
})
|
|
1573
1584
|
// Advisory immediate validation (web selectModel parity): run the same
|
|
1574
1585
|
// local resolveCallConfig check the request pipeline would, so a stale
|
|
@@ -1587,7 +1598,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1587
1598
|
model: selection.model,
|
|
1588
1599
|
...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
1589
1600
|
})).catch((error: unknown) => {
|
|
1590
|
-
bridge.notify(
|
|
1601
|
+
bridge.notify(t('notice.modelSelectionRejected', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1591
1602
|
})
|
|
1592
1603
|
}
|
|
1593
1604
|
return `${row.provider}/${row.model}`
|
|
@@ -1616,7 +1627,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1616
1627
|
*/
|
|
1617
1628
|
const exportTranscript = async (argument: string): Promise<void> => {
|
|
1618
1629
|
if (session === undefined) {
|
|
1619
|
-
bridge.notify('
|
|
1630
|
+
bridge.notify(t('notice.noSessionYet'), 'warning')
|
|
1620
1631
|
return
|
|
1621
1632
|
}
|
|
1622
1633
|
const wanted = argument.trim()
|
|
@@ -1633,9 +1644,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1633
1644
|
const markdown = buildExportMarkdown(store.getView(), session.id)
|
|
1634
1645
|
try {
|
|
1635
1646
|
await writeFileAsync(target, `${markdown}\n`, 'utf8')
|
|
1636
|
-
bridge.notify(
|
|
1647
|
+
bridge.notify(t('notice.exported', { path: target }))
|
|
1637
1648
|
} catch (error: unknown) {
|
|
1638
|
-
bridge.notify(
|
|
1649
|
+
bridge.notify(t('notice.exportFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1639
1650
|
}
|
|
1640
1651
|
}
|
|
1641
1652
|
|
|
@@ -1684,13 +1695,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1684
1695
|
}
|
|
1685
1696
|
}))
|
|
1686
1697
|
}
|
|
1687
|
-
const projected = projectSessionRows(records, options, updated)
|
|
1688
|
-
// Titles are the expensive fold. Fetch
|
|
1689
|
-
//
|
|
1690
|
-
const
|
|
1698
|
+
const projected = projectSessionRows(records, { ...options, query: '' }, updated)
|
|
1699
|
+
// Titles are the expensive fold. Fetch the first picker page when idle;
|
|
1700
|
+
// a non-empty query loads more so the displayed title can match.
|
|
1701
|
+
const titleBudget = options.query.trim() === '' ? 32 : Math.min(projected.length, 128)
|
|
1702
|
+
const page = projected.slice(0, titleBudget)
|
|
1691
1703
|
if (page.length === 0) return projected
|
|
1692
1704
|
const observations = await sessionQuery.readTitleSnapshots(page.map(row => row.id), signal)
|
|
1693
|
-
|
|
1705
|
+
const titled = mergeSessionTitles(projected, observations)
|
|
1706
|
+
return titled.filter(row => sessionRowMatchesQuery(row, options.query))
|
|
1694
1707
|
}
|
|
1695
1708
|
|
|
1696
1709
|
/**
|
|
@@ -1708,9 +1721,12 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1708
1721
|
* Backends without a derivable artifact (non-JSONL) refuse the WHOLE
|
|
1709
1722
|
* deletion here — no file has been touched yet, so a backend or layout
|
|
1710
1723
|
* surprise can never strand a half-deleted subtree.
|
|
1711
|
-
* 3.
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1724
|
+
* 3. Acquire every node's public persistence write handle before touching
|
|
1725
|
+
* files. The JSONL backend holds its cross-process kernel lease for each
|
|
1726
|
+
* handle, so another terminal's live session refuses the whole deletion.
|
|
1727
|
+
* 4. Artifacts are removed children-first while every lease remains held:
|
|
1728
|
+
* only an I/O error mid-delete can stop it short (reported with
|
|
1729
|
+
* removed/total counts), leaving the shallowest lineage intact.
|
|
1714
1730
|
*
|
|
1715
1731
|
* @param id - the root session id to delete.
|
|
1716
1732
|
* @returns the outcome line for the panel/notice.
|
|
@@ -1725,7 +1741,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1725
1741
|
// layout-check every node up front, so a refusal never leaves a
|
|
1726
1742
|
// partially removed subtree behind.
|
|
1727
1743
|
const root = jsonlSessionRoot(persistence)
|
|
1728
|
-
if (root === undefined) {
|
|
1744
|
+
if (root === undefined || persistence === undefined) {
|
|
1729
1745
|
return 'session backend exposes no deletable artifact (deletion is unsupported on this backend)'
|
|
1730
1746
|
}
|
|
1731
1747
|
const byId = new Map<string, (typeof records)[number]>(records.map(record => [record.header.id, record]))
|
|
@@ -1739,14 +1755,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1739
1755
|
}
|
|
1740
1756
|
dirs.set(node.id, dir)
|
|
1741
1757
|
}
|
|
1758
|
+
let leases
|
|
1759
|
+
try {
|
|
1760
|
+
leases = await acquireSessionDeletionLeases(persistence, plan.nodes.map(node => node.id))
|
|
1761
|
+
} catch (error: unknown) {
|
|
1762
|
+
if (error instanceof SessionAlreadyOwnedError) {
|
|
1763
|
+
return `cannot delete ${error.sessionId.slice(-12)} — it is open in this or another process`
|
|
1764
|
+
}
|
|
1765
|
+
return `cannot safely lock sessions for deletion: ${error instanceof Error ? error.message : String(error)}`
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1742
1768
|
let removed = 0
|
|
1769
|
+
let outcome: string | undefined
|
|
1743
1770
|
for (const node of plan.nodes) {
|
|
1744
1771
|
const dir = dirs.get(node.id)!
|
|
1745
1772
|
try {
|
|
1746
1773
|
// Remove every canonical generation artifact this build knows; other
|
|
1747
|
-
// sibling files are never ours to delete
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1774
|
+
// sibling files are never ours to delete. The POSIX session.lock file
|
|
1775
|
+
// deliberately remains because unlinking a held flock inode would
|
|
1776
|
+
// forfeit the backend's exclusion guarantee.
|
|
1750
1777
|
const entries = await readdir(dir, { withFileTypes: true })
|
|
1751
1778
|
for (const entry of entries) {
|
|
1752
1779
|
if (entry.isFile() && isSessionArtifactName(entry.name)) {
|
|
@@ -1756,10 +1783,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1756
1783
|
await rm(dir, { force: true, recursive: false }).catch(() => {})
|
|
1757
1784
|
removed += 1
|
|
1758
1785
|
} catch (error: unknown) {
|
|
1759
|
-
|
|
1786
|
+
outcome = `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
|
|
1787
|
+
break
|
|
1760
1788
|
}
|
|
1761
1789
|
}
|
|
1762
|
-
|
|
1790
|
+
outcome ??= `deleted ${removed} session${removed === 1 ? '' : 's'}`
|
|
1791
|
+
try {
|
|
1792
|
+
await releaseSessionDeletionLeases(leases)
|
|
1793
|
+
} catch (error: unknown) {
|
|
1794
|
+
return `${outcome}; failed to release deletion locks: ${error instanceof Error ? error.message : String(error)}`
|
|
1795
|
+
}
|
|
1796
|
+
return outcome
|
|
1763
1797
|
}
|
|
1764
1798
|
|
|
1765
1799
|
const loadSessionTranscript = async (id: string, signal?: AbortSignal): Promise<string> => {
|
|
@@ -1906,7 +1940,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1906
1940
|
// effect AFTER it, so an immediate notice reaches the UNMOUNTED
|
|
1907
1941
|
// instance and React drops it silently. Defer past the commit.
|
|
1908
1942
|
setTimeout(() => {
|
|
1909
|
-
bridge.notify(
|
|
1943
|
+
bridge.notify(t(next.resumed ? 'notice.sessionResumed' : 'notice.sessionCreated', {
|
|
1944
|
+
id: next.session.id.slice(-12),
|
|
1945
|
+
mode: next.mode,
|
|
1946
|
+
}))
|
|
1910
1947
|
}, 0)
|
|
1911
1948
|
return
|
|
1912
1949
|
}
|
|
@@ -1914,23 +1951,25 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1914
1951
|
try {
|
|
1915
1952
|
await sessions.flush(previous.session)
|
|
1916
1953
|
} catch (error: unknown) {
|
|
1917
|
-
cleanupWarning =
|
|
1954
|
+
cleanupWarning = t('notice.flushFailed', { message: error instanceof Error ? error.message : String(error) })
|
|
1918
1955
|
}
|
|
1919
1956
|
try {
|
|
1920
1957
|
await previous.handle.dispose()
|
|
1921
1958
|
} catch (error: unknown) {
|
|
1922
|
-
|
|
1959
|
+
const release = t('notice.agentReleaseFailed', { message: error instanceof Error ? error.message : String(error) })
|
|
1960
|
+
cleanupWarning = cleanupWarning === undefined ? release : `${cleanupWarning}; ${release}`
|
|
1923
1961
|
}
|
|
1962
|
+
const shortId = next.session.id.slice(-12)
|
|
1924
1963
|
bridge.notify(cleanupWarning === undefined
|
|
1925
|
-
?
|
|
1926
|
-
:
|
|
1964
|
+
? t(next.resumed ? 'notice.sessionResumed' : 'notice.sessionCreated', { id: shortId, mode: next.mode })
|
|
1965
|
+
: t('notice.sessionSwitchedDirty', { id: shortId, detail: cleanupWarning }),
|
|
1927
1966
|
cleanupWarning === undefined ? 'info' : 'warning')
|
|
1928
1967
|
})
|
|
1929
1968
|
}
|
|
1930
1969
|
|
|
1931
1970
|
const switchQueue = new SessionSwitchQueue<PendingSwitch>(
|
|
1932
1971
|
async request => { if (!quitting) await activate(request.target) },
|
|
1933
|
-
error => bridge.notify(
|
|
1972
|
+
error => bridge.notify(t('notice.sessionSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
1934
1973
|
)
|
|
1935
1974
|
|
|
1936
1975
|
const requestSwitch = (request: PendingSwitch): void => {
|
|
@@ -1939,17 +1978,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1939
1978
|
// the target directly — there is no running turn to wait on and nothing
|
|
1940
1979
|
// to flush.
|
|
1941
1980
|
void activate(request.target).catch((error: unknown) => {
|
|
1942
|
-
bridge.notify(
|
|
1981
|
+
bridge.notify(t('notice.sessionSwitchFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
1943
1982
|
})
|
|
1944
1983
|
return
|
|
1945
1984
|
}
|
|
1946
1985
|
if (request.target.sessionId === session.id) {
|
|
1947
|
-
bridge.notify('
|
|
1986
|
+
bridge.notify(t('notice.alreadyActive'), 'warning')
|
|
1948
1987
|
return
|
|
1949
1988
|
}
|
|
1950
1989
|
const outcome = switchQueue.request(agent!, request)
|
|
1951
1990
|
if (outcome === 'queued') {
|
|
1952
|
-
bridge.notify(
|
|
1991
|
+
bridge.notify(t('notice.switchQueued', { label: request.label }))
|
|
1953
1992
|
}
|
|
1954
1993
|
}
|
|
1955
1994
|
|
|
@@ -1975,7 +2014,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
1975
2014
|
const requestResume = (wanted: string): void => {
|
|
1976
2015
|
void resolveResumeId(wanted).then(id => {
|
|
1977
2016
|
requestSwitch({ target: { sessionId: id, resume: true }, label: id.slice(-12) })
|
|
1978
|
-
}, (error: unknown) => bridge.notify(
|
|
2017
|
+
}, (error: unknown) => bridge.notify(t('notice.resumeFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'))
|
|
1979
2018
|
}
|
|
1980
2019
|
|
|
1981
2020
|
const createSession = (mode?: string): void => {
|
|
@@ -2039,7 +2078,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2039
2078
|
|
|
2040
2079
|
const forkSession = (argument: string): void => {
|
|
2041
2080
|
if (session === undefined || active === undefined) {
|
|
2042
|
-
bridge.notify('
|
|
2081
|
+
bridge.notify(t('notice.noSessionYet'), 'warning')
|
|
2043
2082
|
return
|
|
2044
2083
|
}
|
|
2045
2084
|
try {
|
|
@@ -2063,13 +2102,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2063
2102
|
label: id.slice(-12),
|
|
2064
2103
|
})
|
|
2065
2104
|
} catch (error: unknown) {
|
|
2066
|
-
bridge.notify(
|
|
2105
|
+
bridge.notify(t('notice.forkFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
2067
2106
|
}
|
|
2068
2107
|
}
|
|
2069
2108
|
|
|
2070
2109
|
const switchSession = (row: SessionRow): void => {
|
|
2071
2110
|
if (!row.resumable) {
|
|
2072
|
-
bridge.notify('
|
|
2111
|
+
bridge.notify(t('notice.subagentsReadOnly'), 'warning')
|
|
2073
2112
|
return
|
|
2074
2113
|
}
|
|
2075
2114
|
requestSwitch({ target: { sessionId: row.id, resume: true }, label: row.title ?? row.id.slice(-12) })
|
|
@@ -2234,29 +2273,29 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2234
2273
|
// the agent always receives the startup prompt first.
|
|
2235
2274
|
if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
|
|
2236
2275
|
if ((startup.images?.length ?? 0) > 0) {
|
|
2237
|
-
bridge.notify(
|
|
2276
|
+
bridge.notify(t('notice.startupImages', { count: startup.images!.length, plural: startup.images!.length === 1 ? '' : 's' }))
|
|
2238
2277
|
}
|
|
2239
2278
|
void inputGate.run(async deliver => {
|
|
2240
2279
|
const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'))
|
|
2241
|
-
if (images.length > 0) bridge.notify(
|
|
2280
|
+
if (images.length > 0) bridge.notify(t('notice.startupImagesAttached', { count: images.length, plural: images.length === 1 ? '' : 's' }))
|
|
2242
2281
|
deliver({ text: startup.prompt ?? '', mode: 'followup', images })
|
|
2243
2282
|
}).catch((error: unknown) => {
|
|
2244
|
-
bridge.notify(
|
|
2283
|
+
bridge.notify(t('notice.initialPromptFailed', { message: error instanceof Error ? error.message : String(error) }), 'error')
|
|
2245
2284
|
})
|
|
2246
2285
|
}
|
|
2247
2286
|
|
|
2248
2287
|
async function copyLastResponse(): Promise<string> {
|
|
2249
2288
|
const text = latestAssistantText(store.getView())
|
|
2250
|
-
if (text === undefined) return '
|
|
2289
|
+
if (text === undefined) return t('notice.copyEmpty')
|
|
2251
2290
|
await copyText(text)
|
|
2252
|
-
return 'copied
|
|
2291
|
+
return t('notice.copied')
|
|
2253
2292
|
}
|
|
2254
2293
|
|
|
2255
2294
|
// A corrupt statusline config must not vanish silently: surface it once
|
|
2256
2295
|
// the notice channel is live, after the first frame settles.
|
|
2257
2296
|
if (statuslineWarning !== undefined) {
|
|
2258
2297
|
setTimeout(() => {
|
|
2259
|
-
bridge.notify('
|
|
2298
|
+
bridge.notify(t('notice.statuslineConfigUnreadable', { message: statuslineWarning }), 'warning')
|
|
2260
2299
|
}, 50)
|
|
2261
2300
|
}
|
|
2262
2301
|
// Same one-shot surface for a corrupt theme file (dark fallback stays live).
|
|
@@ -2267,13 +2306,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
|
|
|
2267
2306
|
}
|
|
2268
2307
|
if (themeWarning !== undefined) {
|
|
2269
2308
|
setTimeout(() => {
|
|
2270
|
-
bridge.notify('
|
|
2309
|
+
bridge.notify(t('notice.themeConfigUnreadable', { message: themeWarning }), 'warning')
|
|
2271
2310
|
}, 50)
|
|
2272
2311
|
}
|
|
2273
2312
|
// And for a corrupt animations file (on-by-default fallback stays live).
|
|
2274
2313
|
if (animationsWarning !== undefined) {
|
|
2275
2314
|
setTimeout(() => {
|
|
2276
|
-
bridge.notify('
|
|
2315
|
+
bridge.notify(t('notice.animationsConfigUnreadable', { message: animationsWarning }), 'warning')
|
|
2277
2316
|
}, 50)
|
|
2278
2317
|
}
|
|
2279
2318
|
|
package/src/input-split.ts
CHANGED
|
@@ -62,10 +62,30 @@ export function createKeypressSplitter(): KeypressSplitter {
|
|
|
62
62
|
}
|
|
63
63
|
const head = buffer[0]
|
|
64
64
|
if (head !== '\x1b') {
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// C0 / DEL stay one key so a coalesced ` \r` is still space then
|
|
66
|
+
// Enter. A printable run (file drag or unbracketed path paste on
|
|
67
|
+
// any platform, including CJK) must stay ONE unit: splitting a
|
|
68
|
+
// long path into tens of setStates overflows React's update depth
|
|
69
|
+
// and never reaches the attachment parser. Typed keys still
|
|
70
|
+
// arrive as one-byte chunks, so this only batches what the OS
|
|
71
|
+
// already coalesced.
|
|
72
|
+
if (head < ' ' || head === '\x7f') {
|
|
73
|
+
units.push(head)
|
|
74
|
+
buffer = buffer.slice(1)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
let take = 0
|
|
78
|
+
while (take < buffer.length) {
|
|
79
|
+
const ch = buffer[take]
|
|
80
|
+
if (ch === '\x1b' || ch < ' ' || ch === '\x7f') break
|
|
81
|
+
if (ch >= '\uD800' && ch <= '\uDBFF') {
|
|
82
|
+
if (buffer[take + 1] === undefined) break
|
|
83
|
+
take += 2
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
take += 1
|
|
87
|
+
}
|
|
88
|
+
if (take === 0) break
|
|
69
89
|
units.push(buffer.slice(0, take))
|
|
70
90
|
buffer = buffer.slice(take)
|
|
71
91
|
continue
|