feat(app): live session update + tool renderer + input_tokens migration
SessionDetail live update: - Extract session-view-state.mjs: capture scroll position, disclosure (open/skill-md-open) state, and visible-UUID anchor before refresh; reconcile messages by UUID (in-place update, append tail only); restore scroll and disclosure state after DOM patch. findLastMessageAtOrAbove uses binary search (O(log n)) instead of linear scan. - scrollRevision tracks user scrolls during refresh to avoid stale anchors overriding manual navigation. - Throttle onScroll to one rAF per frame. Tool renderer: - Extract tool-renderer.js: standalone module for rendering tool call cards (Read/Write/Edit diffs, Bash terminal output, search results, JS/TS syntax highlighting). Replaces inline rendering in SessionDetail. - tests/app-tool-renderer.test.mjs covers escaping, highlighting, and terminal formatting. Input tokens semantics migration: - Claude provider now sums input_tokens + cache_creation_input_tokens + cache_read_input_tokens into a single input_tokens value (was previously only the raw field, undercounting when cache tokens are present). - One-time index-wide re-parse triggered when the marker __claude_input_tokens_include_cache_v1__ is absent and the DB already has token data (self-healing on first build after upgrade). - App indexer.ts carries the same marker check for the app's build path. Also: - PRODUCT.md: product register (users, purpose, brand, design principles, accessibility targets). - README.md: minor wording updates. Co-Authored-By: Codex (GPT-5) <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
const DISCLOSURE_CLASSES = ['open', 'skill-md-open'];
|
||||
const SCROLL_ITEM_SELECTOR = '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]';
|
||||
|
||||
function arrayFrom(value) {
|
||||
return value ? Array.from(value) : [];
|
||||
}
|
||||
|
||||
function scrollItems(detail) {
|
||||
return arrayFrom(detail?.querySelectorAll?.(SCROLL_ITEM_SELECTOR));
|
||||
}
|
||||
|
||||
export function captureSessionViewState({ wrap, detail, bottomThreshold = 50 } = {}) {
|
||||
if (!wrap) return null;
|
||||
const distanceFromBottom = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;
|
||||
const followTail = distanceFromBottom < bottomThreshold;
|
||||
const wrapTop = wrap.getBoundingClientRect?.().top || 0;
|
||||
const anchorElement = followTail
|
||||
? null
|
||||
: scrollItems(detail).find(element => element.getBoundingClientRect().bottom > wrapTop);
|
||||
|
||||
const disclosures = [];
|
||||
for (const element of arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))) {
|
||||
const key = element.dataset?.viewKey;
|
||||
if (!key) continue;
|
||||
const classes = DISCLOSURE_CLASSES.filter(className => element.classList?.contains(className));
|
||||
const rawOpen = Boolean(element.querySelector?.('.toolcall-raw')?.classList?.contains('show'));
|
||||
if (classes.length || rawOpen) disclosures.push({ key, classes, rawOpen });
|
||||
}
|
||||
|
||||
return {
|
||||
followTail,
|
||||
scrollTop: wrap.scrollTop,
|
||||
anchor: anchorElement?.dataset?.uuid
|
||||
? {
|
||||
uuid: anchorElement.dataset.uuid,
|
||||
offset: anchorElement.getBoundingClientRect().top - wrapTop,
|
||||
}
|
||||
: null,
|
||||
disclosures,
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreSessionViewState(snapshot, { wrap, detail, restoreScroll = true } = {}) {
|
||||
if (!snapshot || !wrap) return;
|
||||
|
||||
const disclosuresByKey = new Map(
|
||||
arrayFrom(detail?.querySelectorAll?.('[data-view-key]'))
|
||||
.filter(element => element.dataset?.viewKey)
|
||||
.map(element => [element.dataset.viewKey, element]),
|
||||
);
|
||||
|
||||
for (const disclosure of snapshot.disclosures || []) {
|
||||
const element = disclosuresByKey.get(disclosure.key);
|
||||
if (!element) continue;
|
||||
element.classList?.add(...disclosure.classes);
|
||||
if (!disclosure.rawOpen) continue;
|
||||
element.querySelector?.('.toolcall-raw')?.classList?.add('show');
|
||||
element.querySelector?.('.toolcall-pretty')?.classList?.add('hidden');
|
||||
element.querySelector?.('.raw-toggle')?.classList?.add('active');
|
||||
}
|
||||
|
||||
if (!restoreScroll) return;
|
||||
|
||||
if (snapshot.followTail) {
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.scrollTop = snapshot.scrollTop;
|
||||
if (!snapshot.anchor) return;
|
||||
const wrapTop = wrap.getBoundingClientRect?.().top || 0;
|
||||
const anchorElement = scrollItems(detail).find(
|
||||
element => element.dataset?.uuid === snapshot.anchor.uuid,
|
||||
);
|
||||
if (!anchorElement) return;
|
||||
const currentOffset = anchorElement.getBoundingClientRect().top - wrapTop;
|
||||
wrap.scrollTop += currentOffset - snapshot.anchor.offset;
|
||||
}
|
||||
|
||||
export function reconcileSessionMessages(current = [], incoming = []) {
|
||||
const currentByUuid = new Map(
|
||||
current.filter(message => message?.uuid).map(message => [message.uuid, message]),
|
||||
);
|
||||
return incoming.map(message => {
|
||||
if (!message?.uuid) return message;
|
||||
const existing = currentByUuid.get(message.uuid);
|
||||
if (!existing) return message;
|
||||
Object.assign(existing, message);
|
||||
return existing;
|
||||
});
|
||||
}
|
||||
|
||||
export function findLastMessageAtOrAbove(messages, bottomLine) {
|
||||
if (!messages?.length) return -1;
|
||||
let low = 0;
|
||||
let high = messages.length - 1;
|
||||
let result = 0;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (messages[middle].getBoundingClientRect().bottom <= bottomLine) {
|
||||
result = middle;
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
function escapeHTML(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
const JAVASCRIPT_KEYWORDS = new Set([
|
||||
'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue',
|
||||
'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
|
||||
'for', 'from', 'function', 'get', 'if', 'implements', 'import', 'in',
|
||||
'instanceof', 'interface', 'let', 'new', 'of', 'package', 'private', 'protected',
|
||||
'public', 'return', 'set', 'static', 'super', 'switch', 'throw', 'try', 'typeof',
|
||||
'var', 'void', 'while', 'with', 'yield',
|
||||
]);
|
||||
const JAVASCRIPT_LITERALS = new Set(['false', 'Infinity', 'NaN', 'null', 'true', 'undefined']);
|
||||
const CODEACT_GLOBALS = new Set([
|
||||
'ALL_TOOLS', 'Array', 'Boolean', 'Date', 'Error', 'JSON', 'Map', 'Math', 'Number',
|
||||
'Object', 'Promise', 'RegExp', 'Set', 'String', 'clearTimeout', 'generatedImage',
|
||||
'image', 'load', 'notify', 'setTimeout', 'store', 'text', 'tools', 'yield_control',
|
||||
]);
|
||||
|
||||
function isIdentifierStart(char) {
|
||||
return /[A-Za-z_$]/.test(char);
|
||||
}
|
||||
|
||||
function isIdentifierPart(char) {
|
||||
return /[\w$]/.test(char);
|
||||
}
|
||||
|
||||
function highlightJavaScript(source) {
|
||||
const code = String(source);
|
||||
let html = '';
|
||||
let plain = '';
|
||||
let index = 0;
|
||||
|
||||
const flushPlain = () => {
|
||||
if (!plain) return;
|
||||
html += escapeHTML(plain);
|
||||
plain = '';
|
||||
};
|
||||
const token = (kind, value) => {
|
||||
flushPlain();
|
||||
html += `<span class="codeact-token ${kind}">${escapeHTML(value)}</span>`;
|
||||
};
|
||||
|
||||
while (index < code.length) {
|
||||
const char = code[index];
|
||||
const next = code[index + 1];
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
const start = index;
|
||||
index += 2;
|
||||
while (index < code.length && code[index] !== '\n') index += 1;
|
||||
token('comment', code.slice(start, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
const start = index;
|
||||
index += 2;
|
||||
while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) index += 1;
|
||||
if (index < code.length) index += 2;
|
||||
token('comment', code.slice(start, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
const start = index;
|
||||
const quote = char;
|
||||
index += 1;
|
||||
while (index < code.length) {
|
||||
if (code[index] === '\\') {
|
||||
index = Math.min(index + 2, code.length);
|
||||
continue;
|
||||
}
|
||||
if (code[index] === quote) {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
token('string', code.slice(start, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\d/.test(char) || (char === '.' && /\d/.test(next))) {
|
||||
const match = code.slice(index).match(/^(?:0[xX][\dA-Fa-f](?:_?[\dA-Fa-f])*n?|0[bB][01](?:_?[01])*n?|0[oO][0-7](?:_?[0-7])*n?|(?:\d(?:_?\d)*)?(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?n?)/);
|
||||
const value = match?.[0];
|
||||
if (value) {
|
||||
token('number', value);
|
||||
index += value.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isIdentifierStart(char)) {
|
||||
const start = index;
|
||||
index += 1;
|
||||
while (index < code.length && isIdentifierPart(code[index])) index += 1;
|
||||
const value = code.slice(start, index);
|
||||
if (JAVASCRIPT_KEYWORDS.has(value)) token('keyword', value);
|
||||
else if (JAVASCRIPT_LITERALS.has(value)) token('literal', value);
|
||||
else if (CODEACT_GLOBALS.has(value)) token('global', value);
|
||||
else plain += value;
|
||||
continue;
|
||||
}
|
||||
|
||||
plain += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
flushPlain();
|
||||
return html;
|
||||
}
|
||||
|
||||
const TERMINAL_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.2"/><path d="M5 7l2 1.5-2 1.5M8.5 10.5h2.5"/></svg>';
|
||||
const TOOL_ICONS = {
|
||||
Bash: TERMINAL_ICON,
|
||||
exec: TERMINAL_ICON,
|
||||
Read: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>',
|
||||
Edit: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 10l4-4M6.5 10.5l-1.2 1.4 1.4-1.2"/></svg>',
|
||||
Write: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 9.5h4M6 11.5h2.5"/></svg>',
|
||||
};
|
||||
|
||||
export function getToolIcon(name) {
|
||||
return TOOL_ICONS[name] || '';
|
||||
}
|
||||
|
||||
export function getArgPreview(toolCall) {
|
||||
try {
|
||||
const input = JSON.parse(toolCall.input_json || '{}');
|
||||
if (typeof input === 'string') return input.slice(0, 90);
|
||||
if (input.file_path) return input.file_path;
|
||||
if (input.command) return input.command;
|
||||
if (input.path) return input.path;
|
||||
if (input.query) return input.query;
|
||||
if (input.description) return input.description;
|
||||
if (input.pattern) return input.pattern;
|
||||
if (input.url) return input.url;
|
||||
if (input.name) return input.name;
|
||||
if (input.title) return input.title;
|
||||
for (const key of Object.keys(input)) {
|
||||
if (typeof input[key] === 'string' && input[key].length < 90) return input[key];
|
||||
}
|
||||
return JSON.stringify(input).slice(0, 90);
|
||||
} catch {
|
||||
return (toolCall.input_json || '').slice(0, 90);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTerminal(command, output, isError) {
|
||||
let formatted = escapeHTML(output);
|
||||
formatted = formatted.replace(/(✓[^\n]*)/g, '<span style="color:#4ade80">$1</span>');
|
||||
formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '<span style="color:#f87171">$1</span>');
|
||||
return `<div class="terminal-view">
|
||||
<div class="terminal-prompt-line"><span class="prompt-marker">$</span><span class="prompt-cmd">${escapeHTML(command)}</span></div>
|
||||
${output ? `<div class="terminal-divider"></div><div class="terminal-output ${isError ? 'is-error' : ''}">${formatted}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function decodeJsonStringPrefix(source, start) {
|
||||
let value = '';
|
||||
let index = start;
|
||||
let complete = false;
|
||||
while (index < source.length) {
|
||||
const char = source[index++];
|
||||
if (char === '"') {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
if (char !== '\\') {
|
||||
value += char;
|
||||
continue;
|
||||
}
|
||||
if (index >= source.length) break;
|
||||
const escaped = source[index++];
|
||||
if (escaped === 'n') value += '\n';
|
||||
else if (escaped === 'r') value += '\r';
|
||||
else if (escaped === 't') value += '\t';
|
||||
else if (escaped === 'b') value += '\b';
|
||||
else if (escaped === 'f') value += '\f';
|
||||
else if (escaped === 'u') {
|
||||
const hex = source.slice(index, index + 4);
|
||||
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||
value += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
index += 4;
|
||||
}
|
||||
} else {
|
||||
value += escaped;
|
||||
}
|
||||
}
|
||||
return { value, next: index, complete };
|
||||
}
|
||||
|
||||
function extractInputTextBlocks(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const texts = parsed
|
||||
.filter(item => item?.type === 'input_text' && typeof item.text === 'string')
|
||||
.map(item => item.text);
|
||||
if (texts.length) {
|
||||
return {
|
||||
texts,
|
||||
unwrapped: true,
|
||||
truncated: false,
|
||||
hasOtherBlocks: texts.length !== parsed.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const marker = '"text":"';
|
||||
const texts = [];
|
||||
let cursor = 0;
|
||||
while (raw.includes('"type":"input_text"', cursor)) {
|
||||
const markerIndex = raw.indexOf(marker, cursor);
|
||||
if (markerIndex === -1) break;
|
||||
const decoded = decodeJsonStringPrefix(raw, markerIndex + marker.length);
|
||||
texts.push(decoded.value);
|
||||
cursor = Math.max(decoded.next, markerIndex + marker.length);
|
||||
if (!decoded.complete) break;
|
||||
}
|
||||
if (texts.length) {
|
||||
return { texts, unwrapped: true, truncated: true, hasOtherBlocks: false };
|
||||
}
|
||||
return { texts: [raw], unwrapped: false, truncated: false, hasOtherBlocks: false };
|
||||
}
|
||||
|
||||
function parseScriptHeader(text, isError) {
|
||||
const match = String(text).match(/^Script (completed|failed|running)(?: with cell ID ([^\n]+))?\nWall time ([^\n]+)\nOutput:\n?/);
|
||||
if (!match) {
|
||||
return {
|
||||
status: isError ? 'failed' : 'complete',
|
||||
cellId: null,
|
||||
rest: String(text),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: match[1] === 'completed' ? 'complete' : match[1],
|
||||
cellId: match[2] || null,
|
||||
rest: String(text).slice(match[0].length),
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
function tryFormatJson(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function highlightJson(json) {
|
||||
let html = '';
|
||||
let plain = '';
|
||||
let index = 0;
|
||||
|
||||
const flushPlain = () => {
|
||||
if (!plain) return;
|
||||
html += escapeHTML(plain);
|
||||
plain = '';
|
||||
};
|
||||
const token = (kind, value) => {
|
||||
flushPlain();
|
||||
html += `<span class="codeact-json-token ${kind}">${escapeHTML(value)}</span>`;
|
||||
};
|
||||
|
||||
while (index < json.length) {
|
||||
const char = json[index];
|
||||
|
||||
if (char === '"') {
|
||||
const start = index;
|
||||
index += 1;
|
||||
while (index < json.length) {
|
||||
if (json[index] === '\\') {
|
||||
index = Math.min(index + 2, json.length);
|
||||
continue;
|
||||
}
|
||||
if (json[index] === '"') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
let lookahead = index;
|
||||
while (/\s/.test(json[lookahead])) lookahead += 1;
|
||||
token(json[lookahead] === ':' ? 'key' : 'string', json.slice(start, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '-' || /\d/.test(char)) {
|
||||
const match = json.slice(index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
|
||||
if (match) {
|
||||
token('number', match[0]);
|
||||
index += match[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const literal = ['true', 'false', 'null'].find(value => json.startsWith(value, index));
|
||||
if (literal) {
|
||||
token('literal', literal);
|
||||
index += literal.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
plain += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
flushPlain();
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatResultBlocks(blocks) {
|
||||
return blocks.filter(block => block !== '').map(block => {
|
||||
const formatted = tryFormatJson(block);
|
||||
return formatted === null
|
||||
? { text: block, html: escapeHTML(block), isJson: false }
|
||||
: { text: formatted, html: highlightJson(formatted), isJson: true };
|
||||
});
|
||||
}
|
||||
|
||||
function decodeCodeActOutput(raw, isError) {
|
||||
const extracted = extractInputTextBlocks(String(raw || ''));
|
||||
const first = extracted.texts[0] || '';
|
||||
const header = parseScriptHeader(first, isError);
|
||||
const bodyBlocks = header.matched
|
||||
? [header.rest, ...extracted.texts.slice(1)]
|
||||
: extracted.texts;
|
||||
return {
|
||||
...header,
|
||||
blocks: formatResultBlocks(bodyBlocks),
|
||||
truncated: extracted.truncated || String(raw || '').length >= 10000,
|
||||
hasOtherBlocks: extracted.hasOtherBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCodeAct(source, output, isError) {
|
||||
const code = String(source || '');
|
||||
const lines = code.split('\n');
|
||||
const gutter = lines.map((_, index) => index + 1).join('\n');
|
||||
const result = decodeCodeActOutput(output, isError);
|
||||
const statusLabel = result.status === 'failed' ? 'Failed' : 'Running';
|
||||
const emptyText = result.status === 'running'
|
||||
? 'Execution was still running when this event was captured.'
|
||||
: result.status === 'failed'
|
||||
? 'No failure details were captured.'
|
||||
: 'No result returned.';
|
||||
const cell = result.cellId ? `<span class="codeact-cell">Cell ${escapeHTML(result.cellId)}</span>` : '';
|
||||
const status = result.status === 'complete'
|
||||
? ''
|
||||
: `<span class="codeact-status"><span class="codeact-status-dot" aria-hidden="true"></span>${statusLabel}</span>`;
|
||||
const metadata = status || cell
|
||||
? `<div class="codeact-result-meta">${status}${cell}</div>`
|
||||
: '';
|
||||
const notes = [
|
||||
result.truncated ? '<div class="codeact-note">Indexed output truncated. Open Raw to inspect the captured envelope.</div>' : '',
|
||||
result.hasOtherBlocks ? '<div class="codeact-note">Additional structured blocks are available in Raw.</div>' : '',
|
||||
].join('');
|
||||
const resultContent = result.blocks.length
|
||||
? `<div class="codeact-result" tabindex="0" role="list" aria-label="CodeAct result">${result.blocks.map((block, index) => `
|
||||
<pre class="codeact-result-block ${block.isJson ? 'is-json' : ''}" role="listitem" aria-label="Result block ${index + 1}">${block.html}</pre>`).join('')}
|
||||
</div>`
|
||||
: `<div class="codeact-result is-empty" tabindex="0" aria-label="CodeAct result">${escapeHTML(emptyText)}</div>`;
|
||||
|
||||
return `<div class="codeact-view is-${result.status}" role="group" aria-label="CodeAct execution">
|
||||
<section class="codeact-section codeact-source-section">
|
||||
<div class="codeact-section-head">
|
||||
<span class="codeact-section-label">Source</span>
|
||||
</div>
|
||||
<div class="codeact-code-frame" tabindex="0" aria-label="CodeAct source">
|
||||
<pre class="codeact-gutter" aria-hidden="true">${gutter}</pre>
|
||||
<pre class="codeact-code"><code>${highlightJavaScript(code)}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
<section class="codeact-section codeact-result-section">
|
||||
<div class="codeact-section-head">
|
||||
<span class="codeact-section-label">Result</span>
|
||||
${metadata}
|
||||
</div>
|
||||
${resultContent}
|
||||
${notes}
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderTerminalTool(name, input, output, isError) {
|
||||
if (name === 'Bash') {
|
||||
const description = input?.description
|
||||
? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeHTML(input.description)}</div>`
|
||||
: '';
|
||||
return description + renderTerminal(input?.command || '', output, isError);
|
||||
}
|
||||
if (name === 'exec') {
|
||||
return renderCodeAct(typeof input === 'string' ? input : '', output, isError);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -4,7 +4,14 @@ import { useRouter, useRoute } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
||||
import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js';
|
||||
import FlapNumber from '../components/FlapNumber.vue';
|
||||
import {
|
||||
captureSessionViewState,
|
||||
findLastMessageAtOrAbove,
|
||||
reconcileSessionMessages,
|
||||
restoreSessionViewState,
|
||||
} from '../session-view-state.mjs';
|
||||
import {
|
||||
escapeHTML,
|
||||
fmtRelative,
|
||||
@@ -27,6 +34,7 @@ const progressPct = ref(0);
|
||||
const active = ref(false);
|
||||
let removeSessionUpdated = null;
|
||||
let keydownAttached = false;
|
||||
let scrollRevision = 0;
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
@@ -119,6 +127,8 @@ onDeactivated(() => {
|
||||
onUnmounted(() => {
|
||||
active.value = false;
|
||||
detachKeydown();
|
||||
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = null;
|
||||
removeSessionUpdated?.();
|
||||
removeSessionUpdated = null;
|
||||
});
|
||||
@@ -135,33 +145,36 @@ watch(() => props.id, async (newId, oldId) => {
|
||||
async function loadMessages({ force = false } = {}) {
|
||||
if (!props.id) return;
|
||||
const hadContent = messages.value.length > 0;
|
||||
const wasAtBottom = hadContent && wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
|
||||
const prevScrollTop = wrapRef.value?.scrollTop || 0;
|
||||
const viewState = hadContent
|
||||
? captureSessionViewState({ wrap: wrapRef.value, detail: detailRef.value })
|
||||
: null;
|
||||
const scrollRevisionAtLoad = scrollRevision;
|
||||
|
||||
loading.value = true;
|
||||
loading.value = !hadContent;
|
||||
try {
|
||||
const s = state.sessions.find(x => x.id === props.id);
|
||||
if (s && (force || !s.messages || s.messages.length === 0)) {
|
||||
const loaded = await loadSessionDetail(props.id);
|
||||
if (loaded) Object.assign(s, loaded);
|
||||
await loadSessionDetail(props.id);
|
||||
}
|
||||
const latest = state.sessions.find(x => x.id === props.id);
|
||||
messages.value = latest?.messages || [];
|
||||
const incoming = latest?.messages || [];
|
||||
messages.value = hadContent
|
||||
? reconcileSessionMessages(messages.value, incoming)
|
||||
: incoming;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (!wrapRef.value) return;
|
||||
if (!state.pendingFocusUuid) {
|
||||
if (wasAtBottom) {
|
||||
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
||||
} else {
|
||||
wrapRef.value.scrollTop = prevScrollTop;
|
||||
}
|
||||
}
|
||||
await nextTick();
|
||||
syncTotalMessages();
|
||||
if (!state.pendingFocusUuid) {
|
||||
restoreSessionViewState(viewState, {
|
||||
wrap: wrapRef.value,
|
||||
detail: detailRef.value,
|
||||
restoreScroll: scrollRevision === scrollRevisionAtLoad,
|
||||
});
|
||||
onScroll();
|
||||
});
|
||||
}
|
||||
|
||||
// Focus pending uuid if any
|
||||
if (state.pendingFocusUuid) {
|
||||
@@ -192,22 +205,36 @@ async function focusPendingMessage() {
|
||||
const currentMsgIdx = ref(0);
|
||||
const totalMsgs = ref(0);
|
||||
let navLock = false;
|
||||
let scrollFrame = null;
|
||||
|
||||
function onScroll() {
|
||||
function syncTotalMessages() {
|
||||
const msgs = detailRef.value?.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||
totalMsgs.value = msgs?.length || 0;
|
||||
}
|
||||
|
||||
function onScroll(event) {
|
||||
if (event) scrollRevision++;
|
||||
if (navLock) return;
|
||||
if (scrollFrame !== null) return;
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = null;
|
||||
updateScrollProgress();
|
||||
});
|
||||
}
|
||||
|
||||
function updateScrollProgress() {
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||
if (!msgs.length) return;
|
||||
totalMsgs.value = msgs.length;
|
||||
if (!msgs.length) {
|
||||
currentMsgIdx.value = 0;
|
||||
progressPct.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = wrapRef.value;
|
||||
const navHeight = 52;
|
||||
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
||||
let bottomMsgIdx = 0;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i;
|
||||
else break;
|
||||
}
|
||||
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
|
||||
currentMsgIdx.value = bottomMsgIdx;
|
||||
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
|
||||
progressPct.value = pct;
|
||||
@@ -283,27 +310,6 @@ function navigateToSubagent(agentId, description) {
|
||||
|
||||
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
||||
|
||||
function getArgPreview(tc) {
|
||||
try {
|
||||
const j = JSON.parse(tc.input_json || '{}');
|
||||
if (j.file_path) return j.file_path;
|
||||
if (j.command) return j.command;
|
||||
if (j.path) return j.path;
|
||||
if (j.query) return j.query;
|
||||
if (j.description) return j.description;
|
||||
if (j.pattern) return j.pattern;
|
||||
if (j.url) return j.url;
|
||||
if (j.name) return j.name;
|
||||
if (j.title) return j.title;
|
||||
for (const k of Object.keys(j)) {
|
||||
if (typeof j[k] === 'string' && j[k].length < 90) return j[k];
|
||||
}
|
||||
return JSON.stringify(j).slice(0, 90);
|
||||
} catch {
|
||||
return (tc.input_json || '').slice(0, 90);
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolInput(tc) {
|
||||
try {
|
||||
const j = JSON.parse(tc.input_json || '{}');
|
||||
@@ -317,17 +323,6 @@ function escapeH(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
const TOOL_ICONS = {
|
||||
Bash: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.2"/><path d="M5 7l2 1.5-2 1.5M8.5 10.5h2.5"/></svg>',
|
||||
Read: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>',
|
||||
Edit: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 10l4-4M6.5 10.5l-1.2 1.4 1.4-1.2"/></svg>',
|
||||
Write: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/><path d="M6 9.5h4M6 11.5h2.5"/></svg>',
|
||||
};
|
||||
|
||||
function getToolIcon(name) {
|
||||
return TOOL_ICONS[name] || '';
|
||||
}
|
||||
|
||||
function renderPrettyTool(tc) {
|
||||
let args;
|
||||
try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; }
|
||||
@@ -367,10 +362,8 @@ function renderPrettyTool(tc) {
|
||||
return diff + chip;
|
||||
}
|
||||
|
||||
if (tc.name === 'Bash') {
|
||||
const desc = args.description ? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeH(args.description)}</div>` : '';
|
||||
return desc + renderTerminal(args.command || '', out, isError);
|
||||
}
|
||||
const terminal = renderTerminalTool(tc.name, args, out, isError);
|
||||
if (terminal !== null) return terminal;
|
||||
|
||||
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>` +
|
||||
(out ? `<div class="body-section" style="margin-top:12px;"><div class="body-label">Output</div>${renderOutput(out, isError)}</div>` : '');
|
||||
@@ -431,16 +424,6 @@ function renderDiff(oldStr, newStr) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTerminal(command, output, isError) {
|
||||
let formatted = escapeH(output);
|
||||
formatted = formatted.replace(/(✓[^\n]*)/g, '<span style="color:#4ade80">$1</span>');
|
||||
formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '<span style="color:#f87171">$1</span>');
|
||||
return `<div class="terminal-view">
|
||||
<div class="terminal-prompt-line"><span class="prompt-marker">$</span><span class="prompt-cmd">${escapeH(command)}</span></div>
|
||||
${output ? `<div class="terminal-divider"></div><div class="terminal-output ${isError ? 'is-error' : ''}">${formatted}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFieldGrid(obj) {
|
||||
const entries = Object.entries(obj);
|
||||
if (!entries.length) return '';
|
||||
@@ -642,7 +625,7 @@ function getToolCallParsedInput(tc) {
|
||||
<!-- Meta messages: collapsed system indicator -->
|
||||
<template v-if="msg.is_meta === 1">
|
||||
<div class="msg meta" :data-uuid="msg.uuid">
|
||||
<div class="msg-meta-collapsed">
|
||||
<div class="msg-meta-collapsed" :data-view-key="`meta:${msg.uuid}`">
|
||||
<button class="meta-toggle" @click="toggleMeta">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="meta-label">System</span>
|
||||
@@ -708,7 +691,7 @@ function getToolCallParsedInput(tc) {
|
||||
<div class="msg-tools">
|
||||
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
||||
<!-- Render non-workflow tool calls -->
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||
@@ -742,7 +725,7 @@ function getToolCallParsedInput(tc) {
|
||||
|
||||
<!-- Skill card (standalone, like workflow) -->
|
||||
<template v-else-if="msg.type === 'assistant' && (msg.tool_calls || []).length === 1 && msg.tool_calls[0].name === 'Skill' && !msg.text">
|
||||
<div class="skill-card" :data-uuid="msg.uuid">
|
||||
<div class="skill-card" :data-uuid="msg.uuid" :data-view-key="`skill:${msg.uuid}`">
|
||||
<div class="skill-card-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
|
||||
</div>
|
||||
@@ -766,7 +749,7 @@ function getToolCallParsedInput(tc) {
|
||||
<!-- Standalone thinking message -->
|
||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||
<div class="msg assistant" :data-uuid="msg.uuid">
|
||||
<div class="msg-thinking">
|
||||
<div class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
|
||||
<button class="thinking-toggle" @click="toggleThinking">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
@@ -790,7 +773,7 @@ function getToolCallParsedInput(tc) {
|
||||
</div>
|
||||
|
||||
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
||||
<div v-if="msg._thinking" class="msg-thinking">
|
||||
<div v-if="msg._thinking" class="msg-thinking" :data-view-key="`thinking:${msg.uuid}`">
|
||||
<button class="thinking-toggle" @click="toggleThinking">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
@@ -825,7 +808,7 @@ function getToolCallParsedInput(tc) {
|
||||
|
||||
<!-- Agent/Task tool call (subagent) -->
|
||||
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||
<div class="msg-tool agent-call">
|
||||
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
|
||||
@@ -852,7 +835,7 @@ function getToolCallParsedInput(tc) {
|
||||
|
||||
<!-- Workflow tool call (inside assistant bubble) -->
|
||||
<template v-else-if="tc.name === 'Workflow'">
|
||||
<div class="msg-tool agent-call">
|
||||
<div class="msg-tool agent-call" :data-view-key="`tool:${tc.id}`">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="tool-name">Workflow</span>
|
||||
@@ -900,7 +883,7 @@ function getToolCallParsedInput(tc) {
|
||||
|
||||
<!-- Generic tool call -->
|
||||
<template v-else>
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }" :data-view-key="`tool:${tc.id}`">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||
@@ -931,7 +914,7 @@ function getToolCallParsedInput(tc) {
|
||||
</div>
|
||||
|
||||
<!-- Summary block -->
|
||||
<div v-if="msg.summary" class="msg-summary">
|
||||
<div v-if="msg.summary" class="msg-summary" :data-view-key="`summary:${msg.uuid}`">
|
||||
<button class="summary-toggle" @click="toggleSummary">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="label">Session summary</span>
|
||||
|
||||
@@ -685,6 +685,141 @@
|
||||
.diff-body .diff-gutter.add { background: rgba(99,102,241,0.04); color: rgba(99,102,241,0.55); }
|
||||
.diff-body .diff-gutter.del { background: rgba(236,72,153,0.04); color: rgba(236,72,153,0.45); }
|
||||
|
||||
/* CodeAct view */
|
||||
.codeact-view {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 6px;
|
||||
background: oklch(0.145 0.018 278);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.codeact-section + .codeact-section { border-top: 1px solid var(--hairline-strong); }
|
||||
.codeact-section-head {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 6px 12px;
|
||||
color: var(--muted);
|
||||
background: oklch(0.18 0.018 278);
|
||||
}
|
||||
.codeact-section-label {
|
||||
color: var(--fg-2);
|
||||
font-size: 9.5px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.codeact-cell {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.codeact-result-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.codeact-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--fg-2);
|
||||
font-size: 10px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.codeact-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: oklch(0.74 0.15 145);
|
||||
}
|
||||
.codeact-view.is-failed .codeact-status-dot { background: oklch(0.7 0.18 25); }
|
||||
.codeact-view.is-running .codeact-status-dot { background: oklch(0.78 0.15 82); }
|
||||
.codeact-code-frame {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
background: oklch(0.115 0.014 278);
|
||||
}
|
||||
.codeact-code-frame:focus-visible,
|
||||
.codeact-result:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.codeact-gutter,
|
||||
.codeact-code,
|
||||
.codeact-result {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
.codeact-gutter {
|
||||
min-height: 100%;
|
||||
padding: 8px 9px 8px 12px;
|
||||
border-right: 1px solid var(--hairline);
|
||||
color: var(--muted-2);
|
||||
background: oklch(0.13 0.014 278);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
.codeact-code {
|
||||
min-width: max-content;
|
||||
padding: 8px 12px;
|
||||
color: var(--fg);
|
||||
white-space: pre;
|
||||
}
|
||||
.codeact-token.keyword { color: oklch(0.79 0.12 298); }
|
||||
.codeact-token.string { color: oklch(0.78 0.1 151); }
|
||||
.codeact-token.number,
|
||||
.codeact-token.literal { color: oklch(0.8 0.105 78); }
|
||||
.codeact-token.global { color: oklch(0.79 0.095 230); }
|
||||
.codeact-token.comment {
|
||||
color: oklch(0.64 0.025 278);
|
||||
font-style: italic;
|
||||
}
|
||||
.codeact-result {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
color: var(--fg-2);
|
||||
background: oklch(0.125 0.014 278);
|
||||
}
|
||||
.codeact-result-block {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-variant-ligatures: none;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.codeact-result-block + .codeact-result-block { border-top: 1px solid var(--hairline); }
|
||||
.codeact-json-token.key { color: oklch(0.79 0.095 230); }
|
||||
.codeact-json-token.string { color: oklch(0.78 0.1 151); }
|
||||
.codeact-json-token.number { color: oklch(0.8 0.105 78); }
|
||||
.codeact-json-token.literal { color: oklch(0.79 0.12 298); }
|
||||
.codeact-result.is-empty {
|
||||
padding: 10px 12px;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.codeact-view.is-failed .codeact-result { color: oklch(0.82 0.09 25); }
|
||||
.codeact-note {
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
color: oklch(0.82 0.09 82);
|
||||
background: oklch(0.2 0.025 82 / 0.45);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
/* Terminal view */
|
||||
.terminal-view {
|
||||
border: 1px solid var(--hairline); border-radius: 5px;
|
||||
|
||||
Reference in New Issue
Block a user