@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.
- package/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- 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
|
+
}
|