mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* 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>
234 lines
11 KiB
TypeScript
234 lines
11 KiB
TypeScript
// Single source of truth for native chat slash-command behavior, shared by the
|
|
// desktop renderer and the mobile app. This is pure data + string helpers (no
|
|
// DOM, no RN-only imports), so both platforms import the SAME values — no
|
|
// mirrored copy to drift, unlike the agent-specific parsers in src/shared that
|
|
// Metro forces us to duplicate.
|
|
|
|
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
|
|
/** Optional one-line description for the suggestion row. */
|
|
description?: string
|
|
/** 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
|
|
// command list, so these track the common, stable commands each TUI documents.
|
|
// The composer treats commands as plain data, so this can grow freely.
|
|
|
|
const COMMON_COMMANDS: readonly SlashCommandSuggestion[] = [
|
|
{ name: 'clear', description: 'Clear the conversation' },
|
|
{ name: 'help', description: 'Show available commands' }
|
|
]
|
|
|
|
const CLAUDE_COMMANDS: readonly SlashCommandSuggestion[] = [
|
|
{ name: 'clear', description: 'Clear conversation history' },
|
|
{ name: 'compact', description: 'Summarize and compact the conversation' },
|
|
{ name: 'init', description: 'Initialize a CLAUDE.md' },
|
|
{ name: 'review', description: 'Review the current changes' },
|
|
{ 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' },
|
|
{ name: 'permissions', description: 'Choose what Codex is allowed to do' },
|
|
{ name: 'keymap', description: 'Remap TUI shortcuts' },
|
|
{ name: 'vim', description: 'Toggle Vim mode' },
|
|
{ name: 'experimental', description: 'Toggle experimental features' },
|
|
{ name: 'approve', description: 'Approve one auto-review retry' },
|
|
{ name: 'memories', description: 'Configure memory use' },
|
|
{ name: 'skills', description: 'Manage and use skills' },
|
|
{ name: 'import', description: 'Import setup from Claude Code' },
|
|
{ name: 'hooks', description: 'View lifecycle hooks' },
|
|
{ name: 'review', description: 'Review the current changes' },
|
|
{ name: 'rename', description: 'Rename the current thread' },
|
|
{ name: 'new', description: 'Start a new chat' },
|
|
{ name: 'archive', description: 'Archive this session and exit' },
|
|
{ name: 'delete', description: 'Delete this session and exit' },
|
|
{ name: 'resume', description: 'Resume a saved chat' },
|
|
{ name: 'fork', description: 'Fork the current chat' },
|
|
{ name: 'app', description: 'Continue in Codex Desktop' },
|
|
{ name: 'init', description: 'Create an AGENTS.md file' },
|
|
{ name: 'compact', description: 'Compact the conversation' },
|
|
{ name: 'plan', description: 'Switch to Plan mode' },
|
|
{ name: 'goal', description: 'Set or view the goal' },
|
|
{ name: 'agent', description: 'Switch the active agent thread' },
|
|
{ name: 'side', description: 'Start a side conversation' },
|
|
{ name: 'copy', description: 'Copy the last response as markdown' },
|
|
{ name: 'raw', description: 'Toggle raw scrollback mode' },
|
|
{ name: 'diff', description: 'Show the working diff' },
|
|
{ name: 'mention', description: 'Mention a file' },
|
|
{ name: 'status', description: 'Show session configuration and usage' },
|
|
{ name: 'usage', description: 'View account usage' },
|
|
{ name: 'title', description: 'Configure the terminal title' },
|
|
{ name: 'statusline', description: 'Configure the status line' },
|
|
{ name: 'theme', description: 'Choose a syntax highlighting theme' },
|
|
{ name: 'pets', description: 'Choose or hide the terminal pet' },
|
|
{ name: 'mcp', description: 'List configured MCP tools' },
|
|
{ name: 'plugins', description: 'Browse plugins' },
|
|
{ name: 'logout', description: 'Log out of Codex' },
|
|
{ name: 'exit', description: 'Exit Codex' },
|
|
{ name: 'feedback', description: 'Send logs to maintainers' },
|
|
{ name: 'ps', description: 'List background terminals' },
|
|
{ name: 'stop', description: 'Stop all background terminals' },
|
|
{ name: 'clear', description: 'Clear the terminal and start a new chat' },
|
|
{ name: 'personality', description: 'Choose a communication style' },
|
|
{ name: 'subagents', description: 'Switch the active agent thread' }
|
|
]
|
|
|
|
// OMP built-in registry; interactive commands still execute in its terminal.
|
|
const OMP_COMMANDS: readonly SlashCommandSuggestion[] = [
|
|
{ name: 'model', description: 'Open the model selector in Terminal' },
|
|
{
|
|
name: 'switch',
|
|
description: 'Open the temporary model selector in Terminal'
|
|
},
|
|
{ name: 'plan', description: 'Toggle plan mode' },
|
|
{ name: 'compact', description: 'Compact conversation context' },
|
|
{ name: 'clear', description: 'Clear context while keeping the session' },
|
|
{ name: 'new', description: 'Start a new session' },
|
|
{ name: 'resume', description: 'Resume a session; without arguments, choose in Terminal' },
|
|
{ name: 'fork', description: 'Fork from a previous message in Terminal' },
|
|
{ name: 'branch', description: 'Rewind to a previous message in Terminal' },
|
|
{ name: 'tree', description: 'Browse the session tree in Terminal' },
|
|
{ name: 'session', description: 'Show session information and controls' },
|
|
{ name: 'rename', description: 'Rename the session' },
|
|
// 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' },
|
|
{ name: 'jobs', description: 'Show background jobs' },
|
|
{ name: 'git', description: 'Open the Git viewer in Terminal' },
|
|
{ name: 'export', description: 'Export the session to HTML' },
|
|
{ name: 'settings', description: 'Open settings in Terminal' },
|
|
{ name: 'extensions', description: 'Open the extension dashboard in Terminal' },
|
|
{ name: 'hotkeys', description: 'Show keyboard shortcuts in Terminal' }
|
|
]
|
|
|
|
const COMMANDS_BY_AGENT: Partial<Record<AgentType, readonly SlashCommandSuggestion[]>> = {
|
|
claude: CLAUDE_COMMANDS,
|
|
openclaude: OPENCLAUDE_COMMANDS,
|
|
codex: CODEX_COMMANDS,
|
|
omp: OMP_COMMANDS
|
|
}
|
|
|
|
/** Known slash commands for an agent, falling back to a small common set so the
|
|
* `/` menu is never empty for a recognized agent. */
|
|
export function getAgentSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] {
|
|
return COMMANDS_BY_AGENT[agent] ?? COMMON_COMMANDS
|
|
}
|
|
|
|
/** The command rows for a session that reports its own `/` surface. The report
|
|
* is the authority on WHICH commands exist and, when it carries one, on how a
|
|
* command is described; the curated catalog above only covers the names whose
|
|
* report is text-free. Skills are excluded — they render in the picker's own
|
|
* skills group. */
|
|
export function sessionSlashCommandSuggestions(
|
|
agent: AgentType,
|
|
reported: readonly AgentSessionSlashCommand[]
|
|
): readonly SlashCommandSuggestion[] {
|
|
const described = new Map(
|
|
getAgentSlashCommands(agent).map((command) => [command.name, command.description])
|
|
)
|
|
return reported
|
|
.filter((entry) => entry.kind === 'command')
|
|
.map((entry) => {
|
|
const description = entry.description ?? described.get(entry.name)
|
|
return {
|
|
name: entry.name,
|
|
...(description ? { description } : {}),
|
|
...(entry.argumentHint ? { argumentHint: entry.argumentHint } : {}),
|
|
...(entry.kindUnspecified ? { kindUnspecified: true as const } : {})
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Names the session reported as skills, in the order it reported them. */
|
|
export function sessionReportedSkillNames(
|
|
reported: readonly AgentSessionSlashCommand[]
|
|
): readonly string[] {
|
|
return reported.filter((entry) => entry.kind === 'skill').map((entry) => entry.name)
|
|
}
|
|
|
|
/** Whether the draft is a slash command (leading `/`, ignoring leading space).
|
|
* Slash drafts dispatch to the agent's own TUI and must NOT render an optimistic
|
|
* user bubble — they are control actions, not chat turns. */
|
|
export function isSlashCommandDraft(draft: string): boolean {
|
|
return draft.trimStart().startsWith('/')
|
|
}
|
|
|
|
/** Case-insensitive prefix filter over an agent's commands. An empty query
|
|
* returns all commands so a bare `/` shows the full menu. */
|
|
export function filterSlashCommands(
|
|
commands: readonly SlashCommandSuggestion[],
|
|
query: string
|
|
): SlashCommandSuggestion[] {
|
|
const normalized = query.toLowerCase()
|
|
if (normalized === '') {
|
|
return [...commands]
|
|
}
|
|
return commands.filter((command) => command.name.toLowerCase().startsWith(normalized))
|
|
}
|
|
|
|
/** Replace the slash token with the chosen command plus a trailing space, so the
|
|
* user can type arguments. This is the Tab-completion path. */
|
|
export function applySlashSuggestion(command: SlashCommandSuggestion): string {
|
|
return `/${command.name} `
|
|
}
|
|
|
|
/** Text to send when Enter accepts a slash command from the menu — no trailing
|
|
* space, because the TUI dispatches the command on Enter. */
|
|
export function slashCommandDispatchText(command: SlashCommandSuggestion): string {
|
|
return `/${command.name}`
|
|
}
|
|
|
|
export type NativeChatSendClassification = 'chat' | 'command' | 'unknown-token'
|
|
|
|
export function classifyNativeChatSend(
|
|
draft: string,
|
|
commands: readonly SlashCommandSuggestion[],
|
|
pickerSkillOriginToken: string | null,
|
|
skillPrefix: '/' | '$' | null
|
|
): NativeChatSendClassification {
|
|
// Why: the supported TUIs only treat a line-leading token as a command, so a
|
|
// draft with leading whitespace is prose; trimming here would claim a "Ran"
|
|
// line for text the agent never dispatched.
|
|
const firstToken = draft.split(/\s/, 1)[0] ?? ''
|
|
if (pickerSkillOriginToken && firstToken === pickerSkillOriginToken) {
|
|
return 'chat'
|
|
}
|
|
if (commands.some((command) => firstToken === `/${command.name}`)) {
|
|
return 'command'
|
|
}
|
|
if (firstToken.startsWith('/')) {
|
|
return 'unknown-token'
|
|
}
|
|
// Why: `$` is Codex grammar only. For other agents a leading `$PATH`-style
|
|
// token is ordinary prose and must keep its bubble and attachments.
|
|
if (skillPrefix === '$' && firstToken.startsWith('$')) {
|
|
return 'unknown-token'
|
|
}
|
|
return 'chat'
|
|
}
|