opencode-sesh 0.1.7 → 0.2.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.
@@ -137,7 +137,14 @@ const SIDEBAR_TITLE_WIDTH = 27
137
137
  const SIDEBAR_GROUP_WIDTH = 30
138
138
  const HOME_TITLE_WIDTH = 30
139
139
 
140
- const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789-_.@/+".split("")
140
+ // The sidebar is a glanceable recent list, not a second picker: cap it so the
141
+ // section cannot grow into a scrolling wall (the docs promise "most recent"),
142
+ // and point at the picker for everything else.
143
+ const SIDEBAR_LIMIT = 15
144
+
145
+ // Uppercase is included deliberately: a search box that silently drops shifted
146
+ // letters cannot be used for acronyms or paths like README.
147
+ const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@/+:,?!()[]".split("")
141
148
 
142
149
  function searchBindings(append: (input: string) => void, exit: () => void) {
143
150
  return [
@@ -354,6 +361,45 @@ async function buildSearchIndexRemote(
354
361
  const TRANSCRIPT_PREVIEW_TURNS = 8
355
362
  const TRANSCRIPT_PREVIEW_CHARS = 2000
356
363
 
364
+ // Case-insensitive range of `query` inside `text`, so a matched row can show
365
+ // which part of it the search hit instead of only filtering invisibly. Kept
366
+ // below the data-layer markers that tests/tui.mjs evaluates: JSX must not enter
367
+ // that extraction window.
368
+ function matchRange(text: string, query: string): [number, number] | undefined {
369
+ const q = query.trim().toLowerCase()
370
+ if (!q) return undefined
371
+ const at = text.toLowerCase().indexOf(q)
372
+ return at < 0 ? undefined : [at, at + q.length]
373
+ }
374
+
375
+ function Highlighted(props: {
376
+ text: string
377
+ query: string
378
+ color: RGBA
379
+ matchColor: RGBA
380
+ bold?: boolean
381
+ }) {
382
+ const parts = createMemo(() => {
383
+ const range = matchRange(props.text, props.query)
384
+ if (!range) return [props.text, "", ""] as const
385
+ return [props.text.slice(0, range[0]), props.text.slice(range[0], range[1]), props.text.slice(range[1])] as const
386
+ })
387
+ return (
388
+ <text
389
+ flexGrow={1}
390
+ flexShrink={1}
391
+ overflow="hidden"
392
+ wrapMode="none"
393
+ attributes={props.bold ? TextAttributes.BOLD : undefined}
394
+ style={{ fg: props.color }}
395
+ >
396
+ {parts()[0]}
397
+ <span style={{ fg: props.matchColor }}>{parts()[1]}</span>
398
+ {parts()[2]}
399
+ </text>
400
+ )
401
+ }
402
+
357
403
  async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
358
404
  try {
359
405
  const result = await api.client.session.messages({ sessionID })
@@ -464,6 +510,11 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
464
510
  const [query, setQuery] = createSignal("")
465
511
  const [deleting, setDeleting] = createSignal<string>()
466
512
  const [searching, setSearching] = createSignal(false)
513
+ const [loadFailed, setLoadFailed] = createSignal(false)
514
+ const [navActive, setNavActive] = createSignal(false)
515
+ const [cursor, setCursor] = createSignal(0)
516
+ const [pendingDelete, setPendingDelete] = createSignal<string>()
517
+ let confirmTimer: ReturnType<typeof setTimeout> | undefined
467
518
  const currentID = createMemo(() => {
468
519
  const route = props.api.route.current
469
520
  if (route.name !== "session") return undefined
@@ -476,9 +527,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
476
527
  const load = async () => {
477
528
  try {
478
529
  const { entries: all } = await fetchEntries(props.api)
479
- if (alive) setEntries(all)
530
+ if (alive) {
531
+ setEntries(all)
532
+ setLoadFailed(false)
533
+ }
480
534
  } catch {
481
- // leave the previous list in place rather than flashing to blank
535
+ // leave the previous list in place rather than flashing to blank, but
536
+ // say so when there is nothing at all to show
537
+ if (alive && entries().length === 0) setLoadFailed(true)
482
538
  }
483
539
  }
484
540
  void load()
@@ -497,9 +553,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
497
553
  )
498
554
  })
499
555
 
556
+ const shownEntries = createMemo(() => filteredEntries().slice(0, SIDEBAR_LIMIT))
557
+ const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
558
+
500
559
  const tree = createMemo<TreeRow[]>(() => {
501
560
  const groups = new Map<string, Entry[]>()
502
- for (const entry of filteredEntries()) {
561
+ for (const entry of shownEntries()) {
503
562
  const list = groups.get(entry.dir) ?? []
504
563
  list.push(entry)
505
564
  groups.set(entry.dir, list)
@@ -522,6 +581,21 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
522
581
  setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
523
582
  }
524
583
 
584
+ // Flat order of the rows a cursor can land on, so keyboard navigation and
585
+ // mouse hover resolve to the same row.
586
+ const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
587
+ const isActive = (id: string) => (navActive() ? itemRows()[cursor()]?.id === id : hovered() === id)
588
+ const isPendingDelete = (id: string) => pendingDelete() === id
589
+ const moveCursor = (delta: number) => {
590
+ const count = itemRows().length
591
+ if (count === 0) return
592
+ setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
593
+ }
594
+ createEffect(() => {
595
+ const count = itemRows().length
596
+ if (cursor() >= count) setCursor(Math.max(0, count - 1))
597
+ })
598
+
525
599
  const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
526
600
  try {
527
601
  const permissions = props.api.state.session.permission(id)
@@ -540,7 +614,28 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
540
614
  return theme().textMuted
541
615
  }
542
616
 
617
+ const cancelDelete = () => {
618
+ if (confirmTimer) clearTimeout(confirmTimer)
619
+ confirmTimer = undefined
620
+ setPendingDelete(undefined)
621
+ }
622
+
623
+ // Deleting is irreversible, so ctrl+x arms the row and a second ctrl+x (or y)
624
+ // commits it. Anything else — another row, five seconds, a keypress elsewhere
625
+ // — lets it go. The old behaviour deleted whatever was hovered outright.
626
+ const requestDelete = (entry: Entry) => {
627
+ if (deleting()) return
628
+ if (pendingDelete() === entry.id) {
629
+ void deleteEntry(entry)
630
+ return
631
+ }
632
+ setPendingDelete(entry.id)
633
+ if (confirmTimer) clearTimeout(confirmTimer)
634
+ confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
635
+ }
636
+
543
637
  const deleteEntry = async (entry: Entry) => {
638
+ cancelDelete()
544
639
  if (deleting()) return
545
640
  setDeleting(entry.id)
546
641
  try {
@@ -568,6 +663,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
568
663
 
569
664
  let disposeHoverSpace: (() => void) | undefined
570
665
  let disposeSearch: (() => void) | undefined
666
+ let disposeNav: (() => void) | undefined
571
667
  let insideSearchBox = false
572
668
 
573
669
  const onRootMouseDown = () => {
@@ -623,7 +719,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
623
719
  key: "ctrl+x",
624
720
  desc: "Delete session",
625
721
  preventDefault: true,
626
- cmd: () => void deleteEntry(entry),
722
+ cmd: () => requestDelete(entry),
627
723
  },
628
724
  {
629
725
  key: "/",
@@ -635,22 +731,111 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
635
731
  })
636
732
  })
637
733
 
734
+ // Mouse hover must not commit a keyboard-initiated confirm (and vice versa):
735
+ // leaving the armed row abandons the pending delete.
736
+ createEffect(() => {
737
+ const pending = pendingDelete()
738
+ if (pending && !navActive() && pending !== hovered()) cancelDelete()
739
+ })
740
+
741
+ // Keyboard navigation is opt-in (click the "Sessions" heading or the search
742
+ // box) so the sidebar never steals the arrow keys from the main prompt.
743
+ createEffect(() => {
744
+ disposeNav?.()
745
+ disposeNav = undefined
746
+ if (!navActive()) return
747
+ const bindings = [
748
+ { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
749
+ { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
750
+ { key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-5) },
751
+ { key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(5) },
752
+ { key: "home", desc: "First session", preventDefault: true, cmd: () => setCursor(0) },
753
+ {
754
+ key: "end",
755
+ desc: "Last session",
756
+ preventDefault: true,
757
+ cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
758
+ },
759
+ {
760
+ key: "space",
761
+ desc: "Preview session transcript",
762
+ preventDefault: true,
763
+ cmd: () => {
764
+ const entry = itemRows()[cursor()]
765
+ if (entry) openSidebarPreview(entry)
766
+ },
767
+ },
768
+ {
769
+ key: "ctrl+x",
770
+ desc: "Delete session",
771
+ preventDefault: true,
772
+ cmd: () => {
773
+ const entry = itemRows()[cursor()]
774
+ if (entry) requestDelete(entry)
775
+ },
776
+ },
777
+ {
778
+ key: "enter",
779
+ desc: "Open session",
780
+ preventDefault: true,
781
+ cmd: () => {
782
+ const entry = itemRows()[cursor()]
783
+ if (entry) props.api.route.navigate("session", { sessionID: entry.id })
784
+ },
785
+ },
786
+ {
787
+ key: "escape",
788
+ desc: "Leave the session list",
789
+ preventDefault: true,
790
+ cmd: () => {
791
+ cancelDelete()
792
+ setNavActive(false)
793
+ },
794
+ },
795
+ ...(pendingDelete()
796
+ ? [
797
+ {
798
+ key: "y",
799
+ desc: "Confirm delete",
800
+ preventDefault: true,
801
+ cmd: () => {
802
+ const entry = itemRows()[cursor()]
803
+ if (entry && pendingDelete() === entry.id) void deleteEntry(entry)
804
+ },
805
+ },
806
+ { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
807
+ ]
808
+ : []),
809
+ ]
810
+ disposeNav = props.api.keymap.registerLayer({ mode: "base", priority: 20, bindings })
811
+ })
812
+
638
813
  onCleanup(() => {
639
814
  disposeHoverSpace?.()
640
815
  disposeSearch?.()
816
+ disposeNav?.()
817
+ if (confirmTimer) clearTimeout(confirmTimer)
641
818
  })
642
819
 
643
820
  return (
644
821
  <box flexDirection="column" paddingRight={1}>
645
- <Show when={entries().length > 0}>
646
- <box flexDirection="row" gap={1}>
647
- <text>
648
- <b>Sessions</b>
649
- <span style={{ fg: theme().textMuted }}>
650
- {query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
651
- </span>
822
+ <box flexDirection="row" gap={1} onMouseDown={() => setNavActive((value) => !value)}>
823
+ <text>
824
+ <b>Sessions</b>
825
+ <span style={{ fg: theme().textMuted }}>
826
+ {query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
827
+ </span>
828
+ <span style={{ fg: navActive() ? theme().accent : theme().textMuted }}>{navActive() ? " ▸ nav" : " ▸"}</span>
829
+ </text>
830
+ </box>
831
+ <Show
832
+ when={entries().length > 0}
833
+ fallback={
834
+ <text style={{ fg: theme().textMuted }}>
835
+ {loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · ctrl+o to browse"}
652
836
  </text>
653
- </box>
837
+ }
838
+ >
654
839
  <box
655
840
  flexDirection="row"
656
841
  gap={1}
@@ -701,7 +886,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
701
886
  paddingLeft={1}
702
887
  paddingRight={1}
703
888
  backgroundColor={
704
- row.entry.id === hovered() && row.entry.id !== currentID()
889
+ isActive(row.entry.id) && row.entry.id !== currentID()
705
890
  ? theme().backgroundElement
706
891
  : RGBA.fromInts(0, 0, 0, 0)
707
892
  }
@@ -712,22 +897,39 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
712
897
  <text flexShrink={0}>
713
898
  <span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
714
899
  <span
715
- style={{ fg: row.entry.id === currentID() ? theme().accent : statusColor(row.entry.id) }}
900
+ style={{
901
+ fg: isPendingDelete(row.entry.id)
902
+ ? theme().error
903
+ : row.entry.id === currentID()
904
+ ? theme().accent
905
+ : statusColor(row.entry.id),
906
+ }}
716
907
  >
717
- {row.entry.id === currentID() ? "●" : "○"}
908
+ {row.entry.id === currentID() ? "●" : isActive(row.entry.id) ? "▸" : "○"}
718
909
  </span>
719
910
  </text>
720
- <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
721
- {truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
722
- </text>
723
- <text flexShrink={0} style={{ fg: theme().textMuted }}>
724
- {ago(row.entry.updated)}
911
+ <Highlighted
912
+ text={truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
913
+ query={query()}
914
+ color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
915
+ matchColor={theme().warning}
916
+ />
917
+ <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
918
+ {isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
725
919
  </text>
726
920
  </box>
727
921
  )
728
922
  }
729
923
  </For>
730
924
  </scrollbox>
925
+ <Show when={remaining() > 0}>
926
+ <text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
927
+ </Show>
928
+ <Show when={navActive()}>
929
+ <box paddingTop={1}>
930
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · space preview · ctrl+x delete · esc done</text>
931
+ </box>
932
+ </Show>
731
933
  </Show>
732
934
  </box>
733
935
  )
@@ -742,6 +944,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
742
944
  const [query, setQuery] = createSignal("")
743
945
  const [hovered, setHovered] = createSignal<string>()
744
946
  const [searching, setSearching] = createSignal(false)
947
+ const [loadFailed, setLoadFailed] = createSignal(false)
745
948
  const preview = createTranscriptPreview(props.api)
746
949
  let disposeHoverSpace: (() => void) | undefined
747
950
  let disposeSearch: (() => void) | undefined
@@ -806,8 +1009,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
806
1009
  const load = async () => {
807
1010
  try {
808
1011
  const { entries: all } = await fetchEntries(props.api)
809
- if (alive) setEntries(all)
810
- } catch {}
1012
+ if (alive) {
1013
+ setEntries(all)
1014
+ setLoadFailed(false)
1015
+ }
1016
+ } catch {
1017
+ if (alive && entries().length === 0) setLoadFailed(true)
1018
+ }
811
1019
  }
812
1020
  void load()
813
1021
  const clock = setInterval(() => void load(), POLL_MS)
@@ -829,9 +1037,22 @@ function HomeSessions(props: { api: TuiPluginApi }) {
829
1037
 
830
1038
  return (
831
1039
  <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
832
- <Show when={entries().length > 0}>
1040
+ <Show
1041
+ when={entries().length > 0}
1042
+ fallback={
1043
+ <box flexDirection="row" gap={1}>
1044
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1045
+ <text style={{ fg: theme().textMuted }}>
1046
+ {loadFailed() ? "· list unavailable, will retry" : "· none yet, ctrl+o to browse"}
1047
+ </text>
1048
+ </box>
1049
+ }
1050
+ >
833
1051
  <box flexDirection="row" justifyContent="space-between" gap={2}>
834
- <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1052
+ <box flexDirection="row" gap={1}>
1053
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1054
+ <text style={{ fg: theme().textMuted }}>· ctrl+o for all</text>
1055
+ </box>
835
1056
  <box
836
1057
  flexDirection="row"
837
1058
  gap={1}
@@ -875,9 +1096,12 @@ function HomeSessions(props: { api: TuiPluginApi }) {
875
1096
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
876
1097
  ○
877
1098
  </text>
878
- <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
879
- {truncate(entry.title, HOME_TITLE_WIDTH)}
880
- </text>
1099
+ <Highlighted
1100
+ text={truncate(entry.title, HOME_TITLE_WIDTH)}
1101
+ query={query()}
1102
+ color={theme().text}
1103
+ matchColor={theme().warning}
1104
+ />
881
1105
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
882
1106
  {prettyDir(entry.dir, home)} · {ago(entry.updated)}
883
1107
  </text>
@@ -922,10 +1146,13 @@ const tui: TuiPlugin = async (api) => {
922
1146
  return
923
1147
  }
924
1148
 
1149
+ const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
925
1150
  const [previewText, setPreviewText] = createSignal("")
926
1151
  const [showPreview, setShowPreview] = createSignal(false)
927
1152
  const [query, setQuery] = createSignal("")
928
1153
  const [cursor, setCursor] = createSignal(0)
1154
+ const [scope, setScope] = createSignal<string>()
1155
+ const [pendingDelete, setPendingDelete] = createSignal<Entry>()
929
1156
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
930
1157
  const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
931
1158
  indexed: 0,
@@ -940,7 +1167,9 @@ const tui: TuiPlugin = async (api) => {
940
1167
  const matchedGroups = createMemo(() => {
941
1168
  const q = query().trim().toLowerCase()
942
1169
  const index = searchIndex()
943
- const matched = entries.filter((entry) => {
1170
+ const group = scope()
1171
+ const matched = allEntries().filter((entry) => {
1172
+ if (group && entry.group !== group) return false
944
1173
  if (!q) return true
945
1174
  const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
946
1175
  return haystack.includes(q)
@@ -961,6 +1190,7 @@ const tui: TuiPlugin = async (api) => {
961
1190
  const coverageLabel = createMemo(() => {
962
1191
  const progress = indexProgress()
963
1192
  const bits: string[] = []
1193
+ if (scope()) bits.push(`scope ${scope()}`)
964
1194
  if (progress.total > 0) {
965
1195
  bits.push(
966
1196
  progress.complete
@@ -968,7 +1198,7 @@ const tui: TuiPlugin = async (api) => {
968
1198
  : `indexing transcripts ${progress.indexed}/${progress.total}`,
969
1199
  )
970
1200
  }
971
- if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
1201
+ if (entriesResult.truncated) bits.push(`showing newest ${allEntries().length}`)
972
1202
  return bits.join(" · ")
973
1203
  })
974
1204
 
@@ -1053,9 +1283,76 @@ const tui: TuiPlugin = async (api) => {
1053
1283
  const moveCursor = (delta: number) => {
1054
1284
  const count = selectableEntries().length
1055
1285
  if (count === 0) return
1286
+ cancelDelete()
1056
1287
  setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
1057
1288
  }
1058
1289
 
1290
+ // Delete confirmation lives in its own layer, registered only while a row
1291
+ // is armed, so y/n never shadow the search box's typing.
1292
+ let disposeConfirm: (() => void) | undefined
1293
+ const cancelDelete = () => {
1294
+ disposeConfirm?.()
1295
+ disposeConfirm = undefined
1296
+ setPendingDelete(undefined)
1297
+ }
1298
+
1299
+ const deleteEntry = async (entry: Entry) => {
1300
+ cancelDelete()
1301
+ try {
1302
+ const result = await api.client.session.delete({
1303
+ sessionID: entry.id,
1304
+ directory: entry.dir || undefined,
1305
+ })
1306
+ if (result && "error" in result && result.error) throw result.error
1307
+ const { entries: remaining } = await fetchEntries(api)
1308
+ if (remaining.some((item) => item.id === entry.id)) {
1309
+ setAllEntries(remaining)
1310
+ api.ui.toast({ message: "Session was not deleted", variant: "error" })
1311
+ return
1312
+ }
1313
+ setAllEntries(remaining)
1314
+ api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
1315
+ } catch {
1316
+ api.ui.toast({ message: "Could not delete session", variant: "error" })
1317
+ }
1318
+ }
1319
+
1320
+ const armDelete = (entry: Entry) => {
1321
+ setPendingDelete(entry)
1322
+ disposeConfirm?.()
1323
+ disposeConfirm = api.keymap.registerLayer({
1324
+ bindings: [
1325
+ {
1326
+ key: "y",
1327
+ desc: "Confirm delete",
1328
+ preventDefault: true,
1329
+ cmd: () => {
1330
+ const armed = pendingDelete()
1331
+ if (armed) void deleteEntry(armed)
1332
+ },
1333
+ },
1334
+ { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1335
+ ],
1336
+ })
1337
+ }
1338
+
1339
+ const forkEntry = async (entry: Entry) => {
1340
+ try {
1341
+ const result = await api.client.session.fork({
1342
+ sessionID: entry.id,
1343
+ directory: entry.dir || undefined,
1344
+ })
1345
+ const created = (result?.data ?? result) as Session | undefined
1346
+ if (!created?.id) throw new Error("fork returned no session")
1347
+ cleanup()
1348
+ api.ui.dialog.clear()
1349
+ api.route.navigate("session", { sessionID: created.id })
1350
+ api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
1351
+ } catch {
1352
+ api.ui.toast({ message: "Could not fork session", variant: "error" })
1353
+ }
1354
+ }
1355
+
1059
1356
  const choose = (entry?: Entry) => {
1060
1357
  const target = entry ?? selectableEntries()[cursor()]
1061
1358
  if (!target) return
@@ -1079,21 +1376,43 @@ const tui: TuiPlugin = async (api) => {
1079
1376
  },
1080
1377
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1081
1378
  {
1082
- key: "escape",
1083
- desc: "Close",
1379
+ key: "ctrl+x",
1380
+ desc: "Delete session",
1084
1381
  preventDefault: true,
1085
1382
  cmd: () => {
1086
- cleanup()
1087
- api.ui.dialog.clear()
1383
+ const target = selectableEntries()[cursor()]
1384
+ if (!target) return
1385
+ const armed = pendingDelete()
1386
+ if (armed && armed.id === target.id) void deleteEntry(target)
1387
+ else armDelete(target)
1388
+ },
1389
+ },
1390
+ {
1391
+ key: "ctrl+f",
1392
+ desc: "Fork session",
1393
+ preventDefault: true,
1394
+ cmd: () => {
1395
+ const target = selectableEntries()[cursor()]
1396
+ if (target) void forkEntry(target)
1088
1397
  },
1089
1398
  },
1090
1399
  {
1091
1400
  key: "ctrl+g",
1401
+ desc: "Toggle project scope",
1402
+ preventDefault: true,
1403
+ cmd: () => {
1404
+ const target = selectableEntries()[cursor()]
1405
+ if (scope()) setScope(undefined)
1406
+ else if (target) setScope(target.group)
1407
+ },
1408
+ },
1409
+ {
1410
+ key: "escape",
1092
1411
  desc: "Close",
1093
1412
  preventDefault: true,
1094
1413
  cmd: () => {
1095
- cleanup()
1096
- api.ui.dialog.clear()
1414
+ cancelDelete()
1415
+ close()
1097
1416
  },
1098
1417
  },
1099
1418
  ],
@@ -1101,6 +1420,8 @@ const tui: TuiPlugin = async (api) => {
1101
1420
 
1102
1421
  const cleanup = () => {
1103
1422
  disposeNav()
1423
+ disposeConfirm?.()
1424
+ disposeConfirm = undefined
1104
1425
  if (previewTimer) clearTimeout(previewTimer)
1105
1426
  }
1106
1427
 
@@ -1194,38 +1515,47 @@ const tui: TuiPlugin = async (api) => {
1194
1515
  ●
1195
1516
  </text>
1196
1517
  </Show>
1197
- <text
1198
- flexGrow={1}
1199
- flexShrink={1}
1200
- overflow="hidden"
1201
- wrapMode="none"
1202
- attributes={
1203
- row.entry.id === selectableEntries()[cursor()]?.id ? TextAttributes.BOLD : undefined
1518
+ <Highlighted
1519
+ text={truncate(row.entry.title, 61)}
1520
+ query={query()}
1521
+ bold={row.entry.id === selectableEntries()[cursor()]?.id}
1522
+ color={
1523
+ row.entry.id === selectableEntries()[cursor()]?.id
1524
+ ? api.theme.current.selectedListItemText
1525
+ : api.theme.current.text
1204
1526
  }
1205
- style={{
1206
- fg:
1207
- row.entry.id === selectableEntries()[cursor()]?.id
1208
- ? api.theme.current.selectedListItemText
1209
- : api.theme.current.text,
1210
- }}
1211
- >
1212
- {truncate(row.entry.title, 61)}
1213
- </text>
1527
+ matchColor={
1528
+ row.entry.id === selectableEntries()[cursor()]?.id
1529
+ ? api.theme.current.selectedListItemText
1530
+ : api.theme.current.warning
1531
+ }
1532
+ />
1214
1533
  <text
1215
1534
  flexShrink={0}
1216
1535
  style={{
1217
1536
  fg:
1218
- row.entry.id === selectableEntries()[cursor()]?.id
1219
- ? api.theme.current.selectedListItemText
1220
- : api.theme.current.textMuted,
1537
+ pendingDelete()?.id === row.entry.id
1538
+ ? api.theme.current.error
1539
+ : row.entry.id === selectableEntries()[cursor()]?.id
1540
+ ? api.theme.current.selectedListItemText
1541
+ : api.theme.current.textMuted,
1221
1542
  }}
1222
1543
  >
1223
- {prettyDir(row.entry.dir, process.env.HOME ?? "")} · {ago(row.entry.updated)}
1544
+ {pendingDelete()?.id === row.entry.id
1545
+ ? "press y to delete"
1546
+ : `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}`}
1224
1547
  </text>
1225
1548
  </box>
1226
1549
  )
1227
1550
  }
1228
1551
  </For>
1552
+ <Show when={selectableEntries().length === 0}>
1553
+ <box paddingLeft={4} paddingRight={4}>
1554
+ <text style={{ fg: api.theme.current.textMuted }}>
1555
+ {scope() ? `No sessions in ${scope()}` : "No sessions match this search"}
1556
+ </text>
1557
+ </box>
1558
+ </Show>
1229
1559
  </box>
1230
1560
  <Show when={showPreview()}>
1231
1561
  <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
@@ -1249,8 +1579,14 @@ const tui: TuiPlugin = async (api) => {
1249
1579
  </box>
1250
1580
  </Show>
1251
1581
  <box paddingLeft={4} paddingRight={4} flexShrink={0}>
1252
- <text style={{ fg: api.theme.current.textMuted }}>
1253
- ↑↓ navigate · enter open · space preview · esc close
1582
+ <text
1583
+ style={{
1584
+ fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
1585
+ }}
1586
+ >
1587
+ {pendingDelete()
1588
+ ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
1589
+ : "↑↓ navigate · enter open · ctrl+x delete · ctrl+f fork · ctrl+g project · space preview · esc close"}
1254
1590
  </text>
1255
1591
  </box>
1256
1592
  </box>