feat: BooCode 2.0 UI — Ember theme, brand banner, coder tabs, model-attribution chips

- Ember theme (Obsidian charcoal + #ff7a18 orange), now DEFAULT_THEME_ID; server theme_id whitelist gains 'ember'
- Brand banner: transparent Westie mascot + >_BooCode wordmark, big/edge-to-edge (flood-filled to transparency + cropped)
- Coder panes are multi-tab: + opens a BooCode tab, split opens a pane (shared ChatTabBar via tabKind + createCoderTab; closeOtherTabs/tab-numbering extended to coder)
- Model-attribution: new messages.model column stamped at finalizeCompletion (BooChat/native coder) + dispatcher assistant-row creation (external coder); surfaced via view + wire types + live frame; rendered as a subtle shortened-name chip (shortenModelName)
- Composer Web toggle moved into a boxed focus-ringed input; glowing accent dot on tool rows
- Claude SDK follow-ups (1M context, follow-up-message fix, collapsed thinking/tool chips) + CLAUDE_SDK_BACKEND=1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:30:47 +00:00
parent 7098014261
commit 3a646fd6df
34 changed files with 482 additions and 163 deletions

View File

@@ -82,6 +82,12 @@ export interface PromptCtx {
export interface TurnResult {
ok: boolean;
error?: string;
// Optional context-window telemetry (claude SDK): the model's reported window
// (ctxMax, 1M-aware) and the peak request input ≈ current fill (ctxUsed). The
// dispatcher writes these onto the assistant message so the ContextBar renders a
// real fill for the turn. Omitted by backends that don't report a window.
ctxUsed?: number;
ctxMax?: number;
}
/**

View File

@@ -165,6 +165,12 @@ export class ClaudeSdkBackend implements AgentBackend {
// Stream partial assistant messages so text/thinking/tool deltas arrive live
// (the mapper reads them; without this only terminal messages land).
includePartialMessages: true,
// BooCode default: enable the documented 1M-context-window beta. Active on
// models that support it (the SDK lists Sonnet 4/4.5); a non-supporting model
// simply doesn't get the larger window. The TRUE window is read back from
// `result.modelUsage[*].contextWindow` and shown in the ContextBar, so whatever
// window a model actually gets is surfaced truthfully (no guessing).
betas: ['context-1m-2025-08-07'],
...(model ? { model } : {}),
...(resumeId ? { resume: resumeId } : {}),
...(this.installPath ? { pathToClaudeCodeExecutable: this.installPath } : {}),
@@ -192,6 +198,11 @@ export class ClaudeSdkBackend implements AgentBackend {
this.busy = true;
const state: ClaudeSdkMapState = createClaudeSdkMapState();
// Peak per-request input (incl. cache) across the turn ≈ the conversation context
// held in the window. result.usage SUMS input over the turn's internal requests
// (overcounts for multi-tool turns), so the per-request peak is the accurate
// "context used" for the ContextBar (paseo's approach).
let maxInputTokens = 0;
// Per-turn abort: interrupt the in-flight query on the SAME generator (never
// tear down the warm query — that's the pool's lifetime). The generator then
// emits its terminal result and the drain loop exits.
@@ -214,7 +225,32 @@ export class ClaudeSdkBackend implements AgentBackend {
queue.push(userMsg);
try {
for await (const msg of gen) {
// Manual iteration — NOT `for await (… of gen)`. Returning out of a for-await
// loop calls gen.return(), which CLOSES the async generator; that killed the
// warm streaming-input query after a single turn, so every FOLLOW-UP message
// hit a dead generator and failed. gen.next() leaves the generator suspended
// (alive) for the next pushed user message — the warm query is only closed
// deliberately in teardownQuery()/dispose().
while (true) {
const next = await gen.next();
if (next.done) {
// Generator ended (e.g. disposed) without a result — non-fatal incomplete.
if (aborted) return { ok: false, error: 'aborted' };
return { ok: false, error: 'claude-sdk: query ended before result' };
}
const msg = next.value;
// Track the peak per-request input from message_start usage (delivered by
// includePartialMessages) — the largest single request's input is the real
// context fill, unlike the summed result.usage.
if (msg.type === 'stream_event') {
const sev = msg.event as { type?: string; message?: { usage?: Record<string, unknown> } };
if (sev?.type === 'message_start' && sev.message?.usage) {
const ru = sev.message.usage;
const reqInput =
num(ru.input_tokens) + num(ru.cache_read_input_tokens) + num(ru.cache_creation_input_tokens);
if (reqInput > maxInputTokens) maxInputTokens = reqInput;
}
}
// Capture the provider session id from the init message (authoritative).
if (msg.type === 'system' && msg.subtype === 'init' && msg.session_id) {
if (this.agentSessionId !== msg.session_id) {
@@ -234,19 +270,28 @@ export class ClaudeSdkBackend implements AgentBackend {
await this.markIdle();
}
if (aborted) return { ok: false, error: 'aborted' };
return ok
? { ok: true }
: { ok: false, error: resultErrorMessage(msg) };
if (!ok) return { ok: false, error: resultErrorMessage(msg) };
// Context-window telemetry for the ContextBar (paseo's method):
// ctxMax = the model's OWN reported window (1M-aware — reflects the active
// window, so the bar shows the truth per model);
// ctxUsed = peak request input (history in the window) + this turn's output.
const ctxMax = extractMaxContextWindow((msg as { modelUsage?: unknown }).modelUsage);
const fallbackInput =
num(msg.usage?.input_tokens) +
num(msg.usage?.cache_read_input_tokens) +
num(msg.usage?.cache_creation_input_tokens);
const ctxUsed = (maxInputTokens || fallbackInput) + num(msg.usage?.output_tokens);
return {
ok: true,
...(ctxMax > 0 ? { ctxMax } : {}),
...(ctxUsed > 0 ? { ctxUsed } : {}),
};
}
// Map renderable content → AgentEvents for the dispatcher's onEvent.
for (const ev of mapSdkMessage(msg, state)) {
ctx.onEvent(ev);
}
}
// Generator ended without a result message (e.g. it was disposed) — treat as
// a non-fatal incomplete turn so the dispatcher still finalizes the row.
if (aborted) return { ok: false, error: 'aborted' };
return { ok: false, error: 'claude-sdk: query ended before result' };
} catch (err) {
if (aborted) return { ok: false, error: 'aborted' };
await this.markCrashed();
@@ -351,6 +396,22 @@ function numF(v: unknown): number {
return Number.isFinite(x) && x > 0 ? x : 0;
}
/** Largest context-window the SDK reports across `result.modelUsage` (a
* `Record<model, ModelUsage>`, each with a `contextWindow`). This is the model's
* OWN window — 1M when the 1M model/beta is active, 200K otherwise — so the
* ContextBar shows the true window without us mapping model→size ourselves. */
function extractMaxContextWindow(modelUsage: unknown): number {
if (!modelUsage || typeof modelUsage !== 'object') return 0;
let max = 0;
for (const v of Object.values(modelUsage as Record<string, unknown>)) {
if (v && typeof v === 'object') {
const cw = (v as { contextWindow?: unknown }).contextWindow;
if (typeof cw === 'number' && Number.isFinite(cw) && cw > max) max = cw;
}
}
return max;
}
/** Build a human-readable error from an SDK error-result message. */
function resultErrorMessage(result: Extract<SDKMessage, { type: 'result' }>): string {
if (result.subtype === 'success') return 'ok';

View File

@@ -213,8 +213,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
RETURNING id
`;
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
@@ -380,8 +380,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
let acpReasoning = '';
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
@@ -723,8 +723,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
@@ -1004,8 +1004,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (warm ACP)');
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
@@ -1260,8 +1260,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready (claude SDK)');
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, model, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
@@ -1373,9 +1373,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
// ctx_used/ctx_max from the SDK result (1M-aware) → the assistant message, so
// the ContextBar renders a real context-window fill for claude.
await sql`
UPDATE messages
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp(),
ctx_used = ${result.ctxUsed ?? null}, ctx_max = ${result.ctxMax ?? null}
WHERE id = ${assistantId}
`;
broker.publishFrame(sessionId, {