opencode-sesh 0.14.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -14
- package/CHANGELOG.md +19 -0
- package/README.md +6 -4
- package/bin/sesh-waiting.sh +2 -2
- package/bin/sesh.sh +2 -2
- package/install.sh +58 -33
- package/package.json +17 -7
- package/plugins/sesh-list.ts +64 -0
- package/tui-plugins/sesh-panel/package.json +9 -0
- package/tui-plugins/sesh-panel/server.ts +2 -0
- package/{tui/sesh-panel.tsx → tui-plugins/sesh-panel/tui.tsx} +564 -253
- package/opencode/tools/sesh-list.ts +0 -51
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import
|
|
3
|
-
import type { Project, Session } from "@opencode
|
|
4
|
-
import {
|
|
2
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
+
import type { Project, SessionInfo as Session } from "@opencode/client"
|
|
4
|
+
import type { Accessor } from "solid-js"
|
|
5
|
+
|
|
6
|
+
type TuiPluginApi = any
|
|
7
|
+
type TuiPlugin = (api: TuiPluginApi) => Promise<void>
|
|
8
|
+
import { createEffect, createMemo, createRoot, createSignal, For, getOwner, on, onCleanup, onMount, runWithOwner, Show } from "solid-js"
|
|
5
9
|
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
6
10
|
import { dirname, join } from "node:path"
|
|
7
11
|
import { spawn } from "node:child_process"
|
|
@@ -69,7 +73,7 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
|
|
|
69
73
|
{ scope: ["markup.list"], style: { foreground: theme.syntaxKeyword } },
|
|
70
74
|
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
71
75
|
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.syntaxString } },
|
|
72
|
-
{ scope: ["markup.raw.inline"], style: { foreground: theme.syntaxString, background: theme.
|
|
76
|
+
{ scope: ["markup.raw.inline"], style: { foreground: theme.syntaxString, background: theme.backgroundElement } },
|
|
73
77
|
{
|
|
74
78
|
scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"],
|
|
75
79
|
style: { foreground: theme.markdownLink, underline: true },
|
|
@@ -80,10 +84,10 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
|
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
|
|
83
|
-
if (markdownStyleCache?.theme === theme) return markdownStyleCache
|
|
87
|
+
if (markdownStyleCache?.theme === theme) return markdownStyleCache?.style
|
|
84
88
|
try {
|
|
85
89
|
markdownStyleCache = { theme, style: buildMarkdownStyle(theme) }
|
|
86
|
-
return markdownStyleCache
|
|
90
|
+
return markdownStyleCache?.style
|
|
87
91
|
} catch {
|
|
88
92
|
try {
|
|
89
93
|
return SyntaxStyle.create()
|
|
@@ -391,6 +395,14 @@ function createContextMenu(host: ContextMenuHost) {
|
|
|
391
395
|
return { open, close, select, activate, click, current: () => state }
|
|
392
396
|
}
|
|
393
397
|
|
|
398
|
+
type PortalContainer = {
|
|
399
|
+
position?: string
|
|
400
|
+
left?: number
|
|
401
|
+
top?: number
|
|
402
|
+
zIndex?: number
|
|
403
|
+
destroyRecursively?: () => void
|
|
404
|
+
}
|
|
405
|
+
|
|
394
406
|
// Loading-spinner frames for the marker of a session whose agent is working.
|
|
395
407
|
const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
396
408
|
|
|
@@ -401,7 +413,7 @@ function newestFirst(entries: Entry[]): Entry[] {
|
|
|
401
413
|
// A working agent is the only state the marker displays: everything that
|
|
402
414
|
// finished goes back to the neutral row, with the open session marked inside.
|
|
403
415
|
function sidebarMarker(current: boolean, live?: string): SidebarMarker {
|
|
404
|
-
if (live === "busy" || live === "retry") return "running"
|
|
416
|
+
if (live === "running" || live === "busy" || live === "retry") return "running"
|
|
405
417
|
return current ? "current" : "idle"
|
|
406
418
|
}
|
|
407
419
|
|
|
@@ -413,7 +425,7 @@ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
|
|
|
413
425
|
const projectResult = await api.client.project.list({})
|
|
414
426
|
const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
|
|
415
427
|
const projectName = new Map<string, string>(
|
|
416
|
-
projects.map((p) => [p.id, p.name?.trim() || shortDir(p.
|
|
428
|
+
projects.map((p) => [p.id, p.name?.trim() || shortDir(p.canonical, home)]),
|
|
417
429
|
)
|
|
418
430
|
const sessions: Session[] = []
|
|
419
431
|
let cursor: number | undefined
|
|
@@ -448,12 +460,13 @@ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
|
|
|
448
460
|
.sort((a, b) => b.time.updated - a.time.updated)
|
|
449
461
|
.map((s) => {
|
|
450
462
|
const projectLabel = s.projectID ? projectName.get(s.projectID) : undefined
|
|
463
|
+
const sDir = (s as any).directory ?? s.location?.directory ?? ""
|
|
451
464
|
return {
|
|
452
465
|
id: s.id,
|
|
453
466
|
title: s.title?.trim() || "(untitled)",
|
|
454
|
-
dir:
|
|
467
|
+
dir: sDir,
|
|
455
468
|
group:
|
|
456
|
-
(projectLabel && projectLabel !== "other" ? projectLabel : shortDir(
|
|
469
|
+
(projectLabel && projectLabel !== "other" ? projectLabel : shortDir(sDir, home)) ||
|
|
457
470
|
"other",
|
|
458
471
|
updated: s.time.updated,
|
|
459
472
|
}
|
|
@@ -490,40 +503,33 @@ async function openTranscriptDb(): Promise<any | undefined> {
|
|
|
490
503
|
// the TUI sync layer only sees permission/question state for sessions owned
|
|
491
504
|
// by the current server, which is invisible cross-project.
|
|
492
505
|
const STUCK_AFTER_MS = 600000
|
|
493
|
-
const
|
|
506
|
+
const LIVE_TOOLS_CTE = (sessionFilter: string) => `
|
|
507
|
+
WITH live AS (
|
|
508
|
+
SELECT m.session_id AS session_id, m.seq AS seq,
|
|
509
|
+
json_extract(c.value, '$.name') AS name,
|
|
510
|
+
json_extract(c.value, '$.state.status') AS status,
|
|
511
|
+
COALESCE(json_extract(c.value, '$.time.created'), m.time_created) AS created
|
|
512
|
+
FROM session_message m, json_each(m.data, '$.content') c
|
|
513
|
+
WHERE m.type = 'assistant'
|
|
514
|
+
AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
|
|
515
|
+
AND json_extract(c.value, '$.type') = 'tool'
|
|
516
|
+
AND json_extract(c.value, '$.state.status') IN ('pending', 'running')${sessionFilter}
|
|
517
|
+
)`
|
|
518
|
+
const QUESTION_OPEN = `live.name = 'question'
|
|
519
|
+
AND NOT EXISTS (
|
|
520
|
+
SELECT 1 FROM session_message u
|
|
521
|
+
WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)`
|
|
522
|
+
const NEEDS_INPUT_SQL = (stuckBefore: number) => `${LIVE_TOOLS_CTE("")}
|
|
494
523
|
SELECT s.id AS id,
|
|
495
|
-
CASE WHEN EXISTS (
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
AND json_extract(p.data, '$.type') = 'tool'
|
|
499
|
-
AND json_extract(p.data, '$.tool') = 'question'
|
|
500
|
-
AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
|
|
501
|
-
AND NOT EXISTS (
|
|
502
|
-
SELECT 1 FROM message m
|
|
503
|
-
WHERE m.session_id = s.id
|
|
504
|
-
AND json_extract(m.data, '$.role') = 'user'
|
|
505
|
-
AND m.time_created > p.time_created)
|
|
506
|
-
) THEN 'question' ELSE 'stuck' END AS reason
|
|
507
|
-
FROM session s
|
|
524
|
+
CASE WHEN EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
|
|
525
|
+
THEN 'question' ELSE 'stuck' END AS reason
|
|
526
|
+
FROM session_v2 s
|
|
508
527
|
WHERE COALESCE(s.time_archived, 0) = 0
|
|
509
528
|
AND s.parent_id IS NULL
|
|
510
|
-
AND (EXISTS (
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
AND
|
|
514
|
-
AND json_extract(p.data, '$.tool') = 'question'
|
|
515
|
-
AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
|
|
516
|
-
AND NOT EXISTS (
|
|
517
|
-
SELECT 1 FROM message m
|
|
518
|
-
WHERE m.session_id = s.id
|
|
519
|
-
AND json_extract(m.data, '$.role') = 'user'
|
|
520
|
-
AND m.time_created > p.time_created)
|
|
521
|
-
) OR EXISTS (
|
|
522
|
-
SELECT 1 FROM part p2
|
|
523
|
-
WHERE p2.session_id = s.id
|
|
524
|
-
AND json_extract(p2.data, '$.type') = 'tool'
|
|
525
|
-
AND json_extract(p2.data, '$.state.status') = 'running'
|
|
526
|
-
AND p2.time_created < ${stuckBefore}))
|
|
529
|
+
AND (EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
|
|
530
|
+
OR EXISTS (
|
|
531
|
+
SELECT 1 FROM live
|
|
532
|
+
WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore}))
|
|
527
533
|
ORDER BY s.time_updated DESC`
|
|
528
534
|
|
|
529
535
|
async function queryWaitingIds(db: any): Promise<Map<string, string>> {
|
|
@@ -558,19 +564,12 @@ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>>
|
|
|
558
564
|
const cutoff = Date.now() - STUCK_AFTER_MS
|
|
559
565
|
for (let i = 0; i < ids.length; i += 200) {
|
|
560
566
|
const batch = ids.slice(i, i + 200)
|
|
561
|
-
const rows = (db.query(`
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
THEN p.time_created END) AS question_since,
|
|
568
|
-
MIN(CASE WHEN json_extract(p.data, '$.state.status') = 'running'
|
|
569
|
-
AND p.time_created < ${cutoff} THEN p.time_created END) AS stuck_since
|
|
570
|
-
FROM part p
|
|
571
|
-
WHERE p.session_id IN (${batch.map(() => "?").join(",")})
|
|
572
|
-
AND json_extract(p.data, '$.type') = 'tool'
|
|
573
|
-
GROUP BY p.session_id`).all(...batch) ?? []) as { id: string; question_since: number | null; stuck_since: number | null }[]
|
|
567
|
+
const rows = (db.query(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
|
|
568
|
+
SELECT live.session_id AS id,
|
|
569
|
+
MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
|
|
570
|
+
MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
|
|
571
|
+
FROM live
|
|
572
|
+
GROUP BY live.session_id`).all(...batch) ?? []) as { id: string; question_since: number | null; stuck_since: number | null }[]
|
|
574
573
|
for (const row of rows) {
|
|
575
574
|
const detail = details.get(row.id)
|
|
576
575
|
if (!detail) continue
|
|
@@ -583,12 +582,20 @@ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>>
|
|
|
583
582
|
|
|
584
583
|
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
585
584
|
try {
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
585
|
+
const message = JSON.parse(data) as { text?: unknown; content?: unknown }
|
|
586
|
+
const texts: string[] = []
|
|
587
|
+
if (typeof message?.text === "string") texts.push(message.text)
|
|
588
|
+
if (Array.isArray(message?.content)) {
|
|
589
|
+
for (const part of message.content as { type?: unknown; text?: unknown }[]) {
|
|
590
|
+
if (part?.type === "text" && typeof part.text === "string") texts.push(part.text)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (const text of texts) {
|
|
594
|
+
const base = index.get(sid) ?? ""
|
|
595
|
+
index.set(sid, `${base} ${text.toLowerCase()}`.trim())
|
|
596
|
+
}
|
|
590
597
|
} catch {
|
|
591
|
-
// ignore malformed
|
|
598
|
+
// ignore malformed messages
|
|
592
599
|
}
|
|
593
600
|
}
|
|
594
601
|
|
|
@@ -619,7 +626,7 @@ async function buildSearchIndex(
|
|
|
619
626
|
const placeholders = batch.map(() => "?").join(",")
|
|
620
627
|
const rows = (db
|
|
621
628
|
.query(
|
|
622
|
-
`SELECT session_id AS sid, data FROM
|
|
629
|
+
`SELECT session_id AS sid, data FROM session_message WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant') ORDER BY seq`,
|
|
623
630
|
)
|
|
624
631
|
.all(...batch) ?? []) as { sid: string; data: string }[]
|
|
625
632
|
for (const row of rows) addTranscript(index, row.sid, row.data)
|
|
@@ -676,10 +683,10 @@ async function buildSearchIndexRemote(
|
|
|
676
683
|
const result = await api.client.session.messages({ sessionID: id })
|
|
677
684
|
const messages = result.data ?? []
|
|
678
685
|
const text = messages
|
|
679
|
-
.flatMap((message) =>
|
|
686
|
+
.flatMap((message: any) =>
|
|
680
687
|
message.parts
|
|
681
|
-
.filter((part) => part.type === "text")
|
|
682
|
-
.map((part) => (part as { text: string }).text),
|
|
688
|
+
.filter((part: any) => part.type === "text")
|
|
689
|
+
.map((part: any) => (part as { text: string }).text),
|
|
683
690
|
)
|
|
684
691
|
.join(" ")
|
|
685
692
|
.toLowerCase()
|
|
@@ -769,10 +776,10 @@ async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promis
|
|
|
769
776
|
const result = await api.client.session.messages({ sessionID })
|
|
770
777
|
const messages = result.data ?? []
|
|
771
778
|
const blocks = messages
|
|
772
|
-
.map((message) => {
|
|
779
|
+
.map((message: any) => {
|
|
773
780
|
const text = message.parts
|
|
774
|
-
.filter((part) => part.type === "text")
|
|
775
|
-
.map((part) => (part as { text: string }).text)
|
|
781
|
+
.filter((part: any) => part.type === "text")
|
|
782
|
+
.map((part: any) => (part as { text: string }).text)
|
|
776
783
|
.join("\n")
|
|
777
784
|
.trim()
|
|
778
785
|
if (!text) return ""
|
|
@@ -815,7 +822,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
815
822
|
})
|
|
816
823
|
api.ui.dialog.replace(
|
|
817
824
|
() => (
|
|
818
|
-
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
|
825
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
|
|
819
826
|
<box flexDirection="row" justifyContent="space-between">
|
|
820
827
|
<text attributes={TextAttributes.BOLD}>{entry.title}</text>
|
|
821
828
|
<text style={{ fg: api.theme.current.textMuted }} onMouseUp={close}>esc</text>
|
|
@@ -908,14 +915,6 @@ function pickerLastStart(rows: PickerRow[], height: number, excerpt: (entry: Ent
|
|
|
908
915
|
return start
|
|
909
916
|
}
|
|
910
917
|
|
|
911
|
-
type PortalContainer = {
|
|
912
|
-
position?: string
|
|
913
|
-
left?: number
|
|
914
|
-
top?: number
|
|
915
|
-
zIndex?: number
|
|
916
|
-
destroyRecursively?: () => void
|
|
917
|
-
}
|
|
918
|
-
|
|
919
918
|
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
|
|
920
919
|
|
|
921
920
|
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
@@ -926,6 +925,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
926
925
|
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
|
|
927
926
|
const [hovered, setHovered] = createSignal<string>()
|
|
928
927
|
const preview = createTranscriptPreview(props.api)
|
|
928
|
+
const [menuState, setMenuState] = createSignal<MenuState>()
|
|
929
|
+
let portalContainer: PortalContainer | undefined
|
|
929
930
|
const [query, setQuery] = createSignal("")
|
|
930
931
|
const [deleting, setDeleting] = createSignal<string>()
|
|
931
932
|
const [searching, setSearching] = createSignal(false)
|
|
@@ -934,8 +935,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
934
935
|
const [cursor, setCursor] = createSignal(0)
|
|
935
936
|
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
936
937
|
const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
|
|
937
|
-
const [menuState, setMenuState] = createSignal<MenuState>()
|
|
938
|
-
let portalContainer: PortalContainer | undefined
|
|
939
938
|
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
940
939
|
const currentID = createMemo(() => {
|
|
941
940
|
const route = props.api.route.current
|
|
@@ -1041,78 +1040,23 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1041
1040
|
}
|
|
1042
1041
|
|
|
1043
1042
|
const toggleSection = () => {
|
|
1044
|
-
menu.close()
|
|
1045
1043
|
if (!sectionCollapsed()) {
|
|
1046
1044
|
cancelDelete()
|
|
1047
1045
|
setNavActive(false)
|
|
1048
1046
|
setSearching(false)
|
|
1049
1047
|
setHovered(undefined)
|
|
1048
|
+
menu.close()
|
|
1050
1049
|
}
|
|
1051
1050
|
setSectionCollapsed((value) => !value)
|
|
1052
1051
|
}
|
|
1053
1052
|
|
|
1054
1053
|
const openSession = (entry: Entry) => props.api.route.navigate("session", { sessionID: entry.id })
|
|
1055
1054
|
|
|
1056
|
-
const forkEntry = async (entry: Entry) => {
|
|
1057
|
-
try {
|
|
1058
|
-
const result = await props.api.client.session.fork({
|
|
1059
|
-
sessionID: entry.id,
|
|
1060
|
-
directory: entry.dir || undefined,
|
|
1061
|
-
})
|
|
1062
|
-
const created = (result?.data ?? result) as Session | undefined
|
|
1063
|
-
if (!created?.id) throw new Error("fork returned no session")
|
|
1064
|
-
props.api.route.navigate("session", { sessionID: created.id })
|
|
1065
|
-
props.api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1066
|
-
} catch {
|
|
1067
|
-
props.api.ui.toast({ message: "Could not fork session", variant: "error" })
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const menu = createContextMenu({
|
|
1072
|
-
registerLayer: (layer) => props.api.keymap.registerLayer(layer),
|
|
1073
|
-
actions: {
|
|
1074
|
-
open: (entry) => void openSession(entry),
|
|
1075
|
-
preview: (entry) => preview.open(entry),
|
|
1076
|
-
workspace: (entry) => openInCmuxWorkspace(props.api, entry),
|
|
1077
|
-
fork: (entry) => void forkEntry(entry),
|
|
1078
|
-
pin: (entry) => props.onTogglePin("sessions", entry.id),
|
|
1079
|
-
delete: (entry) => void deleteEntry(entry),
|
|
1080
|
-
},
|
|
1081
|
-
pinned: (entry) => props.pins().sessions.includes(entry.id),
|
|
1082
|
-
cmux: () => Boolean(process.env.CMUX_WORKSPACE_ID),
|
|
1083
|
-
blocked: () => props.api.ui.dialog.open,
|
|
1084
|
-
onChange: setMenuState,
|
|
1085
|
-
})
|
|
1086
|
-
|
|
1087
|
-
const menuBox = createMemo(() => {
|
|
1088
|
-
const state = menuState()
|
|
1089
|
-
if (!state) return undefined
|
|
1090
|
-
return contextMenuBox(
|
|
1091
|
-
{ x: state.x, y: state.y, ...contextMenuSize(state.items) },
|
|
1092
|
-
props.api.renderer.width,
|
|
1093
|
-
props.api.renderer.height,
|
|
1094
|
-
)
|
|
1095
|
-
})
|
|
1096
|
-
|
|
1097
1055
|
// Flat order of the rows a cursor can land on, so keyboard navigation and
|
|
1098
1056
|
// mouse hover resolve to the same row.
|
|
1099
1057
|
const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
|
|
1100
1058
|
const isActive = (id: string) => (navActive() ? itemRows()[cursor()]?.id === id : hovered() === id)
|
|
1101
1059
|
const isPendingDelete = (id: string) => pendingDelete() === id
|
|
1102
|
-
const moveCursor = (delta: number) => {
|
|
1103
|
-
const count = itemRows().length
|
|
1104
|
-
if (count === 0) return
|
|
1105
|
-
setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
|
|
1106
|
-
}
|
|
1107
|
-
let previousRows: Entry[] = []
|
|
1108
|
-
createEffect(() => {
|
|
1109
|
-
const rows = itemRows()
|
|
1110
|
-
const selected = previousRows[cursor()]?.id
|
|
1111
|
-
const position = rows.findIndex((entry) => entry.id === selected)
|
|
1112
|
-
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
1113
|
-
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1114
|
-
previousRows = rows
|
|
1115
|
-
})
|
|
1116
1060
|
|
|
1117
1061
|
const routeKey = createMemo(() => {
|
|
1118
1062
|
const route = props.api.route.current
|
|
@@ -1128,13 +1072,28 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1128
1072
|
if (props.api.ui.dialog.open) menu.close()
|
|
1129
1073
|
})
|
|
1130
1074
|
|
|
1075
|
+
const moveCursor = (delta: number) => {
|
|
1076
|
+
const count = itemRows().length
|
|
1077
|
+
if (count === 0) return
|
|
1078
|
+
setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
|
|
1079
|
+
}
|
|
1080
|
+
let previousRows: Entry[] = []
|
|
1081
|
+
createEffect(() => {
|
|
1082
|
+
const rows = itemRows()
|
|
1083
|
+
const selected = previousRows[cursor()]?.id
|
|
1084
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
1085
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
1086
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1087
|
+
previousRows = rows
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1131
1090
|
// Marker only: a spinner for a working agent, the open-session dot, and the
|
|
1132
1091
|
// neutral row for idle sessions. Titles stay plain and no status text is
|
|
1133
1092
|
// rendered.
|
|
1134
1093
|
const markerOf = (id: string): SidebarMarker => {
|
|
1135
1094
|
let live: string | undefined
|
|
1136
1095
|
try {
|
|
1137
|
-
live = props.api.state.session.status(id)
|
|
1096
|
+
live = props.api.state.session.status(id)
|
|
1138
1097
|
} catch {}
|
|
1139
1098
|
return sidebarMarker(id === currentID(), live)
|
|
1140
1099
|
}
|
|
@@ -1190,6 +1149,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1190
1149
|
return true
|
|
1191
1150
|
}
|
|
1192
1151
|
|
|
1152
|
+
let sidebarScroll: { scrollTop: number } | undefined
|
|
1153
|
+
|
|
1193
1154
|
const deleteEntry = async (entry: Entry) => {
|
|
1194
1155
|
cancelDelete()
|
|
1195
1156
|
if (deleting()) return
|
|
@@ -1206,8 +1167,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1206
1167
|
props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
1207
1168
|
return
|
|
1208
1169
|
}
|
|
1170
|
+
const scrollTop = sidebarScroll?.scrollTop ?? 0
|
|
1209
1171
|
setEntries(remaining)
|
|
1210
1172
|
setHovered(undefined)
|
|
1173
|
+
const restore = () => {
|
|
1174
|
+
if (sidebarScroll) sidebarScroll.scrollTop = scrollTop
|
|
1175
|
+
}
|
|
1176
|
+
queueMicrotask(restore)
|
|
1177
|
+
setTimeout(restore, 0)
|
|
1211
1178
|
props.api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1212
1179
|
if (currentID() === entry.id) props.api.route.navigate("home")
|
|
1213
1180
|
} catch {
|
|
@@ -1217,6 +1184,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1217
1184
|
}
|
|
1218
1185
|
}
|
|
1219
1186
|
|
|
1187
|
+
const forkEntry = async (entry: Entry) => {
|
|
1188
|
+
try {
|
|
1189
|
+
const result = await props.api.client.session.fork({
|
|
1190
|
+
sessionID: entry.id,
|
|
1191
|
+
directory: entry.dir || undefined,
|
|
1192
|
+
})
|
|
1193
|
+
const created = (result?.data ?? result) as Session | undefined
|
|
1194
|
+
if (!created?.id) throw new Error("fork returned no session")
|
|
1195
|
+
props.api.route.navigate("session", { sessionID: created.id })
|
|
1196
|
+
props.api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1197
|
+
} catch {
|
|
1198
|
+
props.api.ui.toast({ message: "Could not fork session", variant: "error" })
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const menu = createContextMenu({
|
|
1203
|
+
registerLayer: (layer) => props.api.keymap.registerLayer(layer),
|
|
1204
|
+
actions: {
|
|
1205
|
+
open: (entry) => void openSession(entry),
|
|
1206
|
+
preview: (entry) => preview.open(entry),
|
|
1207
|
+
workspace: (entry) => openInCmuxWorkspace(props.api, entry),
|
|
1208
|
+
fork: (entry) => void forkEntry(entry),
|
|
1209
|
+
pin: (entry) => props.onTogglePin("sessions", entry.id),
|
|
1210
|
+
delete: (entry) => void deleteEntry(entry),
|
|
1211
|
+
},
|
|
1212
|
+
pinned: (entry) => props.pins().sessions.includes(entry.id),
|
|
1213
|
+
cmux: () => Boolean(process.env.CMUX_WORKSPACE_ID),
|
|
1214
|
+
blocked: () => props.api.ui.dialog.open,
|
|
1215
|
+
onChange: setMenuState,
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
const menuBox = createMemo(() => {
|
|
1219
|
+
const state = menuState()
|
|
1220
|
+
if (!state) return undefined
|
|
1221
|
+
return contextMenuBox(
|
|
1222
|
+
{ x: state.x, y: state.y, ...contextMenuSize(state.items) },
|
|
1223
|
+
props.api.renderer.width,
|
|
1224
|
+
props.api.renderer.height,
|
|
1225
|
+
)
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1220
1228
|
let disposeHoverSpace: (() => void) | undefined
|
|
1221
1229
|
let disposeSearch: (() => void) | undefined
|
|
1222
1230
|
let disposeNav: (() => void) | undefined
|
|
@@ -1240,7 +1248,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1240
1248
|
},
|
|
1241
1249
|
},
|
|
1242
1250
|
{
|
|
1243
|
-
key: "
|
|
1251
|
+
key: "alt+d",
|
|
1244
1252
|
desc: "Confirm delete",
|
|
1245
1253
|
preventDefault: true,
|
|
1246
1254
|
cmd: () => {
|
|
@@ -1311,13 +1319,13 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1311
1319
|
priority: 20,
|
|
1312
1320
|
bindings: [
|
|
1313
1321
|
{
|
|
1314
|
-
key: "
|
|
1322
|
+
key: "alt+p",
|
|
1315
1323
|
desc: "Preview session transcript",
|
|
1316
1324
|
preventDefault: true,
|
|
1317
1325
|
cmd: () => openSidebarPreview(entry),
|
|
1318
1326
|
},
|
|
1319
1327
|
{
|
|
1320
|
-
key: "
|
|
1328
|
+
key: "alt+d",
|
|
1321
1329
|
desc: "Delete session",
|
|
1322
1330
|
preventDefault: true,
|
|
1323
1331
|
cmd: () => {
|
|
@@ -1355,7 +1363,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1355
1363
|
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
1356
1364
|
},
|
|
1357
1365
|
{
|
|
1358
|
-
key: "
|
|
1366
|
+
key: "alt+p",
|
|
1359
1367
|
desc: "Preview session transcript",
|
|
1360
1368
|
preventDefault: true,
|
|
1361
1369
|
cmd: () => {
|
|
@@ -1364,7 +1372,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1364
1372
|
},
|
|
1365
1373
|
},
|
|
1366
1374
|
{
|
|
1367
|
-
key: "
|
|
1375
|
+
key: "alt+d",
|
|
1368
1376
|
desc: "Delete session",
|
|
1369
1377
|
preventDefault: true,
|
|
1370
1378
|
cmd: () => {
|
|
@@ -1495,6 +1503,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1495
1503
|
</Show>
|
|
1496
1504
|
</box>
|
|
1497
1505
|
<scrollbox
|
|
1506
|
+
ref={(node: { scrollTop: number }) => (sidebarScroll = node)}
|
|
1498
1507
|
flexGrow={1}
|
|
1499
1508
|
flexShrink={1}
|
|
1500
1509
|
verticalScrollbarOptions={{ visible: false }}
|
|
@@ -1567,7 +1576,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1567
1576
|
matchColor={theme().warning}
|
|
1568
1577
|
/>
|
|
1569
1578
|
<text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
|
|
1570
|
-
{isPendingDelete(row.entry.id) ? "
|
|
1579
|
+
{isPendingDelete(row.entry.id) ? "option+d again" : ago(row.entry.updated)}
|
|
1571
1580
|
</text>
|
|
1572
1581
|
</box>
|
|
1573
1582
|
)
|
|
@@ -1582,7 +1591,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1582
1591
|
</Show>
|
|
1583
1592
|
<Show when={navActive()}>
|
|
1584
1593
|
<box paddingTop={1}>
|
|
1585
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin ·
|
|
1594
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · option+d delete · esc done</text>
|
|
1586
1595
|
</box>
|
|
1587
1596
|
</Show>
|
|
1588
1597
|
</Show>
|
|
@@ -1734,7 +1743,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1734
1743
|
priority: 25,
|
|
1735
1744
|
bindings: [
|
|
1736
1745
|
{ key: "y", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1737
|
-
{ key: "
|
|
1746
|
+
{ key: "alt+d", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1738
1747
|
{ key: "enter", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1739
1748
|
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1740
1749
|
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
@@ -1784,13 +1793,13 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1784
1793
|
priority: 20,
|
|
1785
1794
|
bindings: [
|
|
1786
1795
|
{
|
|
1787
|
-
key: "
|
|
1796
|
+
key: "alt+p",
|
|
1788
1797
|
desc: "Preview session transcript",
|
|
1789
1798
|
preventDefault: true,
|
|
1790
1799
|
cmd: () => preview.open(entry),
|
|
1791
1800
|
},
|
|
1792
1801
|
{
|
|
1793
|
-
key: "
|
|
1802
|
+
key: "alt+d",
|
|
1794
1803
|
desc: "Delete session",
|
|
1795
1804
|
preventDefault: true,
|
|
1796
1805
|
cmd: () => {
|
|
@@ -1843,88 +1852,90 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1843
1852
|
})
|
|
1844
1853
|
|
|
1845
1854
|
return (
|
|
1846
|
-
<box
|
|
1847
|
-
<
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
<
|
|
1852
|
-
|
|
1853
|
-
{
|
|
1854
|
-
|
|
1855
|
-
</box>
|
|
1856
|
-
}
|
|
1857
|
-
>
|
|
1858
|
-
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
1859
|
-
<box flexDirection="row" gap={1}>
|
|
1860
|
-
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1861
|
-
<text style={{ fg: theme().textMuted }}>· option+o for all</text>
|
|
1862
|
-
</box>
|
|
1863
|
-
<box
|
|
1864
|
-
flexDirection="row"
|
|
1865
|
-
gap={1}
|
|
1866
|
-
paddingLeft={1}
|
|
1867
|
-
paddingRight={1}
|
|
1868
|
-
backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1869
|
-
onMouseDown={() => {
|
|
1870
|
-
insideSearchBox = true
|
|
1871
|
-
setSearching(true)
|
|
1872
|
-
}}
|
|
1873
|
-
>
|
|
1874
|
-
<box width={24}>
|
|
1875
|
-
<text
|
|
1876
|
-
flexGrow={1}
|
|
1877
|
-
flexShrink={1}
|
|
1878
|
-
overflow="hidden"
|
|
1879
|
-
wrapMode="none"
|
|
1880
|
-
style={{ fg: query() ? theme().text : theme().textMuted }}
|
|
1881
|
-
>
|
|
1882
|
-
{query() || "search…"}
|
|
1883
|
-
{searching() ? "▏" : ""}
|
|
1855
|
+
<box width="100%" alignItems="center">
|
|
1856
|
+
<box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
|
|
1857
|
+
<Show
|
|
1858
|
+
when={entries().length > 0}
|
|
1859
|
+
fallback={
|
|
1860
|
+
<box flexDirection="row" gap={1}>
|
|
1861
|
+
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1862
|
+
<text style={{ fg: theme().textMuted }}>
|
|
1863
|
+
{loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
|
|
1884
1864
|
</text>
|
|
1885
1865
|
</box>
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
<For each={visible()}>
|
|
1894
|
-
{(entry) => (
|
|
1866
|
+
}
|
|
1867
|
+
>
|
|
1868
|
+
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
1869
|
+
<box flexDirection="row" gap={1}>
|
|
1870
|
+
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1871
|
+
<text style={{ fg: theme().textMuted }}>· option+o for all</text>
|
|
1872
|
+
</box>
|
|
1895
1873
|
<box
|
|
1896
1874
|
flexDirection="row"
|
|
1897
1875
|
gap={1}
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
onMouseDown={(
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
}
|
|
1876
|
+
paddingLeft={1}
|
|
1877
|
+
paddingRight={1}
|
|
1878
|
+
backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1879
|
+
onMouseDown={() => {
|
|
1880
|
+
insideSearchBox = true
|
|
1881
|
+
setSearching(true)
|
|
1882
|
+
}}
|
|
1906
1883
|
>
|
|
1907
|
-
<
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1884
|
+
<box width={24}>
|
|
1885
|
+
<text
|
|
1886
|
+
flexGrow={1}
|
|
1887
|
+
flexShrink={1}
|
|
1888
|
+
overflow="hidden"
|
|
1889
|
+
wrapMode="none"
|
|
1890
|
+
style={{ fg: query() ? theme().text : theme().textMuted }}
|
|
1891
|
+
>
|
|
1892
|
+
{query() || "search…"}
|
|
1893
|
+
{searching() ? "▏" : ""}
|
|
1894
|
+
</text>
|
|
1895
|
+
</box>
|
|
1896
|
+
<Show when={query()}>
|
|
1897
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
|
|
1898
|
+
✕
|
|
1899
|
+
</text>
|
|
1900
|
+
</Show>
|
|
1921
1901
|
</box>
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1902
|
+
</box>
|
|
1903
|
+
<For each={visible()}>
|
|
1904
|
+
{(entry) => (
|
|
1905
|
+
<box
|
|
1906
|
+
flexDirection="row"
|
|
1907
|
+
gap={1}
|
|
1908
|
+
backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1909
|
+
onMouseOver={() => setHovered(entry.id)}
|
|
1910
|
+
onMouseOut={() => setHovered(undefined)}
|
|
1911
|
+
onMouseDown={(event: { button?: number }) =>
|
|
1912
|
+
event.button === RIGHT_BUTTON
|
|
1913
|
+
? openInCmuxWorkspace(props.api, entry)
|
|
1914
|
+
: props.api.route.navigate("session", { sessionID: entry.id })
|
|
1915
|
+
}
|
|
1916
|
+
>
|
|
1917
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1918
|
+
○
|
|
1919
|
+
</text>
|
|
1920
|
+
<Highlighted
|
|
1921
|
+
text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
|
|
1922
|
+
query={query()}
|
|
1923
|
+
color={theme().text}
|
|
1924
|
+
matchColor={theme().warning}
|
|
1925
|
+
/>
|
|
1926
|
+
<text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
|
|
1927
|
+
{pendingDelete() === entry.id
|
|
1928
|
+
? "option+d again"
|
|
1929
|
+
: `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
|
|
1930
|
+
</text>
|
|
1931
|
+
</box>
|
|
1932
|
+
)}
|
|
1933
|
+
</For>
|
|
1934
|
+
<Show when={visible().length === 0}>
|
|
1935
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
|
|
1936
|
+
</Show>
|
|
1926
1937
|
</Show>
|
|
1927
|
-
</
|
|
1938
|
+
</box>
|
|
1928
1939
|
</box>
|
|
1929
1940
|
)
|
|
1930
1941
|
}
|
|
@@ -2102,7 +2113,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2102
2113
|
// lets it size to content, so a taller picker drifts downward. Pin the
|
|
2103
2114
|
// content height and offset it up by the same amount to keep it centred.
|
|
2104
2115
|
const dialogWidth = Math.min(116, api.renderer.width - 2)
|
|
2105
|
-
const CHROME_ROWS =
|
|
2116
|
+
const CHROME_ROWS = 13
|
|
2106
2117
|
const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
|
|
2107
2118
|
const dialogHeight = () => listHeight() + CHROME_ROWS
|
|
2108
2119
|
const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1))
|
|
@@ -2113,6 +2124,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2113
2124
|
))
|
|
2114
2125
|
|
|
2115
2126
|
let previousSelection: Entry[] = []
|
|
2127
|
+
let deletedAt: number | undefined
|
|
2116
2128
|
createEffect(() => {
|
|
2117
2129
|
const rows = selectableEntries()
|
|
2118
2130
|
const position = rows.findIndex((entry) => entry.id === previousSelection[cursor()]?.id)
|
|
@@ -2120,12 +2132,17 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2120
2132
|
rows.some((entry, index) => entry.id !== previousSelection[index]?.id))) {
|
|
2121
2133
|
// A search/filter/collapse changed the tree: reveal the retained row or
|
|
2122
2134
|
// start at the first result rather than leaving a stale window open.
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2135
|
+
if (deletedAt !== undefined) {
|
|
2136
|
+
setCursor(Math.max(0, Math.min(deletedAt, rows.length - 1)))
|
|
2137
|
+
deletedAt = undefined
|
|
2138
|
+
} else {
|
|
2139
|
+
const next = position >= 0 ? position : 0
|
|
2140
|
+
setCursor(next)
|
|
2141
|
+
const id = rows[next]?.id
|
|
2142
|
+
setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(
|
|
2143
|
+
(row) => row.kind === "item" && row.entry.id === id,
|
|
2144
|
+
)))
|
|
2145
|
+
}
|
|
2129
2146
|
} else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
2130
2147
|
previousSelection = rows
|
|
2131
2148
|
})
|
|
@@ -2258,6 +2275,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2258
2275
|
api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
2259
2276
|
return
|
|
2260
2277
|
}
|
|
2278
|
+
deletedAt = selectableEntries().findIndex((item) => item.id === entry.id)
|
|
2279
|
+
if (deletedAt < 0) deletedAt = undefined
|
|
2261
2280
|
setAllEntries(remaining)
|
|
2262
2281
|
api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
2263
2282
|
if (currentSessionID() === entry.id) api.route.navigate("home")
|
|
@@ -2283,7 +2302,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2283
2302
|
},
|
|
2284
2303
|
},
|
|
2285
2304
|
{
|
|
2286
|
-
key: "
|
|
2305
|
+
key: "alt+d",
|
|
2287
2306
|
desc: "Confirm delete",
|
|
2288
2307
|
preventDefault: true,
|
|
2289
2308
|
cmd: () => {
|
|
@@ -2332,6 +2351,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2332
2351
|
const disposeNav = api.keymap.registerLayer({
|
|
2333
2352
|
// Outrank the built-in dialog layer so Escape reaches this picker before
|
|
2334
2353
|
// the dialog host closes the whole thing.
|
|
2354
|
+
mode: "global",
|
|
2335
2355
|
priority: 1,
|
|
2336
2356
|
bindings: [
|
|
2337
2357
|
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
@@ -2354,7 +2374,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2354
2374
|
choose()
|
|
2355
2375
|
},
|
|
2356
2376
|
},
|
|
2357
|
-
{ key: "
|
|
2377
|
+
{ key: "alt+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
2358
2378
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
2359
2379
|
const entry = selectableEntries()[cursor()]
|
|
2360
2380
|
if (entry) onTogglePin("sessions", entry.id)
|
|
@@ -2364,7 +2384,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2364
2384
|
if (entry) onTogglePin("directories", entry.dir)
|
|
2365
2385
|
} },
|
|
2366
2386
|
{
|
|
2367
|
-
key: "
|
|
2387
|
+
key: "alt+d",
|
|
2368
2388
|
desc: "Delete session",
|
|
2369
2389
|
preventDefault: true,
|
|
2370
2390
|
cmd: () => {
|
|
@@ -2392,8 +2412,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2392
2412
|
else if (target) setScope(target.group)
|
|
2393
2413
|
},
|
|
2394
2414
|
},
|
|
2395
|
-
{ key: "
|
|
2396
|
-
{ key: "
|
|
2415
|
+
{ key: "alt+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
|
|
2416
|
+
{ key: "alt+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
|
|
2397
2417
|
{
|
|
2398
2418
|
key: "escape",
|
|
2399
2419
|
desc: "Close",
|
|
@@ -2419,6 +2439,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2419
2439
|
flexDirection="column"
|
|
2420
2440
|
height={dialogHeight()}
|
|
2421
2441
|
marginTop={dialogOffset()}
|
|
2442
|
+
paddingTop={1}
|
|
2422
2443
|
paddingBottom={1}
|
|
2423
2444
|
gap={1}
|
|
2424
2445
|
backgroundColor={api.theme.current.backgroundPanel}
|
|
@@ -2455,8 +2476,21 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2455
2476
|
handleEscape()
|
|
2456
2477
|
return
|
|
2457
2478
|
}
|
|
2479
|
+
if (event.ctrl && event.name === "x") {
|
|
2480
|
+
event.preventDefault()
|
|
2481
|
+
if (confirmArmed()) return
|
|
2482
|
+
const target = selectableEntries()[cursor()]
|
|
2483
|
+
if (target) armDelete(target)
|
|
2484
|
+
return
|
|
2485
|
+
}
|
|
2486
|
+
if (event.ctrl && event.name === "f") {
|
|
2487
|
+
event.preventDefault()
|
|
2488
|
+
const target = selectableEntries()[cursor()]
|
|
2489
|
+
if (target) void forkEntry(target)
|
|
2490
|
+
return
|
|
2491
|
+
}
|
|
2458
2492
|
if (pendingDelete()) {
|
|
2459
|
-
if (event.name === "y" ||
|
|
2493
|
+
if (event.name === "y" || event.name === "enter" || event.name === "return") {
|
|
2460
2494
|
event.preventDefault()
|
|
2461
2495
|
confirmArmed()
|
|
2462
2496
|
return
|
|
@@ -2518,7 +2552,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2518
2552
|
paddingRight={4}
|
|
2519
2553
|
backgroundColor={
|
|
2520
2554
|
row.entry.id === selectableEntries()[cursor()]?.id
|
|
2521
|
-
? api.theme.current.
|
|
2555
|
+
? api.theme.current.backgroundElement
|
|
2522
2556
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
2523
2557
|
}
|
|
2524
2558
|
onMouseMove={() => {
|
|
@@ -2533,7 +2567,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2533
2567
|
>
|
|
2534
2568
|
<box flexDirection="row" gap={1}>
|
|
2535
2569
|
<text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
|
|
2536
|
-
? api.theme.current.
|
|
2570
|
+
? api.theme.current.text : api.theme.current.textMuted }}>
|
|
2537
2571
|
{row.last ? "└" : "├"}
|
|
2538
2572
|
</text>
|
|
2539
2573
|
<Show when={row.entry.id === currentSessionID()}>
|
|
@@ -2542,7 +2576,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2542
2576
|
style={{
|
|
2543
2577
|
fg:
|
|
2544
2578
|
row.entry.id === selectableEntries()[cursor()]?.id
|
|
2545
|
-
? api.theme.current.
|
|
2579
|
+
? api.theme.current.text
|
|
2546
2580
|
: api.theme.current.primary,
|
|
2547
2581
|
}}
|
|
2548
2582
|
>
|
|
@@ -2555,12 +2589,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2555
2589
|
bold={row.entry.id === selectableEntries()[cursor()]?.id}
|
|
2556
2590
|
color={
|
|
2557
2591
|
row.entry.id === selectableEntries()[cursor()]?.id
|
|
2558
|
-
? api.theme.current.
|
|
2592
|
+
? api.theme.current.text
|
|
2559
2593
|
: api.theme.current.text
|
|
2560
2594
|
}
|
|
2561
2595
|
matchColor={
|
|
2562
2596
|
row.entry.id === selectableEntries()[cursor()]?.id
|
|
2563
|
-
? api.theme.current.
|
|
2597
|
+
? api.theme.current.text
|
|
2564
2598
|
: api.theme.current.warning
|
|
2565
2599
|
}
|
|
2566
2600
|
/>
|
|
@@ -2571,7 +2605,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2571
2605
|
pendingDelete()?.id === row.entry.id
|
|
2572
2606
|
? api.theme.current.error
|
|
2573
2607
|
: row.entry.id === selectableEntries()[cursor()]?.id
|
|
2574
|
-
? api.theme.current.
|
|
2608
|
+
? api.theme.current.text
|
|
2575
2609
|
: api.theme.current.textMuted,
|
|
2576
2610
|
}}
|
|
2577
2611
|
>
|
|
@@ -2586,13 +2620,13 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2586
2620
|
</text>
|
|
2587
2621
|
</box>
|
|
2588
2622
|
<Show when={excerpt()}>
|
|
2589
|
-
{(match) => (
|
|
2623
|
+
{(match: Accessor<string>) => (
|
|
2590
2624
|
<box flexDirection="row" gap={1} paddingLeft={2}>
|
|
2591
2625
|
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>↳</text>
|
|
2592
2626
|
<Highlighted
|
|
2593
2627
|
text={match()}
|
|
2594
2628
|
query={query()}
|
|
2595
|
-
color={row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.
|
|
2629
|
+
color={row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.text : api.theme.current.textMuted}
|
|
2596
2630
|
matchColor={api.theme.current.warning}
|
|
2597
2631
|
/>
|
|
2598
2632
|
</box>
|
|
@@ -2633,7 +2667,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2633
2667
|
: "↑↓ move · enter open · ctrl+g project · option+w input · option+s pinned · option+p preview"}
|
|
2634
2668
|
</text>
|
|
2635
2669
|
<Show when={!pendingDelete()}>
|
|
2636
|
-
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin ·
|
|
2670
|
+
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · option+d delete · ctrl+f fork · esc close</text>
|
|
2637
2671
|
</Show>
|
|
2638
2672
|
</box>
|
|
2639
2673
|
<Show when={showPreview()}>
|
|
@@ -2644,7 +2678,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2644
2678
|
width={dialogWidth}
|
|
2645
2679
|
height={dialogHeight()}
|
|
2646
2680
|
flexDirection="column"
|
|
2647
|
-
backgroundColor={api.theme.current.
|
|
2681
|
+
backgroundColor={api.theme.current.backgroundPanel}
|
|
2648
2682
|
paddingLeft={4}
|
|
2649
2683
|
paddingRight={4}
|
|
2650
2684
|
paddingTop={1}
|
|
@@ -2665,7 +2699,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2665
2699
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
2666
2700
|
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
2667
2701
|
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
2668
|
-
{(text) =>
|
|
2702
|
+
{(text: Accessor<string>) =>
|
|
2669
2703
|
getMarkdownStyle(api.theme.current) ? (
|
|
2670
2704
|
<markdown
|
|
2671
2705
|
content={text()}
|
|
@@ -2710,9 +2744,9 @@ SELECT s.directory AS directory,
|
|
|
2710
2744
|
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
2711
2745
|
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
2712
2746
|
COUNT(DISTINCT s.id) AS sessions
|
|
2713
|
-
FROM
|
|
2714
|
-
JOIN
|
|
2715
|
-
WHERE
|
|
2747
|
+
FROM session_message m
|
|
2748
|
+
JOIN session_v2 s ON s.id = m.session_id
|
|
2749
|
+
WHERE m.type = 'assistant'
|
|
2716
2750
|
AND json_extract(m.data, '$.cost') IS NOT NULL
|
|
2717
2751
|
GROUP BY s.directory
|
|
2718
2752
|
ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
@@ -2759,7 +2793,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2759
2793
|
})
|
|
2760
2794
|
api.ui.dialog.replace(
|
|
2761
2795
|
() => (
|
|
2762
|
-
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2796
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
|
|
2763
2797
|
<text attributes={TextAttributes.BOLD}>
|
|
2764
2798
|
Cost digest{" "}
|
|
2765
2799
|
<span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
|
|
@@ -2872,7 +2906,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2872
2906
|
const home = process.env.HOME ?? ""
|
|
2873
2907
|
api.ui.dialog.replace(
|
|
2874
2908
|
() => (
|
|
2875
|
-
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2909
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
|
|
2876
2910
|
<text attributes={TextAttributes.BOLD}>
|
|
2877
2911
|
Needs input{" "}
|
|
2878
2912
|
<span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
|
|
@@ -2887,7 +2921,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2887
2921
|
paddingRight={1}
|
|
2888
2922
|
backgroundColor={
|
|
2889
2923
|
index() === cursor()
|
|
2890
|
-
? api.theme.current.
|
|
2924
|
+
? api.theme.current.backgroundElement
|
|
2891
2925
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
2892
2926
|
}
|
|
2893
2927
|
onMouseDown={() => {
|
|
@@ -2902,7 +2936,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2902
2936
|
style={{
|
|
2903
2937
|
fg:
|
|
2904
2938
|
index() === cursor()
|
|
2905
|
-
? api.theme.current.
|
|
2939
|
+
? api.theme.current.text
|
|
2906
2940
|
: api.theme.current.text,
|
|
2907
2941
|
}}
|
|
2908
2942
|
>
|
|
@@ -2913,7 +2947,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2913
2947
|
style={{
|
|
2914
2948
|
fg:
|
|
2915
2949
|
index() === cursor()
|
|
2916
|
-
? api.theme.current.
|
|
2950
|
+
? api.theme.current.text
|
|
2917
2951
|
: api.theme.current.warning,
|
|
2918
2952
|
}}
|
|
2919
2953
|
>
|
|
@@ -2924,7 +2958,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2924
2958
|
style={{
|
|
2925
2959
|
fg:
|
|
2926
2960
|
index() === cursor()
|
|
2927
|
-
? api.theme.current.
|
|
2961
|
+
? api.theme.current.text
|
|
2928
2962
|
: api.theme.current.textMuted,
|
|
2929
2963
|
}}
|
|
2930
2964
|
>
|
|
@@ -2980,8 +3014,285 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2980
3014
|
])
|
|
2981
3015
|
|
|
2982
3016
|
api.keymap.registerLayer({
|
|
2983
|
-
bindings: [{ key: "
|
|
3017
|
+
bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
|
|
2984
3018
|
})
|
|
2985
3019
|
}
|
|
2986
3020
|
|
|
2987
|
-
|
|
3021
|
+
let hostOwner: ReturnType<typeof getOwner> = null
|
|
3022
|
+
|
|
3023
|
+
function adaptTheme(theme: Plugin.Context["theme"]) {
|
|
3024
|
+
const opaque = (color: RGBA) => RGBA.fromValues(color.r, color.g, color.b, 1)
|
|
3025
|
+
return {
|
|
3026
|
+
text: theme.text.base,
|
|
3027
|
+
textMuted: theme.text.muted,
|
|
3028
|
+
background: theme.background.base,
|
|
3029
|
+
backgroundPanel: opaque(theme.background.raised.base),
|
|
3030
|
+
backgroundElement: opaque(theme.background.raised.high),
|
|
3031
|
+
border: theme.border.base,
|
|
3032
|
+
borderActive: theme.hue.accent[200],
|
|
3033
|
+
borderSubtle: theme.border.base,
|
|
3034
|
+
accent: theme.hue.accent[200],
|
|
3035
|
+
primary: theme.hue.accent[200],
|
|
3036
|
+
secondary: theme.text.action.secondary.base,
|
|
3037
|
+
error: theme.text.feedback.error.base,
|
|
3038
|
+
warning: theme.text.feedback.warning.base,
|
|
3039
|
+
success: theme.text.feedback.success.base,
|
|
3040
|
+
info: theme.text.feedback.info.base,
|
|
3041
|
+
selectedListItemText: theme.text.base,
|
|
3042
|
+
syntaxComment: theme.syntax.comment,
|
|
3043
|
+
syntaxString: theme.syntax.string,
|
|
3044
|
+
syntaxNumber: theme.syntax.number,
|
|
3045
|
+
syntaxKeyword: theme.syntax.keyword,
|
|
3046
|
+
syntaxType: theme.syntax.type,
|
|
3047
|
+
syntaxFunction: theme.syntax.function,
|
|
3048
|
+
syntaxOperator: theme.syntax.operator,
|
|
3049
|
+
syntaxVariable: theme.syntax.variable,
|
|
3050
|
+
syntaxPunctuation: theme.syntax.punctuation,
|
|
3051
|
+
markdownLink: theme.markdown.link,
|
|
3052
|
+
markdownLinkText: theme.markdown.linkText,
|
|
3053
|
+
markdownBlockQuote: theme.markdown.blockQuote,
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function createApi(ctx: Plugin.Context): TuiPluginApi {
|
|
3058
|
+
const [dialogOpen, setDialogOpen] = createSignal(false)
|
|
3059
|
+
let sourceTheme: Plugin.Context["theme"] | undefined
|
|
3060
|
+
let currentTheme: ReturnType<typeof adaptTheme>
|
|
3061
|
+
return {
|
|
3062
|
+
theme: {
|
|
3063
|
+
get current() {
|
|
3064
|
+
if (sourceTheme !== ctx.theme) {
|
|
3065
|
+
sourceTheme = ctx.theme
|
|
3066
|
+
currentTheme = adaptTheme(sourceTheme)
|
|
3067
|
+
}
|
|
3068
|
+
return currentTheme!
|
|
3069
|
+
},
|
|
3070
|
+
},
|
|
3071
|
+
ui: {
|
|
3072
|
+
toast(opts: { message: string; variant?: "info" | "warning" | "error" | "success" }) {
|
|
3073
|
+
ctx.ui.toast.show({
|
|
3074
|
+
title: "Session",
|
|
3075
|
+
message: opts.message,
|
|
3076
|
+
variant: opts.variant ?? "info",
|
|
3077
|
+
})
|
|
3078
|
+
},
|
|
3079
|
+
dialog: {
|
|
3080
|
+
clear() {
|
|
3081
|
+
ctx.ui.dialog.clear()
|
|
3082
|
+
setDialogOpen(false)
|
|
3083
|
+
},
|
|
3084
|
+
replace(render: () => any) {
|
|
3085
|
+
setDialogOpen(true)
|
|
3086
|
+
ctx.ui.dialog.show(render, () => setDialogOpen(false))
|
|
3087
|
+
},
|
|
3088
|
+
setSize(size: string) {
|
|
3089
|
+
ctx.ui.dialog.set({ size: size as any })
|
|
3090
|
+
},
|
|
3091
|
+
// V2's Dialog has no reactive open/isOpen accessor; track it locally
|
|
3092
|
+
// from the replace/clear calls above and the host's onClose callback.
|
|
3093
|
+
get open() {
|
|
3094
|
+
return dialogOpen()
|
|
3095
|
+
},
|
|
3096
|
+
},
|
|
3097
|
+
},
|
|
3098
|
+
route: {
|
|
3099
|
+
get current() {
|
|
3100
|
+
const r = ctx.ui.router.current()
|
|
3101
|
+
if (r.type === "session") {
|
|
3102
|
+
return { name: "session", params: { sessionID: r.sessionID } }
|
|
3103
|
+
}
|
|
3104
|
+
return { name: r.type, params: {} }
|
|
3105
|
+
},
|
|
3106
|
+
navigate(name: string, params?: { sessionID?: string }) {
|
|
3107
|
+
if (name === "session" && params?.sessionID) {
|
|
3108
|
+
ctx.ui.router.navigate({ type: "session", sessionID: params.sessionID })
|
|
3109
|
+
} else if (name === "home") {
|
|
3110
|
+
ctx.ui.router.navigate({ type: "home" })
|
|
3111
|
+
}
|
|
3112
|
+
},
|
|
3113
|
+
},
|
|
3114
|
+
state: {
|
|
3115
|
+
path: {
|
|
3116
|
+
get directory() {
|
|
3117
|
+
return ctx.location?.directory ?? process.cwd()
|
|
3118
|
+
},
|
|
3119
|
+
},
|
|
3120
|
+
session: {
|
|
3121
|
+
status(id: string) {
|
|
3122
|
+
return ctx.data.session.status(id)
|
|
3123
|
+
},
|
|
3124
|
+
},
|
|
3125
|
+
},
|
|
3126
|
+
mode: {
|
|
3127
|
+
current() {
|
|
3128
|
+
return ctx.keymap.mode.current()
|
|
3129
|
+
},
|
|
3130
|
+
},
|
|
3131
|
+
renderer: {
|
|
3132
|
+
get height() {
|
|
3133
|
+
return ctx.renderer?.height ?? 40
|
|
3134
|
+
},
|
|
3135
|
+
get width() {
|
|
3136
|
+
return ctx.renderer?.width ?? 120
|
|
3137
|
+
},
|
|
3138
|
+
get root() {
|
|
3139
|
+
return ctx.renderer.root
|
|
3140
|
+
},
|
|
3141
|
+
get currentFocusedEditor() {
|
|
3142
|
+
return ctx.renderer.currentFocusedEditor
|
|
3143
|
+
},
|
|
3144
|
+
on(event: string, handler: (...args: any[]) => void) {
|
|
3145
|
+
ctx.renderer.on(event, handler)
|
|
3146
|
+
},
|
|
3147
|
+
off(event: string, handler: (...args: any[]) => void) {
|
|
3148
|
+
ctx.renderer.off(event, handler)
|
|
3149
|
+
},
|
|
3150
|
+
},
|
|
3151
|
+
keymap: {
|
|
3152
|
+
registerLayer(layer: {
|
|
3153
|
+
mode?: string
|
|
3154
|
+
priority?: number
|
|
3155
|
+
bindings?: Array<{ key: string; desc?: string; preventDefault?: boolean; cmd: () => void }>
|
|
3156
|
+
}) {
|
|
3157
|
+
return runWithOwner(hostOwner, () => createRoot((dispose) => {
|
|
3158
|
+
ctx.keymap.layer(() => ({
|
|
3159
|
+
mode: layer.mode ?? "base",
|
|
3160
|
+
priority: layer.priority ?? 20,
|
|
3161
|
+
commands: (layer.bindings ?? []).map((b, i) => ({
|
|
3162
|
+
id: `sesh-cmd-${Math.random().toString(36).slice(2)}-${i}`,
|
|
3163
|
+
title: b.desc,
|
|
3164
|
+
bind: b.key,
|
|
3165
|
+
run: () => {
|
|
3166
|
+
b.cmd()
|
|
3167
|
+
},
|
|
3168
|
+
})),
|
|
3169
|
+
}))
|
|
3170
|
+
return dispose
|
|
3171
|
+
}))
|
|
3172
|
+
},
|
|
3173
|
+
},
|
|
3174
|
+
slots: {
|
|
3175
|
+
register(cfg: { order?: number; slots: Record<string, () => any> }) {
|
|
3176
|
+
if (cfg.slots.sidebar_content) {
|
|
3177
|
+
ctx.ui.slot({
|
|
3178
|
+
prepend: "sidebar.content",
|
|
3179
|
+
render: cfg.slots.sidebar_content as any,
|
|
3180
|
+
})
|
|
3181
|
+
}
|
|
3182
|
+
if (cfg.slots.home_bottom) {
|
|
3183
|
+
ctx.ui.slot({
|
|
3184
|
+
prepend: "home.footer",
|
|
3185
|
+
render: cfg.slots.home_bottom as any,
|
|
3186
|
+
})
|
|
3187
|
+
}
|
|
3188
|
+
},
|
|
3189
|
+
},
|
|
3190
|
+
command: {
|
|
3191
|
+
register(factory: () => Array<{
|
|
3192
|
+
title: string
|
|
3193
|
+
value: string
|
|
3194
|
+
description?: string
|
|
3195
|
+
category?: string
|
|
3196
|
+
slash?: { name: string }
|
|
3197
|
+
onSelect: () => void
|
|
3198
|
+
}>) {
|
|
3199
|
+
ctx.keymap.layer(() => ({
|
|
3200
|
+
mode: "global",
|
|
3201
|
+
priority: 20,
|
|
3202
|
+
commands: factory().map((cmd) => ({
|
|
3203
|
+
id: cmd.value,
|
|
3204
|
+
title: cmd.title,
|
|
3205
|
+
description: cmd.description,
|
|
3206
|
+
group: cmd.category,
|
|
3207
|
+
palette: true,
|
|
3208
|
+
slash: cmd.slash ? { name: cmd.slash.name } : undefined,
|
|
3209
|
+
run: () => {
|
|
3210
|
+
cmd.onSelect()
|
|
3211
|
+
},
|
|
3212
|
+
})),
|
|
3213
|
+
}))
|
|
3214
|
+
},
|
|
3215
|
+
},
|
|
3216
|
+
client: {
|
|
3217
|
+
project: {
|
|
3218
|
+
async list() {
|
|
3219
|
+
const res: any = await ctx.client.project.list()
|
|
3220
|
+
return { data: Array.isArray(res) ? res : (res?.data ?? []) }
|
|
3221
|
+
},
|
|
3222
|
+
},
|
|
3223
|
+
session: {
|
|
3224
|
+
async delete(req: { sessionID: string; directory?: string }) {
|
|
3225
|
+
await ctx.client.session.remove({ sessionID: req.sessionID })
|
|
3226
|
+
return { data: undefined }
|
|
3227
|
+
},
|
|
3228
|
+
async fork(req: { path: { id: string } }) {
|
|
3229
|
+
const res: any = await ctx.client.session.fork({ sessionID: req.path.id })
|
|
3230
|
+
return { data: res?.data ?? res }
|
|
3231
|
+
},
|
|
3232
|
+
async messages(req: { path?: { id?: string }; sessionID?: string }) {
|
|
3233
|
+
const sessionID = req.sessionID ?? req.path?.id ?? ""
|
|
3234
|
+
const res = await ctx.client.message.list({ sessionID, order: "asc", limit: 100 })
|
|
3235
|
+
const messages = (res.data ?? []).map((m: any) => {
|
|
3236
|
+
let text = ""
|
|
3237
|
+
if (m.type === "user") text = m.text ?? ""
|
|
3238
|
+
else if (m.type === "assistant") {
|
|
3239
|
+
text = (m.content ?? [])
|
|
3240
|
+
.filter((c: any) => c.type === "text")
|
|
3241
|
+
.map((c: any) => c.text)
|
|
3242
|
+
.join("\n")
|
|
3243
|
+
}
|
|
3244
|
+
return {
|
|
3245
|
+
id: m.id,
|
|
3246
|
+
info: { role: m.type },
|
|
3247
|
+
parts: [{ type: "text", text }],
|
|
3248
|
+
}
|
|
3249
|
+
})
|
|
3250
|
+
return { data: messages }
|
|
3251
|
+
},
|
|
3252
|
+
},
|
|
3253
|
+
experimental: {
|
|
3254
|
+
session: {
|
|
3255
|
+
async list(input: any) {
|
|
3256
|
+
// V1 paged the global list with a numeric `time.updated` cursor and a
|
|
3257
|
+
// descending order. V2 returns an opaque cursor per location, so
|
|
3258
|
+
// fetch once and page in memory to keep the caller's loop intact.
|
|
3259
|
+
const limit = Number(input?.limit) > 0 ? Number(input.limit) : 200
|
|
3260
|
+
const cursor = typeof input?.cursor === "number" ? input.cursor : undefined
|
|
3261
|
+
const res: any = await ctx.client.session.list({ limit: 5000, order: "desc" })
|
|
3262
|
+
const all: any[] = Array.isArray(res) ? res : (res?.data ?? [])
|
|
3263
|
+
const normalized = all
|
|
3264
|
+
.map((s: any) => ({
|
|
3265
|
+
...s,
|
|
3266
|
+
directory: s.directory ?? s.location?.directory ?? "",
|
|
3267
|
+
}))
|
|
3268
|
+
.sort((a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
|
3269
|
+
const page =
|
|
3270
|
+
cursor === undefined
|
|
3271
|
+
? normalized.slice(0, limit)
|
|
3272
|
+
: normalized.filter((s: any) => (s.time?.updated ?? 0) < cursor).slice(0, limit)
|
|
3273
|
+
return { data: page }
|
|
3274
|
+
},
|
|
3275
|
+
},
|
|
3276
|
+
},
|
|
3277
|
+
},
|
|
3278
|
+
} as any
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
export default Plugin.define({
|
|
3282
|
+
id: "sesh-panel",
|
|
3283
|
+
async setup(ctx) {
|
|
3284
|
+
const api = createApi(ctx)
|
|
3285
|
+
let initialized = false
|
|
3286
|
+
return ctx.ui.slot({
|
|
3287
|
+
append: "app",
|
|
3288
|
+
render: () => {
|
|
3289
|
+
if (!initialized) {
|
|
3290
|
+
initialized = true
|
|
3291
|
+
hostOwner = getOwner()
|
|
3292
|
+
void tui(api)
|
|
3293
|
+
}
|
|
3294
|
+
return <box />
|
|
3295
|
+
},
|
|
3296
|
+
})
|
|
3297
|
+
},
|
|
3298
|
+
})
|