From b7ae5d552668a22308dec2f7a0558c5fbc8c47c4 Mon Sep 17 00:00:00 2001 From: liukun4515 Date: Thu, 9 Jul 2026 20:09:17 +0800 Subject: [PATCH] feat: add filesystem path suggestions in new session --- src/core/path-suggest.test.ts | 89 +++++++++++++++++ src/core/path-suggest.ts | 127 ++++++++++++++++++++++++ src/tui/component/dialog-new-wizard.tsx | 36 ++++++- src/tui/component/dialog-new.tsx | 36 ++++++- 4 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 src/core/path-suggest.test.ts create mode 100644 src/core/path-suggest.ts diff --git a/src/core/path-suggest.test.ts b/src/core/path-suggest.test.ts new file mode 100644 index 0000000..704483a --- /dev/null +++ b/src/core/path-suggest.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import { getDirectorySuggestions, mergePathSuggestions } from "./path-suggest" + +function withTempDir(run: (dir: string) => void) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "av-path-suggest-")) + try { + run(dir) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +} + +describe("path-suggest", () => { + describe("getDirectorySuggestions", () => { + test("suggests directories from cwd for empty input", () => { + withTempDir((dir) => { + fs.mkdirSync(path.join(dir, "alpha")) + fs.mkdirSync(path.join(dir, "beta")) + fs.writeFileSync(path.join(dir, "not-a-dir.txt"), "x") + + const result = getDirectorySuggestions("", { cwd: dir, home: dir }) + + expect(result).toEqual(["alpha/", "beta/"]) + }) + }) + + test("filters by prefix for relative input", () => { + withTempDir((dir) => { + fs.mkdirSync(path.join(dir, "apps")) + fs.mkdirSync(path.join(dir, "api")) + fs.mkdirSync(path.join(dir, "docs")) + + const result = getDirectorySuggestions("ap", { cwd: dir, home: dir }) + + expect(result).toEqual(["api/", "apps/"]) + }) + }) + + test("hides dot-directories unless prefix starts with dot", () => { + withTempDir((dir) => { + fs.mkdirSync(path.join(dir, ".cache")) + fs.mkdirSync(path.join(dir, "project")) + + const normal = getDirectorySuggestions("", { cwd: dir, home: dir }) + expect(normal).toEqual(["project/"]) + + const hidden = getDirectorySuggestions(".", { cwd: dir, home: dir }) + expect(hidden).toEqual([".cache/"]) + }) + }) + + test("formats home-prefixed input as ~/...", () => { + withTempDir((home) => { + fs.mkdirSync(path.join(home, "workspace")) + fs.mkdirSync(path.join(home, "workbench")) + + const result = getDirectorySuggestions("~/work", { cwd: "/tmp", home }) + + expect(result).toEqual(["~/workbench/", "~/workspace/"]) + }) + }) + + test("returns empty array when parent directory does not exist", () => { + withTempDir((dir) => { + const result = getDirectorySuggestions("does-not-exist/x", { cwd: dir, home: dir }) + expect(result).toEqual([]) + }) + }) + }) + + describe("mergePathSuggestions", () => { + test("prioritizes filesystem suggestions and deduplicates", () => { + withTempDir((dir) => { + fs.mkdirSync(path.join(dir, "api")) + fs.mkdirSync(path.join(dir, "apps")) + + const merged = mergePathSuggestions("ap", ["apps/", "archive/"], { + cwd: dir, + home: dir + }) + + expect(merged).toEqual(["api/", "apps/", "archive/"]) + }) + }) + }) +}) diff --git a/src/core/path-suggest.ts b/src/core/path-suggest.ts new file mode 100644 index 0000000..4ae0502 --- /dev/null +++ b/src/core/path-suggest.ts @@ -0,0 +1,127 @@ +/** + * Filesystem-backed path suggestions for TUI inputs. + */ + +import fs from "fs" +import path from "path" + +export interface PathSuggestionOptions { + cwd?: string + home?: string + limit?: number +} + +type InputMode = "home" | "absolute" | "relative" + +function detectMode(input: string): InputMode { + if (input.startsWith("~")) return "home" + if (path.isAbsolute(input)) return "absolute" + return "relative" +} + +function resolveInputPath(input: string, cwd: string, home: string): string { + if (!input) return cwd + + if (input.startsWith("~")) { + if (input === "~") return home + if (input.startsWith("~/")) return path.join(home, input.slice(2)) + // Keep behavior consistent with existing path expansion in dialogs. + return path.join(home, input.slice(1)) + } + + if (path.isAbsolute(input)) { + return input + } + + return path.resolve(cwd, input) +} + +function formatSuggestion(absPath: string, mode: InputMode, cwd: string, home: string): string { + let display: string + + if (mode === "home") { + display = absPath.startsWith(home) ? `~${absPath.slice(home.length)}` : absPath + } else if (mode === "absolute") { + display = absPath + } else { + display = path.relative(cwd, absPath) || "." + } + + return display.endsWith("/") ? display : `${display}/` +} + +/** + * Return directory suggestions based on the typed path prefix. + * Suggestions are generated from the filesystem (not history). + */ +export function getDirectorySuggestions(input: string, options: PathSuggestionOptions = {}): string[] { + const cwd = options.cwd || process.cwd() + const home = options.home || process.env.HOME || cwd + const limit = options.limit ?? 30 + const trimmed = input.trim() + const mode = detectMode(trimmed) + const resolved = resolveInputPath(trimmed, cwd, home) + const hasTrailingSlash = trimmed.endsWith("/") + + let searchDir: string + let prefix: string + + if (trimmed === "") { + searchDir = cwd + prefix = "" + } else if (trimmed === "~") { + searchDir = home + prefix = "" + } else if (trimmed === ".") { + searchDir = cwd + prefix = "." + } else if (trimmed === "..") { + searchDir = path.resolve(cwd, "..") + prefix = "" + } else if (hasTrailingSlash) { + searchDir = resolved + prefix = "" + } else { + searchDir = path.dirname(resolved) + prefix = path.basename(resolved) + } + + const prefixLower = prefix.toLowerCase() + + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(searchDir, { withFileTypes: true }) + } catch { + return [] + } + + const showDotDirs = prefix.startsWith(".") + + return entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => { + if (!showDotDirs && entry.name.startsWith(".")) return false + return entry.name.toLowerCase().startsWith(prefixLower) + }) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, limit) + .map((entry) => formatSuggestion(path.join(searchDir, entry.name), mode, cwd, home)) +} + +/** + * Merge filesystem directory suggestions with existing history suggestions. + * Filesystem suggestions are prioritized so users can explore valid paths first. + */ +export function mergePathSuggestions(input: string, historySuggestions: string[], options: PathSuggestionOptions = {}): string[] { + const fsSuggestions = getDirectorySuggestions(input, options) + const merged: string[] = [] + const seen = new Set() + + for (const value of [...fsSuggestions, ...historySuggestions]) { + if (seen.has(value)) continue + seen.add(value) + merged.push(value) + } + + return merged +} diff --git a/src/tui/component/dialog-new-wizard.tsx b/src/tui/component/dialog-new-wizard.tsx index abedda1..3cffd97 100644 --- a/src/tui/component/dialog-new-wizard.tsx +++ b/src/tui/component/dialog-new-wizard.tsx @@ -18,6 +18,7 @@ import { ActionButton } from "@tui/ui/action-button" import { attachSessionSync } from "@/core/tmux" import { isGitRepo, getRepoRoot, createWorktree, generateBranchName, generateWorktreePath, sanitizeBranchName, branchExists } from "@/core/git" import { HistoryManager } from "@/core/history" +import { getDirectorySuggestions, mergePathSuggestions } from "@/core/path-suggest" import { getStorage } from "@/core/storage" import type { Tool, ClaudeSessionMode } from "@/core/types" import { getToolCommand } from "@/core/types" @@ -115,6 +116,26 @@ export function DialogNewWizard() { const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] const storage = getStorage() + const filesystemPathSuggestions = createMemo(() => + getDirectorySuggestions(projectPath(), { + cwd: process.cwd(), + home: process.env.HOME || process.cwd(), + limit: 30 + }) + ) + + const projectPathSuggestions = createMemo(() => + mergePathSuggestions( + projectPath(), + projectPathHistory.getFiltered(storage, projectPath()), + { + cwd: process.cwd(), + home: process.env.HOME || process.cwd(), + limit: 30 + } + ) + ) + let titleInputRef: InputRenderable | undefined let customCommandInputRef: InputRenderable | undefined let pathInputRef: InputRenderable | undefined @@ -409,6 +430,15 @@ export function DialogNewWizard() { // Path step: Tab navigation and worktree toggle if (currentStep() === "path") { if (evt.name === "tab") { + if (pathFocusField() === "path" && !evt.shift && !projectPath().trim().endsWith("/")) { + const firstSuggestion = filesystemPathSuggestions()[0] + if (firstSuggestion) { + evt.preventDefault() + setProjectPath(firstSuggestion) + return + } + } + evt.preventDefault() const fields = getPathFocusableFields() const currentIdx = fields.indexOf(pathFocusField()) @@ -542,7 +572,7 @@ export function DialogNewWizard() { + getDirectorySuggestions(projectPath(), { + cwd: process.cwd(), + home: process.env.HOME || process.cwd(), + limit: 30 + }) + ) + + const projectPathSuggestions = createMemo(() => + mergePathSuggestions( + projectPath(), + projectPathHistory.getFiltered(storage, projectPath()), + { + cwd: process.cwd(), + home: process.env.HOME || process.cwd(), + limit: 30 + } + ) + ) + const [focusedField, setFocusedField] = createSignal("title") const [toolIndex, setToolIndex] = createSignal(defaultToolIndex >= 0 ? defaultToolIndex : 0) @@ -309,6 +330,15 @@ export function DialogNew() { } if (evt.name === "tab") { + if (focusedField() === "path" && !evt.shift && !projectPath().trim().endsWith("/")) { + const firstSuggestion = filesystemPathSuggestions()[0] + if (firstSuggestion) { + evt.preventDefault() + setProjectPath(firstSuggestion) + return + } + } + evt.preventDefault() const fields = getFocusableFields() if (fields.length === 0) return @@ -514,7 +544,7 @@ export function DialogNew() { - + ) }