Skip to content

Commit 58cb4d2

Browse files
committed
CLI
1 parent a65d302 commit 58cb4d2

5 files changed

Lines changed: 242 additions & 6 deletions

File tree

app/components/Header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useState, useEffect, useRef } from "react";
22
import { useTerminalStore } from "~/stores/terminalStore";
33

4+
declare const __APP_VERSION__: string;
5+
46
function SystemInfoPopup({ onClose }: { onClose: () => void }) {
57
const [info, setInfo] = useState<Record<string, string | null> | null>(null);
68
const popupRef = useRef<HTMLDivElement>(null);
@@ -71,6 +73,7 @@ export default function Header() {
7173
<div className="flex items-center gap-2">
7274
<img src="/logo-square.png" alt="OTG Code" className="w-6 h-6 rounded" />
7375
<span className="text-white font-bold text-sm">OTG Code</span>
76+
<span className="text-gray-500 text-[10px] font-mono">v{__APP_VERSION__}</span>
7477
</div>
7578
<div className="flex items-center gap-2">
7679
<button

app/components/terminal/InputBox.tsx

Lines changed: 207 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,70 @@ const TMUX_KEYS: QuickKey[] = [
123123
{ label: ": cmd", key: "\x02:", title: "Command prompt" },
124124
];
125125

126+
const CLAUDE_KEYS: QuickKey[] = [
127+
{ label: "Shift+Tab", key: "\x1b[Z", title: "Change permission mode" },
128+
{ label: "Ctrl+O logs", key: "\x0f", title: "Show logs" },
129+
{ label: "Ctrl+C cancel", key: "\x03", title: "Cancel / interrupt" },
130+
{ label: "Esc", key: "\x1b", title: "Escape / go back" },
131+
{ label: "Enter", key: "\r", title: "Confirm / send" },
132+
{ label: "Up", key: "\x1b[A", title: "Previous" },
133+
{ label: "Down", key: "\x1b[B", title: "Next" },
134+
{ label: "y", key: "y", title: "Yes (approve)" },
135+
{ label: "n", key: "n", title: "No (deny)" },
136+
];
137+
138+
const CODEX_KEYS: QuickKey[] = [
139+
{ label: "Shift+Tab", key: "\x1b[Z", title: "Change permission mode" },
140+
{ label: "Ctrl+C cancel", key: "\x03", title: "Cancel / interrupt" },
141+
{ label: "Esc", key: "\x1b", title: "Escape / go back" },
142+
{ label: "Enter", key: "\r", title: "Confirm / send" },
143+
{ label: "Up", key: "\x1b[A", title: "Previous" },
144+
{ label: "Down", key: "\x1b[B", title: "Next" },
145+
{ label: "y", key: "y", title: "Yes (approve)" },
146+
{ label: "n", key: "n", title: "No (deny)" },
147+
];
148+
149+
const OPENCODE_KEYS: QuickKey[] = [
150+
{ label: "Ctrl+C cancel", key: "\x03", title: "Cancel / interrupt" },
151+
{ label: "Esc", key: "\x1b", title: "Escape / go back" },
152+
{ label: "Enter", key: "\r", title: "Confirm / send" },
153+
{ label: "Tab", key: "\t", title: "Autocomplete / cycle" },
154+
{ label: "Up", key: "\x1b[A", title: "Previous" },
155+
{ label: "Down", key: "\x1b[B", title: "Next" },
156+
{ label: "y", key: "y", title: "Yes (approve)" },
157+
{ label: "n", key: "n", title: "No (deny)" },
158+
];
159+
160+
interface CliLaunchOption {
161+
label: string;
162+
title: string;
163+
command: string;
164+
}
165+
166+
const CLAUDE_LAUNCH_OPTIONS: CliLaunchOption[] = [
167+
{ label: "default", title: "Interactive mode (asks for permission)", command: "claude" },
168+
{ label: "yolo", title: "Skip all permission checks", command: "claude --dangerously-skip-permissions" },
169+
{ label: "plan", title: "Plan mode (read-only, no edits)", command: "claude --allowedTools Read,Glob,Grep,WebSearch,WebFetch" },
170+
];
171+
172+
const CODEX_LAUNCH_OPTIONS: CliLaunchOption[] = [
173+
{ label: "suggest", title: "Suggest mode (proposes changes for approval)", command: "codex --approval-mode suggest" },
174+
{ label: "auto-edit", title: "Auto-edit mode (applies file changes, asks for commands)", command: "codex --approval-mode auto-edit" },
175+
{ label: "full-auto", title: "Full auto mode (runs without confirmation)", command: "codex --approval-mode full-auto" },
176+
];
177+
178+
const OPENCODE_LAUNCH_OPTIONS: CliLaunchOption[] = [
179+
{ label: "default", title: "Interactive mode", command: "opencode" },
180+
];
181+
126182
// Tab IDs
127183
const STICKY_TAB = "__sticky__";
128184
const TMUX_TAB = "__tmux__";
129185
const NANO_TAB = "__nano__";
130186
const VIM_TAB = "__vim__";
187+
const CLAUDE_TAB = "__claude__";
188+
const CODEX_TAB = "__codex__";
189+
const OPENCODE_TAB = "__opencode__";
131190

132191
type StickyMode = "ctrl" | "ctrl+shift" | "alt" | "alt+shift";
133192

@@ -166,7 +225,7 @@ export default function InputBox() {
166225
const [activeGroup, setActiveGroup] = useState<string | null>(null);
167226
const [tmuxSessions, setTmuxSessions] = useState<TmuxSession[]>([]);
168227
const [tmuxLoading, setTmuxLoading] = useState(false);
169-
const [toolVersions, setToolVersions] = useState<{ tmux: string | null; nano: string | null; vim: string | null }>({ tmux: null, nano: null, vim: null });
228+
const [toolVersions, setToolVersions] = useState<{ tmux: string | null; nano: string | null; vim: string | null; claude: string | null; codex: string | null; opencode: string | null }>({ tmux: null, nano: null, vim: null, claude: null, codex: null, opencode: null });
170229
const toolVersionsFetched = useRef(false);
171230
const [tmuxNewName, setTmuxNewName] = useState("");
172231
const [editorFileName, setEditorFileName] = useState("");
@@ -283,7 +342,7 @@ export default function InputBox() {
283342
setActiveGroup(id);
284343
if (id === TMUX_TAB && !inTmux) fetchTmuxSessions();
285344
if (id === "cmds") fetchDirs();
286-
if ([TMUX_TAB, NANO_TAB, VIM_TAB].includes(id)) fetchToolVersions();
345+
if ([TMUX_TAB, NANO_TAB, VIM_TAB, CLAUDE_TAB, CODEX_TAB, OPENCODE_TAB].includes(id)) fetchToolVersions();
287346
}
288347
};
289348

@@ -408,6 +467,24 @@ export default function InputBox() {
408467
}, 50);
409468
};
410469

