opencode-sesh 0.11.0 → 0.11.1

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/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.1] - 2026-09-25
11
+
12
+ ### Fixed
13
+
14
+ - Confirming a session delete in the TUI picker no longer leaks `y` or `n` into
15
+ the search input; `Enter` and a second `Ctrl-X` commit the armed delete, and
16
+ session deletion is supported on the home screen.
10
17
  ## [0.11.0] - 2026-09-25
11
18
 
12
19
  ### Changed
@@ -371,7 +378,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
371
378
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
372
379
  CI.
373
380
 
374
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...HEAD
381
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.1...HEAD
382
+ [0.11.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...v0.11.1
375
383
  [0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
376
384
  [0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
377
385
  [0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1013,8 +1013,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1013
1013
  let disposeHoverSpace: (() => void) | undefined
1014
1014
  let disposeSearch: (() => void) | undefined
1015
1015
  let disposeNav: (() => void) | undefined
1016
+ let disposeConfirm: (() => void) | undefined
1016
1017
  let insideSearchBox = false
1017
1018
 
1019
+ createEffect(() => {
1020
+ disposeConfirm?.()
1021
+ disposeConfirm = undefined
1022
+ if (!pendingDelete()) return
1023
+ disposeConfirm = props.api.keymap.registerLayer({
1024
+ mode: "base",
1025
+ priority: 25,
1026
+ bindings: [
1027
+ {
1028
+ key: "y",
1029
+ desc: "Confirm delete",
1030
+ preventDefault: true,
1031
+ cmd: () => {
1032
+ confirmArmed()
1033
+ },
1034
+ },
1035
+ {
1036
+ key: "ctrl+x",
1037
+ desc: "Confirm delete",
1038
+ preventDefault: true,
1039
+ cmd: () => {
1040
+ confirmArmed()
1041
+ },
1042
+ },
1043
+ {
1044
+ key: "enter",
1045
+ desc: "Confirm delete",
1046
+ preventDefault: true,
1047
+ cmd: () => {
1048
+ confirmArmed()
1049
+ },
1050
+ },
1051
+ { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1052
+ { key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1053
+ ],
1054
+ })
1055
+ })
1056
+
1018
1057
  const onRootMouseDown = () => {
1019
1058
  if (insideSearchBox) {
1020
1059
  insideSearchBox = false
@@ -1144,6 +1183,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1144
1183
  desc: "Open session",
1145
1184
  preventDefault: true,
1146
1185
  cmd: () => {
1186
+ if (confirmArmed()) return
1147
1187
  const entry = itemRows()[cursor()]
1148
1188
  if (entry) void openSession(entry)
1149
1189
  },
@@ -1178,6 +1218,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1178
1218
  disposeHoverSpace?.()
1179
1219
  disposeSearch?.()
1180
1220
  disposeNav?.()
1221
+ disposeConfirm?.()
1181
1222
  if (confirmTimer) clearTimeout(confirmTimer)
1182
1223
  })
1183
1224
 
@@ -1337,11 +1378,89 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1337
1378
  const [hovered, setHovered] = createSignal<string>()
1338
1379
  const [searching, setSearching] = createSignal(false)
1339
1380
  const [loadFailed, setLoadFailed] = createSignal(false)
1381
+ const [pendingDelete, setPendingDelete] = createSignal<string>()
1382
+ const [deleting, setDeleting] = createSignal<string>()
1383
+ let confirmTimer: ReturnType<typeof setTimeout> | undefined
1384
+ let disposeConfirm: (() => void) | undefined
1340
1385
  const preview = createTranscriptPreview(props.api)
1341
1386
  let disposeHoverSpace: (() => void) | undefined
1342
1387
  let disposeSearch: (() => void) | undefined
1343
1388
  let insideSearchBox = false
1344
1389
 
1390
+ const cancelDelete = () => {
1391
+ if (confirmTimer) clearTimeout(confirmTimer)
1392
+ confirmTimer = undefined
1393
+ disposeConfirm?.()
1394
+ disposeConfirm = undefined
1395
+ setPendingDelete(undefined)
1396
+ }
1397
+
1398
+ const confirmArmed = (): boolean => {
1399
+ const id = pendingDelete()
1400
+ if (!id) return false
1401
+ const entry = entries().find((e) => e.id === id)
1402
+ if (!entry) cancelDelete()
1403
+ else void deleteEntry(entry)
1404
+ return true
1405
+ }
1406
+
1407
+ const requestDelete = (entry: Entry) => {
1408
+ if (deleting()) return
1409
+ if (pendingDelete() === entry.id) {
1410
+ void deleteEntry(entry)
1411
+ return
1412
+ }
1413
+ setPendingDelete(entry.id)
1414
+ if (confirmTimer) clearTimeout(confirmTimer)
1415
+ confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
1416
+ }
1417
+
1418
+ const deleteEntry = async (entry: Entry) => {
1419
+ cancelDelete()
1420
+ if (deleting()) return
1421
+ setDeleting(entry.id)
1422
+ try {
1423
+ const result = await props.api.client.session.delete({
1424
+ sessionID: entry.id,
1425
+ directory: entry.dir || undefined,
1426
+ })
1427
+ if (result && "error" in result && result.error) throw result.error
1428
+ const { entries: remaining } = await fetchEntries(props.api)
1429
+ if (remaining.some((item) => item.id === entry.id)) {
1430
+ setEntries(remaining)
1431
+ props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
1432
+ return
1433
+ }
1434
+ setEntries(remaining)
1435
+ setHovered(undefined)
1436
+ props.api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
1437
+ if ("params" in props.api.route.current && (props.api.route.current.params as any)?.sessionID === entry.id) {
1438
+ props.api.route.navigate("home")
1439
+ }
1440
+ } catch {
1441
+ props.api.ui.toast({ message: "Could not delete session", variant: "error" })
1442
+ } finally {
1443
+ setDeleting(undefined)
1444
+ }
1445
+ }
1446
+
1447
+ createEffect(() => {
1448
+ disposeConfirm?.()
1449
+ disposeConfirm = undefined
1450
+ if (!pendingDelete()) return
1451
+ disposeConfirm = props.api.keymap.registerLayer({
1452
+ mode: "base",
1453
+ priority: 25,
1454
+ bindings: [
1455
+ { key: "y", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1456
+ { key: "ctrl+x", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1457
+ { key: "enter", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1458
+ { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1459
+ { key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1460
+ ],
1461
+ })
1462
+ })
1463
+
1345
1464
  const onRootMouseDown = () => {
1346
1465
  if (insideSearchBox) {
1347
1466
  insideSearchBox = false
@@ -1389,14 +1508,26 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1389
1508
  preventDefault: true,
1390
1509
  cmd: () => preview.open(entry),
1391
1510
  },
1511
+ {
1512
+ key: "ctrl+x",
1513
+ desc: "Delete session",
1514
+ preventDefault: true,
1515
+ cmd: () => {
1516
+ if (!confirmArmed()) requestDelete(entry)
1517
+ },
1518
+ },
1392
1519
  { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1393
1520
  { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1394
1521
  ],
1395
1522
  })
1396
1523
  })
1397
1524
 
1398
- onCleanup(() => disposeSearch?.())
1399
- onCleanup(() => disposeHoverSpace?.())
1525
+ onCleanup(() => {
1526
+ disposeSearch?.()
1527
+ disposeHoverSpace?.()
1528
+ disposeConfirm?.()
1529
+ if (confirmTimer) clearTimeout(confirmTimer)
1530
+ })
1400
1531
 
1401
1532
  onMount(() => {
1402
1533
  let alive = true
@@ -1497,8 +1628,10 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1497
1628
  color={theme().text}
1498
1629
  matchColor={theme().warning}
1499
1630
  />
1500
- <text flexShrink={0} style={{ fg: theme().textMuted }}>
1501
- {props.pins().directories.includes(entry.dir) ? "★ " : ""}{prettyDir(entry.dir, home)} · {ago(entry.updated)}
1631
+ <text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
1632
+ {pendingDelete() === entry.id
1633
+ ? "ctrl+x again"
1634
+ : `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
1502
1635
  </text>
1503
1636
  </box>
1504
1637
  )}
@@ -1789,12 +1922,22 @@ const tui: TuiPlugin = async (api) => {
1789
1922
  // Delete confirmation lives in its own layer, registered only while a row
1790
1923
  // is armed, so y/n never shadow the search box's typing.
1791
1924
  let disposeConfirm: (() => void) | undefined
1925
+ let confirmTimer: ReturnType<typeof setTimeout> | undefined
1792
1926
  const cancelDelete = () => {
1927
+ if (confirmTimer) clearTimeout(confirmTimer)
1928
+ confirmTimer = undefined
1793
1929
  disposeConfirm?.()
1794
1930
  disposeConfirm = undefined
1795
1931
  setPendingDelete(undefined)
1796
1932
  }
1797
1933
 
1934
+ const confirmArmed = (): boolean => {
1935
+ const armed = pendingDelete()
1936
+ if (!armed) return false
1937
+ void deleteEntry(armed)
1938
+ return true
1939
+ }
1940
+
1798
1941
  // Escape first closes the transcript overlay, then the picker. One Escape
1799
1942
  // can reach both the focused search input and the key layer (sometimes in
1800
1943
  // separate ticks), so ignore the duplicate that trails right behind.
@@ -1807,6 +1950,11 @@ const tui: TuiPlugin = async (api) => {
1807
1950
  escapeHandledAt = now + 150
1808
1951
  return
1809
1952
  }
1953
+ if (pendingDelete()) {
1954
+ cancelDelete()
1955
+ escapeHandledAt = now + 150
1956
+ return
1957
+ }
1810
1958
  cancelDelete()
1811
1959
  close()
1812
1960
  }
@@ -1827,6 +1975,7 @@ const tui: TuiPlugin = async (api) => {
1827
1975
  }
1828
1976
  setAllEntries(remaining)
1829
1977
  api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
1978
+ if (currentSessionID() === entry.id) api.route.navigate("home")
1830
1979
  } catch {
1831
1980
  api.ui.toast({ message: "Could not delete session", variant: "error" })
1832
1981
  }
@@ -1834,19 +1983,38 @@ const tui: TuiPlugin = async (api) => {
1834
1983
 
1835
1984
  const armDelete = (entry: Entry) => {
1836
1985
  setPendingDelete(entry)
1986
+ if (confirmTimer) clearTimeout(confirmTimer)
1987
+ confirmTimer = setTimeout(cancelDelete, 5000)
1837
1988
  disposeConfirm?.()
1838
1989
  disposeConfirm = api.keymap.registerLayer({
1990
+ priority: 20,
1839
1991
  bindings: [
1840
1992
  {
1841
1993
  key: "y",
1842
1994
  desc: "Confirm delete",
1843
1995
  preventDefault: true,
1844
1996
  cmd: () => {
1845
- const armed = pendingDelete()
1846
- if (armed) void deleteEntry(armed)
1997
+ confirmArmed()
1998
+ },
1999
+ },
2000
+ {
2001
+ key: "ctrl+x",
2002
+ desc: "Confirm delete",
2003
+ preventDefault: true,
2004
+ cmd: () => {
2005
+ confirmArmed()
2006
+ },
2007
+ },
2008
+ {
2009
+ key: "enter",
2010
+ desc: "Confirm delete",
2011
+ preventDefault: true,
2012
+ cmd: () => {
2013
+ confirmArmed()
1847
2014
  },
1848
2015
  },
1849
2016
  { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
2017
+ { key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1850
2018
  ],
1851
2019
  })
1852
2020
  }
@@ -1892,7 +2060,15 @@ const tui: TuiPlugin = async (api) => {
1892
2060
  preventDefault: true,
1893
2061
  cmd: () => selectCursor(selectableEntries().length - 1),
1894
2062
  },
1895
- { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
2063
+ {
2064
+ key: "enter",
2065
+ desc: "Open session",
2066
+ preventDefault: true,
2067
+ cmd: () => {
2068
+ if (confirmArmed()) return
2069
+ choose()
2070
+ },
2071
+ },
1896
2072
  { key: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1897
2073
  { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
1898
2074
  const entry = selectableEntries()[cursor()]
@@ -1907,11 +2083,9 @@ const tui: TuiPlugin = async (api) => {
1907
2083
  desc: "Delete session",
1908
2084
  preventDefault: true,
1909
2085
  cmd: () => {
2086
+ if (confirmArmed()) return
1910
2087
  const target = selectableEntries()[cursor()]
1911
- if (!target) return
1912
- const armed = pendingDelete()
1913
- if (armed && armed.id === target.id) void deleteEntry(target)
1914
- else armDelete(target)
2088
+ if (target) armDelete(target)
1915
2089
  },
1916
2090
  },
1917
2091
  {
@@ -1946,6 +2120,7 @@ const tui: TuiPlugin = async (api) => {
1946
2120
 
1947
2121
  const cleanup = () => {
1948
2122
  alive = false
2123
+ if (confirmTimer) clearTimeout(confirmTimer)
1949
2124
  pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
1950
2125
  disposeNav()
1951
2126
  disposeConfirm?.()
@@ -1981,12 +2156,31 @@ const tui: TuiPlugin = async (api) => {
1981
2156
  focusedBackgroundColor={api.theme.current.backgroundPanel}
1982
2157
  focusedTextColor={api.theme.current.text}
1983
2158
  cursorColor={api.theme.current.primary}
1984
- onInput={setQuery}
1985
- onSubmit={() => choose()}
2159
+ onInput={(value) => {
2160
+ if (pendingDelete()) return
2161
+ setQuery(value)
2162
+ }}
2163
+ onSubmit={() => {
2164
+ if (confirmArmed()) return
2165
+ choose()
2166
+ }}
1986
2167
  onKeyDown={(event) => {
1987
2168
  if (event.name === "escape") {
1988
2169
  event.preventDefault()
1989
2170
  handleEscape()
2171
+ return
2172
+ }
2173
+ if (pendingDelete()) {
2174
+ if (event.name === "y" || (event.ctrl && event.name === "x") || event.name === "enter" || event.name === "return") {
2175
+ event.preventDefault()
2176
+ confirmArmed()
2177
+ return
2178
+ }
2179
+ if (event.name === "n") {
2180
+ event.preventDefault()
2181
+ cancelDelete()
2182
+ return
2183
+ }
1990
2184
  }
1991
2185
  }}
1992
2186
  />