@scrylog/cli 0.2.0 → 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 +8 -0
- package/src/api/routes/webhooks/opencode.ts +1 -0
- 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 +85 -3
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +1 -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-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 +44 -2
- 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/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 +407 -69
- package/src/usage/resolvers/claude-auth.ts +140 -40
- package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
- 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 -217
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-invasive terminal capability probes used by `scrylog doctor` to explain
|
|
5
|
+
* how provider icons negotiate graphics. Mirrors the response formats OpenTUI's
|
|
6
|
+
* detector expects, but never enters the alternate screen and always restores
|
|
7
|
+
* the terminal to its original state (`finally`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TerminalGraphicsCapabilities {
|
|
11
|
+
/** Terminal identification from XTVERSION (e.g. "ghostty(1.2.3)"), if answered. */
|
|
12
|
+
terminalName: string | null;
|
|
13
|
+
/** Outer multiplexer detected from the environment ("tmux" | "zellij" | "none"). */
|
|
14
|
+
multiplexer: "tmux" | "zellij" | "none";
|
|
15
|
+
/** Terminal answered a kitty graphics protocol query. */
|
|
16
|
+
kittyGraphics: boolean;
|
|
17
|
+
/** DA1 primary response advertises sixel (attribute 4). */
|
|
18
|
+
sixel: boolean;
|
|
19
|
+
/** Window pixel resolution from `CSI 14t`. */
|
|
20
|
+
pixelResolution: { width: number; height: number } | null;
|
|
21
|
+
/** stdin was not a TTY (SSH without tty, pipes) or raw mode was unavailable. */
|
|
22
|
+
probeFailed: boolean;
|
|
23
|
+
/** Why the probe did not run or answer. */
|
|
24
|
+
probeFailedReason: "not-a-tty" | "raw-unavailable" | "no-answer" | null;
|
|
25
|
+
/** TERM value, useful for manual triage. */
|
|
26
|
+
term: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PROBE_TIMEOUT_MS = 300;
|
|
30
|
+
|
|
31
|
+
const XTVERSION_PROBE = "\x1bP>0q\x1b\\";
|
|
32
|
+
const DA1_PROBE = "\x1b[c";
|
|
33
|
+
const KITTY_PROBE = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\";
|
|
34
|
+
const PIXEL_RES_PROBE = "\x1b[14t";
|
|
35
|
+
|
|
36
|
+
/** Visible attributes in a DA1 primary response (e.g. `ESC[?6;22;4c`). Sixel is attribute 4. */
|
|
37
|
+
const DA1_RESPONSE = /\x1b\[\?(\d+(?:;\d+)*)c/g;
|
|
38
|
+
const XTVERSION_RESPONSE = /\x1bP>[|]([^\x1b]+)\x1b\\/;
|
|
39
|
+
const KITTY_RESPONSE = /\x1b_Gi=31;([^\x1b]*)\x1b\\/;
|
|
40
|
+
const PIXEL_RES_RESPONSE = /\x1b\[4;(\d+);(\d+)t/;
|
|
41
|
+
|
|
42
|
+
export function detectMultiplexer(): "tmux" | "zellij" | "none" {
|
|
43
|
+
if (process.env["TMUX"]) return "tmux";
|
|
44
|
+
if (process.env["ZELLIJ"]) return "zellij";
|
|
45
|
+
return "none";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseResponses(sequence: string): Partial<TerminalGraphicsCapabilities> {
|
|
49
|
+
const result: Partial<TerminalGraphicsCapabilities> = {};
|
|
50
|
+
const version = sequence.match(XTVERSION_RESPONSE)?.[1];
|
|
51
|
+
if (version) result.terminalName = version.trim();
|
|
52
|
+
for (const da1 of sequence.matchAll(DA1_RESPONSE)) {
|
|
53
|
+
if (da1[1]!.split(";").includes("4")) result.sixel = true;
|
|
54
|
+
}
|
|
55
|
+
const kitty = sequence.match(KITTY_RESPONSE);
|
|
56
|
+
if (kitty) result.kittyGraphics = !(kitty[1] ?? "").startsWith("E:");
|
|
57
|
+
const res = sequence.match(PIXEL_RES_RESPONSE);
|
|
58
|
+
if (res) result.pixelResolution = { width: Number(res[2]), height: Number(res[1]) };
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function probeTerminalGraphics(): Promise<TerminalGraphicsCapabilities> {
|
|
63
|
+
const probe: TerminalGraphicsCapabilities = {
|
|
64
|
+
terminalName: null,
|
|
65
|
+
multiplexer: detectMultiplexer(),
|
|
66
|
+
kittyGraphics: false,
|
|
67
|
+
sixel: false,
|
|
68
|
+
pixelResolution: null,
|
|
69
|
+
probeFailed: true,
|
|
70
|
+
probeFailedReason: null,
|
|
71
|
+
term: process.env["TERM"] ?? null,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") {
|
|
75
|
+
return Promise.resolve({ ...probe, probeFailedReason: "not-a-tty" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const rawActive = input.isRaw === true;
|
|
80
|
+
const restore = () => {
|
|
81
|
+
try { input.setRawMode(rawActive); } catch { /* restore best effort */ }
|
|
82
|
+
};
|
|
83
|
+
let buffer = "";
|
|
84
|
+
let settleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
let done = false;
|
|
86
|
+
const stop = () => {
|
|
87
|
+
if (done) return;
|
|
88
|
+
done = true;
|
|
89
|
+
clearTimeout(settleTimeout!);
|
|
90
|
+
input.removeListener("data", onData);
|
|
91
|
+
restore();
|
|
92
|
+
};
|
|
93
|
+
const settle = () => {
|
|
94
|
+
stop();
|
|
95
|
+
const parsed = parseResponses(buffer);
|
|
96
|
+
const failed = Object.keys(parsed).length === 0;
|
|
97
|
+
resolve({
|
|
98
|
+
...probe,
|
|
99
|
+
...parsed,
|
|
100
|
+
probeFailed: failed,
|
|
101
|
+
probeFailedReason: failed ? "no-answer" : null,
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const onData = (chunk: Buffer | string) => {
|
|
105
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("latin1");
|
|
106
|
+
clearTimeout(settleTimeout!);
|
|
107
|
+
settleTimeout = setTimeout(settle, 80);
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
input.setRawMode(true);
|
|
111
|
+
} catch {
|
|
112
|
+
resolve({ ...probe, probeFailedReason: "raw-unavailable" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
input.on("data", onData);
|
|
116
|
+
settleTimeout = setTimeout(settle, PROBE_TIMEOUT_MS);
|
|
117
|
+
output.write(XTVERSION_PROBE + DA1_PROBE + KITTY_PROBE + PIXEL_RES_PROBE);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type ProviderIconAdvice = "graphics" | "kitty" | "sixel" | "blocks";
|
|
122
|
+
|
|
123
|
+
/** Maps probed capabilities to the recommended `tui.providerIcons` value. */
|
|
124
|
+
export function adviseProviderIcons(capabilities: TerminalGraphicsCapabilities): ProviderIconAdvice {
|
|
125
|
+
if (capabilities.multiplexer === "tmux" || capabilities.multiplexer === "zellij") return "blocks";
|
|
126
|
+
if (capabilities.kittyGraphics) return "graphics";
|
|
127
|
+
if (capabilities.sixel && capabilities.pixelResolution) return "graphics";
|
|
128
|
+
if (capabilities.sixel) return "sixel";
|
|
129
|
+
return "blocks";
|
|
130
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -131,7 +131,7 @@ export interface AppOptions extends CollectorOptions {
|
|
|
131
131
|
// ── New unified session model ─────────────────────────────────────────────────
|
|
132
132
|
|
|
133
133
|
/** How the agent session was detected */
|
|
134
|
-
export type SessionSource = "tmux" | "standalone";
|
|
134
|
+
export type SessionSource = "tmux" | "herdr" | "standalone";
|
|
135
135
|
|
|
136
136
|
/** The tool type — extended with "claude" and "error" variants */
|
|
137
137
|
export type SessionTool = "opencode" | "claude" | "codex" | "copilotcli" | "shell" | "unknown";
|
|
@@ -190,6 +190,23 @@ export interface CodexSessionMeta {
|
|
|
190
190
|
forkedFromId?: string;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** Herdr pane and agent metadata */
|
|
194
|
+
export interface HerdrSessionMeta {
|
|
195
|
+
paneId: string;
|
|
196
|
+
terminalId?: string;
|
|
197
|
+
workspaceId?: string;
|
|
198
|
+
workspace?: string;
|
|
199
|
+
tabId?: string;
|
|
200
|
+
tab?: string;
|
|
201
|
+
/** Explicit pane name — undefined when herdr only derived it from the agent name */
|
|
202
|
+
name?: string;
|
|
203
|
+
focused?: boolean;
|
|
204
|
+
providerSource: "socket" | "cli";
|
|
205
|
+
agent?: string;
|
|
206
|
+
agentStatus?: string;
|
|
207
|
+
agentSessionId?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
193
210
|
/** Unified session model used throughout daemon, API, MCP, and TUI */
|
|
194
211
|
export interface SessionModel {
|
|
195
212
|
id: string;
|
|
@@ -201,6 +218,8 @@ export interface SessionModel {
|
|
|
201
218
|
session: string;
|
|
202
219
|
window: string;
|
|
203
220
|
pane: string;
|
|
221
|
+
/** tmux window (tab) name, used as display-name fallback */
|
|
222
|
+
windowName?: string;
|
|
204
223
|
};
|
|
205
224
|
summary: string;
|
|
206
225
|
lastActivity: number;
|
|
@@ -212,10 +231,12 @@ export interface SessionModel {
|
|
|
212
231
|
claude?: ClaudeSessionMeta;
|
|
213
232
|
/** Populated when detected via Codex JSONL transcript or hooks */
|
|
214
233
|
codex?: CodexSessionMeta;
|
|
234
|
+
/** Populated for panes discovered through herdr */
|
|
235
|
+
herdr?: HerdrSessionMeta;
|
|
215
236
|
/** Populated when detected via legacy heuristics */
|
|
216
237
|
legacyPaneId?: string;
|
|
217
238
|
/** Data source description for UI display */
|
|
218
|
-
detectionSource?: "opencode-sdk" | "opencode-hook" | "claude-transcript" | "claude-hook" | "codex-transcript" | "codex-hook" | "fallback-detection" | "opencode-db";
|
|
239
|
+
detectionSource?: "opencode-sdk" | "opencode-hook" | "claude-transcript" | "claude-hook" | "codex-transcript" | "codex-hook" | "fallback-detection" | "opencode-db" | "herdr";
|
|
219
240
|
/** ID of the parent session when this is a sub-agent session spawned by another session */
|
|
220
241
|
parentSessionId?: string;
|
|
221
242
|
/** IDs of direct child sessions spawned by this session (populated on parent sessions) */
|
|
@@ -278,6 +299,16 @@ export interface DaemonState {
|
|
|
278
299
|
refreshedAt: number;
|
|
279
300
|
}
|
|
280
301
|
|
|
302
|
+
export interface HealthResponse {
|
|
303
|
+
ok: true;
|
|
304
|
+
version: string;
|
|
305
|
+
startedAt: number;
|
|
306
|
+
refreshedAt: number;
|
|
307
|
+
collectors: Record<string, CollectorStatus>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type DaemonHealth = Pick<HealthResponse, "ok"> & Partial<Omit<HealthResponse, "ok">>;
|
|
311
|
+
|
|
281
312
|
export interface CollectorStatus {
|
|
282
313
|
name: string;
|
|
283
314
|
running: boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface DaemonVersionMismatch {
|
|
5
|
+
cliVersion: string;
|
|
6
|
+
daemonVersion?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readInstalledVersion(): string {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")) as { version?: unknown };
|
|
12
|
+
return typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "unknown";
|
|
13
|
+
} catch {
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INSTALLED_VERSION = readInstalledVersion();
|
|
19
|
+
|
|
20
|
+
export function getInstalledVersion(): string {
|
|
21
|
+
return INSTALLED_VERSION;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDaemonVersionMismatch(cliVersion: string, daemonVersion?: string): DaemonVersionMismatch | undefined {
|
|
25
|
+
if (cliVersion === "unknown") return undefined;
|
|
26
|
+
if (!daemonVersion || daemonVersion === "unknown") return { cliVersion };
|
|
27
|
+
if (daemonVersion === cliVersion) return undefined;
|
|
28
|
+
return { cliVersion, daemonVersion };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatVersionDiagnostics(cliVersion: string, daemonVersion?: string): string {
|
|
32
|
+
const normalizedDaemonVersion = daemonVersion && daemonVersion !== "unknown" ? daemonVersion : "unknown";
|
|
33
|
+
const mismatch = getDaemonVersionMismatch(cliVersion, daemonVersion);
|
|
34
|
+
const lines = [
|
|
35
|
+
`CLI version: ${cliVersion}`,
|
|
36
|
+
`Daemon version: ${normalizedDaemonVersion}`,
|
|
37
|
+
`Version match: ${mismatch ? "false" : "true"}`,
|
|
38
|
+
];
|
|
39
|
+
if (mismatch) {
|
|
40
|
+
lines.push("Action: scrylog daemon restart");
|
|
41
|
+
lines.push("Fallback: stop the running daemon and run scrylogd");
|
|
42
|
+
}
|
|
43
|
+
return `${lines.join("\n")}\n`;
|
|
44
|
+
}
|
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
2
|
+
import { useEffect, useReducer, useRef, useState } from "react";
|
|
3
|
+
import type { ScrylogConfig } from "../shared/config";
|
|
4
|
+
import type { SessionModel, StoreEvent } from "../shared/types";
|
|
5
|
+
import type { SessionListItem } from "../shared/session-view";
|
|
6
|
+
import { applySessionFilters, buildRows, moveSelection, sessionAttachCommand, visibleRowWindow } from "./model";
|
|
7
|
+
import { dashboardReducer, INITIAL_DASHBOARD_STATE } from "./state";
|
|
8
|
+
import type { DaemonClient } from "./client";
|
|
9
|
+
import { SessionsView } from "./components/SessionsView";
|
|
10
|
+
import { UsageView } from "./components/UsageView";
|
|
11
|
+
import { TabBar } from "./components/TabBar";
|
|
12
|
+
import { KeyBar } from "./components/KeyBar";
|
|
13
|
+
import { BindingsModal } from "./components/BindingsModal";
|
|
14
|
+
import { DashboardHeader, dashboardHeaderHeight } from "./components/DashboardHeader";
|
|
15
|
+
import { isInsideTmux } from "../shared/utils";
|
|
16
|
+
import type { DaemonVersionMismatch } from "../shared/version";
|
|
17
|
+
|
|
18
|
+
export interface AppOptions {
|
|
19
|
+
refreshMs: number;
|
|
20
|
+
includeUnknown?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AppProps {
|
|
24
|
+
client: DaemonClient;
|
|
25
|
+
options: AppOptions;
|
|
26
|
+
config: ScrylogConfig;
|
|
27
|
+
versionMismatch?: DaemonVersionMismatch;
|
|
28
|
+
onExit(): void;
|
|
29
|
+
onAttach(command: string[], sessionId: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function App({ client, options, config, versionMismatch, onExit, onAttach }: AppProps) {
|
|
33
|
+
const { width, height } = useTerminalDimensions();
|
|
34
|
+
const paneHeight = Math.max(1, height - dashboardHeaderHeight(width) - 2);
|
|
35
|
+
const [state, dispatch] = useReducer(dashboardReducer, INITIAL_DASHBOARD_STATE);
|
|
36
|
+
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
37
|
+
const stateRef = useRef(state);
|
|
38
|
+
const rowsRef = useRef<ReturnType<typeof buildRows>>([]);
|
|
39
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
|
+
const knownStatesRef = useRef(new Map<string, SessionModel["state"]>());
|
|
41
|
+
stateRef.current = state;
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let active = true;
|
|
45
|
+
const refreshSessions = async () => {
|
|
46
|
+
const [status, sessionData] = await Promise.all([
|
|
47
|
+
client.getStatusSummary(options.includeUnknown),
|
|
48
|
+
client.getSessions(options.includeUnknown),
|
|
49
|
+
]);
|
|
50
|
+
if (!active) return;
|
|
51
|
+
if (!status || !sessionData) {
|
|
52
|
+
dispatch({ type: "connection", connected: false });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
knownStatesRef.current = new Map(sessionData.sessions.map((session) => [session.id, session.state]));
|
|
56
|
+
dispatch({ type: "sessions-loaded", sessions: sessionData.sessions, counts: status.counts });
|
|
57
|
+
};
|
|
58
|
+
const refreshAll = async () => {
|
|
59
|
+
const usage = await client.getUsage();
|
|
60
|
+
if (active && usage) dispatch({ type: "usage-loaded", usage });
|
|
61
|
+
await refreshSessions();
|
|
62
|
+
};
|
|
63
|
+
const scheduleRefresh = (immediate: boolean) => {
|
|
64
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
65
|
+
if (immediate || config.tui.pushDebounceMs <= 0) {
|
|
66
|
+
debounceRef.current = null;
|
|
67
|
+
void refreshSessions();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
debounceRef.current = setTimeout(() => {
|
|
71
|
+
debounceRef.current = null;
|
|
72
|
+
void refreshSessions();
|
|
73
|
+
}, config.tui.pushDebounceMs);
|
|
74
|
+
};
|
|
75
|
+
const onStoreEvent = (event: StoreEvent | { type: "connected" } | { type: "disconnected" }) => {
|
|
76
|
+
if (event.type === "connected") {
|
|
77
|
+
dispatch({ type: "connection", connected: true });
|
|
78
|
+
void refreshAll();
|
|
79
|
+
} else if (event.type === "disconnected") {
|
|
80
|
+
dispatch({ type: "connection", connected: false });
|
|
81
|
+
} else if (event.type === "usage:updated") {
|
|
82
|
+
dispatch({ type: "usage-loaded", usage: event.usage });
|
|
83
|
+
} else if (event.type === "session:added" || event.type === "session:removed") {
|
|
84
|
+
scheduleRefresh(true);
|
|
85
|
+
} else if (event.type === "session:updated") {
|
|
86
|
+
const known = knownStatesRef.current.get(event.session.id);
|
|
87
|
+
scheduleRefresh(known === undefined || known !== event.session.state);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
void refreshAll();
|
|
92
|
+
const polling = setInterval(() => void refreshAll(), options.refreshMs);
|
|
93
|
+
const unsubscribe = config.tui.usePushUpdates ? client.subscribeEvents(onStoreEvent) : undefined;
|
|
94
|
+
return () => {
|
|
95
|
+
active = false;
|
|
96
|
+
clearInterval(polling);
|
|
97
|
+
unsubscribe?.();
|
|
98
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
99
|
+
debounceRef.current = null;
|
|
100
|
+
};
|
|
101
|
+
}, [client, config.tui.pushDebounceMs, config.tui.usePushUpdates, options.includeUnknown, options.refreshMs]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (state.view !== "sessions") return;
|
|
105
|
+
const animation = setInterval(() => setSpinnerFrame((frame) => (frame + 1) % 10), 180);
|
|
106
|
+
return () => clearInterval(animation);
|
|
107
|
+
}, [state.view]);
|
|
108
|
+
|
|
109
|
+
const filtered = applySessionFilters(state.sessions, state.filters, config.tui.standaloneMaxAgeHours);
|
|
110
|
+
const rows = buildRows(filtered, width < 110);
|
|
111
|
+
rowsRef.current = rows;
|
|
112
|
+
const usagePaneRows = state.view === "sessions" && state.usage ? 3 : 0;
|
|
113
|
+
const visibleRows = visibleRowWindow(rows, state.selectedSessionId, Math.max(4, height - dashboardHeaderHeight(width) - usagePaneRows - (state.mode === "search" ? 8 : 5)));
|
|
114
|
+
|
|
115
|
+
const openDetail = async (session: SessionListItem | undefined) => {
|
|
116
|
+
if (!session) return;
|
|
117
|
+
const detail = await client.getSessionDetail(session.id) ?? ({ ...session } as SessionModel);
|
|
118
|
+
dispatch({ type: "detail", session: detail });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const attach = (session: SessionListItem | undefined) => {
|
|
122
|
+
if (!session) return;
|
|
123
|
+
const command = sessionAttachCommand(session, isInsideTmux());
|
|
124
|
+
if (!command) {
|
|
125
|
+
dispatch({
|
|
126
|
+
type: "message",
|
|
127
|
+
message: session.source === "herdr"
|
|
128
|
+
? "Herdr attach is unavailable because this pane has no terminal ID."
|
|
129
|
+
: "Attach is only available for sessions running inside tmux or herdr.",
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
onAttach(command, session.id);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
useKeyboard((key) => {
|
|
137
|
+
const currentState = stateRef.current;
|
|
138
|
+
const currentSelected = currentState.sessions.find((session) => session.id === currentState.selectedSessionId);
|
|
139
|
+
if ((key.ctrl && key.name === "c") || (key.name === "q" && currentState.mode === "list")) return onExit();
|
|
140
|
+
if (currentState.mode === "search") {
|
|
141
|
+
if (key.name === "escape" || key.name === "return" || key.name === "enter") dispatch({ type: "close-overlay" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (currentState.mode === "detail" || currentState.mode === "message" || currentState.mode === "bindings") {
|
|
145
|
+
if (key.name === "escape" || key.name === "q") dispatch({ type: "close-overlay" });
|
|
146
|
+
if (key.name === "t" && currentState.detail) attach(currentState.detail as SessionListItem);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (key.name === "tab") {
|
|
150
|
+
dispatch({ type: "view", view: key.shift ? "sessions" : currentState.view === "sessions" ? "usage" : "sessions" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (key.name === "u") return dispatch({ type: "view", view: "usage" });
|
|
154
|
+
if (key.name === "s") return dispatch({ type: "view", view: "sessions" });
|
|
155
|
+
if (key.name === "r") {
|
|
156
|
+
void Promise.all([client.getStatusSummary(options.includeUnknown), client.getSessions(options.includeUnknown), client.getUsage()])
|
|
157
|
+
.then(([status, sessions, usage]) => {
|
|
158
|
+
if (status && sessions) dispatch({ type: "sessions-loaded", sessions: sessions.sessions, counts: status.counts });
|
|
159
|
+
if (usage) dispatch({ type: "usage-loaded", usage });
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (key.name === "?") return dispatch({ type: "bindings" });
|
|
164
|
+
if (currentState.view !== "sessions") return;
|
|
165
|
+
if (key.name === "up" || key.name === "k") return dispatch({ type: "select", id: moveSelection(rowsRef.current, currentState.selectedSessionId, -1) });
|
|
166
|
+
if (key.name === "down" || key.name === "j") return dispatch({ type: "select", id: moveSelection(rowsRef.current, currentState.selectedSessionId, 1) });
|
|
167
|
+
if (key.name === "escape") return dispatch({ type: "select", id: undefined });
|
|
168
|
+
if (key.name === "return" || key.name === "enter") return void openDetail(currentSelected);
|
|
169
|
+
if (key.name === "t") return attach(currentSelected);
|
|
170
|
+
if (key.name === "/") return dispatch({ type: "search" });
|
|
171
|
+
if (key.name === "a") return dispatch({ type: "toggle-actionable" });
|
|
172
|
+
if (key.name === "f") return dispatch({ type: "cycle-filters" });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const filterSummary = [
|
|
176
|
+
state.filters.query ? `q=${state.filters.query}` : null,
|
|
177
|
+
state.filters.actionableOnly ? "actionable" : null,
|
|
178
|
+
state.filters.tool !== "all" ? `tool=${state.filters.tool}` : null,
|
|
179
|
+
state.filters.state !== "all" ? `state=${state.filters.state}` : null,
|
|
180
|
+
].filter(Boolean).join(" ");
|
|
181
|
+
const selectedSession = state.sessions.find((session) => session.id === state.selectedSessionId);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<box width="100%" height="100%" flexDirection="column">
|
|
185
|
+
<DashboardHeader
|
|
186
|
+
sessions={state.sessions}
|
|
187
|
+
connected={state.daemonConnected}
|
|
188
|
+
spinnerFrame={spinnerFrame}
|
|
189
|
+
width={width}
|
|
190
|
+
versionMismatch={versionMismatch}
|
|
191
|
+
/>
|
|
192
|
+
<TabBar
|
|
193
|
+
view={state.view}
|
|
194
|
+
onSelect={(next) => dispatch({ type: "view", view: next })}
|
|
195
|
+
/>
|
|
196
|
+
<box
|
|
197
|
+
position={state.view === "sessions" ? "relative" : "absolute"}
|
|
198
|
+
top={state.view === "sessions" ? 0 : height + 2}
|
|
199
|
+
width="100%"
|
|
200
|
+
height={paneHeight}
|
|
201
|
+
>
|
|
202
|
+
<SessionsView
|
|
203
|
+
rows={visibleRows}
|
|
204
|
+
sessions={state.sessions}
|
|
205
|
+
selectedSessionId={state.selectedSessionId}
|
|
206
|
+
filters={state.filters}
|
|
207
|
+
mode={state.mode}
|
|
208
|
+
detail={state.detail}
|
|
209
|
+
message={state.message}
|
|
210
|
+
usage={state.usage}
|
|
211
|
+
iconMode={config.tui.providerIcons}
|
|
212
|
+
spinnerFrame={spinnerFrame}
|
|
213
|
+
width={width}
|
|
214
|
+
interactive={state.mode === "list" && state.view === "sessions"}
|
|
215
|
+
onQueryChange={(query) => dispatch({ type: "query", query })}
|
|
216
|
+
onSearchSubmit={() => dispatch({ type: "close-overlay" })}
|
|
217
|
+
onSelectSession={(id) => dispatch({ type: "select", id })}
|
|
218
|
+
onOpenSessionDetail={(session) => void openDetail(session)}
|
|
219
|
+
onScrollSelection={(direction) => dispatch({ type: "select", id: moveSelection(rowsRef.current, stateRef.current.selectedSessionId, direction) })}
|
|
220
|
+
/>
|
|
221
|
+
</box>
|
|
222
|
+
<box
|
|
223
|
+
position={state.view === "usage" ? "relative" : "absolute"}
|
|
224
|
+
top={state.view === "usage" ? 0 : height + 2}
|
|
225
|
+
width="100%"
|
|
226
|
+
height={paneHeight}
|
|
227
|
+
>
|
|
228
|
+
<UsageView usage={state.usage} width={width} iconMode={config.tui.providerIcons} hidden={state.view !== "usage"} />
|
|
229
|
+
</box>
|
|
230
|
+
<KeyBar
|
|
231
|
+
view={state.view}
|
|
232
|
+
width={width}
|
|
233
|
+
context={state.view === "sessions"
|
|
234
|
+
? `${selectedSession ? `${selectedSession.tool} ${selectedSession.state} ${selectedSession.project?.compactLabel ?? selectedSession.id}` : "no selection"}${filterSummary ? ` ${filterSummary}` : ""}`
|
|
235
|
+
: undefined}
|
|
236
|
+
/>
|
|
237
|
+
{state.mode === "bindings" && <BindingsModal width={width} iconMode={config.tui.providerIcons} />}
|
|
238
|
+
</box>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Provider artwork sources
|
|
2
|
+
|
|
3
|
+
Retrieved from official provider properties. The images are bundled so the dashboard never fetches artwork at runtime.
|
|
4
|
+
|
|
5
|
+
| File | Official source |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `claude.png` | `https://assets-proxy.anthropic.com/claude-ai/v2/assets/v1/c129d018a-0ZbJsTbu.png` |
|
|
8
|
+
| `opencode.png` | `https://opencode.ai/apple-touch-icon-v3.png` |
|
|
9
|
+
| `openai.png` | OpenAI official app icon (`https://cdn.oaistatic.com/assets/apple-touch-icon-mz9nytnj.webp`, white tile with black Blossom from the official brand guidelines); the standalone mono Blossom SVG (`File:OpenAI_logo_2025_(symbol).svg`, Wikimedia Commons, PD-textlogo) served as design reference. Formerly `codex.png` (OpenAI Codex app art). |
|
|
10
|
+
|
|
11
|
+
The `*-terminal.svg` files are editable 8x8, square integer-grid adaptations of those marks. Their generated `*-terminal.png` counterparts map directly to a compact 8x4-cell block target with square half-block pixels (2 pixel rows per cell), avoiding destructive downsampling and aspect distortion. Hand-drawn tool glyphs (no official small-mark equivalent; used for the 2x1-cell mini column icon in image-capable modes, replaced by a brand monogram in blocks mode):
|
|
12
|
+
|
|
13
|
+
| File | Source |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `copilot.svg` / `copilot.png` | Original 8x8 grid drawing modelled on GitHub Copilot's goggles mark |
|
|
16
|
+
| `shell.svg` / `shell.png` | Original 8x8 grid drawing of a `>_` prompt glyph |
|
|
17
|
+
| `unknown.svg` / `unknown.png` | Original 8x8 grid drawing of a `?` marker |
|
|
18
|
+
|
|
19
|
+
They deliberately avoid gradients and antialiasing so Claude's inverted cream spark on an orange tile, OpenCode's light "O" plate, and OpenAI's black Blossom on a white tile remain identifiable after character-cell rendering. Square official artwork uses a separate 6x3-cell fitted target.
|
|
20
|
+
|
|
21
|
+
Claude, OpenCode, Codex, OpenAI, Anthropic, and their marks belong to their respective owners.
|
|
Binary file
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8" shape-rendering="crispEdges">
|
|
2
|
+
<rect x="1" y="0" width="1" height="1" fill="#d97757"/>
|
|
3
|
+
<rect x="2" y="0" width="1" height="1" fill="#d97757"/>
|
|
4
|
+
<rect x="5" y="0" width="1" height="1" fill="#d97757"/>
|
|
5
|
+
<rect x="6" y="0" width="1" height="1" fill="#d97757"/>
|
|
6
|
+
<rect x="0" y="1" width="1" height="1" fill="#d97757"/>
|
|
7
|
+
<rect x="2" y="1" width="1" height="1" fill="#d97757"/>
|
|
8
|
+
<rect x="5" y="1" width="1" height="1" fill="#d97757"/>
|
|
9
|
+
<rect x="7" y="1" width="1" height="1" fill="#d97757"/>
|
|
10
|
+
<rect x="0" y="2" width="1" height="1" fill="#d97757"/>
|
|
11
|
+
<rect x="1" y="2" width="1" height="1" fill="#d97757"/>
|
|
12
|
+
<rect x="6" y="2" width="1" height="1" fill="#d97757"/>
|
|
13
|
+
<rect x="7" y="2" width="1" height="1" fill="#d97757"/>
|
|
14
|
+
<rect x="0" y="3" width="1" height="1" fill="#d97757"/>
|
|
15
|
+
<rect x="7" y="3" width="1" height="1" fill="#d97757"/>
|
|
16
|
+
<rect x="0" y="4" width="1" height="1" fill="#d97757"/>
|
|
17
|
+
<rect x="7" y="4" width="1" height="1" fill="#d97757"/>
|
|
18
|
+
<rect x="0" y="5" width="1" height="1" fill="#d97757"/>
|
|
19
|
+
<rect x="1" y="5" width="1" height="1" fill="#d97757"/>
|
|
20
|
+
<rect x="6" y="5" width="1" height="1" fill="#d97757"/>
|
|
21
|
+
<rect x="7" y="5" width="1" height="1" fill="#d97757"/>
|
|
22
|
+
<rect x="0" y="6" width="1" height="1" fill="#d97757"/>
|
|
23
|
+
<rect x="2" y="6" width="1" height="1" fill="#d97757"/>
|
|
24
|
+
<rect x="5" y="6" width="1" height="1" fill="#d97757"/>
|
|
25
|
+
<rect x="7" y="6" width="1" height="1" fill="#d97757"/>
|
|
26
|
+
<rect x="1" y="7" width="1" height="1" fill="#d97757"/>
|
|
27
|
+
<rect x="2" y="7" width="1" height="1" fill="#d97757"/>
|
|
28
|
+
<rect x="5" y="7" width="1" height="1" fill="#d97757"/>
|
|
29
|
+
<rect x="6" y="7" width="1" height="1" fill="#d97757"/>
|
|
30
|
+
<rect x="3" y="0" width="1" height="1" fill="#f5f1e8"/>
|
|
31
|
+
<rect x="4" y="0" width="1" height="1" fill="#f5f1e8"/>
|
|
32
|
+
<rect x="1" y="1" width="1" height="1" fill="#f5f1e8"/>
|
|
33
|
+
<rect x="3" y="1" width="1" height="1" fill="#f5f1e8"/>
|
|
34
|
+
<rect x="4" y="1" width="1" height="1" fill="#f5f1e8"/>
|
|
35
|
+
<rect x="6" y="1" width="1" height="1" fill="#f5f1e8"/>
|
|
36
|
+
<rect x="2" y="2" width="1" height="1" fill="#f5f1e8"/>
|
|
37
|
+
<rect x="3" y="2" width="1" height="1" fill="#f5f1e8"/>
|
|
38
|
+
<rect x="4" y="2" width="1" height="1" fill="#f5f1e8"/>
|
|
39
|
+
<rect x="5" y="2" width="1" height="1" fill="#f5f1e8"/>
|
|
40
|
+
<rect x="1" y="3" width="1" height="1" fill="#f5f1e8"/>
|
|
41
|
+
<rect x="2" y="3" width="1" height="1" fill="#f5f1e8"/>
|
|
42
|
+
<rect x="3" y="3" width="1" height="1" fill="#f5f1e8"/>
|
|
43
|
+
<rect x="4" y="3" width="1" height="1" fill="#f5f1e8"/>
|
|
44
|
+
<rect x="5" y="3" width="1" height="1" fill="#f5f1e8"/>
|
|
45
|
+
<rect x="6" y="3" width="1" height="1" fill="#f5f1e8"/>
|
|
46
|
+
<rect x="1" y="4" width="1" height="1" fill="#f5f1e8"/>
|
|
47
|
+
<rect x="2" y="4" width="1" height="1" fill="#f5f1e8"/>
|
|
48
|
+
<rect x="3" y="4" width="1" height="1" fill="#f5f1e8"/>
|
|
49
|
+
<rect x="4" y="4" width="1" height="1" fill="#f5f1e8"/>
|
|
50
|
+
<rect x="5" y="4" width="1" height="1" fill="#f5f1e8"/>
|
|
51
|
+
<rect x="6" y="4" width="1" height="1" fill="#f5f1e8"/>
|
|
52
|
+
<rect x="2" y="5" width="1" height="1" fill="#f5f1e8"/>
|
|
53
|
+
<rect x="3" y="5" width="1" height="1" fill="#f5f1e8"/>
|
|
54
|
+
<rect x="4" y="5" width="1" height="1" fill="#f5f1e8"/>
|
|
55
|
+
<rect x="5" y="5" width="1" height="1" fill="#f5f1e8"/>
|
|
56
|
+
<rect x="1" y="6" width="1" height="1" fill="#f5f1e8"/>
|
|
57
|
+
<rect x="3" y="6" width="1" height="1" fill="#f5f1e8"/>
|
|
58
|
+
<rect x="4" y="6" width="1" height="1" fill="#f5f1e8"/>
|
|
59
|
+
<rect x="6" y="6" width="1" height="1" fill="#f5f1e8"/>
|
|
60
|
+
<rect x="3" y="7" width="1" height="1" fill="#f5f1e8"/>
|
|
61
|
+
<rect x="4" y="7" width="1" height="1" fill="#f5f1e8"/>
|
|
62
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8" shape-rendering="crispEdges">
|
|
2
|
+
<rect x="2" y="0" width="1" height="1" fill="#c8cdd8"/>
|
|
3
|
+
<rect x="3" y="0" width="1" height="1" fill="#c8cdd8"/>
|
|
4
|
+
<rect x="4" y="0" width="1" height="1" fill="#c8cdd8"/>
|
|
5
|
+
<rect x="5" y="0" width="1" height="1" fill="#c8cdd8"/>
|
|
6
|
+
<rect x="1" y="1" width="1" height="1" fill="#c8cdd8"/>
|
|
7
|
+
<rect x="2" y="1" width="1" height="1" fill="#c8cdd8"/>
|
|
8
|
+
<rect x="3" y="1" width="1" height="1" fill="#c8cdd8"/>
|
|
9
|
+
<rect x="4" y="1" width="1" height="1" fill="#c8cdd8"/>
|
|
10
|
+
<rect x="5" y="1" width="1" height="1" fill="#c8cdd8"/>
|
|
11
|
+
<rect x="6" y="1" width="1" height="1" fill="#c8cdd8"/>
|
|
12
|
+
<rect x="0" y="2" width="1" height="1" fill="#c8cdd8"/>
|
|
13
|
+
<rect x="1" y="2" width="1" height="1" fill="#c8cdd8"/>
|
|
14
|
+
<rect x="3" y="2" width="1" height="1" fill="#c8cdd8"/>
|
|
15
|
+
<rect x="4" y="2" width="1" height="1" fill="#c8cdd8"/>
|
|
16
|
+
<rect x="6" y="2" width="1" height="1" fill="#c8cdd8"/>
|
|
17
|
+
<rect x="7" y="2" width="1" height="1" fill="#c8cdd8"/>
|
|
18
|
+
<rect x="0" y="3" width="1" height="1" fill="#c8cdd8"/>
|
|
19
|
+
<rect x="1" y="3" width="1" height="1" fill="#c8cdd8"/>
|
|
20
|
+
<rect x="3" y="3" width="1" height="1" fill="#c8cdd8"/>
|
|
21
|
+
<rect x="4" y="3" width="1" height="1" fill="#c8cdd8"/>
|
|
22
|
+
<rect x="6" y="3" width="1" height="1" fill="#c8cdd8"/>
|
|
23
|
+
<rect x="7" y="3" width="1" height="1" fill="#c8cdd8"/>
|
|
24
|
+
<rect x="0" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
25
|
+
<rect x="1" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
26
|
+
<rect x="2" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
27
|
+
<rect x="3" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
28
|
+
<rect x="4" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
29
|
+
<rect x="5" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
30
|
+
<rect x="6" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
31
|
+
<rect x="7" y="4" width="1" height="1" fill="#c8cdd8"/>
|
|
32
|
+
<rect x="1" y="5" width="1" height="1" fill="#c8cdd8"/>
|
|
33
|
+
<rect x="2" y="5" width="1" height="1" fill="#c8cdd8"/>
|
|
34
|
+
<rect x="3" y="5" width="1" height="1" fill="#c8cdd8"/>
|
|
35
|
+
<rect x="4" y="5" width="1" height="1" fill="#c8cdd8"/>
|
|
36
|
+
<rect x="5" y="5" width="1" height="1" fill="#c8cdd8"/>
|
|
37
|
+
<rect x="6" y="5" width="1" height="1" fill="#c8cdd8"/>
|
|
38
|
+
<rect x="2" y="6" width="1" height="1" fill="#c8cdd8"/>
|
|
39
|
+
<rect x="3" y="6" width="1" height="1" fill="#c8cdd8"/>
|
|
40
|
+
<rect x="4" y="6" width="1" height="1" fill="#c8cdd8"/>
|
|
41
|
+
<rect x="5" y="6" width="1" height="1" fill="#c8cdd8"/>
|
|
42
|
+
<rect x="3" y="7" width="1" height="1" fill="#c8cdd8"/>
|
|
43
|
+
<rect x="4" y="7" width="1" height="1" fill="#c8cdd8"/>
|
|
44
|
+
</svg>
|
|
Binary file
|