From b378cc0a72228fa6ec2201a7d7d8dd9b7a5fa77b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:35 -0500 Subject: [PATCH 1/4] fix(gateway): let `@` completion find folders by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzzy branch of `complete.path` ranked basenames from `_list_repo_files`, which lists files only, so a directory was only ever reachable by typing a `/` — `@Desktop` returned nothing at all. Rank each ancestor directory alongside the files, and break same-tier ties toward the folder so `@docs` leads with `docs/` rather than `docs.md`. Outside a git repo the fallback `os.walk` compounded this: it can spend the whole `_FUZZY_CACHE_MAX_FILES` budget inside one deep subtree before reaching a sibling, hiding top-level folders entirely. Seed the scan with a `listdir` of the root so immediate children are always candidates. --- tests/gateway/test_complete_path_at_filter.py | 76 +++++++++++++++++++ tui_gateway/server.py | 58 ++++++++++---- 2 files changed, 120 insertions(+), 14 deletions(-) diff --git a/tests/gateway/test_complete_path_at_filter.py b/tests/gateway/test_complete_path_at_filter.py index 4a3e292b01fe..f46631dea271 100644 --- a/tests/gateway/test_complete_path_at_filter.py +++ b/tests/gateway/test_complete_path_at_filter.py @@ -277,3 +277,79 @@ def test_fuzzy_paths_relative_to_cwd_inside_subdir(tmp_path, monkeypatch): readme_texts = [t for t, _, _ in _items("@README")] assert not any("README.md" in t for t in readme_texts), readme_texts + + +# ── Fuzzy DIRECTORY matching ───────────────────────────────────────────── +# `@Desktop` used to return nothing: the fuzzy scanner ranks basenames from +# `_list_repo_files`, which lists FILES only, so a directory whose name no +# file inside it happens to match was unreachable without typing a `/`. + + +def test_fuzzy_finds_directory_by_name(tmp_path, monkeypatch): + """A folder is reachable by bare name, with no trailing slash typed.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "Desktop" / "nested").mkdir(parents=True) + # Deliberately named so NO file basename fuzzy-matches "Desktop". + (tmp_path / "Desktop" / "nested" / "zzz.txt").write_text("x") + + entries = _items("@Desktop") + texts = [t for t, _, _ in entries] + + assert "@folder:Desktop/" in texts, texts + + row = next(r for r in entries if r[0] == "@folder:Desktop/") + assert row[1] == "Desktop/" + assert row[2] == "dir" + + +def test_fuzzy_directory_prefix_match(tmp_path, monkeypatch): + """Partial folder names match too — `@Desk` finds `Desktop/`.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "Desktop").mkdir() + (tmp_path / "Desktop" / "zzz.txt").write_text("x") + + assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desk")] + + +def test_fuzzy_ranks_folder_above_file_at_same_tier(tmp_path, monkeypatch): + """At an equal match tier the folder leads: `@docs` means the directory.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "intro.md").write_text("x") + (tmp_path / "docs.md").write_text("x") + + texts = [t for t, _, _ in _items("@docs")] + + assert texts[0] == "@folder:docs/", texts + assert "@file:docs.md" in texts + + +def test_fuzzy_hides_dot_directories_unless_asked(tmp_path, monkeypatch): + """Dot-folders follow the same rule as dotfiles.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".config").mkdir() + (tmp_path / ".config" / "zzz.txt").write_text("x") + + assert not any(".config" in t for t, _, _ in _items("@config")) + assert any(t.startswith("@folder:.config") for t, _, _ in _items("@.config")) + + +def test_fuzzy_finds_top_level_entries_outside_a_git_repo(tmp_path, monkeypatch): + """Outside a repo the fallback walk can exhaust its file budget on one + deep subtree before reaching a sibling, hiding top-level folders. The + root listdir seed guarantees immediate children are always candidates. + """ + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(server, "_FUZZY_CACHE_MAX_FILES", 5) + + # A deep subtree that soaks up the entire (patched) file budget... + deep = tmp_path / "aaa_hog" + deep.mkdir() + for i in range(40): + (deep / f"f{i:03d}.txt").write_text("x") + + # ...and the folder the user actually wants, sorted after it. + (tmp_path / "Desktop").mkdir() + (tmp_path / "Desktop" / "note.txt").write_text("x") + + assert "@folder:Desktop/" in [t for t, _, _ in _items("@Desktop")] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 863601d68eb3..5ab0dd446760 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -16184,24 +16184,54 @@ def _(rid, params: dict) -> dict: and "/" not in path_part and prefix_tag != "folder" ): - ranked: list[tuple[tuple[int, int], str, str]] = [] - for rel in _list_repo_files(root): - basename = os.path.basename(rel) - if basename.startswith(".") and not path_part.startswith("."): - continue - rank = _fuzzy_basename_rank(basename, path_part) - if rank is None: - continue - ranked.append((rank, rel, basename)) + ranked: list[tuple[tuple[int, int], str, str, bool]] = [] + walked_dirs: set[str] = set() + seen: set[str] = set() + want_hidden = path_part.startswith(".") - ranked.sort(key=lambda r: (r[0], len(r[1]), r[1])) + def _consider(rel: str, name: str, is_dir: bool) -> None: + if rel in seen or (name.startswith(".") and not want_hidden): + return + rank = _fuzzy_basename_rank(name, path_part) + if rank is not None: + seen.add(rel) + ranked.append((rank, rel, name, is_dir)) + + # Seed with root's immediate children. `_list_repo_files` is capped + # at _FUZZY_CACHE_MAX_FILES, and outside a git repo the fallback + # walk can burn that whole budget on one deep subtree before ever + # reaching a sibling — which is why `@Desk` in a non-repo $HOME + # found nothing. One listdir keeps the top level always reachable. + try: + for entry in os.listdir(root): + if entry not in _FUZZY_FALLBACK_EXCLUDES: + _consider(entry, entry, os.path.isdir(os.path.join(root, entry))) + except OSError: + pass + + for rel in _list_repo_files(root): + _consider(rel, os.path.basename(rel), False) + + # Directories are only implied by the file listing, so rank each + # ancestor too. Without this a bare `@Desktop` finds nothing — + # a folder with no name-matching file inside it is invisible to + # a file-only scan, which is the "can't @ a folder by name" bug. + parent = os.path.dirname(rel) + while parent and parent not in walked_dirs: + walked_dirs.add(parent) + _consider(parent, os.path.basename(parent), True) + parent = os.path.dirname(parent) + + # Same rank tier: folders first, so `@Desktop` leads with the folder + # rather than a file that merely fuzzy-matches the same letters. + ranked.sort(key=lambda r: (r[0], not r[3], len(r[1]), r[1])) tag = prefix_tag or "file" - for _, rel, basename in ranked[:30]: + for _, rel, basename, is_dir in ranked[:30]: items.append( { - "text": f"@{tag}:{rel}", - "display": basename, - "meta": os.path.dirname(rel), + "text": f"@{'folder' if is_dir else tag}:{rel}{'/' if is_dir else ''}", + "display": basename + ("/" if is_dir else ""), + "meta": "dir" if is_dir else os.path.dirname(rel), } ) From de0b376cc9dbafe72f3b7c43dfaacb5b089bd447 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:44 -0500 Subject: [PATCH 2/4] fix(desktop): keep the `@` popover open while typing a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AT_TRIGGER_RE` excluded `/` from the query, so the trigger died on the first separator: `@/desk`, `@./www`, `@~/Desktop` and even `@file:src/foo` all stopped matching the moment a path appeared. The gateway already answered those queries correctly — the composer just never asked. A `/` inside an `@` token is navigation, not a delimiter. The token stays whitespace-bounded, which is what actually ends it. --- .../src/app/chat/composer/text-utils.test.ts | 33 +++++++++++++++++++ .../src/app/chat/composer/text-utils.ts | 8 +++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index ca2ac8035761..2a350593f45c 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -51,6 +51,39 @@ describe('detectTrigger', () => { expect(detectTrigger('and/or')).toBeNull() }) + it('keeps the at-mention live while walking into subfolders', () => { + // A `/` inside the query is path navigation, not the end of the token — + // the popover has to stay open so the next directory level can load. + expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 }) + expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 }) + expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 }) + expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 }) + expect(detectTrigger('@apps/desktop/src')).toEqual({ + kind: '@', + query: 'apps/desktop/src', + tokenLength: 17 + }) + }) + + it('keeps the at-mention live for a typed ref kind with a path', () => { + expect(detectTrigger('@file:src/main.tsx')).toEqual({ + kind: '@', + query: 'file:src/main.tsx', + tokenLength: 18 + }) + expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 }) + }) + + it('still ends the at-mention token at whitespace', () => { + // The token is whitespace-delimited; a path doesn't change that. + expect(detectTrigger('@./src and more')).toBeNull() + expect(detectTrigger('look at @apps/desktop')).toEqual({ + kind: '@', + query: 'apps/desktop', + tokenLength: 13 + }) + }) + it('treats a mid-message slash as an inline reference', () => { // Skills have to be reachable anywhere in a prompt, not just at position 0. expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 }) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 8bc6663210e5..3029c7bc601d 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -10,7 +10,11 @@ export interface TriggerState { } // `@` triggers stop at the first whitespace — `@file:path` and `@diff` are -// single tokens. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids +// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`, +// and `@file:src/foo` all have to keep the popover live while the user walks +// into subdirectories. Excluding `/` from the query class would end the token +// at the first separator, which is exactly the "can't browse into a folder" +// bug. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids // matching file paths like `src/foo/bar`. // // `/` triggers fire in two shapes, because a slash means two different things @@ -27,7 +31,7 @@ export interface TriggerState { // The inline shape is what makes skills reachable anywhere in a prompt. Both // shapes need the trailing `$`: detection runs against the text BEFORE the // caret, so the match must end where the user is typing. -const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ +const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/ const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/ From ecd5c796364a61a77e51a3f5388c06e83958057f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:24:56 -0500 Subject: [PATCH 3/4] fix(desktop): sit composer chips on the text baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `align-middle` centers a pill on the x-height midpoint, which sits above the center of the surrounding text box, so chips rode visibly low against the words they're nestled between. Measured against the rendered surface, `-0.12em` lands the chip's own baseline within 0.08px of the line's (vs 0.79px off before) without growing the line box. Applies to both the directive and slash chip classes — they share a line, so fixing one and not the other just moves the mismatch. --- .../src/components/assistant-ui/directive-text.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 312c7d9414d8..988562c04941 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -113,7 +113,7 @@ const SLASH_CHIP_VARIANT: Record = { } export const SLASH_CHIP_BASE_CLASS = - 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none' + 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-medium leading-none' export function slashChipClass(kind: SlashChipKind): string { return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}` @@ -145,9 +145,15 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({ /** Shared chip styling — used by both the rendered and the * raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain - * muted-foreground text so chips read as quiet tags on any bubble color. */ + * muted-foreground text so chips read as quiet tags on any bubble color. + * + * `align-[-0.12em]` rather than `align-middle`: `middle` centers the pill on + * the x-height midpoint, which sits above the center of the surrounding text + * box, so the chip visibly rides low next to the words it's nestled in. The + * em nudge lands the chip's own text baseline on the line's baseline (measured + * to within 0.08px) without growing the line box. */ export const DIRECTIVE_CHIP_CLASS = - 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' + 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[-0.12em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' /** * Parses our composer's `@type:value` references into directive segments From e00901259c6383744dc6ac13d0939f88f3154971 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:20:58 -0500 Subject: [PATCH 4/4] feat(desktop): Tab into a folder from the `@` popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab and Enter shared one branch, so picking a folder always committed a chip and closed the menu — the list could show `apps/` but never open it. Reaching a nested path meant typing every segment by hand. Split the two intents. Tab re-types the token as a bare path so the next completion lists that folder's children; Enter still commits the folder itself as a chip. Files ignore the distinction — there's nowhere deeper to go. Backspace mirrors the descent, dropping one path segment per press instead of one character, so climbing out costs the same as going in. --- .../composer/at-folder-navigation.test.tsx | 180 ++++++++++++++++++ .../composer/hooks/use-composer-trigger.ts | 56 +++++- apps/desktop/src/app/chat/composer/index.tsx | 15 +- 3 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx diff --git a/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx b/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx new file mode 100644 index 000000000000..25df1140c584 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/at-folder-navigation.test.tsx @@ -0,0 +1,180 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerTrigger } from './hooks/use-composer-trigger' +import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' + +/** + * Folder navigation in the `@` popover, driven through the REAL hook against a + * real contentEditable. + * + * Tab and Enter used to be the same branch, so picking a folder always + * committed a chip and closed the menu — there was no way to walk into a + * subdirectory from the list. Tab now descends, Enter still commits, and + * Backspace climbs back out one segment. + */ +function folderItem(path: string): Unstable_TriggerItem { + return { + id: `@folder:${path}|0`, + type: 'folder', + label: path.split('/').filter(Boolean).pop() ?? path, + metadata: { + icon: 'folder', + display: path, + meta: 'dir', + rawText: `@folder:${path}`, + insertId: path + } + } +} + +function setup(initialText: string) { + const editor = document.createElement('div') + editor.contentEditable = 'true' + // The real composer marks its editor with this slot; `composerPlainText` + // keys off it to decide whether a DIV contributes a trailing newline. + // Without it the harness would silently diverge from production text. + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + renderComposerContents(editor, initialText) + + // Caret at the end, which is where a typed trigger always leaves it. + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) + + const editorRef = createRef() as { current: HTMLDivElement | null } + editorRef.current = editor + + const draftRef = { current: initialText } + const setComposerText = vi.fn() + + const { result } = renderHook(() => + useComposerTrigger({ + at: { adapter: null, loading: false }, + draftRef, + editorRef, + requestMainFocus: vi.fn(), + setComposerText, + slash: { adapter: null, loading: false } + }) + ) + + act(() => { + result.current.refreshTrigger() + }) + + return { editor, result, setComposerText } +} + +describe('@ folder navigation', () => { + it('Tab on a folder walks into it and keeps the popover open', () => { + const { editor, result } = setup('@app') + + expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' }) + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true }) + }) + + // Plain text, not a chip — the token is still being typed. + expect(composerPlainText(editor)).toBe('@apps/') + expect(editor.querySelector('[data-ref-text]')).toBeNull() + }) + + it('descends repeatedly, one level per Tab', () => { + const { editor, result } = setup('@apps/desk') + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true }) + }) + + expect(composerPlainText(editor)).toBe('@apps/desktop/') + }) + + it('Enter on a folder commits it as a chip instead of descending', () => { + const { editor, result } = setup('@app') + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps')) + }) + + const chip = editor.querySelector('[data-ref-text]') + + expect(chip).not.toBeNull() + expect(chip?.getAttribute('data-ref-kind')).toBe('folder') + expect(composerPlainText(editor)).toContain('@folder:') + }) + + it('only descends for `@` folders — a file pick still commits', () => { + const { editor, result } = setup('@main') + + const file: Unstable_TriggerItem = { + id: '@file:src/main.tsx|0', + type: 'file', + label: 'main.tsx', + metadata: { + icon: 'file', + display: 'main.tsx', + meta: 'src', + rawText: '@file:src/main.tsx', + insertId: 'src/main.tsx' + } + } + + act(() => { + result.current.replaceTriggerWithChip(file, { descend: true }) + }) + + expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull() + }) + + it('Backspace climbs out one segment at a time', () => { + const { editor, result } = setup('@apps/desktop/') + + let handled = false + act(() => { + handled = result.current.ascendTriggerPath() + }) + + expect(handled).toBe(true) + expect(composerPlainText(editor)).toBe('@apps/') + }) + + it('Backspace drops a partially typed segment before its parent', () => { + const { editor, result } = setup('@apps/desk') + + act(() => { + result.current.ascendTriggerPath() + }) + + expect(composerPlainText(editor)).toBe('@apps/') + }) + + it('leaves Backspace alone when there is no path to climb', () => { + const { result } = setup('@apps') + + let handled = true + act(() => { + handled = result.current.ascendTriggerPath() + }) + + // No `/` in the query — normal character deletion must still happen. + expect(handled).toBe(false) + }) + + it('preserves text typed before the mention', () => { + const { editor, result } = setup('look at @app') + + act(() => { + result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true }) + }) + + expect(composerPlainText(editor)).toBe('look at @apps/') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 2df218e78f30..5f0be41c80c0 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -189,7 +189,7 @@ export function useComposerTrigger({ return true } - const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { + const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => { const editor = editorRef.current if (!editor || !trigger) { @@ -219,6 +219,31 @@ export function useComposerTrigger({ const serialized = hermesDirectiveFormatter.serialize(item) const starter = serialized.endsWith(':') + // Tab on a folder walks INTO it instead of committing it: re-type the + // token as the bare path so the next `complete.path` lists that folder's + // children, exactly as typing the path by hand would. Enter still commits + // the folder itself — the two intents are distinct, so the keys are too. + // Only `@` folders descend; a slash command's arg list has no hierarchy. + const descendInto = + options?.descend && trigger.kind === '@' && item.type === 'folder' + ? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '') + : '' + + if (descendInto) { + const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/` + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${path}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + requestMainFocus() + window.setTimeout(refreshTrigger, 0) + + return + } + // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit // it — expand to its options step so the popover shows the inline list, just // as typing `/personality ` by hand would. A serialized value with a space is @@ -300,8 +325,37 @@ export function useComposerTrigger({ finish() } + /** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`) + * instead of one character. Descending is one Tab per level, so climbing + * back out should cost one key too rather than a held delete. Returns + * false when the caret isn't in a path, so keydown falls through. */ + const ascendTriggerPath = () => { + const editor = editorRef.current + + if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) { + return false + } + + // Trailing slash means we're listing a folder's children: drop that + // folder. Otherwise a partial segment is typed — drop just that. + const trimmed = trigger.query.replace(/\/$/, '') + const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1) + + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${parent}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + window.setTimeout(refreshTrigger, 0) + + return true + } + return { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, refreshTrigger, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b5a76f349040..6ce8289fa0af 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -299,6 +299,7 @@ export function ChatBar({ // this API; keyup uses triggerKeyConsumedRef to skip its refresh. const { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, refreshTrigger, @@ -556,12 +557,24 @@ export function ChatBar({ const item = triggerItems[triggerActive] if (item) { - replaceTriggerWithChip(item) + // Tab means "go deeper" on a folder; Enter means "I want this one". + // Everything else treats them alike. + replaceTriggerWithChip(item, { descend: event.key === 'Tab' }) } return } + // Backspace climbs out of an `@` path one segment at a time, mirroring + // Tab's one-key descent. Only when the caret sits at the end of the + // token — mid-token editing keeps normal character deletion. + if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) { + event.preventDefault() + triggerKeyConsumedRef.current = true + + return + } + if (event.key === 'Escape') { event.preventDefault() triggerKeyConsumedRef.current = true