From 2a1fbf623ca6e9a417ddfa027017898cb2681024 Mon Sep 17 00:00:00 2001 From: Yoav Franko Date: Sat, 7 Mar 2026 14:43:11 +0200 Subject: [PATCH 1/6] feat: add remote sessions support via SSH - Add RemoteConfig to config.ts for storing remote host settings - Add RemoteSession type extending Session with remote metadata - Create SSHRunner class for executing av commands on remote hosts - Create RemoteManager for coordinating multiple remote hosts - Integrate remote sessions into TUI (home screen, settings dialog) - Add DialogInput component for remote configuration UI --- src/core/config.ts | 16 +- src/core/remote.ts | 258 ++++++++++++++++++++++++++ src/core/ssh.ts | 258 ++++++++++++++++++++++++++ src/core/types.ts | 9 + src/tui/component/dialog-settings.tsx | 208 ++++++++++++++++++++- src/tui/context/sync.tsx | 60 +++++- src/tui/routes/home.tsx | 145 +++++++++++++-- src/tui/ui/dialog-input.tsx | 73 ++++++++ src/tui/ui/index.ts | 1 + 9 files changed, 1007 insertions(+), 21 deletions(-) create mode 100644 src/core/remote.ts create mode 100644 src/core/ssh.ts create mode 100644 src/tui/ui/dialog-input.tsx diff --git a/src/core/config.ts b/src/core/config.ts index b7a8ec0..8807eb9 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,6 +13,11 @@ export interface WorktreeConfig { autoCleanup?: boolean } +export interface RemoteConfig { + host: string // SSH destination (e.g., "user@host") + avPath?: string // Remote agent-view/av binary path (default: "av") +} + export interface AppConfig { defaultTool?: Tool theme?: string @@ -22,6 +27,7 @@ export interface AppConfig { recents?: Recent[] autoHibernateMinutes?: number // 0 = disabled, default 0 autoHibernatePrompted?: boolean // true = user has seen the prompt + remotes?: Record // Named remote hosts for SSH sessions } const CONFIG_DIR = path.join(os.homedir(), ".agent-view") @@ -67,7 +73,8 @@ export async function loadConfig(): Promise { }, // Shortcuts array is replaced entirely, not merged with defaults shortcuts: parsed.shortcuts || [], - recents: parsed.recents || [] + recents: parsed.recents || [], + remotes: parsed.remotes || {} } return cachedConfig @@ -97,6 +104,13 @@ export function getRecents(): Recent[] { return cachedConfig.recents || [] } +/** + * Get remotes from the cached config + */ +export function getRemotes(): Record { + return cachedConfig.remotes || {} +} + /** * Get the cached config synchronously * Call loadConfig() first to ensure config is loaded diff --git a/src/core/remote.ts b/src/core/remote.ts new file mode 100644 index 0000000..22c7346 --- /dev/null +++ b/src/core/remote.ts @@ -0,0 +1,258 @@ +/** + * Remote session manager + * Coordinates fetching and managing sessions across multiple remote hosts + */ + +import { getRemotes, type RemoteConfig } 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 or create SSH runners for all configured remotes + */ + getRunners(): SSHRunner[] { + const remotes = getRemotes() + const runners: SSHRunner[] = [] + + for (const [name, config] of Object.entries(remotes)) { + let runner = this.runners.get(name) + + // Create new runner or update if config changed + if (!runner || runner["host"] !== config.host) { + runner = new SSHRunner(name, config.host, config.avPath) + this.runners.set(name, runner) + } + + runners.push(runner) + } + + // Remove runners for deleted remotes + for (const name of this.runners.keys()) { + if (!(name in remotes)) { + this.runners.delete(name) + } + } + + return runners + } + + /** + * Get runner for a specific remote + */ + getRunner(remoteName: string): SSHRunner | null { + const remotes = getRemotes() + const config = remotes[remoteName] + + if (!config) { + return null + } + + let runner = this.runners.get(remoteName) + if (!runner) { + runner = new SSHRunner(remoteName, config.host, config.avPath) + this.runners.set(remoteName, runner) + } + + return runner + } + + /** + * Fetch sessions from all configured remotes in parallel + * Uses caching to avoid excessive SSH connections + */ + async fetchAllSessions(forceRefresh = false): Promise { + const remotes = getRemotes() + const remoteNames = Object.keys(remotes) + + // No remotes configured + if (remoteNames.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 + */ + attachSession(session: RemoteSession): void { + const runner = this.getRunner(session.remoteName) + if (!runner) { + throw new Error(`Remote "${session.remoteName}" not found`) + } + runner.attachSync(session.id) + } + + /** + * 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.ts b/src/core/ssh.ts new file mode 100644 index 0000000..f63959b --- /dev/null +++ b/src/core/ssh.ts @@ -0,0 +1,258 @@ +/** + * 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 { + const sshArgs = [ + ...sshOptions(this.host), + this.host, + this.avPath, + ...args + ] + + 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) + */ + attachSync(sessionId: string): void { + 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 + }) + + // 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") + } + + /** + * 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 + */ + async checkAvailable(): Promise<{ ok: boolean; version?: string; error?: string }> { + try { + const output = await this.run(["-v"]) + const version = output.trim() + return { ok: true, version } + } catch (err: any) { + return { ok: false, error: err.message } + } + } +} diff --git a/src/core/types.ts b/src/core/types.ts index d8a437a..f8cdeb1 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 @@ -133,6 +138,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/component/dialog-settings.tsx b/src/tui/component/dialog-settings.tsx index 9d7a6fc..fde42d5 100644 --- a/src/tui/component/dialog-settings.tsx +++ b/src/tui/component/dialog-settings.tsx @@ -5,10 +5,11 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" +import { DialogInput } from "@tui/ui/dialog-input" import { useToast } from "@tui/ui/toast" import { useTheme } from "@tui/context/theme" import { useSync } from "@tui/context/sync" -import { getConfig, loadConfig, saveConfig } from "@/core/config" +import { getConfig, loadConfig, saveConfig, type RemoteConfig } from "@/core/config" import type { Tool } from "@/core/types" const TOOL_OPTIONS: { title: string; value: Tool }[] = [ @@ -43,6 +44,7 @@ export function DialogSettings() { function showSettingsList() { const config = getConfig() + const remoteCount = Object.keys(config.remotes || {}).length const options = [ { title: "Default tool", @@ -64,6 +66,11 @@ export function DialogSettings() { value: "autoHibernate" as const, footer: formatHibernate(config.autoHibernateMinutes || 0), }, + { + title: "Remote hosts", + value: "remotes" as const, + footer: remoteCount > 0 ? `${remoteCount} configured` : "none", + }, ] dialog.replace(() => ( @@ -77,6 +84,7 @@ export function DialogSettings() { case "theme": return showTheme() case "defaultGroup": return showDefaultGroup() case "autoHibernate": return showAutoHibernate() + case "remotes": return showRemotes() } }} /> @@ -162,6 +170,204 @@ export function DialogSettings() { )) } + function showRemotes() { + const config = getConfig() + const remotes = config.remotes || {} + const remoteNames = Object.keys(remotes) + + const options = [ + { title: "+ Add remote", value: { action: "add" } as const }, + ...remoteNames.map(name => ({ + title: name, + value: { action: "edit" as const, name }, + footer: remotes[name]!.host + })) + ] + + dialog.replace(() => ( + { + if (opt.value.action === "add") { + showAddRemote() + } else { + showEditRemote(opt.value.name) + } + }} + /> + )) + } + + function showAddRemote() { + dialog.replace(() => ( + { + if (!name.trim()) { + toast.show({ message: "Name is required", variant: "error", duration: 2000 }) + showRemotes() + return + } + const config = getConfig() + if (config.remotes?.[name]) { + toast.show({ message: "Remote already exists", variant: "error", duration: 2000 }) + showRemotes() + return + } + showAddRemoteHost(name.trim()) + }} + /> + )) + } + + function showAddRemoteHost(name: string) { + dialog.replace(() => ( + { + if (!host.trim()) { + toast.show({ message: "Host is required", variant: "error", duration: 2000 }) + showRemotes() + return + } + showAddRemoteAvPath(name, host.trim()) + }} + /> + )) + } + + function showAddRemoteAvPath(name: string, host: string) { + dialog.replace(() => ( + { + const config = await loadConfig() + const remotes = { ...config.remotes } + remotes[name] = { + host, + avPath: avPath.trim() || undefined + } + await saveConfig({ ...config, remotes }) + toast.show({ message: `Added remote "${name}"`, variant: "success", duration: 2000 }) + sync.refreshRemote() + showRemotes() + }} + /> + )) + } + + function showEditRemote(name: string) { + const config = getConfig() + const remote = config.remotes?.[name] + if (!remote) { + showRemotes() + return + } + + const options = [ + { title: "Edit host", value: "host" as const, footer: remote.host }, + { title: "Edit av path", value: "avPath" as const, footer: remote.avPath || "av" }, + { title: "Remove", value: "remove" as const }, + { title: "Back", value: "back" as const }, + ] + + dialog.replace(() => ( + { + switch (opt.value) { + case "host": + showEditRemoteHost(name, remote) + break + case "avPath": + showEditRemoteAvPath(name, remote) + break + case "remove": + showRemoveRemote(name) + break + case "back": + showRemotes() + break + } + }} + /> + )) + } + + function showEditRemoteHost(name: string, remote: RemoteConfig) { + dialog.replace(() => ( + { + if (!host.trim()) { + toast.show({ message: "Host is required", variant: "error", duration: 2000 }) + showEditRemote(name) + return + } + const config = await loadConfig() + const remotes = { ...config.remotes } + remotes[name] = { ...remote, host: host.trim() } + await saveConfig({ ...config, remotes }) + toast.show({ message: "Host updated", variant: "success", duration: 1500 }) + sync.refreshRemote() + showEditRemote(name) + }} + /> + )) + } + + function showEditRemoteAvPath(name: string, remote: RemoteConfig) { + dialog.replace(() => ( + { + const config = await loadConfig() + const remotes = { ...config.remotes } + remotes[name] = { ...remote, avPath: avPath.trim() || undefined } + await saveConfig({ ...config, remotes }) + toast.show({ message: "av path updated", variant: "success", duration: 1500 }) + sync.refreshRemote() + showEditRemote(name) + }} + /> + )) + } + + function showRemoveRemote(name: string) { + dialog.replace(() => ( + { + if (opt.value === "remove") { + const config = await loadConfig() + const remotes = { ...config.remotes } + delete remotes[name] + await saveConfig({ ...config, remotes }) + toast.show({ message: `Removed remote "${name}"`, variant: "info", duration: 2000 }) + sync.refreshRemote() + } + showRemotes() + }} + /> + )) + } + // Show the settings list on mount showSettingsList() diff --git a/src/tui/context/sync.tsx b/src/tui/context/sync.tsx index 8d6689d..62f6e4e 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,39 @@ 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): void { + remoteManager.attachSession(session) + } + }, + refresh, + refreshRemote } } }) diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index e65f0f9..30795ac 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -28,7 +28,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" @@ -157,7 +158,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 +256,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 } }) @@ -294,24 +299,70 @@ export function Home() { previewFetchAbort = true renderer.suspend() try { - attachSessionSync(session.tmuxSession) + if (isRemoteSession(session)) { + // Attach to remote session via SSH + 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()) { + // Check if user pressed Ctrl+K to open command palette (local only) + if (!isRemoteSession(session) && wasCommandPaletteRequested()) { command.open() } - // Check if user pressed Ctrl+L to open session list - if (wasSessionListRequested()) { + // Check if user pressed Ctrl+L to open session list (local only) + if (!isRemoteSession(session) && 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 +407,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(() => ( props.index === selectedIndex()) + const isRemote = createMemo(() => isRemoteSession(props.session)) const statusColor = createMemo(() => { switch (props.session.status) { case "running": return theme.success @@ -776,6 +871,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 +915,13 @@ export function Home() { {title()} + {/* Remote indicator */} + + + {" @" + (props.session as RemoteSession).remoteName} + + + {/* Spacer */} @@ -892,6 +999,9 @@ export function Home() { {s().worktreeBranch} + + @{(s() as RemoteSession).remoteName} + {/* Separator */} @@ -956,6 +1066,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" From b7d251964619c2b18c21c5b438bcc35f6003f52e Mon Sep 17 00:00:00 2001 From: Yoav Franko Date: Sat, 7 Mar 2026 14:56:49 +0200 Subject: [PATCH 2/6] feat: add remote session creation via TUI - Add create() method to SSHRunner for creating sessions via SSH - Add createSession() to RemoteManager - Add remote.create() and getRemoteNames() to sync context - Create DialogNewRemote component with step-by-step flow - Add Shift+N keybind to open new remote session dialog --- src/core/remote.ts | 22 ++ src/core/ssh.ts | 30 +++ src/tui/component/dialog-new-remote.tsx | 280 ++++++++++++++++++++++++ src/tui/context/sync.tsx | 17 ++ src/tui/routes/home.tsx | 7 + 5 files changed, 356 insertions(+) create mode 100644 src/tui/component/dialog-new-remote.tsx diff --git a/src/core/remote.ts b/src/core/remote.ts index 22c7346..7040924 100644 --- a/src/core/remote.ts +++ b/src/core/remote.ts @@ -228,6 +228,28 @@ export class RemoteManager { 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 */ diff --git a/src/core/ssh.ts b/src/core/ssh.ts index f63959b..747629b 100644 --- a/src/core/ssh.ts +++ b/src/core/ssh.ts @@ -255,4 +255,34 @@ export class SSHRunner { return { ok: 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/tui/component/dialog-new-remote.tsx b/src/tui/component/dialog-new-remote.tsx new file mode 100644 index 0000000..e8ec226 --- /dev/null +++ b/src/tui/component/dialog-new-remote.tsx @@ -0,0 +1,280 @@ +/** + * New Remote Session dialog + * Creates a session on a remote host via SSH + */ + +import { createSignal, Show } from "solid-js" +import { InputRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { useSync } from "@tui/context/sync" +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 type { Tool } from "@/core/types" + +type Step = "remote" | "tool" | "path" | "title" | "confirm" + +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" }, +] + +export function DialogNewRemote() { + const dialog = useDialog() + const sync = useSync() + const toast = useToast() + const { theme } = useTheme() + + const remoteNames = sync.remote.getRemoteNames() + + // If no remotes configured, show message + if (remoteNames.length === 0) { + return ( + + + + + No remotes configured. Press 'c' to open settings and add a remote host. + + + + + ) + } + + const [step, setStep] = createSignal("remote") + const [selectedRemote, setSelectedRemote] = createSignal("") + const [selectedTool, setSelectedTool] = createSignal("claude") + const [projectPath, setProjectPath] = createSignal("") + const [title, setTitle] = createSignal("") + const [creating, setCreating] = createSignal(false) + + let pathInputRef: InputRenderable | undefined + let titleInputRef: InputRenderable | undefined + + async function handleCreate() { + if (creating()) return + + const path = projectPath().trim() + if (!path) { + toast.show({ message: "Project path is required", variant: "error", duration: 2000 }) + return + } + + setCreating(true) + + try { + const result = await sync.remote.create(selectedRemote(), { + title: title().trim() || undefined, + projectPath: path, + tool: selectedTool(), + }) + + if (result.success) { + toast.show({ + message: `Created session on @${selectedRemote()}`, + variant: "success", + duration: 2000 + }) + dialog.clear() + } else { + toast.show({ + message: result.error || "Failed to create session", + variant: "error", + duration: 3000 + }) + } + } catch (err) { + toast.error(err as Error) + } finally { + setCreating(false) + } + } + + // Step 1: Select remote + function showRemoteStep() { + const options = remoteNames.map(name => ({ + title: `@${name}`, + value: name, + })) + + dialog.replace(() => ( + { + setSelectedRemote(opt.value) + setStep("tool") + showToolStep() + }} + /> + )) + } + + // Step 2: Select tool + function showToolStep() { + dialog.replace(() => ( + { + setSelectedTool(opt.value) + setStep("path") + showPathStep() + }} + /> + )) + } + + // Step 3: Enter path + function showPathStep() { + dialog.replace(() => ( + { + setProjectPath(path) + setStep("title") + showTitleStep() + }} + /> + )) + } + + // Step 4: Enter title (optional) + function showTitleStep() { + dialog.replace(() => ( + { + setTitle(t) + handleCreate() + }} + onSkip={() => { + handleCreate() + }} + creating={creating()} + /> + )) + } + + // Start with remote selection + showRemoteStep() + + return <> +} + +// Path input step component +function PathStep(props: { + remote: string + tool: string + value: string + onSubmit: (path: string) => void +}) { + const { theme } = useTheme() + const [path, setPath] = createSignal(props.value || "~") + + let inputRef: InputRenderable | undefined + + useKeyboard((evt) => { + if (evt.name === "return" && !evt.shift) { + evt.preventDefault() + const p = path().trim() + if (p) { + props.onSubmit(p) + } + } + }) + + return ( + + + + + Enter the project path on the remote host: + { + inputRef = r + setTimeout(() => inputRef?.focus(), 1) + }} + /> + + + + + ) +} + +// Title input step component +function TitleStep(props: { + remote: string + tool: string + path: string + value: string + onSubmit: (title: string) => void + onSkip: () => void + creating: boolean +}) { + const { theme } = useTheme() + const [title, setTitle] = createSignal(props.value) + + 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/context/sync.tsx b/src/tui/context/sync.tsx index 62f6e4e..45b7278 100644 --- a/src/tui/context/sync.tsx +++ b/src/tui/context/sync.tsx @@ -8,6 +8,7 @@ import { createStore, produce } from "solid-js/store" import { getStorage } from "@/core/storage" import { getSessionManager } from "@/core/session" import { getRemoteManager } from "@/core/remote" +import { getRemotes } from "@/core/config" import type { Session, Group, Config, RemoteSession } from "@/core/types" import { isRemoteSession } from "@/core/types" import { createSimpleContext } from "./helper" @@ -261,6 +262,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, attach(session: RemoteSession): void { 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 + }, + getRemoteNames(): string[] { + return Object.keys(getRemotes()) } }, refresh, diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index 30795ac..4110c00 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" @@ -776,6 +777,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)) { From 565419c0ec4bb44e3029a45728e7f1ca207f169e Mon Sep 17 00:00:00 2001 From: Yoav Franko Date: Sat, 7 Mar 2026 16:02:51 +0200 Subject: [PATCH 3/6] feat: complete remote sessions support - Shift+N opens step-by-step remote session creation dialog - Pre-fills fields with last used values from previous session - Ctrl+L sessions dialog includes remote sessions with attach/delete/restart - Ctrl+L works after detaching from remote (checks signal file via SSH) - Recents (o key) now supports remote sessions - Session panel width expands for remote session titles - Remove legacy remotes config from settings (simplified UX) - Use large dialog size for sessions and remote dialogs --- src/core/config.ts | 27 ++- src/core/recents.ts | 5 +- src/core/remote.ts | 61 +++---- src/core/ssh.ts | 33 +++- src/core/types.ts | 4 + src/tui/app.tsx | 14 +- src/tui/component/dialog-new-remote.tsx | 231 ++++++++++++++---------- src/tui/component/dialog-recents.tsx | 84 ++++++--- src/tui/component/dialog-sessions.tsx | 103 ++++++++++- src/tui/component/dialog-settings.tsx | 208 +-------------------- src/tui/context/sync.tsx | 10 +- src/tui/routes/home.tsx | 33 +++- 12 files changed, 412 insertions(+), 401 deletions(-) diff --git a/src/core/config.ts b/src/core/config.ts index 8807eb9..0345af6 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,9 +13,11 @@ export interface WorktreeConfig { autoCleanup?: boolean } -export interface RemoteConfig { - host: string // SSH destination (e.g., "user@host") - avPath?: string // Remote agent-view/av binary path (default: "av") +export interface LastRemoteSession { + host: string + avPath: string + tool: string + projectPath: string } export interface AppConfig { @@ -27,7 +29,7 @@ export interface AppConfig { recents?: Recent[] autoHibernateMinutes?: number // 0 = disabled, default 0 autoHibernatePrompted?: boolean // true = user has seen the prompt - remotes?: Record // Named remote hosts for SSH sessions + lastRemoteSession?: LastRemoteSession // Last used remote session values } const CONFIG_DIR = path.join(os.homedir(), ".agent-view") @@ -73,8 +75,7 @@ export async function loadConfig(): Promise { }, // Shortcuts array is replaced entirely, not merged with defaults shortcuts: parsed.shortcuts || [], - recents: parsed.recents || [], - remotes: parsed.remotes || {} + recents: parsed.recents || [] } return cachedConfig @@ -105,10 +106,18 @@ export function getRecents(): Recent[] { } /** - * Get remotes from the cached config + * Get last remote session values */ -export function getRemotes(): Record { - return cachedConfig.remotes || {} +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 }) } /** 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 index 7040924..e383fcb 100644 --- a/src/core/remote.ts +++ b/src/core/remote.ts @@ -3,7 +3,7 @@ * Coordinates fetching and managing sessions across multiple remote hosts */ -import { getRemotes, type RemoteConfig } from "./config" +import { getLastRemoteSession } from "./config" import { SSHRunner } from "./ssh" import type { RemoteSession } from "./types" import path from "path" @@ -23,64 +23,44 @@ export class RemoteManager { private fetchPromise: Promise | null = null /** - * Get or create SSH runners for all configured remotes + * Get SSH runners for known remote hosts */ getRunners(): SSHRunner[] { - const remotes = getRemotes() const runners: SSHRunner[] = [] - for (const [name, config] of Object.entries(remotes)) { - let runner = this.runners.get(name) - - // Create new runner or update if config changed - if (!runner || runner["host"] !== config.host) { - runner = new SSHRunner(name, config.host, config.avPath) - this.runners.set(name, runner) - } - + // 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) } - // Remove runners for deleted remotes - for (const name of this.runners.keys()) { - if (!(name in remotes)) { - this.runners.delete(name) - } - } - return runners } /** - * Get runner for a specific remote + * Get runner for a specific host */ - getRunner(remoteName: string): SSHRunner | null { - const remotes = getRemotes() - const config = remotes[remoteName] - - if (!config) { - return null - } - - let runner = this.runners.get(remoteName) - if (!runner) { - runner = new SSHRunner(remoteName, config.host, config.avPath) - this.runners.set(remoteName, runner) - } + 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 all configured remotes in parallel + * Fetch sessions from known remote hosts * Uses caching to avoid excessive SSH connections */ async fetchAllSessions(forceRefresh = false): Promise { - const remotes = getRemotes() - const remoteNames = Object.keys(remotes) + const runners = this.getRunners() - // No remotes configured - if (remoteNames.length === 0) { + // No known remote hosts + if (runners.length === 0) { this.cachedSessions = [] return [] } @@ -219,13 +199,14 @@ export class RemoteManager { /** * Attach to a remote session + * Returns true if Ctrl+L (session list) was requested */ - attachSession(session: RemoteSession): void { + attachSession(session: RemoteSession): boolean { const runner = this.getRunner(session.remoteName) if (!runner) { throw new Error(`Remote "${session.remoteName}" not found`) } - runner.attachSync(session.id) + return runner.attachSync(session.id) } /** diff --git a/src/core/ssh.ts b/src/core/ssh.ts index 747629b..3bb633f 100644 --- a/src/core/ssh.ts +++ b/src/core/ssh.ts @@ -64,11 +64,22 @@ export class SSHRunner { * 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, - this.avPath, - ...args + remoteCommand ] log(`Running SSH command: ssh ${sshArgs.join(" ")}`) @@ -156,8 +167,9 @@ export class SSHRunner { /** * Attach synchronously (blocks until detach) + * Returns true if Ctrl+L (session list) was requested */ - attachSync(sessionId: string): void { + attachSync(sessionId: string): boolean { log(`Attaching sync to remote session ${sessionId} on ${this.name}`) const { spawnSync } = require("child_process") @@ -181,10 +193,25 @@ export class SSHRunner { 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 } /** diff --git a/src/core/types.ts b/src/core/types.ts index f8cdeb1..d664da4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -125,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 { 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 index e8ec226..37a83ae 100644 --- a/src/tui/component/dialog-new-remote.tsx +++ b/src/tui/component/dialog-new-remote.tsx @@ -1,22 +1,22 @@ /** * New Remote Session dialog - * Creates a session on a remote host via SSH + * Step-by-step flow, pre-filled with last used values */ -import { createSignal, Show } from "solid-js" +import { createSignal } from "solid-js" import { InputRenderable } from "@opentui/core" import { useKeyboard } from "@opentui/solid" import { useTheme } from "@tui/context/theme" -import { useSync } from "@tui/context/sync" 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 type { Tool } from "@/core/types" - -type Step = "remote" | "tool" | "path" | "title" | "confirm" +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" }, @@ -24,46 +24,35 @@ const TOOL_OPTIONS: { title: string; value: Tool }[] = [ { 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 sync = useSync() const toast = useToast() - const { theme } = useTheme() - - const remoteNames = sync.remote.getRemoteNames() - // If no remotes configured, show message - if (remoteNames.length === 0) { - return ( - - - - - No remotes configured. Press 'c' to open settings and add a remote host. - - - - - ) - } + // Get last used values for defaults + const lastSession = getLastRemoteSession() - const [step, setStep] = createSignal("remote") - const [selectedRemote, setSelectedRemote] = createSignal("") - const [selectedTool, setSelectedTool] = createSignal("claude") - const [projectPath, setProjectPath] = createSignal("") + 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) - let pathInputRef: InputRenderable | undefined - let titleInputRef: InputRenderable | undefined - async function handleCreate() { if (creating()) return - const path = projectPath().trim() - if (!path) { + 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 } @@ -71,15 +60,39 @@ export function DialogNewRemote() { setCreating(true) try { - const result = await sync.remote.create(selectedRemote(), { + const runner = new SSHRunner("remote", hostVal, avPath() || "av") + const result = await runner.create({ title: title().trim() || undefined, - projectPath: path, + 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 @${selectedRemote()}`, + message: `Created session on ${hostVal}`, variant: "success", duration: 2000 }) @@ -98,115 +111,149 @@ export function DialogNewRemote() { } } - // Step 1: Select remote - function showRemoteStep() { - const options = remoteNames.map(name => ({ - title: `@${name}`, - value: name, - })) + // 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(() => ( - { - setSelectedRemote(opt.value) - setStep("tool") + { + setAvPath(path.trim() || "av") showToolStep() }} /> )) + dialog.setSize("large") } - // Step 2: Select tool + // Step 3: Select tool function showToolStep() { dialog.replace(() => ( { setSelectedTool(opt.value) - setStep("path") + 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 3: Enter path + // Step 4: Enter project path function showPathStep() { dialog.replace(() => ( - { - setProjectPath(path) - setStep("title") + if (!path.trim()) return + setProjectPath(path.trim()) showTitleStep() }} /> )) + dialog.setSize("large") } - // Step 4: Enter title (optional) + // Step 5: Enter title and create function showTitleStep() { dialog.replace(() => ( - { setTitle(t) handleCreate() }} - onSkip={() => { - handleCreate() - }} - creating={creating()} /> )) + dialog.setSize("large") } - // Start with remote selection - showRemoteStep() + // Start + showHostStep() return <> } -// Path input step component -function PathStep(props: { - remote: string - tool: string +// Generic input step +function InputStep(props: { + title: string + hint: string value: string - onSubmit: (path: string) => void + placeholder: string + onSubmit: (value: string) => void }) { const { theme } = useTheme() - const [path, setPath] = createSignal(props.value || "~") + const [value, setValue] = createSignal(props.value) let inputRef: InputRenderable | undefined useKeyboard((evt) => { if (evt.name === "return" && !evt.shift) { evt.preventDefault() - const p = path().trim() - if (p) { - props.onSubmit(p) - } + props.onSubmit(value()) } }) return ( - - + - Enter the project path on the remote host: + {props.hint} - ) } -// Title input step component -function TitleStep(props: { - remote: string +// Final step +function FinalStep(props: { + host: string tool: string path: string - value: string - onSubmit: (title: string) => void - onSkip: () => void creating: boolean + onSubmit: (title: string) => void }) { const { theme } = useTheme() - const [title, setTitle] = createSignal(props.value) + const [title, setTitle] = createSignal("") let inputRef: InputRenderable | undefined @@ -246,8 +290,7 @@ function TitleStep(props: { return ( - - + Tool: {props.tool} Path: {props.path} @@ -266,14 +309,12 @@ function TitleStep(props: { }} /> - 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/component/dialog-settings.tsx b/src/tui/component/dialog-settings.tsx index fde42d5..9d7a6fc 100644 --- a/src/tui/component/dialog-settings.tsx +++ b/src/tui/component/dialog-settings.tsx @@ -5,11 +5,10 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" -import { DialogInput } from "@tui/ui/dialog-input" import { useToast } from "@tui/ui/toast" import { useTheme } from "@tui/context/theme" import { useSync } from "@tui/context/sync" -import { getConfig, loadConfig, saveConfig, type RemoteConfig } from "@/core/config" +import { getConfig, loadConfig, saveConfig } from "@/core/config" import type { Tool } from "@/core/types" const TOOL_OPTIONS: { title: string; value: Tool }[] = [ @@ -44,7 +43,6 @@ export function DialogSettings() { function showSettingsList() { const config = getConfig() - const remoteCount = Object.keys(config.remotes || {}).length const options = [ { title: "Default tool", @@ -66,11 +64,6 @@ export function DialogSettings() { value: "autoHibernate" as const, footer: formatHibernate(config.autoHibernateMinutes || 0), }, - { - title: "Remote hosts", - value: "remotes" as const, - footer: remoteCount > 0 ? `${remoteCount} configured` : "none", - }, ] dialog.replace(() => ( @@ -84,7 +77,6 @@ export function DialogSettings() { case "theme": return showTheme() case "defaultGroup": return showDefaultGroup() case "autoHibernate": return showAutoHibernate() - case "remotes": return showRemotes() } }} /> @@ -170,204 +162,6 @@ export function DialogSettings() { )) } - function showRemotes() { - const config = getConfig() - const remotes = config.remotes || {} - const remoteNames = Object.keys(remotes) - - const options = [ - { title: "+ Add remote", value: { action: "add" } as const }, - ...remoteNames.map(name => ({ - title: name, - value: { action: "edit" as const, name }, - footer: remotes[name]!.host - })) - ] - - dialog.replace(() => ( - { - if (opt.value.action === "add") { - showAddRemote() - } else { - showEditRemote(opt.value.name) - } - }} - /> - )) - } - - function showAddRemote() { - dialog.replace(() => ( - { - if (!name.trim()) { - toast.show({ message: "Name is required", variant: "error", duration: 2000 }) - showRemotes() - return - } - const config = getConfig() - if (config.remotes?.[name]) { - toast.show({ message: "Remote already exists", variant: "error", duration: 2000 }) - showRemotes() - return - } - showAddRemoteHost(name.trim()) - }} - /> - )) - } - - function showAddRemoteHost(name: string) { - dialog.replace(() => ( - { - if (!host.trim()) { - toast.show({ message: "Host is required", variant: "error", duration: 2000 }) - showRemotes() - return - } - showAddRemoteAvPath(name, host.trim()) - }} - /> - )) - } - - function showAddRemoteAvPath(name: string, host: string) { - dialog.replace(() => ( - { - const config = await loadConfig() - const remotes = { ...config.remotes } - remotes[name] = { - host, - avPath: avPath.trim() || undefined - } - await saveConfig({ ...config, remotes }) - toast.show({ message: `Added remote "${name}"`, variant: "success", duration: 2000 }) - sync.refreshRemote() - showRemotes() - }} - /> - )) - } - - function showEditRemote(name: string) { - const config = getConfig() - const remote = config.remotes?.[name] - if (!remote) { - showRemotes() - return - } - - const options = [ - { title: "Edit host", value: "host" as const, footer: remote.host }, - { title: "Edit av path", value: "avPath" as const, footer: remote.avPath || "av" }, - { title: "Remove", value: "remove" as const }, - { title: "Back", value: "back" as const }, - ] - - dialog.replace(() => ( - { - switch (opt.value) { - case "host": - showEditRemoteHost(name, remote) - break - case "avPath": - showEditRemoteAvPath(name, remote) - break - case "remove": - showRemoveRemote(name) - break - case "back": - showRemotes() - break - } - }} - /> - )) - } - - function showEditRemoteHost(name: string, remote: RemoteConfig) { - dialog.replace(() => ( - { - if (!host.trim()) { - toast.show({ message: "Host is required", variant: "error", duration: 2000 }) - showEditRemote(name) - return - } - const config = await loadConfig() - const remotes = { ...config.remotes } - remotes[name] = { ...remote, host: host.trim() } - await saveConfig({ ...config, remotes }) - toast.show({ message: "Host updated", variant: "success", duration: 1500 }) - sync.refreshRemote() - showEditRemote(name) - }} - /> - )) - } - - function showEditRemoteAvPath(name: string, remote: RemoteConfig) { - dialog.replace(() => ( - { - const config = await loadConfig() - const remotes = { ...config.remotes } - remotes[name] = { ...remote, avPath: avPath.trim() || undefined } - await saveConfig({ ...config, remotes }) - toast.show({ message: "av path updated", variant: "success", duration: 1500 }) - sync.refreshRemote() - showEditRemote(name) - }} - /> - )) - } - - function showRemoveRemote(name: string) { - dialog.replace(() => ( - { - if (opt.value === "remove") { - const config = await loadConfig() - const remotes = { ...config.remotes } - delete remotes[name] - await saveConfig({ ...config, remotes }) - toast.show({ message: `Removed remote "${name}"`, variant: "info", duration: 2000 }) - sync.refreshRemote() - } - showRemotes() - }} - /> - )) - } - // Show the settings list on mount showSettingsList() diff --git a/src/tui/context/sync.tsx b/src/tui/context/sync.tsx index 45b7278..4215b69 100644 --- a/src/tui/context/sync.tsx +++ b/src/tui/context/sync.tsx @@ -8,7 +8,6 @@ import { createStore, produce } from "solid-js/store" import { getStorage } from "@/core/storage" import { getSessionManager } from "@/core/session" import { getRemoteManager } from "@/core/remote" -import { getRemotes } from "@/core/config" import type { Session, Group, Config, RemoteSession } from "@/core/types" import { isRemoteSession } from "@/core/types" import { createSimpleContext } from "./helper" @@ -260,8 +259,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ await remoteManager.resumeSession(session) await refreshRemote(true) }, - attach(session: RemoteSession): void { - remoteManager.attachSession(session) + attach(session: RemoteSession): boolean { + return remoteManager.attachSession(session) }, async create(remoteName: string, options: { title?: string @@ -276,10 +275,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } return result }, - getRemoteNames(): string[] { - return Object.keys(getRemotes()) - } - }, + }, refresh, refreshRemote } diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index 4110c00..b50df54 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -120,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 } @@ -299,10 +305,11 @@ export function Home() { function doAttach(session: Session) { previewFetchAbort = true renderer.suspend() + let remoteSessionListRequested = false try { if (isRemoteSession(session)) { - // Attach to remote session via SSH - sync.remote.attach(session) + // Attach to remote session via SSH - returns true if Ctrl+L was pressed + remoteSessionListRequested = sync.remote.attach(session) } else { attachSessionSync(session.tmuxSession) } @@ -312,13 +319,21 @@ export function Home() { renderer.resume() sync.refresh() - // Check if user pressed Ctrl+K to open command palette (local only) - if (!isRemoteSession(session) && wasCommandPaletteRequested()) { - command.open() - } - // Check if user pressed Ctrl+L to open session list (local only) - if (!isRemoteSession(session) && 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(() => ) + } } } From 2f44f17a52722ca223940838700f3cd9c832baa6 Mon Sep 17 00:00:00 2001 From: Yoav Franko Date: Sat, 7 Mar 2026 16:16:00 +0200 Subject: [PATCH 4/6] docs: add remote sessions documentation --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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 From 4951e87e8b40a4e744c59c365ec69687a85cbe12 Mon Sep 17 00:00:00 2001 From: Yoav Franko Date: Sat, 7 Mar 2026 16:39:30 +0200 Subject: [PATCH 5/6] feat: auto-detect av at default install path before prompting install - checkAvailable() now checks both configured path and ~/.agent-view/bin/av - Returns the found path so dialog can use it automatically - Only prompts to install if av not found at either location --- src/core/ssh.ts | 61 ++++++++- src/tui/component/dialog-new-remote.tsx | 158 ++++++++++++++++++------ 2 files changed, 176 insertions(+), 43 deletions(-) diff --git a/src/core/ssh.ts b/src/core/ssh.ts index 3bb633f..9a46c65 100644 --- a/src/core/ssh.ts +++ b/src/core/ssh.ts @@ -272,14 +272,69 @@ export class SSHRunner { /** * Check if av is available on the remote host + * Checks configured path first, then default install location */ - async checkAvailable(): Promise<{ ok: boolean; version?: string; error?: string }> { + 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 } + 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) { - return { ok: false, error: err.message } + log(`Install error: ${err.message}`) + return { success: false, error: err.message } } } diff --git a/src/tui/component/dialog-new-remote.tsx b/src/tui/component/dialog-new-remote.tsx index 37a83ae..f410585 100644 --- a/src/tui/component/dialog-new-remote.tsx +++ b/src/tui/component/dialog-new-remote.tsx @@ -42,6 +42,52 @@ export function DialogNewRemote() { 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 @@ -61,48 +107,23 @@ export function DialogNewRemote() { try { const runner = new SSHRunner("remote", hostVal, avPath() || "av") - 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() + // 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 { - toast.show({ - message: result.error || "Failed to create session", - variant: "error", - duration: 3000 - }) + await doCreate(runner, hostVal, pathVal) } } catch (err) { toast.error(err as Error) @@ -111,6 +132,63 @@ export function DialogNewRemote() { } } + // 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(() => ( From 28db988831a4461e0a7e1f539fc381bb464d41b5 Mon Sep 17 00:00:00 2001 From: Yoav Franko Date: Sat, 7 Mar 2026 16:42:24 +0200 Subject: [PATCH 6/6] feat: add remote session checks for all shortcuts and tests - Block R (rename), m (move), F (fork dialog), y (quick confirm) for remote sessions - Add confirmation dialog for local session deletion (without worktree) - Add SSH module tests for SSHRunner and isRemoteSession type guard --- src/core/ssh.test.ts | 156 ++++++++++++++++++++++++++++++++++++++++ src/tui/routes/home.tsx | 59 +++++++++++---- 2 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/core/ssh.test.ts 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/tui/routes/home.tsx b/src/tui/routes/home.tsx index b50df54..4c3e158 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -472,12 +472,27 @@ export function Home() { )) 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) - } + + // Local session without worktree - show confirmation dialog + 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) { @@ -685,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(() => ) @@ -700,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(() => ) } } @@ -719,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 @@ -748,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 }