Files
orca/src/shared/commit-message-agent-spec.ts
T
Brennan BensonandClaude ca2cdc0ad9 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>
2026-10-05 11:57:25 -07:00

232 lines
8.3 KiB
TypeScript

import { OMP_MODEL_LIST_ARGS, parseOmpModelList } from './omp-model-list-probe'
import type { TuiAgent } from './tui-agent'
import { isTuiAgentEnabled } from './tui-agent-selection'
import { labelFromModelId } from './model-id-label'
import { buildPrimaryCommitMessageAgentSpecs } from './commit-message-agent-specs-primary'
import { buildSecondaryCommitMessageAgentSpecs } from './commit-message-agent-specs-secondary'
import {
BASIC_THINKING_LEVELS,
CLAUDE_THINKING_LEVELS,
OPENAI_THINKING_LEVELS,
parseAntigravityModels,
parseClaudeModels,
parseCodexModels,
parseCursorModels,
parseLineModels,
parsePiModels,
withOpenAiThinking
} from './commit-message-model-parsers'
// Why: this file is the source of truth for non-interactive agent invocation
// (commit-message generation). It is intentionally separate from
// `tui-agent-config.ts`, which describes interactive PTY launching — mixing
// the two confuses both code paths.
export type ThinkingLevel = { id: string; label: string }
export type CommitMessageModel = {
/** Value passed to the agent CLI's --model flag. */
id: string
/** Visible label in the model dropdown. */
label: string
/** Discovery-provided detail, e.g. what a CLI alias resolves to on this host. */
description?: string
/** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */
thinkingLevels?: ThinkingLevel[]
/** Required when thinkingLevels is present. */
defaultThinkingLevel?: string
/** Whether the model exposes Claude's mid-session Fast mode toggle. */
supportsFastMode?: boolean
/** 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 = {
id: TuiAgent
/** Visible label in the agent dropdown. */
label: string
/** Binary spawned in non-interactive mode. */
binary: string
/** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */
promptDelivery: 'argv' | 'stdin'
buildArgs: (params: { prompt: string; model: string; thinkingLevel?: string }) => string[]
/** Alias groups the CLI accepts at most once. Recipe CLI arguments repeating one
* replace the generated flag instead of being appended: yargs-based CLIs collapse
* a repeated flag into an array and crash. Defaults to the model flag alone. */
singletonOptions?: readonly (readonly string[])[]
/** Whether the model list is static or discovered from the agent CLI. */
modelSource: 'static' | 'dynamic'
/** Command used by the main process to discover models when modelSource is dynamic. */
modelDiscovery?: {
binary: string
args: string[]
/** Written to the CLI's stdin, for CLIs whose listing is request-driven. */
stdinPayload?: string
parse: (stdout: string) => CommitMessageModel[]
}
models: CommitMessageModel[]
defaultModelId: string
}
export type CommitMessageModelCapability = {
id: string
label: string
description?: string
thinkingLevels?: ThinkingLevel[]
defaultThinkingLevel?: string
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 = {
id: TuiAgent
label: string
modelSource: 'static' | 'dynamic'
models: CommitMessageModelCapability[]
defaultModelId: string
}
export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageAgentSpec>> = {
omp: {
id: 'omp',
label: 'OMP',
binary: 'omp',
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'--print',
'--no-session',
'--no-tools',
'--no-extensions',
'--no-skills',
'--no-rules',
'--mode',
'text',
...(model && model !== 'default' ? ['--model', model] : []),
...(thinkingLevel ? ['--thinking', thinkingLevel] : [])
],
singletonOptions: [['--model'], ['--thinking']],
modelSource: 'dynamic',
modelDiscovery: { binary: 'omp', args: OMP_MODEL_LIST_ARGS, parse: parseOmpModelList },
models: [{ id: 'default', label: 'Config default' }],
defaultModelId: 'default'
},
...buildPrimaryCommitMessageAgentSpecs({
CLAUDE_THINKING_LEVELS,
OPENAI_THINKING_LEVELS,
parseClaudeModels,
parseCodexModels,
parseLineModels,
parsePiModels,
withOpenAiThinking
}),
...buildSecondaryCommitMessageAgentSpecs({
BASIC_THINKING_LEVELS,
OPENAI_THINKING_LEVELS,
parseCursorModels,
parseAntigravityModels,
parseLineModels
})
}
export const DEFAULT_COMMIT_MESSAGE_AGENT_ID: TuiAgent = 'claude'
// Why: the "custom" choice is not a TuiAgent — it lets the user point Orca
// at any CLI by typing a command template (see customAgentCommand setting +
// planCustomCommand in commit-message-prompt.ts). Keeping it as its own
// sentinel avoids polluting TuiAgent (which is shared with PTY launch /
// new-workspace flows that have nothing to do with this feature).
export const CUSTOM_AGENT_ID = 'custom' as const
export type CustomAgentId = typeof CUSTOM_AGENT_ID
export type CommitMessageAgentChoice = TuiAgent | CustomAgentId
export type DefaultTuiAgentPreference = TuiAgent | 'blank' | null | undefined
export function isCustomAgentId(id: string | null | undefined): id is CustomAgentId {
return id === CUSTOM_AGENT_ID
}
export function getCommitMessageAgentSpec(agentId: TuiAgent): CommitMessageAgentSpec | undefined {
return COMMIT_MESSAGE_AGENT_SPECS[agentId]
}
export function resolveCommitMessageAgentChoice(
configuredAgentId: CommitMessageAgentChoice | null | undefined,
defaultTuiAgent: DefaultTuiAgentPreference,
disabledTuiAgents?: Iterable<unknown> | null
): CommitMessageAgentChoice | null {
if (configuredAgentId) {
return configuredAgentId
}
if (
defaultTuiAgent &&
defaultTuiAgent !== 'blank' &&
isTuiAgentEnabled(defaultTuiAgent, disabledTuiAgents)
) {
return getCommitMessageAgentSpec(defaultTuiAgent) ? defaultTuiAgent : null
}
return isTuiAgentEnabled(DEFAULT_COMMIT_MESSAGE_AGENT_ID, disabledTuiAgents)
? DEFAULT_COMMIT_MESSAGE_AGENT_ID
: null
}
export function getCommitMessageModel(
agentId: TuiAgent,
modelId: string
): CommitMessageModel | undefined {
const spec = getCommitMessageAgentSpec(agentId)
const model = spec?.models.find((m) => m.id === modelId)
if (model || !spec || spec.modelSource !== 'dynamic' || modelId.trim().length === 0) {
return model
}
return {
id: modelId,
label: labelFromModelId(modelId),
...withOpenAiThinking(modelId)
}
}
function toCommitMessageAgentCapability(
spec: CommitMessageAgentSpec
): CommitMessageAgentCapability {
return {
id: spec.id,
label: spec.label,
modelSource: spec.modelSource,
defaultModelId: spec.defaultModelId,
// Why: renderer/settings should consume provider capabilities, not the
// spawn contract. Copy the model metadata so future dynamic probes can
// swap this source without leaking binary/argv details into UI code.
models: spec.models.map((model) => ({
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}),
...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}),
...(model.supportsFastMode ? { supportsFastMode: true } : {})
}))
}
}
export function getCommitMessageAgentCapability(
agentId: TuiAgent
): CommitMessageAgentCapability | undefined {
const spec = getCommitMessageAgentSpec(agentId)
return spec ? toCommitMessageAgentCapability(spec) : undefined
}
/** Ordered list of agents that have a non-interactive mode wired up. */
export function listCommitMessageAgentIds(): TuiAgent[] {
return Object.keys(COMMIT_MESSAGE_AGENT_SPECS) as TuiAgent[]
}
export function listCommitMessageAgentCapabilities(): CommitMessageAgentCapability[] {
return listCommitMessageAgentIds()
.map((id) => getCommitMessageAgentCapability(id))
.filter((capability): capability is CommitMessageAgentCapability => Boolean(capability))
}