From ca2cdc0ad9638695306da1be553aeee8b4723818 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:57:25 -0700 Subject: [PATCH] feat(native-chat): answer /context in chat view for OpenClaude and OMP (#22294) * feat(native-chat): answer /context in chat view for Claude Over a terminal-backed chat, Claude's /context paints a grid only the hidden terminal sees and records nothing in the transcript, so the chat showed nothing. The chat host now answers it itself: the transcript decoder keeps each assistant response's API usage and model, and the composer replies with the same estimate the CLI statusline shows (input + cache tokens against the model's window) instead of typing the command into the PTY. The command is also listed in the curated Claude catalog so the slash menu offers it. * fix(native-chat): only state a /context window the host can establish The /context answer sized the window from the transcript's model id, which never carries the [1m] suffix, so a 1M session read as 200k (e.g. 450k / 200k, 225%). The window now comes from the session's tracked model resolved through the host CLI's own model listing (the resolvedModel field the list_models probe already returned), and only a [1m] id that matches the model the transcript last answered with is sized; anything else reports the used figure alone. Also: - Offer /context only in the desktop terminal-backed composer; the shared catalog is mobile's too, and mobile cannot answer it. - Answer a typed /context even with images attached, as the picker does, and keep the attachments armed instead of sending them to the terminal. - Report nothing until a response lands after a /compact or /clear sent from the chat, instead of the pre-compaction size. * test(native-chat): pin resolvedModel through Claude model discovery * fix(native-chat): say /context is unavailable when the host never reports usage A host that predates usage decoding, or a scraped view, decodes Claude's replies without a model or usage, so the chat promised an answer after the next response that would never come. Once the agent has answered and no answer names its model, say usage is not available for this session. * feat(native-chat): answer /context for OMP instead of Claude Claude and Codex answer /context themselves in the structured chat lane, so the terminal-backed Claude answer, its [1m] window rule, and the resolved-model plumbing through the model listing go away. OMP's /context only paints a panel inside its TUI. The OMP decoder now keeps the provider, model and usage of each reply (none for aborted or errored replies, which OMP's own gauge skips) and turns compaction rows into the existing compaction boundary. The OMP model listing keeps each model's context window, and the chat answers /context from the last reply's prompt size against the window of the provider/model that served it. * feat(native-chat): show OpenClaude's own /context report in chat OpenClaude writes its /context grid to the transcript as a row, which the chat hid as harness noise. The live message preparation now turns the stdout row that answers an opted-in command (/context for OpenClaude) into command output with the ANSI colors stripped, and the notice row renders command output in monospace so the grid keeps its columns. Replies to other local commands stay hidden, and Claude is not opted in. OpenClaude's slash menu now lists /context; Claude's does not. * test(native-chat): pin OpenClaude /context output through live message preparation * test(native-chat): pin the OMP context window through model discovery * test(native-chat): pin monospace rendering of command output * test(native-chat): restore the notice row suite the command-output test replaced The previous commit rewrote NativeChatNoticeRow.test.tsx wholesale, deleting main's compaction, tone, plan, provider-notice and old-reader coverage. Restore it and pin monospace command output through the message row instead. * fix(mobile): keep OpenClaude /context off mobile, which cannot show its report The shared slash catalog now lists /context for OpenClaude because the desktop chat surfaces its transcript reply. Mobile's transcript fold does not, so the command did nothing there. Mobile now offers the catalog minus commands answered only by a surfaced reply, which returns its OpenClaude menu and send classification to main's behavior. * refactor(native-chat): drop the unread estimated flag from context usage Left from the earlier design; the answer text states the estimate itself. * fix(native-chat): keep /model replies hidden after an OpenClaude /context Skill surfacing ran first and rewrote non-catalog envelopes such as /model into plain user text, so command-output pairing no longer saw them. A later /model reply then paired with the earlier /context envelope and appeared in the chat. Pair outputs against the raw envelopes before skill surfacing. * fix(native-chat): word the OMP /context answer for a compacted session and show it as an aside After a compaction the agent has already answered, so the unknown-usage line now promises the next response instead. The host answer is one sentence, so it draws as the muted line it replaces rather than a monospace grid. The provider/model selector now comes from one helper shared by the OMP listing parser and the answer, and new tests drive real OpenClaude and OMP transcript lines through the decoders. Co-Authored-By: Claude * refactor(native-chat): pair command replies by row link and declare /context replies on the catalog A `` row now answers the command row its `parentUuid` names, carried on decoded messages as the optional `parentId`, instead of the newest command row at or before its timestamp. A reply with no loaded linked row (older host, command outside the tail window) stays hidden. How each agent's `/context` is answered is declared once, as `reply` on its catalog row (`composer` for OMP, `transcript` for OpenClaude). The slash menu, the composer's answer, the transcript surfacing and the mobile catalog all derive from it, and both send paths share one intercept. Mobile no longer lists OMP `/context`, which did nothing there. * perf(native-chat): skip command-output surfacing for agents that declare no transcript reply Every Claude-format row now carries its parent link, so once a session holds any local-command reply (/model, /compact) the surfacing pass built a map of every message on each live update and changed nothing for agents like Claude. Agents whose catalog declares no transcript reply now return before the scan. --------- Co-authored-by: Claude --- .../src/session/MobileNativeChatComposer.tsx | 4 +- ...le-native-chat-send-classification.test.ts | 14 +- .../mobile-native-chat-send-classification.ts | 15 +- ...openclaude-0.31.0-local-command-rows.jsonl | 13 + ...ine-decoders-claude-command-output.test.ts | 60 ++++ .../transcript-line-decoders-claude.ts | 9 +- .../transcript-line-decoders-omp.ts | 71 ++++- .../transcript-line-decoders.omp.test.ts | 115 +++++++- .../worker-transcript-payload.test.ts | 7 + .../worker-transcript-payload.ts | 3 + .../native-chat/NativeChatComposer.tsx | 3 + .../native-chat/NativeChatNoticeRow.test.tsx | 21 ++ .../native-chat/NativeChatNoticeRow.tsx | 8 + .../native-chat/NativeChatResolvedView.tsx | 8 +- .../native-chat/native-chat-command-marker.ts | 18 +- .../native-chat/native-chat-composer-types.ts | 8 +- .../native-chat-context-command.test.tsx | 261 ++++++++++++++++++ .../native-chat-context-usage-answer.ts | 38 +++ ...tive-chat-live-message-preparation.test.ts | 63 +++++ .../native-chat-live-message-preparation.ts | 6 +- .../native-chat/native-chat-pending.test.ts | 11 + .../native-chat-pty-session-options.ts | 6 +- .../native-chat-session-option-discovery.ts | 1 + ...ive-chat-session-option-enrichment.test.ts | 31 +++ .../use-native-chat-local-command-answer.ts | 104 +++++++ ...use-native-chat-picker-command-dispatch.ts | 27 +- .../use-native-chat-pty-composer-send.ts | 10 +- src/renderer/src/i18n/locales/en.json | 6 + src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/fr.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + .../agent-session-option-catalog-omp.test.ts | 27 ++ .../agent-session-option-catalog-types.ts | 2 + src/shared/commit-message-agent-spec.ts | 4 + src/shared/native-chat-agent-profiles.test.ts | 36 +++ src/shared/native-chat-agent-profiles.ts | 27 +- src/shared/native-chat-command-output.test.ts | 104 +++++++ src/shared/native-chat-command-output.ts | 75 +++++ src/shared/native-chat-context-usage.test.ts | 105 +++++++ src/shared/native-chat-context-usage.ts | 54 ++++ src/shared/native-chat-slash-commands.test.ts | 7 + src/shared/native-chat-slash-commands.ts | 19 +- src/shared/native-chat-types.ts | 10 + src/shared/omp-model-list-probe.ts | 19 +- 46 files changed, 1427 insertions(+), 33 deletions(-) create mode 100644 src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl create mode 100644 src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-context-command.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts create mode 100644 src/shared/native-chat-command-output.test.ts create mode 100644 src/shared/native-chat-command-output.ts create mode 100644 src/shared/native-chat-context-usage.test.ts create mode 100644 src/shared/native-chat-context-usage.ts diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index b04e09d80e2..ae05d9622c1 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -11,7 +11,6 @@ import { } from 'react-native' import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native' import { colors, radii, spacing } from '../theme/mobile-theme' -import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles' import { structuredSlashCommands } from '../../../src/shared/structured-agent-session-composer' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' import { @@ -31,6 +30,7 @@ import { } from './MobileNativeChatSessionOptionPickers' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' +import { getMobileNativeChatCommands } from './mobile-native-chat-send-classification' import { keepHeldPressThroughLongPress } from './held-press-long-press' const NO_FILE_PATHS: string[] = [] @@ -137,7 +137,7 @@ export function MobileNativeChatComposer({ structuredCommands !== undefined ? structuredSlashCommands(structuredCommands, agent) : agent - ? getVerifiedNativeChatCommands(agent) + ? getMobileNativeChatCommands(agent) : [] // Why: Codex's catalog is 45 commands and this list is a plain ScrollView // (~5 rows visible), so an uncapped `/` would mount every row and diff --git a/mobile/src/session/mobile-native-chat-send-classification.test.ts b/mobile/src/session/mobile-native-chat-send-classification.test.ts index 8ad61d01c61..bdd2168532d 100644 --- a/mobile/src/session/mobile-native-chat-send-classification.test.ts +++ b/mobile/src/session/mobile-native-chat-send-classification.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' +import { + classifyMobileNativeChatSend, + getMobileNativeChatCommands +} from './mobile-native-chat-send-classification' describe('classifyMobileNativeChatSend', () => { it('recognizes OMP selectors and context commands without claiming generic help', () => { @@ -24,6 +27,15 @@ describe('classifyMobileNativeChatSend', () => { expect(classifyMobileNativeChatSend('claude', '/diff')).toBe('unknown-token') }) + it('leaves /context off mobile, which has no way to show its answer', () => { + // OpenClaude's report is a transcript row; OMP's is answered by the desktop composer. + for (const agent of ['openclaude', 'omp']) { + expect(classifyMobileNativeChatSend(agent, '/context')).toBe('unknown-token') + expect(classifyMobileNativeChatSend(agent, '/compact')).toBe('command') + expect(getMobileNativeChatCommands(agent).map(({ name }) => name)).not.toContain('context') + } + }) + it('keeps prose as chat, including leading-whitespace slash text', () => { expect(classifyMobileNativeChatSend('claude', 'hello there')).toBe('chat') expect(classifyMobileNativeChatSend('claude', ' /clear is a command')).toBe('chat') diff --git a/mobile/src/session/mobile-native-chat-send-classification.ts b/mobile/src/session/mobile-native-chat-send-classification.ts index 2a34efabdd2..cd3ae68294b 100644 --- a/mobile/src/session/mobile-native-chat-send-classification.ts +++ b/mobile/src/session/mobile-native-chat-send-classification.ts @@ -5,16 +5,23 @@ // the optimistic echo would never reconcile. import { - getNativeChatAgentProfile, - getVerifiedNativeChatCommands + getAgentAnsweredNativeChatCommands, + getNativeChatAgentProfile } from '../../../src/shared/native-chat-agent-profiles' import { classifyNativeChatSend, - type NativeChatSendClassification + type NativeChatSendClassification, + type SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands' export type { NativeChatSendClassification } +/** The curated catalog mobile offers over a terminal session. Why: mobile has + * neither the desktop composer's answers nor its transcript reply rows. */ +export function getMobileNativeChatCommands(agent: string): readonly SlashCommandSuggestion[] { + return getAgentAnsweredNativeChatCommands(agent) +} + /** Classify a mobile chat send for the tab's agent. Mobile has no skill picker, * so there is never a picker-origin token that reclassifies a `/token` as chat. */ export function classifyMobileNativeChatSend( @@ -27,7 +34,7 @@ export function classifyMobileNativeChatSend( const profile = getNativeChatAgentProfile(agent) return classifyNativeChatSend( text, - getVerifiedNativeChatCommands(agent), + getMobileNativeChatCommands(agent), null, profile?.skillPrefix ?? null ) diff --git a/src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl b/src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl new file mode 100644 index 00000000000..78307deee6c --- /dev/null +++ b/src/main/native-chat/__fixtures__/openclaude-0.31.0-local-command-rows.jsonl @@ -0,0 +1,13 @@ +{"type":"mode","mode":"normal","sessionId":"00000000-0000-4000-8000-000000000000"} +{"type":"file-history-snapshot","messageId":"b57b5064-f405-496e-972e-a8f0401f5195","snapshot":{"messageId":"b57b5064-f405-496e-972e-a8f0401f5195","trackedFileBackups":{},"timestamp":"2026-09-23T00:21:16.966Z"},"isSnapshotUpdate":false} +{"parentUuid":null,"isSidechain":false,"type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"77469cbb-626a-48d7-a43c-c15c7614a95f","timestamp":"2026-09-23T00:21:16.965Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"77469cbb-626a-48d7-a43c-c15c7614a95f","isSidechain":false,"type":"user","message":{"role":"user","content":"/context\n context\n "},"uuid":"b57b5064-f405-496e-972e-a8f0401f5195","timestamp":"2026-09-23T00:21:16.965Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"b57b5064-f405-496e-972e-a8f0401f5195","isSidechain":false,"type":"user","message":{"role":"user","content":" \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m⛁ ⛁ ⛁ ⛁ ⛁ ⛁ \u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[39m \u001b[38;5;246mgpt-4o · 18.3k/128k tokens (14%)\u001b[39m\n\n\u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[38;5;220m⛀ \u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m⛁\u001b[39m System prompt: \u001b[38;5;246m8k tokens (6.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛁\u001b[39m System tools: \u001b[38;5;246m10k tokens (7.8%)\u001b[39m\n \u001b[38;5;220m⛁\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛶\u001b[39m Free space: \u001b[38;5;246m63.3k (49.5%)\u001b[39m\n \u001b[38;5;246m⛝ Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m · /skills\u001b[39m"},"uuid":"ed4aeadc-1c29-4cd6-a074-fc179a168707","timestamp":"2026-09-23T00:21:16.965Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"type":"file-history-snapshot","messageId":"46776637-ece8-482c-b346-65c2938f80aa","snapshot":{"messageId":"46776637-ece8-482c-b346-65c2938f80aa","trackedFileBackups":{},"timestamp":"2026-09-23T00:21:28.270Z"},"isSnapshotUpdate":false} +{"parentUuid":"ed4aeadc-1c29-4cd6-a074-fc179a168707","isSidechain":false,"type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"b42e5475-0947-44ae-a15e-24f1e74d6a72","timestamp":"2026-09-23T00:21:28.270Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"b42e5475-0947-44ae-a15e-24f1e74d6a72","isSidechain":false,"type":"user","message":{"role":"user","content":"/cost\n cost\n "},"uuid":"46776637-ece8-482c-b346-65c2938f80aa","timestamp":"2026-09-23T00:21:28.269Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"46776637-ece8-482c-b346-65c2938f80aa","isSidechain":false,"type":"system","subtype":"local_command","content":"\u001b[2mTotal cost: $0.0000\u001b[22m\n\u001b[2mTotal duration (API): 0s\u001b[22m\n\u001b[2mTotal duration (wall): 24s\u001b[22m\n\u001b[2mTotal code changes: 0 lines added, 0 lines removed\u001b[22m\n\nUsage: 0 input, 0 output","level":"info","timestamp":"2026-09-23T00:21:28.270Z","uuid":"a1ea4717-298a-4f3e-b5d7-f8a6b2936f10","isMeta":false,"userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"type":"file-history-snapshot","messageId":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","snapshot":{"messageId":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","trackedFileBackups":{},"timestamp":"2026-09-23T00:21:39.716Z"},"isSnapshotUpdate":false} +{"parentUuid":"a1ea4717-298a-4f3e-b5d7-f8a6b2936f10","isSidechain":false,"type":"user","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to."},"isMeta":true,"uuid":"a3492e9f-3fd7-4c15-a04f-dd6b170326ff","timestamp":"2026-09-23T00:21:39.715Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"a3492e9f-3fd7-4c15-a04f-dd6b170326ff","isSidechain":false,"type":"user","message":{"role":"user","content":"/context\n context\n "},"uuid":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","timestamp":"2026-09-23T00:21:39.715Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} +{"parentUuid":"4bb33790-bbbf-4b6d-8701-a3e7135805b0","isSidechain":false,"type":"user","message":{"role":"user","content":" \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m⛁ ⛁ ⛁ ⛁ ⛁ ⛁ \u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[39m \u001b[38;5;246mgpt-4o · 18.9k/128k tokens (15%)\u001b[39m\n\n\u001b[38;5;246m⛁ ⛁ ⛁ ⛁ \u001b[38;5;220m⛀ \u001b[38;5;135m⛀ \u001b[38;5;246m⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m⛁\u001b[39m System prompt: \u001b[38;5;246m8k tokens (6.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛁\u001b[39m System tools: \u001b[38;5;246m10k tokens (7.8%)\u001b[39m\n \u001b[38;5;220m⛁\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;135m⛁\u001b[39m Messages: \u001b[38;5;246m607 tokens (0.5%)\u001b[39m\n \u001b[38;5;246m⛶\u001b[39m Free space: \u001b[38;5;246m62.7k (49.0%)\u001b[39m\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m \u001b[38;5;246m⛝ Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ ⛶ \u001b[39m\n\n\u001b[38;5;246m⛶ ⛶ ⛶ ⛶ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\u001b[38;5;246m⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ ⛝ \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m · /skills\u001b[39m"},"uuid":"2b989a6e-c7d6-45b6-8367-f656471e4e2b","timestamp":"2026-09-23T00:21:39.715Z","userType":"external","entrypoint":"cli","cwd":"/tmp/project","sessionId":"00000000-0000-4000-8000-000000000000","version":"unknown","gitBranch":"main"} diff --git a/src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts b/src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts new file mode 100644 index 00000000000..ff35d83822c --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-command-output.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { surfaceNativeChatCommandOutputs } from '../../shared/native-chat-command-output' +import { stripNoiseMessages } from '../../shared/native-chat-noise' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' + +// OpenClaude 0.31.0 running `/context`, `/cost`, `/context` with no model call; +// session id and cwd scrubbed. `/cost` replies in a `system` row. +const FIXTURE_LINES = readFileSync( + join(__dirname, '__fixtures__', 'openclaude-0.31.0-local-command-rows.jsonl'), + 'utf8' +) + .split('\n') + .filter((line) => line.trim() !== '') + +function decoded(lines: readonly string[]): NativeChatMessage[] { + return lines.flatMap((line, index) => decodeClaudeTranscriptLine(line, `f${index}`) ?? []) +} + +function commandRow(messages: NativeChatMessage[], index: number): NativeChatMessage | undefined { + return messages.filter((message) => + message.blocks.some((block) => block.type === 'text' && block.text.includes('')) + )[index] +} + +describe("OpenClaude's local command rows through the transcript decoder", () => { + const messages = decoded(FIXTURE_LINES) + + it("links each reply row to its command row's id", () => { + const replies = messages.filter((message) => + message.blocks.some( + (block) => block.type === 'text' && block.text.includes('') + ) + ) + expect(replies.map(({ parentId }) => parentId)).toEqual([ + commandRow(messages, 0)?.id, + commandRow(messages, 2)?.id + ]) + }) + + it('shows both /context reports as plain command output and nothing else', () => { + const visible = stripNoiseMessages(surfaceNativeChatCommandOutputs(messages, 'openclaude')) + expect(visible.map(({ role }) => role)).toEqual(['system', 'system']) + const totals = visible.map(({ blocks: [block] }) => { + expect(block).toMatchObject({ type: 'text', presentation: 'command-output' }) + const text = block?.type === 'text' ? block.text : '' + expect(text.split('\n')[0]).toBe('Context Usage') + expect(text).not.toContain('\u001b') + return /gpt-4o · \S+ tokens \(\d+%\)/.exec(text)?.[0] + }) + expect(totals).toEqual(['gpt-4o · 18.3k/128k tokens (14%)', 'gpt-4o · 18.9k/128k tokens (15%)']) + }) + + it('keeps the reply hidden for a host that sends no row link', () => { + const unlinked = messages.map(({ parentId: _parentId, ...message }) => message) + expect(stripNoiseMessages(surfaceNativeChatCommandOutputs(unlinked, 'openclaude'))).toEqual([]) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index bc7a988e79e..8693b4296a9 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -85,6 +85,9 @@ export function decodeClaudeTranscriptLine( } const timestamp = parseTimestamp(record.timestamp) const recordMessageId = extractString(record.uuid) ?? fallbackId + // Why: the row link pairs a local command's reply with its command row. + const parentUuid = extractString(record.parentUuid) + const parent = parentUuid ? { parentId: parentUuid } : {} if (claudeInterruptedMessageId(record)) { // Why: keep Claude's injected boilerplate out of the user-bubble path while // preserving the interruption as a quiet, replayable conversation status. @@ -93,7 +96,8 @@ export function decodeClaudeTranscriptLine( role: 'system', blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], timestamp, - source: 'transcript' + source: 'transcript', + ...parent } } const message = asRecord(record.message) @@ -127,7 +131,8 @@ export function decodeClaudeTranscriptLine( role: claudeMessageRole(role, blocks), blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, timestamp, - source: 'transcript' + source: 'transcript', + ...parent } } diff --git a/src/main/native-chat/transcript-line-decoders-omp.ts b/src/main/native-chat/transcript-line-decoders-omp.ts index 60fcd736f35..bfe44fd87bf 100644 --- a/src/main/native-chat/transcript-line-decoders-omp.ts +++ b/src/main/native-chat/transcript-line-decoders-omp.ts @@ -12,6 +12,7 @@ import { type NativeChatBlock, type NativeChatMessage } from '../../shared/native-chat-types' +import type { AgentSessionTokenUsage } from '../../shared/agent-session-context-usage' import { asRecord, extractString, @@ -24,21 +25,35 @@ import { toolResultOutput } from './transcript-record-blocks' * omp session rows: `type: 'message'` turns carrying user/assistant/toolResult/ * developer records with text, thinking, toolCall and image content blocks, the * content-less bash/python execution cells, plus the `type: 'custom_message'` - * rows extensions inject into the conversation. - * Session bookkeeping rows (session_init, mode_change, compaction, custom) are - * skipped, as are records of an unrecognized type. + * rows extensions inject into the conversation, and a `compaction` row as the + * boundary it draws in the context. Other session bookkeeping rows (session_init, + * mode_change, custom) are skipped, as are records of an unrecognized type. */ export function decodeOmpTranscriptLine( line: string, fallbackId: string ): NativeChatMessage | null { const record = parseJsonObject(line) - if (!record || (record.type !== 'message' && record.type !== 'custom_message')) { + if ( + !record || + (record.type !== 'message' && record.type !== 'custom_message' && record.type !== 'compaction') + ) { return null } const id = extractString(record.id) ?? fallbackId const timestamp = parseTimestamp(record.timestamp) + if (record.type === 'compaction') { + // Why: responses before this row measured a context omp has since replaced. + return { + id, + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp, + source: 'transcript' + } + } + if (record.type === 'custom_message') { // Why: these extension-authored turns reach the model, and omp's own // transcript renders them — but only when `display` is set; the rest are @@ -124,8 +139,52 @@ export function decodeOmpTranscriptLine( } : null } - const messageRole = role === 'assistant' ? 'assistant' : role === 'user' ? 'user' : 'system' - return { id, role: messageRole, blocks, timestamp, source: 'transcript' } + if (role === 'assistant') { + return { + id, + role: 'assistant', + blocks, + timestamp, + source: 'transcript', + ...ompServing(message) + } + } + return { id, role: role === 'user' ? 'user' : 'system', blocks, timestamp, source: 'transcript' } +} + +/** Which provider/model served an assistant record, and what prompt it read. */ +function ompServing( + message: Record +): Pick { + const model = extractString(message.model) + const provider = extractString(message.provider) + // Why: omp's own context gauge skips aborted and errored replies' accounting. + const usage = + message.stopReason === 'aborted' || message.stopReason === 'error' + ? null + : ompTokenUsage(message.usage) + return { + ...(model ? { model } : {}), + ...(provider ? { provider } : {}), + ...(usage ? { usage } : {}) + } +} + +function ompTokenUsage(value: unknown): AgentSessionTokenUsage | null { + const usage = asRecord(value) + if (!usage) { + return null + } + return { + inputTokens: tokenCount(usage.input), + cacheCreationInputTokens: tokenCount(usage.cacheWrite), + cacheReadInputTokens: tokenCount(usage.cacheRead), + outputTokens: tokenCount(usage.output) + } +} + +function tokenCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 } /** A bash/python execution cell: the invocation, then its captured output. */ diff --git a/src/main/native-chat/transcript-line-decoders.omp.test.ts b/src/main/native-chat/transcript-line-decoders.omp.test.ts index 04e28dfbc22..6b890146493 100644 --- a/src/main/native-chat/transcript-line-decoders.omp.test.ts +++ b/src/main/native-chat/transcript-line-decoders.omp.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest' +import { deriveNativeChatContextUsage } from '../../shared/native-chat-context-usage' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { ompModelSelector, parseOmpModelList } from '../../shared/omp-model-list-probe' import { decodeOmpTranscriptLine } from './transcript-line-decoders' const line = (record: unknown): string => JSON.stringify(record) @@ -20,7 +23,6 @@ describe('decodeOmpTranscriptLine', () => { expect( decodeOmpTranscriptLine(line({ type: 'custom', customType: 'tool_execution_start' }), 'f') ).toBeNull() - expect(decodeOmpTranscriptLine(line({ type: 'compaction', shortSummary: 's' }), 'f')).toBeNull() expect(decodeOmpTranscriptLine(line({ type: 'a-type-from-the-future' }), 'f')).toBeNull() }) @@ -277,4 +279,115 @@ describe('decodeOmpTranscriptLine', () => { expect(decoded?.id).toBe('fallback-9') expect(decoded?.timestamp).toBeNull() }) + + describe('what served a reply, and the prompt it read', () => { + // An assistant row as OMP 17.0.5 writes it, provider payload and ids trimmed. + const reply = (extra: Record = {}): string => + message('assistant', [{ type: 'text', text: 'The `value` field is set to **42**.' }], { + api: 'openai-codex-responses', + provider: 'openai-codex', + model: 'gpt-5.5', + usage: { + input: 680, + output: 16, + cacheRead: 20992, + cacheWrite: 0, + totalTokens: 21688, + cost: { + input: 0.0034, + output: 0.00048, + cacheRead: 0.010496, + cacheWrite: 0, + total: 0.014376 + } + }, + stopReason: 'stop', + contextSnapshot: { promptTokens: 21672, nonMessageTokens: 20318 }, + ...extra + }) + + it('keeps the provider, model and usage of a completed reply', () => { + expect(decodeOmpTranscriptLine(reply(), 'f')).toMatchObject({ + role: 'assistant', + provider: 'openai-codex', + model: 'gpt-5.5', + usage: { + inputTokens: 680, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 20992, + outputTokens: 16 + } + }) + }) + + it('drops the accounting of an aborted or errored reply, keeping its model', () => { + for (const stopReason of ['aborted', 'error']) { + const decoded = decodeOmpTranscriptLine(reply({ stopReason }), 'f') + expect(decoded?.model).toBe('gpt-5.5') + expect(decoded).not.toHaveProperty('usage') + } + }) + + it("measures the prompt OMP recorded, against its listing's window for that model", () => { + // A `omp models --json` row as OMP 17.0.5 prints it. + const listing = parseOmpModelList( + line({ + models: [ + { + provider: 'openai-codex', + id: 'gpt-5.5', + selector: 'openai-codex/gpt-5.5', + name: 'GPT-5.5', + contextWindow: 272000 + } + ] + }) + ) + const windowFor = (served: NativeChatMessage): number | null => + listing.find(({ id }) => id === ompModelSelector(served.provider, served.model)) + ?.contextWindowTokens ?? null + const decoded = decodeOmpTranscriptLine(reply(), 'f')! + // 21,672 is the `contextSnapshot.promptTokens` OMP stored on this reply. + expect(deriveNativeChatContextUsage([decoded], windowFor)).toEqual({ + usedTokens: 21672, + windowTokens: 272000, + percentage: 8 + }) + const compaction = decodeOmpTranscriptLine(line({ type: 'compaction', id: 'c' }), 'f')! + expect(deriveNativeChatContextUsage([decoded, compaction], windowFor)).toBeNull() + }) + + it('puts none of it on turns that are not replies', () => { + const decoded = decodeOmpTranscriptLine( + message('user', [{ type: 'text', text: 'hi' }], { model: 'gpt-5.5', usage: { input: 5 } }), + 'f' + ) + expect(decoded).not.toHaveProperty('usage') + expect(decoded).not.toHaveProperty('model') + }) + }) + + it('marks a compaction row as the boundary it draws', () => { + // Field shape of `appendCompaction` in OMP 17.0.5; summary text elided. + const decoded = decodeOmpTranscriptLine( + line({ + type: 'compaction', + id: 'cmp-1', + parentId: 'rec-9', + timestamp: '2026-07-16T01:00:00.000Z', + summary: '…', + shortSummary: '…', + firstKeptEntryId: 'rec-7', + tokensBefore: 180000 + }), + 'f' + ) + expect(decoded).toEqual({ + id: 'cmp-1', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp: Date.parse('2026-07-16T01:00:00.000Z'), + source: 'transcript' + }) + }) }) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index 802b724a2aa..d2b1523a96c 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -236,6 +236,7 @@ describe('worker transcript wire bounds', () => { const message = { id: `${transcriptPath}:0000000000000042`, turnId: `${transcriptPath}:0000000000000001`, + parentId: `${transcriptPath}:0000000000000041`, role: 'assistant' as const, timestamp: null, source: 'transcript' as const, @@ -248,6 +249,12 @@ describe('worker transcript wire bounds', () => { expect(first.messages).toEqual(second.messages) expect(first.messages[0]?.id).toMatch(/^worker-message-/) expect(first.messages[0]?.turnId).toMatch(/^worker-message-/) + // The parent link stays joinable to the parent row's opaque id. + const parent = boundWorkerTranscriptMessages( + [{ ...message, id: message.parentId, parentId: undefined }], + transcriptPath + ) + expect(first.messages[0]?.parentId).toBe(parent.messages[0]?.id) expect(first.messages[0]?.blocks[0]).toEqual({ type: 'image-ref' }) expect(JSON.stringify(first)).not.toContain('Users') expect(first.warnings).toEqual( diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index 684dfc771e2..2a05d74a645 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -114,6 +114,9 @@ function boundMessage( ...served, id: boundIdentifier(message.id, transcriptPath, state), ...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}), + ...(message.parentId + ? { parentId: boundIdentifier(message.parentId, transcriptPath, state) } + : {}), blocks: blocks.map((block) => boundBlock(block, state)) } } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d..29f7b099109 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -63,6 +63,7 @@ const NativeChatComposerPane = forwardRef { expect(disclosure?.querySelector('summary')).not.toHaveTextContent('Check the configuration') expect(disclosure?.querySelector('pre')).toHaveTextContent('Check the configuration') }) + it('keeps the column layout of command output in monospace', () => { + const text = + 'Context Usage\n⛁ ⛁ ⛶ gpt-4o · 16.6k/128k tokens (13%)\n ⛁ Skills: 304 tokens' + render( + + ) + const output = screen.getByText(/Context Usage/) + expect(output.tagName).toBe('PRE') + expect(output).toHaveClass('font-mono') + expect(output.textContent).toBe(text) + }) // The host's text is only for a client that can't word the row itself. it.each([ ['history-repaired', "Part of this chat's history couldn't be loaded."], diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index bc9b0a348d6..b63eb73abd1 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -49,6 +49,14 @@ export function NativeChatNoticeRow({ ) } + if (block.presentation === 'command-output') { + // Why: command output is laid out in columns; proportional type breaks its grid. + return ( +
+        {block.text}
+      
+ ) + } if (isAgentSessionHostStatusPresentation(block.presentation)) { // The look of any other host status line; only the words are the reader's. return ( diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 7c9c3f8337e..6adf30e1e50 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -46,6 +46,7 @@ import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import type { NativeChatResolvedViewProps } from './native-chat-view-types' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { useNativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' import { matchNativeChatSplitShortcut } from './native-chat-split-shortcut' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { formatShortcutLabel } from '@/hooks/useShortcutLabel' @@ -180,8 +181,8 @@ export function NativeChatResolvedView({ [record] ) const onSlashCommand = useCallback( - (command: string) => { - setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command)) + (command: string, output?: string) => { + setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command, Date.now(), output)) }, [commandMarkerScope] ) @@ -203,6 +204,8 @@ export function NativeChatResolvedView({ ? sessionWithLaunchPrompt : { ...sessionWithLaunchPrompt, messages } }, [sessionWithLaunchPrompt, commandMarkers]) + // Why: answer from the conversation the pane shows, so a `/clear` sent here reads as reset. + const answerLocally = useNativeChatLocalCommandAnswer(agent, sessionAfterCommandBoundaries) const launchPromptDeliveryNotices = useNativeChatLaunchPromptDeliveryNotice( paneLaunchPrompt?.failed ? launchPromptMessage?.id : null, sessionAfterCommandBoundaries.messages @@ -396,6 +399,7 @@ export function NativeChatResolvedView({ onOptimisticSendCanceled={delivery.cancel} optimisticSendOutcome={delivery} onSlashCommand={onSlashCommand} + answerCommandLocally={answerLocally} onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }} diff --git a/src/renderer/src/components/native-chat/native-chat-command-marker.ts b/src/renderer/src/components/native-chat/native-chat-command-marker.ts index 9625cee5166..33461479ff6 100644 --- a/src/renderer/src/components/native-chat/native-chat-command-marker.ts +++ b/src/renderer/src/components/native-chat/native-chat-command-marker.ts @@ -14,6 +14,8 @@ export type NativeChatCommandMarker = { /** The command as typed, e.g. `/clear`. */ command: string sentAt: number + /** What the host answered, for a command the agent never saw. */ + output?: string } export type NativeChatCommandMarkerScope = { @@ -39,7 +41,8 @@ export function readCommandMarkerCache( export function appendCommandMarkerCache( scope: NativeChatCommandMarkerScope, command: string, - sentAt = Date.now() + sentAt = Date.now(), + output?: string ): NativeChatCommandMarker[] { commandMarkerCounter += 1 const key = commandMarkerScopeKey(scope) @@ -47,7 +50,12 @@ export function appendCommandMarkerCache( // are not transcript turns, so their local feedback needs a pane-scoped cache. const next = [ ...(commandMarkerCache.get(key) ?? []), - { id: `${sentAt}-${commandMarkerCounter}`, command, sentAt } + { + id: `${sentAt}-${commandMarkerCounter}`, + command, + sentAt, + ...(output === undefined ? {} : { output }) + } ].slice(-COMMAND_MARKER_LIMIT) // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, // sessionId changes on every /clear) is ephemeral and was never evicted, so it @@ -92,14 +100,16 @@ export function applyCommandMarkerBoundaries( /** Render command markers as compact `system` messages. The `system` role draws * as a muted aside (not a user bubble); the text avoids the harness noise - * prefixes so stripNoiseMessages keeps it. */ + * prefixes so stripNoiseMessages keeps it. A host-answered command shows its + * answer in place of the `Ran` line: the answer is the feedback. */ export function commandMarkersAsMessages( markers: readonly NativeChatCommandMarker[] ): NativeChatMessage[] { return markers.map((marker) => ({ id: `command:${marker.id}`, role: 'system' as const, - blocks: [{ type: 'text' as const, text: `Ran ${marker.command}` }], + // Why: a host answer is one sentence, so it reads as the aside it replaces, not a grid. + blocks: [{ type: 'text' as const, text: marker.output ?? `Ran ${marker.command}` }], timestamp: marker.sentAt, source: 'scrape' as const })) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 922d16924f3..3ecde4cc22f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -9,6 +9,7 @@ import type { } from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' export type NativeChatOptionPickerRequest = { id: string @@ -65,8 +66,11 @@ export type NativeChatComposerProps = { optimisticSendOutcome?: NativeChatOptimisticSendOutcome /** Remove an optimistic echo when its delayed submit is canceled. */ onOptimisticSendCanceled?: (pendingId: string) => void - /** Record a dispatched slash command that does not create a chat turn. */ - onSlashCommand?: (command: string) => void + /** Record a dispatched slash command that does not create a chat turn; `output` + * carries the host's answer when the agent never saw the command. */ + onSlashCommand?: (command: string, output?: string) => void + /** The host's own answer to a command the agent must not see, or null to send it. */ + answerCommandLocally?: NativeChatLocalCommandAnswer /** Picker-only agent commands continue in the hosted TUI after dispatch. */ onSwitchToTerminal?: () => void /** Reads the hosted TUI's current rendered screen when chat is entered. */ diff --git a/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx b/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx new file mode 100644 index 00000000000..3630860d0a7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx @@ -0,0 +1,261 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' +import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' +import { + answerNativeChatLocalCommand, + type NativeChatLocalCommandAnswer +} from './use-native-chat-local-command-answer' +import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' + +const mocks = vi.hoisted(() => ({ + sendNativeChatMessage: vi.fn(() => ({ id: 'send' })), + sendNativeChatMessageWithImageAttachments: vi.fn(() => ({ id: 'image-send' })) +})) + +vi.mock('../../store', () => { + const state = { clearNativeChatLaunchDraft: vi.fn() } + return { useAppStore: { getState: () => state } } +}) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: mocks.sendNativeChatMessage, + sendNativeChatTypedCommand: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: mocks.sendNativeChatMessageWithImageAttachments +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) + +// `omp models --json` rows as OMP 17.0.5 prints them, reduced to what discovery keeps. +const DISCOVERED: CatalogModel[] = [ + { id: 'openai-codex/gpt-5.5', label: 'GPT-5.5', contextWindowTokens: 272_000, options: [] }, + { id: 'openai-codex/gpt-5.4', label: 'GPT-5.4', contextWindowTokens: 1_000_000, options: [] }, + { id: 'anthropic/claude-opus', label: 'Claude Opus', options: [] } +] + +// A reply as OMP records it: provider and model on the row, usage per request. +function response( + promptTokens: number, + timestamp: number, + model = 'gpt-5.5', + provider = 'openai-codex' +): NativeChatMessage { + return { + id: `a-${timestamp}`, + role: 'assistant', + blocks: [{ type: 'text', text: 'ok' }], + timestamp, + source: 'transcript', + model, + provider, + usage: { + inputTokens: 680, + cacheCreationInputTokens: 0, + cacheReadInputTokens: promptTokens - 680, + outputTokens: 16 + } + } +} + +function liveSurface(models?: CatalogModel[]) { + return createNativeChatPtySessionOptions({ + agent: 'omp', + scopeKey: 'pty-context', + ...(models ? { initialModels: models } : {}), + mode: 'live', + reportedValues: { model: 'openai-codex/gpt-5.5' }, + dispatchCommand: vi.fn() + })! +} + +beforeEach(() => { + clearNativeChatSessionOptionCacheForTests() + mocks.sendNativeChatMessage.mockClear() + mocks.sendNativeChatMessageWithImageAttachments.mockClear() +}) + +describe('the context window the model listing states', () => { + it('reads the window of a listed model once discovery has run', () => { + const surface = liveSurface(DISCOVERED) + expect(surface.contextWindowTokens('openai-codex/gpt-5.4')).toBe(1_000_000) + expect(surface.contextWindowTokens('anthropic/claude-opus')).toBeNull() + expect(surface.contextWindowTokens('unlisted/model')).toBeNull() + }) + + it('knows no window before discovery', () => { + expect(liveSurface().contextWindowTokens('openai-codex/gpt-5.5')).toBeNull() + }) + + it('follows a later discovery result', () => { + const surface = liveSurface() + surface.replaceModels(DISCOVERED) + expect(surface.contextWindowTokens('openai-codex/gpt-5.5')).toBe(272_000) + }) +}) + +describe('answerNativeChatLocalCommand', () => { + const surface = liveSurface(DISCOVERED) + + function answer(args: { messages: NativeChatMessage[]; command?: string }): string | null { + return answerNativeChatLocalCommand({ + agent: 'omp', + command: args.command ?? '/context', + messages: args.messages, + contextWindowTokens: surface.contextWindowTokens + }) + } + + it('states the window of the model that served the last response', () => { + expect(answer({ messages: [response(21_672, 10)] })).toBe( + 'Context: 21.7k / 272k tokens (8%), estimated from the last response.' + ) + // A switch in the TUI shows up on the next reply, not in the picker. + expect(answer({ messages: [response(21_672, 10), response(450_000, 20, 'gpt-5.4')] })).toBe( + 'Context: 450k / 1M tokens (45%), estimated from the last response.' + ) + }) + + it('gives the used figure alone when the listing states no window', () => { + const used = 'Context: 54.6k tokens used, estimated from the last response.' + expect(answer({ messages: [response(54_600, 10, 'claude-opus', 'anthropic')] })).toBe(used) + // The same bare model under a provider the listing does not have. + expect(answer({ messages: [response(54_600, 10, 'gpt-5.5', 'openrouter')] })).toBe(used) + }) + + it('reports nothing between a compaction and the next response', () => { + const unavailable = + 'Context usage is not known yet. It becomes available after the agent next responds.' + const compaction: NativeChatMessage = { + id: 'c', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp: 20, + source: 'transcript' + } + const messages = [response(200_000, 10), compaction] + expect(answer({ messages })).toBe(unavailable) + expect(answer({ messages: [...messages, response(30_000, 30)] })).toBe( + 'Context: 30k / 272k tokens (11%), estimated from the last response.' + ) + }) + + it('does not promise a later answer when the host never reports usage', () => { + const pending = + 'Context usage is not known yet. It becomes available after the agent next responds.' + const { + model: _model, + provider: _provider, + usage: _usage, + ...olderHostRow + } = response(54_600, 10) + // An older host decodes the same reply without its model or usage. + expect(answer({ messages: [olderHostRow] })).toBe( + 'Context usage is not available for this session.' + ) + expect(answer({ messages: [] })).toBe(pending) + // A live preview is not an answer the host decoded. + expect(answer({ messages: [{ ...olderHostRow, source: 'hook' }] })).toBe(pending) + }) + + it('leaves every other command, and other agents, to the agent', () => { + expect(answer({ messages: [], command: '/context all' })).not.toBeNull() + expect(answer({ messages: [], command: '/compact' })).toBeNull() + expect(answer({ messages: [], command: '/contextual' })).toBeNull() + expect(answer({ messages: [], command: 'what is my /context' })).toBeNull() + expect( + answerNativeChatLocalCommand({ + agent: 'openclaude', + command: '/context', + messages: [], + contextWindowTokens: surface.contextWindowTokens + }) + ).toBeNull() + }) +}) + +describe('host-answered /context in the composer', () => { + const target = { ptyId: 'pty-1', settings: {} } + + function composerArgs(answerCommandLocally: NativeChatLocalCommandAnswer) { + return { + agent: 'omp' as const, + disabled: false, + isDispatchingSessionOption: false, + resolveTarget: () => target, + onSlashCommand: vi.fn(), + answerCommandLocally, + sessionOptionsSurface: liveSurface(DISCOVERED), + trackPendingSend: vi.fn(), + setHistory: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + clearSkillOrigin: vi.fn(), + clearImageAttachments: vi.fn(), + setNotice: vi.fn() + } + } + + function typedSend(draft: string, answerCommandLocally: NativeChatLocalCommandAnswer) { + const args = { + ...composerArgs(answerCommandLocally), + draft, + imageAttachments: [{ path: '/tmp/shot.png' }], + launchDraftResolved: true, + classifySend: () => 'command' as const, + terminalTabId: 'tab-1' + } + const { result } = renderHook(() => useNativeChatPtyComposerSend(args)) + result.current() + return args + } + + it('answers a typed /context in the chat with the listing windows, keeping attachments', () => { + const answerCommandLocally = vi.fn( + () => 'Context: 450k / 1M tokens (45%)' + ) + const args = typedSend('/context', answerCommandLocally) + expect(answerCommandLocally).toHaveBeenCalledWith('/context', expect.any(Function)) + const windowFor = answerCommandLocally.mock.calls[0]![1] + expect(windowFor('openai-codex/gpt-5.4')).toBe(1_000_000) + expect(args.onSlashCommand).toHaveBeenCalledWith('/context', 'Context: 450k / 1M tokens (45%)') + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageWithImageAttachments).not.toHaveBeenCalled() + expect(args.clearImageAttachments).not.toHaveBeenCalled() + expect(args.setDraft).toHaveBeenCalledWith('') + }) + + it('still sends a command the host does not answer, attachments included', () => { + const args = typedSend('/compact', () => null) + expect(mocks.sendNativeChatMessageWithImageAttachments).toHaveBeenCalled() + expect(args.onSlashCommand).toHaveBeenCalledWith('/compact') + }) + + it('answers a picked /context the same way as a typed one', () => { + const answerCommandLocally = vi.fn( + () => 'Context: 450k / 1M tokens (45%)' + ) + const args = { ...composerArgs(answerCommandLocally), setActiveSuggestion: vi.fn() } + const { result } = renderHook(() => useNativeChatPickerCommandDispatch(args)) + result.current({ + kind: 'command', + id: 'command:context', + name: 'context', + token: '/context', + skillCollision: false + }) + expect(answerCommandLocally.mock.calls[0]![1]('openai-codex/gpt-5.5')).toBe(272_000) + expect(args.onSlashCommand).toHaveBeenCalledWith('/context', 'Context: 450k / 1M tokens (45%)') + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(args.clearImageAttachments).not.toHaveBeenCalled() + expect(args.setHistory).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts b/src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts new file mode 100644 index 00000000000..aa24ef1f641 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-context-usage-answer.ts @@ -0,0 +1,38 @@ +import { translate } from '@/i18n/i18n' +import type { NativeChatContextUsage } from '../../../../shared/native-chat-context-usage' +import { formatContextTokenCount } from './native-chat-context-usage-summary' + +/** The chat host's answer to `/context` over a terminal session. */ +export function formatNativeChatContextUsageAnswer(usage: NativeChatContextUsage | null): string { + if (!usage) { + return translate( + 'components.native-chat.context.unavailable', + 'Context usage is not known yet. It becomes available after the agent next responds.' + ) + } + // Why: a guessed window would print a plausible but wrong percentage. + if (usage.windowTokens === null || usage.percentage === null) { + return translate( + 'components.native-chat.context.used', + 'Context: {{used}} tokens used, estimated from the last response.', + { used: formatContextTokenCount(usage.usedTokens) } + ) + } + return translate( + 'components.native-chat.context.summary', + 'Context: {{used}} / {{window}} tokens ({{percent}}%), estimated from the last response.', + { + used: formatContextTokenCount(usage.usedTokens), + window: formatContextTokenCount(usage.windowTokens), + percent: String(usage.percentage) + } + ) +} + +/** For a session whose messages will never carry usage, so no later reply helps. */ +export function formatNativeChatContextUsageUnreported(): string { + return translate( + 'components.native-chat.context.unreported', + 'Context usage is not available for this session.' + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts new file mode 100644 index 00000000000..ea18d1a175f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { prepareNativeChatLiveMessages } from './native-chat-live-message-preparation' +import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' + +const row = (id: string, text: string, source: NativeChatMessage['source'] = 'transcript') => ({ + id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }], + timestamp: 100, + source +}) + +const CONTEXT_ROWS: NativeChatMessage[] = [ + row('b-envelope', '/context\n'), + { + ...row( + 'a-stdout', + ' \u001b[1mContext Usage\u001b[22m' + ), + parentId: 'b-envelope' + } +] + +function visibleText(agent: 'openclaude' | 'claude', messages: NativeChatMessage[]): string[] { + return createNativeChatMessageListProjection()( + prepareNativeChatLiveMessages(messages, agent) + ).conversation.flatMap((message) => + message.blocks.map((block) => (block.type === 'text' ? block.text : '')) + ) +} + +describe('prepareNativeChatLiveMessages command output', () => { + it("lists OpenClaude's /context report in the chat", () => { + expect(visibleText('openclaude', CONTEXT_ROWS)).toEqual(['Context Usage']) + // A live hook preview alongside the transcript takes the reassembly path. + expect( + visibleText('openclaude', [...CONTEXT_ROWS, row('hook', 'next prompt', 'hook')]) + ).toEqual(['Context Usage', 'next prompt']) + }) + + it('keeps a later /model reply hidden after a /context report', () => { + const modelRows = [ + { ...row('d-envelope', '/model'), timestamp: 300 }, + { + ...row('c-stdout', 'Set model to gpt-4o'), + timestamp: 300, + parentId: 'd-envelope' + } + ] + // `/model` is outside the catalog, so its envelope surfaces as the typed turn + // and its linked reply stays hidden. + expect(visibleText('openclaude', [...CONTEXT_ROWS, ...modelRows])).toEqual([ + 'Context Usage', + '/model' + ]) + }) + + it('keeps the reply row hidden for Claude', () => { + // Outside Claude's catalog the envelope reads as the typed turn, as on main. + expect(visibleText('claude', CONTEXT_ROWS)).toEqual(['/context']) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts index 3744c70d570..bed16d90663 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts @@ -1,5 +1,6 @@ import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { surfaceSkillInvocationUserTurns } from '../../../../shared/native-chat-command-envelope' +import { surfaceNativeChatCommandOutputs } from '../../../../shared/native-chat-command-output' import { normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers' import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' import { assembleNativeChatSession } from './native-chat-session-assembler' @@ -9,7 +10,10 @@ export function prepareNativeChatLiveMessages( agent: AgentType ): NativeChatMessage[] { const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) - const surfaced = surfaceSkillInvocationUserTurns(messages, commandNames) + const surfaced = surfaceNativeChatCommandOutputs( + surfaceSkillInvocationUserTurns(messages, commandNames), + agent + ) const normalized = normalizeImageTranscriptMessages(surfaced) if (!hasMixedSources(normalized)) { return normalized diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index 8a6d57f4604..496b4304fb8 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -802,3 +802,14 @@ describe('scope-cache key counts stay bounded (memory-leak regression)', () => { ) }) }) + +describe('commandMarkersAsMessages with a host answer', () => { + it('shows the answer in place of the Ran line', () => { + const [message] = commandMarkersAsMessages([ + { id: 'c2', command: '/context', sentAt: 9, output: 'Context: 54.6k / 200k tokens (27%)' } + ]) + expect(message?.role).toBe('system') + // Plain text: it draws as the muted aside the Ran line would have been. + expect(message?.blocks).toEqual([{ type: 'text', text: 'Context: 54.6k / 200k tokens (27%)' }]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index 0fe37c388e6..242fc813df3 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -40,6 +40,8 @@ export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & { recordOutgoingCommand(command: string): void reportSessionOptions(values: Record): void replaceModels(models: CatalogModel[]): void + /** The context window the host's model listing states for `modelId`, or null. */ + contextWindowTokens(modelId: string): number | null } export type CreateNativeChatPtySessionOptionsArgs = { @@ -221,6 +223,8 @@ export function createNativeChatPtySessionOptions( modelsAreDiscovered = true untrackRetiredModel() publish() - } + }, + contextWindowTokens: (modelId) => + models.find((model) => model.id === modelId)?.contextWindowTokens ?? null } } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index 4fa7e279878..b4520a225c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -155,6 +155,7 @@ export async function discoverNativeChatCatalogModels( label: model.label, ...(model.description ? { description: model.description } : {}), ...(model.isDefault ? { isDefault: true as const } : {}), + ...(model.contextWindowTokens ? { contextWindowTokens: model.contextWindowTokens } : {}), options: agent === 'claude' ? createClaudeCatalogOptions({ diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 15d890f319e..aaa8c7c04a8 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -164,6 +164,37 @@ describe('native chat session option enrichment', () => { ) }) + it('carries the context window OMP lists for each model, absent from an older host', async () => { + mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'probe', + models: [ + { + id: 'openai-codex/gpt-5.5', + label: 'GPT-5.5', + description: 'openai-codex', + contextWindowTokens: 272_000 + }, + { id: 'openai-codex/gpt-5.4', label: 'GPT-5.4', description: 'openai-codex' } + ] + }) + const models = await discoverNativeChatCatalogModels('omp', { + settings: {}, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + }) + expect(models).toEqual([ + { + id: 'openai-codex/gpt-5.5', + label: 'GPT-5.5', + description: 'openai-codex', + contextWindowTokens: 272_000, + options: [] + }, + { id: 'openai-codex/gpt-5.4', label: 'GPT-5.4', description: 'openai-codex', options: [] } + ]) + }) + it('uses only discovered Claude rows and capabilities per host', async () => { mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ success: true, diff --git a/src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts b/src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts new file mode 100644 index 00000000000..0a330c1fd39 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-local-command-answer.ts @@ -0,0 +1,104 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { getNativeChatCommandReply } from '../../../../shared/native-chat-agent-profiles' +import { deriveNativeChatContextUsage } from '../../../../shared/native-chat-context-usage' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { ompModelSelector } from '../../../../shared/omp-model-list-probe' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { + formatNativeChatContextUsageAnswer, + formatNativeChatContextUsageUnreported +} from './native-chat-context-usage-answer' +import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' + +/** The context window the host's model listing states for a model id, or null. */ +export type NativeChatModelContextWindow = (modelId: string) => number | null + +/** Answers a command the chat host owns over a terminal session, or null to send it. */ +export type NativeChatLocalCommandAnswer = ( + command: string, + contextWindowTokens: NativeChatModelContextWindow +) => string | null + +/** OMP's `/context` paints a panel the chat never sees, so the host replies from the + * prompt size the last response reported, against the window of the model that + * served it. Null for any command whose catalog row is not composer-answered. */ +export function answerNativeChatLocalCommand(args: { + agent: AgentType + command: string + messages: readonly NativeChatMessage[] + contextWindowTokens: NativeChatModelContextWindow +}): string | null { + const name = /^\/(\S+)/.exec(args.command)?.[1] + if (name !== 'context' || getNativeChatCommandReply(args.agent, name) !== 'composer') { + return null + } + if (!sessionReportsUsage(args.messages)) { + return formatNativeChatContextUsageUnreported() + } + const usage = deriveNativeChatContextUsage(args.messages, (message) => { + // Why: several providers share a bare model id; the listing keys by selector. + const selector = ompModelSelector(message.provider, message.model) + return selector ? args.contextWindowTokens(selector) : null + }) + return formatNativeChatContextUsageAnswer(usage) +} + +/** False when the agent has answered yet no answer names its model: a host that + * predates usage decoding, or a scraped view, will never report usage. */ +function sessionReportsUsage(messages: readonly NativeChatMessage[]): boolean { + let answered = false + for (const message of messages) { + if (message.role !== 'assistant' || message.source === 'hook') { + continue + } + if (message.model !== undefined) { + return true + } + answered = true + } + return !answered +} + +export function useNativeChatLocalCommandAnswer( + agent: AgentType, + { messages }: { messages: readonly NativeChatMessage[] } +): NativeChatLocalCommandAnswer { + return useCallback( + (command, contextWindowTokens) => + answerNativeChatLocalCommand({ agent, command, messages, contextWindowTokens }), + [agent, messages] + ) +} + +/** The send paths' shared intercept: a composer-answered command is answered in + * place of reaching the PTY. Attachments stay armed for the next prompt. Returns + * false when the command must be sent. */ +export function answerNativeChatCommandInComposer(args: { + draft: string + answerCommandLocally?: NativeChatLocalCommandAnswer + sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null + onSlashCommand?: (command: string, output?: string) => void + setHistory: Dispatch> + setDraft: (value: string) => void + setCaret: Dispatch> + clearSkillOrigin: () => void + setNotice: Dispatch> +}): boolean { + const command = args.draft.trim() + const answer = + args.answerCommandLocally?.( + command, + (modelId) => args.sessionOptionsSurface?.contextWindowTokens(modelId) ?? null + ) ?? null + if (answer === null) { + return false + } + args.onSlashCommand?.(command, answer) + args.setHistory((previous) => pushHistory(previous, args.draft)) + args.setDraft('') + args.setCaret(0) + args.clearSkillOrigin() + args.setNotice(null) + return true +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts index 0022514fd97..1c983733dd9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -17,13 +17,18 @@ import { } from './native-chat-composer-state' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' +import { + answerNativeChatCommandInComposer, + type NativeChatLocalCommandAnswer +} from './use-native-chat-local-command-answer' export function useNativeChatPickerCommandDispatch(args: { agent: AgentType disabled: boolean isDispatchingSessionOption: boolean resolveTarget: () => NativeChatResolvedTarget | null - onSlashCommand?: (command: string) => void + onSlashCommand?: (command: string, output?: string) => void + answerCommandLocally?: NativeChatLocalCommandAnswer sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] setHistory: Dispatch> @@ -40,6 +45,7 @@ export function useNativeChatPickerCommandDispatch(args: { isDispatchingSessionOption, resolveTarget, onSlashCommand, + answerCommandLocally, sessionOptionsSurface, trackPendingSend, setHistory, @@ -57,6 +63,24 @@ export function useNativeChatPickerCommandDispatch(args: { if (!target || disabled || isDispatchingSessionOption) { return } + if ( + answerNativeChatCommandInComposer({ + draft: text, + answerCommandLocally, + sessionOptionsSurface, + onSlashCommand, + setHistory, + setDraft, + setCaret, + clearSkillOrigin, + setNotice + }) + ) { + emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' }) + emitNativeChatSendClassified({ agent, outcome: 'command' }) + setActiveSuggestion(0) + return + } trackPendingSend( agent === 'codex' ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) @@ -83,6 +107,7 @@ export function useNativeChatPickerCommandDispatch(args: { }, [ agent, + answerCommandLocally, clearImageAttachments, clearSkillOrigin, disabled, diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts index 385396190e4..e38e0282f4d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -19,6 +19,10 @@ import type { NativeChatPickerState } from './use-native-chat-picker-state' import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' import type { NativeChatOptimisticSendOutcome } from './native-chat-composer-types' +import { + answerNativeChatCommandInComposer, + type NativeChatLocalCommandAnswer +} from './use-native-chat-local-command-answer' export function useNativeChatPtyComposerSend(args: { agent: AgentType @@ -33,7 +37,8 @@ export function useNativeChatPtyComposerSend(args: { classifySend: NativeChatPickerState['classifySend'] onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined optimisticSendOutcome?: NativeChatOptimisticSendOutcome - onSlashCommand?: (command: string) => void + onSlashCommand?: (command: string, output?: string) => void + answerCommandLocally?: NativeChatLocalCommandAnswer sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null terminalTabId: string trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] @@ -59,6 +64,9 @@ export function useNativeChatPtyComposerSend(args: { return } const classification = args.classifySend(text) + if (classification === 'command' && answerNativeChatCommandInComposer(args)) { + return + } const { sendOptions: launchSendOptions } = resolveNativeChatLaunchDraftSend({ launchDraft: args.launchDraft, launchDraftResolved: args.launchDraftResolved, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a623d23664c..25087a71ea2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18139,6 +18139,12 @@ "subtitle": "Files are added to your message as paths the agent can read." }, "copyCode": "Copy code", + "context": { + "unavailable": "Context usage is not known yet. It becomes available after the agent next responds.", + "summary": "Context: {{used}} / {{window}} tokens ({{percent}}%), estimated from the last response.", + "used": "Context: {{used}} tokens used, estimated from the last response.", + "unreported": "Context usage is not available for this session." + }, "goal": { "placeholder": "Describe your goal, define measurable outcomes for best results", "clear": "Clear goal", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 40f587ab41b..ec2d25bbb34 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17954,6 +17954,12 @@ "subtitle": "Los archivos se añaden a tu mensaje como rutas que el agente puede leer." }, "copyCode": "Copiar código", + "context": { + "unavailable": "El uso del contexto aún no se conoce. Estará disponible cuando el agente vuelva a responder.", + "summary": "Contexto: {{used}} / {{window}} tokens ({{percent}} %), estimado a partir de la última respuesta.", + "used": "Contexto: {{used}} tokens usados, estimado a partir de la última respuesta.", + "unreported": "El uso del contexto no está disponible para esta sesión." + }, "goal": { "placeholder": "Describe tu objetivo y define resultados medibles para obtener mejores resultados", "clear": "Borrar objetivo", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 606c0455062..333d821d346 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18016,6 +18016,12 @@ "subtitle": "Les fichiers sont ajoutés à votre message sous forme de chemins que l'agent peut lire." }, "copyCode": "Copier le code", + "context": { + "unavailable": "L'utilisation du contexte n'est pas encore connue. Elle sera disponible après la prochaine réponse de l'agent.", + "summary": "Contexte : {{used}} / {{window}} tokens ({{percent}} %), estimation basée sur la dernière réponse.", + "used": "Contexte : {{used}} tokens utilisés, estimation basée sur la dernière réponse.", + "unreported": "L'utilisation du contexte n'est pas disponible pour cette session." + }, "goal": { "placeholder": "Décrivez votre objectif, définissez des résultats mesurables pour de meilleurs résultats", "clear": "Objectif clair", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6d1b62a60fa..5d1678b46d0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17952,6 +17952,12 @@ "subtitle": "ファイルは、Agent が読み取ることができるパスとしてメッセージに追加されます。" }, "copyCode": "コードをコピーする", + "context": { + "unavailable": "コンテキストの使用量はまだ不明です。Agent が次に応答すると表示されます。", + "summary": "コンテキスト: {{used}} / {{window}} トークン ({{percent}}%)、最後の応答から推定。", + "used": "コンテキスト: {{used}} トークン使用済み、最後の応答から推定。", + "unreported": "このセッションではコンテキストの使用量を表示できません。" + }, "goal": { "placeholder": "目標を説明し、最良の結果を得るために測定可能な結果を​​定義する", "clear": "明確な目標", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d9827f02336..5cd0371e7be 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17952,6 +17952,12 @@ "subtitle": "파일은 에이전트가 읽을 수 있는 경로로 메시지에 추가됩니다." }, "copyCode": "코드 복사", + "context": { + "unavailable": "컨텍스트 사용량을 아직 알 수 없습니다. agent가 다음에 응답하면 확인할 수 있습니다.", + "summary": "컨텍스트: {{used}}/{{window}} 토큰 ({{percent}}%), 마지막 응답 기준 추정치입니다.", + "used": "컨텍스트: {{used}} 토큰 사용, 마지막 응답 기준 추정치입니다.", + "unreported": "이 세션에서는 컨텍스트 사용량을 확인할 수 없습니다." + }, "goal": { "placeholder": "목표를 설명하고, 최상의 결과를 위해 측정 가능한 결과를 정의하세요.", "clear": "명확한 목표", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 795419d9a84..3ce2dbdb7ba 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17917,6 +17917,12 @@ "subtitle": "文件将作为代理可以读取的路径添加到您的消息中。" }, "copyCode": "复制代码", + "context": { + "unavailable": "上下文用量暂时未知,将在智能体下次响应后提供。", + "summary": "上下文:{{used}}/{{window}} token({{percent}}%),根据上一次响应的估算值。", + "used": "上下文:已用 {{used}} token,根据上一次响应的估算值。", + "unreported": "此会话无法提供上下文用量。" + }, "goal": { "placeholder": "描述您的目标,定义可衡量的结果以获得最佳结果", "clear": "明确的目标", diff --git a/src/shared/agent-session-option-catalog-omp.test.ts b/src/shared/agent-session-option-catalog-omp.test.ts index b5bd44e32f3..d874a1bd0c6 100644 --- a/src/shared/agent-session-option-catalog-omp.test.ts +++ b/src/shared/agent-session-option-catalog-omp.test.ts @@ -43,6 +43,33 @@ describe('omp model list probe', () => { ]) }) + it('keeps the context window each row states', () => { + // Row shape as `omp models --json` prints it (OMP 17.0.5). + const listing = JSON.stringify({ + models: [ + { + provider: 'openai-codex', + id: 'gpt-5.5', + selector: 'openai-codex/gpt-5.5', + name: 'GPT-5.5', + contextWindow: 272000, + maxTokens: 128000 + }, + { provider: 'openai-codex', id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 1000000 }, + { provider: 'zai', id: 'glm', name: 'GLM', contextWindow: 0 }, + { provider: 'zai', id: 'glm-air', name: 'GLM Air', contextWindow: '128000' } + ] + }) + expect( + parseOmpModelList(listing).map(({ id, contextWindowTokens }) => ({ id, contextWindowTokens })) + ).toEqual([ + { id: 'openai-codex/gpt-5.5', contextWindowTokens: 272000 }, + { id: 'openai-codex/gpt-5.4', contextWindowTokens: 1000000 }, + { id: 'zai/glm', contextWindowTokens: undefined }, + { id: 'zai/glm-air', contextWindowTokens: undefined } + ]) + }) + it('tolerates an update notice printed ahead of the JSON', () => { const noisy = `Package updates are available. Run omp update\n${LISTING}\n` expect(parseOmpModelList(noisy).map(({ id }) => id)).toEqual([ diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index eafa7b349f1..cd426987b81 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -49,6 +49,8 @@ export type CatalogModel = { label: string description?: string isDefault?: boolean + /** Tokens the model's context window holds, where the host's listing states it. */ + contextWindowTokens?: number options: CatalogOption[] } diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 2d788739149..bd47acc5bac 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -40,6 +40,8 @@ export type CommitMessageModel = { /** Set when the listing marks this as the id the CLI runs with no --model flag. * Optional so an older remote host that never reports it simply omits it. */ isDefault?: boolean + /** Tokens the model's context window holds, where the listing states it. */ + contextWindowTokens?: number } export type CommitMessageAgentSpec = { @@ -78,6 +80,8 @@ export type CommitMessageModelCapability = { supportsFastMode?: boolean /** Absent from an older remote host, which simply yields no default to display. */ isDefault?: boolean + /** Absent from an older remote host; readers then treat the window as unknown. */ + contextWindowTokens?: number } export type CommitMessageAgentCapability = { diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts index 546a4bf9db0..00b006bec31 100644 --- a/src/shared/native-chat-agent-profiles.test.ts +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { + getAgentAnsweredNativeChatCommands, getHostClaimedNativeChatCommands, getNativeChatAgentProfile, + getNativeChatCommandReply, getVerifiedNativeChatCommands } from './native-chat-agent-profiles' @@ -56,3 +58,37 @@ describe('host-claimed native chat commands', () => { expect(names('grok')).toEqual([]) }) }) + +describe('native chat command replies', () => { + it('declares /context as answered by the composer for OMP and by a transcript row for OpenClaude', () => { + expect(getNativeChatCommandReply('omp', 'context')).toBe('composer') + expect(getNativeChatCommandReply('openclaude', 'context')).toBe('transcript') + // Claude and Codex answer /context in their structured sessions. + expect(getNativeChatCommandReply('claude', 'context')).toBeNull() + expect(getNativeChatCommandReply('codex', 'context')).toBeNull() + expect(getNativeChatCommandReply('omp', 'compact')).toBeNull() + }) + + it('declares only /context, the one reply the desktop chat implements', () => { + for (const agent of ['claude', 'openclaude', 'codex', 'omp', 'grok', 'custom-agent']) { + const declared = getVerifiedNativeChatCommands(agent).filter(({ reply }) => reply) + expect(declared.map(({ name }) => name)).toEqual( + agent === 'omp' || agent === 'openclaude' ? ['context'] : [] + ) + } + }) + + it('leaves out of the agent-answered catalog only the commands that declare a reply', () => { + const names = (agent: string) => + getAgentAnsweredNativeChatCommands(agent).map(({ name }) => name) + expect(names('openclaude')).toEqual(names('claude')) + expect(names('omp')).toEqual( + getVerifiedNativeChatCommands('omp') + .map(({ name }) => name) + .filter((name) => name !== 'context') + ) + expect(getAgentAnsweredNativeChatCommands('codex')).toEqual( + getVerifiedNativeChatCommands('codex') + ) + }) +}) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index 386584c1279..521755aab56 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -1,5 +1,9 @@ import type { AgentType } from './agent-status-types' -import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-chat-slash-commands' +import { + getAgentSlashCommands, + type NativeChatCommandReply, + type SlashCommandSuggestion +} from './native-chat-slash-commands' export type NativeChatAgentProfile = { skillPrefix: '$' | '/' @@ -56,6 +60,27 @@ export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashC return agent === 'grok' ? [] : getAgentSlashCommands(agent) } +/** How the chat finds the reply to a verified command over a terminal session; + * null when the agent answers it in the terminal or the command is unknown. */ +export function getNativeChatCommandReply( + agent: AgentType, + commandName: string +): NativeChatCommandReply | null { + return ( + getVerifiedNativeChatCommands(agent).find((command) => command.name === commandName)?.reply ?? + null + ) +} + +/** The verified catalog minus commands only the desktop chat answers: a surface + * without the composer's answers or the transcript's reply rows (mobile) would + * offer a command that appears to do nothing. */ +export function getAgentAnsweredNativeChatCommands( + agent: AgentType +): readonly SlashCommandSuggestion[] { + return getVerifiedNativeChatCommands(agent).filter((command) => command.reply === undefined) +} + /** The mirror of the claimed set: catalog commands this agent acts on when they * arrive as message text. The picker offers these too, so a command the agent * implements is discoverable and not merely typable. */ diff --git a/src/shared/native-chat-command-output.test.ts b/src/shared/native-chat-command-output.test.ts new file mode 100644 index 00000000000..baba5fad043 --- /dev/null +++ b/src/shared/native-chat-command-output.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { surfaceNativeChatCommandOutputs } from './native-chat-command-output' +import { stripNoiseMessages } from './native-chat-noise' +import type { NativeChatMessage } from './native-chat-types' + +// The two user rows OpenClaude 0.31.0 writes for `/context` (its caveat row is +// `isMeta` and never decoded), as the transcript decoder emits them. +const CONTEXT_ENVELOPE = + '/context\n context\n ' +const CONTEXT_STDOUT = + ' \u001b[1mContext Usage\u001b[22m\n\u001b[38;5;244m\u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u001b[38;5;246m\u26c1 \u26c1 \u26c1 \u26c1 \u001b[39m \u001b[38;5;246mgpt-4o \u00b7 16.6k/128k tokens (13%)\u001b[39m\n\n\u001b[38;5;246m\u26c1 \u26c1 \u26c0 \u001b[38;5;220m\u26c0 \u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u001b[3mEstimated usage by category\u001b[23m\u001b[39m\n \u001b[38;5;244m\u26c1\u001b[39m System prompt: \u001b[38;5;246m7.9k tokens (6.1%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u26c1\u001b[39m System tools: \u001b[38;5;246m8.5k tokens (6.6%)\u001b[39m\n \u001b[38;5;220m\u26c1\u001b[39m Skills: \u001b[38;5;246m304 tokens (0.2%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m \u001b[38;5;246m\u26f6\u001b[39m Free space: \u001b[38;5;246m65k (50.8%)\u001b[39m\n \u001b[38;5;246m\u26dd Autocompact buffer: 46.4k tokens (36.2%)\u001b[39m\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m\n\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u001b[39m\n\n\u001b[38;5;246m\u26f6 \u26f6 \u26f6 \u26f6 \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\u001b[38;5;246m\u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u26dd \u001b[39m\n\n\n\u001b[1mSkills\u001b[22m\u001b[38;5;246m \u00b7 /skills\u001b[39m' + +function userTurn( + id: string, + text: string, + timestamp: number | null = 100, + parentId?: string +): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript', + ...(parentId ? { parentId } : {}) + } +} + +const envelope = (name: string, id = 'env', timestamp = 100): NativeChatMessage => + userTurn(id, CONTEXT_ENVELOPE.replaceAll('context', name), timestamp) + +const reply = (id: string, parentId: string | undefined, timestamp = 100): NativeChatMessage => + userTurn(id, CONTEXT_STDOUT, timestamp, parentId) + +const MODEL_STDOUT = 'Set model to gpt-4o' + +describe('surfaceNativeChatCommandOutputs', () => { + it("shows OpenClaude's /context report as plain command output", () => { + const [shown] = surfaceNativeChatCommandOutputs( + [reply('out', 'env'), envelope('context')], + 'openclaude' + ) + expect(shown).toMatchObject({ id: 'out', role: 'system' }) + const block = shown?.blocks[0] + expect(block).toMatchObject({ type: 'text', presentation: 'command-output' }) + const text = block?.type === 'text' ? block.text : '' + expect(text).not.toContain('\u001b') + expect(text).not.toContain('local-command-stdout') + // Column layout survives: the legend stays indented past the grid. + expect(text.split('\n').slice(0, 5)).toEqual([ + 'Context Usage', + '\u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 \u26c1 gpt-4o \u00b7 16.6k/128k tokens (13%)', + '', + '\u26c1 \u26c1 \u26c0 \u26c0 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 \u26f6 Estimated usage by category', + ' \u26c1 System prompt: 7.9k tokens (6.1%)' + ]) + }) + + it('survives the noise filter while the command envelope stays hidden', () => { + const visible = stripNoiseMessages( + surfaceNativeChatCommandOutputs([envelope('context'), reply('out', 'env')], 'openclaude') + ) + expect(visible.map(({ id }) => id)).toEqual(['out']) + }) + + it('pairs each reply with the row it links to, whatever the timestamps say', () => { + // One batch, one timestamp, sorted by id: each reply sits beside the other + // command, and the /model command is the newest row at the /context reply. + const modelReply = userTurn('a-model-out', MODEL_STDOUT, 100, 'd-model') + const messages = [ + modelReply, + envelope('context', 'b-context', 100), + reply('c-context-out', 'b-context', 100), + envelope('model', 'd-model', 100) + ] + const surfaced = surfaceNativeChatCommandOutputs(messages, 'openclaude') + expect(surfaced.find(({ id }) => id === 'c-context-out')?.role).toBe('system') + // Replies to commands whose effect is the feedback stay hidden. + expect(surfaced.find(({ id }) => id === 'a-model-out')).toBe(modelReply) + // A reply stamped before its command row still answers it. + const early = [reply('out', 'env', 99), envelope('context', 'env', 100)] + expect(surfaceNativeChatCommandOutputs(early, 'openclaude')[0]?.role).toBe('system') + }) + + it('leaves a reply hidden without a link to a loaded /context row', () => { + // An older host sends no link, even for a reply right after its command. + const unlinked = [envelope('context'), reply('out', undefined)] + expect(surfaceNativeChatCommandOutputs(unlinked, 'openclaude')).toBe(unlinked) + // The command row fell outside the loaded tail window. + const windowed = [reply('out', 'env-before-window'), envelope('context', 'env-2', 200)] + expect(surfaceNativeChatCommandOutputs(windowed, 'openclaude')).toBe(windowed) + // Linked to a row that is not a command envelope. + const prose = [userTurn('env', 'what is /context'), reply('out', 'env')] + expect(surfaceNativeChatCommandOutputs(prose, 'openclaude')).toBe(prose) + }) + + it('leaves agents that declare no transcript reply untouched', () => { + const messages = [envelope('context'), reply('out', 'env')] + // Claude's TUI writes no reply row; one that appears is left to the noise filter. + expect(surfaceNativeChatCommandOutputs(messages, 'claude')).toBe(messages) + // OMP's /context is answered by the composer, never by a transcript row. + expect(surfaceNativeChatCommandOutputs(messages, 'omp')).toBe(messages) + }) +}) diff --git a/src/shared/native-chat-command-output.ts b/src/shared/native-chat-command-output.ts new file mode 100644 index 00000000000..2f38890df8a --- /dev/null +++ b/src/shared/native-chat-command-output.ts @@ -0,0 +1,75 @@ +// Claude-family harnesses record a local command's reply as a +// `` user turn linked to the command's envelope row. The +// noise filter hides those rows, which is right for commands whose effect is the +// feedback (`/model`, `/compact`). For a command that exists to report +// something, the reply is the answer, so the chat surfaces it. + +import type { AgentType } from './agent-status-types' +import { stripAnsiEscapeSequences } from './ansi-escape-sequences' +import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import { parseNativeChatCommandEnvelope } from './native-chat-command-envelope' +import { isTextBlock, type NativeChatMessage } from './native-chat-types' + +const LOCAL_COMMAND_STDOUT = /^\s*([\s\S]*?)<\/local-command-stdout>\s*$/ + +function userText(message: NativeChatMessage): string | null { + return message.role === 'user' && message.blocks.every(isTextBlock) + ? message.blocks.map((block) => block.text).join('\n') + : null +} + +/** The command a user turn's envelope names, without its slash; null for other turns. */ +function envelopeCommand(message: NativeChatMessage): string | null { + const text = userText(message) + const envelope = text === null ? null : parseNativeChatCommandEnvelope(text) + return envelope ? envelope.name.replace(/^\//, '') : null +} + +/** + * Replace the stdout row answering a command whose catalog row declares a + * transcript reply with its plain text as command output. A reply answers the + * row it is linked to; without that link (an older host, or the command row + * outside the loaded window) it stays hidden. + */ +export function surfaceNativeChatCommandOutputs( + messages: NativeChatMessage[], + agent: AgentType +): NativeChatMessage[] { + const transcriptReplies = new Set( + getVerifiedNativeChatCommands(agent) + .filter((command) => command.reply === 'transcript') + .map((command) => command.name) + ) + // Why: this runs on every transcript update; agents declaring no such reply skip the scan. + if (transcriptReplies.size === 0) { + return messages + } + let rowsById: Map | null = null + let changed = false + const out = messages.map((message) => { + if (message.parentId === undefined) { + return message + } + const stdout = LOCAL_COMMAND_STDOUT.exec(userText(message) ?? '')?.[1] + if (stdout === undefined) { + return message + } + rowsById ??= new Map(messages.map((row) => [row.id, row])) + const parent = rowsById.get(message.parentId) + const command = parent ? envelopeCommand(parent) : null + if (command === null || !transcriptReplies.has(command)) { + return message + } + const text = stripAnsiEscapeSequences(stdout).trim() + if (!text) { + return message + } + changed = true + return { + ...message, + role: 'system' as const, + blocks: [{ type: 'text' as const, text, presentation: 'command-output' }] + } + }) + return changed ? out : messages +} diff --git a/src/shared/native-chat-context-usage.test.ts b/src/shared/native-chat-context-usage.test.ts new file mode 100644 index 00000000000..0e541d53bb3 --- /dev/null +++ b/src/shared/native-chat-context-usage.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + deriveNativeChatContextUsage, + isNativeChatCompactionBoundary +} from './native-chat-context-usage' +import type { AgentSessionTokenUsage } from './agent-session-context-usage' +import type { NativeChatMessage } from './native-chat-types' + +function assistant( + id: string, + usage?: AgentSessionTokenUsage, + model = 'gpt-5.5' +): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 100, + source: 'transcript', + model, + provider: 'openai-codex', + ...(usage ? { usage } : {}) + } +} + +function usage(inputTokens: number, cacheRead = 0, cacheWrite = 0): AgentSessionTokenUsage { + return { + inputTokens, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + outputTokens: 16 + } +} + +const compaction: NativeChatMessage = { + id: 'compacted', + role: 'system', + blocks: [{ type: 'text', text: 'Context compacted', presentation: 'compaction' }], + timestamp: 150, + source: 'transcript' +} + +const noWindow = (): number | null => null + +describe('deriveNativeChatContextUsage', () => { + it('sums input and both cache counts of the newest response against its window', () => { + const windows: NativeChatMessage[] = [] + const derived = deriveNativeChatContextUsage( + [assistant('older', usage(5)), assistant('newest', usage(680, 20_992, 8))], + (message) => { + windows.push(message) + return 272_000 + } + ) + expect(derived).toEqual({ + usedTokens: 21_680, + windowTokens: 272_000, + percentage: 8 + }) + // The window is asked of the response that was measured, not the session's picker. + expect(windows.map(({ id }) => id)).toEqual(['newest']) + }) + + it('reports the used figure alone when the window is unknown', () => { + expect(deriveNativeChatContextUsage([assistant('a', usage(450_000))], noWindow)).toEqual({ + usedTokens: 450_000, + windowTokens: null, + percentage: null + }) + expect( + deriveNativeChatContextUsage([assistant('a', usage(10))], () => 0)?.windowTokens + ).toBeNull() + }) + + it('skips responses whose accounting does not reflect the prompt', () => { + const messages = [assistant('measured', usage(100)), assistant('aborted')] + expect(deriveNativeChatContextUsage(messages, noWindow)?.usedTokens).toBe(100) + }) + + it('knows nothing after a compaction until the next response lands', () => { + const before = [assistant('before', usage(200_000)), compaction] + expect(deriveNativeChatContextUsage(before, noWindow)).toBeNull() + expect( + deriveNativeChatContextUsage([...before, assistant('after', usage(30_000))], noWindow) + ?.usedTokens + ).toBe(30_000) + }) + + it('is null before any response carried usage', () => { + expect(deriveNativeChatContextUsage([], noWindow)).toBeNull() + expect(deriveNativeChatContextUsage([assistant('zero', usage(0))], noWindow)).toBeNull() + }) +}) + +describe('isNativeChatCompactionBoundary', () => { + it('matches only the compaction presentation', () => { + expect(isNativeChatCompactionBoundary(compaction)).toBe(true) + expect( + isNativeChatCompactionBoundary({ + ...compaction, + blocks: [{ type: 'text', text: 'Context compacted' }] + }) + ).toBe(false) + }) +}) diff --git a/src/shared/native-chat-context-usage.ts b/src/shared/native-chat-context-usage.ts new file mode 100644 index 00000000000..1bb23582b83 --- /dev/null +++ b/src/shared/native-chat-context-usage.ts @@ -0,0 +1,54 @@ +// Context usage a chat surface can derive from the transcript alone: the prompt +// the model read on the last request that reported usage, against the window of +// the model that served it. + +import { contextTokensFromUsage } from './agent-session-context-usage' +import type { NativeChatMessage } from './native-chat-types' + +export type NativeChatContextUsage = { + usedTokens: number + /** Null when the serving model's window is not known. */ + windowTokens: number | null + /** Rounded and never clamped: an over-limit turn reads above 100. Null with the window. */ + percentage: number | null +} + +/** Resolves a model's context window, or null when the host does not know it. */ +export type NativeChatContextWindowLookup = (message: NativeChatMessage) => number | null + +/** True for the transcript row an agent writes when it compacts the conversation. */ +export function isNativeChatCompactionBoundary(message: NativeChatMessage): boolean { + return ( + message.role === 'system' && + message.blocks.some((block) => block.type === 'text' && block.presentation === 'compaction') + ) +} + +/** The newest usage-bearing response decides; a compaction after it means the + * context it measured is gone, so nothing is known until the next response. */ +export function deriveNativeChatContextUsage( + messages: readonly NativeChatMessage[], + windowFor: NativeChatContextWindowLookup +): NativeChatContextUsage | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]! + if (isNativeChatCompactionBoundary(message)) { + return null + } + if (message.role !== 'assistant' || !message.usage) { + continue + } + const usedTokens = contextTokensFromUsage(message.usage) + if (usedTokens <= 0) { + return null + } + const window = windowFor(message) + const windowTokens = window !== null && window > 0 ? window : null + return { + usedTokens, + windowTokens, + percentage: windowTokens === null ? null : Math.round((usedTokens / windowTokens) * 100) + } + } + return null +} diff --git a/src/shared/native-chat-slash-commands.test.ts b/src/shared/native-chat-slash-commands.test.ts index 33219d8f7db..caccdb56162 100644 --- a/src/shared/native-chat-slash-commands.test.ts +++ b/src/shared/native-chat-slash-commands.test.ts @@ -22,6 +22,13 @@ describe('getAgentSlashCommands', () => { expect(names).toContain('clear') expect(names).toContain('compact') expect(names).not.toContain('model') + // Claude's terminal writes no /context reply the chat could show. + expect(names).not.toContain('context') + }) + + it('offers OpenClaude /context, whose report lands in its transcript', () => { + const names = getAgentSlashCommands('openclaude').map((c) => c.name) + expect(names).toEqual([...getAgentSlashCommands('claude').map((c) => c.name), 'context']) }) it('falls back to a small common set for an unknown agent (never empty)', () => { diff --git a/src/shared/native-chat-slash-commands.ts b/src/shared/native-chat-slash-commands.ts index d650c9b0f3a..9defeab6e4f 100644 --- a/src/shared/native-chat-slash-commands.ts +++ b/src/shared/native-chat-slash-commands.ts @@ -7,6 +7,12 @@ import type { AgentSessionSlashCommand } from './agent-session-wire' import type { AgentType } from './agent-status-types' +/** Where the chat finds a command's reply when the agent runs in a terminal. + * `composer`: the chat answers from state it holds and never sends the command. + * `transcript`: the agent writes its reply as a transcript row the chat shows. + * Absent: the agent answers in its own terminal, or the effect is the answer. */ +export type NativeChatCommandReply = 'composer' | 'transcript' + export type SlashCommandSuggestion = { /** The command token without its leading slash, e.g. `clear`. */ name: string @@ -15,6 +21,8 @@ export type SlashCommandSuggestion = { /** Provider-authored argument sketch, e.g. ``. */ argumentHint?: string kindUnspecified?: true + /** Declared only on curated rows; a surface that cannot show the reply drops the row. */ + reply?: NativeChatCommandReply } // Best-effort, curated per-agent catalogs. The CLIs ship no machine-readable @@ -34,6 +42,12 @@ const CLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'help', description: 'Show available commands' } ] +// Why: OpenClaude writes its `/context` report to the transcript; Claude's TUI does not. +const OPENCLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [ + ...CLAUDE_COMMANDS, + { name: 'context', description: 'Show context usage', reply: 'transcript' } +] + const CODEX_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'model', description: 'Choose the model and reasoning effort' }, { name: 'ide', description: 'Include IDE context' }, @@ -99,7 +113,8 @@ const OMP_COMMANDS: readonly SlashCommandSuggestion[] = [ { name: 'tree', description: 'Browse the session tree in Terminal' }, { name: 'session', description: 'Show session information and controls' }, { name: 'rename', description: 'Rename the session' }, - { name: 'context', description: 'Show estimated context usage' }, + // Why: OMP paints this in its TUI and records nothing the chat could show. + { name: 'context', description: 'Show estimated context usage', reply: 'composer' }, { name: 'usage', description: 'Show provider usage and limits' }, { name: 'fast', description: 'Toggle priority service tier' }, { name: 'tools', description: 'Show tools visible to the agent' }, @@ -113,7 +128,7 @@ const OMP_COMMANDS: readonly SlashCommandSuggestion[] = [ const COMMANDS_BY_AGENT: Partial> = { claude: CLAUDE_COMMANDS, - openclaude: CLAUDE_COMMANDS, + openclaude: OPENCLAUDE_COMMANDS, codex: CODEX_COMMANDS, omp: OMP_COMMANDS } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 7fd5fe66192..f48a31f7f38 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -10,6 +10,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' +import type { AgentSessionTokenUsage } from './agent-session-context-usage' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, @@ -207,9 +208,18 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { source: NativeChatSource /** Optional provider row cursor; split projections share it for whole-row paging. */ transcriptOffset?: number + /** Model id that produced an assistant response, as the provider API names it. */ + model?: string + /** The agent's provider that served `model`, where the agent records one. */ + provider?: string + /** On assistant responses whose accounting reflects the prompt the model read. */ + usage?: AgentSessionTokenUsage /** Optional explicit turn key. When present, two messages with the same * `turnId` are treated as the same turn for dedup regardless of `id`. */ turnId?: string + /** `id` of the transcript row this one follows in the agent's own conversation + * tree, where the decoder carries the agent's link. Absent from older hosts. */ + parentId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ diff --git a/src/shared/omp-model-list-probe.ts b/src/shared/omp-model-list-probe.ts index 1654b51ad90..a71c1dcb301 100644 --- a/src/shared/omp-model-list-probe.ts +++ b/src/shared/omp-model-list-probe.ts @@ -26,6 +26,14 @@ function parseJsonObject(stdout: string): unknown { } } +/** OMP's `provider/model` selector, the id its listing and `--model` share. */ +export function ompModelSelector( + provider: string | undefined, + model: string | undefined +): string | null { + return provider && model ? `${provider}/${model}` : null +} + /** Parses `omp models --json`. Ids are OMP's `provider/model` selector — the form * `--model` and `/model` resolve exactly, unlike a bare model id that several * providers can share. */ @@ -49,17 +57,24 @@ export function parseOmpModelList(stdout: string): CommitMessageModel[] { const bareId = 'id' in value && typeof value.id === 'string' ? value.id.trim() : '' const selector = 'selector' in value && typeof value.selector === 'string' ? value.selector.trim() : '' - const id = selector || (provider && bareId ? `${provider}/${bareId}` : '') + const id = selector || ompModelSelector(provider, bareId) if (!id || byId.has(id)) { continue } const name = 'name' in value && typeof value.name === 'string' ? value.name.trim() : '' + const contextWindow = + 'contextWindow' in value && typeof value.contextWindow === 'number' + ? value.contextWindow + : null byId.set(id, { id, label: name || labelFromModelId(id), // Why: the same model name ships under several providers; the provider is // what tells two "DeepSeek V4 Pro" rows apart in the picker. - ...(provider ? { description: provider } : {}) + ...(provider ? { description: provider } : {}), + ...(contextWindow !== null && Number.isFinite(contextWindow) && contextWindow > 0 + ? { contextWindowTokens: contextWindow } + : {}) }) } return [...byId.values()]