From ade3048f236754bc6750e7acbb1063647bf4645b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 13:43:41 -0700 Subject: [PATCH] feat(tui): offer home-relative completions for at-mentions --- src/tui/components/at-mention/list.test.ts | 49 ++++++++++++++++++++-- src/tui/components/at-mention/list.ts | 34 +++++++++++---- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/tui/components/at-mention/list.test.ts b/src/tui/components/at-mention/list.test.ts index 40158ab25..7955d0506 100644 --- a/src/tui/components/at-mention/list.test.ts +++ b/src/tui/components/at-mention/list.test.ts @@ -1,6 +1,14 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; -import { mkdir, writeFile, rm, symlink, mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { + mkdir, + writeFile, + rm, + symlink, + mkdtemp, + readdir, + stat, +} from "node:fs/promises"; +import { tmpdir, homedir } from "node:os"; import { join } from "node:path"; import { listPathSuggestions } from "./list.js"; @@ -81,8 +89,41 @@ describe("listPathSuggestions", () => { expect(results).toContain("../src/"); }); - test("does not browse home-relative paths", async () => { - expect(await listPathSuggestions("~/", fixture)).toEqual([]); + // Why the old `[]` pin changed (CL-7930): the submit path already expands + // `~/…` via expandHome, so completing to nothing was a dead end — the popup + // offered no way to reach a path submit accepts. Completions now list home + // in `~/` display form, so both paths agree. + test("offers home-relative completions for ~/", async () => { + const names = await readdir(homedir()); + if (names.length === 0) return; + const results = await listPathSuggestions("~/", fixture); + expect(results.length).toBeGreaterThan(0); + expect(results.every((r) => r.startsWith("~/"))).toBe(true); + }); + + test("offers home-relative completions for bare ~", async () => { + const names = await readdir(homedir()); + if (names.length === 0) return; + const results = await listPathSuggestions("~", fixture); + expect(results.length).toBeGreaterThan(0); + expect(results.every((r) => r.startsWith("~/"))).toBe(true); + }); + + test("filters home-relative completions by fragment", async () => { + const names = await readdir(homedir()); + const probe = names.find((n) => !n.startsWith(".")) ?? names[0]; + if (probe === undefined) return; + const info = await stat(join(homedir(), probe)); + const expected = `~/${probe}${info.isDirectory() ? "/" : ""}`; + const results = await listPathSuggestions(`~/${probe}`, fixture); + expect(results).toContain(expected); + expect(results.every((r) => r.startsWith("~/"))).toBe(true); + }); + + test("returns [] for a nonexistent home-relative path", async () => { + expect( + await listPathSuggestions("~/nonexistent-path-12345-xyz/", fixture), + ).toEqual([]); }); test("follows symlinked directories", async () => { diff --git a/src/tui/components/at-mention/list.ts b/src/tui/components/at-mention/list.ts index 3bb32e4cf..07350453c 100644 --- a/src/tui/components/at-mention/list.ts +++ b/src/tui/components/at-mention/list.ts @@ -1,4 +1,5 @@ import { opendir, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; import { resolve, dirname, basename } from "node:path"; const MAX_SUGGESTIONS = 20; @@ -18,25 +19,33 @@ async function resolveDirectory( // Given a path prefix the user has typed (e.g. after @ or in a path field), // return up to MAX_SUGGESTIONS matching filesystem entries. Directories get a // trailing / so the user can drill in. Never throws — returns [] on any fs error. +// Home-relative prefixes (`~`, `~/…`) list the operator's home directory and +// keep the `~/` display form, matching the submit-time expandHome behavior in +// mention-resolution.ts (the completion popup previously offered nothing here). export async function listPathSuggestions( prefix: string, cwd: string, ): Promise { - if (prefix === "~" || prefix.startsWith("~/")) return []; + // `~` alone lists home, mirroring submit-time expandHome("~") -> homedir(). + const homeRelative = prefix === "~" || prefix.startsWith("~/"); + const rest = homeRelative ? (prefix === "~" ? "" : prefix.slice(2)) : prefix; + const base = homeRelative ? homedir() : cwd; try { - const endsWithSep = prefix.endsWith("/"); + const endsWithSep = homeRelative + ? rest.endsWith("/") || rest === "" + : prefix.endsWith("/"); // When prefix ends with / the user wants to list that directory. // When prefix contains a slash but doesn't end with one, split on the last // slash: the left side is the dir to list, the right side is the filter. // When prefix has NO slash at all (including the empty string), list cwd // and use the whole prefix as a filter. This is the `@` alone case which // should behave like `ls` in the current directory. - const lastSlash = prefix.lastIndexOf("/"); + const lastSlash = rest.lastIndexOf("/"); const hasSlash = lastSlash !== -1; - const dir = endsWithSep ? prefix : hasSlash ? dirname(prefix) : "."; - const fragment = endsWithSep ? "" : hasSlash ? basename(prefix) : prefix; - const realDir = await resolveDirectory(dir, cwd); + const dir = endsWithSep ? rest || "." : hasSlash ? dirname(rest) : "."; + const fragment = endsWithSep ? "" : hasSlash ? basename(rest) : rest; + const realDir = await resolveDirectory(dir, base); if (realDir === null) return []; const matched: string[] = []; @@ -50,11 +59,18 @@ export async function listPathSuggestions( // Reconstruct the path the user would type. For bare-fragment prefixes // (no slash) entries are shown relative to cwd so dirPrefix is "". - const dirPrefix = endsWithSep - ? prefix + // Home-relative results keep the `~/` form so the completion inserts + // text the submit path expands verbatim. + const innerPrefix = endsWithSep + ? rest : hasSlash - ? prefix.slice(0, prefix.length - fragment.length) + ? rest.slice(0, rest.length - fragment.length) : ""; + const dirPrefix = homeRelative + ? `~/${innerPrefix}` + : endsWithSep + ? prefix + : innerPrefix; matched.push(dirPrefix + entry.name + (entry.isDirectory() ? "/" : "")); }