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