opencode-sesh 0.1.6 → 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.
@@ -130,7 +130,21 @@ function truncate(text: string, width: number): string {
130
130
  return text.slice(0, width - 1) + "…"
131
131
  }
132
132
 
133
- const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789-_.@/+".split("")
133
+ // Sidebar and home rows should read at a similar length. The sidebar title
134
+ // column is ~2 chars narrower than the group column (status dot + timestamp),
135
+ // so cap it a bit lower to guarantee separation.
136
+ const SIDEBAR_TITLE_WIDTH = 27
137
+ const SIDEBAR_GROUP_WIDTH = 30
138
+ const HOME_TITLE_WIDTH = 30
139
+
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("")
134
148
 
135
149
  function searchBindings(append: (input: string) => void, exit: () => void) {
136
150
  return [
@@ -347,6 +361,45 @@ async function buildSearchIndexRemote(
347
361
  const TRANSCRIPT_PREVIEW_TURNS = 8
348
362
  const TRANSCRIPT_PREVIEW_CHARS = 2000
349
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
+
350
403
  async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
351
404
  try {
352
405
  const result = await api.client.session.messages({ sessionID })
@@ -445,7 +498,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
445
498
 
446
499
  type TreeRow =
447
500
  | { kind: "group"; dir: string; label: string; count: number }
448
- | { kind: "item"; entry: Entry }
501
+ | { kind: "item"; entry: Entry; last: boolean }
449
502
 
450
503
  function SidebarSessions(props: { api: TuiPluginApi }) {
451
504
  const theme = () => props.api.theme.current
@@ -457,6 +510,11 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
457
510
  const [query, setQuery] = createSignal("")
458
511
  const [deleting, setDeleting] = createSignal<string>()
459
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
460
518
  const currentID = createMemo(() => {
461
519
  const route = props.api.route.current
462
520
  if (route.name !== "session") return undefined
@@ -469,9 +527,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
469
527
  const load = async () => {
470
528
  try {
471
529
  const { entries: all } = await fetchEntries(props.api)
472
- if (alive) setEntries(all)
530
+ if (alive) {
531
+ setEntries(all)
532
+ setLoadFailed(false)
533
+ }
473
534
  } catch {
474
- // 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)
475
538
  }
476
539
  }
477
540
  void load()
@@ -490,9 +553,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
490
553
  )
491
554
  })
492
555
 
556
+ const shownEntries = createMemo(() => filteredEntries().slice(0, SIDEBAR_LIMIT))
557
+ const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
558
+
493
559
  const tree = createMemo<TreeRow[]>(() => {
494
560
  const groups = new Map<string, Entry[]>()
495
- for (const entry of filteredEntries()) {
561
+ for (const entry of shownEntries()) {
496
562
  const list = groups.get(entry.dir) ?? []
497
563
  list.push(entry)
498
564
  groups.set(entry.dir, list)
@@ -504,7 +570,9 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
504
570
  for (const group of ordered) {
505
571
  rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
506
572
  if (collapsed()[group.dir]) continue
507
- for (const entry of group.list) rows.push({ kind: "item", entry })
573
+ for (let i = 0; i < group.list.length; i++) {
574
+ rows.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
575
+ }
508
576
  }
509
577
  return rows
510
578
  })
@@ -513,6 +581,21 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
513
581
  setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
514
582
  }
515
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
+
516
599
  const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
517
600
  try {
518
601
  const permissions = props.api.state.session.permission(id)
@@ -531,7 +614,28 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
531
614
  return theme().textMuted
532
615
  }
533
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
+
534
637
  const deleteEntry = async (entry: Entry) => {
638
+ cancelDelete()
535
639
  if (deleting()) return
536
640
  setDeleting(entry.id)
537
641
  try {
@@ -559,6 +663,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
559
663
 
560
664
  let disposeHoverSpace: (() => void) | undefined
561
665
  let disposeSearch: (() => void) | undefined
666
+ let disposeNav: (() => void) | undefined
562
667
  let insideSearchBox = false
563
668
 
564
669
  const onRootMouseDown = () => {
@@ -614,7 +719,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
614
719
  key: "ctrl+x",
615
720
  desc: "Delete session",
616
721
  preventDefault: true,
617
- cmd: () => void deleteEntry(entry),
722
+ cmd: () => requestDelete(entry),
618
723
  },
619
724
  {
620
725
  key: "/",
@@ -626,22 +731,111 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
626
731
  })
627
732
  })
628
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
+
629
813
  onCleanup(() => {
630
814
  disposeHoverSpace?.()
631
815
  disposeSearch?.()
816
+ disposeNav?.()
817
+ if (confirmTimer) clearTimeout(confirmTimer)
632
818
  })
633
819
 
634
820
  return (
635
821
  <box flexDirection="column" paddingRight={1}>
636
- <Show when={entries().length > 0}>
637
- <box flexDirection="row" gap={1}>
638
- <text>
639
- <b>Sessions</b>
640
- <span style={{ fg: theme().textMuted }}>
641
- {query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
642
- </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"}
643
836
  </text>
644
- </box>
837
+ }
838
+ >
645
839
  <box
646
840
  flexDirection="row"
647
841
  gap={1}
@@ -676,10 +870,10 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
676
870
  <For each={tree()}>
677
871
  {(row) =>
678
872
  row.kind === "group" ? (
679
- <box flexDirection="row" onMouseDown={() => toggleGroup(row.dir)}>
873
+ <box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
680
874
  <text flexGrow={1} flexShrink={1} wrapMode="none">
681
875
  <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
682
- <b>{row.label}</b>
876
+ <b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
683
877
  </text>
684
878
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
685
879
  {" "}({row.count})
@@ -692,7 +886,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
692
886
  paddingLeft={1}
693
887
  paddingRight={1}
694
888
  backgroundColor={
695
- row.entry.id === hovered() && row.entry.id !== currentID()
889
+ isActive(row.entry.id) && row.entry.id !== currentID()
696
890
  ? theme().backgroundElement
697
891
  : RGBA.fromInts(0, 0, 0, 0)
698
892
  }
@@ -700,23 +894,42 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
700
894
  onMouseOut={() => setHovered(undefined)}
701
895
  onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
702
896
  >
703
- <text
704
- flexShrink={0}
705
- style={{ fg: row.entry.id === currentID() ? theme().accent : statusColor(row.entry.id) }}
706
- >
707
- {row.entry.id === currentID() ? "●" : "○"}
708
- </text>
709
- <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
710
- {row.entry.title}
897
+ <text flexShrink={0}>
898
+ <span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
899
+ <span
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
+ }}
907
+ >
908
+ {row.entry.id === currentID() ? "●" : isActive(row.entry.id) ? "▸" : "○"}
909
+ </span>
711
910
  </text>
712
- <text flexShrink={0} style={{ fg: theme().textMuted }}>
713
- {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)}
714
919
  </text>
715
920
  </box>
716
921
  )
717
922
  }
718
923
  </For>
719
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>
720
933
  </Show>
721
934
  </box>
722
935
  )
@@ -731,6 +944,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
731
944
  const [query, setQuery] = createSignal("")
732
945
  const [hovered, setHovered] = createSignal<string>()
733
946
  const [searching, setSearching] = createSignal(false)
947
+ const [loadFailed, setLoadFailed] = createSignal(false)
734
948
  const preview = createTranscriptPreview(props.api)
735
949
  let disposeHoverSpace: (() => void) | undefined
736
950
  let disposeSearch: (() => void) | undefined
@@ -795,8 +1009,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
795
1009
  const load = async () => {
796
1010
  try {
797
1011
  const { entries: all } = await fetchEntries(props.api)
798
- if (alive) setEntries(all)
799
- } catch {}
1012
+ if (alive) {
1013
+ setEntries(all)
1014
+ setLoadFailed(false)
1015
+ }
1016
+ } catch {
1017
+ if (alive && entries().length === 0) setLoadFailed(true)
1018
+ }
800
1019
  }
