@hienlh/ppm 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,139 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { api, projectUrl } from "@/lib/api-client";
3
+ import { Plus, Trash2, MessageSquare, ChevronDown } from "lucide-react";
4
+ import type { SessionInfo } from "../../../types/chat";
5
+
6
+ interface SessionPickerProps {
7
+ currentSessionId: string | null;
8
+ onSelectSession: (session: SessionInfo) => void;
9
+ onNewSession: () => void;
10
+ projectName?: string;
11
+ }
12
+
13
+ export function SessionPicker({
14
+ currentSessionId,
15
+ onSelectSession,
16
+ onNewSession,
17
+ projectName,
18
+ }: SessionPickerProps) {
19
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
20
+ const [open, setOpen] = useState(false);
21
+ const [loading, setLoading] = useState(false);
22
+
23
+ const loadSessions = useCallback(async () => {
24
+ if (!projectName) return;
25
+ setLoading(true);
26
+ try {
27
+ const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
28
+ setSessions(data);
29
+ } catch {
30
+ // Silently fail — sessions list is non-critical
31
+ } finally {
32
+ setLoading(false);
33
+ }
34
+ }, [projectName]);
35
+
36
+ useEffect(() => {
37
+ loadSessions();
38
+ }, [loadSessions]);
39
+
40
+ // Reload when dropdown opens
41
+ useEffect(() => {
42
+ if (open) loadSessions();
43
+ }, [open, loadSessions]);
44
+
45
+ const currentSession = sessions.find((s) => s.id === currentSessionId);
46
+
47
+ const handleDelete = async (e: React.MouseEvent, session: SessionInfo) => {
48
+ e.stopPropagation();
49
+ try {
50
+ if (!projectName) return;
51
+ await api.del(
52
+ `${projectUrl(projectName)}/chat/sessions/${session.id}?providerId=${session.providerId}`,
53
+ );
54
+ setSessions((prev) => prev.filter((s) => s.id !== session.id));
55
+ } catch {
56
+ // Silently fail
57
+ }
58
+ };
59
+
60
+ return (
61
+ <div className="relative">
62
+ <button
63
+ onClick={() => setOpen(!open)}
64
+ className="flex items-center gap-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors px-2 py-1 rounded hover:bg-surface-elevated"
65
+ >
66
+ <MessageSquare className="size-3.5" />
67
+ <span className="truncate max-w-[150px]">
68
+ {currentSession?.title ?? "Select chat"}
69
+ </span>
70
+ <ChevronDown className="size-3" />
71
+ </button>
72
+
73
+ {open && (
74
+ <>
75
+ {/* Backdrop */}
76
+ <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
77
+
78
+ <div className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border border-border bg-surface shadow-lg overflow-hidden">
79
+ {/* New chat button */}
80
+ <button
81
+ onClick={() => {
82
+ onNewSession();
83
+ setOpen(false);
84
+ }}
85
+ className="flex items-center gap-2 w-full px-3 py-2 text-sm text-primary hover:bg-surface-elevated transition-colors border-b border-border"
86
+ >
87
+ <Plus className="size-4" />
88
+ <span>New Chat</span>
89
+ </button>
90
+
91
+ {/* Sessions list */}
92
+ <div className="max-h-60 overflow-y-auto">
93
+ {loading && (
94
+ <p className="px-3 py-2 text-xs text-text-subtle animate-pulse">
95
+ Loading sessions...
96
+ </p>
97
+ )}
98
+ {!loading && sessions.length === 0 && (
99
+ <p className="px-3 py-2 text-xs text-text-subtle">
100
+ No sessions yet
101
+ </p>
102
+ )}
103
+ {sessions.map((session) => (
104
+ <div
105
+ key={session.id}
106
+ onClick={() => {
107
+ onSelectSession(session);
108
+ setOpen(false);
109
+ }}
110
+ className={`flex items-center justify-between px-3 py-2 text-sm cursor-pointer hover:bg-surface-elevated transition-colors ${
111
+ session.id === currentSessionId
112
+ ? "bg-surface-elevated text-text-primary"
113
+ : "text-text-secondary"
114
+ }`}
115
+ >
116
+ <div className="flex flex-col min-w-0 flex-1">
117
+ <span className="truncate text-xs font-medium">
118
+ {session.title}
119
+ </span>
120
+ <span className="text-xs text-text-subtle">
121
+ {new Date(session.createdAt).toLocaleDateString()}
122
+ </span>
123
+ </div>
124
+ <button
125
+ onClick={(e) => handleDelete(e, session)}
126
+ className="p-1 rounded hover:bg-red-500/20 text-text-subtle hover:text-red-400 transition-colors shrink-0"
127
+ aria-label="Delete session"
128
+ >
129
+ <Trash2 className="size-3" />
130
+ </button>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ </div>
135
+ </>
136
+ )}
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,135 @@
1
+ import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
2
+ import { Sparkles, Terminal } from "lucide-react";
3
+
4
+ export interface SlashItem {
5
+ type: "skill" | "command";
6
+ name: string;
7
+ description: string;
8
+ argumentHint?: string;
9
+ scope?: "project" | "user";
10
+ }
11
+
12
+ interface SlashCommandPickerProps {
13
+ items: SlashItem[];
14
+ filter: string;
15
+ onSelect: (item: SlashItem) => void;
16
+ onClose: () => void;
17
+ visible: boolean;
18
+ }
19
+
20
+ export function SlashCommandPicker({
21
+ items,
22
+ filter,
23
+ onSelect,
24
+ onClose,
25
+ visible,
26
+ }: SlashCommandPickerProps) {
27
+ const [selectedIndex, setSelectedIndex] = useState(0);
28
+ const listRef = useRef<HTMLDivElement>(null);
29
+
30
+ const filtered = items.filter((item) => {
31
+ const q = filter.toLowerCase();
32
+ return (
33
+ item.name.toLowerCase().includes(q) ||
34
+ item.description.toLowerCase().includes(q)
35
+ );
36
+ });
37
+
38
+ // Reset selection when filter changes
39
+ useEffect(() => {
40
+ setSelectedIndex(0);
41
+ }, [filter]);
42
+
43
+ // Scroll selected item into view
44
+ useEffect(() => {
45
+ const list = listRef.current;
46
+ if (!list) return;
47
+ const selected = list.children[selectedIndex] as HTMLElement | undefined;
48
+ selected?.scrollIntoView({ block: "nearest" });
49
+ }, [selectedIndex]);
50
+
51
+ const handleKeyDown = useCallback(
52
+ (e: KeyboardEvent | globalThis.KeyboardEvent) => {
53
+ if (!visible || filtered.length === 0) return false;
54
+
55
+ switch (e.key) {
56
+ case "ArrowUp":
57
+ e.preventDefault();
58
+ setSelectedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));
59
+ return true;
60
+ case "ArrowDown":
61
+ e.preventDefault();
62
+ setSelectedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));
63
+ return true;
64
+ case "Enter":
65
+ case "Tab":
66
+ e.preventDefault();
67
+ if (filtered[selectedIndex]) {
68
+ onSelect(filtered[selectedIndex]);
69
+ }
70
+ return true;
71
+ case "Escape":
72
+ e.preventDefault();
73
+ onClose();
74
+ return true;
75
+ }
76
+ return false;
77
+ },
78
+ [visible, filtered, selectedIndex, onSelect, onClose],
79
+ );
80
+
81
+ // Global keyboard handler (captures before textarea)
82
+ useEffect(() => {
83
+ if (!visible) return;
84
+ const handler = (e: globalThis.KeyboardEvent) => {
85
+ handleKeyDown(e);
86
+ };
87
+ document.addEventListener("keydown", handler, true);
88
+ return () => document.removeEventListener("keydown", handler, true);
89
+ }, [visible, handleKeyDown]);
90
+
91
+ if (!visible || filtered.length === 0) return null;
92
+
93
+ return (
94
+ <div className="max-h-52 overflow-y-auto border-b border-border bg-surface">
95
+ <div ref={listRef} className="py-1">
96
+ {filtered.map((item, i) => (
97
+ <button
98
+ key={`${item.type}-${item.name}`}
99
+ className={`flex items-start gap-3 w-full px-3 py-2 text-left transition-colors ${
100
+ i === selectedIndex
101
+ ? "bg-primary/10 text-primary"
102
+ : "hover:bg-surface-hover text-text-primary"
103
+ }`}
104
+ onMouseEnter={() => setSelectedIndex(i)}
105
+ onClick={() => onSelect(item)}
106
+ >
107
+ <span className="shrink-0 mt-0.5">
108
+ {item.type === "skill" ? (
109
+ <Sparkles className="size-4 text-amber-500" />
110
+ ) : (
111
+ <Terminal className="size-4 text-blue-500" />
112
+ )}
113
+ </span>
114
+ <div className="min-w-0 flex-1">
115
+ <div className="flex items-baseline gap-2">
116
+ <span className="font-medium text-sm">/{item.name}</span>
117
+ {item.argumentHint && (
118
+ <span className="text-xs text-text-subtle">{item.argumentHint}</span>
119
+ )}
120
+ <span className="text-xs text-text-subtle capitalize ml-auto">
121
+ {item.scope === "user" ? "global" : item.type}
122
+ </span>
123
+ </div>
124
+ {item.description && (
125
+ <p className="text-xs text-text-subtle mt-0.5 line-clamp-2">
126
+ {item.description}
127
+ </p>
128
+ )}
129
+ </div>
130
+ </button>
131
+ ))}
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,186 @@
1
+ import { Activity, RefreshCw } from "lucide-react";
2
+ import type { UsageInfo, LimitBucket } from "../../../types/chat";
3
+
4
+ interface UsageBadgeProps {
5
+ usage: UsageInfo;
6
+ onClick?: () => void;
7
+ }
8
+
9
+ function pctColor(pct: number): string {
10
+ if (pct >= 90) return "text-red-500";
11
+ if (pct >= 70) return "text-amber-500";
12
+ return "text-green-500";
13
+ }
14
+
15
+ function barColor(pct: number): string {
16
+ if (pct >= 90) return "bg-red-500";
17
+ if (pct >= 70) return "bg-amber-500";
18
+ return "bg-green-500";
19
+ }
20
+
21
+ export function UsageBadge({ usage, onClick }: UsageBadgeProps) {
22
+ const fiveHourPct = usage.fiveHour != null ? Math.round(usage.fiveHour * 100) : null;
23
+ const sevenDayPct = usage.sevenDay != null ? Math.round(usage.sevenDay * 100) : null;
24
+
25
+ const fiveHourLabel = fiveHourPct != null ? `${fiveHourPct}%` : "--%";
26
+ const sevenDayLabel = sevenDayPct != null ? `${sevenDayPct}%` : "--%";
27
+
28
+ const worstPct = Math.max(fiveHourPct ?? 0, sevenDayPct ?? 0);
29
+ const colorClass = fiveHourPct != null || sevenDayPct != null ? pctColor(worstPct) : "text-text-subtle";
30
+
31
+ return (
32
+ <button
33
+ onClick={onClick}
34
+ className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium tabular-nums transition-colors hover:bg-surface-hover ${colorClass}`}
35
+ title="Click for usage details"
36
+ >
37
+ <Activity className="size-3" />
38
+ <span>5h:{fiveHourLabel}</span>
39
+ <span className="text-text-subtle">·</span>
40
+ <span>Wk:{sevenDayLabel}</span>
41
+ </button>
42
+ );
43
+ }
44
+
45
+ // --- Detail panel ---
46
+
47
+ interface UsageDetailPanelProps {
48
+ usage: UsageInfo;
49
+ visible: boolean;
50
+ onClose: () => void;
51
+ onReload?: () => void;
52
+ loading?: boolean;
53
+ }
54
+
55
+ function formatResetTime(bucket?: LimitBucket): string | null {
56
+ if (!bucket) return null;
57
+ // Compute total minutes from whichever field is available
58
+ let totalMins: number | null = null;
59
+ if (bucket.resetsInMinutes != null) {
60
+ totalMins = bucket.resetsInMinutes;
61
+ } else if (bucket.resetsInHours != null) {
62
+ totalMins = Math.round(bucket.resetsInHours * 60);
63
+ } else if (bucket.resetsAt) {
64
+ const diff = new Date(bucket.resetsAt).getTime() - Date.now();
65
+ totalMins = diff > 0 ? Math.ceil(diff / 60_000) : 0;
66
+ }
67
+ if (totalMins == null) return null;
68
+ if (totalMins <= 0) return "now";
69
+ const d = Math.floor(totalMins / 1440);
70
+ const h = Math.floor((totalMins % 1440) / 60);
71
+ const m = totalMins % 60;
72
+ if (d > 0) return m > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${d}d ${h}h` : `${d}d`;
73
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
74
+ return `${m}m`;
75
+ }
76
+
77
+ function statusLabel(status?: string): { text: string; color: string } | null {
78
+ if (!status) return null;
79
+ switch (status) {
80
+ case "ahead_of_pace": return { text: "Ahead of pace", color: "text-green-500" };
81
+ case "behind_pace": return { text: "Behind pace", color: "text-amber-500" };
82
+ case "on_pace": return { text: "On pace", color: "text-text-subtle" };
83
+ default: return { text: status.replace(/_/g, " "), color: "text-text-subtle" };
84
+ }
85
+ }
86
+
87
+ function BucketRow({ label, bucket }: { label: string; bucket?: LimitBucket }) {
88
+ if (!bucket) return null;
89
+ const pct = Math.round(bucket.utilization * 100);
90
+ const reset = formatResetTime(bucket);
91
+ const status = statusLabel(bucket.status);
92
+
93
+ return (
94
+ <div className="space-y-1">
95
+ <div className="flex items-center justify-between">
96
+ <span className="text-xs font-medium text-text-primary">{label}</span>
97
+ <div className="flex items-center gap-2">
98
+ {status && (
99
+ <span className={`text-[10px] ${status.color}`}>{status.text}</span>
100
+ )}
101
+ {reset && (
102
+ <span className="text-[10px] text-text-subtle">↻ {reset}</span>
103
+ )}
104
+ </div>
105
+ </div>
106
+ <div className="flex items-center gap-2">
107
+ <div className="flex-1 h-2 rounded-full bg-border overflow-hidden">
108
+ <div
109
+ className={`h-full rounded-full transition-all ${barColor(pct)}`}
110
+ style={{ width: `${Math.min(pct, 100)}%` }}
111
+ />
112
+ </div>
113
+ <span className={`text-xs font-medium tabular-nums w-10 text-right ${pctColor(pct)}`}>
114
+ {pct}%
115
+ </span>
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading }: UsageDetailPanelProps) {
122
+ if (!visible) return null;
123
+
124
+ const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
125
+ const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
126
+
127
+ return (
128
+ <div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5">
129
+ <div className="flex items-center justify-between">
130
+ <span className="text-xs font-semibold text-text-primary">Usage Limits</span>
131
+ <div className="flex items-center gap-1">
132
+ {onReload && (
133
+ <button
134
+ onClick={onReload}
135
+ disabled={loading}
136
+ className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50"
137
+ title="Refresh usage data"
138
+ >
139
+ <RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
140
+ </button>
141
+ )}
142
+ <button
143
+ onClick={onClose}
144
+ className="text-xs text-text-subtle hover:text-text-primary px-1"
145
+ >
146
+
147
+ </button>
148
+ </div>
149
+ </div>
150
+
151
+ {hasBuckets ? (
152
+ <div className="space-y-2.5">
153
+ <BucketRow label="5-Hour Session" bucket={usage.session} />
154
+ <BucketRow label="Weekly" bucket={usage.weekly} />
155
+ <BucketRow label="Weekly (Opus)" bucket={usage.weeklyOpus} />
156
+ <BucketRow label="Weekly (Sonnet)" bucket={usage.weeklySonnet} />
157
+ </div>
158
+ ) : (
159
+ <p className="text-xs text-text-subtle">
160
+ No data — run <code className="bg-surface-elevated px-1 rounded">bun install</code>
161
+ </p>
162
+ )}
163
+
164
+ {hasCost && (
165
+ <div className="border-t border-border pt-2 space-y-1">
166
+ {usage.queryCostUsd != null && (
167
+ <div className="flex items-center justify-between text-xs">
168
+ <span className="text-text-subtle">Last query</span>
169
+ <span className="text-text-primary font-medium tabular-nums">
170
+ ${usage.queryCostUsd.toFixed(4)}
171
+ </span>
172
+ </div>
173
+ )}
174
+ {usage.totalCostUsd != null && (
175
+ <div className="flex items-center justify-between text-xs">
176
+ <span className="text-text-subtle">Session total</span>
177
+ <span className="text-text-primary font-medium tabular-nums">
178
+ ${usage.totalCostUsd.toFixed(4)}
179
+ </span>
180
+ </div>
181
+ )}
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ }