Stage 298: PR #1667 — feat: add WebUI status command card by @Michaelyklam

This commit is contained in:
test
2026-05-05 01:12:26 +00:00
6 changed files with 238 additions and 33 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+60 -28
View File
@@ -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();
+23
View File
@@ -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: '예',
+15
View File
@@ -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 ── */
+43 -5
View File
@@ -50,7 +50,6 @@ function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=>({'&':'&amp;','<':'&lt;','>':'&
* with the same <pre><code> 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
? `<button class="status-card-session-copy" type="button" data-copy-status-session="${esc(card.sessionId||'')}" title="${esc(t('copy'))}" onclick="copyStatusSessionId(this);event.stopPropagation()"><span>${esc(shortSessionId)}</span>${copyIcon}</button>`
: '';
const rowHtml=rows.map(row=>`
<div class="status-card-row">
<span class="status-card-label">${esc(row.label||'')}</span>
<span class="status-card-value">${esc(row.value||'')}</span>
</div>`).join('');
return `<div class="status-card" data-status-card="1">
<div class="status-card-head">
<div class="status-card-title-wrap">
<div class="status-card-title">${esc(card.title||t('status_heading'))}</div>
<div class="status-card-subtitle">${esc(card.subtitle||'')}</div>
</div>
${copyBtn}
</div>
<div class="status-card-grid">${rowHtml}</div>
</div>`;
}
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('')}</div>`;
}
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 ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo',13)}</button>` : '';
@@ -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}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
seg.classList.add('assistant-segment-anchor');
+97
View File
@@ -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