470+
// CLI apps (Claude Code, Codex, OpenCode)
471+
const handleLaunchCli = (app: "claude" | "codex" | "opencode", command: string) => {
472+
if (!activeSessionId) return;
473+
sendInput(activeSessionId, command + "\n");
474+
setInEditor(activeSessionId, app);
475+
setActiveGroup(app === "claude" ? CLAUDE_TAB : app === "codex" ? CODEX_TAB : OPENCODE_TAB);
476+
};
477+
478+
const handleExitCli = () => {
479+
if (!activeSessionId || !inEditor) return;
480+
sendInput(activeSessionId, "\x03");
481+
setTimeout(() => {
482+
sendInput(activeSessionId, "/exit\n");
483+
setInEditor(activeSessionId, null);
484+
setActiveGroup(null);
485+
}, 100);
486+
};
487+
411488
// --- Styles ---
412489
// Tab buttons: teal tint to distinguish from popup action buttons
413490
const tabBase = "px-2.5 py-0.5 text-[11px] rounded border whitespace-nowrap transition-colors shrink-0 select-none touch-manipulation";
@@ -438,7 +515,7 @@ export default function InputBox() {
438515
);
439516

440517
// Resolve which key group to show in the popup
441-
const activeStandardGroup = activeGroup && ![STICKY_TAB, TMUX_TAB, NANO_TAB, VIM_TAB].includes(activeGroup)
518+
const activeStandardGroup = activeGroup && ![STICKY_TAB, TMUX_TAB, NANO_TAB, VIM_TAB, CLAUDE_TAB, CODEX_TAB, OPENCODE_TAB].includes(activeGroup)
442519
? (isEditorMode
443520
? (activeGroup === "nav" ? EDITOR_NAV : null)
444521
: TERMINAL_GROUPS.find((g) => g.label === activeGroup))
@@ -451,9 +528,12 @@ export default function InputBox() {
451528
<div className="flex items-center gap-1 px-2 py-1 w-max">
452529
{isEditorMode ? (
453530
<>
454-
{/* Editor mode tabs */}
531+
{/* Editor/app mode tabs */}
455532
{inEditor === "nano" && tabBtn(NANO_TAB, "nano \u270f", "nano commands")}
456533
{inEditor === "vim" && tabBtn(VIM_TAB, "vim \u270f", "vim commands")}
534+
{inEditor === "claude" && tabBtn(CLAUDE_TAB, "claude \u2728", "Claude Code actions")}
535+
{inEditor === "codex" && tabBtn(CODEX_TAB, "codex \u26a1", "Codex CLI actions")}
536+
{inEditor === "opencode" && tabBtn(OPENCODE_TAB, "opencode \u2318", "OpenCode actions")}
457537
{tabBtn("nav", "nav", "Navigation keys")}
458538
{tabBtn(STICKY_TAB, STICKY_MODES.find((m) => m.id === stickyMode)?.label || "Ctrl+", "Modifier combos")}
459539
</>
@@ -465,6 +545,9 @@ export default function InputBox() {
465545
{tabBtn(NANO_TAB, "nano", "Open file in nano", !activeSessionId)}
466546
{tabBtn(VIM_TAB, "vim", "Open file in vim", !activeSessionId)}
467547
{tabBtn(TMUX_TAB, inTmux ? "tmux \u2318" : "tmux", inTmux ? "tmux commands" : "tmux sessions", !activeSessionId)}
548+
{tabBtn(CLAUDE_TAB, "claude", "Launch Claude Code", !activeSessionId)}
549+
{tabBtn(CODEX_TAB, "codex", "Launch Codex CLI", !activeSessionId)}
550+
{tabBtn(OPENCODE_TAB, "opencode", "Launch OpenCode", !activeSessionId)}
468551
</>
469552
)}
470553
</div>
@@ -727,6 +810,126 @@ export default function InputBox() {
727810
</div>
728811
)}
729812

813+
{/* Claude Code popup: actions when inside, launcher when outside */}
814+
{activeGroup === CLAUDE_TAB && inEditor === "claude" && (
815+
<div className="border-b border-gray-700/50 bg-[#12122a] px-2 py-1.5">
816+
<div className="flex flex-wrap gap-1">
817+
{CLAUDE_KEYS.map((qk) => (
818+
<button key={qk.label} {...repeatProps(qk.key)} disabled={!activeSessionId} title={qk.title} className={keyBtn}>
819+
{qk.label}
820+
</button>
821+
))}
822+
<button onClick={handleExitCli} disabled={!activeSessionId} title="Exit Claude Code" className={exitBtn}>
823+
exit
824+
</button>
825+
</div>
826+
</div>
827+
)}
828+
{activeGroup === CLAUDE_TAB && inEditor !== "claude" && (
829+
<div className="border-b border-gray-700/50 bg-[#12122a] px-3 py-2">
830+
{!toolVersions.claude ? (
831+
<span className="text-[11px] text-yellow-400">Claude Code is not installed. Install via: npm install -g @anthropic-ai/claude-code</span>
832+
) : (
833+
<div className="flex flex-col gap-1.5">
834+
<span className="text-[11px] text-gray-500">Claude Code <span className="text-gray-600">v{toolVersions.claude}</span></span>
835+
<div className="flex flex-wrap gap-1.5">
836+
{CLAUDE_LAUNCH_OPTIONS.map((opt) => (
837+
<button
838+
key={opt.label}
839+
onClick={() => handleLaunchCli("claude", opt.command)}
840+
disabled={!activeSessionId}
841+
title={opt.title}
842+
className="px-2.5 py-1 text-[11px] bg-[#1a1a2e] text-purple-400 hover:text-purple-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-purple-800 whitespace-nowrap transition-colors select-none"
843+
>
844+
{opt.label}
845+
</button>
846+
))}
847+
</div>
848+
</div>
849+
)}
850+
</div>
851+
)}
852+
853+
{/* Codex CLI popup: actions when inside, launcher when outside */}
854+
{activeGroup === CODEX_TAB && inEditor === "codex" && (
855+
<div className="border-b border-gray-700/50 bg-[#12122a] px-2 py-1.5">
856+
<div className="flex flex-wrap gap-1">
857+
{CODEX_KEYS.map((qk) => (
858+
<button key={qk.label} {...repeatProps(qk.key)} disabled={!activeSessionId} title={qk.title} className={keyBtn}>
859+
{qk.label}
860+
</button>
861+
))}
862+
<button onClick={handleExitCli} disabled={!activeSessionId} title="Exit Codex CLI" className={exitBtn}>
863+
exit
864+
</button>
865+
</div>
866+
</div>
867+
)}
868+
{activeGroup === CODEX_TAB && inEditor !== "codex" && (
869+
<div className="border-b border-gray-700/50 bg-[#12122a] px-3 py-2">
870+
{!toolVersions.codex ? (
871+
<span className="text-[11px] text-yellow-400">Codex CLI is not installed. Install via: npm install -g @openai/codex</span>
872+
) : (
873+
<div className="flex flex-col gap-1.5">
874+
<span className="text-[11px] text-gray-500">Codex CLI <span className="text-gray-600">v{toolVersions.codex}</span></span>
875+
<div className="flex flex-wrap gap-1.5">
876+
{CODEX_LAUNCH_OPTIONS.map((opt) => (
877+
<button
878+
key={opt.label}
879+
onClick={() => handleLaunchCli("codex", opt.command)}
880+
disabled={!activeSessionId}
881+
title={opt.title}
882+
className="px-2.5 py-1 text-[11px] bg-[#1a1a2e] text-orange-400 hover:text-orange-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-orange-800 whitespace-nowrap transition-colors select-none"
883+
>
884+
{opt.label}
885+
</button>
886+
))}
887+
</div>
888+
</div>
889+
)}
890+
</div>
891+
)}
892+
893+
{/* OpenCode popup: actions when inside, launcher when outside */}
894+
{activeGroup === OPENCODE_TAB && inEditor === "opencode" && (
895+
<div className="border-b border-gray-700/50 bg-[#12122a] px-2 py-1.5">
896+
<div className="flex flex-wrap gap-1">
897+
{OPENCODE_KEYS.map((qk) => (
898+
<button key={qk.label} {...repeatProps(qk.key)} disabled={!activeSessionId} title={qk.title} className={keyBtn}>
899+
{qk.label}
900+
</button>
901+
))}
902+
<button onClick={handleExitCli} disabled={!activeSessionId} title="Exit OpenCode" className={exitBtn}>
903+
exit
904+
</button>
905+
</div>
906+
</div>
907+
)}
908+
{activeGroup === OPENCODE_TAB && inEditor !== "opencode" && (
909+
<div className="border-b border-gray-700/50 bg-[#12122a] px-3 py-2">
910+
{!toolVersions.opencode ? (
911+
<span className="text-[11px] text-yellow-400">OpenCode is not installed. See: https://github.com/opencode-ai/opencode</span>
912+
) : (
913+
<div className="flex flex-col gap-1.5">
914+
<span className="text-[11px] text-gray-500">OpenCode <span className="text-gray-600">v{toolVersions.opencode}</span></span>
915+
<div className="flex flex-wrap gap-1.5">
916+
{OPENCODE_LAUNCH_OPTIONS.map((opt) => (
917+
<button
918+
key={opt.label}
919+
onClick={() => handleLaunchCli("opencode", opt.command)}
920+
disabled={!activeSessionId}
921+
title={opt.title}
922+
className="px-2.5 py-1 text-[11px] bg-[#1a1a2e] text-cyan-400 hover:text-cyan-200 hover:bg-[#2a2a4a] disabled:text-gray-600 rounded border border-cyan-800 whitespace-nowrap transition-colors select-none"
923+
>
924+
{opt.label}
925+
</button>
926+
))}
927+
</div>
928+
</div>
929+
)}
930+
</div>
931+
)}
932+
730933
{/* Text input */}
731934
<div className="flex gap-2 p-2">
732935
<textarea

app/routes/api/tool-versions.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,34 @@ function getVimVersion(): string | null {
3636
return match?.[1] || null;
3737
}
3838

39+
function getClaudeVersion(): string | null {
40+
const out = run("claude --version");
41+
if (!out) return null;
42+
const match = out.match(/([\d.]+)/);
43+
return match?.[1] || out.split("\n")[0].slice(0, 30);
44+
}
45+
46+
function getCodexVersion(): string | null {
47+
const out = run("codex --version");
48+
if (!out) return null;
49+
const match = out.match(/([\d.]+)/);
50+
return match?.[1] || out.split("\n")[0].slice(0, 30);
51+
}
52+
53+
function getOpencodeVersion(): string | null {
54+
const out = run("opencode version");
55+
if (!out) return null;
56+
const match = out.match(/([\d.]+)/);
57+
return match?.[1] || out.split("\n")[0].slice(0, 30);
58+
}
59+
3960
export async function loader({ request }: Route.LoaderArgs) {
4061
return Response.json({
4162
tmux: getTmuxVersion(),
4263
nano: getNanoVersion(),
4364
vim: getVimVersion(),
65+
claude: getClaudeVersion(),
66+
codex: getCodexVersion(),
67+
opencode: getOpencodeVersion(),
4468
});
4569
}

app/stores/terminalStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface TerminalSession {
1313
error: string | null;
1414
outputBuffer: string[];
1515
inTmux: boolean;
16-
inEditor: "nano" | "vim" | null;
16+
inEditor: "nano" | "vim" | "claude" | "codex" | "opencode" | null;
1717
}
1818

1919
interface TerminalState {
@@ -34,7 +34,7 @@ interface TerminalState {
3434
setFontSize: (size: number) => void;
3535
setDefaultCwd: (cwd: string) => void;
3636
setInTmux: (sessionId: string, inTmux: boolean) => void;
37-
setInEditor: (sessionId: string, editor: "nano" | "vim" | null) => void;
37+
setInEditor: (sessionId: string, editor: "nano" | "vim" | "claude" | "codex" | "opencode" | null) => void;
3838
}
3939

4040
let socketInitialized = false;

vite.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import { readFileSync } from "fs";
12
import { reactRouter } from "@react-router/dev/vite";
23
import tailwindcss from "@tailwindcss/vite";
34
import { defineConfig } from "vite";
45
import tsconfigPaths from "vite-tsconfig-paths";
56

7+
const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
8+
69
export default defineConfig({
10+
define: {
11+
__APP_VERSION__: JSON.stringify(pkg.version),
12+
},
713
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
814
ssr: {
915
noExternal: [],

0 commit comments

Comments
 (0)