@scrylog/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/package.json +75 -0
- package/src/api/docs/sessions.keys.ts +6 -0
- package/src/api/docs/sessions.ts +81 -0
- package/src/api/docs/system.keys.ts +5 -0
- package/src/api/docs/system.ts +66 -0
- package/src/api/docs/usage.keys.ts +3 -0
- package/src/api/docs/usage.ts +24 -0
- package/src/api/docs/webhooks.keys.ts +5 -0
- package/src/api/docs/webhooks.ts +78 -0
- package/src/api/index.ts +18 -0
- package/src/api/lib/auth.ts +43 -0
- package/src/api/lib/errors.ts +28 -0
- package/src/api/lib/route.ts +18 -0
- package/src/api/models/claude.ts +4 -0
- package/src/api/models/codex.ts +4 -0
- package/src/api/models/opencode.ts +11 -0
- package/src/api/routes/sessions.ts +47 -0
- package/src/api/routes/system.ts +103 -0
- package/src/api/routes/usage.ts +15 -0
- package/src/api/routes/webhooks/claude.ts +161 -0
- package/src/api/routes/webhooks/codex.ts +175 -0
- package/src/api/routes/webhooks/opencode.ts +107 -0
- package/src/api/routes.ts +93 -0
- package/src/api/schemas/common.ts +21 -0
- package/src/api/schemas/sessions.ts +72 -0
- package/src/api/schemas/usage.ts +50 -0
- package/src/api/schemas/webhooks/claude.ts +32 -0
- package/src/api/schemas/webhooks/codex.ts +34 -0
- package/src/api/schemas/webhooks/opencode.ts +29 -0
- package/src/api/services/claude-session.ts +9 -0
- package/src/api/services/codex-session.ts +8 -0
- package/src/api/services/opencode-session.ts +73 -0
- package/src/classifier.ts +206 -0
- package/src/cli/commands.ts +498 -0
- package/src/cli/prompt.ts +44 -0
- package/src/daemon/cli.ts +27 -0
- package/src/daemon/collectors/claude/README.md +108 -0
- package/src/daemon/collectors/claude/collector.ts +413 -0
- package/src/daemon/collectors/claude/discovery.ts +148 -0
- package/src/daemon/collectors/claude/hooks.ts +181 -0
- package/src/daemon/collectors/claude/index.ts +8 -0
- package/src/daemon/collectors/claude/jsonl.ts +85 -0
- package/src/daemon/collectors/claude/transcript.ts +246 -0
- package/src/daemon/collectors/codex/README.md +147 -0
- package/src/daemon/collectors/codex/collector.ts +661 -0
- package/src/daemon/collectors/codex/discovery.ts +104 -0
- package/src/daemon/collectors/codex/hooks.ts +179 -0
- package/src/daemon/collectors/codex/index.ts +7 -0
- package/src/daemon/collectors/codex/jsonl.ts +108 -0
- package/src/daemon/collectors/codex/transcript.ts +299 -0
- package/src/daemon/collectors/git.ts +295 -0
- package/src/daemon/collectors/opencode/README.md +106 -0
- package/src/daemon/collectors/opencode/collector.ts +543 -0
- package/src/daemon/collectors/opencode/discovery.ts +257 -0
- package/src/daemon/collectors/opencode/index.ts +5 -0
- package/src/daemon/collectors/opencode/session-builder.ts +59 -0
- package/src/daemon/collectors/opencode-db.ts +282 -0
- package/src/daemon/collectors/process.ts +201 -0
- package/src/daemon/collectors/tmux/README.md +128 -0
- package/src/daemon/collectors/tmux/collector.ts +472 -0
- package/src/daemon/collectors/tmux/enrichment.ts +241 -0
- package/src/daemon/collectors/tmux/index.ts +4 -0
- package/src/daemon/collectors/tmux/parse.ts +128 -0
- package/src/daemon/collectors/usage.ts +193 -0
- package/src/daemon/index.ts +207 -0
- package/src/daemon/scheduler.ts +86 -0
- package/src/daemon/store.ts +535 -0
- package/src/detect.ts +143 -0
- package/src/hooks/claude.ts +596 -0
- package/src/hooks/codex.ts +517 -0
- package/src/legacy/classifier.ts +215 -0
- package/src/legacy/detect.ts +143 -0
- package/src/mcp/client-server.ts +21 -0
- package/src/mcp/http-handler.ts +126 -0
- package/src/mcp/index.ts +61 -0
- package/src/mcp/resources/config-current.ts +19 -0
- package/src/mcp/resources/index.ts +11 -0
- package/src/mcp/resources/sessions-active.ts +19 -0
- package/src/mcp/resources/sessions-all.ts +19 -0
- package/src/mcp/schemas/sessions.ts +3 -0
- package/src/mcp/server.ts +17 -0
- package/src/mcp/services/api-adapter.ts +48 -0
- package/src/mcp/services/data-source.ts +22 -0
- package/src/mcp/services/store-adapter.ts +40 -0
- package/src/mcp/tools/get-active-sessions.ts +16 -0
- package/src/mcp/tools/get-session-children.ts +24 -0
- package/src/mcp/tools/get-session-messages.ts +24 -0
- package/src/mcp/tools/get-session.ts +23 -0
- package/src/mcp/tools/index.ts +15 -0
- package/src/mcp/tools/list-sessions.ts +16 -0
- package/src/neo-blessed.d.ts +4 -0
- package/src/oc/index.ts +302 -0
- package/src/service/manager.ts +242 -0
- package/src/shared/config.ts +478 -0
- package/src/shared/ipc.ts +80 -0
- package/src/shared/logger.ts +248 -0
- package/src/shared/paths.ts +73 -0
- package/src/shared/platform.ts +110 -0
- package/src/shared/session-view.ts +241 -0
- package/src/shared/types.ts +299 -0
- package/src/shared/utils.ts +339 -0
- package/src/tui/app.ts +920 -0
- package/src/tui/client.ts +149 -0
- package/src/tui/index.ts +181 -0
- package/src/types.ts +113 -0
- package/src/usage/README.md +323 -0
- package/src/usage/api.ts +99 -0
- package/src/usage/client.ts +3 -0
- package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
- package/src/usage/clients/anthropic-web-usage.ts +245 -0
- package/src/usage/clients/claude-cli-probe.ts +187 -0
- package/src/usage/clients/codex-oauth-usage.ts +113 -0
- package/src/usage/clients/opencode-go-usage.ts +441 -0
- package/src/usage/index.ts +19 -0
- package/src/usage/mappers.ts +273 -0
- package/src/usage/planner.ts +54 -0
- package/src/usage/providers/claude.ts +113 -0
- package/src/usage/providers/codex.ts +34 -0
- package/src/usage/providers/opencode-go.ts +37 -0
- package/src/usage/resolver.ts +4 -0
- package/src/usage/resolvers/browser-cookies.ts +198 -0
- package/src/usage/resolvers/claude-auth.ts +99 -0
- package/src/usage/resolvers/claude-cli.ts +24 -0
- package/src/usage/resolvers/claude-web.ts +16 -0
- package/src/usage/resolvers/codex-auth.ts +117 -0
- package/src/usage/resolvers/opencode-go-web.ts +14 -0
- package/src/usage/types.ts +190 -0
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
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
|
+
}
|