From ecb8c1a6e6b508bc7826f1a1b228cc782f835658 Mon Sep 17 00:00:00 2001 From: "zhao.qiao" Date: Tue, 12 May 2026 11:04:04 +0800 Subject: [PATCH] feat: path input improvements, Ctrl+D delete, scrollback fix, copyClaudeDir - Add Ctrl+V paste support via clipboard utility (src/core/clipboard.ts) - Add filesystem-aware path autocomplete showing subdirectories (src/core/filesystem.ts, InputAutocomplete usePaste hook) - Add Ctrl+B directory browser for navigating to git repos (src/tui/component/dialog-directory-browser.tsx) - Add Ctrl+D delete session from within tmux (signal file pattern) - Fix scrollback buffer not being cleared on session switch (\x1b[3J) - Add copyClaudeDir utility to copy .claude config to new worktrees - Add "Copy .claude to worktree" setting in settings dialog - Add dynamic footer hints for path/branch autocomplete fields Co-Authored-By: Claude Opus 4.7 --- src/core/clipboard.ts | 37 +++++++ src/core/config.ts | 1 + src/core/filesystem.ts | 77 +++++++++++++++ src/core/git.ts | 13 +++ src/core/ssh.ts | 8 +- src/core/tmux.conf | 10 +- src/core/tmux.ts | 24 ++++- src/core/updater.ts | 4 +- .../component/dialog-directory-browser.tsx | 99 +++++++++++++++++++ src/tui/component/dialog-new-wizard.tsx | 69 ++++++++++++- src/tui/component/dialog-new.tsx | 72 ++++++++++++-- src/tui/component/dialog-settings.tsx | 23 +++++ src/tui/routes/home.tsx | 7 +- src/tui/ui/input-autocomplete.tsx | 8 +- 14 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 src/core/clipboard.ts create mode 100644 src/core/filesystem.ts create mode 100644 src/tui/component/dialog-directory-browser.tsx diff --git a/src/core/clipboard.ts b/src/core/clipboard.ts new file mode 100644 index 0000000..0cd23cb --- /dev/null +++ b/src/core/clipboard.ts @@ -0,0 +1,37 @@ +import { exec } from "child_process" +import { promisify } from "util" + +const execAsync = promisify(exec) + +/** + * Read text from system clipboard. + * Attempts multiple clipboard tools based on platform. + * Returns null if no clipboard tool is available. + */ +export async function readClipboard(): Promise { + const platform = process.platform + + const commands: string[] = [] + + if (platform === "darwin") { + commands.push("pbpaste") + } else if (platform === "linux") { + commands.push("xclip -selection clipboard -o") + commands.push("xsel --clipboard --output") + commands.push("powershell.exe -Command Get-Clipboard") + commands.push("pwsh.exe -Command Get-Clipboard") + } else if (platform === "win32") { + commands.push("powershell.exe -Command Get-Clipboard") + } + + for (const cmd of commands) { + try { + const { stdout } = await execAsync(cmd, { timeout: 2000 }) + if (stdout) return stdout + } catch { + continue + } + } + + return null +} \ No newline at end of file diff --git a/src/core/config.ts b/src/core/config.ts index 0345af6..33b850c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -30,6 +30,7 @@ export interface AppConfig { autoHibernateMinutes?: number // 0 = disabled, default 0 autoHibernatePrompted?: boolean // true = user has seen the prompt lastRemoteSession?: LastRemoteSession // Last used remote session values + copyClaudeDir?: boolean // true = copy .claude dir to worktree (default false) } const CONFIG_DIR = path.join(os.homedir(), ".agent-view") diff --git a/src/core/filesystem.ts b/src/core/filesystem.ts new file mode 100644 index 0000000..6c837c2 --- /dev/null +++ b/src/core/filesystem.ts @@ -0,0 +1,77 @@ +import { readdirSync, statSync, existsSync } from "fs" +import path from "path" +import os from "os" + +/** + * List subdirectories of a given directory that match a prefix. + * Returns absolute paths for matching directories. + */ +export function listSubdirectories(dirPath: string, prefix?: string): string[] { + const expanded = dirPath.startsWith("~") + ? path.join(os.homedir(), dirPath.slice(1)) + : dirPath + + if (!expanded || !existsSync(expanded)) return [] + + try { + const entries = readdirSync(expanded, { withFileTypes: true }) + return entries + .filter(e => e.isDirectory() && !e.name.startsWith(".")) + .filter(e => !prefix || e.name.startsWith(prefix)) + .map(e => path.join(expanded, e.name)) + } catch { + return [] + } +} + +/** + * Given a partial path, resolve the parent directory and prefix + * to match against, then return matching subdirectory completions. + */ +export function resolvePathCompletion(partialPath: string): { + parentDir: string + prefix: string + completions: string[] +} { + let expanded = partialPath.startsWith("~") + ? path.join(os.homedir(), partialPath.slice(1)) + : partialPath + + if (!expanded) expanded = process.cwd() + + let parentDir: string + let prefix: string + + if (expanded.endsWith("/") || expanded.endsWith(path.sep)) { + parentDir = expanded + prefix = "" + } else { + parentDir = path.dirname(expanded) + prefix = path.basename(expanded) + } + + if (!existsSync(parentDir) && prefix) { + const grandparent = path.dirname(parentDir) + if (existsSync(grandparent)) { + const parentPrefix = path.basename(parentDir) + const completions = listSubdirectories(grandparent, parentPrefix) + return { parentDir: grandparent, prefix: parentPrefix, completions } + } + return { parentDir, prefix, completions: [] } + } + + const completions = listSubdirectories(parentDir, prefix) + return { parentDir, prefix, completions } +} + +/** + * Check if a directory is a git repository (synchronous, for autocomplete). + */ +export function isDirGitRepoSync(dirPath: string): boolean { + try { + statSync(path.join(dirPath, ".git")) + return true + } catch { + return false + } +} \ No newline at end of file diff --git a/src/core/git.ts b/src/core/git.ts index ae219c7..5cb3d15 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -7,6 +7,8 @@ import { exec } from "child_process" import { promisify } from "util" import * as path from "path" import * as os from "os" +import { existsSync } from "fs" +import { cp } from "fs/promises" const execAsync = promisify(exec) @@ -338,3 +340,14 @@ export async function pruneWorktrees(repoDir: string): Promise { throw new Error(`failed to prune worktrees: ${output}`) } } + +/** + * Copy the .claude directory from the repo root into a worktree. + * No-op if the source directory does not exist. + */ +export async function copyClaudeDir(repoRoot: string, worktreePath: string): Promise { + const src = path.join(repoRoot, ".claude") + const dest = path.join(worktreePath, ".claude") + if (!existsSync(src)) return + await cp(src, dest, { recursive: true }) +} diff --git a/src/core/ssh.ts b/src/core/ssh.ts index 9a46c65..502b67e 100644 --- a/src/core/ssh.ts +++ b/src/core/ssh.ts @@ -139,7 +139,7 @@ export class SSHRunner { // Exit alternate screen buffer before attaching process.stdout.write("\x1b[?1049l") - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?25h") const sshArgs = [ @@ -159,7 +159,7 @@ export class SSHRunner { child.on("exit", () => { // Clear screen and re-enter alternate buffer for TUI - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?1049h") process.stdout.write("\x1b]0;Agent View\x07") }) @@ -175,7 +175,7 @@ export class SSHRunner { // Exit alternate screen buffer process.stdout.write("\x1b[?1049l") - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?25h") const sshArgs = [ @@ -207,7 +207,7 @@ export class SSHRunner { } // Clear screen and re-enter alternate buffer for TUI - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?1049h") process.stdout.write("\x1b]0;Agent View\x07") diff --git a/src/core/tmux.conf b/src/core/tmux.conf index ca7f90d..0a1c96b 100644 --- a/src/core/tmux.conf +++ b/src/core/tmux.conf @@ -4,6 +4,7 @@ # # Reserved keybinds (avoid overriding in tmux-user.conf): # Ctrl+Q - detach +# Ctrl+D - delete session # Ctrl+K - command palette # Ctrl+L - session list # Ctrl+T - toggle terminal pane @@ -20,13 +21,16 @@ # --- Keybindings --- bind-key -n C-q detach-client +bind-key -n C-d run-shell "touch /tmp/agent-view-delete-session" \; detach-client bind-key -n C-k run-shell "touch /tmp/agent-view-cmd-palette" \; detach-client bind-key -n C-t if-shell "[ $(tmux display -p '#{window_panes}') -eq 1 ]" "split-window -v" "kill-pane -t :.1" bind-key -n C-o select-pane -t :.+ # Session switching (no prefix needed) -bind-key -n C-] switch-client -n -bind-key -n 'C-\' switch-client -p +# refresh-client after switch forces a full terminal redraw to prevent +# screen artifacts from the previous session. +bind-key -n C-] switch-client -n \; refresh-client +bind-key -n 'C-\' switch-client -p \; refresh-client bind-key -n C-l run-shell "touch /tmp/agent-view-session-list" \; detach-client # --- Status bar --- @@ -36,7 +40,7 @@ set-option -g status-style "bg=#1e1e2e,fg=#cdd6f4" set-option -g status-left "#[fg=#a6e3a1,bold] #{window_name} #[fg=#6c7086]| " set-option -g status-left-length 30 set-option -g status-right-length 120 -set-option -g status-right "#[fg=#89b4fa]Ctrl+]/\\#[fg=#6c7086] switch #[fg=#89b4fa]Ctrl+L#[fg=#6c7086] list #[fg=#89b4fa]Ctrl+T#[fg=#6c7086] term #[fg=#89b4fa]Ctrl+K#[fg=#6c7086] cmd #[fg=#89b4fa]Ctrl+Q#[fg=#6c7086] detach" +set-option -g status-right "#[fg=#89b4fa]Ctrl+]/\\#[fg=#6c7086] switch #[fg=#89b4fa]Ctrl+L#[fg=#6c7086] list #[fg=#89b4fa]Ctrl+T#[fg=#6c7086] term #[fg=#89b4fa]Ctrl+K#[fg=#6c7086] cmd #[fg=#89b4fa]Ctrl+D#[fg=#6c7086] delete #[fg=#89b4fa]Ctrl+Q#[fg=#6c7086] detach" # --- Terminal titles --- set-option -g set-titles on diff --git a/src/core/tmux.ts b/src/core/tmux.ts index 953e280..7f81d55 100644 --- a/src/core/tmux.ts +++ b/src/core/tmux.ts @@ -29,6 +29,7 @@ export const SESSION_PREFIX = "agentorch_" // Signal files for UI requests from tmux keybinds const COMMAND_PALETTE_SIGNAL = "/tmp/agent-view-cmd-palette" const SESSION_LIST_SIGNAL = "/tmp/agent-view-session-list" +const DELETE_SESSION_SIGNAL = "/tmp/agent-view-delete-session" // --- Isolated tmux server configuration --- // All agent-view sessions run on a dedicated tmux socket with a custom config, @@ -615,7 +616,7 @@ export async function attachWithPty(sessionName: string): Promise { } // Clear screen before returning to TUI - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") } }) } @@ -647,6 +648,18 @@ export function wasSessionListRequested(): boolean { return false } +export function wasDeleteSessionRequested(): boolean { + try { + if (fs.existsSync(DELETE_SESSION_SIGNAL)) { + fs.unlinkSync(DELETE_SESSION_SIGNAL) + return true + } + } catch { + // Ignore errors + } + return false +} + /** * Attach to a tmux session with Ctrl+Q to detach * Keybindings and status bar are configured via the custom tmux.conf, @@ -732,10 +745,15 @@ export function attachSessionSync(sessionName: string): void { } catch { // Ignore if doesn't exist } + try { + fs.unlinkSync(DELETE_SESSION_SIGNAL) + } catch { + // Ignore if doesn't exist + } // Exit alternate screen buffer (TUI uses this) process.stdout.write("\x1b[?1049l") - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?25h") // Attach to tmux - this blocks until user detaches (Ctrl+Q or Ctrl+B d) @@ -748,7 +766,7 @@ export function attachSessionSync(sessionName: string): void { }) // Clear screen and re-enter alternate buffer for TUI - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?1049h") // Restore terminal title to "Agent View" diff --git a/src/core/updater.ts b/src/core/updater.ts index 3663b19..ff72e56 100644 --- a/src/core/updater.ts +++ b/src/core/updater.ts @@ -52,7 +52,7 @@ export function performUpdateSync(): void { // Exit alternate screen buffer process.stdout.write("\x1b[?1049l") - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?25h") spawnSync("bash", ["-c", "curl -fsSL https://raw.githubusercontent.com/frayo44/agent-view/main/install.sh | bash"], { @@ -61,7 +61,7 @@ export function performUpdateSync(): void { }) // Clear screen and re-enter alternate buffer for TUI - process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[2J\x1b[3J\x1b[H") process.stdout.write("\x1b[?1049h") // Restore terminal title diff --git a/src/tui/component/dialog-directory-browser.tsx b/src/tui/component/dialog-directory-browser.tsx new file mode 100644 index 0000000..a3d25f1 --- /dev/null +++ b/src/tui/component/dialog-directory-browser.tsx @@ -0,0 +1,99 @@ +import { createMemo } from "solid-js" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { listSubdirectories, isDirGitRepoSync } from "@/core/filesystem" +import path from "path" +import { existsSync } from "fs" + +export interface DialogDirectoryBrowserProps { + initialPath?: string + onSelect: (selectedPath: string) => void +} + +export function DialogDirectoryBrowser(props: DialogDirectoryBrowserProps) { + const dialog = useDialog() + const [currentDir, setCurrentDir] = [props.initialPath || process.cwd(), (v: string) => { + // Mutate currentDir via dialog state trick: use DialogSelect's onSelect to navigate + // We'll manage state through the option selection callback + return v + }] + + // We use a simple approach: re-render DialogSelect by pushing a new instance + // whenever the user navigates into a subdirectory. This avoids complex state management. + + // Initial render: build options for the starting directory + const startDir = props.initialPath || process.cwd() + + const options: DialogSelectOption[] = [] + + // "Select this directory" option + if (isDirGitRepoSync(startDir)) { + options.push({ + title: `✓ Select ${startDir} (git repo)`, + value: startDir, + description: "git repository", + category: "confirm", + }) + } else { + options.push({ + title: `✓ Select ${startDir}`, + value: startDir, + description: "directory", + category: "confirm", + }) + } + + // "Go up" option + const parent = path.dirname(startDir) + if (parent !== startDir && existsSync(parent)) { + options.push({ + title: `↩ .. (${parent})`, + value: parent, + description: "parent directory", + category: "navigate", + }) + } + + // Subdirectory options + const subdirs = listSubdirectories(startDir) + for (const sub of subdirs) { + const name = path.basename(sub) + const isGit = isDirGitRepoSync(sub) + options.push({ + title: isGit ? `${name} ★` : name, + value: sub, + description: isGit ? "git repo" : "", + category: isGit ? "git" : "directory", + }) + } + + function handleSelect(option: DialogSelectOption, ctx: DialogContext) { + const selectedPath = option.value + const selectedCategory = option.category + + if (selectedCategory === "confirm") { + // User selected "Select this directory" — confirm the path + ctx.pop() + props.onSelect(selectedPath) + } else { + // User navigated into a subdirectory or went up — re-open browser at new path + ctx.pop() + ctx.push(() => ( + + )) + } + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/tui/component/dialog-new-wizard.tsx b/src/tui/component/dialog-new-wizard.tsx index abedda1..9416be6 100644 --- a/src/tui/component/dialog-new-wizard.tsx +++ b/src/tui/component/dialog-new-wizard.tsx @@ -16,9 +16,12 @@ import { DialogHeader } from "@tui/ui/dialog-header" import { DialogFooter } from "@tui/ui/dialog-footer" import { ActionButton } from "@tui/ui/action-button" import { attachSessionSync } from "@/core/tmux" -import { isGitRepo, getRepoRoot, createWorktree, generateBranchName, generateWorktreePath, sanitizeBranchName, branchExists } from "@/core/git" +import { isGitRepo, getRepoRoot, createWorktree, generateBranchName, generateWorktreePath, sanitizeBranchName, branchExists, copyClaudeDir } from "@/core/git" import { HistoryManager } from "@/core/history" import { getStorage } from "@/core/storage" +import { readClipboard } from "@/core/clipboard" +import { resolvePathCompletion } from "@/core/filesystem" +import { DialogDirectoryBrowser } from "@tui/component/dialog-directory-browser" import type { Tool, ClaudeSessionMode } from "@/core/types" import { getToolCommand } from "@/core/types" import { exec } from "child_process" @@ -115,6 +118,32 @@ export function DialogNewWizard() { const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] const storage = getStorage() + // Merge history + filesystem completions for path suggestions + const pathSuggestions = createMemo(() => { + const currentPath = projectPath() + const historyMatches = projectPathHistory.getFiltered(storage, currentPath) + const { completions: fsCompletions } = resolvePathCompletion(currentPath) + + const seen = new Set(historyMatches) + const merged = [...historyMatches] + for (const completion of fsCompletions) { + if (!seen.has(completion)) merged.push(completion) + } + return merged.slice(0, 15) + }) + + function browseRepos() { + dialog.push(() => ( + { + setProjectPath(selectedPath) + setPathFocusField("path") + }} + /> + )) + } + let titleInputRef: InputRenderable | undefined let customCommandInputRef: InputRenderable | undefined let pathInputRef: InputRenderable | undefined @@ -314,6 +343,10 @@ export function DialogNewWizard() { const wtPath = generateWorktreePath(repoRoot, branchName) worktreePath = await createWorktree(repoRoot, branchName, wtPath, baseBranch) + const shouldCopy = config().copyClaudeDir === true + if (shouldCopy) { + await copyClaudeDir(repoRoot, worktreePath) + } sessionProjectPath = worktreePath worktreeRepo = repoRoot worktreeBranchName = branchName @@ -368,6 +401,29 @@ export function DialogNewWizard() { useKeyboard((evt) => { if (creating()) return + // Ctrl+V: paste clipboard content into focused input field + if (evt.ctrl && evt.name === "v") { + evt.preventDefault() + const step = currentStep() + const pff = pathFocusField() + readClipboard().then(text => { + if (!text) return + const sanitized = text.replace(/[\n\r]/g, "") + if (step === "path" && pff === "path") setProjectPath(projectPath() + sanitized) + else if (step === "path" && pff === "branch") setWorktreeBranch(worktreeBranch() + sanitized) + else if (step === "options" && selectedTool() === "custom") setCustomCommand(customCommand() + sanitized) + else if (step === "options") setTitle(title() + sanitized) + }) + return + } + + // Ctrl+B: open directory browser (only in path step) + if (evt.ctrl && evt.name === "b" && currentStep() === "path") { + evt.preventDefault() + browseRepos() + return + } + // Escape: go back to previous step if (evt.name === "escape") { evt.preventDefault() @@ -535,14 +591,17 @@ export function DialogNewWizard() { function PathStep() { return ( - - Enter the project path: - + + + Enter the project path: + + Ctrl+B: browse + setPathFocusField("path")}> { + const currentPath = projectPath() + const historyMatches = projectPathHistory.getFiltered(storage, currentPath) + const { completions: fsCompletions } = resolvePathCompletion(currentPath) + + const seen = new Set(historyMatches) + const merged = [...historyMatches] + for (const completion of fsCompletions) { + if (!seen.has(completion)) merged.push(completion) + } + return merged.slice(0, 15) + }) + + function browseRepos() { + dialog.push(() => ( + { + setProjectPath(selectedPath) + setFocusedField("path") + }} + /> + )) + } + const [focusedField, setFocusedField] = createSignal("title") const [toolIndex, setToolIndex] = createSignal(defaultToolIndex >= 0 ? defaultToolIndex : 0) @@ -244,6 +273,10 @@ export function DialogNew() { const wtPath = generateWorktreePath(repoRoot, branchName) worktreePath = await createWorktree(repoRoot, branchName, wtPath, baseBranch) + const shouldCopy = config().copyClaudeDir === true + if (shouldCopy) { + await copyClaudeDir(repoRoot, worktreePath) + } sessionProjectPath = worktreePath worktreeRepo = repoRoot worktreeBranchName = branchName @@ -296,6 +329,28 @@ export function DialogNew() { } useKeyboard((evt) => { + // Ctrl+V: paste clipboard content into focused input field + if (evt.ctrl && evt.name === "v") { + evt.preventDefault() + const field = focusedField() + readClipboard().then(text => { + if (!text) return + const sanitized = text.replace(/[\n\r]/g, "") + if (field === "path") setProjectPath(projectPath() + sanitized) + else if (field === "title") setTitle(title() + sanitized) + else if (field === "branch") setWorktreeBranch(worktreeBranch() + sanitized) + else if (field === "customCommand") setCustomCommand(customCommand() + sanitized) + }) + return + } + + // Ctrl+B: open directory browser to find git repos + if (evt.ctrl && evt.name === "b" && focusedField() === "path") { + evt.preventDefault() + browseRepos() + return + } + if (evt.name === "escape") { evt.preventDefault() dialog.clear() @@ -508,13 +563,16 @@ export function DialogNew() { {/* Path field with autocomplete */} - - Project Path - + + + Project Path + + Ctrl+B: browse + - + ) } diff --git a/src/tui/component/dialog-settings.tsx b/src/tui/component/dialog-settings.tsx index 9d7a6fc..1740c0d 100644 --- a/src/tui/component/dialog-settings.tsx +++ b/src/tui/component/dialog-settings.tsx @@ -64,6 +64,11 @@ export function DialogSettings() { value: "autoHibernate" as const, footer: formatHibernate(config.autoHibernateMinutes || 0), }, + { + title: "Copy .claude to worktree", + value: "copyClaudeDir" as const, + footer: (config.copyClaudeDir === true) ? "Yes" : "No", + }, ] dialog.replace(() => ( @@ -77,6 +82,7 @@ export function DialogSettings() { case "theme": return showTheme() case "defaultGroup": return showDefaultGroup() case "autoHibernate": return showAutoHibernate() + case "copyClaudeDir": return showCopyClaudeDir() } }} /> @@ -162,6 +168,23 @@ export function DialogSettings() { )) } + function showCopyClaudeDir() { + const config = getConfig() + const options = [ + { title: "Yes (copy .claude directory to new worktree)", value: true }, + { title: "No", value: false }, + ] + dialog.replace(() => ( + updateConfig((c) => ({ ...c, copyClaudeDir: opt.value }))} + /> + )) + } + // Show the settings list on mount showSettingsList() diff --git a/src/tui/routes/home.tsx b/src/tui/routes/home.tsx index 5bb141f..eb4168a 100644 --- a/src/tui/routes/home.tsx +++ b/src/tui/routes/home.tsx @@ -26,7 +26,7 @@ import { executeShortcut, getShortcutGroupPath } from "@/core/shortcut" import { useKeybind } from "@tui/context/keybind" import { useKV } from "@tui/context/kv" import { DialogUpdate } from "@tui/component/dialog-update" -import { attachSessionSync, capturePane, wasCommandPaletteRequested, wasSessionListRequested, sendKeys } from "@/core/tmux" +import { attachSessionSync, capturePane, wasCommandPaletteRequested, wasSessionListRequested, wasDeleteSessionRequested, sendKeys } from "@/core/tmux" import { useCommandDialog } from "@tui/component/dialog-command" import type { Session, Group, RemoteSession } from "@/core/types" import { isRemoteSession } from "@/core/types" @@ -325,6 +325,11 @@ export function Home() { dialog.replace(() => ) } } else { + // Check if user pressed Ctrl+D to delete current session (local only) + if (wasDeleteSessionRequested()) { + handleDelete(session) + return + } // Check if user pressed Ctrl+K to open command palette (local only) if (wasCommandPaletteRequested()) { command.open() diff --git a/src/tui/ui/input-autocomplete.tsx b/src/tui/ui/input-autocomplete.tsx index 88c650a..3164667 100644 --- a/src/tui/ui/input-autocomplete.tsx +++ b/src/tui/ui/input-autocomplete.tsx @@ -5,7 +5,7 @@ import { createSignal, createMemo, For, Show, batch, createEffect, on } from "solid-js" import { TextAttributes, RGBA, InputRenderable } from "@opentui/core" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, usePaste } from "@opentui/solid" import { useTheme, selectedForeground } from "@tui/context/theme" export interface InputAutocompleteProps { @@ -146,6 +146,12 @@ export function InputAutocomplete(props: InputAutocompleteProps) { } }) + // Ensure suggestions update after a paste operation + usePaste(() => { + setShowSuggestions(true) + setSelectedIdx(-1) + }) + const fg = selectedForeground(theme) return (