herm-tui 1.0.0-dev.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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +82 -0
- package/scripts/postinstall.ts +29 -0
- package/src/app/gateway.tsx +83 -0
- package/src/app/gatewayEvents.ts +203 -0
- package/src/app/launch.ts +41 -0
- package/src/app/skin.tsx +31 -0
- package/src/app/spawnHistory.ts +75 -0
- package/src/app/tabs.ts +23 -0
- package/src/app/turnReducer.ts +390 -0
- package/src/app/useAppKeys.ts +268 -0
- package/src/app/useAtRefPopover.ts +99 -0
- package/src/app/useInputHistory.ts +66 -0
- package/src/app/useSession.ts +102 -0
- package/src/app/useSlashCommands.ts +70 -0
- package/src/app/useSlashPopover.ts +48 -0
- package/src/app.tsx +917 -0
- package/src/commands/slash.ts +151 -0
- package/src/components/avatar/AnimatedAvatar.tsx +66 -0
- package/src/components/avatar/eikon.ts +144 -0
- package/src/components/avatar/states/error.ts +1155 -0
- package/src/components/avatar/states/idle.ts +1155 -0
- package/src/components/avatar/states/index.ts +30 -0
- package/src/components/avatar/states/listening.ts +1155 -0
- package/src/components/avatar/states/speaking.ts +1155 -0
- package/src/components/avatar/states/thinking.ts +1155 -0
- package/src/components/avatar/states/working.ts +1155 -0
- package/src/components/chat/AtRefPopover.tsx +54 -0
- package/src/components/chat/CodeBlock.tsx +67 -0
- package/src/components/chat/Composer.tsx +347 -0
- package/src/components/chat/DiffBlock.tsx +116 -0
- package/src/components/chat/ErrorBlock.tsx +70 -0
- package/src/components/chat/MediaChip.tsx +114 -0
- package/src/components/chat/MessageItem.tsx +282 -0
- package/src/components/chat/MessageList.tsx +114 -0
- package/src/components/chat/PromptCard.tsx +359 -0
- package/src/components/chat/SlashPopover.tsx +158 -0
- package/src/components/chat/ThoughtCloud.tsx +185 -0
- package/src/components/chat/TypingIndicator.tsx +25 -0
- package/src/components/chat/tool/Subagent.tsx +75 -0
- package/src/components/chat/tool/frame.tsx +69 -0
- package/src/components/chat/tool/index.tsx +65 -0
- package/src/components/chat/tool/preview.ts +57 -0
- package/src/components/sidebar/ContextGauge.tsx +102 -0
- package/src/components/sidebar/Sidebar.tsx +143 -0
- package/src/components/tabs/TabBar.tsx +50 -0
- package/src/components/ui/FileLink.tsx +52 -0
- package/src/config/index.ts +156 -0
- package/src/config/lane.ts +161 -0
- package/src/config/models.ts +95 -0
- package/src/config/rules.ts +80 -0
- package/src/config/schema.ts +308 -0
- package/src/dialogs/alert.tsx +52 -0
- package/src/dialogs/chafa.tsx +72 -0
- package/src/dialogs/confirm.tsx +58 -0
- package/src/dialogs/curator.tsx +153 -0
- package/src/dialogs/eikon-picker.tsx +95 -0
- package/src/dialogs/help.tsx +80 -0
- package/src/dialogs/history.tsx +92 -0
- package/src/dialogs/info.tsx +115 -0
- package/src/dialogs/keys.tsx +170 -0
- package/src/dialogs/logs.tsx +42 -0
- package/src/dialogs/message.tsx +38 -0
- package/src/dialogs/model-picker.tsx +123 -0
- package/src/dialogs/new-profile.tsx +69 -0
- package/src/dialogs/new-task.tsx +103 -0
- package/src/dialogs/profile.tsx +55 -0
- package/src/dialogs/rollback.tsx +190 -0
- package/src/dialogs/spawn-history.tsx +80 -0
- package/src/dialogs/text-prompt.tsx +68 -0
- package/src/dialogs/theme-picker.tsx +50 -0
- package/src/home/index.ts +23 -0
- package/src/home/store.ts +267 -0
- package/src/index.tsx +113 -0
- package/src/keys/catalog.ts +115 -0
- package/src/keys/chord.ts +125 -0
- package/src/keys/conflicts.ts +48 -0
- package/src/keys/context.tsx +112 -0
- package/src/keys/index.ts +5 -0
- package/src/keys/list.ts +94 -0
- package/src/keys/oc-compat.ts +87 -0
- package/src/tabs/Agents.tsx +607 -0
- package/src/tabs/Analytics.tsx +154 -0
- package/src/tabs/Chat.tsx +50 -0
- package/src/tabs/Config.tsx +605 -0
- package/src/tabs/Context.tsx +599 -0
- package/src/tabs/Cron.tsx +294 -0
- package/src/tabs/Env.tsx +227 -0
- package/src/tabs/Kanban.tsx +367 -0
- package/src/tabs/Memory.tsx +294 -0
- package/src/tabs/Sessions.tsx +786 -0
- package/src/tabs/Skills.tsx +507 -0
- package/src/tabs/Toolsets.tsx +266 -0
- package/src/theme/builtin.ts +78 -0
- package/src/theme/context.tsx +106 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/resolve.ts +134 -0
- package/src/theme/syntax.ts +31 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +119 -0
- package/src/types/message.ts +97 -0
- package/src/ui/ChafaImage.tsx +64 -0
- package/src/ui/Splash.tsx +118 -0
- package/src/ui/borders.ts +28 -0
- package/src/ui/command.tsx +104 -0
- package/src/ui/dialog-select.tsx +164 -0
- package/src/ui/dialog.tsx +102 -0
- package/src/ui/fmt.ts +82 -0
- package/src/ui/kv.tsx +28 -0
- package/src/ui/shell.tsx +45 -0
- package/src/ui/spinner.tsx +59 -0
- package/src/ui/splash-art.ts +123 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/ticker.tsx +90 -0
- package/src/ui/toast.tsx +130 -0
- package/src/utils/categorical.ts +77 -0
- package/src/utils/chafa.ts +173 -0
- package/src/utils/clipboard.ts +67 -0
- package/src/utils/context-segments.ts +317 -0
- package/src/utils/control.ts +495 -0
- package/src/utils/drop.ts +25 -0
- package/src/utils/editor.ts +33 -0
- package/src/utils/fuzzy.ts +45 -0
- package/src/utils/gateway-client.ts +253 -0
- package/src/utils/gateway-types.ts +282 -0
- package/src/utils/git.ts +57 -0
- package/src/utils/hermes-analytics.ts +134 -0
- package/src/utils/hermes-home.ts +821 -0
- package/src/utils/hermes-kanban.ts +154 -0
- package/src/utils/hermes-profiles.ts +217 -0
- package/src/utils/interpolate.ts +31 -0
- package/src/utils/math-unicode.ts +818 -0
- package/src/utils/memory-activity.ts +140 -0
- package/src/utils/open-file.ts +13 -0
- package/src/utils/paths.ts +52 -0
- package/src/utils/perf.ts +235 -0
- package/src/utils/preferences.ts +150 -0
- package/src/utils/sessions-db.ts +396 -0
- package/src/utils/subagent-tree.ts +146 -0
- package/src/utils/terminal-reset.ts +129 -0
- package/src/utils/tips.ts +67 -0
- package/src/utils/tokens.ts +87 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sessions-db.ts — herm's window onto the Hermes session store.
|
|
3
|
+
*
|
|
4
|
+
* Architectural line: the Sessions tab is a **local state.db reader**.
|
|
5
|
+
* Stock tui_gateway covers ≈30% of what the tab needs — session.list
|
|
6
|
+
* returns {id, title, preview, started_at, message_count, source} and
|
|
7
|
+
* nothing else. There is no session.search, no lineage/children RPC,
|
|
8
|
+
* no arbitrary-id session.history, and session.title only retitles the
|
|
9
|
+
* *current* gateway session. Per herm policy we don't patch upstream,
|
|
10
|
+
* so everything richer than "which ids can the gateway resume" reads
|
|
11
|
+
* state.db directly. The gateway RPCs herm *does* use:
|
|
12
|
+
*
|
|
13
|
+
* session.list — source of truth for "resumable" (row.id is known
|
|
14
|
+
* to the connected gateway process)
|
|
15
|
+
* session.delete — preferred over direct DELETE because it refuses
|
|
16
|
+
* to remove the active session and cleans transcript
|
|
17
|
+
* files; local remove() is the fallback
|
|
18
|
+
*
|
|
19
|
+
* All query functions here share ONE readonly connection and ONE
|
|
20
|
+
* parent→child classification rule. Upstream owns that semantic
|
|
21
|
+
* (hermes_state.py:893-970); if it changes, `kind()` is the only line
|
|
22
|
+
* that moves.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Database, type Statement } from "bun:sqlite"
|
|
26
|
+
import { homedir } from "os"
|
|
27
|
+
import * as perf from "./perf"
|
|
28
|
+
|
|
29
|
+
const HERMES = process.env.HERMES_HOME || `${process.env.HOME || homedir()}/.hermes`
|
|
30
|
+
const PATH = `${HERMES}/state.db`
|
|
31
|
+
// Source provenance mirrors hermes-home.ts makeSource("state.db") —
|
|
32
|
+
// inlined to keep this module leaf (hermes-home re-exports from here).
|
|
33
|
+
export type Source = { file: string; relative: string; label: string }
|
|
34
|
+
const SRC: Source = { file: PATH, relative: "state.db", label: "state.db" }
|
|
35
|
+
|
|
36
|
+
// ─── Connection ──────────────────────────────────────────────────────
|
|
37
|
+
// One readonly handle, opened on first use. SQLite readonly connections
|
|
38
|
+
// see writes from other processes (WAL or rollback), so the gateway
|
|
39
|
+
// appending messages while herm holds this open is fine. Writes
|
|
40
|
+
// (rename/remove) open a short-lived RW handle — rare enough that
|
|
41
|
+
// pooling isn't worth it.
|
|
42
|
+
|
|
43
|
+
let ro: Database | null = null
|
|
44
|
+
|
|
45
|
+
/** Shared readonly handle. Null when state.db doesn't exist yet. */
|
|
46
|
+
const stateDb = (): Database | null => {
|
|
47
|
+
if (ro) return ro
|
|
48
|
+
try { return (ro = new Database(PATH, { readonly: true })) }
|
|
49
|
+
catch { return null }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Test hook — drop the cached handle so the next call reopens. */
|
|
53
|
+
export const resetDb = () => { ro?.close(); ro = null; stmts.clear() }
|
|
54
|
+
|
|
55
|
+
// Prepared-statement cache keyed by SQL text. db.query() already
|
|
56
|
+
// memoises internally, but holding our own map lets stats()/perf
|
|
57
|
+
// count distinct statements and makes the no-db path trivially cheap.
|
|
58
|
+
const stmts = new Map<string, Statement>()
|
|
59
|
+
const q = (sql: string): Statement | null => {
|
|
60
|
+
const db = stateDb()
|
|
61
|
+
if (!db) return null
|
|
62
|
+
let s = stmts.get(sql)
|
|
63
|
+
if (!s) stmts.set(sql, (s = db.query(sql)))
|
|
64
|
+
return s
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** A row from the sessions table enriched for the list/detail view. */
|
|
70
|
+
export interface SessionRow {
|
|
71
|
+
source: Source
|
|
72
|
+
id: string
|
|
73
|
+
sessionSource: string
|
|
74
|
+
model: string | null
|
|
75
|
+
started_at: number
|
|
76
|
+
ended_at: number | null
|
|
77
|
+
end_reason: string | null
|
|
78
|
+
message_count: number
|
|
79
|
+
tool_call_count: number
|
|
80
|
+
input_tokens: number
|
|
81
|
+
output_tokens: number
|
|
82
|
+
cache_read_tokens: number
|
|
83
|
+
cache_write_tokens: number
|
|
84
|
+
reasoning_tokens: number
|
|
85
|
+
estimated_cost_usd: number | null
|
|
86
|
+
title: string | null
|
|
87
|
+
lastMessage: string | null
|
|
88
|
+
last_active: number | null
|
|
89
|
+
parent_session_id: string | null
|
|
90
|
+
/** Count of subagent children — see kind() === 'subagent'. */
|
|
91
|
+
subagent_count: number
|
|
92
|
+
/** Original root id when this row was tip-projected from a
|
|
93
|
+
* compression chain; null otherwise. */
|
|
94
|
+
lineage_root_id: string | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface LineageInfo {
|
|
98
|
+
continuesFrom?: { id: string; title: string | null }
|
|
99
|
+
compressedTo?: { id: string; title: string | null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SessionHit {
|
|
103
|
+
session_id: string
|
|
104
|
+
snippet: string
|
|
105
|
+
role: string
|
|
106
|
+
source: string
|
|
107
|
+
model: string | null
|
|
108
|
+
started_at: number
|
|
109
|
+
title: string | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One raw message row for transcript peek. content is SUBSTR-capped
|
|
113
|
+
* in SQL so multi-MB tool outputs don't allocate on read. */
|
|
114
|
+
export interface PeekMsg {
|
|
115
|
+
role: "user" | "assistant" | "tool" | "system"
|
|
116
|
+
content: string | null
|
|
117
|
+
tool_name: string | null
|
|
118
|
+
/** JSON string of tool_calls when role='assistant' and the model
|
|
119
|
+
* invoked tools instead of / as well as emitting content. */
|
|
120
|
+
tool_calls: string | null
|
|
121
|
+
at: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── parent→child classification ─────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// parent_session_id is overloaded across three unrelated relationships
|
|
127
|
+
// in hermes-agent. The ONLY discriminator is (parent.end_reason,
|
|
128
|
+
// child.started_at vs parent.ended_at):
|
|
129
|
+
//
|
|
130
|
+
// subagent — child started while parent was still live
|
|
131
|
+
// (parent.ended_at NULL OR child.started_at < it)
|
|
132
|
+
// continuation — parent.end_reason='compression' AND child started
|
|
133
|
+
// at/after parent.ended_at
|
|
134
|
+
// branch — parent.end_reason='branched' AND child started
|
|
135
|
+
// at/after parent.ended_at
|
|
136
|
+
//
|
|
137
|
+
// This mirrors hermes_state.py compression-tip walker (:893-926) and
|
|
138
|
+
// list_sessions_rich root filter (:956-971). Every query below derives
|
|
139
|
+
// its WHERE from these three predicates — change the rule here, not
|
|
140
|
+
// per-call. They take the child-table alias because queries variously
|
|
141
|
+
// see the child as the outer `s` or an inner `c`.
|
|
142
|
+
|
|
143
|
+
export type Kind = "root" | "subagent" | "continuation" | "branch"
|
|
144
|
+
|
|
145
|
+
const SUB = (c: string) => `(p.ended_at IS NULL OR ${c}.started_at < p.ended_at)`
|
|
146
|
+
const CONT = (c: string) => `(p.end_reason = 'compression' AND ${c}.started_at >= p.ended_at)`
|
|
147
|
+
const BR = (c: string) => `(p.end_reason = 'branched' AND ${c}.started_at >= p.ended_at)`
|
|
148
|
+
|
|
149
|
+
/** Classify a child session given its parent. Pure — for tests and
|
|
150
|
+
* any caller that already has both rows in hand. */
|
|
151
|
+
export const kind = (
|
|
152
|
+
parent: { ended_at: number | null; end_reason: string | null } | null,
|
|
153
|
+
child: { started_at: number },
|
|
154
|
+
): Kind => {
|
|
155
|
+
if (!parent) return "root"
|
|
156
|
+
if (parent.ended_at == null || child.started_at < parent.ended_at) return "subagent"
|
|
157
|
+
if (parent.end_reason === "compression") return "continuation"
|
|
158
|
+
if (parent.end_reason === "branched") return "branch"
|
|
159
|
+
return "subagent"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Shared SQL ──────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
// Column projection shared by roots()/children()/one(). Aliased `s`.
|
|
165
|
+
// First-user-msg, last-user-msg, last-active, and subagent_count are
|
|
166
|
+
// correlated subqueries — cheap at herm's DB sizes (thousands of rows)
|
|
167
|
+
// and keeps the outer query a plain single-table scan.
|
|
168
|
+
const COLS = `
|
|
169
|
+
s.id, s.source, s.model, s.started_at, s.ended_at, s.end_reason,
|
|
170
|
+
s.message_count, s.tool_call_count,
|
|
171
|
+
s.input_tokens, s.output_tokens,
|
|
172
|
+
s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens,
|
|
173
|
+
s.estimated_cost_usd, s.parent_session_id,
|
|
174
|
+
COALESCE(s.title,
|
|
175
|
+
(SELECT SUBSTR(content,1,120) FROM messages
|
|
176
|
+
WHERE session_id = s.id AND role = 'user' ORDER BY id LIMIT 1)) AS title,
|
|
177
|
+
(SELECT SUBSTR(content,1,120) FROM messages
|
|
178
|
+
WHERE session_id = s.id AND role = 'user' ORDER BY id DESC LIMIT 1) AS lastMessage,
|
|
179
|
+
(SELECT MAX(timestamp) FROM messages WHERE session_id = s.id) AS last_active,
|
|
180
|
+
(SELECT COUNT(*) FROM sessions c
|
|
181
|
+
WHERE c.parent_session_id = s.id
|
|
182
|
+
AND (s.ended_at IS NULL OR c.started_at < s.ended_at)) AS subagent_count`
|
|
183
|
+
|
|
184
|
+
type Raw = {
|
|
185
|
+
id: string; source: string; model: string | null
|
|
186
|
+
started_at: number; ended_at: number | null; end_reason: string | null
|
|
187
|
+
message_count: number; tool_call_count: number
|
|
188
|
+
input_tokens: number; output_tokens: number
|
|
189
|
+
cache_read_tokens: number; cache_write_tokens: number; reasoning_tokens: number
|
|
190
|
+
estimated_cost_usd: number | null; parent_session_id: string | null
|
|
191
|
+
title: string | null; lastMessage: string | null
|
|
192
|
+
last_active: number | null; subagent_count: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const toRow = (r: Raw, lineage: string | null = null): SessionRow => ({
|
|
196
|
+
source: SRC,
|
|
197
|
+
id: r.id,
|
|
198
|
+
sessionSource: r.source,
|
|
199
|
+
model: r.model,
|
|
200
|
+
started_at: r.started_at,
|
|
201
|
+
ended_at: r.ended_at,
|
|
202
|
+
end_reason: r.end_reason,
|
|
203
|
+
message_count: r.message_count,
|
|
204
|
+
tool_call_count: r.tool_call_count,
|
|
205
|
+
input_tokens: r.input_tokens,
|
|
206
|
+
output_tokens: r.output_tokens,
|
|
207
|
+
cache_read_tokens: r.cache_read_tokens,
|
|
208
|
+
cache_write_tokens: r.cache_write_tokens,
|
|
209
|
+
reasoning_tokens: r.reasoning_tokens,
|
|
210
|
+
estimated_cost_usd: r.estimated_cost_usd,
|
|
211
|
+
title: r.title,
|
|
212
|
+
lastMessage: r.lastMessage,
|
|
213
|
+
last_active: r.last_active,
|
|
214
|
+
parent_session_id: r.parent_session_id,
|
|
215
|
+
subagent_count: r.subagent_count,
|
|
216
|
+
lineage_root_id: lineage,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/** Fetch one session by id with the full column projection. */
|
|
220
|
+
const one = (id: string): Raw | null =>
|
|
221
|
+
(q(`SELECT ${COLS} FROM sessions s WHERE s.id = ?`)?.get(id) as Raw | undefined) ?? null
|
|
222
|
+
|
|
223
|
+
/** Single session by id, or null if missing / db unavailable. */
|
|
224
|
+
export const byId = (id: string): SessionRow | null => {
|
|
225
|
+
const r = one(id)
|
|
226
|
+
return r ? toRow(r) : null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Newest root TUI session that actually has messages. Target of `-c`
|
|
230
|
+
* and source of the splash continue-prompt title. */
|
|
231
|
+
export const lastReal = (): SessionRow | undefined =>
|
|
232
|
+
roots().find(r => r.message_count > 0 && r.sessionSource === "tui")
|
|
233
|
+
|
|
234
|
+
// ─── Readers ─────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** Root-level sessions, newest first, compression chains projected to
|
|
237
|
+
* their tip (the resumable end), with lineage_root_id recording the
|
|
238
|
+
* original root when projection happened. Mirrors list_sessions_rich. */
|
|
239
|
+
export function roots(limit = 30): SessionRow[] {
|
|
240
|
+
const end = perf.mark("io:sessions.roots")
|
|
241
|
+
try {
|
|
242
|
+
// Root filter: no parent, OR parent link is a branch. Subagents
|
|
243
|
+
// and continuations are hidden — they surface via children()/
|
|
244
|
+
// lineage() instead. `p`/`c` aliases satisfy SUB/CONT/BR above.
|
|
245
|
+
const raw = (q(
|
|
246
|
+
`SELECT ${COLS} FROM sessions s
|
|
247
|
+
WHERE s.parent_session_id IS NULL
|
|
248
|
+
OR EXISTS (SELECT 1 FROM sessions p
|
|
249
|
+
WHERE p.id = s.parent_session_id
|
|
250
|
+
AND ${BR("s")})
|
|
251
|
+
ORDER BY s.started_at DESC
|
|
252
|
+
LIMIT ?`,
|
|
253
|
+
)?.all(limit) ?? []) as Raw[]
|
|
254
|
+
|
|
255
|
+
return raw.map((r) => {
|
|
256
|
+
if (r.end_reason !== "compression") return toRow(r)
|
|
257
|
+
const tid = tip(r.id)
|
|
258
|
+
if (tid === r.id) return toRow(r)
|
|
259
|
+
const t = one(tid)
|
|
260
|
+
// Tip stats replace the root's, but started_at stays the root's
|
|
261
|
+
// so chronological list order is preserved.
|
|
262
|
+
return t ? { ...toRow(t, r.id), started_at: r.started_at } : toRow(r)
|
|
263
|
+
})
|
|
264
|
+
} finally { end() }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Subagent children of a session, spawn-order. Each child carries its
|
|
268
|
+
* own subagent_count so the tree view can recurse to N levels. */
|
|
269
|
+
export function children(pid: string): SessionRow[] {
|
|
270
|
+
const end = perf.mark("io:sessions.children")
|
|
271
|
+
try {
|
|
272
|
+
return ((q(
|
|
273
|
+
`SELECT ${COLS} FROM sessions s
|
|
274
|
+
JOIN sessions p ON p.id = s.parent_session_id
|
|
275
|
+
WHERE s.parent_session_id = ? AND ${SUB("s")}
|
|
276
|
+
ORDER BY s.started_at ASC`,
|
|
277
|
+
)?.all(pid) ?? []) as Raw[]).map(r => toRow(r))
|
|
278
|
+
} finally { end() }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Compression-chain neighbours of a session. */
|
|
282
|
+
export function lineage(sid: string): LineageInfo {
|
|
283
|
+
const end = perf.mark("io:sessions.lineage")
|
|
284
|
+
try {
|
|
285
|
+
const pred = q(
|
|
286
|
+
`SELECT p.id, p.title FROM sessions c
|
|
287
|
+
JOIN sessions p ON p.id = c.parent_session_id
|
|
288
|
+
WHERE c.id = ? AND ${CONT("c")}`,
|
|
289
|
+
)?.get(sid) as { id: string; title: string | null } | undefined
|
|
290
|
+
const succ = q(
|
|
291
|
+
`SELECT c.id, c.title FROM sessions c
|
|
292
|
+
JOIN sessions p ON p.id = c.parent_session_id
|
|
293
|
+
WHERE p.id = ? AND ${CONT("c")}
|
|
294
|
+
ORDER BY c.started_at DESC LIMIT 1`,
|
|
295
|
+
)?.get(sid) as { id: string; title: string | null } | undefined
|
|
296
|
+
return {
|
|
297
|
+
...(pred && { continuesFrom: pred }),
|
|
298
|
+
...(succ && { compressedTo: succ }),
|
|
299
|
+
}
|
|
300
|
+
} finally { end() }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Walk the compression chain forward to its live tip. Bounded at 100
|
|
304
|
+
* links (upstream's defensive cap). */
|
|
305
|
+
function tip(sid: string): string {
|
|
306
|
+
const step = q(
|
|
307
|
+
`SELECT c.id FROM sessions c
|
|
308
|
+
JOIN sessions p ON p.id = c.parent_session_id
|
|
309
|
+
WHERE p.id = ? AND ${CONT("c")}
|
|
310
|
+
ORDER BY c.started_at DESC LIMIT 1`,
|
|
311
|
+
)
|
|
312
|
+
let cur = sid
|
|
313
|
+
for (let i = 0; i < 100; i++) {
|
|
314
|
+
const next = step?.get(cur) as { id: string } | undefined
|
|
315
|
+
if (!next) return cur
|
|
316
|
+
cur = next.id
|
|
317
|
+
}
|
|
318
|
+
return cur
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Last `n` raw message rows for a session, chronological. Content
|
|
322
|
+
* is SUBSTR(…,400)'d in SQL — the peek view renders one line per
|
|
323
|
+
* row, so anything past the first ~200 chars is wasted. Uses the
|
|
324
|
+
* (session_id, timestamp) index; sub-ms for any realistic n. */
|
|
325
|
+
export function peek(sid: string, n = 60): PeekMsg[] {
|
|
326
|
+
const end = perf.mark("io:sessions.peek")
|
|
327
|
+
try {
|
|
328
|
+
return ((q(
|
|
329
|
+
`SELECT role, SUBSTR(content,1,400) AS content, tool_name,
|
|
330
|
+
SUBSTR(tool_calls,1,400) AS tool_calls, timestamp AS at
|
|
331
|
+
FROM (SELECT * FROM messages WHERE session_id = ?
|
|
332
|
+
ORDER BY id DESC LIMIT ?)
|
|
333
|
+
ORDER BY id ASC`,
|
|
334
|
+
)?.all(sid, n) ?? []) as PeekMsg[])
|
|
335
|
+
} finally { end() }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Search ──────────────────────────────────────────────────────────
|
|
339
|
+
// FTS5 over messages_fts — same table/triggers SessionDB builds, so
|
|
340
|
+
// results match `hermes sessions search` and the session_search tool.
|
|
341
|
+
|
|
342
|
+
// FTS5 treats - . ( ) " as syntax. Quote non-alnum tokens as phrases;
|
|
343
|
+
// bare words get a * suffix so incremental typing narrows live.
|
|
344
|
+
const fts = (s: string): string =>
|
|
345
|
+
s.trim().split(/\s+/).filter(Boolean)
|
|
346
|
+
.map(w => /^\w+$/.test(w) ? `${w}*` : `"${w.replace(/"/g, '""')}"`)
|
|
347
|
+
.join(" ")
|
|
348
|
+
|
|
349
|
+
export function search(query: string, limit = 30): SessionHit[] {
|
|
350
|
+
const m = fts(query)
|
|
351
|
+
if (!m) return []
|
|
352
|
+
const end = perf.mark("io:sessions.search")
|
|
353
|
+
try {
|
|
354
|
+
const raw = (q(
|
|
355
|
+
`SELECT m.session_id, m.role,
|
|
356
|
+
snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet,
|
|
357
|
+
s.source, s.model, s.started_at,
|
|
358
|
+
COALESCE(s.title, SUBSTR(m.content, 1, 120)) AS title
|
|
359
|
+
FROM messages_fts
|
|
360
|
+
JOIN messages m ON m.id = messages_fts.rowid
|
|
361
|
+
JOIN sessions s ON s.id = m.session_id
|
|
362
|
+
WHERE messages_fts MATCH ?
|
|
363
|
+
ORDER BY rank LIMIT ?`,
|
|
364
|
+
)?.all(m, limit * 4) ?? []) as Array<SessionHit & { session_id: string }>
|
|
365
|
+
const seen = new Set<string>()
|
|
366
|
+
return raw.filter(r =>
|
|
367
|
+
!seen.has(r.session_id) && (seen.add(r.session_id), true),
|
|
368
|
+
).slice(0, limit)
|
|
369
|
+
} finally { end() }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Writes ──────────────────────────────────────────────────────────
|
|
373
|
+
// Fresh RW handle per call — writes are rare (user-initiated) and a
|
|
374
|
+
// long-lived writer would hold locks the gateway's own connection
|
|
375
|
+
// wants. Callers should prefer the session.delete RPC and fall back
|
|
376
|
+
// here only when the gateway is down.
|
|
377
|
+
|
|
378
|
+
export function rename(sid: string, title: string): boolean {
|
|
379
|
+
const db = new Database(PATH)
|
|
380
|
+
try {
|
|
381
|
+
db.run("UPDATE sessions SET title = ? WHERE id = ?", [title, sid])
|
|
382
|
+
return (db.query("SELECT changes() AS c").get() as { c: number }).c > 0
|
|
383
|
+
} finally { db.close() }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Delete a session. Orphans children (matches upstream delete_session). */
|
|
387
|
+
export function remove(sid: string): boolean {
|
|
388
|
+
const db = new Database(PATH)
|
|
389
|
+
try {
|
|
390
|
+
if (!db.query("SELECT 1 FROM sessions WHERE id = ?").get(sid)) return false
|
|
391
|
+
db.run("UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?", [sid])
|
|
392
|
+
db.run("DELETE FROM messages WHERE session_id = ?", [sid])
|
|
393
|
+
db.run("DELETE FROM sessions WHERE id = ?", [sid])
|
|
394
|
+
return true
|
|
395
|
+
} finally { db.close() }
|
|
396
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Subagent tree aggregation for the Agents tab header + detail rollups.
|
|
2
|
+
// Ink reference: ui-tui/src/lib/subagentTree.ts. Herm's wire data is
|
|
3
|
+
// thinner (DelegationRecord has no tokens/cost; those arrive via
|
|
4
|
+
// subagent.complete into `live`), so aggregate() takes both.
|
|
5
|
+
|
|
6
|
+
import type { DelegationRecord } from "./gateway-types"
|
|
7
|
+
|
|
8
|
+
// Per-node enrichment accumulated from subagent.* push events between
|
|
9
|
+
// registry polls. Mirrors the `Live` shape in Agents.tsx.
|
|
10
|
+
export type Live = {
|
|
11
|
+
tool_count?: number
|
|
12
|
+
input_tokens?: number
|
|
13
|
+
output_tokens?: number
|
|
14
|
+
cost_usd?: number
|
|
15
|
+
status?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Agg = {
|
|
19
|
+
agents: number
|
|
20
|
+
tools: number
|
|
21
|
+
dur: number
|
|
22
|
+
tok: number
|
|
23
|
+
cost: number
|
|
24
|
+
active: number
|
|
25
|
+
depth: number
|
|
26
|
+
hot: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Node = { rec: DelegationRecord; agg: Agg; kids: Node[] }
|
|
30
|
+
|
|
31
|
+
const running = (s: string | undefined) => !s || s === "running" || s === "queued"
|
|
32
|
+
|
|
33
|
+
export function tree(
|
|
34
|
+
recs: readonly DelegationRecord[],
|
|
35
|
+
live: ReadonlyMap<string, Live>,
|
|
36
|
+
now: number,
|
|
37
|
+
): Node[] {
|
|
38
|
+
const ids = new Set(recs.map(r => r.subagent_id))
|
|
39
|
+
const by = new Map<string, DelegationRecord[]>()
|
|
40
|
+
for (const r of recs) {
|
|
41
|
+
const k = r.parent_id && ids.has(r.parent_id) ? r.parent_id : ""
|
|
42
|
+
;(by.get(k) ?? by.set(k, []).get(k)!).push(r)
|
|
43
|
+
}
|
|
44
|
+
const build = (r: DelegationRecord): Node => {
|
|
45
|
+
const kids = (by.get(r.subagent_id) ?? []).map(build)
|
|
46
|
+
const lv = live.get(r.subagent_id) ?? {}
|
|
47
|
+
const dur = r.started_at != null ? Math.max(0, now - r.started_at) : 0
|
|
48
|
+
let a: Agg = {
|
|
49
|
+
agents: 1,
|
|
50
|
+
tools: lv.tool_count ?? r.tool_count ?? 0,
|
|
51
|
+
dur,
|
|
52
|
+
tok: (lv.input_tokens ?? 0) + (lv.output_tokens ?? 0),
|
|
53
|
+
cost: lv.cost_usd ?? 0,
|
|
54
|
+
active: running(lv.status ?? r.status) ? 1 : 0,
|
|
55
|
+
depth: 0,
|
|
56
|
+
hot: 0,
|
|
57
|
+
}
|
|
58
|
+
for (const k of kids) {
|
|
59
|
+
a = {
|
|
60
|
+
agents: a.agents + k.agg.agents,
|
|
61
|
+
tools: a.tools + k.agg.tools,
|
|
62
|
+
dur: a.dur + k.agg.dur,
|
|
63
|
+
tok: a.tok + k.agg.tok,
|
|
64
|
+
cost: a.cost + k.agg.cost,
|
|
65
|
+
active: a.active + k.agg.active,
|
|
66
|
+
depth: Math.max(a.depth, k.agg.depth + 1),
|
|
67
|
+
hot: 0,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
a.hot = a.dur > 0 ? a.tools / a.dur : 0
|
|
71
|
+
return { rec: r, agg: a, kids }
|
|
72
|
+
}
|
|
73
|
+
return (by.get("") ?? []).map(build)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function totals(nodes: readonly Node[]): Agg {
|
|
77
|
+
const z: Agg = { agents: 0, tools: 0, dur: 0, tok: 0, cost: 0, active: 0, depth: 0, hot: 0 }
|
|
78
|
+
for (const n of nodes) {
|
|
79
|
+
z.agents += n.agg.agents
|
|
80
|
+
z.tools += n.agg.tools
|
|
81
|
+
z.dur += n.agg.dur
|
|
82
|
+
z.tok += n.agg.tok
|
|
83
|
+
z.cost += n.agg.cost
|
|
84
|
+
z.active += n.agg.active
|
|
85
|
+
z.depth = Math.max(z.depth, n.agg.depth + 1)
|
|
86
|
+
}
|
|
87
|
+
z.hot = z.dur > 0 ? z.tools / z.dur : 0
|
|
88
|
+
return z
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const SPARK = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const
|
|
92
|
+
|
|
93
|
+
export function spark(nodes: readonly Node[]): string {
|
|
94
|
+
const w: number[] = []
|
|
95
|
+
const walk = (ns: readonly Node[], d: number) => {
|
|
96
|
+
if (ns.length === 0) return
|
|
97
|
+
w[d] = (w[d] ?? 0) + ns.length
|
|
98
|
+
for (const n of ns) walk(n.kids, d + 1)
|
|
99
|
+
}
|
|
100
|
+
walk(nodes, 0)
|
|
101
|
+
if (w.length === 0) return ""
|
|
102
|
+
const max = Math.max(...w)
|
|
103
|
+
return w.map(v => v <= 0 ? " "
|
|
104
|
+
: SPARK[Math.min(7, Math.ceil((v / max) * 7))]).join("")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tk = (n: number) =>
|
|
108
|
+
n < 1000 ? String(Math.round(n))
|
|
109
|
+
: n < 10_000 ? `${(n / 1000).toFixed(1)}k`
|
|
110
|
+
: `${Math.round(n / 1000)}k`
|
|
111
|
+
|
|
112
|
+
const $$ = (n: number) =>
|
|
113
|
+
n <= 0 ? "" : n < 0.01 ? "<$0.01" : n < 10 ? `$${n.toFixed(2)}` : `$${n.toFixed(1)}`
|
|
114
|
+
|
|
115
|
+
const sec = (s: number) => {
|
|
116
|
+
if (s < 60) return `${Math.round(s)}s`
|
|
117
|
+
const m = Math.floor(s / 60)
|
|
118
|
+
const r = Math.round(s - m * 60)
|
|
119
|
+
return r === 0 ? `${m}m` : `${m}m${r}s`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `d2 · 7 agents · 124 tools · 2m14s · 10k tok · $0.42 · ⚡3` */
|
|
123
|
+
export function summary(a: Agg): string {
|
|
124
|
+
const p = [`d${a.depth}`, `${a.agents} agent${a.agents === 1 ? "" : "s"}`]
|
|
125
|
+
if (a.tools > 0) p.push(`${a.tools} tools`)
|
|
126
|
+
if (a.dur > 0) p.push(sec(a.dur))
|
|
127
|
+
if (a.tok > 0) p.push(`${tk(a.tok)} tok`)
|
|
128
|
+
if (a.cost > 0) p.push($$(a.cost))
|
|
129
|
+
if (a.active > 0) p.push(`⚡${a.active}`)
|
|
130
|
+
return p.join(" · ")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 0..(buckets-1) normalized against peak across all nodes. */
|
|
134
|
+
export function heat(hot: number, peak: number, buckets: number): number {
|
|
135
|
+
if (hot <= 0 || peak <= 0 || buckets <= 1) return 0
|
|
136
|
+
return Math.min(buckets - 1, Math.round((Math.min(1, hot / peak)) * (buckets - 1)))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function peak(nodes: readonly Node[]): number {
|
|
140
|
+
let p = 0
|
|
141
|
+
const walk = (ns: readonly Node[]) => {
|
|
142
|
+
for (const n of ns) { p = Math.max(p, n.agg.hot); walk(n.kids) }
|
|
143
|
+
}
|
|
144
|
+
walk(nodes)
|
|
145
|
+
return p
|
|
146
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Reset sticky terminal modes. Ported from hermes-agent
|
|
2
|
+
// ui-tui/src/lib/terminalModes.ts (v0.12.0, commits d05497f81 +
|
|
3
|
+
// cad7944b9).
|
|
4
|
+
//
|
|
5
|
+
// Why this exists: @opentui/core only resets ?1049 (alt-screen) on
|
|
6
|
+
// shutdown. If a prior TUI crashed with any of the modes below still
|
|
7
|
+
// enabled — mouse reporting, focus events, bracketed paste, kitty
|
|
8
|
+
// keyboard protocol — the terminal tab poisons the next process.
|
|
9
|
+
// Symptoms: raw `[<35;12;8M` cursor-motion escapes dumped into the
|
|
10
|
+
// composer, phantom focus events, paste wrapped in [200~...[201~,
|
|
11
|
+
// kitty CSI-u sequences interpreted as literal input.
|
|
12
|
+
//
|
|
13
|
+
// We emit this on startup (self-heal a poisoned tab from a prior
|
|
14
|
+
// crashed process) and on every exit path (so our own crash doesn't
|
|
15
|
+
// poison the next shell prompt).
|
|
16
|
+
|
|
17
|
+
import { writeSync } from "node:fs"
|
|
18
|
+
|
|
19
|
+
export const TERMINAL_MODE_RESET =
|
|
20
|
+
"\x1b[0'z" + // DEC locator reporting
|
|
21
|
+
"\x1b[0'{" + // selectable locator events
|
|
22
|
+
"\x1b[?2029l" + // passive mouse
|
|
23
|
+
"\x1b[?1016l" + // SGR-pixels mouse
|
|
24
|
+
"\x1b[?1015l" + // urxvt decimal mouse
|
|
25
|
+
"\x1b[?1006l" + // SGR mouse
|
|
26
|
+
"\x1b[?1005l" + // UTF-8 extended mouse
|
|
27
|
+
"\x1b[?1003l" + // any-motion mouse
|
|
28
|
+
"\x1b[?1002l" + // button-motion mouse
|
|
29
|
+
"\x1b[?1001l" + // highlight mouse
|
|
30
|
+
"\x1b[?1000l" + // click mouse
|
|
31
|
+
"\x1b[?9l" + // X10 mouse
|
|
32
|
+
"\x1b[?1004l" + // focus events
|
|
33
|
+
"\x1b[?2004l" + // bracketed paste
|
|
34
|
+
"\x1b[?1049l" + // alternate screen
|
|
35
|
+
"\x1b[<u" + // kitty keyboard (pop stack)
|
|
36
|
+
"\x1b[>4;0m" + // modifyOtherKeys → level 0
|
|
37
|
+
"\x1b[0m" + // SGR attributes
|
|
38
|
+
"\x1b[?25h" // cursor visible
|
|
39
|
+
|
|
40
|
+
type ResettableStream = Pick<NodeJS.WriteStream, "isTTY" | "write"> & {
|
|
41
|
+
fd?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Synchronously emit the reset blob to `stream`.
|
|
46
|
+
*
|
|
47
|
+
* Returns true if the write succeeded. Skips non-TTY streams (piped
|
|
48
|
+
* stdout, test mocks) — ANSI reset on a plain pipe would corrupt
|
|
49
|
+
* downstream consumers.
|
|
50
|
+
*
|
|
51
|
+
* Prefers `fs.writeSync(fd, …)` over `stream.write(…)` because async
|
|
52
|
+
* writes don't flush when `process.exit()` terminates the loop.
|
|
53
|
+
* `stream.write` is the fallback for mocked streams where `fd` isn't
|
|
54
|
+
* a real kernel descriptor.
|
|
55
|
+
*/
|
|
56
|
+
export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean {
|
|
57
|
+
if (!stream.isTTY) return false
|
|
58
|
+
|
|
59
|
+
const fd = typeof stream.fd === "number"
|
|
60
|
+
? stream.fd
|
|
61
|
+
: stream === process.stdout ? 1 : undefined
|
|
62
|
+
|
|
63
|
+
if (fd !== undefined) {
|
|
64
|
+
try {
|
|
65
|
+
writeSync(fd, TERMINAL_MODE_RESET)
|
|
66
|
+
return true
|
|
67
|
+
} catch {
|
|
68
|
+
// Fall through to stream.write for mocked or unusual TTY streams.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
stream.write(TERMINAL_MODE_RESET)
|
|
74
|
+
return true
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wire exit-path hooks so the reset blob fires on every shutdown
|
|
82
|
+
* route: clean exit, signals (SIGINT/SIGTERM/SIGHUP), and uncaught
|
|
83
|
+
* throws. Idempotent — calling more than once is a no-op.
|
|
84
|
+
*
|
|
85
|
+
* On signals we let the process continue to exit naturally after the
|
|
86
|
+
* reset; we don't `process.exit(code)` ourselves because that would
|
|
87
|
+
* race with OpenTUI's own signal handler (which has its own
|
|
88
|
+
* alt-screen cleanup). The reset is synchronous stdout writeSync, so
|
|
89
|
+
* it always lands before the process reaps.
|
|
90
|
+
*/
|
|
91
|
+
let wired = false
|
|
92
|
+
export function installExitResetHooks(): void {
|
|
93
|
+
if (wired) return
|
|
94
|
+
wired = true
|
|
95
|
+
|
|
96
|
+
// Normal exit — fires on process.exit() and on main() falling off.
|
|
97
|
+
// `exit` handler must be synchronous (node discards async work).
|
|
98
|
+
process.on("exit", () => { resetTerminalModes() })
|
|
99
|
+
|
|
100
|
+
// Signals. Attaching a listener suppresses node's default terminate,
|
|
101
|
+
// so we must exit ourselves. OpenTUI's exitHandler was registered
|
|
102
|
+
// after ours (createCliRenderer runs later) and also listens here —
|
|
103
|
+
// but all it does is destroy(), whose terminal writes our reset blob
|
|
104
|
+
// already covers. writeSync flushes before exit() tears the fd.
|
|
105
|
+
const codes = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 } as const
|
|
106
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
107
|
+
process.on(sig, () => {
|
|
108
|
+
resetTerminalModes()
|
|
109
|
+
process.exit(codes[sig])
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Uncaught throws. Emit reset before node prints the stack, so the
|
|
114
|
+
// traceback renders on a clean primary screen.
|
|
115
|
+
process.on("uncaughtException", err => {
|
|
116
|
+
resetTerminalModes()
|
|
117
|
+
// Re-throw via default behavior by writing + exiting after a tick.
|
|
118
|
+
// We print here instead of letting it reach the default handler
|
|
119
|
+
// because OpenTUI may have the alt-screen active and the default
|
|
120
|
+
// traceback would land there and disappear on exit.
|
|
121
|
+
console.error(err)
|
|
122
|
+
process.exit(1)
|
|
123
|
+
})
|
|
124
|
+
process.on("unhandledRejection", reason => {
|
|
125
|
+
resetTerminalModes()
|
|
126
|
+
console.error(reason)
|
|
127
|
+
process.exit(1)
|
|
128
|
+
})
|
|
129
|
+
}
|