Claw Web commited on
Commit
6aeba60
Β·
1 Parent(s): ea87ddf

feat: complete P0 fixes + Manus UI overhaul phase 2

Browse files

P0 Fixes:
- tool_choice: pass through from caller instead of hardcoded 'auto' (llm.ts)
- glob matching: replaced custom regex with micromatch library (permissions.ts)
- graceful shutdown: SIGTERM/SIGINT handlers with DB close (index.ts, db.ts)
- Dockerfile: chmod 777 β†’ 755 with proper chown

Manus UI:
- RightPanel: tabbed panel with Actions/Files/Preview tabs
- FileExplorer: inline file browser with directory navigation and file preview
- PreviewPanel: live viewport showing latest tool output (terminal/web/file)
- CodeDiffViewer: LCS-based diff viewer with Diff/Original/Modified tabs
- Integrated CodeDiffViewer into ActionTree for edit_file operations

Build: clean, 0 errors

Dockerfile CHANGED
@@ -28,7 +28,7 @@ COPY . .
28
  RUN pnpm build
29
 
30
  # Create workspace directory for agent
31
- RUN mkdir -p /home/ubuntu && chmod 777 /home/ubuntu
32
 
33
  # HF Spaces uses port 7860
34
  ENV PORT=7860
 
28
  RUN pnpm build
29
 
30
  # Create workspace directory for agent
31
+ RUN mkdir -p /home/ubuntu && chown node:node /home/ubuntu && chmod 755 /home/ubuntu
32
 
33
  # HF Spaces uses port 7860
34
  ENV PORT=7860
client/src/components/ActionTree.tsx CHANGED
@@ -23,6 +23,7 @@ import {
23
  Minimize2,
24
  } from "lucide-react";
25
  import type { ChatMessage } from "@/hooks/useChat";
 
26
 
27
  // Tool category mapping for icons and colors
