opencode-sesh 0.14.1 → 1.0.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 +9 -10
- package/CHANGELOG.md +12 -0
- package/README.md +4 -2
- 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} +523 -230
- 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
|
}
|
|
@@ -1217,6 +1176,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1217
1176
|
}
|
|
1218
1177
|
}
|
|
1219
1178
|
|
|
1179
|
+
const forkEntry = async (entry: Entry) => {
|
|
1180
|
+
try {
|
|
1181
|
+
const result = await props.api.client.session.fork({
|
|
1182
|
+
sessionID: entry.id,
|
|
1183
|
+
directory: entry.dir || undefined,
|
|
1184
|
+
})
|
|
1185
|
+
const created = (result?.data ?? result) as Session | undefined
|
|
1186
|
+
if (!created?.id) throw new Error("fork returned no session")
|
|
1187
|
+
props.api.route.navigate("session", { sessionID: created.id })
|
|
1188
|
+
props.api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1189
|
+
} catch {
|
|
1190
|
+
props.api.ui.toast({ message: "Could not fork session", variant: "error" })
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const menu = createContextMenu({
|
|
1195
|
+
registerLayer: (layer) => props.api.keymap.registerLayer(layer),
|
|
1196
|
+
actions: {
|
|
1197
|
+
open: (entry) => void openSession(entry),
|
|
1198
|
+
preview: (entry) => preview.open(entry),
|
|
1199
|
+
workspace: (entry) => openInCmuxWorkspace(props.api, entry),
|
|
1200
|
+
fork: (entry) => void forkEntry(entry),
|
|
1201
|
+
pin: (entry) => props.onTogglePin("sessions", entry.id),
|
|
1202
|
+
delete: (entry) => void deleteEntry(entry),
|
|
1203
|
+
},
|
|
1204
|
+
pinned: (entry) => props.pins().sessions.includes(entry.id),
|
|
1205
|
+
cmux: () => Boolean(process.env.CMUX_WORKSPACE_ID),
|
|
1206
|
+
blocked: () => props.api.ui.dialog.open,
|
|
1207
|
+
onChange: setMenuState,
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
const menuBox = createMemo(() => {
|
|
1211
|
+
const state = menuState()
|
|
1212
|
+
if (!state) return undefined
|
|
1213
|
+
return contextMenuBox(
|
|
1214
|
+
{ x: state.x, y: state.y, ...contextMenuSize(state.items) },
|
|
1215
|
+
props.api.renderer.width,
|
|
1216
|
+
props.api.renderer.height,
|
|
1217
|
+
)
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
1220
|
let disposeHoverSpace: (() => void) | undefined
|
|
1221
1221
|
let disposeSearch: (() => void) | undefined
|
|
1222
1222
|
let disposeNav: (() => void) | undefined
|
|
@@ -1311,7 +1311,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1311
1311
|
priority: 20,
|
|
1312
1312
|
bindings: [
|
|
1313
1313
|
{
|
|
1314
|
-
key: "
|
|
1314
|
+
key: "alt+p",
|
|
1315
1315
|
desc: "Preview session transcript",
|
|
1316
1316
|
preventDefault: true,
|
|
1317
1317
|
cmd: () => openSidebarPreview(entry),
|
|
@@ -1355,7 +1355,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1355
1355
|
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
1356
1356
|
},
|
|
1357
1357
|
{
|
|
1358
|
-
key: "
|
|
1358
|
+
key: "alt+p",
|
|
1359
1359
|
desc: "Preview session transcript",
|
|
1360
1360
|
preventDefault: true,
|
|
1361
1361
|
cmd: () => {
|
|
@@ -1463,7 +1463,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1463
1463
|
when={entries().length > 0}
|
|
1464
1464
|
fallback={
|
|
1465
1465
|
<text style={{ fg: theme().textMuted }}>
|
|
1466
|
-
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet ·
|
|
1466
|
+
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · alt+o to browse"}
|
|
1467
1467
|
</text>
|
|
1468
1468
|
}
|
|
1469
1469
|
>
|
|
@@ -1575,14 +1575,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1575
1575
|
</For>
|
|
1576
1576
|
</scrollbox>
|
|
1577
1577
|
<Show when={remaining() > 0}>
|
|
1578
|
-
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more ·
|
|
1578
|
+
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · alt+o for all`}</text>
|
|
1579
1579
|
</Show>
|
|
1580
1580
|
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1581
|
-
<text style={{ fg: theme().textMuted }}>No title or directory matches ·
|
|
1581
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · alt+o to search transcripts</text>
|
|
1582
1582
|
</Show>
|
|
1583
1583
|
<Show when={navActive()}>
|
|
1584
1584
|
<box paddingTop={1}>
|
|
1585
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open ·
|
|
1585
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · alt+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
|
|
1586
1586
|
</box>
|
|
1587
1587
|
</Show>
|
|
1588
1588
|
</Show>
|
|
@@ -1784,7 +1784,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1784
1784
|
priority: 20,
|
|
1785
1785
|
bindings: [
|
|
1786
1786
|
{
|
|
1787
|
-
key: "
|
|
1787
|
+
key: "alt+p",
|
|
1788
1788
|
desc: "Preview session transcript",
|
|
1789
1789
|
preventDefault: true,
|
|
1790
1790
|
cmd: () => preview.open(entry),
|
|
@@ -1843,88 +1843,90 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1843
1843
|
})
|
|
1844
1844
|
|
|
1845
1845
|
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() ? "▏" : ""}
|
|
1846
|
+
<box width="100%" alignItems="center">
|
|
1847
|
+
<box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
|
|
1848
|
+
<Show
|
|
1849
|
+
when={entries().length > 0}
|
|
1850
|
+
fallback={
|
|
1851
|
+
<box flexDirection="row" gap={1}>
|
|
1852
|
+
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1853
|
+
<text style={{ fg: theme().textMuted }}>
|
|
1854
|
+
{loadFailed() ? "· list unavailable, will retry" : "· none yet, alt+o to browse"}
|
|
1884
1855
|
</text>
|
|
1885
1856
|
</box>
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
<For each={visible()}>
|
|
1894
|
-
{(entry) => (
|
|
1857
|
+
}
|
|
1858
|
+
>
|
|
1859
|
+
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
1860
|
+
<box flexDirection="row" gap={1}>
|
|
1861
|
+
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1862
|
+
<text style={{ fg: theme().textMuted }}>· alt+o for all</text>
|
|
1863
|
+
</box>
|
|
1895
1864
|
<box
|
|
1896
1865
|
flexDirection="row"
|
|
1897
1866
|
gap={1}
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
onMouseDown={(
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
}
|
|
1867
|
+
paddingLeft={1}
|
|
1868
|
+
paddingRight={1}
|
|
1869
|
+
backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1870
|
+
onMouseDown={() => {
|
|
1871
|
+
insideSearchBox = true
|
|
1872
|
+
setSearching(true)
|
|
1873
|
+
}}
|
|
1906
1874
|
>
|
|
1907
|
-
<
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1875
|
+
<box width={24}>
|
|
1876
|
+
<text
|
|
1877
|
+
flexGrow={1}
|
|
1878
|
+
flexShrink={1}
|
|
1879
|
+
overflow="hidden"
|
|
1880
|
+
wrapMode="none"
|
|
1881
|
+
style={{ fg: query() ? theme().text : theme().textMuted }}
|
|
1882
|
+
>
|
|
1883
|
+
{query() || "search…"}
|
|
1884
|
+
{searching() ? "▏" : ""}
|
|
1885
|
+
</text>
|
|
1886
|
+
</box>
|
|
1887
|
+
<Show when={query()}>
|
|
1888
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
|
|
1889
|
+
✕
|
|
1890
|
+
</text>
|
|
1891
|
+
</Show>
|
|
1921
1892
|
</box>
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1893
|
+
</box>
|
|
1894
|
+
<For each={visible()}>
|
|
1895
|
+
{(entry) => (
|
|
1896
|
+
<box
|
|
1897
|
+
flexDirection="row"
|
|
1898
|
+
gap={1}
|
|
1899
|
+
backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1900
|
+
onMouseOver={() => setHovered(entry.id)}
|
|
1901
|
+
onMouseOut={() => setHovered(undefined)}
|
|
1902
|
+
onMouseDown={(event: { button?: number }) =>
|
|
1903
|
+
event.button === RIGHT_BUTTON
|
|
1904
|
+
? openInCmuxWorkspace(props.api, entry)
|
|
1905
|
+
: props.api.route.navigate("session", { sessionID: entry.id })
|
|
1906
|
+
}
|
|
1907
|
+
>
|
|
1908
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1909
|
+
○
|
|
1910
|
+
</text>
|
|
1911
|
+
<Highlighted
|
|
1912
|
+
text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
|
|
1913
|
+
query={query()}
|
|
1914
|
+
color={theme().text}
|
|
1915
|
+
matchColor={theme().warning}
|
|
1916
|
+
/>
|
|
1917
|
+
<text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
|
|
1918
|
+
{pendingDelete() === entry.id
|
|
1919
|
+
? "ctrl+x again"
|
|
1920
|
+
: `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
|
|
1921
|
+
</text>
|
|
1922
|
+
</box>
|
|
1923
|
+
)}
|
|
1924
|
+
</For>
|
|
1925
|
+
<Show when={visible().length === 0}>
|
|
1926
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · alt+o to search transcripts</text>
|
|
1927
|
+
</Show>
|
|
1926
1928
|
</Show>
|
|
1927
|
-
</
|
|
1929
|
+
</box>
|
|
1928
1930
|
</box>
|
|
1929
1931
|
)
|
|
1930
1932
|
}
|
|
@@ -2332,6 +2334,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2332
2334
|
const disposeNav = api.keymap.registerLayer({
|
|
2333
2335
|
// Outrank the built-in dialog layer so Escape reaches this picker before
|
|
2334
2336
|
// the dialog host closes the whole thing.
|
|
2337
|
+
mode: "global",
|
|
2335
2338
|
priority: 1,
|
|
2336
2339
|
bindings: [
|
|
2337
2340
|
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
@@ -2354,7 +2357,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2354
2357
|
choose()
|
|
2355
2358
|
},
|
|
2356
2359
|
},
|
|
2357
|
-
{ key: "
|
|
2360
|
+
{ key: "alt+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
2358
2361
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
2359
2362
|
const entry = selectableEntries()[cursor()]
|
|
2360
2363
|
if (entry) onTogglePin("sessions", entry.id)
|
|
@@ -2392,8 +2395,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2392
2395
|
else if (target) setScope(target.group)
|
|
2393
2396
|
},
|
|
2394
2397
|
},
|
|
2395
|
-
{ key: "
|
|
2396
|
-
{ key: "
|
|
2398
|
+
{ key: "alt+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
|
|
2399
|
+
{ key: "alt+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
|
|
2397
2400
|
{
|
|
2398
2401
|
key: "escape",
|
|
2399
2402
|
desc: "Close",
|
|
@@ -2455,8 +2458,21 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2455
2458
|
handleEscape()
|
|
2456
2459
|
return
|
|
2457
2460
|
}
|
|
2461
|
+
if (event.ctrl && event.name === "x") {
|
|
2462
|
+
event.preventDefault()
|
|
2463
|
+
if (confirmArmed()) return
|
|
2464
|
+
const target = selectableEntries()[cursor()]
|
|
2465
|
+
if (target) armDelete(target)
|
|
2466
|
+
return
|
|
2467
|
+
}
|
|
2468
|
+
if (event.ctrl && event.name === "f") {
|
|
2469
|
+
event.preventDefault()
|
|
2470
|
+
const target = selectableEntries()[cursor()]
|
|
2471
|
+
if (target) void forkEntry(target)
|
|
2472
|
+
return
|
|
2473
|
+
}
|
|
2458
2474
|
if (pendingDelete()) {
|
|
2459
|
-
if (event.name === "y" ||
|
|
2475
|
+
if (event.name === "y" || event.name === "enter" || event.name === "return") {
|
|
2460
2476
|
event.preventDefault()
|
|
2461
2477
|
confirmArmed()
|
|
2462
2478
|
return
|
|
@@ -2586,7 +2602,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2586
2602
|
</text>
|
|
2587
2603
|
</box>
|
|
2588
2604
|
<Show when={excerpt()}>
|
|
2589
|
-
{(match) => (
|
|
2605
|
+
{(match: Accessor<string>) => (
|
|
2590
2606
|
<box flexDirection="row" gap={1} paddingLeft={2}>
|
|
2591
2607
|
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>↳</text>
|
|
2592
2608
|
<Highlighted
|
|
@@ -2630,7 +2646,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2630
2646
|
>
|
|
2631
2647
|
{pendingDelete()
|
|
2632
2648
|
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2633
|
-
: "↑↓ move · enter open · ctrl+g project ·
|
|
2649
|
+
: "↑↓ move · enter open · ctrl+g project · alt+w input · alt+s pinned · alt+p preview"}
|
|
2634
2650
|
</text>
|
|
2635
2651
|
<Show when={!pendingDelete()}>
|
|
2636
2652
|
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
|
|
@@ -2644,7 +2660,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2644
2660
|
width={dialogWidth}
|
|
2645
2661
|
height={dialogHeight()}
|
|
2646
2662
|
flexDirection="column"
|
|
2647
|
-
backgroundColor={api.theme.current.
|
|
2663
|
+
backgroundColor={api.theme.current.backgroundPanel}
|
|
2648
2664
|
paddingLeft={4}
|
|
2649
2665
|
paddingRight={4}
|
|
2650
2666
|
paddingTop={1}
|
|
@@ -2665,7 +2681,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2665
2681
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
2666
2682
|
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
2667
2683
|
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
2668
|
-
{(text) =>
|
|
2684
|
+
{(text: Accessor<string>) =>
|
|
2669
2685
|
getMarkdownStyle(api.theme.current) ? (
|
|
2670
2686
|
<markdown
|
|
2671
2687
|
content={text()}
|
|
@@ -2710,9 +2726,9 @@ SELECT s.directory AS directory,
|
|
|
2710
2726
|
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
2711
2727
|
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
2712
2728
|
COUNT(DISTINCT s.id) AS sessions
|
|
2713
|
-
FROM
|
|
2714
|
-
JOIN
|
|
2715
|
-
WHERE
|
|
2729
|
+
FROM session_message m
|
|
2730
|
+
JOIN session_v2 s ON s.id = m.session_id
|
|
2731
|
+
WHERE m.type = 'assistant'
|
|
2716
2732
|
AND json_extract(m.data, '$.cost') IS NOT NULL
|
|
2717
2733
|
GROUP BY s.directory
|
|
2718
2734
|
ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
@@ -2759,7 +2775,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2759
2775
|
})
|
|
2760
2776
|
api.ui.dialog.replace(
|
|
2761
2777
|
() => (
|
|
2762
|
-
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2778
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
|
|
2763
2779
|
<text attributes={TextAttributes.BOLD}>
|
|
2764
2780
|
Cost digest{" "}
|
|
2765
2781
|
<span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
|
|
@@ -2872,7 +2888,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2872
2888
|
const home = process.env.HOME ?? ""
|
|
2873
2889
|
api.ui.dialog.replace(
|
|
2874
2890
|
() => (
|
|
2875
|
-
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
|
|
2891
|
+
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
|
|
2876
2892
|
<text attributes={TextAttributes.BOLD}>
|
|
2877
2893
|
Needs input{" "}
|
|
2878
2894
|
<span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
|
|
@@ -2980,8 +2996,285 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2980
2996
|
])
|
|
2981
2997
|
|
|
2982
2998
|
api.keymap.registerLayer({
|
|
2983
|
-
bindings: [{ key: "
|
|
2999
|
+
bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
|
|
2984
3000
|
})
|
|
2985
3001
|
}
|
|
2986
3002
|
|
|
2987
|
-
|
|
3003
|
+
let hostOwner: ReturnType<typeof getOwner> = null
|
|
3004
|
+
|
|
3005
|
+
function adaptTheme(theme: Plugin.Context["theme"]) {
|
|
3006
|
+
const opaque = (color: RGBA) => RGBA.fromValues(color.r, color.g, color.b, 1)
|
|
3007
|
+
return {
|
|
3008
|
+
text: theme.text.base,
|
|
3009
|
+
textMuted: theme.text.muted,
|
|
3010
|
+
background: theme.background.base,
|
|
3011
|
+
backgroundPanel: opaque(theme.background.raised.base),
|
|
3012
|
+
backgroundElement: opaque(theme.background.raised.high),
|
|
3013
|
+
border: theme.border.base,
|
|
3014
|
+
borderActive: theme.hue.accent[200],
|
|
3015
|
+
borderSubtle: theme.border.base,
|
|
3016
|
+
accent: theme.hue.accent[200],
|
|
3017
|
+
primary: theme.hue.accent[200],
|
|
3018
|
+
secondary: theme.text.action.secondary.base,
|
|
3019
|
+
error: theme.text.feedback.error.base,
|
|
3020
|
+
warning: theme.text.feedback.warning.base,
|
|
3021
|
+
success: theme.text.feedback.success.base,
|
|
3022
|
+
info: theme.text.feedback.info.base,
|
|
3023
|
+
selectedListItemText: theme.text.base,
|
|
3024
|
+
syntaxComment: theme.syntax.comment,
|
|
3025
|
+
syntaxString: theme.syntax.string,
|
|
3026
|
+
syntaxNumber: theme.syntax.number,
|
|
3027
|
+
syntaxKeyword: theme.syntax.keyword,
|
|
3028
|
+
syntaxType: theme.syntax.type,
|
|
3029
|
+
syntaxFunction: theme.syntax.function,
|
|
3030
|
+
syntaxOperator: theme.syntax.operator,
|
|
3031
|
+
syntaxVariable: theme.syntax.variable,
|
|
3032
|
+
syntaxPunctuation: theme.syntax.punctuation,
|
|
3033
|
+
markdownLink: theme.markdown.link,
|
|
3034
|
+
markdownLinkText: theme.markdown.linkText,
|
|
3035
|
+
markdownBlockQuote: theme.markdown.blockQuote,
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function createApi(ctx: Plugin.Context): TuiPluginApi {
|
|
3040
|
+
const [dialogOpen, setDialogOpen] = createSignal(false)
|
|
3041
|
+
let sourceTheme: Plugin.Context["theme"] | undefined
|
|
3042
|
+
let currentTheme: ReturnType<typeof adaptTheme>
|
|
3043
|
+
return {
|
|
3044
|
+
theme: {
|
|
3045
|
+
get current() {
|
|
3046
|
+
if (sourceTheme !== ctx.theme) {
|
|
3047
|
+
sourceTheme = ctx.theme
|
|
3048
|
+
currentTheme = adaptTheme(sourceTheme)
|
|
3049
|
+
}
|
|
3050
|
+
return currentTheme!
|
|
3051
|
+
},
|
|
3052
|
+
},
|
|
3053
|
+
ui: {
|
|
3054
|
+
toast(opts: { message: string; variant?: "info" | "warning" | "error" | "success" }) {
|
|
3055
|
+
ctx.ui.toast.show({
|
|
3056
|
+
title: "Session",
|
|
3057
|
+
message: opts.message,
|
|
3058
|
+
variant: opts.variant ?? "info",
|
|
3059
|
+
})
|
|
3060
|
+
},
|
|
3061
|
+
dialog: {
|
|
3062
|
+
clear() {
|
|
3063
|
+
ctx.ui.dialog.clear()
|
|
3064
|
+
setDialogOpen(false)
|
|
3065
|
+
},
|
|
3066
|
+
replace(render: () => any) {
|
|
3067
|
+
setDialogOpen(true)
|
|
3068
|
+
ctx.ui.dialog.show(render, () => setDialogOpen(false))
|
|
3069
|
+
},
|
|
3070
|
+
setSize(size: string) {
|
|
3071
|
+
ctx.ui.dialog.set({ size: size as any })
|
|
3072
|
+
},
|
|
3073
|
+
// V2's Dialog has no reactive open/isOpen accessor; track it locally
|
|
3074
|
+
// from the replace/clear calls above and the host's onClose callback.
|
|
3075
|
+
get open() {
|
|
3076
|
+
return dialogOpen()
|
|
3077
|
+
},
|
|
3078
|
+
},
|
|
3079
|
+
},
|
|
3080
|
+
route: {
|
|
3081
|
+
get current() {
|
|
3082
|
+
const r = ctx.ui.router.current()
|
|
3083
|
+
if (r.type === "session") {
|
|
3084
|
+
return { name: "session", params: { sessionID: r.sessionID } }
|
|
3085
|
+
}
|
|
3086
|
+
return { name: r.type, params: {} }
|
|
3087
|
+
},
|
|
3088
|
+
navigate(name: string, params?: { sessionID?: string }) {
|
|
3089
|
+
if (name === "session" && params?.sessionID) {
|
|
3090
|
+
ctx.ui.router.navigate({ type: "session", sessionID: params.sessionID })
|
|
3091
|
+
} else if (name === "home") {
|
|
3092
|
+
ctx.ui.router.navigate({ type: "home" })
|
|
3093
|
+
}
|
|
3094
|
+
},
|
|
3095
|
+
},
|
|
3096
|
+
state: {
|
|
3097
|
+
path: {
|
|
3098
|
+
get directory() {
|
|
3099
|
+
return ctx.location?.directory ?? process.cwd()
|
|
3100
|
+
},
|
|
3101
|
+
},
|
|
3102
|
+
session: {
|
|
3103
|
+
status(id: string) {
|
|
3104
|
+
return ctx.data.session.status(id)
|
|
3105
|
+
},
|
|
3106
|
+
},
|
|
3107
|
+
},
|
|
3108
|
+
mode: {
|
|
3109
|
+
current() {
|
|
3110
|
+
return ctx.keymap.mode.current()
|
|
3111
|
+
},
|
|
3112
|
+
},
|
|
3113
|
+
renderer: {
|
|
3114
|
+
get height() {
|
|
3115
|
+
return ctx.renderer?.height ?? 40
|
|
3116
|
+
},
|
|
3117
|
+
get width() {
|
|
3118
|
+
return ctx.renderer?.width ?? 120
|
|
3119
|
+
},
|
|
3120
|
+
get root() {
|
|
3121
|
+
return ctx.renderer.root
|
|
3122
|
+
},
|
|
3123
|
+
get currentFocusedEditor() {
|
|
3124
|
+
return ctx.renderer.currentFocusedEditor
|
|
3125
|
+
},
|
|
3126
|
+
on(event: string, handler: (...args: any[]) => void) {
|
|
3127
|
+
ctx.renderer.on(event, handler)
|
|
3128
|
+
},
|
|
3129
|
+
off(event: string, handler: (...args: any[]) => void) {
|
|
3130
|
+
ctx.renderer.off(event, handler)
|
|
3131
|
+
},
|
|
3132
|
+
},
|
|
3133
|
+
keymap: {
|
|
3134
|
+
registerLayer(layer: {
|
|
3135
|
+
mode?: string
|
|
3136
|
+
priority?: number
|
|
3137
|
+
bindings?: Array<{ key: string; desc?: string; preventDefault?: boolean; cmd: () => void }>
|
|
3138
|
+
}) {
|
|
3139
|
+
return runWithOwner(hostOwner, () => createRoot((dispose) => {
|
|
3140
|
+
ctx.keymap.layer(() => ({
|
|
3141
|
+
mode: layer.mode ?? "base",
|
|
3142
|
+
priority: layer.priority ?? 20,
|
|
3143
|
+
commands: (layer.bindings ?? []).map((b, i) => ({
|
|
3144
|
+
id: `sesh-cmd-${Math.random().toString(36).slice(2)}-${i}`,
|
|
3145
|
+
title: b.desc,
|
|
3146
|
+
bind: b.key,
|
|
3147
|
+
run: () => {
|
|
3148
|
+
b.cmd()
|
|
3149
|
+
},
|
|
3150
|
+
})),
|
|
3151
|
+
}))
|
|
3152
|
+
return dispose
|
|
3153
|
+
}))
|
|
3154
|
+
},
|
|
3155
|
+
},
|
|
3156
|
+
slots: {
|
|
3157
|
+
register(cfg: { order?: number; slots: Record<string, () => any> }) {
|
|
3158
|
+
if (cfg.slots.sidebar_content) {
|
|
3159
|
+
ctx.ui.slot({
|
|
3160
|
+
prepend: "sidebar.content",
|
|
3161
|
+
render: cfg.slots.sidebar_content as any,
|
|
3162
|
+
})
|
|
3163
|
+
}
|
|
3164
|
+
if (cfg.slots.home_bottom) {
|
|
3165
|
+
ctx.ui.slot({
|
|
3166
|
+
prepend: "home.footer",
|
|
3167
|
+
render: cfg.slots.home_bottom as any,
|
|
3168
|
+
})
|
|
3169
|
+
}
|
|
3170
|
+
},
|
|
3171
|
+
},
|
|
3172
|
+
command: {
|
|
3173
|
+
register(factory: () => Array<{
|
|
3174
|
+
title: string
|
|
3175
|
+
value: string
|
|
3176
|
+
description?: string
|
|
3177
|
+
category?: string
|
|
3178
|
+
slash?: { name: string }
|
|
3179
|
+
onSelect: () => void
|
|
3180
|
+
}>) {
|
|
3181
|
+
ctx.keymap.layer(() => ({
|
|
3182
|
+
mode: "global",
|
|
3183
|
+
priority: 20,
|
|
3184
|
+
commands: factory().map((cmd) => ({
|
|
3185
|
+
id: cmd.value,
|
|
3186
|
+
title: cmd.title,
|
|
3187
|
+
description: cmd.description,
|
|
3188
|
+
group: cmd.category,
|
|
3189
|
+
palette: true,
|
|
3190
|
+
slash: cmd.slash ? { name: cmd.slash.name } : undefined,
|
|
3191
|
+
run: () => {
|
|
3192
|
+
cmd.onSelect()
|
|
3193
|
+
},
|
|
3194
|
+
})),
|
|
3195
|
+
}))
|
|
3196
|
+
},
|
|
3197
|
+
},
|
|
3198
|
+
client: {
|
|
3199
|
+
project: {
|
|
3200
|
+
async list() {
|
|
3201
|
+
const res: any = await ctx.client.project.list()
|
|
3202
|
+
return { data: Array.isArray(res) ? res : (res?.data ?? []) }
|
|
3203
|
+
},
|
|
3204
|
+
},
|
|
3205
|
+
session: {
|
|
3206
|
+
async delete(req: { sessionID: string; directory?: string }) {
|
|
3207
|
+
await ctx.client.session.remove({ sessionID: req.sessionID })
|
|
3208
|
+
return { data: undefined }
|
|
3209
|
+
},
|
|
3210
|
+
async fork(req: { path: { id: string } }) {
|
|
3211
|
+
const res: any = await ctx.client.session.fork({ sessionID: req.path.id })
|
|
3212
|
+
return { data: res?.data ?? res }
|
|
3213
|
+
},
|
|
3214
|
+
async messages(req: { path?: { id?: string }; sessionID?: string }) {
|
|
3215
|
+
const sessionID = req.sessionID ?? req.path?.id ?? ""
|
|
3216
|
+
const res = await ctx.client.message.list({ sessionID, order: "asc", limit: 100 })
|
|
3217
|
+
const messages = (res.data ?? []).map((m: any) => {
|
|
3218
|
+
let text = ""
|
|
3219
|
+
if (m.type === "user") text = m.text ?? ""
|
|
3220
|
+
else if (m.type === "assistant") {
|
|
3221
|
+
text = (m.content ?? [])
|
|
3222
|
+
.filter((c: any) => c.type === "text")
|
|
3223
|
+
.map((c: any) => c.text)
|
|
3224
|
+
.join("\n")
|
|
3225
|
+
}
|
|
3226
|
+
return {
|
|
3227
|
+
id: m.id,
|
|
3228
|
+
info: { role: m.type },
|
|
3229
|
+
parts: [{ type: "text", text }],
|
|
3230
|
+
}
|
|
3231
|
+
})
|
|
3232
|
+
return { data: messages }
|
|
3233
|
+
},
|
|
3234
|
+
},
|
|
3235
|
+
experimental: {
|
|
3236
|
+
session: {
|
|
3237
|
+
async list(input: any) {
|
|
3238
|
+
// V1 paged the global list with a numeric `time.updated` cursor and a
|
|
3239
|
+
// descending order. V2 returns an opaque cursor per location, so
|
|
3240
|
+
// fetch once and page in memory to keep the caller's loop intact.
|
|
3241
|
+
const limit = Number(input?.limit) > 0 ? Number(input.limit) : 200
|
|
3242
|
+
const cursor = typeof input?.cursor === "number" ? input.cursor : undefined
|
|
3243
|
+
const res: any = await ctx.client.session.list({ limit: 5000, order: "desc" })
|
|
3244
|
+
const all: any[] = Array.isArray(res) ? res : (res?.data ?? [])
|
|
3245
|
+
const normalized = all
|
|
3246
|
+
.map((s: any) => ({
|
|
3247
|
+
...s,
|
|
3248
|
+
directory: s.directory ?? s.location?.directory ?? "",
|
|
3249
|
+
}))
|
|
3250
|
+
.sort((a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
|
3251
|
+
const page =
|
|
3252
|
+
cursor === undefined
|
|
3253
|
+
? normalized.slice(0, limit)
|
|
3254
|
+
: normalized.filter((s: any) => (s.time?.updated ?? 0) < cursor).slice(0, limit)
|
|
3255
|
+
return { data: page }
|
|
3256
|
+
},
|
|
3257
|
+
},
|
|
3258
|
+
},
|
|
3259
|
+
},
|
|
3260
|
+
} as any
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
export default Plugin.define({
|
|
3264
|
+
id: "sesh-panel",
|
|
3265
|
+
async setup(ctx) {
|
|
3266
|
+
const api = createApi(ctx)
|
|
3267
|
+
let initialized = false
|
|
3268
|
+
return ctx.ui.slot({
|
|
3269
|
+
append: "app",
|
|
3270
|
+
render: () => {
|
|
3271
|
+
if (!initialized) {
|
|
3272
|
+
initialized = true
|
|
3273
|
+
hostOwner = getOwner()
|
|
3274
|
+
void tui(api)
|
|
3275
|
+
}
|
|
3276
|
+
return <box />
|
|
3277
|
+
},
|
|
3278
|
+
})
|
|
3279
|
+
},
|
|
3280
|
+
})
|