shine-code-submit 0.2.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.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/LICENSE +21 -0
  4. package/README.md +217 -0
  5. package/bin/launcher.cjs +35 -0
  6. package/bun.lock +45 -0
  7. package/dist/install.cjs +8 -0
  8. package/hooks/hooks.json +81 -0
  9. package/package.json +68 -0
  10. package/src/cli/main.ts +128 -0
  11. package/src/daemon/auth.ts +7 -0
  12. package/src/daemon/bus.ts +25 -0
  13. package/src/daemon/git.ts +118 -0
  14. package/src/daemon/logger.ts +66 -0
  15. package/src/daemon/main.ts +95 -0
  16. package/src/daemon/server.ts +208 -0
  17. package/src/daemon/spool-consumer.ts +52 -0
  18. package/src/daemon/stats.ts +33 -0
  19. package/src/daemon/store.ts +147 -0
  20. package/src/daemon/token-cache.ts +35 -0
  21. package/src/daemon/transcript.ts +130 -0
  22. package/src/daemon/ui-assets.ts +4 -0
  23. package/src/daemon/ui.ts +34 -0
  24. package/src/daemon/ws.ts +54 -0
  25. package/src/hook/main.ts +154 -0
  26. package/src/install/bun.ts +97 -0
  27. package/src/install/deploy.ts +78 -0
  28. package/src/install/json-safe.ts +43 -0
  29. package/src/install/main.ts +167 -0
  30. package/src/install/paths.ts +28 -0
  31. package/src/install/register.ts +108 -0
  32. package/src/shared/config.ts +77 -0
  33. package/src/shared/daemonctl.ts +110 -0
  34. package/src/shared/id.ts +19 -0
  35. package/src/shared/paths.ts +23 -0
  36. package/src/shared/pidfile.ts +32 -0
  37. package/src/shared/spool.ts +60 -0
  38. package/src/shared/types.ts +116 -0
  39. package/ui/.build/app.js +76 -0
  40. package/ui/app.tsx +31 -0
  41. package/ui/components/App.tsx +55 -0
  42. package/ui/components/CommitsModule.tsx +25 -0
  43. package/ui/components/CommitsView.tsx +84 -0
  44. package/ui/components/Conversation.tsx +101 -0
  45. package/ui/components/DiffBlock.tsx +64 -0
  46. package/ui/components/EventDetail.tsx +8 -0
  47. package/ui/components/EventItem.tsx +24 -0
  48. package/ui/components/EventList.tsx +22 -0
  49. package/ui/components/EventsModule.tsx +123 -0
  50. package/ui/components/Header.tsx +15 -0
  51. package/ui/components/Icon.tsx +114 -0
  52. package/ui/components/Markdown.tsx +13 -0
  53. package/ui/components/Message.tsx +60 -0
  54. package/ui/components/OverviewModule.tsx +86 -0
  55. package/ui/components/SessionDetail.tsx +51 -0
  56. package/ui/components/SessionTree.tsx +94 -0
  57. package/ui/components/SessionsModule.tsx +39 -0
  58. package/ui/components/SessionsPanel.tsx +15 -0
  59. package/ui/components/SideNav.tsx +46 -0
  60. package/ui/components/Splitter.tsx +23 -0
  61. package/ui/components/StatsModule.tsx +133 -0
  62. package/ui/components/Status.tsx +66 -0
  63. package/ui/components/SummaryView.tsx +83 -0
  64. package/ui/components/SystemModule.tsx +38 -0
  65. package/ui/components/ToolCard.tsx +57 -0
  66. package/ui/hooks/useAllCommits.ts +59 -0
  67. package/ui/hooks/useApi.ts +13 -0
  68. package/ui/hooks/useCommits.ts +45 -0
  69. package/ui/hooks/useConversation.ts +43 -0
  70. package/ui/hooks/useEvents.ts +45 -0
  71. package/ui/hooks/useSplitter.ts +43 -0
  72. package/ui/hooks/useStatsPolling.ts +35 -0
  73. package/ui/hooks/useWebSocket.ts +38 -0
  74. package/ui/index.html +13 -0
  75. package/ui/lib/aggregate.ts +81 -0
  76. package/ui/lib/diff.ts +45 -0
  77. package/ui/lib/export.ts +45 -0
  78. package/ui/lib/format.ts +100 -0
  79. package/ui/lib/util.ts +106 -0
  80. package/ui/state/AppContext.tsx +65 -0
  81. package/ui/style.css +849 -0
  82. package/ui/types.ts +46 -0
