Release v0.51.251 — Release HS (stage-q23) (#3527)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
## Release v0.51.251 — Release HS (stage-q23) UX-verified live (path dropdown opens on `~/`). ### Fixed | Issue | Author | Fix | |-------|--------|-----| | #3433 | @puneetdixit200 | **Composer `~/` path autocomplete** (TUI parity). Typing a `~/` token in the composer opens a home-directory path-suggestion dropdown. Reuses the existing slash-command dropdown (positioning + keyboard nav) and the trusted `/api/workspaces/suggest` endpoint; replaces only the matched token on selection (surrounding text preserved). Slash-command autocomplete still takes precedence for `/`-prefixed input. | ### Gate - Full pytest suite: **7568 passed, 0 failed** - ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · live-verified the dropdown opens on `~/` - Codex (regression): **SAFE TO SHIP** — slash-precedence preserved, `~/../../etc` → no suggestions (path-escape blocked via root-confined endpoint), bounds-clamped token replacement, esc-escaped, URLSearchParams-encoded Co-authored-by: puneetdixit200 <puneetdixit200@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(end<value.length&&!/\s/.test(value.charAt(end))) end+=1;
|
||||
const prefix=value.slice(start,pos);
|
||||
if(!prefix.startsWith('~/')) return null;
|
||||
return {start,end,prefix};
|
||||
}
|
||||
|
||||
async function getComposerPathAutocompleteMatches(text,cursor){
|
||||
const token=_findComposerPathToken(text,cursor);
|
||||
if(!token||typeof api!=='function') return [];
|
||||
const qs=new URLSearchParams({prefix:token.prefix}).toString();
|
||||
const data=await api(`/api/workspaces/suggest?${qs}`);
|
||||
const needle=token.prefix.toLowerCase();
|
||||
return ((data&&data.suggestions)||[])
|
||||
.map(path=>String(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)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
|
||||
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
|
||||
if(c.source==='skill') el.classList.add('cmd-item-skill');
|
||||
const nameHtml=isSubArg
|
||||
if(isPath) el.classList.add('cmd-item-path');
|
||||
const nameHtml=isPath
|
||||
? `<div class="cmd-item-name"><span class="cmd-item-path-value">${esc(c.value)}</span></div>`
|
||||
: isSubArg
|
||||
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
|
||||
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
|
||||
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
||||
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();
|
||||
|
||||
@@ -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);}
|
||||
|
||||
114
tests/test_issue3433_composer_path_completion.py
Normal file
114
tests/test_issue3433_composer_path_completion.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user