From eb9614854e97c99910c333ee198e6fa7070c7ec1 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 28 Apr 2026 09:42:53 +0800 Subject: [PATCH] Refine embedded terminal card entrypoint --- CHANGELOG.md | 2 +- static/commands.js | 21 ++++ static/i18n.js | 1 + static/index.html | 35 ------- static/messages.js | 2 +- static/style.css | 19 +--- static/terminal.js | 13 +-- tests/test_approval_card_layering.py | 12 +++ tests/test_embedded_workspace_terminal.py | 122 ++-------------------- 9 files changed, 52 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eeb281f..0d1b115c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Added -- **Embedded workspace terminal prototype** — composer now has a terminal toggle next to the workspace control. It opens a compact terminal panel bound to the current session workspace, with live output, command input, resize, restart, clear, copy output, and close actions. Backend terminal processes use explicit per-process `cwd`/`env` and do not mutate global `os.environ`. (`api/terminal.py`, `api/routes.py`, `static/index.html`, `static/terminal.js`, `static/style.css`, `static/i18n.js`) +- **Embedded workspace terminal prototype** — `/terminal` opens a compact utility card tucked behind the composer, following the approval-card layering pattern instead of adding a permanent composer icon or full-width dock. The terminal stays bound to the current session workspace with live output, command input, resize, restart, clear, copy output, and close actions. Backend terminal processes use explicit per-process `cwd`/`env` and do not mutate global `os.environ`. (`api/terminal.py`, `api/routes.py`, `static/index.html`, `static/terminal.js`, `static/commands.js`, `static/style.css`, `static/i18n.js`) ### Fixed - **Background session completion unread dots** — sidebar unread dots no longer diff --git a/static/commands.js b/static/commands.js index f70e7358..431c079d 100644 --- a/static/commands.js +++ b/static/commands.js @@ -11,6 +11,7 @@ const COMMANDS=[ {name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true}, {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true}, {name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true}, + {name:'terminal', desc:t('cmd_terminal'), fn:cmdTerminal, noEcho:true}, {name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true}, {name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true}, {name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true}, @@ -262,6 +263,26 @@ async function cmdWorkspace(args){ }catch(e){showToast(t('workspace_switch_failed')+e.message);} } +async function cmdTerminal(){ + if(!S.session&&typeof newSession==='function'){ + if(!S._profileSwitchWorkspace&&!S._profileDefaultWorkspace){ + try{ + const data=await api('/api/workspaces'); + const first=(data.workspaces||[])[0]; + S._profileSwitchWorkspace=data.last||(first&&first.path)||null; + }catch(_){} + } + await newSession(); + if(typeof renderSessionList==='function') await renderSessionList(); + } + if(!S.session||!S.session.workspace){ + showToast(t('terminal_no_workspace_title'),2600,'warning'); + if(typeof syncTerminalButton==='function') syncTerminalButton(); + return; + } + if(typeof toggleComposerTerminal==='function') await toggleComposerTerminal(true); +} + async function cmdNew(){ if(typeof clearCompressionUi==='function') clearCompressionUi(); await newSession(); diff --git a/static/i18n.js b/static/i18n.js index 7ba75191..8bdcdabc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -105,6 +105,7 @@ const LOCALES = { cmd_compact_alias: 'Legacy alias for /compress', cmd_model: 'Switch model (e.g. /model gpt-4o)', cmd_workspace: 'Switch workspace by name', + cmd_terminal: 'Open the workspace terminal', cmd_new: 'Start a new chat session', cmd_usage: 'Toggle token usage display on/off', cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)', diff --git a/static/index.html b/static/index.html index 015175b2..f87ccfb7 100644 --- a/static/index.html +++ b/static/index.html @@ -314,7 +314,6 @@ - @@ -388,9 +374,6 @@
- @@ -478,24 +461,6 @@
-
diff --git a/static/messages.js b/static/messages.js index 8eb268d1..172a89ec 100644 --- a/static/messages.js +++ b/static/messages.js @@ -53,7 +53,7 @@ async function send(){ // cmdSteer / cmdInterrupt say "No active task to stop." if(text.startsWith('/')){ const _pc=typeof parseCommand==='function'&&parseCommand(text); - if(_pc&&['steer','interrupt','queue'].includes(_pc.name)){ + if(_pc&&['steer','interrupt','queue','terminal'].includes(_pc.name)){ const _bc=COMMANDS.find(c=>c.name===_pc.name); if(_bc){ $('msg').value='';autoResize(); diff --git a/static/style.css b/static/style.css index 47364ee2..cb037168 100644 --- a/static/style.css +++ b/static/style.css @@ -716,17 +716,12 @@ .composer-workspace-group{display:inline-flex;align-items:stretch;max-width:284px;border-radius:999px;overflow:hidden;background-color:transparent;border:1px solid var(--border2);transition:background-color .15s,border-color .15s;} .composer-workspace-group:hover{background-color:var(--hover-bg);} .composer-workspace-group:hover{border-color:var(--border2);} - .composer-workspace-group:hover .composer-workspace-terminal-btn, .composer-workspace-group:hover .composer-workspace-files-btn, .composer-workspace-group:hover .composer-workspace-chip{color:var(--text);} - .composer-workspace-terminal-btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 10px 8px 12px;background-color:transparent;border:none;border-radius:999px 0 0 999px;color:var(--muted);cursor:pointer;transition:color .15s,background-color .15s;-webkit-tap-highlight-color:transparent;} - .composer-workspace-terminal-btn:disabled{opacity:.45;cursor:not-allowed;} - .composer-workspace-terminal-btn.active{color:var(--accent-text);background:var(--accent-bg);} - .composer-workspace-files-btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 10px;background-color:transparent;border:none;border-left:1px solid transparent;border-radius:0;color:var(--muted);cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;} + .composer-workspace-files-btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 10px 8px 12px;background-color:transparent;border:none;border-left:1px solid transparent;border-radius:999px 0 0 999px;color:var(--muted);cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;} .composer-workspace-files-btn:disabled{opacity:.45;cursor:not-allowed;} .composer-workspace-files-btn.active{color:var(--accent-text);background:var(--accent-bg);} .composer-workspace-chip{display:inline-flex;align-items:center;gap:8px;min-width:0;max-width:200px;padding:8px 12px 8px 10px;border:none;border-left:1px solid transparent;border-radius:0 999px 999px 0;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,border-color .15s;} - .composer-workspace-group:hover .composer-workspace-terminal-btn+.composer-workspace-files-btn, .composer-workspace-group:hover .composer-workspace-files-btn+.composer-workspace-chip{border-left-color:var(--border2);} .composer-workspace-chip:disabled{opacity:.45;cursor:not-allowed;} .composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);} @@ -798,14 +793,10 @@ .send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none;} .send-btn.visible{animation:send-pop-in .18s cubic-bezier(.34,1.56,.64,1) forwards;} .composer-terminal-panel{position:absolute;left:0;right:0;bottom:-24px;width:min(calc(100% - 64px),720px);margin:0 auto;box-sizing:border-box;overflow:hidden;pointer-events:none;z-index:1;} - .composer-terminal-panel.is-open,.composer-terminal-panel.is-collapsed{pointer-events:auto;} + .composer-terminal-panel.is-open{pointer-events:auto;} .composer-terminal-panel[hidden]{display:none!important;} - .composer-terminal-inner{height:var(--composer-terminal-height,260px);min-height:180px;max-height:min(520px,50vh);display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border2);border-radius:14px;background:var(--surface);box-shadow:0 12px 32px rgba(0,0,0,.22);padding-bottom:38px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;} + .composer-terminal-inner{height:260px;min-height:180px;display:flex;flex-direction:column;overflow:hidden;resize:vertical;border:1px solid var(--border2);border-radius:14px;background:var(--surface);box-shadow:0 12px 32px rgba(0,0,0,.22);padding-bottom:38px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;} .composer-terminal-panel.is-open .composer-terminal-inner{transform:translateY(0);opacity:1;} - .composer-terminal-resize-handle{height:12px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;cursor:ns-resize;touch-action:none;background:linear-gradient(to bottom,rgba(255,255,255,.04),transparent);} - .composer-terminal-resize-handle::before{content:"";width:52px;height:4px;border-radius:999px;background:var(--border2);opacity:.72;transition:opacity .15s,background .15s;} - .composer-terminal-resize-handle:hover::before,.composer-terminal-resize-handle:focus-visible::before{opacity:1;background:var(--muted);} - .composer-terminal-inner.is-resizing{transition:none;user-select:none;} .composer-terminal-header{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--border);background:rgba(255,255,255,.025);} .composer-terminal-title{min-width:0;display:flex;align-items:center;gap:6px;color:var(--text);font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;} .composer-terminal-dot{color:var(--muted);font-weight:400;} @@ -973,7 +964,6 @@ .composer-reasoning-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;} /* Workspace group: keep split layout on mobile — files icon + chevron-only picker */ .composer-workspace-group{min-height:44px;} - .composer-workspace-terminal-btn{min-width:44px;padding:6px 8px;} .composer-workspace-files-btn{min-width:44px;padding:6px 8px;} .composer-workspace-chip{min-width:32px;padding:6px 8px;gap:0;} .composer-divider{display:none;} @@ -982,8 +972,7 @@ .ctx-indicator{width:32px;height:32px;} .ctx-tooltip{right:-4px;min-width:190px;max-width:220px;} .composer-terminal-panel{width:calc(100% - 20px);} - .composer-terminal-inner{height:var(--composer-terminal-height,190px);min-height:140px;max-height:min(300px,44vh);border-radius:12px;padding-bottom:28px;} - .composer-terminal-resize-handle{height:10px;cursor:default;} + .composer-terminal-inner{height:190px;min-height:140px;border-radius:12px;padding-bottom:28px;} .composer-terminal-header{padding:7px 8px;} .composer-terminal-actions{gap:2px;overflow-x:auto;} .composer-terminal-action{padding:5px 7px;font-size:10px;white-space:nowrap;} diff --git a/static/terminal.js b/static/terminal.js index 41351758..f1a14df5 100644 --- a/static/terminal.js +++ b/static/terminal.js @@ -28,7 +28,6 @@ function _terminalEls(){ return { panel:$('composerTerminalPanel'), inner:$('composerTerminalPanel')&&$('composerTerminalPanel').querySelector('.composer-terminal-inner'), - handle:$('terminalResizeHandle'), viewport:$('terminalViewport'), surface:$('terminalSurface'), toggle:$('btnTerminalToggle'), @@ -332,12 +331,12 @@ function _setTerminalChromeState(state){ function syncTerminalButton(){ const {toggle}= _terminalEls(); - if(!toggle)return; const currentSid=_terminalSessionId(); const currentWorkspace=S.session&&S.session.workspace; if(TERMINAL_UI.open&&TERMINAL_UI.sessionId&&(currentSid!==TERMINAL_UI.sessionId||currentWorkspace!==TERMINAL_UI.workspace)){ closeComposerTerminal(TERMINAL_UI.sessionId); } + if(!toggle)return; const hasWorkspace=!!(S.session&&S.session.workspace); toggle.disabled=!hasWorkspace; toggle.classList.toggle('active',TERMINAL_UI.open); @@ -412,13 +411,7 @@ async function _startComposerTerminal(restart=false){ async function toggleComposerTerminal(force){ const next=typeof force==='boolean'?force:!TERMINAL_UI.open; if(next){ - if(TERMINAL_UI.open){ - if(TERMINAL_UI.collapsed)expandComposerTerminal(); - else focusComposerTerminalInput(); - return; - } - const {panel,inner}= _terminalEls(); - const messages=_terminalMessagesEl(); + const {panel,inner,workspace}= _terminalEls(); if(!panel)return; clearTimeout(TERMINAL_UI.closeTimer); panel.hidden=false; @@ -440,7 +433,7 @@ async function toggleComposerTerminal(force){ syncTerminalButton(); if(!TERMINAL_UI.resizeObserver&&window.ResizeObserver){ TERMINAL_UI.resizeObserver=new ResizeObserver(()=>_fitTerminal()); - TERMINAL_UI.resizeObserver.observe(panel); + TERMINAL_UI.resizeObserver.observe(inner||panel); } try{ await _startComposerTerminal(false); diff --git a/tests/test_approval_card_layering.py b/tests/test_approval_card_layering.py index 3127b0fd..3098c34a 100644 --- a/tests/test_approval_card_layering.py +++ b/tests/test_approval_card_layering.py @@ -38,3 +38,15 @@ def test_approval_card_visible_outranks_queue_card(): f"greater than .queue-card z-index ({queue_z}) so approval buttons " f"remain clickable when both flyouts are open." ) + + +def test_approval_card_visible_outranks_terminal_card(): + terminal_z = _z_index_of(r"\.composer-terminal-panel") + approval_visible_z = _z_index_of(r"\.approval-card\.visible") + assert terminal_z is not None, ".composer-terminal-panel must declare a z-index" + assert approval_visible_z is not None + assert approval_visible_z > terminal_z, ( + f".approval-card.visible z-index ({approval_visible_z}) must stay above " + f".composer-terminal-panel z-index ({terminal_z}) so approval controls " + f"remain clickable when the terminal flyout is open." + ) diff --git a/tests/test_embedded_workspace_terminal.py b/tests/test_embedded_workspace_terminal.py index 16a181b8..830de17e 100644 --- a/tests/test_embedded_workspace_terminal.py +++ b/tests/test_embedded_workspace_terminal.py @@ -9,13 +9,18 @@ def _read(path: str) -> str: return (REPO_ROOT / path).read_text(encoding="utf-8") -def test_composer_has_workspace_terminal_entrypoint(): +def test_terminal_is_opened_by_slash_command_not_permanent_composer_icon(): html = _read("static/index.html") + commands_js = _read("static/commands.js") sw = _read("static/sw.js") - assert 'id="btnTerminalToggle"' in html + assert 'id="btnTerminalToggle"' not in html + assert "name:'terminal'" in commands_js + assert "fn:cmdTerminal" in commands_js + assert "api('/api/workspaces')" in commands_js + assert "await newSession()" in commands_js + assert "toggleComposerTerminal(true)" in commands_js assert 'id="terminalViewport"' in html assert 'id="terminalSurface"' in html - assert 'onclick="toggleComposerTerminal()"' in html assert 'static/terminal.js' in html assert './static/terminal.js' in sw assert "xterm@5.3.0" in html @@ -28,123 +33,14 @@ def test_terminal_surface_uses_composer_flyout_card_pattern(): flyout = html.split('
', 1)[1].split('
', 1)[0] assert 'id="composerTerminalPanel"' in flyout assert 'class="composer-terminal-inner"' in flyout - assert 'id="terminalResizeHandle"' in flyout assert 'id="composerTerminalPanel"' not in html.split('
', 1)[1] assert ".composer-terminal-panel{position:absolute" in style_css assert "bottom:-24px" in style_css assert "width:min(calc(100% - 64px),720px)" in style_css - assert ".composer-wrap.terminal-dock-visible .composer-flyout{z-index:4" in style_css - assert ".composer-terminal-panel.is-collapsed{bottom:-2px;width:min(calc(100% - 112px),560px);overflow:visible;z-index:4" in style_css - assert ".composer-terminal-panel.is-expanding-from-dock .composer-terminal-inner{transition:opacity .18s ease" in style_css - assert ".messages.terminal-expanding-from-dock{transition:none!important" in style_css - assert ".composer-terminal-dock{min-height:42px" in style_css - assert ".composer-terminal-inner{height:var(--composer-terminal-height,260px)" in style_css + assert ".composer-terminal-inner{height:260px" in style_css assert "transform:translateY(100%)" in style_css -def test_terminal_uses_controlled_desktop_resize_handle(): - html = _read("static/index.html") - style_css = _read("static/style.css") - terminal_js = _read("static/terminal.js") - - assert 'class="composer-terminal-resize-handle"' in html - assert 'role="separator"' in html - assert 'aria-orientation="horizontal"' in html - terminal_inner_rule = style_css.split(".composer-terminal-inner{", 1)[1].split("}", 1)[0] - assert "resize:" not in terminal_inner_rule - assert "cursor:ns-resize" in style_css - assert "const TERMINAL_HEIGHT_DEFAULT=260" in terminal_js - assert "const TERMINAL_HEIGHT_MIN=180" in terminal_js - assert "const TERMINAL_HEIGHT_MAX=520" in terminal_js - assert "max:Math.max(min,Math.min(hardMax,maxByViewport))" in terminal_js - - -def test_terminal_resize_path_refits_backend_and_transcript_space(): - terminal_js = _read("static/terminal.js") - - assert "function _applyTerminalHeight" in terminal_js - apply_block = terminal_js.split("function _applyTerminalHeight", 1)[1].split("function _resetTerminalHeightForViewport", 1)[0] - assert "_fitTerminal();" in apply_block - assert "_syncTerminalTranscriptSpace(true);" in apply_block - assert "function _moveTerminalHeightResize" in terminal_js - assert "_applyTerminalHeight(TERMINAL_UI.resizeStartHeight+(TERMINAL_UI.resizeStartY-ev.clientY))" in terminal_js - assert "handle.addEventListener('pointerdown',_startTerminalHeightResize)" in terminal_js - assert "handle.addEventListener('pointermove',_moveTerminalHeightResize)" in terminal_js - assert "clearTimeout(TERMINAL_UI.resizeTimer)" in terminal_js - assert "api('/api/terminal/resize'" in terminal_js - - -def test_terminal_open_reserves_transcript_space(): - style_css = _read("static/style.css") - terminal_js = _read("static/terminal.js") - - assert ".messages.terminal-open{padding-bottom:var(--terminal-card-height" in style_css - assert "scroll-padding-bottom:var(--terminal-card-height" in style_css - assert "classList.add('terminal-open')" in terminal_js - assert "classList.remove('terminal-open')" in terminal_js - assert "messages.style.setProperty('--terminal-card-height'" in terminal_js - assert "messages.style.removeProperty('--terminal-card-height')" in terminal_js - assert "function _terminalIsMessagesNearBottom" in terminal_js - assert "scrollToBottom" in terminal_js - - -def test_terminal_initial_open_settles_transcript_space_before_reveal(): - terminal_js = _read("static/terminal.js") - - open_block = terminal_js.split("async function toggleComposerTerminal", 1)[1].split("function collapseComposerTerminal", 1)[0] - assert "messages.classList.add('terminal-expanding-from-dock')" in open_block - assert "_syncTerminalTranscriptSpace(true,{immediate:true});" in open_block - assert "void messages.offsetHeight;" in open_block - assert "panel.classList.add('is-open')" in open_block - assert "messages.classList.remove('terminal-expanding-from-dock')" in open_block - assert open_block.index("_syncTerminalTranscriptSpace(true,{immediate:true});") < open_block.index("panel.classList.add('is-open')") - assert open_block.index("void messages.offsetHeight;") < open_block.index("panel.classList.add('is-open')") - - -def test_terminal_collapsed_state_preserves_pty_and_output_surface(): - html = _read("static/index.html") - terminal_js = _read("static/terminal.js") - - assert 'id="btnTerminalCollapse"' in html - assert 'onclick="collapseComposerTerminal()"' in html - assert 'id="btnTerminalExpand"' in html - assert 'onclick="expandComposerTerminal()"' in html - assert 'id="btnTerminalDockClose"' in html - assert 'onclick="closeComposerTerminal()"' in html - assert "collapsed:false" in terminal_js - collapse_block = terminal_js.split("function collapseComposerTerminal", 1)[1].split("function expandComposerTerminal", 1)[0] - assert "api('/api/terminal/close'" not in collapse_block - assert "_disposeXterm" not in collapse_block - assert "_setTerminalChromeState('collapsed')" in collapse_block - assert "composerWrap.classList.toggle('terminal-dock-visible',collapsed)" in terminal_js - expand_block = terminal_js.split("function expandComposerTerminal", 1)[1].split("function _disposeXterm", 1)[0] - assert "_setTerminalChromeState('expanded')" in expand_block - assert "panel.classList.add('is-expanding-from-dock')" in expand_block - assert "panel.classList.remove('is-expanding-from-dock')" in expand_block - assert "messages.classList.add('terminal-expanding-from-dock')" in expand_block - assert "messages.classList.remove('terminal-expanding-from-dock')" in expand_block - assert "_syncTerminalTranscriptSpace(true,{immediate:true});" in expand_block - assert "void messages.offsetHeight;" in expand_block - assert expand_block.index("_syncTerminalTranscriptSpace(true,{immediate:true});") < expand_block.index("_setTerminalChromeState('expanded')") - assert expand_block.index("void messages.offsetHeight;") < expand_block.index("_setTerminalChromeState('expanded')") - assert "_resetTerminalHeightForViewport();" in expand_block - assert "focusComposerTerminalInput();" in expand_block - close_block = terminal_js.split("async function closeComposerTerminal", 1)[1].split("async function restartComposerTerminal", 1)[0] - assert "api('/api/terminal/close'" in close_block - assert "_disposeXterm();" in close_block - - -def test_terminal_slash_command_expands_existing_collapsed_terminal(): - commands_js = _read("static/commands.js") - terminal_js = _read("static/terminal.js") - - assert "await toggleComposerTerminal(true)" in commands_js - toggle_block = terminal_js.split("async function toggleComposerTerminal", 1)[1].split("function collapseComposerTerminal", 1)[0] - assert "if(TERMINAL_UI.open)" in toggle_block - assert "if(TERMINAL_UI.collapsed)expandComposerTerminal();" in toggle_block - assert "else focusComposerTerminalInput();" in toggle_block - - def test_terminal_v1_does_not_expose_send_to_chat_action(): html = _read("static/index.html") terminal_js = _read("static/terminal.js")