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,13 @@
1
+ import { useMemo } from "react";
2
+ import { marked } from "marked";
3
+
4
+ marked.setOptions({ gfm: true, breaks: false });
5
+
6
+ /** marked 渲染(默认转义 HTML 防注入)→ dangerouslySetInnerHTML。useMemo 按 src 缓存。 */
7
+ export function Markdown({ src }: { src: string }) {
8
+ const html = useMemo(() => {
9
+ const out = marked.parse(src);
10
+ return typeof out === "string" ? out : "";
11
+ }, [src]);
12
+ return <div dangerouslySetInnerHTML={{ __html: html }} />;
13
+ }
@@ -0,0 +1,60 @@
1
+ import { fmtTime, fmtUsage, fmtUsageFull } from "../lib/util";
2
+ import type { TranscriptMessage } from "../types";
3
+ import { Icon } from "./Icon";
4
+ import { Markdown } from "./Markdown";
5
+ import { ToolCard } from "./ToolCard";
6
+
7
+ /** 单条对话消息:user(右气泡)/ tool(结果卡片)/ assistant(角色头 + 正文 + 工具卡)。 */
8
+ export function Message({ m }: { m: TranscriptMessage }) {
9
+ if (m.role === "user") {
10
+ return (
11
+ <div className="msg-row user-row">
12
+ <div className="bubble">{m.text}</div>
13
+ </div>
14
+ );
15
+ }
16
+ if (m.role === "tool") {
17
+ return (
18
+ <div className="msg-row">
19
+ <details className={`result-card${m.isError ? " is-error" : ""}`}>
20
+ <summary className="card-trigger">
21
+ <span className="card-chev"><Icon name="chevron" size={12} /></span>
22
+ <span className="result-label">{m.toolName || "结果"}</span>
23
+ </summary>
24
+ <pre className="card-output">{m.text}</pre>
25
+ </details>
26
+ </div>
27
+ );
28
+ }
29
+ return (
30
+ <div className="msg-row assistant-row">
31
+ <div className="role-head">
32
+ <span className="role-mark"><Icon name="diamond" size={10} /></span>
33
+ <span className="role-name">Claude</span>
34
+ {m.usage && (
35
+ <span className="role-tokens" title={fmtUsageFull(m.usage)}>{fmtUsage(m.usage)}</span>
36
+ )}
37
+ {m.ts != null && <span className="role-ts">{fmtTime(m.ts)}</span>}
38
+ </div>
39
+ {m.thinking && (
40
+ <details className="think-card">
41
+ <summary className="card-trigger">
42
+ <span className="card-chev"><Icon name="chevron" size={12} /></span>
43
+ <span className="think-label">Thinking</span>
44
+ </summary>
45
+ <div className="think-body">
46
+ <Markdown src={m.thinking} />
47
+ </div>
48
+ </details>
49
+ )}
50
+ {m.text && (
51
+ <div className="assistant-text msg-text">
52
+ <Markdown src={m.text} />
53
+ </div>
54
+ )}
55
+ {m.tools.map((t, i) => (
56
+ <ToolCard key={i} t={t} />
57
+ ))}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,86 @@
1
+ import { useMemo } from "react";
2
+ import { useApi } from "../hooks/useApi";
3
+ import { useEvents } from "../hooks/useEvents";
4
+ import { useAllCommits } from "../hooks/useAllCommits";
5
+ import { useApp } from "../state/AppContext";
6
+ import { fmtDateTime, fmtTokens, shortDir, sumTokenUsage } from "../lib/util";
7
+
8
+ interface TimelineItem {
9
+ ts: number;
10
+ kind: "event" | "commit";
11
+ text: string;
12
+ cwd: string;
13
+ }
14
+
15
+ /** 概览首页:KPI 卡(token/会话/事件/提交)+ 近期活动时间线(事件与提交混合,按时间倒序)。 */
16
+ export function OverviewModule() {
17
+ const { token, sessions, stats } = useApp();
18
+ const api = useApi(token);
19
+ const { events } = useEvents(api, null, true);
20
+ const { commits } = useAllCommits(api, sessions, true);
21
+
22
+ const tokenSum = useMemo(() => sumTokenUsage(sessions.map((s) => s.tokenTotal)), [sessions]);
23
+ const recent = useMemo<TimelineItem[]>(() => {
24
+ const es: TimelineItem[] = events.slice(0, 20).map((e) => ({
25
+ ts: e.timestamp,
26
+ kind: "event",
27
+ text: e.type,
28
+ cwd: e.cwd,
29
+ }));
30
+ const cs: TimelineItem[] = commits.slice(0, 20).map((c) => ({
31
+ ts: c.time,
32
+ kind: "commit",
33
+ text: c.subject || "(无说明)",
34
+ cwd: c.cwd,
35
+ }));
36
+ return [...es, ...cs].sort((a, b) => b.ts - a.ts).slice(0, 20);
37
+ }, [events, commits]);
38
+
39
+ return (
40
+ <div className="overview-view">
41
+ <div className="kpi-grid">
42
+ <div className="kpi-card">
43
+ <span className="kpi-label">Token 输入</span>
44
+ <b className="kpi-value">{fmtTokens(tokenSum.total.input)}</b>
45
+ </div>
46
+ <div className="kpi-card">
47
+ <span className="kpi-label">Token 输出</span>
48
+ <b className="kpi-value">{fmtTokens(tokenSum.total.output)}</b>
49
+ </div>
50
+ <div className="kpi-card">
51
+ <span className="kpi-label">会话数</span>
52
+ <b className="kpi-value">{sessions.length}</b>
53
+ </div>
54
+ <div className="kpi-card">
55
+ <span className="kpi-label">事件总数</span>
56
+ <b className="kpi-value">{stats?.totalEvents ?? 0}</b>
57
+ </div>
58
+ <div className="kpi-card">
59
+ <span className="kpi-label">提交数</span>
60
+ <b className="kpi-value">{commits.length}</b>
61
+ </div>
62
+ </div>
63
+ <section className="sum-section">
64
+ <div className="sum-head">
65
+ <h3>近期活动</h3>
66
+ </div>
67
+ {recent.length === 0 ? (
68
+ <div className="sum-empty">暂无活动</div>
69
+ ) : (
70
+ <ul className="sum-list">
71
+ {recent.map((it, i) => (
72
+ <li key={i}>
73
+ <span className="sum-ts">{fmtDateTime(it.ts)}</span>
74
+ <span className={`tl-kind ${it.kind}`}>{it.kind === "event" ? "事件" : "提交"}</span>
75
+ <span className="sum-cwd" title={it.cwd}>
76
+ {shortDir(it.cwd) || "?"}
77
+ </span>
78
+ <span className="sum-subject">{it.text}</span>
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ )}
83
+ </section>
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,51 @@
1
+ import { useState } from "react";
2
+ import { useApi } from "../hooks/useApi";
3
+ import { useConversation } from "../hooks/useConversation";
4
+ import { useApp } from "../state/AppContext";
5
+ import { fmtUsage, fmtUsageFull, shortDir } from "../lib/util";
6
+ import { Conversation } from "./Conversation";
7
+
8
+ /** 会话详情(右侧):顶部摘要(sid/cwd/token)+ 该会话对话。
9
+ * 两栏布局下不再需要返回按钮(左侧树常驻,选会话即切换)。 */
10
+ export function SessionDetail({ sessionId }: { sessionId: string }) {
11
+ const { token, sessions } = useApp();
12
+ const api = useApi(token);
13
+ const [searchConv, setSearchConv] = useState("");
14
+ const { messages, loading, error } = useConversation(api, sessionId, true);
15
+
16
+ const session = sessions.find((s) => s.sessionId === sessionId);
17
+ const tokenTotal = session?.tokenTotal ?? null;
18
+
19
+ return (
20
+ <div className="session-detail">
21
+ <div className="detail-head">
22
+ <span className="detail-sid" title={sessionId}>
23
+ {sessionId.slice(0, 8)}
24
+ </span>
25
+ {session?.cwd && (
26
+ <span className="detail-cwd" title={session.cwd}>
27
+ {shortDir(session.cwd)}
28
+ </span>
29
+ )}
30
+ {tokenTotal && (tokenTotal.input > 0 || tokenTotal.output > 0) && (
31
+ <span className="detail-token" title={fmtUsageFull(tokenTotal)}>
32
+ {fmtUsage(tokenTotal)}
33
+ </span>
34
+ )}
35
+ </div>
36
+ <div className="toolbar">
37
+ <input
38
+ className="search-input"
39
+ type="search"
40
+ placeholder="搜索对话…"
41
+ value={searchConv}
42
+ onChange={(e) => setSearchConv(e.target.value)}
43
+ autoComplete="off"
44
+ />
45
+ </div>
46
+ <div className="detail-body">
47
+ <Conversation messages={messages} loading={loading} error={error} search={searchConv} />
48
+ </div>
49
+ </div>
50
+ );
51
+ }
@@ -0,0 +1,94 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useApp } from "../state/AppContext";
3
+ import { fmtDateTime, fmtUsage, fmtUsageFull } from "../lib/util";
4
+ import type { SessionSummary } from "../types";
5
+
6
+ /** 会话树(按 cwd 分组、可折叠、token 角标、时间点)。纯展示 + onSelect 回调。
7
+ * 会话模块用它选会话进详情;事件模块用它选会话筛选(allLabel 显「全部」项)。 */
8
+ export function SessionTree({
9
+ selectedId,
10
+ onSelect,
11
+ allLabel,
12
+ }: {
13
+ selectedId: string | null;
14
+ onSelect: (sid: string | null) => void;
15
+ allLabel?: string;
16
+ }) {
17
+ const { sessions } = useApp();
18
+ const [collapsedCwds, setCollapsedCwds] = useState<Set<string>>(new Set());
19
+ const toggleCwd = (cwd: string) => {
20
+ setCollapsedCwds((prev) => {
21
+ const next = new Set(prev);
22
+ if (next.has(cwd)) next.delete(cwd);
23
+ else next.add(cwd);
24
+ return next;
25
+ });
26
+ };
27
+
28
+ const groups = useMemo(() => {
29
+ const g: Record<string, SessionSummary[]> = {};
30
+ for (const s of sessions) {
31
+ const arr = g[s.cwd] ?? (g[s.cwd] = []);
32
+ arr.push(s);
33
+ }
34
+ return g;
35
+ }, [sessions]);
36
+
37
+ const groupEntries = Object.entries(groups);
38
+
39
+ return (
40
+ <ul className="session-tree">
41
+ {allLabel && (
42
+ <li
43
+ className={`tree-all${selectedId === null ? " active" : ""}`}
44
+ onClick={() => onSelect(null)}
45
+ >
46
+ {allLabel}
47
+ </li>
48
+ )}
49
+ {groupEntries.length === 0 && !allLabel ? (
50
+ <li className="empty-state">
51
+ <span className="es-hint">暂无 session</span>
52
+ <span className="es-sub">启动 Claude Code 后会出现</span>
53
+ </li>
54
+ ) : (
55
+ groupEntries.map(([cwd, sess]) => {
56
+ const collapsed = collapsedCwds.has(cwd);
57
+ return (
58
+ <li className="group" key={cwd}>
59
+ <div className="group-head" title={cwd} onClick={() => toggleCwd(cwd)}>
60
+ <span className="group-caret">{collapsed ? "▸" : "▾"}</span>
61
+ <span className="group-cwd">{cwd}</span>
62
+ </div>
63
+ {!collapsed && (
64
+ <ul>
65
+ {sess.map((s) => (
66
+ <li
67
+ key={s.sessionId}
68
+ className={s.sessionId === selectedId ? "active" : undefined}
69
+ title={s.sessionId}
70
+ onClick={() => onSelect(s.sessionId)}
71
+ >
72
+ <div className="sess-row">
73
+ <span className="sess-time">{fmtDateTime(s.lastActive)}</span>
74
+ <span className="sess-sid">{s.sessionId.slice(0, 8)}</span>
75
+ {s.tokenTotal && (s.tokenTotal.input > 0 || s.tokenTotal.output > 0) && (
76
+ <span className="sess-tokens" title={fmtUsageFull(s.tokenTotal)}>
77
+ {fmtUsage(s.tokenTotal)}
78
+ </span>
79
+ )}
80
+ </div>
81
+ <div className="sess-sub">
82
+ {s.lastType ?? "?"} · {s.eventCount}
83
+ </div>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ )}
88
+ </li>
89
+ );
90
+ })
91
+ )}
92
+ </ul>
93
+ );
94
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect } from "react";
2
+ import { useApp } from "../state/AppContext";
3
+ import { SessionsPanel } from "./SessionsPanel";
4
+ import { SessionDetail } from "./SessionDetail";
5
+ import { Splitter } from "./Splitter";
6
+ import { Icon } from "./Icon";
7
+
8
+ /** 会话模块(两栏筛选式):左侧会话树(选会话)+ 右侧该会话对话。
9
+ * 默认选中第一个会话;树宽可拖拽(--tree-w)。 */
10
+ export function SessionsModule() {
11
+ const { selectedSessionId, setSelectedSessionId, sessions } = useApp();
12
+ // 默认选中第一个会话(首次进入或选中被清空时)
13
+ useEffect(() => {
14
+ const first = sessions[0];
15
+ if (!selectedSessionId && first) {
16
+ setSelectedSessionId(first.sessionId);
17
+ }
18
+ }, [selectedSessionId, sessions, setSelectedSessionId]);
19
+
20
+ return (
21
+ <div className="sessions-with-tree">
22
+ <aside className="sessions-tree-panel panel">
23
+ <SessionsPanel />
24
+ </aside>
25
+ <Splitter orient="v" varName="--tree-w" />
26
+ <div className="sessions-main">
27
+ {selectedSessionId ? (
28
+ <SessionDetail sessionId={selectedSessionId} />
29
+ ) : (
30
+ <div className="empty-state">
31
+ <Icon name="chat" size={30} />
32
+ <span className="es-hint">暂无会话</span>
33
+ <span className="es-sub">启动 Claude Code 后会出现</span>
34
+ </div>
35
+ )}
36
+ </div>
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,15 @@
1
+ import { useApp } from "../state/AppContext";
2
+ import { SessionTree } from "./SessionTree";
3
+
4
+ /** 会话模块列表:header + SessionTree(点击进详情)。 */
5
+ export function SessionsPanel() {
6
+ const { selectedSessionId, setSelectedSessionId } = useApp();
7
+ return (
8
+ <>
9
+ <div className="panel-header">
10
+ <h2>Sessions</h2>
11
+ </div>
12
+ <SessionTree selectedId={selectedSessionId} onSelect={setSelectedSessionId} />
13
+ </>
14
+ );
15
+ }
@@ -0,0 +1,46 @@
1
+ import { useApp } from "../state/AppContext";
2
+ import { Icon, type IconName } from "./Icon";
3
+ import type { ModuleId } from "../types";
4
+
5
+ /** 左侧模块导航:6 个一级模块,active 项左侧蓝条;底部折叠按钮收成图标条。 */
6
+ const ITEMS: Array<{ id: ModuleId; label: string; icon: IconName }> = [
7
+ { id: "overview", label: "概览", icon: "home" },
8
+ { id: "sessions", label: "会话", icon: "sessions" },
9
+ // { id: "events", label: "事件", icon: "activity" }, // 暂时屏蔽,恢复取消注释即可
10
+ // { id: "commits", label: "提交", icon: "git" }, // 暂时屏蔽,恢复取消注释即可
11
+ { id: "stats", label: "统计", icon: "chart" },
12
+ { id: "system", label: "系统", icon: "server" },
13
+ ];
14
+
15
+ export function SideNav() {
16
+ const { activeModule, selectModule, navCollapsed, setNavCollapsed } = useApp();
17
+ return (
18
+ <nav className={`side-nav${navCollapsed ? " collapsed" : ""}`}>
19
+ <ul className="nav-list">
20
+ {ITEMS.map((it) => (
21
+ <li key={it.id}>
22
+ <button
23
+ type="button"
24
+ className={`nav-item${activeModule === it.id ? " active" : ""}`}
25
+ title={it.label}
26
+ aria-label={it.label}
27
+ onClick={() => selectModule(it.id)}
28
+ >
29
+ <Icon name={it.icon} size={18} />
30
+ <span className="nav-label">{it.label}</span>
31
+ </button>
32
+ </li>
33
+ ))}
34
+ </ul>
35
+ <button
36
+ type="button"
37
+ className="nav-collapse"
38
+ title={navCollapsed ? "展开导航" : "收起导航"}
39
+ aria-label={navCollapsed ? "展开导航" : "收起导航"}
40
+ onClick={() => setNavCollapsed(!navCollapsed)}
41
+ >
42
+ <Icon name="chevron" size={16} />
43
+ </button>
44
+ </nav>
45
+ );
46
+ }
@@ -0,0 +1,23 @@
1
+ import { useSplitter } from "../hooks/useSplitter";
2
+
3
+ export function Splitter({
4
+ orient,
5
+ hidden,
6
+ varName,
7
+ }: {
8
+ orient: "v" | "h";
9
+ hidden?: boolean;
10
+ varName?: string;
11
+ }) {
12
+ const { ref, onMouseDown } = useSplitter(orient, varName);
13
+ const cls = `splitter splitter-${orient}${hidden ? " hidden" : ""}`;
14
+ return (
15
+ <div
16
+ ref={ref}
17
+ className={cls}
18
+ data-orient={orient}
19
+ onMouseDown={onMouseDown}
20
+ title={orient === "v" ? "拖拽调整宽度" : "拖拽调整高度"}
21
+ />
22
+ );
23
+ }
@@ -0,0 +1,133 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useApi } from "../hooks/useApi";
3
+ import { useAllCommits } from "../hooks/useAllCommits";
4
+ import { useApp } from "../state/AppContext";
5
+ import { aggregateCommitsByProject, aggregateTokenByProject, bucketByDay } from "../lib/aggregate";
6
+ import { fmtUsage, shortDir } from "../lib/util";
7
+
8
+ type StatTab = "token" | "commits" | "day";
9
+
10
+ /** 统计模块:tab 切换三个维度,每个 tab 单条形列表全宽显示(空间更大)。 */
11
+ export function StatsModule() {
12
+ const { token, sessions } = useApp();
13
+ const api = useApi(token);
14
+ const { commits } = useAllCommits(api, sessions, true);
15
+ const [tab, setTab] = useState<StatTab>("token");
16
+
17
+ const projTokens = useMemo(() => aggregateTokenByProject(sessions), [sessions]);
18
+ const projCommits = useMemo(() => aggregateCommitsByProject(commits), [commits]);
19
+ const commitsByDay = useMemo(() => bucketByDay(commits.map((c) => c.time), 7), [commits]);
20
+
21
+ const maxToken = Math.max(1, ...projTokens.map((r) => r.token.input + r.token.output));
22
+ const maxCommitCount = Math.max(1, ...projCommits.map((r) => r.count));
23
+ const maxDayCount = Math.max(1, ...commitsByDay.map((b) => b.count));
24
+
25
+ return (
26
+ <div className="stats-view">
27
+ <div className="toolbar">
28
+ <div className="tabs">
29
+ <button
30
+ type="button"
31
+ className={`tab${tab === "token" ? " active" : ""}`}
32
+ onClick={() => setTab("token")}
33
+ >
34
+ Token 按项目
35
+ </button>
36
+ <button
37
+ type="button"
38
+ className={`tab${tab === "commits" ? " active" : ""}`}
39
+ onClick={() => setTab("commits")}
40
+ >
41
+ 提交按项目
42
+ </button>
43
+ <button
44
+ type="button"
45
+ className={`tab${tab === "day" ? " active" : ""}`}
46
+ onClick={() => setTab("day")}
47
+ >
48
+ 提交按天
49
+ </button>
50
+ </div>
51
+ </div>
52
+ <div className="stats-body">
53
+ {tab === "token" && (
54
+ <section className="sum-section">
55
+ <div className="sum-head">
56
+ <h3>Token 按项目</h3>
57
+ </div>
58
+ {projTokens.length === 0 ? (
59
+ <div className="sum-empty">暂无 token 数据</div>
60
+ ) : (
61
+ <div className="bar-list">
62
+ {projTokens.map((r) => {
63
+ const v = r.token.input + r.token.output;
64
+ return (
65
+ <div className="bar-row" key={r.cwd}>
66
+ <span className="bar-label" title={r.cwd}>
67
+ {shortDir(r.cwd) || "?"}
68
+ </span>
69
+ <div className="bar-track">
70
+ <div className="bar-fill tok" style={{ width: `${(v / maxToken) * 100}%` }} />
71
+ </div>
72
+ <span className="bar-val">{fmtUsage(r.token)}</span>
73
+ </div>
74
+ );
75
+ })}
76
+ </div>
77
+ )}
78
+ </section>
79
+ )}
80
+ {tab === "commits" && (
81
+ <section className="sum-section">
82
+ <div className="sum-head">
83
+ <h3>提交次数按项目</h3>
84
+ </div>
85
+ {projCommits.length === 0 ? (
86
+ <div className="sum-empty">暂无提交</div>
87
+ ) : (
88
+ <div className="bar-list">
89
+ {projCommits.map((r) => (
90
+ <div className="bar-row" key={r.cwd}>
91
+ <span className="bar-label" title={r.cwd}>
92
+ {shortDir(r.cwd) || "?"}
93
+ </span>
94
+ <div className="bar-track">
95
+ <div
96
+ className="bar-fill commit"
97
+ style={{ width: `${(r.count / maxCommitCount) * 100}%` }}
98
+ />
99
+ </div>
100
+ <span className="bar-val">
101
+ {r.count} 次 · +{r.added}/-{r.deleted}
102
+ </span>
103
+ </div>
104
+ ))}
105
+ </div>
106
+ )}
107
+ </section>
108
+ )}
109
+ {tab === "day" && (
110
+ <section className="sum-section">
111
+ <div className="sum-head">
112
+ <h3>提交按天(近 7 天)</h3>
113
+ </div>
114
+ <div className="bar-list day">
115
+ {commitsByDay.map((b) => (
116
+ <div className="bar-row" key={b.day}>
117
+ <span className="bar-label">{b.day}</span>
118
+ <div className="bar-track">
119
+ <div
120
+ className="bar-fill commit"
121
+ style={{ width: `${(b.count / maxDayCount) * 100}%` }}
122
+ />
123
+ </div>
124
+ <span className="bar-val">{b.count}</span>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ </section>
129
+ )}
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,66 @@
1
+ import { useMemo } from "react";
2
+ import { useApp } from "../state/AppContext";
3
+ import { Icon } from "./Icon";
4
+ import { fmtUsage, fmtUsageFull, sumTokenUsage } from "../lib/util";
5
+
6
+ /** 顶栏运行状态:常驻版本号 + 全局 token + 速率/积压/错误徽章;pid/uptime/总计 收进 info popover。 */
7
+ export function Status() {
8
+ const { stats, sessions } = useApp();
9
+ // 全局 token:累加已加载会话的 tokenTotal。仅最近 50 个被 enrich(见 SESSION_TOKEN_ENRICH_LIMIT),
10
+ // 故为「最近活跃会话累计」而非历史全量;count 即实际覆盖的会话数,展示在 title 里。
11
+ const token = useMemo(
12
+ () => sumTokenUsage(sessions.map((s) => s.tokenTotal)),
13
+ [sessions],
14
+ );
15
+ if (!stats) {
16
+ return (
17
+ <div className="status">
18
+ <span className="st-dot connecting">连接中…</span>
19
+ </div>
20
+ );
21
+ }
22
+ const up = Math.floor(stats.uptime / 1000);
23
+ const backlog = stats.spoolBacklog;
24
+ const lastError = stats.lastError;
25
+ const hasTokens = token.total.input > 0 || token.total.output > 0;
26
+
27
+ return (
28
+ <div className="status">
29
+ <span className="st-ver">v{stats.version}</span>
30
+ {hasTokens && (
31
+ <span
32
+ className="st-tokens"
33
+ title={`近 ${token.count} 会话累计(最多覆盖最近 50 个活跃会话)· ${fmtUsageFull(token.total)}`}
34
+ >
35
+ {fmtUsage(token.total)}
36
+ </span>
37
+ )}
38
+ {backlog > 0 && (
39
+ <span className="badge warn" title={`spool 积压 ${backlog} 条待消费`}>
40
+ 积压 {backlog}
41
+ </span>
42
+ )}
43
+ {lastError ? (
44
+ <span className="badge err" title={lastError.message}>
45
+ <Icon name="warning" size={12} /> 错误
46
+ </span>
47
+ ) : (
48
+ <span className="st-rate" title="事件速率">
49
+ <Icon name="activity" size={12} />
50
+ {stats.eventsPerSec.toFixed(1)}/s
51
+ </span>
52
+ )}
53
+ <div className="status-detail" tabIndex={0} title="运行详情">
54
+ <Icon name="info" />
55
+ <div className="popover" role="tooltip">
56
+ <div className="pop-row"><span>pid</span><b>{stats.pid}</b></div>
57
+ <div className="pop-row"><span>uptime</span><b>{up}s</b></div>
58
+ <div className="pop-row"><span>积压</span><b>{backlog}</b></div>
59
+ <div className="pop-row"><span>速率</span><b>{stats.eventsPerSec.toFixed(1)} evt/s</b></div>
60
+ <div className="pop-row"><span>总计</span><b>{stats.totalEvents}</b></div>
61
+ {lastError && <div className="pop-err">{lastError.message}</div>}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ );
66
+ }