801
1020
  void load()
802
1021
  const clock = setInterval(() => void load(), POLL_MS)
@@ -818,9 +1037,22 @@ function HomeSessions(props: { api: TuiPluginApi }) {
818
1037
 
819
1038
  return (
820
1039
  <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
821
- <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
+ >
822
1051
  <box flexDirection="row" justifyContent="space-between" gap={2}>
823
- <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>
824
1056
  <box
825
1057
  flexDirection="row"
826
1058
  gap={1}
@@ -864,9 +1096,12 @@ function HomeSessions(props: { api: TuiPluginApi }) {
864
1096
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
865
1097
  ○
866
1098
  </text>
867
- <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
868
- {truncate(entry.title, 52)}
869
- </text>
1099
+ <Highlighted
1100
+ text={truncate(entry.title, HOME_TITLE_WIDTH)}
1101
+ query={query()}
1102
+ color={theme().text}
1103
+ matchColor={theme().warning}
1104
+ />
870
1105
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
871
1106
  {prettyDir(entry.dir, home)} · {ago(entry.updated)}
872
1107
  </text>
@@ -911,10 +1146,13 @@ const tui: TuiPlugin = async (api) => {
911
1146
  return
912
1147
  }
913
1148
 
1149
+ const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
914
1150
  const [previewText, setPreviewText] = createSignal("")
915
1151
  const [showPreview, setShowPreview] = createSignal(false)
916
1152
  const [query, setQuery] = createSignal("")
917
1153
  const [cursor, setCursor] = createSignal(0)
1154
+ const [scope, setScope] = createSignal<string>()
1155
+ const [pendingDelete, setPendingDelete] = createSignal<Entry>()
918
1156
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
919
1157
  const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
920
1158
  indexed: 0,
@@ -929,7 +1167,9 @@ const tui: TuiPlugin = async (api) => {
929
1167
  const matchedGroups = createMemo(() => {
930
1168
  const q = query().trim().toLowerCase()
931
1169
  const index = searchIndex()
932
- const matched = entries.filter((entry) => {
1170
+ const group = scope()
1171
+ const matched = allEntries().filter((entry) => {
1172
+ if (group && entry.group !== group) return false
933
1173
  if (!q) return true
934
1174
  const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
935
1175
  return haystack.includes(q)
@@ -950,6 +1190,7 @@ const tui: TuiPlugin = async (api) => {
950
1190
  const coverageLabel = createMemo(() => {
951
1191
  const progress = indexProgress()
952
1192
  const bits: string[] = []
1193
+ if (scope()) bits.push(`scope ${scope()}`)
953
1194
  if (progress.total > 0) {
954
1195
  bits.push(
955
1196
  progress.complete
@@ -957,7 +1198,7 @@ const tui: TuiPlugin = async (api) => {
957
1198
  : `indexing transcripts ${progress.indexed}/${progress.total}`,
958
1199
  )
959
1200
  }
960
- if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
1201
+ if (entriesResult.truncated) bits.push(`showing newest ${allEntries().length}`)
961
1202
  return bits.join(" · ")
962
1203
  })
963
1204
 
@@ -1032,7 +1273,8 @@ const tui: TuiPlugin = async (api) => {
1032
1273
  setPreviewText(cached)
1033
1274
  return
1034
1275
  }
1035
- setPreviewText("")
1276
+ // Keep the previous transcript visible while the next one loads so the
1277
+ // preview does not blink away on every cursor move.
1036
1278
  previewTimer = setTimeout(() => void loadPreview(id), 120)
1037
1279
  })
1038
1280
 
@@ -1041,9 +1283,76 @@ const tui: TuiPlugin = async (api) => {
1041
1283
  const moveCursor = (delta: number) => {
1042
1284
  const count = selectableEntries().length
1043
1285
  if (count === 0) return
1286
+ cancelDelete()
1044
1287
  setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
1045
1288
  }
1046
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
+
1047
1356
  const choose = (entry?: Entry) => {
1048
1357
  const target = entry ?? selectableEntries()[cursor()]
1049
1358
  if (!target) return
@@ -1067,21 +1376,43 @@ const tui: TuiPlugin = async (api) => {
1067
1376
  },
1068
1377
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1069
1378
  {
1070
- key: "escape",
1071
- desc: "Close",
1379
+ key: "ctrl+x",
1380
+ desc: "Delete session",
1072
1381
  preventDefault: true,
1073
1382
  cmd: () => {
1074
- cleanup()
1075
- 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)
1076
1397
  },
1077
1398
  },
1078
1399
  {
1079
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",
1080
1411
  desc: "Close",
1081
1412
  preventDefault: true,
1082
1413
  cmd: () => {
1083
- cleanup()
1084
- api.ui.dialog.clear()
1414
+ cancelDelete()
1415
+ close()
1085
1416
  },
1086
1417
  },
1087
1418
  ],
@@ -1089,6 +1420,8 @@ const tui: TuiPlugin = async (api) => {
1089
1420
 
1090
1421
  const cleanup = () => {
1091
1422
  disposeNav()
1423
+ disposeConfirm?.()
1424
+ disposeConfirm = undefined
1092
1425
  if (previewTimer) clearTimeout(previewTimer)
1093
1426
  }
1094
1427
 
@@ -1167,10 +1500,6 @@ const tui: TuiPlugin = async (api) => {
1167
1500
  ? api.theme.current.primary
1168
1501
  : RGBA.fromInts(0, 0, 0, 0)
1169
1502
  }
1170
- onMouseOver={() => {
1171
- const i = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
1172
- if (i >= 0 && i !== cursor()) setCursor(i)
1173
- }}
1174
1503
  onMouseDown={() => choose(row.entry)}
1175
1504
  >
1176
1505
  <Show when={row.entry.id === currentSessionID()}>
@@ -1186,38 +1515,47 @@ const tui: TuiPlugin = async (api) => {
1186
1515
  ●
1187
1516
  </text>
1188
1517
  </Show>
1189
- <text
1190
- flexGrow={1}
1191
- flexShrink={1}
1192
- overflow="hidden"
1193
- wrapMode="none"
1194
- attributes={
1195
- 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
1196
1526
  }
1197
- style={{
1198
- fg:
1199
- row.entry.id === selectableEntries()[cursor()]?.id
1200
- ? api.theme.current.selectedListItemText
1201
- : api.theme.current.text,
1202
- }}
1203
- >
1204
- {truncate(row.entry.title, 61)}
1205
- </text>
1527
+ matchColor={
1528
+ row.entry.id === selectableEntries()[cursor()]?.id
1529
+ ? api.theme.current.selectedListItemText
1530
+ : api.theme.current.warning
1531
+ }
1532
+ />
1206
1533
  <text
1207
1534
  flexShrink={0}
1208
1535
  style={{
1209
1536
  fg:
1210
- row.entry.id === selectableEntries()[cursor()]?.id
1211
- ? api.theme.current.selectedListItemText
1212
- : 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,
1213
1542
  }}
1214
1543
  >
1215
- {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)}`}
1216
1547
  </text>
1217
1548
  </box>
1218
1549
  )
1219
1550
  }
1220
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>
1221
1559
  </box>
1222
1560
  <Show when={showPreview()}>
1223
1561
  <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
@@ -1241,8 +1579,14 @@ const tui: TuiPlugin = async (api) => {
1241
1579
  </box>
1242
1580
  </Show>
1243
1581
  <box paddingLeft={4} paddingRight={4} flexShrink={0}>
1244
- <text style={{ fg: api.theme.current.textMuted }}>
1245
- ↑↓ 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"}
1246
1590
  </text>
1247
1591
  </box>
1248
1592
  </box>