@scrylog/cli 0.1.4 → 0.3.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/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -193
package/src/tui/client.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createDaemonFetch, getSocketPath } from "../shared/ipc";
|
|
2
2
|
import { IS_WINDOWS } from "../shared/platform";
|
|
3
3
|
import type { ScrylogConfig } from "../shared/config";
|
|
4
|
-
import type { SessionModel, StoreEvent } from "../shared/types";
|
|
4
|
+
import type { DaemonHealth, HealthResponse, SessionModel, StoreEvent } from "../shared/types";
|
|
5
5
|
import type { SessionListItem, SessionTreeItem, SessionViewCounts } from "../shared/session-view";
|
|
6
6
|
import { flattenSessionTree } from "../shared/session-view";
|
|
7
|
+
import type { UsageState } from "../usage/types";
|
|
7
8
|
|
|
8
9
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
9
10
|
|
|
@@ -19,9 +20,9 @@ export class DaemonClient {
|
|
|
19
20
|
});
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
async getStatusSummary(): Promise<{ counts: SessionViewCounts; collectorStatus: Record<string, unknown>; generatedAt: number } | null> {
|
|
23
|
+
async getStatusSummary(includeUnknown = false): Promise<{ counts: SessionViewCounts; collectorStatus: Record<string, unknown>; generatedAt: number } | null> {
|
|
23
24
|
try {
|
|
24
|
-
const response = await this.fetch("/api/status");
|
|
25
|
+
const response = await this.fetch(includeUnknown ? "/api/status?includeUnknown=true" : "/api/status");
|
|
25
26
|
if (!response.ok) return null;
|
|
26
27
|
return await response.json() as { counts: SessionViewCounts; collectorStatus: Record<string, unknown>; generatedAt: number };
|
|
27
28
|
} catch {
|
|
@@ -29,18 +30,21 @@ export class DaemonClient {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
async
|
|
33
|
+
async getHealth(): Promise<DaemonHealth | null> {
|
|
33
34
|
try {
|
|
34
35
|
const response = await this.fetch("/api/health");
|
|
35
|
-
|
|
36
|
+
if (!response.ok) return null;
|
|
37
|
+
const body = await response.json() as Partial<HealthResponse>;
|
|
38
|
+
if (body.ok !== true) return null;
|
|
39
|
+
return body as DaemonHealth;
|
|
36
40
|
} catch {
|
|
37
|
-
return
|
|
41
|
+
return null;
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
async getSessions(): Promise<{ sessions: SessionListItem[]; counts: SessionViewCounts; generatedAt: number } | null> {
|
|
45
|
+
async getSessions(includeUnknown = false): Promise<{ sessions: SessionListItem[]; counts: SessionViewCounts; generatedAt: number } | null> {
|
|
42
46
|
try {
|
|
43
|
-
const response = await this.fetch("/api/sessions");
|
|
47
|
+
const response = await this.fetch(includeUnknown ? "/api/sessions?includeUnknown=true" : "/api/sessions");
|
|
44
48
|
if (!response.ok) return null;
|
|
45
49
|
const data = await response.json() as { sessions: SessionTreeItem[]; counts: SessionViewCounts; generatedAt: number };
|
|
46
50
|
return {
|
|
@@ -64,6 +68,16 @@ export class DaemonClient {
|
|
|
64
68
|
}
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
async getUsage(): Promise<UsageState | null> {
|
|
72
|
+
try {
|
|
73
|
+
const response = await this.fetch("/api/usage");
|
|
74
|
+
if (!response.ok) return null;
|
|
75
|
+
return await response.json() as UsageState;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
/**
|
|
68
82
|
* Subscribe to daemon SSE events with automatic reconnect.
|
|
69
83
|
* Returns an unsubscribe function.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { bindingsForModal } from "../keybindings";
|
|
3
|
+
import type { TuiConfig } from "../../shared/config";
|
|
4
|
+
import { ProviderIcon } from "./UsageView";
|
|
5
|
+
import { MINI_ICON_SIZE, isImageIconMode, toolProviderKey } from "./tool-icons";
|
|
6
|
+
import { BRAND } from "../theme";
|
|
7
|
+
|
|
8
|
+
const TOOL_LEGEND: { tool: string; label: string }[] = [
|
|
9
|
+
{ tool: "claude", label: "Claude" },
|
|
10
|
+
{ tool: "codex", label: "Codex" },
|
|
11
|
+
{ tool: "opencode", label: "OpenCode" },
|
|
12
|
+
{ tool: "copilotcli", label: "Copilot CLI" },
|
|
13
|
+
{ tool: "shell", label: "Shell" },
|
|
14
|
+
{ tool: "unknown", label: "Unknown" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function BindingsModal({ width, iconMode }: { width: number; iconMode: TuiConfig["providerIcons"] }) {
|
|
18
|
+
const groups = bindingsForModal();
|
|
19
|
+
const showIcons = isImageIconMode(iconMode);
|
|
20
|
+
return (
|
|
21
|
+
<box
|
|
22
|
+
position="absolute"
|
|
23
|
+
top="8%"
|
|
24
|
+
left="10%"
|
|
25
|
+
width="80%"
|
|
26
|
+
height="70%"
|
|
27
|
+
title="Keybindings"
|
|
28
|
+
border
|
|
29
|
+
borderColor={BRAND.primary}
|
|
30
|
+
backgroundColor={BRAND.surface}
|
|
31
|
+
flexDirection="column"
|
|
32
|
+
padding={1}
|
|
33
|
+
zIndex={20}
|
|
34
|
+
>
|
|
35
|
+
<box flexGrow={1} overflow="hidden" flexDirection="column">
|
|
36
|
+
<scrollbox flexGrow={1} height="100%" scrollY focused>
|
|
37
|
+
{groups.map((group) => (
|
|
38
|
+
<box key={group.scope} flexDirection="column">
|
|
39
|
+
<text
|
|
40
|
+
fg={BRAND.grey}
|
|
41
|
+
attributes={TextAttributes.BOLD}
|
|
42
|
+
content={`── ${group.scope === "global" ? "Global" : group.scope === "sessions" ? "Sessions tab" : "Usage tab"} ──`}
|
|
43
|
+
/>
|
|
44
|
+
{group.bindings.map((binding) => (
|
|
45
|
+
<box key={`${group.scope}:${binding.key}`} flexDirection="row" height={1}>
|
|
46
|
+
<text width={Math.min(14, Math.max(10, Math.floor(width * 0.2)))} fg={BRAND.primary} attributes={TextAttributes.BOLD} content={binding.key} />
|
|
47
|
+
<text fg={BRAND.grey} content={binding.description} />
|
|
48
|
+
</box>
|
|
49
|
+
))}
|
|
50
|
+
<text height={1} content="" />
|
|
51
|
+
</box>
|
|
52
|
+
))}
|
|
53
|
+
{showIcons && (
|
|
54
|
+
<box flexDirection="column">
|
|
55
|
+
<text
|
|
56
|
+
fg={BRAND.grey}
|
|
57
|
+
attributes={TextAttributes.BOLD}
|
|
58
|
+
content="── Tool icons ──"
|
|
59
|
+
/>
|
|
60
|
+
{TOOL_LEGEND.map((entry) => {
|
|
61
|
+
const key = toolProviderKey(entry.tool);
|
|
62
|
+
const labelWidth = Math.min(14, Math.max(10, Math.floor(width * 0.2)));
|
|
63
|
+
return (
|
|
64
|
+
<box key={entry.tool} flexDirection="row" height={1}>
|
|
65
|
+
{key ? (
|
|
66
|
+
<ProviderIcon provider={key} mode={iconMode} size={MINI_ICON_SIZE} />
|
|
67
|
+
) : (
|
|
68
|
+
<box width={MINI_ICON_SIZE.width} />
|
|
69
|
+
)}
|
|
70
|
+
<text fg={BRAND.grey} content={` ${entry.label}`} />
|
|
71
|
+
</box>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
<text height={1} content="" />
|
|
75
|
+
</box>
|
|
76
|
+
)}
|
|
77
|
+
</scrollbox>
|
|
78
|
+
</box>
|
|
79
|
+
<text height={1} flexShrink={0} fg={BRAND.grey} content="Press q or Esc to close." />
|
|
80
|
+
</box>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import type { SessionListItem } from "../../shared/session-view";
|
|
3
|
+
import { BRAND, BRAND_MARK } from "../theme";
|
|
4
|
+
import { STATE_COLORS, stateGlyph } from "../model";
|
|
5
|
+
import type { DaemonVersionMismatch } from "../../shared/version";
|
|
6
|
+
|
|
7
|
+
interface DashboardHeaderProps {
|
|
8
|
+
sessions: SessionListItem[];
|
|
9
|
+
connected: boolean;
|
|
10
|
+
spinnerFrame: number;
|
|
11
|
+
width: number;
|
|
12
|
+
versionMismatch?: DaemonVersionMismatch;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function dashboardHeaderHeight(_width?: number): number {
|
|
16
|
+
return 6;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function countStates(sessions: SessionListItem[]): Record<string, number> {
|
|
20
|
+
const counts: Record<string, number> = { working: 0, idle: 0, "waiting-input": 0, error: 0, shell: 0, unknown: 0 };
|
|
21
|
+
for (const session of sessions) {
|
|
22
|
+
if (session.state in counts) counts[session.state]! += 1;
|
|
23
|
+
else counts.unknown! += 1;
|
|
24
|
+
}
|
|
25
|
+
return counts;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TILE_WIDTH = 13;
|
|
29
|
+
/** Right column usable width required to render a full 3-tile row (3*13 + 2 gaps) with room. */
|
|
30
|
+
const TILE_MIN_USABLE_FOR_IDLE = 42;
|
|
31
|
+
/** two tiles + gap: the narrow fallback that needs no separator or idle tile. */
|
|
32
|
+
const TILE_MIN_USABLE_FOR_INPUT = 27;
|
|
33
|
+
/** Brand column width in narrow layouts. */
|
|
34
|
+
const HEADER_NARROW_LEFT_WIDTH = 16;
|
|
35
|
+
/** Narrow terminals keep the brand mark whenever working/input tiles still fit next to it. */
|
|
36
|
+
const HEADER_NARROW_LOGO_MIN = 46;
|
|
37
|
+
|
|
38
|
+
interface StatusTileProps {
|
|
39
|
+
state: string;
|
|
40
|
+
label: string;
|
|
41
|
+
count: number;
|
|
42
|
+
spinnerFrame: number;
|
|
43
|
+
tileWidth?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface StatusBlockProps {
|
|
47
|
+
sessions: SessionListItem[];
|
|
48
|
+
spinnerFrame: number;
|
|
49
|
+
showSeparator: boolean;
|
|
50
|
+
narrow: boolean;
|
|
51
|
+
usable: number;
|
|
52
|
+
/** Idle tile may only render when the brand mark is reserved beside 3 tiles. */
|
|
53
|
+
allowIdle: boolean;
|
|
54
|
+
/** Brand mark is hidden: working/input stretch to fill the full row. */
|
|
55
|
+
stretch: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function StatusBlock({ sessions, spinnerFrame, showSeparator, narrow, usable, allowIdle, stretch }: StatusBlockProps) {
|
|
59
|
+
const counts = countStates(sessions);
|
|
60
|
+
let tiles: StatusTileProps[] = [
|
|
61
|
+
{ state: "working", label: "working", count: counts.working ?? 0, spinnerFrame },
|
|
62
|
+
{ state: "waiting-input", label: "input", count: (counts["waiting-input"] ?? 0) + (counts.error ?? 0), spinnerFrame },
|
|
63
|
+
{ state: "idle", label: "idle", count: counts.idle ?? 0, spinnerFrame },
|
|
64
|
+
];
|
|
65
|
+
if (narrow) {
|
|
66
|
+
// working/input are always visible (tinted when inactive); idle only shows
|
|
67
|
+
// when the full 3-tile row fits next to the brand mark.
|
|
68
|
+
tiles = tiles.filter((tile) => {
|
|
69
|
+
if (tile.state === "working") return true;
|
|
70
|
+
if (tile.state === "waiting-input") return usable >= TILE_MIN_USABLE_FOR_INPUT;
|
|
71
|
+
return allowIdle && tile.count > 0;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (tiles.length === 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const blockWidth = stretch
|
|
78
|
+
? usable
|
|
79
|
+
: tiles.length * TILE_WIDTH + (tiles.length - 1);
|
|
80
|
+
const tileWidth = stretch ? Math.floor((usable - 1) / 2) : TILE_WIDTH;
|
|
81
|
+
return (
|
|
82
|
+
<box flexDirection="column" height={showSeparator ? 5 : 4}>
|
|
83
|
+
<box flexDirection="row" height={4} gap={1}>
|
|
84
|
+
{tiles.map((tile) => (
|
|
85
|
+
<StatusTile key={tile.state} {...tile} tileWidth={tileWidth} />
|
|
86
|
+
))}
|
|
87
|
+
</box>
|
|
88
|
+
{showSeparator && <TileSeparator width={blockWidth} />}
|
|
89
|
+
</box>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function StatusTile({ state, label, count, spinnerFrame, tileWidth = TILE_WIDTH }: StatusTileProps) {
|
|
94
|
+
const active = count > 0;
|
|
95
|
+
const bg = active ? STATE_COLORS[state] : undefined;
|
|
96
|
+
const fg = active ? BRAND.bgDark : STATE_COLORS[state];
|
|
97
|
+
const spinner = active ? stateGlyph(state, spinnerFrame) : stateGlyph(state);
|
|
98
|
+
return (
|
|
99
|
+
<box
|
|
100
|
+
width={tileWidth}
|
|
101
|
+
height={4}
|
|
102
|
+
backgroundColor={bg}
|
|
103
|
+
flexDirection="column"
|
|
104
|
+
justifyContent="center"
|
|
105
|
+
alignItems="center"
|
|
106
|
+
>
|
|
107
|
+
<text attributes={TextAttributes.BOLD} fg={fg} content={`${count}`} maxWidth={tileWidth} />
|
|
108
|
+
<text fg={fg} content={`${spinner} ${label}`} maxWidth={tileWidth} />
|
|
109
|
+
</box>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function TileSeparator({ width }: { width: number }) {
|
|
114
|
+
return (
|
|
115
|
+
<box height={1}>
|
|
116
|
+
<text fg={BRAND.border} content={"─".repeat(width)} />
|
|
117
|
+
</box>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function StatusItem({ state, count, spinnerFrame, width }: { state: string; count: number; spinnerFrame: number; width?: number }) {
|
|
122
|
+
const label = state === "waiting-input" ? "input" : state;
|
|
123
|
+
return (
|
|
124
|
+
<text
|
|
125
|
+
width={width}
|
|
126
|
+
fg={STATE_COLORS[state] ?? BRAND.grey}
|
|
127
|
+
content={`${stateGlyph(state, spinnerFrame)} (${label}): ${count}`}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function StatusFooterRow({ sessions, spinnerFrame, narrow, usable }: { sessions: SessionListItem[]; spinnerFrame: number; narrow: boolean; usable: number }) {
|
|
133
|
+
const counts = countStates(sessions);
|
|
134
|
+
let avail = usable - 1;
|
|
135
|
+
const fits = (size: number) => {
|
|
136
|
+
if (!narrow) return true;
|
|
137
|
+
if (avail < size) return false;
|
|
138
|
+
avail -= size;
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
const showShell = fits(15);
|
|
142
|
+
const showUnknown = fits(14);
|
|
143
|
+
const showTotal = fits(10);
|
|
144
|
+
return (
|
|
145
|
+
<box flexDirection="row" height={1} paddingLeft={1}>
|
|
146
|
+
{showShell && <StatusItem state="shell" count={counts.shell ?? 0} spinnerFrame={spinnerFrame} width={15} />}
|
|
147
|
+
{showUnknown && <StatusItem state="unknown" count={counts.unknown ?? 0} spinnerFrame={spinnerFrame} width={14} />}
|
|
148
|
+
{showTotal && <text width={12} content={` total: ${sessions.length}`} />}
|
|
149
|
+
</box>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function BrandMark() {
|
|
154
|
+
return (
|
|
155
|
+
<box flexDirection="column" height={5}>
|
|
156
|
+
{BRAND_MARK.art.map((line, index) => (
|
|
157
|
+
<text key={index} fg={line.fg} content={line.content} />
|
|
158
|
+
))}
|
|
159
|
+
</box>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const SCRY_BLOCKS = " █▀ █▀▀ █▀█ █▄█\n ▄█ █▄▄ █▀▄ ░█░";
|
|
164
|
+
const LOG_BLOCKS = " █░ █▀█ █▀▀\n █▄ █▄█ █▄█";
|
|
165
|
+
|
|
166
|
+
function Wordmark({ width }: { width: number }) {
|
|
167
|
+
if (width < 72) {
|
|
168
|
+
return (
|
|
169
|
+
<box flexDirection="row">
|
|
170
|
+
<text attributes={TextAttributes.BOLD} fg={BRAND.primary} content=" SCRY" />
|
|
171
|
+
<text attributes={TextAttributes.BOLD} content="LOG" />
|
|
172
|
+
</box>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return (
|
|
176
|
+
<box flexDirection="row">
|
|
177
|
+
<text height={2} fg={BRAND.primary} content={SCRY_BLOCKS} />
|
|
178
|
+
<text height={2} fg={BRAND.grey} content={LOG_BLOCKS} />
|
|
179
|
+
</box>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function DaemonWarning({ connected, mismatch, narrow, width }: { connected: boolean; mismatch?: DaemonVersionMismatch; narrow: boolean; width: number }) {
|
|
184
|
+
if (!connected) {
|
|
185
|
+
return (
|
|
186
|
+
<box height={1}>
|
|
187
|
+
<text fg={BRAND.danger} content=" ● daemon disconnected" />
|
|
188
|
+
</box>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (!mismatch) return null;
|
|
192
|
+
const content = width < 26
|
|
193
|
+
? " restart daemon"
|
|
194
|
+
: narrow
|
|
195
|
+
? " scrylog daemon restart"
|
|
196
|
+
: mismatch.daemonVersion
|
|
197
|
+
? ` daemon ${mismatch.daemonVersion} != CLI ${mismatch.cliVersion} · scrylog daemon restart`
|
|
198
|
+
: " old daemon · scrylog daemon restart";
|
|
199
|
+
return (
|
|
200
|
+
<box height={1}>
|
|
201
|
+
<text fg={BRAND.warning} content={content} />
|
|
202
|
+
</box>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function DashboardHeader(props: DashboardHeaderProps) {
|
|
207
|
+
const narrow = props.width < 110;
|
|
208
|
+
const leftWidth = Math.floor(Math.max(20, props.width - 2) / 2);
|
|
209
|
+
const showLogo = !narrow || props.width >= HEADER_NARROW_LOGO_MIN;
|
|
210
|
+
const usable = narrow
|
|
211
|
+
? Math.max(0, props.width - (showLogo ? HEADER_NARROW_LEFT_WIDTH + 1 : 0) - 2)
|
|
212
|
+
: props.width;
|
|
213
|
+
const allowIdle = showLogo && usable >= TILE_MIN_USABLE_FOR_IDLE;
|
|
214
|
+
const showWarning = !props.connected || Boolean(props.versionMismatch);
|
|
215
|
+
return (
|
|
216
|
+
<box height={dashboardHeaderHeight(props.width)} flexDirection="column">
|
|
217
|
+
<box flexDirection="row" height={6}>
|
|
218
|
+
{showLogo && (
|
|
219
|
+
<box
|
|
220
|
+
width={narrow ? HEADER_NARROW_LEFT_WIDTH : leftWidth}
|
|
221
|
+
flexShrink={0}
|
|
222
|
+
flexDirection="row"
|
|
223
|
+
gap={1}
|
|
224
|
+
paddingLeft={1}
|
|
225
|
+
>
|
|
226
|
+
<BrandMark />
|
|
227
|
+
{!narrow && (
|
|
228
|
+
<box flexDirection="column" justifyContent="center">
|
|
229
|
+
<Wordmark width={props.width} />
|
|
230
|
+
</box>
|
|
231
|
+
)}
|
|
232
|
+
</box>
|
|
233
|
+
)}
|
|
234
|
+
<box
|
|
235
|
+
{...(narrow ? { width: Math.max(0, props.width - (showLogo ? HEADER_NARROW_LEFT_WIDTH : 0)) } : { flexGrow: 1 })}
|
|
236
|
+
flexDirection="column"
|
|
237
|
+
justifyContent="flex-start"
|
|
238
|
+
alignItems="flex-end"
|
|
239
|
+
paddingRight={2}
|
|
240
|
+
>
|
|
241
|
+
<DaemonWarning connected={props.connected} mismatch={props.versionMismatch} narrow={narrow} width={props.width} />
|
|
242
|
+
<StatusBlock
|
|
243
|
+
sessions={props.sessions}
|
|
244
|
+
spinnerFrame={props.spinnerFrame}
|
|
245
|
+
showSeparator={!showWarning}
|
|
246
|
+
narrow={narrow}
|
|
247
|
+
usable={usable}
|
|
248
|
+
allowIdle={allowIdle}
|
|
249
|
+
stretch={narrow && !showLogo}
|
|
250
|
+
/>
|
|
251
|
+
<StatusFooterRow sessions={props.sessions} spinnerFrame={props.spinnerFrame} narrow={narrow} usable={usable} />
|
|
252
|
+
</box>
|
|
253
|
+
</box>
|
|
254
|
+
</box>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { truncate } from "../../shared/utils";
|
|
3
|
+
import type { DashboardView } from "../model";
|
|
4
|
+
import { contextBindings, essentialBindings } from "../keybindings";
|
|
5
|
+
import { BRAND } from "../theme";
|
|
6
|
+
|
|
7
|
+
const KEY_COLOR = BRAND.primary;
|
|
8
|
+
const DESCRIPTION_COLOR = BRAND.grey;
|
|
9
|
+
const SEPARATOR = " · ";
|
|
10
|
+
|
|
11
|
+
function KeyBarItem({ binding, separator }: { binding: { key: string; description: string }; separator?: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<box flexDirection="row" height={1} flexShrink={0} gap={1}>
|
|
14
|
+
<text fg={KEY_COLOR} attributes={TextAttributes.BOLD} content={binding.key} />
|
|
15
|
+
<text fg={DESCRIPTION_COLOR} content={binding.description} />
|
|
16
|
+
{separator && <text fg={DESCRIPTION_COLOR} content={separator} />}
|
|
17
|
+
</box>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function KeyBar({
|
|
22
|
+
view,
|
|
23
|
+
width,
|
|
24
|
+
context,
|
|
25
|
+
}: {
|
|
26
|
+
view: DashboardView;
|
|
27
|
+
width: number;
|
|
28
|
+
context?: string;
|
|
29
|
+
}) {
|
|
30
|
+
const bindings = contextBindings(view).filter((binding) => binding.footer !== false);
|
|
31
|
+
const essentials = essentialBindings(view).filter((binding) => binding.footer !== false);
|
|
32
|
+
const narrow = width < 110;
|
|
33
|
+
const candidates = narrow ? essentials : bindings;
|
|
34
|
+
const contextSuffix = context ? `${SEPARATOR}${context}` : "";
|
|
35
|
+
const maxHidden = Math.max(0, bindings.length - essentials.length);
|
|
36
|
+
const indicatorWidth = narrow && maxHidden > 0 ? SEPARATOR.length + `+${maxHidden} more (?)`.length : 0;
|
|
37
|
+
const budget = Math.max(0, width - 2 - contextSuffix.length - indicatorWidth);
|
|
38
|
+
|
|
39
|
+
const items: { key: string; description: string }[] = [];
|
|
40
|
+
let used = 0;
|
|
41
|
+
for (const binding of candidates) {
|
|
42
|
+
const itemWidth = binding.key.length + 1 + binding.description.length + (items.length > 0 ? SEPARATOR.length : 0);
|
|
43
|
+
if (items.length > 0 && used + itemWidth > budget) break;
|
|
44
|
+
items.push(binding);
|
|
45
|
+
used += itemWidth;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hidden = bindings.length - items.length;
|
|
49
|
+
const indicator = hidden > 0 ? `+${hidden} more (?)` : null;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<box height={1} flexDirection="row" width={Math.max(1, width - 1)} paddingX={1} flexShrink={0}>
|
|
53
|
+
{items.map((binding, index) => (
|
|
54
|
+
<KeyBarItem key={binding.key} binding={binding} separator={index < items.length - 1 ? SEPARATOR : undefined} />
|
|
55
|
+
))}
|
|
56
|
+
{indicator && <text fg={DESCRIPTION_COLOR} content={`${SEPARATOR}${indicator}`} />}
|
|
57
|
+
{context && (
|
|
58
|
+
<text
|
|
59
|
+
flexGrow={1}
|
|
60
|
+
fg={DESCRIPTION_COLOR}
|
|
61
|
+
content={truncate(contextSuffix, Math.max(1, width - 2 - used))}
|
|
62
|
+
truncate
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</box>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import type { SessionModel } from "../../shared/types";
|
|
3
|
+
import type { TuiConfig } from "../../shared/config";
|
|
4
|
+
import { formatDuration, truncate } from "../../shared/utils";
|
|
5
|
+
import { BRAND } from "../theme";
|
|
6
|
+
import { STATE_COLORS } from "../model";
|
|
7
|
+
import { providerAccentColor, providerVisual, ProviderIcon } from "./UsageView";
|
|
8
|
+
import { MINI_ICON_SIZE, isImageIconMode, toolProviderKey } from "./tool-icons";
|
|
9
|
+
|
|
10
|
+
const LABEL_WIDTH = 12;
|
|
11
|
+
|
|
12
|
+
interface DetailRow {
|
|
13
|
+
label: string;
|
|
14
|
+
value: string;
|
|
15
|
+
color?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatRelativeTime(timestamp: number, now = Date.now()): string {
|
|
19
|
+
const seconds = Math.max(1, Math.round((now - timestamp) / 1000));
|
|
20
|
+
if (seconds > 86400) return `${Math.floor(seconds / 86400)}d ago`;
|
|
21
|
+
if (seconds > 3600) return `${Math.floor(seconds / 3600)}h ago`;
|
|
22
|
+
if (seconds > 60) return `${Math.floor(seconds / 60)}m ago`;
|
|
23
|
+
return "just now";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildInfoRows(session: SessionModel): DetailRow[] {
|
|
27
|
+
const rows: DetailRow[] = [];
|
|
28
|
+
rows.push({ label: "id", value: session.id });
|
|
29
|
+
rows.push({ label: "state", value: `${session.state} (${formatDuration(session.stableForMs ?? 0)})`, color: STATE_COLORS[session.state] ?? BRAND.fallback });
|
|
30
|
+
rows.push({ label: "tool", value: session.tool });
|
|
31
|
+
rows.push({ label: "source", value: `${session.source}${session.detectionSource ? ` / ${session.detectionSource}` : ""}` });
|
|
32
|
+
if (session.project?.displayName) rows.push({ label: "project", value: session.project.displayName });
|
|
33
|
+
if (session.project?.isGitRepository && session.project?.branch) {
|
|
34
|
+
rows.push({ label: "git", value: session.project.branch, color: BRAND.accentBlue });
|
|
35
|
+
}
|
|
36
|
+
if (session.currentPath) rows.push({ label: "path", value: session.currentPath });
|
|
37
|
+
if (session.summary) rows.push({ label: "summary", value: session.summary });
|
|
38
|
+
rows.push({ label: "activity", value: formatRelativeTime(session.lastActivity) });
|
|
39
|
+
if (session.processChain?.length) {
|
|
40
|
+
rows.push({ label: "command", value: session.processChain.join(" → "), color: BRAND.grey });
|
|
41
|
+
}
|
|
42
|
+
if (session.tmux) {
|
|
43
|
+
rows.push({ label: "tmux", value: `${session.tmux.session}:${session.tmux.window}.${session.tmux.pane}`, color: BRAND.accentBlue });
|
|
44
|
+
if (session.tmux.windowName) rows.push({ label: "window", value: session.tmux.windowName });
|
|
45
|
+
}
|
|
46
|
+
if (session.herdr) {
|
|
47
|
+
const herdr = session.herdr;
|
|
48
|
+
if (herdr.workspace) rows.push({ label: "workspace", value: herdr.workspace, color: BRAND.accentBlue });
|
|
49
|
+
if (herdr.tab) rows.push({ label: "tab", value: herdr.tab });
|
|
50
|
+
if (herdr.name) rows.push({ label: "pane", value: herdr.name });
|
|
51
|
+
if (herdr.agent) {
|
|
52
|
+
rows.push({ label: "agent", value: herdr.agent + (herdr.agentStatus ? ` (${herdr.agentStatus})` : ""), color: BRAND.accentBlue });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (session.opencode) {
|
|
56
|
+
rows.push({ label: "instance", value: session.opencode.instanceUrl, color: BRAND.grey });
|
|
57
|
+
}
|
|
58
|
+
if (session.claude?.permissionMode) rows.push({ label: "perm mode", value: session.claude.permissionMode });
|
|
59
|
+
if (session.codex?.model) rows.push({ label: "model", value: session.codex.model });
|
|
60
|
+
if (session.codex?.permissionMode) rows.push({ label: "perm mode", value: session.codex.permissionMode });
|
|
61
|
+
if (session.parentSessionId) rows.push({ label: "parent", value: session.parentSessionId });
|
|
62
|
+
if (session.childSessionIds?.length) rows.push({ label: "children", value: String(session.childSessionIds.length) });
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SessionDetailModal(props: {
|
|
67
|
+
detail: SessionModel;
|
|
68
|
+
iconMode: TuiConfig["providerIcons"];
|
|
69
|
+
width: number;
|
|
70
|
+
}) {
|
|
71
|
+
const { detail } = props;
|
|
72
|
+
const toolMode = isImageIconMode(props.iconMode) ? "icons" : props.iconMode === "text" ? "text" : "monogram";
|
|
73
|
+
const toolKey = toolProviderKey(detail.tool);
|
|
74
|
+
const stateColor = STATE_COLORS[detail.state] ?? BRAND.fallback;
|
|
75
|
+
const modalWidth = Math.max(40, Math.floor(props.width * 0.9));
|
|
76
|
+
const innerWidth = Math.max(20, modalWidth - 4);
|
|
77
|
+
const titleWidth = Math.max(10, innerWidth - 12);
|
|
78
|
+
|
|
79
|
+
const infoRows = buildInfoRows(detail);
|
|
80
|
+
const reasonRows = [
|
|
81
|
+
...(detail.toolReasons ?? []).map((reason) => ({ kind: "tool" as const, value: `${reason.label} — ${reason.detail}` })),
|
|
82
|
+
...(detail.stateReasons ?? []).map((reason) => ({ kind: "state" as const, value: `${reason.label} — ${reason.detail}` })),
|
|
83
|
+
];
|
|
84
|
+
const longestValue = Math.max(
|
|
85
|
+
0,
|
|
86
|
+
...infoRows.map((row) => row.value.length),
|
|
87
|
+
...reasonRows.map((reason) => reason.value.length),
|
|
88
|
+
);
|
|
89
|
+
const tableWidth = Math.max(innerWidth, LABEL_WIDTH + 2 + longestValue);
|
|
90
|
+
const tableRule = `${"─".repeat(LABEL_WIDTH)}┼${"─".repeat(tableWidth - LABEL_WIDTH - 1)}`;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<box
|
|
94
|
+
position="absolute"
|
|
95
|
+
top="5%"
|
|
96
|
+
left="5%"
|
|
97
|
+
width="90%"
|
|
98
|
+
height="90%"
|
|
99
|
+
title="Session detail"
|
|
100
|
+
border
|
|
101
|
+
borderColor={BRAND.primary}
|
|
102
|
+
backgroundColor={BRAND.surface}
|
|
103
|
+
flexDirection="column"
|
|
104
|
+
padding={1}
|
|
105
|
+
zIndex={10}
|
|
106
|
+
>
|
|
107
|
+
<box flexGrow={1} overflow="hidden" flexDirection="column">
|
|
108
|
+
<scrollbox flexGrow={1} height="100%" scrollY scrollX focused>
|
|
109
|
+
<box flexDirection="column">
|
|
110
|
+
<box flexDirection="row" height={1}>
|
|
111
|
+
{toolMode === "icons" && toolKey ? (
|
|
112
|
+
<>
|
|
113
|
+
<ProviderIcon provider={toolKey} mode={props.iconMode} size={MINI_ICON_SIZE} />
|
|
114
|
+
<text width={1} content=" " />
|
|
115
|
+
<text
|
|
116
|
+
fg={providerAccentColor(toolKey)}
|
|
117
|
+
attributes={TextAttributes.BOLD}
|
|
118
|
+
content={providerVisual(toolKey).short}
|
|
119
|
+
/>
|
|
120
|
+
</>
|
|
121
|
+
) : (
|
|
122
|
+
<text
|
|
123
|
+
fg={toolKey ? providerAccentColor(toolKey) : BRAND.fallback}
|
|
124
|
+
attributes={TextAttributes.BOLD}
|
|
125
|
+
content={toolKey ? providerVisual(toolKey).short : truncate(detail.tool, 8)}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
<text width={1} content=" " />
|
|
129
|
+
<text
|
|
130
|
+
fg={BRAND.fallback}
|
|
131
|
+
attributes={TextAttributes.BOLD}
|
|
132
|
+
content={truncate(detail.project?.displayName ?? detail.id, titleWidth)}
|
|
133
|
+
truncate
|
|
134
|
+
/>
|
|
135
|
+
<text fg={BRAND.grey} content=" · " />
|
|
136
|
+
<text fg={stateColor} attributes={TextAttributes.BOLD} content={detail.state} />
|
|
137
|
+
</box>
|
|
138
|
+
<box height={1} flexDirection="row"><text fg={BRAND.border} content={tableRule} /></box>
|
|
139
|
+
{infoRows.map((row, index) => (
|
|
140
|
+
<box key={row.label} flexDirection="column">
|
|
141
|
+
<box flexDirection="row" height={1}>
|
|
142
|
+
<text width={LABEL_WIDTH} fg={BRAND.grey} content={row.label} />
|
|
143
|
+
<text width={1} fg={BRAND.border} content="│" />
|
|
144
|
+
<text width={1} content=" " />
|
|
145
|
+
<text width={row.value.length} fg={row.color ?? BRAND.fallback} content={row.value} />
|
|
146
|
+
</box>
|
|
147
|
+
<box height={1} flexDirection="row"><text fg={BRAND.border} content={tableRule} /></box>
|
|
148
|
+
{index === infoRows.length - 1 && reasonRows.length > 0 && <text content="" height={1} />}
|
|
149
|
+
</box>
|
|
150
|
+
))}
|
|
151
|
+
{reasonRows.length > 0 && <text fg={BRAND.grey} attributes={TextAttributes.BOLD} content="Classification" />}
|
|
152
|
+
{reasonRows.map((reason, index) => (
|
|
153
|
+
<box key={`${reason.kind}:${reason.value}:${index}`} flexDirection="column">
|
|
154
|
+
<box flexDirection="row" height={1}>
|
|
155
|
+
<text width={LABEL_WIDTH} fg={BRAND.grey} content={reason.kind} />
|
|
156
|
+
<text width={1} fg={BRAND.border} content="│" />
|
|
157
|
+
<text width={1} content=" " />
|
|
158
|
+
<text width={reason.value.length} fg={BRAND.fallback} content={reason.value} />
|
|
159
|
+
</box>
|
|
160
|
+
<box height={1} flexDirection="row"><text fg={BRAND.border} content={tableRule} /></box>
|
|
161
|
+
</box>
|
|
162
|
+
))}
|
|
163
|
+
</box>
|
|
164
|
+
</scrollbox>
|
|
165
|
+
</box>
|
|
166
|
+
<text height={1} flexShrink={0} fg={BRAND.grey} content="←→ scroll · q/Esc close · t attach" />
|
|
167
|
+
</box>
|
|
168
|
+
);
|
|
169
|
+
}
|