@scrylog/cli 0.2.0 → 0.3.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.
Files changed (171) hide show
  1. package/README.md +46 -7
  2. package/dist/assets/highlights-eq9cgrbb.scm +604 -0
  3. package/dist/assets/highlights-ghv9g403.scm +205 -0
  4. package/dist/assets/highlights-hk7bwhj4.scm +284 -0
  5. package/dist/assets/highlights-r812a2qc.scm +150 -0
  6. package/dist/assets/highlights-x6tmsnaa.scm +115 -0
  7. package/dist/assets/injections-73j83es3.scm +27 -0
  8. package/dist/assets/parser.worker-xyhf83rf.js +4563 -0
  9. package/dist/assets/tree-sitter-3jzf13jk.wasm +0 -0
  10. package/dist/assets/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/assets/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/assets/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/assets/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/assets/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/dist/cli/assets/providers/SOURCES.md +21 -0
  16. package/dist/cli/assets/providers/claude-terminal.png +0 -0
  17. package/dist/cli/assets/providers/claude-terminal.svg +62 -0
  18. package/dist/cli/assets/providers/claude.png +0 -0
  19. package/dist/cli/assets/providers/copilot.png +0 -0
  20. package/dist/cli/assets/providers/copilot.svg +44 -0
  21. package/dist/cli/assets/providers/openai-terminal.png +0 -0
  22. package/dist/cli/assets/providers/openai-terminal.svg +62 -0
  23. package/dist/cli/assets/providers/openai.png +0 -0
  24. package/dist/cli/assets/providers/opencode-terminal.png +0 -0
  25. package/dist/cli/assets/providers/opencode-terminal.svg +62 -0
  26. package/dist/cli/assets/providers/opencode.png +0 -0
  27. package/dist/cli/assets/providers/shell.png +0 -0
  28. package/dist/cli/assets/providers/shell.svg +23 -0
  29. package/dist/cli/assets/providers/unknown.png +0 -0
  30. package/dist/cli/assets/providers/unknown.svg +23 -0
  31. package/dist/cli/index.js +499 -0
  32. package/dist/cli/index.js.map +61 -0
  33. package/dist/daemon/index.js +670 -0
  34. package/dist/daemon/index.js.map +1475 -0
  35. package/dist/hooks/claude.js +6 -0
  36. package/dist/hooks/claude.js.map +11 -0
  37. package/dist/hooks/codex.js +10 -0
  38. package/dist/hooks/codex.js.map +11 -0
  39. package/dist/mcp/index.js +100 -0
  40. package/dist/mcp/index.js.map +231 -0
  41. package/dist/oc/index.js +21 -0
  42. package/dist/oc/index.js.map +10 -0
  43. package/package.json +26 -20
  44. package/src/postinstall.cjs +11 -0
  45. package/src/api/docs/sessions.keys.ts +0 -6
  46. package/src/api/docs/sessions.ts +0 -81
  47. package/src/api/docs/system.keys.ts +0 -5
  48. package/src/api/docs/system.ts +0 -66
  49. package/src/api/docs/usage.keys.ts +0 -3
  50. package/src/api/docs/usage.ts +0 -24
  51. package/src/api/docs/webhooks.keys.ts +0 -5
  52. package/src/api/docs/webhooks.ts +0 -78
  53. package/src/api/index.ts +0 -18
  54. package/src/api/lib/auth.ts +0 -43
  55. package/src/api/lib/errors.ts +0 -28
  56. package/src/api/lib/route.ts +0 -18
  57. package/src/api/models/claude.ts +0 -4
  58. package/src/api/models/codex.ts +0 -4
  59. package/src/api/models/opencode.ts +0 -11
  60. package/src/api/routes/sessions.ts +0 -47
  61. package/src/api/routes/system.ts +0 -103
  62. package/src/api/routes/usage.ts +0 -15
  63. package/src/api/routes/webhooks/claude.ts +0 -141
  64. package/src/api/routes/webhooks/codex.ts +0 -175
  65. package/src/api/routes/webhooks/opencode.ts +0 -111
  66. package/src/api/routes.ts +0 -93
  67. package/src/api/schemas/common.ts +0 -21
  68. package/src/api/schemas/sessions.ts +0 -72
  69. package/src/api/schemas/usage.ts +0 -50
  70. package/src/api/schemas/webhooks/claude.ts +0 -32
  71. package/src/api/schemas/webhooks/codex.ts +0 -34
  72. package/src/api/schemas/webhooks/opencode.ts +0 -29
  73. package/src/api/services/claude-session.ts +0 -9
  74. package/src/api/services/codex-session.ts +0 -8
  75. package/src/api/services/opencode-session.ts +0 -73
  76. package/src/classifier.ts +0 -206
  77. package/src/cli/commands.ts +0 -632
  78. package/src/cli/prompt.ts +0 -44
  79. package/src/daemon/cli.ts +0 -27
  80. package/src/daemon/collectors/claude/README.md +0 -108
  81. package/src/daemon/collectors/claude/collector.ts +0 -423
  82. package/src/daemon/collectors/claude/discovery.ts +0 -148
  83. package/src/daemon/collectors/claude/hooks.ts +0 -181
  84. package/src/daemon/collectors/claude/index.ts +0 -8
  85. package/src/daemon/collectors/claude/jsonl.ts +0 -85
  86. package/src/daemon/collectors/claude/transcript.ts +0 -246
  87. package/src/daemon/collectors/codex/README.md +0 -147
  88. package/src/daemon/collectors/codex/collector.ts +0 -661
  89. package/src/daemon/collectors/codex/discovery.ts +0 -104
  90. package/src/daemon/collectors/codex/hooks.ts +0 -179
  91. package/src/daemon/collectors/codex/index.ts +0 -7
  92. package/src/daemon/collectors/codex/jsonl.ts +0 -108
  93. package/src/daemon/collectors/codex/transcript.ts +0 -299
  94. package/src/daemon/collectors/git.ts +0 -295
  95. package/src/daemon/collectors/opencode/README.md +0 -106
  96. package/src/daemon/collectors/opencode/collector.ts +0 -545
  97. package/src/daemon/collectors/opencode/discovery.ts +0 -257
  98. package/src/daemon/collectors/opencode/index.ts +0 -5
  99. package/src/daemon/collectors/opencode/session-builder.ts +0 -59
  100. package/src/daemon/collectors/opencode-db.ts +0 -282
  101. package/src/daemon/collectors/process.ts +0 -201
  102. package/src/daemon/collectors/tmux/README.md +0 -128
  103. package/src/daemon/collectors/tmux/collector.ts +0 -472
  104. package/src/daemon/collectors/tmux/enrichment.ts +0 -241
  105. package/src/daemon/collectors/tmux/index.ts +0 -4
  106. package/src/daemon/collectors/tmux/parse.ts +0 -128
  107. package/src/daemon/collectors/usage.ts +0 -193
  108. package/src/daemon/index.ts +0 -207
  109. package/src/daemon/scheduler.ts +0 -86
  110. package/src/daemon/store.ts +0 -588
  111. package/src/detect.ts +0 -143
  112. package/src/hooks/claude.ts +0 -596
  113. package/src/hooks/codex.ts +0 -517
  114. package/src/legacy/classifier.ts +0 -215
  115. package/src/legacy/detect.ts +0 -143
  116. package/src/mcp/client-server.ts +0 -21
  117. package/src/mcp/http-handler.ts +0 -126
  118. package/src/mcp/index.ts +0 -61
  119. package/src/mcp/resources/config-current.ts +0 -19
  120. package/src/mcp/resources/index.ts +0 -11
  121. package/src/mcp/resources/sessions-active.ts +0 -19
  122. package/src/mcp/resources/sessions-all.ts +0 -19
  123. package/src/mcp/schemas/sessions.ts +0 -3
  124. package/src/mcp/server.ts +0 -17
  125. package/src/mcp/services/api-adapter.ts +0 -48
  126. package/src/mcp/services/data-source.ts +0 -22
  127. package/src/mcp/services/store-adapter.ts +0 -40
  128. package/src/mcp/tools/get-active-sessions.ts +0 -16
  129. package/src/mcp/tools/get-session-children.ts +0 -24
  130. package/src/mcp/tools/get-session-messages.ts +0 -24
  131. package/src/mcp/tools/get-session.ts +0 -23
  132. package/src/mcp/tools/index.ts +0 -15
  133. package/src/mcp/tools/list-sessions.ts +0 -16
  134. package/src/neo-blessed.d.ts +0 -4
  135. package/src/oc/index.ts +0 -302
  136. package/src/service/manager.ts +0 -284
  137. package/src/shared/config.ts +0 -478
  138. package/src/shared/ipc.ts +0 -80
  139. package/src/shared/logger.ts +0 -248
  140. package/src/shared/paths.ts +0 -73
  141. package/src/shared/platform.ts +0 -110
  142. package/src/shared/session-view.ts +0 -241
  143. package/src/shared/types.ts +0 -299
  144. package/src/shared/utils.ts +0 -339
  145. package/src/tui/app.ts +0 -920
  146. package/src/tui/client.ts +0 -149
  147. package/src/tui/index.ts +0 -217
  148. package/src/types.ts +0 -113
  149. package/src/usage/README.md +0 -323
  150. package/src/usage/api.ts +0 -99
  151. package/src/usage/client.ts +0 -3
  152. package/src/usage/clients/anthropic-oauth-usage.ts +0 -178
  153. package/src/usage/clients/anthropic-web-usage.ts +0 -245
  154. package/src/usage/clients/claude-cli-probe.ts +0 -187
  155. package/src/usage/clients/codex-oauth-usage.ts +0 -113
  156. package/src/usage/clients/opencode-go-usage.ts +0 -441
  157. package/src/usage/index.ts +0 -20
  158. package/src/usage/mappers.ts +0 -273
  159. package/src/usage/planner.ts +0 -54
  160. package/src/usage/providers/claude.ts +0 -113
  161. package/src/usage/providers/codex.ts +0 -34
  162. package/src/usage/providers/opencode-go.ts +0 -37
  163. package/src/usage/resolver.ts +0 -4
  164. package/src/usage/resolvers/browser-cookies.ts +0 -470
  165. package/src/usage/resolvers/claude-auth.ts +0 -108
  166. package/src/usage/resolvers/claude-cli.ts +0 -24
  167. package/src/usage/resolvers/claude-desktop-auth.ts +0 -160
  168. package/src/usage/resolvers/claude-web.ts +0 -16
  169. package/src/usage/resolvers/codex-auth.ts +0 -117
  170. package/src/usage/resolvers/opencode-go-web.ts +0 -14
  171. package/src/usage/types.ts +0 -190
