Skip to content

Commit 95a164d

Browse files
davindicodeclaude
andcommitted
Add tmux session manager: attach, create, kill from UI
- New "tmux ↗" button in quick actions bar (green accent) - Popup lists running tmux sessions with attach/kill actions - Create new named session via text input - Server endpoint /api/tmux/sessions lists sessions via tmux CLI - Integrated action pattern: UI fetches server state, sends terminal commands Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e7ac707 commit 95a164d

3 files changed

Lines changed: 140 additions & 8 deletions

File tree

app/components/terminal/InputBox.tsx

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,18 @@ const GROUPS: QuickKeyGroup[] = [
115115
},
116116
];
117117

118+
interface TmuxSession {
119+
name: string;
120+
windows: number;
121+
attached: boolean;
122+
}
123+
118124
export default function InputBox() {
119125
const [text, setText] = useState("");
120126
const [activeGroup, setActiveGroup] = useState<string | null>(null);
127+
const [tmuxSessions, setTmuxSessions] = useState<TmuxSession[]>([]);
128+
const [tmuxOpen, setTmuxOpen] = useState(false);
129+
const [tmuxLoading, setTmuxLoading] = useState(false);
121130
const textareaRef = useRef<HTMLTextAreaElement>(null);
122131
const activeSessionId = useTerminalStore((s) => s.activeSessionId);
123132
const sendInput = useTerminalStore((s) => s.sendInput);
@@ -213,6 +222,33 @@ export default function InputBox() {
213222
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
214223
});
215224

225+
const fetchTmuxSessions = async () => {
226+
setTmuxLoading(true);
227+
try {
228+
const res = await fetch("/api/tmux/sessions");
229+
const data = await res.json();
230+
setTmuxSessions(data.sessions || []);
231+
} catch {
232+
setTmuxSessions([]);
233+
}
234+
setTmuxLoading(false);
235+
};
236+
237+
const handleTmuxAttach = (sessionName: string) => {
238+
if (!activeSessionId) return;
239+
sendInput(activeSessionId, `tmux attach -t ${sessionName}\n`);
240+
setTmuxOpen(false);
241+
};
242+
243+
const [tmuxNewName, setTmuxNewName] = useState("");
244+
245+
const handleTmuxNew = () => {
246+
if (!activeSessionId || !tmuxNewName.trim()) return;
247+
sendInput(activeSessionId, `tmux new -s ${tmuxNewName.trim()}\n`);
248+
setTmuxNewName("");
249+
setTmuxOpen(false);
250+
};
251+
216252
const group = activeGroup ? GROUPS.find((g) => g.label === activeGroup) : null;
217253

