opencode-sesh 0.14.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -1190,6 +1149,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1190
1149
  return true
1191
1150
  }
1192
1151
 
1152
+ let sidebarScroll: { scrollTop: number } | undefined
1153
+
1193
1154
  const deleteEntry = async (entry: Entry) => {
1194
1155
  cancelDelete()
1195
1156
  if (deleting()) return
@@ -1206,8 +1167,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1206
1167
  props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
1207
1168
  return
1208
1169
  }
1170
+ const scrollTop = sidebarScroll?.scrollTop ?? 0
1209
1171
  setEntries(remaining)
1210
1172
  setHovered(undefined)
1173
+ const restore = () => {
1174
+ if (sidebarScroll) sidebarScroll.scrollTop = scrollTop
1175
+ }
1176
+ queueMicrotask(restore)
1177
+ setTimeout(restore, 0)
1211
1178
  props.api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
1212
1179
  if (currentID() === entry.id) props.api.route.navigate("home")
1213
1180
  } catch {
@@ -1217,6 +1184,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1217
1184
  }
1218
1185
  }
1219
1186
 
1187
+ const forkEntry = async (entry: Entry) => {
1188
+ try {
1189
+ const result = await props.api.client.session.fork({
1190
+ sessionID: entry.id,
1191
+ directory: entry.dir || undefined,
1192
+ })
1193
+ const created = (result?.data ?? result) as Session | undefined
1194
+ if (!created?.id) throw new Error("fork returned no session")
1195
+ props.api.route.navigate("session", { sessionID: created.id })
1196
+ props.api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
1197
+ } catch {
1198
+ props.api.ui.toast({ message: "Could not fork session", variant: "error" })
1199
+ }
1200
+ }
1201
+
1202
+ const menu = createContextMenu({
1203
+ registerLayer: (layer) => props.api.keymap.registerLayer(layer),
1204
+ actions: {
1205
+ open: (entry) => void openSession(entry),
1206
+ preview: (entry) => preview.open(entry),
1207
+ workspace: (entry) => openInCmuxWorkspace(props.api, entry),
1208
+ fork: (entry) => void forkEntry(entry),
1209
+ pin: (entry) => props.onTogglePin("sessions", entry.id),
1210
+ delete: (entry) => void deleteEntry(entry),
1211
+ },
1212
+ pinned: (entry) => props.pins().sessions.includes(entry.id),
1213
+ cmux: () => Boolean(process.env.CMUX_WORKSPACE_ID),
1214
+ blocked: () => props.api.ui.dialog.open,
1215
+ onChange: setMenuState,
1216
+ })
1217
+
1218
+ const menuBox = createMemo(() => {
1219
+ const state = menuState()
1220
+ if (!state) return undefined
1221
+ return contextMenuBox(
1222
+ { x: state.x, y: state.y, ...contextMenuSize(state.items) },
1223
+ props.api.renderer.width,
1224
+ props.api.renderer.height,
1225
+ )
1226
+ })
1227
+
1220
1228
  let disposeHoverSpace: (() => void) | undefined
1221
1229
  let disposeSearch: (() => void) | undefined
1222
1230
  let disposeNav: (() => void) | undefined
@@ -1240,7 +1248,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1240
1248
  },
1241
1249
  },
