Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/core/path-suggest.test.ts
Original file line number Diff line number Diff line change
@@ -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/"])
})
})
})
})
127 changes: 127 additions & 0 deletions src/core/path-suggest.ts
Original file line number Diff line number Diff line change
@@ -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<string>()

for (const value of [...fsSuggestions, ...historySuggestions]) {
if (seen.has(value)) continue
seen.add(value)
merged.push(value)
}

return merged
}
36 changes: 33 additions & 3 deletions src/tui/component/dialog-new-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -542,7 +572,7 @@ export function DialogNewWizard() {
<InputAutocomplete
value={projectPath()}
onInput={setProjectPath}
suggestions={projectPathHistory.getFiltered(storage, projectPath())}
suggestions={projectPathSuggestions()}
onSelect={setProjectPath}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
Expand Down Expand Up @@ -757,9 +787,9 @@ export function DialogNewWizard() {
}
if (step === "path" && isInGitRepo()) {
if (!canProceed()) {
return "Tab: Navigate | Esc: Back"
return "Tab: Complete/Navigate | Esc: Back"
}
return "Tab: Navigate | Enter: Next | Esc: Back"
return "Tab: Complete/Navigate | Enter: Next | Esc: Back"
}
if (!canProceed()) {
return "Esc: Back"
Expand Down
36 changes: 33 additions & 3 deletions src/tui/component/dialog-new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* New session dialog with Tab navigation and worktree support
*/

import { createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import { createSignal, createEffect, For, Show, onCleanup, createMemo } from "solid-js"
import { TextAttributes, InputRenderable } from "@opentui/core"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { useTheme } from "@tui/context/theme"
Expand All @@ -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"
Expand Down Expand Up @@ -106,6 +107,26 @@ export function DialogNew() {

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
}
)
)

const [focusedField, setFocusedField] = createSignal<FocusField>("title")
const [toolIndex, setToolIndex] = createSignal(defaultToolIndex >= 0 ? defaultToolIndex : 0)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -514,7 +544,7 @@ export function DialogNew() {
<InputAutocomplete
value={projectPath()}
onInput={setProjectPath}
suggestions={projectPathHistory.getFiltered(storage, projectPath())}
suggestions={projectPathSuggestions()}
onSelect={setProjectPath}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={theme.primary}
Expand Down Expand Up @@ -610,7 +640,7 @@ export function DialogNew() {
onAction={handleCreate}
/>

<DialogFooter hint={creating() ? statusMessage() : "Tab | Enter: create"} />
<DialogFooter hint={creating() ? statusMessage() : "Tab: complete/next | Enter: create"} />
</box>
)
}