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.
@@ -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,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)?.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>
@@ -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: { button?: number; stopPropagation: () => void }) => {
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: "option+p",
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 flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
1845
- <Show
1846
- when={entries().length > 0}
1847
- fallback={
1848
- <box flexDirection="row" gap={1}>
1849
- <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1850
- <text style={{ fg: theme().textMuted }}>
1851
- {loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
1852
- </text>
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
- <Show when={query()}>
1885
- <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
1886
- ✕
1887
- </text>
1888
- </Show>
1889
- </box>
1890
- </box>
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
- backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1897
- onMouseOver={() => setHovered(entry.id)}
1898
- onMouseOut={() => setHovered(undefined)}
1899
- onMouseDown={(event: { button?: number }) =>
1900
- event.button === RIGHT_BUTTON
1901
- ? openInCmuxWorkspace(props.api, entry)
1902
- : props.api.route.navigate("session", { sessionID: entry.id })
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
- <text flexShrink={0} style={{ fg: theme().textMuted }}>
1906
- ○
1907
- </text>
1908
- <Highlighted
1909
- text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
1910
- query={query()}
1911
- color={theme().text}
1912
- matchColor={theme().warning}
1913
- />
1914
- <text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
1915
- {pendingDelete() === entry.id
1916
- ? "ctrl+x again"
1917
- : `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
1918
- </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>
1919
1892
  </box>
1920
- )}
1921
- </For>
1922
- <Show when={visible().length === 0}>
1923
- <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>
1924
1928
  </Show>
1925
- </Show>
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: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
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: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
2394
- { 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) },
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" || (event.ctrl && event.name === "x") || event.name === "enter" || event.name === "return") {
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 · 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"}
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.backgroundElement}
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 message m
2712
- JOIN session s ON s.id = m.session_id
2713
- 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'
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: "option+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2999
+ bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2982
3000
  })
2983
3001
  }
2984
3002
 
2985
- 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
+ })