218254
return (
@@ -247,20 +283,90 @@ export default function InputBox() {
247283
))}
248284
</>
249285
) : (
250-
/* Top-level group buttons */
251-
GROUPS.map((g) => (
286+
<>
287+
{/* Top-level group buttons */}
288+
{GROUPS.map((g) => (
289+
<button
290+
key={g.label}
291+
onClick={() => setActiveGroup(g.label)}
292+
title={g.title}
293+
className="px-2.5 py-0.5 text-[11px] bg-[#1a1a2e] text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded border border-gray-700 whitespace-nowrap transition-colors shrink-0 select-none touch-manipulation"
294+
>
295+
{g.label}
296+
</button>
297+
))}
298+
{/* Tmux sessions button */}
252299
<button
253-
key={g.label}
254-
onClick={() => setActiveGroup(g.label)}
255-
title={g.title}
256-
className="px-2.5 py-0.5 text-[11px] bg-[#1a1a2e] text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded border border-gray-700 whitespace-nowrap transition-colors shrink-0 select-none touch-manipulation"
300+
onClick={() => { setTmuxOpen(!tmuxOpen); if (!tmuxOpen) fetchTmuxSessions(); }}
301+
disabled={!activeSessionId}
302+
title="tmux sessions"
303+
className="px-2.5 py-0.5 text-[11px] bg-[#1a1a2e] text-green-400 hover:text-green-300 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-green-800 whitespace-nowrap transition-colors shrink-0 select-none touch-manipulation"
257304
>
258-
{g.label}
305+
tmux ↗
259306
</button>
260-
))
307+
</>
261308
)}
262309
</div>
263310
</div>
311+
{/* Tmux session picker popup */}
312+
{tmuxOpen && (
313+
<div className="border-b border-gray-700/50 bg-[#12122a] px-3 py-2">
314+
<div className="flex items-center justify-between mb-2">
315+
<span className="text-[11px] text-gray-400 font-medium">tmux sessions</span>
316+
<button onClick={() => setTmuxOpen(false)} className="text-gray-500 hover:text-white">
317+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
318+
</button>
319+
</div>
320+
{tmuxLoading ? (
321+
<span className="text-[11px] text-gray-500">Loading...</span>
322+
) : tmuxSessions.length === 0 ? (
323+
<span className="text-[11px] text-gray-500">No tmux sessions running</span>
324+
) : (
325+
<div className="flex flex-col gap-1 mb-2">
326+
{tmuxSessions.map((s) => (
327+
<div key={s.name} className="flex items-center gap-2">
328+
<button
329+
onClick={() => handleTmuxAttach(s.name)}
330+
className="flex-1 text-left px-2 py-1 text-[11px] bg-[#1a1a2e] text-gray-300 hover:text-white hover:bg-[#2a2a4a] rounded border border-gray-700 transition-colors"
331+
>
332+
<span className="font-medium">{s.name}</span>
333+
<span className="text-gray-500 ml-2">{s.windows}w</span>
334+
{s.attached && <span className="text-green-500 ml-1">(attached)</span>}
335+
</button>
336+
<button
337+
onClick={() => {
338+
if (!activeSessionId) return;
339+
sendInput(activeSessionId, `tmux kill-session -t ${s.name}\n`);
340+
setTimeout(fetchTmuxSessions, 500);
341+
}}
342+
className="p-1 text-gray-500 hover:text-red-400 transition-colors"
343+
title={`Kill session ${s.name}`}
344+
>
345+
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
346+
</button>
347+
</div>
348+
))}
349+
</div>
350+
)}
351+
{/* New session */}
352+
<div className="flex items-center gap-2 mt-1">
353+
<input
354+
value={tmuxNewName}
355+
onChange={(e) => setTmuxNewName(e.target.value)}
356+
onKeyDown={(e) => e.key === "Enter" && handleTmuxNew()}
357+
placeholder="New session name..."
358+
className="flex-1 bg-[#1a1a2e] text-white border border-gray-700 rounded px-2 py-1 text-[11px] focus:outline-none focus:ring-1 focus:ring-green-500"
359+
/>
360+
<button
361+
onClick={handleTmuxNew}
362+
disabled={!tmuxNewName.trim() || !activeSessionId}
363+
className="px-2 py-1 text-[11px] bg-green-700 hover:bg-green-600 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded transition-colors"
364+
>
365+
Create
366+
</button>
367+
</div>
368+
</div>
369+
)}
264370
{/* Text input */}
265371
<div className="flex gap-2 p-2">
266372
<textarea

app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export default [
1010
route("api/files/upload", "routes/api/files.upload.ts"),
1111
route("api/files/download", "routes/api/files.download.ts"),
1212
route("api/files/mkdir", "routes/api/files.mkdir.ts"),
13+
route("api/tmux/sessions", "routes/api/tmux.sessions.ts"),
1314
] satisfies RouteConfig;

app/routes/api/tmux.sessions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { execSync } from "child_process";
2+
import type { Route } from "./+types/tmux.sessions";
3+
4+
export async function loader({ request }: Route.LoaderArgs) {
5+
try {
6+
const output = execSync("tmux list-sessions -F '#{session_name}:#{session_windows}:#{session_attached}'", {
7+
encoding: "utf-8",
8+
timeout: 3000,
9+
}).trim();
10+
11+
if (!output) {
12+
return Response.json({ sessions: [] });
13+
}
14+
15+
const sessions = output.split("\n").map((line) => {
16+
const [name, windows, attached] = line.split(":");
17+
return { name, windows: parseInt(windows, 10), attached: attached === "1" };
18+
});
19+
20+
return Response.json({ sessions });
21+
} catch {
22+
// tmux not running or not installed
23+
return Response.json({ sessions: [] });
24+
}
25+
}

0 commit comments

Comments
 (0)