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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user