Release v0.51.251 — Release HS (stage-q23) (#3527)
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:
nesquena-hermes
2026-06-03 21:12:23 -07:00
committed by GitHub
parent 703aba3f3e
commit 15e654d468
5 changed files with 181 additions and 1 deletions

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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();

View File

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

View 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