Merge pull request #30 from nesquena/sprint-17-workspace-slash-commands-settings

feat: Sprint 17 -- Workspace breadcrumbs, slash commands, send key setting
This commit is contained in:
Nathan Esquenazi
2026-04-03 04:14:23 -07:00
committed by GitHub
13 changed files with 478 additions and 22 deletions

View File

@@ -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 <name>`, `/workspace <name>`, `/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*

View File

@@ -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: <repo>/
---
@@ -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 | <repo>/server.py (~76 lines) + api/ modules (~2145 lines) | Thin shell + business logic in api/ |
| HTML template | <repo>/static/index.html | Served from disk |
| CSS | <repo>/static/style.css (~560 lines) | Served from disk |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot}.js | 6 modules, ~2786 lines total |
| JavaScript | <repo>/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 |

View File

@@ -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 <name>` (switch model with fuzzy match), `/workspace <name>` (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)*

View File

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

View File

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

156
static/commands.js Normal file
View File

@@ -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 <name>');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 <name>');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<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
el.dataset.idx=i;
const usage=c.arg?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
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<items.length){
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
} else if(items.length===1){
items[0].onmousedown({preventDefault:()=>{}});
}
hideCmdDropdown();
}

View File

@@ -206,6 +206,7 @@
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="composer-box" id="composerBox">
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
@@ -236,12 +237,14 @@
<div class="panel-header">
<span>Workspace</span>
<div class="panel-actions">
<button class="panel-icon-btn" id="btnUpDir" title="Parent directory" onclick="navigateUp()" style="display:none">&#8593;</button>
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">&#43;</button>
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()">&#128193;</button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">&#8635;</button>
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)">&#8635;</button>
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">&#10005;</button>
</div>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
@@ -272,6 +275,13 @@
<label for="settingsWorkspace">Default Workspace</label>
<select id="settingsWorkspace" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
</div>
<div class="settings-field">
<label for="settingsSendKey">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="enter">Enter (Shift+Enter for newline)</option>
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
</div>
</div>
@@ -280,6 +290,7 @@
<script src="/static/ui.js"></script>
<script src="/static/workspace.js"></script>
<script src="/static/sessions.js"></script>
<script src="/static/commands.js"></script>
<script src="/static/messages.js"></script>
<script src="/static/panels.js"></script>
<script src="/static/boot.js"></script>

View File

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

View File

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

View File

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

View File

@@ -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;i<parts.length;i++){
const sep=document.createElement('span');
sep.className='breadcrumb-sep';sep.textContent='/';
bar.appendChild(sep);
accumulated+=(accumulated?'/':'')+parts[i];
const seg=document.createElement('span');
seg.textContent=parts[i];
if(i<parts.length-1){
seg.className='breadcrumb-seg breadcrumb-link';
const target=accumulated;
seg.onclick=()=>loadDir(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);}
}

View File

@@ -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']);

96
tests/test_sprint17.py Normal file
View File

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