@hienlh/ppm 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CLAUDE.md +45 -0
  2. package/bun.lock +55 -0
  3. package/dist/ppm +0 -0
  4. package/dist/web/assets/api-client-BgVufYKf.js +1 -0
  5. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
  6. package/dist/web/assets/button-KIZetva8.js +41 -0
  7. package/dist/web/assets/chat-tab-D7dR7kbZ.js +6 -0
  8. package/dist/web/assets/code-editor-r8P6Gk4M.js +2 -0
  9. package/dist/web/assets/copy-B-kLwqzg.js +1 -0
  10. package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
  11. package/dist/web/assets/diff-viewer-vSvrem_i.js +4 -0
  12. package/dist/web/assets/dist-C4W3AGh3.js +1 -0
  13. package/dist/web/assets/dist-PA84y4Ga.js +1 -0
  14. package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
  15. package/dist/web/assets/git-graph-Cn-s1k0-.js +1 -0
  16. package/dist/web/assets/git-status-panel-QjAQzNAi.js +1 -0
  17. package/dist/web/assets/index-DUBI96T5.css +2 -0
  18. package/dist/web/assets/index-nk1dAWff.js +10 -0
  19. package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
  20. package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
  21. package/dist/web/assets/project-list-DqiatpaH.js +1 -0
  22. package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
  23. package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
  24. package/dist/web/assets/settings-tab-iCGeFFdt.js +1 -0
  25. package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
  26. package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
  27. package/dist/web/assets/x-BxhOxZ5p.js +1 -0
  28. package/dist/web/index.html +11 -10
  29. package/dist/web/sw.js +1 -1
  30. package/docs/claude-agent-sdk-reference.md +780 -0
  31. package/docs/code-standards.md +74 -0
  32. package/docs/codebase-summary.md +22 -20
  33. package/docs/deployment-guide.md +81 -11
  34. package/docs/lessons-learned.md +58 -0
  35. package/docs/project-overview-pdr.md +62 -2
  36. package/docs/system-architecture.md +102 -10
  37. package/package.json +4 -1
  38. package/schemas/ppm-config.schema.json +87 -0
  39. package/src/cli/commands/init.ts +186 -43
  40. package/src/cli/commands/status.ts +73 -0
  41. package/src/cli/commands/stop.ts +24 -10
  42. package/src/index.ts +28 -5
  43. package/src/providers/claude-agent-sdk.ts +84 -3
  44. package/src/providers/registry.ts +0 -2
  45. package/src/server/index.ts +106 -15
  46. package/src/server/routes/settings.ts +70 -0
  47. package/src/server/ws/chat.ts +8 -6
  48. package/src/services/cloudflared.service.ts +99 -0
  49. package/src/services/git.service.ts +23 -1
  50. package/src/services/tunnel.service.ts +100 -0
  51. package/src/types/chat.ts +8 -1
  52. package/src/types/config.ts +50 -3
  53. package/src/web/app.tsx +10 -2
  54. package/src/web/components/auth/login-screen.tsx +1 -1
  55. package/src/web/components/chat/message-input.tsx +1 -1
  56. package/src/web/components/chat/message-list.tsx +112 -251
  57. package/src/web/components/chat/tool-cards.tsx +411 -0
  58. package/src/web/components/editor/code-editor.tsx +80 -20
  59. package/src/web/components/editor/diff-viewer.tsx +72 -7
  60. package/src/web/components/git/git-graph.tsx +3 -0
  61. package/src/web/components/git/git-status-panel.tsx +50 -1
  62. package/src/web/components/layout/command-palette.tsx +215 -0
  63. package/src/web/components/layout/mobile-drawer.tsx +143 -42
  64. package/src/web/components/layout/sidebar.tsx +103 -67
  65. package/src/web/components/layout/tab-bar.tsx +1 -2
  66. package/src/web/components/settings/ai-settings-section.tsx +166 -0
  67. package/src/web/components/settings/settings-tab.tsx +5 -0
  68. package/src/web/components/terminal/terminal-tab.tsx +45 -22
  69. package/src/web/components/ui/input.tsx +4 -3
  70. package/src/web/components/ui/label.tsx +24 -0
  71. package/src/web/components/ui/select.tsx +188 -0
  72. package/src/web/hooks/use-global-keybindings.ts +56 -0
  73. package/src/web/hooks/use-terminal.ts +14 -1
  74. package/src/web/lib/api-settings.ts +24 -0
  75. package/src/web/stores/project-store.ts +47 -2
  76. package/src/web/stores/tab-store.ts +1 -1
  77. package/src/web/styles/globals.css +20 -6
  78. package/test-tool.mjs +41 -0
  79. package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
  80. package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
  81. package/dist/web/assets/button-DxRZgE8F.js +0 -1
  82. package/dist/web/assets/chat-tab-p2mwkdec.js +0 -61
  83. package/dist/web/assets/code-editor-vMRyRKV3.js +0 -2
  84. package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
  85. package/dist/web/assets/dialog-Db6prp1p.js +0 -45
  86. package/dist/web/assets/diff-viewer-BdDje3Wr.js +0 -4
  87. package/dist/web/assets/external-link-WSiY-639.js +0 -1
  88. package/dist/web/assets/git-graph-B-qwuFoO.js +0 -1
  89. package/dist/web/assets/git-status-panel-NkZFb5v1.js +0 -1
  90. package/dist/web/assets/index-BHEFCU01.js +0 -10
  91. package/dist/web/assets/index-DYd_2slk.css +0 -2
  92. package/dist/web/assets/project-list-NkR7IHT5.js +0 -1
  93. package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
  94. package/dist/web/assets/settings-tab-DKx0s3Q1.js +0 -1
  95. package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
  96. package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
  97. package/dist/web/assets/x-BISR7bpK.js +0 -1
  98. package/src/providers/claude-binary-finder.ts +0 -256
  99. package/src/providers/claude-code-cli.ts +0 -413
  100. package/src/providers/claude-process-registry.ts +0 -106
  101. /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
  102. /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
