diff --git a/README.md b/README.md index e3652a1..1233b82 100644 --- a/README.md +++ b/README.md @@ -170,11 +170,34 @@ Create `~/.agent-view/config.json` to customize defaults: | `keybind` | No | Direct keybind, e.g. `"1"`, `"ctrl+1"` | | `command` | No | Custom command (required when `tool` is `custom`) | +## Remote Sessions + +Manage AI coding sessions running on remote machines (dev boxes, cloud VMs, etc.) from your local Agent View dashboard. + +### Setup + +1. Install `av` on the remote machine +2. Ensure SSH access is configured (key-based auth recommended) +3. Press `Shift+N` to create a remote session + +### Creating Remote Sessions + +Press `Shift+N` to open the remote session wizard: + +1. **SSH Host** - Enter the SSH destination (e.g., `user@hostname` or an SSH config name) +2. **av Path** - Path to `av` binary on remote (default: `av`) +3. **Tool** - Select the AI tool to use +4. **Project Path** - Working directory on the remote machine +5. **Title** - Optional session name + +Values are remembered for next time. + ## Requirements - [Bun](https://bun.sh) runtime - [tmux](https://github.com/tmux/tmux) for session management - At least one AI coding tool installed (claude, gemini, opencode, etc.) +- For remote sessions: SSH access to remote host with `av` installed ## Acknowledgments diff --git a/src/core/config.ts b/src/core/config.ts index b7a8ec0..0345af6 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,6 +13,13 @@ export interface WorktreeConfig { autoCleanup?: boolean } +export interface LastRemoteSession { + host: string + avPath: string + tool: string + projectPath: string +} + export interface AppConfig { defaultTool?: Tool theme?: string @@ -22,6 +29,7 @@ export interface AppConfig { recents?: Recent[] autoHibernateMinutes?: number // 0 = disabled, default 0 autoHibernatePrompted?: boolean // true = user has seen the prompt + lastRemoteSession?: LastRemoteSession // Last used remote session values } const CONFIG_DIR = path.join(os.homedir(), ".agent-view") @@ -97,6 +105,21 @@ export function getRecents(): Recent[] { return cachedConfig.recents || [] } +/** + * Get last remote session values + */ +export function getLastRemoteSession(): LastRemoteSession | undefined { + return cachedConfig.lastRemoteSession +} + +/** + * Save last remote session values + */ +export async function saveLastRemoteSession(session: LastRemoteSession): Promise { + const config = await loadConfig() + await saveConfig({ ...config, lastRemoteSession: session }) +} + /** * Get the cached config synchronously * Call loadConfig() first to ensure config is loaded diff --git a/src/core/recents.ts b/src/core/recents.ts index f1ce7cc..6949e96 100644 --- a/src/core/recents.ts +++ b/src/core/recents.ts @@ -15,11 +15,12 @@ const MAX_RECENTS = 15 export function addRecent(recents: Recent[], newRecent: Recent): Recent[] { const list = [...recents] - // Dedupe by projectPath + tool + name (allows same folder with different names) + // Dedupe by projectPath + tool + name + remoteHost (allows same folder with different names) const existingIdx = list.findIndex(r => r.projectPath === newRecent.projectPath && r.tool === newRecent.tool && - r.name === newRecent.name + r.name === newRecent.name && + r.remoteHost === newRecent.remoteHost ) // Remove existing if found, then prepend (most recent first) diff --git a/src/core/remote.ts b/src/core/remote.ts new file mode 100644 index 0000000..e383fcb --- /dev/null +++ b/src/core/remote.ts @@ -0,0 +1,261 @@ +/** + * Remote session manager + * Coordinates fetching and managing sessions across multiple remote hosts + */ + +import { getLastRemoteSession } from "./config" +import { SSHRunner } from "./ssh" +import type { RemoteSession } from "./types" +import path from "path" +import os from "os" +import fs from "fs" + +const logFile = path.join(os.homedir(), ".agent-orchestrator", "debug.log") +function log(...args: unknown[]) { + const msg = `[${new Date().toISOString()}] [REMOTE] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")}\n` + try { fs.appendFileSync(logFile, msg) } catch {} +} + +export class RemoteManager { + private runners: Map = new Map() + private cachedSessions: RemoteSession[] = [] + private lastFetchTime: number = 0 + private fetchPromise: Promise | null = null + + /** + * Get SSH runners for known remote hosts + */ + getRunners(): SSHRunner[] { + const runners: SSHRunner[] = [] + + // Only use last session host if available + const lastSession = getLastRemoteSession() + if (lastSession) { + const runner = new SSHRunner(lastSession.host, lastSession.host, lastSession.avPath) + this.runners.set(lastSession.host, runner) + runners.push(runner) + } + + return runners + } + + /** + * Get runner for a specific host + */ + getRunner(host: string): SSHRunner | null { + // Check last remote session for avPath + const lastSession = getLastRemoteSession() + const avPath = (lastSession && lastSession.host === host) ? lastSession.avPath : "av" + + const runner = new SSHRunner(host, host, avPath) + this.runners.set(host, runner) + return runner + } + + /** + * Fetch sessions from known remote hosts + * Uses caching to avoid excessive SSH connections + */ + async fetchAllSessions(forceRefresh = false): Promise { + const runners = this.getRunners() + + // No known remote hosts + if (runners.length === 0) { + this.cachedSessions = [] + return [] + } + + // Return cached if recent and not forced + const now = Date.now() + const cacheAge = now - this.lastFetchTime + if (!forceRefresh && cacheAge < 5000 && this.cachedSessions.length > 0) { + return this.cachedSessions + } + + // Deduplicate concurrent fetches + if (this.fetchPromise) { + return this.fetchPromise + } + + this.fetchPromise = this.doFetchAll() + + try { + const sessions = await this.fetchPromise + this.cachedSessions = sessions + this.lastFetchTime = Date.now() + return sessions + } finally { + this.fetchPromise = null + } + } + + private async doFetchAll(): Promise { + const runners = this.getRunners() + + if (runners.length === 0) { + return [] + } + + log(`Fetching sessions from ${runners.length} remotes`) + + // Fetch from all remotes in parallel with timeout + const results = await Promise.allSettled( + runners.map(async (runner) => { + try { + return await runner.fetchSessions() + } catch (err: any) { + log(`Failed to fetch from remote: ${err.message}`) + return [] as RemoteSession[] + } + }) + ) + + // Collect all successful results + const allSessions: RemoteSession[] = [] + for (const result of results) { + if (result.status === "fulfilled") { + allSessions.push(...result.value) + } + } + + log(`Fetched ${allSessions.length} remote sessions`) + return allSessions + } + + /** + * Get cached sessions without triggering a fetch + */ + getCachedSessions(): RemoteSession[] { + return this.cachedSessions + } + + /** + * Clear the session cache + */ + clearCache(): void { + this.cachedSessions = [] + this.lastFetchTime = 0 + } + + /** + * Stop a remote session + */ + async stopSession(session: RemoteSession): Promise { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + await runner.stop(session.id) + this.clearCache() + } + + /** + * Restart a remote session + */ + async restartSession(session: RemoteSession): Promise { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + await runner.restart(session.id) + this.clearCache() + } + + /** + * Delete a remote session + */ + async deleteSession(session: RemoteSession): Promise { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + await runner.delete(session.id) + this.clearCache() + } + + /** + * Hibernate a remote session + */ + async hibernateSession(session: RemoteSession): Promise { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + await runner.hibernate(session.id) + this.clearCache() + } + + /** + * Resume a remote session + */ + async resumeSession(session: RemoteSession): Promise { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + await runner.resume(session.id) + this.clearCache() + } + + /** + * Attach to a remote session + * Returns true if Ctrl+L (session list) was requested + */ + attachSession(session: RemoteSession): boolean { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + return runner.attachSync(session.id) + } + + /** + * Create a new session on a remote host + */ + async createSession(remoteName: string, options: { + title?: string + projectPath: string + tool: string + group?: string + command?: string + }): Promise<{ success: boolean; error?: string }> { + const runner = this.getRunner(remoteName) + if (!runner) { + return { success: false, error: `Remote "${remoteName}" not found` } + } + + const result = await runner.create(options) + if (result.success) { + this.clearCache() + } + return result + } + + /** + * Test connectivity to all remotes + */ + async testAllConnections(): Promise> { + const runners = this.getRunners() + const results = new Map() + + await Promise.allSettled( + runners.map(async (runner) => { + const name = runner["name"] + const result = await runner.testConnection() + results.set(name, result) + }) + ) + + return results + } +} + +// Singleton instance +let remoteManager: RemoteManager | null = null + +export function getRemoteManager(): RemoteManager { + if (!remoteManager) { + remoteManager = new RemoteManager() + } + return remoteManager +} diff --git a/src/core/ssh.test.ts b/src/core/ssh.test.ts new file mode 100644 index 0000000..9382faa --- /dev/null +++ b/src/core/ssh.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect } from "bun:test" + +import { SSHRunner } from "./ssh" + +describe("SSHRunner", () => { + describe("constructor", () => { + test("creates runner with required parameters", () => { + const runner = new SSHRunner("myremote", "user@host") + expect(runner).toBeDefined() + }) + + test("creates runner with custom av path", () => { + const runner = new SSHRunner("myremote", "user@host", "/custom/path/av") + expect(runner).toBeDefined() + }) + }) + + describe("argument quoting", () => { + // Test that arguments with spaces are properly quoted + // We can't test the actual SSH execution, but we can verify the runner handles various inputs + + test("handles simple arguments", async () => { + const runner = new SSHRunner("test", "localhost", "av") + // This will fail to connect but we're testing argument handling + try { + await runner.run(["--list", "--json"]) + } catch { + // Expected to fail - no SSH connection + } + }) + + test("handles arguments with spaces", async () => { + const runner = new SSHRunner("test", "localhost", "av") + try { + await runner.run(["--title", "My Session Name"]) + } catch { + // Expected to fail - no SSH connection + } + }) + + test("handles arguments with quotes", async () => { + const runner = new SSHRunner("test", "localhost", "av") + try { + await runner.run(["--title", "Session's \"Name\""]) + } catch { + // Expected to fail - no SSH connection + } + }) + }) + + describe("fetchSessions", () => { + test("returns empty array on connection failure", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + const sessions = await runner.fetchSessions() + expect(sessions).toEqual([]) + }) + }) + + describe("testConnection", () => { + test("returns error for invalid host", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + const result = await runner.testConnection() + expect(result.ok).toBe(false) + expect(result.error).toBeDefined() + }) + }) + + describe("checkAvailable", () => { + test("returns error for invalid host", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + const result = await runner.checkAvailable() + expect(result.ok).toBe(false) + expect(result.error).toBeDefined() + }) + }) + + describe("installAv", () => { + test("returns error for invalid host", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + const result = await runner.installAv() + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + }) + + describe("create", () => { + test("returns error for invalid host", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + const result = await runner.create({ + projectPath: "/home/user/project", + tool: "claude", + }) + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + + test("builds correct arguments for basic session", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + // We can verify the runner doesn't throw for valid inputs + const result = await runner.create({ + projectPath: "/home/user/project", + tool: "claude", + title: "My Session", + group: "work", + }) + expect(result.success).toBe(false) // Connection fails + }) + + test("handles custom tool with command", async () => { + const runner = new SSHRunner("test", "nonexistent-host-12345", "av") + const result = await runner.create({ + projectPath: "/home/user/project", + tool: "custom", + command: "./my-script.sh", + }) + expect(result.success).toBe(false) // Connection fails + }) + }) +}) + +describe("isRemoteSession type guard", () => { + // Import the type guard + const { isRemoteSession } = require("./types") + + test("returns true for remote session", () => { + const remoteSession = { + id: "123", + title: "Test", + projectPath: "/path", + tool: "claude", + status: "running", + groupPath: "@remote/group", + createdAt: new Date(), + lastAccessed: new Date(), + acknowledged: true, + remoteName: "myremote", + remoteHost: "user@host", + } + expect(isRemoteSession(remoteSession)).toBe(true) + }) + + test("returns false for local session", () => { + const localSession = { + id: "123", + title: "Test", + projectPath: "/path", + tool: "claude", + status: "running", + groupPath: "default", + createdAt: new Date(), + lastAccessed: new Date(), + acknowledged: true, + } + expect(isRemoteSession(localSession)).toBe(false) + }) +}) diff --git a/src/core/ssh.ts b/src/core/ssh.ts new file mode 100644 index 0000000..9a46c65 --- /dev/null +++ b/src/core/ssh.ts @@ -0,0 +1,370 @@ +/** + * SSH runner for executing commands on remote hosts + * Manages agent-view sessions on remote machines via SSH + */ + +import { spawn } from "child_process" +import { promisify } from "util" +import { exec, execFile } from "child_process" +import path from "path" +import os from "os" +import fs from "fs" +import type { Session, RemoteSession, SessionStatus, Tool } from "./types" + +const execAsync = promisify(exec) +const execFileAsync = promisify(execFile) + +// SSH ControlMaster settings for connection reuse +const SSH_CONTROL_DIR = "/tmp/agent-view-ssh" +const SSH_CONTROL_PERSIST = 600 // seconds +const SSH_TIMEOUT = 10 // seconds + +const logFile = path.join(os.homedir(), ".agent-orchestrator", "debug.log") +function log(...args: unknown[]) { + const msg = `[${new Date().toISOString()}] [SSH] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")}\n` + try { fs.appendFileSync(logFile, msg) } catch {} +} + +/** + * Ensure the SSH control directory exists + */ +function ensureControlDir(): void { + try { + if (!fs.existsSync(SSH_CONTROL_DIR)) { + fs.mkdirSync(SSH_CONTROL_DIR, { recursive: true, mode: 0o700 }) + } + } catch { + // Ignore errors - connection will work without ControlMaster + } +} + +/** + * Build SSH options for connection reuse + */ +function sshOptions(host: string): string[] { + ensureControlDir() + return [ + "-o", "ControlMaster=auto", + "-o", `ControlPath=${SSH_CONTROL_DIR}/%r@%h:%p`, + "-o", `ControlPersist=${SSH_CONTROL_PERSIST}`, + "-o", "BatchMode=yes", + "-o", `ConnectTimeout=${SSH_TIMEOUT}`, + "-o", "StrictHostKeyChecking=accept-new", + ] +} + +export class SSHRunner { + constructor( + private name: string, + private host: string, + private avPath: string = "av" + ) {} + + /** + * Execute an av command on the remote host + */ + async run(args: string[]): Promise { + // Build the remote command as a single quoted string + // This preserves arguments with spaces when passed through SSH + const quotedArgs = args.map(arg => { + // If arg contains spaces or special chars, quote it + if (arg.includes(" ") || arg.includes("'") || arg.includes('"')) { + // Escape single quotes and wrap in single quotes + return `'${arg.replace(/'/g, "'\\''")}'` + } + return arg + }) + const remoteCommand = `${this.avPath} ${quotedArgs.join(" ")}` + + const sshArgs = [ + ...sshOptions(this.host), + this.host, + remoteCommand + ] + + log(`Running SSH command: ssh ${sshArgs.join(" ")}`) + + try { + const { stdout, stderr } = await execFileAsync("ssh", sshArgs, { + timeout: SSH_TIMEOUT * 1000, + maxBuffer: 10 * 1024 * 1024 // 10MB + }) + + if (stderr) { + log(`SSH stderr: ${stderr}`) + } + + return stdout + } catch (err: any) { + log(`SSH error: ${err.message}`) + throw new Error(`SSH to ${this.name}: ${err.message}`) + } + } + + /** + * Fetch sessions from remote via `av --list --json` + */ + async fetchSessions(): Promise { + try { + const output = await this.run(["--list", "--json"]) + + if (!output.trim()) { + return [] + } + + const sessions = JSON.parse(output) as Session[] + + return sessions.map(s => ({ + ...s, + // Parse dates from JSON + createdAt: new Date(s.createdAt), + lastAccessed: new Date(s.lastAccessed), + // Add remote metadata + remoteName: this.name, + remoteHost: this.host, + // Prefix group path with remote name for display + groupPath: `@${this.name}/${s.groupPath}` + })) + } catch (err: any) { + log(`Failed to fetch sessions from ${this.name}: ${err.message}`) + return [] + } + } + + /** + * Attach to a remote session interactively via SSH + */ + attach(sessionId: string): void { + log(`Attaching to remote session ${sessionId} on ${this.name}`) + + // Exit alternate screen buffer before attaching + process.stdout.write("\x1b[?1049l") + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[?25h") + + const sshArgs = [ + "-t", // Force TTY allocation + "-o", `ConnectTimeout=${SSH_TIMEOUT}`, + "-o", "StrictHostKeyChecking=accept-new", + this.host, + this.avPath, + "--attach", + sessionId + ] + + const child = spawn("ssh", sshArgs, { + stdio: "inherit", + env: process.env + }) + + child.on("exit", () => { + // Clear screen and re-enter alternate buffer for TUI + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[?1049h") + process.stdout.write("\x1b]0;Agent View\x07") + }) + } + + /** + * Attach synchronously (blocks until detach) + * Returns true if Ctrl+L (session list) was requested + */ + attachSync(sessionId: string): boolean { + log(`Attaching sync to remote session ${sessionId} on ${this.name}`) + const { spawnSync } = require("child_process") + + // Exit alternate screen buffer + process.stdout.write("\x1b[?1049l") + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[?25h") + + const sshArgs = [ + "-t", + "-o", `ConnectTimeout=${SSH_TIMEOUT}`, + "-o", "StrictHostKeyChecking=accept-new", + this.host, + this.avPath, + "--attach", + sessionId + ] + + spawnSync("ssh", sshArgs, { + stdio: "inherit", + env: process.env + }) + + // Check if Ctrl+L was pressed on remote by checking signal file + let sessionListRequested = false + try { + const checkResult = spawnSync("ssh", [ + ...sshOptions(this.host), + this.host, + "test -f /tmp/agent-view-session-list && rm /tmp/agent-view-session-list && echo yes" + ], { encoding: "utf-8", timeout: 5000 }) + sessionListRequested = checkResult.stdout?.trim() === "yes" + } catch { + // Ignore errors + } + + // Clear screen and re-enter alternate buffer for TUI + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[?1049h") + process.stdout.write("\x1b]0;Agent View\x07") + + return sessionListRequested + } + + /** + * Stop a remote session + */ + async stop(sessionId: string): Promise { + await this.run(["--stop", sessionId]) + } + + /** + * Restart a remote session + */ + async restart(sessionId: string): Promise { + await this.run(["--restart", sessionId]) + } + + /** + * Delete a remote session (with --force to skip confirmation) + */ + async delete(sessionId: string): Promise { + await this.run(["--delete", sessionId, "--force"]) + } + + /** + * Hibernate a remote session (Claude only) + */ + async hibernate(sessionId: string): Promise { + await this.run(["--hibernate", sessionId]) + } + + /** + * Resume a remote session (Claude only) - uses 'wake' CLI command + */ + async resume(sessionId: string): Promise { + await this.run(["--wake", sessionId]) + } + + /** + * Test SSH connectivity to the remote host + */ + async testConnection(): Promise<{ ok: boolean; error?: string }> { + try { + const sshArgs = [ + ...sshOptions(this.host), + this.host, + "echo", "ok" + ] + + await execFileAsync("ssh", sshArgs, { + timeout: SSH_TIMEOUT * 1000 + }) + + return { ok: true } + } catch (err: any) { + return { ok: false, error: err.message } + } + } + + /** + * Check if av is available on the remote host + * Checks configured path first, then default install location + */ + async checkAvailable(): Promise<{ ok: boolean; version?: string; path?: string; error?: string }> { + // First try the configured avPath + try { + const output = await this.run(["-v"]) + const version = output.trim() + return { ok: true, version, path: this.avPath } + } catch { + // Configured path failed, try default install location + } + + // Try default install path + const defaultPath = "~/.agent-view/bin/av" + if (this.avPath !== defaultPath) { + try { + const sshArgs = [ + ...sshOptions(this.host), + this.host, + `${defaultPath} -v` + ] + const { stdout } = await execFileAsync("ssh", sshArgs, { + timeout: SSH_TIMEOUT * 1000 + }) + const version = stdout.trim() + return { ok: true, version, path: defaultPath } + } catch { + // Default path also failed + } + } + + return { ok: false, error: "av not found on remote" } + } + + /** + * Install av on the remote host using the install script + */ + async installAv(): Promise<{ success: boolean; error?: string }> { + try { + // Don't use BatchMode for install - it needs to run curl | bash + const sshArgs = [ + "-o", `ConnectTimeout=${SSH_TIMEOUT}`, + "-o", "StrictHostKeyChecking=accept-new", + this.host, + "curl -fsSL https://raw.githubusercontent.com/frayo44/agent-view/main/install.sh | bash" + ] + + log(`Installing av on remote: ssh ${sshArgs.join(" ")}`) + + const { stdout, stderr } = await execFileAsync("ssh", sshArgs, { + timeout: 180000, // 3 minutes for install + maxBuffer: 10 * 1024 * 1024 + }) + + log(`Install stdout: ${stdout}`) + if (stderr) { + log(`Install stderr: ${stderr}`) + } + + return { success: true } + } catch (err: any) { + log(`Install error: ${err.message}`) + return { success: false, error: err.message } + } + } + + /** + * Create a new session on the remote host + */ + async create(options: { + title?: string + projectPath: string + tool: string + group?: string + command?: string + }): Promise<{ success: boolean; error?: string }> { + const args = ["--new", "--path", options.projectPath, "--tool", options.tool] + + if (options.title) { + args.push("--title", options.title) + } + if (options.group) { + args.push("--group", options.group) + } + if (options.command && options.tool === "custom") { + args.push("--command", options.command) + } + + try { + await this.run(args) + return { success: true } + } catch (err: any) { + return { success: false, error: err.message } + } + } +} diff --git a/src/core/types.ts b/src/core/types.ts index d8a437a..d664da4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -40,6 +40,11 @@ export interface Session { acknowledged: boolean } +export interface RemoteSession extends Session { + remoteName: string // Key from remotes config + remoteHost: string // SSH host +} + export interface Group { path: string name: string @@ -120,6 +125,10 @@ export interface Recent { projectPath: string // Working directory tool: Tool // Tool type groupPath?: string // Target group (created if missing) + // Remote session fields (optional) + remoteHost?: string // SSH host for remote sessions + remoteAvPath?: string // av binary path on remote + command?: string // Custom command (when tool === "custom") } export interface Config { @@ -133,6 +142,10 @@ export interface Config { recents?: Recent[] } +export function isRemoteSession(session: Session): session is RemoteSession { + return "remoteName" in session && "remoteHost" in session +} + export function getToolCommand(tool: Tool, customCmd?: string): string { switch (tool) { case "claude": diff --git a/src/tui/app.tsx b/src/tui/app.tsx index f5c091a..60ee984 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -30,6 +30,7 @@ import { ToastProvider, useToast } from "@tui/ui/toast" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogSessions } from "@tui/component/dialog-sessions" import { DialogNew } from "@tui/component/dialog-new" +import { DialogNewRemote } from "@tui/component/dialog-new-remote" import { DialogUpdate } from "@tui/component/dialog-update" import { checkForUpdate } from "@/core/updater" import { Home } from "@tui/routes/home" @@ -232,13 +233,20 @@ function App(props: { onExit: () => Promise; onRendererReady: (r: CliRende command.open() } - if (evt.name === "n") { + if (evt.name === "n" && !evt.shift) { evt.preventDefault() log("Opening new dialog from App") dialog.replace(() => ) } - if (evt.name === "l") { + if (evt.name === "n" && evt.shift) { + evt.preventDefault() + log("Opening new remote dialog from App") + dialog.replace(() => ) + } + + if (evt.name === "l" && evt.ctrl) { + evt.preventDefault() log("Opening sessions dialog from App") dialog.replace(() => ) } @@ -257,7 +265,7 @@ function App(props: { onExit: () => Promise; onRendererReady: (r: CliRende if (evt.name === "?") { toast.show({ title: "Help", - message: "Ctrl+K: Commands | L: Sessions | N: New | Q: Quit", + message: "Ctrl+K: Commands | Ctrl+L: Sessions | N: New | Q: Quit", variant: "info", duration: 5000 }) diff --git a/src/tui/component/dialog-new-remote.tsx b/src/tui/component/dialog-new-remote.tsx new file mode 100644 index 0000000..f410585 --- /dev/null +++ b/src/tui/component/dialog-new-remote.tsx @@ -0,0 +1,399 @@ +/** + * New Remote Session dialog + * Step-by-step flow, pre-filled with last used values + */ + +import { createSignal } from "solid-js" +import { InputRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { useToast } from "@tui/ui/toast" +import { DialogSelect } from "@tui/ui/dialog-select" +import { DialogHeader } from "@tui/ui/dialog-header" +import { DialogFooter } from "@tui/ui/dialog-footer" +import { ActionButton } from "@tui/ui/action-button" +import { SSHRunner } from "@/core/ssh" +import { getLastRemoteSession, saveLastRemoteSession, getRecents, loadConfig, saveConfig } from "@/core/config" +import { addRecent } from "@/core/recents" +import type { Tool, Recent } from "@/core/types" + +const TOOL_OPTIONS: { title: string; value: Tool }[] = [ + { title: "Claude Code", value: "claude" }, + { title: "Shell", value: "shell" }, + { title: "OpenCode", value: "opencode" }, + { title: "Gemini CLI", value: "gemini" }, + { title: "Codex CLI", value: "codex" }, + { title: "Custom command", value: "custom" }, +] + +export function DialogNewRemote() { + const dialog = useDialog() + const toast = useToast() + + // Get last used values for defaults + const lastSession = getLastRemoteSession() + + const [host, setHost] = createSignal(lastSession?.host || "") + const [avPath, setAvPath] = createSignal(lastSession?.avPath || "av") + const [selectedTool, setSelectedTool] = createSignal((lastSession?.tool as Tool) || "claude") + const [customCommand, setCustomCommand] = createSignal("") + const [projectPath, setProjectPath] = createSignal(lastSession?.projectPath || "~") + const [title, setTitle] = createSignal("") + const [creating, setCreating] = createSignal(false) + + async function doCreate(runner: SSHRunner, hostVal: string, pathVal: string) { + const result = await runner.create({ + title: title().trim() || undefined, + projectPath: pathVal, + tool: selectedTool(), + command: selectedTool() === "custom" ? customCommand() : undefined, + }) + + if (result.success) { + // Save last used values + await saveLastRemoteSession({ + host: hostVal, + avPath: avPath() || "av", + tool: selectedTool(), + projectPath: pathVal, + }) + + // Save to recents + const sessionName = title().trim() || pathVal.split("/").pop() || "remote" + const newRecent: Recent = { + name: sessionName, + projectPath: pathVal, + tool: selectedTool(), + remoteHost: hostVal, + remoteAvPath: avPath() || "av", + command: selectedTool() === "custom" ? customCommand() : undefined, + } + const config = await loadConfig() + const updatedRecents = addRecent(getRecents(), newRecent) + await saveConfig({ ...config, recents: updatedRecents }) + + toast.show({ + message: `Created session on ${hostVal}`, + variant: "success", + duration: 2000 + }) + dialog.clear() + } else { + toast.show({ + message: result.error || "Failed to create session", + variant: "error", + duration: 3000 + }) + } + } + + async function handleCreate() { + if (creating()) return + + const hostVal = host().trim() + const pathVal = projectPath().trim() + + if (!hostVal) { + toast.show({ message: "Host is required", variant: "error", duration: 2000 }) + return + } + if (!pathVal) { + toast.show({ message: "Project path is required", variant: "error", duration: 2000 }) + return + } + + setCreating(true) + + try { + const runner = new SSHRunner("remote", hostVal, avPath() || "av") + + // Check if av is available on remote + const avCheck = await runner.checkAvailable() + if (!avCheck.ok) { + // av not found - prompt to install + setCreating(false) + showInstallPrompt(runner, hostVal, pathVal) + return + } + + // If av was found at a different path than configured, use that path + if (avCheck.path && avCheck.path !== avPath()) { + setAvPath(avCheck.path) + const newRunner = new SSHRunner("remote", hostVal, avCheck.path) + await doCreate(newRunner, hostVal, pathVal) + } else { + await doCreate(runner, hostVal, pathVal) + } + } catch (err) { + toast.error(err as Error) + } finally { + setCreating(false) + } + } + + // Show prompt to install av on remote + function showInstallPrompt(runner: SSHRunner, hostVal: string, pathVal: string) { + dialog.replace(() => ( + { + if (opt.value === "cancel") { + dialog.clear() + return + } + + // Show installing status + dialog.replace(() => ( + + + + This may take a minute... + + + )) + + const installResult = await runner.installAv() + if (installResult.success) { + toast.show({ message: "av installed successfully", variant: "success", duration: 2000 }) + + // Update avPath to use full path (shell PATH may not be set in non-interactive mode) + const fullAvPath = "~/.agent-view/bin/av" + setAvPath(fullAvPath) + const newRunner = new SSHRunner("remote", hostVal, fullAvPath) + + // Now create the session + setCreating(true) + try { + await doCreate(newRunner, hostVal, pathVal) + } catch (err) { + toast.error(err as Error) + } finally { + setCreating(false) + } + } else { + toast.show({ + message: `Failed to install av: ${installResult.error}`, + variant: "error", + duration: 5000 + }) + dialog.clear() + } + }} + /> + )) + dialog.setSize("large") + } + + // Step 1: Enter host + function showHostStep() { + dialog.replace(() => ( + { + if (!h.trim()) return + setHost(h.trim()) + showAvPathStep() + }} + /> + )) + dialog.setSize("large") + } + + // Step 2: Enter av path + function showAvPathStep() { + dialog.replace(() => ( + { + setAvPath(path.trim() || "av") + showToolStep() + }} + /> + )) + dialog.setSize("large") + } + + // Step 3: Select tool + function showToolStep() { + dialog.replace(() => ( + { + setSelectedTool(opt.value) + if (opt.value === "custom") { + showCommandStep() + } else { + showPathStep() + } + }} + /> + )) + dialog.setSize("large") + } + + // Step 3.5: Enter custom command + function showCommandStep() { + dialog.replace(() => ( + { + if (!cmd.trim()) return + setCustomCommand(cmd.trim()) + showPathStep() + }} + /> + )) + dialog.setSize("large") + } + + // Step 4: Enter project path + function showPathStep() { + dialog.replace(() => ( + { + if (!path.trim()) return + setProjectPath(path.trim()) + showTitleStep() + }} + /> + )) + dialog.setSize("large") + } + + // Step 5: Enter title and create + function showTitleStep() { + dialog.replace(() => ( + { + setTitle(t) + handleCreate() + }} + /> + )) + dialog.setSize("large") + } + + // Start + showHostStep() + + return <> +} + +// Generic input step +function InputStep(props: { + title: string + hint: string + value: string + placeholder: string + onSubmit: (value: string) => void +}) { + const { theme } = useTheme() + const [value, setValue] = createSignal(props.value) + + let inputRef: InputRenderable | undefined + + useKeyboard((evt) => { + if (evt.name === "return" && !evt.shift) { + evt.preventDefault() + props.onSubmit(value()) + } + }) + + return ( + + + + {props.hint} + { + inputRef = r + setTimeout(() => inputRef?.focus(), 1) + }} + /> + + + + ) +} + +// Final step +function FinalStep(props: { + host: string + tool: string + path: string + creating: boolean + onSubmit: (title: string) => void +}) { + const { theme } = useTheme() + const [title, setTitle] = createSignal("") + + let inputRef: InputRenderable | undefined + + useKeyboard((evt) => { + if (evt.name === "return" && !evt.shift) { + evt.preventDefault() + props.onSubmit(title().trim()) + } + }) + + return ( + + + + Tool: {props.tool} + Path: {props.path} + + Title (optional): + { + inputRef = r + setTimeout(() => inputRef?.focus(), 1) + }} + /> + + props.onSubmit(title().trim())} + /> + + + ) +} diff --git a/src/tui/component/dialog-recents.tsx b/src/tui/component/dialog-recents.tsx index 9c368cc..2805431 100644 --- a/src/tui/component/dialog-recents.tsx +++ b/src/tui/component/dialog-recents.tsx @@ -12,6 +12,7 @@ import { useToast } from "@tui/ui/toast" import { DialogHeader } from "@tui/ui/dialog-header" import { DialogFooter } from "@tui/ui/dialog-footer" import { getRecents } from "@/core/config" +import { SSHRunner } from "@/core/ssh" import { createListNavigation } from "@tui/util/navigation" import type { Recent } from "@/core/types" @@ -66,34 +67,64 @@ export function DialogRecents() { async function handleExecute(recent: Recent) { if (executing()) return setExecuting(true) - setStatusMessage(`Creating session from ${recent.name}...`) + + const isRemote = !!recent.remoteHost + setStatusMessage(`Creating ${isRemote ? "remote " : ""}session from ${recent.name}...`) try { - // Ensure group exists (create if missing) - if (recent.groupPath) { - const existingGroups = sync.group.list() - const groupExists = existingGroups.some(g => g.path === recent.groupPath) - if (!groupExists) { - sync.group.create(recent.groupPath) + if (isRemote) { + // Create remote session + const runner = new SSHRunner("remote", recent.remoteHost!, recent.remoteAvPath || "av") + const result = await runner.create({ + title: recent.name, + projectPath: recent.projectPath, + tool: recent.tool, + command: recent.command, + }) + + if (result.success) { + toast.show({ + message: `Created session on ${recent.remoteHost}`, + variant: "success", + duration: 2000 + }) + dialog.clear() + sync.refreshRemote() + } else { + toast.show({ + message: result.error || "Failed to create remote session", + variant: "error", + duration: 3000 + }) + } + } else { + // Create local session + // Ensure group exists (create if missing) + if (recent.groupPath) { + const existingGroups = sync.group.list() + const groupExists = existingGroups.some(g => g.path === recent.groupPath) + if (!groupExists) { + sync.group.create(recent.groupPath) + } } - } - const session = await sync.session.create({ - title: recent.name, - projectPath: recent.projectPath, - tool: recent.tool, - groupPath: recent.groupPath, - claudeOptions: { sessionMode: "new" } // Always start fresh - }) + const session = await sync.session.create({ + title: recent.name, + projectPath: recent.projectPath, + tool: recent.tool, + groupPath: recent.groupPath, + claudeOptions: { sessionMode: "new" } // Always start fresh + }) - toast.show({ - message: `Created session '${session.title}'`, - variant: "success", - duration: 2000 - }) + toast.show({ + message: `Created session '${session.title}'`, + variant: "success", + duration: 2000 + }) - dialog.clear() - sync.refresh() + dialog.clear() + sync.refresh() + } } catch (err) { toast.error(err as Error) } finally { @@ -197,11 +228,18 @@ export function DialogRecents() { {recent.name} + {/* Remote indicator */} + + + {" "}@{recent.remoteHost} + + + {/* Spacer */} {/* Group */} - + {recent.groupPath} diff --git a/src/tui/component/dialog-sessions.tsx b/src/tui/component/dialog-sessions.tsx index a9ba636..fd0af9b 100644 --- a/src/tui/component/dialog-sessions.tsx +++ b/src/tui/component/dialog-sessions.tsx @@ -13,7 +13,8 @@ import { useDialog } from "@tui/ui/dialog" import { useToast } from "@tui/ui/toast" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { attachSessionSync, wasSessionListRequested } from "@/core/tmux" -import type { Session, SessionStatus } from "@/core/types" +import type { Session, SessionStatus, RemoteSession } from "@/core/types" +import { isRemoteSession } from "@/core/types" import { formatSmartTime, truncatePath } from "@tui/util/locale" import { STATUS_ICONS } from "@tui/util/status" @@ -27,17 +28,21 @@ export function DialogSessions() { const { theme } = useTheme() const renderer = useRenderer() + // Use large dialog for better display of session info + dialog.setSize("large") + const currentSessionId = createMemo(() => { return route.data.type === "session" ? route.data.sessionId : undefined }) - // Build options grouped by status + // Build options grouped by status (local + remote) const options = createMemo[]>(() => { - const sessions = sync.session.list() const byStatus = sync.session.byStatus() + const remoteSessions = sync.remote.list() const result: DialogSelectOption[] = [] + // Local sessions grouped by status for (const status of STATUS_ORDER) { const sessionsInStatus = byStatus[status] || [] if (sessionsInStatus.length === 0) continue @@ -54,10 +59,41 @@ export function DialogSessions() { } } + // Remote sessions + if (remoteSessions.length > 0) { + for (const session of remoteSessions) { + result.push({ + title: `${session.title} @${session.remoteName}`, + value: `remote:${session.remoteName}:${session.id}`, + category: `🌐 Remote (${remoteSessions.length})`, + description: truncatePath(session.projectPath), + footer: session.status, + gutter: + }) + } + } + return result }) async function handleDelete(sessionId: string) { + // Handle remote session delete + if (sessionId.startsWith("remote:")) { + const parts = sessionId.split(":") + const remoteName = parts[1] + const remoteSessionId = parts.slice(2).join(":") + const remoteSession = sync.remote.list().find(s => s.remoteName === remoteName && s.id === remoteSessionId) + if (!remoteSession) return + + try { + await sync.remote.delete(remoteSession) + toast.show({ message: `Deleted remote session`, variant: "info", duration: 2000 }) + } catch (err) { + toast.error(err as Error) + } + return + } + const session = sync.session.get(sessionId) if (!session) return @@ -99,6 +135,23 @@ export function DialogSessions() { } async function handleRestart(sessionId: string) { + // Handle remote session restart + if (sessionId.startsWith("remote:")) { + const parts = sessionId.split(":") + const remoteName = parts[1] + const remoteSessionId = parts.slice(2).join(":") + const remoteSession = sync.remote.list().find(s => s.remoteName === remoteName && s.id === remoteSessionId) + if (!remoteSession) return + + try { + await sync.remote.restart(remoteSession) + toast.show({ message: "Remote session restarted", variant: "success", duration: 2000 }) + } catch (err) { + toast.error(err as Error) + } + return + } + try { await sync.session.restart(sessionId) toast.show({ message: "Session restarted", variant: "success", duration: 2000 }) @@ -108,6 +161,12 @@ export function DialogSessions() { } async function handleFork(sessionId: string) { + // Fork not supported for remote sessions + if (sessionId.startsWith("remote:")) { + toast.show({ message: "Fork not supported for remote sessions", variant: "error", duration: 2000 }) + return + } + try { const forked = await sync.session.fork({ sourceSessionId: sessionId }) toast.show({ message: `Forked as ${forked.title}`, variant: "success", duration: 2000 }) @@ -119,6 +178,40 @@ export function DialogSessions() { } function handleAttach(sessionId: string) { + // Check if this is a remote session + if (sessionId.startsWith("remote:")) { + const parts = sessionId.split(":") + const remoteName = parts[1] + const remoteSessionId = parts.slice(2).join(":") + + const remoteSession = sync.remote.list().find(s => s.remoteName === remoteName && s.id === remoteSessionId) + if (!remoteSession) { + toast.show({ message: "Remote session not found", variant: "error", duration: 2000 }) + return + } + + // Suspend the TUI and attach to remote + renderer.suspend() + let sessionListRequested = false + try { + sessionListRequested = sync.remote.attach(remoteSession) + } catch (err) { + console.error("Remote attach error:", err) + } + renderer.resume() + sync.refresh() + sync.refreshRemote() + + // Check if Ctrl+L was pressed on remote + if (sessionListRequested) { + dialog.replace(() => ) + } else { + dialog.clear() + } + return + } + + // Local session const session = sync.session.get(sessionId) if (!session) { toast.show({ message: "Session not found", variant: "error", duration: 2000 }) @@ -169,6 +262,10 @@ export function DialogSessions() { { key: "r", title: "Restart", onTrigger: (opt) => handleRestart(opt.value) }, { key: "f", title: "Fork", onTrigger: (opt) => handleFork(opt.value) }, { key: "v", title: "View", onTrigger: (opt) => { + if (opt.value.startsWith("remote:")) { + toast.show({ message: "View not supported for remote sessions", variant: "error", duration: 2000 }) + return + } route.navigate({ type: "session", sessionId: opt.value }) dialog.clear() }} diff --git a/src/tui/context/sync.tsx b/src/tui/context/sync.tsx index 8d6689d..4215b69 100644 --- a/src/tui/context/sync.tsx +++ b/src/tui/context/sync.tsx @@ -7,7 +7,9 @@ import { createSignal, createEffect, onCleanup, batch } from "solid-js" import { createStore, produce } from "solid-js/store" import { getStorage } from "@/core/storage" import { getSessionManager } from "@/core/session" -import type { Session, Group, Config } from "@/core/types" +import { getRemoteManager } from "@/core/remote" +import type { Session, Group, Config, RemoteSession } from "@/core/types" +import { isRemoteSession } from "@/core/types" import { createSimpleContext } from "./helper" export type SyncStatus = "loading" | "partial" | "complete" @@ -20,10 +22,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sessions: Session[] groups: Group[] config: Config + remoteSessions: RemoteSession[] }>({ sessions: [], groups: [], - config: {} + config: {}, + remoteSessions: [] }) // In-memory reactive store for per-session memory usage (KB) @@ -32,6 +36,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // Initial load const storage = getStorage() const manager = getSessionManager() + const remoteManager = getRemoteManager() // Load sessions and groups function refresh() { @@ -55,7 +60,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) } + // Refresh remote sessions (async, doesn't block) + async function refreshRemote(force = false) { + try { + const remoteSessions = await remoteManager.fetchAllSessions(force) + setStore("remoteSessions", remoteSessions) + } catch { + // Ignore errors - remote sessions are optional + } + } + refresh() + refreshRemote() setStatus("complete") // Start refresh loop @@ -71,8 +87,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } }, 200) + // Poll remote sessions less frequently (every 10 seconds) + const remotePollInterval = setInterval(() => { + refreshRemote() + }, 10000) + onCleanup(() => { clearInterval(pollInterval) + clearInterval(remotePollInterval) manager.stopRefreshLoop() }) @@ -210,7 +232,52 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ refresh() } }, - refresh + remote: { + list(): RemoteSession[] { + return store.remoteSessions + }, + async refresh(): Promise { + await refreshRemote(true) + }, + async stop(session: RemoteSession): Promise { + await remoteManager.stopSession(session) + await refreshRemote(true) + }, + async restart(session: RemoteSession): Promise { + await remoteManager.restartSession(session) + await refreshRemote(true) + }, + async delete(session: RemoteSession): Promise { + await remoteManager.deleteSession(session) + await refreshRemote(true) + }, + async hibernate(session: RemoteSession): Promise { + await remoteManager.hibernateSession(session) + await refreshRemote(true) + }, + async resume(session: RemoteSession): Promise { + await remoteManager.resumeSession(session) + await refreshRemote(true) + }, + attach(session: RemoteSession): boolean { + return remoteManager.attachSession(session) + }, + async create(remoteName: string, options: { + title?: string + projectPath: string + tool: string + group?: string + command?: string + }): Promise<{ success: boolean; error?: string }> { + const result = await remoteManager.createSession(remoteName, options) + if (result.success) { + await refreshRemote(true) + } + return result + }, + }, + refresh, + refreshRemote } } }) diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index e65f0f9..4c3e158 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -20,6 +20,7 @@ import { DialogMove } from "@tui/component/dialog-move" import { DialogShortcuts } from "@tui/component/dialog-shortcuts" import { DialogRecents } from "@tui/component/dialog-recents" import { DialogSettings } from "@tui/component/dialog-settings" +import { DialogNewRemote } from "@tui/component/dialog-new-remote" import { DialogHelp } from "@tui/component/dialog-help" import { getShortcuts } from "@/core/config" import { executeShortcut, getShortcutGroupPath } from "@/core/shortcut" @@ -28,7 +29,8 @@ import { useKV } from "@tui/context/kv" import { DialogUpdate } from "@tui/component/dialog-update" import { attachSessionSync, capturePane, wasCommandPaletteRequested, wasSessionListRequested, sendKeys } from "@/core/tmux" import { useCommandDialog } from "@tui/component/dialog-command" -import type { Session, Group } from "@/core/types" +import type { Session, Group, RemoteSession } from "@/core/types" +import { isRemoteSession } from "@/core/types" import { formatRelativeTime, truncatePath } from "@tui/util/locale" import { STATUS_ICONS } from "@tui/util/status" import { sortSessionsByCreatedAt } from "@tui/util/session" @@ -118,11 +120,17 @@ export function Home() { // Calculate longest session/group title for dynamic panel sizing const longestTitleLen = createMemo(() => { const sessions = sync.session.list() + const remoteSessions = sync.remote.list() const groups = sync.group.list() let maxLen = 0 for (const s of sessions) { if (s.title.length > maxLen) maxLen = s.title.length } + for (const s of remoteSessions) { + // Remote sessions show "title @host" so include host length + const displayLen = s.title.length + s.remoteName.length + 2 + if (displayLen > maxLen) maxLen = displayLen + } for (const g of groups) { if (g.name.length > maxLen) maxLen = g.name.length } @@ -157,7 +165,9 @@ export function Home() { } }) - const allSessions = createMemo(() => sync.session.list()) + const localSessions = createMemo(() => sync.session.list()) + const remoteSessions = createMemo(() => sync.remote.list()) + const allSessions = createMemo(() => [...localSessions(), ...remoteSessions()]) const groupedItems = createMemo(() => { const groups = ensureDefaultGroup(sync.group.list()) @@ -253,10 +263,12 @@ export function Home() { const stats = createMemo(() => { const byStatus = sync.session.byStatus() + const remotes = remoteSessions() return { - running: byStatus.running.length, - waiting: byStatus.waiting.length, - total: sync.session.list().length + running: byStatus.running.length + remotes.filter(s => s.status === "running").length, + waiting: byStatus.waiting.length + remotes.filter(s => s.status === "waiting").length, + total: sync.session.list().length, + remoteTotal: remotes.length } }) @@ -293,25 +305,80 @@ export function Home() { function doAttach(session: Session) { previewFetchAbort = true renderer.suspend() + let remoteSessionListRequested = false try { - attachSessionSync(session.tmuxSession) + if (isRemoteSession(session)) { + // Attach to remote session via SSH - returns true if Ctrl+L was pressed + remoteSessionListRequested = sync.remote.attach(session) + } else { + attachSessionSync(session.tmuxSession) + } } catch (err) { console.error("Attach error:", err) } renderer.resume() sync.refresh() - // Check if user pressed Ctrl+K to open command palette - if (wasCommandPaletteRequested()) { - command.open() - } - // Check if user pressed Ctrl+L to open session list - if (wasSessionListRequested()) { - dialog.replace(() => ) + if (isRemoteSession(session)) { + sync.refreshRemote() + // Check if Ctrl+L was pressed on remote + if (remoteSessionListRequested) { + dialog.replace(() => ) + } + } else { + // Check if user pressed Ctrl+K to open command palette (local only) + if (wasCommandPaletteRequested()) { + command.open() + } + // Check if user pressed Ctrl+L to open session list (local only) + if (wasSessionListRequested()) { + dialog.replace(() => ) + } } } function handleAttach(session: Session) { + // For remote sessions, check remoteName instead of tmuxSession + if (isRemoteSession(session)) { + // If remote session is stopped or hibernated, offer to resume or restart + if (session.status === "stopped" || session.status === "hibernated") { + const isClaudeWithSession = session.tool === "claude" && session.toolData?.claudeSessionId + const options = [ + ...(isClaudeWithSession + ? [{ title: "Resume session", value: "resume" }] + : []), + { title: "Restart session", value: "restart" }, + ] + + dialog.replace(() => ( + { + dialog.clear() + try { + if (opt.value === "resume") { + await sync.remote.resume(session) + } else { + await sync.remote.restart(session) + } + toast.show({ message: `Session ${opt.value === "resume" ? "resumed" : "restarted"}`, variant: "success", duration: 2000 }) + await sync.refreshRemote() + doAttach(session) + } catch (err) { + toast.error(err as Error) + } + }} + /> + )) + return + } + + doAttach(session) + return + } + + // Local session handling if (!session.tmuxSession) { toast.show({ message: "Session has no tmux session", variant: "error", duration: 2000 }) return @@ -356,6 +423,31 @@ export function Home() { } async function handleDelete(session: Session) { + // Handle remote session deletion + if (isRemoteSession(session)) { + dialog.replace(() => ( + { + dialog.clear() + if (opt.value === "cancel") return + try { + await sync.remote.delete(session) + toast.show({ message: `Deleted ${session.title} on @${session.remoteName}`, variant: "info", duration: 2000 }) + } catch (err) { + toast.error(err as Error) + } + }} + /> + )) + return + } + + // Local session deletion if (session.worktreePath) { dialog.replace(() => ( ( + { + dialog.clear() + if (opt.value === "cancel") return + try { + await sync.session.delete(session.id) + toast.show({ message: `Deleted ${session.title}`, variant: "info", duration: 2000 }) + } catch (err) { + toast.error(err as Error) + } + }} + /> + )) } async function handleRestart(session: Session) { try { - await sync.session.restart(session.id) - toast.show({ message: "Session restarted", variant: "success", duration: 2000 }) - sync.refresh() + if (isRemoteSession(session)) { + await sync.remote.restart(session) + toast.show({ message: `Session restarted on @${session.remoteName}`, variant: "success", duration: 2000 }) + await sync.refreshRemote() + } else { + await sync.session.restart(session.id) + toast.show({ message: "Session restarted", variant: "success", duration: 2000 }) + sync.refresh() + } } catch (err) { toast.error(err as Error) } @@ -417,6 +530,12 @@ export function Home() { async function handleFork(session: Session) { log("handleFork called for session:", session.id, "tool:", session.tool, "projectPath:", session.projectPath) + if (isRemoteSession(session)) { + log("Fork rejected: remote session") + toast.show({ message: "Remote sessions cannot be forked from here", variant: "error", duration: 2000 }) + return + } + if (session.tool !== "claude") { log("Fork rejected: not a claude session") toast.show({ message: "Only Claude sessions can be forked", variant: "error", duration: 2000 }) @@ -451,9 +570,15 @@ export function Home() { async function handleHibernate(session: Session) { try { - await sync.session.hibernate(session.id) - toast.show({ message: `Hibernated ${session.title}`, variant: "success", duration: 2000 }) - sync.refresh() + if (isRemoteSession(session)) { + await sync.remote.hibernate(session) + toast.show({ message: `Hibernated ${session.title} on @${session.remoteName}`, variant: "success", duration: 2000 }) + await sync.refreshRemote() + } else { + await sync.session.hibernate(session.id) + toast.show({ message: `Hibernated ${session.title}`, variant: "success", duration: 2000 }) + sync.refresh() + } } catch (err) { toast.error(err as Error) } @@ -575,6 +700,10 @@ export function Home() { if (evt.name === "r" && evt.shift) { const item = selectedItem() if (item?.type === "session" && item.session) { + if (isRemoteSession(item.session)) { + toast.show({ message: "Rename not supported for remote sessions", variant: "error", duration: 2000 }) + return + } dialog.push(() => ) } else if (item?.type === "group" && item.group) { dialog.push(() => ) @@ -590,6 +719,10 @@ export function Home() { if (evt.name === "m") { const session = selectedSession() if (session) { + if (isRemoteSession(session)) { + toast.show({ message: "Move not supported for remote sessions", variant: "error", duration: 2000 }) + return + } dialog.push(() => ) } } @@ -609,6 +742,10 @@ export function Home() { evt.preventDefault() const session = selectedSession() if (session) { + if (isRemoteSession(session)) { + toast.show({ message: "Remote sessions cannot be forked from here", variant: "error", duration: 2000 }) + return + } if (session.tool !== "claude") { toast.show({ message: "Only Claude sessions can be forked", variant: "error", duration: 2000 }) return @@ -638,13 +775,19 @@ export function Home() { // y to quick-confirm a waiting session (sends Enter without attaching) if (evt.name === "y" && !evt.shift && !evt.ctrl) { const session = selectedSession() - if (session && session.status === "waiting" && session.tmuxSession) { - sendKeys(session.tmuxSession, "").then(() => { - toast.show({ message: "✓ Confirmed", variant: "success", duration: 1500 }) - sync.refresh() - }).catch((err) => { - toast.error(err as Error) - }) + if (session && session.status === "waiting") { + if (isRemoteSession(session)) { + toast.show({ message: "Quick confirm not supported for remote sessions", variant: "error", duration: 2000 }) + return + } + if (session.tmuxSession) { + sendKeys(session.tmuxSession, "").then(() => { + toast.show({ message: "✓ Confirmed", variant: "success", duration: 1500 }) + sync.refresh() + }).catch((err) => { + toast.error(err as Error) + }) + } } return } @@ -682,6 +825,12 @@ export function Home() { return } + // N (Shift+n) to create new remote session + if (evt.name === "n" && evt.shift) { + dialog.push(() => ) + return + } + const currentShortcuts = shortcuts() for (const shortcut of currentShortcuts) { if (shortcut.keybind && keybind.matchDynamic(shortcut.keybind, evt)) { @@ -754,6 +903,7 @@ export function Home() { function SessionItem(props: { session: Session; index: number; indented?: boolean }) { const isSelected = createMemo(() => props.index === selectedIndex()) + const isRemote = createMemo(() => isRemoteSession(props.session)) const statusColor = createMemo(() => { switch (props.session.status) { case "running": return theme.success @@ -776,6 +926,11 @@ export function Home() { if (!useDualColumn()) { reserved += 8 // tool name + space in single column mode } + // Reserve space for remote indicator + if (isRemote()) { + const remoteName = (props.session as RemoteSession).remoteName + reserved += remoteName.length + 2 // "@name " + } return reserved }) @@ -815,6 +970,13 @@ export function Home() { {title()} + {/* Remote indicator */} + + + {" @" + (props.session as RemoteSession).remoteName} + + + {/* Spacer */} @@ -892,6 +1054,9 @@ export function Home() { {s().worktreeBranch} + + @{(s() as RemoteSession).remoteName} + {/* Separator */} @@ -956,6 +1121,9 @@ export function Home() { ◐ {stats().waiting} {stats().total} sessions + 0}> + ({stats().remoteTotal} remote) + diff --git a/src/tui/ui/dialog-input.tsx b/src/tui/ui/dialog-input.tsx new file mode 100644 index 0000000..fe07af0 --- /dev/null +++ b/src/tui/ui/dialog-input.tsx @@ -0,0 +1,73 @@ +/** + * Simple input dialog component + * Used for collecting single text values + */ + +import { createSignal } from "solid-js" +import { InputRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { DialogHeader } from "@tui/ui/dialog-header" +import { DialogFooter } from "@tui/ui/dialog-footer" +import { ActionButton } from "@tui/ui/action-button" + +export interface DialogInputProps { + title: string + placeholder?: string + initialValue?: string + onSubmit: (value: string) => void +} + +export function DialogInput(props: DialogInputProps) { + const dialog = useDialog() + const { theme } = useTheme() + + const [value, setValue] = createSignal(props.initialValue || "") + const [submitting, setSubmitting] = createSignal(false) + + let inputRef: InputRenderable | undefined + + function handleSubmit() { + if (submitting()) return + setSubmitting(true) + props.onSubmit(value()) + } + + useKeyboard((evt) => { + if (evt.name === "return" && !evt.shift) { + evt.preventDefault() + handleSubmit() + } + }) + + return ( + + + + + { + inputRef = r + setTimeout(() => inputRef?.focus(), 1) + }} + /> + + + + + + + ) +} diff --git a/src/tui/ui/index.ts b/src/tui/ui/index.ts index 84fbe8a..d848f0f 100644 --- a/src/tui/ui/index.ts +++ b/src/tui/ui/index.ts @@ -4,6 +4,7 @@ export { Dialog, DialogProvider, useDialog, scrollDialogBy, scrollDialogTo } from "./dialog" export { DialogSelect, type DialogSelectOption, type DialogSelectProps } from "./dialog-select" +export { DialogInput, type DialogInputProps } from "./dialog-input" export { DialogHeader } from "./dialog-header" export { DialogFooter } from "./dialog-footer" export { ActionButton } from "./action-button"