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:
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
Developers and coding-agent users who need to revisit past work, understand what an agent did, verify why a decision was made, and manage durable memory without leaving the evidence behind.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Obelisk is explicit memory infrastructure for coding agents. Its core indexes local agent history into a queryable evidence layer; the desktop app is the human observability and memory-control surface for inspecting sessions, auditing evidence, managing the index, and reviewing or revoking durable memories. Success means a person can move from a remembered question to the exact historical evidence with low friction and high confidence.
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
Calm, exact, auditable. Obelisk should feel trustworthy around private local history, dense without being hostile, and confident without hiding uncertainty or provenance.
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
Obelisk should not resemble an undifferentiated chat-log browser, a terminal log wall, an opaque AI-summary dashboard, or a decorative analytics product that separates conclusions from their source evidence.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. Evidence before assertion: every interpretation keeps a clear path back to the raw session record.
|
||||||
|
2. Human-readable by default: expose protocol and storage detail on demand, not as the primary reading experience.
|
||||||
|
3. Preserve uncertainty: never present inferred structure as observed execution fact.
|
||||||
|
4. Progressive density: make long sessions scannable without discarding the depth experts need.
|
||||||
|
5. Local trust is visible: controls, provenance, and failure states should reinforce that the user remains in charge of their history and memories.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Target WCAG AA contrast for essential text and controls. Core session navigation and disclosure controls should work from the keyboard, focus must remain visible, state cannot rely on color alone, and motion should respect reduced-motion preferences.
|
||||||
@@ -42,11 +42,11 @@ For live app refresh, Obelisk watches `~/.claude/projects` and `~/.codex/session
|
|||||||
You can use obelisk like:
|
You can use obelisk like:
|
||||||
|
|
||||||
```
|
```
|
||||||
/obelisk-skill 上次 auth bug 最后到底改了哪些文件,为什么这么改
|
/obelisk 上次 auth bug 最后到底改了哪些文件,为什么这么改
|
||||||
/obelisk-skill 这个文件最近在哪些 sessions 里被反复修改
|
/obelisk 这个文件最近在哪些 sessions 里被反复修改
|
||||||
/obelisk-skill 找出最近失败的 tool calls,它们分别发生在哪些任务里
|
/obelisk 找出最近失败的 tool calls,它们分别发生在哪些任务里
|
||||||
/obelisk-skill 那个 review workflow 的 subagents 各自结论是什么
|
/obelisk 那个 review workflow 的 subagents 各自结论是什么
|
||||||
/obelisk-skill recap this week
|
/obelisk recap this week
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
@@ -60,7 +60,7 @@ Or manually: copy `obelisk-skill/` into your project's `.claude/skills/`
|
|||||||
Then in any Claude Code session:
|
Then in any Claude Code session:
|
||||||
|
|
||||||
```
|
```
|
||||||
/obelisk-skill <your question>
|
/obelisk <your question>
|
||||||
```
|
```
|
||||||
|
|
||||||
First run builds the index (~5 seconds for 100 sessions). After that it rebuilds incrementally.
|
First run builds the index (~5 seconds for 100 sessions). After that it rebuilds incrementally.
|
||||||
|
|||||||
+30
-6
@@ -3,7 +3,10 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { parse as claudeParse } from '../../../packages/core/src/providers/claude.ts';
|
import {
|
||||||
|
CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER,
|
||||||
|
parse as claudeParse,
|
||||||
|
} from '../../../packages/core/src/providers/claude.ts';
|
||||||
import { parse as codexParse } from '../../../packages/core/src/providers/codex.ts';
|
import { parse as codexParse } from '../../../packages/core/src/providers/codex.ts';
|
||||||
import { persist } from '../../../packages/core/src/persist.ts';
|
import { persist } from '../../../packages/core/src/persist.ts';
|
||||||
import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts';
|
import { runWriteTransaction, configureConnection, betterSqliteTransactionAdapter } from '../../../packages/core/src/tx.ts';
|
||||||
@@ -307,9 +310,9 @@ function needsReindex(db, fp) {
|
|||||||
|
|
||||||
// Index one Claude transcript via the shared provider + persist core.
|
// Index one Claude transcript via the shared provider + persist core.
|
||||||
// Returns { sessionId, path } when reindexed, undefined when skipped.
|
// Returns { sessionId, path } when reindexed, undefined when skipped.
|
||||||
function indexClaudeFile(db, file) {
|
function indexClaudeFile(db, file, { forceFull = false } = {}) {
|
||||||
const { needed, skip, mtime } = needsReindex(db, file.path);
|
const { needed, skip, mtime } = needsReindex(db, file.path);
|
||||||
if (!needed) return undefined;
|
if (!needed && !forceFull) return undefined;
|
||||||
const unit = {
|
const unit = {
|
||||||
key: file.path,
|
key: file.path,
|
||||||
sessionId: file.sessionId,
|
sessionId: file.sessionId,
|
||||||
@@ -317,7 +320,7 @@ function indexClaudeFile(db, file) {
|
|||||||
isSubagent: file.isSubagent,
|
isSubagent: file.isSubagent,
|
||||||
agentId: file.agentId,
|
agentId: file.agentId,
|
||||||
};
|
};
|
||||||
const cursor = skip > 0 ? `${mtime}:${skip}` : null;
|
const cursor = !forceFull && skip > 0 ? `${mtime}:${skip}` : null;
|
||||||
persist(db, unit, claudeParse(unit, cursor));
|
persist(db, unit, claudeParse(unit, cursor));
|
||||||
return { sessionId: file.sessionId, path: file.path };
|
return { sessionId: file.sessionId, path: file.path };
|
||||||
}
|
}
|
||||||
@@ -617,13 +620,25 @@ function buildIndex({
|
|||||||
try {
|
try {
|
||||||
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
|
const db = openIndexDb({ dbPath, schemaPath, DatabaseImpl });
|
||||||
const txDb = betterSqliteTransactionAdapter(db);
|
const txDb = betterSqliteTransactionAdapter(db);
|
||||||
|
const claudeInputMarkerMissing = !db.prepare(
|
||||||
|
'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?',
|
||||||
|
).get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER);
|
||||||
|
const claudeInputSemanticsOutdated = claudeInputMarkerMissing && Boolean(db.prepare(`
|
||||||
|
SELECT 1 FROM messages
|
||||||
|
WHERE COALESCE(source, 'claude') = 'claude'
|
||||||
|
AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
||||||
|
LIMIT 1
|
||||||
|
`).get());
|
||||||
let messageFtsTriggersDropped = false;
|
let messageFtsTriggersDropped = false;
|
||||||
try {
|
try {
|
||||||
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
|
if (preserveDbPath && path.resolve(preserveDbPath) !== path.resolve(dbPath)) {
|
||||||
copyMemoriesFromDb(db, preserveDbPath);
|
copyMemoriesFromDb(db, preserveDbPath);
|
||||||
}
|
}
|
||||||
const files = [
|
const files = [
|
||||||
...discoverJsonlFiles({ projectsDir, changedPaths: force ? undefined : changedPaths }),
|
...discoverJsonlFiles({
|
||||||
|
projectsDir,
|
||||||
|
changedPaths: force || claudeInputSemanticsOutdated ? undefined : changedPaths,
|
||||||
|
}),
|
||||||
...discoverCodexJsonlFiles({ codexDir, changedPaths: force ? undefined : changedPaths }),
|
...discoverCodexJsonlFiles({ codexDir, changedPaths: force ? undefined : changedPaths }),
|
||||||
];
|
];
|
||||||
const latestSourceMtime = files.reduce((latest, file) => {
|
const latestSourceMtime = files.reduce((latest, file) => {
|
||||||
@@ -682,12 +697,15 @@ function buildIndex({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const skipped: SkippedFile[] = [];
|
const skipped: SkippedFile[] = [];
|
||||||
|
let claudeInputMigrationFailed = false;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
// The write is committed before affectedSessionIds is updated, so a
|
// The write is committed before affectedSessionIds is updated, so a
|
||||||
// failed/rolled-back file never reports a phantom updated session.
|
// failed/rolled-back file never reports a phantom updated session.
|
||||||
const indexed = runRetryableWriteTransaction(txDb, () => {
|
const indexed = runRetryableWriteTransaction(txDb, () => {
|
||||||
const result = file.source === 'codex' ? indexCodexFile(db, file) : indexClaudeFile(db, file);
|
const result = file.source === 'codex'
|
||||||
|
? indexCodexFile(db, file)
|
||||||
|
: indexClaudeFile(db, file, { forceFull: claudeInputSemanticsOutdated });
|
||||||
const metaIndexed = file.source !== 'codex' && indexSubagentMeta(db, file);
|
const metaIndexed = file.source !== 'codex' && indexSubagentMeta(db, file);
|
||||||
if (!result?.sessionId && metaIndexed && changedMetaJsonlPaths.has(file.path)) {
|
if (!result?.sessionId && metaIndexed && changedMetaJsonlPaths.has(file.path)) {
|
||||||
return { sessionId: file.sessionId, path: file.path };
|
return { sessionId: file.sessionId, path: file.path };
|
||||||
@@ -706,6 +724,9 @@ function buildIndex({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (hasUnusableTransaction(error)) throw error;
|
if (hasUnusableTransaction(error)) throw error;
|
||||||
|
if (claudeInputSemanticsOutdated && file.source !== 'codex') {
|
||||||
|
claudeInputMigrationFailed = true;
|
||||||
|
}
|
||||||
skipped.push({ path: file.path, error: (error as Error).message, diagnostics: (error as { obelisk?: unknown }).obelisk });
|
skipped.push({ path: file.path, error: (error as Error).message, diagnostics: (error as { obelisk?: unknown }).obelisk });
|
||||||
console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`);
|
console.warn(`Warning: failed to index ${file.path}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -724,6 +745,9 @@ function buildIndex({
|
|||||||
writeIndexMarker(db, '__last_build__');
|
writeIndexMarker(db, '__last_build__');
|
||||||
writeIndexMarker(db, '__app_last_successful_build__');
|
writeIndexMarker(db, '__app_last_successful_build__');
|
||||||
writeIndexMarker(db, '__indexer_owner_app__');
|
writeIndexMarker(db, '__indexer_owner_app__');
|
||||||
|
if (!claudeInputSemanticsOutdated || !claudeInputMigrationFailed) {
|
||||||
|
writeIndexMarker(db, CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER);
|
||||||
|
}
|
||||||
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
|
if (latestSourceMtime) writeIndexMarker(db, '__last_source_mtime__', latestSourceMtime);
|
||||||
}, { label: 'finalize' });
|
}, { label: 'finalize' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 { state, FOLDER_SVG } from '../store.js';
|
||||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||||
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
|
||||||
|
import { getArgPreview, getToolIcon, renderTerminalTool } from '../tool-renderer.js';
|
||||||
import FlapNumber from '../components/FlapNumber.vue';
|
import FlapNumber from '../components/FlapNumber.vue';
|
||||||
|
import {
|
||||||
|
captureSessionViewState,
|
||||||
|
findLastMessageAtOrAbove,
|
||||||
|
reconcileSessionMessages,
|
||||||
|
restoreSessionViewState,
|
||||||
|
} from '../session-view-state.mjs';
|
||||||
import {
|
import {
|
||||||
escapeHTML,
|
escapeHTML,
|
||||||
fmtRelative,
|
fmtRelative,
|
||||||
@@ -27,6 +34,7 @@ const progressPct = ref(0);
|
|||||||
const active = ref(false);
|
const active = ref(false);
|
||||||
let removeSessionUpdated = null;
|
let removeSessionUpdated = null;
|
||||||
let keydownAttached = false;
|
let keydownAttached = false;
|
||||||
|
let scrollRevision = 0;
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
const wrapRef = ref(null);
|
const wrapRef = ref(null);
|
||||||
@@ -119,6 +127,8 @@ onDeactivated(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
active.value = false;
|
active.value = false;
|
||||||
detachKeydown();
|
detachKeydown();
|
||||||
|
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
||||||
|
scrollFrame = null;
|
||||||
removeSessionUpdated?.();
|
removeSessionUpdated?.();
|
||||||
removeSessionUpdated = null;
|
removeSessionUpdated = null;
|
||||||
});
|
});
|
||||||
@@ -135,33 +145,36 @@ watch(() => props.id, async (newId, oldId) => {
|
|||||||
async function loadMessages({ force = false } = {}) {
|
async function loadMessages({ force = false } = {}) {
|
||||||
if (!props.id) return;
|
if (!props.id) return;
|
||||||
const hadContent = messages.value.length > 0;
|
const hadContent = messages.value.length > 0;
|
||||||
const wasAtBottom = hadContent && wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
|
const viewState = hadContent
|
||||||
const prevScrollTop = wrapRef.value?.scrollTop || 0;
|
? captureSessionViewState({ wrap: wrapRef.value, detail: detailRef.value })
|
||||||
|
: null;
|
||||||
|
const scrollRevisionAtLoad = scrollRevision;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = !hadContent;
|
||||||
try {
|
try {
|
||||||
const s = state.sessions.find(x => x.id === props.id);
|
const s = state.sessions.find(x => x.id === props.id);
|
||||||
if (s && (force || !s.messages || s.messages.length === 0)) {
|
if (s && (force || !s.messages || s.messages.length === 0)) {
|
||||||
const loaded = await loadSessionDetail(props.id);
|
await loadSessionDetail(props.id);
|
||||||
if (loaded) Object.assign(s, loaded);
|
|
||||||
}
|
}
|
||||||
const latest = state.sessions.find(x => x.id === 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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
await nextTick();
|
||||||
if (!wrapRef.value) return;
|
syncTotalMessages();
|
||||||
if (!state.pendingFocusUuid) {
|
if (!state.pendingFocusUuid) {
|
||||||
if (wasAtBottom) {
|
restoreSessionViewState(viewState, {
|
||||||
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
|
wrap: wrapRef.value,
|
||||||
} else {
|
detail: detailRef.value,
|
||||||
wrapRef.value.scrollTop = prevScrollTop;
|
restoreScroll: scrollRevision === scrollRevisionAtLoad,
|
||||||
}
|
|
||||||
}
|
|
||||||
onScroll();
|
|
||||||
});
|
});
|
||||||
|
onScroll();
|
||||||
|
}
|
||||||
|
|
||||||
// Focus pending uuid if any
|
// Focus pending uuid if any
|
||||||
if (state.pendingFocusUuid) {
|
if (state.pendingFocusUuid) {
|
||||||
@@ -192,22 +205,36 @@ async function focusPendingMessage() {
|
|||||||
const currentMsgIdx = ref(0);
|
const currentMsgIdx = ref(0);
|
||||||
const totalMsgs = ref(0);
|
const totalMsgs = ref(0);
|
||||||
let navLock = false;
|
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 (navLock) return;
|
||||||
|
if (scrollFrame !== null) return;
|
||||||
|
scrollFrame = requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
|
updateScrollProgress();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollProgress() {
|
||||||
if (!wrapRef.value || !detailRef.value) return;
|
if (!wrapRef.value || !detailRef.value) return;
|
||||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||||
if (!msgs.length) return;
|
if (!msgs.length) {
|
||||||
totalMsgs.value = msgs.length;
|
currentMsgIdx.value = 0;
|
||||||
|
progressPct.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const el = wrapRef.value;
|
const el = wrapRef.value;
|
||||||
const navHeight = 52;
|
const navHeight = 52;
|
||||||
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
||||||
let bottomMsgIdx = 0;
|
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
|
||||||
for (let i = 0; i < msgs.length; i++) {
|
|
||||||
if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
currentMsgIdx.value = bottomMsgIdx;
|
currentMsgIdx.value = bottomMsgIdx;
|
||||||
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
|
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
|
||||||
progressPct.value = pct;
|
progressPct.value = pct;
|
||||||
@@ -283,27 +310,6 @@ function navigateToSubagent(agentId, description) {
|
|||||||
|
|
||||||
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
// --- 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) {
|
function formatToolInput(tc) {
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(tc.input_json || '{}');
|
const j = JSON.parse(tc.input_json || '{}');
|
||||||
@@ -317,17 +323,6 @@ function escapeH(s) {
|
|||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
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) {
|
function renderPrettyTool(tc) {
|
||||||
let args;
|
let args;
|
||||||
try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; }
|
try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; }
|
||||||
@@ -367,10 +362,8 @@ function renderPrettyTool(tc) {
|
|||||||
return diff + chip;
|
return diff + chip;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tc.name === 'Bash') {
|
const terminal = renderTerminalTool(tc.name, args, out, isError);
|
||||||
const desc = args.description ? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeH(args.description)}</div>` : '';
|
if (terminal !== null) return terminal;
|
||||||
return desc + renderTerminal(args.command || '', out, isError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>` +
|
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>` : '');
|
(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>`;
|
</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) {
|
function renderFieldGrid(obj) {
|
||||||
const entries = Object.entries(obj);
|
const entries = Object.entries(obj);
|
||||||
if (!entries.length) return '';
|
if (!entries.length) return '';
|
||||||
@@ -642,7 +625,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
<!-- Meta messages: collapsed system indicator -->
|
<!-- Meta messages: collapsed system indicator -->
|
||||||
<template v-if="msg.is_meta === 1">
|
<template v-if="msg.is_meta === 1">
|
||||||
<div class="msg meta" :data-uuid="msg.uuid">
|
<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">
|
<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>
|
<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>
|
<span class="meta-label">System</span>
|
||||||
@@ -708,7 +691,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
<div class="msg-tools">
|
<div class="msg-tools">
|
||||||
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
||||||
<!-- Render non-workflow tool calls -->
|
<!-- 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">
|
<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>
|
<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>
|
<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) -->
|
<!-- 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">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
@@ -766,7 +749,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
<!-- Standalone thinking message -->
|
<!-- Standalone thinking message -->
|
||||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||||
<div class="msg assistant" :data-uuid="msg.uuid">
|
<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">
|
<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>
|
<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>
|
<span class="thinking-label">Thinking</span>
|
||||||
@@ -790,7 +773,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
<!-- 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">
|
<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>
|
<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>
|
<span class="thinking-label">Thinking</span>
|
||||||
@@ -825,7 +808,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Agent/Task tool call (subagent) -->
|
<!-- Agent/Task tool call (subagent) -->
|
||||||
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
|
<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">
|
<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>
|
<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>
|
<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) -->
|
<!-- Workflow tool call (inside assistant bubble) -->
|
||||||
<template v-else-if="tc.name === 'Workflow'">
|
<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">
|
<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>
|
<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>
|
<span class="tool-name">Workflow</span>
|
||||||
@@ -900,7 +883,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
|
|
||||||
<!-- Generic tool call -->
|
<!-- Generic tool call -->
|
||||||
<template v-else>
|
<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">
|
<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>
|
<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>
|
<span v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||||
@@ -931,7 +914,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary block -->
|
<!-- 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">
|
<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>
|
<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>
|
<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.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); }
|
.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 */
|
||||||
.terminal-view {
|
.terminal-view {
|
||||||
border: 1px solid var(--hairline); border-radius: 5px;
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { persist } from './persist.ts';
|
|||||||
import { nodeSqliteTransactionAdapter } from './tx.ts';
|
import { nodeSqliteTransactionAdapter } from './tx.ts';
|
||||||
import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
|
import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
|
||||||
import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts';
|
import { runRetryableWriteTransaction, isBeginBusyFailure, hasUnusableTransaction } from './write-coordinator.ts';
|
||||||
import { parse as claudeParse } from './providers/claude.ts';
|
import {
|
||||||
|
CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER,
|
||||||
|
parse as claudeParse,
|
||||||
|
} from './providers/claude.ts';
|
||||||
import { parse as codexParse } from './providers/codex.ts';
|
import { parse as codexParse } from './providers/codex.ts';
|
||||||
import type { Cursor, IndexRecord } from './providers/types.ts';
|
import type { Cursor, IndexRecord } from './providers/types.ts';
|
||||||
import type { ClaudeJsonlFile } from './parsing.ts';
|
import type { ClaudeJsonlFile } from './parsing.ts';
|
||||||
@@ -210,7 +213,17 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
|
|||||||
|
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
const txDb = nodeSqliteTransactionAdapter(db);
|
const txDb = nodeSqliteTransactionAdapter(db);
|
||||||
|
const claudeInputMarkerMissing = !db.prepare(
|
||||||
|
'SELECT jsonl_path FROM index_state WHERE jsonl_path = ?',
|
||||||
|
).get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER);
|
||||||
|
const claudeInputSemanticsOutdated = claudeInputMarkerMissing && Boolean(db.prepare(`
|
||||||
|
SELECT 1 FROM messages
|
||||||
|
WHERE COALESCE(source, 'claude') = 'claude'
|
||||||
|
AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
||||||
|
LIMIT 1
|
||||||
|
`).get());
|
||||||
const skippedFiles: SkippedFile[] = [];
|
const skippedFiles: SkippedFile[] = [];
|
||||||
|
let claudeInputMigrationFailed = false;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
if (force) {
|
if (force) {
|
||||||
@@ -261,9 +274,10 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
|
|||||||
// (docs/adr/0001). needsReindex keeps the "skip unchanged file" fast path;
|
// (docs/adr/0001). needsReindex keeps the "skip unchanged file" fast path;
|
||||||
// the cursor's line count drives incremental resume inside parse().
|
// the cursor's line count drives incremental resume inside parse().
|
||||||
const { needed, skip } = needsReindex(db, f.path);
|
const { needed, skip } = needsReindex(db, f.path);
|
||||||
if (needed) {
|
if (needed || claudeInputSemanticsOutdated) {
|
||||||
const unit = { key: f.path, sessionId: f.sessionId, project: f.project, isSubagent: f.isSubagent, agentId: f.agentId };
|
const unit = { key: f.path, sessionId: f.sessionId, project: f.project, isSubagent: f.isSubagent, agentId: f.agentId };
|
||||||
persist(db, unit, claudeParse(unit, skip > 0 ? `0:${skip}` : null));
|
const cursor = !claudeInputSemanticsOutdated && skip > 0 ? `0:${skip}` : null;
|
||||||
|
persist(db, unit, claudeParse(unit, cursor));
|
||||||
}
|
}
|
||||||
indexSubagentMeta(db, f);
|
indexSubagentMeta(db, f);
|
||||||
}
|
}
|
||||||
@@ -273,6 +287,9 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
|
|||||||
return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles };
|
return { skip: true, reason: 'database_busy', skipped: skippedFiles.length, skippedFiles };
|
||||||
}
|
}
|
||||||
if (hasUnusableTransaction(e)) throw e;
|
if (hasUnusableTransaction(e)) throw e;
|
||||||
|
if (claudeInputSemanticsOutdated && f.source !== 'codex') {
|
||||||
|
claudeInputMigrationFailed = true;
|
||||||
|
}
|
||||||
// A per-file failure is skippable: log and move on.
|
// A per-file failure is skippable: log and move on.
|
||||||
const error = e as { message?: unknown; obelisk?: unknown } | null;
|
const error = e as { message?: unknown; obelisk?: unknown } | null;
|
||||||
const message = errorMessage(e);
|
const message = errorMessage(e);
|
||||||
@@ -291,6 +308,10 @@ function buildIndex({ force = false }: { force?: boolean } = {}) {
|
|||||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||||
rebuildMemoryFts(db);
|
rebuildMemoryFts(db);
|
||||||
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
|
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
|
||||||
|
if (!claudeInputSemanticsOutdated || !claudeInputMigrationFailed) {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)')
|
||||||
|
.run(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER, Date.now());
|
||||||
|
}
|
||||||
}, { label: 'finalize' });
|
}, { label: 'finalize' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isBeginBusyFailure(error)) {
|
if (isBeginBusyFailure(error)) {
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ function cursorToSkip(cursor: Cursor): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const name = 'claude';
|
export const name = 'claude';
|
||||||
|
export const CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER = '__claude_input_tokens_include_cache_v1__';
|
||||||
|
|
||||||
|
function totalInputTokens(usage: Record<string, unknown>): number | null {
|
||||||
|
const fields = [
|
||||||
|
'input_tokens',
|
||||||
|
'cache_creation_input_tokens',
|
||||||
|
'cache_read_input_tokens',
|
||||||
|
];
|
||||||
|
let seen = false;
|
||||||
|
let total = 0;
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = usage[field];
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
||||||
|
seen = true;
|
||||||
|
total += value;
|
||||||
|
}
|
||||||
|
return seen ? total : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function discover(_ctx: DiscoverContext): IndexUnit[] {
|
export function discover(_ctx: DiscoverContext): IndexUnit[] {
|
||||||
return discoverJsonlFiles().map((f: any) => ({
|
return discoverJsonlFiles().map((f: any) => ({
|
||||||
@@ -92,7 +110,7 @@ export function* parse(unit: IndexUnit, cursor: Cursor): Generator<IndexRecord,
|
|||||||
parent_uuid: obj.parentUuid || null, timestamp: ts, role: msg.role || obj.type,
|
parent_uuid: obj.parentUuid || null, timestamp: ts, role: msg.role || obj.type,
|
||||||
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), model: msg.model || null,
|
text, content_type: contentType, is_meta: (isMeta ? 1 : 0), model: msg.model || null,
|
||||||
is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid,
|
is_sidechain: obj.isSidechain ? 1 : 0, agent_id: aid,
|
||||||
input_tokens: usage.input_tokens || null, output_tokens: usage.output_tokens || null,
|
input_tokens: totalInputTokens(usage), output_tokens: usage.output_tokens || null,
|
||||||
cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude',
|
cwd: obj.cwd || null, skill: obj.attributionSkill || null, source: 'claude',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface MessageRecord {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
is_sidechain: 0 | 1;
|
is_sidechain: 0 | 1;
|
||||||
agent_id: string | null;
|
agent_id: string | null;
|
||||||
|
/** Provider-normalized total input, including provider-reported cached input. */
|
||||||
input_tokens: number | null;
|
input_tokens: number | null;
|
||||||
output_tokens: number | null;
|
output_tokens: number | null;
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
import { buildIndex } from '../app/src/main/indexer.ts';
|
import { buildIndex } from '../app/src/main/indexer.ts';
|
||||||
|
import { CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER } from '../packages/core/src/providers/claude.ts';
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
class TestDatabase {
|
class TestDatabase {
|
||||||
@@ -89,6 +90,61 @@ test('app indexer records build success without claiming daemon ownership', () =
|
|||||||
db2.close();
|
db2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('app indexer refreshes unchanged Claude usage when input token semantics change', () => {
|
||||||
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-token-semantics-'));
|
||||||
|
const claudeDir = join(home, '.claude');
|
||||||
|
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
|
||||||
|
mkdirSync(projectDir, { recursive: true });
|
||||||
|
const sessionId = 'session-token-semantics-1';
|
||||||
|
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
writeFileSync(jsonlPath, [
|
||||||
|
JSON.stringify({
|
||||||
|
uuid: 'msg-token-semantics-1',
|
||||||
|
type: 'assistant',
|
||||||
|
timestamp: '2026-06-13T10:00:00Z',
|
||||||
|
cwd: '/tmp/obelisk-app',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'cached response' }],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 5,
|
||||||
|
cache_creation_input_tokens: 20,
|
||||||
|
cache_read_input_tokens: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const dbPath = join(claudeDir, 'obelisk.sqlite');
|
||||||
|
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||||
|
|
||||||
|
const stale = new TestDatabase(dbPath);
|
||||||
|
stale.prepare('UPDATE messages SET input_tokens = 10 WHERE uuid = ?').run('msg-token-semantics-1');
|
||||||
|
stale.prepare('DELETE FROM index_state WHERE jsonl_path = ?')
|
||||||
|
.run(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER);
|
||||||
|
stale.close();
|
||||||
|
|
||||||
|
buildIndex({
|
||||||
|
claudeDir,
|
||||||
|
dbPath,
|
||||||
|
DatabaseImpl: TestDatabase,
|
||||||
|
changedPaths: [`-tmp-obelisk-app/${sessionId}.jsonl`],
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = new TestDatabase(dbPath);
|
||||||
|
assert.equal(
|
||||||
|
refreshed.prepare('SELECT input_tokens FROM messages WHERE uuid = ?').get('msg-token-semantics-1').input_tokens,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
refreshed.prepare('SELECT jsonl_path FROM index_state WHERE jsonl_path = ?')
|
||||||
|
.get(CLAUDE_INPUT_TOKEN_SEMANTICS_MARKER),
|
||||||
|
);
|
||||||
|
refreshed.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('force rebuild ignores stale JSONL index_state rows after session tables were cleared', () => {
|
test('force rebuild ignores stale JSONL index_state rows after session tables were cleared', () => {
|
||||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-'));
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-'));
|
||||||
const claudeDir = join(home, '.claude');
|
const claudeDir = join(home, '.claude');
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { getArgPreview, getToolIcon, renderTerminalTool } from '../app/src/renderer/src/tool-renderer.js';
|
||||||
|
|
||||||
|
test('Codex exec renders source and decoded result instead of a Bash terminal', () => {
|
||||||
|
const output = JSON.stringify([
|
||||||
|
{ type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' },
|
||||||
|
{ type: 'input_text', text: 'first line\nsecond line' },
|
||||||
|
]);
|
||||||
|
const html = renderTerminalTool('exec', 'const value = await tools.read();', output, false);
|
||||||
|
|
||||||
|
assert.match(html, /class="codeact-view is-complete"/);
|
||||||
|
assert.match(html, />Source</);
|
||||||
|
assert.match(html, /codeact-token keyword">const</);
|
||||||
|
assert.match(html, /codeact-token keyword">await</);
|
||||||
|
assert.match(html, /codeact-token global">tools</);
|
||||||
|
assert.doesNotMatch(html, />JavaScript</);
|
||||||
|
assert.match(html, />Result</);
|
||||||
|
assert.match(html, /first line\nsecond line/);
|
||||||
|
assert.doesNotMatch(html, />Completed</);
|
||||||
|
assert.doesNotMatch(html, /codeact-status-dot/);
|
||||||
|
assert.doesNotMatch(html, /0\.1 seconds/);
|
||||||
|
assert.doesNotMatch(html, /terminal-view|input_text|Script completed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec formats consecutive JSON result blocks independently', () => {
|
||||||
|
const output = JSON.stringify([
|
||||||
|
{ type: 'input_text', text: 'Script completed\nWall time 0.2 seconds\nOutput:\n' },
|
||||||
|
{ type: 'input_text', text: JSON.stringify({ exit_code: 0, output: 'first\nline' }) },
|
||||||
|
{ type: 'input_text', text: JSON.stringify({ exit_code: 1, output: 'second\nline' }) },
|
||||||
|
]);
|
||||||
|
const html = renderTerminalTool('exec', 'await task();', output, false);
|
||||||
|
const visibleText = html.replace(/<[^>]+>/g, '');
|
||||||
|
const resultBlockCount = html.match(/class="codeact-result-block/g)?.length || 0;
|
||||||
|
|
||||||
|
assert.equal(resultBlockCount, 2);
|
||||||
|
assert.match(visibleText, /\{\n {2}"exit_code": 0,\n {2}"output": "first\\nline"\n\}/);
|
||||||
|
assert.match(visibleText, /\{\n {2}"exit_code": 1,\n {2}"output": "second\\nline"\n\}/);
|
||||||
|
assert.match(html, /codeact-json-token key">"exit_code"</);
|
||||||
|
assert.match(html, /codeact-json-token number">0</);
|
||||||
|
assert.doesNotMatch(visibleText, /\{"exit_code":/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Claude Bash continues to use terminal rendering', () => {
|
||||||
|
const html = renderTerminalTool('Bash', { command: 'echo hello' }, 'hello\n', false);
|
||||||
|
|
||||||
|
assert.match(html, /class="terminal-view"/);
|
||||||
|
assert.match(html, /echo hello/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec decodes a truncated input_text envelope without inventing missing output', () => {
|
||||||
|
const truncated = '[{"type":"input_text","text":"Script completed\\nWall time 0.2 seconds\\nOutput:\\n"},{"type":"input_text","text":"line 1\\nline 2';
|
||||||
|
const html = renderTerminalTool('exec', 'text(result);', truncated, false);
|
||||||
|
|
||||||
|
assert.match(html, /line 1\nline 2/);
|
||||||
|
assert.match(html, /Indexed output truncated/);
|
||||||
|
assert.doesNotMatch(html, /\\n|input_text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec derives failed and running states from the captured result', () => {
|
||||||
|
const failed = JSON.stringify([
|
||||||
|
{ type: 'input_text', text: 'Script failed\nWall time 9.4 seconds\nOutput:\n' },
|
||||||
|
{ type: 'input_text', text: 'Script error:\nExit code: 1' },
|
||||||
|
]);
|
||||||
|
const running = 'Script running with cell ID 128\nWall time 10.0 seconds\nOutput:\n';
|
||||||
|
|
||||||
|
const failedHtml = renderTerminalTool('exec', 'await task();', failed, false);
|
||||||
|
const runningHtml = renderTerminalTool('exec', 'await task();', running, false);
|
||||||
|
|
||||||
|
assert.match(failedHtml, /class="codeact-view is-failed"/);
|
||||||
|
assert.match(failedHtml, />Failed</);
|
||||||
|
assert.match(runningHtml, /class="codeact-view is-running"/);
|
||||||
|
assert.match(runningHtml, />Running</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec keeps large payloads in a bounded DOM structure', () => {
|
||||||
|
const source = 'const value = await tools.read();\n'.repeat(300);
|
||||||
|
const output = JSON.stringify([
|
||||||
|
{ type: 'input_text', text: 'Script completed\nWall time 0.3 seconds\nOutput:\n' },
|
||||||
|
{ type: 'input_text', text: 'x'.repeat(9_000) },
|
||||||
|
]);
|
||||||
|
const html = renderTerminalTool('exec', source, output, false);
|
||||||
|
const spanCount = html.match(/<span\b/g)?.length || 0;
|
||||||
|
const preCount = html.match(/<pre\b/g)?.length || 0;
|
||||||
|
|
||||||
|
assert.ok(spanCount < source.length / 4, `expected token-level markup, received ${spanCount} spans`);
|
||||||
|
assert.equal(preCount, 3);
|
||||||
|
assert.doesNotMatch(html, /code-char|ansi-char/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec syntax highlighting escapes source content', () => {
|
||||||
|
const source = 'const value = "<script>"; // not markup';
|
||||||
|
const output = JSON.stringify([
|
||||||
|
{ type: 'input_text', text: 'Script completed\nWall time 0.1 seconds\nOutput:\n' },
|
||||||
|
{ type: 'input_text', text: JSON.stringify({ html: '<script>result</script>' }) },
|
||||||
|
]);
|
||||||
|
const html = renderTerminalTool('exec', source, output, false);
|
||||||
|
|
||||||
|
assert.match(html, /codeact-token string">"<script>"</);
|
||||||
|
assert.match(html, /codeact-token comment">\/\/ not markup</);
|
||||||
|
assert.match(html, /codeact-json-token string">"<script>result<\/script>"</);
|
||||||
|
assert.doesNotMatch(html, /<script>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec preview uses its string input', () => {
|
||||||
|
assert.equal(getArgPreview({ input_json: JSON.stringify('echo hello') }), 'echo hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codex exec uses the Claude Bash terminal icon', () => {
|
||||||
|
assert.equal(getToolIcon('exec'), getToolIcon('Bash'));
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ function writeFixture() {
|
|||||||
const lines = [
|
const lines = [
|
||||||
{ type: 'ai-title', aiTitle: 'My Session' },
|
{ type: 'ai-title', aiTitle: 'My Session' },
|
||||||
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', gitBranch: 'main', message: { role: 'user', content: 'hi' } },
|
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', gitBranch: 'main', message: { role: 'user', content: 'hi' } },
|
||||||
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'claude-opus', content: [{ type: 'text', text: 'ok' }, { type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }] } },
|
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'claude-opus', content: [{ type: 'text', text: 'ok' }, { type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }], usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 20, cache_read_input_tokens: 30 } } },
|
||||||
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 1234 },
|
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 1234 },
|
||||||
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'file body', is_error: false }] } },
|
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'file body', is_error: false }] } },
|
||||||
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'a summary' },
|
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'a summary' },
|
||||||
@@ -42,6 +42,12 @@ test('claude parse() yields the expected record stream for a main session', () =
|
|||||||
// Three user/assistant messages, correct order and fields.
|
// Three user/assistant messages, correct order and fields.
|
||||||
assert.deepEqual(byKind('message').map(m => m.uuid), ['u1', 'a1', 'u2']);
|
assert.deepEqual(byKind('message').map(m => m.uuid), ['u1', 'a1', 'u2']);
|
||||||
assert.equal(byKind('message').find(m => m.uuid === 'a1').model, 'claude-opus');
|
assert.equal(byKind('message').find(m => m.uuid === 'a1').model, 'claude-opus');
|
||||||
|
assert.deepEqual(
|
||||||
|
(({ input_tokens, output_tokens }) => ({ input_tokens, output_tokens }))(
|
||||||
|
byKind('message').find(m => m.uuid === 'a1'),
|
||||||
|
),
|
||||||
|
{ input_tokens: 60, output_tokens: 5 },
|
||||||
|
);
|
||||||
assert.equal(byKind('message').every(m => m.source === 'claude'), true);
|
assert.equal(byKind('message').every(m => m.source === 'claude'), true);
|
||||||
|
|
||||||
// Tool call + tool result extracted.
|
// Tool call + tool result extracted.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fixtureUnit() {
|
|||||||
const lines = [
|
const lines = [
|
||||||
{ type: 'ai-title', aiTitle: 'Persist Session' },
|
{ type: 'ai-title', aiTitle: 'Persist Session' },
|
||||||
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', message: { role: 'user', content: 'hi' } },
|
{ uuid: 'u1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/proj', message: { role: 'user', content: 'hi' } },
|
||||||
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'm', content: [{ type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }] } },
|
{ uuid: 'a1', type: 'assistant', timestamp: '2026-06-10T10:00:05Z', message: { role: 'assistant', model: 'm', content: [{ type: 'tool_use', id: 'tc1', name: 'Read', input: { file_path: '/f' } }], usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 20, cache_read_input_tokens: 30 } } },
|
||||||
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 999 },
|
{ type: 'system', subtype: 'turn_duration', parentUuid: 'a1', durationMs: 999 },
|
||||||
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'body', is_error: false }] } },
|
{ uuid: 'u2', type: 'user', timestamp: '2026-06-10T10:00:10Z', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tc1', content: 'body', is_error: false }] } },
|
||||||
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'sum' },
|
{ type: 'system', subtype: 'away_summary', uuid: 's1', timestamp: '2026-06-10T10:00:11Z', content: 'sum' },
|
||||||
@@ -56,6 +56,9 @@ test('persist writes all record kinds from one claude parse', () => {
|
|||||||
|
|
||||||
// turn_duration applied via targeted UPDATE.
|
// turn_duration applied via targeted UPDATE.
|
||||||
assert.equal(db.prepare('SELECT turn_duration_ms FROM messages WHERE uuid=?').get('a1').turn_duration_ms, 999);
|
assert.equal(db.prepare('SELECT turn_duration_ms FROM messages WHERE uuid=?').get('a1').turn_duration_ms, 999);
|
||||||
|
const usage = db.prepare('SELECT input_tokens, output_tokens FROM messages WHERE uuid=?').get('a1');
|
||||||
|
assert.equal(usage.input_tokens, 60);
|
||||||
|
assert.equal(usage.output_tokens, 5);
|
||||||
|
|
||||||
// Cursor persisted into index_state (mtime:lines → two columns).
|
// Cursor persisted into index_state (mtime:lines → two columns).
|
||||||
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key);
|
const state = db.prepare('SELECT lines_processed FROM index_state WHERE jsonl_path=?').get(unit.key);
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
captureSessionViewState,
|
||||||
|
findLastMessageAtOrAbove,
|
||||||
|
reconcileSessionMessages,
|
||||||
|
restoreSessionViewState,
|
||||||
|
} from '../app/src/renderer/src/session-view-state.mjs';
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
constructor(classes = []) { this.classes = new Set(classes); }
|
||||||
|
add(...classes) { for (const value of classes) this.classes.add(value); }
|
||||||
|
contains(value) { return this.classes.has(value); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function disclosure(key, classes = [], { rawOpen = false } = {}) {
|
||||||
|
const raw = { classList: new FakeClassList(rawOpen ? ['show'] : []) };
|
||||||
|
const pretty = { classList: new FakeClassList() };
|
||||||
|
const button = { classList: new FakeClassList() };
|
||||||
|
return {
|
||||||
|
dataset: { viewKey: key },
|
||||||
|
classList: new FakeClassList(classes),
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '.toolcall-raw') return raw;
|
||||||
|
if (selector === '.toolcall-pretty') return pretty;
|
||||||
|
if (selector === '.raw-toggle') return button;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
raw,
|
||||||
|
pretty,
|
||||||
|
button,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollItem(uuid, top, bottom) {
|
||||||
|
return {
|
||||||
|
dataset: { uuid },
|
||||||
|
getBoundingClientRect: () => ({ top, bottom }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail(disclosures, scrollItems) {
|
||||||
|
return {
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
if (selector === '[data-view-key]') return disclosures;
|
||||||
|
if (selector === '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]') return scrollItems;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) {
|
||||||
|
return {
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
getBoundingClientRect: () => ({ top }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionSource(source, name) {
|
||||||
|
const start = source.indexOf(`function ${name}(`);
|
||||||
|
assert.notEqual(start, -1, `${name} should exist`);
|
||||||
|
const signatureEnd = source.indexOf(') {', start);
|
||||||
|
assert.notEqual(signatureEnd, -1, `${name} should have a function body`);
|
||||||
|
const bodyStart = signatureEnd + 2;
|
||||||
|
let depth = 0;
|
||||||
|
for (let index = bodyStart; index < source.length; index++) {
|
||||||
|
if (source[index] === '{') depth++;
|
||||||
|
if (source[index] === '}') depth--;
|
||||||
|
if (depth === 0) return source.slice(start, index + 1);
|
||||||
|
}
|
||||||
|
assert.fail(`${name} should have a complete function body`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('session refresh restores disclosure state and the visible scroll anchor', () => {
|
||||||
|
const oldTool = disclosure('tool:call-1', ['open'], { rawOpen: true });
|
||||||
|
const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 });
|
||||||
|
const snapshot = captureSessionViewState({
|
||||||
|
wrap: oldWrap,
|
||||||
|
detail: detail([oldTool], [scrollItem('msg-1', -20, 180)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTool = disclosure('tool:call-1');
|
||||||
|
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
|
||||||
|
restoreSessionViewState(snapshot, {
|
||||||
|
wrap: newWrap,
|
||||||
|
detail: detail([newTool], [scrollItem('msg-1', 80, 280)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(newTool.classList.contains('open'), true);
|
||||||
|
assert.equal(newTool.raw.classList.contains('show'), true);
|
||||||
|
assert.equal(newTool.pretty.classList.contains('hidden'), true);
|
||||||
|
assert.equal(newTool.button.classList.contains('active'), true);
|
||||||
|
assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session refresh follows appended content only when already at the tail', () => {
|
||||||
|
const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 });
|
||||||
|
const snapshot = captureSessionViewState({
|
||||||
|
wrap: oldWrap,
|
||||||
|
detail: detail([], [scrollItem('msg-last', 300, 590)]),
|
||||||
|
});
|
||||||
|
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 });
|
||||||
|
|
||||||
|
restoreSessionViewState(snapshot, {
|
||||||
|
wrap: newWrap,
|
||||||
|
detail: detail([], [scrollItem('msg-last', 300, 590)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(newWrap.scrollTop, 2400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session refresh never restores an old anchor over newer user scrolling', () => {
|
||||||
|
const oldTool = disclosure('tool:call-1', ['open']);
|
||||||
|
const snapshot = captureSessionViewState({
|
||||||
|
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
|
||||||
|
detail: detail([oldTool], [scrollItem('msg-1', -20, 180)]),
|
||||||
|
});
|
||||||
|
const newTool = disclosure('tool:call-1');
|
||||||
|
const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 });
|
||||||
|
|
||||||
|
restoreSessionViewState(snapshot, {
|
||||||
|
wrap: userScrolledWrap,
|
||||||
|
detail: detail([newTool], [scrollItem('msg-1', 80, 280)]),
|
||||||
|
restoreScroll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(newTool.classList.contains('open'), true, 'disclosure state still restores');
|
||||||
|
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('message reconciliation preserves existing identities and appends the tail', () => {
|
||||||
|
const first = { uuid: 'm1', text: 'old', tool_calls: [{ id: 't1' }] };
|
||||||
|
const current = [first];
|
||||||
|
const incoming = [
|
||||||
|
{ uuid: 'm1', text: 'updated', tool_calls: [{ id: 't1', result: { content: 'progress' } }] },
|
||||||
|
{ uuid: 'm2', text: 'new tail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const reconciled = reconcileSessionMessages(current, incoming);
|
||||||
|
|
||||||
|
assert.equal(reconciled[0], first);
|
||||||
|
assert.equal(reconciled[0].text, 'updated');
|
||||||
|
assert.equal(reconciled[0].tool_calls[0].result.content, 'progress');
|
||||||
|
assert.equal(reconciled[1], incoming[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scroll progress locates the visible message without scanning the full session', () => {
|
||||||
|
let layoutReads = 0;
|
||||||
|
const messages = Array.from({ length: 2048 }, (_, index) => ({
|
||||||
|
getBoundingClientRect() {
|
||||||
|
layoutReads++;
|
||||||
|
return { bottom: (index + 1) * 20 };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(findLastMessageAtOrAbove(messages, 20100), 1004);
|
||||||
|
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SessionDetail integrates view-state capture and restore into live reloads', () => {
|
||||||
|
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /captureSessionViewState/);
|
||||||
|
assert.match(source, /reconcileSessionMessages/);
|
||||||
|
assert.match(source, /restoreSessionViewState/);
|
||||||
|
assert.match(source, /loading\.value\s*=\s*!hadContent/);
|
||||||
|
assert.match(source, /scrollRevision/);
|
||||||
|
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionAtLoad/);
|
||||||
|
assert.match(source, /requestAnimationFrame/);
|
||||||
|
assert.match(source, /findLastMessageAtOrAbove/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live totals and scroll position remain isolated across interleaved updates', () => {
|
||||||
|
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
||||||
|
const loadMessages = functionSource(source, 'loadMessages');
|
||||||
|
const syncTotalMessages = functionSource(source, 'syncTotalMessages');
|
||||||
|
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
|
||||||
|
|
||||||
|
assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTotalMessages\(\)/);
|
||||||
|
assert.match(syncTotalMessages, /totalMsgs\.value\s*=/);
|
||||||
|
assert.doesNotMatch(syncTotalMessages, /currentMsgIdx\.value\s*=/);
|
||||||
|
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
|
||||||
|
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user