Refine embedded terminal card entrypoint

This commit is contained in:
Frank Song
2026-04-28 09:42:53 +08:00
committed by Hermes Agent
parent 940c82b2da
commit eb9614854e
9 changed files with 52 additions and 175 deletions

View File

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

View File

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

View File

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

View File

@@ -314,7 +314,6 @@
</div>
<div class="composer-terminal-panel" id="composerTerminalPanel" hidden>
<div class="composer-terminal-inner">
<div class="composer-terminal-resize-handle" id="terminalResizeHandle" role="separator" aria-orientation="horizontal" aria-label="Resize terminal" tabindex="0"></div>
<div class="composer-terminal-header">
<div class="composer-terminal-title">
<span data-i18n="terminal_title">Terminal</span>
@@ -325,7 +324,6 @@
<button type="button" class="composer-terminal-action" id="btnTerminalClear" onclick="clearComposerTerminal()" data-i18n="terminal_clear">Clear</button>
<button type="button" class="composer-terminal-action" id="btnTerminalCopy" onclick="copyComposerTerminalOutput()" data-i18n="terminal_copy_output">Copy output</button>
<button type="button" class="composer-terminal-action" id="btnTerminalRestart" onclick="restartComposerTerminal()" data-i18n="terminal_restart">Restart</button>
<button type="button" class="composer-terminal-action" id="btnTerminalCollapse" onclick="collapseComposerTerminal()" data-i18n="terminal_collapse">Collapse</button>
<button type="button" class="composer-terminal-action" id="btnTerminalClose" onclick="closeComposerTerminal()" data-i18n="terminal_close">Close</button>
</div>
</div>
@@ -333,18 +331,6 @@
<div class="composer-terminal-surface" id="terminalSurface" aria-label="Workspace terminal"></div>
</div>
</div>
<div class="composer-terminal-dock" id="composerTerminalDock" hidden>
<div class="composer-terminal-dock-title">
<span class="composer-terminal-dock-dot" aria-hidden="true"></span>
<span data-i18n="terminal_title">Terminal</span>
<span class="composer-terminal-dot" aria-hidden="true">·</span>
<span id="terminalDockWorkspaceLabel"></span>
</div>
<div class="composer-terminal-actions">
<button type="button" class="composer-terminal-action" id="btnTerminalExpand" onclick="expandComposerTerminal()" data-i18n="terminal_expand">Expand</button>
<button type="button" class="composer-terminal-action" id="btnTerminalDockClose" onclick="closeComposerTerminal()" data-i18n="terminal_close">Close</button>
</div>
</div>
</div>
</div>
<!-- Queue pill outer: same positioning wrapper as .queue-card (max-width + padding) -->
@@ -388,9 +374,6 @@
</div>
<div class="composer-ws-wrap">
<div class="composer-workspace-group ws-chip" id="composerWorkspaceGroup" role="group" aria-label="Workspace controls">
<button class="composer-workspace-terminal-btn" id="btnTerminalToggle" type="button" onclick="toggleComposerTerminal()" title="Open workspace terminal" data-i18n-title="terminal_open_title" aria-pressed="false" aria-label="Open workspace terminal">
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg></span>
</button>
<button class="composer-workspace-files-btn" id="btnWorkspacePanelToggle" type="button" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false" aria-label="Toggle workspace files panel">
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
</button>
@@ -478,24 +461,6 @@
</div>
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
</div>
<div class="composer-terminal-panel" id="composerTerminalPanel" hidden>
<div class="composer-terminal-header">
<div class="composer-terminal-title">
<span data-i18n="terminal_title">Terminal</span>
<span class="composer-terminal-dot" aria-hidden="true">·</span>
<span id="terminalWorkspaceLabel"></span>
</div>
<div class="composer-terminal-actions">
<button type="button" class="composer-terminal-action" id="btnTerminalClear" onclick="clearComposerTerminal()" data-i18n="terminal_clear">Clear</button>
<button type="button" class="composer-terminal-action" id="btnTerminalCopy" onclick="copyComposerTerminalOutput()" data-i18n="terminal_copy_output">Copy output</button>
<button type="button" class="composer-terminal-action" id="btnTerminalRestart" onclick="restartComposerTerminal()" data-i18n="terminal_restart">Restart</button>
<button type="button" class="composer-terminal-action" id="btnTerminalClose" onclick="closeComposerTerminal()" data-i18n="terminal_close">Close</button>
</div>
</div>
<div class="composer-terminal-viewport" id="terminalViewport" onclick="focusComposerTerminalInput()">
<div class="composer-terminal-surface" id="terminalSurface" aria-label="Workspace terminal"></div>
</div>
</div>
</div>
</div><!-- /#mainChat -->
<div id="mainSkills" class="main-view">

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('<div class="composer-flyout">', 1)[1].split('<div class="queue-pill-outer">', 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('<div class="queue-pill-outer">', 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")