Show Assistant last turn summary

This commit is contained in:
rcourtman
2026-06-08 03:13:19 +01:00
parent 6c8a6cc03e
commit 2f1ec1fa61
8 changed files with 253 additions and 105 deletions
@@ -1515,8 +1515,9 @@ deriving an older display status from `workflowStatusHistory`.
The session route injects that prompt through
`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx`
`session_prompt` lines 1313-1333. Pulse adapts that by keeping the active
model route selector, control-mode selector, and last-turn usage in composer
chrome while the drawer header stays limited to drawer/session commands.
model route selector, control-mode selector, and last-turn summary in
composer chrome while the drawer header stays limited to drawer/session
commands.
Empty Assistant sessions are prompt-first transcript surfaces, not product
marketing or instruction panels. The referenced OpenCode source at commit
`9ed17da55ab1f7360cc0e01075f763e27fa899e9`
@@ -1536,20 +1537,23 @@ deriving an older display status from `workflowStatusHistory`.
than token counts. Pulse Assistant rows adapt that by keeping visible token
accounting out of the transcript while showing a compact completed-turn
duration beside the effective model label once a turn reaches `done`,
`error`, or user interruption. Runtime token usage may surface in the
composer chrome instead: OpenCode commit
`9ed17da55ab1f7360cc0e01075f763e27fa899e9`
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx` derives
prompt usage from the last assistant message with output tokens in `Prompt`
lines 246-264 and renders it in the prompt footer at lines 1652-1667. Pulse
adapts that source-backed contract by deriving the latest completed
`ChatMessage.tokens` with positive output tokens and rendering only the
backend-proven total token count as low-priority Assistant composer chrome.
The input/output split remains in the footer title/accessibility text, not
the visible transcript or composer copy, so shorthand such as `4358 in · 943
out` cannot read like assistant output. Cost and context-limit percentages
stay absent until the runtime exposes those values through a governed
contract.
`error`, or user interruption. Runtime token usage may surface in
low-priority composer chrome instead, but the completed-turn surface is a
route summary rather than a token counter: the referenced OpenCode source at
fetched `dev` commit `3867fa2bad0e644166e360e2e99cfe426fe71105` imports
`turnSummaryCommit` in
`packages/opencode/src/cli/cmd/run/scrollback.surface.ts` lines 19-20, and
`packages/opencode/src/cli/cmd/run/turn-summary.ts` lines 5-46 renders a
final system summary from agent, model, and duration. Pulse adapts that by
deriving the latest non-streaming assistant `ChatMessage`, rendering the
effective provider/model route, completed-turn duration, and backend-proven
total token count in the composer footer. The input/output split remains in
the footer title/accessibility text, not the visible transcript or composer
copy, so shorthand such as `4358 in · 943 out` cannot read like assistant
output. If provider usage is missing, the route and duration remain visible;
active streaming turns never become the completed-turn summary. Cost and
context-limit percentages stay absent until the runtime exposes those values
through a governed contract.
Assistant tool activity is visible transcript activity, not a hidden context
footer. The referenced OpenCode source at fetched `origin/dev` commit
`e82542b8023a8374f29c23b70ec019c8f256354e`
@@ -2243,6 +2247,18 @@ provider route-switch metadata without changing the selected route, and labeling
failed-turn or readiness recovery buttons as explicit route/model-route choices
instead of implying automatic route adoption.
Assistant completed-turn chrome is route-owned summary, not raw usage output.
The OpenCode reference at fetched `dev` commit
`3867fa2bad0e644166e360e2e99cfe426fe71105` imports `turnSummaryCommit` in
`packages/opencode/src/cli/cmd/run/scrollback.surface.ts` lines 19-20, while
`packages/opencode/src/cli/cmd/run/turn-summary.ts` lines 5-46 emits a final
system summary with agent, model, and duration. Pulse adapts that by deriving
the latest completed, non-streaming assistant turn and surfacing its effective
provider/model route, duration, and backend-proven token total in low-priority
composer chrome. Input/output usage remains title/accessibility detail only,
missing provider usage does not hide route and duration, and active streaming
turns never appear as the completed-turn summary.
Assistant tool activity now follows an OpenCode-referenced chronological row
model where appropriate for Pulse. Consecutive context/read/query tools render
as visible transcript rows in arrival order instead of being replaced by a
@@ -25,6 +25,7 @@ import { QuestionCard } from './QuestionCard';
import { ThinkingBlock } from './ThinkingBlock';
import { getAssistantAnswerText } from './assistantAnswerText';
import { stripAssistantOutputArtifacts } from './assistantOutputHygiene';
import { formatAssistantTurnDuration } from './assistantTurnSummary';
import { formatAssistantWorkflowStatus } from './activeTurnStatus';
import { groupStreamEventsForDisplay } from './streamEventGrouping';
import {
@@ -64,24 +65,6 @@ interface MessageItemProps {
onCancelQueued?: () => void;
}
const formatAssistantTurnDuration = (startedAt: Date, completedAt?: Date): string => {
if (!completedAt) return '';
const durationMs = completedAt.getTime() - startedAt.getTime();
if (!Number.isFinite(durationMs) || durationMs < 0) return '';
if (durationMs < 1000) return '<1s';
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
const markdownClass =
'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none prose-p:leading-relaxed prose-p:my-2 prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-md prose-pre:text-xs prose-pre:border prose-pre:border-slate-800 prose-code:text-blue-700 dark:prose-code:text-blue-300 prose-code:bg-blue-50 dark:prose-code:bg-blue-900 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:font-mono prose-code:text-[0.9em] prose-code:border prose-code:border-blue-100 dark:prose-code:border-blue-800 prose-code:before:content-none prose-code:after:content-none prose-headings:font-semibold prose-hr:border-slate-200 dark:prose-hr:border-slate-700 prose-ul:my-2 prose-ol:my-2 prose-li:my-1';
@@ -5894,42 +5894,45 @@ describe('AIChat', () => {
});
});
// ── Last-turn usage footer ───────────────────────────────────────────────
// ── Last-turn summary footer ───────────────────────────────────────────────
describe('last-turn usage footer', () => {
it('shows completed assistant token usage in the composer chrome', () => {
describe('last-turn summary footer', () => {
it('shows completed assistant route, duration, and token usage in the composer chrome', () => {
mockChat.messages.mockReturnValue([
{
id: 'asst-1',
role: 'assistant' as const,
content: 'Done.',
timestamp: new Date(),
completedAt: new Date(),
timestamp: new Date('2026-06-06T12:00:00Z'),
completedAt: new Date('2026-06-06T12:00:03Z'),
isStreaming: false,
model: 'openrouter:qwen/qwen3.7-plus',
tokens: { input: 500, output: 200 },
},
]);
renderChat();
const usage = screen.getByLabelText(/Last assistant turn usage/);
expect(usage).toHaveTextContent('Last turn: 700 tokens');
expect(usage).not.toHaveTextContent('500 in');
expect(usage).not.toHaveTextContent('200 out');
expect(usage).toHaveAttribute(
const summary = screen.getByLabelText(/Last assistant turn summary/);
expect(summary).toHaveTextContent(
'Last turn: Qwen: Qwen3.7 Plus via OpenRouter · 3s · 700 tokens',
);
expect(summary).not.toHaveTextContent('500 in');
expect(summary).not.toHaveTextContent('200 out');
expect(summary).toHaveAttribute(
'title',
'Last assistant turn usage: 700 total, 500 input, 200 output',
'Last assistant turn summary: Model: Qwen: Qwen3.7 Plus via OpenRouter. Duration: 3s. Usage: 700 total, 500 input, 200 output',
);
});
it('uses the latest completed assistant turn with output tokens', () => {
it('uses the latest completed assistant turn and skips the active streaming turn', () => {
mockChat.messages.mockReturnValue([
{
id: 'asst-1',
role: 'assistant' as const,
content: 'Earlier result.',
timestamp: new Date(),
completedAt: new Date(),
timestamp: new Date('2026-06-06T12:00:00Z'),
completedAt: new Date('2026-06-06T12:00:02Z'),
isStreaming: false,
tokens: { input: 100, output: 50 },
},
@@ -5945,29 +5948,50 @@ describe('AIChat', () => {
renderChat();
const usage = screen.getByLabelText(/Last assistant turn usage/);
expect(usage).toHaveTextContent('Last turn: 150 tokens');
expect(usage).not.toHaveTextContent('100 in');
expect(usage).not.toHaveTextContent('50 out');
expect(usage).not.toHaveTextContent('1,700 tokens');
const summary = screen.getByLabelText(/Last assistant turn summary/);
expect(summary).toHaveTextContent('Last turn: 2s · 150 tokens');
expect(summary).not.toHaveTextContent('100 in');
expect(summary).not.toHaveTextContent('50 out');
expect(summary).not.toHaveTextContent('1,700 tokens');
});
it('does not show usage before an assistant turn has output tokens', () => {
it('still summarizes completed turns when token usage is missing', () => {
mockChat.messages.mockReturnValue([
{
id: 'asst-1',
role: 'assistant' as const,
content: '',
timestamp: new Date(),
completedAt: new Date(),
content: 'Done.',
timestamp: new Date('2026-06-06T12:00:00Z'),
completedAt: new Date('2026-06-06T12:00:05Z'),
isStreaming: false,
model: 'deepseek:deepseek-chat',
tokens: { input: 500, output: 0 },
},
]);
renderChat();
expect(screen.queryByLabelText(/Last assistant turn usage/)).not.toBeInTheDocument();
const summary = screen.getByLabelText(/Last assistant turn summary/);
expect(summary).toHaveTextContent('Last turn: DeepSeek: DeepSeek Chat · 5s');
expect(summary).not.toHaveTextContent('tokens');
});
it('does not show the summary for an active streaming turn', () => {
mockChat.messages.mockReturnValue([
{
id: 'asst-1',
role: 'assistant' as const,
content: 'Streaming.',
timestamp: new Date('2026-06-06T12:00:00Z'),
isStreaming: true,
model: 'openrouter:qwen/qwen3.7-plus',
tokens: { input: 500, output: 200 },
},
]);
renderChat();
expect(screen.queryByLabelText(/Last assistant turn summary/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
formatAssistantTurnDuration,
getAssistantTurnSummary,
} from '../assistantTurnSummary';
import type { ChatMessage } from '../types';
const makeAssistantMessage = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: 'asst-1',
role: 'assistant',
content: 'Done.',
timestamp: new Date('2026-06-06T12:00:00Z'),
completedAt: new Date('2026-06-06T12:00:03Z'),
isStreaming: false,
model: 'openrouter:qwen/qwen3.7-plus',
tokens: { input: 500, output: 200 },
...overrides,
});
describe('assistantTurnSummary', () => {
it('formats assistant turn duration for short and longer completed turns', () => {
expect(
formatAssistantTurnDuration(
new Date('2026-06-06T12:00:00Z'),
new Date('2026-06-06T12:00:00.400Z'),
),
).toBe('<1s');
expect(
formatAssistantTurnDuration(
new Date('2026-06-06T12:00:00Z'),
new Date('2026-06-06T12:01:05Z'),
),
).toBe('1m 5s');
expect(
formatAssistantTurnDuration(
new Date('2026-06-06T12:00:00Z'),
new Date('2026-06-06T11:59:59Z'),
),
).toBe('');
});
it('summarizes completed assistant route, duration, and billed usage', () => {
expect(
getAssistantTurnSummary(makeAssistantMessage(), {
getModelRouteLabel: () => 'Qwen via OpenRouter',
}),
).toEqual({
label: 'Last turn: Qwen via OpenRouter · 3s · 700 tokens',
title:
'Last assistant turn summary: Model: Qwen via OpenRouter. Duration: 3s. Usage: 700 total, 500 input, 200 output',
});
});
it('keeps route and duration visible when provider usage is missing', () => {
expect(
getAssistantTurnSummary(
makeAssistantMessage({
tokens: { input: 500, output: 0 },
}),
{
getModelRouteLabel: () => 'DeepSeek direct',
},
),
).toEqual({
label: 'Last turn: DeepSeek direct · 3s',
title: 'Last assistant turn summary: Model: DeepSeek direct. Duration: 3s',
});
});
it('does not summarize active streaming or non-assistant turns', () => {
expect(getAssistantTurnSummary(makeAssistantMessage({ isStreaming: true }))).toBeNull();
expect(
getAssistantTurnSummary(
makeAssistantMessage({
role: 'user',
content: 'Question',
}),
),
).toBeNull();
});
});
@@ -0,0 +1,76 @@
import { AI_CHAT_LAST_TURN_SUMMARY_LABEL } from '@/utils/aiChatPresentation';
import type { ChatMessage } from './types';
export interface AssistantTurnSummary {
label: string;
title: string;
}
const tokenNumberFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
const normalizeAssistantTokenCount = (value: number | undefined): number => {
if (!Number.isFinite(value) || !value || value < 0) return 0;
return Math.floor(value);
};
const formatAssistantTokenCount = (count: number): string => tokenNumberFormat.format(count);
export const formatAssistantTurnDuration = (startedAt: Date, completedAt?: Date): string => {
if (!completedAt) return '';
const durationMs = completedAt.getTime() - startedAt.getTime();
if (!Number.isFinite(durationMs) || durationMs < 0) return '';
if (durationMs < 1000) return '<1s';
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
const assistantTokenSummary = (message: ChatMessage) => {
const input = normalizeAssistantTokenCount(message.tokens?.input);
const output = normalizeAssistantTokenCount(message.tokens?.output);
if (output <= 0) return null;
const total = input + output;
return {
label: `${formatAssistantTokenCount(total)} ${total === 1 ? 'token' : 'tokens'}`,
detail: `${formatAssistantTokenCount(total)} total, ${formatAssistantTokenCount(
input,
)} input, ${formatAssistantTokenCount(output)} output`,
};
};
export const getAssistantTurnSummary = (
message: ChatMessage,
options: { getModelRouteLabel?: (modelId: string) => string } = {},
): AssistantTurnSummary | null => {
if (message.role !== 'assistant' || message.isStreaming) return null;
const model = message.model?.trim();
const modelLabel = model ? options.getModelRouteLabel?.(model) || model : '';
const durationLabel = formatAssistantTurnDuration(message.timestamp, message.completedAt);
const tokenSummary = assistantTokenSummary(message);
const labelParts = [modelLabel, durationLabel, tokenSummary?.label].filter(
(part): part is string => Boolean(part?.trim()),
);
if (labelParts.length === 0) return null;
const titleParts = [
modelLabel ? `Model: ${modelLabel}` : undefined,
durationLabel ? `Duration: ${durationLabel}` : undefined,
tokenSummary ? `Usage: ${tokenSummary.detail}` : undefined,
].filter((part): part is string => Boolean(part));
return {
label: `Last turn: ${labelParts.join(' · ')}`,
title: `${AI_CHAT_LAST_TURN_SUMMARY_LABEL}: ${titleParts.join('. ')}`,
};
};
@@ -75,7 +75,6 @@ import {
AI_CHAT_FORK_SESSION_LOADING_MESSAGE,
AI_CHAT_FORK_SESSION_SUCCESS_MESSAGE,
AI_CHAT_INPUT_PLACEHOLDER,
AI_CHAT_LAST_TURN_USAGE_LABEL,
AI_CHAT_NEW_SESSION_BUTTON_TITLE,
AI_CHAT_NEW_SESSION_MENU_ARIA_LABEL,
AI_CHAT_NEW_SESSION_MENU_LABEL,
@@ -134,6 +133,7 @@ import {
import { normalizeChatMentionKeyPart } from '@/utils/chatIdentifiers';
import { getGlobalWebSocketStore } from '@/stores/websocket-global';
import { copyToClipboard } from '@/utils/clipboard';
import { getAssistantTurnSummary } from './assistantTurnSummary';
import {
getPreferredResourceDisplayName,
getPreferredResourceHostname,
@@ -255,11 +255,6 @@ interface ComposerDraftStash {
restoredPromptDraft: RestoredPromptDraft | null;
}
interface AssistantUsageSummary {
label: string;
title: string;
}
interface TranscriptCopyFallback {
generatedAt: Date;
transcript: string;
@@ -297,35 +292,6 @@ const compactText = (items: Array<string | undefined>): string[] =>
const pluralizeCount = (count: number, singular: string, plural: string) =>
`${count} ${count === 1 ? singular : plural}`;
const tokenNumberFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
const normalizeAssistantTokenCount = (value: number | undefined): number => {
if (!Number.isFinite(value) || !value || value < 0) return 0;
return Math.floor(value);
};
const formatAssistantTokenCount = (count: number): string => tokenNumberFormat.format(count);
const getAssistantUsageSummary = (message: ChatMessage): AssistantUsageSummary | null => {
if (message.role !== 'assistant' || message.isStreaming) return null;
const input = normalizeAssistantTokenCount(message.tokens?.input);
const output = normalizeAssistantTokenCount(message.tokens?.output);
if (output <= 0) return null;
const total = input + output;
const totalLabel = `${formatAssistantTokenCount(total)} ${total === 1 ? 'token' : 'tokens'}`;
const titleDetail = [
`${formatAssistantTokenCount(total)} total`,
`${formatAssistantTokenCount(input)} input`,
`${formatAssistantTokenCount(output)} output`,
].join(', ');
return {
label: `Last turn: ${totalLabel}`,
title: `${AI_CHAT_LAST_TURN_USAGE_LABEL}: ${titleDetail}`,
};
};
const normalizePromptHistoryEntry = (value: unknown): PromptHistoryEntry | null => {
if (!value || typeof value !== 'object') return null;
const record = value as Record<string, unknown>;
@@ -2475,11 +2441,13 @@ export const AIChat: Component<AIChatProps> = (props) => {
]).join('. '),
};
});
const lastAssistantUsage = createMemo(() => {
const lastAssistantTurnSummary = createMemo(() => {
const messages = chat.messages();
for (let index = messages.length - 1; index >= 0; index -= 1) {
const usage = getAssistantUsageSummary(messages[index]);
if (usage) return usage;
const summary = getAssistantTurnSummary(messages[index], {
getModelRouteLabel: formatChatMessageModelRoute,
});
if (summary) return summary;
}
return null;
});
@@ -4917,14 +4885,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
</div>
<Show when={lastAssistantUsage()}>
{(usage) => (
<Show when={lastAssistantTurnSummary()}>
{(summary) => (
<div
class="flex h-5 min-w-0 items-center justify-end self-end text-[10px] font-medium text-muted sm:h-7 sm:max-w-[12rem] sm:shrink-0 sm:self-auto"
aria-label={usage().title}
title={usage().title}
class="flex h-5 min-w-0 items-center justify-end self-end text-[10px] font-medium text-muted sm:h-7 sm:max-w-[14rem] sm:shrink-0 sm:self-auto"
aria-label={summary().title}
title={summary().title}
>
<span class="truncate">{usage().label}</span>
<span class="truncate">{summary().label}</span>
</div>
)}
</Show>
@@ -21,7 +21,7 @@ import {
AI_CHAT_DRAWER_SUBTITLE,
AI_CHAT_DRAWER_TITLE,
AI_CHAT_LAUNCHER_ARIA_LABEL,
AI_CHAT_LAST_TURN_USAGE_LABEL,
AI_CHAT_LAST_TURN_SUMMARY_LABEL,
AI_CHAT_MODEL_SELECTOR_EMPTY_STATE,
AI_CHAT_NEW_SESSION_BUTTON_TITLE,
AI_CHAT_NEW_SESSION_MENU_ARIA_LABEL,
@@ -116,7 +116,7 @@ describe('aiChatPresentation', () => {
expect(AI_CHAT_QUESTION_CARD_TITLE).toBe('Pulse Assistant needs your input');
expect(AI_CHAT_ASSISTANT_MESSAGE_LABEL).toBe('Pulse Assistant');
expect(AI_CHAT_CONTEXT_USED_LABEL).toBe('Context used');
expect(AI_CHAT_LAST_TURN_USAGE_LABEL).toBe('Last assistant turn usage');
expect(AI_CHAT_LAST_TURN_SUMMARY_LABEL).toBe('Last assistant turn summary');
expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF).toBe('/settings/system-ai');
expect(AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL).toBe('Open settings');
expect(AI_CHAT_PROVIDER_READINESS_RETRY_LABEL).toBe('Retry');
@@ -67,7 +67,7 @@ export const AI_CHAT_QUESTION_CARD_TITLE = 'Pulse Assistant needs your input';
export const AI_CHAT_QUESTION_CARD_PLACEHOLDER = 'Type your answer...';
export const AI_CHAT_ASSISTANT_MESSAGE_LABEL = 'Pulse Assistant';
export const AI_CHAT_CONTEXT_USED_LABEL = 'Context used';
export const AI_CHAT_LAST_TURN_USAGE_LABEL = 'Last assistant turn usage';
export const AI_CHAT_LAST_TURN_SUMMARY_LABEL = 'Last assistant turn summary';
export const AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF = '/settings/system-ai';
export const AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL = 'Open settings';
export const AI_CHAT_PROVIDER_READINESS_RETRY_LABEL = 'Retry';