@@ -0,0 +1,83 @@
1
+ import { useMemo } from "react";
2
+ import { useApi } from "../hooks/useApi";
3
+ import { useAllCommits } from "../hooks/useAllCommits";
4
+ import { useApp } from "../state/AppContext";
5
+ import { fmtDateTime, fmtUsage, fmtUsageFull, shortDir, sumTokenUsage } from "../lib/util";
6
+
7
+ /** 汇总视图(system 模块临时占位,Step 5 替换为 SystemModule):
8
+ * Token 按会话 + 代码提交按时间。提交拉取改用 useAllCommits(与 Overview/Stats 共用)。 */
9
+ export function SummaryView() {
10
+ const { sessions, token } = useApp();
11
+ const api = useApi(token);
12
+
13
+ const tokenRows = useMemo(
14
+ () =>
15
+ sessions
16
+ .filter((s) => s.tokenTotal && (s.tokenTotal.input > 0 || s.tokenTotal.output > 0))
17
+ .slice()
18
+ .sort((a, b) => b.lastActive - a.lastActive),
19
+ [sessions],
20
+ );
21
+ const tokenSum = useMemo(() => sumTokenUsage(sessions.map((s) => s.tokenTotal)), [sessions]);
22
+ const { commits, loading } = useAllCommits(api, sessions, true);
23
+
24
+ const commitCount = commits.length;
25
+ const added = commits.reduce((n, c) => n + (c.added || 0), 0);
26
+ const deleted = commits.reduce((n, c) => n + (c.deleted || 0), 0);
27
+ const hasTokens = tokenSum.total.input > 0 || tokenSum.total.output > 0;
28
+
29
+ return (
30
+ <div id="summary-view" className="summary-view">
31
+ <section className="sum-section">
32
+ <div className="sum-head">
33
+ <h3>Token 消耗(按会话)</h3>
34
+ <span className="sum-total" title={fmtUsageFull(tokenSum.total)}>
35
+ 总计 {hasTokens ? fmtUsage(tokenSum.total) : "—"}
36
+ </span>
37
+ </div>
38
+ {tokenRows.length === 0 ? (
39
+ <div className="sum-empty">暂无 token 数据(启动 Claude Code 会话后产生)</div>
40
+ ) : (
41
+ <ul className="sum-list sum-token-list">
42
+ {tokenRows.map((s) => (
43
+ <li key={s.sessionId} title={fmtUsageFull(s.tokenTotal)}>
44
+ <span className="sum-ts">{fmtDateTime(s.lastActive)}</span>
45
+ <span className="sum-cwd" title={s.cwd}>
46
+ {shortDir(s.cwd) || "?"}
47
+ </span>
48
+ <span className="sum-tok">{fmtUsage(s.tokenTotal)}</span>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ )}
53
+ </section>
54
+
55
+ <section className="sum-section">
56
+ <div className="sum-head">
57
+ <h3>代码提交(按时间)</h3>
58
+ <span className="sum-total">
59
+ {commitCount} 次 · <span className="sum-add">+{added}</span> /{" "}
60
+ <span className="sum-del">-{deleted}</span>
61
+ </span>
62
+ </div>
63
+ {loading ? (
64
+ <div className="sum-empty">拉取各项目提交…</div>
65
+ ) : commits.length === 0 ? (
66
+ <div className="sum-empty">暂无提交</div>
67
+ ) : (
68
+ <ul className="sum-list sum-commit-list">
69
+ {commits.map((c) => (
70
+ <li key={`${c.cwd}:${c.hash}`} title={c.cwd}>
71
+ <span className="sum-ts">{fmtDateTime(c.time)}</span>
72
+ <span className="sum-add">+{c.added}</span>
73
+ <span className="sum-del">-{c.deleted}</span>
74
+ <span className="sum-cwd">{shortDir(c.cwd)}</span>
75
+ <span className="sum-subject">{c.subject || "(无说明)"}</span>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ )}
80
+ </section>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,38 @@
1
+ import { useApp } from "../state/AppContext";
2
+
3
+ /** 系统模块:daemon 运行信息(来自 /api/stats)。 */
4
+ export function SystemModule() {
5
+ const { stats } = useApp();
6
+ if (!stats) {
7
+ return (
8
+ <div className="empty-state">
9
+ <span className="es-hint">连接中…</span>
10
+ </div>
11
+ );
12
+ }
13
+ const up = Math.floor(stats.uptime / 1000);
14
+ return (
15
+ <div className="system-view">
16
+ <section className="sum-section">
17
+ <div className="sum-head">
18
+ <h3>Daemon</h3>
19
+ </div>
20
+ <div className="sys-grid">
21
+ <div className="sys-row"><span>服务</span><b>{stats.service}</b></div>
22
+ <div className="sys-row"><span>版本</span><b>v{stats.version}</b></div>
23
+ <div className="sys-row"><span>pid</span><b>{stats.pid}</b></div>
24
+ <div className="sys-row"><span>uptime</span><b>{up}s</b></div>
25
+ <div className="sys-row"><span>事件总数</span><b>{stats.totalEvents}</b></div>
26
+ <div className="sys-row"><span>事件速率</span><b>{stats.eventsPerSec.toFixed(1)} evt/s</b></div>
27
+ <div className="sys-row"><span>spool 积压</span><b>{stats.spoolBacklog}</b></div>
28
+ {stats.lastError && (
29
+ <div className="sys-row err">
30
+ <span>最近错误</span>
31
+ <b>{stats.lastError.message}</b>
32
+ </div>
33
+ )}
34
+ </div>
35
+ </section>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,57 @@
1
+ import { useMemo } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { computeLineDiff } from "../lib/diff";
4
+ import { renderContentBlock, toolIcon, toolParamSummary } from "../lib/format";
5
+ import { safeJson } from "../lib/util";
6
+ import { Icon } from "./Icon";
7
+ import { DiffBlock } from "./DiffBlock";
8
+
9
+ interface ToolT {
10
+ name: string;
11
+ input: unknown;
12
+ }
13
+
14
+ /** 工具调用卡片:图标 + 标题 + 副标题 + stat,折叠看 diff / 内容 / json。 */
15
+ export function ToolCard({ t }: { t: ToolT }) {
16
+ const name = t.name || "?";
17
+ const input = t.input && typeof t.input === "object" ? (t.input as Record<string, unknown>) : {};
18
+ const g = (k: string): string => (input[k] != null ? String(input[k]) : "");
19
+ const sub = toolParamSummary(name, input).slice(0, 100);
20
+
21
+ const { body, stat } = useMemo<{ body: ReactNode; stat: { add: number; del: number } | null }>(() => {
22
+ if (name === "Edit") {
23
+ const diffs = computeLineDiff(g("old_string"), g("new_string"));
24
+ let add = 0;
25
+ let del = 0;
26
+ for (const x of diffs) {
27
+ if (x.op === "add") add++;
28
+ else if (x.op === "del") del++;
29
+ }
30
+ return { body: <DiffBlock diffs={diffs} />, stat: { add, del } };
31
+ }
32
+ if (name === "Write") {
33
+ return {
34
+ body: <div dangerouslySetInnerHTML={{ __html: renderContentBlock(g("content")) }} />,
35
+ stat: null,
36
+ };
37
+ }
38
+ return { body: <pre className="card-output">{safeJson(input)}</pre>, stat: null };
39
+ }, [name, input]);
40
+
41
+ return (
42
+ <details className="tool-card">
43
+ <summary className="card-trigger">
44
+ <span className="card-chev"><Icon name="chevron" size={12} /></span>
45
+ <span className="tool-icon">{toolIcon(name)}</span>
46
+ <span className="tool-title">{name}</span>
47
+ <span className="tool-sub">{sub}</span>
48
+ {stat && (
49
+ <span className="tool-stat">
50
+ <span className="s-add">+{stat.add}</span> <span className="s-del">-{stat.del}</span>
51
+ </span>
52
+ )}
53
+ </summary>
54
+ <div className="tool-content">{body}</div>
55
+ </details>
56
+ );
57
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { CommitLog, CommitsResponse, SessionSummary } from "../types";
3
+ import type { ApiFn } from "./useApi";
4
+
5
+ /** 跨所有 cwd 合并 git log(概览/统计模块用)。
6
+ * 从 sessions 提取 distinct cwd,各拉 /api/commits,按 time 倒序合并。
7
+ * cwd 集合不变不重拉(sig 缓存,避 sessions 2s 轮询抖动)。 */
8
+ export interface AllCommit extends CommitLog {
9
+ cwd: string;
10
+ }
11
+
12
+ export function useAllCommits(api: ApiFn, sessions: SessionSummary[], active: boolean) {
13
+ const [commits, setCommits] = useState<AllCommit[]>([]);
14
+ const [loading, setLoading] = useState(false);
15
+ const loadedSigRef = useRef("");
16
+
17
+ useEffect(() => {
18
+ if (!active) return;
19
+ const cwds = Array.from(new Set(sessions.map((s) => s.cwd).filter(Boolean)));
20
+ const sig = cwds.join("\n");
21
+ if (loadedSigRef.current === sig) return;
22
+ loadedSigRef.current = sig;
23
+ if (cwds.length === 0) {
24
+ setCommits([]);
25
+ return;
26
+ }
27
+ let alive = true;
28
+ setLoading(true);
29
+ void (async () => {
30
+ try {
31
+ const results = await Promise.all(
32
+ cwds.map(async (cwd) => {
33
+ try {
34
+ const r = await api<CommitsResponse>(
35
+ `/api/commits?cwd=${encodeURIComponent(cwd)}&limit=200`,
36
+ );
37
+ return { cwd, commits: r.commits ?? [] };
38
+ } catch {
39
+ return { cwd, commits: [] as CommitLog[] };
40
+ }
41
+ }),
42
+ );
43
+ if (!alive) return;
44
+ const all: AllCommit[] = results.flatMap((r) =>
45
+ r.commits.map((c) => ({ ...c, cwd: r.cwd })),
46
+ );
47
+ all.sort((a, b) => b.time - a.time);
48
+ setCommits(all);
49
+ } finally {
50
+ if (alive) setLoading(false);
51
+ }
52
+ })();
53
+ return () => {
54
+ alive = false;
55
+ };
56
+ }, [api, sessions, active]);
57
+
58
+ return { commits, loading };
59
+ }
@@ -0,0 +1,13 @@
1
+ import { useCallback } from "react";
2
+
3
+ /** 带鉴权 token 的 fetch 封装;返回稳定引用(仅依赖 token)。 */
4
+ export function useApi(token: string) {
5
+ const base = location.origin;
6
+ return useCallback(async function api<T>(path: string): Promise<T> {
7
+ const res = await fetch(base + path, { headers: { Authorization: "Bearer " + token } });
8
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
9
+ return res.json() as Promise<T>;
10
+ }, [token]);
11
+ }
12
+
13
+ export type ApiFn = <T>(path: string) => Promise<T>;
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { CommitLog, CommitsResponse } from "../types";
3
+ import type { ApiFn } from "./useApi";
4
+
5
+ /** 拉某 cwd 的 git log(/api/commits)。active=true 且 cwd 存在才拉。
6
+ * Step 3 下沉:返回自包含 {commits, loading, error, cwd}。loadedCwdRef 换仓库先清空防闪。 */
7
+ export function useCommits(api: ApiFn, cwd: string | null, active: boolean) {
8
+ const [commits, setCommits] = useState<CommitLog[]>([]);
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const [actualCwd, setActualCwd] = useState<string | null>(null);
12
+ const loadedCwdRef = useRef<string | null | undefined>(undefined);
13
+
14
+ useEffect(() => {
15
+ if (!active || !cwd) return;
16
+ if (loadedCwdRef.current !== cwd) {
17
+ setCommits([]);
18
+ loadedCwdRef.current = cwd;
19
+ }
20
+ let alive = true;
21
+ setLoading(true);
22
+ setError(null);
23
+ void (async () => {
24
+ try {
25
+ const data = await api<CommitsResponse>(
26
+ "/api/commits?cwd=" + encodeURIComponent(cwd) + "&limit=200",
27
+ );
28
+ if (alive) {
29
+ setCommits(data.commits);
30
+ setActualCwd(data.cwd);
31
+ setError(data.error ?? null);
32
+ }
33
+ } catch (e) {
34
+ if (alive) setError(e instanceof Error ? e.message : String(e));
35
+ } finally {
36
+ if (alive) setLoading(false);
37
+ }
38
+ })();
39
+ return () => {
40
+ alive = false;
41
+ };
42
+ }, [api, cwd, active]);
43
+
44
+ return { commits, loading, error, cwd: actualCwd };
45
+ }
@@ -0,0 +1,43 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { TranscriptMessage, TranscriptResponse } from "../types";
3
+ import type { ApiFn } from "./useApi";
4
+
5
+ /** 拉某会话 transcript(/api/transcript)。active=true 且 sessionId 存在才拉。
6
+ * Step 3 下沉:返回自包含 {messages, loading, error}。loadedSidRef 换会话先清空防闪。 */
7
+ export function useConversation(api: ApiFn, sessionId: string | null, active: boolean) {
8
+ const [messages, setMessages] = useState<TranscriptMessage[]>([]);
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const loadedSidRef = useRef<string | null | undefined>(undefined);
12
+
13
+ useEffect(() => {
14
+ if (!active || !sessionId) return;
15
+ if (loadedSidRef.current !== sessionId) {
16
+ setMessages([]);
17
+ loadedSidRef.current = sessionId;
18
+ }
19
+ let alive = true;
20
+ setLoading(true);
21
+ setError(null);
22
+ void (async () => {
23
+ try {
24
+ const data = await api<TranscriptResponse>(
25
+ "/api/transcript?sessionId=" + encodeURIComponent(sessionId),
26
+ );
27
+ if (alive) {
28
+ setMessages(data.messages);
29
+ setError(null);
30
+ }
31
+ } catch (e) {
32
+ if (alive) setError(e instanceof Error ? e.message : String(e));
33
+ } finally {
34
+ if (alive) setLoading(false);
35
+ }
36
+ })();
37
+ return () => {
38
+ alive = false;
39
+ };
40
+ }, [api, sessionId, active]);
41
+
42
+ return { messages, loading, error };
43
+ }
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { EventsResponse, HookEvent } from "../types";
3
+ import type { ApiFn } from "./useApi";
4
+
5
+ /** 拉事件历史(/api/events)。active=true 才拉;sessionId=null 看全部、非 null 单会话。
6
+ * Step 3 下沉:返回自包含 {events, loading, error},各模块自管实例,互不覆盖。
7
+ * loadedSessionRef:换 sessionId 先清空,避免闪现旧数据。 */
8
+ export function useEvents(api: ApiFn, sessionId: string | null, active: boolean) {
9
+ const [events, setEvents] = useState<HookEvent[]>([]);
10
+ const [loading, setLoading] = useState(false);
11
+ const [error, setError] = useState<string | null>(null);
12
+ const loadedSessionRef = useRef<string | null | undefined>(undefined);
13
+
14
+ useEffect(() => {
15
+ if (!active) return;
16
+ if (loadedSessionRef.current !== sessionId) {
17
+ setEvents([]);
18
+ loadedSessionRef.current = sessionId;
19
+ }
20
+ let alive = true;
21
+ setLoading(true);
22
+ setError(null);
23
+ void (async () => {
24
+ try {
25
+ const qs = sessionId
26
+ ? "?sessionId=" + encodeURIComponent(sessionId) + "&limit=200"
27
+ : "?limit=200";
28
+ const data = await api<EventsResponse>("/api/events" + qs);
29
+ if (alive) {
30
+ setEvents(data.events);
31
+ setError(null);
32
+ }
33
+ } catch (e) {
34
+ if (alive) setError(e instanceof Error ? e.message : String(e));
35
+ } finally {
36
+ if (alive) setLoading(false);
37
+ }
38
+ })();
39
+ return () => {
40
+ alive = false;
41
+ };
42
+ }, [api, sessionId, active]);
43
+
44
+ return { events, loading, error };
45
+ }
@@ -0,0 +1,43 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { MouseEvent as ReactMouseEvent } from "react";
3
+
4
+ /** 可拖拽分隔条:mousedown 后改 CSS 变量。
5
+ * orient=v 改 varName(默认 --nav-w 导航宽);会话/事件树传 "--tree-w"。
6
+ * orient=h 改 --footer-h(底部日志高,已弃用但保留兼容)。 */
7
+ export function useSplitter(orient: "v" | "h", varName?: string) {
8
+ const ref = useRef<HTMLDivElement>(null);
9
+ const onMouseDown = useCallback(
10
+ (e: ReactMouseEvent<HTMLDivElement>) => {
11
+ e.preventDefault();
12
+ const el = ref.current;
13
+ if (!el) return;
14
+ el.classList.add("dragging");
15
+ document.body.style.cursor = orient === "v" ? "col-resize" : "row-resize";
16
+ document.body.style.userSelect = "none";
17
+ const startX = e.clientX;
18
+ const startY = e.clientY;
19
+ const root = document.documentElement;
20
+ const wVar = orient === "v" ? varName ?? "--nav-w" : "--footer-h";
21
+ const startW = parseFloat(getComputedStyle(root).getPropertyValue(wVar)) || 200;
22
+ const startH = parseFloat(getComputedStyle(root).getPropertyValue("--footer-h")) || 170;
23
+ const onMove = (ev: MouseEvent) => {
24
+ if (orient === "v") {
25
+ root.style.setProperty(wVar, Math.max(140, startW + (ev.clientX - startX)) + "px");
26
+ } else {
27
+ root.style.setProperty("--footer-h", Math.max(60, startH - (ev.clientY - startY)) + "px");
28
+ }
29
+ };
30
+ const onUp = () => {
31
+ el.classList.remove("dragging");
32
+ document.body.style.cursor = "";
33
+ document.body.style.userSelect = "";
34
+ document.removeEventListener("mousemove", onMove);
35
+ document.removeEventListener("mouseup", onUp);
36
+ };
37
+ document.addEventListener("mousemove", onMove);
38
+ document.addEventListener("mouseup", onUp);
39
+ },
40
+ [orient, varName],
41
+ );
42
+ return { ref, onMouseDown };
43
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect } from "react";
2
+ import type { Dispatch, SetStateAction } from "react";
3
+ import type { SessionSummary, StatsResponse } from "../types";
4
+ import type { ApiFn } from "./useApi";
5
+
6
+ /** 每 2s 轮询 /api/stats + /api/sessions(启动即一次)。 */
7
+ export function useStatsPolling(
8
+ api: ApiFn,
9
+ setStats: Dispatch<SetStateAction<StatsResponse | null>>,
10
+ setSessions: Dispatch<SetStateAction<SessionSummary[]>>,
11
+ ): void {
12
+ useEffect(() => {
13
+ let alive = true;
14
+ const refresh = async () => {
15
+ try {
16
+ const [s, sess] = await Promise.all([
17
+ api<StatsResponse>("/api/stats"),
18
+ api<{ sessions: SessionSummary[] }>("/api/sessions"),
19
+ ]);
20
+ if (alive) {
21
+ setStats(s);
22
+ setSessions(sess.sessions);
23
+ }
24
+ } catch (e) {
25
+ console.warn("refresh", e);
26
+ }
27
+ };
28
+ void refresh();
29
+ const id = setInterval(refresh, 2000);
30
+ return () => {
31
+ alive = false;
32
+ clearInterval(id);
33
+ };
34
+ }, [api, setStats, setSessions]);
35
+ }
@@ -0,0 +1,38 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { HookEvent, WsMessage } from "../types";
3
+
4
+ /**
5
+ * WS 连接 + 2s 自动重连。onEvent 用 ref 存最新值,effect 只依赖 token,
6
+ * 避免 onEvent 变化导致连接重建。onEvent 传 null 时不分发事件(仍保持连接)。
7
+ */
8
+ export function useWebSocket(token: string, onEvent: ((ev: HookEvent) => void) | null): void {
9
+ const ref = useRef(onEvent);
10
+ ref.current = onEvent;
11
+ useEffect(() => {
12
+ const proto = location.protocol === "https:" ? "wss" : "ws";
13
+ let ws: WebSocket | null = null;
14
+ let timer: ReturnType<typeof setTimeout> | null = null;
15
+ let closed = false;
16
+ const connect = () => {
17
+ ws = new WebSocket(`${proto}://${location.host}/api/ws?t=${encodeURIComponent(token)}`);
18
+ ws.onmessage = (m) => {
19
+ try {
20
+ const msg = JSON.parse(typeof m.data === "string" ? m.data : "") as WsMessage;
21
+ if (msg.kind === "event" && ref.current) ref.current(msg.event);
22
+ } catch (e) {
23
+ console.warn("ws msg", e);
24
+ }
25
+ };
26
+ ws.onclose = () => {
27
+ if (!closed) timer = setTimeout(connect, 2000);
28
+ };
29
+ ws.onerror = () => ws?.close();
30
+ };
31
+ connect();
32
+ return () => {
33
+ closed = true;
34
+ if (timer) clearTimeout(timer);
35
+ ws?.close();
36
+ };
37
+ }, [token]);
38
+ }
package/ui/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Shine Code Submit</title>
7
+ <link rel="stylesheet" href="/ui/style.css" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/ui/app.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,81 @@
1
+ // 纯聚合函数(统计模块用):按项目/按天分组,无副作用。
2
+ import type { SessionSummary, TokenUsage } from "../types";
3
+
4
+ export interface ProjTokenRow {
5
+ cwd: string;
6
+ token: TokenUsage;
7
+ sessionCount: number;
8
+ }
9
+
10
+ /** 按项目聚合 token:group by cwd,累加 tokenTotal + 会话数,按 token 总量倒序。 */
11
+ export function aggregateTokenByProject(sessions: SessionSummary[]): ProjTokenRow[] {
12
+ const m = new Map<string, ProjTokenRow>();
13
+ for (const s of sessions) {
14
+ const r =
15
+ m.get(s.cwd) ??
16
+ {
17
+ cwd: s.cwd,
18
+ token: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
19
+ sessionCount: 0,
20
+ };
21
+ r.sessionCount++;
22
+ if (s.tokenTotal) {
23
+ r.token.input += s.tokenTotal.input;
24
+ r.token.output += s.tokenTotal.output;
25
+ r.token.cacheCreation += s.tokenTotal.cacheCreation;
26
+ r.token.cacheRead += s.tokenTotal.cacheRead;
27
+ }
28
+ m.set(s.cwd, r);
29
+ }
30
+ return [...m.values()].sort(
31
+ (a, b) => b.token.input + b.token.output - (a.token.input + a.token.output),
32
+ );
33
+ }
34
+
35
+ export interface ProjCommitRow {
36
+ cwd: string;
37
+ count: number;
38
+ added: number;
39
+ deleted: number;
40
+ }
41
+
42
+ /** 按项目聚合提交:group by cwd,统计次数/新增/删除,按次数倒序。 */
43
+ export function aggregateCommitsByProject(commits: Array<{ cwd: string; added: number; deleted: number }>): ProjCommitRow[] {
44
+ const m = new Map<string, ProjCommitRow>();
45
+ for (const c of commits) {
46
+ const r = m.get(c.cwd) ?? { cwd: c.cwd, count: 0, added: 0, deleted: 0 };
47
+ r.count++;
48
+ r.added += c.added;
49
+ r.deleted += c.deleted;
50
+ m.set(c.cwd, r);
51
+ }
52
+ return [...m.values()].sort((a, b) => b.count - a.count);
53
+ }
54
+
55
+ export interface DayBucket {
56
+ day: string; // MM-DD
57
+ count: number;
58
+ }
59
+
60
+ function fmtDay(ts: number): string {
61
+ const d = new Date(ts);
62
+ return `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
63
+ }
64
+
65
+ /** 按天分桶(近 days 天,含今天),统计每桶 count。非近 days 内的时间忽略。 */
66
+ export function bucketByDay(times: number[], days = 7): DayBucket[] {
67
+ const buckets: DayBucket[] = [];
68
+ const today = new Date();
69
+ today.setHours(0, 0, 0, 0);
70
+ for (let i = days - 1; i >= 0; i--) {
71
+ const d = new Date(today);
72
+ d.setDate(d.getDate() - i);
73
+ buckets.push({ day: fmtDay(d.getTime()), count: 0 });
74
+ }
75
+ const map = new Map(buckets.map((b) => [b.day, b]));
76
+ for (const t of times) {
77
+ const b = map.get(fmtDay(t));
78
+ if (b) b.count++;
79
+ }
80
+ return buckets;
81
+ }
package/ui/lib/diff.ts ADDED
@@ -0,0 +1,45 @@
1
+ // 行级 LCS diff(从原 app.js 搬运)。
2
+
3
+ export interface DiffLine {
4
+ op: "ctx" | "add" | "del";
5
+ text: string;
6
+ }
7
+
8
+ export function computeLineDiff(oldS: unknown, newS: unknown): DiffLine[] {
9
+ const a = String(oldS || "").split("\n");
10
+ const b = String(newS || "").split("\n");
11
+ const n = a.length;
12
+ const m = b.length;
13
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
14
+ for (let i = n - 1; i >= 0; i--) {
15
+ for (let j = m - 1; j >= 0; j--) {
16
+ dp[i]![j] =
17
+ a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
18
+ }
19
+ }
20
+ const out: DiffLine[] = [];
21
+ let i = 0;
22
+ let j = 0;
23
+ while (i < n && j < m) {
24
+ if (a[i] === b[j]) {
25
+ out.push({ op: "ctx", text: a[i]! });
26
+ i++;
27
+ j++;
28
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
29
+ out.push({ op: "del", text: a[i]! });
30
+ i++;
31
+ } else {
32
+ out.push({ op: "add", text: b[j]! });
33
+ j++;
34
+ }
35
+ }
36
+ while (i < n) {
37
+ out.push({ op: "del", text: a[i]! });
38
+ i++;
39
+ }
40
+ while (j < m) {
41
+ out.push({ op: "add", text: b[j]! });
42
+ j++;
43
+ }
44
+ return out;
45
+ }