1242
1250
  {
1243
- key: "ctrl+x",
1251
+ key: "alt+d",
1244
1252
  desc: "Confirm delete",
1245
1253
  preventDefault: true,
1246
1254
  cmd: () => {
@@ -1311,13 +1319,13 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1311
1319
  priority: 20,
1312
1320
  bindings: [
1313
1321
  {
1314
- key: "option+p",
1322
+ key: "alt+p",
1315
1323
  desc: "Preview session transcript",
1316
1324
  preventDefault: true,
1317
1325
  cmd: () => openSidebarPreview(entry),
1318
1326
  },
1319
1327
  {
1320
- key: "ctrl+x",
1328
+ key: "alt+d",
1321
1329
  desc: "Delete session",
1322
1330
  preventDefault: true,
1323
1331
  cmd: () => {
@@ -1355,7 +1363,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1355
1363
  cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
1356
1364
  },
1357
1365
  {
1358
- key: "option+p",
1366
+ key: "alt+p",
1359
1367
  desc: "Preview session transcript",
1360
1368
  preventDefault: true,
1361
1369
  cmd: () => {
@@ -1364,7 +1372,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1364
1372
  },
1365
1373
  },
1366
1374
  {
1367
- key: "ctrl+x",
1375
+ key: "alt+d",
1368
1376
  desc: "Delete session",
1369
1377
  preventDefault: true,
1370
1378
  cmd: () => {
@@ -1495,6 +1503,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1495
1503
  </Show>
1496
1504
  </box>
1497
1505
  <scrollbox
1506
+ ref={(node: { scrollTop: number }) => (sidebarScroll = node)}
1498
1507
  flexGrow={1}
1499
1508
  flexShrink={1}
1500
1509
  verticalScrollbarOptions={{ visible: false }}
@@ -1567,7 +1576,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1567
1576
  matchColor={theme().warning}
1568
1577
  />
1569
1578
  <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
1570
- {isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
1579
+ {isPendingDelete(row.entry.id) ? "option+d again" : ago(row.entry.updated)}
1571
1580
  </text>
1572
1581
  </box>
1573
1582
  )
@@ -1582,7 +1591,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1582
1591
  </Show>
1583
1592
  <Show when={navActive()}>
1584
1593
  <box paddingTop={1}>
1585
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
1594
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · option+d delete · esc done</text>
1586
1595
  </box>
1587
1596
  </Show>
1588
1597
  </Show>
@@ -1734,7 +1743,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1734
1743
  priority: 25,
1735
1744
  bindings: [
1736
1745
  { key: "y", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1737
- { key: "ctrl+x", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1746
+ { key: "alt+d", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1738
1747
  { key: "enter", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
1739
1748
  { key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
1740
1749
  { key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
@@ -1784,13 +1793,13 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1784
1793
  priority: 20,
1785
1794
  bindings: [
1786
1795
  {
1787
- key: "option+p",
1796
+ key: "alt+p",
1788
1797
  desc: "Preview session transcript",
1789
1798
  preventDefault: true,
1790
1799
  cmd: () => preview.open(entry),
1791
1800
  },
1792
1801
  {
1793
- key: "ctrl+x",
1802
+ key: "alt+d",
1794
1803
  desc: "Delete session",
1795
1804
  preventDefault: true,
1796
1805
  cmd: () => {
@@ -1843,88 +1852,90 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1843
1852
  })
1844
1853
 
1845
1854
  return (
1846
- <box 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() ? "▏" : ""}
1855
+ <box width="100%" alignItems="center">
1856
+ <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
1857
+ <Show
1858
+ when={entries().length > 0}
1859
+ fallback={
1860
+ <box flexDirection="row" gap={1}>
1861
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1862
+ <text style={{ fg: theme().textMuted }}>
1863
+ {loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
1884
1864
  </text>
1885
1865
  </box>
1886
- <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) => (
1866
+ }
1867
+ >
1868
+ <box flexDirection="row" justifyContent="space-between" gap={2}>
1869
+ <box flexDirection="row" gap={1}>
1870
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1871
+ <text style={{ fg: theme().textMuted }}>· option+o for all</text>
1872
+ </box>
1895
1873
  <box
1896
1874
  flexDirection="row"
1897
1875
  gap={1}
1898
- 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
- }
1876
+ paddingLeft={1}
1877
+ paddingRight={1}
1878
+ backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1879
+ onMouseDown={() => {
1880
+ insideSearchBox = true
1881
+ setSearching(true)
1882
+ }}
1906
1883
  >
1907
- <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>
1884
+ <box width={24}>
1885
+ <text
1886
+ flexGrow={1}
1887
+ flexShrink={1}
1888
+ overflow="hidden"
1889
+ wrapMode="none"
1890
+ style={{ fg: query() ? theme().text : theme().textMuted }}
1891
+ >
1892
+ {query() || "search…"}
1893
+ {searching() ? "▏" : ""}
1894
+ </text>
1895
+ </box>
1896
+ <Show when={query()}>
1897
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
1898
+ ✕
1899
+ </text>
1900
+ </Show>
1921
1901
  </box>
1922
- )}
1923
- </For>
1924
- <Show when={visible().length === 0}>
1925
- <text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
1902
+ </box>
1903
+ <For each={visible()}>
1904
+ {(entry) => (
1905
+ <box
1906
+ flexDirection="row"
1907
+ gap={1}
1908
+ backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1909
+ onMouseOver={() => setHovered(entry.id)}
1910
+ onMouseOut={() => setHovered(undefined)}
1911
+ onMouseDown={(event: { button?: number }) =>
1912
+ event.button === RIGHT_BUTTON
1913
+ ? openInCmuxWorkspace(props.api, entry)
1914
+ : props.api.route.navigate("session", { sessionID: entry.id })
1915
+ }
1916
+ >
1917
+ <text flexShrink={0} style={{ fg: theme().textMuted }}>
1918
+ ○
1919
+ </text>
1920
+ <Highlighted
1921
+ text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
1922
+ query={query()}
1923
+ color={theme().text}
1924
+ matchColor={theme().warning}
1925
+ />
1926
+ <text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
1927
+ {pendingDelete() === entry.id
1928
+ ? "option+d again"
1929
+ : `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
1930
+ </text>
1931
+ </box>
1932
+ )}
1933
+ </For>
1934
+ <Show when={visible().length === 0}>
1935
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
1936
+ </Show>
1926
1937
  </Show>
1927
- </Show>
1938
+ </box>
1928
1939
  </box>
1929
1940
  )
1930
1941
  }
@@ -2102,7 +2113,7 @@ const tui: TuiPlugin = async (api) => {
2102
2113
  // lets it size to content, so a taller picker drifts downward. Pin the
2103
2114
  // content height and offset it up by the same amount to keep it centred.
2104
2115
  const dialogWidth = Math.min(116, api.renderer.width - 2)
2105
- const CHROME_ROWS = 12
2116
+ const CHROME_ROWS = 13
2106
2117
  const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
2107
2118
  const dialogHeight = () => listHeight() + CHROME_ROWS
2108
2119
  const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1))
@@ -2113,6 +2124,7 @@ const tui: TuiPlugin = async (api) => {
2113
2124
  ))
2114
2125
 
2115
2126
  let previousSelection: Entry[] = []
2127
+ let deletedAt: number | undefined
2116
2128
  createEffect(() => {
2117
2129
  const rows = selectableEntries()
2118
2130
  const position = rows.findIndex((entry) => entry.id === previousSelection[cursor()]?.id)
@@ -2120,12 +2132,17 @@ const tui: TuiPlugin = async (api) => {
2120
2132
  rows.some((entry, index) => entry.id !== previousSelection[index]?.id))) {
2121
2133
  // A search/filter/collapse changed the tree: reveal the retained row or
2122
2134
  // start at the first result rather than leaving a stale window open.
2123
- const next = position >= 0 ? position : 0
2124
- setCursor(next)
2125
- const id = rows[next]?.id
2126
- setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(
2127
- (row) => row.kind === "item" && row.entry.id === id,
2128
- )))
2135
+ if (deletedAt !== undefined) {
2136
+ setCursor(Math.max(0, Math.min(deletedAt, rows.length - 1)))
2137
+ deletedAt = undefined
2138
+ } else {
2139
+ const next = position >= 0 ? position : 0
2140
+ setCursor(next)
2141
+ const id = rows[next]?.id
2142
+ setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(
2143
+ (row) => row.kind === "item" && row.entry.id === id,
2144
+ )))
2145
+ }
2129
2146
  } else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
2130
2147
  previousSelection = rows
2131
2148
  })
@@ -2258,6 +2275,8 @@ const tui: TuiPlugin = async (api) => {
2258
2275
  api.ui.toast({ message: "Session was not deleted", variant: "error" })
2259
2276
  return
2260
2277
  }
2278
+ deletedAt = selectableEntries().findIndex((item) => item.id === entry.id)
2279
+ if (deletedAt < 0) deletedAt = undefined
2261
2280
  setAllEntries(remaining)
2262
2281
  api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
2263
2282
  if (currentSessionID() === entry.id) api.route.navigate("home")
@@ -2283,7 +2302,7 @@ const tui: TuiPlugin = async (api) => {
2283
2302
  },
2284
2303
  },
2285
2304
  {
2286
- key: "ctrl+x",
2305
+ key: "alt+d",
2287
2306
  desc: "Confirm delete",
2288
2307
  preventDefault: true,
2289
2308
  cmd: () => {
@@ -2332,6 +2351,7 @@ const tui: TuiPlugin = async (api) => {
2332
2351
  const disposeNav = api.keymap.registerLayer({
2333
2352
  // Outrank the built-in dialog layer so Escape reaches this picker before
2334
2353
  // the dialog host closes the whole thing.
2354
+ mode: "global",
2335
2355
  priority: 1,
2336
2356
  bindings: [
2337
2357
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
@@ -2354,7 +2374,7 @@ const tui: TuiPlugin = async (api) => {
2354
2374
  choose()
2355
2375
  },
2356
2376
  },
2357
- { key: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
2377
+ { key: "alt+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
2358
2378
  { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
2359
2379
  const entry = selectableEntries()[cursor()]
2360
2380
  if (entry) onTogglePin("sessions", entry.id)
@@ -2364,7 +2384,7 @@ const tui: TuiPlugin = async (api) => {
2364
2384
  if (entry) onTogglePin("directories", entry.dir)
2365
2385
  } },
2366
2386
  {
2367
- key: "ctrl+x",
2387
+ key: "alt+d",
2368
2388
  desc: "Delete session",
2369
2389
  preventDefault: true,
2370
2390
  cmd: () => {
@@ -2392,8 +2412,8 @@ const tui: TuiPlugin = async (api) => {
2392
2412
  else if (target) setScope(target.group)
2393
2413
  },
2394
2414
  },
2395
- { key: "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) },
2415
+ { key: "alt+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
2416
+ { key: "alt+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
2397
2417
  {
2398
2418
  key: "escape",
2399
2419
  desc: "Close",
@@ -2419,6 +2439,7 @@ const tui: TuiPlugin = async (api) => {
2419
2439
  flexDirection="column"
2420
2440
  height={dialogHeight()}
2421
2441
  marginTop={dialogOffset()}
2442
+ paddingTop={1}
2422
2443
  paddingBottom={1}
2423
2444
  gap={1}
2424
2445
  backgroundColor={api.theme.current.backgroundPanel}
@@ -2455,8 +2476,21 @@ const tui: TuiPlugin = async (api) => {
2455
2476
  handleEscape()
2456
2477
  return
2457
2478
  }
2479
+ if (event.ctrl && event.name === "x") {
2480
+ event.preventDefault()
2481
+ if (confirmArmed()) return
2482
+ const target = selectableEntries()[cursor()]
2483
+ if (target) armDelete(target)
2484
+ return
2485
+ }
2486
+ if (event.ctrl && event.name === "f") {
2487
+ event.preventDefault()
2488
+ const target = selectableEntries()[cursor()]
2489
+ if (target) void forkEntry(target)
2490
+ return
2491
+ }
2458
2492
  if (pendingDelete()) {
2459
- if (event.name === "y" || (event.ctrl && event.name === "x") || event.name === "enter" || event.name === "return") {
2493
+ if (event.name === "y" || event.name === "enter" || event.name === "return") {
2460
2494
  event.preventDefault()
2461
2495
  confirmArmed()
2462
2496
  return
@@ -2518,7 +2552,7 @@ const tui: TuiPlugin = async (api) => {
2518
2552
  paddingRight={4}
2519
2553
  backgroundColor={
2520
2554
  row.entry.id === selectableEntries()[cursor()]?.id
2521
- ? api.theme.current.primary
2555
+ ? api.theme.current.backgroundElement
2522
2556
  : RGBA.fromInts(0, 0, 0, 0)
2523
2557
  }
2524
2558
  onMouseMove={() => {
@@ -2533,7 +2567,7 @@ const tui: TuiPlugin = async (api) => {
2533
2567
  >
2534
2568
  <box flexDirection="row" gap={1}>
2535
2569
  <text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
2536
- ? api.theme.current.selectedListItemText : api.theme.current.textMuted }}>
2570
+ ? api.theme.current.text : api.theme.current.textMuted }}>
2537
2571
  {row.last ? "└" : "├"}
2538
2572
  </text>
2539
2573
  <Show when={row.entry.id === currentSessionID()}>
@@ -2542,7 +2576,7 @@ const tui: TuiPlugin = async (api) => {
2542
2576
  style={{
2543
2577
  fg:
2544
2578
  row.entry.id === selectableEntries()[cursor()]?.id
2545
- ? api.theme.current.selectedListItemText
2579
+ ? api.theme.current.text
2546
2580
  : api.theme.current.primary,
2547
2581
  }}
2548
2582
  >
@@ -2555,12 +2589,12 @@ const tui: TuiPlugin = async (api) => {
2555
2589
  bold={row.entry.id === selectableEntries()[cursor()]?.id}
2556
2590
  color={
2557
2591
  row.entry.id === selectableEntries()[cursor()]?.id
2558
- ? api.theme.current.selectedListItemText
2592
+ ? api.theme.current.text
2559
2593
  : api.theme.current.text
2560
2594
  }
2561
2595
  matchColor={
2562
2596
  row.entry.id === selectableEntries()[cursor()]?.id
2563
- ? api.theme.current.selectedListItemText
2597
+ ? api.theme.current.text
2564
2598
  : api.theme.current.warning
2565
2599
  }
2566
2600
  />
@@ -2571,7 +2605,7 @@ const tui: TuiPlugin = async (api) => {
2571
2605
  pendingDelete()?.id === row.entry.id
2572
2606
  ? api.theme.current.error
2573
2607
  : row.entry.id === selectableEntries()[cursor()]?.id
2574
- ? api.theme.current.selectedListItemText
2608
+ ? api.theme.current.text
2575
2609
  : api.theme.current.textMuted,
2576
2610
  }}
2577
2611
  >
@@ -2586,13 +2620,13 @@ const tui: TuiPlugin = async (api) => {
2586
2620
  </text>
2587
2621
  </box>
2588
2622
  <Show when={excerpt()}>
2589
- {(match) => (
2623
+ {(match: Accessor<string>) => (
2590
2624
  <box flexDirection="row" gap={1} paddingLeft={2}>
2591
2625
  <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>↳</text>
2592
2626
  <Highlighted
2593
2627
  text={match()}
2594
2628
  query={query()}
2595
- color={row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.selectedListItemText : api.theme.current.textMuted}
2629
+ color={row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.text : api.theme.current.textMuted}
2596
2630
  matchColor={api.theme.current.warning}
2597
2631
  />
2598
2632
  </box>
@@ -2633,7 +2667,7 @@ const tui: TuiPlugin = async (api) => {
2633
2667
  : "↑↓ move · enter open · ctrl+g project · option+w input · option+s pinned · option+p preview"}
2634
2668
  </text>
2635
2669
  <Show when={!pendingDelete()}>
2636
- <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2670
+ <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · option+d delete · ctrl+f fork · esc close</text>
2637
2671
  </Show>
2638
2672
  </box>
2639
2673
  <Show when={showPreview()}>
@@ -2644,7 +2678,7 @@ const tui: TuiPlugin = async (api) => {
2644
2678
  width={dialogWidth}
2645
2679
  height={dialogHeight()}
2646
2680
  flexDirection="column"
2647
- backgroundColor={api.theme.current.backgroundElement}
2681
+ backgroundColor={api.theme.current.backgroundPanel}
2648
2682
  paddingLeft={4}
2649
2683
  paddingRight={4}
2650
2684
  paddingTop={1}
@@ -2665,7 +2699,7 @@ const tui: TuiPlugin = async (api) => {
2665
2699
  <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
2666
2700
  <Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
2667
2701
  <Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
2668
- {(text) =>
2702
+ {(text: Accessor<string>) =>
2669
2703
  getMarkdownStyle(api.theme.current) ? (
2670
2704
  <markdown
2671
2705
  content={text()}
@@ -2710,9 +2744,9 @@ SELECT s.directory AS directory,
2710
2744
  ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
2711
2745
  ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
2712
2746
  COUNT(DISTINCT s.id) AS sessions
2713
- FROM message m
2714
- JOIN session s ON s.id = m.session_id
2715
- WHERE json_extract(m.data, '$.role') = 'assistant'
2747
+ FROM session_message m
2748
+ JOIN session_v2 s ON s.id = m.session_id
2749
+ WHERE m.type = 'assistant'
2716
2750
  AND json_extract(m.data, '$.cost') IS NOT NULL
2717
2751
  GROUP BY s.directory
2718
2752
  ORDER BY lifetime_cost DESC`).all() ?? []) as {
@@ -2759,7 +2793,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2759
2793
  })
2760
2794
  api.ui.dialog.replace(
2761
2795
  () => (
2762
- <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2796
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
2763
2797
  <text attributes={TextAttributes.BOLD}>
2764
2798
  Cost digest{" "}
2765
2799
  <span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
@@ -2872,7 +2906,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2872
2906
  const home = process.env.HOME ?? ""
2873
2907
  api.ui.dialog.replace(
2874
2908
  () => (
2875
- <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2909
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1} backgroundColor={api.theme.current.backgroundPanel}>
2876
2910
  <text attributes={TextAttributes.BOLD}>
2877
2911
  Needs input{" "}
2878
2912
  <span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
@@ -2887,7 +2921,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2887
2921
  paddingRight={1}
2888
2922
  backgroundColor={
2889
2923
  index() === cursor()
2890
- ? api.theme.current.primary
2924
+ ? api.theme.current.backgroundElement
2891
2925
  : RGBA.fromInts(0, 0, 0, 0)
2892
2926
  }
2893
2927
  onMouseDown={() => {
@@ -2902,7 +2936,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2902
2936
  style={{
2903
2937
  fg:
2904
2938
  index() === cursor()
2905
- ? api.theme.current.selectedListItemText
2939
+ ? api.theme.current.text
2906
2940
  : api.theme.current.text,
2907
2941
  }}
2908
2942
  >
@@ -2913,7 +2947,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2913
2947
  style={{
2914
2948
  fg:
2915
2949
  index() === cursor()
2916
- ? api.theme.current.selectedListItemText
2950
+ ? api.theme.current.text
2917
2951
  : api.theme.current.warning,
2918
2952
  }}
2919
2953
  >
@@ -2924,7 +2958,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2924
2958
  style={{
2925
2959
  fg:
2926
2960
  index() === cursor()
2927
- ? api.theme.current.selectedListItemText
2961
+ ? api.theme.current.text
2928
2962
  : api.theme.current.textMuted,
2929
2963
  }}
2930
2964
  >
@@ -2980,8 +3014,285 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2980
3014
  ])
2981
3015
 
2982
3016
  api.keymap.registerLayer({
2983
- bindings: [{ key: "option+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
3017
+ bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2984
3018
  })
2985
3019
  }
2986
3020
 
2987
- export default { id: "sesh-panel", tui }
3021
+ let hostOwner: ReturnType<typeof getOwner> = null
3022
+
3023
+ function adaptTheme(theme: Plugin.Context["theme"]) {
3024
+ const opaque = (color: RGBA) => RGBA.fromValues(color.r, color.g, color.b, 1)
3025
+ return {
3026
+ text: theme.text.base,
3027
+ textMuted: theme.text.muted,
3028
+ background: theme.background.base,
3029
+ backgroundPanel: opaque(theme.background.raised.base),
3030
+ backgroundElement: opaque(theme.background.raised.high),
3031
+ border: theme.border.base,
3032
+ borderActive: theme.hue.accent[200],
3033
+ borderSubtle: theme.border.base,
3034
+ accent: theme.hue.accent[200],
3035
+ primary: theme.hue.accent[200],
3036
+ secondary: theme.text.action.secondary.base,
3037
+ error: theme.text.feedback.error.base,
3038
+ warning: theme.text.feedback.warning.base,
3039
+ success: theme.text.feedback.success.base,
3040
+ info: theme.text.feedback.info.base,
3041
+ selectedListItemText: theme.text.base,
3042
+ syntaxComment: theme.syntax.comment,
3043
+ syntaxString: theme.syntax.string,
3044
+ syntaxNumber: theme.syntax.number,
3045
+ syntaxKeyword: theme.syntax.keyword,
3046
+ syntaxType: theme.syntax.type,
3047
+ syntaxFunction: theme.syntax.function,
3048
+ syntaxOperator: theme.syntax.operator,
3049
+ syntaxVariable: theme.syntax.variable,
3050
+ syntaxPunctuation: theme.syntax.punctuation,
3051
+ markdownLink: theme.markdown.link,
3052
+ markdownLinkText: theme.markdown.linkText,
3053
+ markdownBlockQuote: theme.markdown.blockQuote,
3054
+ }
3055
+ }
3056
+
3057
+ function createApi(ctx: Plugin.Context): TuiPluginApi {
3058
+ const [dialogOpen, setDialogOpen] = createSignal(false)
3059
+ let sourceTheme: Plugin.Context["theme"] | undefined
3060
+ let currentTheme: ReturnType<typeof adaptTheme>
3061
+ return {
3062
+ theme: {
3063
+ get current() {
3064
+ if (sourceTheme !== ctx.theme) {
3065
+ sourceTheme = ctx.theme
3066
+ currentTheme = adaptTheme(sourceTheme)
3067
+ }
3068
+ return currentTheme!
3069
+ },
3070
+ },
3071
+ ui: {
3072
+ toast(opts: { message: string; variant?: "info" | "warning" | "error" | "success" }) {
3073
+ ctx.ui.toast.show({
3074
+ title: "Session",
3075
+ message: opts.message,
3076
+ variant: opts.variant ?? "info",
3077
+ })
3078
+ },
3079
+ dialog: {
3080
+ clear() {
3081
+ ctx.ui.dialog.clear()
3082
+ setDialogOpen(false)
3083
+ },
3084
+ replace(render: () => any) {
3085
+ setDialogOpen(true)
3086
+ ctx.ui.dialog.show(render, () => setDialogOpen(false))
3087
+ },
3088
+ setSize(size: string) {
3089
+ ctx.ui.dialog.set({ size: size as any })
3090
+ },
3091
+ // V2's Dialog has no reactive open/isOpen accessor; track it locally
3092
+ // from the replace/clear calls above and the host's onClose callback.
3093
+ get open() {
3094
+ return dialogOpen()
3095
+ },
3096
+ },
3097
+ },
3098
+ route: {
3099
+ get current() {
3100
+ const r = ctx.ui.router.current()
3101
+ if (r.type === "session") {
3102
+ return { name: "session", params: { sessionID: r.sessionID } }
3103
+ }
3104
+ return { name: r.type, params: {} }
3105
+ },
3106
+ navigate(name: string, params?: { sessionID?: string }) {
3107
+ if (name === "session" && params?.sessionID) {
3108
+ ctx.ui.router.navigate({ type: "session", sessionID: params.sessionID })
3109
+ } else if (name === "home") {
3110
+ ctx.ui.router.navigate({ type: "home" })
3111
+ }
3112
+ },
3113
+ },
3114
+ state: {
3115
+ path: {
3116
+ get directory() {
3117
+ return ctx.location?.directory ?? process.cwd()
3118
+ },
3119
+ },
3120
+ session: {
3121
+ status(id: string) {
3122
+ return ctx.data.session.status(id)
3123
+ },
3124
+ },
3125
+ },
3126
+ mode: {
3127
+ current() {
3128
+ return ctx.keymap.mode.current()
3129
+ },
3130
+ },
3131
+ renderer: {
3132
+ get height() {
3133
+ return ctx.renderer?.height ?? 40
3134
+ },
3135
+ get width() {
3136
+ return ctx.renderer?.width ?? 120
3137
+ },
3138
+ get root() {
3139
+ return ctx.renderer.root
3140
+ },
3141
+ get currentFocusedEditor() {
3142
+ return ctx.renderer.currentFocusedEditor
3143
+ },
3144
+ on(event: string, handler: (...args: any[]) => void) {
3145
+ ctx.renderer.on(event, handler)
3146
+ },
3147
+ off(event: string, handler: (...args: any[]) => void) {
3148
+ ctx.renderer.off(event, handler)
3149
+ },
3150
+ },
3151
+ keymap: {
3152
+ registerLayer(layer: {
3153
+ mode?: string
3154
+ priority?: number
3155
+ bindings?: Array<{ key: string; desc?: string; preventDefault?: boolean; cmd: () => void }>
3156
+ }) {
3157
+ return runWithOwner(hostOwner, () => createRoot((dispose) => {
3158
+ ctx.keymap.layer(() => ({
3159
+ mode: layer.mode ?? "base",
3160
+ priority: layer.priority ?? 20,
3161
+ commands: (layer.bindings ?? []).map((b, i) => ({
3162
+ id: `sesh-cmd-${Math.random().toString(36).slice(2)}-${i}`,
3163
+ title: b.desc,
3164
+ bind: b.key,
3165
+ run: () => {
3166
+ b.cmd()
3167
+ },
3168
+ })),
3169
+ }))
3170
+ return dispose
3171
+ }))
3172
+ },
3173
+ },
3174
+ slots: {
3175
+ register(cfg: { order?: number; slots: Record<string, () => any> }) {
3176
+ if (cfg.slots.sidebar_content) {
3177
+ ctx.ui.slot({
3178
+ prepend: "sidebar.content",
3179
+ render: cfg.slots.sidebar_content as any,
3180
+ })
3181
+ }
3182
+ if (cfg.slots.home_bottom) {
3183
+ ctx.ui.slot({
3184
+ prepend: "home.footer",
3185
+ render: cfg.slots.home_bottom as any,
3186
+ })
3187
+ }
3188
+ },
3189
+ },
3190
+ command: {
3191
+ register(factory: () => Array<{
3192
+ title: string
3193
+ value: string
3194
+ description?: string
3195
+ category?: string
3196
+ slash?: { name: string }
3197
+ onSelect: () => void
3198
+ }>) {
3199
+ ctx.keymap.layer(() => ({
3200
+ mode: "global",
3201
+ priority: 20,
3202
+ commands: factory().map((cmd) => ({
3203
+ id: cmd.value,
3204
+ title: cmd.title,
3205
+ description: cmd.description,
3206
+ group: cmd.category,
3207
+ palette: true,
3208
+ slash: cmd.slash ? { name: cmd.slash.name } : undefined,
3209
+ run: () => {
3210
+ cmd.onSelect()
3211
+ },
3212
+ })),
3213
+ }))
3214
+ },
3215
+ },
3216
+ client: {
3217
+ project: {
3218
+ async list() {
3219
+ const res: any = await ctx.client.project.list()
3220
+ return { data: Array.isArray(res) ? res : (res?.data ?? []) }
3221
+ },
3222
+ },
3223
+ session: {
3224
+ async delete(req: { sessionID: string; directory?: string }) {
3225
+ await ctx.client.session.remove({ sessionID: req.sessionID })
3226
+ return { data: undefined }
3227
+ },
3228
+ async fork(req: { path: { id: string } }) {
3229
+ const res: any = await ctx.client.session.fork({ sessionID: req.path.id })
3230
+ return { data: res?.data ?? res }
3231
+ },
3232
+ async messages(req: { path?: { id?: string }; sessionID?: string }) {
3233
+ const sessionID = req.sessionID ?? req.path?.id ?? ""
3234
+ const res = await ctx.client.message.list({ sessionID, order: "asc", limit: 100 })
3235
+ const messages = (res.data ?? []).map((m: any) => {
3236
+ let text = ""
3237
+ if (m.type === "user") text = m.text ?? ""
3238
+ else if (m.type === "assistant") {
3239
+ text = (m.content ?? [])
3240
+ .filter((c: any) => c.type === "text")
3241
+ .map((c: any) => c.text)
3242
+ .join("\n")
3243
+ }
3244
+ return {
3245
+ id: m.id,
3246
+ info: { role: m.type },
3247
+ parts: [{ type: "text", text }],
3248
+ }
3249
+ })
3250
+ return { data: messages }
3251
+ },
3252
+ },
3253
+ experimental: {
3254
+ session: {
3255
+ async list(input: any) {
3256
+ // V1 paged the global list with a numeric `time.updated` cursor and a
3257
+ // descending order. V2 returns an opaque cursor per location, so
3258
+ // fetch once and page in memory to keep the caller's loop intact.
3259
+ const limit = Number(input?.limit) > 0 ? Number(input.limit) : 200
3260
+ const cursor = typeof input?.cursor === "number" ? input.cursor : undefined
3261
+ const res: any = await ctx.client.session.list({ limit: 5000, order: "desc" })
3262
+ const all: any[] = Array.isArray(res) ? res : (res?.data ?? [])
3263
+ const normalized = all
3264
+ .map((s: any) => ({
3265
+ ...s,
3266
+ directory: s.directory ?? s.location?.directory ?? "",
3267
+ }))
3268
+ .sort((a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
3269
+ const page =
3270
+ cursor === undefined
3271
+ ? normalized.slice(0, limit)
3272
+ : normalized.filter((s: any) => (s.time?.updated ?? 0) < cursor).slice(0, limit)
3273
+ return { data: page }
3274
+ },
3275
+ },
3276
+ },
3277
+ },
3278
+ } as any
3279
+ }
3280
+
3281
+ export default Plugin.define({
3282
+ id: "sesh-panel",
3283
+ async setup(ctx) {
3284
+ const api = createApi(ctx)
3285
+ let initialized = false
3286
+ return ctx.ui.slot({
3287
+ append: "app",
3288
+ render: () => {
3289
+ if (!initialized) {
3290
+ initialized = true
3291
+ hostOwner = getOwner()
3292
+ void tui(api)
3293
+ }
3294
+ return <box />
3295
+ },
3296
+ })
3297
+ },
3298
+ })