opencode-sesh 0.1.7 → 0.2.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.
@@ -2,8 +2,9 @@
2
2
  import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3
3
  import type { Project, Session } from "@opencode-ai/sdk/v2"
4
4
  import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
5
- import { readFile } from "node:fs/promises"
6
- import { RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
5
+ import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
6
+ import { dirname, join } from "node:path"
7
+ import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
7
8
 
8
9
  type ThemeColors = TuiPluginApi["theme"]["current"]
9
10
 
@@ -49,21 +50,24 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
49
50
  ],
50
51
  style: { foreground: theme.error },
51
52
  },
52
- { scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
53
+ // Some opencode themes give Markdown roles the same foreground as body
54
+ // text. Use their semantic accents here so transcript structure remains
55
+ // legible without recoloring the surrounding TUI or ordinary prose.
56
+ { scope: ["markup.heading"], style: { foreground: theme.accent, bold: true } },
53
57
  {
54
58
  scope: ["markup.heading.1"],
55
- style: { foreground: theme.markdownHeading, bold: true, underline: true },
59
+ style: { foreground: theme.primary, bold: true, underline: true },
56
60
  },
57
61
  {
58
62
  scope: ["markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"],
59
- style: { foreground: theme.markdownHeading, bold: true },
63
+ style: { foreground: theme.accent, bold: true },
60
64
  },
61
- { scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
62
- { scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
63
- { scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
65
+ { scope: ["markup.bold", "markup.strong"], style: { foreground: theme.accent, bold: true } },
66
+ { scope: ["markup.italic"], style: { foreground: theme.syntaxString, italic: true } },
67
+ { scope: ["markup.list"], style: { foreground: theme.syntaxKeyword } },
64
68
  { scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
65
- { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } },
66
- { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } },
69
+ { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.syntaxString } },
70
+ { scope: ["markup.raw.inline"], style: { foreground: theme.syntaxString, background: theme.background } },
67
71
  {
68
72
  scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"],
69
73
  style: { foreground: theme.markdownLink, underline: true },
@@ -87,6 +91,77 @@ function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
87
91
  }
88
92
  }
89
93
 
94
+ type Pins = { sessions: string[]; directories: string[] }
95
+ const emptyPins = (): Pins => ({ sessions: [], directories: [] })
96
+ const pinsPath = () =>
97
+ process.env.SESH_PINS_FILE ?? join(process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local/share"), "sesh/pins.json")
98
+
99
+ async function readPins(): Promise<Pins> {
100
+ try {
101
+ const data = JSON.parse(await readFile(pinsPath(), "utf8")) as Partial<Pins>
102
+ return {
103
+ sessions: Array.isArray(data.sessions) ? data.sessions.filter((id): id is string => typeof id === "string") : [],
104
+ directories: Array.isArray(data.directories) ? data.directories.filter((dir): dir is string => typeof dir === "string") : [],
105
+ }
106
+ } catch {
107
+ return emptyPins()
108
+ }
109
+ }
110
+
111
+ async function togglePin(kind: keyof Pins, value: string): Promise<Pins> {
112
+ const file = pinsPath()
113
+ await mkdir(dirname(file), { recursive: true })
114
+ const lock = `${file}.lock`
115
+ let locked = false
116
+ for (let attempt = 0; attempt < 50; attempt++) {
117
+ try {
118
+ await mkdir(lock)
119
+ locked = true
120
+ break
121
+ } catch (error) {
122
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
123
+ try {
124
+ const owner = Number((await readFile(join(lock, "pid"), "utf8")).trim())
125
+ if (Number.isSafeInteger(owner) && owner > 0) {
126
+ try {
127
+ process.kill(owner, 0)
128
+ } catch (check) {
129
+ if ((check as NodeJS.ErrnoException).code === "ESRCH") {
130
+ await unlink(join(lock, "pid")).catch(() => {})
131
+ await rmdir(lock).catch(() => {})
132
+ }
133
+ }
134
+ }
135
+ } catch {}
136
+ await new Promise((resolve) => setTimeout(resolve, 100))
137
+ }
138
+ }
139
+ if (!locked) throw new Error("pins are busy")
140
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}`
141
+ try {
142
+ await writeFile(join(lock, "pid"), `${process.pid}\n`)
143
+ const next = await readPins()
144
+ next[kind] = next[kind].includes(value) ? next[kind].filter((item) => item !== value) : [...next[kind], value]
145
+ await writeFile(tmp, JSON.stringify(next) + "\n", { mode: 0o600 })
146
+ await rename(tmp, file)
147
+ return next
148
+ } finally {
149
+ await unlink(tmp).catch(() => {})
150
+ await unlink(join(lock, "pid")).catch(() => {})
151
+ await rmdir(lock)
152
+ }
153
+ }
154
+
155
+ function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
156
+ const sessions = new Set(pins.sessions)
157
+ const directories = new Set(pins.directories)
158
+ return [...entries].sort((a, b) => {
159
+ const aRank = sessions.has(a.id) ? 2 : directories.has(a.dir) ? 1 : 0
160
+ const bRank = sessions.has(b.id) ? 2 : directories.has(b.dir) ? 1 : 0
161
+ return bRank - aRank || b.updated - a.updated
162
+ })
163
+ }
164
+
90
165
  // Sessions panel: an opencode-native session switcher in two parts.
91
166
  //
92
167
  // 1. Sidebar (always visible): a compact recent-sessions section appended to
@@ -137,7 +212,13 @@ const SIDEBAR_TITLE_WIDTH = 27
137
212
  const SIDEBAR_GROUP_WIDTH = 30
138
213
  const HOME_TITLE_WIDTH = 30
139
214
 
140
- const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789-_.@/+".split("")
215
+ // The sidebar lists every session (newest first, pins ahead) and fills
216
+ // whatever vertical space it gets: overflow scrolls inside the stretched
217
+ // scrollbox. The picker (ctrl+o) remains the place for transcript search.
218
+
219
+ // Uppercase is included deliberately: a search box that silently drops shifted
220
+ // letters cannot be used for acronyms or paths like README.
221
+ const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@/+:,?!()[]".split("")
141
222
 
142
223
  function searchBindings(append: (input: string) => void, exit: () => void) {
143
224
  return [
@@ -262,7 +343,7 @@ function addTranscript(index: Map<string, string>, sid: string, data: string): v
262
343
  async function buildSearchIndex(
263
344
  api: TuiPluginApi,
264
345
  entries: Entry[],
265
- onProgress?: (progress: IndexProgress) => void,
346
+ onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
266
347
  ): Promise<Map<string, string>> {
267
348
  const index = new Map<string, string>()
268
349
  for (const entry of entries) {
@@ -272,7 +353,7 @@ async function buildSearchIndex(
272
353
  const remaining = new Set(ids)
273
354
  const total = ids.length
274
355
  let indexed = 0
275
- const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
356
+ const report = (complete: boolean) => onProgress?.({ indexed, total, complete }, index)
276
357
  report(false)
277
358
 
278
359
  const db = await openTranscriptDb()
@@ -302,7 +383,7 @@ async function buildSearchIndex(
302
383
  if (remaining.size > 0) {
303
384
  await buildSearchIndexRemote(api, [...remaining], index, () => {
304
385
  indexed += 1
305
- report(false)
386
+ if (indexed % TRANSCRIPT_BATCH === 0) report(false)
306
387
  })
307
388
  }
308
389
  report(true)
@@ -354,6 +435,45 @@ async function buildSearchIndexRemote(
354
435
  const TRANSCRIPT_PREVIEW_TURNS = 8
355
436
  const TRANSCRIPT_PREVIEW_CHARS = 2000
356
437
 
438
+ // Case-insensitive range of `query` inside `text`, so a matched row can show
439
+ // which part of it the search hit instead of only filtering invisibly. Kept
440
+ // below the data-layer markers that tests/tui.mjs evaluates: JSX must not enter
441
+ // that extraction window.
442
+ function matchRange(text: string, query: string): [number, number] | undefined {
443
+ const q = query.trim().toLowerCase()
444
+ if (!q) return undefined
445
+ const at = text.toLowerCase().indexOf(q)
446
+ return at < 0 ? undefined : [at, at + q.length]
447
+ }
448
+
449
+ function Highlighted(props: {
450
+ text: string
451
+ query: string
452
+ color: RGBA
453
+ matchColor: RGBA
454
+ bold?: boolean
455
+ }) {
456
+ const parts = createMemo(() => {
457
+ const range = matchRange(props.text, props.query)
458
+ if (!range) return [props.text, "", ""] as const
459
+ return [props.text.slice(0, range[0]), props.text.slice(range[0], range[1]), props.text.slice(range[1])] as const
460
+ })
461
+ return (
462
+ <text
463
+ flexGrow={1}
464
+ flexShrink={1}
465
+ overflow="hidden"
466
+ wrapMode="none"
467
+ attributes={props.bold ? TextAttributes.BOLD : undefined}
468
+ style={{ fg: props.color }}
469
+ >
470
+ {parts()[0]}
471
+ <span style={{ fg: props.matchColor }}>{parts()[1]}</span>
472
+ {parts()[2]}
473
+ </text>
474
+ )
475
+ }
476
+
357
477
  async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
358
478
  try {
359
479
  const result = await api.client.session.messages({ sessionID })
@@ -400,7 +520,9 @@ function createTranscriptPreview(api: TuiPluginApi) {
400
520
  }
401
521
  setPreviewID(entry.id)
402
522
  setPreviewText("Loading…")
403
- void fetchTranscriptText(api, entry.id).then(setPreviewText)
523
+ void fetchTranscriptText(api, entry.id).then((text) => {
524
+ if (previewID() === entry.id) setPreviewText(text)
525
+ })
404
526
  api.ui.dialog.replace(
405
527
  () => (
406
528
  <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
@@ -418,7 +540,11 @@ function createTranscriptPreview(api: TuiPluginApi) {
418
540
  maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
419
541
  >
420
542
  {getMarkdownStyle(api.theme.current) ? (
421
- <markdown content={previewText()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
543
+ <markdown
544
+ content={previewText()}
545
+ syntaxStyle={getMarkdownStyle(api.theme.current)!}
546
+ treeSitterClient={getTreeSitterClient()}
547
+ />
422
548
  ) : (
423
549
  <text wrapMode="word">{previewText()}</text>
424
550
  )}
@@ -454,16 +580,24 @@ type TreeRow =
454
580
  | { kind: "group"; dir: string; label: string; count: number }
455
581
  | { kind: "item"; entry: Entry; last: boolean }
456
582
 
457
- function SidebarSessions(props: { api: TuiPluginApi }) {
583
+ type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
584
+
585
+ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
458
586
  const theme = () => props.api.theme.current
459
587
  const home = process.env.HOME ?? ""
460
588
  const [entries, setEntries] = createSignal<Entry[]>([])
589
+ const [sectionCollapsed, setSectionCollapsed] = createSignal(false)
461
590
  const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
462
591
  const [hovered, setHovered] = createSignal<string>()
463
592
  const preview = createTranscriptPreview(props.api)
464
593
  const [query, setQuery] = createSignal("")
465
594
  const [deleting, setDeleting] = createSignal<string>()
466
595
  const [searching, setSearching] = createSignal(false)
596
+ const [loadFailed, setLoadFailed] = createSignal(false)
597
+ const [navActive, setNavActive] = createSignal(false)
598
+ const [cursor, setCursor] = createSignal(0)
599
+ const [pendingDelete, setPendingDelete] = createSignal<string>()
600
+ let confirmTimer: ReturnType<typeof setTimeout> | undefined
467
601
  const currentID = createMemo(() => {
468
602
  const route = props.api.route.current
469
603
  if (route.name !== "session") return undefined
@@ -474,11 +608,17 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
474
608
  onMount(() => {
475
609
  let alive = true
476
610
  const load = async () => {
611
+ props.refreshPins()
477
612
  try {
478
613
  const { entries: all } = await fetchEntries(props.api)
479
- if (alive) setEntries(all)
614
+ if (alive) {
615
+ setEntries(all)
616
+ setLoadFailed(false)
617
+ }
480
618
  } catch {
481
- // leave the previous list in place rather than flashing to blank
619
+ // leave the previous list in place rather than flashing to blank, but
620
+ // say so when there is nothing at all to show
621
+ if (alive && entries().length === 0) setLoadFailed(true)
482
622
  }
483
623
  }
484
624
  void load()
@@ -491,22 +631,31 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
491
631
 
492
632
  const filteredEntries = createMemo(() => {
493
633
  const q = query().trim().toLowerCase()
494
- if (!q) return entries()
495
- return entries().filter(
634
+ const ordered = pinnedFirst(entries(), props.pins())
635
+ if (!q) return ordered
636
+ return ordered.filter(
496
637
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
497
638
  )
498
639
  })
499
640
 
641
+ const shownEntries = createMemo(() => filteredEntries())
642
+ const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
643
+
500
644
  const tree = createMemo<TreeRow[]>(() => {
501
645
  const groups = new Map<string, Entry[]>()
502
- for (const entry of filteredEntries()) {
646
+ for (const entry of shownEntries()) {
503
647
  const list = groups.get(entry.dir) ?? []
504
648
  list.push(entry)
505
649
  groups.set(entry.dir, list)
506
650
  }
507
651
  const ordered = [...groups.entries()]
508
652
  .map(([dir, list]) => ({ dir, list, updated: Math.max(...list.map((entry) => entry.updated)) }))
509
- .sort((a, b) => b.updated - a.updated)
653
+ .sort((a, b) => {
654
+ const pins = props.pins()
655
+ const rank = (group: { dir: string; list: Entry[] }) =>
656
+ pins.directories.includes(group.dir) ? 2 : group.list.some((entry) => pins.sessions.includes(entry.id)) ? 1 : 0
657
+ return rank(b) - rank(a) || b.updated - a.updated
658
+ })
510
659
  const rows: TreeRow[] = []
511
660
  for (const group of ordered) {
512
661
  rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
@@ -522,6 +671,36 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
522
671
  setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
523
672
  }
524
673
 
674
+ const toggleSection = () => {
675
+ if (!sectionCollapsed()) {
676
+ cancelDelete()
677
+ setNavActive(false)
678
+ setSearching(false)
679
+ setHovered(undefined)
680
+ }
681
+ setSectionCollapsed((value) => !value)
682
+ }
683
+
684
+ // Flat order of the rows a cursor can land on, so keyboard navigation and
685
+ // mouse hover resolve to the same row.
686
+ const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
687
+ const isActive = (id: string) => (navActive() ? itemRows()[cursor()]?.id === id : hovered() === id)
688
+ const isPendingDelete = (id: string) => pendingDelete() === id
689
+ const moveCursor = (delta: number) => {
690
+ const count = itemRows().length
691
+ if (count === 0) return
692
+ setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
693
+ }
694
+ let previousRows: Entry[] = []
695
+ createEffect(() => {
696
+ const rows = itemRows()
697
+ const selected = previousRows[cursor()]?.id
698
+ const position = rows.findIndex((entry) => entry.id === selected)
699
+ if (position >= 0 && position !== cursor()) setCursor(position)
700
+ else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
701
+ previousRows = rows
702
+ })
703
+
525
704
  const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
526
705
  try {
527
706
  const permissions = props.api.state.session.permission(id)
@@ -540,7 +719,41 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
540
719
  return theme().textMuted
541
720
  }
542
721
 
722
+ const cancelDelete = () => {
723
+ if (confirmTimer) clearTimeout(confirmTimer)
724
+ confirmTimer = undefined
725
+ setPendingDelete(undefined)
726
+ }
727
+
728
+ // Deleting is irreversible, so ctrl+x arms the row and a second ctrl+x (or y)
729
+ // commits it. The arm survives pointer movement — edge-hover flicker must
730
+ // not eat the confirm — and is cleared by the five-second timeout, n, Esc,
731
+ // collapsing the section, or the delete itself.
732
+ const requestDelete = (entry: Entry) => {
733
+ if (deleting()) return
734
+ if (pendingDelete() === entry.id) {
735
+ void deleteEntry(entry)
736
+ return
737
+ }
738
+ setPendingDelete(entry.id)
739
+ if (confirmTimer) clearTimeout(confirmTimer)
740
+ confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
741
+ }
742
+
743
+ // The confirm always targets the visibly armed row ("ctrl+x again"),
744
+ // wherever the pointer or cursor currently is. Returns true when an arm
745
+ // was pending (and is now committed or discarded as stale).
746
+ const confirmArmed = (): boolean => {
747
+ const id = pendingDelete()
748
+ if (!id) return false
749
+ const entry = entries().find((e) => e.id === id)
750
+ if (!entry) cancelDelete()
751
+ else void deleteEntry(entry)
752
+ return true
753
+ }
754
+
543
755
  const deleteEntry = async (entry: Entry) => {
756
+ cancelDelete()
544
757
  if (deleting()) return
545
758
  setDeleting(entry.id)
546
759
  try {
@@ -568,6 +781,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
568
781
 
569
782
  let disposeHoverSpace: (() => void) | undefined
570
783
  let disposeSearch: (() => void) | undefined
784
+ let disposeNav: (() => void) | undefined
571
785
  let insideSearchBox = false
572
786
 
573
787
  const onRootMouseDown = () => {
@@ -614,7 +828,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
614
828
  priority: 20,
615
829
  bindings: [
616
830
  {
617
- key: "space",
831
+ key: "ctrl+p",
618
832
  desc: "Preview session transcript",
619
833
  preventDefault: true,
620
834
  cmd: () => openSidebarPreview(entry),
@@ -623,8 +837,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
623
837
  key: "ctrl+x",
624
838
  desc: "Delete session",
625
839
  preventDefault: true,
626
- cmd: () => void deleteEntry(entry),
840
+ cmd: () => {
841
+ if (!confirmArmed()) requestDelete(entry)
842
+ },
627
843
  },
844
+ { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
845
+ { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
628
846
  {
629
847
  key: "/",
630
848
  desc: "Search sessions",
@@ -635,22 +853,128 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
635
853
  })
636
854
  })
637
855
 
856
+ // Keyboard navigation is opt-in (click the "Sessions" heading or the search
857
+ // box) so the sidebar never steals the arrow keys from the main prompt.
858
+ createEffect(() => {
859
+ disposeNav?.()
860
+ disposeNav = undefined
861
+ if (!navActive()) return
862
+ const bindings = [
863
+ { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
864
+ { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
865
+ { key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-5) },
866
+ { key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(5) },
867
+ { key: "home", desc: "First session", preventDefault: true, cmd: () => setCursor(0) },
868
+ {
869
+ key: "end",
870
+ desc: "Last session",
871
+ preventDefault: true,
872
+ cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
873
+ },
874
+ {
875
+ key: "ctrl+p",
876
+ desc: "Preview session transcript",
877
+ preventDefault: true,
878
+ cmd: () => {
879
+ const entry = itemRows()[cursor()]
880
+ if (entry) openSidebarPreview(entry)
881
+ },
882
+ },
883
+ {
884
+ key: "ctrl+x",
885
+ desc: "Delete session",
886
+ preventDefault: true,
887
+ cmd: () => {
888
+ if (confirmArmed()) return
889
+ const entry = itemRows()[cursor()]
890
+ if (entry) requestDelete(entry)
891
+ },
892
+ },
893
+ {
894
+ key: "option+s",
895
+ desc: "Pin session",
896
+ preventDefault: true,
897
+ cmd: () => {
898
+ const entry = itemRows()[cursor()]
899
+ if (entry) props.onTogglePin("sessions", entry.id)
900
+ },
901
+ },
902
+ {
903
+ key: "option+d",
904
+ desc: "Pin directory",
905
+ preventDefault: true,
906
+ cmd: () => {
907
+ const entry = itemRows()[cursor()]
908
+ if (entry) props.onTogglePin("directories", entry.dir)
909
+ },
910
+ },
911
+ {
912
+ key: "enter",
913
+ desc: "Open session",
914
+ preventDefault: true,
915
+ cmd: () => {
916
+ const entry = itemRows()[cursor()]
917
+ if (entry) props.api.route.navigate("session", { sessionID: entry.id })
918
+ },
919
+ },
920
+ {
921
+ key: "escape",
922
+ desc: "Leave the session list",
923
+ preventDefault: true,
924
+ cmd: () => {
925
+ cancelDelete()
926
+ setNavActive(false)
927
+ },
928
+ },
929
+ ...(pendingDelete()
930
+ ? [
931
+ {
932
+ key: "y",
933
+ desc: "Confirm delete",
934
+ preventDefault: true,
935
+ cmd: () => {
936
+ confirmArmed()
937
+ },
938
+ },
939
+ { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
940
+ ]
941
+ : []),
942
+ ]
943
+ disposeNav = props.api.keymap.registerLayer({ mode: "base", priority: 20, bindings })
944
+ })
945
+
638
946
  onCleanup(() => {
639
947
  disposeHoverSpace?.()
640
948
  disposeSearch?.()
949
+ disposeNav?.()
950
+ if (confirmTimer) clearTimeout(confirmTimer)
641
951
  })
642
952
 
643
953
  return (
644
- <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>
954
+ <box flexDirection="column" paddingRight={1} flexGrow={1} flexShrink={1}>
955
+ <box flexDirection="row" justifyContent="space-between" gap={1}>
956
+ <text flexShrink={1} onMouseDown={() => {
957
+ if (!sectionCollapsed()) setNavActive((value) => !value)
958
+ }}>
959
+ <b>Sessions</b>
960
+ <span style={{ fg: theme().textMuted }}>
961
+ {query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
962
+ </span>
963
+ <span style={{ fg: theme().accent }}>{navActive() ? " · nav" : ""}</span>
964
+ </text>
965
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
966
+ {sectionCollapsed() ? "▸ show" : "▾ hide"}
967
+ </text>
968
+ </box>
969
+ <Show when={!sectionCollapsed()}>
970
+ <Show
971
+ when={entries().length > 0}
972
+ fallback={
973
+ <text style={{ fg: theme().textMuted }}>
974
+ {loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · ctrl+o to browse"}
652
975
  </text>
653
- </box>
976
+ }
977
+ >
654
978
  <box
655
979
  flexDirection="row"
656
980
  gap={1}
@@ -679,6 +1003,8 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
679
1003
  </Show>
680
1004
  </box>
681
1005
  <scrollbox
1006
+ flexGrow={1}
1007
+ flexShrink={1}
682
1008
  verticalScrollbarOptions={{ visible: false }}
683
1009
  horizontalScrollbarOptions={{ visible: false }}
684
1010
  >
@@ -688,7 +1014,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
688
1014
  <box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
689
1015
  <text flexGrow={1} flexShrink={1} wrapMode="none">
690
1016
  <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
691
- <b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
1017
+ <b>{props.pins().directories.includes(row.dir) ? "★ " : ""}{truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0))}</b>
692
1018
  </text>
693
1019
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
694
1020
  {" "}({row.count})
@@ -701,33 +1027,63 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
701
1027
  paddingLeft={1}
702
1028
  paddingRight={1}
703
1029
  backgroundColor={
704
- row.entry.id === hovered() && row.entry.id !== currentID()
1030
+ isActive(row.entry.id) && row.entry.id !== currentID()
705
1031
  ? theme().backgroundElement
706
1032
  : RGBA.fromInts(0, 0, 0, 0)
707
1033
  }
708
- onMouseOver={() => setHovered(row.entry.id)}
1034
+ onMouseOver={() => {
1035
+ setHovered(row.entry.id)
1036
+ // The keyboard layer wins over the hover layer while nav is
1037
+ // active. Keep its cursor on the hovered row so Option-S/Option-D
1038
+ // cannot silently pin a different (often current) session.
1039
+ if (navActive()) {
1040
+ const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
1041
+ if (index >= 0) setCursor(index)
1042
+ }
1043
+ }}
709
1044
  onMouseOut={() => setHovered(undefined)}
710
1045
  onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
711
1046
  >
712
1047
  <text flexShrink={0}>
713
1048
  <span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
714
1049
  <span
715
- style={{ fg: row.entry.id === currentID() ? theme().accent : statusColor(row.entry.id) }}
1050
+ style={{
1051
+ fg: isPendingDelete(row.entry.id)
1052
+ ? theme().error
1053
+ : row.entry.id === currentID()
1054
+ ? theme().accent
1055
+ : statusColor(row.entry.id),
1056
+ }}
716
1057
  >
717
- {row.entry.id === currentID() ? "●" : "○"}
1058
+ {row.entry.id === currentID() ? "●" : isActive(row.entry.id) ? "▸" : "○"}
718
1059
  </span>
719
1060
  </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)}
1061
+ <Highlighted
1062
+ text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
1063
+ query={query()}
1064
+ color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
1065
+ matchColor={theme().warning}
1066
+ />
1067
+ <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
1068
+ {isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
725
1069
  </text>
726
1070
  </box>
727
1071
  )
728
1072
  }
729
1073
  </For>
730
1074
  </scrollbox>
1075
+ <Show when={remaining() > 0}>
1076
+ <text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
1077
+ </Show>
1078
+ <Show when={query().trim() && filteredEntries().length === 0}>
1079
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
1080
+ </Show>
1081
+ <Show when={navActive()}>
1082
+ <box paddingTop={1}>
1083
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
1084
+ </box>
1085
+ </Show>
1086
+ </Show>
731
1087
  </Show>
732
1088
  </box>
733
1089
  )
@@ -735,13 +1091,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
735
1091
 
736
1092
  const HOME_SESSION_LIMIT = 6
737
1093
 
738
- function HomeSessions(props: { api: TuiPluginApi }) {
1094
+ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
739
1095
  const theme = () => props.api.theme.current
740
1096
  const home = process.env.HOME ?? ""
741
1097
  const [entries, setEntries] = createSignal<Entry[]>([])
742
1098
  const [query, setQuery] = createSignal("")
743
1099
  const [hovered, setHovered] = createSignal<string>()
744
1100
  const [searching, setSearching] = createSignal(false)
1101
+ const [loadFailed, setLoadFailed] = createSignal(false)
745
1102
  const preview = createTranscriptPreview(props.api)
746
1103
  let disposeHoverSpace: (() => void) | undefined
747
1104
  let disposeSearch: (() => void) | undefined
@@ -789,11 +1146,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
789
1146
  priority: 20,
790
1147
  bindings: [
791
1148
  {
792
- key: "space",
1149
+ key: "ctrl+p",
793
1150
  desc: "Preview session transcript",
794
1151
  preventDefault: true,
795
1152
  cmd: () => preview.open(entry),
796
1153
  },
1154
+ { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1155
+ { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
797
1156
  ],
798
1157
  })
799
1158
  })
@@ -804,10 +1163,16 @@ function HomeSessions(props: { api: TuiPluginApi }) {
804
1163
  onMount(() => {
805
1164
  let alive = true
806
1165
  const load = async () => {
1166
+ props.refreshPins()
807
1167
  try {
808
1168
  const { entries: all } = await fetchEntries(props.api)
809
- if (alive) setEntries(all)
810
- } catch {}
1169
+ if (alive) {
1170
+ setEntries(all)
1171
+ setLoadFailed(false)
1172
+ }
1173
+ } catch {
1174
+ if (alive && entries().length === 0) setLoadFailed(true)
1175
+ }
811
1176
  }
812
1177
  void load()
813
1178
  const clock = setInterval(() => void load(), POLL_MS)
@@ -824,14 +1189,27 @@ function HomeSessions(props: { api: TuiPluginApi }) {
824
1189
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
825
1190
  )
826
1191
  : entries()
827
- return list.slice(0, HOME_SESSION_LIMIT)
1192
+ return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT)
828
1193
  })
829
1194
 
830
1195
  return (
831
1196
  <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
832
- <Show when={entries().length > 0}>
1197
+ <Show
1198
+ when={entries().length > 0}
1199
+ fallback={
1200
+ <box flexDirection="row" gap={1}>
1201
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1202
+ <text style={{ fg: theme().textMuted }}>
1203
+ {loadFailed() ? "· list unavailable, will retry" : "· none yet, ctrl+o to browse"}
1204
+ </text>
1205
+ </box>
1206
+ }
1207
+ >
833
1208
  <box flexDirection="row" justifyContent="space-between" gap={2}>
834
- <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1209
+ <box flexDirection="row" gap={1}>
1210
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1211
+ <text style={{ fg: theme().textMuted }}>· ctrl+o for all</text>
1212
+ </box>
835
1213
  <box
836
1214
  flexDirection="row"
837
1215
  gap={1}
@@ -875,17 +1253,20 @@ function HomeSessions(props: { api: TuiPluginApi }) {
875
1253
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
876
1254
  ○
877
1255
  </text>
878
- <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
879
- {truncate(entry.title, HOME_TITLE_WIDTH)}
880
- </text>
1256
+ <Highlighted
1257
+ text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
1258
+ query={query()}
1259
+ color={theme().text}
1260
+ matchColor={theme().warning}
1261
+ />
881
1262
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
882
- {prettyDir(entry.dir, home)} · {ago(entry.updated)}
1263
+ {props.pins().directories.includes(entry.dir) ? "★ " : ""}{prettyDir(entry.dir, home)} · {ago(entry.updated)}
883
1264
  </text>
884
1265
  </box>
885
1266
  )}
886
1267
  </For>
887
1268
  <Show when={visible().length === 0}>
888
- <text style={{ fg: theme().textMuted }}>No matching sessions</text>
1269
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
889
1270
  </Show>
890
1271
  </Show>
891
1272
  </box>
@@ -893,19 +1274,40 @@ function HomeSessions(props: { api: TuiPluginApi }) {
893
1274
  }
894
1275
 
895
1276
  const tui: TuiPlugin = async (api) => {
1277
+ const [pins, setPins] = createSignal<Pins>(emptyPins())
1278
+ let pinVersion = 0
1279
+ const refreshPins = () => {
1280
+ const version = pinVersion
1281
+ void readPins().then((latest) => {
1282
+ if (version === pinVersion) setPins(latest)
1283
+ })
1284
+ }
1285
+ refreshPins()
1286
+ const onTogglePin = (kind: keyof Pins, value: string) => {
1287
+ if (!value) return
1288
+ pinVersion += 1
1289
+ void togglePin(kind, value).then(
1290
+ (updated) => {
1291
+ setPins(updated)
1292
+ api.ui.toast({ message: `${updated[kind].includes(value) ? "Pinned" : "Unpinned"} ${kind === "sessions" ? "session" : "directory"}`, variant: "info" })
1293
+ },
1294
+ () => api.ui.toast({ message: "Could not update pins", variant: "error" }),
1295
+ )
1296
+ }
896
1297
  api.slots.register({
897
1298
  order: 100,
898
1299
  slots: {
899
1300
  sidebar_content() {
900
- return <SidebarSessions api={api} />
1301
+ return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
901
1302
  },
902
1303
  home_bottom() {
903
- return <HomeSessions api={api} />
1304
+ return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
904
1305
  },
905
1306
  },
906
1307
  })
907
1308
 
908
1309
  const openPicker = async () => {
1310
+ refreshPins()
909
1311
  if (api.mode.current() !== BASE_MODE) return
910
1312
  if (api.renderer.currentFocusedEditor === null) return
911
1313
 
@@ -922,25 +1324,34 @@ const tui: TuiPlugin = async (api) => {
922
1324
  return
923
1325
  }
924
1326
 
1327
+ const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
925
1328
  const [previewText, setPreviewText] = createSignal("")
926
1329
  const [showPreview, setShowPreview] = createSignal(false)
927
1330
  const [query, setQuery] = createSignal("")
928
1331
  const [cursor, setCursor] = createSignal(0)
1332
+ const [scope, setScope] = createSignal<string>()
1333
+ const [pendingDelete, setPendingDelete] = createSignal<Entry>()
929
1334
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
930
1335
  const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
931
1336
  indexed: 0,
932
1337
  total: 0,
933
1338
  complete: false,
934
1339
  })
935
- void buildSearchIndex(api, entries, setIndexProgress).then(setSearchIndex)
936
- let pickerInput: { value: string; isDestroyed?: boolean } | undefined
937
-
1340
+ void buildSearchIndex(api, entries, (progress, index) => {
1341
+ setIndexProgress(progress)
1342
+ // Publish a fresh snapshot: the indexing workers mutate their own map,
1343
+ // while Solid needs a new reference to update search results mid-scan.
1344
+ setSearchIndex(new Map(index))
1345
+ })
938
1346
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
939
1347
 
1348
+ const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
940
1349
  const matchedGroups = createMemo(() => {
941
1350
  const q = query().trim().toLowerCase()
942
1351
  const index = searchIndex()
943
- const matched = entries.filter((entry) => {
1352
+ const group = scope()
1353
+ const matched = orderedEntries().filter((entry) => {
1354
+ if (group && entry.group !== group) return false
944
1355
  if (!q) return true
945
1356
  const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
946
1357
  return haystack.includes(q)
@@ -951,7 +1362,14 @@ const tui: TuiPlugin = async (api) => {
951
1362
  list.push(entry)
952
1363
  byGroup.set(entry.group, list)
953
1364
  }
954
- return [...byGroup.entries()].map(([name, list]) => ({ name, list }))
1365
+ const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
1366
+ const currentPins = pins()
1367
+ const rank = (group: (typeof groups)[number]) =>
1368
+ group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
1369
+ : group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
1370
+ return groups.sort((a, b) =>
1371
+ rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
1372
+ )
955
1373
  })
956
1374
 
957
1375
  const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
@@ -961,6 +1379,7 @@ const tui: TuiPlugin = async (api) => {
961
1379
  const coverageLabel = createMemo(() => {
962
1380
  const progress = indexProgress()
963
1381
  const bits: string[] = []
1382
+ if (scope()) bits.push(`scope ${scope()}`)
964
1383
  if (progress.total > 0) {
965
1384
  bits.push(
966
1385
  progress.complete
@@ -968,13 +1387,18 @@ const tui: TuiPlugin = async (api) => {
968
1387
  : `indexing transcripts ${progress.indexed}/${progress.total}`,
969
1388
  )
970
1389
  }
971
- if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
1390
+ if (entriesResult.truncated) bits.push(`showing newest ${allEntries().length}`)
972
1391
  return bits.join(" · ")
973
1392
  })
974
1393
 
1394
+ let previousSelection: Entry[] = []
975
1395
  createEffect(() => {
976
- const count = selectableEntries().length
977
- if (cursor() >= count) setCursor(Math.max(0, count - 1))
1396
+ const rows = selectableEntries()
1397
+ const selected = previousSelection[cursor()]?.id
1398
+ const position = rows.findIndex((entry) => entry.id === selected)
1399
+ if (position >= 0 && position !== cursor()) setCursor(position)
1400
+ else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
1401
+ previousSelection = rows
978
1402
  })
979
1403
 
980
1404
  const pickerRows = createMemo<DialogRow[]>(() => {
@@ -1043,8 +1467,8 @@ const tui: TuiPlugin = async (api) => {
1043
1467
  setPreviewText(cached)
1044
1468
  return
1045
1469
  }
1046
- // Keep the previous transcript visible while the next one loads so the
1047
- // preview does not blink away on every cursor move.
1470
+ // Never show another session's transcript beneath the new selection.
1471
+ setPreviewText("Loading…")
1048
1472
  previewTimer = setTimeout(() => void loadPreview(id), 120)
1049
1473
  })
1050
1474
 
@@ -1053,9 +1477,76 @@ const tui: TuiPlugin = async (api) => {
1053
1477
  const moveCursor = (delta: number) => {
1054
1478
  const count = selectableEntries().length
1055
1479
  if (count === 0) return
1480
+ cancelDelete()
1056
1481
  setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
1057
1482
  }
1058
1483
 
1484
+ // Delete confirmation lives in its own layer, registered only while a row
1485
+ // is armed, so y/n never shadow the search box's typing.
1486
+ let disposeConfirm: (() => void) | undefined
1487
+ const cancelDelete = () => {
1488
+ disposeConfirm?.()
1489
+ disposeConfirm = undefined
1490
+ setPendingDelete(undefined)
1491
+ }
1492
+
1493
+ const deleteEntry = async (entry: Entry) => {
1494
+ cancelDelete()
1495
+ try {
1496
+ const result = await api.client.session.delete({
1497
+ sessionID: entry.id,
1498
+ directory: entry.dir || undefined,
1499
+ })
1500
+ if (result && "error" in result && result.error) throw result.error
1501
+ const { entries: remaining } = await fetchEntries(api)
1502
+ if (remaining.some((item) => item.id === entry.id)) {
1503
+ setAllEntries(remaining)
1504
+ api.ui.toast({ message: "Session was not deleted", variant: "error" })
1505
+ return
1506
+ }
1507
+ setAllEntries(remaining)
1508
+ api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
1509
+ } catch {
1510
+ api.ui.toast({ message: "Could not delete session", variant: "error" })
1511
+ }
1512
+ }
1513
+
1514
+ const armDelete = (entry: Entry) => {
1515
+ setPendingDelete(entry)
1516
+ disposeConfirm?.()
1517
+ disposeConfirm = api.keymap.registerLayer({
1518
+ bindings: [
1519
+ {
1520
+ key: "y",
1521
+ desc: "Confirm delete",
1522
+ preventDefault: true,
1523
+ cmd: () => {
1524
+ const armed = pendingDelete()
1525
+ if (armed) void deleteEntry(armed)
1526
+ },
1527
+ },
1528
+ { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1529
+ ],
1530
+ })
1531
+ }
1532
+
1533
+ const forkEntry = async (entry: Entry) => {
1534
+ try {
1535
+ const result = await api.client.session.fork({
1536
+ sessionID: entry.id,
1537
+ directory: entry.dir || undefined,
1538
+ })
1539
+ const created = (result?.data ?? result) as Session | undefined
1540
+ if (!created?.id) throw new Error("fork returned no session")
1541
+ cleanup()
1542
+ api.ui.dialog.clear()
1543
+ api.route.navigate("session", { sessionID: created.id })
1544
+ api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
1545
+ } catch {
1546
+ api.ui.toast({ message: "Could not fork session", variant: "error" })
1547
+ }
1548
+ }
1549
+
1059
1550
  const choose = (entry?: Entry) => {
1060
1551
  const target = entry ?? selectableEntries()[cursor()]
1061
1552
  if (!target) return
@@ -1078,22 +1569,53 @@ const tui: TuiPlugin = async (api) => {
1078
1569
  cmd: () => setCursor(selectableEntries().length - 1),
1079
1570
  },
1080
1571
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1572
+ { key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1573
+ { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
1574
+ const entry = selectableEntries()[cursor()]
1575
+ if (entry) onTogglePin("sessions", entry.id)
1576
+ } },
1577
+ { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1578
+ const entry = selectableEntries()[cursor()]
1579
+ if (entry) onTogglePin("directories", entry.dir)
1580
+ } },
1081
1581
  {
1082
- key: "escape",
1083
- desc: "Close",
1582
+ key: "ctrl+x",
1583
+ desc: "Delete session",
1084
1584
  preventDefault: true,
1085
1585
  cmd: () => {
1086
- cleanup()
1087
- api.ui.dialog.clear()
1586
+ const target = selectableEntries()[cursor()]
1587
+ if (!target) return
1588
+ const armed = pendingDelete()
1589
+ if (armed && armed.id === target.id) void deleteEntry(target)
1590
+ else armDelete(target)
1591
+ },
1592
+ },
1593
+ {
1594
+ key: "ctrl+f",
1595
+ desc: "Fork session",
1596
+ preventDefault: true,
1597
+ cmd: () => {
1598
+ const target = selectableEntries()[cursor()]
1599
+ if (target) void forkEntry(target)
1088
1600
  },
1089
1601
  },
1090
1602
  {
1091
1603
  key: "ctrl+g",
1604
+ desc: "Toggle project scope",
1605
+ preventDefault: true,
1606
+ cmd: () => {
1607
+ const target = selectableEntries()[cursor()]
1608
+ if (scope()) setScope(undefined)
1609
+ else if (target) setScope(target.group)
1610
+ },
1611
+ },
1612
+ {
1613
+ key: "escape",
1092
1614
  desc: "Close",
1093
1615
  preventDefault: true,
1094
1616
  cmd: () => {
1095
- cleanup()
1096
- api.ui.dialog.clear()
1617
+ cancelDelete()
1618
+ close()
1097
1619
  },
1098
1620
  },
1099
1621
  ],
@@ -1101,19 +1623,11 @@ const tui: TuiPlugin = async (api) => {
1101
1623
 
1102
1624
  const cleanup = () => {
1103
1625
  disposeNav()
1626
+ disposeConfirm?.()
1627
+ disposeConfirm = undefined
1104
1628
  if (previewTimer) clearTimeout(previewTimer)
1105
1629
  }
1106
1630
 
1107
- const onSearchInput = (value: string) => {
1108
- if (query() === "" && value.trim() === "" && value.length > 0) {
1109
- if (pickerInput && !pickerInput.isDestroyed) pickerInput.value = ""
1110
- setQuery("")
1111
- togglePreview()
1112
- return
1113
- }
1114
- setQuery(value)
1115
- }
1116
-
1117
1631
  api.ui.dialog.replace(
1118
1632
  () => (
1119
1633
  <box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
@@ -1128,7 +1642,6 @@ const tui: TuiPlugin = async (api) => {
1128
1642
  </box>
1129
1643
  <box paddingTop={1}>
1130
1644
  <input
1131
- ref={(r) => (pickerInput = r)}
1132
1645
  focused
1133
1646
  placeholder="Search title, directory, transcript…"
1134
1647
  value={query()}
@@ -1136,7 +1649,7 @@ const tui: TuiPlugin = async (api) => {
1136
1649
  focusedBackgroundColor={api.theme.current.backgroundPanel}
1137
1650
  focusedTextColor={api.theme.current.text}
1138
1651
  cursorColor={api.theme.current.primary}
1139
- onInput={onSearchInput}
1652
+ onInput={setQuery}
1140
1653
  onSubmit={() => choose()}
1141
1654
  onKeyDown={(event) => {
1142
1655
  if (event.name === "escape") {
@@ -1165,7 +1678,7 @@ const tui: TuiPlugin = async (api) => {
1165
1678
  row.kind === "group" ? (
1166
1679
  <box paddingTop={1} paddingLeft={4}>
1167
1680
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1168
- {row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
1681
+ {matchedGroups().find((group) => group.name === row.label)?.list.some((entry) => pins().directories.includes(entry.dir)) ? "★ " : ""}{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
1169
1682
  </text>
1170
1683
  </box>
1171
1684
  ) : (
@@ -1194,63 +1707,90 @@ const tui: TuiPlugin = async (api) => {
1194
1707
  ●
1195
1708
  </text>
1196
1709
  </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
1710
+ <Highlighted
1711
+ text={`${pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, pins().sessions.includes(row.entry.id) ? 59 : 61)}`}
1712
+ query={query()}
1713
+ bold={row.entry.id === selectableEntries()[cursor()]?.id}
1714
+ color={
1715
+ row.entry.id === selectableEntries()[cursor()]?.id
1716
+ ? api.theme.current.selectedListItemText
1717
+ : api.theme.current.text
1204
1718
  }
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>
1719
+ matchColor={
1720
+ row.entry.id === selectableEntries()[cursor()]?.id
1721
+ ? api.theme.current.selectedListItemText
1722
+ : api.theme.current.warning
1723
+ }
1724
+ />
1214
1725
  <text
1215
1726
  flexShrink={0}
1216
1727
  style={{
1217
1728
  fg:
1218
- row.entry.id === selectableEntries()[cursor()]?.id
1219
- ? api.theme.current.selectedListItemText
1220
- : api.theme.current.textMuted,
1729
+ pendingDelete()?.id === row.entry.id
1730
+ ? api.theme.current.error
1731
+ : row.entry.id === selectableEntries()[cursor()]?.id
1732
+ ? api.theme.current.selectedListItemText
1733
+ : api.theme.current.textMuted,
1221
1734
  }}
1222
1735
  >
1223
- {prettyDir(row.entry.dir, process.env.HOME ?? "")} · {ago(row.entry.updated)}
1736
+ {pendingDelete()?.id === row.entry.id
1737
+ ? "press y to delete"
1738
+ : `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}${
1739
+ query().trim() &&
1740
+ !`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
1741
+ ? " · transcript match"
1742
+ : ""
1743
+ }`}
1224
1744
  </text>
