diff --git a/apps/desktop/src/lib/trpc/routers/autocomplete/autocomplete.ts b/apps/desktop/src/lib/trpc/routers/autocomplete/autocomplete.ts new file mode 100644 index 000000000..87ea8468f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/autocomplete/autocomplete.ts @@ -0,0 +1,240 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { commandHistoryManager } from "main/lib/command-history"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Autocomplete router for command history and file completion + * + * Provides: + * - Command history search with fuzzy matching + * - Recent command matching for ghost text suggestions + * - File/directory listing for path completion + */ +export const createAutocompleteRouter = () => { + return router({ + /** + * Search command history with fuzzy matching + * Uses FTS5 for efficient search across all recorded commands + */ + searchHistory: publicProcedure + .input( + z.object({ + query: z.string(), + limit: z.number().default(50), + workspaceId: z.string().optional(), + }), + ) + .query(({ input }) => { + return commandHistoryManager.search({ + query: input.query, + limit: input.limit, + workspaceId: input.workspaceId, + }); + }), + + /** + * Get the most recent command matching a prefix + * Used for inline ghost text suggestions (Fish-style autosuggestions) + */ + getRecentMatch: publicProcedure + .input( + z.object({ + prefix: z.string(), + workspaceId: z.string().optional(), + }), + ) + .query(({ input }) => { + return commandHistoryManager.getRecentMatch({ + prefix: input.prefix, + workspaceId: input.workspaceId, + }); + }), + + /** + * Record a command execution + * Called when shell emits OSC 133 sequences + */ + recordCommand: publicProcedure + .input( + z.object({ + command: z.string(), + workspaceId: z.string().optional(), + cwd: z.string().optional(), + exitCode: z.number().optional(), + }), + ) + .mutation(({ input }) => { + commandHistoryManager.record({ + command: input.command, + workspaceId: input.workspaceId, + cwd: input.cwd, + exitCode: input.exitCode, + }); + }), + + /** + * Get recent commands (no search query) + * Used for initial history picker display + */ + getRecent: publicProcedure + .input( + z.object({ + limit: z.number().default(50), + workspaceId: z.string().optional(), + }), + ) + .query(({ input }) => { + return commandHistoryManager.getRecent({ + limit: input.limit, + workspaceId: input.workspaceId, + }); + }), + + /** + * List files and directories for path completion + * Supports partial path matching (e.g., "src/comp" matches "src/components/") + */ + listCompletions: publicProcedure + .input( + z.object({ + partial: z.string(), + cwd: z.string(), + showHidden: z.boolean().default(false), + type: z.enum(["all", "files", "directories"]).default("all"), + }), + ) + .query(async ({ input }) => { + const { partial, cwd, showHidden, type } = input; + + try { + // Resolve the partial path + const isAbsolute = path.isAbsolute(partial); + const basePath = isAbsolute ? partial : path.join(cwd, partial); + + // Check if partial ends with separator (user is in directory) + const _endsWithSep = + partial.endsWith("/") || partial.endsWith(path.sep); + + // Get directory and prefix for filtering + let dirPath: string; + let prefix: string; + + try { + const stat = await fs.stat(basePath); + if (stat.isDirectory()) { + dirPath = basePath; + prefix = ""; + } else { + dirPath = path.dirname(basePath); + prefix = path.basename(basePath); + } + } catch { + // Path doesn't exist, treat as partial + dirPath = path.dirname(basePath); + prefix = path.basename(basePath); + } + + // Read directory + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + // Filter and map entries + const completions = entries + .filter((entry) => { + // Filter by prefix + if ( + prefix && + !entry.name.toLowerCase().startsWith(prefix.toLowerCase()) + ) { + return false; + } + + // Filter hidden files + if (!showHidden && entry.name.startsWith(".")) { + return false; + } + + // Filter by type + if (type === "files" && entry.isDirectory()) { + return false; + } + if (type === "directories" && !entry.isDirectory()) { + return false; + } + + return true; + }) + .map((entry) => { + const isDirectory = entry.isDirectory(); + const name = entry.name; + const fullPath = path.join(dirPath, name); + + // Build the completion text (what to insert) + // If user typed "src/comp", completion for "components" should be "onents/" + const completionSuffix = isDirectory ? "/" : ""; + const insertText = name.slice(prefix.length) + completionSuffix; + + return { + name, + insertText, + fullPath, + isDirectory, + icon: isDirectory ? "folder" : getFileIcon(name), + }; + }) + .sort((a, b) => { + // Directories first, then alphabetical + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name); + }) + .slice(0, 50); // Limit results + + return { + basePath: dirPath, + prefix, + completions, + }; + } catch (error) { + return { + basePath: cwd, + prefix: partial, + completions: [], + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }), + }); +}; + +/** + * Get a simple icon identifier based on file extension + */ +function getFileIcon(filename: string): string { + const ext = path.extname(filename).toLowerCase(); + const iconMap: Record = { + ".ts": "typescript", + ".tsx": "react", + ".js": "javascript", + ".jsx": "react", + ".json": "json", + ".md": "markdown", + ".css": "css", + ".scss": "css", + ".html": "html", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".sh": "shell", + ".bash": "shell", + ".zsh": "shell", + ".yml": "yaml", + ".yaml": "yaml", + ".toml": "config", + ".env": "config", + ".gitignore": "git", + ".git": "git", + }; + return iconMap[ext] || "file"; +} diff --git a/apps/desktop/src/lib/trpc/routers/autocomplete/index.ts b/apps/desktop/src/lib/trpc/routers/autocomplete/index.ts new file mode 100644 index 000000000..c77960052 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/autocomplete/index.ts @@ -0,0 +1 @@ +export { createAutocompleteRouter } from "./autocomplete"; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index e089234a5..47f3d7395 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -3,6 +3,7 @@ import { router } from ".."; import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; import { createAutoUpdateRouter } from "./auto-update"; +import { createAutocompleteRouter } from "./autocomplete"; import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; import { createExternalRouter } from "./external"; @@ -29,6 +30,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ analytics: createAnalyticsRouter(), auth: createAuthRouter(getWindow), + autocomplete: createAutocompleteRouter(), autoUpdate: createAutoUpdateRouter(), user: createUserRouter(), window: createWindowRouter(getWindow), diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index f440e96e6..a0070b9b8 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -26,6 +26,26 @@ _superset_home="\${SUPERSET_ORIG_ZDOTDIR:-$HOME}" export ZDOTDIR="$_superset_home" [[ -f "$_superset_home/.zshrc" ]] && source "$_superset_home/.zshrc" export PATH="$HOME/${SUPERSET_DIR_NAME}/bin:$PATH" + +# Superset command history hooks +# OSC 133 sequences for shell integration +# C = command start (with command text), D = command done (with exit code) +_superset_preexec() { + # Emit OSC 133;C with the command being executed + printf '\\033]133;C;%s\\033\\\\' "\${1//[[:cntrl:]]}" +} +_superset_precmd() { + local exit_code=$? + # Emit OSC 133;D with the exit code + printf '\\033]133;D;%d\\033\\\\' "$exit_code" +} +# Add hooks if not already added +if [[ -z "\${_superset_hooks_installed}" ]]; then + autoload -Uz add-zsh-hook + add-zsh-hook preexec _superset_preexec + add-zsh-hook precmd _superset_precmd + _superset_hooks_installed=1 +fi `; fs.writeFileSync(zshrcPath, zshrcScript, { mode: 0o644 }); console.log("[agent-setup] Created zsh wrapper"); @@ -58,6 +78,30 @@ fi export PATH="$HOME/${SUPERSET_DIR_NAME}/bin:$PATH" # Minimal prompt (path/env shown in toolbar) - emerald to match app theme export PS1=$'\\[\\e[1;38;2;52;211;153m\\]❯\\[\\e[0m\\] ' + +# Superset command history hooks +# OSC 133 sequences for shell integration +_superset_last_cmd="" +_superset_trap_debug() { + # Capture the command before execution + _superset_last_cmd="$BASH_COMMAND" +} +_superset_prompt_command() { + local exit_code=$? + # Emit command done with exit code + printf '\\033]133;D;%d\\033\\\\' "$exit_code" + # Emit command start when we have a captured command + if [[ -n "$_superset_last_cmd" && "$_superset_last_cmd" != "_superset_prompt_command" ]]; then + printf '\\033]133;C;%s\\033\\\\' "\${_superset_last_cmd//[[:cntrl:]]}" + fi + _superset_last_cmd="" +} +# Install hooks if not already installed +if [[ -z "\${_superset_hooks_installed}" ]]; then + trap '_superset_trap_debug' DEBUG + PROMPT_COMMAND="_superset_prompt_command\${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + _superset_hooks_installed=1 +fi `; fs.writeFileSync(rcfilePath, script, { mode: 0o644 }); console.log("[agent-setup] Created bash wrapper"); diff --git a/apps/desktop/src/main/lib/command-history/command-history.ts b/apps/desktop/src/main/lib/command-history/command-history.ts new file mode 100644 index 000000000..a26bb7f81 --- /dev/null +++ b/apps/desktop/src/main/lib/command-history/command-history.ts @@ -0,0 +1,250 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { SUPERSET_HOME_DIR } from "../app-environment"; + +export interface CommandRecord { + id: number; + command: string; + timestamp: number; + workspaceId: string | null; + cwd: string | null; + exitCode: number | null; +} + +export interface SearchResult { + command: string; + timestamp: number; + workspaceId: string | null; + cwd: string | null; +} + +const COMMAND_HISTORY_DIR = join(SUPERSET_HOME_DIR, "command-history"); +const DB_PATH = join(COMMAND_HISTORY_DIR, "index.db"); + +class CommandHistoryManager { + private db: Database.Database | null = null; + + private getDb(): Database.Database { + if (!this.db) { + mkdirSync(COMMAND_HISTORY_DIR, { recursive: true }); + + this.db = new Database(DB_PATH); + this.db.pragma("journal_mode = WAL"); + + // Create main commands table + this.db.exec(` + CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + command TEXT NOT NULL, + timestamp INTEGER NOT NULL, + workspace_id TEXT, + cwd TEXT, + exit_code INTEGER + ) + `); + + // Create FTS5 virtual table for fuzzy search + this.db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS commands_fts USING fts5( + command, + content='commands', + content_rowid='id' + ) + `); + + // Create triggers to keep FTS in sync + this.db.exec(` + CREATE TRIGGER IF NOT EXISTS commands_ai AFTER INSERT ON commands BEGIN + INSERT INTO commands_fts(rowid, command) VALUES (new.id, new.command); + END + `); + + this.db.exec(` + CREATE TRIGGER IF NOT EXISTS commands_ad AFTER DELETE ON commands BEGIN + INSERT INTO commands_fts(commands_fts, rowid, command) VALUES('delete', old.id, old.command); + END + `); + + this.db.exec(` + CREATE TRIGGER IF NOT EXISTS commands_au AFTER UPDATE ON commands BEGIN + INSERT INTO commands_fts(commands_fts, rowid, command) VALUES('delete', old.id, old.command); + INSERT INTO commands_fts(rowid, command) VALUES (new.id, new.command); + END + `); + + // Create index for prefix matching (for ghost text) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_commands_command ON commands(command) + `); + + // Create index for timestamp ordering + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_commands_timestamp ON commands(timestamp DESC) + `); + + console.log(`[command-history] Database initialized at: ${DB_PATH}`); + } + return this.db; + } + + /** + * Record a command execution + */ + record(params: { + command: string; + workspaceId?: string; + cwd?: string; + exitCode?: number; + }): void { + const { command, workspaceId, cwd, exitCode } = params; + + // Skip empty commands or common noise + const trimmed = command.trim(); + if (!trimmed || trimmed.length < 2) { + return; + } + + const db = this.getDb(); + const stmt = db.prepare(` + INSERT INTO commands (command, timestamp, workspace_id, cwd, exit_code) + VALUES (?, ?, ?, ?, ?) + `); + + stmt.run( + trimmed, + Date.now(), + workspaceId ?? null, + cwd ?? null, + exitCode ?? null, + ); + } + + /** + * Search commands using FTS5 fuzzy matching + */ + search(params: { + query: string; + limit?: number; + workspaceId?: string; + }): SearchResult[] { + const { query, limit = 50, workspaceId } = params; + + if (!query.trim()) { + return this.getRecent({ limit, workspaceId }); + } + + const db = this.getDb(); + + // Use FTS5 with prefix matching for better fuzzy search + // Escape special FTS5 characters and add prefix matching + const escapedQuery = query + .replace(/['"]/g, "") + .split(/\s+/) + .filter((term) => term.length > 0) + .map((term) => `"${term}"*`) + .join(" "); + + if (!escapedQuery) { + return this.getRecent({ limit, workspaceId }); + } + + let sql = ` + SELECT DISTINCT c.command, c.timestamp, c.workspace_id as workspaceId, c.cwd + FROM commands c + JOIN commands_fts fts ON c.id = fts.rowid + WHERE commands_fts MATCH ? + `; + + const sqlParams: (string | number)[] = [escapedQuery]; + + if (workspaceId) { + sql += ` AND c.workspace_id = ?`; + sqlParams.push(workspaceId); + } + + sql += ` ORDER BY c.timestamp DESC LIMIT ?`; + sqlParams.push(limit); + + const stmt = db.prepare(sql); + return stmt.all(...sqlParams) as SearchResult[]; + } + + /** + * Get the most recent command matching a prefix (for ghost text) + */ + getRecentMatch(params: { + prefix: string; + workspaceId?: string; + }): string | null { + const { prefix, workspaceId } = params; + + const trimmedPrefix = prefix.trim(); + if (!trimmedPrefix || trimmedPrefix.length < 2) { + return null; + } + + const db = this.getDb(); + + let sql = ` + SELECT command + FROM commands + WHERE command LIKE ? || '%' + AND command != ? + `; + + const sqlParams: (string | number)[] = [trimmedPrefix, trimmedPrefix]; + + if (workspaceId) { + sql += ` AND workspace_id = ?`; + sqlParams.push(workspaceId); + } + + sql += ` ORDER BY timestamp DESC LIMIT 1`; + + const stmt = db.prepare(sql); + const result = stmt.get(...sqlParams) as { command: string } | undefined; + + return result?.command ?? null; + } + + /** + * Get most recent commands + */ + getRecent(params: { limit?: number; workspaceId?: string }): SearchResult[] { + const { limit = 50, workspaceId } = params; + + const db = this.getDb(); + + let sql = ` + SELECT DISTINCT command, MAX(timestamp) as timestamp, workspace_id as workspaceId, cwd + FROM commands + `; + + const sqlParams: (string | number)[] = []; + + if (workspaceId) { + sql += ` WHERE workspace_id = ?`; + sqlParams.push(workspaceId); + } + + sql += ` GROUP BY command ORDER BY timestamp DESC LIMIT ?`; + sqlParams.push(limit); + + const stmt = db.prepare(sql); + return stmt.all(...sqlParams) as SearchResult[]; + } + + /** + * Close the database connection + */ + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + } + } +} + +// Singleton instance +export const commandHistoryManager = new CommandHistoryManager(); diff --git a/apps/desktop/src/main/lib/command-history/index.ts b/apps/desktop/src/main/lib/command-history/index.ts new file mode 100644 index 000000000..8b894a1d2 --- /dev/null +++ b/apps/desktop/src/main/lib/command-history/index.ts @@ -0,0 +1,12 @@ +export { + type CommandRecord, + commandHistoryManager, + type SearchResult, +} from "./command-history"; +export { + CommandTracker, + type OscCommandDone, + type OscCommandStart, + type OscEvent, + parseOscSequences, +} from "./osc-parser"; diff --git a/apps/desktop/src/main/lib/command-history/osc-parser.ts b/apps/desktop/src/main/lib/command-history/osc-parser.ts new file mode 100644 index 000000000..3c451868c --- /dev/null +++ b/apps/desktop/src/main/lib/command-history/osc-parser.ts @@ -0,0 +1,112 @@ +/** + * OSC 133 Shell Integration Sequence Parser + * + * Parses OSC 133 sequences emitted by shell hooks to extract command information. + * + * Sequence format: + * - OSC 133;C;{command} ST - Command start (preexec) + * - OSC 133;D;{exit_code} ST - Command done (precmd) + * + * Where: + * - OSC = \x1b] (ESC ]) + * - ST = \x1b\\ (ESC \) or \x07 (BEL) + */ + +export interface OscCommandStart { + type: "command_start"; + command: string; +} + +export interface OscCommandDone { + type: "command_done"; + exitCode: number; +} + +export type OscEvent = OscCommandStart | OscCommandDone; + +// Regex to match OSC 133 sequences +// Matches: \x1b]133;C;{command}\x1b\\ or \x1b]133;C;{command}\x07 +// And: \x1b]133;D;{exit_code}\x1b\\ or \x1b]133;D;{exit_code}\x07 +// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional - parsing escape sequences +const OSC_133_REGEX = /\x1b\]133;([CD]);([^\x07\x1b]*?)(?:\x1b\\|\x07)/g; + +/** + * Parse OSC 133 sequences from terminal data + * Returns extracted events and data with sequences stripped + */ +export function parseOscSequences(data: string): { + events: OscEvent[]; + cleanData: string; +} { + const events: OscEvent[] = []; + let cleanData = data; + + // Find all matches using matchAll + const regex = new RegExp(OSC_133_REGEX.source, "g"); + for (const match of data.matchAll(regex)) { + const [, type, payload] = match; + + if (type === "C") { + // Command start + events.push({ + type: "command_start", + command: payload, + }); + } else if (type === "D") { + // Command done + const exitCode = Number.parseInt(payload, 10); + events.push({ + type: "command_done", + exitCode: Number.isNaN(exitCode) ? 0 : exitCode, + }); + } + } + + // Strip OSC sequences from data + cleanData = data.replace(OSC_133_REGEX, ""); + + return { events, cleanData }; +} + +/** + * State machine for tracking command execution + * Correlates command start and done events + */ +export class CommandTracker { + private pendingCommand: string | null = null; + private onCommand: (command: string, exitCode: number) => void; + + constructor(onCommand: (command: string, exitCode: number) => void) { + this.onCommand = onCommand; + } + + /** + * Process an OSC event + */ + processEvent(event: OscEvent): void { + if (event.type === "command_start") { + // Store the command for when we get the done event + this.pendingCommand = event.command; + } else if (event.type === "command_done") { + // Emit the completed command + if (this.pendingCommand) { + this.onCommand(this.pendingCommand, event.exitCode); + this.pendingCommand = null; + } + } + } + + /** + * Get the current pending command (if any) + */ + getPendingCommand(): string | null { + return this.pendingCommand; + } + + /** + * Clear the pending command + */ + clear(): void { + this.pendingCommand = null; + } +} diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 54580184c..9e370ccc0 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -1,6 +1,11 @@ import os from "node:os"; import * as pty from "node-pty"; import { getShellArgs } from "../agent-setup"; +import { + CommandTracker, + commandHistoryManager, + parseOscSequences, +} from "../command-history"; import { DataBatcher } from "../data-batcher"; import { containsClearScrollbackSequence, @@ -120,6 +125,16 @@ export async function createSession( onData(paneId, batchedData); }); + // Create command tracker for history recording + const commandTracker = new CommandTracker((command, exitCode) => { + commandHistoryManager.record({ + command, + workspaceId, + cwd: workingDir, + exitCode, + }); + }); + return { pty: ptyProcess, paneId, @@ -133,6 +148,7 @@ export async function createSession( wasRecovered, historyWriter, dataBatcher, + commandTracker, shell, startTime: Date.now(), usedFallback: useFallbackShell, @@ -150,12 +166,20 @@ export function setupDataHandler( let commandsSent = false; session.pty.onData((data) => { - let dataToStore = data; + // Parse OSC 133 sequences for command history tracking + const { events, cleanData } = parseOscSequences(data); + + // Process command history events + for (const event of events) { + session.commandTracker?.processEvent(event); + } + + let dataToStore = cleanData; - if (containsClearScrollbackSequence(data)) { + if (containsClearScrollbackSequence(cleanData)) { session.scrollback = ""; onHistoryReinit().catch(() => {}); - dataToStore = extractContentAfterClear(data); + dataToStore = extractContentAfterClear(cleanData); } session.scrollback += dataToStore; @@ -164,7 +188,8 @@ export function setupDataHandler( // Scan for port patterns in terminal output portManager.scanOutput(dataToStore, session.paneId, session.workspaceId); - session.dataBatcher.write(data); + // Send original data (with OSC sequences stripped) to renderer + session.dataBatcher.write(cleanData); if (shouldRunCommands && !commandsSent) { commandsSent = true; diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index 0a53eb35a..a8a8f7e08 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -1,4 +1,5 @@ import type * as pty from "node-pty"; +import type { CommandTracker } from "../command-history"; import type { DataBatcher } from "../data-batcher"; import type { HistoryWriter } from "../terminal-history"; @@ -16,6 +17,7 @@ export interface TerminalSession { wasRecovered: boolean; historyWriter?: HistoryWriter; dataBatcher: DataBatcher; + commandTracker?: CommandTracker; shell: string; startTime: number; usedFallback: boolean; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/CompletionDropdown/CompletionDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/CompletionDropdown/CompletionDropdown.tsx new file mode 100644 index 000000000..306ae3f74 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/CompletionDropdown/CompletionDropdown.tsx @@ -0,0 +1,194 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { HiOutlineDocument, HiOutlineFolder } from "react-icons/hi2"; +import { useAutocompleteStore } from "../stores/autocomplete-store"; + +interface CompletionDropdownProps { + xterm: XTerm | null; + onSelect: (insertText: string) => void; + onClose: () => void; +} + +interface Position { + x: number; + y: number; +} + +/** + * CompletionDropdown displays file/directory completions below the cursor. + * Triggered by Tab key when in a path context. + */ +export function CompletionDropdown({ + xterm, + onSelect, + onClose, +}: CompletionDropdownProps) { + const isOpen = useAutocompleteStore((s) => s.isCompletionDropdownOpen); + const completions = useAutocompleteStore((s) => s.completions); + const selectedIndex = useAutocompleteStore((s) => s.selectedCompletionIndex); + const selectNext = useAutocompleteStore((s) => s.selectNextCompletion); + const selectPrev = useAutocompleteStore((s) => s.selectPrevCompletion); + + const [position, setPosition] = useState(null); + const listRef = useRef(null); + + // Calculate dropdown position based on cursor + useEffect(() => { + if (!xterm || !isOpen) { + setPosition(null); + return; + } + + const updatePosition = () => { + try { + const container = xterm.element; + if (!container) return; + + // Get cell dimensions - access internal xterm properties + const core = ( + xterm as unknown as { + _core?: { + _renderService?: { + dimensions?: { + css?: { cell?: { width?: number; height?: number } }; + }; + }; + }; + } + )._core; + const cellWidth = + core?._renderService?.dimensions?.css?.cell?.width ?? 9; + const cellHeight = + core?._renderService?.dimensions?.css?.cell?.height ?? 17; + + // Get cursor position + const cursorX = xterm.buffer.active.cursorX; + const cursorY = xterm.buffer.active.cursorY; + + // Position dropdown below cursor + const x = Math.max(0, cursorX * cellWidth - 100); // Center-ish + const y = (cursorY + 1) * cellHeight + 4; + + setPosition({ x, y }); + } catch { + setPosition(null); + } + }; + + updatePosition(); + }, [xterm, isOpen]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const selectedElement = listRef.current.children[selectedIndex] as + | HTMLElement + | undefined; + selectedElement?.scrollIntoView({ block: "nearest" }); + } + }, [selectedIndex]); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isOpen) return; + + switch (e.key) { + case "ArrowDown": + case "Tab": + if (!e.shiftKey) { + e.preventDefault(); + selectNext(); + } else { + e.preventDefault(); + selectPrev(); + } + break; + case "ArrowUp": + e.preventDefault(); + selectPrev(); + break; + case "Enter": + e.preventDefault(); + if (completions[selectedIndex]) { + onSelect(completions[selectedIndex].insertText); + } + break; + case "Escape": + e.preventDefault(); + onClose(); + break; + } + }, + [ + isOpen, + completions, + selectedIndex, + selectNext, + selectPrev, + onSelect, + onClose, + ], + ); + + // Add keyboard listener + useEffect(() => { + if (!isOpen) return; + + window.addEventListener("keydown", handleKeyDown, true); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isOpen, handleKeyDown]); + + const handleItemClick = useCallback( + (insertText: string) => { + onSelect(insertText); + }, + [onSelect], + ); + + if (!isOpen || !position || completions.length === 0) { + return null; + } + + return ( +
+
+ {completions.map((item, index) => ( + + ))} +
+
+ Tab next + · + Enter select +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/CompletionDropdown/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/CompletionDropdown/index.ts new file mode 100644 index 000000000..25500928c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/CompletionDropdown/index.ts @@ -0,0 +1 @@ +export { CompletionDropdown } from "./CompletionDropdown"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/GhostText/GhostText.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/GhostText/GhostText.tsx new file mode 100644 index 000000000..ce70a543b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/GhostText/GhostText.tsx @@ -0,0 +1,168 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAutocompleteStore } from "../stores/autocomplete-store"; + +interface GhostTextProps { + xterm: XTerm | null; + isVisible: boolean; +} + +/** + * Determine if a color is "light" based on luminance. + * Returns true if the color is light (should use dark ghost text). + */ +function isLightColor(color: string | undefined): boolean { + if (!color) return false; + + // Parse hex color + let r = 0; + let g = 0; + let b = 0; + + if (color.startsWith("#")) { + const hex = color.slice(1); + if (hex.length === 3) { + r = Number.parseInt(hex[0] + hex[0], 16); + g = Number.parseInt(hex[1] + hex[1], 16); + b = Number.parseInt(hex[2] + hex[2], 16); + } else if (hex.length === 6) { + r = Number.parseInt(hex.slice(0, 2), 16); + g = Number.parseInt(hex.slice(2, 4), 16); + b = Number.parseInt(hex.slice(4, 6), 16); + } + } else if (color.startsWith("rgb")) { + const match = color.match(/\d+/g); + if (match && match.length >= 3) { + r = Number.parseInt(match[0], 10); + g = Number.parseInt(match[1], 10); + b = Number.parseInt(match[2], 10); + } + } + + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5; +} + +/** + * GhostText displays a faded inline suggestion after the cursor. + * The suggestion shows the remaining text (suffix) that would complete the command. + * + * User can accept with Right Arrow key. + */ +export function GhostText({ xterm, isVisible }: GhostTextProps) { + const [position, setPosition] = useState<{ x: number; y: number } | null>( + null, + ); + const [cellDimensions, setCellDimensions] = useState({ + width: 9, + height: 17, + }); + const rafRef = useRef(null); + + const suggestion = useAutocompleteStore((s) => s.suggestion); + const commandBuffer = useAutocompleteStore((s) => s.commandBuffer); + + // Determine ghost text color based on terminal background + const ghostColor = useMemo(() => { + const bgColor = xterm?.options?.theme?.background; + if (isLightColor(bgColor)) { + // Light background: use dark ghost text + return "rgba(0, 0, 0, 0.4)"; + } + // Dark background: use light ghost text + return "rgba(255, 255, 255, 0.35)"; + }, [xterm?.options?.theme?.background]); + + // Calculate the suffix to display (what's after what user typed) + const suggestionSuffix = + suggestion && commandBuffer && suggestion.startsWith(commandBuffer) + ? suggestion.slice(commandBuffer.length) + : null; + + // Update position continuously while visible + useEffect(() => { + if (!xterm || !isVisible || !suggestionSuffix) { + // Only update state if needed to prevent unnecessary re-renders + setPosition((prev) => (prev === null ? prev : null)); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + const updatePosition = () => { + try { + const container = xterm.element; + if (!container) return; + + // Get cell dimensions from xterm's internal renderer + // @ts-expect-error - accessing internal property + const dims = xterm._core?._renderService?.dimensions; + const cellWidth = dims?.css?.cell?.width ?? 9; + const cellHeight = dims?.css?.cell?.height ?? 17; + + setCellDimensions({ width: cellWidth, height: cellHeight }); + + // Get cursor position + const cursorX = xterm.buffer.active.cursorX; + const cursorY = xterm.buffer.active.cursorY; + + // Calculate pixel position - account for any viewport offset + const x = cursorX * cellWidth; + // The y position needs to align with the text baseline + const y = cursorY * cellHeight; + + setPosition({ x, y }); + } catch { + setPosition(null); + } + + // Keep updating while visible + rafRef.current = requestAnimationFrame(updatePosition); + }; + + updatePosition(); + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [xterm, isVisible, suggestionSuffix]); + + if (!isVisible || !suggestionSuffix || !position) { + return null; + } + + // Get the actual font settings from xterm options + const fontSize = xterm?.options?.fontSize ?? 14; + const fontFamily = xterm?.options?.fontFamily ?? "monospace"; + + return ( +
+ + {suggestionSuffix} + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/GhostText/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/GhostText/index.ts new file mode 100644 index 000000000..1a12d025f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/GhostText/index.ts @@ -0,0 +1 @@ +export { GhostText } from "./GhostText"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/HistoryPicker/HistoryPicker.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/HistoryPicker/HistoryPicker.tsx new file mode 100644 index 000000000..885c45c30 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/HistoryPicker/HistoryPicker.tsx @@ -0,0 +1,258 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useAutocompleteStore } from "../stores/autocomplete-store"; + +interface HistoryPickerProps { + workspaceId: string; + onSelect: (command: string) => void; + onClose: () => void; +} + +interface HistoryItem { + command: string; + timestamp: number; + workspaceId: string | null; + cwd: string | null; +} + +/** + * HistoryPicker - A polished command palette for searching command history. + * Triggered by Ctrl+R. + */ +export function HistoryPicker({ + workspaceId, + onSelect, + onClose, +}: HistoryPickerProps) { + const isOpen = useAutocompleteStore((s) => s.isHistoryPickerOpen); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Fetch history based on query + const { data: historyResults, isLoading } = + trpc.autocomplete.searchHistory.useQuery( + { + query, + limit: 15, + workspaceId, + }, + { + enabled: isOpen, + }, + ); + + // Also fetch recent commands when query is empty + const { data: recentResults } = trpc.autocomplete.getRecent.useQuery( + { + limit: 15, + workspaceId, + }, + { + enabled: isOpen && !query, + }, + ); + + const results: HistoryItem[] = query + ? (historyResults ?? []) + : (recentResults ?? []); + + // Reset selection when results change + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset on results.length change + useEffect(() => { + setSelectedIndex(0); + }, [results.length]); + + // Focus input when opened + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + setQuery(""); + setSelectedIndex(0); + } + }, [isOpen]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current) { + const selectedElement = listRef.current.children[selectedIndex] as + | HTMLElement + | undefined; + selectedElement?.scrollIntoView({ block: "nearest" }); + } + }, [selectedIndex]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (results[selectedIndex]) { + onSelect(results[selectedIndex].command); + onClose(); + } + break; + case "Escape": + e.preventDefault(); + onClose(); + break; + case "Tab": + e.preventDefault(); + if (e.shiftKey) { + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else { + setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); + } + break; + } + }, + [results, selectedIndex, onSelect, onClose], + ); + + const handleItemClick = useCallback( + (command: string) => { + onSelect(command); + onClose(); + }, + [onSelect, onClose], + ); + + // Highlight matching portions of the command + const highlightMatch = (command: string, searchQuery: string) => { + if (!searchQuery) return {command}; + + const lowerCommand = command.toLowerCase(); + const lowerQuery = searchQuery.toLowerCase(); + const index = lowerCommand.indexOf(lowerQuery); + + if (index === -1) return {command}; + + return ( + <> + {command.slice(0, index)} + + {command.slice(index, index + searchQuery.length)} + + + {command.slice(index + searchQuery.length)} + + + ); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Search Input */} +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search command history..." + className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+ + ↑↓ + + navigate +
+
+ + {/* Results List */} +
+ {isLoading && query ? ( +
+ Searching... +
+ ) : results.length === 0 ? ( +
+ {query ? "No matching commands" : "No command history"} +
+ ) : ( +
+ {results.map((item, index) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ Command History +
+ + + esc + {" "} + to close + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/HistoryPicker/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/HistoryPicker/index.ts new file mode 100644 index 000000000..0fb842898 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/HistoryPicker/index.ts @@ -0,0 +1 @@ +export { HistoryPicker } from "./HistoryPicker"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/index.ts new file mode 100644 index 000000000..43858b213 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/index.ts @@ -0,0 +1,2 @@ +export { useFileCompletions } from "./useFileCompletions"; +export { useGhostSuggestion } from "./useGhostSuggestion"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/useFileCompletions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/useFileCompletions.ts new file mode 100644 index 000000000..f19de0c5c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/useFileCompletions.ts @@ -0,0 +1,102 @@ +import { useCallback } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useAutocompleteStore } from "../stores/autocomplete-store"; + +interface UseFileCompletionsOptions { + cwd: string | null; +} + +/** + * Hook for fetching and managing file completions. + * Called when user presses Tab in a path context. + */ +export function useFileCompletions({ cwd }: UseFileCompletionsOptions) { + const openCompletionDropdown = useAutocompleteStore( + (s) => s.openCompletionDropdown, + ); + const closeCompletionDropdown = useAutocompleteStore( + (s) => s.closeCompletionDropdown, + ); + const commandBuffer = useAutocompleteStore((s) => s.commandBuffer); + + const utils = trpc.useUtils(); + + /** + * Extract the path portion from the command buffer. + * Looks for the last space-separated token that could be a path. + */ + const extractPathFromBuffer = useCallback((buffer: string): string | null => { + if (!buffer.trim()) return null; + + // Split by spaces, get last token + const tokens = buffer.split(/\s+/); + const lastToken = tokens[tokens.length - 1]; + + // If it looks like a path or starts a path + if ( + lastToken && + (lastToken.includes("/") || + lastToken.startsWith(".") || + lastToken.startsWith("~") || + // Or it's after common path-taking commands + [ + "cd", + "ls", + "cat", + "vim", + "code", + "open", + "rm", + "cp", + "mv", + "mkdir", + ].includes(tokens[0])) + ) { + return lastToken; + } + + return null; + }, []); + + /** + * Trigger file completion for the current command buffer. + */ + const triggerCompletion = useCallback(async () => { + if (!cwd) return false; + + const partial = extractPathFromBuffer(commandBuffer); + if (!partial && commandBuffer.trim()) { + // No path context, but there's content - might be after a command + // Try completing with empty partial (list cwd) + } + + try { + const result = await utils.autocomplete.listCompletions.fetch({ + partial: partial || "", + cwd, + showHidden: false, + type: "all", + }); + + if (result.completions.length > 0) { + openCompletionDropdown(result.completions); + return true; + } + return false; + } catch { + return false; + } + }, [ + cwd, + commandBuffer, + extractPathFromBuffer, + utils.autocomplete.listCompletions, + openCompletionDropdown, + ]); + + return { + triggerCompletion, + closeCompletionDropdown, + extractPathFromBuffer, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/useGhostSuggestion.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/useGhostSuggestion.ts new file mode 100644 index 000000000..86fe3f8a1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/hooks/useGhostSuggestion.ts @@ -0,0 +1,95 @@ +import debounce from "lodash/debounce"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { useAutocompleteStore } from "../stores/autocomplete-store"; + +interface UseGhostSuggestionOptions { + workspaceId: string; + enabled?: boolean; + debounceMs?: number; +} + +/** + * Hook that fetches ghost text suggestions based on the current command buffer. + * Uses debouncing to avoid excessive API calls. + */ +export function useGhostSuggestion({ + workspaceId, + enabled = true, + debounceMs = 100, +}: UseGhostSuggestionOptions) { + const commandBuffer = useAutocompleteStore((s) => s.commandBuffer); + const setSuggestion = useAutocompleteStore((s) => s.setSuggestion); + + const utils = trpc.useUtils(); + const lastQueryRef = useRef(""); + + const fetchSuggestion = useCallback( + async (prefix: string) => { + if (!prefix || prefix.length < 2) { + setSuggestion(null); + return; + } + + // Skip if same as last query + if (prefix === lastQueryRef.current) { + return; + } + lastQueryRef.current = prefix; + + try { + const result = await utils.autocomplete.getRecentMatch.fetch({ + prefix, + workspaceId, + }); + + // Only update if this is still the current query + if (prefix === lastQueryRef.current) { + setSuggestion(result, prefix); + } + } catch { + // Silently fail - don't break typing experience + } + }, + [setSuggestion, utils.autocomplete.getRecentMatch, workspaceId], + ); + + const debouncedFetch = useMemo( + () => debounce(fetchSuggestion, debounceMs), + [fetchSuggestion, debounceMs], + ); + + // Fetch suggestion when command buffer changes + useEffect(() => { + if (!enabled) { + setSuggestion(null); + return; + } + + const trimmed = commandBuffer.trim(); + if (trimmed) { + debouncedFetch(trimmed); + } else { + setSuggestion(null); + debouncedFetch.cancel(); + lastQueryRef.current = ""; + } + + return () => { + debouncedFetch.cancel(); + }; + }, [commandBuffer, enabled, debouncedFetch, setSuggestion]); + + // Clear on unmount + useEffect(() => { + return () => { + setSuggestion(null); + debouncedFetch.cancel(); + }; + }, [setSuggestion, debouncedFetch]); + + return { + suggestion: useAutocompleteStore((s) => s.suggestion), + commandBuffer, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/index.ts new file mode 100644 index 000000000..015be0dd1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/index.ts @@ -0,0 +1,5 @@ +export { CompletionDropdown } from "./CompletionDropdown"; +export { GhostText } from "./GhostText"; +export { HistoryPicker } from "./HistoryPicker"; +export { useFileCompletions, useGhostSuggestion } from "./hooks"; +export { useAutocompleteStore } from "./stores"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/stores/autocomplete-store.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/stores/autocomplete-store.ts new file mode 100644 index 000000000..1c476e4a7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/stores/autocomplete-store.ts @@ -0,0 +1,134 @@ +import { create } from "zustand"; + +export interface AutocompleteState { + // Ghost text suggestion + suggestion: string | null; + suggestionPrefix: string; + + // Command buffer tracking + commandBuffer: string; + + // UI state + isHistoryPickerOpen: boolean; + isCompletionDropdownOpen: boolean; + + // Completion dropdown state + completions: Array<{ + name: string; + insertText: string; + isDirectory: boolean; + icon: string; + }>; + selectedCompletionIndex: number; + + // Actions + setSuggestion: (suggestion: string | null, prefix?: string) => void; + setCommandBuffer: (buffer: string) => void; + appendToCommandBuffer: (char: string) => void; + backspaceCommandBuffer: () => void; + clearCommandBuffer: () => void; + openHistoryPicker: () => void; + closeHistoryPicker: () => void; + openCompletionDropdown: ( + completions: Array<{ + name: string; + insertText: string; + isDirectory: boolean; + icon: string; + }>, + ) => void; + closeCompletionDropdown: () => void; + selectNextCompletion: () => void; + selectPrevCompletion: () => void; + getSelectedCompletion: () => + | { + name: string; + insertText: string; + isDirectory: boolean; + icon: string; + } + | undefined; + reset: () => void; +} + +const initialState = { + suggestion: null, + suggestionPrefix: "", + commandBuffer: "", + isHistoryPickerOpen: false, + isCompletionDropdownOpen: false, + completions: [], + selectedCompletionIndex: 0, +}; + +export const useAutocompleteStore = create((set, get) => ({ + ...initialState, + + setSuggestion: (suggestion, prefix = "") => { + set({ suggestion, suggestionPrefix: prefix }); + }, + + setCommandBuffer: (buffer) => { + set({ commandBuffer: buffer }); + }, + + appendToCommandBuffer: (char) => { + set((state) => ({ commandBuffer: state.commandBuffer + char })); + }, + + backspaceCommandBuffer: () => { + set((state) => ({ commandBuffer: state.commandBuffer.slice(0, -1) })); + }, + + clearCommandBuffer: () => { + set({ commandBuffer: "", suggestion: null, suggestionPrefix: "" }); + }, + + openHistoryPicker: () => { + set({ isHistoryPickerOpen: true }); + }, + + closeHistoryPicker: () => { + set({ isHistoryPickerOpen: false }); + }, + + openCompletionDropdown: (completions) => { + set({ + isCompletionDropdownOpen: true, + completions, + selectedCompletionIndex: 0, + }); + }, + + closeCompletionDropdown: () => { + set({ + isCompletionDropdownOpen: false, + completions: [], + selectedCompletionIndex: 0, + }); + }, + + selectNextCompletion: () => { + set((state) => ({ + selectedCompletionIndex: + (state.selectedCompletionIndex + 1) % state.completions.length, + })); + }, + + selectPrevCompletion: () => { + set((state) => ({ + selectedCompletionIndex: + (state.selectedCompletionIndex - 1 + state.completions.length) % + state.completions.length, + })); + }, + + getSelectedCompletion: () => { + const state = get(); + return state.completions[state.selectedCompletionIndex]; + }, + + reset: () => { + set(initialState); + }, +})); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/stores/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/stores/index.ts new file mode 100644 index 000000000..2d95c2468 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Autocomplete/stores/index.ts @@ -0,0 +1,4 @@ +export { + type AutocompleteState, + useAutocompleteStore, +} from "./autocomplete-store"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index acca806c0..5b4b038c1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -10,6 +10,14 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; import { HOTKEYS } from "shared/hotkeys"; +import { + CompletionDropdown, + GhostText, + HistoryPicker, + useAutocompleteStore, + useFileCompletions, + useGhostSuggestion, +} from "./Autocomplete"; import { sanitizeForTitle } from "./commandBuffer"; import { createTerminalInstance, @@ -44,6 +52,28 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const [isSearchOpen, setIsSearchOpen] = useState(false); const [terminalCwd, setTerminalCwd] = useState(null); const [cwdConfirmed, setCwdConfirmed] = useState(false); + + // Autocomplete state + const setCommandBuffer = useAutocompleteStore((s) => s.setCommandBuffer); + const clearCommandBuffer = useAutocompleteStore((s) => s.clearCommandBuffer); + const backspaceCommandBuffer = useAutocompleteStore( + (s) => s.backspaceCommandBuffer, + ); + const appendToCommandBuffer = useAutocompleteStore( + (s) => s.appendToCommandBuffer, + ); + const closeHistoryPicker = useAutocompleteStore((s) => s.closeHistoryPicker); + const isHistoryPickerOpen = useAutocompleteStore( + (s) => s.isHistoryPickerOpen, + ); + const isCompletionDropdownOpen = useAutocompleteStore( + (s) => s.isCompletionDropdownOpen, + ); + const suggestion = useAutocompleteStore((s) => s.suggestion); + const closeCompletionDropdown = useAutocompleteStore( + (s) => s.closeCompletionDropdown, + ); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); @@ -55,7 +85,21 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const isFocused = pane?.tabId ? focusedPaneIds[pane.tabId] === paneId : false; + // Ghost suggestion hook + useGhostSuggestion({ + workspaceId, + enabled: isFocused && !isHistoryPickerOpen && !isCompletionDropdownOpen, + }); + + // File completions hook + const { triggerCompletion } = useFileCompletions({ + cwd: terminalCwd, + }); + // Refs avoid effect re-runs when these values change + const triggerCompletionRef = useRef(triggerCompletion); + triggerCompletionRef.current = triggerCompletion; + const isFocusedRef = useRef(isFocused); isFocusedRef.current = isFocused; @@ -290,16 +334,20 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); } commandBufferRef.current = ""; + clearCommandBuffer(); } else if (domEvent.key === "Backspace") { commandBufferRef.current = commandBufferRef.current.slice(0, -1); + backspaceCommandBuffer(); } else if (domEvent.key === "c" && domEvent.ctrlKey) { commandBufferRef.current = ""; + clearCommandBuffer(); } else if ( domEvent.key.length === 1 && !domEvent.ctrlKey && !domEvent.metaKey ) { commandBufferRef.current += domEvent.key; + appendToCommandBuffer(domEvent.key); } }; @@ -357,6 +405,33 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const cleanupKeyboard = setupKeyboardHandler(xterm, { onShiftEnter: () => handleWrite("\\\n"), onClear: handleClear, + onHistoryPicker: () => { + useAutocompleteStore.getState().openHistoryPicker(); + }, + autocomplete: { + getSuggestion: () => ({ + suggestion: useAutocompleteStore.getState().suggestion, + buffer: useAutocompleteStore.getState().commandBuffer, + }), + onAcceptSuggestion: (suffix) => { + const fullCommand = + useAutocompleteStore.getState().commandBuffer + suffix; + handleWrite(suffix); + commandBufferRef.current = fullCommand; + setCommandBuffer(fullCommand); + }, + onTabCompletion: async () => { + const triggered = await triggerCompletionRef.current(); + if (!triggered) { + // No completions, let shell handle Tab + handleWrite("\t"); + } + return triggered; + }, + isDropdownOpen: () => + useAutocompleteStore.getState().isCompletionDropdownOpen, + closeDropdown: () => closeCompletionDropdown(), + }, }); // Setup click-to-move cursor (click on prompt line to move cursor) @@ -381,6 +456,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const cleanupPaste = setupPasteHandler(xterm, { onPaste: (text) => { commandBufferRef.current += text; + setCommandBuffer(commandBufferRef.current); }, }); @@ -400,11 +476,25 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // Detach instead of kill to keep PTY running for reattachment detachRef.current({ paneId }); setSubscriptionEnabled(false); + // Clear autocomplete state + clearCommandBuffer(); + closeHistoryPicker(); + closeCompletionDropdown(); xterm.dispose(); xtermRef.current = null; searchAddonRef.current = null; }; - }, [paneId, workspaceId, workspaceCwd]); + }, [ + paneId, + workspaceId, + workspaceCwd, + appendToCommandBuffer, + backspaceCommandBuffer, + clearCommandBuffer, + closeCompletionDropdown, + closeHistoryPicker, + setCommandBuffer, + ]); useEffect(() => { const xterm = xtermRef.current; @@ -434,6 +524,42 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }; + // Handler for history picker selection + const handleHistorySelect = useCallback( + (command: string) => { + if (!isExitedRef.current) { + // Clear current line and write selected command + writeRef.current({ paneId, data: "\x15" }); // Ctrl+U to clear line + writeRef.current({ paneId, data: command }); + commandBufferRef.current = command; + setCommandBuffer(command); + } + // Refocus terminal after selection + xtermRef.current?.focus(); + }, + [paneId, setCommandBuffer], + ); + + // Handler for closing history picker (refocuses terminal) + const handleHistoryClose = useCallback(() => { + closeHistoryPicker(); + // Refocus terminal after closing + xtermRef.current?.focus(); + }, [closeHistoryPicker]); + + // Handler for completion selection + const handleCompletionSelect = useCallback( + (insertText: string) => { + if (!isExitedRef.current) { + writeRef.current({ paneId, data: insertText }); + commandBufferRef.current += insertText; + setCommandBuffer(commandBufferRef.current); + } + closeCompletionDropdown(); + }, + [paneId, setCommandBuffer, closeCompletionDropdown], + ); + return (
{ isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} /> + + {/* Ghost text suggestion overlay */} + + + {/* History picker modal (Ctrl+R) */} + + + {/* File completion dropdown (Tab) */} + +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 22861e8df..e96ec8417 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -177,6 +177,21 @@ export interface KeyboardHandlerOptions { onShiftEnter?: () => void; /** Callback for Cmd+K to clear the terminal */ onClear?: () => void; + /** Callback for Ctrl+R to open history picker */ + onHistoryPicker?: () => void; + /** Autocomplete callbacks */ + autocomplete?: { + /** Get current suggestion for ghost text */ + getSuggestion?: () => { suggestion: string | null; buffer: string }; + /** Called when user accepts suggestion with Right Arrow */ + onAcceptSuggestion?: (suffix: string) => void; + /** Called when Tab is pressed for file completion */ + onTabCompletion?: () => Promise; + /** Check if completion dropdown is open */ + isDropdownOpen?: () => boolean; + /** Close completion dropdown */ + closeDropdown?: () => void; + }; } export interface PasteHandlerOptions { @@ -264,6 +279,67 @@ export function setupKeyboardHandler( return false; } + // Ctrl+R to open history picker (intercept before shell gets it) + const isHistorySearch = + event.key.toLowerCase() === "r" && + event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey; + + if (isHistorySearch) { + if (event.type === "keydown" && options.onHistoryPicker) { + options.onHistoryPicker(); + } + return false; + } + + // Handle autocomplete interactions (only on keydown) + if (event.type === "keydown" && options.autocomplete) { + const { autocomplete } = options; + + // Handle Escape to close dropdown + if (event.key === "Escape" && autocomplete.isDropdownOpen?.()) { + autocomplete.closeDropdown?.(); + return false; + } + + // Handle Right Arrow to accept ghost suggestion + if ( + event.key === "ArrowRight" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + const state = autocomplete.getSuggestion?.(); + if ( + state?.suggestion && + state.buffer && + state.suggestion.startsWith(state.buffer) + ) { + const suffix = state.suggestion.slice(state.buffer.length); + if (suffix) { + autocomplete.onAcceptSuggestion?.(suffix); + return false; + } + } + } + + // Tab completion disabled for now - let shell handle Tab natively + // TODO: Re-enable when we have proper context-aware completion + // if ( + // event.key === "Tab" && + // !event.shiftKey && + // !event.ctrlKey && + // !event.metaKey && + // !autocomplete.isDropdownOpen?.() + // ) { + // autocomplete.onTabCompletion?.(); + // return false; + // } + } + if (event.type !== "keydown") return true; if (!event.metaKey && !event.ctrlKey) return true;