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.
@@ -1,7 +1,11 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3
- import type { Project, Session } from "@opencode-ai/sdk/v2"
4
- import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from "solid-js"
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.background } },
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.style
87
+ if (markdownStyleCache?.theme === theme) return markdownStyleCache?.style
84
88
  try {
85
89
  markdownStyleCache = { theme, style: buildMarkdownStyle(theme) }
86
- return markdownStyleCache.style
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.worktree ?? "", home)]),
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: s.directory ?? "",
467
+ dir: sDir,
455
468
  group:
456
- (projectLabel && projectLabel !== "other" ? projectLabel : shortDir(s.directory ?? "", home)) ||
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 NEEDS_INPUT_SQL = (stuckBefore: number) => `
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
- SELECT 1 FROM part p
497
- WHERE p.session_id = s.id
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
- SELECT 1 FROM part p
512
- WHERE p.session_id = s.id
513
- AND json_extract(p.data, '$.type') = 'tool'
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
- SELECT p.session_id AS id,
563
- MIN(CASE WHEN json_extract(p.data, '$.tool') = 'question'
564
- AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
565
- AND NOT EXISTS (SELECT 1 FROM message m WHERE m.session_id = p.session_id
566
- AND json_extract(m.data, '$.role') = 'user' AND m.time_created > p.time_created)
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 part = JSON.parse(data) as { type?: unknown; text?: unknown }
587
- if (part?.type !== "text" || typeof part.text !== "string") return
588
- const base = index.get(sid) ?? ""
589
- index.set(sid, `${base} ${part.text.toLowerCase()}`.trim())
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 parts
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 part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
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)?.type
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: "option+p",
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: "option+p",
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 · option+o to browse"}
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 · option+o for all`}</text>
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 · option+o to search transcripts</text>
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 · option+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
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: "option+p",
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 flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
1847
- <Show
1848
- when={entries().length > 0}
1849
- fallback={
1850
- <box flexDirection="row" gap={1}>
1851
- <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1852
- <text style={{ fg: theme().textMuted }}>
1853
- {loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
1854
- </text>
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
- <Show when={query()}>
1887
- <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
1888
- ✕
1889
- </text>
1890
- </Show>
1891
- </box>
1892
- </box>
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
- backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1899
- onMouseOver={() => setHovered(entry.id)}
1900
- onMouseOut={() => setHovered(undefined)}
1901
- onMouseDown={(event: { button?: number }) =>
1902
- event.button === RIGHT_BUTTON
1903
- ? openInCmuxWorkspace(props.api, entry)
1904
- : props.api.route.navigate("session", { sessionID: entry.id })
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
- <text flexShrink={0} style={{ fg: theme().textMuted }}>
1908
- ○
1909
- </text>
1910
- <Highlighted
1911
- text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
1912
- query={query()}
1913
- color={theme().text}
1914
- matchColor={theme().warning}
1915
- />
1916
- <text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
1917
- {pendingDelete() === entry.id
1918
- ? "ctrl+x again"
1919
- : `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
1920
- </text>
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
- </For>
1924
- <Show when={visible().length === 0}>
1925
- <text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
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
- </Show>
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: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
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: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
2396
- { key: "option+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
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" || (event.ctrl && event.name === "x") || event.name === "enter" || event.name === "return") {
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 · option+w input · option+s pinned · option+p preview"}
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.backgroundElement}
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 message m
2714
- JOIN session s ON s.id = m.session_id
2715
- WHERE json_extract(m.data, '$.role') = 'assistant'
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: "option+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2999
+ bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2984
3000
  })
2985
3001
  }
2986
3002
 
2987
- export default { id: "sesh-panel", tui }
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
+ })