herm-tui 1.0.0-dev.1 → 1.0.0-dev.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db.worker.js +81 -0
- package/highlights-eq9cgrbb.scm +604 -0
- package/highlights-ghv9g403.scm +205 -0
- package/highlights-hk7bwhj4.scm +284 -0
- package/highlights-r812a2qc.scm +150 -0
- package/highlights-x6tmsnaa.scm +115 -0
- package/index.js +10374 -0
- package/injections-73j83es3.scm +27 -0
- package/package.json +14 -64
- package/parser.worker.js +8 -0
- package/tree-sitter-3jzf13jk.wasm +0 -0
- package/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/scripts/postinstall.ts +0 -29
- package/src/app/gateway.tsx +0 -83
- package/src/app/gatewayEvents.ts +0 -203
- package/src/app/launch.ts +0 -41
- package/src/app/skin.tsx +0 -31
- package/src/app/spawnHistory.ts +0 -75
- package/src/app/tabs.ts +0 -23
- package/src/app/turnReducer.ts +0 -390
- package/src/app/useAppKeys.ts +0 -268
- package/src/app/useAtRefPopover.ts +0 -99
- package/src/app/useInputHistory.ts +0 -66
- package/src/app/useSession.ts +0 -102
- package/src/app/useSlashCommands.ts +0 -70
- package/src/app/useSlashPopover.ts +0 -48
- package/src/app.tsx +0 -917
- package/src/commands/slash.ts +0 -151
- package/src/components/avatar/AnimatedAvatar.tsx +0 -66
- package/src/components/avatar/eikon.ts +0 -144
- package/src/components/avatar/states/error.ts +0 -1155
- package/src/components/avatar/states/idle.ts +0 -1155
- package/src/components/avatar/states/index.ts +0 -30
- package/src/components/avatar/states/listening.ts +0 -1155
- package/src/components/avatar/states/speaking.ts +0 -1155
- package/src/components/avatar/states/thinking.ts +0 -1155
- package/src/components/avatar/states/working.ts +0 -1155
- package/src/components/chat/AtRefPopover.tsx +0 -54
- package/src/components/chat/CodeBlock.tsx +0 -67
- package/src/components/chat/Composer.tsx +0 -347
- package/src/components/chat/DiffBlock.tsx +0 -116
- package/src/components/chat/ErrorBlock.tsx +0 -70
- package/src/components/chat/MediaChip.tsx +0 -114
- package/src/components/chat/MessageItem.tsx +0 -282
- package/src/components/chat/MessageList.tsx +0 -114
- package/src/components/chat/PromptCard.tsx +0 -359
- package/src/components/chat/SlashPopover.tsx +0 -158
- package/src/components/chat/ThoughtCloud.tsx +0 -185
- package/src/components/chat/TypingIndicator.tsx +0 -25
- package/src/components/chat/tool/Subagent.tsx +0 -75
- package/src/components/chat/tool/frame.tsx +0 -69
- package/src/components/chat/tool/index.tsx +0 -65
- package/src/components/chat/tool/preview.ts +0 -57
- package/src/components/sidebar/ContextGauge.tsx +0 -102
- package/src/components/sidebar/Sidebar.tsx +0 -143
- package/src/components/tabs/TabBar.tsx +0 -50
- package/src/components/ui/FileLink.tsx +0 -52
- package/src/config/index.ts +0 -156
- package/src/config/lane.ts +0 -161
- package/src/config/models.ts +0 -95
- package/src/config/rules.ts +0 -80
- package/src/config/schema.ts +0 -308
- package/src/dialogs/alert.tsx +0 -52
- package/src/dialogs/chafa.tsx +0 -72
- package/src/dialogs/confirm.tsx +0 -58
- package/src/dialogs/curator.tsx +0 -153
- package/src/dialogs/eikon-picker.tsx +0 -95
- package/src/dialogs/help.tsx +0 -80
- package/src/dialogs/history.tsx +0 -92
- package/src/dialogs/info.tsx +0 -115
- package/src/dialogs/keys.tsx +0 -170
- package/src/dialogs/logs.tsx +0 -42
- package/src/dialogs/message.tsx +0 -38
- package/src/dialogs/model-picker.tsx +0 -123
- package/src/dialogs/new-profile.tsx +0 -69
- package/src/dialogs/new-task.tsx +0 -103
- package/src/dialogs/profile.tsx +0 -55
- package/src/dialogs/rollback.tsx +0 -190
- package/src/dialogs/spawn-history.tsx +0 -80
- package/src/dialogs/text-prompt.tsx +0 -68
- package/src/dialogs/theme-picker.tsx +0 -50
- package/src/home/index.ts +0 -23
- package/src/home/store.ts +0 -267
- package/src/index.tsx +0 -113
- package/src/keys/catalog.ts +0 -115
- package/src/keys/chord.ts +0 -125
- package/src/keys/conflicts.ts +0 -48
- package/src/keys/context.tsx +0 -112
- package/src/keys/index.ts +0 -5
- package/src/keys/list.ts +0 -94
- package/src/keys/oc-compat.ts +0 -87
- package/src/tabs/Agents.tsx +0 -607
- package/src/tabs/Analytics.tsx +0 -154
- package/src/tabs/Chat.tsx +0 -50
- package/src/tabs/Config.tsx +0 -605
- package/src/tabs/Context.tsx +0 -599
- package/src/tabs/Cron.tsx +0 -294
- package/src/tabs/Env.tsx +0 -227
- package/src/tabs/Kanban.tsx +0 -367
- package/src/tabs/Memory.tsx +0 -294
- package/src/tabs/Sessions.tsx +0 -786
- package/src/tabs/Skills.tsx +0 -507
- package/src/tabs/Toolsets.tsx +0 -266
- package/src/theme/builtin.ts +0 -78
- package/src/theme/context.tsx +0 -106
- package/src/theme/index.ts +0 -4
- package/src/theme/resolve.ts +0 -134
- package/src/theme/syntax.ts +0 -31
- package/src/theme/themes/aura.json +0 -69
- package/src/theme/themes/ayu.json +0 -80
- package/src/theme/themes/carbonfox.json +0 -248
- package/src/theme/themes/catppuccin-frappe.json +0 -233
- package/src/theme/themes/catppuccin-macchiato.json +0 -233
- package/src/theme/themes/catppuccin.json +0 -112
- package/src/theme/themes/cobalt2.json +0 -228
- package/src/theme/themes/cursor.json +0 -249
- package/src/theme/themes/dracula.json +0 -219
- package/src/theme/themes/everforest.json +0 -241
- package/src/theme/themes/flexoki.json +0 -237
- package/src/theme/themes/github.json +0 -233
- package/src/theme/themes/gruvbox.json +0 -242
- package/src/theme/themes/kanagawa.json +0 -77
- package/src/theme/themes/lucent-orng.json +0 -237
- package/src/theme/themes/material.json +0 -235
- package/src/theme/themes/matrix.json +0 -77
- package/src/theme/themes/mercury.json +0 -252
- package/src/theme/themes/monokai.json +0 -221
- package/src/theme/themes/nightowl.json +0 -221
- package/src/theme/themes/nord.json +0 -223
- package/src/theme/themes/one-dark.json +0 -84
- package/src/theme/themes/opencode.json +0 -245
- package/src/theme/themes/orng.json +0 -249
- package/src/theme/themes/osaka-jade.json +0 -93
- package/src/theme/themes/palenight.json +0 -222
- package/src/theme/themes/rosepine.json +0 -234
- package/src/theme/themes/solarized.json +0 -223
- package/src/theme/themes/synthwave84.json +0 -226
- package/src/theme/themes/tokyonight.json +0 -243
- package/src/theme/themes/vercel.json +0 -245
- package/src/theme/themes/vesper.json +0 -218
- package/src/theme/themes/zenburn.json +0 -223
- package/src/theme/types.ts +0 -119
- package/src/types/message.ts +0 -97
- package/src/ui/ChafaImage.tsx +0 -64
- package/src/ui/Splash.tsx +0 -118
- package/src/ui/borders.ts +0 -28
- package/src/ui/command.tsx +0 -104
- package/src/ui/dialog-select.tsx +0 -164
- package/src/ui/dialog.tsx +0 -102
- package/src/ui/fmt.ts +0 -82
- package/src/ui/kv.tsx +0 -28
- package/src/ui/shell.tsx +0 -45
- package/src/ui/spinner.tsx +0 -59
- package/src/ui/splash-art.ts +0 -123
- package/src/ui/table.tsx +0 -117
- package/src/ui/ticker.tsx +0 -90
- package/src/ui/toast.tsx +0 -130
- package/src/utils/categorical.ts +0 -77
- package/src/utils/chafa.ts +0 -173
- package/src/utils/clipboard.ts +0 -67
- package/src/utils/context-segments.ts +0 -317
- package/src/utils/control.ts +0 -495
- package/src/utils/drop.ts +0 -25
- package/src/utils/editor.ts +0 -33
- package/src/utils/fuzzy.ts +0 -45
- package/src/utils/gateway-client.ts +0 -253
- package/src/utils/gateway-types.ts +0 -282
- package/src/utils/git.ts +0 -57
- package/src/utils/hermes-analytics.ts +0 -134
- package/src/utils/hermes-home.ts +0 -821
- package/src/utils/hermes-kanban.ts +0 -154
- package/src/utils/hermes-profiles.ts +0 -217
- package/src/utils/interpolate.ts +0 -31
- package/src/utils/math-unicode.ts +0 -818
- package/src/utils/memory-activity.ts +0 -140
- package/src/utils/open-file.ts +0 -13
- package/src/utils/paths.ts +0 -52
- package/src/utils/perf.ts +0 -235
- package/src/utils/preferences.ts +0 -150
- package/src/utils/sessions-db.ts +0 -396
- package/src/utils/subagent-tree.ts +0 -146
- package/src/utils/terminal-reset.ts +0 -129
- package/src/utils/tips.ts +0 -67
- package/src/utils/tokens.ts +0 -87
package/src/tabs/Sessions.tsx
DELETED
|
@@ -1,786 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef, memo } from "react"
|
|
2
|
-
import { SIDE_PIPE } from "../ui/borders"
|
|
3
|
-
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
4
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
5
|
-
import { useKeys, handleListKey } from "../keys"
|
|
6
|
-
import * as sdb from "../utils/sessions-db"
|
|
7
|
-
import type { SessionRow, SessionHit, LineageInfo, PeekMsg } from "../utils/sessions-db"
|
|
8
|
-
import type {
|
|
9
|
-
SessionListItem, SessionListResponse,
|
|
10
|
-
} from "../utils/gateway-types"
|
|
11
|
-
import { useGateway } from "../app/gateway"
|
|
12
|
-
import { useTheme } from "../theme"
|
|
13
|
-
import { useDialog } from "../ui/dialog"
|
|
14
|
-
import { useToast } from "../ui/toast"
|
|
15
|
-
import { TabShell } from "../ui/shell"
|
|
16
|
-
import { KVBlock } from "../ui/kv"
|
|
17
|
-
import { Col, Hdr, Marquee, VBAR } from "../ui/table"
|
|
18
|
-
import { Spinner } from "../ui/spinner"
|
|
19
|
-
import { Ticker, inline } from "../ui/ticker"
|
|
20
|
-
import { openConfirm } from "../dialogs/confirm"
|
|
21
|
-
import { openTextPrompt } from "../dialogs/text-prompt"
|
|
22
|
-
import { fmt, cost, trunc, ago, when, span, stamp } from "../ui/fmt"
|
|
23
|
-
import { home } from "../home"
|
|
24
|
-
|
|
25
|
-
// Architecture: herm's Sessions tab is a **local state.db reader**.
|
|
26
|
-
// Stock tui_gateway exposes only ~30% of what the tab needs via RPC
|
|
27
|
-
// (see sessions-db.ts header). The gateway is authoritative for
|
|
28
|
-
// exactly one thing — *which session ids it can resume* — so we join
|
|
29
|
-
// session.list against the local roots() by id. Enrichment (tokens,
|
|
30
|
-
// cost, model, lineage, subagents, last_active) all comes from
|
|
31
|
-
// state.db. When state.db isn't the gateway's state.db (remote
|
|
32
|
-
// gateway, separate profile), enrichment is absent and rows render
|
|
33
|
-
// un-enriched; the tab keeps working at session.list fidelity.
|
|
34
|
-
|
|
35
|
-
type Row = SessionListItem & { detail?: SessionRow }
|
|
36
|
-
|
|
37
|
-
// ─── Formatting ──────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
const badge = (src: string): string => ({
|
|
40
|
-
cli: "CLI", tui: "TUI", api_server: "API", discord: "Discord",
|
|
41
|
-
telegram: "Telegram", slack: "Slack", whatsapp: "WhatsApp", signal: "Signal",
|
|
42
|
-
} as Record<string, string>)[src] ?? src
|
|
43
|
-
|
|
44
|
-
// ─── Transcript Peek ─────────────────────────────────────────────────
|
|
45
|
-
//
|
|
46
|
-
// Purpose: decide whether to load a session without replacing the
|
|
47
|
-
// current chat. So: conversation only. Tool chatter is collapsed to
|
|
48
|
-
// a single count in the footer — scanning "what was said" matters;
|
|
49
|
-
// which tools ran doesn't, at peek granularity.
|
|
50
|
-
//
|
|
51
|
-
// Row style mirrors the chat transcript (MessageItem's Gutter): user
|
|
52
|
-
// = left bar in theme.primary, assistant = right bar in theme.accent.
|
|
53
|
-
// One line each; hover a row to fast-marquee the clipped text.
|
|
54
|
-
|
|
55
|
-
type Folded = { role: "user" | "assistant"; text: string }
|
|
56
|
-
|
|
57
|
-
const line = (s: string | null) =>
|
|
58
|
-
(s ?? "").replace(/\s+/g, " ").trim()
|
|
59
|
-
|
|
60
|
-
/** Reduce raw PeekMsg[] to { turns, tools }. Exported for tests. */
|
|
61
|
-
export const fold = (msgs: PeekMsg[]): { turns: Folded[]; tools: number } => {
|
|
62
|
-
const turns: Folded[] = []
|
|
63
|
-
let tools = 0
|
|
64
|
-
for (const m of msgs) {
|
|
65
|
-
if (m.role === "tool") { tools++; continue }
|
|
66
|
-
if (m.role !== "user" && m.role !== "assistant") continue
|
|
67
|
-
const text = line(m.content)
|
|
68
|
-
// Assistant rows with tool_calls but no content are pure tool-
|
|
69
|
-
// invocation turns — nothing to read, counted in `tools` via
|
|
70
|
-
// their result rows.
|
|
71
|
-
if (!text) continue
|
|
72
|
-
turns.push({ role: m.role, text })
|
|
73
|
-
}
|
|
74
|
-
return { turns, tools }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const PeekRow = memo((props: { row: Folded }) => {
|
|
78
|
-
const theme = useTheme().theme
|
|
79
|
-
const [hot, setHot] = useState(false)
|
|
80
|
-
const left = props.row.role === "user"
|
|
81
|
-
const color = left ? theme.primary : theme.accent
|
|
82
|
-
const fg = left ? theme.text : theme.markdownText
|
|
83
|
-
// Width-2 box with a single-side border draws exactly "│ " / " │",
|
|
84
|
-
// matching components/chat/MessageItem Gutter at height=1.
|
|
85
|
-
const bar = (side: "left" | "right") => (
|
|
86
|
-
<box width={2} flexShrink={0} height={1}
|
|
87
|
-
border={[side]} borderColor={color}
|
|
88
|
-
customBorderChars={SIDE_PIPE} />
|
|
89
|
-
)
|
|
90
|
-
return (
|
|
91
|
-
<box height={1} flexDirection="row"
|
|
92
|
-
backgroundColor={hot ? theme.backgroundElement : undefined}
|
|
93
|
-
onMouseOver={() => setHot(true)}
|
|
94
|
-
onMouseOut={() => setHot(false)}>
|
|
95
|
-
{left ? bar("left") : null}
|
|
96
|
-
<Ticker active={hot} speed={35} hold={150} fg={fg}>
|
|
97
|
-
{inline(props.row.text).map((s, i) =>
|
|
98
|
-
s.c ? <span key={i} fg={theme.warning}>{s.t}</span>
|
|
99
|
-
: s.b ? <span key={i} fg={fg}><strong>{s.t}</strong></span>
|
|
100
|
-
: s.i ? <span key={i} fg={fg}><u>{s.t}</u></span>
|
|
101
|
-
: <span key={i} fg={fg}>{s.t}</span>)}
|
|
102
|
-
</Ticker>
|
|
103
|
-
{left ? null : bar("right")}
|
|
104
|
-
</box>
|
|
105
|
-
)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
const Peek = memo((props: { sid: string; total: number; peek: typeof sdb.peek }) => {
|
|
109
|
-
const theme = useTheme().theme
|
|
110
|
-
const [data, setData] = useState<{ turns: Folded[]; tools: number } | null>(null)
|
|
111
|
-
const sb = useRef<ScrollBoxRenderable | null>(null)
|
|
112
|
-
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
setData(fold(props.peek(props.sid, 60)))
|
|
115
|
-
}, [props.sid, props.peek])
|
|
116
|
-
// Pin to bottom on load — "where did this end up", not "how did
|
|
117
|
-
// it start".
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
if (data && sb.current) sb.current.scrollTop = sb.current.scrollHeight
|
|
120
|
-
}, [data])
|
|
121
|
-
|
|
122
|
-
if (data === null) return null
|
|
123
|
-
if (data.turns.length === 0 && data.tools === 0) return (
|
|
124
|
-
<box height={1}><text fg={theme.textMuted}>(no local transcript)</text></box>
|
|
125
|
-
)
|
|
126
|
-
const more = Math.max(0, props.total - 60)
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<box flexDirection="column" flexGrow={1} minHeight={5}
|
|
130
|
-
border borderStyle="single" borderColor={theme.border}
|
|
131
|
-
title={` Transcript${more > 0 ? ` · ${more} earlier` : ""} `}
|
|
132
|
-
titleAlignment="left">
|
|
133
|
-
<scrollbox ref={sb} scrollY flexGrow={1} minHeight={3}>
|
|
134
|
-
<box flexDirection="column" width="100%">
|
|
135
|
-
{data.turns.map((r, i) => <PeekRow key={i} row={r} />)}
|
|
136
|
-
</box>
|
|
137
|
-
</scrollbox>
|
|
138
|
-
<box height={1}>
|
|
139
|
-
<text fg={theme.textMuted}>
|
|
140
|
-
{`${data.turns.length} turn${data.turns.length === 1 ? "" : "s"} · ${data.tools} tool call${data.tools === 1 ? "" : "s"}`}
|
|
141
|
-
</text>
|
|
142
|
-
</box>
|
|
143
|
-
</box>
|
|
144
|
-
)
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
// ─── Detail Panel ────────────────────────────────────────────────────
|
|
148
|
-
//
|
|
149
|
-
// Data provenance:
|
|
150
|
-
// RPC (session.list): id, title, preview (first user msg), source,
|
|
151
|
-
// started_at, message_count
|
|
152
|
-
// state.db enrich: model, last_active, ended_at, end_reason,
|
|
153
|
-
// tokens, cost, tool_call_count, lastMessage
|
|
154
|
-
//
|
|
155
|
-
// `ended_at` is NULL for ~80% of rows — hermes-agent only sets it on
|
|
156
|
-
// clean CLI exit / compression / explicit reset, never on process
|
|
157
|
-
// kill or abandoned TUI connects. So we derive Duration and "Last
|
|
158
|
-
// active" from MAX(messages.timestamp) instead, which is always
|
|
159
|
-
// accurate, and only show the ended_at/end_reason pair when it's
|
|
160
|
-
// actually populated.
|
|
161
|
-
|
|
162
|
-
const Detail = memo((props: {
|
|
163
|
-
row: Row
|
|
164
|
-
onSwitch?: (sid: string) => void
|
|
165
|
-
lineage?: (sid: string) => LineageInfo
|
|
166
|
-
peek: (sid: string, n?: number) => PeekMsg[]
|
|
167
|
-
}) => {
|
|
168
|
-
const theme = useTheme().theme
|
|
169
|
-
const r = props.row
|
|
170
|
-
const d = r.detail
|
|
171
|
-
const lastActive = d?.last_active ?? d?.ended_at ?? null
|
|
172
|
-
const subs = d?.subagent_count ?? 0
|
|
173
|
-
// Lineage is pulled fresh on row change — query is in-process and
|
|
174
|
-
// sub-ms. Cached by row.id so the lookup doesn't fire on every render.
|
|
175
|
-
const [info, setInfo] = useState<LineageInfo>({})
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
setInfo((props.lineage ?? sdb.lineage)(r.id))
|
|
178
|
-
}, [r.id, props.lineage])
|
|
179
|
-
const hasLineage = info.continuesFrom || info.compressedTo || subs > 0
|
|
180
|
-
const go = (sid: string) => () => props.onSwitch?.(sid)
|
|
181
|
-
|
|
182
|
-
return (
|
|
183
|
-
<TabShell title="Session Detail" hint="" grow={2}>
|
|
184
|
-
<box flexDirection="column" width="100%" flexGrow={1} overflow="hidden">
|
|
185
|
-
<box flexDirection="column" flexShrink={0}>
|
|
186
|
-
<box minHeight={1}>
|
|
187
|
-
<text wrapMode="word"><span fg={theme.accent}><strong>{r.title || "Untitled"}</strong></span></text>
|
|
188
|
-
</box>
|
|
189
|
-
<box height={1} />
|
|
190
|
-
<KVBlock rows={[
|
|
191
|
-
["ID", r.id],
|
|
192
|
-
["Source", badge(r.source ?? "")],
|
|
193
|
-
["Model", d?.model ?? "—"],
|
|
194
|
-
["Started", when(r.started_at)],
|
|
195
|
-
["Last active", lastActive ? `${when(lastActive)} (${ago(lastActive)})` : "—"],
|
|
196
|
-
["Duration", lastActive ? span(r.started_at, lastActive) : "—"],
|
|
197
|
-
["Ended", d?.ended_at ? `${when(d.ended_at)} · ${d.end_reason ?? "—"}` : undefined],
|
|
198
|
-
]} />
|
|
199
|
-
<box height={1} />
|
|
200
|
-
<KVBlock rows={[
|
|
201
|
-
["Messages", String(r.message_count)],
|
|
202
|
-
["Tool calls", d ? String(d.tool_call_count) : undefined],
|
|
203
|
-
["Input", d ? `${fmt(d.input_tokens)} tok` : undefined],
|
|
204
|
-
["Output", d ? `${fmt(d.output_tokens)} tok` : undefined],
|
|
205
|
-
["Cache", d ? `${fmt(d.cache_read_tokens)} r / ${fmt(d.cache_write_tokens)} w` : undefined],
|
|
206
|
-
["Reasoning", d ? `${fmt(d.reasoning_tokens)} tok` : undefined],
|
|
207
|
-
["Cost", d ? cost(d.estimated_cost_usd) : undefined, theme.success],
|
|
208
|
-
]} />
|
|
209
|
-
{hasLineage ? <>
|
|
210
|
-
<box height={1} />
|
|
211
|
-
<box minHeight={1}><text fg={theme.textMuted}>Lineage</text></box>
|
|
212
|
-
{info.continuesFrom ? (
|
|
213
|
-
<box height={1} onMouseDown={go(info.continuesFrom.id)}>
|
|
214
|
-
<text>
|
|
215
|
-
<span fg={theme.textMuted}>{" ← continues from "}</span>
|
|
216
|
-
<span fg={theme.accent}>{info.continuesFrom.title || info.continuesFrom.id}</span>
|
|
217
|
-
</text>
|
|
218
|
-
</box>
|
|
219
|
-
) : null}
|
|
220
|
-
{info.compressedTo ? (
|
|
221
|
-
<box height={1} onMouseDown={go(info.compressedTo.id)}>
|
|
222
|
-
<text>
|
|
223
|
-
<span fg={theme.textMuted}>{" → compressed to "}</span>
|
|
224
|
-
<span fg={theme.accent}>{info.compressedTo.title || info.compressedTo.id}</span>
|
|
225
|
-
</text>
|
|
226
|
-
</box>
|
|
227
|
-
) : null}
|
|
228
|
-
{subs > 0 ? (
|
|
229
|
-
<box height={1}>
|
|
230
|
-
<text>
|
|
231
|
-
<span fg={theme.textMuted}>{" ⎇ spawned "}</span>
|
|
232
|
-
<span fg={theme.text}>{String(subs)}</span>
|
|
233
|
-
<span fg={theme.textMuted}>{` subagent${subs === 1 ? "" : "s"}`}</span>
|
|
234
|
-
</text>
|
|
235
|
-
</box>
|
|
236
|
-
) : null}
|
|
237
|
-
</> : null}
|
|
238
|
-
{!d ? <>
|
|
239
|
-
<box height={1} />
|
|
240
|
-
<box height={1}><text fg={theme.textMuted}>(no local detail — state.db mismatch)</text></box>
|
|
241
|
-
</> : null}
|
|
242
|
-
<box height={1} />
|
|
243
|
-
</box>
|
|
244
|
-
<Peek sid={r.id} total={r.message_count} peek={props.peek} />
|
|
245
|
-
</box>
|
|
246
|
-
</TabShell>
|
|
247
|
-
)
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
// ─── Search Detail Panel ─────────────────────────────────────────────
|
|
251
|
-
|
|
252
|
-
const SearchDetail = memo((props: { result: SessionHit }) => {
|
|
253
|
-
const theme = useTheme().theme
|
|
254
|
-
const r = props.result
|
|
255
|
-
|
|
256
|
-
// Render snippet with >>> <<< markers as highlights.
|
|
257
|
-
const parts: Array<{ text: string; hi: boolean }> = []
|
|
258
|
-
let rest = r.snippet
|
|
259
|
-
while (rest.length) {
|
|
260
|
-
const start = rest.indexOf(">>>")
|
|
261
|
-
if (start < 0) { parts.push({ text: rest, hi: false }); break }
|
|
262
|
-
if (start > 0) parts.push({ text: rest.slice(0, start), hi: false })
|
|
263
|
-
const end = rest.indexOf("<<<", start + 3)
|
|
264
|
-
if (end < 0) { parts.push({ text: rest.slice(start + 3), hi: true }); break }
|
|
265
|
-
parts.push({ text: rest.slice(start + 3, end), hi: true })
|
|
266
|
-
rest = rest.slice(end + 3)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return (
|
|
270
|
-
<TabShell title="Search Match" hint="" grow={2}>
|
|
271
|
-
<scrollbox scrollY flexGrow={1}>
|
|
272
|
-
<box flexDirection="column" width="100%">
|
|
273
|
-
<box minHeight={1}>
|
|
274
|
-
<text wrapMode="word"><span fg={theme.accent}><strong>{r.title ?? "Untitled"}</strong></span></text>
|
|
275
|
-
</box>
|
|
276
|
-
<box height={1} />
|
|
277
|
-
<KVBlock rows={[
|
|
278
|
-
["Source", badge(r.source)],
|
|
279
|
-
["Model", r.model ?? "—"],
|
|
280
|
-
["Time", when(r.started_at)],
|
|
281
|
-
]} />
|
|
282
|
-
<box height={1} />
|
|
283
|
-
<box height={1}><text fg={theme.textMuted}>Snippet</text></box>
|
|
284
|
-
<box minHeight={1}>
|
|
285
|
-
<text wrapMode="word">
|
|
286
|
-
{parts.map((p, i) => p.hi
|
|
287
|
-
? <span key={i} fg={theme.accent}><strong>{p.text}</strong></span>
|
|
288
|
-
: <span key={i} fg={theme.text}>{p.text}</span>
|
|
289
|
-
)}
|
|
290
|
-
</text>
|
|
291
|
-
</box>
|
|
292
|
-
</box>
|
|
293
|
-
</scrollbox>
|
|
294
|
-
</TabShell>
|
|
295
|
-
)
|
|
296
|
-
})
|
|
297
|
-
// ─── Rows ────────────────────────────────────────────────────────────
|
|
298
|
-
// Col/Hdr live in ui/table; header pads by VBAR_W so its grow column
|
|
299
|
-
// matches body rows inside the forced-visible v-bar scrollbox.
|
|
300
|
-
|
|
301
|
-
const HeaderRow = memo(() => {
|
|
302
|
-
const theme = useTheme().theme
|
|
303
|
-
const fg = theme.textMuted
|
|
304
|
-
return (
|
|
305
|
-
<Hdr>
|
|
306
|
-
<Col w={2} fg={fg}>{" "}</Col>
|
|
307
|
-
<Col grow fg={fg} bold>Title</Col>
|
|
308
|
-
<Col w={9} fg={fg} bold>Source</Col>
|
|
309
|
-
<Col w={8} fg={fg} bold>Start</Col>
|
|
310
|
-
<Col w={10} fg={fg} bold right>Active</Col>
|
|
311
|
-
<Col w={7} fg={fg} bold right>Msgs</Col>
|
|
312
|
-
<box width={3} />
|
|
313
|
-
</Hdr>
|
|
314
|
-
)
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
// Row callbacks take the index so the *functions* are stable across
|
|
318
|
-
// renders — otherwise every row gets a fresh closure every parent
|
|
319
|
-
// render and memo() never bails (O(N) React work per keystroke).
|
|
320
|
-
type RowCbs = {
|
|
321
|
-
onActivate: (i: number) => void
|
|
322
|
-
onHover: (i: number) => void
|
|
323
|
-
onDelete: (i: number) => void
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const Item = memo((props: {
|
|
327
|
-
id: string; row: Row; idx: number; selected: boolean; indent?: boolean
|
|
328
|
-
} & RowCbs) => {
|
|
329
|
-
const theme = useTheme().theme
|
|
330
|
-
const { row: r, idx: i } = props
|
|
331
|
-
const [x, setX] = useState(false)
|
|
332
|
-
const active = r.detail?.last_active ?? r.detail?.ended_at ?? null
|
|
333
|
-
// Parent rows get "▸ "/" " leaders; child rows get "└─" as the tree
|
|
334
|
-
// marker. Selected children still highlight via backgroundColor +
|
|
335
|
-
// text color — indent is the only hierarchy signal.
|
|
336
|
-
const leader = props.indent ? "└─" : (props.selected ? "▸ " : " ")
|
|
337
|
-
const muted = props.indent && !props.selected ? theme.textMuted : undefined
|
|
338
|
-
|
|
339
|
-
return (
|
|
340
|
-
<box id={props.id} flexDirection="row" height={1}
|
|
341
|
-
backgroundColor={props.selected ? theme.backgroundElement : undefined}
|
|
342
|
-
onMouseDown={() => props.onActivate(i)} onMouseMove={() => props.onHover(i)}>
|
|
343
|
-
<Col w={2} fg={props.selected ? theme.primary : (muted ?? theme.text)}>{leader}</Col>
|
|
344
|
-
<Marquee grow active={props.selected}
|
|
345
|
-
fg={props.selected ? theme.accent : (muted ?? theme.text)}
|
|
346
|
-
bold={props.selected}>
|
|
347
|
-
{r.title || "Untitled"}
|
|
348
|
-
</Marquee>
|
|
349
|
-
<Col w={9} fg={muted ?? theme.info}>{badge(r.source ?? "")}</Col>
|
|
350
|
-
<Col w={8} fg={theme.textMuted}>{stamp(r.started_at)}</Col>
|
|
351
|
-
<Col w={10} fg={theme.textMuted} right>{active ? ago(active) : "—"}</Col>
|
|
352
|
-
<Col w={7} fg={theme.textMuted} right>{String(r.message_count)}</Col>
|
|
353
|
-
{props.indent ? <box width={3} /> : (
|
|
354
|
-
<box width={3}
|
|
355
|
-
onMouseDown={(e) => { e.stopPropagation(); props.onDelete(i) }}
|
|
356
|
-
onMouseOver={() => setX(true)} onMouseOut={() => setX(false)}>
|
|
357
|
-
<text><span fg={x ? theme.error : theme.textMuted}>{" ✕"}</span></text>
|
|
358
|
-
</box>
|
|
359
|
-
)}
|
|
360
|
-
</box>
|
|
361
|
-
)
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
const SearchHeaderRow = memo(() => {
|
|
365
|
-
const theme = useTheme().theme
|
|
366
|
-
const fg = theme.textMuted
|
|
367
|
-
return (
|
|
368
|
-
<Hdr>
|
|
369
|
-
<Col w={2} fg={fg}>{" "}</Col>
|
|
370
|
-
<Col grow fg={fg} bold>Title</Col>
|
|
371
|
-
<Col w={9} fg={fg} bold>Source</Col>
|
|
372
|
-
<Col w={10} fg={fg} bold>When</Col>
|
|
373
|
-
<Col w={20} fg={fg} bold>Model</Col>
|
|
374
|
-
</Hdr>
|
|
375
|
-
)
|
|
376
|
-
})
|
|
377
|
-
|
|
378
|
-
const SearchItem = memo((props: {
|
|
379
|
-
id: string; result: SessionHit; idx: number; selected: boolean
|
|
380
|
-
onActivate: (i: number) => void; onHover: (i: number) => void
|
|
381
|
-
}) => {
|
|
382
|
-
const theme = useTheme().theme
|
|
383
|
-
const { result: r, idx: i } = props
|
|
384
|
-
return (
|
|
385
|
-
<box id={props.id} flexDirection="row" height={1}
|
|
386
|
-
backgroundColor={props.selected ? theme.backgroundElement : undefined}
|
|
387
|
-
onMouseDown={() => props.onActivate(i)} onMouseMove={() => props.onHover(i)}>
|
|
388
|
-
<Col w={2} fg={props.selected ? theme.primary : theme.text}>{props.selected ? "▸ " : " "}</Col>
|
|
389
|
-
<Col grow fg={props.selected ? theme.accent : theme.text} bold={props.selected}>
|
|
390
|
-
{r.title ?? "Untitled"}
|
|
391
|
-
</Col>
|
|
392
|
-
<Col w={9} fg={theme.info}>{badge(r.source)}</Col>
|
|
393
|
-
<Col w={10} fg={theme.textMuted}>{ago(r.started_at)}</Col>
|
|
394
|
-
<Col w={20} fg={theme.textMuted}>{r.model ?? "—"}</Col>
|
|
395
|
-
</box>
|
|
396
|
-
)
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
// ─── Main ────────────────────────────────────────────────────────────
|
|
400
|
-
|
|
401
|
-
// Data-layer ops are injectable so tests don't fight analytics.test
|
|
402
|
-
// for the shared sandbox state.db. Defaults are the real functions.
|
|
403
|
-
type IO = {
|
|
404
|
-
list: typeof sdb.roots
|
|
405
|
-
search: typeof sdb.search
|
|
406
|
-
remove: typeof sdb.remove
|
|
407
|
-
rename: typeof sdb.rename
|
|
408
|
-
subagents: typeof sdb.children
|
|
409
|
-
lineage: typeof sdb.lineage
|
|
410
|
-
peek: typeof sdb.peek
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
type Props = { focused?: boolean; currentId?: string; onSwitch?: (sid: string) => void; io?: Partial<IO> }
|
|
414
|
-
|
|
415
|
-
export const Sessions = memo((props: Props) => {
|
|
416
|
-
const theme = useTheme().theme
|
|
417
|
-
const gw = useGateway()
|
|
418
|
-
const dialog = useDialog()
|
|
419
|
-
const toast = useToast()
|
|
420
|
-
const dims = useTerminalDimensions()
|
|
421
|
-
|
|
422
|
-
const io: IO = {
|
|
423
|
-
list: props.io?.list ?? sdb.roots,
|
|
424
|
-
search: props.io?.search ?? sdb.search,
|
|
425
|
-
remove: props.io?.remove ?? sdb.remove,
|
|
426
|
-
rename: props.io?.rename ?? sdb.rename,
|
|
427
|
-
subagents: props.io?.subagents ?? sdb.children,
|
|
428
|
-
lineage: props.io?.lineage ?? sdb.lineage,
|
|
429
|
-
peek: props.io?.peek ?? sdb.peek,
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const [rows, setRows] = useState<Row[]>([])
|
|
433
|
-
const [warn, setWarn] = useState("")
|
|
434
|
-
const [pending, setPending] = useState(true)
|
|
435
|
-
// Selection is tracked by row identity so that collapsing children
|
|
436
|
-
// (which changes the flat index of every row below) never lands sel
|
|
437
|
-
// on the wrong row. The numeric index consumers use (handleListKey,
|
|
438
|
-
// rowActivate, etc.) is derived from visible[] each render.
|
|
439
|
-
const [anchor, setAnchor] = useState<{ id: string; indent: boolean } | null>(null)
|
|
440
|
-
const [searching, setSearching] = useState(false)
|
|
441
|
-
const [query, setQuery] = useState("")
|
|
442
|
-
const [results, setResults] = useState<SessionHit[]>([])
|
|
443
|
-
const [searchSel, setSearchSel] = useState(0)
|
|
444
|
-
// parent_id → children, populated at load() time for every row
|
|
445
|
-
// with subagent_count > 0 so render stays pure (no sync fetch).
|
|
446
|
-
const [kids, setKids] = useState<Map<string, Row[]>>(new Map())
|
|
447
|
-
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
448
|
-
const vscroll = useRef<ScrollBoxRenderable | null>(null)
|
|
449
|
-
|
|
450
|
-
// Expansion is derived from the anchor: if the anchor is a parent
|
|
451
|
-
// row with subagents, that parent is expanded; if the anchor is a
|
|
452
|
-
// child, the child's owning parent is expanded. Anything else = no
|
|
453
|
-
// expansion. This makes collapse/expand atomic with sel changes —
|
|
454
|
-
// no lagging effect, no clamp pass.
|
|
455
|
-
const anchored = anchor && rows.find(r => r.id === anchor.id)
|
|
456
|
-
const owner =
|
|
457
|
-
anchor?.indent
|
|
458
|
-
? rows.find(r => kids.get(r.id)?.some(c => c.id === anchor.id))
|
|
459
|
-
: (anchored?.detail?.subagent_count ?? 0) > 0 ? anchored : undefined
|
|
460
|
-
|
|
461
|
-
// Flat visible sequence = parents with `owner`'s children inlined.
|
|
462
|
-
const visible = rows.flatMap((r, i) =>
|
|
463
|
-
r.id === owner?.id
|
|
464
|
-
? [{ row: r, indent: false, parentIdx: i },
|
|
465
|
-
...(kids.get(r.id) ?? []).map(c =>
|
|
466
|
-
({ row: c, indent: true, parentIdx: i }))]
|
|
467
|
-
: [{ row: r, indent: false, parentIdx: i }])
|
|
468
|
-
|
|
469
|
-
// Resolve anchor → numeric index into visible. Fallback to 0 when
|
|
470
|
-
// the anchor row is gone (reload dropped it) or never set.
|
|
471
|
-
const sel = anchor
|
|
472
|
-
? Math.max(0, visible.findIndex(v => v.row.id === anchor.id && v.indent === anchor.indent))
|
|
473
|
-
: 0
|
|
474
|
-
|
|
475
|
-
// Latest-value refs so the stable row callbacks below don't close
|
|
476
|
-
// over stale arrays (and therefore don't need to be in their deps,
|
|
477
|
-
// which would defeat the memo).
|
|
478
|
-
const live = useRef({ rows, visible, anchor, results, searching, onSwitch: props.onSwitch, currentId: props.currentId })
|
|
479
|
-
live.current = { rows, visible, anchor, results, searching, onSwitch: props.onSwitch, currentId: props.currentId }
|
|
480
|
-
|
|
481
|
-
// Adapter for handleListKey, which speaks numeric sel. Translating
|
|
482
|
-
// through the anchor means the target row is resolved against the
|
|
483
|
-
// CURRENT visible layout at call time — collapse/expand re-renders
|
|
484
|
-
// later and sel follows the row, not the stale index.
|
|
485
|
-
const setSel: typeof setSearchSel = useCallback((arg) => {
|
|
486
|
-
const cur = live.current
|
|
487
|
-
const prev = cur.visible.findIndex(v =>
|
|
488
|
-
v.row.id === cur.anchor?.id && v.indent === cur.anchor.indent)
|
|
489
|
-
const n = typeof arg === "function" ? arg(Math.max(0, prev)) : arg
|
|
490
|
-
const v = cur.visible[Math.max(0, Math.min(cur.visible.length - 1, n))]
|
|
491
|
-
if (v) setAnchor({ id: v.row.id, indent: v.indent })
|
|
492
|
-
}, [])
|
|
493
|
-
|
|
494
|
-
const LIMIT = 2000
|
|
495
|
-
|
|
496
|
-
const toRow = (d: SessionRow): Row => ({
|
|
497
|
-
id: d.id, title: d.title ?? "", preview: d.lastMessage ?? "",
|
|
498
|
-
message_count: d.message_count, started_at: d.started_at,
|
|
499
|
-
source: d.sessionSource, detail: d,
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
// Paint fs rows synchronously (io.list is an in-process sqlite read),
|
|
503
|
-
// then reconcile with the RPC result. session.list is the slow path
|
|
504
|
-
// (2000-row limit over stdio) and is what made the tab sit empty on
|
|
505
|
-
// mount. Enrichment comes from fs either way, so the optimistic paint
|
|
506
|
-
// is visually identical when state.db == gateway's state.db — which
|
|
507
|
-
// is the common local case. Remote gateways paint [] first, then RPC;
|
|
508
|
-
// `pending` covers that gap with a spinner.
|
|
509
|
-
const fill = (list: Row[]) => {
|
|
510
|
-
setKids(new Map(list
|
|
511
|
-
.filter(r => (r.detail?.subagent_count ?? 0) > 0)
|
|
512
|
-
.map(r => [r.id, io.subagents(r.id).map(toRow)])))
|
|
513
|
-
setRows(list)
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const load = useCallback(async () => {
|
|
517
|
-
const fs = Promise.resolve().then(() => io.list(LIMIT)).catch(() => [])
|
|
518
|
-
const rpc = gw.request<SessionListResponse>("session.list", { limit: LIMIT })
|
|
519
|
-
.then(r => ({ ok: true as const, v: r }))
|
|
520
|
-
.catch((e: Error) => ({ ok: false as const, e }))
|
|
521
|
-
|
|
522
|
-
const disk = await fs
|
|
523
|
-
const local = new Map(disk.map(r => [r.id, r]))
|
|
524
|
-
fill(disk.filter(d => d.message_count > 0).map(toRow))
|
|
525
|
-
setPending(true)
|
|
526
|
-
|
|
527
|
-
// Stock session.list doesn't drop 0-msg stubs — every abandoned
|
|
528
|
-
// connect leaves one, and they're never useful to resume.
|
|
529
|
-
const r = await rpc
|
|
530
|
-
if (r.ok && r.v.sessions?.length)
|
|
531
|
-
fill(r.v.sessions
|
|
532
|
-
.filter(s => (s.message_count ?? 0) > 0)
|
|
533
|
-
.map(s => ({ ...s, detail: local.get(s.id) })))
|
|
534
|
-
setPending(false)
|
|
535
|
-
setWarn(!r.ok
|
|
536
|
-
? local.size
|
|
537
|
-
? `gateway session.list failed (${r.e.message}) — listing state.db directly; rows may not resume`
|
|
538
|
-
: r.e.message
|
|
539
|
-
: "")
|
|
540
|
-
}, [gw])
|
|
541
|
-
|
|
542
|
-
useEffect(() => { load() }, [load])
|
|
543
|
-
|
|
544
|
-
// Seed anchor once rows arrive (first row, unexpanded).
|
|
545
|
-
useEffect(() => {
|
|
546
|
-
if (!anchor && rows.length) setAnchor({ id: rows[0].id, indent: false })
|
|
547
|
-
}, [rows, anchor])
|
|
548
|
-
|
|
549
|
-
// Search is a synchronous FTS5 query on state.db, so debounce —
|
|
550
|
-
// running it on every keystroke blocks the render thread. The
|
|
551
|
-
// cleanup clears the pending timer, which also drops superseded
|
|
552
|
-
// queries for free (only the most recent query value ever runs).
|
|
553
|
-
useEffect(() => {
|
|
554
|
-
if (!searching || !query.trim()) { setResults([]); return }
|
|
555
|
-
debounce.current = setTimeout(() => {
|
|
556
|
-
setResults(io.search(query, 30))
|
|
557
|
-
setSearchSel(0)
|
|
558
|
-
}, 150)
|
|
559
|
-
return () => { if (debounce.current) clearTimeout(debounce.current) }
|
|
560
|
-
}, [query, searching])
|
|
561
|
-
|
|
562
|
-
// ── Stable row callbacks (identity never changes) ────────────────
|
|
563
|
-
// Hover-to-select is onMouseMove, not onMouseOver — the latter fires
|
|
564
|
-
// when scrollChildIntoView moves rows under a stationary cursor and
|
|
565
|
-
// would snap sel back during ↓-repeat (the "stutter"). Mouse motion
|
|
566
|
-
// events only arrive on real pointer movement.
|
|
567
|
-
const rowHover = useCallback((i: number) => {
|
|
568
|
-
live.current.searching ? setSearchSel(i) : setSel(i)
|
|
569
|
-
}, [setSel])
|
|
570
|
-
// Switching sessions reset()s the current chat; confirm unless it's
|
|
571
|
-
// a no-op (same id) or there's nothing to switch to.
|
|
572
|
-
const rowActivate = useCallback((i: number) => {
|
|
573
|
-
const l = live.current
|
|
574
|
-
l.searching ? setSearchSel(i) : setSel(i)
|
|
575
|
-
const hit = l.searching ? l.results[i] : l.visible[i]?.row
|
|
576
|
-
const id = l.searching ? (hit as SessionHit | undefined)?.session_id : (hit as Row | undefined)?.id
|
|
577
|
-
if (!id || !l.onSwitch) return
|
|
578
|
-
if (id === l.currentId) return l.onSwitch(id)
|
|
579
|
-
const title = (hit as { title?: string } | undefined)?.title || "Untitled"
|
|
580
|
-
const n = l.searching ? undefined : (hit as Row).message_count
|
|
581
|
-
void openConfirm(dialog, {
|
|
582
|
-
title: "Load session?",
|
|
583
|
-
body: `${trunc(title, 60)}${n != null ? ` · ${n} msg${n === 1 ? "" : "s"}` : ""}\n\nCurrent chat will be replaced.`,
|
|
584
|
-
yes: "load",
|
|
585
|
-
}).then(ok => { if (ok) l.onSwitch?.(id) })
|
|
586
|
-
}, [dialog])
|
|
587
|
-
// Delete on a child row is a no-op (only parents can be deleted from
|
|
588
|
-
// the list). The ✕ glyph is hidden for indented rows anyway; this
|
|
589
|
-
// guard covers the keyboard shortcut path.
|
|
590
|
-
const rowDelete = useCallback((i: number) => {
|
|
591
|
-
const v = live.current.visible[i]
|
|
592
|
-
if (v && !v.indent) confirmDeleteRef.current(v.row)
|
|
593
|
-
}, [])
|
|
594
|
-
|
|
595
|
-
// Lineage-click switches target a SPECIFIC session (the predecessor
|
|
596
|
-
// or successor), not the projected tip. Confirm to match list click.
|
|
597
|
-
const lineageSwitch = useCallback((sid: string) => {
|
|
598
|
-
const l = live.current
|
|
599
|
-
if (!l.onSwitch) return
|
|
600
|
-
if (sid === l.currentId) return l.onSwitch(sid)
|
|
601
|
-
void openConfirm(dialog, {
|
|
602
|
-
title: "Load session?",
|
|
603
|
-
body: `Switch to ${trunc(sid, 24)}?\n\nCurrent chat will be replaced.`,
|
|
604
|
-
yes: "load",
|
|
605
|
-
}).then(ok => { if (ok) l.onSwitch?.(sid) })
|
|
606
|
-
}, [dialog])
|
|
607
|
-
|
|
608
|
-
const confirmDeleteRef = useRef<(r: Row) => void>(() => {})
|
|
609
|
-
const confirmDelete = useCallback((r: Row) => {
|
|
610
|
-
openConfirm(dialog, {
|
|
611
|
-
title: "Delete Session?",
|
|
612
|
-
body: trunc(r.title || "Untitled", 46),
|
|
613
|
-
yes: "Delete",
|
|
614
|
-
danger: true,
|
|
615
|
-
}).then(async ok => {
|
|
616
|
-
if (!ok) return
|
|
617
|
-
// session.delete RPC first — it refuses to remove the active
|
|
618
|
-
// session and also unlinks transcript files. Fall back to the
|
|
619
|
-
// direct DELETE only when the gateway rejects/is down.
|
|
620
|
-
const done = await gw.request<{ deleted: string }>("session.delete", { session_id: r.id })
|
|
621
|
-
.then(() => true)
|
|
622
|
-
.catch((e: Error) => {
|
|
623
|
-
if (/active session/i.test(e.message)) {
|
|
624
|
-
toast.show({ variant: "error", message: "Can't delete the active session" })
|
|
625
|
-
return false
|
|
626
|
-
}
|
|
627
|
-
return io.remove(r.id)
|
|
628
|
-
})
|
|
629
|
-
if (!done) return
|
|
630
|
-
home.invalidate("recentSessions")
|
|
631
|
-
toast.show({ variant: "success", message: "Session deleted" })
|
|
632
|
-
void load()
|
|
633
|
-
})
|
|
634
|
-
}, [gw, dialog, toast, load])
|
|
635
|
-
confirmDeleteRef.current = confirmDelete
|
|
636
|
-
|
|
637
|
-
const rename = useCallback(async () => {
|
|
638
|
-
const v = live.current.visible[sel]
|
|
639
|
-
// Rename only operates on parent rows — subagent children don't
|
|
640
|
-
// have stable titles (usually a single-line delegate prompt) and
|
|
641
|
-
// the ✕ affordance is already hidden for them.
|
|
642
|
-
if (!v || v.indent) return
|
|
643
|
-
const r = v.row
|
|
644
|
-
const title = await openTextPrompt(dialog, {
|
|
645
|
-
title: `Rename: ${trunc(r.title || "Untitled", 42)}`, label: "Title", initial: r.title || "",
|
|
646
|
-
})
|
|
647
|
-
if (title === null) return
|
|
648
|
-
Promise.resolve()
|
|
649
|
-
.then(() => {
|
|
650
|
-
if (!io.rename(r.id, title)) throw new Error("not found")
|
|
651
|
-
home.invalidate("recentSessions")
|
|
652
|
-
// Patch in place so the row updates without a full RPC reload
|
|
653
|
-
// (session.list is the slow path). reload still happens next r.
|
|
654
|
-
setRows(prev => prev.map(row => row.id === r.id ? { ...row, title } : row))
|
|
655
|
-
toast.show({ variant: "success", message: "Renamed" })
|
|
656
|
-
})
|
|
657
|
-
.catch((e: Error) =>
|
|
658
|
-
toast.show({ variant: "error", message: `Rename failed: ${e.message}` }))
|
|
659
|
-
}, [dialog, toast, sel])
|
|
660
|
-
|
|
661
|
-
const count = searching ? results.length : visible.length
|
|
662
|
-
// Stable ids — include row.id + indent flag so a row moving between
|
|
663
|
-
// indices (because a sibling expanded above it) doesn't collide with
|
|
664
|
-
// the previous occupant. OpenTUI's reconciler keys on this; reused
|
|
665
|
-
// ids after a layout shift log "Anchor is the same as the node X
|
|
666
|
-
// being inserted, skipping insertBefore" and drop rows.
|
|
667
|
-
const rowId = (i: number) => {
|
|
668
|
-
if (searching) return `sess-s-${results[i]?.session_id ?? i}`
|
|
669
|
-
const v = visible[i]
|
|
670
|
-
return v ? `sess-${v.indent ? "c" : "p"}-${v.row.id}` : `sess-empty-${i}`
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
const keys = useKeys()
|
|
674
|
-
useKeyboard((key) => {
|
|
675
|
-
if (!props.focused || dialog.stack.length > 0) return
|
|
676
|
-
if (searching) {
|
|
677
|
-
if (key.name === "escape") { setSearching(false); setQuery(""); setResults([]); setSearchSel(0); return }
|
|
678
|
-
if (key.name === "backspace") return setQuery(p => p.slice(0, -1))
|
|
679
|
-
if (key.name === "return") return rowActivate(searchSel)
|
|
680
|
-
if (key.name === "up") return setSearchSel(p => Math.max(0, p - 1))
|
|
681
|
-
if (key.name === "down") return setSearchSel(p => Math.min(count - 1, p + 1))
|
|
682
|
-
if (key.raw && key.raw.length === 1 && key.raw >= " ") return setQuery(p => p + key.raw)
|
|
683
|
-
return
|
|
684
|
-
}
|
|
685
|
-
const matched = handleListKey(keys, key, {
|
|
686
|
-
count, setSel,
|
|
687
|
-
page: Math.max(1, (vscroll.current?.viewport.height ?? 10) - 1),
|
|
688
|
-
scrollTo: n => vscroll.current?.scrollChildIntoView(rowId(n)),
|
|
689
|
-
onActivate: () => rowActivate(sel),
|
|
690
|
-
onRefresh: () => { void load(); toast.show({ variant: "info", message: "Reloaded", duration: 1000 }) },
|
|
691
|
-
onDelete: () => {
|
|
692
|
-
const v = visible[sel]
|
|
693
|
-
if (v && !v.indent) confirmDelete(v.row)
|
|
694
|
-
},
|
|
695
|
-
onSearch: () => { setSearching(true); setQuery(""); setResults([]); setSearchSel(0) },
|
|
696
|
-
})
|
|
697
|
-
if (matched) return
|
|
698
|
-
if (keys.match("sessions.rename", key)) return void rename()
|
|
699
|
-
if (keys.match("sessions.prev", key) || keys.match("sessions.next", key)) {
|
|
700
|
-
// Walk the compression chain. continuesFrom is the ancestor
|
|
701
|
-
// (older session this was resumed from), compressedTo is the
|
|
702
|
-
// descendant (newer session this compressed into). Look up
|
|
703
|
-
// lineage on demand — query is in-process and sub-ms, no
|
|
704
|
-
// reason to cache across the small number of ←/→ presses.
|
|
705
|
-
const v = visible[sel]
|
|
706
|
-
if (!v) return
|
|
707
|
-
const ln = io.lineage(v.row.id)
|
|
708
|
-
const target = keys.match("sessions.prev", key)
|
|
709
|
-
? ln.continuesFrom?.id
|
|
710
|
-
: ln.compressedTo?.id
|
|
711
|
-
if (!target) return
|
|
712
|
-
// Match lineage-click semantics: confirm switching (unless it's
|
|
713
|
-
// the current session, which lineageSwitch short-circuits).
|
|
714
|
-
lineageSwitch(target)
|
|
715
|
-
return
|
|
716
|
-
}
|
|
717
|
-
})
|
|
718
|
-
|
|
719
|
-
const empty = searching ? results.length === 0 && query.length > 0 : rows.length === 0
|
|
720
|
-
// Sidebar yields at <140 on non-Chat tabs (app.tsx), so detail can
|
|
721
|
-
// stay mounted down to the shell's own floor.
|
|
722
|
-
const showDetailPanel = dims.width >= 120
|
|
723
|
-
|
|
724
|
-
return (
|
|
725
|
-
<box flexDirection="row" flexGrow={1}>
|
|
726
|
-
<TabShell
|
|
727
|
-
title={searching
|
|
728
|
-
? `Search Results (${results.length})`
|
|
729
|
-
: `Sessions (${rows.length}${pending ? "…" : ""})`}
|
|
730
|
-
hint={searching
|
|
731
|
-
? "↑↓ navigate Enter/click switch Esc cancel"
|
|
732
|
-
: `↑↓ navigate ←→ lineage ${keys.print("list.activate")}/click switch ${keys.print("list.search")} search ${keys.print("sessions.rename")} rename ${keys.print("list.delete")} delete ${keys.print("list.refresh")} refresh`}
|
|
733
|
-
error={warn || null}
|
|
734
|
-
grow={3}
|
|
735
|
-
>
|
|
736
|
-
{searching ? (
|
|
737
|
-
<box height={1} marginBottom={1}>
|
|
738
|
-
<text>
|
|
739
|
-
<span fg={theme.accent}>/ </span>
|
|
740
|
-
<span fg={theme.text}>{query}</span>
|
|
741
|
-
<span fg={theme.accent}>█</span>
|
|
742
|
-
</text>
|
|
743
|
-
</box>
|
|
744
|
-
) : null}
|
|
745
|
-
|
|
746
|
-
{empty ? (
|
|
747
|
-
// key prevents OpenTUI reconciler reusing this <box> for the
|
|
748
|
-
// table wrapper below — it doesn't unset padding when the new
|
|
749
|
-
// vnode omits it, so padding={2} would leak into the table.
|
|
750
|
-
<box key="empty" flexGrow={1} padding={2}>
|
|
751
|
-
{pending && !searching
|
|
752
|
-
? <Spinner color={theme.textMuted} label="loading sessions…" />
|
|
753
|
-
: <text fg={theme.textMuted}>
|
|
754
|
-
{searching ? "No matching sessions found" : "No sessions found"}
|
|
755
|
-
</text>}
|
|
756
|
-
</box>
|
|
757
|
-
) : (
|
|
758
|
-
<box key="table" flexDirection="column" flexGrow={1} minWidth={0}>
|
|
759
|
-
{searching ? <SearchHeaderRow /> : <HeaderRow />}
|
|
760
|
-
<box height={1} />
|
|
761
|
-
<scrollbox ref={vscroll} scrollY viewportCulling flexGrow={1}
|
|
762
|
-
verticalScrollbarOptions={VBAR}>
|
|
763
|
-
{searching
|
|
764
|
-
? results.map((r, i) => (
|
|
765
|
-
<SearchItem key={r.session_id} id={rowId(i)} idx={i}
|
|
766
|
-
result={r} selected={i === searchSel}
|
|
767
|
-
onActivate={rowActivate} onHover={rowHover} />
|
|
768
|
-
))
|
|
769
|
-
: visible.map((v, i) => (
|
|
770
|
-
<Item key={`${v.row.id}-${v.indent ? "c" : "p"}`} id={rowId(i)} idx={i}
|
|
771
|
-
row={v.row} selected={i === sel} indent={v.indent}
|
|
772
|
-
onActivate={rowActivate} onHover={rowHover} onDelete={rowDelete} />
|
|
773
|
-
))}
|
|
774
|
-
</scrollbox>
|
|
775
|
-
</box>
|
|
776
|
-
)}
|
|
777
|
-
</TabShell>
|
|
778
|
-
|
|
779
|
-
{showDetailPanel && searching && results[searchSel]
|
|
780
|
-
? <SearchDetail result={results[searchSel]} />
|
|
781
|
-
: showDetailPanel && !searching && visible[sel]?.row
|
|
782
|
-
? <Detail row={visible[sel].row} lineage={io.lineage} peek={io.peek} onSwitch={lineageSwitch} />
|
|
783
|
-
: null}
|
|
784
|
-
</box>
|
|
785
|
-
)
|
|
786
|
-
})
|