1225
1745
  </box>
1226
1746
  )
1227
1747
  }
1228
1748
  </For>
1749
+ <Show when={selectableEntries().length === 0}>
1750
+ <box paddingLeft={4} paddingRight={4}>
1751
+ <text style={{ fg: api.theme.current.textMuted }}>
1752
+ {!indexProgress().complete
1753
+ ? "No matches yet · still searching transcripts…"
1754
+ : scope()
1755
+ ? `No matches in ${scope()} · ctrl+g for all projects`
1756
+ : "No sessions match · try another search"}
1757
+ </text>
1758
+ </box>
1759
+ </Show>
1229
1760
  </box>
1230
1761
  <Show when={showPreview()}>
1231
1762
  <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
1232
1763
  <text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
1233
- Preview
1764
+ {previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
1234
1765
  </text>
1235
1766
  <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
1236
- <Show
1237
- when={previewText()}
1238
- fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}
1239
- >
1240
- {(text) =>
1241
- getMarkdownStyle(api.theme.current) ? (
1242
- <markdown content={text()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
1243
- ) : (
1244
- <text wrapMode="word">{text()}</text>
1245
- )
1246
- }
1767
+ <Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
1768
+ <Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
1769
+ {(text) =>
1770
+ getMarkdownStyle(api.theme.current) ? (
1771
+ <markdown
1772
+ content={text()}
1773
+ syntaxStyle={getMarkdownStyle(api.theme.current)!}
1774
+ treeSitterClient={getTreeSitterClient()}
1775
+ />
1776
+ ) : (
1777
+ <text wrapMode="word">{text()}</text>
1778
+ )
1779
+ }
1780
+ </Show>
1247
1781
  </Show>
1248
1782
  </scrollbox>
1249
1783
  </box>
1250
1784
  </Show>
1251
1785
  <box paddingLeft={4} paddingRight={4} flexShrink={0}>
1252
- <text style={{ fg: api.theme.current.textMuted }}>
1253
- ↑↓ navigate · enter open · space preview · esc close
1786
+ <text
1787
+ style={{
1788
+ fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
1789
+ }}
1790
+ >
1791
+ {pendingDelete()
1792
+ ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
1793
+ : "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
1254
1794
  </text>
1255
1795
  </box>
1256
1796
  </box>