opencode-sesh 1.0.1 → 1.0.3

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/AGENTS.md CHANGED
@@ -85,7 +85,10 @@ the schema below. Keystrokes never touch the database:
85
85
  - Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
86
86
  (`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
87
87
  use a sibling lock directory and atomic rename. Ctrl-S toggles a selected
88
- session; Ctrl-D toggles its directory. Pinned directories sort before groups
88
+ session; Ctrl-D toggles its directory (terminal picker and option+o picker).
89
+ The sidebar and home lists use Option-S / Option-R instead: opencode binds
90
+ `ctrl+d` itself (composer delete-char, session delete), so ctrl pins would
91
+ shadow the composer. Pinned directories sort before groups
89
92
  containing pinned sessions, with pinned sessions first inside each group.
90
93
 
91
94
  **Output contract** (TSV, 6 fields):
@@ -203,6 +206,10 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
203
206
  - **The sidebar never binds arrow keys globally.** Keyboard navigation is
204
207
  opt-in: clicking the *Sessions* heading (or its search box) activates a
205
208
  priority-20 layer, because a global `↑`/`↓` would hijack the main prompt.
209
+ Clicking outside the sidebar, opening a session, or navigating releases it
210
+ again (with search, hover and any armed delete), so the sidebar cannot
211
+ shadow the composer afterwards. Sidebar-internal clicks stop propagation so
212
+ the outside-click release cannot undo them.
206
213
  Cursor state is derived from `itemRows()`, so hover and cursor resolve to the
207
214
  same row. The section lists every session with no row cap
208
215
  (`tests/tui.mjs` asserts the cap stays out); overflow scrolls inside the
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.3] - 2026-10-08
9
+
10
+ ### Fixed
11
+
12
+ - **tui:** Rebind sidebar pins to option keys to stop shadowing composer
13
+
14
+ ## [1.0.2] - 2026-10-08
15
+
16
+ ### Fixed
17
+
18
+ - **tui:** Release sidebar captures when focus returns to composer
19
+
8
20
  ## [1.0.1] - 2026-10-08
9
21
 
10
22
  ### Fixed
@@ -424,6 +436,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
424
436
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
425
437
  CI.
426
438
 
439
+ [1.0.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.2...v1.0.3
440
+ [1.0.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.1...v1.0.2
427
441
  [1.0.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.0...v1.0.1
428
442
  [1.0.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.14.1...v1.0.0
429
443
  [0.14.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.14.0...v0.14.1
package/README.md CHANGED
@@ -18,14 +18,18 @@ grouped by directory, full-text searchable, and previewable from anywhere.
18
18
  [![GitHub stars](https://img.shields.io/github/stars/ozandogrultan/opencode-sesh?style=social)](https://github.com/ozandogrultan/opencode-sesh/stargazers)
19
19
 
20
20
  ```text
21
- ❯ auth
22
- ~/code/api (8)
23
- ├ Retry the OAuth token refresh 2h
24
- ├ Login rate limiting notes 3d
25
- └ auth middleware cleanup 11d
26
- ~/code/web (3)
27
- ├ Fix session cookie on Safari 1d
28
- └ Redirect loop after logout 6d
21
+ Sessions (8) ▾ hide
22
+ search…
23
+ ▾ …/Projects/portal (5)
24
+ ├○ Retry the OAuth token refr… 2h
25
+ ├○ Login rate limiting notes 3d
26
+ ├○ Draft the release notes 7d
27
+ ├○ Auth middleware cleanup 11d
28
+ └○ Rotate the signing keys 12d
29
+ ▾ ~/Projects/mobile (3)
30
+ ├○ Author avatar upload retry 1d
31
+ ├○ Fix session cookie on Safari 2d
32
+ └○ Auth redirect loop after log… 6d
29
33
  ```
30
34
 
31
35
  ## Why sesh
@@ -197,7 +201,7 @@ workspace at its directory; outside cmux it shows a toast instead.
197
201
  Click the sidebar's `search…` box to filter recent sessions in place; click
198
202
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
199
203
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
200
- Option-P to preview, Ctrl-S/Ctrl-D to pin, Option-D to delete, Esc to leave). Pinned
204
+ Option-P to preview, Option-S/Option-R to pin, Option-D to delete, Esc to leave). Pinned
201
205
  directories rise first, while sessions within each directory are ordered by
202
206
  last update, newest first. The section lists every session and scrolls to fill
203
207
  the sidebar, so click **▾ hide** to collapse it when you need the room.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1050,7 +1050,17 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1050
1050
  setSectionCollapsed((value) => !value)
1051
1051
  }
1052
1052
 
1053
- const openSession = (entry: Entry) => props.api.route.navigate("session", { sessionID: entry.id })
1053
+ const openSession = (entry: Entry) => {
1054
+ // Opening a session hands input back to the main view: release every
1055
+ // sidebar capture first, so its key layers cannot shadow the composer's
1056
+ // once the new session is open.
1057
+ menu.close()
1058
+ cancelDelete()
1059
+ setNavActive(false)
1060
+ setSearching(false)
1061
+ setHovered(undefined)
1062
+ props.api.route.navigate("session", { sessionID: entry.id })
1063
+ }
1054
1064
 
1055
1065
  // Flat order of the rows a cursor can land on, so keyboard navigation and
1056
1066
  // mouse hover resolve to the same row.
@@ -1064,12 +1074,29 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1064
1074
  return `${route.name}:${id}`
1065
1075
  })
1066
1076
  createEffect(on(routeKey, () => menu.close(), { defer: true }))
1077
+ // Navigating away also releases the sidebar's keyboard captures: a nav,
1078
+ // search, hover or armed delete left over from the previous session must
1079
+ // not shadow the composer's keys in the next one.
1080
+ createEffect(on(routeKey, () => {
1081
+ cancelDelete()
1082
+ setNavActive(false)
1083
+ setSearching(false)
1084
+ setHovered(undefined)
1085
+ }, { defer: true }))
1067
1086
  createEffect(() => {
1068
1087
  const state = menuState()
1069
1088
  if (state && !itemRows().some((entry) => entry.id === state.entry.id)) menu.close()
1070
1089
  })
1071
1090
  createEffect(() => {
1072
1091
  if (props.api.ui.dialog.open) menu.close()
1092
+ // A modal dialog owns input while open: yield the sidebar's keyboard
1093
+ // captures too, so its layers cannot double-fire with the dialog's own.
1094
+ if (props.api.ui.dialog.open) {
1095
+ cancelDelete()
1096
+ setNavActive(false)
1097
+ setSearching(false)
1098
+ setHovered(undefined)
1099
+ }
1073
1100
  })
1074
1101
 
1075
1102
  const moveCursor = (delta: number) => {
@@ -1276,6 +1303,12 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1276
1303
  return
1277
1304
  }
1278
1305
  if (searching()) setSearching(false)
1306
+ // A click anywhere outside the sidebar means input is back in the main
1307
+ // view: release the nav layer, hover shortcuts and any armed delete so
1308
+ // the sidebar's keys and rows stop shadowing the composer.
1309
+ cancelDelete()
1310
+ if (navActive()) setNavActive(false)
1311
+ setHovered(undefined)
1279
1312
  }
1280
1313
 
1281
1314
  onMount(() => {
@@ -1332,8 +1365,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1332
1365
  if (!confirmArmed()) requestDelete(entry)
1333
1366
  },
1334
1367
  },
1335
- { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1336
- { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1368
+ { key: "alt+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1369
+ { key: "alt+r", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1337
1370
  {
1338
1371
  key: "/",
1339
1372
  desc: "Search sessions",
@@ -1346,6 +1379,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1346
1379
 
1347
1380
  // Keyboard navigation is opt-in (click the "Sessions" heading or the search
1348
1381
  // box) so the sidebar never steals the arrow keys from the main prompt.
1382
+ // Clicking anywhere outside the sidebar, opening a session, or navigating
1383
+ // releases it again, so its layers cannot shadow the composer afterwards.
1349
1384
  createEffect(() => {
1350
1385
  disposeNav?.()
1351
1386
  disposeNav = undefined
@@ -1382,7 +1417,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1382
1417
  },
1383
1418
  },
1384
1419
  {
1385
- key: "ctrl+s",
1420
+ key: "alt+s",
1386
1421
  desc: "Pin session",
1387
1422
  preventDefault: true,
1388
1423
  cmd: () => {
@@ -1391,7 +1426,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1391
1426
  },
1392
1427
  },
1393
1428
  {
1394
- key: "ctrl+d",
1429
+ key: "alt+r",
1395
1430
  desc: "Pin directory",
1396
1431
  preventDefault: true,
1397
1432
  cmd: () => {
@@ -1450,7 +1485,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1450
1485
  return (
1451
1486
  <box flexDirection="column" paddingRight={1} flexGrow={1} flexShrink={1}>
1452
1487
  <box flexDirection="row" justifyContent="space-between" gap={1}>
1453
- <text flexShrink={1} onMouseDown={() => {
1488
+ <text flexShrink={1} onMouseDown={(event: { stopPropagation: () => void }) => {
1489
+ // Sidebar-internal: keep this click from reaching the root handler,
1490
+ // which would release the nav layer this toggle just activated.
1491
+ event.stopPropagation()
1454
1492
  if (!sectionCollapsed()) setNavActive((value) => !value)
1455
1493
  }}>
1456
1494
  <b>Sessions</b>
@@ -1462,7 +1500,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1462
1500
  <span style={{ fg: theme().warning }}> · {waitingEntries().length} need input</span>
1463
1501
  </Show>
1464
1502
  </text>
1465
- <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
1503
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={(event: { stopPropagation: () => void }) => {
1504
+ event.stopPropagation()
1505
+ toggleSection()
1506
+ }}>
1466
1507
  {sectionCollapsed() ? "▸ show" : "▾ hide"}
1467
1508
  </text>
1468
1509
  </box>
@@ -1481,7 +1522,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1481
1522
  paddingLeft={1}
1482
1523
  paddingRight={1}
1483
1524
  backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1484
- onMouseDown={() => {
1525
+ onMouseDown={(event: { stopPropagation: () => void }) => {
1526
+ // Sidebar-internal: the root handler must not see this click, or
1527
+ // it would clear the search this box just activated.
1528
+ event.stopPropagation()
1485
1529
  insideSearchBox = true
1486
1530
  setSearching(true)
1487
1531
  }}
@@ -1497,7 +1541,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1497
1541
  {searching() ? "▏" : ""}
1498
1542
  </text>
1499
1543
  <Show when={query()}>
1500
- <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
1544
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={(event: { stopPropagation: () => void }) => {
1545
+ event.stopPropagation()
1546
+ setQuery("")
1547
+ }}>
1501
1548
  ✕
1502
1549
  </text>
1503
1550
  </Show>
@@ -1513,7 +1560,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1513
1560
  <For each={tree()}>
1514
1561
  {(row) => {
1515
1562
  if (row.kind === "group") return (
1516
- <box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
1563
+ <box flexDirection="row" paddingTop={1} onMouseDown={(event: { stopPropagation: () => void }) => {
1564
+ event.stopPropagation()
1565
+ toggleGroup(row.dir)
1566
+ }}>
1517
1567
  <text flexGrow={1} flexShrink={1} wrapMode="none">
1518
1568
  <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
1519
1569
  <b>{props.pins().directories.includes(row.dir) ? "★ " : ""}{truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0))}</b>
@@ -1550,7 +1600,13 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1550
1600
  if (event.button === RIGHT_BUTTON) {
1551
1601
  event.stopPropagation()
1552
1602
  menu.open(row.entry, event.x, event.y)
1553
- } else void openSession(row.entry)
1603
+ } else {
1604
+ // Sidebar-internal: openSession releases the sidebar's own
1605
+ // captures, so this click must not also reach the root
1606
+ // handler as an outside click.
1607
+ event.stopPropagation()
1608
+ void openSession(row.entry)
1609
+ }
1554
1610
  }}
1555
1611
  >
1556
1612
  <text flexShrink={0}>
@@ -1591,7 +1647,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1591
1647
  </Show>
1592
1648
  <Show when={navActive()}>
1593
1649
  <box paddingTop={1}>
1594
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · option+d delete · esc done</text>
1650
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · option+s/r pin · option+d delete · esc done</text>
1595
1651
  </box>
1596
1652
  </Show>
1597
1653
  </Show>
@@ -1759,6 +1815,16 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1759
1815
  if (searching()) setSearching(false)
1760
1816
  }
1761
1817
 
1818
+ // A modal dialog owns input while open: yield this list's keyboard captures
1819
+ // too, so its layers cannot double-fire with the dialog's own.
1820
+ createEffect(() => {
1821
+ if (props.api.ui.dialog.open) {
1822
+ cancelDelete()
1823
+ setSearching(false)
1824
+ setHovered(undefined)
1825
+ }
1826
+ })
1827
+
1762
1828
  onMount(() => {
1763
1829
  ensureRootMouse(props.api.renderer)
1764
1830
  rootMouseHandlers.add(onRootMouseDown)
@@ -1806,8 +1872,8 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1806
1872
  if (!confirmArmed()) requestDelete(entry)
1807
1873
  },
1808
1874
  },
1809
- { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1810
- { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1875
+ { key: "alt+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1876
+ { key: "alt+r", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1811
1877
  ],
1812
1878
  })
1813
1879
  })