diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f0fa2b..8cf74261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.251] — 2026-06-03 — Release HS (stage-q23 — composer ~/ path autocomplete) + +### Fixed +- Typing a `~/` path token in the composer (e.g. `check this file ~/`) now opens a home-directory path-suggestion dropdown, matching the TUI's path completion. It reuses the existing slash-command dropdown (positioning + keyboard nav) and the server's trusted `/api/workspaces/suggest` endpoint, and only replaces the matched path token on selection (surrounding message text is preserved). Slash-command autocomplete still takes precedence for `/`-prefixed input. (#3433, @puneetdixit200) + ## [v0.51.250] — 2026-06-03 — Release HR (stage-q22 — Zeus appearance skin) ### Added diff --git a/static/boot.js b/static/boot.js index b9c38919..370db14f 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1209,6 +1209,13 @@ $('msg').addEventListener('input',()=>{ if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); } if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete(); + } else if(typeof getComposerPathAutocompleteMatches==='function'){ + const cursor=$('msg').selectionStart; + getComposerPathAutocompleteMatches(text,cursor).then(matches=>{ + const ta=$('msg'); + if(!ta||ta.value!==text||ta.selectionStart!==cursor) return; + if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); + }).catch(()=>hideCmdDropdown()); } else { hideCmdDropdown(); } diff --git a/static/commands.js b/static/commands.js index 7663b504..0c130a38 100644 --- a/static/commands.js +++ b/static/commands.js @@ -284,6 +284,38 @@ async function getSlashAutocompleteMatches(text){ })); } +function _findComposerPathToken(text,cursor){ + const value=String(text||''); + const rawCursor=Number(cursor); + const pos=Number.isFinite(rawCursor)?Math.max(0,Math.min(rawCursor,value.length)):value.length; + let start=pos; + while(start>0&&!/\s/.test(value.charAt(start-1))) start-=1; + let end=pos; + while(endString(path||'')) + .filter(path=>path&&path.toLowerCase().startsWith(needle)) + .map(path=>({ + name:path, + value:path, + desc:'Workspace path', + source:'path', + tokenStart:token.start, + tokenEnd:token.end, + })); +} + function _compressionAnchorMessageKey(m){ if(!m||!m.role||m.role==='tool') return null; let content=''; @@ -1456,16 +1488,37 @@ function showCmdDropdown(matches){ if(i===_cmdSelectedIdx) el.classList.add('selected'); el.dataset.idx=i; const isSubArg=c.source==='subarg'; + const isPath=c.source==='path'; const usage=(!isSubArg&&c.arg)?` ${esc(c.arg)}`:''; const badge=c.source==='skill'?`${esc(t('slash_skill_badge'))}`:''; if(c.source==='skill') el.classList.add('cmd-item-skill'); - const nameHtml=isSubArg + if(isPath) el.classList.add('cmd-item-path'); + const nameHtml=isPath + ? `
${esc(c.value)}
` + : isSubArg ? `
/${esc(c.parent)} ${esc(c.value)}
` : `
/${esc(c.name)}${usage}${badge}
`; const descHtml=`
${esc(c.desc)}
`; el.innerHTML=`${nameHtml}${descHtml}`; el.onmousedown=(e)=>{ e.preventDefault(); + if(isPath){ + const ta=$('msg'); + if(!ta){hideCmdDropdown();return;} + const start=Number.isFinite(Number(c.tokenStart))?Number(c.tokenStart):ta.selectionStart; + const end=Number.isFinite(Number(c.tokenEnd))?Number(c.tokenEnd):ta.selectionEnd; + const nextPath=String(c.value||'').endsWith('/')?String(c.value||''):`${String(c.value||'')}/`; + const current=String(ta.value||''); + const safeStart=Math.max(0,Math.min(start,current.length)); + const safeEnd=Math.max(safeStart,Math.min(end,current.length)); + ta.value=current.slice(0,safeStart)+nextPath+current.slice(safeEnd); + const pos=safeStart+nextPath.length; + ta.focus(); + ta.setSelectionRange(pos,pos); + ta.dispatchEvent(new Event('input',{bubbles:true})); + hideCmdDropdown(); + return; + } const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':'')); $('msg').value=nextValue; $('msg').focus(); diff --git a/static/style.css b/static/style.css index 882d2988..0dae0554 100644 --- a/static/style.css +++ b/static/style.css @@ -2548,6 +2548,7 @@ .cmd-item-name{font-size:13px;color:var(--text);font-weight:500;} .cmd-item-parent{color:var(--muted);font-weight:400;} .cmd-item-subarg{font-weight:600;} +.cmd-item-path-value{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;} .cmd-item-arg{color:var(--muted);font-weight:400;font-style:italic;} .cmd-item-desc{font-size:11px;color:var(--muted);margin-top:1px;} .cmd-item-badge{flex-shrink:0;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px;border:1px solid var(--border2);color:var(--muted);background:var(--hover-bg);} diff --git a/tests/test_issue3433_composer_path_completion.py b/tests/test_issue3433_composer_path_completion.py new file mode 100644 index 00000000..bfd7a1ab --- /dev/null +++ b/tests/test_issue3433_composer_path_completion.py @@ -0,0 +1,114 @@ +"""Regression tests for #3433: chat composer path completion for ~/ tokens.""" + +import json +import pathlib +import shutil +import subprocess +import textwrap + +import pytest + + +REPO_ROOT = pathlib.Path(__file__).parent.parent +COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def _run_commands_js(script_body: str) -> dict: + script = textwrap.dedent( + f""" + const vm = require('vm'); + const ctx = {{ + console, + URL, + URLSearchParams, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + const url = new URL('http://hermes.local' + path); + if (url.pathname !== '/api/workspaces/suggest') {{ + throw new Error('unexpected api path: ' + path); + }} + const prefix = url.searchParams.get('prefix'); + return {{ + suggestions: prefix === '~/' ? ['~', '~/Documents', '~/Projects'] : [] + }}; + }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ {script_body} }})()`, ctx); + process.stdout.write(JSON.stringify(result)); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + proc = subprocess.run( + [NODE, "-e", script], + check=True, + capture_output=True, + text=True, + ) + return json.loads(proc.stdout) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_composer_path_token_matches_tilde_path_inside_message(): + result = _run_commands_js( + """ + return { + token: _findComposerPathToken('please inspect ~/Doc', 20), + slash: _findComposerPathToken('/model gpt', 10), + bareTilde: _findComposerPathToken('please inspect ~', 16) + }; + """ + ) + + assert result["token"] == {"start": 15, "end": 20, "prefix": "~/Doc"} + assert result["slash"] is None + assert result["bareTilde"] is None + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_composer_path_autocomplete_uses_workspace_suggest_endpoint(): + result = _run_commands_js( + """ + const matches = await getComposerPathAutocompleteMatches('please inspect ~/', 17); + return { + count: matches.length, + first: matches[0], + second: matches[1] + }; + """ + ) + + assert result["count"] == 2 + assert result["first"]["source"] == "path" + assert result["first"]["value"] == "~/Documents" + assert result["first"]["tokenStart"] == 15 + assert result["first"]["tokenEnd"] == 17 + assert result["second"]["value"] == "~/Projects" + + +def test_composer_input_uses_path_autocomplete_after_slash_branch(): + assert "const cursor=$('msg').selectionStart;" in BOOT_JS + assert "getComposerPathAutocompleteMatches(text,cursor).then(matches=>" in BOOT_JS + assert "ta.value!==text||ta.selectionStart!==cursor" in BOOT_JS + + +def test_dropdown_selection_replaces_only_path_token(): + assert "const isPath=c.source==='path';" in COMMANDS_JS + assert "tokenStart:token.start" in COMMANDS_JS + assert "tokenEnd:token.end" in COMMANDS_JS + assert "current.slice(0,safeStart)+nextPath+current.slice(safeEnd)" in COMMANDS_JS + assert "ta.setSelectionRange(pos,pos);" in COMMANDS_JS + assert "ta.dispatchEvent(new Event('input',{bubbles:true}));" in COMMANDS_JS + + +def test_path_suggestions_have_distinct_dropdown_style(): + assert ".cmd-item-path-value" in STYLE_CSS