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.
@@ -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
- * Parse a paste/drop into its image and file paths: image-suffixed tokens
57
- * stay images, other path-shaped tokens ride as file attachments (0.1.5
58
- * file blocks), and anything that is neither leaves both empty — the caller
59
- * then treats the paste as plain text.
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 dot-suffixed leaf after a separator): a dropped terminal path always
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 any dot-suffixed leaf falls through as
65
- * text — the @ mention route still attaches such files deliberately.
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 matcher = /"([^"]+)"|'([^']+)'|(\S+)/gu
77
- for (const match of text.matchAll(matcher)) {
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 { images: [], files: [] }
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
- continue
117
+ return 'image'
118
+ }
119
+ if (looksLikeDroppedFile(path) || looksLikeFilesystemDrop(path)) {
120
+ files.push(path)
121
+ return 'file'
94
122
  }
95
- if (!looksLikeDroppedFile(path)) return { images: [], files: [] }
96
- files.push(path)
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
- props.cancel(props.row.key)
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
- }, [props.row.key])
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
- const anchored = atSeq === undefined
17
- ? undefined
18
- : events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
19
- const boundary = anchored
20
- ?? (atSeq === undefined || atSeq > lastSeq
21
- ? events.findLast(event => event.type === 'turn/end')
22
- : undefined)
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 { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
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(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`${mode === 'steer' ? 'steering' : 'message'} failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`session activation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`session creation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`mode ${selected}`),
1399
- error => bridge.notify(`mode switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
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(`permission ${selected}`)
1417
+ bridge.notify(t('notice.permissionChanged', { value: selected }))
1407
1418
  } catch (error: unknown) {
1408
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`cancel failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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('permission presets are not mounted in this composition', 'warning')
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(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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(`model switch applies to this session but was not saved as the default: ${error instanceof Error ? error.message : String(error)}`, 'warning')
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(`model selection rejected: ${error instanceof Error ? error.message : String(error)} — reopen /model to pick again`, 'error')
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('no session yet — submit a message to start', 'warning')
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(`exported to ${target}`)
1647
+ bridge.notify(t('notice.exported', { path: target }))
1637
1648
  } catch (error: unknown) {
1638
- bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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 only the first bounded picker page;
1689
- // navigation/filter changes trigger a fresh, cancellable observation.
1690
- const page = projected.slice(0, 32)
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
- return mergeSessionTitles(projected, observations)
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. Artifacts are removed children-first: only an I/O error mid-delete
1712
- * can stop it short (reported with removed/total counts), leaving the
1713
- * shallowest lineage intact.
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, and the directory itself is
1748
- // only removed once empty. An unreadable directory counts as a
1749
- // failure (not a silent success) so the outcome line stays honest.
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
- return `delete failed for ${node.id.slice(-12)} after ${removed} of ${plan.nodes.length}: ${error instanceof Error ? error.message : String(error)}`
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
- return `deleted ${removed} session${removed === 1 ? '' : 's'}`
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(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
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 = `previous session flush failed: ${error instanceof Error ? error.message : String(error)}`
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
- cleanupWarning = `${cleanupWarning === undefined ? '' : `${cleanupWarning}; `}previous agent release failed: ${error instanceof Error ? error.message : String(error)}`
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
- ? `${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`
1926
- : `switched to ${next.session.id.slice(-12)}, but ${cleanupWarning}`,
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(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
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(`session switch failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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('that session is already active', 'warning')
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(`will switch to ${request.label} when the current turn finishes · /resume cancel to abort`)
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(`resume failed: ${error instanceof Error ? error.message : String(error)}`, 'error'))
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('no session yet - submit a message to start', 'warning')
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(`fork failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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('subagent conversations are read-only', 'warning')
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(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
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(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
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(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
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 'nothing to copy yet'
2289
+ if (text === undefined) return t('notice.copyEmpty')
2251
2290
  await copyText(text)
2252
- return 'copied latest response'
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('statusline config unreadable, using defaults: ' + statuslineWarning, 'warning')
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('theme config unreadable, using dark: ' + themeWarning, 'warning')
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('animations config unreadable, animations stay on: ' + animationsWarning, 'warning')
2315
+ bridge.notify(t('notice.animationsConfigUnreadable', { message: animationsWarning }), 'warning')
2277
2316
  }, 50)
2278
2317
  }
2279
2318
 
@@ -62,10 +62,30 @@ export function createKeypressSplitter(): KeypressSplitter {
62
62
  }
63
63
  const head = buffer[0]
64
64
  if (head !== '\x1b') {
65
- // Plain bytes key one at a time; a surrogate pair is one grapheme
66
- // and must not split into two lone surrogates.
67
- const pair = head >= '\uD800' && head <= '\uDBFF' && buffer[1] !== undefined
68
- const take = pair ? 2 : 1
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