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