28
  const TOOL_CATEGORIES: Record<string, { icon: typeof Terminal; color: string; label: string }> = {
@@ -181,32 +182,58 @@ function ActionNode({ tool, index, isLast }: ActionNodeProps) {
181
  </pre>
182
  </div>
183
 
184
- {/* Output */}
185
  {tool.result && (
186
  <div className="relative group/block mt-2">
187
- <div className="text-[10px] text-muted-foreground/60 mb-1 flex items-center gap-1">
188
- <Terminal className="size-2.5" /> Output
189
- {tool.isError && (
190
- <span className="text-destructive text-[9px]">ERROR</span>
191
- )}
192
- <button
193
- onClick={() => handleCopy(tool.result!)}
194
- className="ml-auto opacity-0 group-hover/block:opacity-100 transition-opacity"
195
- >
196
- <Copy className="size-2.5 text-muted-foreground hover:text-foreground" />
197
- </button>
198
- </div>
199
- <pre
200
- className={`text-[11px] font-mono rounded-md p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-all ${
201
- tool.isError
202
- ? "bg-destructive/10 text-destructive/80 border border-destructive/20"
203
- : "bg-secondary/30 text-foreground/80"
204
- }`}
205
- >
206
- {tool.result.length > 5000
207
- ? tool.result.slice(0, 5000) + "\n\n[...truncated]"
208
- : tool.result}
209
- </pre>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  </div>
211
  )}
212
  </div>
 
23
  Minimize2,
24
  } from "lucide-react";
25
  import type { ChatMessage } from "@/hooks/useChat";
26
+ import { CodeDiffViewer } from "./CodeDiffViewer";
27
 
28
  // Tool category mapping for icons and colors
29
  const TOOL_CATEGORIES: Record<string, { icon: typeof Terminal; color: string; label: string }> = {
 
182
  </pre>
183
  </div>
184
 
185
+ {/* Output β€” with CodeDiffViewer for edit/write operations */}
186
  {tool.result && (
187
  <div className="relative group/block mt-2">
188
+ {(tool.name === "edit_file" || tool.name === "multi_edit_file") && tool.result && !tool.isError ? (
189
+ (() => {
190
+ // Try to extract old/new content from edit result
191
+ try {
192
+ const parsed = JSON.parse(tool.arguments);
193
+ const fileName = parsed.path?.split("/").pop() || parsed.path;
194
+ // Show diff viewer with result as the "after" content
195
+ return (
196
+ <CodeDiffViewer
197
+ oldContent={parsed.old_string || parsed.old_text || ""}
198
+ newContent={parsed.new_string || parsed.new_text || parsed.old_string || ""}
199
+ fileName={fileName}
200
+ />
201
+ );
202
+ } catch {
203
+ return null;
204
+ }
205
+ })() || (
206
+ <pre className="text-[11px] font-mono bg-secondary/30 rounded-md p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-all text-foreground/80">
207
+ {tool.result.length > 5000 ? tool.result.slice(0, 5000) + "\n\n[...truncated]" : tool.result}
208
+ </pre>
209
+ )
210
+ ) : (
211
+ <>
212
+ <div className="text-[10px] text-muted-foreground/60 mb-1 flex items-center gap-1">
213
+ <Terminal className="size-2.5" /> Output
214
+ {tool.isError && (
215
+ <span className="text-destructive text-[9px]">ERROR</span>
216
+ )}
217
+ <button
218
+ onClick={() => handleCopy(tool.result!)}
219
+ className="ml-auto opacity-0 group-hover/block:opacity-100 transition-opacity"
220
+ >
221
+ <Copy className="size-2.5 text-muted-foreground hover:text-foreground" />
222
+ </button>
223
+ </div>
224
+ <pre
225
+ className={`text-[11px] font-mono rounded-md p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-all ${
226
+ tool.isError
227
+ ? "bg-destructive/10 text-destructive/80 border border-destructive/20"
228
+ : "bg-secondary/30 text-foreground/80"
229
+ }`}
230
+ >
231
+ {tool.result.length > 5000
232
+ ? tool.result.slice(0, 5000) + "\n\n[...truncated]"
233
+ : tool.result}
234
+ </pre>
235
+ </>
236
+ )}
237
  </div>
238
  )}
239
  </div>
client/src/components/CodeDiffViewer.tsx ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CodeDiffViewer β€” Manus-style code diff viewer with tabs:
3
+ * Diff / Original / Modified
4
+ * Used in ActionTree when edit_file or write_file tool calls are shown.
5
+ */
6
+
7
+ import { useState, useMemo } from "react";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ type DiffTab = "diff" | "original" | "modified";
11
+
12
+ interface DiffLine {
13
+ type: "added" | "removed" | "context";
14
+ lineOld?: number;
15
+ lineNew?: number;
16
+ content: string;
17
+ }
18
+
19
+ /**
20
+ * Simple unified diff parser.
21
+ * Takes old text and new text, produces a line-by-line diff.
22
+ */
23
+ function computeDiff(oldText: string, newText: string): DiffLine[] {
24
+ const oldLines = oldText.split("\n");
25
+ const newLines = newText.split("\n");
26
+ const result: DiffLine[] = [];
27
+
28
+ // Simple LCS-based diff
29
+ const m = oldLines.length;
30
+ const n = newLines.length;
31
+
32
+ // For large files, fall back to a simpler approach
33
+ if (m * n > 1_000_000) {
34
+ // Just show removed then added for very large files
35
+ oldLines.forEach((line, i) => {
36
+ result.push({ type: "removed", lineOld: i + 1, content: line });
37
+ });
38
+ newLines.forEach((line, i) => {
39
+ result.push({ type: "added", lineNew: i + 1, content: line });
40
+ });
41
+ return result;
42
+ }
43
+
44
+ // Build LCS table
45
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
46
+ for (let i = 1; i <= m; i++) {
47
+ for (let j = 1; j <= n; j++) {
48
+ if (oldLines[i - 1] === newLines[j - 1]) {
49
+ dp[i][j] = dp[i - 1][j - 1] + 1;
50
+ } else {
51
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Backtrack to produce diff
57
+ const diffLines: DiffLine[] = [];
58
+ let i = m, j = n;
59
+ while (i > 0 || j > 0) {
60
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
61
+ diffLines.unshift({ type: "context", lineOld: i, lineNew: j, content: oldLines[i - 1] });
62
+ i--; j--;
63
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
64
+ diffLines.unshift({ type: "added", lineNew: j, content: newLines[j - 1] });
65
+ j--;
66
+ } else {
67
+ diffLines.unshift({ type: "removed", lineOld: i, content: oldLines[i - 1] });
68
+ i--;
69
+ }
70
+ }
71
+
72
+ return diffLines;
73
+ }
74
+
75
+ interface CodeDiffViewerProps {
76
+ oldContent: string;
77
+ newContent: string;
78
+ fileName?: string;
79
+ className?: string;
80
+ }
81
+
82
+ export function CodeDiffViewer({ oldContent, newContent, fileName, className }: CodeDiffViewerProps) {
83
+ const [activeTab, setActiveTab] = useState<DiffTab>("diff");
84
+
85
+ const diffLines = useMemo(
86
+ () => computeDiff(oldContent, newContent),
87
+ [oldContent, newContent]
88
+ );
89
+
90
+ const stats = useMemo(() => {
91
+ const added = diffLines.filter(l => l.type === "added").length;
92
+ const removed = diffLines.filter(l => l.type === "removed").length;
93
+ return { added, removed };
94
+ }, [diffLines]);
95
+
96
+ const tabs: { id: DiffTab; label: string }[] = [
97
+ { id: "diff", label: "Diff" },
98
+ { id: "original", label: "Original" },
99
+ { id: "modified", label: "Modified" },
100
+ ];
101
+
102
+ return (
103
+ <div className={cn("flex flex-col rounded-md border border-border overflow-hidden", className)}>
104
+ {/* Header with tabs */}
105
+ <div className="flex items-center justify-between bg-secondary/30 border-b border-border">
106
+ <div className="flex">
107
+ {tabs.map((tab) => (
108
+ <button
109
+ key={tab.id}
110
+ onClick={() => setActiveTab(tab.id)}
111
+ className={cn(
112
+ "px-3 py-1.5 text-[11px] font-medium transition-colors border-b-2 -mb-px",
113
+ activeTab === tab.id
114
+ ? "border-primary text-foreground bg-background/50"
115
+ : "border-transparent text-muted-foreground hover:text-foreground"
116
+ )}
117
+ >
118
+ {tab.label}
119
+ </button>
120
+ ))}
121
+ </div>
122
+ <div className="flex items-center gap-2 px-2">
123
+ {fileName && (
124
+ <span className="text-[10px] font-mono text-muted-foreground">
125
+ {fileName}
126
+ </span>
127
+ )}
128
+ <span className="text-[10px] font-mono text-green-500">+{stats.added}</span>
129
+ <span className="text-[10px] font-mono text-red-500">-{stats.removed}</span>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Content */}
134
+ <div className="overflow-auto max-h-[400px] bg-[#0d1117]">
135
+ {activeTab === "diff" && (
136
+ <table className="w-full text-[11px] font-mono border-collapse">
137
+ <tbody>
138
+ {diffLines.map((line, idx) => (
139
+ <tr
140
+ key={idx}
141
+ className={cn(
142
+ line.type === "added" && "bg-green-500/10",
143
+ line.type === "removed" && "bg-red-500/10"
144
+ )}
145
+ >
146
+ <td className="w-[1px] px-1 text-right text-muted-foreground/40 select-none border-r border-border/30 whitespace-nowrap">
147
+ {line.lineOld || ""}
148
+ </td>
149
+ <td className="w-[1px] px-1 text-right text-muted-foreground/40 select-none border-r border-border/30 whitespace-nowrap">
150
+ {line.lineNew || ""}
151
+ </td>
152
+ <td className="w-[1px] px-1 select-none font-bold">
153
+ <span className={cn(
154
+ line.type === "added" && "text-green-500",
155
+ line.type === "removed" && "text-red-500",
156
+ line.type === "context" && "text-muted-foreground/30"
157
+ )}>
158
+ {line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
159
+ </span>
160
+ </td>
161
+ <td className="px-2 whitespace-pre-wrap break-all">
162
+ <span className={cn(
163
+ line.type === "added" && "text-green-400/90",
164
+ line.type === "removed" && "text-red-400/90",
165
+ line.type === "context" && "text-foreground/60"
166
+ )}>
167
+ {line.content}
168
+ </span>
169
+ </td>
170
+ </tr>
171
+ ))}
172
+ </tbody>
173
+ </table>
174
+ )}
175
+
176
+ {activeTab === "original" && (
177
+ <pre className="text-[11px] font-mono p-3 whitespace-pre-wrap break-all text-foreground/70">
178
+ {oldContent || "(empty)"}
179
+ </pre>
180
+ )}
181
+
182
+ {activeTab === "modified" && (
183
+ <pre className="text-[11px] font-mono p-3 whitespace-pre-wrap break-all text-foreground/70">
184
+ {newContent || "(empty)"}
185
+ </pre>
186
+ )}
187
+ </div>
188
+ </div>
189
+ );
190
+ }
client/src/components/RightPanel.tsx ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * RightPanel β€” Manus-style tabbed right panel.
3
+ * Tabs: Actions (tool timeline), Files (explorer), Preview (live viewport).
4
+ */
5
+
6
+ import { useState, useCallback, useEffect, useRef } from "react";
7
+ import {
8
+ Activity,
9
+ FolderTree,
10
+ Monitor,
11
+ Folder,
12
+ File,
13
+ ChevronRight,
14
+ ChevronDown,
15
+ RefreshCw,
16
+ Home,
17
+ ArrowUp,
18
+ FileText,
19
+ FileCode,
20
+ FileJson,
21
+ Image as ImageIcon,
22
+ Loader2,
23
+ ExternalLink,
24
+ } from "lucide-react";
25
+ import { ActionTree } from "./ActionTree";
26
+ import type { ChatMessage } from "@/hooks/useChat";
27
+ import { cn } from "@/lib/utils";
28
+
29
+ // ─── Tab definitions ──────────────────────────────────────────────────
30
+
31
+ type TabId = "actions" | "files" | "preview";
32
+
33
+ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
34
+ { id: "actions", label: "Actions", icon: Activity },
35
+ { id: "files", label: "Files", icon: FolderTree },
36
+ { id: "preview", label: "Preview", icon: Monitor },
37
+ ];
38
+
39
+ // ─── File Explorer types ──────────────────────────────────────────────
40
+
41
+ interface FileNode {
42
+ name: string;
43
+ path: string;
44
+ isDirectory: boolean;
45
+ size?: number;
46
+ }
47
+
48
+ function getFileIcon(name: string) {
49
+ const ext = name.split(".").pop()?.toLowerCase() || "";
50
+ if (["ts", "tsx", "js", "jsx", "py", "rs", "go", "java", "c", "cpp", "rb", "sh"].includes(ext))
51
+ return FileCode;
52
+ if (["json", "yaml", "yml", "toml", "xml", "env"].includes(ext))
53
+ return FileJson;
54
+ if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(ext))
55
+ return ImageIcon;
56
+ if (["md", "txt", "log", "csv"].includes(ext))
57
+ return FileText;
58
+ return File;
59
+ }
60
+
61
+ function formatSize(bytes?: number): string {
62
+ if (bytes === undefined) return "";
63
+ if (bytes < 1024) return `${bytes}B`;
64
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
65
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
66
+ }
67
+
68
+ // ─── Inline File Explorer ─────────────────────────────────────────────
69
+
70
+ function FileExplorer() {
71
+ const [currentPath, setCurrentPath] = useState("/home/user");
72
+ const [files, setFiles] = useState<FileNode[]>([]);
73
+ const [loading, setLoading] = useState(false);
74
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
75
+ const [fileContent, setFileContent] = useState<string | null>(null);
76
+ const [loadingContent, setLoadingContent] = useState(false);
77
+
78
+ const fetchFiles = useCallback(async (dirPath: string) => {
79
+ setLoading(true);
80
+ try {
81
+ const response = await fetch("/api/chat/slash", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ command: "/files", args: `list ${dirPath}` }),
85
+ });
86
+ const data = await response.json();
87
+ if (data.files) {
88
+ setFiles(data.files);
89
+ } else if (data.result) {
90
+ // Parse text listing
91
+ const lines = data.result.split("\n").filter((l: string) => l.trim());
92
+ const parsed: FileNode[] = lines
93
+ .filter((l: string) => !l.startsWith("##") && !l.startsWith("---"))
94
+ .map((l: string) => {
95
+ const isDir = l.includes("[DIR]") || l.endsWith("/");
96
+ const name = l.replace("[DIR]", "").replace(/\/$/, "").trim().split(/\s+/).pop() || l.trim();
97
+ return { name, path: `${dirPath}/${name}`.replace("//", "/"), isDirectory: isDir };
98
+ })
99
+ .filter((f: FileNode) => f.name && f.name !== "." && f.name !== "..");
100
+ setFiles(parsed);
101
+ }
102
+ } catch (err) {
103
+ console.error("Failed to fetch files:", err);
104
+ setFiles([]);
105
+ } finally {
106
+ setLoading(false);
107
+ }
108
+ }, []);
109
+
110
+ const fetchFileContent = useCallback(async (filePath: string) => {
111
+ setLoadingContent(true);
112
+ try {
113
+ const response = await fetch("/api/chat/slash", {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({ command: "/files", args: `read ${filePath}` }),
117
+ });
118
+ const data = await response.json();
119
+ setFileContent(data.content || data.result || "Unable to read file");
120
+ } catch {
121
+ setFileContent("Error reading file");
122
+ } finally {
123
+ setLoadingContent(false);
124
+ }
125
+ }, []);
126
+
127
+ useEffect(() => {
128
+ fetchFiles(currentPath);
129
+ }, [currentPath, fetchFiles]);
130
+
131
+ const navigateUp = () => {
132
+ const parent = currentPath.split("/").slice(0, -1).join("/") || "/";
133
+ setCurrentPath(parent);
134
+ setSelectedFile(null);
135
+ setFileContent(null);
136
+ };
137
+
138
+ const handleClick = (file: FileNode) => {
139
+ if (file.isDirectory) {
140
+ setCurrentPath(file.path);
141
+ setSelectedFile(null);
142
+ setFileContent(null);
143
+ } else {
144
+ setSelectedFile(file.path);
145
+ fetchFileContent(file.path);
146
+ }
147
+ };
148
+
149
+ return (
150
+ <div className="h-full flex flex-col">
151
+ {/* Path bar */}
152
+ <div className="flex items-center gap-1 px-2 py-1.5 border-b border-border bg-secondary/20">
153
+ <button
154
+ onClick={() => { setCurrentPath("/home/user"); setSelectedFile(null); setFileContent(null); }}
155
+ className="p-1 rounded hover:bg-accent/50"
156
+ title="Home"
157
+ >
158
+ <Home className="size-3 text-muted-foreground" />
159
+ </button>
160
+ <button
161
+ onClick={navigateUp}
162
+ className="p-1 rounded hover:bg-accent/50"
163
+ title="Up"
164
+ >
165
+ <ArrowUp className="size-3 text-muted-foreground" />
166
+ </button>
167
+ <div className="flex-1 text-[10px] font-mono text-muted-foreground truncate px-1">
168
+ {currentPath}
169
+ </div>
170
+ <button
171
+ onClick={() => fetchFiles(currentPath)}
172
+ className="p-1 rounded hover:bg-accent/50"
173
+ title="Refresh"
174
+ >
175
+ <RefreshCw className={cn("size-3 text-muted-foreground", loading && "animate-spin")} />
176
+ </button>
177
+ </div>
178
+
179
+ {/* File list or content preview */}
180
+ {selectedFile && fileContent !== null ? (
181
+ <div className="flex-1 flex flex-col overflow-hidden">
182
+ <div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-secondary/10">
183
+ <button
184
+ onClick={() => { setSelectedFile(null); setFileContent(null); }}
185
+ className="text-[10px] text-primary hover:underline"
186
+ >
187
+ Back
188
+ </button>
189
+ <span className="text-[10px] text-muted-foreground truncate">
190
+ {selectedFile.split("/").pop()}
191
+ </span>
192
+ </div>
193
+ <div className="flex-1 overflow-auto">
194
+ {loadingContent ? (
195
+ <div className="flex items-center justify-center h-full">
196
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
197
+ </div>
198
+ ) : (
199
+ <pre className="text-[11px] font-mono p-2 whitespace-pre-wrap break-all text-foreground/80">
200
+ {fileContent.length > 10000
201
+ ? fileContent.slice(0, 10000) + "\n\n[...truncated]"
202
+ : fileContent}
203
+ </pre>
204
+ )}
205
+ </div>
206
+ </div>
207
+ ) : (
208
+ <div className="flex-1 overflow-y-auto">
209
+ {loading ? (
210
+ <div className="flex items-center justify-center h-32">
211
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
212
+ </div>
213
+ ) : files.length === 0 ? (
214
+ <div className="flex items-center justify-center h-32 text-[11px] text-muted-foreground">
215
+ Empty directory
216
+ </div>
217
+ ) : (
218
+ <div className="py-0.5">
219
+ {files
220
+ .sort((a, b) => {
221
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
222
+ return a.name.localeCompare(b.name);
223
+ })
224
+ .map((file) => {
225
+ const Icon = file.isDirectory ? Folder : getFileIcon(file.name);
226
+ return (
227
+ <button
228
+ key={file.path}
229
+ onClick={() => handleClick(file)}
230
+ className={cn(
231
+ "flex items-center gap-1.5 w-full px-2 py-1 text-left hover:bg-accent/50 transition-colors",
232
+ selectedFile === file.path && "bg-accent"
233
+ )}
234
+ >
235
+ <Icon
236
+ className={cn(
237
+ "size-3.5 shrink-0",
238
+ file.isDirectory ? "text-amber-400" : "text-muted-foreground"
239
+ )}
240
+ />
241
+ <span className="text-[11px] truncate flex-1">{file.name}</span>
242
+ {!file.isDirectory && file.size !== undefined && (
243
+ <span className="text-[9px] text-muted-foreground/50 font-mono">
244
+ {formatSize(file.size)}
245
+ </span>
246
+ )}
247
+ {file.isDirectory && (
248
+ <ChevronRight className="size-3 text-muted-foreground/30" />
249
+ )}
250
+ </button>
251
+ );
252
+ })}
253
+ </div>
254
+ )}
255
+ </div>
256
+ )}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ // ─── Preview Panel ────────────────────────────────────────────────────
262
+
263
+ function PreviewPanel({ messages }: { messages: ChatMessage[] }) {
264
+ // Extract the latest tool output that could be previewed
265
+ // (bash output, web fetch results, file contents)
266
+ const latestPreviewable = (() => {
267
+ for (let i = messages.length - 1; i >= 0; i--) {
268
+ const msg = messages[i];
269
+ if (msg.role === "assistant" && msg.toolCalls) {
270
+ for (let j = msg.toolCalls.length - 1; j >= 0; j--) {
271
+ const tc = msg.toolCalls[j];
272
+ if (tc.result && !tc.isError) {
273
+ if (tc.name === "bash" || tc.name === "PowerShell") {
274
+ return { type: "terminal" as const, name: tc.name, content: tc.result, command: (() => { try { return JSON.parse(tc.arguments).command; } catch { return ""; } })() };
275
+ }
276
+ if (tc.name === "WebFetch" || tc.name === "WebSearch") {
277
+ return { type: "web" as const, name: tc.name, content: tc.result, url: (() => { try { return JSON.parse(tc.arguments).url || JSON.parse(tc.arguments).query; } catch { return ""; } })() };
278
+ }
279
+ if (tc.name === "read_file") {
280
+ return { type: "file" as const, name: tc.name, content: tc.result, path: (() => { try { return JSON.parse(tc.arguments).path; } catch { return ""; } })() };
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+ return null;
287
+ })();
288
+
289
+ if (!latestPreviewable) {
290
+ return (
291
+ <div className="h-full flex flex-col items-center justify-center text-muted-foreground/40 gap-2 p-4">
292
+ <Monitor className="size-8" />
293
+ <p className="text-xs text-center">
294
+ Live preview will appear here when the agent runs commands or fetches content
295
+ </p>
296
+ </div>
297
+ );
298
+ }
299
+
300
+ return (
301
+ <div className="h-full flex flex-col">
302
+ {/* Preview header */}
303
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-secondary/20">
304
+ {latestPreviewable.type === "terminal" && (
305
+ <>
306
+ <div className="flex gap-1">
307
+ <div className="size-2 rounded-full bg-red-500/70" />
308
+ <div className="size-2 rounded-full bg-yellow-500/70" />
309
+ <div className="size-2 rounded-full bg-green-500/70" />
310
+ </div>
311
+ <span className="text-[10px] font-mono text-muted-foreground truncate">
312
+ $ {latestPreviewable.command?.slice(0, 60)}
313
+ </span>
314
+ </>
315
+ )}
316
+ {latestPreviewable.type === "web" && (
317
+ <>
318
+ <ExternalLink className="size-3 text-muted-foreground" />
319
+ <span className="text-[10px] font-mono text-muted-foreground truncate">
320
+ {latestPreviewable.url}
321
+ </span>
322
+ </>
323
+ )}
324
+ {latestPreviewable.type === "file" && (
325
+ <>
326
+ <FileText className="size-3 text-muted-foreground" />
327
+ <span className="text-[10px] font-mono text-muted-foreground truncate">
328
+ {latestPreviewable.path}
329
+ </span>
330
+ </>
331
+ )}
332
+ </div>
333
+
334
+ {/* Preview content */}
335
+ <div className="flex-1 overflow-auto bg-[#0d1117]">
336
+ <pre className="text-[11px] font-mono p-3 whitespace-pre-wrap break-all text-green-400/90">
337
+ {latestPreviewable.content.length > 15000
338
+ ? latestPreviewable.content.slice(0, 15000) + "\n\n[...truncated]"
339
+ : latestPreviewable.content}
340
+ </pre>
341
+ </div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ // ─── Main RightPanel ──────────────────────────────────────────────────
347
+
348
+ interface RightPanelProps {
349
+ messages: ChatMessage[];
350
+ isStreaming: boolean;
351
+ }
352
+
353
+ export function RightPanel({ messages, isStreaming }: RightPanelProps) {
354
+ const [activeTab, setActiveTab] = useState<TabId>("actions");
355
+
356
+ return (
357
+ <div className="h-full flex flex-col bg-background">
358
+ {/* Tab bar */}
359
+ <div className="flex border-b border-border bg-secondary/10">
360
+ {TABS.map((tab) => {
361
+ const Icon = tab.icon;
362
+ const isActive = activeTab === tab.id;
363
+ return (
364
+ <button
365
+ key={tab.id}
366
+ onClick={() => setActiveTab(tab.id)}
367
+ className={cn(
368
+ "flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium transition-colors border-b-2 -mb-px",
369
+ isActive
370
+ ? "border-primary text-foreground"
371
+ : "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
372
+ )}
373
+ >
374
+ <Icon className="size-3.5" />
375
+ {tab.label}
376
+ </button>
377
+ );
378
+ })}
379
+ </div>
380
+
381
+ {/* Tab content */}
382
+ <div className="flex-1 overflow-hidden">
383
+ {activeTab === "actions" && (
384
+ <ActionTree messages={messages} isStreaming={isStreaming} />
385
+ )}
386
+ {activeTab === "files" && (
387
+ <FileExplorer />
388
+ )}
389
+ {activeTab === "preview" && (
390
+ <PreviewPanel messages={messages} />
391
+ )}
392
+ </div>
393
+ </div>
394
+ );
395
+ }
client/src/pages/Home.tsx CHANGED
@@ -16,6 +16,7 @@ import { BuddySprite } from "@/components/BuddySprite";
16
  import { useBuddy } from "@/buddy";
17
  import { useChat, type ChatMessage } from "@/hooks/useChat";
18
  import { ActionTree } from "@/components/ActionTree";
 
19
  import {
20
  ResizablePanelGroup,
21
  ResizablePanel,
@@ -1004,7 +1005,7 @@ export default function Home() {
1004
  <ResizableHandle withHandle />
1005
  <ResizablePanel defaultSize={40} minSize={25} maxSize={55}>
1006
  <div className="h-full bg-background border-l border-border">
1007
- <ActionTree messages={messages} isStreaming={isStreaming} />
1008
  </div>
1009
  </ResizablePanel>
1010
  </>
 
16
  import { useBuddy } from "@/buddy";
17
  import { useChat, type ChatMessage } from "@/hooks/useChat";
18
  import { ActionTree } from "@/components/ActionTree";
19
+ import { RightPanel } from "@/components/RightPanel";
20
  import {
21
  ResizablePanelGroup,
22
  ResizablePanel,
 
1005
  <ResizableHandle withHandle />
1006
  <ResizablePanel defaultSize={40} minSize={25} maxSize={55}>
1007
  <div className="h-full bg-background border-l border-border">
1008
+ <RightPanel messages={messages} isStreaming={isStreaming} />
1009
  </div>
1010
  </ResizablePanel>
1011
  </>
package.json CHANGED
@@ -65,6 +65,7 @@
65
  "input-otp": "^1.4.2",
66
  "jose": "6.1.0",
67
  "lucide-react": "^0.453.0",
 
68
  "mysql2": "^3.15.0",
69
  "nanoid": "^5.1.5",
70
  "next-themes": "^0.4.6",
@@ -90,6 +91,7 @@
90
  "@types/diff": "^8.0.0",
91
  "@types/express": "4.17.21",
92
  "@types/google.maps": "^3.58.1",
 
93
  "@types/node": "^24.7.0",
94
  "@types/react": "^19.2.1",
95
  "@types/react-dom": "^19.2.1",
 
65
  "input-otp": "^1.4.2",
66
  "jose": "6.1.0",
67
  "lucide-react": "^0.453.0",
68
+ "micromatch": "^4.0.8",
69
  "mysql2": "^3.15.0",
70
  "nanoid": "^5.1.5",
71
  "next-themes": "^0.4.6",
 
91
  "@types/diff": "^8.0.0",
92
  "@types/express": "4.17.21",
93
  "@types/google.maps": "^3.58.1",
94
+ "@types/micromatch": "^4.0.10",
95
  "@types/node": "^24.7.0",
96
  "@types/react": "^19.2.1",
97
  "@types/react-dom": "^19.2.1",
pnpm-lock.yaml CHANGED
@@ -167,6 +167,9 @@ importers:
167
  lucide-react:
168
  specifier: ^0.453.0
169
  version: 0.453.0(react@19.2.4)
 
 
 
170
  mysql2:
171
  specifier: ^3.15.0
172
  version: 3.20.0(@types/node@24.12.0)
@@ -237,6 +240,9 @@ importers:
237
  '@types/google.maps':
238
  specifier: ^3.58.1
239
  version: 3.58.1
 
 
 
240
  '@types/node':
241
  specifier: ^24.7.0
242
  version: 24.12.0
@@ -2375,6 +2381,9 @@ packages:
2375
  '@types/body-parser@1.19.6':
2376
  resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
2377
 
 
 
 
2378
  '@types/connect@3.4.38':
2379
  resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
2380
 
@@ -2512,6 +2521,9 @@ packages:
2512
  '@types/mdast@4.0.4':
2513
  resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
2514
 
 
 
 
2515
  '@types/ms@2.1.0':
2516
  resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
2517
 
@@ -2664,6 +2676,10 @@ packages:
2664
  resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
2665
  engines: {node: 18 || 20 || >=22}
2666
 
 
 
 
 
2667
  browserslist@4.28.2:
2668
  resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
2669
  engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3284,6 +3300,10 @@ packages:
3284
  file-uri-to-path@1.0.0:
3285
  resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
3286
 
 
 
 
 
3287
  finalhandler@1.3.2:
3288
  resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
3289
  engines: {node: '>= 0.8'}
@@ -3501,6 +3521,10 @@ packages:
3501
  is-hexadecimal@2.0.1:
3502
  resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
3503
 
 
 
 
 
3504
  is-plain-obj@4.1.0:
3505
  resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
3506
  engines: {node: '>=12'}
@@ -3855,6 +3879,10 @@ packages:
3855
  micromark@4.0.2:
3856
  resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
3857
 
 
 
 
 
3858
  mime-db@1.52.0:
3859
  resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
3860
  engines: {node: '>= 0.6'}
@@ -4008,6 +4036,10 @@ packages:
4008
  picocolors@1.1.1:
4009
  resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
4010
 
 
 
 
 
4011
  picomatch@4.0.4:
4012
  resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
4013
  engines: {node: '>=12'}
@@ -4427,6 +4459,10 @@ packages:
4427
  resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
4428
  engines: {node: '>=14.0.0'}
4429
 
 
 
 
 
4430
  toidentifier@1.0.1:
4431
  resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
4432
  engines: {node: '>=0.6'}
@@ -6932,6 +6968,8 @@ snapshots:
6932
  '@types/connect': 3.4.38
6933
  '@types/node': 24.12.0
6934
 
 
 
6935
  '@types/connect@3.4.38':
6936
  dependencies:
6937
  '@types/node': 24.12.0
@@ -7101,6 +7139,10 @@ snapshots:
7101
  dependencies:
7102
  '@types/unist': 3.0.3
7103
 
 
 
 
 
7104
  '@types/ms@2.1.0': {}
7105
 
7106
  '@types/node@24.12.0':
@@ -7278,6 +7320,10 @@ snapshots:
7278
  dependencies:
7279
  balanced-match: 4.0.4
7280
 
 
 
 
 
7281
  browserslist@4.28.2:
7282
  dependencies:
7283
  baseline-browser-mapping: 2.10.13
@@ -7904,6 +7950,10 @@ snapshots:
7904
 
7905
  file-uri-to-path@1.0.0: {}
7906
 
 
 
 
 
7907
  finalhandler@1.3.2:
7908
  dependencies:
7909
  debug: 2.6.9
@@ -8186,6 +8236,8 @@ snapshots:
8186
 
8187
  is-hexadecimal@2.0.1: {}
8188
 
 
 
8189
  is-plain-obj@4.1.0: {}
8190
 
8191
  is-property@1.0.2: {}
@@ -8739,6 +8791,11 @@ snapshots:
8739
  transitivePeerDependencies:
8740
  - supports-color
8741
 
 
 
 
 
 
8742
  mime-db@1.52.0: {}
8743
 
8744
  mime-types@2.1.35:
@@ -8872,6 +8929,8 @@ snapshots:
8872
 
8873
  picocolors@1.1.1: {}
8874
 
 
 
8875
  picomatch@4.0.4: {}
8876
 
8877
  pkg-types@1.3.1:
@@ -9417,6 +9476,10 @@ snapshots:
9417
 
9418
  tinyspy@3.0.2: {}
9419
 
 
 
 
 
9420
  toidentifier@1.0.1: {}
9421
 
9422
  trim-lines@3.0.1: {}
 
167
  lucide-react:
168
  specifier: ^0.453.0
169
  version: 0.453.0(react@19.2.4)
170
+ micromatch:
171
+ specifier: ^4.0.8
172
+ version: 4.0.8
173
  mysql2:
174
  specifier: ^3.15.0
175
  version: 3.20.0(@types/node@24.12.0)
 
240
  '@types/google.maps':
241
  specifier: ^3.58.1
242
  version: 3.58.1
243
+ '@types/micromatch':
244
+ specifier: ^4.0.10
245
+ version: 4.0.10
246
  '@types/node':
247
  specifier: ^24.7.0
248
  version: 24.12.0
 
2381
  '@types/body-parser@1.19.6':
2382
  resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
2383
 
2384
+ '@types/braces@3.0.5':
2385
+ resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==}
2386
+
2387
  '@types/connect@3.4.38':
2388
  resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
2389
 
 
2521
  '@types/mdast@4.0.4':
2522
  resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
2523
 
2524
+ '@types/micromatch@4.0.10':
2525
+ resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==}
2526
+
2527
  '@types/ms@2.1.0':
2528
  resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
2529
 
 
2676
  resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==}
2677
  engines: {node: 18 || 20 || >=22}
2678
 
2679
+ braces@3.0.3:
2680
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
2681
+ engines: {node: '>=8'}
2682
+
2683
  browserslist@4.28.2:
2684
  resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
2685
  engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
 
3300
  file-uri-to-path@1.0.0:
3301
  resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
3302
 
3303
+ fill-range@7.1.1:
3304
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
3305
+ engines: {node: '>=8'}
3306
+
3307
  finalhandler@1.3.2:
3308
  resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
3309
  engines: {node: '>= 0.8'}
 
3521
  is-hexadecimal@2.0.1:
3522
  resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
3523
 
3524
+ is-number@7.0.0:
3525
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
3526
+ engines: {node: '>=0.12.0'}
3527
+
3528
  is-plain-obj@4.1.0:
3529
  resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
3530
  engines: {node: '>=12'}
 
3879
  micromark@4.0.2:
3880
  resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
3881
 
3882
+ micromatch@4.0.8:
3883
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
3884
+ engines: {node: '>=8.6'}
3885
+
3886
  mime-db@1.52.0:
3887
  resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
3888
  engines: {node: '>= 0.6'}
 
4036
  picocolors@1.1.1:
4037
  resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
4038
 
4039
+ picomatch@2.3.2:
4040
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
4041
+ engines: {node: '>=8.6'}
4042
+
4043
  picomatch@4.0.4:
4044
  resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
4045
  engines: {node: '>=12'}
 
4459
  resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
4460
  engines: {node: '>=14.0.0'}
4461
 
4462
+ to-regex-range@5.0.1:
4463
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
4464
+ engines: {node: '>=8.0'}
4465
+
4466
  toidentifier@1.0.1:
4467
  resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
4468
  engines: {node: '>=0.6'}
 
6968
  '@types/connect': 3.4.38
6969
  '@types/node': 24.12.0
6970
 
6971
+ '@types/braces@3.0.5': {}
6972
+
6973
  '@types/connect@3.4.38':
6974
  dependencies:
6975
  '@types/node': 24.12.0
 
7139
  dependencies:
7140
  '@types/unist': 3.0.3
7141
 
7142
+ '@types/micromatch@4.0.10':
7143
+ dependencies:
7144
+ '@types/braces': 3.0.5
7145
+
7146
  '@types/ms@2.1.0': {}
7147
 
7148
  '@types/node@24.12.0':
 
7320
  dependencies:
7321
  balanced-match: 4.0.4
7322
 
7323
+ braces@3.0.3:
7324
+ dependencies:
7325
+ fill-range: 7.1.1
7326
+
7327
  browserslist@4.28.2:
7328
  dependencies:
7329
  baseline-browser-mapping: 2.10.13
 
7950
 
7951
  file-uri-to-path@1.0.0: {}
7952
 
7953
+ fill-range@7.1.1:
7954
+ dependencies:
7955
+ to-regex-range: 5.0.1
7956
+
7957
  finalhandler@1.3.2:
7958
  dependencies:
7959
  debug: 2.6.9
 
8236
 
8237
  is-hexadecimal@2.0.1: {}
8238
 
8239
+ is-number@7.0.0: {}
8240
+
8241
  is-plain-obj@4.1.0: {}
8242
 
8243
  is-property@1.0.2: {}
 
8791
  transitivePeerDependencies:
8792
  - supports-color
8793
 
8794
+ micromatch@4.0.8:
8795
+ dependencies:
8796
+ braces: 3.0.3
8797
+ picomatch: 2.3.2
8798
+
8799
  mime-db@1.52.0: {}
8800
 
8801
  mime-types@2.1.35:
 
8929
 
8930
  picocolors@1.1.1: {}
8931
 
8932
+ picomatch@2.3.2: {}
8933
+
8934
  picomatch@4.0.4: {}
8935
 
8936
  pkg-types@1.3.1:
 
9476
 
9477
  tinyspy@3.0.2: {}
9478
 
9479
+ to-regex-range@5.0.1:
9480
+ dependencies:
9481
+ is-number: 7.0.0
9482
+
9483
  toidentifier@1.0.1: {}
9484
 
9485
  trim-lines@3.0.1: {}
server/_core/index.ts CHANGED
@@ -80,6 +80,30 @@ async function startServer() {
80
  server.listen(port, "0.0.0.0", () => {
81
  console.log(`Server running on http://0.0.0.0:${port}/`);
82
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
  startServer().catch(console.error);
 
80
  server.listen(port, "0.0.0.0", () => {
81
  console.log(`Server running on http://0.0.0.0:${port}/`);
82
  });
83
+
84
+ // ─── Graceful shutdown ─────────────────────────────────────────────
85
+ const shutdown = (signal: string) => {
86
+ console.log(`\n[server] ${signal} received β€” shutting down gracefully...`);
87
+ server.close(() => {
88
+ console.log("[server] HTTP server closed");
89
+ // Close database connections
90
+ try {
91
+ const { closeDb } = require("../db");
92
+ if (typeof closeDb === "function") closeDb();
93
+ } catch {}
94
+ console.log("[server] Cleanup complete, exiting");
95
+ process.exit(0);
96
+ });
97
+
98
+ // Force exit after 10 seconds if graceful shutdown hangs
99
+ setTimeout(() => {
100
+ console.error("[server] Forced exit after timeout");
101
+ process.exit(1);
102
+ }, 10_000).unref();
103
+ };
104
+
105
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
106
+ process.on("SIGINT", () => shutdown("SIGINT"));
107
  }
108
 
109
  startServer().catch(console.error);
server/_core/llm.ts CHANGED
@@ -341,6 +341,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
341
  export interface StreamInvokeParams {
342
  messages: Message[];
343
  tools?: Tool[];
 
344
  model?: string;
345
  maxTokens?: number;
346
  temperature?: number;
@@ -363,7 +364,7 @@ export async function invokeLLMStream(params: StreamInvokeParams): Promise<globa
363
  model,
364
  messages: params.messages.map(normalizeMessage),
365
  tools: params.tools,
366
- tool_choice: "auto",
367
  max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
368
  temperature: params.temperature ?? 0.7,
369
  top_p: params.topP ?? 1,
 
341
  export interface StreamInvokeParams {
342
  messages: Message[];
343
  tools?: Tool[];
344
+ tool_choice?: ToolChoice;
345
  model?: string;
346
  maxTokens?: number;
347
  temperature?: number;
 
364
  model,
365
  messages: params.messages.map(normalizeMessage),
366
  tools: params.tools,
367
+ tool_choice: params.tool_choice || "auto",
368
  max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
369
  temperature: params.temperature ?? 0.7,
370
  top_p: params.topP ?? 1,
server/db.ts CHANGED
@@ -397,3 +397,18 @@ export function deleteSessionState(sessionId: number, key?: string): void {
397
  console.error(`[db] Failed to delete session state:`, err.message);
398
  }
399
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  console.error(`[db] Failed to delete session state:`, err.message);
398
  }
399
  }
400
+
401
+ // ─── Graceful shutdown ────────────────────────────────────────────────────────
402
+
403
+ export function closeDb() {
404
+ if (_sqlite) {
405
+ try {
406
+ _sqlite.close();
407
+ console.log("[db] SQLite connection closed");
408
+ } catch (err) {
409
+ console.error("[db] Error closing SQLite:", err);
410
+ }
411
+ _sqlite = null;
412
+ _db = null;
413
+ }
414
+ }
server/runtime/permissions.ts CHANGED
@@ -154,19 +154,15 @@ export class PermissionPolicy {
154
  }
155
  }
156
 
 
 
157
  // ─── Glob matching for tool names ────────────────────────────────────────────
158
 
159
  export function globMatchToolName(
160
  pattern: string,
161
  toolName: string
162
  ): boolean {
163
- // Convert glob pattern to regex
164
- const regexStr = pattern
165
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
166
- .replace(/\*/g, ".*")
167
- .replace(/\?/g, ".");
168
- const regex = new RegExp(`^${regexStr}$`);
169
- return regex.test(toolName);
170
  }
171
 
172
  // ─── Glob matching for file paths ────────────────────────────────────────────
@@ -175,17 +171,7 @@ export function globMatchPath(pattern: string, filePath: string): boolean {
175
  // Normalize: remove trailing slashes
176
  const normalizedPath = filePath.replace(/\/+$/, "");
177
  const normalizedPattern = pattern.replace(/\/+$/, "");
178
-
179
- // Convert glob pattern to regex
180
- let regexStr = normalizedPattern
181
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
182
- .replace(/\*\*\//g, "(?:.*\/)?")
183
- .replace(/\/\*\*/g, "(?:\/.*)?")
184
- .replace(/\*\*/g, ".*")
185
- .replace(/\*/g, "[^/]*")
186
- .replace(/\?/g, "[^/]");
187
- const regex = new RegExp(`^${regexStr}$`);
188
- return regex.test(normalizedPath);
189
  }
190
 
191
  // ─── Permission prompt text ──────────────────────────────────────────────────
 
154
  }
155
  }
156
 
157
+ import micromatch from "micromatch";
158
+
159
  // ─── Glob matching for tool names ────────────────────────────────────────────
160
 
161
  export function globMatchToolName(
162
  pattern: string,
163
  toolName: string
164
  ): boolean {
165
+ return micromatch.isMatch(toolName, pattern);
 
 
 
 
 
 
166
  }
167
 
168
  // ─── Glob matching for file paths ────────────────────────────────────────────
 
171
  // Normalize: remove trailing slashes
172
  const normalizedPath = filePath.replace(/\/+$/, "");
173
  const normalizedPattern = pattern.replace(/\/+$/, "");
174
+ return micromatch.isMatch(normalizedPath, normalizedPattern, { dot: true });
 
 
 
 
 
 
 
 
 
 
175
  }
176
 
177
  // ─── Permission prompt text ──────────────────────────────────────────────────