@@ -1,89 +1,125 @@
1
- import { FolderOpen, ChevronRight, ChevronDown } from "lucide-react";
2
- import { useProjectStore } from "@/stores/project-store";
1
+ import { useState, useMemo } from "react";
2
+ import { FolderOpen, ChevronDown, Check, Plus, Search } from "lucide-react";
3
+ import { useProjectStore, sortByRecent } from "@/stores/project-store";
3
4
  import { useTabStore } from "@/stores/tab-store";
4
- import { cn } from "@/lib/utils";
5
5
  import { FileTree } from "@/components/explorer/file-tree";
6
- import { Separator } from "@/components/ui/separator";
7
- import { useState } from "react";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ } from "@/components/ui/dropdown-menu";
12
+ import { cn } from "@/lib/utils";
13
+
14
+ /** Max projects shown before needing to search (desktop) */
15
+ const MAX_VISIBLE = 8;
8
16
 
9
17
  export function Sidebar() {
10
18
  const { projects, activeProject, setActiveProject, loading } =
11
19
  useProjectStore();
12
20
  const openTab = useTabStore((s) => s.openTab);
13
- const [projectsExpanded, setProjectsExpanded] = useState(true);
21
+ const [query, setQuery] = useState("");
22
+
23
+ const sorted = useMemo(() => sortByRecent(projects), [projects]);
24
+
25
+ const filtered = useMemo(() => {
26
+ if (!query.trim()) return sorted.slice(0, MAX_VISIBLE);
27
+ const q = query.toLowerCase();
28
+ return sorted.filter(
29
+ (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
30
+ );
31
+ }, [sorted, query]);
14
32
 
15
- function handleProjectClick(project: (typeof projects)[number]) {
16
- setActiveProject(project);
33
+ const showSearch = projects.length > MAX_VISIBLE || query.length > 0;
34
+
35
+ function handleAddProject() {
36
+ openTab({ type: "projects", title: "Projects", projectId: null, closable: true });
17
37
  }
18
38
 
19
39
  return (
20
- <aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-y-auto">
21
- {/* Projects section header */}
22
- <button
23
- onClick={() => setProjectsExpanded(!projectsExpanded)}
24
- className="flex items-center gap-2 px-4 py-3 border-b border-border hover:bg-surface-elevated transition-colors"
25
- >
26
- {projectsExpanded ? (
27
- <ChevronDown className="size-3.5 text-text-subtle" />
28
- ) : (
29
- <ChevronRight className="size-3.5 text-text-subtle" />
30
- )}
31
- <FolderOpen className="size-4 text-primary" />
32
- <span className="text-sm font-semibold">Projects</span>
33
- </button>
34
-
35
- {/* Projects list (collapsible) */}
36
- {projectsExpanded && (
37
- <div className="p-2 space-y-1">
38
- {loading && (
39
- <p className="px-2 py-1 text-xs text-text-secondary">Loading...</p>
40
- )}
40
+ <aside className="hidden md:flex flex-col w-[280px] min-w-[280px] bg-background border-r border-border overflow-hidden">
41
+ {/* Logo + project dropdown — same height as tab bar */}
42
+ <div className="flex items-center gap-2 px-3 h-[41px] border-b border-border shrink-0">
43
+ <span className="text-sm font-bold text-primary tracking-tight shrink-0">PPM</span>
41
44
 
42
- {!loading && projects.length === 0 && (
43
- <p className="px-2 py-1 text-xs text-text-secondary">
44
- No projects found. Register one via CLI.
45
- </p>
46
- )}
45
+ <DropdownMenu onOpenChange={() => setQuery("")}>
46
+ <DropdownMenuTrigger asChild>
47
+ <button className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-surface-elevated transition-colors min-w-0 flex-1">
48
+ <FolderOpen className="size-3.5 text-text-subtle shrink-0" />
49
+ <span className="text-sm truncate flex-1 text-left">
50
+ {activeProject?.name ?? "Select Project"}
51
+ </span>
52
+ <ChevronDown className="size-3 text-text-subtle shrink-0" />
53
+ </button>
54
+ </DropdownMenuTrigger>
55
+ <DropdownMenuContent align="start" className="w-[360px] p-0">
56
+ {/* Search — only when many projects */}
57
+ {showSearch && (
58
+ <div className="flex items-center gap-2 px-2.5 py-2 border-b border-border">
59
+ <Search className="size-3.5 text-text-subtle shrink-0" />
60
+ <input
61
+ type="text"
62
+ value={query}
63
+ onChange={(e) => setQuery(e.target.value)}
64
+ placeholder="Search projects..."
65
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-text-subtle text-text-primary"
66
+ autoFocus
67
+ />
68
+ </div>
69
+ )}
47
70
 
48
- {projects.map((project) => (
49
- <button
50
- key={project.name}
51
- onClick={() => handleProjectClick(project)}
52
- className={cn(
53
- "w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors text-left",
54
- "min-h-[44px]",
55
- activeProject?.name === project.name
56
- ? "bg-surface text-foreground"
57
- : "text-text-secondary hover:bg-surface-elevated hover:text-foreground",
71
+ {/* Project list */}
72
+ <div className="max-h-64 overflow-y-auto py-1">
73
+ {loading && (
74
+ <p className="px-3 py-1.5 text-xs text-text-secondary">Loading...</p>
58
75
  )}
59
- >
60
- <FolderOpen className="size-4 shrink-0" />
61
- <div className="flex-1 min-w-0">
62
- <p className="truncate font-medium">{project.name}</p>
63
- <p className="truncate text-xs text-text-subtle">
64
- {project.path}
76
+ {!loading && filtered.length === 0 && (
77
+ <p className="px-3 py-2 text-xs text-text-subtle text-center">
78
+ {query ? "No matches" : "No projects"}
65
79
  </p>
66
- </div>
67
- {project.branch && (
68
- <span className="text-xs text-primary shrink-0">
69
- {project.branch}
70
- </span>
71
80
  )}
72
- <ChevronRight className="size-3 text-text-subtle shrink-0" />
81
+ {filtered.map((project) => (
82
+ <button
83
+ key={project.name}
84
+ onClick={() => setActiveProject(project)}
85
+ className={cn(
86
+ "w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors hover:bg-surface-elevated",
87
+ activeProject?.name === project.name && "bg-accent/10",
88
+ )}
89
+ >
90
+ <FolderOpen className="size-3.5 shrink-0 text-text-subtle" />
91
+ <span className="truncate font-semibold text-text-primary">{project.name}</span>
92
+ <span className="truncate text-xs text-text-subtle ml-auto">{project.path}</span>
93
+ {activeProject?.name === project.name && (
94
+ <Check className="size-3.5 text-primary shrink-0" />
95
+ )}
96
+ </button>
97
+ ))}
98
+ </div>
99
+
100
+ <DropdownMenuSeparator className="my-0" />
101
+ <button
102
+ onClick={handleAddProject}
103
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-elevated transition-colors"
104
+ >
105
+ <Plus className="size-3.5 shrink-0" />
106
+ <span>Add Project...</span>
73
107
  </button>
74
- ))}
75
- </div>
76
- )}
108
+ </DropdownMenuContent>
109
+ </DropdownMenu>
110
+ </div>
77
111
 
78
- {/* File tree section */}
79
- {activeProject && (
80
- <>
81
- <Separator />
82
- <div className="flex items-center gap-2 px-4 py-2 text-xs font-semibold text-text-secondary uppercase tracking-wider">
83
- Files
84
- </div>
112
+ {/* File tree */}
113
+ {activeProject ? (
114
+ <div className="flex-1 overflow-y-auto">
85
115
  <FileTree />
86
- </>
116
+ </div>
117
+ ) : (
118
+ <div className="flex-1 flex items-center justify-center p-4">
119
+ <p className="text-xs text-text-subtle text-center">
120
+ Select a project to browse files
121
+ </p>
122
+ </div>
87
123
  )}
88
124
  </aside>
89
125
  );
@@ -34,7 +34,6 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
34
34
  };
35
35
 
36
36
  const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
37
- { type: "projects", label: "Projects" },
38
37
  { type: "terminal", label: "Terminal" },
39
38
  { type: "chat", label: "AI Chat" },
40
39
  { type: "git-graph", label: "Git Graph" },
@@ -76,7 +75,7 @@ export function TabBar() {
76
75
  }
77
76
 
78
77
  return (
79
- <div className="hidden md:flex items-center border-b border-border bg-background">
78
+ <div className="hidden md:flex items-center h-[41px] border-b border-border bg-background">
80
79
  <ScrollArea className="flex-1">
81
80
  <div className="flex items-center gap-0.5 px-2 py-1">
82
81
  {tabs.map((tab) => {
@@ -0,0 +1,166 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Input } from "@/components/ui/input";
3
+ import { Label } from "@/components/ui/label";
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from "@/components/ui/select";
11
+ import { getAISettings, updateAISettings, type AISettings } from "@/lib/api-settings";
12
+
13
+ const MODEL_OPTIONS = [
14
+ { value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
15
+ { value: "claude-opus-4-6", label: "Claude Opus 4.6" },
16
+ { value: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
17
+ ];
18
+
19
+ const EFFORT_OPTIONS = [
20
+ { value: "low", label: "Low" },
21
+ { value: "medium", label: "Medium" },
22
+ { value: "high", label: "High" },
23
+ { value: "max", label: "Max" },
24
+ ];
25
+
26
+ export function AISettingsSection() {
27
+ const [settings, setSettings] = useState<AISettings | null>(null);
28
+ const [saving, setSaving] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+ // Revision counter forces number inputs to re-render with fresh defaultValue after save
31
+ const [revision, setRevision] = useState(0);
32
+
33
+ useEffect(() => {
34
+ getAISettings().then(setSettings).catch((e) => setError(e.message));
35
+ }, []);
36
+
37
+ const providerName = settings?.default_provider ?? "claude";
38
+ const config = settings?.providers[providerName];
39
+
40
+ const handleSave = async (field: string, value: unknown) => {
41
+ if (!settings) return;
42
+ setSaving(true);
43
+ setError(null);
44
+ try {
45
+ const updated = await updateAISettings({
46
+ providers: { [providerName]: { [field]: value } },
47
+ });
48
+ setSettings(updated);
49
+ setRevision((r) => r + 1);
50
+ } catch (e) {
51
+ setError((e as Error).message);
52
+ } finally {
53
+ setSaving(false);
54
+ }
55
+ };
56
+
57
+ if (!settings) {
58
+ return (
59
+ <div className="space-y-3">
60
+ <h3 className="text-sm font-medium text-text-secondary">AI Provider</h3>
61
+ <p className="text-sm text-text-subtle">
62
+ {error ? `Error: ${error}` : "Loading..."}
63
+ </p>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div className="space-y-4">
70
+ <h3 className="text-sm font-medium text-text-secondary">AI Provider</h3>
71
+
72
+ <div className="space-y-3">
73
+ <div className="space-y-1.5">
74
+ <Label htmlFor="ai-model">Model</Label>
75
+ <Select
76
+ value={config?.model ?? "claude-sonnet-4-6"}
77
+ onValueChange={(v) => handleSave("model", v)}
78
+ >
79
+ <SelectTrigger id="ai-model" className="w-full">
80
+ <SelectValue />
81
+ </SelectTrigger>
82
+ <SelectContent>
83
+ {MODEL_OPTIONS.map((opt) => (
84
+ <SelectItem key={opt.value} value={opt.value}>
85
+ {opt.label}
86
+ </SelectItem>
87
+ ))}
88
+ </SelectContent>
89
+ </Select>
90
+ </div>
91
+
92
+ <div className="space-y-1.5">
93
+ <Label htmlFor="ai-effort">Effort</Label>
94
+ <Select
95
+ value={config?.effort ?? "high"}
96
+ onValueChange={(v) => handleSave("effort", v)}
97
+ >
98
+ <SelectTrigger id="ai-effort" className="w-full">
99
+ <SelectValue />
100
+ </SelectTrigger>
101
+ <SelectContent>
102
+ {EFFORT_OPTIONS.map((opt) => (
103
+ <SelectItem key={opt.value} value={opt.value}>
104
+ {opt.label}
105
+ </SelectItem>
106
+ ))}
107
+ </SelectContent>
108
+ </Select>
109
+ </div>
110
+
111
+ <div className="space-y-1.5">
112
+ <Label htmlFor="ai-max-turns">Max Turns (1-500)</Label>
113
+ <Input
114
+ key={`turns-${revision}`}
115
+ id="ai-max-turns"
116
+ type="number"
117
+ min={1}
118
+ max={500}
119
+ defaultValue={config?.max_turns ?? 100}
120
+ onBlur={(e) => {
121
+ const val = parseInt(e.target.value);
122
+ if (!isNaN(val)) handleSave("max_turns", val);
123
+ }}
124
+ />
125
+ </div>
126
+
127
+ <div className="space-y-1.5">
128
+ <Label htmlFor="ai-budget">Max Budget (USD)</Label>
129
+ <Input
130
+ key={`budget-${revision}`}
131
+ id="ai-budget"
132
+ type="number"
133
+ step={0.1}
134
+ min={0.01}
135
+ max={50}
136
+ defaultValue={config?.max_budget_usd ?? ""}
137
+ placeholder="No limit"
138
+ onBlur={(e) => {
139
+ const val = parseFloat(e.target.value);
140
+ handleSave("max_budget_usd", isNaN(val) ? undefined : val);
141
+ }}
142
+ />
143
+ </div>
144
+
145
+ <div className="space-y-1.5">
146
+ <Label htmlFor="ai-thinking">Thinking Budget (tokens)</Label>
147
+ <Input
148
+ key={`thinking-${revision}`}
149
+ id="ai-thinking"
150
+ type="number"
151
+ min={0}
152
+ defaultValue={config?.thinking_budget_tokens ?? ""}
153
+ placeholder="Disabled"
154
+ onBlur={(e) => {
155
+ const val = parseInt(e.target.value);
156
+ handleSave("thinking_budget_tokens", isNaN(val) ? undefined : val);
157
+ }}
158
+ />
159
+ </div>
160
+ </div>
161
+
162
+ {saving && <p className="text-xs text-text-subtle">Saving...</p>}
163
+ {error && <p className="text-xs text-red-500">{error}</p>}
164
+ </div>
165
+ );
166
+ }
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
3
3
  import { Separator } from "@/components/ui/separator";
4
4
  import { useSettingsStore, type Theme } from "@/stores/settings-store";
5
5
  import { cn } from "@/lib/utils";
6
+ import { AISettingsSection } from "./ai-settings-section";
6
7
 
7
8
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
8
9
  { value: "light", label: "Light", icon: Sun },
@@ -43,6 +44,10 @@ export function SettingsTab() {
43
44
 
44
45
  <Separator />
45
46
 
47
+ <AISettingsSection />
48
+
49
+ <Separator />
50
+
46
51
  <div className="space-y-3">
47
52
  <h3 className="text-sm font-medium text-text-secondary">About</h3>
48
53
  <p className="text-sm text-text-secondary">
@@ -1,6 +1,7 @@
1
1
  import { useRef, useEffect, useState, useCallback } from "react";
2
2
  import { useTerminal } from "@/hooks/use-terminal";
3
3
  import { cn } from "@/lib/utils";
4
+ import { Copy, ClipboardPaste } from "lucide-react";
4
5
  import "@xterm/xterm/css/xterm.css";
5
6
 
6
7
  interface TerminalTabProps {
@@ -21,7 +22,7 @@ export function TerminalTab({ metadata }: TerminalTabProps) {
21
22
  const sessionId = (metadata?.sessionId as string) ?? "new";
22
23
  const projectName = metadata?.projectName as string | undefined;
23
24
  const containerRef = useRef<HTMLDivElement>(null);
24
- const { connected, reconnecting } = useTerminal({ sessionId, projectName, containerRef });
25
+ const { connected, reconnecting, sendData, getSelection } = useTerminal({ sessionId, projectName, containerRef });
25
26
  const [ctrlMode, setCtrlMode] = useState(false);
26
27
  const [viewportHeight, setViewportHeight] = useState<number | null>(null);
27
28
 
@@ -39,42 +40,51 @@ export function TerminalTab({ metadata }: TerminalTabProps) {
39
40
  return () => vv.removeEventListener("resize", handleResize);
40
41
  }, []);
41
42
 
43
+ const focusTerminal = useCallback(() => {
44
+ const termElement = containerRef.current?.querySelector(
45
+ ".xterm-helper-textarea",
46
+ ) as HTMLTextAreaElement | null;
47
+ termElement?.focus();
48
+ }, []);
49
+
42
50
  const sendKey = useCallback(
43
51
  (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
- }
52
+ focusTerminal();
53
53
 
54
- // For Ctrl combos, we rely on the terminal processing keystrokes
55
- // For direct chars, dispatch input event
56
54
  if (ctrlMode && value.length === 1) {
57
55
  // Ctrl+key: send char code 1-26 for a-z
58
56
  const code = value.toLowerCase().charCodeAt(0) - 96;
59
57
  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);
58
+ sendData(String.fromCharCode(code));
67
59
  }
68
60
  setCtrlMode(false);
69
61
  return;
70
62
  }
71
63
 
72
- // For simple values, use input event approach by focusing textarea
73
- // xterm handles the rest
64
+ sendData(value);
74
65
  },
75
- [ctrlMode],
66
+ [ctrlMode, sendData, focusTerminal],
76
67
  );
77
68
 
69
+ const handleCopy = useCallback(async () => {
70
+ const selection = getSelection();
71
+ if (selection) {
72
+ await navigator.clipboard.writeText(selection);
73
+ }
74
+ }, [getSelection]);
75
+
76
+ const handlePaste = useCallback(async () => {
77
+ try {
78
+ const text = await navigator.clipboard.readText();
79
+ if (text) {
80
+ sendData(text);
81
+ focusTerminal();
82
+ }
83
+ } catch {
84
+ // Clipboard permission denied
85
+ }
86
+ }, [sendData, focusTerminal]);
87
+
78
88
  const isMobile = typeof window !== "undefined" && "ontouchstart" in window;
79
89
 
80
90
  return (
@@ -106,6 +116,19 @@ export function TerminalTab({ metadata }: TerminalTabProps) {
106
116
  {/* Mobile toolbar */}
107
117
  {isMobile && (
108
118
  <div className="flex items-center gap-1 px-2 py-1.5 bg-surface border-t border-border overflow-x-auto">
119
+ <button
120
+ onClick={handleCopy}
121
+ className="px-2 py-1.5 rounded text-xs min-w-[36px] min-h-[32px] bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground transition-colors select-none"
122
+ >
123
+ <Copy size={14} />
124
+ </button>
125
+ <button
126
+ onClick={handlePaste}
127
+ className="px-2 py-1.5 rounded text-xs min-w-[36px] min-h-[32px] bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground transition-colors select-none"
128
+ >
129
+ <ClipboardPaste size={14} />
130
+ </button>
131
+ <div className="w-px h-5 bg-border mx-0.5" />
109
132
  {MOBILE_KEYS.map((key) => (
110
133
  <button
111
134
  key={key.label}
@@ -8,9 +8,10 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
12
- "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
13
- "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
11
+ "h-9 w-full min-w-0 rounded-lg border border-border bg-surface px-3 py-2 text-base md:text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
12
+ "selection:bg-primary selection:text-primary-foreground",
13
+ "file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
14
+ "aria-invalid:border-destructive",
14
15
  className
15
16
  )}
16
17
  {...props}
@@ -0,0 +1,24 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Label as LabelPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Label({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
12
+ return (
13
+ <LabelPrimitive.Root
14
+ data-slot="label"
15
+ className={cn(
16
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ export { Label }