package/src/tui/app.ts DELETED
@@ -1,920 +0,0 @@
1
- import blessed from "neo-blessed";
2
- import type { ScrylogConfig } from "../shared/config";
3
- import type { DaemonClient } from "./client";
4
- import type { DetectionReason, ProcessInfo, SessionModel, SessionState, SessionTool, StoreEvent } from "../shared/types";
5
- import type { SessionListItem, SessionViewCounts } from "../shared/session-view";
6
- import { formatDuration, truncate } from "../shared/utils";
7
-
8
- type SessionToolFilter = SessionTool | "all";
9
- type SessionStateFilter = SessionState | "all";
10
-
11
- const TOOL_FILTERS: SessionToolFilter[] = ["all", "opencode", "claude", "codex", "copilotcli", "shell", "unknown"];
12
- const STATE_FILTERS: SessionStateFilter[] = ["all", "working", "waiting-input", "idle", "shell", "unknown"];
13
- const WORKING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
14
- const HEADER_HEIGHT_NARROW = 8;
15
- const HEADER_HEIGHT_WIDE = 6;
16
- const STATE_COLORS: Record<string, string> = {
17
- working: "yellow",
18
- idle: "green",
19
- "waiting-input": "magenta",
20
- shell: "blue",
21
- error: "red",
22
- unknown: "gray",
23
- };
24
-
25
- // ── Row types ─────────────────────────────────────────────────────────────────
26
-
27
- type DashboardRow = ProjectHeaderRow | SessionRow | GapRow;
28
-
29
- interface ProjectHeaderRow {
30
- kind: "project-header";
31
- projectKey: string;
32
- projectName: string;
33
- }
34
-
35
- interface SessionRow {
36
- kind: "session";
37
- projectKey: string;
38
- session: SessionListItem;
39
- }
40
-
41
- interface GapRow {
42
- kind: "gap";
43
- projectKey: string;
44
- }
45
-
46
- // ── App ───────────────────────────────────────────────────────────────────────
47
-
48
- export interface AppOptions {
49
- refreshMs: number;
50
- showStandalone: boolean;
51
- selectedSessionId?: string;
52
- }
53
-
54
- export class DashboardApp {
55
- private readonly screen = blessed.screen({
56
- smartCSR: true,
57
- fullUnicode: false,
58
- title: "scrylog",
59
- dockBorders: true,
60
- });
61
-
62
- private readonly header = blessed.box({ top: 0, left: 0, width: "100%", height: HEADER_HEIGHT_NARROW, tags: true });
63
- private readonly table = blessed.listtable({
64
- top: HEADER_HEIGHT_NARROW,
65
- left: 0,
66
- width: "100%",
67
- bottom: 1,
68
- keys: false,
69
- tags: true,
70
- border: "line",
71
- style: {
72
- header: { bold: true, fg: "white" },
73
- cell: { selected: { bg: "black", bold: true } },
74
- border: { fg: "gray" },
75
- },
76
- align: "left",
77
- vi: false,
78
- });
79
- private readonly footer = blessed.box({ bottom: 0, left: 0, width: "100%", height: 1, tags: true });
80
- private readonly overlay = blessed.box({
81
- parent: this.screen,
82
- top: "center",
83
- left: "center",
84
- width: "90%",
85
- height: "90%",
86
- border: "line",
87
- hidden: true,
88
- tags: true,
89
- scrollable: true,
90
- alwaysScroll: true,
91
- keys: true,
92
- vi: true,
93
- style: { border: { fg: "cyan" } },
94
- });
95
- private readonly promptBox = blessed.prompt({
96
- parent: this.screen,
97
- border: "line",
98
- height: 7,
99
- width: "70%",
100
- top: "center",
101
- left: "center",
102
- label: "Input",
103
- hidden: true,
104
- });
105
- private readonly message = blessed.message({
106
- parent: this.screen,
107
- border: "line",
108
- height: 7,
109
- width: "70%",
110
- top: "center",
111
- left: "center",
112
- hidden: true,
113
- label: "scrylog",
114
- });
115
-
116
- private sessions: SessionListItem[] = [];
117
- private visibleCounts: SessionViewCounts | null = null;
118
- private visibleRows: DashboardRow[] = [];
119
- private selectedSessionId: string | undefined;
120
- private isProgrammaticSelection = false;
121
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
122
- private animationTimer: ReturnType<typeof setInterval> | null = null;
123
- private unsubscribeSSE: (() => void) | null = null;
124
- private spinnerFrame = 0;
125
- private daemonConnected = true;
126
- private errors: string[] = [];
127
- private filters: { query: string; actionableOnly: boolean; tool: SessionToolFilter; state: SessionStateFilter } = {
128
- query: "",
129
- actionableOnly: false,
130
- tool: "all",
131
- state: "all",
132
- };
133
- private pushDebounceTimer: ReturnType<typeof setTimeout> | null = null;
134
- private knownSessionStates = new Map<string, SessionModel["state"]>();
135
-
136
- constructor(
137
- private readonly client: DaemonClient,
138
- private readonly options: AppOptions,
139
- private readonly config: ScrylogConfig,
140
- ) {}
141
-
142
- start(): void {
143
- this.screen.append(this.header);
144
- this.screen.append(this.table);
145
- this.screen.append(this.footer);
146
- this.registerKeys();
147
-
148
- // Initial load
149
- void this.refresh();
150
-
151
- // Subscribe to SSE push updates if enabled
152
- if (this.config.tui.usePushUpdates) {
153
- this.unsubscribeSSE = this.client.subscribeEvents((event) => {
154
- if (event.type === "connected") {
155
- this.daemonConnected = true;
156
- this.flushPushRefresh();
157
- void this.refresh();
158
- } else if (event.type === "disconnected") {
159
- this.daemonConnected = false;
160
- this.flushPushRefresh();
161
- this.renderHeader();
162
- this.renderFooter();
163
- this.screen.render();
164
- } else if (this.shouldRefreshFromEvent(event)) {
165
- this.schedulePushRefresh(this.isStateChangeEvent(event));
166
- }
167
- });
168
- }
169
-
170
- // Fallback polling timer
171
- this.refreshTimer = setInterval(() => void this.refresh(), this.options.refreshMs);
172
- this.animationTimer = setInterval(() => this.tickAnimation(), 180);
173
- this.screen.render();
174
- }
175
-
176
- private registerKeys(): void {
177
- this.screen.key(["q", "C-c"], () => this.destroy());
178
- this.screen.key(["r"], () => void this.refresh());
179
- this.screen.key(["enter"], () => void this.openDetail());
180
- this.table.key(["enter"], () => void this.openDetail());
181
- this.screen.key(["/"], () => {
182
- this.prompt("Search", this.filters.query, (value) => {
183
- this.filters.query = value.trim();
184
- void this.refresh();
185
- });
186
- });
187
- this.screen.key(["a"], () => {
188
- this.filters.actionableOnly = !this.filters.actionableOnly;
189
- void this.refresh();
190
- });
191
- this.screen.key(["f"], () => this.cycleFilters());
192
- this.screen.key(["t"], () => this.enterSelectedSession());
193
- this.table.key(["up", "k"], () => this.moveSelection(-1));
194
- this.table.key(["down", "j"], () => this.moveSelection(1));
195
- this.table.key(["escape"], () => this.clearSelection());
196
- this.overlay.key(["escape", "q"], () => {
197
- this.overlay.hide();
198
- this.table.focus();
199
- this.screen.render();
200
- });
201
-
202
- this.table.on("select", (_item: unknown, index: unknown) => {
203
- if (this.isProgrammaticSelection) return;
204
-
205
- const currentSelected = Number((this.table as blessed.Widgets.ListTableElement & { selected?: number }).selected ?? 0);
206
- const rowIndex = typeof index === "number" ? index : currentSelected;
207
- if (rowIndex <= 0) return;
208
-
209
- const row = this.visibleRows[rowIndex - 1];
210
- if (!row) return;
211
-
212
- if (row.kind === "session") {
213
- this.selectedSessionId = row.session.id;
214
- this.renderFooter();
215
- return;
216
- }
217
-
218
- const selectedIndex = this.getRowIndexForSessionId(this.selectedSessionId);
219
- if (selectedIndex >= 0) {
220
- this.selectTableRow(selectedIndex);
221
- }
222
- this.renderFooter();
223
- });
224
-
225
- this.table.focus();
226
- }
227
-
228
- private async refresh(render = true): Promise<void> {
229
- const [status, sessionData] = await Promise.all([
230
- this.client.getStatusSummary(),
231
- this.client.getSessions(),
232
- ]);
233
-
234
- if (status && sessionData) {
235
- this.sessions = sessionData.sessions;
236
- this.visibleCounts = status.counts;
237
- this.daemonConnected = true;
238
- this.errors = [];
239
- this.knownSessionStates = new Map(this.sessions.map((s) => [s.id, s.state]));
240
- } else {
241
- this.daemonConnected = false;
242
- }
243
-
244
- const filtered = this.applyFilters(this.sessions);
245
- const narrow = this.screen.program.cols < 110;
246
- this.visibleRows = buildRows(filtered, narrow);
247
- this.renderHeader();
248
- this.renderTable();
249
- this.keepSelection();
250
- this.renderFooter();
251
- if (render) {
252
- this.screen.render();
253
- }
254
- }
255
-
256
- private tickAnimation(): void {
257
- this.spinnerFrame = (this.spinnerFrame + 1) % WORKING_FRAMES.length;
258
- this.renderTable();
259
- this.keepSelection();
260
- this.renderFooter();
261
- if (this.overlay.hidden) {
262
- this.screen.render();
263
- }
264
- }
265
-
266
- private keepSelection(): void {
267
- const filtered = this.applyFilters(this.sessions);
268
- if (!filtered.some((s) => s.id === this.selectedSessionId)) {
269
- this.selectedSessionId = undefined;
270
- this.resetTableSelection();
271
- }
272
-
273
- setTableInteractive(this.table, Boolean(this.selectedSessionId));
274
-
275
- const selectedIndex = this.getRowIndexForSessionId(this.selectedSessionId);
276
- if (selectedIndex >= 0) {
277
- this.selectTableRow(selectedIndex);
278
- const listTable = this.table as blessed.Widgets.ListTableElement & { scrollTo?: (offset: number) => void };
279
- listTable.scrollTo?.(Math.max(0, selectedIndex));
280
- }
281
- }
282
-
283
- private applyFilters(sessions: SessionListItem[]): SessionListItem[] {
284
- const q = this.filters.query.toLowerCase();
285
- const maxAgeHours = this.config.tui.standaloneMaxAgeHours;
286
- const cutoff = maxAgeHours > 0 ? Date.now() - maxAgeHours * 3_600_000 : 0;
287
- return sessions.filter((s) => {
288
- const isSubSession = !!s.parentSessionId;
289
- // Sub-sessions: only show when active; never age-filter them
290
- if (isSubSession) {
291
- if (s.state !== "working" && s.state !== "waiting-input") return false;
292
- } else {
293
- if (cutoff > 0 && s.source === "standalone" && s.lastActivity < cutoff) return false;
294
- }
295
- if (this.filters.actionableOnly && !(s.hasAttention ?? s.state === "waiting-input")) return false;
296
- if (this.filters.tool !== "all" && s.tool !== this.filters.tool) return false;
297
- if (this.filters.state !== "all" && s.state !== this.filters.state) return false;
298
- if (!q) return true;
299
- const hay = [
300
- s.id, s.tool, s.state, s.summary,
301
- s.project?.displayName, s.project?.compactLabel, s.project?.branch,
302
- s.tmux?.session, s.currentPath,
303
- ].filter(Boolean).join(" ").toLowerCase();
304
- return hay.includes(q);
305
- });
306
- }
307
-
308
- private moveSelection(direction: 1 | -1): void {
309
- if (this.visibleRows.length === 0) return;
310
-
311
- if (!this.selectedSessionId) {
312
- const first = direction > 0
313
- ? this.visibleRows.find((r): r is SessionRow => r.kind === "session")
314
- : [...this.visibleRows].reverse().find((r): r is SessionRow => r.kind === "session");
315
- if (!first) return;
316
- setTableInteractive(this.table, true);
317
- this.selectedSessionId = first.session.id;
318
- this.keepSelection();
319
- this.renderFooter();
320
- this.screen.render();
321
- return;
322
- }
323
-
324
- const currentIndex = this.getRowIndexForSessionId(this.selectedSessionId);
325
- if (currentIndex < 0) return;
326
-
327
- let nextIndex = currentIndex + direction;
328
- while (nextIndex >= 0 && nextIndex < this.visibleRows.length) {
329
- if (this.visibleRows[nextIndex]?.kind === "session") break;
330
- nextIndex += direction;
331
- }
332
-
333
- if (nextIndex < 0 || nextIndex >= this.visibleRows.length) return;
334
- const nextRow = this.visibleRows[nextIndex];
335
- if (nextRow?.kind !== "session") return;
336
-
337
- this.selectedSessionId = nextRow.session.id;
338
- this.selectTableRow(nextIndex);
339
- const listTable = this.table as blessed.Widgets.ListTableElement & { scrollTo?: (offset: number) => void };
340
- listTable.scrollTo?.(Math.max(0, nextIndex));
341
- this.renderFooter();
342
- this.screen.render();
343
- }
344
-
345
- private clearSelection(): void {
346
- this.selectedSessionId = undefined;
347
- this.resetTableSelection();
348
- setTableInteractive(this.table, false);
349
- this.renderFooter();
350
- this.screen.render();
351
- }
352
-
353
- private selectTableRow(rowIndex: number): void {
354
- this.isProgrammaticSelection = true;
355
- this.table.select(rowIndex + 1);
356
- this.isProgrammaticSelection = false;
357
- }
358
-
359
- private resetTableSelection(): void {
360
- this.isProgrammaticSelection = true;
361
- this.table.select(0);
362
- this.isProgrammaticSelection = false;
363
- }
364
-
365
- private getRowIndexForSessionId(id: string | undefined): number {
366
- if (!id) return -1;
367
- return this.visibleRows.findIndex((r) => r.kind === "session" && r.session.id === id);
368
- }
369
-
370
- private getSelectedSession(): SessionListItem | undefined {
371
- return this.sessions.find((s) => s.id === this.selectedSessionId);
372
- }
373
-
374
- // ── Rendering ──────────────────────────────────────────────────────────────
375
-
376
- private renderHeader(): void {
377
- const cols = this.screen.program.cols;
378
- const headerHeight = cols < 110 ? HEADER_HEIGHT_NARROW : HEADER_HEIGHT_WIDE;
379
- this.header.height = headerHeight;
380
- this.table.top = headerHeight;
381
-
382
- const counts = countStates(this.sessions);
383
- const sessionSummary = this.visibleCounts
384
- ? `${this.visibleCounts.visible} sessions`
385
- : `${this.sessions.length} sessions`;
386
- const statusRows = buildStatusSummaryRows(counts, this.spinnerFrame);
387
- const connStatus = this.daemonConnected ? "" : " {red-fg}● daemon disconnected{/red-fg}";
388
- const errorLine = this.errors[0] ? ` {red-fg}${this.errors[0]}{/red-fg}` : "";
389
- const titleRows = buildTitleRows(cols);
390
-
391
- if (cols < 110) {
392
- this.header.setContent([
393
- "",
394
- ...titleRows,
395
- "",
396
- ` ${sessionSummary}${connStatus}${errorLine}`,
397
- ` ${statusRows[0]}`,
398
- ` ${statusRows[1]}`,
399
- ].join("\n"));
400
- return;
401
- }
402
-
403
- const contentWidth = Math.max(20, cols - 2);
404
- const leftWidth = Math.floor(contentWidth / 2);
405
- const rightWidth = contentWidth - leftWidth;
406
- const rightRows = [
407
- ` ${sessionSummary}${connStatus}${errorLine}`,
408
- ` ${statusRows[0]}`,
409
- ` ${statusRows[1]}`,
410
- ];
411
-
412
- this.header.setContent([
413
- "",
414
- joinHeaderColumns(titleRows[0] ?? "", rightRows[0] ?? "", leftWidth, rightWidth),
415
- joinHeaderColumns(titleRows[1] ?? "", rightRows[1] ?? "", leftWidth, rightWidth),
416
- joinHeaderColumns("", rightRows[2] ?? "", leftWidth, rightWidth),
417
- ].join("\n"));
418
- }
419
-
420
- private renderTable(): void {
421
- const narrow = this.screen.program.cols < 110;
422
- const projectHeaderWidth = getProjectHeaderWidth(this.visibleRows, narrow ? 28 : 36);
423
- const headers = narrow
424
- ? ["Session", "St", "Tool", "Summary"]
425
- : ["Session", "St", "Tool", "Stable", "Summary"];
426
-
427
- // Build a map: sessionId → count of active sub-sessions (for narrow mode badge)
428
- const visibleChildCount = new Map<string, number>();
429
- if (narrow) {
430
- for (const s of this.sessions) {
431
- if (s.parentSessionId && (s.state === "working" || s.state === "waiting-input")) {
432
- visibleChildCount.set(s.parentSessionId, (visibleChildCount.get(s.parentSessionId) ?? 0) + 1);
433
- }
434
- }
435
- }
436
-
437
- const rows = this.visibleRows.map((row) => {
438
- if (row.kind === "gap") {
439
- return narrow ? ["", "", "", ""] : ["", "", "", "", ""];
440
- }
441
-
442
- if (row.kind === "project-header") {
443
- return narrow
444
- ? [formatProjectHeader(row.projectName, projectHeaderWidth), "", "", ""]
445
- : [formatProjectHeader(row.projectName, projectHeaderWidth), "", "", "", ""];
446
- }
447
-
448
- const s = row.session;
449
- const color = STATE_COLORS[s.state] ?? "white";
450
- const statusCell = formatStatusCell(s.state, s.stableForMs ?? 0, this.spinnerFrame, narrow);
451
- const activeChildCount = narrow ? (visibleChildCount.get(s.id) ?? 0) : 0;
452
- const label = formatTargetLabel(s, narrow);
453
- const summaryWidth = narrow ? 34 : 82;
454
-
455
- if (narrow) {
456
- const toolLabel = activeChildCount > 0 ? `${s.tool} [+${activeChildCount}]` : s.tool;
457
- return [
458
- `{${color}-fg}${truncate(label, 28)}{/}`,
459
- `{${color}-fg}${statusCell}{/}`,
460
- `{${color}-fg}${truncate(toolLabel, 12)}{/}`,
461
- `{${color}-fg}${truncate(s.summary || "-", summaryWidth)}{/}`,
462
- ];
463
- }
464
-
465
- return [
466
- `{${color}-fg}${truncate(label, 30)}{/}`,
467
- `{${color}-fg}{bold}${stateGlyph(s.state, this.spinnerFrame)}{/bold}{/}`,
468
- `{${color}-fg}${s.tool}{/}`,
469
- `{${color}-fg}${formatDuration(s.stableForMs ?? 0)}{/}`,
470
- `{${color}-fg}${truncate(s.summary || "-", summaryWidth)}{/}`,
471
- ];
472
- });
473
-
474
- if (rows.length === 0) {
475
- rows.push(narrow
476
- ? ["No sessions", "-", "-", "No sessions detected"]
477
- : ["No sessions", "-", "-", "-", "No sessions detected"]);
478
- }
479
-
480
- this.table.setData([headers, ...rows]);
481
- }
482
-
483
- private renderFooter(): void {
484
- const session = this.getSelectedSession();
485
- const filterSummary = [
486
- this.filters.query ? `q=${this.filters.query}` : null,
487
- this.filters.actionableOnly ? "actionable" : null,
488
- this.filters.tool !== "all" ? `tool=${this.filters.tool}` : null,
489
- this.filters.state !== "all" ? `state=${this.filters.state}` : null,
490
- ].filter(Boolean).join(" ");
491
-
492
- const sel = session
493
- ? `${session.tool} ${session.state} ${session.project?.compactLabel ?? session.id}`
494
- : "no selection";
495
-
496
- this.footer.setContent(
497
- ` {bold}Enter{/bold} detail {bold}t{/bold} tmux {bold}/{/bold} search {bold}f{/bold} filters {bold}a{/bold} actionable {bold}r{/bold} refresh {bold}q{/bold} quit ${sel} ${filterSummary}`,
498
- );
499
- }
500
-
501
- private async openDetail(): Promise<void> {
502
- const session = this.getSelectedSession();
503
- if (!session) return;
504
-
505
- const fresh = await this.client.getSessionDetail(session.id) ?? ({ ...session } as SessionModel);
506
-
507
- const src = fresh.detectionSource === "opencode-sdk"
508
- ? `Source: OpenCode SDK (${fresh.opencode?.instanceUrl ?? "-"})`
509
- : fresh.detectionSource === "opencode-hook"
510
- ? "Source: OpenCode Hook"
511
- : fresh.detectionSource === "opencode-db"
512
- ? "Source: OpenCode DB"
513
- : fresh.detectionSource === "claude-transcript"
514
- ? `Source: Claude transcript (${fresh.claude?.transcriptPath ?? "-"})`
515
- : fresh.detectionSource === "claude-hook"
516
- ? "Source: Claude Hook"
517
- : fresh.detectionSource === "codex-transcript"
518
- ? `Source: Codex transcript (${fresh.codex?.transcriptPath ?? "-"})`
519
- : fresh.detectionSource === "codex-hook"
520
- ? "Source: Codex Hook"
521
- : "Source: Fallback detection (tmux pane heuristics)";
522
-
523
- const detailLines = [
524
- `{bold}${fresh.id}{/bold} ${fresh.tool} ${fresh.state}`,
525
- `source: ${fresh.source}`,
526
- src,
527
- `project: ${fresh.project?.displayName ?? "-"}`,
528
- `project label: ${fresh.project?.compactLabel ?? "-"}`,
529
- `git: ${fresh.project?.isGitRepository ? `branch=${fresh.project?.branch ?? "detached"} worktree=${fresh.project?.isWorktree ? "yes" : "no"}` : "no"}`,
530
- ...(fresh.project?.gitRootPath ? [`git root: ${fresh.project.gitRootPath}`] : []),
531
- ...(fresh.currentPath ? [`path: ${fresh.currentPath}`] : []),
532
- ...(fresh.tty || fresh.panePid || fresh.paneTitle
533
- ? [`tty: ${fresh.tty ?? "-"} pid: ${fresh.panePid ?? "-"} title: ${fresh.paneTitle || "-"}`]
534
- : []),
535
- ...(fresh.tmux ? [`tmux: ${fresh.tmux.session}:${fresh.tmux.window}.${fresh.tmux.pane}`] : []),
536
- `summary: ${fresh.summary}`,
537
- `lastActivity: ${new Date(fresh.lastActivity).toISOString()}`,
538
- ...(fresh.activeTool ? [`activeTool: ${fresh.activeTool}`] : []),
539
- "",
540
- "{bold}Legend{/bold} {yellow-fg}working{/yellow-fg} {magenta-fg}input{/magenta-fg} {green-fg}idle{/green-fg} {blue-fg}shell{/blue-fg} {gray-fg}unknown{/gray-fg}",
541
- ];
542
-
543
- if (fresh.toolReasons && fresh.toolReasons.length > 0 || fresh.stateReasons && fresh.stateReasons.length > 0) {
544
- detailLines.push("", "{bold}Classification{/bold}");
545
- for (const r of (fresh.toolReasons ?? []) as DetectionReason[]) {
546
- detailLines.push(`- tool: ${r.label} -> ${r.detail}`);
547
- }
548
- for (const r of (fresh.stateReasons ?? []) as DetectionReason[]) {
549
- detailLines.push(`- state: ${r.label} -> ${r.detail}`);
550
- }
551
- }
552
-
553
- if (fresh.codex) {
554
- detailLines.push("", "{bold}Codex metadata{/bold}");
555
- detailLines.push(`- model: ${fresh.codex.model ?? "-"}`);
556
- detailLines.push(`- transcriptPath: ${fresh.codex.transcriptPath ?? "-"}`);
557
- if (fresh.codex.parentSessionId) {
558
- detailLines.push(`- parentSessionId: ${fresh.codex.parentSessionId}`);
559
- }
560
- if (fresh.codex.forkedFromId) {
561
- detailLines.push(`- forkedFromId: ${fresh.codex.forkedFromId}`);
562
- }
563
- }
564
-
565
- if (fresh.processTree) {
566
- detailLines.push("", "{bold}Process chain{/bold}");
567
- if (fresh.processTree.length > 0) {
568
- for (const p of fresh.processTree as ProcessInfo[]) {
569
- detailLines.push(`- ${p.pid} ${p.comm} [${p.state}] ${truncate(p.command, Math.max(40, this.screen.program.cols - 20))}`);
570
- }
571
- } else {
572
- detailLines.push("- No process metadata available");
573
- }
574
- }
575
-
576
- if (fresh.sample !== undefined) {
577
- detailLines.push("", "{bold}Pane tail{/bold}");
578
- if (fresh.sample?.lines.length) {
579
- detailLines.push(...fresh.sample.lines);
580
- } else {
581
- detailLines.push("(no pane output captured)");
582
- }
583
- }
584
-
585
- detailLines.push("", "Press q or Esc to close. Press t to enter the tmux session (tmux sessions only).");
586
-
587
- this.overlay.setContent(detailLines.join("\n"));
588
- this.overlay.setScroll(0);
589
- this.overlay.show();
590
- (this.overlay as blessed.Widgets.BoxElement & { setFront?: () => void }).setFront?.();
591
- this.overlay.focus();
592
- this.screen.render();
593
- }
594
-
595
- private prompt(label: string, initial: string, onSubmit: (value: string) => void): void {
596
- this.promptBox.input(label, initial, (_err: Error | null, value: string | null) => {
597
- if (value !== null) onSubmit(value);
598
- this.table.focus();
599
- this.screen.render();
600
- });
601
- }
602
-
603
- private cycleFilters(): void {
604
- const nextTool = TOOL_FILTERS[(TOOL_FILTERS.indexOf(this.filters.tool) + 1) % TOOL_FILTERS.length];
605
- if (nextTool !== "all") {
606
- this.filters.tool = nextTool;
607
- this.filters.state = "all";
608
- } else {
609
- this.filters.tool = "all";
610
- this.filters.state = STATE_FILTERS[(STATE_FILTERS.indexOf(this.filters.state) + 1) % STATE_FILTERS.length];
611
- }
612
- void this.refresh();
613
- }
614
-
615
- private enterSelectedSession(): void {
616
- const session = this.getSelectedSession();
617
- if (!session) return;
618
-
619
- if (session.source !== "tmux" || !session.tmux) {
620
- (this.message as blessed.Widgets.MessageElement).display(
621
- "tmux attach is only available for sessions running inside tmux.",
622
- 3,
623
- () => {
624
- this.table.focus();
625
- this.screen.render();
626
- },
627
- );
628
- return;
629
- }
630
-
631
- const { isInsideTmux } = require("../shared/utils");
632
- const command = isInsideTmux()
633
- ? ["tmux", "switch-client", "-t", session.tmux.session]
634
- : ["tmux", "attach-session", "-t", session.tmux.session];
635
-
636
- try {
637
- this.destroy(false);
638
- const proc = Bun.spawnSync(command, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
639
- if (proc.exitCode !== 0) {
640
- throw new Error(`Could not enter session ${session.tmux.session}`);
641
- }
642
- process.exit(0);
643
- } catch (error) {
644
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
645
- process.exit(1);
646
- }
647
- }
648
-
649
- private isStateChangeEvent(event: StoreEvent): boolean {
650
- if (event.type === "session:added" || event.type === "session:removed") return true;
651
- if (event.type === "session:updated") {
652
- const known = this.knownSessionStates.get(event.session.id);
653
- return known === undefined || known !== event.session.state;
654
- }
655
- return false;
656
- }
657
-
658
- private shouldRefreshFromEvent(event: StoreEvent): boolean {
659
- if (event.type === "session:added" || event.type === "session:removed") return true;
660
- if (event.type === "session:updated") {
661
- const known = this.knownSessionStates.get(event.session.id);
662
- if (known === undefined) return true;
663
- return known !== event.session.state;
664
- }
665
- return false;
666
- }
667
-
668
- private schedulePushRefresh(immediate: boolean): void {
669
- if (this.pushDebounceTimer) {
670
- clearTimeout(this.pushDebounceTimer);
671
- this.pushDebounceTimer = null;
672
- }
673
- if (immediate || this.config.tui.pushDebounceMs <= 0) {
674
- void this.refresh(false);
675
- return;
676
- }
677
- this.pushDebounceTimer = setTimeout(() => {
678
- this.pushDebounceTimer = null;
679
- void this.refresh(false);
680
- }, this.config.tui.pushDebounceMs);
681
- }
682
-
683
- private flushPushRefresh(): void {
684
- if (this.pushDebounceTimer) {
685
- clearTimeout(this.pushDebounceTimer);
686
- this.pushDebounceTimer = null;
687
- void this.refresh(false);
688
- }
689
- }
690
-
691
- private destroy(shouldExit = true): void {
692
- if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; }
693
- if (this.animationTimer) { clearInterval(this.animationTimer); this.animationTimer = null; }
694
- if (this.unsubscribeSSE) { this.unsubscribeSSE(); this.unsubscribeSSE = null; }
695
- if (this.pushDebounceTimer) { clearTimeout(this.pushDebounceTimer); this.pushDebounceTimer = null; }
696
- this.screen.destroy();
697
- if (shouldExit) process.exit(0);
698
- }
699
- }
700
-
701
- // ── Row building ──────────────────────────────────────────────────────────────
702
-
703
- /**
704
- * Lower weight = higher priority in the sorted list.
705
- * Order: tmux agent < tmux shell < tmux unknown < standalone agent < standalone shell < standalone unknown
706
- */
707
- function sessionSortWeight(s: SessionListItem): number {
708
- const sourcePenalty = s.source === "tmux" ? 0 : 100;
709
- const toolPenalty = s.tool === "unknown" ? 20 : s.tool === "shell" ? 10 : 0;
710
- return sourcePenalty + toolPenalty;
711
- }
712
-
713
- /**
714
- * Sort weight for a project group.
715
- * Groups where no session has a resolved project (standalone tool groups) are pushed
716
- * to the end by adding a large penalty on top of their minimum session weight.
717
- */
718
- function groupSortWeight(group: { sessions: SessionListItem[] }): number {
719
- const minWeight = Math.min(...group.sessions.map(sessionSortWeight));
720
- const noProject = group.sessions.every((s) => !s.project);
721
- return noProject ? 1000 + minWeight : minWeight;
722
- }
723
-
724
- export function buildRows(sessions: SessionListItem[], narrow = false): DashboardRow[] {
725
- // Separate root sessions from sub-sessions
726
- const roots = sessions.filter((s) => !s.parentSessionId);
727
- const childrenByParent = new Map<string, SessionListItem[]>();
728
- for (const s of sessions) {
729
- if (s.parentSessionId) {
730
- const arr = childrenByParent.get(s.parentSessionId) ?? [];
731
- arr.push(s);
732
- childrenByParent.set(s.parentSessionId, arr);
733
- }
734
- }
735
-
736
- const grouped = new Map<string, { projectName: string; sessions: SessionListItem[] }>();
737
-
738
- for (const s of roots) {
739
- const key = getProjectGroupKey(s);
740
- const existing = grouped.get(key);
741
- if (existing) {
742
- existing.sessions.push(s);
743
- } else {
744
- grouped.set(key, {
745
- projectName: s.project?.displayName ?? (s.source === "standalone"
746
- ? (s.tool !== "unknown" ? s.tool : "standalone")
747
- : s.tmux?.session ?? key),
748
- sessions: [s],
749
- });
750
- }
751
- }
752
-
753
- // Sort groups by weight; groups with no resolved project (standalone tool groups) go last
754
- const sortedGroups = [...grouped.entries()].sort(([, a], [, b]) => {
755
- return groupSortWeight(a) - groupSortWeight(b);
756
- });
757
-
758
- const rows: DashboardRow[] = [];
759
- if (sortedGroups.length > 0) {
760
- rows.push({ kind: "gap", projectKey: "__initial-gap__" });
761
- }
762
-
763
- let groupIndex = 0;
764
- for (const [projectKey, group] of sortedGroups) {
765
- if (groupIndex > 0) {
766
- rows.push({ kind: "gap", projectKey });
767
- }
768
- // Within each group, sort by session weight; tie-break by lastActivity descending
769
- group.sessions.sort((a, b) => {
770
- const weightDiff = sessionSortWeight(a) - sessionSortWeight(b);
771
- return weightDiff !== 0 ? weightDiff : b.lastActivity - a.lastActivity;
772
- });
773
- rows.push({ kind: "project-header", projectKey, projectName: group.projectName });
774
- for (const s of group.sessions) {
775
- rows.push({ kind: "session", projectKey, session: s });
776
- // In wide mode: emit child rows immediately after parent
777
- // In narrow mode: children are summarised as [+N] badge on parent row; skip child rows
778
- if (!narrow) {
779
- const children = (childrenByParent.get(s.id) ?? []).sort((a, b) => {
780
- const weightDiff = sessionSortWeight(a) - sessionSortWeight(b);
781
- return weightDiff !== 0 ? weightDiff : b.lastActivity - a.lastActivity;
782
- });
783
- for (const child of children) {
784
- rows.push({ kind: "session", projectKey, session: child });
785
- }
786
- }
787
- }
788
- groupIndex++;
789
- }
790
-
791
- return rows;
792
- }
793
-
794
- function getProjectGroupKey(s: SessionListItem): string {
795
- return s.project?.repositoryIdentityPath
796
- ?? s.project?.gitRootPath
797
- ?? s.project?.sourcePath
798
- ?? s.project?.displayName
799
- ?? (s.source === "standalone" ? `standalone:${s.tool}` : s.tmux?.session ?? s.id);
800
- }
801
-
802
- // ── Helpers ───────────────────────────────────────────────────────────────────
803
-
804
- function stateGlyph(state: string, spinnerFrame = 0): string {
805
- switch (state) {
806
- case "working": return WORKING_FRAMES[spinnerFrame % WORKING_FRAMES.length] ?? WORKING_FRAMES[0]!;
807
- case "idle": return "○";
808
- case "waiting-input": return "?";
809
- case "shell": return ">";
810
- case "error": return "✗";
811
- default: return "?";
812
- }
813
- }
814
-
815
- export function formatStatusCell(state: string, stableForMs: number, spinnerFrame: number, narrow: boolean): string {
816
- const glyph = stateGlyph(state, spinnerFrame);
817
- if (narrow) {
818
- return `{bold}${glyph}{/bold} (${formatDuration(stableForMs)})`;
819
- }
820
- return `{bold}${glyph}{/bold}`;
821
- }
822
-
823
- function formatBranchLabel(s: SessionListItem): string {
824
- if (!s.project?.isGitRepository) return "";
825
- return s.project.branch || "detached";
826
- }
827
-
828
- function formatTargetLabel(s: SessionListItem, narrow = false): string {
829
- let base: string;
830
- if (s.parentSessionId) {
831
- // Sub-agent session — always use ↳ with extra indent
832
- base = ` ↳ ${truncate(s.summary || s.id, narrow ? 22 : 26)}`;
833
- } else if (s.tmux) {
834
- base = ` ▸ ${s.tmux.session}`;
835
- } else {
836
- // Standalone session: project header already shows the project name.
837
- // Priority: activeTool (B) → short session id (D) → just the glyph (E)
838
- const placeholder = s.activeTool
839
- ? `[${s.activeTool}]`
840
- : (s.id.includes(":") ? s.id.split(":")[1]!.slice(0, 8) : s.id.slice(0, 8));
841
- base = ` ◇ ${placeholder}`;
842
- }
843
- const branch = formatBranchLabel(s);
844
- return branch ? `${base}@${branch}` : base;
845
- }
846
-
847
- function formatProjectHeader(projectName: string, width = projectName.length): string {
848
- const label = truncate(projectName, Math.max(8, width));
849
- const padded = label.padEnd(Math.max(width, label.length), " ");
850
- return `{white-fg}{gray-fg}{bold}== ${padded} =={/bold}{/gray-fg}{/white-fg}`;
851
- }
852
-
853
- function getProjectHeaderWidth(rows: DashboardRow[], maxWidth: number): number {
854
- const names = rows
855
- .filter((r): r is ProjectHeaderRow => r.kind === "project-header")
856
- .map((r) => r.projectName.length);
857
- return Math.min(maxWidth, Math.max(8, ...names));
858
- }
859
-
860
- function countStates(sessions: SessionListItem[]): Record<string, number> {
861
- const counts: Record<string, number> = { working: 0, idle: 0, "waiting-input": 0, shell: 0, unknown: 0 };
862
- for (const s of sessions) {
863
- if (s.state in counts) {
864
- counts[s.state]!++;
865
- } else {
866
- counts.unknown!++;
867
- }
868
- }
869
- return counts;
870
- }
871
-
872
- function buildStatusSummaryRows(counts: Record<string, number>, spinnerFrame: number): [string, string] {
873
- const leftColumn = [
874
- formatStatusSummary("working", counts.working ?? 0, spinnerFrame),
875
- formatStatusSummary("idle", counts.idle ?? 0, spinnerFrame),
876
- ];
877
- const rightColumn = [
878
- formatStatusSummary("waiting-input", counts["waiting-input"] ?? 0, spinnerFrame),
879
- formatStatusSummary("shell", counts.shell ?? 0, spinnerFrame),
880
- ];
881
- const overflow = formatStatusSummary("unknown", counts.unknown ?? 0, spinnerFrame);
882
- const columnWidth = Math.max(...[...leftColumn, ...rightColumn].map((v) => visualLength(v))) + 4;
883
-
884
- return [
885
- `${padVisual(leftColumn[0] ?? "", columnWidth)}${rightColumn[0] ?? ""}`,
886
- `${padVisual(leftColumn[1] ?? "", columnWidth)}${rightColumn[1] ?? ""} ${overflow}`,
887
- ];
888
- }
889
-
890
- function formatStatusSummary(state: string, count: number, spinnerFrame: number): string {
891
- const glyph = stateGlyph(state, spinnerFrame);
892
- const label = state === "waiting-input" ? "input" : state;
893
- const color = STATE_COLORS[state] ?? "white";
894
- return `{${color}-fg}${glyph} (${label}): ${count}{/${color}-fg}`;
895
- }
896
-
897
- function padVisual(value: string, width: number): string {
898
- const padding = Math.max(0, width - visualLength(value));
899
- return `${value}${" ".repeat(padding)}`;
900
- }
901
-
902
- function visualLength(value: string): number {
903
- return value.replace(/\{[^}]+\}/g, "").length;
904
- }
905
-
906
- function buildTitleRows(width: number): string[] {
907
- if (width < 72) return [" {bold}SCRYLOG{/bold}"];
908
- return [
909
- " {gray-fg}█▀ █▀▀ █▀█ █▄█ █░ █▀█ █▀▀{/gray-fg}",
910
- " {gray-fg}▄█ █▄▄ █▀▄ ░█░ █▄ █▄█ █▄█{/gray-fg}",
911
- ];
912
- }
913
-
914
- function joinHeaderColumns(left: string, right: string, leftWidth: number, rightWidth: number): string {
915
- return `${padVisual(left, leftWidth)}${padVisual(right, rightWidth)}`;
916
- }
917
-
918
- function setTableInteractive(table: blessed.Widgets.ListTableElement, interactive: boolean): void {
919
- (table as blessed.Widgets.ListTableElement & { interactive?: boolean }).interactive = interactive;
920
- }