opencode-sesh 0.14.0 → 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 +19 -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} +527 -232
- 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,10 +822,10 @@ 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
|
-
<text style={{ fg: api.theme.current.textMuted }}>esc</text>
|
|
828
|
+
<text style={{ fg: api.theme.current.textMuted }} onMouseUp={close}>esc</text>
|
|
822
829
|
</box>
|
|
823
830
|
<text style={{ fg: api.theme.current.textMuted }}>
|
|
824
831
|
{prettyDir(entry.dir, process.env.HOME ?? "")} · {ago(entry.updated)}
|
|
@@ -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>
|
|
@@ -1612,6 +1612,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1612
1612
|
paddingLeft={1}
|
|
1613
1613
|
paddingRight={1}
|
|
1614
1614
|
onMouseDown={(event: { stopPropagation: () => void }) => event.stopPropagation()}
|
|
1615
|
+
onMouseUp={(event: { stopPropagation: () => void }) => event.stopPropagation()}
|
|
1615
1616
|
>
|
|
1616
1617
|
<For each={menuState()!.items}>
|
|
1617
1618
|
{(item, index) => (
|
|
@@ -1619,7 +1620,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1619
1620
|
height={1}
|
|
1620
1621
|
backgroundColor={menuState()!.index === index() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1621
1622
|
onMouseOver={() => menu.select(index())}
|
|
1622
|
-
onMouseDown={(event: {
|
|
1623
|
+
onMouseDown={(event: { stopPropagation: () => void }) => event.stopPropagation()}
|
|
1624
|
+
onMouseUp={(event: { button?: number; stopPropagation: () => void }) => {
|
|
1623
1625
|
event.stopPropagation()
|
|
1624
1626
|
menu.click(index(), event.button)
|
|
1625
1627
|
}}
|
|
@@ -1782,7 +1784,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1782
1784
|
priority: 20,
|
|
1783
1785
|
bindings: [
|
|
1784
1786
|
{
|
|
1785
|
-
key: "
|
|
1787
|
+
key: "alt+p",
|
|
1786
1788
|
desc: "Preview session transcript",
|
|
1787
1789
|
preventDefault: true,
|
|
1788
1790
|
cmd: () => preview.open(entry),
|
|
@@ -1841,88 +1843,90 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1841
1843
|
})
|
|
1842
1844
|
|
|
1843
1845
|
return (
|
|
1844
|
-
<box
|
|
1845
|
-
<
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
<
|
|
1850
|
-
|
|
1851
|
-
{
|
|
1852
|
-
|
|
1853
|
-
</box>
|
|
1854
|
-
}
|
|
1855
|
-
>
|
|
1856
|
-
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
1857
|
-
<box flexDirection="row" gap={1}>
|
|
1858
|
-
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1859
|
-
<text style={{ fg: theme().textMuted }}>· option+o for all</text>
|
|
1860
|
-
</box>
|
|
1861
|
-
<box
|
|
1862
|
-
flexDirection="row"
|
|
1863
|
-
gap={1}
|
|
1864
|
-
paddingLeft={1}
|
|
1865
|
-
paddingRight={1}
|
|
1866
|
-
backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1867
|
-
onMouseDown={() => {
|
|
1868
|
-
insideSearchBox = true
|
|
1869
|
-
setSearching(true)
|
|
1870
|
-
}}
|
|
1871
|
-
>
|
|
1872
|
-
<box width={24}>
|
|
1873
|
-
<text
|
|
1874
|
-
flexGrow={1}
|
|
1875
|
-
flexShrink={1}
|
|
1876
|
-
overflow="hidden"
|
|
1877
|
-
wrapMode="none"
|
|
1878
|
-
style={{ fg: query() ? theme().text : theme().textMuted }}
|
|
1879
|
-
>
|
|
1880
|
-
{query() || "search…"}
|
|
1881
|
-
{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"}
|
|
1882
1855
|
</text>
|
|
1883
1856
|
</box>
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
<For each={visible()}>
|
|
1892
|
-
{(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>
|
|
1893
1864
|
<box
|
|
1894
1865
|
flexDirection="row"
|
|
1895
1866
|
gap={1}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
onMouseDown={(
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
}
|
|
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
|
+
}}
|
|
1904
1874
|
>
|
|
1905
|
-
<
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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>
|
|
1919
1892
|
</box>
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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>
|
|
1924
1928
|
</Show>
|
|
1925
|
-
</
|
|
1929
|
+
</box>
|
|
1926
1930
|
</box>
|
|
1927
1931
|
)
|
|
1928
1932
|
}
|
|
@@ -2330,6 +2334,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2330
2334
|
const disposeNav = api.keymap.registerLayer({
|
|
2331
2335
|
// Outrank the built-in dialog layer so Escape reaches this picker before
|
|
2332
2336
|
// the dialog host closes the whole thing.
|
|
2337
|
+
mode: "global",
|
|
2333
2338
|
priority: 1,
|
|
2334
2339
|
bindings: [
|
|
2335
2340
|
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
@@ -2352,7 +2357,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2352
2357
|
choose()
|
|
2353
2358
|
},
|
|
2354
2359
|
},
|
|
2355
|
-
{ key: "
|
|
2360
|
+
{ key: "alt+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
2356
2361
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
2357
2362
|
const entry = selectableEntries()[cursor()]
|
|
2358
2363
|
if (entry) onTogglePin("sessions", entry.id)
|
|
@@ -2390,8 +2395,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2390
2395
|
else if (target) setScope(target.group)
|
|
2391
2396
|
},
|
|
2392
2397
|
},
|
|
2393
|
-
{ key: "
|
|
2394
|
-
{ 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) },
|
|
2395
2400
|
{
|
|
2396
2401
|
key: "escape",
|
|
2397
2402
|
desc: "Close",
|
|
@@ -2453,8 +2458,21 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2453
2458
|
handleEscape()
|
|
2454
2459
|
return
|
|
2455
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
|
+
}
|
|
2456
2474
|
if (pendingDelete()) {
|
|
2457
|
-
if (event.name === "y" ||
|
|
2475
|
+
if (event.name === "y" || event.name === "enter" || event.name === "return") {
|
|
2458
2476
|
event.preventDefault()
|
|
2459
2477
|
confirmArmed()
|
|
2460
2478
|
return
|
|
@@ -2584,7 +2602,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2584
2602
|
</text>
|
|
2585
2603
|
</box>
|
|
2586
2604
|
<Show when={excerpt()}>
|
|
2587
|
-
{(match) => (
|
|
2605
|
+
{(match: Accessor<string>) => (
|
|
2588
2606
|
<box flexDirection="row" gap={1} paddingLeft={2}>
|
|
2589
2607
|
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>↳</text>
|
|
2590
2608
|
<Highlighted
|
|
@@ -2628,7 +2646,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2628
2646
|
>
|
|
2629
2647
|
{pendingDelete()
|
|
2630
2648
|
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2631
|
-
: "↑↓ move · enter open · ctrl+g project ·
|
|
2649
|
+
: "↑↓ move · enter open · ctrl+g project · alt+w input · alt+s pinned · alt+p preview"}
|
|
2632
2650
|
</text>
|
|
2633
2651
|
<Show when={!pendingDelete()}>
|
|
2634
2652
|
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
|
|
@@ -2642,7 +2660,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2642
2660
|
width={dialogWidth}
|
|
2643
2661
|
height={dialogHeight()}
|
|
2644
2662
|
flexDirection="column"
|
|
2645
|
-
backgroundColor={api.theme.current.
|
|
2663
|
+
backgroundColor={api.theme.current.backgroundPanel}
|
|
2646
2664
|
paddingLeft={4}
|
|
2647
2665
|
paddingRight={4}
|
|
2648
2666
|
paddingTop={1}
|
|
@@ -2663,7 +2681,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2663
2681
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
2664
2682
|
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
2665
2683
|
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
2666
|
-
{(text) =>
|
|
2684
|
+
{(text: Accessor<string>) =>
|
|
2667
2685
|
getMarkdownStyle(api.theme.current) ? (
|
|
2668
2686
|
<markdown
|
|
2669
2687
|
content={text()}
|
|
@@ -2708,9 +2726,9 @@ SELECT s.directory AS directory,
|
|
|
2708
2726
|
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
2709
2727
|
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
2710
2728
|
COUNT(DISTINCT s.id) AS sessions
|
|
2711
|
-
FROM
|
|
2712
|
-
JOIN
|
|
2713
|
-
WHERE
|
|
2729
|
+
FROM session_message m
|
|
2730
|
+
JOIN session_v2 s ON s.id = m.session_id
|
|
2731
|
+
WHERE m.type = 'assistant'
|
|
2714
2732
|
AND json_extract(m.data, '$.cost') IS NOT NULL
|
|
2715
2733
|
GROUP BY s.directory
|
|
2716
2734
|
ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
@@ -2757,7 +2775,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2757
2775
|
})
|
|
2758
2776
|
api.ui.dialog.replace(
|
|
2759
2777
|
() => (
|
|
2760
|
-
<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}>
|
|
2761
2779
|
<text attributes={TextAttributes.BOLD}>
|
|
2762
2780
|
Cost digest{" "}
|
|
2763
2781
|
<span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
|
|
@@ -2870,7 +2888,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2870
2888
|
const home = process.env.HOME ?? ""
|
|
2871
2889
|
api.ui.dialog.replace(
|
|
2872
2890
|
() => (
|
|
2873
|
-
<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}>
|
|
2874
2892
|
<text attributes={TextAttributes.BOLD}>
|
|
2875
2893
|
Needs input{" "}
|
|
2876
2894
|
<span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
|
|
@@ -2978,8 +2996,285 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2978
2996
|
])
|
|
2979
2997
|
|
|
2980
2998
|
api.keymap.registerLayer({
|
|
2981
|
-
bindings: [{ key: "
|
|
2999
|
+
bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
|
|
2982
3000
|
})
|
|
2983
3001
|
}
|
|
2984
3002
|
|
|
2985
|
-
|
|
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
|
+
})
|