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
<local-command-stdout> 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 <noreply@anthropic.com>

* refactor(native-chat): pair command replies by row link and declare /context replies on the catalog

A `<local-command-stdout>` 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 <noreply@anthropic.com>
This commit is contained in:
Brennan Benson
2026-10-05 11:57:25 -07:00
committed by GitHub
co-authored by Claude
parent b354aab18e
commit ca2cdc0ad9
46 changed files with 1427 additions and 33 deletions
@@ -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
@@ -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')
@@ -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
)
@@ -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":"<local-command-caveat>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.</local-command-caveat>"},"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":"<command-name>/context</command-name>\n <command-message>context</command-message>\n <command-args></command-args>"},"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":"<local-command-stdout> \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</local-command-stdout>"},"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":"<local-command-caveat>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.</local-command-caveat>"},"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":"<command-name>/cost</command-name>\n <command-message>cost</command-message>\n <command-args></command-args>"},"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":"<local-command-stdout>\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</local-command-stdout>","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":"<local-command-caveat>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.</local-command-caveat>"},"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":"<command-name>/context</command-name>\n <command-message>context</command-message>\n <command-args></command-args>"},"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":"<local-command-stdout> \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</local-command-stdout>"},"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"}
@@ -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('<command-name>'))
)[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('<local-command-stdout>')
)
)
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([])
})
})
@@ -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
}
}
@@ -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<string, unknown>
): Pick<NativeChatMessage, 'model' | 'provider' | 'usage'> {
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. */
@@ -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, unknown> = {}): 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'
})
})
})
@@ -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(
@@ -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))
}
}
@@ -63,6 +63,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
optimisticSendOutcome,
onOptimisticSendCanceled,
onSlashCommand,
answerCommandLocally,
onSwitchToTerminal,
readTerminalScreen,
launchSeed,
@@ -274,6 +275,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
onOptimisticSend,
optimisticSendOutcome,
onSlashCommand,
answerCommandLocally,
sessionOptionsSurface: ptySessionOptionsSurface,
terminalTabId,
trackPendingSend,
@@ -310,6 +312,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
isDispatchingSessionOption,
resolveTarget,
onSlashCommand,
answerCommandLocally,
sessionOptionsSurface: ptySessionOptionsSurface,
trackPendingSend,
setHistory,
@@ -70,6 +70,27 @@ describe('notice rows', () => {
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(
<MessageRow
message={{
id: 'command-output',
role: 'system',
blocks: [{ type: 'text', text, presentation: 'command-output' }],
timestamp: 1,
source: 'transcript'
}}
expandSignal={false}
onScrollMessageToTop={vi.fn()}
/>
)
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."],
@@ -49,6 +49,14 @@ export function NativeChatNoticeRow({
</div>
)
}
if (block.presentation === 'command-output') {
// Why: command output is laid out in columns; proportional type breaks its grid.
return (
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-foreground">
{block.text}
</pre>
)
}
if (isAgentSessionHostStatusPresentation(block.presentation)) {
// The look of any other host status line; only the words are the reader's.
return (
@@ -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 }}
@@ -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
}))
@@ -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. */
@@ -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<NativeChatLocalCommandAnswer>(
() => '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<NativeChatLocalCommandAnswer>(
() => '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()
})
})
@@ -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.'
)
}
@@ -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', '<command-name>/context</command-name>\n<command-args></command-args>'),
{
...row(
'a-stdout',
'<local-command-stdout> \u001b[1mContext Usage\u001b[22m</local-command-stdout>'
),
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', '<command-name>/model</command-name>'), timestamp: 300 },
{
...row('c-stdout', '<local-command-stdout>Set model to gpt-4o</local-command-stdout>'),
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'])
})
})
@@ -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
@@ -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%)' }])
})
})
@@ -40,6 +40,8 @@ export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & {
recordOutgoingCommand(command: string): void
reportSessionOptions(values: Record<string, SessionOptionValue>): 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
}
}
@@ -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({
@@ -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,
@@ -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<SetStateAction<HistoryState>>
setDraft: (value: string) => void
setCaret: Dispatch<SetStateAction<number>>
clearSkillOrigin: () => void
setNotice: Dispatch<SetStateAction<string | null>>
}): 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
}
@@ -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<SetStateAction<HistoryState>>
@@ -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,
@@ -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,
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -17952,6 +17952,12 @@
"subtitle": "ファイルは、Agent が読み取ることができるパスとしてメッセージに追加されます。"
},
"copyCode": "コードをコピーする",
"context": {
"unavailable": "コンテキストの使用量はまだ不明です。Agent が次に応答すると表示されます。",
"summary": "コンテキスト: {{used}} / {{window}} トークン ({{percent}}%)、最後の応答から推定。",
"used": "コンテキスト: {{used}} トークン使用済み、最後の応答から推定。",
"unreported": "このセッションではコンテキストの使用量を表示できません。"
},
"goal": {
"placeholder": "目標を説明し、最良の結果を得るために測定可能な結果を​​定義する",
"clear": "明確な目標",
+6
View File
@@ -17952,6 +17952,12 @@
"subtitle": "파일은 에이전트가 읽을 수 있는 경로로 메시지에 추가됩니다."
},
"copyCode": "코드 복사",
"context": {
"unavailable": "컨텍스트 사용량을 아직 알 수 없습니다. agent가 다음에 응답하면 확인할 수 있습니다.",
"summary": "컨텍스트: {{used}}/{{window}} 토큰 ({{percent}}%), 마지막 응답 기준 추정치입니다.",
"used": "컨텍스트: {{used}} 토큰 사용, 마지막 응답 기준 추정치입니다.",
"unreported": "이 세션에서는 컨텍스트 사용량을 확인할 수 없습니다."
},
"goal": {
"placeholder": "목표를 설명하고, 최상의 결과를 위해 측정 가능한 결과를 정의하세요.",
"clear": "명확한 목표",
+6
View File
@@ -17917,6 +17917,12 @@
"subtitle": "文件将作为代理可以读取的路径添加到您的消息中。"
},
"copyCode": "复制代码",
"context": {
"unavailable": "上下文用量暂时未知,将在智能体下次响应后提供。",
"summary": "上下文:{{used}}/{{window}} token({{percent}}%),根据上一次响应的估算值。",
"used": "上下文:已用 {{used}} token,根据上一次响应的估算值。",
"unreported": "此会话无法提供上下文用量。"
},
"goal": {
"placeholder": "描述您的目标,定义可衡量的结果以获得最佳结果",
"clear": "明确的目标",
@@ -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([
@@ -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[]
}
+4
View File
@@ -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 = {
@@ -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')
)
})
})
+26 -1
View File
@@ -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. */
@@ -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 =
'<command-name>/context</command-name>\n <command-message>context</command-message>\n <command-args></command-args>'
const CONTEXT_STDOUT =
'<local-command-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</local-command-stdout>'
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 = '<local-command-stdout>Set model to gpt-4o</local-command-stdout>'
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)
})
})
+75
View File
@@ -0,0 +1,75 @@
// Claude-family harnesses record a local command's reply as a
// `<local-command-stdout>` 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*<local-command-stdout>([\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<string, NativeChatMessage> | 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
}
@@ -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)
})
})
+54
View File
@@ -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
}
@@ -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)', () => {
+17 -2
View File
@@ -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. `<objective>`. */
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<Record<AgentType, readonly SlashCommandSuggestion[]>> = {
claude: CLAUDE_COMMANDS,
openclaude: CLAUDE_COMMANDS,
openclaude: OPENCLAUDE_COMMANDS,
codex: CODEX_COMMANDS,
omp: OMP_COMMANDS
}
+10
View File
@@ -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. */
+17 -2
View File
@@ -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()]