diff --git a/docs/pr-media/463/status-command-card.png b/docs/pr-media/463/status-command-card.png new file mode 100644 index 00000000..ac63b7d7 Binary files /dev/null and b/docs/pr-media/463/status-command-card.png differ diff --git a/static/commands.js b/static/commands.js index 6dce4c48..90fc5aca 100644 --- a/static/commands.js +++ b/static/commands.js @@ -819,35 +819,67 @@ async function cmdBackground(args){ if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt); }catch(e){showToast(t('bg_failed')+e.message);} } -async function cmdStatus(){ +function _formatStatusTimestamp(value){ + if(value===undefined||value===null||value==='') return t('status_unknown'); + let date; + if(typeof value==='number') date=new Date(value < 1000000000000 ? value*1000 : value); + else date=new Date(value); + if(Number.isNaN(date.getTime())) return t('status_unknown'); + return date.toLocaleString(); +} +function _formatStatusTokens(s){ + const lastUsage=(typeof S!=='undefined'&&(S.lastUsage||s.last_usage))||{}; + const input=Number(s.input_tokens??lastUsage.input_tokens??0)||0; + const output=Number(s.output_tokens??lastUsage.output_tokens??0)||0; + const total=Number(s.total_tokens??lastUsage.total_tokens??(input+output))||0; + const cost=Number(s.estimated_cost??lastUsage.estimated_cost??0)||0; + if(!total&&!cost) return t('status_no_tokens'); + const fmtNum=n=>Number(n||0).toLocaleString(); + return `${fmtNum(input)} in / ${fmtNum(output)} out${cost?` (~$${cost.toFixed(4)})`:''}`; +} +function _statusProviderForSession(s){ + if(s.model_provider) return String(s.model_provider); + if(window._activeProvider) return String(window._activeProvider); + const model=String(s.model||''); + return model.includes('/') ? model.split('/')[0] : ''; +} +function _statusCardFromSession(s){ + const provider=_statusProviderForSession(s); + const model=s.model||(($('modelSelect')&&$('modelSelect').value)||t('usage_default_model')); + const running=!!(s.active_stream_id||S.activeStreamId||S.busy); + const profile=s.profile||S.activeProfile||'default'; + const workspace=s.workspace||S.currentDir||t('status_unknown'); + const rows=[ + {label:t('status_session_id'), value:s.session_id||t('status_unknown')}, + {label:t('status_title'), value:s.title||t('untitled')}, + {label:t('status_model'), value:model}, + {label:t('status_provider'), value:provider||t('status_unknown')}, + {label:t('status_profile'), value:profile}, + {label:t('status_workspace'), value:workspace}, + {label:t('status_personality'), value:s.personality||t('usage_personality_none')}, + {label:t('status_started'), value:_formatStatusTimestamp(s.created_at)}, + {label:t('status_updated'), value:_formatStatusTimestamp(s.updated_at||s.last_message_at)}, + {label:t('status_tokens'), value:_formatStatusTokens(s)}, + {label:t('status_messages'), value:String(s.message_count??(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length)}, + {label:t('status_agent_running'), value:running?t('status_yes'):t('status_no')}, + ]; + return { + title:t('status_heading'), + subtitle:t('status_ephemeral'), + sessionId:s.session_id||'', + rows, + }; +} +function cmdStatus(){ if(!S.session){showToast(t('no_active_session'));return;} - try{ - const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id)); - if(r&&r.error){showToast(r.error);return;} - // Build status card lines matching CLI /status output - const provider=window._activeProvider||''; - const profile=r.profile||S.activeProfile||'default'; - const started=r.created_at?new Date(r.created_at).toLocaleString():t('status_unknown'); - const fmtNum=n=>typeof n==='number'?n.toLocaleString():'0'; - const tokens=r.total_tokens?`${fmtNum(r.input_tokens)} in / ${fmtNum(r.output_tokens)} out`:t('status_no_tokens'); - const cost=r.estimated_cost?` (~$${Number(r.estimated_cost).toFixed(4)})`:''; - const lines=[ - `**${t('status_heading')}**`,'', - `\`${r.session_id}\``,'', - `**${t('status_title')}:** ${r.title||t('untitled')}`, - `**${t('status_model')}:** ${r.model||t('usage_default_model')}${provider?' ('+provider+')':''}`, - `**${t('status_profile')}:** ${profile}`, - `**${t('status_hermes_home')}:** ${r.hermes_home||t('status_unknown')}`, - `**${t('status_workspace')}:** ${r.workspace}`, - `**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`, - `**${t('status_started')}:** ${started}`, - `**${t('status_tokens')}:** ${tokens}${cost}`, - `**${t('status_messages')}:** ${r.message_count}`, - `**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`, - ]; - S.messages.push({role:'assistant',content:lines.join('\n')}); - renderMessages(); - }catch(e){showToast(t('status_load_failed')+e.message);} + S.messages.push({ + role:'assistant', + content:'', + _ephemeral:true, + _statusCard:_statusCardFromSession(S.session), + _ts:Date.now()/1000, + }); + renderMessages(); } function cmdReasoning(args){ const arg=(args||'').trim().toLowerCase(); diff --git a/static/i18n.js b/static/i18n.js index 94625d78..1cc86b7e 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -225,6 +225,7 @@ const LOCALES = { status_session_id:'Session ID', status_title:'Title', status_model:'Model', + status_provider:'Provider', status_workspace:'Workspace', status_personality:'Personality', status_messages:'Messages', @@ -232,7 +233,9 @@ const LOCALES = { status_profile: 'Profile', status_hermes_home: 'Hermes home', status_started: 'Started', + status_updated: 'Updated', status_tokens: 'Tokens', + status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_no_tokens: 'No tokens used', status_unknown: 'Unknown', status_yes:'Yes', @@ -1185,6 +1188,7 @@ const LOCALES = { status_session_id:'セッションID', status_title:'タイトル', status_model:'モデル', + status_provider:'プロバイダー', status_workspace:'ワークスペース', status_personality:'パーソナリティ', status_messages:'メッセージ', @@ -1193,6 +1197,8 @@ const LOCALES = { status_hermes_home: 'Hermes ホーム', status_started: '開始', status_tokens: 'トークン', + status_updated: '更新', + status_ephemeral: '一時的なスナップショット — 履歴には保存されません。', status_no_tokens: 'トークン未使用', status_unknown: '不明', status_yes:'はい', @@ -2714,6 +2720,8 @@ const LOCALES = { settings_tab_conversation: 'Conversation', settings_tab_preferences: 'Preferences', settings_tab_system: 'System', + status_updated: 'Updated', + status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_no_tokens: 'No token data', status_profile: 'Profile', status_hermes_home: 'Hermes home', @@ -3598,6 +3606,8 @@ const LOCALES = { settings_tab_conversation: 'Conversation', settings_tab_preferences: 'Preferences', settings_tab_system: 'System', + status_updated: 'Updated', + status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_no_tokens: 'No token data', status_profile: 'Profile', status_hermes_home: 'Hermes home', @@ -4229,6 +4239,8 @@ const LOCALES = { settings_tab_conversation: 'Conversation', settings_tab_preferences: 'Preferences', settings_tab_system: 'System', + status_updated: 'Updated', + status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_no_tokens: 'No token data', status_profile: 'Profile', status_hermes_home: 'Hermes home', @@ -5387,6 +5399,8 @@ const LOCALES = { settings_tab_conversation: 'Conversation', settings_tab_preferences: 'Preferences', settings_tab_system: 'System', + status_updated: 'Updated', + status_ephemeral: 'Ephemeral snapshot — not saved to transcript history.', status_no_tokens: 'No token data', status_profile: 'Profile', status_hermes_home: 'Hermes home', @@ -6253,6 +6267,8 @@ const LOCALES = { status_hermes_home: 'Hermes 主目錄', status_started: '開始時間', status_tokens: 'Token', + status_updated: '已更新', + status_ephemeral: '临时快照 — 不会保存到对话历史。', status_no_tokens: '未使用 Token', status_unknown: '未知', status_completed: '\u5df2\u5b8c\u6210', @@ -6261,6 +6277,7 @@ const LOCALES = { status_load_failed: '\u8f09\u5165\u72c0\u614b\u5931\u6557\uff1a', status_messages: '\u8a0a\u606f\u6578', status_model: '\u6a21\u578b', + status_provider: '供应商', status_no: '\u5426', status_personality: '\u4eba\u8a2d', status_session_id: '\u6703\u8a71 ID', @@ -6626,6 +6643,7 @@ const LOCALES = { status_session_id: 'ID da Sessão', status_title: 'Título', status_model: 'Modelo', + status_provider: 'Provedor', status_workspace: 'Workspace', status_personality: 'Personalidade', status_messages: 'Mensagens', @@ -6634,6 +6652,8 @@ const LOCALES = { status_hermes_home: 'Diretório Hermes', status_started: 'Iniciado', status_tokens: 'Tokens', + status_updated: 'Atualizado', + status_ephemeral: 'Instantâneo efêmero — não salvo no histórico.', status_no_tokens: 'Nenhum token usado', status_unknown: 'Desconhecido', status_yes: 'Sim', @@ -7464,6 +7484,7 @@ const LOCALES = { status_session_id: '세션 ID', status_title: '제목', status_model: '모델', + status_provider: '제공자', status_workspace: '워크스페이스', status_personality: '페르소나', status_messages: '메시지', @@ -7472,6 +7493,8 @@ const LOCALES = { status_hermes_home: 'Hermes 홈', status_started: '시작 시간', status_tokens: '토큰', + status_updated: '업데이트됨', + status_ephemeral: '임시 스냅샷 — 대화 기록에 저장되지 않습니다.', status_no_tokens: '사용된 토큰 없음', status_unknown: '알 수 없음', status_yes: '예', diff --git a/static/style.css b/static/style.css index 926fefd5..e512a0f3 100644 --- a/static/style.css +++ b/static/style.css @@ -2637,6 +2637,21 @@ main.main.showing-profiles > #mainProfiles{display:flex;} .msg-body:empty { display: none; } .assistant-turn { width: 100%; } .assistant-turn-blocks { display: flex; flex-direction: column; } +.status-card{margin:8px 0 8px var(--msg-rail);max-width:min(var(--msg-max),760px);border:1px solid var(--border-subtle);background:var(--surface-subtle);border-radius:var(--radius-card);box-shadow:0 10px 24px rgba(0,0,0,.05);overflow:hidden;} +.status-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--border-subtle);background:linear-gradient(180deg,var(--surface-subtle-hover),var(--surface-subtle));} +.status-card-title-wrap{min-width:0;} +.status-card-title{font-weight:650;color:var(--text);font-size:14px;letter-spacing:.01em;} +.status-card-subtitle{font-size:12px;color:var(--muted);margin-top:3px;} +.status-card-session-copy{display:inline-flex;align-items:center;gap:7px;min-height:28px;padding:5px 9px;border:1px solid var(--border-subtle);border-radius:999px;background:var(--surface);color:var(--muted);font-size:12px;font-family:var(--font-mono);cursor:pointer;max-width:230px;} +.status-card-session-copy span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} +.status-card-session-copy:hover,.status-card-session-copy.copied{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} +.status-card-grid{display:grid;grid-template-columns:minmax(120px,180px) minmax(0,1fr);gap:0;border-top:0;} +.status-card-row{display:contents;} +.status-card-label,.status-card-value{padding:9px 16px;border-top:1px solid var(--border-subtle);font-size:13px;line-height:1.4;} +.status-card-row:first-child .status-card-label,.status-card-row:first-child .status-card-value{border-top:0;} +.status-card-label{color:var(--muted);font-weight:550;background:rgba(0,0,0,.015);} +.status-card-value{color:var(--text);word-break:break-word;font-family:var(--font-mono);} +@media (max-width:700px){.status-card{margin-left:0;}.status-card-head{flex-direction:column;}.status-card-session-copy{max-width:100%;}.status-card-grid{grid-template-columns:1fr;}.status-card-label{padding-bottom:2px;border-top:1px solid var(--border-subtle);}.status-card-value{padding-top:2px;border-top:0;}} .assistant-segment-anchor { display: none; } /* ── Classic conversation layout: user right, half-width; assistant left ── */ diff --git a/static/ui.js b/static/ui.js index a0af5707..9d33b4db 100644 --- a/static/ui.js +++ b/static/ui.js @@ -50,7 +50,6 @@ function _setCompressionSessionLock(sid){ window._compressionLockSid=sid||null; } const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - /** * Render fenced code blocks inside user messages. * Extracts ```…``` fences, replaces them with placeholders, @@ -58,6 +57,7 @@ const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'& * with the same
pipeline used by renderMd().
* All non-fenced text stays escaped (no bold/italic/link interpretation).
*/
+
function _renderUserFencedBlocks(text){
const stash=[];
let s=String(text||'');
@@ -89,6 +89,31 @@ function _renderUserFencedBlocks(text){
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
return s;
}
+function _statusCardHtml(card){
+ card=card||{};
+ const rows=Array.isArray(card.rows)?card.rows:[];
+ const sessionId=String(card.sessionId||'');
+ const shortSessionId=sessionId.length>22?`${sessionId.slice(0,10)}…${sessionId.slice(-8)}`:sessionId;
+ const copyIcon=(typeof li==='function')?li('copy',13):'Copy';
+ const copyBtn=sessionId
+ ? ``
+ : '';
+ const rowHtml=rows.map(row=>`
+
+ ${esc(row.label||'')}
+ ${esc(row.value||'')}
+ `).join('');
+ return `
+
+
+ ${esc(card.title||t('status_heading'))}
+ ${esc(card.subtitle||'')}
+
+ ${copyBtn}
+
+ ${rowHtml}
+ `;
+}
const MESSAGE_RENDER_WINDOW_DEFAULT=50;
let _messageRenderWindowSid=null;
@@ -2716,6 +2741,16 @@ function _fallbackCopy(text){
finally{document.body.removeChild(ta);}
});
}
+function copyStatusSessionId(btn){
+ const text=btn&&btn.getAttribute('data-copy-status-session');
+ if(!text)return;
+ _copyText(text).then(()=>{
+ const orig=btn.innerHTML;
+ btn.innerHTML=(typeof li==='function')?li('check',13):t('copied');
+ btn.classList.add('copied');
+ setTimeout(()=>{btn.innerHTML=orig;btn.classList.remove('copied');},1500);
+ }).catch(()=>showToast(t('copy_failed')));
+}
function copyMsg(btn){
const row=btn.closest('[data-raw-text]');
const text=row?row.dataset.rawText:'';
@@ -3763,7 +3798,7 @@ function renderMessages(){
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;
}
- return msgContent(m)||m.attachments?.length;
+ return m._statusCard||msgContent(m)||m.attachments?.length;
});
$('emptyState').style.display=(vis.length||preservedCompressionTaskMessages.length)?'none':'';
inner.innerHTML='';
@@ -3782,7 +3817,7 @@ function renderMessages(){
if(_isPreservedCompressionTaskListMessage(m)){preservedCompressionRawIdxs.push(rawIdx);rawIdx++;continue;}
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
- if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
+ if(msgContent(m)||m._statusCard||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
rawIdx++;
}
// Show a top affordance when earlier transcript content exists either in
@@ -3889,6 +3924,7 @@ function renderMessages(){
}).join('')}`;
}
const bodyHtml = isUser ? _renderUserFencedBlocks(content) : renderMd(_stripXmlToolCallsDisplay(String(content)));
+ const statusHtml = (!isUser&&m._statusCard) ? _statusCardHtml(m._statusCard) : '';
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
const editBtn = isEditableUser ? `` : '';
const undoBtn = isLastAssistant ? `` : '';
@@ -3954,8 +3990,10 @@ function renderMessages(){
if(isSimplifiedToolCalling()) assistantThinking.set(rawIdx, thinkingText);
else if(window._showThinking!==false) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
}
- const hasVisibleBody=!!(String(content||'').trim()||filesHtml);
- if(hasVisibleBody){
+ const hasVisibleBody=!!(String(content||'').trim()||filesHtml||statusHtml);
+ if(statusHtml){
+ seg.insertAdjacentHTML('beforeend', statusHtml);
+ }else if(hasVisibleBody){
seg.insertAdjacentHTML('beforeend', `${filesHtml}${bodyHtml}${footHtml}`);
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
seg.classList.add('assistant-segment-anchor');
diff --git a/tests/test_status_command_card.py b/tests/test_status_command_card.py
new file mode 100644
index 00000000..ca3fa44d
--- /dev/null
+++ b/tests/test_status_command_card.py
@@ -0,0 +1,97 @@
+"""Regression tests for issue #463: WebUI /status info card.
+
+/status should be a client-handled slash command that renders a safe,
+ephemeral assistant-style card from already-loaded session/profile/model data.
+It must not round-trip through the agent or a status endpoint just to draw the
+card.
+"""
+import pathlib
+
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent
+COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
+UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
+STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
+I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
+MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
+
+
+def _function_body(src: str, name: str) -> str:
+ marker = f"function {name}"
+ start = src.index(marker)
+ brace = src.index("{", start)
+ depth = 0
+ for idx in range(brace, len(src)):
+ if src[idx] == "{":
+ depth += 1
+ elif src[idx] == "}":
+ depth -= 1
+ if depth == 0:
+ return src[start:idx + 1]
+ raise AssertionError(f"Could not extract {name}()")
+
+
+def test_status_command_is_registered_with_help_text():
+ assert "{name:'status'" in COMMANDS_JS
+ assert "desc:t('cmd_status')" in COMMANDS_JS
+ assert "fn:cmdStatus" in COMMANDS_JS
+ assert "cmd_status:'Show session info'" in I18N_JS
+
+
+def test_status_command_uses_client_state_not_status_endpoint():
+ body = _function_body(COMMANDS_JS, "cmdStatus")
+ assert "/api/session/status" not in body
+ assert "api(" not in body
+ assert "S.session" in body
+ assert "S.activeProfile" in COMMANDS_JS
+ assert "model_provider" in COMMANDS_JS
+ assert "last_usage" in COMMANDS_JS
+
+
+def test_status_command_pushes_ephemeral_status_card_message():
+ body = _function_body(COMMANDS_JS, "cmdStatus")
+ assert "_statusCard" in body
+ assert "_ephemeral:true" in body
+ assert "renderMessages()" in body
+ assert "_statusCardFromSession(S.session)" in body
+ helper = _function_body(COMMANDS_JS, "_statusCardFromSession")
+ assert "session_id" in helper
+ assert "updated_at" in helper
+ assert "message_count" in helper
+ assert "active_stream_id" in helper
+
+
+def test_status_card_renderer_escapes_all_dynamic_values_and_is_copyable():
+ body = _function_body(UI_JS, "_statusCardHtml")
+ assert "data-status-card" in body
+ assert "data-copy-status-session" in body
+ assert "onclick=\"copyStatusSessionId(this);event.stopPropagation()\"" in body
+ assert "esc(card.title" in body
+ assert "esc(card.subtitle" in body
+ assert "esc(row.label" in body
+ assert "esc(row.value" in body
+ assert "esc(card.sessionId" in body
+ assert "renderMd(" not in body, "Status card data should not be interpreted as markdown"
+
+
+def test_render_messages_treats_status_card_as_visible_assistant_content():
+ render_body = _function_body(UI_JS, "renderMessages")
+ assert "m._statusCard" in render_body
+ assert "_statusCardHtml(m._statusCard)" in render_body
+ assert "statusHtml" in render_body
+
+
+def test_status_card_styles_exist():
+ assert ".status-card" in STYLE_CSS
+ assert ".status-card-grid" in STYLE_CSS
+ assert ".status-card-session-copy" in STYLE_CSS
+
+
+def test_status_command_never_reaches_agent_send_path():
+ send_body = _function_body(MESSAGES_JS, "send")
+ branch_start = send_body.index("if(text.startsWith('/')")
+ branch_end = send_body.index("if(_parsedCmd&&!_cmd)", branch_start)
+ cmd_branch = send_body[branch_start:branch_end]
+ assert "COMMANDS.find" in cmd_branch
+ assert "return;" in cmd_branch
+ assert "api('/api/chat/start'" not in cmd_branch