@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,152 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { FolderGit2, Loader2 } from "lucide-react";
3
+ import { api } from "@/lib/api-client";
4
+ import { Input } from "@/components/ui/input";
5
+
6
+ interface DirSuggestItem {
7
+ path: string;
8
+ name: string;
9
+ }
10
+
11
+ interface DirSuggestProps {
12
+ value: string;
13
+ onChange: (value: string) => void;
14
+ onSelect?: (item: DirSuggestItem) => void;
15
+ placeholder?: string;
16
+ autoFocus?: boolean;
17
+ }
18
+
19
+ export function DirSuggest({ value, onChange, onSelect, placeholder, autoFocus }: DirSuggestProps) {
20
+ const [allDirs, setAllDirs] = useState<DirSuggestItem[]>([]);
21
+ const [filtered, setFiltered] = useState<DirSuggestItem[]>([]);
22
+ const [showSuggestions, setShowSuggestions] = useState(false);
23
+ const [selectedIndex, setSelectedIndex] = useState(0);
24
+ const [loading, setLoading] = useState(false);
25
+ const listRef = useRef<HTMLDivElement>(null);
26
+ const fetchedRef = useRef(false);
27
+
28
+ // Fetch all git dirs once on mount (cached server-side for 5 min)
29
+ useEffect(() => {
30
+ if (fetchedRef.current) return;
31
+ fetchedRef.current = true;
32
+ setLoading(true);
33
+ api
34
+ .get<DirSuggestItem[]>("/api/projects/suggest-dirs")
35
+ .then((items) => {
36
+ setAllDirs(items);
37
+ setFiltered(items.slice(0, 50));
38
+ setShowSuggestions(items.length > 0);
39
+ })
40
+ .catch(() => setAllDirs([]))
41
+ .finally(() => setLoading(false));
42
+ }, []);
43
+
44
+ // Filter locally when value changes
45
+ useEffect(() => {
46
+ if (allDirs.length === 0) return;
47
+ const q = value.trim().toLowerCase();
48
+ if (!q) {
49
+ setFiltered(allDirs.slice(0, 50));
50
+ } else {
51
+ setFiltered(
52
+ allDirs
53
+ .filter((d) => d.path.toLowerCase().includes(q) || d.name.toLowerCase().includes(q))
54
+ .slice(0, 50),
55
+ );
56
+ }
57
+ setSelectedIndex(0);
58
+ }, [value, allDirs]);
59
+
60
+ // Scroll selected into view
61
+ useEffect(() => {
62
+ const list = listRef.current;
63
+ if (!list) return;
64
+ const selected = list.children[selectedIndex] as HTMLElement | undefined;
65
+ selected?.scrollIntoView({ block: "nearest" });
66
+ }, [selectedIndex]);
67
+
68
+ const handleSelect = useCallback(
69
+ (item: DirSuggestItem) => {
70
+ onChange(item.path);
71
+ onSelect?.(item);
72
+ setShowSuggestions(false);
73
+ },
74
+ [onChange, onSelect],
75
+ );
76
+
77
+ const handleKeyDown = useCallback(
78
+ (e: React.KeyboardEvent) => {
79
+ if (!showSuggestions || filtered.length === 0) return;
80
+
81
+ switch (e.key) {
82
+ case "ArrowDown":
83
+ e.preventDefault();
84
+ setSelectedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));
85
+ break;
86
+ case "ArrowUp":
87
+ e.preventDefault();
88
+ setSelectedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));
89
+ break;
90
+ case "Tab":
91
+ case "Enter":
92
+ if (filtered[selectedIndex]) {
93
+ e.preventDefault();
94
+ handleSelect(filtered[selectedIndex]);
95
+ }
96
+ break;
97
+ case "Escape":
98
+ e.preventDefault();
99
+ setShowSuggestions(false);
100
+ break;
101
+ }
102
+ },
103
+ [showSuggestions, filtered, selectedIndex, handleSelect],
104
+ );
105
+
106
+ return (
107
+ <div className="relative">
108
+ <div className="relative">
109
+ <Input
110
+ placeholder={placeholder ?? "/home/user/my-project"}
111
+ value={value}
112
+ onChange={(e) => onChange(e.target.value)}
113
+ onKeyDown={handleKeyDown}
114
+ onFocus={() => filtered.length > 0 && setShowSuggestions(true)}
115
+ onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
116
+ autoFocus={autoFocus}
117
+ />
118
+ {loading && (
119
+ <Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 size-4 text-text-subtle animate-spin" />
120
+ )}
121
+ </div>
122
+ {showSuggestions && filtered.length > 0 && (
123
+ <div className="absolute z-50 left-0 right-0 top-full mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-surface shadow-lg">
124
+ <div ref={listRef} className="py-1">
125
+ {filtered.map((item, i) => (
126
+ <button
127
+ key={item.path}
128
+ type="button"
129
+ className={`flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm transition-colors ${
130
+ i === selectedIndex
131
+ ? "bg-primary/10 text-primary"
132
+ : "hover:bg-surface-hover text-text-primary"
133
+ }`}
134
+ onMouseEnter={() => setSelectedIndex(i)}
135
+ onMouseDown={(e) => {
136
+ e.preventDefault();
137
+ handleSelect(item);
138
+ }}
139
+ >
140
+ <FolderGit2 className="size-4 text-green-500 shrink-0" />
141
+ <div className="min-w-0 flex-1 flex items-baseline gap-2">
142
+ <span className="font-medium">{item.name}</span>
143
+ <span className="text-xs text-text-subtle truncate">{item.path}</span>
144
+ </div>
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,187 @@
1
+ import { useEffect, useState, useCallback } from "react";
2
+ import { FolderOpen, GitBranch, Circle, Plus } from "lucide-react";
3
+ import { useProjectStore } from "@/stores/project-store";
4
+ import { useTabStore } from "@/stores/tab-store";
5
+ import { api } from "@/lib/api-client";
6
+ import { cn } from "@/lib/utils";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogFooter,
13
+ } from "@/components/ui/dialog";
14
+ import { Input } from "@/components/ui/input";
15
+ import { Button } from "@/components/ui/button";
16
+ import { DirSuggest } from "./dir-suggest";
17
+
18
+ export function ProjectList() {
19
+ const { projects, activeProject, setActiveProject, fetchProjects, loading, error } =
20
+ useProjectStore();
21
+ const openTab = useTabStore((s) => s.openTab);
22
+ const [showAdd, setShowAdd] = useState(false);
23
+ const [addPath, setAddPath] = useState("");
24
+ const [addName, setAddName] = useState("");
25
+ const [addError, setAddError] = useState("");
26
+
27
+ useEffect(() => {
28
+ fetchProjects();
29
+ }, [fetchProjects]);
30
+
31
+ function handleClick(project: (typeof projects)[number]) {
32
+ setActiveProject(project);
33
+ }
34
+
35
+ function handleOpen(project: (typeof projects)[number]) {
36
+ setActiveProject(project);
37
+ openTab({
38
+ type: "terminal",
39
+ title: `Terminal - ${project.name}`,
40
+ metadata: { projectName: project.name },
41
+ projectId: project.name,
42
+ closable: true,
43
+ });
44
+ }
45
+
46
+ const handleAddProject = useCallback(async () => {
47
+ if (!addPath.trim()) return;
48
+ setAddError("");
49
+ try {
50
+ await api.post("/api/projects", {
51
+ path: addPath.trim(),
52
+ name: addName.trim() || undefined,
53
+ });
54
+ await fetchProjects();
55
+ setShowAdd(false);
56
+ setAddPath("");
57
+ setAddName("");
58
+ } catch (e) {
59
+ setAddError(e instanceof Error ? e.message : "Failed to add project");
60
+ }
61
+ }, [addPath, addName, fetchProjects]);
62
+
63
+ if (error) {
64
+ return (
65
+ <div className="flex items-center justify-center h-full p-4">
66
+ <p className="text-error text-sm">{error}</p>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div className="h-full p-4 space-y-4 overflow-auto">
73
+ <div className="flex items-center justify-between">
74
+ <h2 className="text-lg font-semibold">Projects</h2>
75
+ <Button
76
+ variant="outline"
77
+ size="sm"
78
+ onClick={() => setShowAdd(true)}
79
+ className="gap-1.5"
80
+ >
81
+ <Plus className="size-4" />
82
+ Add Project
83
+ </Button>
84
+ </div>
85
+
86
+ {loading && (
87
+ <p className="text-text-secondary text-sm">Loading projects...</p>
88
+ )}
89
+
90
+ {!loading && projects.length === 0 && (
91
+ <div className="text-center py-8 space-y-2">
92
+ <FolderOpen className="size-10 mx-auto text-text-subtle" />
93
+ <p className="text-text-secondary text-sm">No projects registered</p>
94
+ <p className="text-text-subtle text-xs">
95
+ Click "Add Project" or use <code className="font-mono bg-surface-elevated px-1 py-0.5 rounded">ppm projects add &lt;path&gt;</code>
96
+ </p>
97
+ </div>
98
+ )}
99
+
100
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
101
+ {projects.map((project) => (
102
+ <button
103
+ key={project.name}
104
+ onClick={() => handleClick(project)}
105
+ onDoubleClick={() => handleOpen(project)}
106
+ className={cn(
107
+ "text-left p-4 rounded-lg border transition-colors",
108
+ "min-h-[44px]",
109
+ activeProject?.name === project.name
110
+ ? "bg-surface border-primary"
111
+ : "bg-surface border-border hover:border-text-subtle",
112
+ )}
113
+ >
114
+ <div className="flex items-start gap-3">
115
+ <FolderOpen className="size-5 text-primary shrink-0 mt-0.5" />
116
+ <div className="flex-1 min-w-0 space-y-1">
117
+ <p className="font-medium truncate">{project.name}</p>
118
+ <p className="text-xs text-text-secondary truncate">
119
+ {project.path}
120
+ </p>
121
+ {project.branch && (
122
+ <div className="flex items-center gap-1.5 text-xs text-text-secondary">
123
+ <GitBranch className="size-3" />
124
+ <span>{project.branch}</span>
125
+ {project.status && (
126
+ <Circle
127
+ className={cn(
128
+ "size-2 fill-current",
129
+ project.status === "clean"
130
+ ? "text-success"
131
+ : "text-warning",
132
+ )}
133
+ />
134
+ )}
135
+ </div>
136
+ )}
137
+ </div>
138
+ </div>
139
+ </button>
140
+ ))}
141
+ </div>
142
+
143
+ {/* Add Project Dialog */}
144
+ <Dialog open={showAdd} onOpenChange={setShowAdd}>
145
+ <DialogContent>
146
+ <DialogHeader>
147
+ <DialogTitle>Add Project</DialogTitle>
148
+ </DialogHeader>
149
+ <div className="space-y-3">
150
+ <div>
151
+ <label className="text-sm text-text-secondary">Path (required)</label>
152
+ <DirSuggest
153
+ value={addPath}
154
+ onChange={setAddPath}
155
+ onSelect={(item) => {
156
+ if (!addName.trim()) setAddName(item.name);
157
+ }}
158
+ placeholder="/home/user/my-project"
159
+ autoFocus
160
+ />
161
+ </div>
162
+ <div>
163
+ <label className="text-sm text-text-secondary">Name (optional)</label>
164
+ <Input
165
+ placeholder="Auto-detected from folder name"
166
+ value={addName}
167
+ onChange={(e) => setAddName(e.target.value)}
168
+ onKeyDown={(e) => e.key === "Enter" && handleAddProject()}
169
+ />
170
+ </div>
171
+ {addError && (
172
+ <p className="text-sm text-error">{addError}</p>
173
+ )}
174
+ </div>
175
+ <DialogFooter>
176
+ <Button variant="outline" onClick={() => setShowAdd(false)}>
177
+ Cancel
178
+ </Button>
179
+ <Button onClick={handleAddProject} disabled={!addPath.trim()}>
180
+ Add
181
+ </Button>
182
+ </DialogFooter>
183
+ </DialogContent>
184
+ </Dialog>
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,57 @@
1
+ import { Moon, Sun, Monitor } from "lucide-react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Separator } from "@/components/ui/separator";
4
+ import { useSettingsStore, type Theme } from "@/stores/settings-store";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
8
+ { value: "light", label: "Light", icon: Sun },
9
+ { value: "dark", label: "Dark", icon: Moon },
10
+ { value: "system", label: "System", icon: Monitor },
11
+ ];
12
+
13
+ export function SettingsTab() {
14
+ const { theme, setTheme } = useSettingsStore();
15
+
16
+ return (
17
+ <div className="h-full p-4 space-y-6 overflow-auto max-w-lg">
18
+ <h2 className="text-lg font-semibold">Settings</h2>
19
+
20
+ <div className="space-y-3">
21
+ <h3 className="text-sm font-medium text-text-secondary">Theme</h3>
22
+ <div className="flex gap-2">
23
+ {THEME_OPTIONS.map((opt) => {
24
+ const Icon = opt.icon;
25
+ return (
26
+ <Button
27
+ key={opt.value}
28
+ variant={theme === opt.value ? "default" : "outline"}
29
+ size="lg"
30
+ onClick={() => setTheme(opt.value)}
31
+ className={cn(
32
+ "flex-1 gap-2",
33
+ theme === opt.value && "ring-2 ring-primary",
34
+ )}
35
+ >
36
+ <Icon className="size-4" />
37
+ {opt.label}
38
+ </Button>
39
+ );
40
+ })}
41
+ </div>
42
+ </div>
43
+
44
+ <Separator />
45
+
46
+ <div className="space-y-3">
47
+ <h3 className="text-sm font-medium text-text-secondary">About</h3>
48
+ <p className="text-sm text-text-secondary">
49
+ PPM — Personal Project Manager
50
+ </p>
51
+ <p className="text-xs text-text-subtle">
52
+ A mobile-first web IDE for managing your projects.
53
+ </p>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,10 @@
1
+ import { Terminal } from "lucide-react";
2
+
3
+ export function TerminalPlaceholder() {
4
+ return (
5
+ <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
6
+ <Terminal className="size-10 text-text-subtle" />
7
+ <p className="text-sm">Terminal — coming in Phase 5</p>
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,133 @@
1
+ import { useRef, useEffect, useState, useCallback } from "react";
2
+ import { useTerminal } from "@/hooks/use-terminal";
3
+ import { cn } from "@/lib/utils";
4
+ import "@xterm/xterm/css/xterm.css";
5
+
6
+ interface TerminalTabProps {
7
+ metadata?: Record<string, unknown>;
8
+ }
9
+
10
+ const MOBILE_KEYS = [
11
+ { label: "Tab", value: "\t" },
12
+ { label: "Esc", value: "\x1b" },
13
+ { label: "Ctrl", value: null, isModifier: true },
14
+ { label: "\u2191", value: "\x1b[A" },
15
+ { label: "\u2193", value: "\x1b[B" },
16
+ { label: "\u2190", value: "\x1b[D" },
17
+ { label: "\u2192", value: "\x1b[C" },
18
+ ] as const;
19
+
20
+ export function TerminalTab({ metadata }: TerminalTabProps) {
21
+ const sessionId = (metadata?.sessionId as string) ?? "new";
22
+ const projectName = metadata?.projectName as string | undefined;
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+ const { connected, reconnecting } = useTerminal({ sessionId, projectName, containerRef });
25
+ const [ctrlMode, setCtrlMode] = useState(false);
26
+ const [viewportHeight, setViewportHeight] = useState<number | null>(null);
27
+
28
+ // Adjust height when mobile keyboard opens
29
+ useEffect(() => {
30
+ const vv = window.visualViewport;
31
+ if (!vv) return;
32
+
33
+ function handleResize() {
34
+ if (!vv) return;
35
+ setViewportHeight(vv.height);
36
+ }
37
+
38
+ vv.addEventListener("resize", handleResize);
39
+ return () => vv.removeEventListener("resize", handleResize);
40
+ }, []);
41
+
42
+ const sendKey = useCallback(
43
+ (value: string) => {
44
+ // Access the terminal container's xterm instance indirectly via the WS
45
+ // The useTerminal hook handles this — we need to dispatch to the terminal
46
+ const termElement = containerRef.current?.querySelector(
47
+ ".xterm-helper-textarea",
48
+ ) as HTMLTextAreaElement | null;
49
+
50
+ if (termElement) {
51
+ termElement.focus();
52
+ }
53
+
54
+ // For Ctrl combos, we rely on the terminal processing keystrokes
55
+ // For direct chars, dispatch input event
56
+ if (ctrlMode && value.length === 1) {
57
+ // Ctrl+key: send char code 1-26 for a-z
58
+ const code = value.toLowerCase().charCodeAt(0) - 96;
59
+ if (code >= 1 && code <= 26) {
60
+ // The terminal onData handler in useTerminal sends to WS
61
+ const event = new KeyboardEvent("keydown", {
62
+ key: value,
63
+ ctrlKey: true,
64
+ bubbles: true,
65
+ });
66
+ termElement?.dispatchEvent(event);
67
+ }
68
+ setCtrlMode(false);
69
+ return;
70
+ }
71
+
72
+ // For simple values, use input event approach by focusing textarea
73
+ // xterm handles the rest
74
+ },
75
+ [ctrlMode],
76
+ );
77
+
78
+ const isMobile = typeof window !== "undefined" && "ontouchstart" in window;
79
+
80
+ return (
81
+ <div
82
+ className="flex flex-col h-full"
83
+ style={viewportHeight ? { maxHeight: `${viewportHeight}px` } : undefined}
84
+ >
85
+ {/* Status bar */}
86
+ <div className="flex items-center gap-2 px-3 py-1 bg-surface border-b border-border text-xs">
87
+ <span
88
+ className={cn(
89
+ "size-2 rounded-full",
90
+ connected ? "bg-success" : reconnecting ? "bg-warning" : "bg-error",
91
+ )}
92
+ />
93
+ <span className="text-text-secondary">
94
+ {connected
95
+ ? "Connected"
96
+ : reconnecting
97
+ ? "Reconnecting..."
98
+ : "Disconnected"}
99
+ </span>
100
+ <span className="text-text-subtle ml-auto font-mono">{sessionId}</span>
101
+ </div>
102
+
103
+ {/* Terminal container */}
104
+ <div ref={containerRef} className="flex-1 min-h-0 bg-background p-1" />
105
+
106
+ {/* Mobile toolbar */}
107
+ {isMobile && (
108
+ <div className="flex items-center gap-1 px-2 py-1.5 bg-surface border-t border-border overflow-x-auto">
109
+ {MOBILE_KEYS.map((key) => (
110
+ <button
111
+ key={key.label}
112
+ onClick={() => {
113
+ if (key.label === "Ctrl") {
114
+ setCtrlMode(!ctrlMode);
115
+ } else if (key.value) {
116
+ sendKey(key.value);
117
+ }
118
+ }}
119
+ className={cn(
120
+ "px-3 py-1.5 rounded text-xs font-mono min-w-[36px] min-h-[32px]",
121
+ "bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground",
122
+ "transition-colors select-none",
123
+ key.label === "Ctrl" && ctrlMode && "bg-primary text-primary-foreground",
124
+ )}
125
+ >
126
+ {key.label}
127
+ </button>
128
+ ))}
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,64 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { Slot } from "radix-ui"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
15
+ outline:
16
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost:
20
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
25
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
30
+ "icon-sm": "size-8",
31
+ "icon-lg": "size-10",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ size: "default",
37
+ },
38
+ }
39
+ )
40
+
41
+ function Button({
42
+ className,
43
+ variant = "default",
44
+ size = "default",
45
+ asChild = false,
46
+ ...props
47
+ }: React.ComponentProps<"button"> &
48
+ VariantProps<typeof buttonVariants> & {
49
+ asChild?: boolean
50
+ }) {
51
+ const Comp = asChild ? Slot.Root : "button"
52
+
53
+ return (
54
+ <Comp
55
+ data-slot="button"
56
+ data-variant={variant}
57
+ data-size={size}
58
+ className={cn(buttonVariants({ variant, size, className }))}
59
+ {...props}
60
+ />
61
+ )
62
+ }
63
+
64
+ export { Button, buttonVariants }