From 0f2bd537f1c2c4cf71cd3655d9067dbf8502e0d0 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Thu, 2 Apr 2026 17:31:31 -0700 Subject: [PATCH] feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting Track A: Workspace breadcrumb navigation - Breadcrumb path bar with clickable segments when inside subdirectories - Up button in panel header for parent directory navigation - S.currentDir state tracking; file ops stay in current directory - New file/folder creation respects current subdirectory Track B: Slash commands foundation - New commands.js module (7th JS module) with command registry and parser - Built-in commands: /help, /clear, /model, /workspace, /new - Autocomplete dropdown on / input with arrow/tab/enter/escape navigation - Unrecognized commands pass through to agent normally Track C: Send key setting (closes #26) - send_key added to settings defaults in api/config.py - Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter - Keydown handler rewritten for autocomplete + send key preference - Setting loaded on boot, persisted to settings.json 5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 28 ++++++++ ROADMAP.md | 8 ++- SPRINTS.md | 62 ++++++++++++++-- api/config.py | 7 ++ static/boot.js | 35 ++++++++- static/commands.js | 156 +++++++++++++++++++++++++++++++++++++++++ static/index.html | 13 +++- static/messages.js | 4 ++ static/panels.js | 6 ++ static/style.css | 17 ++++- static/ui.js | 58 ++++++++++++--- static/workspace.js | 10 ++- tests/test_sprint17.py | 96 +++++++++++++++++++++++++ 13 files changed, 478 insertions(+), 22 deletions(-) create mode 100644 static/commands.js create mode 100644 tests/test_sprint17.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3e09e..ecde099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ --- +## [v0.19] Sprint 17 -- Workspace Polish + Slash Commands + Settings +*April 3, 2026 | 294 tests* + +### Features +- **Workspace breadcrumb navigation.** Clicking into subdirectories now shows a + breadcrumb path bar (e.g. `~ / src / components`) with clickable segments to + navigate back. An "up" button appears in the panel header when inside a + subdirectory. File operations (rename, delete, new file/folder) stay in the + current directory instead of jumping back to root. Foundation for Issue #22 + (tree view). +- **Slash commands.** Type `/` in the composer to see an autocomplete dropdown + of built-in commands. New `commands.js` module with command registry. Built-in + commands: `/help`, `/clear`, `/model `, `/workspace `, `/new`. + Arrow keys navigate, Tab/Enter select, Escape closes. Unrecognized commands + pass through to the agent normally. +- **Send key setting (Issue #26).** New setting in Settings panel to choose + between Enter (default) and Ctrl/Cmd+Enter as the send key. Persisted to + `settings.json` via the existing settings API. Setting loads on boot. + Server-side validation ensures only valid values (`enter`, `ctrl+enter`). + +### Architecture +- New `static/commands.js` module (7th JS module): command registry, parser, + autocomplete dropdown, and built-in command handlers. +- `send_key` added to `_SETTINGS_DEFAULTS` in `api/config.py` with enum validation. +- `S.currentDir` state tracking added to `ui.js` for workspace navigation. + +--- + ## [v0.18.1] Safe HTML Rendering + Sprint 16 Tests *April 2, 2026 | 289 tests* diff --git a/ROADMAP.md b/ROADMAP.md index 1f35539..b143802 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,8 +3,8 @@ > Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > Everything you can do from the CLI terminal, you can do from this UI. > -> Last updated: Sprint 16 / v0.18.1 (April 2, 2026) -> Tests: 289 passing +> Last updated: Sprint 17 / v0.19 (April 3, 2026) +> Tests: 294 passing > Source: / --- @@ -33,6 +33,7 @@ | Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 | | Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 | | Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, custom model discovery, GLM-5.1 | 237 | +| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 294 | --- @@ -43,7 +44,7 @@ | Python server | /server.py (~76 lines) + api/ modules (~2145 lines) | Thin shell + business logic in api/ | | HTML template | /static/index.html | Served from disk | | CSS | /static/style.css (~560 lines) | Served from disk | -| JavaScript | /static/{ui,workspace,sessions,messages,panels,boot}.js | 6 modules, ~2786 lines total | +| JavaScript | /static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~2990 lines total | | Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files | | Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run | | Production server | Port 8787 | SSH tunnel from Mac | @@ -331,4 +332,5 @@ Community-requested enhancements tracked from GitHub issues. | Docker container | #7 | Docker Compose setup with separate hermes-agent and hermes-webui containers, multi-arch (amd64 + arm64), volume mounts for config. | Medium-High | | Authentication | #23 | Password gate via `HERMES_WEBUI_PASSWORD` env var, login page, signed cookie. Already planned in Sprint 7.1. | Low-Medium | | Send key / personalization | #26 | Toggle send key (Enter vs Ctrl/Cmd+Enter) and queue vs interrupt mode as global settings. | Low | +| Multi-profile support | #28 | Profile management UI: create, delete, switch, configure agent profiles. | Medium | | Mobile responsive UI | #21 | Hamburger menu, slide-out sidebar drawer, touch-friendly controls. Already planned in Sprint 7.3. | Medium-High | diff --git a/SPRINTS.md b/SPRINTS.md index 71246da..2c9228f 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.18.1 | 289 tests | Daily driver ready +> Current state: v0.19 | 294 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -271,7 +271,59 @@ inconsistently across platforms. These were the most common visual complaints. --- -## Sprint 17 -- Voice + Multimodal Input +## Sprint 17 -- Workspace Polish + Slash Commands + Settings (COMPLETED) + +**Theme:** Workspace polish, slash commands, and composer settings. + +**Why now:** Three things converge: @nothingmn filed Issue #22 requesting a +tree/accordion workspace view (breadcrumb navigation is the foundation for +that), slash commands were deferred from Sprint 16, and Issue #26 (send key +personalization) fits naturally since we are already touching the keydown +handler for slash command autocomplete. + +### Track A: Workspace Breadcrumb Navigation +- **Breadcrumb path bar.** When users click into subdirectories, a breadcrumb + bar appears showing the path (e.g. `~ / src / components`) with clickable + segments to navigate back. Hidden at root level for a clean UI. +- **Up button.** Arrow-up button in the panel header navigates to the parent + directory. Hidden when already at workspace root. +- **Current directory tracking.** `S.currentDir` state property tracks the + active directory. File operations (rename, delete, new file, new folder) + stay in the current directory instead of jumping back to root. +- **New file/folder in subdirectories.** Creating files or folders now respects + the current directory, creating them in the viewed subdirectory. + +### Track B: Slash Commands Foundation +- **commands.js module.** New 7th JS module with command registry, parser, + autocomplete dropdown, and built-in command handlers. +- **Built-in commands:** `/help` (list commands), `/clear` (clear conversation), + `/model ` (switch model with fuzzy match), `/workspace ` (switch + workspace), `/new` (start new session). +- **Autocomplete dropdown.** Typing `/` in the composer shows a filtered + dropdown. Arrow keys navigate, Tab/Enter select, Escape closes. Positioned + above the composer using the workspace dropdown CSS pattern. +- **Transparent pass-through.** Unrecognized `/` commands pass through to the + agent normally (not intercepted). + +### Track C: Send Key Setting (Issue #26) +- **`send_key` setting.** New setting in Settings panel: "Enter" (default) or + "Ctrl+Enter". Persisted to `settings.json`. Loaded on boot. +- **Keydown handler rewrite.** Combined handler for autocomplete navigation + and send key preference. When `ctrl+enter` is selected, plain Enter inserts + a newline and Ctrl/Cmd+Enter sends. + +### Deferred to Sprint 18 +- Thinking/reasoning display for extended-thinking models +- Voice input via Whisper +- Workspace tree/accordion view (full implementation of Issue #22) + +**Tests:** 5 new. Total: 294. +**Hermes CLI parity impact:** Low (slash commands add convenience) +**Claude parity impact:** Medium (workspace nav, slash commands match Claude UX) + +--- + +## Sprint 18 -- Voice + Multimodal Input **Theme:** Input beyond the keyboard. @@ -451,6 +503,6 @@ address. --- -*Last updated: April 2, 2026* -*Current version: v0.18.1 | 289 tests* -*Next sprint: Sprint 17 (Voice + Multimodal Input)* +*Last updated: April 3, 2026* +*Current version: v0.19 | 294 tests* +*Next sprint: Sprint 18 (Voice + Multimodal Input)* diff --git a/api/config.py b/api/config.py index 3d20459..be25d54 100644 --- a/api/config.py +++ b/api/config.py @@ -594,6 +594,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock: _SETTINGS_DEFAULTS = { 'default_model': DEFAULT_MODEL, 'default_workspace': str(DEFAULT_WORKSPACE), + 'send_key': 'enter', # 'enter' or 'ctrl+enter' } def load_settings() -> dict: @@ -609,12 +610,18 @@ def load_settings() -> dict: return settings _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) +_SETTINGS_ENUM_VALUES = { + 'send_key': {'enter', 'ctrl+enter'}, +} def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" current = load_settings() for k, v in settings.items(): if k in _SETTINGS_ALLOWED_KEYS: + # Validate enum-constrained keys + if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]: + continue current[k] = v SETTINGS_FILE.write_text( json.dumps(current, ensure_ascii=False, indent=2), diff --git a/static/boot.js b/static/boot.js index 2f92890..cb76044 100644 --- a/static/boot.js +++ b/static/boot.js @@ -59,8 +59,37 @@ $('modelSelect').onchange=async()=>{ await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})}); S.session.model=selectedModel;syncTopbar(); }; -$('msg').addEventListener('input',autoResize); -$('msg').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}); +$('msg').addEventListener('input',()=>{ + autoResize(); + const text=$('msg').value; + if(text.startsWith('/')&&text.indexOf('\n')===-1){ + const prefix=text.slice(1); + const matches=getMatchingCommands(prefix); + if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); + } else { + hideCmdDropdown(); + } +}); +$('msg').addEventListener('keydown',e=>{ + // Autocomplete navigation when dropdown is open + const dd=$('cmdDropdown'); + const dropdownOpen=dd&&dd.classList.contains('open'); + if(dropdownOpen){ + if(e.key==='ArrowUp'){e.preventDefault();navigateCmdDropdown(-1);return;} + if(e.key==='ArrowDown'){e.preventDefault();navigateCmdDropdown(1);return;} + if(e.key==='Tab'){e.preventDefault();selectCmdDropdownItem();return;} + if(e.key==='Escape'){e.preventDefault();hideCmdDropdown();return;} + if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();selectCmdDropdownItem();return;} + } + // Send key: respect user preference + if(e.key==='Enter'){ + if(window._sendKey==='ctrl+enter'){ + if(e.ctrlKey||e.metaKey){e.preventDefault();send();} + } else { + if(!e.shiftKey){e.preventDefault();send();} + } + } +}); // B14: Cmd/Ctrl+K creates a new chat from anywhere document.addEventListener('keydown',async e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='k'){ @@ -151,6 +180,8 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ })(); (async()=>{ + // Load send key preference + try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';} // Fetch available models from server and populate dropdown dynamically await populateModelDropdown(); // Restore last-used model preference diff --git a/static/commands.js b/static/commands.js new file mode 100644 index 0000000..9ab2d7c --- /dev/null +++ b/static/commands.js @@ -0,0 +1,156 @@ +// ── Slash commands ────────────────────────────────────────────────────────── +// Built-in commands intercepted before send(). Each command runs locally +// (no round-trip to the agent) and shows feedback via toast or local message. + +const COMMANDS=[ + {name:'help', desc:'List available commands', fn:cmdHelp}, + {name:'clear', desc:'Clear conversation messages', fn:cmdClear}, + {name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'}, + {name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'}, + {name:'new', desc:'Start a new chat session', fn:cmdNew}, +]; + +function parseCommand(text){ + if(!text.startsWith('/'))return null; + const parts=text.slice(1).split(/\s+/); + const name=parts[0].toLowerCase(); + const args=parts.slice(1).join(' ').trim(); + return {name,args}; +} + +function executeCommand(text){ + const parsed=parseCommand(text); + if(!parsed)return false; + const cmd=COMMANDS.find(c=>c.name===parsed.name); + if(!cmd)return false; + cmd.fn(parsed.args); + return true; +} + +function getMatchingCommands(prefix){ + const q=prefix.toLowerCase(); + return COMMANDS.filter(c=>c.name.startsWith(q)); +} + +// ── Command handlers ──────────────────────────────────────────────────────── + +function cmdHelp(){ + const lines=COMMANDS.map(c=>{ + const usage=c.arg?` <${c.arg}>`:''; + return ` /${c.name}${usage} — ${c.desc}`; + }); + const msg={role:'assistant',content:'**Available commands:**\n'+lines.join('\n')}; + S.messages.push(msg); + renderMessages(); + showToast('Type / to see commands'); +} + +function cmdClear(){ + if(!S.session)return; + S.messages=[];S.toolCalls=[]; + clearLiveToolCards(); + renderMessages(); + $('emptyState').style.display=''; + showToast('Conversation cleared'); +} + +async function cmdModel(args){ + if(!args){showToast('Usage: /model ');return;} + const sel=$('modelSelect'); + if(!sel)return; + const q=args.toLowerCase(); + // Fuzzy match: find first option whose label or value contains the query + let match=null; + for(const opt of sel.options){ + if(opt.value.toLowerCase().includes(q)||opt.textContent.toLowerCase().includes(q)){ + match=opt.value;break; + } + } + if(!match){showToast(`No model matching "${args}"`);return;} + sel.value=match; + await sel.onchange(); + showToast(`Switched to ${match}`); +} + +async function cmdWorkspace(args){ + if(!args){showToast('Usage: /workspace ');return;} + try{ + const data=await api('/api/workspaces'); + const q=args.toLowerCase(); + const ws=(data.workspaces||[]).find(w=> + (w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q) + ); + if(!ws){showToast(`No workspace matching "${args}"`);return;} + if(!S.session)return; + await api('/api/session/update',{method:'POST',body:JSON.stringify({ + session_id:S.session.session_id,workspace:ws.path,model:S.session.model + })}); + S.session.workspace=ws.path; + syncTopbar();await loadDir('.'); + showToast(`Switched to workspace: ${ws.name||ws.path}`); + }catch(e){showToast('Workspace switch failed: '+e.message);} +} + +async function cmdNew(){ + await newSession(); + await renderSessionList(); + $('msg').focus(); + showToast('New session created'); +} + +// ── Autocomplete dropdown ─────────────────────────────────────────────────── + +let _cmdSelectedIdx=-1; + +function showCmdDropdown(matches){ + const dd=$('cmdDropdown'); + if(!dd)return; + dd.innerHTML=''; + _cmdSelectedIdx=-1; + for(let i=0;i${esc(c.arg)}`:''; + el.innerHTML=`
/${esc(c.name)}${usage}
${esc(c.desc)}
`; + el.onmousedown=(e)=>{ + e.preventDefault(); + $('msg').value='/'+c.name+(c.arg?' ':''); + hideCmdDropdown(); + $('msg').focus(); + }; + dd.appendChild(el); + } + dd.classList.add('open'); +} + +function hideCmdDropdown(){ + const dd=$('cmdDropdown'); + if(dd)dd.classList.remove('open'); + _cmdSelectedIdx=-1; +} + +function navigateCmdDropdown(dir){ + const dd=$('cmdDropdown'); + if(!dd)return; + const items=dd.querySelectorAll('.cmd-item'); + if(!items.length)return; + items.forEach(el=>el.classList.remove('selected')); + _cmdSelectedIdx+=dir; + if(_cmdSelectedIdx<0)_cmdSelectedIdx=items.length-1; + if(_cmdSelectedIdx>=items.length)_cmdSelectedIdx=0; + items[_cmdSelectedIdx].classList.add('selected'); +} + +function selectCmdDropdownItem(){ + const dd=$('cmdDropdown'); + if(!dd)return; + const items=dd.querySelectorAll('.cmd-item'); + if(_cmdSelectedIdx>=0&&_cmdSelectedIdx{}}); + } else if(items.length===1){ + items[0].onmousedown({preventDefault:()=>{}}); + } + hideCmdDropdown(); +} diff --git a/static/index.html b/static/index.html index a2f1aed..08c9990 100644 --- a/static/index.html +++ b/static/index.html @@ -206,6 +206,7 @@
+
@@ -236,12 +237,14 @@
Workspace
+ - +
+
@@ -272,6 +275,13 @@
+
+ + +
@@ -280,6 +290,7 @@ + diff --git a/static/messages.js b/static/messages.js index c2413c2..6e7cbc0 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1,6 +1,10 @@ async function send(){ const text=$('msg').value.trim(); if(!text&&!S.pendingFiles.length)return; + // Slash command intercept -- local commands handled without agent round-trip + if(text.startsWith('/')&&!S.pendingFiles.length&&executeCommand(text)){ + $('msg').value='';autoResize();hideCmdDropdown();return; + } // Don't send while an inline message edit is active if(document.querySelector('.msg-edit-area'))return; // If busy, queue the message instead of dropping it diff --git a/static/panels.js b/static/panels.js index 0cff2e0..0074a80 100644 --- a/static/panels.js +++ b/static/panels.js @@ -646,6 +646,9 @@ async function loadSettingsPanel(){ }catch(e){} wsSel.value=settings.default_workspace||''; } + // Send key preference + const sendKeySel=$('settingsSendKey'); + if(sendKeySel) sendKeySel.value=settings.send_key||'enter'; }catch(e){ showToast('Failed to load settings: '+e.message); } @@ -654,11 +657,14 @@ async function loadSettingsPanel(){ async function saveSettings(){ const model=($('settingsModel')||{}).value; const workspace=($('settingsWorkspace')||{}).value; + const sendKey=($('settingsSendKey')||{}).value; const body={}; if(model) body.default_model=model; if(workspace) body.default_workspace=workspace; + if(sendKey) body.send_key=sendKey; try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); + window._sendKey=sendKey||'enter'; showToast('Settings saved'); toggleSettings(); }catch(e){ diff --git a/static/style.css b/static/style.css index 0113b0a..5c05c70 100644 --- a/static/style.css +++ b/static/style.css @@ -205,6 +205,13 @@ .file-action-btn{width:20px;height:20px;background:rgba(0,0,0,.4);border:none;border-radius:4px;color:var(--muted);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;} .file-action-btn:hover{color:var(--accent);} .close-preview{cursor:pointer;opacity:.6;}.close-preview:hover{opacity:1;} + /* Breadcrumb navigation */ + .breadcrumb-bar{display:flex;align-items:center;gap:2px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border);flex-shrink:0;overflow:hidden;white-space:nowrap;} + .breadcrumb-seg{padding:1px 3px;border-radius:3px;} + .breadcrumb-link{color:var(--muted);cursor:pointer;transition:color .12s;} + .breadcrumb-link:hover{color:var(--text);background:rgba(255,255,255,.06);} + .breadcrumb-current{color:var(--text);font-weight:500;} + .breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;} .file-tree{flex:1;overflow-y:auto;padding:8px;} .file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;} .file-item:hover{background:rgba(255,255,255,.07);color:var(--text);} @@ -297,6 +304,14 @@ .ws-row-actions{display:flex;gap:4px;flex-shrink:0;} .ws-action-btn{padding:4px 9px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap;} .ws-action-btn:hover{background:rgba(255,255,255,.1);color:var(--text);} +/* ── Slash command autocomplete dropdown ── */ +.cmd-dropdown{display:none;position:absolute;bottom:100%;left:0;right:0;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;margin-bottom:4px;} +.cmd-dropdown.open{display:block;} +.cmd-item{padding:8px 14px;cursor:pointer;transition:background .12s;} +.cmd-item:hover,.cmd-item.selected{background:rgba(255,255,255,.07);} +.cmd-item-name{font-size:13px;color:var(--text);font-weight:500;} +.cmd-item-arg{color:var(--muted);font-weight:400;font-style:italic;} +.cmd-item-desc{font-size:11px;color:var(--muted);margin-top:1px;} .ws-action-btn.danger:hover{background:rgba(233,69,96,.12);color:var(--accent);border-color:rgba(233,69,96,.3);} .ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;} /* ── Message action buttons (copy, edit, retry) ── */ @@ -352,7 +367,7 @@ .msg-role > span{line-height:1;} /* Composer wrap: slightly less padding on smaller heights */ -.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;} +.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;position:relative;} /* Cron status badges: pill shape refinement */ .cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;} diff --git a/static/ui.js b/static/ui.js index 97bb8fc..9bc77f2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1,4 +1,4 @@ -const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null}; +const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.'}; const INFLIGHT={}; // keyed by session_id while request in-flight const MSG_QUEUE=[]; // messages queued while a request is in-flight const $=id=>document.getElementById(id); @@ -705,6 +705,45 @@ function fileIcon(name, type){ return '📄'; } +function renderBreadcrumb(){ + const bar=$('breadcrumbBar'); + const upBtn=$('btnUpDir'); + if(!bar)return; + if(S.currentDir==='.'){ + bar.style.display='none'; + if(upBtn)upBtn.style.display='none'; + return; + } + bar.style.display='flex'; + if(upBtn)upBtn.style.display=''; + bar.innerHTML=''; + // Root segment + const root=document.createElement('span'); + root.className='breadcrumb-seg breadcrumb-link'; + root.textContent='~'; + root.onclick=()=>loadDir('.'); + bar.appendChild(root); + // Path segments + const parts=S.currentDir.split('/'); + let accumulated=''; + for(let i=0;iloadDir(target); + } else { + seg.className='breadcrumb-seg breadcrumb-current'; + } + bar.appendChild(seg); + } +} + function renderFileTree(){ const box=$('fileTree');box.innerHTML=''; for(const item of S.entries){ @@ -734,7 +773,7 @@ function renderFileTree(){ session_id:S.session.session_id,path:item.path,new_name:newName })}); showToast(`Renamed to ${newName}`); - await loadDir('.'); + await loadDir(S.currentDir); }catch(err){showToast('Rename failed: '+err.message);} } } @@ -779,7 +818,7 @@ async function deleteWorkspaceFile(relPath, name){ showToast(`Deleted ${name}`); // Close preview if we just deleted the viewed file if($('previewPathText').textContent===relPath)$('btnClearPreview').onclick(); - await loadDir('.'); + await loadDir(S.currentDir); }catch(e){setStatus('Delete failed: '+e.message);} } @@ -787,12 +826,12 @@ async function promptNewFile(){ if(!S.session)return; const name=prompt('New file name (e.g. notes.md):',''); if(!name||!name.trim())return; + const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim()); try{ - await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim(),content:''})}); + await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,content:''})}); showToast(`Created ${name.trim()}`); - await loadDir('.'); - // Open the new file immediately - openFile(name.trim()); + await loadDir(S.currentDir); + openFile(relPath); }catch(e){setStatus('Create failed: '+e.message);} } @@ -800,10 +839,11 @@ async function promptNewFolder(){ if(!S.session)return; const name=prompt('New folder name:',''); if(!name||!name.trim())return; + const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim()); try{ - await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:name.trim()})}); + await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})}); showToast(`Created folder ${name.trim()}`); - await loadDir('.'); + await loadDir(S.currentDir); }catch(e){setStatus('Create folder failed: '+e.message);} } diff --git a/static/workspace.js b/static/workspace.js index fdcb30e..b19c9c2 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -9,11 +9,19 @@ async function api(path,opts={}){ async function loadDir(path){ if(!S.session)return; try{ + S.currentDir=path||'.'; const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`); - S.entries=data.entries||[];renderFileTree(); + S.entries=data.entries||[];renderBreadcrumb();renderFileTree(); }catch(e){console.warn('loadDir',e);} } +function navigateUp(){ + if(!S.session||S.currentDir==='.')return; + const parts=S.currentDir.split('/'); + parts.pop(); + loadDir(parts.length?parts.join('/'):'.'); +} + // File extension sets for preview routing (must match server-side sets) const IMAGE_EXTS = new Set(['.png','.jpg','.jpeg','.gif','.svg','.webp','.ico','.bmp']); const MD_EXTS = new Set(['.md','.markdown','.mdown']); diff --git a/tests/test_sprint17.py b/tests/test_sprint17.py new file mode 100644 index 0000000..61e4776 --- /dev/null +++ b/tests/test_sprint17.py @@ -0,0 +1,96 @@ +""" +Sprint 17 Tests: send_key setting, commands.js static file, workspace subdir listing. +""" +import json, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, d["session"] + + +# ── Settings: send_key ────────────────────────────────────────────────────── + +def test_settings_send_key_default(): + """GET /api/settings returns send_key with default value 'enter'.""" + data, status = get("/api/settings") + assert status == 200 + assert data.get("send_key") == "enter" + + +def test_settings_save_send_key(): + """POST /api/settings with send_key persists and round-trips.""" + try: + # Save ctrl+enter + _, status = post("/api/settings", {"send_key": "ctrl+enter"}) + assert status == 200 + # Verify it persisted + data, _ = get("/api/settings") + assert data["send_key"] == "ctrl+enter" + finally: + # Always restore default + post("/api/settings", {"send_key": "enter"}) + data, _ = get("/api/settings") + assert data["send_key"] == "enter" + + +def test_settings_invalid_send_key_rejected(): + """POST /api/settings with invalid send_key value is silently ignored.""" + # Set a known good value first + post("/api/settings", {"send_key": "enter"}) + # Try to set an invalid value + data, status = post("/api/settings", {"send_key": "invalid_value"}) + assert status == 200 + # Should still be 'enter' (invalid value ignored) + assert data["send_key"] == "enter" + + +def test_settings_unknown_key_ignored(): + """POST /api/settings ignores unknown keys.""" + data, status = post("/api/settings", {"unknown_key": "value", "send_key": "enter"}) + assert status == 200 + assert "unknown_key" not in data + + +# ── Static file: commands.js ──────────────────────────────────────────────── + +def test_static_commands_js_served(): + """GET /static/commands.js returns 200 and contains COMMANDS registry.""" + req = urllib.request.Request(BASE + "/static/commands.js") + with urllib.request.urlopen(req, timeout=10) as r: + body = r.read().decode() + assert r.status == 200 + assert "COMMANDS" in body + assert "executeCommand" in body + + +# ── Workspace: subdir listing ─────────────────────────────────────────────── + +def test_list_workspace_root(): + """GET /api/list with path=. returns entries for workspace root.""" + created = [] + sid, _ = make_session(created) + data, status = get(f"/api/list?session_id={sid}&path=.") + assert status == 200 + assert "entries" in data + assert isinstance(data["entries"], list)