@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,212 @@
|
|
|
1
|
+
import type { Subprocess, Terminal } from "bun";
|
|
2
|
+
|
|
3
|
+
/** Max output buffer size per session (10KB) */
|
|
4
|
+
const MAX_BUFFER_SIZE = 10 * 1024;
|
|
5
|
+
|
|
6
|
+
/** Idle session timeout: 1 hour */
|
|
7
|
+
const IDLE_TIMEOUT_MS = 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
/** Reconnect grace period after WS disconnect */
|
|
10
|
+
const RECONNECT_GRACE_MS = 30 * 1000;
|
|
11
|
+
|
|
12
|
+
export interface TerminalSession {
|
|
13
|
+
id: string;
|
|
14
|
+
proc: Subprocess;
|
|
15
|
+
terminal: Terminal;
|
|
16
|
+
projectPath: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
/** Connected WebSocket (if any) */
|
|
19
|
+
ws: unknown | null;
|
|
20
|
+
/** Timeout to kill session after WS disconnect */
|
|
21
|
+
disconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
22
|
+
/** Idle timeout timer */
|
|
23
|
+
idleTimer: ReturnType<typeof setTimeout>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TerminalSessionInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
projectPath: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
connected: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type OutputCallback = (sessionId: string, data: string) => void;
|
|
34
|
+
|
|
35
|
+
class TerminalService {
|
|
36
|
+
private sessions = new Map<string, TerminalSession>();
|
|
37
|
+
private outputBuffers = new Map<string, string>();
|
|
38
|
+
private outputListeners = new Map<string, OutputCallback>();
|
|
39
|
+
|
|
40
|
+
/** Create a new terminal session using Bun's native PTY */
|
|
41
|
+
create(projectPath: string, cols = 80, rows = 24): string {
|
|
42
|
+
const id = crypto.randomUUID();
|
|
43
|
+
const shellCmd = process.env.SHELL || "/bin/zsh";
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
|
|
46
|
+
const proc = Bun.spawn([shellCmd, "-l"], {
|
|
47
|
+
cwd: projectPath,
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
TERM: "xterm-256color",
|
|
51
|
+
},
|
|
52
|
+
terminal: {
|
|
53
|
+
cols,
|
|
54
|
+
rows,
|
|
55
|
+
data: (_terminal: Terminal, data: Uint8Array) => {
|
|
56
|
+
const text = decoder.decode(data);
|
|
57
|
+
this.appendBuffer(id, text);
|
|
58
|
+
const listener = this.outputListeners.get(id);
|
|
59
|
+
if (listener) listener(id, text);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const terminal = proc.terminal!;
|
|
65
|
+
|
|
66
|
+
const session: TerminalSession = {
|
|
67
|
+
id,
|
|
68
|
+
proc,
|
|
69
|
+
terminal,
|
|
70
|
+
projectPath,
|
|
71
|
+
createdAt: new Date(),
|
|
72
|
+
ws: null,
|
|
73
|
+
disconnectTimer: null,
|
|
74
|
+
idleTimer: this.createIdleTimer(id),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
this.sessions.set(id, session);
|
|
78
|
+
this.outputBuffers.set(id, "");
|
|
79
|
+
|
|
80
|
+
// When process exits, notify
|
|
81
|
+
proc.exited.then(() => {
|
|
82
|
+
const listener = this.outputListeners.get(id);
|
|
83
|
+
if (listener) listener(id, "\r\n[Process exited]\r\n");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Write data to terminal via PTY */
|
|
90
|
+
write(id: string, data: string): void {
|
|
91
|
+
const session = this.sessions.get(id);
|
|
92
|
+
if (!session || session.terminal.closed) return;
|
|
93
|
+
|
|
94
|
+
this.resetIdleTimer(id);
|
|
95
|
+
session.terminal.write(data);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resize terminal PTY */
|
|
99
|
+
resize(id: string, cols: number, rows: number): void {
|
|
100
|
+
const session = this.sessions.get(id);
|
|
101
|
+
if (!session || session.terminal.closed) return;
|
|
102
|
+
session.terminal.resize(cols, rows);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Kill a terminal session */
|
|
106
|
+
kill(id: string): void {
|
|
107
|
+
const session = this.sessions.get(id);
|
|
108
|
+
if (!session) return;
|
|
109
|
+
|
|
110
|
+
if (session.disconnectTimer) clearTimeout(session.disconnectTimer);
|
|
111
|
+
clearTimeout(session.idleTimer);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (!session.terminal.closed) session.terminal.close();
|
|
115
|
+
} catch {
|
|
116
|
+
// Already closed
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
session.proc.kill();
|
|
120
|
+
} catch {
|
|
121
|
+
// Already dead
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.sessions.delete(id);
|
|
125
|
+
this.outputBuffers.delete(id);
|
|
126
|
+
this.outputListeners.delete(id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Get buffered output for reconnect */
|
|
130
|
+
getBuffer(id: string): string {
|
|
131
|
+
return this.outputBuffers.get(id) ?? "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** List all active sessions */
|
|
135
|
+
list(): TerminalSessionInfo[] {
|
|
136
|
+
const result: TerminalSessionInfo[] = [];
|
|
137
|
+
for (const [, session] of this.sessions) {
|
|
138
|
+
result.push({
|
|
139
|
+
id: session.id,
|
|
140
|
+
projectPath: session.projectPath,
|
|
141
|
+
createdAt: session.createdAt.toISOString(),
|
|
142
|
+
connected: session.ws !== null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Get a session by ID */
|
|
149
|
+
get(id: string): TerminalSession | undefined {
|
|
150
|
+
return this.sessions.get(id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Register output listener for a session (used by WS handler) */
|
|
154
|
+
onOutput(id: string, callback: OutputCallback): void {
|
|
155
|
+
this.outputListeners.set(id, callback);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Remove output listener */
|
|
159
|
+
removeOutputListener(id: string): void {
|
|
160
|
+
this.outputListeners.delete(id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Mark session as connected to a WebSocket */
|
|
164
|
+
setConnected(id: string, ws: unknown): void {
|
|
165
|
+
const session = this.sessions.get(id);
|
|
166
|
+
if (!session) return;
|
|
167
|
+
session.ws = ws;
|
|
168
|
+
|
|
169
|
+
if (session.disconnectTimer) {
|
|
170
|
+
clearTimeout(session.disconnectTimer);
|
|
171
|
+
session.disconnectTimer = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Mark session as disconnected — start grace period */
|
|
176
|
+
setDisconnected(id: string): void {
|
|
177
|
+
const session = this.sessions.get(id);
|
|
178
|
+
if (!session) return;
|
|
179
|
+
session.ws = null;
|
|
180
|
+
|
|
181
|
+
session.disconnectTimer = setTimeout(() => {
|
|
182
|
+
this.kill(id);
|
|
183
|
+
}, RECONNECT_GRACE_MS);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Append to circular output buffer (max 10KB) */
|
|
187
|
+
private appendBuffer(id: string, data: string): void {
|
|
188
|
+
let buf = this.outputBuffers.get(id) ?? "";
|
|
189
|
+
buf += data;
|
|
190
|
+
if (buf.length > MAX_BUFFER_SIZE) {
|
|
191
|
+
buf = buf.slice(buf.length - MAX_BUFFER_SIZE);
|
|
192
|
+
}
|
|
193
|
+
this.outputBuffers.set(id, buf);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Create idle timeout — kills session after IDLE_TIMEOUT_MS */
|
|
197
|
+
private createIdleTimer(id: string): ReturnType<typeof setTimeout> {
|
|
198
|
+
return setTimeout(() => {
|
|
199
|
+
this.kill(id);
|
|
200
|
+
}, IDLE_TIMEOUT_MS);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Reset idle timer on activity */
|
|
204
|
+
private resetIdleTimer(id: string): void {
|
|
205
|
+
const session = this.sessions.get(id);
|
|
206
|
+
if (!session) return;
|
|
207
|
+
clearTimeout(session.idleTimer);
|
|
208
|
+
session.idleTimer = this.createIdleTimer(id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const terminalService = new TerminalService();
|
package/src/types/api.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Standard API response envelope — backend wraps all responses in this */
|
|
2
|
+
export interface ApiResponse<T = unknown> {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
data?: T;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Helper to create success response */
|
|
9
|
+
export function ok<T>(data: T): ApiResponse<T> {
|
|
10
|
+
return { ok: true, data };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Helper to create error response */
|
|
14
|
+
export function err(error: string): ApiResponse<never> {
|
|
15
|
+
return { ok: false, error };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** WebSocket message types (terminal) */
|
|
19
|
+
export type TerminalWsMessage =
|
|
20
|
+
| { type: "input"; data: string }
|
|
21
|
+
| { type: "resize"; cols: number; rows: number }
|
|
22
|
+
| { type: "output"; data: string };
|
|
23
|
+
|
|
24
|
+
/** WebSocket message types (chat) */
|
|
25
|
+
export type ChatWsClientMessage =
|
|
26
|
+
| { type: "message"; content: string }
|
|
27
|
+
| { type: "cancel" }
|
|
28
|
+
| { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown };
|
|
29
|
+
|
|
30
|
+
export type ChatWsServerMessage =
|
|
31
|
+
| { type: "text"; content: string }
|
|
32
|
+
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string }
|
|
33
|
+
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string }
|
|
34
|
+
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
35
|
+
| { type: "usage"; usage: { totalCostUsd?: number; fiveHour?: number; sevenDay?: number } }
|
|
36
|
+
| { type: "done"; sessionId: string }
|
|
37
|
+
| { type: "error"; message: string };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface AIProvider {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
createSession(config: SessionConfig): Promise<Session>;
|
|
5
|
+
resumeSession(sessionId: string): Promise<Session>;
|
|
6
|
+
listSessions(): Promise<SessionInfo[]>;
|
|
7
|
+
deleteSession(sessionId: string): Promise<void>;
|
|
8
|
+
sendMessage(
|
|
9
|
+
sessionId: string,
|
|
10
|
+
message: string,
|
|
11
|
+
): AsyncIterable<ChatEvent>;
|
|
12
|
+
/** Resolve a pending tool/question approval by requestId */
|
|
13
|
+
resolveApproval?(requestId: string, approved: boolean, data?: unknown): void;
|
|
14
|
+
onToolApproval?: (callback: ToolApprovalHandler) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Session {
|
|
18
|
+
id: string;
|
|
19
|
+
providerId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
projectName?: string;
|
|
22
|
+
projectPath?: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SessionConfig {
|
|
27
|
+
providerId?: string;
|
|
28
|
+
projectName?: string;
|
|
29
|
+
projectPath?: string;
|
|
30
|
+
title?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionInfo {
|
|
34
|
+
id: string;
|
|
35
|
+
providerId: string;
|
|
36
|
+
title: string;
|
|
37
|
+
projectName?: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
updatedAt?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LimitBucket {
|
|
43
|
+
utilization: number;
|
|
44
|
+
budgetPace: number;
|
|
45
|
+
resetsAt: string;
|
|
46
|
+
resetsInMinutes: number | null;
|
|
47
|
+
resetsInHours: number | null;
|
|
48
|
+
windowHours: number;
|
|
49
|
+
status: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UsageInfo {
|
|
53
|
+
/** Cumulative cost across the session */
|
|
54
|
+
totalCostUsd?: number;
|
|
55
|
+
/** Cost of the last query only (resets each query) */
|
|
56
|
+
queryCostUsd?: number;
|
|
57
|
+
/** 0–1 utilization for five_hour limit */
|
|
58
|
+
fiveHour?: number;
|
|
59
|
+
/** 0–1 utilization for seven_day limit */
|
|
60
|
+
sevenDay?: number;
|
|
61
|
+
/** ISO timestamp when five_hour limit resets */
|
|
62
|
+
fiveHourResetsAt?: string;
|
|
63
|
+
/** ISO timestamp when seven_day limit resets */
|
|
64
|
+
sevenDayResetsAt?: string;
|
|
65
|
+
/** Detailed limit buckets from ccburn */
|
|
66
|
+
session?: LimitBucket;
|
|
67
|
+
weekly?: LimitBucket;
|
|
68
|
+
weeklyOpus?: LimitBucket;
|
|
69
|
+
weeklySonnet?: LimitBucket;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ChatEvent =
|
|
73
|
+
| { type: "text"; content: string }
|
|
74
|
+
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string }
|
|
75
|
+
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string }
|
|
76
|
+
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
77
|
+
| { type: "usage"; usage: UsageInfo }
|
|
78
|
+
| { type: "error"; message: string }
|
|
79
|
+
| { type: "done"; sessionId: string };
|
|
80
|
+
|
|
81
|
+
export type ToolApprovalHandler = (
|
|
82
|
+
tool: string,
|
|
83
|
+
input: unknown,
|
|
84
|
+
) => Promise<{ approved: boolean; reason?: string }>;
|
|
85
|
+
|
|
86
|
+
export interface ChatMessage {
|
|
87
|
+
id: string;
|
|
88
|
+
role: "user" | "assistant" | "system";
|
|
89
|
+
content: string;
|
|
90
|
+
events?: ChatEvent[];
|
|
91
|
+
timestamp: string;
|
|
92
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface PpmConfig {
|
|
2
|
+
port: number;
|
|
3
|
+
host: string;
|
|
4
|
+
auth: AuthConfig;
|
|
5
|
+
projects: ProjectConfig[];
|
|
6
|
+
ai: AIConfig;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AuthConfig {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
token: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProjectConfig {
|
|
15
|
+
path: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AIConfig {
|
|
20
|
+
default_provider: string;
|
|
21
|
+
providers: Record<string, AIProviderConfig>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AIProviderConfig {
|
|
25
|
+
type: "agent-sdk" | "cli";
|
|
26
|
+
api_key_env?: string;
|
|
27
|
+
command?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_CONFIG: PpmConfig = {
|
|
31
|
+
port: 8080,
|
|
32
|
+
host: "0.0.0.0",
|
|
33
|
+
auth: { enabled: true, token: "" },
|
|
34
|
+
projects: [],
|
|
35
|
+
ai: {
|
|
36
|
+
default_provider: "claude",
|
|
37
|
+
providers: {
|
|
38
|
+
claude: { type: "agent-sdk", api_key_env: "ANTHROPIC_API_KEY" },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
package/src/types/git.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface GitCommit {
|
|
2
|
+
hash: string;
|
|
3
|
+
abbreviatedHash: string;
|
|
4
|
+
subject: string;
|
|
5
|
+
body: string;
|
|
6
|
+
authorName: string;
|
|
7
|
+
authorEmail: string;
|
|
8
|
+
authorDate: string;
|
|
9
|
+
parents: string[];
|
|
10
|
+
refs: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GitBranch {
|
|
14
|
+
name: string;
|
|
15
|
+
current: boolean;
|
|
16
|
+
remote: boolean;
|
|
17
|
+
commitHash: string;
|
|
18
|
+
ahead: number;
|
|
19
|
+
behind: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GitStatus {
|
|
23
|
+
current: string | null;
|
|
24
|
+
staged: GitFileChange[];
|
|
25
|
+
unstaged: GitFileChange[];
|
|
26
|
+
untracked: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GitFileChange {
|
|
30
|
+
path: string;
|
|
31
|
+
status: "M" | "A" | "D" | "R" | "C" | "?";
|
|
32
|
+
oldPath?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GitGraphData {
|
|
36
|
+
commits: GitCommit[];
|
|
37
|
+
branches: GitBranch[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GitDiffResult {
|
|
41
|
+
files: GitDiffFile[];
|
|
42
|
+
raw: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GitDiffFile {
|
|
46
|
+
path: string;
|
|
47
|
+
additions: number;
|
|
48
|
+
deletions: number;
|
|
49
|
+
content: string;
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface Project {
|
|
2
|
+
name: string;
|
|
3
|
+
path: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ProjectInfo extends Project {
|
|
7
|
+
branch?: string;
|
|
8
|
+
status?: "clean" | "dirty";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FileNode {
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
type: "file" | "directory";
|
|
15
|
+
children?: FileNode[];
|
|
16
|
+
size?: number;
|
|
17
|
+
modified?: string;
|
|
18
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface TerminalSession {
|
|
2
|
+
id: string;
|
|
3
|
+
projectPath: string;
|
|
4
|
+
shell: string;
|
|
5
|
+
createdAt: Date;
|
|
6
|
+
lastActivity: Date;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TerminalSessionInfo {
|
|
10
|
+
id: string;
|
|
11
|
+
projectPath: string;
|
|
12
|
+
shell: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
lastActivity: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TerminalResize {
|
|
18
|
+
cols: number;
|
|
19
|
+
rows: number;
|
|
20
|
+
}
|
package/src/web/app.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { Toaster } from "@/components/ui/sonner";
|
|
3
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
4
|
+
import { TabBar } from "@/components/layout/tab-bar";
|
|
5
|
+
import { TabContent } from "@/components/layout/tab-content";
|
|
6
|
+
import { Sidebar } from "@/components/layout/sidebar";
|
|
7
|
+
import { MobileNav } from "@/components/layout/mobile-nav";
|
|
8
|
+
import { MobileDrawer } from "@/components/layout/mobile-drawer";
|
|
9
|
+
import { LoginScreen } from "@/components/auth/login-screen";
|
|
10
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
11
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
12
|
+
import {
|
|
13
|
+
useSettingsStore,
|
|
14
|
+
applyThemeClass,
|
|
15
|
+
} from "@/stores/settings-store";
|
|
16
|
+
import { getAuthToken } from "@/lib/api-client";
|
|
17
|
+
import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
|
|
18
|
+
|
|
19
|
+
type AuthState = "checking" | "authenticated" | "unauthenticated";
|
|
20
|
+
|
|
21
|
+
export function App() {
|
|
22
|
+
const [authState, setAuthState] = useState<AuthState>("checking");
|
|
23
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
24
|
+
const theme = useSettingsStore((s) => s.theme);
|
|
25
|
+
const fetchProjects = useProjectStore((s) => s.fetchProjects);
|
|
26
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
27
|
+
|
|
28
|
+
// Apply theme on mount and when it changes
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
applyThemeClass(theme);
|
|
31
|
+
|
|
32
|
+
// Listen for OS theme changes when set to "system"
|
|
33
|
+
if (theme === "system") {
|
|
34
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
35
|
+
const handler = () => applyThemeClass("system");
|
|
36
|
+
mq.addEventListener("change", handler);
|
|
37
|
+
return () => mq.removeEventListener("change", handler);
|
|
38
|
+
}
|
|
39
|
+
}, [theme]);
|
|
40
|
+
|
|
41
|
+
// Auth check on mount
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
async function checkAuth() {
|
|
44
|
+
const token = getAuthToken();
|
|
45
|
+
|
|
46
|
+
// If no token stored, try without auth (server may have auth disabled)
|
|
47
|
+
try {
|
|
48
|
+
const headers: HeadersInit = {};
|
|
49
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
50
|
+
|
|
51
|
+
const res = await fetch("/api/auth/check", { headers });
|
|
52
|
+
const json = await res.json();
|
|
53
|
+
|
|
54
|
+
if (json.ok) {
|
|
55
|
+
setAuthState("authenticated");
|
|
56
|
+
} else {
|
|
57
|
+
setAuthState("unauthenticated");
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Network error — try to proceed if no auth required
|
|
61
|
+
setAuthState("unauthenticated");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
checkAuth();
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
// URL sync — keeps browser URL in sync with active project/tab
|
|
69
|
+
useUrlSync();
|
|
70
|
+
|
|
71
|
+
// Fetch projects after auth, then restore from URL if applicable
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (authState !== "authenticated") return;
|
|
74
|
+
|
|
75
|
+
fetchProjects().then(() => {
|
|
76
|
+
const { projectName: urlProject, tabId: urlTab } = parseUrlState();
|
|
77
|
+
const projects = useProjectStore.getState().projects;
|
|
78
|
+
|
|
79
|
+
if (urlProject) {
|
|
80
|
+
const matched = projects.find((p) => p.name === urlProject);
|
|
81
|
+
if (matched) {
|
|
82
|
+
useProjectStore.getState().setActiveProject(matched);
|
|
83
|
+
// After switchProject runs, restore active tab from URL
|
|
84
|
+
if (urlTab) {
|
|
85
|
+
queueMicrotask(() => {
|
|
86
|
+
const { tabs } = useTabStore.getState();
|
|
87
|
+
if (tabs.some((t) => t.id === urlTab)) {
|
|
88
|
+
useTabStore.getState().setActiveTab(urlTab);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}, [authState, fetchProjects]);
|
|
97
|
+
|
|
98
|
+
// Switch project tabs when active project changes
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const projectName = activeProject?.name ?? "__global__";
|
|
101
|
+
useTabStore.getState().switchProject(projectName);
|
|
102
|
+
}, [activeProject?.name]);
|
|
103
|
+
|
|
104
|
+
// On initial auth with no project selected, ensure a tab set exists
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (authState === "authenticated" && !activeProject) {
|
|
107
|
+
useTabStore.getState().switchProject("__global__");
|
|
108
|
+
}
|
|
109
|
+
}, [authState, activeProject]);
|
|
110
|
+
|
|
111
|
+
const handleLoginSuccess = useCallback(() => {
|
|
112
|
+
setAuthState("authenticated");
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
if (authState === "checking") {
|
|
116
|
+
return (
|
|
117
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
118
|
+
<div className="animate-pulse text-text-secondary text-sm">
|
|
119
|
+
Loading...
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (authState === "unauthenticated") {
|
|
126
|
+
return <LoginScreen onSuccess={handleLoginSuccess} />;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<TooltipProvider>
|
|
131
|
+
<div className="h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
|
132
|
+
{/* Main layout */}
|
|
133
|
+
<div className="flex flex-1 overflow-hidden">
|
|
134
|
+
{/* Desktop sidebar */}
|
|
135
|
+
<Sidebar />
|
|
136
|
+
|
|
137
|
+
{/* Content area */}
|
|
138
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
139
|
+
{/* Desktop tab bar */}
|
|
140
|
+
<TabBar />
|
|
141
|
+
|
|
142
|
+
{/* Tab content */}
|
|
143
|
+
<main className="flex-1 overflow-hidden pb-12 md:pb-0">
|
|
144
|
+
<TabContent />
|
|
145
|
+
</main>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Mobile bottom nav */}
|
|
150
|
+
<MobileNav onMenuPress={() => setDrawerOpen(true)} />
|
|
151
|
+
|
|
152
|
+
{/* Mobile drawer overlay */}
|
|
153
|
+
<MobileDrawer
|
|
154
|
+
isOpen={drawerOpen}
|
|
155
|
+
onClose={() => setDrawerOpen(false)}
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
{/* Toast notifications */}
|
|
159
|
+
<Toaster
|
|
160
|
+
position="bottom-left"
|
|
161
|
+
toastOptions={{
|
|
162
|
+
className: "bg-surface border-border text-foreground",
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
</TooltipProvider>
|
|
167
|
+
);
|
|
168
|
+
}
|