opencode-sesh 0.2.0 → 0.3.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.
@@ -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,10 +212,9 @@ const SIDEBAR_TITLE_WIDTH = 27
137
212
  const SIDEBAR_GROUP_WIDTH = 30
138
213
  const HOME_TITLE_WIDTH = 30
139
214
 
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
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.
144
218
 
145
219
  // Uppercase is included deliberately: a search box that silently drops shifted
146
220
  // letters cannot be used for acronyms or paths like README.
@@ -252,6 +326,68 @@ async function openTranscriptDb(): Promise<any | undefined> {
252
326
  }
253
327
  }
254
328
 
329
+ // NEEDS_INPUT_SQL: sessions waiting on the user (shared heuristic — keep in
330
+ // sync with bin/sesh-waiting.sh). A session waits when it is not archived,
331
+ // is not a fork child, and either has an unanswered `question` tool part or
332
+ // a `tool` part still marked running older than STUCK_AFTER_MS (awaiting
333
+ // approval, or orphaned by a dead server). Server-independent on purpose:
334
+ // the TUI sync layer only sees permission/question state for sessions owned
335
+ // by the current server, which is invisible cross-project.
336
+ const STUCK_AFTER_MS = 600000
337
+ const NEEDS_INPUT_SQL = (stuckBefore: number) => `
338
+ SELECT s.id AS id,
339
+ CASE WHEN EXISTS (
340
+ SELECT 1 FROM part p
341
+ WHERE p.session_id = s.id
342
+ AND json_extract(p.data, '$.type') = 'tool'
343
+ AND json_extract(p.data, '$.tool') = 'question'
344
+ AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
345
+ AND NOT EXISTS (
346
+ SELECT 1 FROM message m
347
+ WHERE m.session_id = s.id
348
+ AND json_extract(m.data, '$.role') = 'user'
349
+ AND m.time_created > p.time_created)
350
+ ) THEN 'question' ELSE 'stuck' END AS reason
351
+ FROM session s
352
+ WHERE COALESCE(s.time_archived, 0) = 0
353
+ AND s.parent_id IS NULL
354
+ AND (EXISTS (
355
+ SELECT 1 FROM part p
356
+ WHERE p.session_id = s.id
357
+ AND json_extract(p.data, '$.type') = 'tool'
358
+ AND json_extract(p.data, '$.tool') = 'question'
359
+ AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
360
+ AND NOT EXISTS (
361
+ SELECT 1 FROM message m
362
+ WHERE m.session_id = s.id
363
+ AND json_extract(m.data, '$.role') = 'user'
364
+ AND m.time_created > p.time_created)
365
+ ) OR EXISTS (
366
+ SELECT 1 FROM part p2
367
+ WHERE p2.session_id = s.id
368
+ AND json_extract(p2.data, '$.type') = 'tool'
369
+ AND json_extract(p2.data, '$.state.status') = 'running'
370
+ AND p2.time_created < ${stuckBefore}))
371
+ ORDER BY s.time_updated DESC`
372
+
373
+ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
374
+ const rows = (db.query(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS)).all() ?? []) as {
375
+ id: unknown
376
+ reason: unknown
377
+ }[]
378
+ const out = new Map<string, string>()
379
+ for (const row of rows) {
380
+ if (
381
+ typeof row?.id === "string" &&
382
+ SESSION_ID_PATTERN.test(row.id) &&
383
+ (row.reason === "question" || row.reason === "stuck")
384
+ ) {
385
+ out.set(row.id, row.reason)
386
+ }
387
+ }
388
+ return out
389
+ }
390
+
255
391
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
256
392
  try {
257
393
  const part = JSON.parse(data) as { type?: unknown; text?: unknown }
@@ -269,7 +405,7 @@ function addTranscript(index: Map<string, string>, sid: string, data: string): v
269
405
  async function buildSearchIndex(
270
406
  api: TuiPluginApi,
271
407
  entries: Entry[],
272
- onProgress?: (progress: IndexProgress) => void,
408
+ onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
273
409
  ): Promise<Map<string, string>> {
274
410
  const index = new Map<string, string>()
275
411
  for (const entry of entries) {
@@ -279,7 +415,7 @@ async function buildSearchIndex(
279
415
  const remaining = new Set(ids)
280
416
  const total = ids.length
281
417
  let indexed = 0
282
- const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
418
+ const report = (complete: boolean) => onProgress?.({ indexed, total, complete }, index)
283
419
  report(false)
284
420
 
285
421
  const db = await openTranscriptDb()
@@ -309,7 +445,7 @@ async function buildSearchIndex(
309
445
  if (remaining.size > 0) {
310
446
  await buildSearchIndexRemote(api, [...remaining], index, () => {
311
447
  indexed += 1
312
- report(false)
448
+ if (indexed % TRANSCRIPT_BATCH === 0) report(false)
313
449
  })
314
450
  }
315
451
  report(true)
@@ -446,7 +582,9 @@ function createTranscriptPreview(api: TuiPluginApi) {
446
582
  }
447
583
  setPreviewID(entry.id)
448
584
  setPreviewText("Loading…")
449
- void fetchTranscriptText(api, entry.id).then(setPreviewText)
585
+ void fetchTranscriptText(api, entry.id).then((text) => {
586
+ if (previewID() === entry.id) setPreviewText(text)
587
+ })
450
588
  api.ui.dialog.replace(
451
589
  () => (
452
590
  <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
@@ -464,7 +602,11 @@ function createTranscriptPreview(api: TuiPluginApi) {
464
602
  maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
465
603
  >
466
604
  {getMarkdownStyle(api.theme.current) ? (
467
- <markdown content={previewText()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
605
+ <markdown
606
+ content={previewText()}
607
+ syntaxStyle={getMarkdownStyle(api.theme.current)!}
608
+ treeSitterClient={getTreeSitterClient()}
609
+ />
468
610
  ) : (
469
611
  <text wrapMode="word">{previewText()}</text>
470
612
  )}
@@ -500,10 +642,13 @@ type TreeRow =
500
642
  | { kind: "group"; dir: string; label: string; count: number }
501
643
  | { kind: "item"; entry: Entry; last: boolean }
502
644
 
503
- function SidebarSessions(props: { api: TuiPluginApi }) {
645
+ type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
646
+
647
+ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
504
648
  const theme = () => props.api.theme.current
505
649
  const home = process.env.HOME ?? ""
506
650
  const [entries, setEntries] = createSignal<Entry[]>([])
651
+ const [sectionCollapsed, setSectionCollapsed] = createSignal(false)
507
652
  const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
508
653
  const [hovered, setHovered] = createSignal<string>()
509
654
  const preview = createTranscriptPreview(props.api)
@@ -514,6 +659,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
514
659
  const [navActive, setNavActive] = createSignal(false)
515
660
  const [cursor, setCursor] = createSignal(0)
516
661
  const [pendingDelete, setPendingDelete] = createSignal<string>()
662
+ const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
517
663
  let confirmTimer: ReturnType<typeof setTimeout> | undefined
518
664
  const currentID = createMemo(() => {
519
665
  const route = props.api.route.current
@@ -525,6 +671,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
525
671
  onMount(() => {
526
672
  let alive = true
527
673
  const load = async () => {
674
+ props.refreshPins()
528
675
  try {
529
676
  const { entries: all } = await fetchEntries(props.api)
530
677
  if (alive) {
@@ -536,6 +683,19 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
536
683
  // say so when there is nothing at all to show
537
684
  if (alive && entries().length === 0) setLoadFailed(true)
538
685
  }
686
+ try {
687
+ const db = await openTranscriptDb()
688
+ if (db) {
689
+ try {
690
+ const ids = await queryWaitingIds(db)
691
+ if (alive) setWaiting(ids)
692
+ } finally {
693
+ db.close()
694
+ }
695
+ }
696
+ } catch {
697
+ // leave the previous waiting set in place rather than flashing
698
+ }
539
699
  }
540
700
  void load()
541
701
  const clock = setInterval(() => void load(), POLL_MS)
@@ -547,26 +707,49 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
547
707
 
548
708
  const filteredEntries = createMemo(() => {
549
709
  const q = query().trim().toLowerCase()
550
- if (!q) return entries()
551
- return entries().filter(
710
+ const ordered = pinnedFirst(entries(), props.pins())
711
+ if (!q) return ordered
712
+ return ordered.filter(
552
713
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
553
714
  )
554
715
  })
555
716
 
556
- const shownEntries = createMemo(() => filteredEntries().slice(0, SIDEBAR_LIMIT))
717
+ const shownEntries = createMemo(() => filteredEntries())
557
718
  const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
558
719
 
720
+ const NEEDS_INPUT_DIR = "__needs_input__"
721
+ const waitingEntries = createMemo(() => {
722
+ const ids = waiting()
723
+ return shownEntries().filter((entry) => ids.has(entry.id))
724
+ })
725
+ const waitingReason = (id: string) => (waiting().get(id) === "question" ? "awaiting answer" : "run stuck")
726
+
559
727
  const tree = createMemo<TreeRow[]>(() => {
728
+ const rows: TreeRow[] = []
729
+ const pending = waitingEntries()
730
+ if (pending.length > 0) {
731
+ rows.push({ kind: "group", dir: NEEDS_INPUT_DIR, label: "Needs input", count: pending.length })
732
+ if (!collapsed()[NEEDS_INPUT_DIR]) {
733
+ for (let i = 0; i < pending.length; i++) {
734
+ rows.push({ kind: "item", entry: pending[i], last: i === pending.length - 1 })
735
+ }
736
+ }
737
+ }
560
738
  const groups = new Map<string, Entry[]>()
561
739
  for (const entry of shownEntries()) {
740
+ if (waiting().has(entry.id)) continue
562
741
  const list = groups.get(entry.dir) ?? []
563
742
  list.push(entry)
564
743
  groups.set(entry.dir, list)
565
744
  }
566
745
  const ordered = [...groups.entries()]
567
746
  .map(([dir, list]) => ({ dir, list, updated: Math.max(...list.map((entry) => entry.updated)) }))
568
- .sort((a, b) => b.updated - a.updated)
569
- const rows: TreeRow[] = []
747
+ .sort((a, b) => {
748
+ const pins = props.pins()
749
+ const rank = (group: { dir: string; list: Entry[] }) =>
750
+ pins.directories.includes(group.dir) ? 2 : group.list.some((entry) => pins.sessions.includes(entry.id)) ? 1 : 0
751
+ return rank(b) - rank(a) || b.updated - a.updated
752
+ })
570
753
  for (const group of ordered) {
571
754
  rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
572
755
  if (collapsed()[group.dir]) continue
@@ -581,6 +764,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
581
764
  setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
582
765
  }
583
766
 
767
+ const toggleSection = () => {
768
+ if (!sectionCollapsed()) {
769
+ cancelDelete()
770
+ setNavActive(false)
771
+ setSearching(false)
772
+ setHovered(undefined)
773
+ }
774
+ setSectionCollapsed((value) => !value)
775
+ }
776
+
584
777
  // Flat order of the rows a cursor can land on, so keyboard navigation and
585
778
  // mouse hover resolve to the same row.
586
779
  const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
@@ -591,9 +784,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
591
784
  if (count === 0) return
592
785
  setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
593
786
  }
787
+ let previousRows: Entry[] = []
594
788
  createEffect(() => {
595
- const count = itemRows().length
596
- if (cursor() >= count) setCursor(Math.max(0, count - 1))
789
+ const rows = itemRows()
790
+ const selected = previousRows[cursor()]?.id
791
+ const position = rows.findIndex((entry) => entry.id === selected)
792
+ if (position >= 0 && position !== cursor()) setCursor(position)
793
+ else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
794
+ previousRows = rows
597
795
  })
598
796
 
599
797
  const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
@@ -621,8 +819,9 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
621
819
  }
622
820
 
623
821
  // 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.
822
+ // commits it. The arm survives pointer movement — edge-hover flicker must
823
+ // not eat the confirm — and is cleared by the five-second timeout, n, Esc,
824
+ // collapsing the section, or the delete itself.
626
825
  const requestDelete = (entry: Entry) => {
627
826
  if (deleting()) return
628
827
  if (pendingDelete() === entry.id) {
@@ -634,6 +833,18 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
634
833
  confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
635
834
  }
636
835
 
836
+ // The confirm always targets the visibly armed row ("ctrl+x again"),
837
+ // wherever the pointer or cursor currently is. Returns true when an arm
838
+ // was pending (and is now committed or discarded as stale).
839
+ const confirmArmed = (): boolean => {
840
+ const id = pendingDelete()
841
+ if (!id) return false
842
+ const entry = entries().find((e) => e.id === id)
843
+ if (!entry) cancelDelete()
844
+ else void deleteEntry(entry)
845
+ return true
846
+ }
847
+
637
848
  const deleteEntry = async (entry: Entry) => {
638
849
  cancelDelete()
639
850
  if (deleting()) return
@@ -710,7 +921,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
710
921
  priority: 20,
711
922
  bindings: [
712
923
  {
713
- key: "space",
924
+ key: "ctrl+p",
714
925
  desc: "Preview session transcript",
715
926
  preventDefault: true,
716
927
  cmd: () => openSidebarPreview(entry),
@@ -719,8 +930,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
719
930
  key: "ctrl+x",
720
931
  desc: "Delete session",
721
932
  preventDefault: true,
722
- cmd: () => requestDelete(entry),
933
+ cmd: () => {
934
+ if (!confirmArmed()) requestDelete(entry)
935
+ },
723
936
  },
937
+ { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
938
+ { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
724
939
  {
725
940
  key: "/",
726
941
  desc: "Search sessions",
@@ -731,13 +946,6 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
731
946
  })
732
947
  })
733
948
 
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
949
  // Keyboard navigation is opt-in (click the "Sessions" heading or the search
742
950
  // box) so the sidebar never steals the arrow keys from the main prompt.
743
951
  createEffect(() => {
@@ -757,7 +965,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
757
965
  cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
758
966
  },
759
967
  {
760
- key: "space",
968
+ key: "ctrl+p",
761
969
  desc: "Preview session transcript",
762
970
  preventDefault: true,
763
971
  cmd: () => {
@@ -770,10 +978,29 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
770
978
  desc: "Delete session",
771
979
  preventDefault: true,
772
980
  cmd: () => {
981
+ if (confirmArmed()) return
773
982
  const entry = itemRows()[cursor()]
774
983
  if (entry) requestDelete(entry)
775
984
  },
776
985
  },
986
+ {
987
+ key: "option+s",
988
+ desc: "Pin session",
989
+ preventDefault: true,
990
+ cmd: () => {
991
+ const entry = itemRows()[cursor()]
992
+ if (entry) props.onTogglePin("sessions", entry.id)
993
+ },
994
+ },
995
+ {
996
+ key: "option+d",
997
+ desc: "Pin directory",
998
+ preventDefault: true,
999
+ cmd: () => {
1000
+ const entry = itemRows()[cursor()]
1001
+ if (entry) props.onTogglePin("directories", entry.dir)
1002
+ },
1003
+ },
777
1004
  {
778
1005
  key: "enter",
779
1006
  desc: "Open session",
@@ -799,8 +1026,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
799
1026
  desc: "Confirm delete",
800
1027
  preventDefault: true,
801
1028
  cmd: () => {
802
- const entry = itemRows()[cursor()]
803
- if (entry && pendingDelete() === entry.id) void deleteEntry(entry)
1029
+ confirmArmed()
804
1030
  },
805
1031
  },
806
1032
  { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
@@ -818,16 +1044,25 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
818
1044
  })
819
1045
 
820
1046
  return (
821
- <box flexDirection="column" paddingRight={1}>
822
- <box flexDirection="row" gap={1} onMouseDown={() => setNavActive((value) => !value)}>
823
- <text>
1047
+ <box flexDirection="column" paddingRight={1} flexGrow={1} flexShrink={1}>
1048
+ <box flexDirection="row" justifyContent="space-between" gap={1}>
1049
+ <text flexShrink={1} onMouseDown={() => {
1050
+ if (!sectionCollapsed()) setNavActive((value) => !value)
1051
+ }}>
824
1052
  <b>Sessions</b>
825
1053
  <span style={{ fg: theme().textMuted }}>
826
1054
  {query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
827
1055
  </span>
828
- <span style={{ fg: navActive() ? theme().accent : theme().textMuted }}>{navActive() ? " ▸ nav" : " ▸"}</span>
1056
+ <span style={{ fg: theme().accent }}>{navActive() ? " · nav" : ""}</span>
1057
+ <Show when={waitingEntries().length > 0}>
1058
+ <span style={{ fg: theme().warning }}> · {waitingEntries().length} need input</span>
1059
+ </Show>
1060
+ </text>
1061
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
1062
+ {sectionCollapsed() ? "▸ show" : "▾ hide"}
829
1063
  </text>
830
1064
  </box>
1065
+ <Show when={!sectionCollapsed()}>
831
1066
  <Show
832
1067
  when={entries().length > 0}
833
1068
  fallback={
@@ -864,6 +1099,8 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
864
1099
  </Show>
865
1100
  </box>
866
1101
  <scrollbox
1102
+ flexGrow={1}
1103
+ flexShrink={1}
867
1104
  verticalScrollbarOptions={{ visible: false }}
868
1105
  horizontalScrollbarOptions={{ visible: false }}
869
1106
  >
@@ -873,7 +1110,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
873
1110
  <box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
874
1111
  <text flexGrow={1} flexShrink={1} wrapMode="none">
875
1112
  <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
876
- <b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
1113
+ <b>{props.pins().directories.includes(row.dir) ? "★ " : ""}{truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0))}</b>
877
1114
  </text>
878
1115
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
879
1116
  {" "}({row.count})
@@ -890,7 +1127,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
890
1127
  ? theme().backgroundElement
891
1128
  : RGBA.fromInts(0, 0, 0, 0)
892
1129
  }
893
- onMouseOver={() => setHovered(row.entry.id)}
1130
+ onMouseOver={() => {
1131
+ setHovered(row.entry.id)
1132
+ // The keyboard layer wins over the hover layer while nav is
1133
+ // active. Keep its cursor on the hovered row so Option-S/Option-D
1134
+ // cannot silently pin a different (often current) session.
1135
+ if (navActive()) {
1136
+ const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
1137
+ if (index >= 0) setCursor(index)
1138
+ }
1139
+ }}
894
1140
  onMouseOut={() => setHovered(undefined)}
895
1141
  onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
896
1142
  >
@@ -909,13 +1155,21 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
909
1155
  </span>
910
1156
  </text>
911
1157
  <Highlighted
912
- text={truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
1158
+ text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
913
1159
  query={query()}
914
1160
  color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
915
1161
  matchColor={theme().warning}
916
1162
  />
917
1163
  <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)}
1164
+ {isPendingDelete(row.entry.id) ? (
1165
+ "ctrl+x again"
1166
+ ) : waiting().has(row.entry.id) ? (
1167
+ <span>
1168
+ {ago(row.entry.updated)} · <span style={{ fg: theme().warning }}>{waitingReason(row.entry.id)}</span>
1169
+ </span>
1170
+ ) : (
1171
+ ago(row.entry.updated)
1172
+ )}
919
1173
  </text>
920
1174
  </box>
921
1175
  )
@@ -925,19 +1179,23 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
925
1179
  <Show when={remaining() > 0}>
926
1180
  <text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
927
1181
  </Show>
1182
+ <Show when={query().trim() && filteredEntries().length === 0}>
1183
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
1184
+ </Show>
928
1185
  <Show when={navActive()}>
929
1186
  <box paddingTop={1}>
930
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · space preview · ctrl+x delete · esc done</text>
1187
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
931
1188
  </box>
932
1189
  </Show>
933
1190
  </Show>
1191
+ </Show>
934
1192
  </box>
935
1193
  )
936
1194
  }
937
1195
 
938
1196
  const HOME_SESSION_LIMIT = 6
939
1197
 
940
- function HomeSessions(props: { api: TuiPluginApi }) {
1198
+ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
941
1199
  const theme = () => props.api.theme.current
942
1200
  const home = process.env.HOME ?? ""
943
1201
  const [entries, setEntries] = createSignal<Entry[]>([])
@@ -992,11 +1250,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
992
1250
  priority: 20,
993
1251
  bindings: [
994
1252
  {
995
- key: "space",
1253
+ key: "ctrl+p",
996
1254
  desc: "Preview session transcript",
997
1255
  preventDefault: true,
998
1256
  cmd: () => preview.open(entry),
999
1257
  },
1258
+ { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1259
+ { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1000
1260
  ],
1001
1261
  })
1002
1262
  })
@@ -1007,6 +1267,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
1007
1267
  onMount(() => {
1008
1268
  let alive = true
1009
1269
  const load = async () => {
1270
+ props.refreshPins()
1010
1271
  try {
1011
1272
  const { entries: all } = await fetchEntries(props.api)
1012
1273
  if (alive) {
@@ -1032,7 +1293,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
1032
1293
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
1033
1294
  )
1034
1295
  : entries()
1035
- return list.slice(0, HOME_SESSION_LIMIT)
1296
+ return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT)
1036
1297
  })
1037
1298
 
1038
1299
  return (
@@ -1097,19 +1358,19 @@ function HomeSessions(props: { api: TuiPluginApi }) {
1097
1358
  ○
1098
1359
  </text>
1099
1360
  <Highlighted
1100
- text={truncate(entry.title, HOME_TITLE_WIDTH)}
1361
+ text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
1101
1362
  query={query()}
1102
1363
  color={theme().text}
1103
1364
  matchColor={theme().warning}
1104
1365
  />
1105
1366
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
1106
- {prettyDir(entry.dir, home)} · {ago(entry.updated)}
1367
+ {props.pins().directories.includes(entry.dir) ? "★ " : ""}{prettyDir(entry.dir, home)} · {ago(entry.updated)}
1107
1368
  </text>
1108
1369
  </box>
1109
1370
  )}
1110
1371
  </For>
1111
1372
  <Show when={visible().length === 0}>
1112
- <text style={{ fg: theme().textMuted }}>No matching sessions</text>
1373
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
1113
1374
  </Show>
1114
1375
  </Show>
1115
1376
  </box>
@@ -1117,19 +1378,40 @@ function HomeSessions(props: { api: TuiPluginApi }) {
1117
1378
  }
1118
1379
 
1119
1380
  const tui: TuiPlugin = async (api) => {
1381
+ const [pins, setPins] = createSignal<Pins>(emptyPins())
1382
+ let pinVersion = 0
1383
+ const refreshPins = () => {
1384
+ const version = pinVersion
1385
+ void readPins().then((latest) => {
1386
+ if (version === pinVersion) setPins(latest)
1387
+ })
1388
+ }
1389
+ refreshPins()
1390
+ const onTogglePin = (kind: keyof Pins, value: string) => {
1391
+ if (!value) return
1392
+ pinVersion += 1
1393
+ void togglePin(kind, value).then(
1394
+ (updated) => {
1395
+ setPins(updated)
1396
+ api.ui.toast({ message: `${updated[kind].includes(value) ? "Pinned" : "Unpinned"} ${kind === "sessions" ? "session" : "directory"}`, variant: "info" })
1397
+ },
1398
+ () => api.ui.toast({ message: "Could not update pins", variant: "error" }),
1399
+ )
1400
+ }
1120
1401
  api.slots.register({
1121
1402
  order: 100,
1122
1403
  slots: {
1123
1404
  sidebar_content() {
1124
- return <SidebarSessions api={api} />
1405
+ return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
1125
1406
  },
1126
1407
  home_bottom() {
1127
- return <HomeSessions api={api} />
1408
+ return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
1128
1409
  },
1129
1410
  },
1130
1411
  })
1131
1412
 
1132
1413
  const openPicker = async () => {
1414
+ refreshPins()
1133
1415
  if (api.mode.current() !== BASE_MODE) return
1134
1416
  if (api.renderer.currentFocusedEditor === null) return
1135
1417
 
@@ -1159,16 +1441,20 @@ const tui: TuiPlugin = async (api) => {
1159
1441
  total: 0,
1160
1442
  complete: false,
1161
1443
  })
1162
- void buildSearchIndex(api, entries, setIndexProgress).then(setSearchIndex)
1163
- let pickerInput: { value: string; isDestroyed?: boolean } | undefined
1164
-
1444
+ void buildSearchIndex(api, entries, (progress, index) => {
1445
+ setIndexProgress(progress)
1446
+ // Publish a fresh snapshot: the indexing workers mutate their own map,
1447
+ // while Solid needs a new reference to update search results mid-scan.
1448
+ setSearchIndex(new Map(index))
1449
+ })
1165
1450
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
1166
1451
 
1452
+ const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
1167
1453
  const matchedGroups = createMemo(() => {
1168
1454
  const q = query().trim().toLowerCase()
1169
1455
  const index = searchIndex()
1170
1456
  const group = scope()
1171
- const matched = allEntries().filter((entry) => {
1457
+ const matched = orderedEntries().filter((entry) => {
1172
1458
  if (group && entry.group !== group) return false
1173
1459
  if (!q) return true
1174
1460
  const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
@@ -1180,7 +1466,14 @@ const tui: TuiPlugin = async (api) => {
1180
1466
  list.push(entry)
1181
1467
  byGroup.set(entry.group, list)
1182
1468
  }
1183
- return [...byGroup.entries()].map(([name, list]) => ({ name, list }))
1469
+ const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
1470
+ const currentPins = pins()
1471
+ const rank = (group: (typeof groups)[number]) =>
1472
+ group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
1473
+ : group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
1474
+ return groups.sort((a, b) =>
1475
+ rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
1476
+ )
1184
1477
  })
1185
1478
 
1186
1479
  const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
@@ -1202,9 +1495,14 @@ const tui: TuiPlugin = async (api) => {
1202
1495
  return bits.join(" · ")
1203
1496
  })
1204
1497
 
1498
+ let previousSelection: Entry[] = []
1205
1499
  createEffect(() => {
1206
- const count = selectableEntries().length
1207
- if (cursor() >= count) setCursor(Math.max(0, count - 1))
1500
+ const rows = selectableEntries()
1501
+ const selected = previousSelection[cursor()]?.id
1502
+ const position = rows.findIndex((entry) => entry.id === selected)
1503
+ if (position >= 0 && position !== cursor()) setCursor(position)
1504
+ else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
1505
+ previousSelection = rows
1208
1506
  })
1209
1507
 
1210
1508
  const pickerRows = createMemo<DialogRow[]>(() => {
@@ -1273,8 +1571,8 @@ const tui: TuiPlugin = async (api) => {
1273
1571
  setPreviewText(cached)
1274
1572
  return
1275
1573
  }
1276
- // Keep the previous transcript visible while the next one loads so the
1277
- // preview does not blink away on every cursor move.
1574
+ // Never show another session's transcript beneath the new selection.
1575
+ setPreviewText("Loading…")
1278
1576
  previewTimer = setTimeout(() => void loadPreview(id), 120)
1279
1577
  })
1280
1578
 
@@ -1375,6 +1673,15 @@ const tui: TuiPlugin = async (api) => {
1375
1673
  cmd: () => setCursor(selectableEntries().length - 1),
1376
1674
  },
1377
1675
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1676
+ { key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1677
+ { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
1678
+ const entry = selectableEntries()[cursor()]
1679
+ if (entry) onTogglePin("sessions", entry.id)
1680
+ } },
1681
+ { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1682
+ const entry = selectableEntries()[cursor()]
1683
+ if (entry) onTogglePin("directories", entry.dir)
1684
+ } },
1378
1685
  {
1379
1686
  key: "ctrl+x",
1380
1687
  desc: "Delete session",
@@ -1425,16 +1732,6 @@ const tui: TuiPlugin = async (api) => {
1425
1732
  if (previewTimer) clearTimeout(previewTimer)
1426
1733
  }
1427
1734
 
1428
- const onSearchInput = (value: string) => {
1429
- if (query() === "" && value.trim() === "" && value.length > 0) {
1430
- if (pickerInput && !pickerInput.isDestroyed) pickerInput.value = ""
1431
- setQuery("")
1432
- togglePreview()
1433
- return
1434
- }
1435
- setQuery(value)
1436
- }
1437
-
1438
1735
  api.ui.dialog.replace(
1439
1736
  () => (
1440
1737
  <box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
@@ -1449,7 +1746,6 @@ const tui: TuiPlugin = async (api) => {
1449
1746
  </box>
1450
1747
  <box paddingTop={1}>
1451
1748
  <input
1452
- ref={(r) => (pickerInput = r)}
1453
1749
  focused
1454
1750
  placeholder="Search title, directory, transcript…"
1455
1751
  value={query()}
@@ -1457,7 +1753,7 @@ const tui: TuiPlugin = async (api) => {
1457
1753
  focusedBackgroundColor={api.theme.current.backgroundPanel}
1458
1754
  focusedTextColor={api.theme.current.text}
1459
1755
  cursorColor={api.theme.current.primary}
1460
- onInput={onSearchInput}
1756
+ onInput={setQuery}
1461
1757
  onSubmit={() => choose()}
1462
1758
  onKeyDown={(event) => {
1463
1759
  if (event.name === "escape") {
@@ -1486,7 +1782,7 @@ const tui: TuiPlugin = async (api) => {
1486
1782
  row.kind === "group" ? (
1487
1783
  <box paddingTop={1} paddingLeft={4}>
1488
1784
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1489
- {row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
1785
+ {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>
1490
1786
  </text>
1491
1787
  </box>
1492
1788
  ) : (
@@ -1516,7 +1812,7 @@ const tui: TuiPlugin = async (api) => {
1516
1812
  </text>
1517
1813
  </Show>
1518
1814
  <Highlighted
1519
- text={truncate(row.entry.title, 61)}
1815
+ text={`${pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, pins().sessions.includes(row.entry.id) ? 59 : 61)}`}
1520
1816
  query={query()}
1521
1817
  bold={row.entry.id === selectableEntries()[cursor()]?.id}
1522
1818
  color={
@@ -1543,7 +1839,12 @@ const tui: TuiPlugin = async (api) => {
1543
1839
  >
1544
1840
  {pendingDelete()?.id === row.entry.id
1545
1841
  ? "press y to delete"
1546
- : `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}`}
1842
+ : `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}${
1843
+ query().trim() &&
1844
+ !`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
1845
+ ? " · transcript match"
1846
+ : ""
1847
+ }`}
1547
1848
  </text>
1548
1849
  </box>
1549
1850
  )
@@ -1552,7 +1853,11 @@ const tui: TuiPlugin = async (api) => {
1552
1853
  <Show when={selectableEntries().length === 0}>
1553
1854
  <box paddingLeft={4} paddingRight={4}>
1554
1855
  <text style={{ fg: api.theme.current.textMuted }}>
1555
- {scope() ? `No sessions in ${scope()}` : "No sessions match this search"}
1856
+ {!indexProgress().complete
1857
+ ? "No matches yet · still searching transcripts…"
1858
+ : scope()
1859
+ ? `No matches in ${scope()} · ctrl+g for all projects`
1860
+ : "No sessions match · try another search"}
1556
1861
  </text>
1557
1862
  </box>
1558
1863
  </Show>
@@ -1560,20 +1865,23 @@ const tui: TuiPlugin = async (api) => {
1560
1865
  <Show when={showPreview()}>
1561
1866
  <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
1562
1867
  <text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
1563
- Preview
1868
+ {previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
1564
1869
  </text>
1565
1870
  <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
1566
- <Show
1567
- when={previewText()}
1568
- fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}
1569
- >
1570
- {(text) =>
1571
- getMarkdownStyle(api.theme.current) ? (
1572
- <markdown content={text()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
1573
- ) : (
1574
- <text wrapMode="word">{text()}</text>
1575
- )
1576
- }
1871
+ <Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
1872
+ <Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
1873
+ {(text) =>
1874
+ getMarkdownStyle(api.theme.current) ? (
1875
+ <markdown
1876
+ content={text()}
1877
+ syntaxStyle={getMarkdownStyle(api.theme.current)!}
1878
+ treeSitterClient={getTreeSitterClient()}
1879
+ />
1880
+ ) : (
1881
+ <text wrapMode="word">{text()}</text>
1882
+ )
1883
+ }
1884
+ </Show>
1577
1885
  </Show>
1578
1886
  </scrollbox>
1579
1887
  </box>
@@ -1586,7 +1894,7 @@ const tui: TuiPlugin = async (api) => {
1586
1894
  >
1587
1895
  {pendingDelete()
1588
1896
  ? `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"}
1897
+ : "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
1590
1898
  </text>
1591
1899
  </box>
1592
1900
  </box>