Skip to content

Commit 3247fba

Browse files
davindicodeclaude
andcommitted
Streaming uploads, upload progress UI, vendor-grouped code tab
- Streaming file upload via busboy — pipes to disk without buffering, supports large files (GB+) with constant memory usage - Upload progress overlay locks explorer tab, shows per-file progress bars with status (waiting/uploading %/done/error) - Code tab reorganized by vendor (claude/codex/opencode) — each section has launch buttons, vendor-specific keys, and slash commands together - Common CLI keys updated from docs: Ctrl+L, Ctrl+G, Esc Esc, plus vendor-specific keys (Shift+Tab, Alt+T, Alt+P for Claude, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e80494c commit 3247fba

6 files changed

Lines changed: 352 additions & 92 deletions

File tree

app/components/files/FilesPage.tsx

Lines changed: 92 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,19 @@ function FileSessionView({ session }: { session: FileSession }) {
108108
const { cwd, entries, showHidden, selectedFile, fileContent, loading } = session;
109109
const [fileLoading, setFileLoading] = useState(false);
110110
const [fileError, setFileError] = useState<string | null>(null);
111-
const [uploading, setUploading] = useState(false);
112111
const uploadInputRef = useRef<HTMLInputElement>(null);
113112

113+
// Upload state: queue of files with per-file progress
114+
interface UploadFile {
115+
name: string;
116+
size: number;
117+
status: "pending" | "uploading" | "done" | "error";
118+
progress: number; // 0-100
119+
error?: string;
120+
}
121+
const [uploadQueue, setUploadQueue] = useState<UploadFile[]>([]);
122+
const uploading = uploadQueue.length > 0;
123+
114124
const fullPath = (name: string) => (cwd === "/" ? `/${name}` : `${cwd}/${name}`);
115125

116126
const handleOpen = async (entry: FileEntry) => {
@@ -223,23 +233,60 @@ function FileSessionView({ session }: { session: FileSession }) {
223233

224234
const handleInfo = (entry: FileEntry) => setDialog({ type: "info", entry });
225235

236+
const uploadFile = (file: File, index: number): Promise<void> => {
237+
return new Promise((resolve) => {
238+
const formData = new FormData();
239+
formData.append("file", file);
240+
formData.append("dir", cwd);
241+
242+
const xhr = new XMLHttpRequest();
243+
xhr.upload.onprogress = (e) => {
244+
if (e.lengthComputable) {
245+
const pct = Math.round((e.loaded / e.total) * 100);
246+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, progress: pct } : f));
247+
}
248+
};
249+
xhr.onload = () => {
250+
try {
251+
const data = JSON.parse(xhr.responseText);
252+
if (data.error) {
253+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: data.error } : f));
254+
} else {
255+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
256+
}
257+
} catch {
258+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "done", progress: 100 } : f));
259+
}
260+
resolve();
261+
};
262+
xhr.onerror = () => {
263+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "error", error: "Network error" } : f));
264+
resolve();
265+
};
266+
267+
setUploadQueue((q) => q.map((f, i) => i === index ? { ...f, status: "uploading" } : f));
268+
xhr.open("POST", "/api/files/upload");
269+
xhr.send(formData);
270+
});
271+
};
272+
226273
const handleUpload = async (files: FileList | null) => {
227274
if (!files || files.length === 0) return;
228-
setUploading(true);
229-
try {
230-
for (const file of Array.from(files)) {
231-
const formData = new FormData();
232-
formData.append("file", file);
233-
formData.append("dir", cwd);
234-
const res = await fetch("/api/files/upload", { method: "POST", body: formData });
235-
const data = await res.json();
236-
if (data.error) patch({ error: `Upload failed: ${data.error}` });
237-
}
238-
await loadDirectory(cwd);
239-
} catch (err: any) {
240-
patch({ error: `Upload failed: ${err.message}` });
275+
const queue: UploadFile[] = Array.from(files).map((f) => ({
276+
name: f.name,
277+
size: f.size,
278+
status: "pending" as const,
279+
progress: 0,
280+
}));
281+
setUploadQueue(queue);
282+
283+
const fileArray = Array.from(files);
284+
for (let i = 0; i < fileArray.length; i++) {
285+
await uploadFile(fileArray[i], i);
241286
}
242-
setUploading(false);
287+
await loadDirectory(cwd);
288+
// Keep queue visible briefly so user sees completion
289+
setTimeout(() => setUploadQueue([]), 1500);
243290
if (uploadInputRef.current) uploadInputRef.current.value = "";
244291
};
245292

@@ -333,7 +380,36 @@ function FileSessionView({ session }: { session: FileSession }) {
333380
</label>
334381
</div>
335382

336-
{loading ? (
383+
{uploading ? (
384+
/* Upload progress overlay — locks the explorer */
385+
<div className="flex-1 flex flex-col bg-[#0d0d1a] overflow-y-auto">
386+
<div className="px-3 py-2 border-b border-gray-700/50">
387+
<span className="text-sm text-gray-400">
388+
Uploading to <span className="text-gray-300">{cwd}</span>
389+
</span>
390+
</div>
391+
<div className="flex-1 px-3 py-2 space-y-2">
392+
{uploadQueue.map((file, i) => (
393+
<div key={i} className="flex flex-col gap-1">
394+
<div className="flex items-center justify-between text-[11px]">
395+
<span className={`truncate flex-1 mr-2 ${file.status === "error" ? "text-red-400" : file.status === "done" ? "text-green-400" : "text-gray-300"}`}>
396+
{file.name}
397+
</span>
398+
<span className="text-gray-500 shrink-0">
399+
{file.status === "error" ? file.error : file.status === "done" ? "done" : file.status === "uploading" ? `${file.progress}%` : "waiting"}
400+
</span>
401+
</div>
402+
<div className="w-full h-1 bg-gray-800 rounded overflow-hidden">
403+
<div
404+
className={`h-full transition-all duration-200 rounded ${file.status === "error" ? "bg-red-500" : file.status === "done" ? "bg-green-500" : "bg-blue-500"}`}
405+
style={{ width: `${file.progress}%` }}
406+
/>
407+
</div>
408+
</div>
409+
))}
410+
</div>
411+
</div>
412+
) : loading ? (
337413
<div className="flex flex-col items-center justify-center flex-1 text-gray-500 gap-2">
338414
<svg className="w-6 h-6 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
339415
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />

app/components/terminal/InputBox.tsx

Lines changed: 92 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -112,47 +112,41 @@ const TMUX_KEYS: QuickKey[] = [
112112
{ label: ": cmd", key: "\x02:", title: "Command prompt" },
113113
];
114114

115-
// Common key combos for coding CLIs (Claude Code, Codex, OpenCode)
116-
const CODE_CLI_KEYS: QuickKey[] = [
117-
{ label: "Shift+Tab", key: "\x1b[Z", title: "Change permission mode (Claude Code)" },
118-
{ label: "Ctrl+C", key: "\x03", title: "Cancel / interrupt" },
119-
{ label: "Ctrl+O", key: "\x0f", title: "Show logs (Claude Code)" },
120-
{ label: "Esc", key: "\x1b", title: "Escape / go back" },
115+
// Common key combos shared across all coding CLIs
116+
const CODE_COMMON_KEYS: QuickKey[] = [
117+
{ label: "Ctrl+C", key: "\x03", title: "Cancel / interrupt / quit" },
118+
{ label: "Ctrl+L", key: "\x0c", title: "Clear screen (Claude/Codex) / View logs (OpenCode)" },
119+
{ label: "Ctrl+G", key: "\x07", title: "Open external editor (Claude/Codex)" },
120+
{ label: "Esc", key: "\x1b", title: "Cancel generation / dismiss / go back" },
121+
{ label: "Esc Esc", key: "\x1b\x1b", title: "Rewind history (Claude) / Edit prev message (Codex)" },
121122
{ label: "y", key: "y", title: "Yes (approve)" },
122123
{ label: "n", key: "n", title: "No (deny)" },
123124
];
124125

125-
interface CodeLaunchCmd {
126-
label: string;
127-
title: string;
128-
command: string;
129-
cli: string; // which CLI this belongs to, for grouping
130-
}
131-
132-
// Git quick action commands
133-
const GIT_QUICK_CMDS: { label: string; title: string; command: string }[] = [
134-
{ label: "status", title: "git status", command: "git status\n" },
135-
{ label: "log", title: "git log --oneline -10", command: "git log --oneline -10\n" },
136-
{ label: "diff", title: "git diff", command: "git diff\n" },
137-
{ label: "add .", title: "git add . (stage all)", command: "git add .\n" },
138-
{ label: "fetch", title: "git fetch", command: "git fetch\n" },
139-
{ label: "pull", title: "git pull", command: "git pull\n" },
140-
{ label: "push", title: "git push", command: "git push\n" },
141-
{ label: "stash", title: "git stash", command: "git stash\n" },
142-
{ label: "stash pop", title: "git stash pop", command: "git stash pop\n" },
143-
{ label: "branch", title: "git branch (list branches)", command: "git branch\n" },
144-
];
145-
146-
// Slash commands grouped by CLI
147-
interface SlashCmdGroup {
148-
cli: string;
149-
cmds: { label: string; title: string; command: string }[];
126+
// Per-vendor CLI groups: launch commands, keys, and slash commands
127+
interface CliVendor {
128+
name: string;
129+
keys: QuickKey[];
130+
launch: { label: string; title: string; command: string }[];
131+
slashCmds: { label: string; title: string; command: string }[];
150132
}
151133

152-
const SLASH_CMD_GROUPS: SlashCmdGroup[] = [
134+
const CLI_VENDORS: CliVendor[] = [
153135
{
154-
cli: "claude",
155-
cmds: [
136+
name: "claude",
137+
keys: [
138+
{ label: "Shift+Tab", key: "\x1b[Z", title: "Cycle permission modes" },
139+
{ label: "Ctrl+O", key: "\x0f", title: "Toggle full transcript view" },
140+
{ label: "Ctrl+R", key: "\x12", title: "Reverse history search" },
141+
{ label: "Alt+T", key: "\x1bt", title: "Toggle extended thinking" },
142+
{ label: "Alt+P", key: "\x1bp", title: "Model picker" },
143+
],
144+
launch: [
145+
{ label: "claude", title: "Interactive mode", command: "claude\n" },
146+
{ label: "yolo", title: "Skip all permission checks", command: "claude --dangerously-skip-permissions\n" },
147+
{ label: "plan", title: "Read-only plan mode", command: "claude --allowedTools Read,Glob,Grep,WebSearch,WebFetch\n" },
148+
],
149+
slashCmds: [
156150
{ label: "/clear", title: "Clear conversation context", command: "/clear\n" },
157151
{ label: "/compact", title: "Compact conversation to save context", command: "/compact\n" },
158152
{ label: "/cost", title: "Show token usage and cost", command: "/cost\n" },
@@ -166,8 +160,16 @@ const SLASH_CMD_GROUPS: SlashCmdGroup[] = [
166160
],
167161
},
168162
{
169-
cli: "codex",
170-
cmds: [
163+
name: "codex",
164+
keys: [
165+
{ label: "Ctrl+O", key: "\x0f", title: "Choose environment (cloud)" },
166+
],
167+
launch: [
168+
{ label: "suggest", title: "Proposes changes for approval", command: "codex --approval-mode suggest\n" },
169+
{ label: "auto-edit", title: "Applies file changes, asks for commands", command: "codex --approval-mode auto-edit\n" },
170+
{ label: "full-auto", title: "Runs without confirmation", command: "codex --approval-mode full-auto\n" },
171+
],
172+
slashCmds: [
171173
{ label: "/help", title: "Show available commands", command: "/help\n" },
172174
{ label: "/model", title: "Show or switch model", command: "/model\n" },
173175
{ label: "/exit", title: "Exit Codex", command: "/exit\n" },
@@ -176,8 +178,19 @@ const SLASH_CMD_GROUPS: SlashCmdGroup[] = [
176178
],
177179
},
178180
{
179-
cli: "opencode",
180-
cmds: [
181+
name: "opencode",
182+
keys: [
183+
{ label: "Ctrl+O", key: "\x0f", title: "Model selection dialog" },
184+
{ label: "Ctrl+K", key: "\x0b", title: "Command dialog" },
185+
{ label: "Ctrl+N", key: "\x0e", title: "New session" },
186+
{ label: "Ctrl+X", key: "\x18", title: "Cancel generation" },
187+
{ label: "Ctrl+S", key: "\x13", title: "Send message" },
188+
{ label: "Ctrl+A", key: "\x01", title: "Switch session" },
189+
],
190+
launch: [
191+
{ label: "opencode", title: "Interactive mode", command: "opencode\n" },
192+
],
193+
slashCmds: [
181194
{ label: "/help", title: "Show available commands", command: "/help\n" },
182195
{ label: "/exit", title: "Exit OpenCode", command: "/exit\n" },
183196
{ label: "/clear", title: "Clear conversation", command: "/clear\n" },
@@ -186,17 +199,18 @@ const SLASH_CMD_GROUPS: SlashCmdGroup[] = [
186199
},
187200
];
188201

189-
const CODE_LAUNCH_CMDS: CodeLaunchCmd[] = [
190-
// Claude Code
191-
{ cli: "claude", label: "claude", title: "Claude Code — interactive mode", command: "claude\n" },
192-
{ cli: "claude", label: "claude yolo", title: "Claude Code — skip all permission checks", command: "claude --dangerously-skip-permissions\n" },
193-
{ cli: "claude", label: "claude plan", title: "Claude Code — read-only plan mode", command: "claude --allowedTools Read,Glob,Grep,WebSearch,WebFetch\n" },
194-
// Codex CLI
195-
{ cli: "codex", label: "codex suggest", title: "Codex — suggest mode (proposes changes)", command: "codex --approval-mode suggest\n" },
196-
{ cli: "codex", label: "codex auto", title: "Codex — auto-edit mode", command: "codex --approval-mode auto-edit\n" },
197-
{ cli: "codex", label: "codex full", title: "Codex — full auto mode", command: "codex --approval-mode full-auto\n" },
198-
// OpenCode
199-
{ cli: "opencode", label: "opencode", title: "OpenCode — interactive mode", command: "opencode\n" },
202+
// Git quick action commands
203+
const GIT_QUICK_CMDS: { label: string; title: string; command: string }[] = [
204+
{ label: "status", title: "git status", command: "git status\n" },
205+
{ label: "log", title: "git log --oneline -10", command: "git log --oneline -10\n" },
206+
{ label: "diff", title: "git diff", command: "git diff\n" },
207+
{ label: "add .", title: "git add . (stage all)", command: "git add .\n" },
208+
{ label: "fetch", title: "git fetch", command: "git fetch\n" },
209+
{ label: "pull", title: "git pull", command: "git pull\n" },
210+
{ label: "push", title: "git push", command: "git push\n" },
211+
{ label: "stash", title: "git stash", command: "git stash\n" },
212+
{ label: "stash pop", title: "git stash pop", command: "git stash pop\n" },
213+
{ label: "branch", title: "git branch (list branches)", command: "git branch\n" },
200214
];
201215

202216
// Tab IDs
@@ -803,26 +817,45 @@ export default function InputBox() {
803817
</div>
804818
)}
805819

806-
{/* Code tab: coding CLI launchers + common keys */}
820+
{/* Code tab: common keys + per-vendor panels */}
807821
{activeGroup === CODE_TAB && (
808822
<div className="border-b border-gray-700/50 bg-[#12122a] px-2 py-1.5 max-h-64 overflow-y-auto">
809-
{/* Coding CLI key combos */}
823+
{/* Common keys across all CLIs */}
810824
<div className="flex flex-wrap gap-1 mb-1.5">
811-
<span className="text-[10px] text-gray-600 w-full">cli keys</span>
812-
{CODE_CLI_KEYS.map((qk) => (
825+
<span className="text-[10px] text-gray-600 w-full">common keys</span>
826+
{CODE_COMMON_KEYS.map((qk) => (
813827
<button key={qk.label} {...repeatProps(qk.key)} disabled={!activeSessionId} title={qk.title} className={keyBtn}>
814828
{qk.label}
815829
</button>
816830
))}
817831
</div>
818-
{/* Slash commands grouped by CLI */}
819-
{SLASH_CMD_GROUPS.map((group) => (
820-
<div key={group.cli} className="border-t border-gray-700/50 pt-1.5 mb-1.5">
821-
<span className="text-[10px] text-gray-600">{group.cli} /commands</span>
832+
{/* Per-vendor sections: launch + keys + slash commands */}
833+
{CLI_VENDORS.map((vendor) => (
834+
<div key={vendor.name} className="border-t border-gray-700/50 pt-1.5 mb-1.5">
835+
<span className="text-[10px] text-gray-500 font-medium">{vendor.name}</span>
822836
<div className="flex flex-wrap gap-1 mt-0.5">
823-
{group.cmds.map((cmd) => (
837+
{/* Launch buttons (purple) */}
838+
{vendor.launch.map((cmd) => (
824839
<button
825-
key={`${group.cli}-${cmd.label}`}
840+
key={`${vendor.name}-${cmd.label}`}
841+
onClick={() => { if (activeSessionId) sendInput(activeSessionId, cmd.command); }}
842+
disabled={!activeSessionId}
843+
title={cmd.title}
844+
className="px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-purple-400 hover:text-purple-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-purple-800/60 whitespace-nowrap transition-colors select-none"
845+
>
846+
{cmd.label}
847+
</button>
848+
))}
849+
{/* Vendor-specific keys (gray, like common keys) */}
850+
{vendor.keys.map((qk) => (
851+
<button key={`${vendor.name}-${qk.label}`} {...repeatProps(qk.key)} disabled={!activeSessionId} title={qk.title} className={keyBtn}>
852+
{qk.label}
853+
</button>
854+
))}
855+
{/* Slash commands (cyan) */}
856+
{vendor.slashCmds.map((cmd) => (
857+
<button
858+
key={`${vendor.name}-${cmd.label}`}
826859
onClick={() => { if (activeSessionId) sendInput(activeSessionId, cmd.command); }}
827860
disabled={!activeSessionId}
828861
title={cmd.title}
@@ -834,23 +867,6 @@ export default function InputBox() {
834867
</div>
835868
</div>
836869
))}
837-
{/* CLI launch shortcuts */}
838-
<div className="border-t border-gray-700/50 pt-1.5">
839-
<span className="text-[10px] text-gray-600">launch</span>
840-
<div className="flex flex-wrap gap-1 mt-0.5">
841-
{CODE_LAUNCH_CMDS.map((cmd) => (
842-
<button
843-
key={cmd.label}
844-
onClick={() => { if (activeSessionId) sendInput(activeSessionId, cmd.command); }}
845-
disabled={!activeSessionId}
846-
title={cmd.title}
847-
className="px-2 py-0.5 text-[11px] bg-[#1a1a2e] text-purple-400 hover:text-purple-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-purple-800/60 whitespace-nowrap transition-colors select-none"
848-
>
849-
{cmd.label}
850-
</button>
851-
))}
852-
</div>
853-
</div>
854870
</div>
855871
)}
856872

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@xterm/addon-fit": "^0.11.0",
1818
"@xterm/addon-web-links": "^0.12.0",
1919
"@xterm/xterm": "^6.0.0",
20+
"busboy": "^1.6.0",
2021
"dotenv": "^17.3.1",
2122
"express": "^5.2.1",
2223
"http-proxy-middleware": "^3.0.5",
@@ -35,6 +36,7 @@
3536
"devDependencies": {
3637
"@react-router/dev": "7.12.0",
3738
"@tailwindcss/vite": "^4.1.13",
39+
"@types/busboy": "^1.5.4",
3840
"@types/express": "^5.0.6",
3941
"@types/mime-types": "^3.0.1",
4042
"@types/node": "^22",

0 commit comments

Comments
 (0)