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:
28
CHANGELOG.md
28
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 <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*
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
62
SPRINTS.md
62
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 <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)*
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
156
static/commands.js
Normal 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();
|
||||
}
|
||||
@@ -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">↑</button>
|
||||
<button class="panel-icon-btn" id="btnNewFile" title="New file" onclick="promptNewFile()">+</button>
|
||||
<button class="panel-icon-btn" id="btnNewFolder" title="New folder" onclick="promptNewFolder()">📁</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir('.')">↻</button>
|
||||
<button class="panel-icon-btn" id="btnRefreshPanel" title="Refresh" onclick="if(S.session)loadDir(S.currentDir)">↻</button>
|
||||
<button class="panel-icon-btn close-preview" id="btnClearPreview" title="Close preview">✕</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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;}
|
||||
|
||||
58
static/ui.js
58
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;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);}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
96
tests/test_sprint17.py
Normal 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)
|
||||
Reference in New Issue
Block a user