@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.
- package/CLAUDE.md +45 -0
- package/bun.lock +55 -0
- package/dist/ppm +0 -0
- package/dist/web/assets/api-client-BgVufYKf.js +1 -0
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
- package/dist/web/assets/button-KIZetva8.js +41 -0
- package/dist/web/assets/chat-tab-D7dR7kbZ.js +6 -0
- package/dist/web/assets/code-editor-r8P6Gk4M.js +2 -0
- package/dist/web/assets/copy-B-kLwqzg.js +1 -0
- package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
- package/dist/web/assets/diff-viewer-vSvrem_i.js +4 -0
- package/dist/web/assets/dist-C4W3AGh3.js +1 -0
- package/dist/web/assets/dist-PA84y4Ga.js +1 -0
- package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
- package/dist/web/assets/git-graph-Cn-s1k0-.js +1 -0
- package/dist/web/assets/git-status-panel-QjAQzNAi.js +1 -0
- package/dist/web/assets/index-DUBI96T5.css +2 -0
- package/dist/web/assets/index-nk1dAWff.js +10 -0
- package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
- package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
- package/dist/web/assets/project-list-DqiatpaH.js +1 -0
- package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
- package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
- package/dist/web/assets/settings-tab-iCGeFFdt.js +1 -0
- package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
- package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
- package/dist/web/assets/x-BxhOxZ5p.js +1 -0
- package/dist/web/index.html +11 -10
- package/dist/web/sw.js +1 -1
- package/docs/claude-agent-sdk-reference.md +780 -0
- package/docs/code-standards.md +74 -0
- package/docs/codebase-summary.md +22 -20
- package/docs/deployment-guide.md +81 -11
- package/docs/lessons-learned.md +58 -0
- package/docs/project-overview-pdr.md +62 -2
- package/docs/system-architecture.md +102 -10
- package/package.json +4 -1
- package/schemas/ppm-config.schema.json +87 -0
- package/src/cli/commands/init.ts +186 -43
- package/src/cli/commands/status.ts +73 -0
- package/src/cli/commands/stop.ts +24 -10
- package/src/index.ts +28 -5
- package/src/providers/claude-agent-sdk.ts +84 -3
- package/src/providers/registry.ts +0 -2
- package/src/server/index.ts +106 -15
- package/src/server/routes/settings.ts +70 -0
- package/src/server/ws/chat.ts +8 -6
- package/src/services/cloudflared.service.ts +99 -0
- package/src/services/git.service.ts +23 -1
- package/src/services/tunnel.service.ts +100 -0
- package/src/types/chat.ts +8 -1
- package/src/types/config.ts +50 -3
- package/src/web/app.tsx +10 -2
- package/src/web/components/auth/login-screen.tsx +1 -1
- package/src/web/components/chat/message-input.tsx +1 -1
- package/src/web/components/chat/message-list.tsx +112 -251
- package/src/web/components/chat/tool-cards.tsx +411 -0
- package/src/web/components/editor/code-editor.tsx +80 -20
- package/src/web/components/editor/diff-viewer.tsx +72 -7
- package/src/web/components/git/git-graph.tsx +3 -0
- package/src/web/components/git/git-status-panel.tsx +50 -1
- package/src/web/components/layout/command-palette.tsx +215 -0
- package/src/web/components/layout/mobile-drawer.tsx +143 -42
- package/src/web/components/layout/sidebar.tsx +103 -67
- package/src/web/components/layout/tab-bar.tsx +1 -2
- package/src/web/components/settings/ai-settings-section.tsx +166 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/terminal/terminal-tab.tsx +45 -22
- package/src/web/components/ui/input.tsx +4 -3
- package/src/web/components/ui/label.tsx +24 -0
- package/src/web/components/ui/select.tsx +188 -0
- package/src/web/hooks/use-global-keybindings.ts +56 -0
- package/src/web/hooks/use-terminal.ts +14 -1
- package/src/web/lib/api-settings.ts +24 -0
- package/src/web/stores/project-store.ts +47 -2
- package/src/web/stores/tab-store.ts +1 -1
- package/src/web/styles/globals.css +20 -6
- package/test-tool.mjs +41 -0
- package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
- package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
- package/dist/web/assets/button-DxRZgE8F.js +0 -1
- package/dist/web/assets/chat-tab-p2mwkdec.js +0 -61
- package/dist/web/assets/code-editor-vMRyRKV3.js +0 -2
- package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
- package/dist/web/assets/dialog-Db6prp1p.js +0 -45
- package/dist/web/assets/diff-viewer-BdDje3Wr.js +0 -4
- package/dist/web/assets/external-link-WSiY-639.js +0 -1
- package/dist/web/assets/git-graph-B-qwuFoO.js +0 -1
- package/dist/web/assets/git-status-panel-NkZFb5v1.js +0 -1
- package/dist/web/assets/index-BHEFCU01.js +0 -10
- package/dist/web/assets/index-DYd_2slk.css +0 -2
- package/dist/web/assets/project-list-NkR7IHT5.js +0 -1
- package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
- package/dist/web/assets/settings-tab-DKx0s3Q1.js +0 -1
- package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
- package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
- package/dist/web/assets/x-BISR7bpK.js +0 -1
- package/src/providers/claude-binary-finder.ts +0 -256
- package/src/providers/claude-code-cli.ts +0 -413
- package/src/providers/claude-process-registry.ts +0 -106
- /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
- /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
|
@@ -1,89 +1,125 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
7
|
-
|
|
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 [
|
|
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
|
-
|
|
16
|
-
|
|
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-
|
|
21
|
-
{/*
|
|
22
|
-
<
|
|
23
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
76
|
-
|
|
108
|
+
</DropdownMenuContent>
|
|
109
|
+
</DropdownMenu>
|
|
110
|
+
</div>
|
|
77
111
|
|
|
78
|
-
{/* File tree
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
12
|
-
"
|
|
13
|
-
"
|
|
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 }
|