Polish desktop native chat view (#6641)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong
2026-06-28 15:43:07 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Orca gsxdsm
parent bcbcf4126b
commit b916248294
191 changed files with 15163 additions and 204 deletions
+217
View File
@@ -0,0 +1,217 @@
# Native Chat Codex TUI Parity
This note maps Codex TUI behavior to Orca native chat on branch
`inspect/pr-5824-native-chat`. It is intentionally concrete: the current Orca
surface is a PTY harness around the running TUI, while real native parity should
move selected paths to Codex app-server protocol v2.
## Source Map
- Codex TUI composer: `/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/chat_composer.rs`
- Slash command parsing and popup:
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/prompt_args.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/slash_commands.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/slash_command.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/chatwidget/slash_dispatch.rs`
- Skills and mentions:
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/bottom_pane/skill_popup.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/tui/src/skills_helpers.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/loader.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/root_loader.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/core-skills/src/injection.rs`
- Structured input and native protocol:
`/Users/jinwoohong/stably/codex/codex-rs/protocol/src/user_input.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/v2/turn.rs`,
`/Users/jinwoohong/stably/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
## Current Orca Architecture
Orca native chat currently sends through the hosted terminal PTY. The composer
builds paste bytes, writes them through `sendRuntimePtyInput`, then sends a
delayed Enter. This preserves local and SSH behavior because it uses the same
runtime path as terminal typing.
That architecture is useful for incremental adoption, but it means native chat
does not own Codex state. It cannot directly set model, reasoning, permissions,
skills, or session lifecycle. It can only type commands into the TUI and observe
agent hooks/transcripts after the fact.
## Slash Commands
Codex behavior:
- The parser accepts a first-line command of `/name <rest>`.
- The slash popup uses Codex's `SlashCommand` enum order as presentation order.
- Enter on a selected popup row dispatches the command. Tab completes it into
the draft.
- Some commands accept inline args: `review`, `rename`, `plan`, `goal`, `ide`,
`keymap`, `mcp`, `raw`, `usage`, `pets`, `side`, `resume`, and
`sandbox-add-read-dir`.
- Commands are control actions, not ordinary user chat turns. For example
`/clear` sends `AppEvent::ClearUi`; `/compact` starts compaction; `/model`
opens the model picker; `/skills` opens skill management.
Current Orca behavior:
- Slash commands are still typed into the TUI over PTY.
- Native optimistic chat bubbles are suppressed for slash drafts so `/clear`
does not render as a fake queued user message.
- The Codex slash catalog now mirrors the visible TUI command list much more
closely, but it is still a copied catalog, not a live TUI query.
Recommended route:
- Short term: keep PTY dispatch for slash commands, but treat them as command
submissions. No optimistic chat bubbles. Enter dispatches; Tab completes.
- Medium term: route commands with app-server equivalents directly. Examples:
`thread/compact/start`, `thread/list`, `thread/archive`, `thread/delete`,
`model/list`, permissions/config reads and writes, `skills/list`.
- Long term: stop maintaining a renderer-side Codex command catalog. Either ask
Codex for the command inventory or host the Codex composer state machine.
## Skills And `$`
Codex behavior:
- `$` opens the skill popup. Rows show display name, description, category tags,
selection state, filtering, sorting, and scrolling.
- Codex discovers skills from repo, user, system, admin, and plugin roots. Repo
scope sorts before user/system/admin. Exact duplicate paths are deduped.
- Skill selection is structured. `UserInput` has `Skill { name, path }`, and
app-server protocol v2 mirrors it. Text `$skill` mentions are only the
fallback path and must be unambiguous.
- Skill injection reads the selected `SKILL.md` by path, records telemetry, and
avoids double-injecting already provided host skill prompts.
Current Orca behavior:
- Native chat discovers skills through Orca's skills IPC with the active
terminal tab's cwd. This is important for worktree symlinks like
`.agents/skills`.
- `$` autocomplete inserts plain `$skillName` text. That can work through the
TUI's text fallback, but it is not equivalent to structured
`UserInput::Skill { name, path }`.
Recommended route:
- PTY mode: keep `$skill` text insertion, but preserve Codex-like filtering,
scrolling, dedupe, and active-cwd discovery.
- Native mode: retain the selected skill's path and submit
`UserInput::Skill { name, path }` through app-server `turn` input. This avoids
ambiguity when multiple skills share a name and lets Codex inject the exact
file the user selected.
## Files, Mentions, And Images
Codex behavior:
- User input supports `Text` with text elements, `Image`, `LocalImage`,
`Skill`, and `Mention`.
- The TUI has file search/mentions and image placeholders. Large pastes become
placeholders so text element ranges stay aligned.
- Remote image rows are first-class composer attachments and can be removed with
keyboard navigation.
Current Orca behavior:
- File attach inserts a path/reference into the draft and relies on the TUI to
interpret it.
- Image paste saves a temp file, then inserts the agent-specific reference.
- Local attachments are blocked for remote sessions because the local path may
not exist on the SSH target.
Recommended route:
- PTY mode: keep conservative path insertion and remote-session blocking.
- Native mode: send structured `LocalImage` or `Image` input through Codex
protocol and use remote runtime file transfer semantics for SSH.
## Model, Reasoning, Permissions
Codex behavior:
- `/model`, `/permissions`, `/keymap`, `/vim`, `/experimental`, and related
commands are stateful TUI/app-server surfaces.
- App-server v2 already exposes model listing, config requirements, approval
policies, permission profiles, and reasoning effort fields.
Current Orca behavior:
- Native chat does not know or set Codex model/reasoning directly. Typing
`/model` opens Codex's TUI picker.
- Earlier UI controls for model/thinking were removed because they were not
wired to real Codex state.
Recommended route:
- Do not re-add model or reasoning dropdowns until they read from and write to
Codex app-server state.
- In PTY mode, expose `/model` as a command shortcut only.
## Approvals, Elicitations, And Tool UI
Codex behavior:
- Approval overlays cover exec approval, permission approval, file change
approval, network approval, MCP elicitation, and request-user-input forms.
- App-server notifications include thread status, waiting-on-approval/user-input
flags, item start/completion, diff/plan updates, and skill changes.
Current Orca behavior:
- Native chat has interactive cards sourced from Orca's existing agent status
hooks. This is good for common question/approval flows, but it is not the full
Codex approval overlay model.
Recommended route:
- Keep PTY fallback for anything not represented in Orca hooks.
- For Codex-native mode, subscribe to app-server notifications and render
approvals/tool calls from protocol events rather than scraping terminal text.
## Session And History
Codex behavior:
- `/new`, `/resume`, `/fork`, `/archive`, `/delete`, `/compact`, and `/clear`
are session lifecycle commands.
- The composer has local and persistent history; Up/Down recall, Ctrl+R reverse
search, Esc edit/interrupt behavior, Ctrl+J newline, Ctrl+T transcript, and
Ctrl+C quit/interrupt behavior.
Current Orca behavior:
- Native chat has small in-memory draft history and Enter/Shift+Enter.
- Session commands are typed into the hosted TUI.
Recommended route:
- Short term: keep TUI command dispatch and avoid fake optimistic bubbles for
lifecycle commands.
- Native mode: use app-server thread APIs for lifecycle and expose real thread
transitions in the Orca UI.
## Priority
1. Fix PTY-command correctness: Enter dispatches slash commands, Tab completes,
slash commands never render as queued chat turns, interrupt clears working UI.
2. Make `$` skill popup match Codex basics: active cwd, dedupe, scrolling,
filtering, source labels, and no product-specific hardcoding.
3. Keep fake model/thinking controls out until backed by Codex app-server state.
4. Add an app-server integration spike for Codex native mode: `skills/list`,
structured `UserInput::Skill`, model list/settings, and thread lifecycle.
5. Move approvals/tool rendering from hook approximations to protocol events.
## Test Targets
- `/clear` from native slash popup dispatches immediately and produces no
pending user bubble.
- `/compact`, `/model`, `/skills`, `/resume`, `/diff`, `/status`, and unknown
slash commands behave like the hosted TUI.
- `$ref-oss` appears exactly once when the worktree has `.agents/skills` as a
symlink.
- Down-arrow in `$` suggestions scrolls the popup window.
- Interrupt during work returns the composer from Stop to Send after the agent
status settles.
- SSH sessions never insert local-only attachment paths as if they were remote
files.
+1
View File
@@ -256,6 +256,7 @@ function equivalentParsedAgentStatusPayload(
a.agentType === b.agentType &&
a.toolName === b.toolName &&
a.toolInput === b.toolInput &&
a.interactivePrompt === b.interactivePrompt &&
a.lastAssistantMessage === b.lastAssistantMessage &&
a.interrupted === b.interrupted
)
+259
View File
@@ -0,0 +1,259 @@
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, listeners } = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
}),
on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
listeners.set(channel, handler)
})
}
}))
import {
clearNativeChatSubscriptions,
clearNativeChatTranscriptCache,
registerNativeChatHandlers
} from './native-chat'
let tempRoots: string[] = []
beforeEach(() => {
handlers.clear()
listeners.clear()
clearNativeChatTranscriptCache()
clearNativeChatSubscriptions()
})
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('timed out waiting for condition')
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
async function invokeReadSession(args: {
agent: string
sessionId: string
limit?: number
}): Promise<unknown> {
registerNativeChatHandlers()
const handler = handlers.get('nativeChat:readSession')
if (!handler) {
throw new Error('handler not registered')
}
return handler({}, args)
}
describe('nativeChat:readSession handler', () => {
it('resolves a Claude transcript and returns the full conversation', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-'))
tempRoots.push(root)
const projectsDir = join(root, '.claude', 'projects')
const projectDir = join(projectsDir, '-repo')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'sess-ipc.jsonl'),
jsonLines([
{
type: 'user',
uuid: 'u-1',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'Hi' }
},
{
type: 'assistant',
uuid: 'a-1',
timestamp: '2026-06-01T10:00:01.000Z',
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
}
])
)
// Point homedir-derived Claude root at our fixture via HOME so the resolver
// (which reads homedir() internally) finds the transcript.
const previousHome = process.env.HOME
process.env.HOME = root
try {
const result = (await invokeReadSession({ agent: 'claude', sessionId: 'sess-ipc' })) as {
messages?: unknown[]
error?: string
}
expect(result.error).toBeUndefined()
expect(result.messages).toHaveLength(2)
} finally {
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('windows to the most-recent `limit` turns and pages older history when raised', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-limit-'))
tempRoots.push(root)
const projectDir = join(root, '.claude', 'projects', '-repo')
await mkdir(projectDir, { recursive: true })
// Five user turns; reading with limit 2 returns only the last two, and a
// larger limit pages in older ones (chronological order preserved).
const records = [1, 2, 3, 4, 5].map((n) => ({
type: 'user',
uuid: `u-${n}`,
timestamp: `2026-06-01T10:00:0${n}.000Z`,
message: { role: 'user', content: `m${n}` }
}))
await writeFile(join(projectDir, 'sess-limit.jsonl'), jsonLines(records))
const previousHome = process.env.HOME
process.env.HOME = root
try {
const windowed = (await invokeReadSession({
agent: 'claude',
sessionId: 'sess-limit',
limit: 2
})) as { messages: { id: string }[] }
expect(windowed.messages.map((m) => m.id)).toEqual(['u-4', 'u-5'])
const wider = (await invokeReadSession({
agent: 'claude',
sessionId: 'sess-limit',
limit: 4
})) as { messages: { id: string }[] }
expect(wider.messages.map((m) => m.id)).toEqual(['u-2', 'u-3', 'u-4', 'u-5'])
} finally {
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('emits appended messages over nativeChat:appended and tears down on destroy', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-sub-'))
tempRoots.push(root)
const projectsDir = join(root, '.claude', 'projects')
const projectDir = join(projectsDir, '-repo')
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'sess-sub.jsonl')
await writeFile(
filePath,
`${jsonLines([
{
type: 'user',
uuid: 'u-1',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'Hi' }
}
])}\n`
)
registerNativeChatHandlers()
const subscribe = listeners.get('nativeChat:subscribe')
expect(subscribe).toBeDefined()
const sent: { channel: string; payload: unknown }[] = []
let destroyedCb: (() => void) | undefined
const sender = {
id: 1,
isDestroyed: () => false,
once: (event: string, cb: () => void) => {
if (event === 'destroyed') {
destroyedCb = cb
}
},
send: (channel: string, payload: unknown) => sent.push({ channel, payload })
}
const previousHome = process.env.HOME
process.env.HOME = root
try {
subscribe!(
{ sender },
{
subscriptionId: 'sub-1',
agent: 'claude',
sessionId: 'sess-sub'
}
)
// The listener dispatches handleSubscribe fire-and-forget; give it a beat
// to resolve the path and install the watcher before we append.
await new Promise((resolve) => setTimeout(resolve, 100))
await appendFile(
filePath,
`${JSON.stringify({
type: 'assistant',
uuid: 'a-1',
timestamp: '2026-06-01T10:00:01.000Z',
message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }
})}\n`
)
// Seed-at-0 means the first appended event carries the whole-file re-read;
// the new turn 'a-1' arrives across one of the appended events. Collect ids
// from every appended event and assert the new turn shows up.
const appendedIds = (): string[] =>
sent
.filter((s) => s.channel === 'nativeChat:appended')
.flatMap((s) => (s.payload as { messages: { id: string }[] }).messages.map((m) => m.id))
await waitFor(() => appendedIds().includes('a-1'))
const appendedEvent = sent.find((s) => s.channel === 'nativeChat:appended')!
const payload = appendedEvent.payload as { subscriptionId: string }
expect(payload.subscriptionId).toBe('sub-1')
expect(appendedIds()).toContain('a-1')
// Destroyed window tears down the watcher without error.
expect(destroyedCb).toBeDefined()
destroyedCb!()
} finally {
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('returns an error for an unknown session without throwing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-'))
tempRoots.push(root)
const previousHome = process.env.HOME
process.env.HOME = root
try {
const result = (await invokeReadSession({ agent: 'claude', sessionId: 'nope' })) as {
error?: string
}
expect(result.error).toBeTruthy()
} finally {
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
})
+168
View File
@@ -0,0 +1,168 @@
import { ipcMain, type IpcMainEvent, type WebContents } from 'electron'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached
} from '../native-chat/transcript-read-cache'
import type { ReadTranscriptResult } from '../native-chat/transcript-reader'
import {
subscribeNativeChatTranscript,
type NativeChatTranscriptSubscription
} from '../native-chat/transcript-watch'
// Re-export so existing test imports of `clearNativeChatTranscriptCache` from
// this module keep working after the cache moved to transcript-read-cache.ts.
export { clearNativeChatTranscriptCache }
export type NativeChatReadSessionArgs = {
agent: AgentType
sessionId: string
/** How many of the most-recent turns to return. The renderer starts at the
* default window and raises this to page in older history as it scrolls up. */
limit?: number
/** Authoritative transcript path from the agent hook (providerSession), used to
* locate the file when the session id no longer names it (recent Claude Code). */
transcriptPath?: string
}
// Why: render only the most recent turns so switching to chat view on a long
// session (thousands of messages) doesn't stall on building that many message
// components. The full transcript is still cached; only the returned slice is
// capped. The renderer raises `limit` to page in older history; live appends
// extend it from there.
const DESKTOP_READ_WINDOW = 300
function windowTranscript(result: ReadTranscriptResult, limit: number): ReadTranscriptResult {
if (!('messages' in result) || result.messages.length <= limit) {
return result
}
return { ...result, messages: result.messages.slice(-limit) }
}
async function readSession(args: NativeChatReadSessionArgs): Promise<ReadTranscriptResult> {
const { agent, sessionId } = args
// Clamp to a positive window; default to the desktop window for the first page.
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW
// Desktop is full-class: window by count only, no char truncation.
const result = await readNativeChatTranscriptCached(agent, sessionId, args.transcriptPath)
return windowTranscript(result, limit)
}
export type NativeChatSubscribeArgs = {
/** Renderer-minted id, unique per webContents, echoed back on every emit so
* the renderer can route appends to the right hook instance. */
subscriptionId: string
agent: AgentType
sessionId: string
/** Authoritative transcript path from the agent hook (providerSession). */
transcriptPath?: string
}
export type NativeChatAppendedPayload = {
subscriptionId: string
messages: NativeChatMessage[]
}
type LiveSubscription = {
subscription: NativeChatTranscriptSubscription
}
// Why: live subscriptions are keyed by (webContents.id, subscriptionId) so the
// same renderer can watch several panes, and a destroyed window tears down all
// of its watchers — strict teardown to avoid fd leaks (plan U4 risk).
const liveSubscriptions = new Map<number, Map<string, LiveSubscription>>()
const senderCleanupRegistered = new Set<number>()
function teardownSubscription(senderId: number, subscriptionId: string): void {
const bySubId = liveSubscriptions.get(senderId)
const live = bySubId?.get(subscriptionId)
if (!live || !bySubId) {
return
}
live.subscription.unsubscribe()
bySubId.delete(subscriptionId)
if (bySubId.size === 0) {
liveSubscriptions.delete(senderId)
}
}
function teardownAllForSender(senderId: number): void {
const bySubId = liveSubscriptions.get(senderId)
if (!bySubId) {
return
}
for (const live of bySubId.values()) {
live.subscription.unsubscribe()
}
liveSubscriptions.delete(senderId)
senderCleanupRegistered.delete(senderId)
}
function registerSenderCleanup(sender: WebContents): void {
if (senderCleanupRegistered.has(sender.id)) {
return
}
senderCleanupRegistered.add(sender.id)
// Strict teardown: a closed/reloaded window releases every watcher it owns.
sender.once('destroyed', () => teardownAllForSender(sender.id))
}
async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise<void> {
const sender = event.sender
if (sender.isDestroyed()) {
return
}
const { subscriptionId, agent, sessionId, transcriptPath } = args
// Replace any prior subscription under the same id (session change/resubscribe).
teardownSubscription(sender.id, subscriptionId)
registerSenderCleanup(sender)
const subscription = await subscribeNativeChatTranscript({
agent,
sessionId,
transcriptPath,
onAppend: (messages) => {
if (sender.isDestroyed()) {
return
}
const payload: NativeChatAppendedPayload = { subscriptionId, messages }
sender.send('nativeChat:appended', payload)
}
})
// The window may have gone away (or the subscription been replaced) while we
// resolved the file path — don't register a now-orphaned watcher.
const stillCurrent = !sender.isDestroyed()
if (!stillCurrent) {
subscription.unsubscribe()
return
}
const bySubId = liveSubscriptions.get(sender.id) ?? new Map<string, LiveSubscription>()
// A concurrent subscribe with the same id beat us here; honor the latest.
const existing = bySubId.get(subscriptionId)
if (existing) {
existing.subscription.unsubscribe()
}
bySubId.set(subscriptionId, { subscription })
liveSubscriptions.set(sender.id, bySubId)
}
/** Test-only: drop all live transcript subscriptions between runs. */
export function clearNativeChatSubscriptions(): void {
const senderIds = Array.from(liveSubscriptions.keys())
for (const senderId of senderIds) {
teardownAllForSender(senderId)
}
}
export function registerNativeChatHandlers(): void {
ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) =>
readSession(args)
)
ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => {
void handleSubscribe(event, args)
})
ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => {
teardownSubscription(event.sender.id, args.subscriptionId)
})
}
@@ -52,6 +52,7 @@ const {
registerSkillsHandlersMock,
registerWorkspaceSpaceHandlersMock,
registerWorkspacePortHandlersMock,
registerNativeChatHandlersMock,
registerEmulatorFrameStreamHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
@@ -103,6 +104,7 @@ const {
registerSkillsHandlersMock: vi.fn(),
registerWorkspaceSpaceHandlersMock: vi.fn(),
registerWorkspacePortHandlersMock: vi.fn(),
registerNativeChatHandlersMock: vi.fn(),
registerEmulatorFrameStreamHandlersMock: vi.fn()
}))
@@ -294,6 +296,10 @@ vi.mock('./hosted-review', () => ({
registerHostedReviewHandlers: registerHostedReviewHandlersMock
}))
vi.mock('./native-chat', () => ({
registerNativeChatHandlers: registerNativeChatHandlersMock
}))
import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
@@ -346,6 +352,7 @@ describe('registerCoreHandlers', () => {
registerSkillsHandlersMock.mockReset()
registerWorkspaceSpaceHandlersMock.mockReset()
registerWorkspacePortHandlersMock.mockReset()
registerNativeChatHandlersMock.mockReset()
registerEmulatorFrameStreamHandlersMock.mockReset()
})
@@ -417,6 +424,7 @@ describe('registerCoreHandlers', () => {
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
})
expect(registerNativeChatHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
+2
View File
@@ -24,6 +24,7 @@ import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { registerAiVaultHandlers } from './ai-vault'
import { registerNativeChatHandlers } from './native-chat'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
import { registerOnboardingHandlers } from './onboarding'
@@ -164,6 +165,7 @@ export function registerCoreHandlers(
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
})
registerNativeChatHandlers()
registerClipboardHandlers(store)
registerUpdaterHandlers(store)
registerSpeechHandlers(store)
+8
View File
@@ -83,6 +83,14 @@ describe('registerSkillsHandlers', () => {
expect(getWslHomeMock).not.toHaveBeenCalled()
})
it('scopes host skill discovery to the active workspace cwd when provided', async () => {
const handler = getDiscoverHandler()
await handler(null, { cwd: '/repo/worktree' })
expect(discoverSkillsMock).toHaveBeenCalledWith({ repos: [], cwd: '/repo/worktree' })
})
it('uses the selected project WSL distro for skill discovery', async () => {
const handler = getDiscoverHandler()
+2 -1
View File
@@ -51,7 +51,8 @@ export function registerSkillsHandlers(store: Store): void {
return discoverSkills({ repos: [], homeDir, cwd: homeDir })
}
return discoverSkills({ repos: store.getRepos() })
const cwd = target?.cwd?.trim() || undefined
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
}
)
}
+8 -4
View File
@@ -179,17 +179,21 @@ describe('registerAppMenu', () => {
expect(paletteItem?.accelerator).toBeUndefined()
})
it('keeps Edit > Paste on the native Electron paste role in this split', () => {
it('routes Edit > Paste through Orca coordinated paste ownership', () => {
const send = vi.fn()
getFocusedWindowMock.mockReturnValue({ webContents: { send } })
registerAppMenu(buildMenuOptions())
const editSubmenu = getSubmenu(getTemplate(), 'Edit')
const pasteItem = editSubmenu.find((item) => item.role === 'paste')
const pasteItem = editSubmenu.find((item) => item.label === 'Paste')
expect(pasteItem).toBeDefined()
expect(pasteItem?.click).toBeUndefined()
expect(send).not.toHaveBeenCalled()
expect(pasteItem?.role).toBeUndefined()
expect(pasteItem?.accelerator).toBe('CmdOrCtrl+V')
pasteItem?.click?.({} as never, {} as never, {} as never)
expect(send).toHaveBeenCalledWith('ui:appMenuPaste')
})
it.runIf(!isMac)('puts Settings and Exit under File on Windows/Linux', () => {
+9 -1
View File
@@ -162,7 +162,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{
label: translateMain('menu.paste', 'Paste'),
accelerator: 'CmdOrCtrl+V',
click: () => {
// Why: a focused terminal/native-chat pane is not a native editable
// control, so raw Electron paste cannot know which Orca surface owns it.
BrowserWindow.getFocusedWindow()?.webContents.send('ui:appMenuPaste')
}
},
{ role: 'selectAll' }
]
}
@@ -0,0 +1,163 @@
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveSessionFilePath } from './session-file-resolver'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
async function makeRoot(prefix: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix))
tempRoots.push(root)
return root
}
function restoreEnv(key: string, previous: string | undefined): void {
if (previous === undefined) {
delete process.env[key]
} else {
process.env[key] = previous
}
}
describe('resolveSessionFilePath', () => {
it('globs Claude project subdirs for <sessionId>.jsonl', async () => {
const root = await makeRoot('orca-native-chat-resolve-claude-')
const claudeProjectsDir = join(root, 'claude-projects')
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
await mkdir(projectDir, { recursive: true })
const target = join(projectDir, 'sess-123.jsonl')
await writeFile(target, '{}\n')
const resolved = await resolveSessionFilePath('claude', 'sess-123', { claudeProjectsDir })
expect(resolved).toBe(target)
})
it('matches Codex rollout files by session id suffix', async () => {
const root = await makeRoot('orca-native-chat-resolve-codex-')
const codexSessionsDir = join(root, 'codex-sessions')
const dayDir = join(codexSessionsDir, '2026', '06', '04')
await mkdir(dayDir, { recursive: true })
const target = join(dayDir, 'rollout-2026-06-04T10-00-00-abc-session.jsonl')
await writeFile(target, '{}\n')
const resolved = await resolveSessionFilePath('codex', 'abc-session', {
codexSessionsDirs: [codexSessionsDir]
})
expect(resolved).toBe(target)
})
it('resolves a rollout from the orca-managed Codex home (ORCA_USER_DATA_PATH)', async () => {
// Orca launches Codex with its own managed CODEX_HOME, so rollout files land
// under <userData>/codex-runtime-home/home/sessions, NOT ~/.codex/sessions.
const root = await makeRoot('orca-native-chat-resolve-managed-')
const managedSessionsDir = join(root, 'codex-runtime-home', 'home', 'sessions')
const dayDir = join(managedSessionsDir, '2026', '06', '19')
await mkdir(dayDir, { recursive: true })
const target = join(dayDir, 'rollout-2026-06-19T04-20-39-019edf9c-managed.jsonl')
await writeFile(target, '{}\n')
const previous = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = root
try {
const resolved = await resolveSessionFilePath('codex', '019edf9c-managed')
expect(resolved).toBe(target)
} finally {
if (previous === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previous
}
}
})
it('falls back to CODEX_HOME when the managed home has no match', async () => {
const root = await makeRoot('orca-native-chat-resolve-codex-home-')
const managedRoot = join(root, 'managed-userdata')
await mkdir(managedRoot, { recursive: true })
const codexHome = join(root, 'custom-codex-home')
const dayDir = join(codexHome, 'sessions', '2026', '06', '05')
await mkdir(dayDir, { recursive: true })
const target = join(dayDir, 'rollout-xyz-session.jsonl')
await writeFile(target, '{}\n')
const previousCodex = process.env.CODEX_HOME
const previousUserData = process.env.ORCA_USER_DATA_PATH
process.env.CODEX_HOME = codexHome
// Point the managed home at an empty dir so the fallback is exercised.
process.env.ORCA_USER_DATA_PATH = managedRoot
try {
const resolved = await resolveSessionFilePath('codex', 'xyz-session')
expect(resolved).toBe(target)
} finally {
restoreEnv('CODEX_HOME', previousCodex)
restoreEnv('ORCA_USER_DATA_PATH', previousUserData)
}
})
it('returns null when no transcript matches', async () => {
const root = await makeRoot('orca-native-chat-resolve-missing-')
const claudeProjectsDir = join(root, 'claude-projects')
await mkdir(claudeProjectsDir, { recursive: true })
expect(await resolveSessionFilePath('claude', 'nope', { claudeProjectsDir })).toBeNull()
})
it('returns null for unsupported agents', async () => {
expect(await resolveSessionFilePath('gemini', 'whatever')).toBeNull()
})
it('prefers the hook transcriptPath when it exists (Claude id != file name)', async () => {
// Recent Claude Code names the file with a UUID that differs from the hook
// session_id, so the id glob would miss it — but transcript_path is exact.
const root = await makeRoot('orca-native-chat-resolve-path-')
const claudeProjectsDir = join(root, 'claude-projects')
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
await mkdir(projectDir, { recursive: true })
// The real transcript is named by a DIFFERENT id than the hook session id.
const realFile = join(projectDir, 'real-file-uuid.jsonl')
await writeFile(realFile, '{}\n')
const resolved = await resolveSessionFilePath('claude', 'hook-session-id', {
claudeProjectsDir,
transcriptPath: realFile
})
expect(resolved).toBe(realFile)
})
it('falls back to the id glob when the hook transcriptPath does not exist', async () => {
const root = await makeRoot('orca-native-chat-resolve-path-stale-')
const claudeProjectsDir = join(root, 'claude-projects')
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
await mkdir(projectDir, { recursive: true })
const target = join(projectDir, 'sess-xyz.jsonl')
await writeFile(target, '{}\n')
const resolved = await resolveSessionFilePath('claude', 'sess-xyz', {
claudeProjectsDir,
transcriptPath: join(projectDir, 'does-not-exist.jsonl')
})
expect(resolved).toBe(target)
})
it('ignores a non-jsonl transcriptPath and falls back to the glob', async () => {
const root = await makeRoot('orca-native-chat-resolve-path-ext-')
const claudeProjectsDir = join(root, 'claude-projects')
const projectDir = join(claudeProjectsDir, '-Users-ada-repo')
await mkdir(projectDir, { recursive: true })
const bogus = join(projectDir, 'not-a-transcript.txt')
await writeFile(bogus, 'x')
const target = join(projectDir, 'sess-ok.jsonl')
await writeFile(target, '{}\n')
const resolved = await resolveSessionFilePath('claude', 'sess-ok', {
claudeProjectsDir,
transcriptPath: bogus
})
expect(resolved).toBe(target)
})
})
@@ -0,0 +1,117 @@
import { existsSync } from 'fs'
import { homedir } from 'os'
import { basename, extname, join } from 'path'
import type { AgentType } from '../../shared/native-chat-types'
import { walkSessionFiles } from '../ai-vault/session-scanner-discovery'
import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths'
// Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads
// run in the main process against the runtime's own home directory; over SSH
// the remote main resolves its local home, so we never hardcode an absolute
// user path — homedir()/CODEX_HOME resolution stays runtime-relative and is
// computed per call (not at module load) so it tracks the live home.
function claudeProjectsDir(): string {
return join(homedir(), '.claude', 'projects')
}
// Why: Orca launches Codex with ORCA_CODEX_HOME pointing at its own managed
// runtime home, so Orca-started Codex rollout files land under
// `<managed home>/sessions`, NOT `~/.codex/sessions`. Search the managed home
// first (that's where this main process's Codex sessions actually live), then
// fall back to CODEX_HOME/~/.codex so a non-Orca Codex transcript still resolves.
// Duplicates are filtered so a managed-home symlink to ~/.codex isn't scanned twice.
function codexSessionsDirs(): string[] {
const candidates = [
join(getOrcaManagedCodexHomePath(), 'sessions'),
join(process.env.CODEX_HOME?.trim() || join(homedir(), '.codex'), 'sessions')
]
return candidates.filter((dir, index) => candidates.indexOf(dir) === index)
}
export type ResolveSessionFileOptions = {
/** Override the Claude projects root (used by tests / isolated scans). */
claudeProjectsDir?: string
/** Override the Codex sessions roots, searched in order (tests / isolated
* scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */
codexSessionsDirs?: string[]
/** Authoritative transcript path reported by the agent hook
* (`providerSession.transcriptPath`). When set and the file exists, it is used
* directly — recent Claude Code names the transcript with a UUID that differs
* from the hook session_id, so the id-based glob below would miss it. */
transcriptPath?: string
}
/**
* Resolve the on-disk JSONL transcript path for a given agent + session id.
*
* Prefers the hook-reported `transcriptPath` when it exists on disk (authoritative).
* Otherwise: Claude nests transcripts by project slug
* (`~/.claude/projects/<slug>/<id>.jsonl`), so we glob the projects subdirs for
* `<id>.jsonl`. Codex stores rollout files under date-nested dirs whose file name
* embeds the session id, so we match by the session id appearing in the file name.
* Returns null when no matching transcript exists.
*/
export async function resolveSessionFilePath(
agent: AgentType,
sessionId: string,
options: ResolveSessionFileOptions = {}
): Promise<string | null> {
// Why: the hook's transcript_path is the exact file the agent is writing, so it
// beats reconstructing a path from the session id. Guard with existsSync so a
// stale/remote path falls through to the id-based search rather than returning
// a non-existent file.
const hookPath = options.transcriptPath?.trim()
if (hookPath && extname(hookPath) === '.jsonl' && existsSync(hookPath)) {
return hookPath
}
const trimmedId = sessionId.trim()
if (!trimmedId) {
return null
}
if (agent === 'claude') {
return resolveClaudeSessionFile(trimmedId, options.claudeProjectsDir ?? claudeProjectsDir())
}
if (agent === 'codex') {
return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs())
}
return null
}
async function resolveClaudeSessionFile(
sessionId: string,
projectsDir: string
): Promise<string | null> {
const targetName = `${sessionId}.jsonl`
const files = await walkSessionFiles(projectsDir, 'claude', [], {
extensions: new Set(['.jsonl']),
filePredicate: (path) => basename(path) === targetName
})
return files[0] ?? null
}
async function resolveCodexSessionFile(
sessionId: string,
sessionsDirs: string[]
): Promise<string | null> {
// Codex rollout file names embed the session id (rollout-<ts>-<id>.jsonl), so
// match the id as a suffix of the file's base name rather than an exact name.
// Search each candidate root (managed home first) and stop at the first match.
for (const sessionsDir of sessionsDirs) {
if (!existsSync(sessionsDir)) {
continue
}
const files = await walkSessionFiles(sessionsDir, 'codex', [], {
extensions: new Set(['.jsonl']),
filePredicate: (path) => {
const name = basename(path, extname(path))
return name === sessionId || name.endsWith(`-${sessionId}`)
}
})
if (files[0]) {
return files[0]
}
}
return null
}
@@ -0,0 +1,185 @@
// Per-line record→NativeChatMessage decoders, shared by the full transcript
// reader (transcript-reader.ts) and the live tailer (transcript-watch.ts) so
// both paths apply identical record-shape mapping. Each decoder is stateless:
// it takes a single JSONL line plus a stable fallback id and returns one message
// or null (unknown/empty records are skipped, never thrown — plan KTD risk:
// schema drift). `fallbackId` is used only when the record carries no intrinsic
// id; the caller supplies a value unique per line.
import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types'
import {
asRecord,
extractString,
parseJsonObject,
timestampMs
} from '../ai-vault/session-scanner-values'
import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks'
export function decodeClaudeTranscriptLine(
line: string,
fallbackId: string
): NativeChatMessage | null {
const record = parseJsonObject(line)
if (!record) {
return null
}
const role = record.type
if (role !== 'user' && role !== 'assistant') {
return null
}
const message = asRecord(record.message)
const blocks = claudeContentBlocks(message?.content)
if (blocks.length === 0) {
return null
}
const messageId = extractString(record.uuid) ?? extractString(message?.id)
return {
id: messageId ?? fallbackId,
role: claudeMessageRole(role, blocks),
blocks,
timestamp: parseTimestamp(record.timestamp),
source: 'transcript'
}
}
// Claude marks reasoning via `thinking` content blocks; when a message is made
// up solely of reasoning, surface it as a reasoning-role message.
function claudeMessageRole(
role: 'user' | 'assistant',
blocks: NativeChatBlock[]
): NativeChatMessage['role'] {
if (role === 'user') {
const onlyToolResults = blocks.every((block) => block.type === 'tool-result')
return onlyToolResults && blocks.length > 0 ? 'tool' : 'user'
}
return role
}
export function decodeCodexTranscriptLine(
line: string,
fallbackId: string
): NativeChatMessage | null {
const record = parseJsonObject(line)
if (!record) {
return null
}
const payload = asRecord(record.payload)
if (!payload) {
return null
}
const timestamp = parseTimestamp(record.timestamp)
const baseId = extractString(payload.id) ?? fallbackId
if (record.type === 'response_item') {
return codexResponseItem(payload, baseId, timestamp)
}
if (record.type === 'event_msg') {
return codexEventMessage(payload, baseId, timestamp)
}
return null
}
function codexResponseItem(
payload: Record<string, unknown>,
id: string,
timestamp: number | null
): NativeChatMessage | null {
if (payload.type === 'message') {
const blocks = claudeContentBlocks(payload.content)
if (blocks.length === 0) {
return null
}
const role =
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system'
return { id, role, blocks, timestamp, source: 'transcript' }
}
if (payload.type === 'reasoning') {
const text = extractString(payload.text) ?? codexSummaryText(payload.summary)
if (!text) {
return null
}
return {
id,
role: 'reasoning',
blocks: [{ type: 'text', text }],
timestamp,
source: 'transcript'
}
}
if (payload.type === 'function_call' || payload.type === 'local_shell_call') {
const name = extractString(payload.name) ?? 'tool'
return {
id,
role: 'assistant',
blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }],
timestamp,
source: 'transcript'
}
}
if (payload.type === 'function_call_output') {
return {
id,
role: 'tool',
blocks: [codexToolResult(payload.output)],
timestamp,
source: 'transcript'
}
}
return null
}
function codexEventMessage(
payload: Record<string, unknown>,
id: string,
timestamp: number | null
): NativeChatMessage | null {
if (payload.type === 'user_message') {
const text = extractString(payload.message)
return text
? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
: null
}
if (payload.type === 'agent_message') {
const text = extractString(payload.message)
return text
? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' }
: null
}
return null
}
function codexCallInput(payload: Record<string, unknown>): unknown {
if (payload.arguments !== undefined) {
return payload.arguments
}
return payload.input ?? payload.action ?? null
}
function codexToolResult(output: unknown): NativeChatBlock {
const record = asRecord(output)
const isError = record?.success === false || record?.is_error === true
return {
type: 'tool-result',
output: toolResultOutput(record?.content ?? record?.output ?? output),
...(isError ? { isError: true } : {})
}
}
function codexSummaryText(summary: unknown): string | null {
if (!Array.isArray(summary)) {
return null
}
const parts: string[] = []
for (const item of summary) {
const text = extractString(asRecord(item)?.text) ?? extractString(item)
if (text) {
parts.push(text)
}
}
return parts.length ? parts.join('\n') : null
}
function parseTimestamp(value: unknown): number | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? parsed : null
}
@@ -0,0 +1,92 @@
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as TranscriptReader from './transcript-reader'
// Spy on the underlying reader so we can assert cache hits issue zero reads.
const readSpy = vi.hoisted(() => vi.fn())
vi.mock('./transcript-reader', async (importOriginal) => {
const actual = await importOriginal<typeof TranscriptReader>()
return {
...actual,
readNativeChatTranscript: (...args: Parameters<typeof actual.readNativeChatTranscript>) => {
readSpy(...args)
return actual.readNativeChatTranscript(...args)
}
}
})
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached
} from './transcript-read-cache'
let tempRoots: string[] = []
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
async function seedSession(sessionId: string, turns: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-'))
tempRoots.push(root)
const projectDir = join(root, '.claude', 'projects', '-repo')
await mkdir(projectDir, { recursive: true })
const records = Array.from({ length: turns }, (_unused, n) => ({
type: 'user',
uuid: `u-${n}`,
timestamp: `2026-06-01T10:00:0${n}.000Z`,
message: { role: 'user', content: `m${n}` }
}))
const filePath = join(projectDir, `${sessionId}.jsonl`)
await writeFile(filePath, jsonLines(records))
process.env.HOME = root
return filePath
}
beforeEach(() => {
clearNativeChatTranscriptCache()
readSpy.mockClear()
})
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('readNativeChatTranscriptCached', () => {
it('returns the same cached object on an mtime hit without re-reading', async () => {
await seedSession('sess-hit', 3)
const first = await readNativeChatTranscriptCached('claude', 'sess-hit')
const second = await readNativeChatTranscriptCached('claude', 'sess-hit')
expect(readSpy).toHaveBeenCalledTimes(1)
// Same reference: the second call served the cached parse.
expect(second).toBe(first)
})
it('re-reads when the file mtime changes', async () => {
const filePath = await seedSession('sess-mtime', 2)
await readNativeChatTranscriptCached('claude', 'sess-mtime')
expect(readSpy).toHaveBeenCalledTimes(1)
// Bump mtime into the future to invalidate without changing content shape.
const future = new Date(Date.now() + 5_000)
await utimes(filePath, future, future)
await readNativeChatTranscriptCached('claude', 'sess-mtime')
expect(readSpy).toHaveBeenCalledTimes(2)
})
it('clear() empties the cache so the next read re-reads', async () => {
await seedSession('sess-clear', 1)
await readNativeChatTranscriptCached('claude', 'sess-clear')
clearNativeChatTranscriptCache()
await readNativeChatTranscriptCached('claude', 'sess-clear')
expect(readSpy).toHaveBeenCalledTimes(2)
})
it('returns an error result for an unknown session without throwing', async () => {
await seedSession('present', 1)
const result = await readNativeChatTranscriptCached('claude', 'absent')
expect('error' in result && result.error).toBeTruthy()
})
})
@@ -0,0 +1,90 @@
import { stat } from 'fs/promises'
import type { AgentType } from '../../shared/native-chat-types'
import { resolveSessionFilePath } from './session-file-resolver'
import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader'
// Why: both the desktop IPC handler and the runtime RPC handler read the same
// host-filesystem transcript, so a single process-global cache keyed by
// agent:sessionId maximizes the hit rate across desktop + every paired
// web/mobile client. Keying by connection instead would defeat the multi-client
// case this feature targets and multiply memory by the connection count.
// The cache stores ONE canonical, unwindowed parse; windowing and per-surface
// truncation stay in the callers so the same parse is reused across all `limit`
// values and every client kind.
type CachedTranscript = {
result: ReadTranscriptResult
/** mtime of the resolved file when cached; a newer mtime invalidates it. */
mtimeMs: number
}
const cache = new Map<string, CachedTranscript>()
// Why: cap the cache so a long-lived process browsing many sessions can't grow
// it unbounded. Map preserves insertion order, so evicting the first key drops
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling
// shows RSS pressure now that one process serves many remote clients.
const MAX_CACHE_ENTRIES = 50
function setCached(key: string, value: CachedTranscript): void {
// Re-insert moves the key to the most-recent position for LRU eviction.
cache.delete(key)
cache.set(key, value)
while (cache.size > MAX_CACHE_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
cache.delete(oldest)
}
}
function cacheKey(agent: AgentType, sessionId: string): string {
return `${agent}:${sessionId}`
}
async function fileMtimeMs(filePath: string): Promise<number> {
try {
return (await stat(filePath)).mtimeMs
} catch {
return Number.NaN
}
}
/**
* Read the full transcript for an agent + session, returning the cached parse on
* an mtime hit and re-reading (and re-caching) when the file changed. Returns the
* canonical, unwindowed result; callers apply their own windowing/truncation.
*/
export async function readNativeChatTranscriptCached(
agent: AgentType,
sessionId: string,
/** Hook-reported authoritative transcript path, preferred over the id glob. */
transcriptPath?: string
): Promise<ReadTranscriptResult> {
const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath })
if (!filePath) {
return { error: `No transcript found for ${agent} session ${sessionId}` }
}
const key = cacheKey(agent, sessionId)
const mtimeMs = await fileMtimeMs(filePath)
const cached = cache.get(key)
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
// Bump recency so a frequently-read session survives eviction.
setCached(key, cached)
return cached.result
}
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
if (Number.isFinite(mtimeMs)) {
setCached(key, { result, mtimeMs })
}
return result
}
/** Test-only: drop the per-session transcript cache between runs. */
export function clearNativeChatTranscriptCache(): void {
cache.clear()
}
@@ -0,0 +1,192 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { readNativeChatTranscript } from './transcript-reader'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
async function writeFixture(prefix: string, records: unknown[]): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix))
tempRoots.push(root)
const filePath = join(root, 'transcript.jsonl')
await writeFile(filePath, jsonLines(records))
return filePath
}
describe('readNativeChatTranscript (claude)', () => {
it('returns ordered user/assistant/tool messages with no 5-message cap', async () => {
const records: unknown[] = []
// 4 user/assistant turns = 8 messages, well past the AI-Vault preview cap.
for (let turn = 0; turn < 4; turn++) {
records.push({
type: 'user',
uuid: `u-${turn}`,
timestamp: `2026-06-01T10:0${turn}:00.000Z`,
message: { role: 'user', content: `Prompt **${turn}**` }
})
records.push({
type: 'assistant',
uuid: `a-${turn}`,
timestamp: `2026-06-01T10:0${turn}:30.000Z`,
message: { role: 'assistant', content: [{ type: 'text', text: `Reply _${turn}_` }] }
})
}
// A tool_use then a tool_result (carried on a user record).
records.push({
type: 'assistant',
uuid: 'a-tool',
timestamp: '2026-06-01T10:05:00.000Z',
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]
}
})
records.push({
type: 'user',
uuid: 'u-toolresult',
timestamp: '2026-06-01T10:05:01.000Z',
message: {
role: 'user',
content: [{ type: 'tool_result', content: 'file-a\nfile-b', is_error: false }]
}
})
const filePath = await writeFixture('orca-native-chat-claude-', records)
const result = await readNativeChatTranscript('claude', 'sess', { filePath })
expect('messages' in result).toBe(true)
if (!('messages' in result)) {
return
}
expect(result.messages.length).toBe(10)
expect(result.messages.length).toBeGreaterThan(5)
expect(result.messages[0]).toMatchObject({ role: 'user', source: 'transcript' })
// Markdown text preserved verbatim.
expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'Prompt **0**' })
expect(result.messages[1].blocks[0]).toEqual({ type: 'text', text: 'Reply _0_' })
const toolCall = result.messages.find((m) => m.blocks[0]?.type === 'tool-call')
expect(toolCall?.blocks[0]).toEqual({
type: 'tool-call',
name: 'Bash',
input: { command: 'ls' }
})
const toolResult = result.messages.at(-1)
expect(toolResult?.role).toBe('tool')
expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'file-a\nfile-b' })
})
it('marks thinking-only assistant content as a reasoning surface', async () => {
const filePath = await writeFixture('orca-native-chat-claude-think-', [
{
type: 'assistant',
uuid: 'a-think',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'assistant', content: [{ type: 'thinking', thinking: 'pondering' }] }
}
])
const result = await readNativeChatTranscript('claude', 'sess', { filePath })
if (!('messages' in result)) {
throw new Error('expected messages')
}
expect(result.messages[0].blocks[0]).toEqual({ type: 'text', text: 'pondering' })
})
})
describe('readNativeChatTranscript (codex)', () => {
it('maps tool calls and results to tool-call/tool-result blocks', async () => {
const filePath = await writeFixture('orca-native-chat-codex-', [
{
type: 'session_meta',
timestamp: '2026-06-01T10:00:00.000Z',
payload: { id: 'codex-sess', cwd: '/repo' }
},
{
type: 'response_item',
timestamp: '2026-06-01T10:00:01.000Z',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text: 'Run the build' }]
}
},
{
type: 'response_item',
timestamp: '2026-06-01T10:00:02.000Z',
payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: 'I will run it' }] }
},
{
type: 'response_item',
timestamp: '2026-06-01T10:00:03.000Z',
payload: {
type: 'function_call',
name: 'shell',
arguments: '{"command":["bash","-lc","make"]}'
}
},
{
type: 'response_item',
timestamp: '2026-06-01T10:00:04.000Z',
payload: {
type: 'function_call_output',
output: { content: 'build ok', success: true }
}
},
{
type: 'response_item',
timestamp: '2026-06-01T10:00:05.000Z',
payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }
}
])
const result = await readNativeChatTranscript('codex', 'codex-sess', { filePath })
if (!('messages' in result)) {
throw new Error(`expected messages, got error`)
}
const roles = result.messages.map((m) => m.role)
expect(roles).toEqual(['user', 'reasoning', 'assistant', 'tool', 'assistant'])
const call = result.messages.find((m) => m.blocks[0]?.type === 'tool-call')
expect(call?.blocks[0]).toEqual({
type: 'tool-call',
name: 'shell',
input: '{"command":["bash","-lc","make"]}'
})
const toolResult = result.messages.find((m) => m.blocks[0]?.type === 'tool-result')
expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'build ok' })
const reasoning = result.messages.find((m) => m.role === 'reasoning')
expect(reasoning?.blocks[0]).toEqual({ type: 'text', text: 'I will run it' })
})
})
describe('readNativeChatTranscript (errors)', () => {
it('returns an error for an unreadable/missing file without throwing', async () => {
const result = await readNativeChatTranscript('claude', 'sess', {
filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl')
})
expect('error' in result).toBe(true)
})
it('returns an error when no transcript can be resolved', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-'))
tempRoots.push(root)
const result = await readNativeChatTranscript('claude', 'missing', {
claudeProjectsDir: join(root, 'empty')
})
expect('error' in result).toBe(true)
})
})
+66
View File
@@ -0,0 +1,66 @@
import { createReadStream } from 'fs'
import { createInterface } from 'readline'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import { errorMessage } from '../ai-vault/session-scanner-values'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders'
export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string }
export type ReadTranscriptOptions = ResolveSessionFileOptions & {
/** Resolve directly to this file, skipping path discovery (used by tests). */
filePath?: string
}
/**
* Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into
* the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies
* NO message cap. Unknown record types are skipped rather than throwing, so a
* single malformed/unrecognized line cannot fail the whole read. The per-line
* record→message mapping is shared with the live tailer (transcript-watch.ts)
* via transcript-line-decoders.ts.
*/
export async function readNativeChatTranscript(
agent: AgentType,
sessionId: string,
options: ReadTranscriptOptions = {}
): Promise<ReadTranscriptResult> {
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
if (!filePath) {
return { error: `No transcript found for ${agent} session ${sessionId}` }
}
try {
if (agent === 'claude') {
return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) }
}
if (agent === 'codex') {
return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) }
}
return { error: `Unsupported agent for native chat transcript: ${agent}` }
} catch (err) {
return { error: errorMessage(err) }
}
}
async function readTranscript(
filePath: string,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): Promise<NativeChatMessage[]> {
const reader = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
const messages: NativeChatMessage[] = []
let index = 0
for await (const line of reader) {
// Why: fallback id embeds start offset 0 so it matches the live tailer's id
// for the same record (the tailer's first drain reads from offset 0 too).
// Records that re-emit then collapse by id in the assembler — no dup, no drop.
const message = decode(line, `${filePath}:0:${index}`)
if (message) {
messages.push(message)
}
index++
}
return messages
}
@@ -0,0 +1,117 @@
// Centralized record→block mapping for native-chat transcripts. Kept separate
// from the reader so the Claude and Codex per-record decoders share one place
// to evolve as CLI transcript schemas drift (plan KTD risk: schema drift).
import type {
NativeChatBlock,
NativeChatImageRefBlock,
NativeChatToolResultBlock
} from '../../shared/native-chat-types'
import { asRecord, extractString } from '../ai-vault/session-scanner-values'
/** Coerce an arbitrary tool-result payload into a single output string. */
export function toolResultOutput(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (!Array.isArray(value)) {
const record = asRecord(value)
if (record) {
const text = extractString(record.text) ?? extractString(record.content)
if (text) {
return text
}
}
return value === undefined || value === null ? '' : JSON.stringify(value)
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
const record = asRecord(item)
const text = extractString(record?.text) ?? extractString(record?.content)
if (text) {
parts.push(text)
}
}
return parts.join('\n')
}
/** Build the blocks for one Claude content array (string or block[]). */
export function claudeContentBlocks(content: unknown): NativeChatBlock[] {
if (typeof content === 'string') {
const text = content.trim()
return text ? [{ type: 'text', text: content }] : []
}
if (!Array.isArray(content)) {
return []
}
const blocks: NativeChatBlock[] = []
for (const item of content) {
if (typeof item === 'string') {
if (item.trim()) {
blocks.push({ type: 'text', text: item })
}
continue
}
const record = asRecord(item)
if (!record) {
continue
}
const block = claudeContentBlock(record)
if (block) {
blocks.push(block)
}
}
return blocks
}
function claudeContentBlock(record: Record<string, unknown>): NativeChatBlock | null {
switch (record.type) {
case 'text': {
const text = extractString(record.text)
return text ? { type: 'text', text } : null
}
case 'thinking': {
// Reasoning surfaces as a text block; the message role marks it as reasoning.
const text = extractString(record.thinking) ?? extractString(record.text)
return text ? { type: 'text', text } : null
}
case 'tool_use': {
const name = extractString(record.name) ?? 'tool'
return { type: 'tool-call', name, input: record.input }
}
case 'tool_result':
return toolResultBlock(record)
case 'image':
return imageRefBlock(record)
default:
return null
}
}
function toolResultBlock(record: Record<string, unknown>): NativeChatToolResultBlock {
return {
type: 'tool-result',
output: toolResultOutput(record.content),
...(record.is_error === true ? { isError: true } : {})
}
}
function imageRefBlock(record: Record<string, unknown>): NativeChatImageRefBlock | null {
const source = asRecord(record.source)
const url = extractString(source?.url) ?? extractString(record.url)
const path = extractString(record.path)
const alt = extractString(record.alt) ?? undefined
if (!url && !path) {
return null
}
return {
type: 'image-ref',
...(path ? { path } : {}),
...(url ? { url } : {}),
...(alt ? { alt } : {})
}
}
@@ -0,0 +1,239 @@
import { appendFile, mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch'
let tempRoots: string[] = []
beforeEach(() => {
tempRoots = []
})
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
async function tempFile(initial: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-'))
tempRoots.push(root)
const filePath = join(root, 'rollout.jsonl')
await writeFile(filePath, initial)
return filePath
}
function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
return `${JSON.stringify({
type: role,
uuid,
timestamp: '2026-06-01T10:00:00.000Z',
message: { role, content: role === 'user' ? text : [{ type: 'text', text }] }
})}\n`
}
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error('timed out waiting for condition')
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
describe('subscribeNativeChatTranscript', () => {
it('re-emits from the top on first drain so appended turns are never dropped', async () => {
const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
const batches: NativeChatMessage[][] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => batches.push(messages),
debounceMs: 5
})
await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply'))
await waitFor(() => batches.flat().some((m) => m.id === 'a-1'))
sub.unsubscribe()
// Seed-at-0 means the first drain re-reads the whole file; the assembler
// dedups by id. The appended turn must appear; the pre-existing line may
// appear too (collapsed downstream by id).
const ids = batches.flat().map((m) => m.id)
expect(ids).toContain('a-1')
})
it('appends a turn in the gap between initial read and first watcher drain exactly once', async () => {
// Simulate the read/subscribe race: a turn lands after the caller's
// readSession EOF but before the watcher's first drain. Seeding at 0 means
// the first drain reads it; the assembler later dedups by deterministic id.
const filePath = await tempFile(claudeLine('u-1', 'user', 'first'))
const seen: NativeChatMessage[] = []
// The gap turn is written BEFORE subscribe completes its first drain.
await appendFile(filePath, claudeLine('a-gap', 'assistant', 'raced reply'))
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => seen.push(...messages),
debounceMs: 5
})
await waitFor(() => seen.some((m) => m.id === 'a-gap'))
sub.unsubscribe()
// The raced turn is present, and not duplicated within a single drain pass.
expect(seen.filter((m) => m.id === 'a-gap')).toHaveLength(1)
})
it('recovers cleanly when a read throws (subscription not left deaf)', async () => {
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
const seen: NativeChatMessage[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => seen.push(...messages),
debounceMs: 5
})
// Make the file unreadable mid-flight (EACCES on the read path). The drain's
// try/catch must break and reset `reading` in finally so a later append
// still tails once permissions are restored.
await waitFor(() => seen.some((m) => m.id === 'u-1'))
const { chmod } = await import('fs/promises')
await chmod(filePath, 0o000)
await appendFile(filePath, claudeLine('a-1', 'assistant', 'reply')).catch(() => {})
// Give the watcher a chance to attempt (and fail) a drain.
await new Promise((resolve) => setTimeout(resolve, 40))
await chmod(filePath, 0o644)
await appendFile(filePath, claudeLine('a-2', 'assistant', 'recovered'))
await waitFor(() => seen.some((m) => m.id === 'a-2'))
sub.unsubscribe()
expect(seen.some((m) => m.id === 'a-2')).toBe(true)
})
it('releases the watcher on unsubscribe (no leak)', async () => {
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
const before = getActiveNativeChatWatcherCount()
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: () => {},
debounceMs: 5
})
expect(getActiveNativeChatWatcherCount()).toBe(before + 1)
sub.unsubscribe()
expect(getActiveNativeChatWatcherCount()).toBe(before)
// Idempotent: a second unsubscribe must not under-count.
sub.unsubscribe()
expect(getActiveNativeChatWatcherCount()).toBe(before)
})
it('coalesces rapid successive appends without dropping messages', async () => {
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
const seen: NativeChatMessage[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => seen.push(...messages),
debounceMs: 10
})
// Fire several appends back-to-back within the debounce window.
await appendFile(filePath, claudeLine('a-1', 'assistant', 'one'))
await appendFile(filePath, claudeLine('a-2', 'assistant', 'two'))
await appendFile(filePath, claudeLine('a-3', 'assistant', 'three'))
await waitFor(() => ['a-1', 'a-2', 'a-3'].every((id) => seen.some((m) => m.id === id)))
sub.unsubscribe()
// Order is preserved for the appended turns (the seed re-read may also carry
// the pre-existing u-1, which the assembler dedups downstream).
const appendedIds = seen.map((m) => m.id).filter((id) => id !== 'u-1')
expect(appendedIds).toEqual(['a-1', 'a-2', 'a-3'])
})
it('waits for an incomplete trailing JSONL line before advancing the offset', async () => {
const filePath = await tempFile(claudeLine('u-1', 'user', 'hi'))
const seen: NativeChatMessage[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => seen.push(...messages),
debounceMs: 5
})
await waitFor(() => seen.some((m) => m.id === 'u-1'))
const line = claudeLine('a-partial', 'assistant', 'split reply')
const splitAt = Math.floor(line.length / 2)
await appendFile(filePath, line.slice(0, splitAt))
await new Promise((resolve) => setTimeout(resolve, 40))
expect(seen.some((m) => m.id === 'a-partial')).toBe(false)
await appendFile(filePath, line.slice(splitAt))
await waitFor(() => seen.some((m) => m.id === 'a-partial'))
sub.unsubscribe()
expect(seen.filter((m) => m.id === 'a-partial')).toHaveLength(1)
})
it('survives file replacement / rotation (offset reset on shrink)', async () => {
const filePath = await tempFile(
claudeLine('u-1', 'user', 'old') + claudeLine('a-1', 'assistant', 'old-reply')
)
const seen: NativeChatMessage[] = []
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'ignored',
filePath,
onAppend: (messages) => seen.push(...messages),
debounceMs: 5
})
// Replace the file with shorter content (simulates rotation to a new,
// smaller session file at the same resolved path).
await writeFile(filePath, claudeLine('u-2', 'user', 'fresh'))
await waitFor(() => seen.some((m) => m.id === 'u-2'))
// A subsequent append on the rotated file is still tailed.
await appendFile(filePath, claudeLine('a-2', 'assistant', 'fresh-reply'))
await waitFor(() => seen.some((m) => m.id === 'a-2'))
sub.unsubscribe()
const ids = seen.map((m) => m.id)
expect(ids).toContain('u-2')
expect(ids).toContain('a-2')
})
it('returns a no-op unsubscribe when the file cannot be resolved', async () => {
const before = getActiveNativeChatWatcherCount()
const sub = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: '',
onAppend: () => {}
})
expect(getActiveNativeChatWatcherCount()).toBe(before)
// Must not throw.
sub.unsubscribe()
})
})
+254
View File
@@ -0,0 +1,254 @@
// Live transcript tailing: watch a resolved session JSONL file and emit only
// the messages parsed from bytes appended since the last read. Modeled on the
// incremental byte-offset read in codex-usage/scanner.ts (parseCodexUsageFile's
// skipInitialBytes), but specialized to the NativeChatMessage record decoders.
//
// Teardown discipline (plan U4 risk: file-watch fd leaks): every subscription
// owns exactly one fs.FSWatcher and one debounce timer. unsubscribe() closes
// the watcher and clears the timer synchronously, and the module tracks the live
// watcher count so tests can assert no watcher survives teardown.
import { watch, type FSWatcher } from 'fs'
import { open, stat } from 'fs/promises'
import type { Readable } from 'stream'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders'
export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
agent: AgentType
sessionId: string
/** Called with the newly-appended messages whenever the file grows. Never
* called with an empty array. */
onAppend: (messages: NativeChatMessage[]) => void
/** Resolve directly to this file, skipping path discovery (used by tests). */
filePath?: string
/** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */
debounceMs?: number
}
export type NativeChatTranscriptSubscription = {
/** Closes the watcher and releases the file handle. Idempotent. */
unsubscribe: () => void
}
// Why: a single watch event can fire several times for one append; we read from
// the last byte offset so re-entrant reads never re-emit prior messages. Each
// decoder is stateless per-line, so tailing reuses the same record→message
// mapping the full reader uses.
const DEFAULT_DEBOUNCE_MS = 40
// Why: process-wide count of live FSWatchers opened by this module. The U4 leak
// test asserts this returns to zero after unsubscribe so a forgotten handle is
// caught deterministically rather than relying on OS fd inspection.
let activeWatcherCount = 0
/** Test-only: number of fs watchers this module currently holds open. */
export function getActiveNativeChatWatcherCount(): number {
return activeWatcherCount
}
function lineDecoderForAgent(
agent: AgentType
): ((line: string, fallbackId: string) => NativeChatMessage | null) | null {
if (agent === 'claude') {
return decodeClaudeTranscriptLine
}
if (agent === 'codex') {
return decodeCodexTranscriptLine
}
return null
}
async function fileSize(filePath: string): Promise<number> {
try {
return (await stat(filePath)).size
} catch {
return 0
}
}
/**
* Read bytes [start, end) of the file and decode each complete line into a
* NativeChatMessage. Opens its own fd and always closes it (no leak on the read
* path, distinct from the long-lived watcher). Returns the messages plus the
* byte offset actually consumed so a partially-written trailing line is re-read
* on the next append rather than dropped.
*/
async function readAppendedMessages(
filePath: string,
start: number,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): Promise<{ messages: NativeChatMessage[]; consumedTo: number }> {
const end = await fileSize(filePath)
if (end <= start) {
// File shrank (rotation/replacement) or unchanged — caller resets offset.
return { messages: [], consumedTo: end }
}
const handle = await open(filePath, 'r')
try {
const stream = handle.createReadStream({
encoding: 'utf-8',
start,
end: end - 1,
autoClose: false
})
const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode)
return { messages, consumedTo: start + consumedBytes }
} finally {
await handle.close()
}
}
async function decodeStreamLines(
stream: Readable,
filePath: string,
start: number,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
const text = await readStreamText(stream)
const completeEnd = text.lastIndexOf('\n')
if (completeEnd === -1) {
return { messages: [], consumedBytes: 0 }
}
// Why: transcript writers can flush mid-record. Only advance through
// newline-terminated JSONL so invalid partial JSON is retried on the next
// append instead of being lost forever.
const completeText = text.slice(0, completeEnd + 1)
const lines = completeText.split('\n')
const messages: NativeChatMessage[] = []
for (const [index, line] of lines.entries()) {
if (!line) {
continue
}
// Fallback id embeds the byte offset so ids stay stable+unique across
// appends even when a record carries no intrinsic id.
const message = decode(line, `${filePath}:${start}:${index}`)
if (message) {
messages.push(message)
}
}
return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') }
}
async function readStreamText(stream: Readable): Promise<string> {
const chunks: string[] = []
for await (const chunk of stream) {
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
}
return chunks.join('')
}
/**
* Subscribe to live appends on an agent's transcript file. Returns an
* unsubscribe fn that tears the watcher down completely.
*
* Handles file rotation/replacement: when the file shrinks (a new session id
* resolved to a smaller/newer file, or the file was truncated), the offset is
* reset to 0 so the replacement's content is read from the top.
*/
export async function subscribeNativeChatTranscript(
args: SubscribeNativeChatTranscriptArgs
): Promise<NativeChatTranscriptSubscription> {
const { agent, sessionId, onAppend, debounceMs } = args
const decode = lineDecoderForAgent(agent)
const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args))
if (!filePath || !decode) {
// Nothing watchable — return a no-op teardown so callers can unconditionally
// unsubscribe without null-checks.
return { unsubscribe: () => {} }
}
// Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This
// closes the read/subscribe race — a turn appended between the caller's
// readSession EOF and the watcher install is still emitted. Re-emitted lines
// collapse by deterministic id in the assembler (no dup, no drop). Subsequent
// drains use the incremental offset so the full re-read happens only once.
let offset = 0
let closed = false
let reading = false
let pendingReadRequested = false
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function drain(): Promise<void> {
if (closed) {
return
}
if (reading) {
// A read is already in flight; mark that another pass is needed so rapid
// successive appends coalesce without dropping the trailing one.
pendingReadRequested = true
return
}
reading = true
try {
do {
pendingReadRequested = false
try {
const currentSize = await fileSize(filePath!)
if (currentSize < offset) {
// Rotation/replacement/truncation: re-read from the top.
offset = 0
}
const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!)
offset = consumedTo
if (!closed && messages.length > 0) {
onAppend(messages)
}
} catch {
// Why: a transient read failure (EACCES/EIO/ENOENT during rotation)
// must not leave the subscription permanently deaf. Stop this drain;
// the finally resets `reading` so a later fs event re-arms the read.
break
}
} while (pendingReadRequested && !closed)
} finally {
reading = false
}
}
function scheduleDrain(): void {
if (closed) {
return
}
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
debounceTimer = null
void drain()
}, debounceMs ?? DEFAULT_DEBOUNCE_MS)
}
let watcher: FSWatcher
try {
watcher = watch(filePath, scheduleDrain)
} catch {
// File vanished between resolve and watch — return a no-op teardown.
return { unsubscribe: () => {} }
}
activeWatcherCount++
// Why: on some platforms fs.watch can miss the very first append that lands
// between offset-seed and watcher install. Kick one debounced drain so a
// turn written immediately after subscribe is still picked up.
scheduleDrain()
return {
unsubscribe: () => {
if (closed) {
return
}
closed = true
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
watcher.close()
activeWatcherCount--
}
}
}
+82
View File
@@ -8621,3 +8621,85 @@ describe('Store host-partitioned workspace sessions', () => {
expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull()
})
})
describe('Store native-chat tab viewMode persistence', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
// Why: a tab persisted in 'chat' must restore to 'chat' (R1), and a tab
// persisted before the field existed must default to 'terminal' — i.e. the
// field is absent on restore — so older sessions stay backward-compatible.
it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => {
const WORKTREE = 'repo1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {
activeRepoId: 'r1',
activeWorktreeId: WORKTREE,
activeTabId: 'chat-tab',
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {},
unifiedTabs: {
[WORKTREE]: [
{
id: 'chat-tab',
entityId: 'chat-tab',
groupId: 'g1',
worktreeId: WORKTREE,
contentType: 'terminal',
label: 'Agent',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1,
viewMode: 'chat'
},
{
// Legacy tab persisted before viewMode existed — no field at all.
id: 'legacy-tab',
entityId: 'legacy-tab',
groupId: 'g1',
worktreeId: WORKTREE,
contentType: 'terminal',
label: 'Legacy',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
},
tabGroups: {
[WORKTREE]: [
{
id: 'g1',
worktreeId: WORKTREE,
activeTabId: 'chat-tab',
tabOrder: ['chat-tab', 'legacy-tab']
}
]
}
}
})
const store = await createStore()
const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? []
const chatTab = restored.find((tab) => tab.id === 'chat-tab')
const legacyTab = restored.find((tab) => tab.id === 'legacy-tab')
expect(chatTab?.viewMode).toBe('chat')
// Missing on a legacy tab; renderer hydration treats absent as 'terminal'.
expect(legacyTab?.viewMode).toBeUndefined()
})
})
+56 -5
View File
@@ -11111,7 +11111,12 @@ describe('OrcaRuntimeService', () => {
title: 'claude agents'
})
)
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
// The stale "working" status is suppressed (no spinner), but agent identity
// is retained so native chat can still address the idle agent's transcript.
const suppressed = result.tabs[0]
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('suppresses saved mobile agent status when the current terminal title is neutral', async () => {
@@ -11178,7 +11183,11 @@ describe('OrcaRuntimeService', () => {
title: 'bash'
})
)
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
// Stale "working" suppressed; agent identity retained for native chat.
const suppressed = result.tabs[0]
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('suppresses saved mobile agent status when fresh live OSC title is Claude agents', async () => {
@@ -11251,7 +11260,11 @@ describe('OrcaRuntimeService', () => {
title: 'claude agents'
})
)
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
// Stale "working" suppressed; agent identity retained for native chat.
const suppressed = result.tabs[0]
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('keeps saved PTY bindings pending until the runtime knows the PTY is connected', async () => {
@@ -12793,7 +12806,11 @@ describe('OrcaRuntimeService', () => {
title: 'claude agents'
})
)
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
// Stale "working" suppressed; agent identity retained for native chat.
const suppressed = result.tabs[0]
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('uses fresh neutral PTY titles over stale mobile snapshot and OSC titles', async () => {
@@ -12864,7 +12881,11 @@ describe('OrcaRuntimeService', () => {
title: 'zsh'
})
)
expect(result.tabs[0]).not.toHaveProperty('agentStatus')
// Stale "working" suppressed; agent identity retained for native chat.
const suppressed = result.tabs[0]
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.state).toBe('done')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.agentType).toBe('claude')
expect(suppressed?.type === 'terminal' && suppressed.agentStatus?.terminalTitle).toBeUndefined()
})
it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => {
@@ -13391,6 +13412,36 @@ describe('OrcaRuntimeService', () => {
expect(clearedSurface?.type === 'browser' && clearedSurface.isPinned).toBe(false)
})
it('persists headless tab viewMode and surfaces it through a cold rehydrate', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
await runtime.setMobileSessionTabProps(`id:${TEST_WORKTREE_ID}`, {
tabId: 'host-tab',
viewMode: 'chat'
})
const persisted = getSession().tabsByWorktree[TEST_WORKTREE_ID]!.find(
(tab) => tab.id === 'host-tab'
)!
expect(persisted.viewMode).toBe('chat')
const live = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const liveSurface = live.tabs.find(
(tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
)
expect(liveSurface?.type === 'terminal' && liveSurface.viewMode).toBe('chat')
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
runtime['hydrateHeadlessMobileSessionTabsFromWorkspaceSession'](TEST_WORKTREE_ID)
const rehydrated = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const surface = rehydrated.tabs.find(
(tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab'
)
expect(surface?.type === 'terminal' && surface.viewMode).toBe('chat')
})
it('still persists tab props in serve mode after syncWindowGraph(0) (gate does not fire)', async () => {
// Why: the renderer-authoritative gate uses getAvailableAuthoritativeWindow,
// and serve startup calls syncWindowGraph(0,...) which sets authoritativeWindowId=0.
+48 -8
View File
@@ -3293,6 +3293,7 @@ export class OrcaRuntimeService {
...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}),
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout)
}
})
@@ -4208,7 +4209,12 @@ export class OrcaRuntimeService {
// was never persisted. Persist to the workspace session + live snapshot.
async setMobileSessionTabProps(
worktreeSelector: string,
args: { tabId: string; color?: string | null; isPinned?: boolean }
args: {
tabId: string
color?: string | null
isPinned?: boolean
viewMode?: 'terminal' | 'chat'
}
): Promise<{ updated: true }> {
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
@@ -4230,7 +4236,7 @@ export class OrcaRuntimeService {
private persistHeadlessSessionTabProps(
worktreeId: string,
tabId: string,
props: { color?: string | null; isPinned?: boolean }
props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' }
): void {
const session = this.store?.getWorkspaceSession?.()
if (!session || !this.store?.setWorkspaceSession) {
@@ -4248,7 +4254,8 @@ export class OrcaRuntimeService {
? {
...tab,
...(props.color !== undefined ? { color: props.color } : {}),
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}),
...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {})
}
: tab
)
@@ -4281,7 +4288,7 @@ export class OrcaRuntimeService {
private applyHeadlessSessionTabPropsToSnapshot(
worktreeId: string,
tabId: string,
props: { color?: string | null; isPinned?: boolean }
props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' }
): void {
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!snapshot) {
@@ -4296,7 +4303,8 @@ export class OrcaRuntimeService {
return {
...tab,
...(props.color !== undefined ? { color: props.color } : {}),
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {})
...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}),
...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {})
}
})
if (!changed) {
@@ -17818,10 +17826,41 @@ export class OrcaRuntimeService {
const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
const liveTitleEvidence = leafTitle ?? ptyTitle
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
const agentStatus =
// Why: keep the rich hook-driven status when the agent has a live
// interactive prompt or an active tool — those are authoritative agent
// activity even if the terminal's title isn't agent-classified (e.g. it
// shows a task/branch name). Otherwise the mobile/web client falls back to
// the OSC-title-only status and never sees interactivePrompt (the question
// card never renders).
const hasLiveAgentSignal =
tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null
const keepFullAgentStatus =
tab.agentStatus &&
(liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent')
? { agentStatus: tab.agentStatus }
(liveTitleEvidence === null ||
liveTitleEvidenceClassification === 'agent' ||
hasLiveAgentSignal)
const agentStatus = keepFullAgentStatus
? { agentStatus: tab.agentStatus }
: // Why: when live title evidence says the pane is idle (e.g. the Claude
// agents picker or a neutral shell title), suppress the stale "working"
// state so the client shows no spinner — but retain agent identity
// (agentType + providerSession) so native chat can still address an
// idle agent's transcript. Reset the transient state to 'done'.
tab.agentStatus?.agentType != null
? {
agentStatus: {
state: 'done' as const,
prompt: '',
updatedAt: tab.agentStatus.updatedAt,
stateStartedAt: tab.agentStatus.stateStartedAt,
paneKey: tab.agentStatus.paneKey,
stateHistory: [],
agentType: tab.agentStatus.agentType,
...(tab.agentStatus.providerSession
? { providerSession: tab.agentStatus.providerSession }
: {})
}
}
: null
// Why: web/mobile clients hold these handles across renderer graph syncs;
// leaf handles are graph-epoch-bound, but PTY handles remain streamable.
@@ -17849,6 +17888,7 @@ export class OrcaRuntimeService {
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: tab.isActive,
...(terminalHandle
? { status: 'ready' as const, terminal: terminalHandle }
+5
View File
@@ -59,6 +59,11 @@ export type RpcContext = {
// Why: WebSocket RPCs authenticate by mobile device token. State-owning
// handlers use this to clean up when that paired device disconnects.
clientId?: string
// Why: payload windowing/truncation tuned for the constrained mobile payload
// (e.g. native-chat block char cap) must not clip full-screen web/desktop
// clients. Carries the paired device's scope so handlers can gate the diet to
// phones only. Undefined for in-process callers → treat as full-class (no clip).
clientKind?: 'mobile' | 'runtime'
// Why: mobile terminal traffic is byte-oriented and bypasses JSON streaming
// responses after the binary terminal cutover. Undefined on Unix/socket
// transports and non-E2EE WebSocket paths.
+3
View File
@@ -93,6 +93,7 @@ export class RpcDispatcher {
connectionId?: string
signal?: AbortSignal
clientId?: string
clientKind?: 'mobile' | 'runtime'
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => void
registerBinaryStreamHandler?: (
streamId: number,
@@ -125,6 +126,7 @@ export class RpcDispatcher {
requestId: request.id,
connectionId: options?.connectionId,
clientId: options?.clientId,
clientKind: options?.clientKind,
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
})
@@ -158,6 +160,7 @@ export class RpcDispatcher {
requestId: request.id,
connectionId: options?.connectionId,
clientId: options?.clientId,
clientKind: options?.clientKind,
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
},
+2
View File
@@ -15,6 +15,7 @@ import { ACCOUNT_METHODS } from './accounts'
import { PREFLIGHT_METHODS } from './preflight'
import { COMPUTER_METHODS } from './computer'
import { SESSION_TAB_METHODS } from './session-tabs'
import { NATIVE_CHAT_METHODS } from './native-chat'
import { FILE_METHODS } from './files'
import { GIT_METHODS } from './git'
import { GITHUB_METHODS } from './github'
@@ -53,6 +54,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...PREFLIGHT_METHODS,
...COMPUTER_METHODS,
...SESSION_TAB_METHODS,
...NATIVE_CHAT_METHODS,
...FILE_METHODS,
...GIT_METHODS,
...GITHUB_METHODS,
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { RpcContext } from '../core'
// Stub the shared cache so the handler returns a deterministic transcript with
// one oversized tool-result block; the test then asserts clip behavior per client.
const OVERSIZED = 'x'.repeat(5000)
const cachedResult = vi.hoisted(() => ({ value: { messages: [] as NativeChatMessage[] } }))
vi.mock('../../../native-chat/transcript-read-cache', () => ({
readNativeChatTranscriptCached: () => Promise.resolve(cachedResult.value)
}))
import { NATIVE_CHAT_METHODS } from './native-chat'
function makeMessage(text: string): NativeChatMessage {
return {
id: 'a-1',
role: 'assistant',
timestamp: 1_717_236_000_000,
source: 'transcript',
blocks: [{ type: 'tool-result', output: text, isError: false }]
}
}
function readSessionHandler(): (params: unknown, ctx: RpcContext) => Promise<unknown> {
const method = NATIVE_CHAT_METHODS.find((m) => m.name === 'nativeChat.readSession')
if (!method) {
throw new Error('readSession method not registered')
}
return method.handler as (params: unknown, ctx: RpcContext) => Promise<unknown>
}
function ctxWith(clientKind: RpcContext['clientKind']): RpcContext {
return { runtime: {} as RpcContext['runtime'], clientKind }
}
function firstOutput(result: unknown): string {
const messages = (result as { messages: NativeChatMessage[] }).messages
const block = messages[0].blocks[0] as { output: string }
return block.output
}
describe('nativeChat.readSession clientKind truncation gating', () => {
it('clips oversized tool output for mobile clients', async () => {
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
const result = await readSessionHandler()(
{ agent: 'claude', sessionId: 's' },
ctxWith('mobile')
)
const output = firstOutput(result)
expect(output.length).toBeLessThan(OVERSIZED.length)
expect(output).toContain('truncated')
})
it('passes oversized tool output through intact for runtime (web/desktop) clients', async () => {
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
const result = await readSessionHandler()(
{ agent: 'claude', sessionId: 's' },
ctxWith('runtime')
)
expect(firstOutput(result)).toBe(OVERSIZED)
})
it('defaults to no clip when clientKind is undefined (in-process callers)', async () => {
cachedResult.value = { messages: [makeMessage(OVERSIZED)] }
const result = await readSessionHandler()(
{ agent: 'claude', sessionId: 's' },
ctxWith(undefined)
)
expect(firstOutput(result)).toBe(OVERSIZED)
})
it('windows by count for all client kinds', async () => {
const many = Array.from({ length: 60 }, (_unused, n) => {
const message = makeMessage('small')
return { ...message, id: `m-${n}` }
})
cachedResult.value = { messages: many }
const result = await readSessionHandler()(
{ agent: 'claude', sessionId: 's', limit: 40 },
ctxWith('runtime')
)
const messages = (result as { messages: NativeChatMessage[] }).messages
expect(messages).toHaveLength(40)
// Tail-only: the last id survives, the first is dropped.
expect(messages.at(-1)?.id).toBe('m-59')
expect(messages[0].id).toBe('m-20')
})
})
+180
View File
@@ -0,0 +1,180 @@
import { z } from 'zod'
import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types'
import type { AgentType } from '../../../../shared/native-chat-types'
import { readNativeChatTranscriptCached } from '../../../native-chat/transcript-read-cache'
import { subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
// desktop reaches the readers via Electron IPC; mobile/web clients reach the
// same pure readers through these runtime RPC methods so the native chat view
// works over the paired connection, not just in the desktop renderer.
const NativeChatSession = z.object({
agent: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing agent'))
.transform((v) => v as AgentType),
sessionId: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing session id')),
// How many of the most-recent messages to return. Clients start small for a
// fast first paint and raise it to page older history in as the user scrolls.
limit: z.number().int().positive().max(2000).optional(),
// Optional client-supplied cleanup token. When present, the subscribe handler
// keys the fs-watcher cleanup under it so registration and unsubscribe derive
// from the SAME token (back-compat: falls back to `agent:sessionId` when absent,
// which is exactly what existing mobile clients rely on).
subscriptionId: z.string().min(1).optional(),
// Authoritative transcript path from the agent hook (providerSession), used to
// locate the file directly when the session id no longer names it (recent
// Claude Code). Optional for back-compat with older clients.
transcriptPath: z.string().min(1).optional()
})
const NativeChatUnsubscribe = z.object({
subscriptionId: z.string().min(1).optional()
})
// Why: a long agent session can hold thousands of turns (with full tool I/O).
// Shipping all of them over the paired connection and rendering them at once
// freezes the mobile app, so the runtime RPC windows to the most recent slice —
// the conversation tail is what the chat view shows first. The desktop IPC path
// is unaffected (it reads locally with a virtualized list).
// Small first page for a fast initial paint; the client raises `limit` to load
// older history as the user scrolls back.
const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
// The mobile view only previews block bodies, so truncate them on the wire to
// keep the payload small; the marker tells the user content was clipped.
const MOBILE_BLOCK_CHAR_CAP = 4000
const TRUNCATION_MARKER = '\n… (truncated)'
function clip(text: string): string {
return text.length > MOBILE_BLOCK_CHAR_CAP
? text.slice(0, MOBILE_BLOCK_CHAR_CAP) + TRUNCATION_MARKER
: text
}
function clipBlock(block: NativeChatBlock): NativeChatBlock {
if (block.type === 'text') {
return block.text.length > MOBILE_BLOCK_CHAR_CAP ? { ...block, text: clip(block.text) } : block
}
if (block.type === 'tool-result') {
return block.output.length > MOBILE_BLOCK_CHAR_CAP
? { ...block, output: clip(block.output) }
: block
}
return block
}
function sanitizeMessage(message: NativeChatMessage): NativeChatMessage {
return { ...message, blocks: message.blocks.map(clipBlock) }
}
/** Window a transcript to its most recent `limit` messages so a long session
* can't freeze the client. Windowing by count applies to ALL RPC clients —
* shipping thousands of turns over the paired link is bad for web and mobile
* alike. Char-clipping (the mobile-only payload diet) is applied separately. */
function windowTranscript(
messages: readonly NativeChatMessage[],
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
): NativeChatMessage[] {
const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW)
return messages.length > window ? messages.slice(-window) : messages.slice()
}
/** Apply the windowed slice plus, for `mobile` clients only, oversized-block
* char truncation. Web/desktop (`runtime`, or undefined for in-process callers)
* are full-class surfaces and pass block bodies through untruncated — matching
* the desktop IPC path, which never clips. */
function windowForClient(
messages: readonly NativeChatMessage[],
clientKind: RpcContext['clientKind'],
limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW
): NativeChatMessage[] {
const windowed = windowTranscript(messages, limit)
return clientKind === 'mobile' ? windowed.map(sanitizeMessage) : windowed
}
export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'nativeChat.readSession',
params: NativeChatSession,
handler: async (params, { clientKind }) => {
const result = await readNativeChatTranscriptCached(
params.agent,
params.sessionId,
params.transcriptPath
)
// Window to the conversation tail (all clients); clip blocks for mobile only.
return 'messages' in result
? { messages: windowForClient(result.messages, clientKind, params.limit) }
: result
}
}),
defineStreamingMethod({
name: 'nativeChat.subscribe',
params: NativeChatSession,
handler: async (params, { runtime, connectionId, clientKind }, emit) => {
let closed = false
let unsubscribe = (): void => {}
// Why: the subscriber seeds its read offset at 0, so the first drain emits
// the whole transcript and later drains emit only appended turns. The first
// batch is windowed to the tail (a full transcript would freeze mobile);
// later incremental batches are smaller than the window so they pass through.
// Clients merge by message id, so the initial windowed batch doubles as the
// snapshot. Keyed by the client-supplied subscriptionId when present so
// registration and unsubscribe derive from the same token; otherwise by
// agent:sessionId, which is exactly the token existing mobile clients send to
// unsubscribe (no wire break).
const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}`
const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}`
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
closed = true
unsubscribe()
emit({ type: 'end' })
},
connectionId
)
if (closed) {
return
}
const subscription = await subscribeNativeChatTranscript({
agent: params.agent,
sessionId: params.sessionId,
transcriptPath: params.transcriptPath,
onAppend: (messages) => {
if (closed) {
return
}
emit({ type: 'appended', messages: windowForClient(messages, clientKind) })
}
})
// The connection may have closed while the file was being resolved.
if (closed) {
subscription.unsubscribe()
return
}
unsubscribe = subscription.unsubscribe
}
}),
defineMethod({
name: 'nativeChat.unsubscribe',
params: NativeChatUnsubscribe,
handler: async (params, { runtime, connectionId }) => {
const connection = connectionId ?? 'local'
if (params.subscriptionId) {
runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`)
return { unsubscribed: true }
}
runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`)
return { unsubscribed: true }
}
})
]
@@ -104,7 +104,9 @@ export const SetTabProps = WorktreeTabSelector.extend({
.pipe(z.string().min(1, 'Missing tab id')),
// undefined = leave unchanged; null = clear color / unset.
color: z.string().max(64).nullable().optional(),
isPinned: z.boolean().optional()
isPinned: z.boolean().optional(),
// undefined = leave unchanged; no "clear" semantic (absence means default 'terminal').
viewMode: z.enum(['terminal', 'chat']).optional()
})
export const CreateTerminalTab = WorktreeTabSelector.extend({
+2 -1
View File
@@ -103,7 +103,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
runtime.setMobileSessionTabProps(params.worktree, {
tabId: params.tabId,
...(params.color !== undefined ? { color: params.color } : {}),
...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {})
...(params.isPinned !== undefined ? { isPinned: params.isPinned } : {}),
...(params.viewMode !== undefined ? { viewMode: params.viewMode } : {})
})
}),
defineStreamingMethod({
+12 -2
View File
@@ -1,10 +1,20 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { discoverSkills } from '../../../skills/discovery'
const SkillDiscoveryParams = z.object({
cwd: z.string().optional().nullable()
})
export const SKILL_METHODS: RpcMethod[] = [
defineMethod({
name: 'skills.discover',
params: null,
handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() })
params: SkillDiscoveryParams,
handler: async (params, { runtime }) => {
const cwd = params.cwd?.trim() || undefined
return cwd
? discoverSkills({ repos: [], cwd })
: discoverSkills({ repos: runtime.listRepos() })
}
})
]
+6
View File
@@ -297,6 +297,9 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'session.tabs.subscribeAll',
'session.tabs.unsubscribe',
'session.tabs.unsubscribeAll',
'nativeChat.readSession',
'nativeChat.subscribe',
'nativeChat.unsubscribe',
'settings.get',
'settings.update',
'ssh.connect',
@@ -971,6 +974,9 @@ export class OrcaRuntimeRpcServer {
await this.dispatcher.dispatchStreaming(request, reply, {
connectionId,
clientId: token,
// Why: gates the mobile-only payload diet (native-chat char clipping) so
// full-screen web/desktop runtime clients aren't truncated.
clientKind: device.scope,
signal: abortRegistration?.signal,
sendBinary,
registerBinaryStreamHandler: (streamId, handler) =>
+27
View File
@@ -77,6 +77,33 @@ describe('skill discovery', () => {
expect(skill?.directoryPath).toBe(linkedSkill)
})
it('discovers worktree .agents skill symlinks from the requested cwd', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
const worktree = join(root, 'worktree')
const realSkill = join(root, 'central-skills', 'ref-oss')
const linkedSkill = join(worktree, '.agents', 'skills', 'ref-oss')
await mkdir(realSkill, { recursive: true })
await mkdir(join(worktree, '.agents', 'skills'), { recursive: true })
await writeFile(join(realSkill, 'SKILL.md'), '# ref-oss\n\nUse local OSS reference repos.')
await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir')
const result = await discoverSkills({
homeDir: home,
cwd: worktree,
repos: []
})
expect(result.skills.filter((entry) => entry.name === 'ref-oss')).toMatchObject([
{
sourceKind: 'repo',
sourceLabel: 'Repo worktree .agents',
directoryPath: linkedSkill,
providers: ['agent-skills']
}
])
})
it('keeps home classification when cwd points at the same directory as home', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
+20
View File
@@ -92,6 +92,18 @@ function nativeZoomCommandMatchesKeybindings(
)
}
function isMacAppPasteInput(input: Electron.Input): boolean {
return (
process.platform === 'darwin' &&
input.type === 'keyDown' &&
input.meta &&
!input.control &&
!input.alt &&
!input.shift &&
(input.code === 'KeyV' || input.key.toLowerCase() === 'v')
)
}
// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual
// center of the CSS-centered content sits at ~18 CSS px from the top.
// At zoom factor z that becomes 18·z window px. Traffic lights are
@@ -844,6 +856,14 @@ export function createMainWindow(
return
}
if (isMacAppPasteInput(input)) {
// Why: native chat/terminal panes can own focus without being native
// editable controls, so route Cmd+V through Orca's paste ownership first.
event.preventDefault()
mainWindow.webContents.send('ui:appMenuPaste')
return
}
const keybindings = opts?.getKeybindings?.()
const terminalShortcutContext: KeybindingMatchOptions = {
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
+42
View File
@@ -362,6 +362,7 @@ import type {
OpenCodeUsageSummary
} from '../shared/opencode-usage-types'
import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types'
import type { AgentType, NativeChatMessage } from '../shared/native-chat-types'
import type { TelemetryConsentState } from '../shared/telemetry-consent-types'
import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events'
import type { AppStarSource } from '../shared/gh-star-source'
@@ -720,6 +721,46 @@ export type AiVaultApi = {
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
}
export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string }
/** Messages appended to a live-tailed transcript since the previous emit. */
export type NativeChatAppendedMessages = NativeChatMessage[]
/** Wire payload for the `nativeChat:appended` push channel. */
export type NativeChatAppendedPayload = {
subscriptionId: string
messages: NativeChatAppendedMessages
}
export type NativeChatSubscribeArgs = {
/** Unique per-caller id, echoed on every append so multiple live panes in
* one renderer don't cross-talk. */
subscriptionId: string
agent: AgentType
sessionId: string
/** Authoritative transcript path from the agent hook (providerSession). */
transcriptPath?: string
}
export type NativeChatApi = {
/** Read the on-disk transcript for an agent + session id, windowed to the most
* recent `limit` turns (defaults to the desktop window). The renderer raises
* `limit` to page in older history as it scrolls to the top. `transcriptPath`
* is the hook-reported authoritative file path, preferred over the id glob. */
readSession: (
agent: AgentType,
sessionId: string,
limit?: number,
transcriptPath?: string
) => Promise<NativeChatReadSessionResult>
/** Live-tail a transcript: `onAppended` fires with only newly-appended
* messages. Returns an unsubscribe fn that closes the main-process watcher. */
subscribe: (
args: NativeChatSubscribeArgs,
onAppended: (messages: NativeChatAppendedMessages) => void
) => () => void
}
export type AppApi = {
/** Returns the app identity currently exposed to native chrome and the titlebar. */
getIdentity: () => Promise<AppIdentity>
@@ -2014,6 +2055,7 @@ export type PreloadApi = {
codexUsage: CodexUsageApi
openCodeUsage: OpenCodeUsageApi
aiVault: AiVaultApi
nativeChat: NativeChatApi
fs: {
readDir: (args: { dirPath: string; connectionId?: string }) => Promise<DirEntry[]>
readFile: (args: {
+40
View File
@@ -153,6 +153,12 @@ import type {
} from '../shared/automations-types'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
import type { AiVaultListArgs } from '../shared/ai-vault-types'
import type { AgentType } from '../shared/native-chat-types'
import type {
NativeChatAppendedMessages,
NativeChatAppendedPayload,
NativeChatReadSessionResult
} from './api-types'
import {
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
type EditorPrepareHotExitDetail
@@ -3489,6 +3495,40 @@ const api = {
ipcRenderer.invoke('aiVault:listSessions', args)
},
nativeChat: {
readSession: (
agent: AgentType,
sessionId: string,
limit?: number,
transcriptPath?: string
): Promise<NativeChatReadSessionResult> =>
ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }),
/** Start live tailing for a transcript. `onAppended` fires with only the
* newly-appended messages. Returns an unsubscribe fn that closes the
* main-process watcher (subscriptionId routes appends to this caller). */
subscribe: (
args: {
subscriptionId: string
agent: AgentType
sessionId: string
transcriptPath?: string
},
onAppended: (messages: NativeChatAppendedMessages) => void
): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => {
if (payload.subscriptionId === args.subscriptionId) {
onAppended(payload.messages)
}
}
ipcRenderer.on('nativeChat:appended', listener)
ipcRenderer.send('nativeChat:subscribe', args)
return () => {
ipcRenderer.removeListener('nativeChat:appended', listener)
ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId })
}
}
},
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeSyncWindowGraphResult> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
@@ -13,6 +13,7 @@ import { recordStoppedSession, waitForStoppedSession } from './dictation-stopped
import { translate } from '@/i18n/i18n'
import { showDictationStartErrorToast } from './dictation-start-error-toast'
import { useHoldDictationGesture } from './use-hold-dictation-gesture'
import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictation-control-events'
export function DictationController() {
const dictationState = useAppStore((s) => s.dictationState)
@@ -265,6 +266,35 @@ export function DictationController() {
stopDictation
])
useEffect(() => {
const canDictate = (): boolean => Boolean(settings?.voice?.enabled && settings.voice.sttModel)
const handleControl = (event: Event): void => {
if (!canDictate() || dictationStateRef.current === 'stopping') {
return
}
const action = (event as CustomEvent<DictationControlAction>).detail
if (action === 'start') {
if (dictationStateRef.current === 'idle') {
void startDictation()
}
return
}
if (action === 'stop') {
if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') {
void stopDictation()
}
return
}
if (dictationStateRef.current === 'listening' || dictationStateRef.current === 'starting') {
void stopDictation()
} else {
void startDictation()
}
}
document.addEventListener(DICTATION_CONTROL_EVENT, handleControl)
return () => document.removeEventListener(DICTATION_CONTROL_EVENT, handleControl)
}, [settings?.voice?.enabled, settings?.voice?.sttModel, startDictation, stopDictation])
useHoldDictationGesture({
dictationStateRef,
holdGestureActiveRef,
@@ -0,0 +1,9 @@
export const DICTATION_CONTROL_EVENT = 'dictation:control'
export type DictationControlAction = 'toggle' | 'start' | 'stop'
export function dispatchDictationControl(action: DictationControlAction): void {
document.dispatchEvent(
new CustomEvent<DictationControlAction>(DICTATION_CONTROL_EVENT, { detail: action })
)
}
@@ -0,0 +1,55 @@
import { ShieldQuestion } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatApproval } from './native-chat-interactive-prompt'
export type NativeChatApprovalCardProps = {
approval: ChatApproval
/** Send the chosen option's literal string to the agent's PTY. */
onChoose: (send: string) => void
}
/**
* Native renderer for an agent tool-approval (PermissionRequest) as an
* Allow/Deny card. Each button writes its option's literal `send` string back
* to the agent (a number to allow; ESC to deny). The first option reads as the
* affirmative action and gets the primary styling.
*/
export function NativeChatApprovalCard({
approval,
onChoose
}: NativeChatApprovalCardProps): React.JSX.Element {
return (
<div className="shrink-0 border-t border-border bg-muted/30">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 py-3 sm:px-4">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
{approval.detail ? (
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
{approval.detail}
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{approval.options.map((opt, i) => (
<button
key={`${opt.label}-${i}`}
type="button"
onClick={() => onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === 0
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'border border-border bg-background text-foreground hover:bg-accent'
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
import { useEffect, useRef } from 'react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import type { SlashCommandSuggestion } from './native-chat-composer-state'
import type { DiscoveredSkill } from '../../../../shared/skills'
export function NativeChatSlashMenu({
suggestions,
activeIndex,
onChoose
}: {
suggestions: SlashCommandSuggestion[]
activeIndex: number
onChoose: (command: SlashCommandSuggestion) => void
}): React.JSX.Element {
return (
<div className="absolute bottom-full left-3 right-3 mb-1 overflow-hidden rounded-md border border-border bg-popover shadow-md sm:left-4 sm:right-4">
{suggestions.map((command, index) => (
<button
key={command.name}
type="button"
onClick={() => onChoose(command)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
index === activeIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
)}
>
<span className="font-medium">/{command.name}</span>
{command.description ? (
<span className="truncate text-xs text-muted-foreground">{command.description}</span>
) : null}
</button>
))}
</div>
)
}
export function NativeChatMentionHint({
query,
onAccept
}: {
query: string
onAccept: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onAccept}
className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4"
>
{translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '}
<span className="font-medium text-foreground">@{query || '…'}</span>
</button>
)
}
export function NativeChatSkillMenu({
suggestions,
activeIndex,
onChoose
}: {
suggestions: DiscoveredSkill[]
activeIndex: number
onChoose: (skill: DiscoveredSkill) => void
}): React.JSX.Element {
const activeItemRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
activeItemRef.current?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, suggestions])
return (
<div className="scrollbar-sleek absolute bottom-full left-0 right-0 mb-1 max-h-64 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md">
{suggestions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{translate('components.native-chat.composer.noSkills', 'No matching skills')}
</div>
) : null}
{suggestions.map((skill, index) => (
<button
key={skill.id}
ref={index === activeIndex ? activeItemRef : null}
type="button"
onClick={() => onChoose(skill)}
className={cn(
'flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
index === activeIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">${skill.name}</span>
{skill.description ? (
<span className="block truncate text-xs text-muted-foreground">
{skill.description}
</span>
) : null}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{skill.sourceLabel}</span>
</button>
))}
</div>
)
}
@@ -0,0 +1,424 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { sendNativeChatMessage, submitNativeChatPrompt } from './native-chat-runtime-send'
import { getAgentSlashCommands } from './native-chat-agent-commands'
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
import {
applyMentionSuggestion,
applySkillSuggestion,
applySlashSuggestion,
deriveComposerAutocomplete,
EMPTY_HISTORY,
isSlashCommandDraft,
pushHistory,
slashCommandDispatchText,
type HistoryState,
type SlashCommandSuggestion
} from './native-chat-composer-state'
import { resolveImagePaste } from './native-chat-image-paste'
import { NativeChatComposerField } from './NativeChatComposerField'
import {
nativeChatComposerTargetIsRemote,
type NativeChatResolvedTarget
} from './native-chat-composer-target'
import { useNativeChatSkills } from './use-native-chat-skills'
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
import { dispatchDictationControl } from '../dictation/dictation-control-events'
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a
// PTY (matching how xterm forwards Escape). The richer interrupt-intent
// inference (agent-interrupt-intent.ts) is driven by the existing PTY input
// observers, so writing ESC through the same send path feeds that machinery.
const ESC = '\x1b'
export type NativeChatComposerProps = {
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
terminalTabId: string
/** Specific split-pane PTY this chat view owns. */
targetPtyId: string | null
agent: AgentType
/**
* Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop
* sends must be guarded rather than silently dropped. U9 wires the real lock
* state in; until then this defaults to `true` (sendable) and the composer
* already renders the guarded/disabled affordance when it is `false`.
*/
canSend?: boolean
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
isWorking?: boolean
/** Interrupt the hosted agent, usually by sending ESC into the PTY. */
onStop?: () => void
/** Optional optimistic-send hook: called with the sent text so the view can
* render a "queued" echo until the real transcript turn lands (mobile parity). */
onOptimisticSend?: (text: string, imagePaths?: string[]) => void
/** Called with a dispatched slash command (e.g. `/clear`) so the view can show
* a small "Ran /clear" system line — slash commands aren't chat turns and
* otherwise leave no visible trace that anything happened. */
onSlashCommand?: (command: string) => void
}
export type NativeChatComposerHandle = {
focus: () => boolean
insertTypedText: (text: string) => boolean
}
/**
* Rich native input for the chat view. Sends prompts into the running agent
* through the same verified runtime path as typed input (KTD4), so the agent
* cannot distinguish native input from keystrokes. Enter sends; Shift+Enter
* inserts a newline; multi-line is bracketed-paste wrapped; Esc interrupts.
* Slash-command and `@file` autocomplete are agent-aware; image paste persists a
* temp file and injects the agent-appropriate path (or reports unsupported).
*/
export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeChatComposerProps>(
function NativeChatComposer(
{
terminalTabId,
targetPtyId,
agent,
canSend = true,
isWorking = false,
onStop,
onOptimisticSend,
onSlashCommand
},
ref
): React.JSX.Element {
const [draft, setDraft] = useState('')
const [caret, setCaret] = useState(0)
const [history, setHistory] = useState<HistoryState>(EMPTY_HISTORY)
const [activeSuggestion, setActiveSuggestion] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [dictationPressed, setDictationPressed] = useState(false)
const skills = useNativeChatSkills(agent, terminalTabId)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const dictationState = useAppStore((store) => store.dictationState)
const voiceSettings = useAppStore((store) => store.settings?.voice)
const isDictationHoldMode = voiceSettings?.dictationMode === 'hold'
const dictationDisabled = voiceSettings?.enabled !== true || !voiceSettings.sttModel
const isDictating =
dictationPressed ||
dictationState === 'starting' ||
dictationState === 'listening' ||
dictationState === 'stopping'
const agentCommands = useMemo(() => getAgentSlashCommands(agent), [agent])
const autocomplete = useMemo(
() =>
deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []),
[draft, caret, agentCommands, agent, skills]
)
// Resolve the live ptyId for this chat leaf; runtime owner settings route
// local vs remote (SSH) sends.
const resolveTarget = useCallback((): NativeChatResolvedTarget | null => {
if (!targetPtyId) {
return null
}
return { ptyId: targetPtyId, settings: getSettingsForAgentTabRuntimeOwner(terminalTabId) }
}, [targetPtyId, terminalTabId])
const hasPty = targetPtyId !== null
const disabled = !hasPty || !canSend
const syncCaret = useCallback((el: HTMLTextAreaElement) => {
setCaret(el.selectionStart ?? el.value.length)
}, [])
const { imageAttachments, attachLocalPaths, clearImageAttachments, removeImageAttachment } =
useNativeChatComposerAttachments({
attachmentScopeKey: targetPtyId ?? terminalTabId,
caret,
resolveTarget,
textareaRef,
setCaret,
setDraft,
setNotice
})
const sendButtonDisabled = isWorking
? !hasPty || !onStop
: disabled || (draft.trim() === '' && imageAttachments.length === 0)
const insertTypedText = useCallback(
(text: string): boolean => {
const textarea = textareaRef.current
if (!textarea || textarea.disabled) {
return false
}
const selectionStart = textarea.selectionStart ?? caret
const selectionEnd = textarea.selectionEnd ?? selectionStart
const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}`
const nextCaret = selectionStart + text.length
textarea.focus()
setDraft(next)
setCaret(nextCaret)
setHistory((prev) => ({ entries: prev.entries, index: null }))
setActiveSuggestion(0)
requestAnimationFrame(() => {
textarea.setSelectionRange(nextCaret, nextCaret)
})
return true
},
[caret, draft]
)
const focus = useCallback((): boolean => {
const textarea = textareaRef.current
if (!textarea || textarea.disabled) {
return false
}
textarea.focus()
return true
}, [])
useImperativeHandle(ref, () => ({ focus, insertTypedText }), [focus, insertTypedText])
useEffect(() => {
return window.api.ui.onFileDrop((payload) => {
if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) {
return
}
attachLocalPaths(payload.paths)
})
}, [attachLocalPaths])
const pickAttachment = useCallback(() => {
void (async () => {
const filePath = await window.api.shell.pickAttachment()
if (!filePath) {
return
}
attachLocalPaths([filePath])
})()
}, [attachLocalPaths])
const focusForDictation = useCallback(() => {
textareaRef.current?.focus()
}, [])
const toggleDictation = useCallback(() => {
focusForDictation()
dispatchDictationControl('toggle')
}, [focusForDictation])
const startHoldDictation = useCallback(() => {
setDictationPressed(true)
focusForDictation()
dispatchDictationControl('start')
}, [focusForDictation])
const stopHoldDictation = useCallback(() => {
setDictationPressed(false)
dispatchDictationControl('stop')
}, [])
const send = useCallback(() => {
const text = draft
const imagePaths = imageAttachments.map((attachment) => attachment.path)
if ((text.trim() === '' && imagePaths.length === 0) || disabled) {
return
}
const target = resolveTarget()
if (!target) {
return
}
// Images are pasted into the hosted TUI as soon as they are attached, so the
// Send action only submits the text body (if any) plus Enter.
if (text.trim().length > 0) {
sendNativeChatMessage(target.settings, target.ptyId, text)
} else {
submitNativeChatPrompt(target.settings, target.ptyId)
}
// Slash commands are TUI controls, not chat turns: don't echo a user bubble,
// but DO surface a small "Ran /clear" system line so the command leaves a
// visible trace instead of seeming to do nothing.
if (isSlashCommandDraft(text)) {
onSlashCommand?.(text.trim())
} else {
onOptimisticSend?.(text, imagePaths)
}
// Why: U10 telemetry — record adoption + local-vs-remote runtime split. The
// agent prop is the loose AgentType; the emitter narrows unknowns to 'other'.
emitNativeChatMessageSent({
agent,
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
})
setHistory((prev) => pushHistory(prev, text))
setDraft('')
setCaret(0)
clearImageAttachments()
setNotice(null)
}, [
agent,
clearImageAttachments,
draft,
imageAttachments,
disabled,
resolveTarget,
onOptimisticSend,
onSlashCommand
])
const interrupt = useCallback(() => {
if (isWorking && onStop) {
onStop()
return
}
const target = resolveTarget()
if (!target) {
return
}
sendRuntimePtyInput(target.settings, target.ptyId, ESC)
}, [isWorking, onStop, resolveTarget])
const chooseSlash = useCallback((command: SlashCommandSuggestion) => {
const next = applySlashSuggestion(command)
setDraft(next)
setCaret(next.length)
setActiveSuggestion(0)
textareaRef.current?.focus()
}, [])
const dispatchSlash = useCallback(
(command: SlashCommandSuggestion) => {
const next = slashCommandDispatchText(command)
const target = resolveTarget()
if (!target || disabled) {
return
}
sendNativeChatMessage(target.settings, target.ptyId, next)
// Surface the command as a system line (this is the autocomplete-menu
// dispatch path; the typed-Enter path in `send` does the same).
onSlashCommand?.(next.trim())
emitNativeChatMessageSent({
agent,
runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local'
})
setHistory((prev) => pushHistory(prev, next))
setDraft('')
setCaret(0)
setActiveSuggestion(0)
setNotice(null)
},
[agent, disabled, resolveTarget, onSlashCommand]
)
const handlePaste = useCallback(
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const hasImage = Array.from(event.clipboardData.items).some((item) =>
item.type.startsWith('image/')
)
if (!hasImage) {
return
}
event.preventDefault()
// Why: snapshot the caret before the async temp-file round-trip — `caret`
// state can move (further typing/selection) while the await is in flight.
const caretAtPaste = caret
void (async () => {
const tempPath = await window.api.ui.saveClipboardImageAsTempFile()
if (!tempPath) {
return
}
const result = resolveImagePaste(agent, tempPath)
if (result.kind === 'unsupported') {
setNotice(
translate(
'components.native-chat.composer.imageUnsupported',
'Image paste is not supported for this agent.'
)
)
return
}
attachLocalPaths([result.path])
setCaret(caretAtPaste)
setNotice(null)
})()
},
[agent, attachLocalPaths, caret]
)
const handleKeyDown = useNativeChatComposerKeyDown({
autocomplete,
activeSuggestion,
draft,
caret,
history,
chooseSlash,
dispatchSlash,
interrupt,
send,
setActiveSuggestion,
setDraft,
setCaret,
setHistory
})
return (
<NativeChatComposerField
textareaRef={textareaRef}
draft={draft}
disabled={disabled}
hasPty={hasPty}
canSend={canSend}
autocomplete={autocomplete}
activeSuggestion={activeSuggestion}
notice={notice}
imageAttachments={imageAttachments}
sendButtonDisabled={sendButtonDisabled}
isWorking={isWorking}
attachDisabled={disabled}
dictationDisabled={dictationDisabled}
isDictating={isDictating}
isDictationHoldMode={isDictationHoldMode}
onDraftChange={(value, element) => {
setDraft(value)
setHistory((prev) => ({ entries: prev.entries, index: null }))
syncCaret(element)
setActiveSuggestion(0)
}}
onTextareaSelect={syncCaret}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onChooseSlash={chooseSlash}
onAcceptMention={() => {
if (autocomplete.mode !== 'mention') {
return
}
const result = applyMentionSuggestion(draft, caret, autocomplete.query)
setDraft(result.draft)
setCaret(result.caret)
textareaRef.current?.focus()
}}
onChooseSkill={(skill) => {
const result = applySkillSuggestion(draft, caret, skill.name)
setDraft(result.draft)
setCaret(result.caret)
setActiveSuggestion(0)
textareaRef.current?.focus()
}}
onRemoveImageAttachment={(id) => removeImageAttachment(id)}
onAttach={pickAttachment}
onDictationToggle={toggleDictation}
onDictationHoldStart={startHoldDictation}
onDictationHoldEnd={stopHoldDictation}
onSend={send}
onStop={onStop}
/>
)
}
)
@@ -0,0 +1,125 @@
import { ArrowUp, Mic, Plus, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
export type NativeChatComposerActionsProps = {
attachDisabled: boolean
dictationDisabled: boolean
sendDisabled: boolean
isWorking: boolean
isDictating: boolean
isDictationHoldMode: boolean
onAttach: () => void
onDictationToggle: () => void
onDictationHoldStart: () => void
onDictationHoldEnd: () => void
onSend: () => void
onStop?: () => void
}
export function NativeChatComposerActions({
attachDisabled,
dictationDisabled,
sendDisabled,
isWorking,
isDictating,
isDictationHoldMode,
onAttach,
onDictationToggle,
onDictationHoldStart,
onDictationHoldEnd,
onSend,
onStop
}: NativeChatComposerActionsProps): React.JSX.Element {
const dictationLabel = isDictating
? translate('components.native-chat.composer.stopDictation', 'Stop dictation')
: translate('components.native-chat.composer.startDictation', 'Start dictation')
return (
<div className="flex w-full items-center justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate('components.native-chat.composer.attach', 'Attach file')}
disabled={attachDisabled}
onClick={onAttach}
className="pointer-coarse:size-11"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('components.native-chat.composer.attach', 'Attach file')}
</TooltipContent>
</Tooltip>
<div className="ml-auto flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={isDictating ? 'secondary' : 'ghost'}
size="icon-sm"
aria-label={dictationLabel}
disabled={dictationDisabled}
onClick={isDictationHoldMode ? undefined : onDictationToggle}
onPointerDown={(event) => {
if (!isDictationHoldMode || dictationDisabled) {
return
}
event.preventDefault()
onDictationHoldStart()
}}
onPointerUp={() => {
if (isDictationHoldMode && !dictationDisabled) {
onDictationHoldEnd()
}
}}
onPointerCancel={() => {
if (isDictationHoldMode && !dictationDisabled) {
onDictationHoldEnd()
}
}}
onPointerLeave={(event) => {
if (isDictationHoldMode && event.buttons === 1 && !dictationDisabled) {
onDictationHoldEnd()
}
}}
className="pointer-coarse:size-11"
>
{isDictating ? (
<Square className="size-3.5 fill-current" />
) : (
<Mic className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{dictationLabel}
</TooltipContent>
</Tooltip>
<Button
type="button"
aria-label={
isWorking
? translate('components.native-chat.stop', 'Stop the agent')
: translate('components.native-chat.composer.send', 'Send')
}
disabled={sendDisabled}
onClick={isWorking ? onStop : onSend}
variant={isWorking ? 'secondary' : 'default'}
size="icon"
className="size-8 rounded-full pointer-coarse:size-10"
>
{isWorking ? (
<Square className="size-3.5 fill-current" />
) : (
<ArrowUp className="size-4" />
)}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,181 @@
import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react'
import { Image as ImageIcon, ImageOff, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop'
import { basename } from '@/lib/path'
import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state'
import {
NativeChatMentionHint,
NativeChatSkillMenu,
NativeChatSlashMenu
} from './NativeChatAutocompleteMenus'
import { NativeChatComposerActions } from './NativeChatComposerActions'
import { nativeChatComposerPlaceholder } from './native-chat-composer-target'
import type { DiscoveredSkill } from '../../../../shared/skills'
export type NativeChatComposerFieldProps = {
textareaRef: RefObject<HTMLTextAreaElement | null>
draft: string
disabled: boolean
hasPty: boolean
canSend: boolean
autocomplete: ComposerAutocomplete
activeSuggestion: number
notice: string | null
imageAttachments: readonly NativeChatComposerImageAttachment[]
sendButtonDisabled: boolean
isWorking: boolean
attachDisabled: boolean
dictationDisabled: boolean
isDictating: boolean
isDictationHoldMode: boolean
onDraftChange: (value: string, element: HTMLTextAreaElement) => void
onTextareaSelect: (element: HTMLTextAreaElement) => void
onKeyDown: KeyboardEventHandler<HTMLTextAreaElement>
onPaste: ClipboardEventHandler<HTMLTextAreaElement>
onChooseSlash: (command: SlashCommandSuggestion) => void
onAcceptMention: () => void
onChooseSkill: (skill: DiscoveredSkill) => void
onRemoveImageAttachment: (id: string) => void
onAttach: () => void
onDictationToggle: () => void
onDictationHoldStart: () => void
onDictationHoldEnd: () => void
onSend: () => void
onStop?: () => void
}
export type NativeChatComposerImageAttachment = {
id: string
path: string
}
export function NativeChatComposerField({
textareaRef,
draft,
disabled,
hasPty,
canSend,
autocomplete,
activeSuggestion,
notice,
imageAttachments,
sendButtonDisabled,
isWorking,
attachDisabled,
dictationDisabled,
isDictating,
isDictationHoldMode,
onDraftChange,
onTextareaSelect,
onKeyDown,
onPaste,
onChooseSlash,
onAcceptMention,
onChooseSkill,
onRemoveImageAttachment,
onAttach,
onDictationToggle,
onDictationHoldStart,
onDictationHoldEnd,
onSend,
onStop
}: NativeChatComposerFieldProps): React.JSX.Element {
return (
<div className="shrink-0 bg-background">
<div className="px-3 py-2 sm:px-4">
<div className="relative mx-auto w-full max-w-3xl">
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? (
<NativeChatSlashMenu
suggestions={autocomplete.suggestions}
activeIndex={activeSuggestion}
onChoose={onChooseSlash}
/>
) : null}
{autocomplete.mode === 'mention' ? (
<NativeChatMentionHint query={autocomplete.query} onAccept={onAcceptMention} />
) : null}
{autocomplete.mode === 'skill' ? (
<NativeChatSkillMenu
suggestions={autocomplete.suggestions}
activeIndex={activeSuggestion}
onChoose={onChooseSkill}
/>
) : null}
{notice ? (
<div className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<ImageOff className="size-3.5 shrink-0" />
<span>{notice}</span>
</div>
) : null}
<div
data-native-file-drop-target={NATIVE_FILE_DROP_TARGET.composer}
className={cn(
'rounded-xl border border-input bg-card p-1.5 shadow-xs transition-colors',
'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30'
)}
>
{imageAttachments.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5 px-1">
{imageAttachments.map((attachment) => (
<div
key={attachment.id}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
title={attachment.path}
>
<ImageIcon className="size-3.5 shrink-0" />
<span className="max-w-56 truncate">{basename(attachment.path)}</span>
<button
type="button"
onClick={() => onRemoveImageAttachment(attachment.id)}
aria-label={translate(
'components.native-chat.composer.removeAttachment',
'Remove attachment'
)}
className="flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
</div>
))}
</div>
) : null}
<textarea
ref={textareaRef}
value={draft}
disabled={disabled}
rows={2}
onChange={(e) => onDraftChange(e.target.value, e.currentTarget)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onSelect={(e) => onTextareaSelect(e.currentTarget)}
placeholder={nativeChatComposerPlaceholder(hasPty, canSend)}
// Why: coarse-pointer min-height follows the app's touch target convention.
className={cn(
'min-h-12 max-h-28 w-full resize-none bg-transparent px-2 py-1 text-sm outline-none pointer-coarse:min-h-14',
'placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50'
)}
/>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<NativeChatComposerActions
attachDisabled={attachDisabled}
dictationDisabled={dictationDisabled}
sendDisabled={sendButtonDisabled}
isWorking={isWorking}
isDictating={isDictating}
isDictationHoldMode={isDictationHoldMode}
onAttach={onAttach}
onDictationToggle={onDictationToggle}
onDictationHoldStart={onDictationHoldStart}
onDictationHoldEnd={onDictationHoldEnd}
onSend={onSend}
onStop={onStop}
/>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
/**
* Per-message copy affordance for the native chat. Copies the message's text to
* the clipboard and briefly swaps the icon to a check tint as success feedback —
* matching the app's other inline copy buttons (icon swap, no toast). Uses
* Electron's clipboard IPC, which wraps navigator.clipboard.writeText and avoids
* the silent failures navigator.clipboard hits inside some renderer contexts.
*/
export function NativeChatCopyButton({
text,
className
}: {
text: string
className?: string
}): React.JSX.Element {
const [copied, setCopied] = useState(false)
const resetTimerRef = useRef<number | null>(null)
useEffect(() => {
return () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
}
}
}, [])
const handleCopy = useCallback(async () => {
try {
await window.api.ui.writeClipboardText(text)
setCopied(true)
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current)
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null
setCopied(false)
}, 1500)
} catch {
/* best-effort: clipboard can reject when unfocused */
}
}, [text])
const label = copied
? translate('components.native-chat.copyMessage.copied', 'Copied')
: translate('components.native-chat.copyMessage.copy', 'Copy message')
return (
<button
type="button"
onClick={handleCopy}
aria-label={label}
title={label}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
copied && 'text-status-success',
className
)}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
)
}
@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
import type { DiffLine } from './native-chat-diff'
/** Inline coloured diff, used for Edit/Write tool calls and diff-style tool
* results. Adds/dels use the git-decoration tokens with a faint tinted ground,
* matching the terminal's diff palette (no invented colors). */
export function NativeChatDiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
return (
<div className="overflow-hidden rounded bg-accent py-1 font-mono text-[11px] leading-relaxed">
{lines.map((line, i) => (
<div
key={i}
className={cn(
'whitespace-pre-wrap break-words px-2',
line.kind === 'add' && 'bg-emerald-500/10 text-[var(--git-decoration-added)]',
line.kind === 'del' && 'bg-rose-500/10 text-[var(--git-decoration-deleted)]',
line.kind === 'meta' && 'text-muted-foreground',
line.kind === 'context' && 'text-foreground/70'
)}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</div>
))}
</div>
)
}
@@ -0,0 +1,87 @@
import { MessageSquare, TriangleAlert } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
export function NativeChatEmptyState({
kind,
message,
agent
}: {
kind: 'loading' | 'empty' | 'error' | 'not-agent'
message?: string
agent?: NativeChatSession['agent']
}): React.JSX.Element {
const copy = emptyStateCopy(kind, message, agent)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center">
<div
className={
kind === 'error'
? 'flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive'
: 'flex size-12 items-center justify-center rounded-full bg-accent text-accent-foreground'
}
>
{kind === 'error' ? (
<TriangleAlert className="size-6" />
) : (
<MessageSquare className="size-6" />
)}
</div>
<p className="text-sm font-medium text-foreground">{copy.title}</p>
{copy.subtitle ? (
<p className="max-w-sm text-balance text-xs text-muted-foreground">{copy.subtitle}</p>
) : null}
</div>
)
}
function emptyStateCopy(
kind: 'loading' | 'empty' | 'error' | 'not-agent',
message?: string,
agent?: NativeChatSession['agent']
): { title: string; subtitle: string | null } {
switch (kind) {
case 'loading':
return {
title: translate('components.native-chat.state.loading.title', 'Loading conversation…'),
subtitle: translate(
'components.native-chat.state.loading.subtitle',
'Reading the agent transcript.'
)
}
case 'error':
return {
title: translate('components.native-chat.state.error.title', 'Could not load conversation'),
subtitle:
message ??
translate(
'components.native-chat.state.error.subtitle',
'The transcript could not be read. Toggle back to the terminal to keep working.'
)
}
case 'not-agent':
return {
title: translate('components.native-chat.state.notAgent.title', 'No conversation here'),
subtitle: translate(
'components.native-chat.state.notAgent.subtitle',
'This terminal is not running a recognized coding agent.'
)
}
case 'empty': {
const agentName = agent ? formatAgentTypeLabel(agent) : 'the agent'
return {
title: translate(
'components.native-chat.state.empty.title',
'Start a chat with {{value0}}',
{ value0: agentName }
),
subtitle: translate(
'components.native-chat.state.empty.subtitle',
'Ask {{value0}} to inspect code, explain output, or make a change.',
{ value0: agentName }
)
}
}
}
}
@@ -0,0 +1,85 @@
import { useEffect, useMemo, useState } from 'react'
import { useAppStore } from '../../store'
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
import { NativeChatQuestionCard } from './NativeChatQuestionCard'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send'
/**
* Render the live interactive card for the pane while the agent's
* `interactivePrompt` is present: a question wizard (precedence) or a tool
* approval. Cleared by the host once the agent moves on, so it disappears
* automatically. Sends through the composer's verified runtime path (R8/R6):
* answers as bracketed-paste + Enter; cancel/deny as ESC. Guarded by `canSend`
* so a mobile presence-lock blocks desktop sends the same way it guards xterm.
*
* Dismiss-on-answer (mobile parity): the live status lingers after answering —
* the agent emits a post-tool event carrying the same prompt — so we track the
* answered prompt by content key and hide the card until a genuinely different
* prompt arrives. The dismissal resets once the prompt clears, so a later
* (even identical) prompt shows again instead of staying hidden.
*/
export function NativeChatInteractiveCard({
paneKey,
send,
canSend
}: {
paneKey: string
send: NativeChatInteractiveSend
canSend: boolean
}): React.JSX.Element | null {
const interactivePrompt = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null
)
// Thread the sibling `toolName` from the same status entry so the question
// parser can dispatch through the tool's registered parser (mobile parity).
const interactiveToolName = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null
)
const { sendAnswer, sendRaw, cancel } = send
const card = useMemo(
() => parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined),
[interactivePrompt, interactiveToolName]
)
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
// Forget the dismissal once the prompt clears so a fresh prompt can show.
const present = card != null
useEffect(() => {
if (!present) {
setDismissedKey(null)
}
}, [present])
if (!card || !canSend || cardKey === dismissedKey) {
return null
}
if (card.kind === 'question') {
return (
<NativeChatQuestionCard
key={cardKey ?? 'question'}
prompt={card.prompt}
onAnswer={(text) => {
setDismissedKey(cardKey)
sendAnswer(text)
}}
onCancel={() => {
setDismissedKey(cardKey)
cancel()
}}
/>
)
}
return (
<NativeChatApprovalCard
approval={card.approval}
onChoose={(raw) => {
setDismissedKey(cardKey)
sendRaw(raw)
}}
/>
)
}
@@ -0,0 +1,361 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Image as ImageIcon } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { basename } from '@/lib/path'
import {
isTextBlock,
type NativeChatBlock,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { orderNativeChatMessages } from './native-chat-message-grouping'
import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
import { NativeChatToolRun } from './NativeChatToolRun'
import { NativeChatCopyButton } from './NativeChatCopyButton'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
function geometryOf(el: HTMLElement): ScrollGeometry {
return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
}
function proseToMarkdown(blocks: NativeChatBlock[]): string {
return blocks
.map((block) => {
if (isTextBlock(block)) {
return block.text
}
return ''
})
.filter((part) => part.length > 0)
.join('\n\n')
}
function ImageAttachmentRefs({ blocks }: { blocks: NativeChatBlock[] }): React.JSX.Element | null {
const images = blocks.filter((block) => block.type === 'image-ref')
if (images.length === 0) {
return null
}
return (
<div className="mb-2 flex flex-wrap gap-1.5">
{images.map((image, index) => {
const label = image.alt ?? image.path ?? image.url ?? 'Image'
const name = image.path ? basename(image.path) : label
return (
<div
key={`${label}-${index}`}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
title={label}
>
<ImageIcon className="size-3.5 shrink-0" />
<span className="truncate">{name}</span>
</div>
)
})}
</div>
)
}
/** Inline controls for an agent message (mobile AgentControls parity): copy the
* message's prose, and scroll so this message's top aligns to the viewport top.
* Reveals on hover / keyboard focus like the prior copy affordance. */
function AgentControls({
markdown,
onScrollToTop,
className
}: {
markdown: string
onScrollToTop: () => void
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex items-center gap-1', className)}>
<NativeChatCopyButton text={markdown} />
<button
type="button"
onClick={onScrollToTop}
aria-label={translate(
'components.native-chat.scrollMessageToTop',
'Scroll this message to top'
)}
title={translate('components.native-chat.scrollMessageToTop', 'Scroll this message to top')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUp className="size-3.5" />
</button>
</div>
)
}
function TypingIndicatorRow(): React.JSX.Element {
return (
<div
className="flex items-center justify-start"
aria-label={translate('components.native-chat.status.responding', 'Agent is responding')}
aria-live="polite"
>
<div className="flex h-8 items-center gap-1.5 text-muted-foreground">
{[0, 1, 2].map((i) => (
<span
key={i}
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/70"
// Stagger the three dots so they ripple rather than pulse in unison.
style={{ animationDelay: `${i * 160}ms` }}
/>
))}
</div>
</div>
)
}
/** One message: its prose first, then a collapsible run folding all of the
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
* lifted card, assistant prose as body copy, reasoning de-emphasized. */
function MessageRow({
message,
expandSignal,
onScrollMessageToTop
}: {
message: NativeChatMessage
expandSignal: boolean
/** Align this message's top to the top of the scroll viewport. */
onScrollMessageToTop: (el: HTMLElement) => void
}): React.JSX.Element | null {
const rowRef = useRef<HTMLDivElement | null>(null)
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
const markdown = proseToMarkdown(prose)
const hasImages = prose.some((block) => block.type === 'image-ref')
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isSystem = message.role === 'system'
const scrollToTop = useCallback(() => {
if (rowRef.current) {
onScrollMessageToTop(rowRef.current)
}
}, [onScrollMessageToTop])
// Skip rows with nothing renderable so the transcript shows no empty/ghost
// bubble.
// After all hooks, so hook order stays unconditional.
if (markdown.length === 0 && !hasImages && tools.length === 0) {
return null
}
if (isUser) {
// Why: an optimistic echo is rendered identically to a real user turn (no
// muting, no "Queued" label) so that when the real transcript turn lands and
// replaces it, there is no visible state change — the send just appears and
// stays. (A distinct "queued" treatment flickered normal→queued→normal as the
// transcript caught up.)
return (
<div ref={rowRef} className="flex flex-col items-end gap-0.5">
<div className="max-w-[85%] rounded-xl rounded-tr-sm border border-border bg-card px-3 py-2 text-sm text-card-foreground">
{markdown ? (
<>
<ImageAttachmentRefs blocks={prose} />
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
</>
) : (
<ImageAttachmentRefs blocks={prose} />
)}
</div>
</div>
)
}
// Plain assistant prose is the copyable unit; reasoning/system asides stay
// chrome-free. The controls reveal on hover (and on keyboard focus-within).
const showControls = !isReasoning && !isSystem && markdown.length > 0
return (
<div
ref={rowRef}
className={cn(
'group relative max-w-full text-sm leading-relaxed text-foreground',
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
isSystem && 'text-xs text-muted-foreground'
)}
>
{showControls ? (
<AgentControls
markdown={markdown}
onScrollToTop={scrollToTop}
className="absolute -top-1 right-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
/>
) : null}
<ImageAttachmentRefs blocks={prose} />
{markdown ? (
<CommentMarkdown content={markdown} variant="document" className="text-sm" />
) : null}
{tools.length > 0 ? <NativeChatToolRun blocks={tools} expandSignal={expandSignal} /> : null}
</div>
)
}
export function NativeChatMessageList({
session,
isWorking,
expandSignal,
fontScale
}: {
session: NativeChatLiveSession
isWorking: boolean
/** Toolbar-driven desired open state for every tool run; each flip re-syncs. */
expandSignal: boolean
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
fontScale: number
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
const [showJump, setShowJump] = useState(false)
// Why: mirror stuck state into a ref so the auto-scroll layout effect can read
// it without depending on it — depending on stuckToBottom (which scrollToBottom
// sets) would re-fire the effect in a self-loop.
const stuckToBottomRef = useRef(stuckToBottom)
stuckToBottomRef.current = stuckToBottom
const { hasMore, loadingEarlier, loadEarlier } = session
// Strip harness noise (task-notifications, system reminders, slash-command
// envelopes) before folding so they don't render as the user's own bubbles —
// matching the mobile chat. Then fold each turn's tool activity into the
// assistant message it belongs to, ordered stably, so a turn's tools collapse
// under one run.
const messages = useMemo(
() => foldToolMessages(orderNativeChatMessages(stripNoiseMessages(session.messages))),
[session.messages]
)
const showTypingIndicator =
isWorking && !messages.some((message) => message.id === NATIVE_CHAT_STREAMING_ID)
// When an older page prepends, the scroll content grows above the viewport.
// Capture the pre-render scroll height so the layout effect can restore the
// user's position (no jump) instead of letting the browser keep scrollTop.
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
const geometry = geometryOf(el)
const stick = isNearBottom(geometry)
setStuckToBottom(stick)
setShowJump(shouldShowJumpToLatest(stick, geometry))
// Near the top — page in older history, anchoring the current position so the
// prepend doesn't yank the view.
if (geometry.scrollTop < 80 && hasMore && !loadingEarlier) {
prependAnchorRef.current = { scrollHeight: el.scrollHeight, scrollTop: el.scrollTop }
loadEarlier()
}
}, [hasMore, loadingEarlier, loadEarlier])
const scrollToBottom = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
el.scrollTop = el.scrollHeight
setStuckToBottom(true)
setShowJump(false)
}, [])
// Align a single message's top to the top of the scroll viewport.
const scrollMessageToTop = useCallback((el: HTMLElement) => {
const container = scrollRef.current
if (!container) {
return
}
const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top
container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' })
}, [])
// Re-pin to the bottom when new content arrives, but only if the user hasn't
// scrolled up. Layout effect so the jump happens before paint (no flicker).
// When an older page just prepended, restore the prior position instead.
useLayoutEffect(() => {
const el = scrollRef.current
if (el && prependAnchorRef.current) {
// Preserve the viewport: shift scrollTop by however much taller the content
// got, so the message the user was reading stays put.
const grew = el.scrollHeight - prependAnchorRef.current.scrollHeight
el.scrollTop = prependAnchorRef.current.scrollTop + grew
prependAnchorRef.current = null
return
}
if (stuckToBottomRef.current) {
scrollToBottom()
}
}, [messages.length, isWorking, showTypingIndicator, scrollToBottom])
// Keep the affordances in sync if the container resizes (e.g. composer mounts,
// viewport reflow) without a scroll event.
useEffect(() => {
const el = scrollRef.current
if (!el || typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(handleScroll)
observer.observe(el)
return () => observer.disconnect()
}, [handleScroll])
return (
<div className="relative min-h-0 flex-1">
<div
ref={scrollRef}
onScroll={handleScroll}
className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4"
>
<div
className="mx-auto flex w-full max-w-3xl flex-col gap-3"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this container so the rest of the app is untouched. It's
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
style={{ zoom: fontScale }}
>
{hasMore ? (
<div className="flex justify-center py-1">
<button
type="button"
onClick={loadEarlier}
disabled={loadingEarlier}
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>
{loadingEarlier
? translate('components.native-chat.loadingEarlier', 'Loading…')
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
</button>
</div>
) : null}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
expandSignal={expandSignal}
onScrollMessageToTop={scrollMessageToTop}
/>
))}
{showTypingIndicator ? <TypingIndicatorRow /> : null}
</div>
</div>
{showJump ? (
<button
type="button"
onClick={scrollToBottom}
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-3.5" />
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
</button>
) : null}
</div>
)
}
@@ -0,0 +1,229 @@
import { useMemo, useState } from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { formatAskAnswer, type AskPrompt } from './native-chat-interactive-prompt'
export type NativeChatQuestionCardProps = {
prompt: AskPrompt
/** Send the formatted answer text to the agent. */
onAnswer: (text: string) => void
/** Dismiss the prompt (sends Escape to the agent). */
onCancel: () => void
}
// Synthetic option value for the "Other…" free-text row, kept out of the
// answer text and replaced by the typed value when selected.
const OTHER = '__other__'
/**
* Native renderer for an agent's AskUserQuestion prompt as a wizard: one
* question per step with tabs across the top (tap to jump, a check once
* answered), single- or multi-select option rows, an "Other…" row that reveals a
* free-text input, and a Next button that advances and becomes "Send answer" on
* the last step. Matches the desktop chat's neutral shadcn styling.
*/
export function NativeChatQuestionCard({
prompt,
onAnswer,
onCancel
}: NativeChatQuestionCardProps): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<string[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const toggle = (qi: number, label: string, multi: boolean): void => {
setSelections((prev) => {
const next = prev.map((s) => [...s])
const cur = next[qi] ?? []
if (multi) {
next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label]
} else {
next[qi] = cur.includes(label) ? [] : [label]
}
return next
})
}
const setOther = (qi: number, value: string): void => {
setOtherText((prev) => {
const next = [...prev]
next[qi] = value
return next
})
}
// The resolved answer for a question: picked labels plus the typed "Other"
// value (which replaces the synthetic OTHER marker).
const answerFor = (qi: number): string => {
const picked = (selections[qi] ?? []).filter((l) => l !== OTHER)
const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
return [...picked, other].filter((p) => p.length > 0).join(', ')
}
const total = prompt.questions.length
const isLast = index === total - 1
const currentAnswered = useMemo(
() => answerFor(index).length > 0,
// eslint-disable-next-line react-hooks/exhaustive-deps
[selections, otherText, index]
)
const submit = (): void => {
// Build per-question label lists, substituting the typed Other value, then
// format to one line per answered question.
const resolved = prompt.questions.map((_, i) => {
const picked = (selections[i] ?? []).filter((l) => l !== OTHER)
const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : ''
return [...picked, ...(other ? [other] : [])]
})
const text = formatAskAnswer(prompt, resolved)
if (text.length > 0) {
onAnswer(text)
}
}
const advance = (): void => {
if (isLast) {
submit()
} else {
setIndex((i) => Math.min(i + 1, total - 1))
}
}
const q = prompt.questions[index]!
const otherSelected = (selections[index] ?? []).includes(OTHER)
return (
<div className="shrink-0 border-t border-border bg-muted/30">
<div className="mx-auto flex max-h-[22rem] w-full max-w-3xl flex-col px-3 py-2 sm:px-4">
{total > 1 ? (
<div className="flex gap-1 overflow-x-auto border-b border-border pb-2 scrollbar-sleek">
{prompt.questions.map((qq, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
className={cn(
'flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-medium',
i === index
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<span className="max-w-[10rem] truncate">
{qq.header ||
translate('components.native-chat.question.step', 'Step {{value0}}', {
value0: i + 1
})}
</span>
{answerFor(i).length > 0 ? (
<Check className="size-3 text-primary" strokeWidth={3} />
) : null}
</button>
))}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto py-2 scrollbar-sleek">
<p className="mb-2 text-sm font-semibold text-foreground">{q.question}</p>
<div className="flex flex-col gap-1.5">
{q.options.map((opt) => (
<OptionRow
key={opt.label}
label={opt.label}
description={opt.description}
selected={(selections[index] ?? []).includes(opt.label)}
onSelect={() => toggle(index, opt.label, q.multiSelect)}
/>
))}
<OptionRow
label={translate('components.native-chat.question.other', 'Other…')}
selected={otherSelected}
onSelect={() => toggle(index, OTHER, q.multiSelect)}
/>
{otherSelected ? (
<textarea
autoFocus
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
placeholder={translate(
'components.native-chat.question.otherPlaceholder',
'Type your answer'
)}
rows={2}
className="w-full resize-none rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
/>
) : null}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border pt-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-2 py-1 text-sm font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{translate('components.native-chat.question.cancel', 'Cancel')}
</button>
{total > 1 ? (
<span className="text-xs text-muted-foreground">
{index + 1}/{total}
</span>
) : null}
<button
type="button"
onClick={advance}
disabled={!currentAnswered}
className={cn(
'rounded-md bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors',
'hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50'
)}
>
{isLast
? translate('components.native-chat.question.send', 'Send answer')
: translate('components.native-chat.question.next', 'Next')}
</button>
</div>
</div>
</div>
)
}
function OptionRow({
label,
description,
selected,
onSelect
}: {
label: string
description?: string
selected: boolean
onSelect: () => void
}): React.JSX.Element {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full items-start gap-2.5 rounded-md border bg-background px-3 py-2 text-left transition-colors',
selected ? 'border-primary' : 'border-border hover:bg-accent/50'
)}
>
<span
className={cn(
'mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border',
selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground'
)}
>
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
{description ? (
<span className="block text-xs text-muted-foreground">{description}</span>
) : null}
</span>
</button>
)
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { ChevronDown, SquareChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock
} from '../../../../shared/native-chat-types'
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
import { countToolCalls, summarizeToolInput, summarizeToolRun } from './native-chat-tool-summary'
import { NativeChatDiffView } from './NativeChatDiffView'
const MAX_TOOL_RESULT_CHARS = 4000
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
* show the call's diff/input or the result's body. Tool calls read as flat
* lines in the conversation rather than boxed blocks (mobile parity). */
function ToolLine({ block }: { block: NativeChatBlock }): React.JSX.Element | null {
const [expanded, setExpanded] = useState(false)
let name: string
let preview: string
let diff: DiffLine[] | null = null
let body: { output: string; isError?: boolean } | null = null
if (isToolCallBlock(block)) {
name = block.name
preview = summarizeToolInput(block.input)
diff = diffFromToolCall(block.name, block.input)
} else if (isToolResultBlock(block)) {
name = translate('components.native-chat.tool.result', 'Result')
preview = block.output.split('\n')[0]?.slice(0, 80) ?? ''
diff = diffFromText(block.output)
body = { output: block.output, isError: block.isError }
} else {
return null
}
const hasDetail = diff !== null || body !== null || preview.length > 40
return (
<div>
<button
type="button"
onClick={() => hasDetail && setExpanded((v) => !v)}
className={cn(
'flex w-full items-center gap-1.5 py-0.5 text-left',
hasDetail ? 'cursor-pointer' : 'cursor-default'
)}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<code className="shrink-0 font-mono text-xs font-semibold text-foreground/90">{name}</code>
{preview ? (
<span
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground"
title={preview}
>
{preview}
</span>
) : null}
</button>
{expanded ? (
<div className="space-y-1.5 py-1 pl-5">
{diff ? <NativeChatDiffView lines={diff} /> : null}
{!diff && body ? (
<pre
className={cn(
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] scrollbar-sleek',
body.isError ? 'text-destructive' : 'text-foreground/80'
)}
>
{body.output.length > MAX_TOOL_RESULT_CHARS
? `${body.output.slice(0, MAX_TOOL_RESULT_CHARS)}`
: body.output}
</pre>
) : null}
{!diff && !body && preview ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-accent p-2 font-mono text-[11px] text-foreground/80 scrollbar-sleek">
{preview}
</pre>
) : null}
</div>
) : null}
</div>
)
}
/** A run of a message's tool calls/results, collapsed to a one-line summary that
* expands to the individual inline tool lines. `expandSignal` lets the global
* toolbar toggle drive every run at once while still allowing per-run override. */
export function NativeChatToolRun({
blocks,
expandSignal
}: {
blocks: NativeChatBlock[]
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
expandSignal: boolean
}): React.JSX.Element {
const [open, setOpen] = useState(expandSignal)
// Re-sync when the global toolbar toggle flips.
useEffect(() => setOpen(expandSignal), [expandSignal])
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
const fallbackLabel = translate(
callCount === 1 ? 'components.native-chat.tool.countOne' : 'components.native-chat.tool.countN',
callCount === 1 ? '1 tool call' : `${callCount} tool calls`,
{ count: callCount }
)
return (
<div className="mt-1">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 py-0.5 text-left"
>
{open ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<SquareChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{summary || fallbackLabel}
</span>
</button>
{open ? (
<div className="mt-1 border-l-2 border-border/60 pl-2.5">
{blocks.map((block, i) => (
<ToolLine key={i} block={block} />
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,430 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import type { TuiAgent } from '../../../../shared/types'
import type { NativeChatSession } from '../../../../shared/native-chat-types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
import { useNativeChatLiveSession } from './use-native-chat-live-session'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
import { useNativeChatFontScale } from './use-native-chat-font-scale'
import { useNativeChatCanSend } from './use-native-chat-can-send'
import { NativeChatInteractiveCard } from './NativeChatInteractiveCard'
import { NativeChatEmptyState } from './NativeChatEmptyState'
import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send'
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
import {
shouldClearNativeChatWorkingSuppression,
shouldShowNativeChatWorking
} from './native-chat-working-suppression'
import {
applyCommandMarkerBoundaries,
appendPendingSendCache,
commandMarkersAsMessages,
appendCommandMarkerCache,
pendingSendsAsMessages,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
writePendingSendCache,
type NativeChatCommandMarker,
type NativeChatPendingSend
} from './native-chat-pending'
import {
deriveNativeChatStreamingText,
nativeChatStreamingMessage
} from '../../../../shared/native-chat-streaming'
import {
shouldFocusNativeChatPaneFromPointerTarget,
shouldRedirectNativeChatTyping
} from './native-chat-typing-redirect'
import { useNativeChatContextMenu } from './use-native-chat-context-menu'
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
import { isMacPlatform } from './native-chat-shortcut'
const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024
const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuActions, 'onPaste'> = {
onSplitRight: () => {},
onSplitDown: () => {},
canEqualizePaneSizes: false,
onEqualizePaneSizes: () => {},
canExpandPane: false,
isPaneExpanded: false,
onToggleExpand: () => {},
onForkAgentSession: () => {},
onSetTitle: () => {},
onCopyTerminalId: () => {},
onCopyPaneId: () => {},
canClosePane: false,
onClosePane: () => {}
}
export type NativeChatViewProps = {
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
terminalTabId: string
/** Specific split leaf this chat surface replaces. */
paneKey?: string
/** PTY bound to `paneKey`, used for composer and interactive-card sends. */
targetPtyId?: string | null
/** Launch-time agent hint from the TerminalTab, when Orca started one. */
launchAgent?: TuiAgent | null
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}
/**
* Native chat surface for an agent terminal. Resolves the pane to its agent +
* session id, streams the assembled conversation via the U4 live-session hook,
* and renders the message list, live status, and all empty/loading/error
* states. When no session id is known yet the hook surfaces live hook state on
* an empty transcript; a true scrollback-scrape fallback (U6) is wired but only
* runs when scrollback is obtainable — it degrades to the empty state otherwise.
*/
export default function NativeChatView({
terminalTabId,
paneKey: preferredPaneKey,
targetPtyId = null,
launchAgent,
onSwitchToTerminal,
contextMenuActions
}: NativeChatViewProps): React.JSX.Element {
// Select only this tab's status entry (shallow-compared) so an unrelated
// pane's status tick doesn't re-render this view or re-run the resolution.
const agentStatusEntry = useAppStore(
useShallow((s) =>
preferredPaneKey
? s.agentStatusByPaneKey[preferredPaneKey]
: findTabAgentEntry(s.agentStatusByPaneKey, terminalTabId)
)
)
const resolution = useMemo(() => {
// paneKey: prefer the live entry's key; fall back to the tab id so the hook
// still has a stable key to select live status by before any pane reports.
const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:`
return resolveNativeChatSession({
paneKey,
launchAgent,
...(agentStatusEntry ? { agentStatusEntry } : {}),
ptyId: targetPtyId
})
}, [agentStatusEntry, terminalTabId, preferredPaneKey, targetPtyId, launchAgent])
if (!resolution) {
return <NativeChatEmptyState kind="not-agent" />
}
return (
<NativeChatResolvedView
paneKey={resolution.paneKey}
agent={resolution.agent}
sessionId={resolution.sessionId}
transcriptPath={resolution.transcriptPath}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
onSwitchToTerminal={onSwitchToTerminal}
contextMenuActions={contextMenuActions}
/>
)
}
function NativeChatResolvedView({
paneKey,
agent,
sessionId,
transcriptPath,
targetPtyId,
terminalTabId,
onSwitchToTerminal,
contextMenuActions
}: {
paneKey: string
agent: NativeChatSession['agent']
sessionId: string | null
transcriptPath: string | null
targetPtyId: string | null
terminalTabId: string
onSwitchToTerminal?: () => void
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}): React.JSX.Element {
const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath })
// Live hook state for this pane, selected directly so the working indicator
// flips the instant the agent reports 'working' — even when switching to chat
// mid-turn before the transcript merge has caught up.
const hookWorking = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.state === 'working')
// The agent's in-progress reply preview (hook), shown as a live streaming
// bubble while it works — before the completed turn flushes to the transcript.
const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage)
const canSend = useNativeChatCanSend(targetPtyId)
// Reuse the verified composer send path for interactive cards and composer
// stop (Stop sends ESC, the agent-TUI interrupt key).
const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent)
const [workingInterrupted, setWorkingInterrupted] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle>(null)
const isMac = useMemo(() => isMacPlatform(), [])
const pasteClipboardIntoComposer = useCallback(() => {
void window.api.ui
.readClipboardText({ maxBytes: NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES })
.then((text) => {
if (text.length > 0) {
composerRef.current?.insertTypedText(text)
}
})
.catch(() => {})
}, [])
const contextMenu = useNativeChatContextMenu({
rootRef,
onSwitchToTerminal,
actions: {
onPaste: pasteClipboardIntoComposer,
...(contextMenuActions ?? emptyNativeChatContextMenuActions)
}
})
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
const onKeyPaste = (event: KeyboardEvent): void => {
if (
!matchesNativeChatPasteShortcut(event, isMac) ||
!shouldFocusNativeChatPaneFromPointerTarget(event.target)
) {
return
}
event.preventDefault()
event.stopPropagation()
pasteClipboardIntoComposer()
}
root.addEventListener('keydown', onKeyPaste, { capture: true })
return () => {
root.removeEventListener('keydown', onKeyPaste, { capture: true })
}
}, [isMac, pasteClipboardIntoComposer])
useEffect(() => {
const onAppMenuPaste = (event: Event): void => {
const root = rootRef.current
const activeElement = document.activeElement
if (
!root ||
!(activeElement instanceof Element) ||
!root.contains(activeElement) ||
!shouldFocusNativeChatPaneFromPointerTarget(activeElement)
) {
return
}
event.preventDefault()
event.stopPropagation()
pasteClipboardIntoComposer()
}
window.addEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
return () => {
window.removeEventListener(APP_MENU_PASTE_EVENT, onAppMenuPaste)
}
}, [pasteClipboardIntoComposer])
// Optimistic "queued" sends (mobile parity): a composer send is echoed
// immediately and pruned once its real user turn lands in the transcript, so
// the message never vanishes between send and transcript catch-up.
const commandMarkerScope = useMemo(
() => ({ paneKey, agent, sessionId }),
[paneKey, agent, sessionId]
)
const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent])
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
readPendingSendCache(pendingScope)
)
const pendingCounter = useRef(0)
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
// system line instead of a user bubble. Capped + cached per conversation.
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
readCommandMarkerCache(commandMarkerScope)
)
// Reset the optimistic queue only when the pane/agent changes. A fresh launch
// often learns its provider session id after the first send; clearing pending
// on that transition briefly flashes the empty state before the transcript
// user turn lands.
useEffect(() => {
setPending(readPendingSendCache(pendingScope))
setWorkingInterrupted(false)
}, [pendingScope])
// Command markers are session-scoped because slash commands like /clear are
// local feedback for a specific transcript boundary.
useEffect(() => {
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
setWorkingInterrupted(false)
}, [commandMarkerScope])
// Prune echoes whose real user turn is now in the transcript.
useEffect(() => {
setPending((prev) =>
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
)
}, [session.messages, pendingScope])
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
pendingCounter.current += 1
const entry: NativeChatPendingSend = {
id: `${pendingCounter.current}`,
text,
sentAt: Date.now(),
...(imagePaths ? { imagePaths } : {})
}
setPending(appendPendingSendCache(pendingScope, entry))
},
[pendingScope]
)
const onSlashCommand = useCallback(
(command: string) => {
setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command))
},
[commandMarkerScope]
)
const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers)
return messages === session.messages ? session : { ...session, messages }
}, [session, commandMarkers])
// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
const streamingText = useMemo(
() =>
deriveNativeChatStreamingText({
messages: sessionAfterCommandBoundaries.messages,
previewText: hookPreview,
working: hookWorking
}),
[sessionAfterCommandBoundaries.messages, hookPreview, hookWorking]
)
const sessionWithPending = useMemo<typeof session>(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
}
return {
...sessionAfterCommandBoundaries,
messages: [
...sessionAfterCommandBoundaries.messages,
...commandMarkersAsMessages(commandMarkers),
...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []),
...pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages)
]
}
}, [sessionAfterCommandBoundaries, pending, commandMarkers, streamingText])
// Derive the view state from the pending-augmented session so a send into an
// otherwise-empty conversation flips to the list (showing the queued bubble)
// instead of staying on the empty state.
const viewState = selectNativeChatViewState(sessionWithPending)
const isConversation = viewState.kind === 'ready'
// Drive "working" from the live hook state too: when toggling to chat while the
// agent is mid-turn, the merged transcript may not yet reflect the in-flight
// turn, but the hook already says 'working' — show the indicator immediately.
const viewWorking = viewState.kind === 'ready' && viewState.isWorking
useEffect(() => {
if (shouldClearNativeChatWorkingSuppression({ viewWorking, hookWorking })) {
setWorkingInterrupted(false)
}
}, [viewWorking, hookWorking])
const isWorking = shouldShowNativeChatWorking({
isConversation,
viewWorking,
hookWorking,
interrupted: workingInterrupted
})
const stopAgent = useCallback(() => {
setWorkingInterrupted(true)
interactiveSend.cancel()
}, [interactiveSend])
// Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so
// the chord is inert on the loading/empty/error states and elsewhere.
const fontScale = useNativeChatFontScale(isConversation)
return (
<div
ref={rootRef}
data-native-chat-root="true"
tabIndex={-1}
onPointerDownCapture={(event) => {
if (event.button === 2) {
contextMenu.onSelectionCapture()
event.preventDefault()
event.stopPropagation()
return
}
if (event.button === 0 && shouldFocusNativeChatPaneFromPointerTarget(event.target)) {
rootRef.current?.focus({ preventScroll: true })
}
}}
onKeyDownCapture={(event) => {
if (!shouldRedirectNativeChatTyping(event)) {
return
}
if (!composerRef.current?.insertTypedText(event.key)) {
return
}
event.preventDefault()
event.stopPropagation()
}}
onMouseUpCapture={contextMenu.onSelectionCapture}
onKeyUpCapture={contextMenu.onSelectionCapture}
onContextMenuCapture={contextMenu.onContextMenuCapture}
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
>
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
) : viewState.kind === 'error' ? (
<NativeChatEmptyState kind="error" message={viewState.message} />
) : viewState.kind === 'empty' ? (
<NativeChatEmptyState kind="empty" agent={agent} />
) : (
<NativeChatMessageList
session={sessionWithPending}
isWorking={isWorking}
expandSignal={false}
fontScale={fontScale.scale}
/>
)}
</div>
{/* Live interactive cards (question / approval) render just above the
composer while the agent's interactivePrompt is present (mobile parity). */}
<NativeChatInteractiveCard paneKey={paneKey} send={interactiveSend} canSend={canSend} />
{/* canSend reflects the mobile presence-lock: when a mobile client holds
the pty, the composer shows its guarded state instead of racing the
mobile driver (R8). */}
<NativeChatComposer
ref={composerRef}
terminalTabId={terminalTabId}
targetPtyId={targetPtyId}
agent={agent}
canSend={canSend}
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onSlashCommand={onSlashCommand}
/>
{contextMenu.menu}
</div>
)
}
function matchesNativeChatPasteShortcut(
event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
isMac: boolean
): boolean {
if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'v') {
return false
}
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
}
@@ -0,0 +1,8 @@
// The per-agent slash-command catalog now lives in src/shared so the desktop
// renderer and the mobile app share one source of truth (no drift). This file
// re-exports it for existing desktop import sites.
export {
getAgentSlashCommands,
type SlashCommandSuggestion
} from '../../../../shared/native-chat-slash-commands'
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { mergeNativeChatMessagesWith } from '../../../../shared/native-chat-merge'
import { assembleNativeChatSession } from './native-chat-session-assembler'
// On single-source (pure transcript) data the desktop assembler's cross-source
// turnKey pass is a no-op, so it must agree with mobile's id-only merge on the
// final id set and order. This locks the "mobile is single-source, so id-only ≡
// assembler-with-same-source-gated-fallback" invariant from the design (#10).
function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): NativeChatMessage {
return {
role: 'assistant',
blocks: [{ type: 'text', text: overrides.id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('assembler ↔ id-merge parity on single-source data', () => {
it('produces the same ids in the same order as the id-only merge', () => {
// Includes two identical-text same-source prompts (distinct ids): both must
// survive in both implementations.
const transcript: NativeChatMessage[] = [
msg({ id: 'u1', role: 'user', timestamp: 10, blocks: [{ type: 'text', text: 'run tests' }] }),
msg({ id: 'a1', timestamp: 20, blocks: [{ type: 'text', text: 'ok' }] }),
msg({ id: 'u2', role: 'user', timestamp: 30, blocks: [{ type: 'text', text: 'run tests' }] }),
msg({ id: 'a2', timestamp: 40, blocks: [{ type: 'text', text: 'done' }] })
]
const assembled = assembleNativeChatSession({
sources: { transcript },
sessionId: 's1',
agent: 'claude'
}).messages
const merged = mergeNativeChatMessagesWith([], transcript, NATIVE_CHAT_SOURCE_PRIORITY)
// The assembler sorts by (timestamp, id); the merge preserves arrival order.
// The fixture's arrival order already matches timestamp order, so ids align.
expect(assembled.map((m) => m.id)).toEqual(merged.map((m) => m.id))
expect(assembled).toHaveLength(transcript.length)
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import {
distanceFromBottom,
isNearBottom,
shouldShowJumpToLatest,
NATIVE_CHAT_BOTTOM_THRESHOLD_PX
} from './native-chat-autoscroll'
const atBottom = { scrollTop: 952, scrollHeight: 1000, clientHeight: 48 }
const scrolledUp = { scrollTop: 0, scrollHeight: 1000, clientHeight: 48 }
const noOverflow = { scrollTop: 0, scrollHeight: 48, clientHeight: 48 }
describe('distanceFromBottom', () => {
it('is zero at the exact bottom and never negative', () => {
expect(distanceFromBottom(atBottom)).toBe(0)
expect(distanceFromBottom({ scrollTop: 5000, scrollHeight: 1000, clientHeight: 48 })).toBe(0)
})
})
describe('isNearBottom', () => {
it('sticks within the threshold and detaches beyond it', () => {
expect(isNearBottom(atBottom)).toBe(true)
expect(
isNearBottom({
scrollTop: 952 - NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
scrollHeight: 1000,
clientHeight: 48
})
).toBe(true)
expect(isNearBottom(scrolledUp)).toBe(false)
})
})
describe('shouldShowJumpToLatest', () => {
it('shows only when detached with content below', () => {
expect(shouldShowJumpToLatest(false, scrolledUp)).toBe(true)
})
it('hides while stuck to bottom', () => {
expect(shouldShowJumpToLatest(true, scrolledUp)).toBe(false)
})
it('hides when there is nothing to scroll', () => {
expect(shouldShowJumpToLatest(false, noOverflow)).toBe(false)
})
})
@@ -0,0 +1,44 @@
// Pure auto-scroll logic for the native chat message list. The component owns
// the DOM ref and the imperative scroll; this module owns only the decisions —
// "are we near the bottom?", "should we stick on new content?", "show the jump
// affordance?" — so they can be unit-tested without a scroll container.
/** A scroll container's geometry. Mirrors the three DOM props we read so tests
* can pass plain numbers instead of a fake element. */
export type ScrollGeometry = {
scrollTop: number
scrollHeight: number
clientHeight: number
}
/** Pixels from the bottom within which we treat the view as "at the bottom" and
* keep it pinned as content arrives. A small slack absorbs sub-pixel rounding
* and the height jitter of a streaming last message. */
export const NATIVE_CHAT_BOTTOM_THRESHOLD_PX = 48
/** Distance in px from the bottom edge of the scroll range. */
export function distanceFromBottom(geometry: ScrollGeometry): number {
return Math.max(0, geometry.scrollHeight - geometry.clientHeight - geometry.scrollTop)
}
/** True when the viewport is close enough to the bottom that new content should
* keep it pinned (auto-scroll "attached"). */
export function isNearBottom(
geometry: ScrollGeometry,
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
): boolean {
return distanceFromBottom(geometry) <= threshold
}
/** Whether the "jump to latest" affordance should show: only when the user has
* detached (scrolled up) and there is actually scrollable content below. */
export function shouldShowJumpToLatest(
isStuckToBottom: boolean,
geometry: ScrollGeometry,
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
): boolean {
if (isStuckToBottom) {
return false
}
return distanceFromBottom(geometry) > threshold
}
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { canToggleNativeChat } from './native-chat-availability'
describe('canToggleNativeChat', () => {
it('allows a terminal launched with a coding agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: 'claude'
})
).toBe(true)
})
it('allows a terminal with a live detected agent but no launchAgent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
hasDetectedAgent: true
})
).toBe(true)
})
it('allows a terminal with a resolved title/foreground agent before hooks arrive', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
hasResolvedAgent: true
})
).toBe(true)
})
it('allows an existing chat view to toggle back after live signals are gone', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
isChatViewMode: true
})
).toBe(true)
})
it('rejects otherwise eligible terminals while the experimental flag is off', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: false,
contentType: 'terminal',
launchAgent: 'claude'
})
).toBe(false)
})
it('rejects a plain shell terminal with no agent', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'terminal',
launchAgent: null,
hasDetectedAgent: false
})
).toBe(false)
})
it('rejects a plain shell terminal with everything omitted', () => {
expect(
canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal' })
).toBe(false)
})
it('rejects an editor tab even if an agent hint were somehow present', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'editor',
launchAgent: 'codex',
hasDetectedAgent: true
})
).toBe(false)
})
it('rejects a browser tab', () => {
expect(
canToggleNativeChat({
experimentalNativeChatEnabled: true,
contentType: 'browser',
hasDetectedAgent: true
})
).toBe(false)
})
})
@@ -0,0 +1,43 @@
import type { Tab, TuiAgent } from '../../../../shared/types'
/** Inputs that decide whether a tab may toggle into the native chat view.
* Kept as a plain shape (not the live store) so the decision stays pure and
* unit-testable; call sites resolve `launchAgent`/`hasDetectedAgent` from the
* terminal tab + agent-status before calling. */
export type NativeChatAvailabilityInput = {
/** Feature flag: hidden unless enabled from Settings > Experimental. */
experimentalNativeChatEnabled?: boolean
contentType: Tab['contentType']
/** The coding-agent Orca launched in this terminal, if any (from TerminalTab). */
launchAgent?: TuiAgent | null
/** True when a live agent-status entry exists for any pane of this tab — i.e.
* an agent was detected at runtime even though `launchAgent` was not set
* (manually-started agents, resumed sessions). */
hasDetectedAgent?: boolean
/** True when another trusted tab signal (for example the terminal title
* resolver) identifies the foreground as an agent before hooks arrive. */
hasResolvedAgent?: boolean
/** Already-chat tabs must always be allowed to toggle back to terminal, even
* if live hook state was lost during a dev/app restart. */
isChatViewMode?: boolean
}
/** Native chat is a rendering of a coding-agent conversation, so the toggle is
* only meaningful on terminals that actually run an agent. Plain shells and
* non-terminal surfaces (editor, browser, …) never qualify. Eligibility is the
* union of the launch-time hint and live detection so the control appears both
* for Orca-launched agents and for agents the user started themselves. */
export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean {
if (input.experimentalNativeChatEnabled !== true) {
return false
}
if (input.contentType !== 'terminal') {
return false
}
return (
input.isChatViewMode === true ||
Boolean(input.launchAgent) ||
input.hasDetectedAgent === true ||
input.hasResolvedAgent === true
)
}
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import {
applyMentionSuggestion,
applySkillSuggestion,
applySlashSuggestion,
deriveComposerAutocomplete,
EMPTY_HISTORY,
filterSkillSuggestions,
filterSlashCommands,
isSlashCommandDraft,
pushHistory,
recallNext,
recallPrevious,
slashCommandDispatchText,
type SlashCommandSuggestion
} from './native-chat-composer-state'
import type { DiscoveredSkill } from '../../../../shared/skills'
const COMMANDS: SlashCommandSuggestion[] = [
{ name: 'clear' },
{ name: 'compact' },
{ name: 'help' }
]
function skill(overrides: Partial<DiscoveredSkill>): DiscoveredSkill {
return {
id: overrides.name ?? 'skill',
name: 'typescript',
description: null,
providers: ['codex'],
sourceKind: 'repo',
sourceLabel: 'Repository',
rootPath: '/repo/.agents/skills',
directoryPath: '/repo/.agents/skills/typescript',
skillFilePath: '/repo/.agents/skills/typescript/SKILL.md',
installed: true,
fileCount: 1,
updatedAt: null,
...overrides
}
}
describe('deriveComposerAutocomplete — slash', () => {
it('enters slash mode for `/` at the start and filters by query', () => {
const result = deriveComposerAutocomplete('/cl', 3, COMMANDS)
expect(result.mode).toBe('slash')
if (result.mode !== 'slash') {
return
}
expect(result.query).toBe('cl')
expect(result.suggestions.map((c) => c.name)).toEqual(['clear'])
})
it('a bare `/` returns the full command list', () => {
const result = deriveComposerAutocomplete('/', 1, COMMANDS)
expect(result.mode).toBe('slash')
if (result.mode !== 'slash') {
return
}
expect(result.suggestions).toHaveLength(3)
})
it('does not fire slash mode after a space', () => {
expect(deriveComposerAutocomplete('/clear now', 10, COMMANDS).mode).toBe('none')
})
it('does not fire slash mode mid-line', () => {
expect(deriveComposerAutocomplete('hi /clear', 9, COMMANDS).mode).toBe('none')
})
})
describe('deriveComposerAutocomplete — mention', () => {
it('enters mention mode with the query after `@`', () => {
const result = deriveComposerAutocomplete('look at @src/ind', 16, COMMANDS)
expect(result.mode).toBe('mention')
if (result.mode !== 'mention') {
return
}
expect(result.query).toBe('src/ind')
})
it('fires at the start of input too', () => {
const result = deriveComposerAutocomplete('@foo', 4, COMMANDS)
expect(result.mode).toBe('mention')
if (result.mode !== 'mention') {
return
}
expect(result.query).toBe('foo')
})
it('does not fire for an email-like `@` (no preceding whitespace)', () => {
expect(deriveComposerAutocomplete('me@example', 10, COMMANDS).mode).toBe('none')
})
})
describe('deriveComposerAutocomplete — skill', () => {
const skills = [
skill({ name: 'typescript' }),
skill({ name: 'react-useeffect', directoryPath: '/repo/.agents/skills/react-useeffect' })
]
it('enters skill mode with the query after `$`', () => {
const result = deriveComposerAutocomplete('use $type', 9, COMMANDS, skills)
expect(result.mode).toBe('skill')
if (result.mode !== 'skill') {
return
}
expect(result.query).toBe('type')
expect(result.suggestions.map((entry) => entry.name)).toEqual(['typescript'])
})
it('fires at the start of input too', () => {
expect(deriveComposerAutocomplete('$react', 6, COMMANDS, skills).mode).toBe('skill')
})
it('does not fire inside shell-style text', () => {
expect(deriveComposerAutocomplete('price$tag', 9, COMMANDS, skills).mode).toBe('none')
})
})
describe('filterSlashCommands', () => {
it('is case-insensitive prefix match', () => {
expect(filterSlashCommands(COMMANDS, 'C').map((c) => c.name)).toEqual(['clear', 'compact'])
})
})
describe('isSlashCommandDraft', () => {
it('treats leading slash drafts as TUI commands, not chat prompts', () => {
expect(isSlashCommandDraft('/clear')).toBe(true)
expect(isSlashCommandDraft(' /compact')).toBe(true)
expect(isSlashCommandDraft('please run /clear')).toBe(false)
})
})
describe('filterSkillSuggestions', () => {
it('filters installed skills by name or directory basename', () => {
const skills = [
skill({ name: 'TypeScript' }),
skill({ name: 'Display Name', directoryPath: '/repo/.agents/skills/ref-oss' }),
skill({ name: 'hidden', installed: false })
]
expect(filterSkillSuggestions(skills, 'ref').map((entry) => entry.name)).toEqual([
'Display Name'
])
expect(filterSkillSuggestions(skills, 'h')).toEqual([])
})
})
describe('history recall', () => {
it('up-arrow on empty composer recalls the last sent input', () => {
const history = pushHistory(EMPTY_HISTORY, 'first')
const recall = recallPrevious(history)
expect(recall.draft).toBe('first')
expect(recall.history.index).toBe(0)
})
it('walks backward and clamps at the oldest entry', () => {
let history = pushHistory(EMPTY_HISTORY, 'a')
history = pushHistory(history, 'b')
const first = recallPrevious(history)
expect(first.draft).toBe('b')
const second = recallPrevious(first.history)
expect(second.draft).toBe('a')
const third = recallPrevious(second.history)
expect(third.draft).toBe('a') // clamped
})
it('down-arrow walks forward and returns to a live empty draft', () => {
let history = pushHistory(EMPTY_HISTORY, 'a')
history = pushHistory(history, 'b')
const up1 = recallPrevious(history) // 'b'
const up2 = recallPrevious(up1.history) // 'a'
const down = recallNext(up2.history) // 'b'
expect(down.draft).toBe('b')
const back = recallNext(down.history) // live
expect(back.draft).toBe('')
expect(back.history.index).toBeNull()
})
it('does not record blank sends or immediate duplicates', () => {
let history = pushHistory(EMPTY_HISTORY, ' ')
expect(history.entries).toHaveLength(0)
history = pushHistory(history, 'x')
history = pushHistory(history, 'x')
expect(history.entries).toHaveLength(1)
})
it('recall on empty history is a no-op', () => {
expect(recallPrevious(EMPTY_HISTORY).draft).toBeNull()
})
})
describe('apply suggestions', () => {
it('applySlashSuggestion replaces the token with a trailing space', () => {
expect(applySlashSuggestion({ name: 'clear' })).toBe('/clear ')
})
it('slashCommandDispatchText returns the command without completion whitespace', () => {
expect(slashCommandDispatchText({ name: 'clear' })).toBe('/clear')
})
it('applyMentionSuggestion replaces the active @token at the caret', () => {
const result = applyMentionSuggestion('open @sr more', 8, 'src/app.ts')
expect(result.draft).toBe('open @src/app.ts more')
expect(result.caret).toBe('open @src/app.ts '.length)
})
it('applySkillSuggestion replaces the active $token at the caret', () => {
const result = applySkillSuggestion('use $typ now', 8, 'typescript')
expect(result.draft).toBe('use $typescript now')
expect(result.caret).toBe('use $typescript '.length)
})
})
@@ -0,0 +1,187 @@
// Pure state machine for the native chat composer. Given the current draft,
// caret position, sent-input history, and the active agent's known slash
// commands/skills, it derives the autocomplete mode, the
// query, and the filtered suggestions. Keeping this DOM-free makes the slash /
// mention / skill / history behavior unit-testable; the .tsx only owns rendering and
// the actual textarea/caret wiring.
import type { DiscoveredSkill } from '../../../../shared/skills'
import {
filterSlashCommands,
isSlashCommandDraft,
applySlashSuggestion,
slashCommandDispatchText,
type SlashCommandSuggestion
} from '../../../../shared/native-chat-slash-commands'
export type { SlashCommandSuggestion }
export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText }
export type ComposerAutocompleteMode = 'none' | 'slash' | 'mention' | 'skill'
export type ComposerAutocomplete =
| { mode: 'none' }
| { mode: 'slash'; query: string; suggestions: SlashCommandSuggestion[] }
| { mode: 'mention'; query: string }
| { mode: 'skill'; query: string; suggestions: DiscoveredSkill[] }
export type ComposerDerivation = {
autocomplete: ComposerAutocomplete
}
/**
* Detect the active autocomplete trigger from the text before the caret.
*
* Rules (intentionally conservative to avoid firing inside ordinary prose):
* - Slash: the draft starts with `/` and the caret is within that first token
* (no whitespace between `/` and the caret). Agent slash commands are only
* valid at the very start of the input, mirroring how the TUIs accept them.
* - Mention: an `@` immediately preceded by start-of-input or whitespace, with
* no whitespace between it and the caret. Works anywhere in the line so you
* can reference a file mid-sentence.
*/
export function deriveComposerAutocomplete(
draft: string,
caret: number,
agentCommands: readonly SlashCommandSuggestion[],
skills: readonly DiscoveredSkill[] = []
): ComposerAutocomplete {
const before = draft.slice(0, caret)
// Slash: only at the absolute start of the input, and only while the caret is
// still inside the unbroken command token.
if (before.startsWith('/') && !/\s/.test(before)) {
const query = before.slice(1)
return { mode: 'slash', query, suggestions: filterSlashCommands(agentCommands, query) }
}
const mentionMatch = before.match(/(?:^|\s)@(\S*)$/)
if (mentionMatch) {
return { mode: 'mention', query: mentionMatch[1] }
}
const skillMatch = before.match(/(?:^|\s)\$(\S*)$/)
if (skillMatch) {
const query = skillMatch[1]
return { mode: 'skill', query, suggestions: filterSkillSuggestions(skills, query) }
}
return { mode: 'none' }
}
export function filterSkillSuggestions(
skills: readonly DiscoveredSkill[],
query: string
): DiscoveredSkill[] {
const normalized = query.toLowerCase()
const installed = skills.filter((skill) => skill.installed)
if (normalized === '') {
return installed.slice(0, 12)
}
return installed
.filter((skill) => {
const name = skill.name.toLowerCase()
const dirName = skill.directoryPath.split(/[\\/]/).filter(Boolean).at(-1)?.toLowerCase()
return name.startsWith(normalized) || dirName?.startsWith(normalized)
})
.slice(0, 12)
}
export type HistoryState = {
/** Most-recent-last list of previously sent drafts. */
entries: readonly string[]
/** Cursor into `entries`; null means "live draft" (not recalling). */
index: number | null
}
export const EMPTY_HISTORY: HistoryState = { entries: [], index: null }
/** Append a sent draft to history and reset the recall cursor. Blank sends and
* immediate duplicates of the last entry are not recorded (shell-style). */
export function pushHistory(history: HistoryState, sent: string): HistoryState {
if (sent.trim() === '') {
return { entries: history.entries, index: null }
}
if (history.entries.at(-1) === sent) {
return { entries: history.entries, index: null }
}
return { entries: [...history.entries, sent], index: null }
}
export type HistoryRecall = {
history: HistoryState
/** The draft text to show, or null to leave the live draft untouched. */
draft: string | null
}
/**
* Move one step toward older entries (Up arrow). From the live draft this jumps
* to the most recent entry; thereafter it walks backward and clamps at the
* oldest. Returns the recalled draft, or null when there is nothing to recall.
*/
export function recallPrevious(history: HistoryState): HistoryRecall {
if (history.entries.length === 0) {
return { history, draft: null }
}
const nextIndex =
history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1)
return {
history: { entries: history.entries, index: nextIndex },
draft: history.entries[nextIndex]
}
}
/**
* Move one step toward newer entries (Down arrow). Walking past the newest entry
* returns to the live (empty) draft and clears the recall cursor. Returns null
* draft when not currently recalling.
*/
export function recallNext(history: HistoryState): HistoryRecall {
if (history.index === null) {
return { history, draft: null }
}
const nextIndex = history.index + 1
if (nextIndex >= history.entries.length) {
return { history: { entries: history.entries, index: null }, draft: '' }
}
return {
history: { entries: history.entries, index: nextIndex },
draft: history.entries[nextIndex]
}
}
/** Replace the active `@query` token before the caret with the chosen path.
* Returns the new full draft and the caret offset to place after insertion. */
export function applyMentionSuggestion(
draft: string,
caret: number,
path: string
): { draft: string; caret: number } {
const before = draft.slice(0, caret)
const after = draft.slice(caret)
const match = before.match(/(^|\s)@(\S*)$/)
if (!match) {
return { draft, caret }
}
const tokenStart = before.length - match[2].length - 1 // -1 for the '@'
const insertion = `@${path} `
const nextBefore = before.slice(0, tokenStart) + insertion
return { draft: nextBefore + after, caret: nextBefore.length }
}
export function applySkillSuggestion(
draft: string,
caret: number,
skillName: string
): { draft: string; caret: number } {
const before = draft.slice(0, caret)
const after = draft.slice(caret)
const match = before.match(/(^|\s)\$(\S*)$/)
if (!match) {
return { draft, caret }
}
const tokenStart = before.length - match[2].length - 1 // -1 for the '$'
const insertion = `$${skillName} `
const nextBefore = before.slice(0, tokenStart) + insertion
return { draft: nextBefore + after, caret: nextBefore.length }
}
@@ -0,0 +1,30 @@
import { translate } from '@/i18n/i18n'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
export type NativeChatResolvedTarget = {
ptyId: string
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
}
export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string {
if (!hasPty) {
return translate(
'components.native-chat.composer.noPty',
'No live terminal — toggle back to reconnect.'
)
}
if (!canSend) {
return translate('components.native-chat.composer.locked', 'Input is held by another device.')
}
return translate('components.native-chat.composer.placeholder', 'Send a message…')
}
export function nativeChatComposerTargetIsRemote(ptyId: string | null): boolean {
return ptyId !== null && isRemoteRuntimePtyId(ptyId)
}
export function formatNativeChatFileReference(filePath: string): string {
const escaped = filePath.replace(/"/g, '\\"')
return /\s/.test(filePath) ? `@"${escaped}"` : `@${filePath}`
}
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { diffFromText, diffFromToolCall } from './native-chat-diff'
describe('diffFromToolCall', () => {
it('returns null for non-edit tools', () => {
expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull()
})
it('builds del/add lines from old_string/new_string', () => {
const diff = diffFromToolCall('Edit', {
file_path: '/app.ts',
old_string: 'a\nb',
new_string: 'a\nc'
})
expect(diff).toEqual([
{ kind: 'meta', text: '/app.ts' },
{ kind: 'del', text: 'a' },
{ kind: 'del', text: 'b' },
{ kind: 'add', text: 'a' },
{ kind: 'add', text: 'c' }
])
})
it('reads Write content as adds', () => {
const diff = diffFromToolCall('Write', { path: '/new.ts', content: 'line1\nline2' })
expect(diff).toEqual([
{ kind: 'meta', text: '/new.ts' },
{ kind: 'add', text: 'line1' },
{ kind: 'add', text: 'line2' }
])
})
it('returns null when there is no old/new payload', () => {
expect(diffFromToolCall('Edit', { file_path: '/x' })).toBeNull()
})
})
describe('diffFromText', () => {
it('parses unified-diff text into coloured lines', () => {
const diff = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new')
expect(diff).toEqual([
{ kind: 'meta', text: '@@ -1,2 +1,2 @@' },
{ kind: 'context', text: ' context' },
{ kind: 'del', text: 'old' },
{ kind: 'add', text: 'new' }
])
})
it('returns null when there is not enough diff signal', () => {
expect(diffFromText('just a sentence with - a dash')).toBeNull()
expect(diffFromText('+only one add line')).toBeNull()
})
it('returns null for empty input', () => {
expect(diffFromText('')).toBeNull()
})
it('ignores +++/--- file headers as add/del', () => {
const diff = diffFromText('--- a/x\n+++ b/x\n-old\n+new')
expect(diff?.filter((l) => l.kind === 'add').map((l) => l.text)).toEqual(['new'])
expect(diff?.filter((l) => l.kind === 'del').map((l) => l.text)).toEqual(['old'])
})
})
@@ -0,0 +1,70 @@
// Why: agent edits show up as tool calls (Edit/Write with old/new strings) and
// tool results that contain unified-diff text. The chat renders these as inline
// coloured diffs like the terminal, so detection/parsing is pure and testable.
// Ported from the mobile diffFromToolCall/diffFromText (desktop parity).
export type DiffLineKind = 'add' | 'del' | 'context' | 'meta'
export type DiffLine = {
kind: DiffLineKind
text: string
}
const EDIT_TOOL_NAMES = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace', 'apply_patch'])
function toLines(value: unknown): string[] {
return typeof value === 'string' ? value.replace(/\n$/, '').split('\n') : []
}
/** Build diff lines from an Edit-style tool call input (old_string/new_string),
* or null when the input isn't an editing payload. Old lines render as deletes,
* new lines as adds — a simple, readable before/after rather than a full LCS. */
export function diffFromToolCall(name: string, input: unknown): DiffLine[] | null {
if (!EDIT_TOOL_NAMES.has(name) || typeof input !== 'object' || input === null) {
return null
}
const obj = input as Record<string, unknown>
const oldText = obj.old_string ?? obj.oldString ?? obj.old
const newText = obj.new_string ?? obj.newString ?? obj.new ?? obj.content ?? obj.file_text
const dels = toLines(oldText).map((text): DiffLine => ({ kind: 'del', text }))
const adds = toLines(newText).map((text): DiffLine => ({ kind: 'add', text }))
if (dels.length === 0 && adds.length === 0) {
return null
}
const lines: DiffLine[] = []
if (typeof obj.file_path === 'string' || typeof obj.path === 'string') {
lines.push({ kind: 'meta', text: String(obj.file_path ?? obj.path) })
}
return [...lines, ...dels, ...adds]
}
/** Parse unified-diff-looking text into coloured lines, or null when the text
* doesn't read as a diff (no +/- lines). */
export function diffFromText(text: string): DiffLine[] | null {
if (typeof text !== 'string' || text.length === 0) {
return null
}
const raw = text.split('\n')
let added = 0
let removed = 0
const lines: DiffLine[] = raw.map((line): DiffLine => {
if (line.startsWith('@@') || line.startsWith('diff ') || line.startsWith('index ')) {
return { kind: 'meta', text: line }
}
if (line.startsWith('+') && !line.startsWith('+++')) {
added++
return { kind: 'add', text: line.slice(1) }
}
if (line.startsWith('-') && !line.startsWith('---')) {
removed++
return { kind: 'del', text: line.slice(1) }
}
return { kind: 'context', text: line }
})
// Require a meaningful amount of diff signal so ordinary prose isn't mistaken
// for a diff (a stray leading '-' bullet shouldn't trigger diff rendering).
if (added + removed < 2) {
return null
}
return lines
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
describe('nativeChatCardDismissKey', () => {
it('returns null for no card', () => {
expect(nativeChatCardDismissKey(null)).toBeNull()
})
it('keys a question by its count and first question text', () => {
const key = nativeChatCardDismissKey({
kind: 'question',
prompt: {
questions: [
{ question: 'Pick a color', multiSelect: false, options: [{ label: 'Red' }] },
{ question: 'Pick a size', multiSelect: false, options: [{ label: 'L' }] }
]
}
})
expect(key).toBe('question:2:Pick a color')
})
it('gives identical questions the same key (so a lingering re-emit stays hidden)', () => {
const make = (): ReturnType<typeof nativeChatCardDismissKey> =>
nativeChatCardDismissKey({
kind: 'question',
prompt: { questions: [{ question: 'Continue?', multiSelect: false, options: [] }] }
})
expect(make()).toBe(make())
})
it('keys an approval by its title and detail', () => {
const key = nativeChatCardDismissKey({
kind: 'approval',
approval: {
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [{ label: 'Allow', send: '1' }]
}
})
expect(key).toBe('approval:Allow Bash?:rm -rf build')
})
it('distinguishes different approvals', () => {
const a = nativeChatCardDismissKey({
kind: 'approval',
approval: { title: 'Allow Bash?', options: [] }
})
const b = nativeChatCardDismissKey({
kind: 'approval',
approval: { title: 'Allow Write?', options: [] }
})
expect(a).not.toBe(b)
})
})
@@ -0,0 +1,21 @@
// Pure content key for an interactive card (question / approval), used to dismiss
// it once answered. The live status lingers briefly — the agent emits a post-tool
// event carrying the same prompt — so the view hides the card until a genuinely
// different prompt arrives. Keying by content (not identity) means an identical
// follow-up prompt only re-shows after the prompt has cleared in between. Mirrors
// mobile's askKey/dismissedAskKey. Kept pure so the keying is unit-testable.
import type { InteractivePromptCard } from './native-chat-interactive-prompt'
/** A stable string identifying a card by its content, or null when there is no
* card. Two cards with the same key are treated as "the same prompt". */
export function nativeChatCardDismissKey(card: InteractivePromptCard): string | null {
if (!card) {
return null
}
if (card.kind === 'question') {
const { questions } = card.prompt
return `question:${questions.length}:${questions[0]?.question ?? ''}`
}
return `approval:${card.approval.title}:${card.approval.detail ?? ''}`
}
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import {
chatFontScaleActionForEvent,
chatFontScaleShortcutLabels,
clampChatFontScale,
decreaseChatFontScale,
DEFAULT_CHAT_FONT_SCALE,
increaseChatFontScale,
MAX_CHAT_FONT_SCALE,
MIN_CHAT_FONT_SCALE
} from './native-chat-font-scale'
type Combo = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>
function combo(overrides: Partial<Combo>): Combo {
return { key: '=', metaKey: false, ctrlKey: false, ...overrides }
}
describe('clampChatFontScale', () => {
it('keeps a value inside the band untouched', () => {
expect(clampChatFontScale(1.2)).toBe(1.2)
})
it('clamps below the minimum', () => {
expect(clampChatFontScale(0.1)).toBe(MIN_CHAT_FONT_SCALE)
})
it('clamps above the maximum', () => {
expect(clampChatFontScale(5)).toBe(MAX_CHAT_FONT_SCALE)
})
it('rounds away float drift to clean tenths', () => {
expect(clampChatFontScale(0.7999999999)).toBe(0.8)
})
})
describe('increase/decreaseChatFontScale', () => {
it('steps up by a tenth without drift', () => {
expect(increaseChatFontScale(1)).toBe(1.1)
expect(increaseChatFontScale(1.1)).toBe(1.2)
})
it('steps down by a tenth without drift', () => {
expect(decreaseChatFontScale(1)).toBe(0.9)
expect(decreaseChatFontScale(0.9)).toBe(0.8)
})
it('does not exceed the max when stepping up at the ceiling', () => {
expect(increaseChatFontScale(MAX_CHAT_FONT_SCALE)).toBe(MAX_CHAT_FONT_SCALE)
})
it('does not drop below the min when stepping down at the floor', () => {
expect(decreaseChatFontScale(MIN_CHAT_FONT_SCALE)).toBe(MIN_CHAT_FONT_SCALE)
})
})
describe('chatFontScaleActionForEvent', () => {
it('maps Cmd+= to increase on Mac', () => {
expect(chatFontScaleActionForEvent(combo({ key: '=', metaKey: true }), true)).toBe('increase')
})
it('maps Cmd++ (shifted equals) to increase on Mac', () => {
expect(chatFontScaleActionForEvent(combo({ key: '+', metaKey: true }), true)).toBe('increase')
})
it('maps Cmd+- to decrease on Mac', () => {
expect(chatFontScaleActionForEvent(combo({ key: '-', metaKey: true }), true)).toBe('decrease')
})
it('maps Cmd+0 to reset on Mac', () => {
expect(chatFontScaleActionForEvent(combo({ key: '0', metaKey: true }), true)).toBe('reset')
})
it('maps Ctrl+= to increase on Windows/Linux', () => {
expect(chatFontScaleActionForEvent(combo({ key: '=', ctrlKey: true }), false)).toBe('increase')
})
it('ignores the wrong primary modifier on Mac', () => {
expect(chatFontScaleActionForEvent(combo({ key: '=', ctrlKey: true }), true)).toBeNull()
})
it('ignores Cmd+Ctrl chords', () => {
expect(
chatFontScaleActionForEvent(combo({ key: '=', metaKey: true, ctrlKey: true }), true)
).toBeNull()
})
it('returns null for an unrelated key', () => {
expect(chatFontScaleActionForEvent(combo({ key: 'a', metaKey: true }), true)).toBeNull()
})
it('returns null without a primary modifier', () => {
expect(chatFontScaleActionForEvent(combo({ key: '=' }), true)).toBeNull()
})
})
describe('chatFontScaleShortcutLabels', () => {
it('uses Cmd glyphs on Mac', () => {
expect(chatFontScaleShortcutLabels(true)).toEqual({
increase: '⌘+',
decrease: '⌘-',
reset: '⌘0'
})
})
it('uses Ctrl+ text elsewhere', () => {
expect(chatFontScaleShortcutLabels(false)).toEqual({
increase: 'Ctrl++',
decrease: 'Ctrl+-',
reset: 'Ctrl+0'
})
})
})
it('default scale sits inside the band', () => {
expect(DEFAULT_CHAT_FONT_SCALE).toBeGreaterThanOrEqual(MIN_CHAT_FONT_SCALE)
expect(DEFAULT_CHAT_FONT_SCALE).toBeLessThanOrEqual(MAX_CHAT_FONT_SCALE)
})
@@ -0,0 +1,69 @@
/** Pure font-scale logic for the desktop native chat view — the keyboard analog
* of the mobile pinch-zoom. The chat text scale is clamped to a readable band
* and adjusted in fixed steps so Cmd/Ctrl +/-/0 behave like a browser zoom but
* scoped to the chat surface only. Kept DOM-free so it can be unit-tested. */
import { isMacPlatform } from './native-chat-shortcut'
export const MIN_CHAT_FONT_SCALE = 0.8
export const MAX_CHAT_FONT_SCALE = 1.6
export const DEFAULT_CHAT_FONT_SCALE = 1
export const CHAT_FONT_SCALE_STEP = 0.1
/** Clamp a scale into the readable band and round away float drift so repeated
* steps land on clean tenths (e.g. 0.7999999 -> 0.8). */
export function clampChatFontScale(scale: number): number {
const clamped = Math.min(MAX_CHAT_FONT_SCALE, Math.max(MIN_CHAT_FONT_SCALE, scale))
return Math.round(clamped * 100) / 100
}
export function increaseChatFontScale(scale: number): number {
return clampChatFontScale(scale + CHAT_FONT_SCALE_STEP)
}
export function decreaseChatFontScale(scale: number): number {
return clampChatFontScale(scale - CHAT_FONT_SCALE_STEP)
}
export type ChatFontScaleAction = 'increase' | 'decrease' | 'reset' | null
/** Map a keydown to a font-scale action when it's the Cmd/Ctrl +/-/0 chord.
* Primary modifier follows AGENTS.md (metaKey on Mac, ctrlKey elsewhere) and
* must be the only primary modifier so it can't collide with Cmd+Ctrl chords.
* Shift/Alt are ignored on purpose: `+` is Shift+`=` on many layouts. Pure so
* it can be unit-tested without a DOM. */
export function chatFontScaleActionForEvent(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>,
isMac: boolean
): ChatFontScaleAction {
const primary = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
if (!primary) {
return null
}
switch (e.key) {
case '=':
case '+':
return 'increase'
case '-':
case '_':
return 'decrease'
case '0':
return 'reset'
default:
return null
}
}
/** Human-readable labels for the chat font-scale shortcuts, platform-correct. */
export function chatFontScaleShortcutLabels(isMac = isMacPlatform()): {
increase: string
decrease: string
reset: string
} {
const mod = isMac ? '⌘' : 'Ctrl+'
return {
increase: `${mod}+`,
decrease: `${mod}-`,
reset: `${mod}0`
}
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { getAgentImageHandling, resolveImagePaste } from './native-chat-image-paste'
describe('image paste agent map', () => {
it('known image-capable agent attaches the temp file path', () => {
expect(getAgentImageHandling('claude')).toBe('attachment')
const result = resolveImagePaste('claude', '/tmp/orca-img-123.png')
expect(result).toEqual({ kind: 'attach', path: '/tmp/orca-img-123.png' })
})
it('codex also attaches image paths', () => {
expect(resolveImagePaste('codex', '/tmp/x.png')).toEqual({
kind: 'attach',
path: '/tmp/x.png'
})
})
it('unknown/custom agent is unsupported', () => {
expect(getAgentImageHandling('some-custom-agent')).toBe('unsupported')
expect(resolveImagePaste('some-custom-agent', '/tmp/x.png')).toEqual({
kind: 'unsupported',
agent: 'some-custom-agent'
})
})
})
@@ -0,0 +1,47 @@
// Pure decision layer for image paste. The composer persists a pasted image to
// a temp file (via the preload clipboard API) and then needs to know, per
// agent, whether that file can be sent as a TUI image attachment. Confirmed
// agents get a native attachment chip; unsupported/custom agents get a clear
// message instead of silently injecting a path that the model reads as text.
import type { AgentType } from '../../../../shared/agent-status-types'
import { isImageDropPath } from '../terminal-pane/terminal-drop-image-path'
/** How a given agent consumes a pasted image. `attachment` = bracket-paste the
* image path into the hosted TUI so it becomes an image chip; `unsupported` =
* no confirmed mechanism. */
export type AgentImageHandling = 'attachment' | 'unsupported'
const IMAGE_ATTACHMENT_AGENTS: ReadonlySet<AgentType> = new Set<AgentType>([
'claude',
'openclaude',
'codex',
'gemini',
'cursor',
'copilot',
'droid'
])
export function getAgentImageHandling(agent: AgentType): AgentImageHandling {
return IMAGE_ATTACHMENT_AGENTS.has(agent) ? 'attachment' : 'unsupported'
}
export type ImagePasteResult =
| { kind: 'attach'; path: string }
| { kind: 'unsupported'; agent: AgentType }
/**
* Given the agent and the temp-file path the image was written to, decide what
* (if anything) to attach. Attachment-capable agents receive the path through
* the same bracketed image-paste channel as the terminal TUI.
*/
export function resolveImagePaste(agent: AgentType, tempFilePath: string): ImagePasteResult {
if (getAgentImageHandling(agent) === 'attachment') {
return { kind: 'attach', path: tempFilePath }
}
return { kind: 'unsupported', agent }
}
export function isNativeChatImageAttachmentPath(path: string): boolean {
return isImageDropPath(path)
}
@@ -0,0 +1,84 @@
import {
isTextBlock,
type NativeChatBlock,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/
const IMAGE_PROMPT_MARKER = /^\[Image #\d+\]\s*/
function soleText(message: NativeChatMessage): string | null {
return message.blocks.length === 1 && isTextBlock(message.blocks[0])
? message.blocks[0].text
: null
}
export function imageSourcePathFromText(text: string): string | null {
return text.match(IMAGE_SOURCE_MARKER)?.[1]?.trim() ?? null
}
export function stripImagePromptMarker(text: string): string {
return text.replace(IMAGE_PROMPT_MARKER, '')
}
function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
let stripped = false
const next: NativeChatBlock[] = []
for (const block of blocks) {
if (!stripped && isTextBlock(block)) {
stripped = true
const text = stripImagePromptMarker(block.text)
if (text.trim().length > 0) {
next.push({ ...block, text })
}
continue
}
next.push(block)
}
return next
}
function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean {
const firstText = message.blocks.find(isTextBlock)
return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false
}
/** Claude records an attached image as two user transcript turns:
* `[Image: source: /path]` and then `[Image #1] prompt`. Merge them back into
* one native turn so the UI keeps the same chip+text shape as the optimistic
* send and does not show raw TUI marker text after a view remount. */
export function normalizeImageTranscriptMessages(
messages: readonly NativeChatMessage[]
): NativeChatMessage[] {
const normalized: NativeChatMessage[] = []
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]!
if (message.role !== 'user') {
normalized.push(message)
continue
}
const imagePath = imageSourcePathFromText(soleText(message) ?? '')
const next = messages[index + 1]
if (
imagePath &&
next?.role === 'user' &&
next.source === message.source &&
imagePromptMarkerStartsMessage(next)
) {
normalized.push({
...next,
blocks: [
{ type: 'image-ref', path: imagePath },
...stripFirstImagePromptMarker(next.blocks)
]
})
index += 1
continue
}
normalized.push({
...message,
blocks: stripFirstImagePromptMarker(message.blocks)
})
}
return normalized
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { assembleNativeChatSession } from './native-chat-session-assembler'
import {
applyAppends,
createIncrementalAssembler,
reset
} from './native-chat-incremental-assembler'
function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): NativeChatMessage {
return {
role: 'assistant',
blocks: [{ type: 'text', text: overrides.id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
// Canonical reference: assemble the full transcript from scratch.
function fullRebuild(messages: NativeChatMessage[]): NativeChatMessage[] {
return assembleNativeChatSession({
sources: { transcript: messages },
sessionId: 's1',
agent: 'claude'
}).messages
}
// Drive the incremental assembler over base + each append batch, capturing the
// emitted list after every batch so we can compare each prefix to a full rebuild.
function incrementalPrefixes(
base: NativeChatMessage[],
batches: NativeChatMessage[][]
): NativeChatMessage[][] {
const assembler = createIncrementalAssembler()
const out: NativeChatMessage[][] = []
out.push(reset(assembler, base))
for (const batch of batches) {
out.push(applyAppends(assembler, batch))
}
return out
}
describe('incremental assembler — oracle differential', () => {
// An adversarial append sequence exercising every hard case the design calls
// out: re-emitted ids, transcript-supersedes-hook, turnKey collisions,
// out-of-order + null timestamps, empty batches.
const base: NativeChatMessage[] = [
msg({ id: 'a', timestamp: 10, role: 'user', blocks: [{ type: 'text', text: 'hello' }] }),
msg({ id: 'b', timestamp: 20, blocks: [{ type: 'text', text: 'partial' }], source: 'hook' })
]
const batches: NativeChatMessage[][] = [
// pure tail append (fast path)
[msg({ id: 'c', timestamp: 30, blocks: [{ type: 'text', text: 'c' }] })],
// empty batch
[],
// re-emit id 'b' from transcript — supersedes the hook copy in place
[msg({ id: 'b', timestamp: 20, source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })],
// out-of-order timestamp: sorts BEFORE the current tail → forces re-sort
[msg({ id: 'd', timestamp: 5, blocks: [{ type: 'text', text: 'early' }] })],
// turnKey collision with 'a': same role+text+timestamp, DIFFERENT source
// (scrape) — lower priority, must be dropped by the cross-source gate
[msg({ id: 'a-scrape', timestamp: 10, role: 'user', source: 'scrape', blocks: [{ type: 'text', text: 'hello' }] })],
// same-source identical prompt (distinct id) — must NOT collapse (#10)
[msg({ id: 'e', timestamp: 40, role: 'user', blocks: [{ type: 'text', text: 'hello' }] })],
// null timestamp append → forces re-sort, sorts to the front
[msg({ id: 'f', timestamp: null, blocks: [{ type: 'text', text: 'f' }] })],
// re-emitted id at the tail (already seen 'c'), no change
[msg({ id: 'c', timestamp: 30, blocks: [{ type: 'text', text: 'c' }] })],
// multi-message tail batch
[
msg({ id: 'g', timestamp: 50, blocks: [{ type: 'text', text: 'g' }] }),
msg({ id: 'h', timestamp: 60, blocks: [{ type: 'text', text: 'h' }] })
]
]
it('matches a full rebuild for every prefix of the append sequence', () => {
const inc = incrementalPrefixes(base, batches)
let cumulative = [...base]
// Prefix 0 = base only.
expect(inc[0]).toEqual(fullRebuild(cumulative))
for (let i = 0; i < batches.length; i += 1) {
cumulative = [...cumulative, ...batches[i]!]
expect(inc[i + 1]).toEqual(fullRebuild(cumulative))
}
})
it('keeps prior message object identity on a pure tail append', () => {
const assembler = createIncrementalAssembler()
reset(assembler, base)
const before = assembler.messages
const tail = msg({ id: 'z', timestamp: 99, blocks: [{ type: 'text', text: 'z' }] })
const after = applyAppends(assembler, [tail])
// New array reference (React needs it) but the existing rows keep identity.
expect(after).not.toBe(before)
expect(after[0]).toBe(before[0])
expect(after.at(-1)).toBe(tail)
})
it('returns the same reference for an empty append batch', () => {
const assembler = createIncrementalAssembler()
reset(assembler, base)
const out = assembler.messages
expect(applyAppends(assembler, [])).toBe(out)
})
})
@@ -0,0 +1,100 @@
// Incremental native-chat assembler. The full `assembleNativeChatSession` does
// an O(n log n) Map-build + sort on every call; on the hot streaming path the
// agent emits many small append batches over a growing transcript, so the full
// rebuild is quadratic per turn (#17). This splits the two mutation axes:
//
// - base axis (session swap / loadEarlier re-read): rare, user-driven → reset,
// a full rebuild that is byte-for-byte identical to assembleNativeChatSession.
// - append axis (live streaming): hot → applyAppends, which feeds only the new
// batch through the SAME mergeOne rule and splices at the tail when the batch
// is purely-new and already-sorted, falling back to a full re-sort otherwise.
//
// Correctness invariant: applyAppends output deep-equals a full rebuild over
// base ++ all-appends for every prefix (locked by the oracle differential test).
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { compareMessages, mergeOne } from './native-chat-session-assembler'
export type IncrementalChatAssembler = {
byId: Map<string, NativeChatMessage>
byTurn: Map<string, NativeChatMessage>
// Last emitted sorted output; stable reference until a mutation occurs.
messages: NativeChatMessage[]
}
export function createIncrementalAssembler(): IncrementalChatAssembler {
return { byId: new Map(), byTurn: new Map(), messages: [] }
}
/** Rebuild the assembled state from a base list (the windowed read). Canonical
* path — equivalent to assembleNativeChatSession over `{ transcript: base }`. */
export function reset(
assembler: IncrementalChatAssembler,
base: readonly NativeChatMessage[]
): NativeChatMessage[] {
assembler.byId = new Map()
assembler.byTurn = new Map()
for (const message of base) {
mergeOne(assembler.byId, assembler.byTurn, message)
}
assembler.messages = Array.from(assembler.byId.values()).sort(compareMessages)
return assembler.messages
}
/** Fold a live append batch through the same merge rule as the full rebuild.
* Fast path: when every incoming message is a brand-new id, has a brand-new
* turnKey-free identity (no merge/removal), and sorts at/after the current
* tail, splice the batch in (O(k log k)). Any ambiguity → full re-sort of the
* whole map (still correct, just O(n log n) for that one rare batch). */
export function applyAppends(
assembler: IncrementalChatAssembler,
incoming: readonly NativeChatMessage[]
): NativeChatMessage[] {
if (incoming.length === 0) {
return assembler.messages
}
const sizeBefore = assembler.byId.size
for (const message of incoming) {
mergeOne(assembler.byId, assembler.byTurn, message)
}
// A merge or removal happened if the map didn't grow by exactly the batch
// size — some incoming id/turn collided with or superseded an existing entry,
// which can change an existing entry's sort position. Fall back to re-sort.
const grewByBatch = assembler.byId.size === sizeBefore + incoming.length
if (grewByBatch && isTailAppend(assembler.messages, incoming)) {
// Every incoming message is new and sorts at/after the tail: splice the
// batch in its own sorted order without touching the existing prefix.
const tail = [...incoming].sort(compareMessages)
assembler.messages = [...assembler.messages, ...tail]
return assembler.messages
}
assembler.messages = Array.from(assembler.byId.values()).sort(compareMessages)
return assembler.messages
}
/** True when the whole batch sorts strictly at/after the current last message
* AND is internally unambiguous to splice. A null timestamp in the batch sorts
* before any real timestamp, so it can never be a pure tail append — bail to
* the full re-sort. */
function isTailAppend(
current: readonly NativeChatMessage[],
incoming: readonly NativeChatMessage[]
): boolean {
const last = current.at(-1)
if (!last) {
return true
}
for (const message of incoming) {
// Null timestamp (sorts to the front) can never be a tail append.
if (message.timestamp === null) {
return false
}
if (compareMessages(message, last) < 0) {
return false
}
}
return true
}
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest'
import {
formatAskAnswer,
parseApprovalFromStatus,
parseAskFromStatus,
parseInteractivePrompt
} from './native-chat-interactive-prompt'
const ESC = String.fromCharCode(27)
describe('parseAskFromStatus', () => {
it('returns null for empty/invalid input', () => {
expect(parseAskFromStatus(null)).toBeNull()
expect(parseAskFromStatus(undefined)).toBeNull()
expect(parseAskFromStatus('')).toBeNull()
expect(parseAskFromStatus('not json')).toBeNull()
expect(parseAskFromStatus('{}')).toBeNull()
expect(parseAskFromStatus('{"questions":[]}')).toBeNull()
})
it('parses the canonical AskUserQuestion shape', () => {
const prompt = parseAskFromStatus(
JSON.stringify({
questions: [
{
question: 'Pick a color',
header: 'Color',
multiSelect: false,
options: [{ label: 'Red', description: 'warm' }, { label: 'Blue' }]
}
]
})
)
expect(prompt).toEqual({
questions: [
{
question: 'Pick a color',
header: 'Color',
multiSelect: false,
options: [{ label: 'Red', description: 'warm' }, { label: 'Blue' }]
}
]
})
})
it('accepts string options and defaults multiSelect to false', () => {
const prompt = parseAskFromStatus(
JSON.stringify({ questions: [{ question: 'q', options: ['A', 'B'] }] })
)
expect(prompt?.questions[0]).toMatchObject({
multiSelect: false,
options: [{ label: 'A' }, { label: 'B' }]
})
})
it('honors multiSelect: true and multiple questions', () => {
const prompt = parseAskFromStatus(
JSON.stringify({
questions: [
{ question: 'q1', multiSelect: true, options: [{ label: 'X' }] },
{ question: 'q2', options: [{ label: 'Y' }] }
]
})
)
expect(prompt?.questions).toHaveLength(2)
expect(prompt?.questions[0]?.multiSelect).toBe(true)
expect(prompt?.questions[1]?.multiSelect).toBe(false)
})
it('skips malformed question entries', () => {
const prompt = parseAskFromStatus(
JSON.stringify({ questions: [null, 42, { question: 'ok', options: ['A'] }] })
)
expect(prompt?.questions).toHaveLength(1)
expect(prompt?.questions[0]?.question).toBe('ok')
})
})
describe('parseApprovalFromStatus', () => {
it('returns null for non-approval envelopes', () => {
expect(parseApprovalFromStatus(null)).toBeNull()
expect(parseApprovalFromStatus('not json')).toBeNull()
expect(parseApprovalFromStatus('{}')).toBeNull()
expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull()
expect(parseApprovalFromStatus(JSON.stringify({ approval: { tool: '' } }))).toBeNull()
})
it('builds an Allow/Deny card from { approval: { tool, summary } }', () => {
const approval = parseApprovalFromStatus(
JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
)
expect(approval).toEqual({
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ label: 'Allow', send: '1' },
{ label: 'Deny', send: ESC }
]
})
})
it('omits detail when summary is missing', () => {
const approval = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'Edit' } }))
expect(approval?.title).toBe('Allow Edit?')
expect(approval?.detail).toBeUndefined()
})
})
describe('parseInteractivePrompt', () => {
it('returns a question card, with question taking precedence', () => {
const card = parseInteractivePrompt(
JSON.stringify({
questions: [{ question: 'q', options: ['A'] }],
approval: { tool: 'Bash', summary: 's' }
})
)
expect(card?.kind).toBe('question')
})
it('returns an approval card when no question is present', () => {
const card = parseInteractivePrompt(JSON.stringify({ approval: { tool: 'Bash' } }))
expect(card?.kind).toBe('approval')
})
it('returns null when neither parses', () => {
expect(parseInteractivePrompt(null)).toBeNull()
expect(parseInteractivePrompt('{}')).toBeNull()
})
})
describe('formatAskAnswer', () => {
it('joins selected labels per question, one line each', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }] }
]
}
expect(formatAskAnswer(prompt, [['A', 'B'], ['C']])).toBe('A, B\nC')
})
it('preserves empty answers as empty lines so N lines == N questions', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'B' }] }
]
}
// Leading blank stays an empty line: '\nB' (2 lines), not 'B'.
expect(formatAskAnswer(prompt, [[], ['B']])).toBe('\nB')
})
it('keeps one line per question with a blank middle answer (3 questions)', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'B' }] },
{ question: 'q3', multiSelect: false, options: [{ label: 'C' }] }
]
}
const answer = formatAskAnswer(prompt, [['A'], [], ['C']])
expect(answer).toBe('A\n\nC')
expect(answer.split('\n')).toHaveLength(3)
})
})
@@ -0,0 +1,200 @@
// Pure parser for the live `agentStatus.interactivePrompt` envelope (JSON the
// host captures from the agent's hook). It resolves to either a structured
// question prompt (AskUserQuestion) or a tool-approval (PermissionRequest), the
// two interactive cards the native chat renders just above the composer. Kept
// pure (no React/IO) so the envelope rules are unit-testable.
//
// Why: the Ask question parser (parseQuestionsShape/parseOptions/the
// QUESTION_TOOL_PARSERS registry/parseToolInput/formatAskAnswer) is a byte-for-byte
// mirror of mobile's `mobile-native-chat-ask.ts` — Metro can't import these runtime
// values from src/shared, so both copies must stay in sync; parity is asserted by
// `src/shared/native-chat-ask-parser-parity.test.ts`. The approval-card logic below
// (ChatApproval/parseApprovalFromStatus/ESCAPE) is desktop-only.
import type {
AskOption,
AskPrompt,
AskQuestion,
InteractiveQuestionParser
} from '../../../../shared/native-chat-ask-types'
import { translate } from '@/i18n/i18n'
export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser }
/** A detected tool-approval, rendered as an Allow/Deny card. Each option's
* `send` is the literal string written back to the agent's PTY when chosen
* (a number to allow; the ESC char to deny). */
export type ChatApproval = {
title: string
detail?: string
options: { label: string; send: string }[]
}
/** The interactive card to render for the current live status, or null. A
* question takes precedence over an approval when both somehow parse. */
export type InteractivePromptCard =
| { kind: 'question'; prompt: AskPrompt }
| { kind: 'approval'; approval: ChatApproval }
| null
// ESC interrupts the agent over the PTY (matches how the composer forwards
// Escape), so "Cancel"/"Deny" sends this byte.
const ESCAPE = String.fromCharCode(27)
// Registry of question-tool parsers keyed by the tool name the agent reports.
// To support a new terminal/agent's question tool, register its parser here (or
// via registerQuestionTool) — the renderer and wiring stay unchanged.
const QUESTION_TOOL_PARSERS = new Map<string, InteractiveQuestionParser>()
export function registerQuestionTool(toolName: string, parser: InteractiveQuestionParser): void {
QUESTION_TOOL_PARSERS.set(toolName, parser)
}
/** Claude's AskUserQuestion shape: `{ questions: [{ question, header,
* multiSelect, options: [{ label, description }] }] }`. Also the de-facto
* default shape, so a new agent that reuses it works without registration. */
function parseQuestionsShape(input: unknown): AskPrompt | null {
if (!input || typeof input !== 'object') {
return null
}
const rawQuestions = (input as { questions?: unknown }).questions
if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
return null
}
const questions: AskQuestion[] = []
for (const raw of rawQuestions) {
if (!raw || typeof raw !== 'object') {
continue
}
const q = raw as Record<string, unknown>
const question = typeof q.question === 'string' ? q.question : ''
const options = parseOptions(q.options)
if (question || options.length > 0) {
questions.push({
question,
header: typeof q.header === 'string' ? q.header : undefined,
multiSelect: q.multiSelect === true,
options
})
}
}
return questions.length > 0 ? { questions } : null
}
function parseOptions(raw: unknown): AskOption[] {
if (!Array.isArray(raw)) {
return []
}
return raw
.map((o): AskOption | null => {
if (typeof o === 'string') {
return { label: o }
}
if (o && typeof o === 'object' && typeof (o as { label?: unknown }).label === 'string') {
const obj = o as { label: string; description?: unknown }
return {
label: obj.label,
description: typeof obj.description === 'string' ? obj.description : undefined
}
}
return null
})
.filter((o): o is AskOption => o !== null)
}
// Claude's AskUserQuestion (and aliases) ship the canonical questions shape.
for (const name of ['AskUserQuestion', 'ask_user_question', 'askUserQuestion']) {
QUESTION_TOOL_PARSERS.set(name, parseQuestionsShape)
}
/** Resolve an interactive-prompt payload to an AskPrompt: try the tool's
* registered parser first, then fall back to the canonical questions shape so a
* new agent that happens to use the same structure works without registration. */
function parseToolInput(toolName: string | undefined, input: unknown): AskPrompt | null {
const parser = toolName ? QUESTION_TOOL_PARSERS.get(toolName) : undefined
return (parser ? parser(input) : null) ?? parseQuestionsShape(input)
}
/** Parse the live `agentStatus.interactivePrompt` (the agent's untruncated
* question-tool input as JSON) into an AskPrompt, or null. Dispatches through
* the tool's registered parser (keyed by `toolName`) with the canonical
* questions shape as the fallback. */
export function parseAskFromStatus(
interactivePrompt: string | undefined | null,
toolName?: string
): AskPrompt | null {
if (!interactivePrompt) {
return null
}
try {
return parseToolInput(toolName, JSON.parse(interactivePrompt))
} catch {
return null
}
}
/** Parse the `{ approval: { tool, summary } }` envelope (emitted by the host on
* a PermissionRequest) into an Allow/Deny card, or null. Allow sends "1"; Deny
* sends ESC — matching the common TUI approval prompt. */
export function parseApprovalFromStatus(
interactivePrompt: string | undefined | null
): ChatApproval | null {
if (!interactivePrompt) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(interactivePrompt)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
return null
}
const approval = (parsed as { approval?: unknown }).approval
if (!approval || typeof approval !== 'object') {
return null
}
const tool = (approval as { tool?: unknown }).tool
if (typeof tool !== 'string' || tool.length === 0) {
return null
}
const summary = (approval as { summary?: unknown }).summary
return {
title: translate('components.native-chat.approval.title', 'Allow {{value0}}?', {
value0: tool
}),
detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined,
options: [
{ label: translate('components.native-chat.approval.allow', 'Allow'), send: '1' },
{ label: translate('components.native-chat.approval.deny', 'Deny'), send: ESCAPE }
]
}
}
/** Resolve the live `interactivePrompt` to the single card to render. A question
* takes precedence over an approval. `toolName` is forwarded to the Ask branch
* for registry dispatch; the approval branch ignores it. */
export function parseInteractivePrompt(
interactivePrompt: string | undefined | null,
toolName?: string
): InteractivePromptCard {
const prompt = parseAskFromStatus(interactivePrompt, toolName)
if (prompt) {
return { kind: 'question', prompt }
}
const approval = parseApprovalFromStatus(interactivePrompt)
if (approval) {
return { kind: 'approval', approval }
}
return null
}
/** Build the answer text to send: exactly one line per question, in question
* order, each line the selected option label(s) joined by ", ". Empty answers
* stay as empty lines (not dropped) so N lines always == N questions — the
* per-question Enter stepping counts one Enter per line, so dropping a blank
* middle answer would misalign the count and leave the prompt unsubmitted. */
export function formatAskAnswer(prompt: AskPrompt, selections: string[][]): string {
return prompt.questions.map((_, i) => (selections[i] ?? []).join(', ')).join('\n')
}
@@ -0,0 +1,77 @@
// Pure merge of live hook turn-state into a NativeChatSession status override.
// Kept separate from the React hook so the precedence rule (live 'working'
// surfaces before the transcript flushes the final assistant message, then is
// superseded once it lands) is unit-testable without IPC or the store.
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import { assembleNativeChatSession, type NativeChatSources } from './native-chat-session-assembler'
import type {
AgentType,
NativeChatSession,
NativeChatSessionStatus
} from '../../../../shared/native-chat-types'
export type NativeChatLiveMergeInput = {
sources: NativeChatSources
sessionId: string | null
agent: AgentType
/** Live hook state for the pane, or null when no hook entry exists. */
hookState: AgentStatusState | null
/** True before the initial readSession resolves; forces 'loading'. */
loading?: boolean
/** Set when the initial read failed; forces 'error'. */
error?: string
}
/**
* Decide the session status given the merged transcript/append messages and the
* live hook state. The transcript is the source of truth for content; the hook
* only fills the gap while the agent is mid-turn.
*
* Precedence:
* - error / loading overrides win outright.
* - hook 'working' shows a live working indicator BEFORE the assistant turn
* lands in the transcript. Once the transcript's last message is an
* assistant reply (the turn flushed), 'working' is no longer asserted and
* the derived 'ready' status from the assembler stands.
*/
export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession {
const { sources, sessionId, agent, hookState, loading, error } = input
if (error) {
return assembleNativeChatSession({ sources, sessionId, agent, status: 'error', error })
}
if (loading) {
return assembleNativeChatSession({ sources, sessionId, agent, status: 'loading' })
}
const status = liveStatusOverride(sources, hookState)
return assembleNativeChatSession({
sources,
sessionId,
agent,
...(status ? { status } : {})
})
}
function liveStatusOverride(
sources: NativeChatSources,
hookState: AgentStatusState | null
): NativeChatSessionStatus | undefined {
// Only 'working' drives a live override; blocked/waiting/done leave the
// derived (ready/empty) status alone so completed turns render normally.
if (hookState !== 'working') {
return undefined
}
// If the transcript has already flushed the in-flight assistant reply, the
// turn is effectively visible — don't keep asserting 'working' on top of it.
if (lastMessageIsFreshAssistant(sources)) {
return undefined
}
return 'working'
}
function lastMessageIsFreshAssistant(sources: NativeChatSources): boolean {
const transcript = sources.transcript ?? []
const last = transcript.at(-1)
return last?.role === 'assistant'
}
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping'
function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): NativeChatMessage {
return {
role: 'assistant',
blocks: [],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('orderNativeChatMessages', () => {
it('orders by ascending timestamp, null first', () => {
const ordered = orderNativeChatMessages([
msg({ id: 'b', timestamp: 20 }),
msg({ id: 'a', timestamp: 10 }),
msg({ id: 'n', timestamp: null })
])
expect(ordered.map((m) => m.id)).toEqual(['n', 'a', 'b'])
})
it('breaks timestamp ties by id deterministically', () => {
const ordered = orderNativeChatMessages([
msg({ id: 'z', timestamp: 5 }),
msg({ id: 'a', timestamp: 5 })
])
expect(ordered.map((m) => m.id)).toEqual(['a', 'z'])
})
})
describe('buildNativeChatRenderItems', () => {
it('renders messages in order', () => {
const items = buildNativeChatRenderItems([
msg({ id: 'u', role: 'user', timestamp: 1, blocks: [{ type: 'text', text: 'hi' }] }),
msg({ id: 'a', role: 'assistant', timestamp: 2, blocks: [{ type: 'text', text: 'hello' }] })
])
expect(items.map((i) => i.id)).toEqual(['u', 'a'])
expect(items[0]?.kind).toBe('message')
})
it('pairs a tool-call with its tool-result into one step', () => {
const items = buildNativeChatRenderItems([
msg({
id: 'a',
role: 'assistant',
timestamp: 1,
blocks: [{ type: 'tool-call', name: 'Bash', input: { cmd: 'ls' } }]
}),
msg({
id: 't',
role: 'tool',
timestamp: 2,
blocks: [{ type: 'tool-result', output: 'file.txt' }]
})
])
const steps = items.filter((i) => i.kind === 'tool-step')
expect(steps).toHaveLength(1)
const step = steps[0]
if (step?.kind !== 'tool-step') {
throw new Error('expected tool-step')
}
expect(step.step.call.name).toBe('Bash')
expect(step.step.result?.output).toBe('file.txt')
})
it('leaves an unanswered tool-call in flight (result null)', () => {
const items = buildNativeChatRenderItems([
msg({
id: 'a',
role: 'assistant',
timestamp: 1,
blocks: [{ type: 'tool-call', name: 'Read', input: {} }]
})
])
const step = items.find((i) => i.kind === 'tool-step')
if (step?.kind !== 'tool-step') {
throw new Error('expected tool-step')
}
expect(step.step.result).toBeNull()
})
it('separates prose blocks from tool blocks in the same message', () => {
const items = buildNativeChatRenderItems([
msg({
id: 'a',
role: 'assistant',
timestamp: 1,
blocks: [
{ type: 'text', text: 'running it' },
{ type: 'tool-call', name: 'Bash', input: {} }
]
})
])
expect(items.map((i) => i.kind)).toEqual(['message', 'tool-step'])
const message = items[0]
if (message?.kind !== 'message') {
throw new Error('expected message')
}
expect(message.blocks).toEqual([{ type: 'text', text: 'running it' }])
})
})
@@ -0,0 +1,118 @@
// Pure grouping logic for the native chat message list. Kept out of the .tsx so
// the pairing/ordering rules are unit-testable without rendering. Two jobs:
// 1. Order messages stably (timestamp then id; null timestamps sort first as
// the shared model documents) — the assembler already sorts, but the list
// re-sorts defensively so a caller passing unordered fixtures still reads
// correctly.
// 2. Within an assistant turn, pair each tool-call block with the tool-result
// that answers it so the view can render one collapsible step instead of
// two disconnected rows.
import {
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock,
type NativeChatMessage,
type NativeChatToolCallBlock,
type NativeChatToolResultBlock
} from '../../../../shared/native-chat-types'
import { compareMessages } from './native-chat-session-assembler'
/** A tool-call block paired with the result that answered it, when one exists.
* `result` is null while the call is still in flight (no result yet). */
export type NativeChatToolStep = {
call: NativeChatToolCallBlock
result: NativeChatToolResultBlock | null
}
/** One renderable item in the list: either a prose/role message carrying its
* non-tool blocks, or a tool step (call + optional result). The view renders
* each variant differently. */
export type NativeChatRenderItem =
| {
kind: 'message'
id: string
message: NativeChatMessage
/** The message's blocks minus tool-call/tool-result (those become steps). */
blocks: NativeChatBlock[]
}
| {
kind: 'tool-step'
id: string
/** Role of the message the call originated from (assistant/tool). */
role: NativeChatMessage['role']
timestamp: number | null
step: NativeChatToolStep
}
/** Order messages stably: null timestamps first (model rule), then ascending
* timestamp, ties broken by id. Shares the assembler's comparator so both
* paths order identically. */
export function orderNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
return [...messages].sort(compareMessages)
}
/** Collect every tool-result across the whole conversation in document order so
* a call can find its answer even when the result lands in a later message (the
* common transcript shape: assistant emits the call, a following tool message
* carries the result). Results carry no originating name in our model, so they
* are handed out FIFO to calls. */
function collectToolResults(messages: NativeChatMessage[]): NativeChatToolResultBlock[] {
const results: NativeChatToolResultBlock[] = []
for (const message of messages) {
for (const block of message.blocks) {
if (isToolResultBlock(block)) {
results.push(block)
}
}
}
return results
}
/**
* Flatten ordered messages into render items, pairing tool calls with results.
* Result pairing is FIFO across the conversation: tool results in our model
* carry no back-reference to a call id, so we match the Nth call to the Nth
* result in document order — the order both providers emit them. A call with no
* remaining result renders as in-flight (`result: null`).
*/
export function buildNativeChatRenderItems(messages: NativeChatMessage[]): NativeChatRenderItem[] {
const ordered = orderNativeChatMessages(messages)
const resultQueue = collectToolResults(ordered)
let resultCursor = 0
const items: NativeChatRenderItem[] = []
for (const message of ordered) {
const nonToolBlocks: NativeChatBlock[] = []
const steps: NativeChatToolStep[] = []
for (const block of message.blocks) {
if (isToolCallBlock(block)) {
const result = resultQueue[resultCursor] ?? null
if (result) {
resultCursor += 1
}
steps.push({ call: block, result })
} else if (isToolResultBlock(block)) {
// Results are emitted as steps from the call side; skip standalone ones.
continue
} else {
nonToolBlocks.push(block)
}
}
if (nonToolBlocks.length > 0) {
items.push({ kind: 'message', id: message.id, message, blocks: nonToolBlocks })
}
for (const [index, step] of steps.entries()) {
items.push({
kind: 'tool-step',
id: `${message.id}:tool:${index}`,
role: message.role,
timestamp: message.timestamp,
step
})
}
}
return items
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { isNoiseMessage, stripNoiseMessages } from './native-chat-noise'
function msg(
role: NativeChatMessage['role'],
text: string,
blocks?: NativeChatMessage['blocks']
): NativeChatMessage {
return {
id: text.slice(0, 8),
role,
blocks: blocks ?? [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
describe('isNoiseMessage', () => {
it('flags task-notification and system-reminder user turns', () => {
expect(isNoiseMessage(msg('user', '<task-notification>\n<task-id>x</task-id>'))).toBe(true)
expect(isNoiseMessage(msg('user', '<system-reminder>\nbe careful'))).toBe(true)
expect(
isNoiseMessage(
msg(
'user',
'Caveat: The messages below were generated by the user while running local commands'
)
)
).toBe(true)
expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true)
})
it('keeps real user messages', () => {
expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false)
})
it('keeps assistant and tool turns', () => {
expect(isNoiseMessage(msg('assistant', '<system-reminder> in prose'))).toBe(false)
})
it('keeps a user turn that carries tool results', () => {
expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false)
})
it('stripNoiseMessages removes only the noise', () => {
const out = stripNoiseMessages([
msg('user', 'hello'),
msg('user', '<task-notification>done'),
msg('assistant', 'hi')
])
expect(out.map((m) => m.role)).toEqual(['user', 'assistant'])
})
})
@@ -0,0 +1,53 @@
import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
// Why: the harness injects machinery into the agent's conversation as user-role
// turns — background task-notifications, system reminders, local-command output,
// slash-command envelopes, interruption/compaction notices. These land in the
// transcript but are not real user messages, so the chat filters them out (they
// were confusingly rendered as the user's own bubbles). Mirrors the mobile
// predicate in mobile/src/session/mobile-native-chat-noise.ts.
const NOISE_PREFIXES = [
'<task-notification>',
'<system-reminder>',
'<local-command-stdout>',
'<local-command-caveat>',
'<command-name>',
'<command-message>',
'<command-args>',
'<bash-',
'[request interrupted',
'caveat: the messages below were generated by the user while running local commands',
'this session is being continued from a previous conversation'
]
function messageText(message: NativeChatMessage): string {
return message.blocks
.filter(isTextBlock)
.map((b) => b.text)
.join('')
.trim()
}
/** True when a message is harness machinery rather than real conversation. Only
* user/system turns qualify — assistant/tool turns and any turn carrying real
* tool activity are always kept. */
export function isNoiseMessage(message: NativeChatMessage): boolean {
if (message.role !== 'user' && message.role !== 'system') {
return false
}
// Keep turns that carry tool activity (e.g. a user turn with tool results).
if (message.blocks.some((b) => b.type === 'tool-call' || b.type === 'tool-result')) {
return false
}
const text = messageText(message).toLowerCase()
if (text.length === 0) {
return false
}
return NOISE_PREFIXES.some((prefix) => text.startsWith(prefix))
}
/** Drop harness-noise messages from a transcript. */
export function stripNoiseMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
return messages.filter((m) => !isNoiseMessage(m))
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
hasMoreNativeChatHistory,
NATIVE_CHAT_INITIAL_LIMIT,
NATIVE_CHAT_PAGE,
nextNativeChatLimit
} from './native-chat-pagination'
describe('nextNativeChatLimit', () => {
it('grows the limit by one page', () => {
expect(nextNativeChatLimit(NATIVE_CHAT_INITIAL_LIMIT)).toBe(
NATIVE_CHAT_INITIAL_LIMIT + NATIVE_CHAT_PAGE
)
expect(nextNativeChatLimit(NATIVE_CHAT_INITIAL_LIMIT + NATIVE_CHAT_PAGE)).toBe(
NATIVE_CHAT_INITIAL_LIMIT + 2 * NATIVE_CHAT_PAGE
)
})
})
describe('hasMoreNativeChatHistory', () => {
it('reports more when the read filled the requested window', () => {
expect(hasMoreNativeChatHistory(300, 300)).toBe(true)
expect(hasMoreNativeChatHistory(301, 300)).toBe(true)
})
it('reports done when the read returned fewer than requested (head reached)', () => {
expect(hasMoreNativeChatHistory(120, 300)).toBe(false)
expect(hasMoreNativeChatHistory(0, 300)).toBe(false)
})
})
@@ -0,0 +1,21 @@
// Pure pagination math for the native-chat read window. The renderer reads the
// transcript tail with a `limit`; when the user scrolls to the top it raises the
// limit by a page to load older history. Kept pure (no React/IO) so the limit
// growth and the "is there more?" decision are unit-testable.
// First page mirrors the desktop default window (300 most-recent turns) so the
// initial paint matches the prior behavior; each load-earlier grows by a page.
export const NATIVE_CHAT_INITIAL_LIMIT = 300
export const NATIVE_CHAT_PAGE = 200
/** The limit to request for the next older page. */
export function nextNativeChatLimit(currentLimit: number): number {
return currentLimit + NATIVE_CHAT_PAGE
}
/** Whether an older page may still exist: the last read filled the window, so
* there could be more behind it. If the read returned fewer than requested we
* reached the head of the transcript and there is nothing older to load. */
export function hasMoreNativeChatHistory(returnedCount: number, requestedLimit: number): boolean {
return returnedCount >= requestedLimit
}
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
function entry(
overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>
): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
stateHistory: [],
...overrides
}
}
describe('resolveNativeChatSession', () => {
it('resolves a pane with a captured Claude session', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
agentStatusEntry: entry({
paneKey,
agentType: 'claude',
providerSession: { key: 'session_id', id: 'sess-abc' }
}),
ptyId: 'pty-1'
})
).toEqual({
agent: 'claude',
sessionId: 'sess-abc',
transcriptPath: null,
ptyId: 'pty-1',
paneKey
})
})
it('surfaces the hook transcriptPath when the providerSession carries one', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
agentStatusEntry: entry({
paneKey,
agentType: 'claude',
providerSession: {
key: 'session_id',
id: 'sess-abc',
transcriptPath: '/home/u/.claude/projects/slug/real-uuid.jsonl'
}
}),
ptyId: 'pty-1'
})
).toEqual({
agent: 'claude',
sessionId: 'sess-abc',
transcriptPath: '/home/u/.claude/projects/slug/real-uuid.jsonl',
ptyId: 'pty-1',
paneKey
})
})
it('resolves a just-launched pane with sessionId null', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: 'claude',
// Entry exists (agent launched) but no providerSession reported yet.
agentStatusEntry: entry({ paneKey, agentType: 'claude' }),
ptyId: 'pty-1'
})
).toEqual({ agent: 'claude', sessionId: null, transcriptPath: null, ptyId: 'pty-1', paneKey })
})
it('resolves two split leaves independently to their own values', () => {
const leftKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const rightKey = 'tab-1:22222222-2222-4222-8222-222222222222'
const left = resolveNativeChatSession({
paneKey: leftKey,
launchAgent: 'claude',
agentStatusEntry: entry({
paneKey: leftKey,
agentType: 'claude',
providerSession: { key: 'session_id', id: 'left-sess' }
}),
ptyId: 'pty-left'
})
const right = resolveNativeChatSession({
paneKey: rightKey,
launchAgent: 'codex',
agentStatusEntry: entry({
paneKey: rightKey,
agentType: 'codex',
providerSession: { key: 'session_id', id: 'right-sess' }
}),
ptyId: 'pty-right'
})
expect(left).toEqual({
agent: 'claude',
sessionId: 'left-sess',
transcriptPath: null,
ptyId: 'pty-left',
paneKey: leftKey
})
expect(right).toEqual({
agent: 'codex',
sessionId: 'right-sess',
transcriptPath: null,
ptyId: 'pty-right',
paneKey: rightKey
})
})
it('derives the agent from the status entry when no launchAgent is set', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
expect(
resolveNativeChatSession({
paneKey,
launchAgent: null,
agentStatusEntry: entry({
paneKey,
agentType: 'gemini',
providerSession: { key: 'session_id', id: 'g-1' }
}),
ptyId: 'pty-1'
})
).toEqual({ agent: 'gemini', sessionId: 'g-1', transcriptPath: null, ptyId: 'pty-1', paneKey })
})
it('returns null for a non-agent pane (no launchAgent, no entry)', () => {
expect(
resolveNativeChatSession({
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
launchAgent: null,
ptyId: 'pty-1'
})
).toBeNull()
})
})
@@ -0,0 +1,56 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { TuiAgent } from '../../../../shared/types'
/** Inputs that resolve the active pane to the agent/session/pty triple the
* native-chat data + input layers need. Kept as a plain shape (not the live
* store or pane-manager singleton) so the resolver stays pure and unit-
* testable — call sites read the agent-status entry and the runtime ptyId for
* the pane's `paneKey` before calling. */
export type NativeChatPaneResolutionInput = {
/** Composite `${tabId}:${leafId}` key of the active leaf. */
paneKey: string
/** The coding-agent Orca launched in this terminal, if any (from TerminalTab).
* Drives the agent label when no live status entry has reported one yet. */
launchAgent?: TuiAgent | null
/** Live agent-status entry for this pane, when one exists. Carries the
* captured `providerSession` (the agent's own session id) once the agent has
* reported it, plus the detected `agentType`. */
agentStatusEntry?: AgentStatusEntry
/** Runtime PTY id bound to this pane. ptyId is pane-manager runtime state, so
* it's passed in rather than looked up inside this pure function. */
ptyId: string | null
}
export type NativeChatPaneResolution = {
agent: AgentType
/** The agent's own captured session/conversation id, or null before the
* agent has reported one (entry exists but no providerSession yet). */
sessionId: string | null
/** Authoritative transcript path from the hook, when reported. Preferred over
* reconstructing the path from sessionId (recent Claude Code diverges them). */
transcriptPath: string | null
ptyId: string | null
paneKey: string
}
/** Resolve the active pane to `{ agent, sessionId, ptyId, paneKey }`, or null
* when the pane runs no agent. A pane qualifies when either a launch-time
* agent hint or a live agent-status entry is present (mirrors the eligibility
* union in native-chat-availability). sessionId comes from the entry's
* `providerSession.id` (the captured agent session id) — null until the agent
* reports one, so a just-launched pane resolves without throwing. */
export function resolveNativeChatSession(
input: NativeChatPaneResolutionInput
): NativeChatPaneResolution | null {
const agent = input.launchAgent ?? input.agentStatusEntry?.agentType
if (!agent) {
return null
}
return {
agent,
sessionId: input.agentStatusEntry?.providerSession?.id ?? null,
transcriptPath: input.agentStatusEntry?.providerSession?.transcriptPath ?? null,
ptyId: input.ptyId,
paneKey: input.paneKey
}
}
@@ -0,0 +1,259 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import {
appendPendingSendCache,
appendCommandMarkerCache,
applyCommandMarkerBoundaries,
clearCommandMarkerCacheForTests,
clearPendingSendCacheForTests,
commandMarkersAsMessages,
isCommandMarkerId,
isPendingMessageId,
pendingSendsAsMessages,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
writePendingSendCache,
type NativeChatPendingSend
} from './native-chat-pending'
import { stripNoiseMessages } from './native-chat-noise'
function userMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: 1,
source: 'transcript'
}
}
function assistantMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: 2,
source: 'transcript'
}
}
const pendingOf = (id: string, text: string): NativeChatPendingSend => ({ id, text, sentAt: 100 })
describe('prunePendingSends', () => {
it('returns the same reference when there is nothing pending', () => {
const pending: NativeChatPendingSend[] = []
expect(prunePendingSends(pending, [userMessage('m1', 'hi')])).toBe(pending)
})
it('keeps a pending send while only its user turn has landed', () => {
const pending = [pendingOf('p1', 'fix the bug')]
const next = prunePendingSends(pending, [userMessage('m1', 'fix the bug')])
expect(next).toBe(pending)
})
it('drops a pending send once the transcript advances beyond its user turn', () => {
const pending = [pendingOf('p1', 'fix the bug')]
const next = prunePendingSends(pending, [
userMessage('m1', 'fix the bug'),
assistantMessage('m2', 'working on it')
])
expect(next).toEqual([])
})
it('matches advanced turns ignoring surrounding/collapsed whitespace', () => {
const pending = [pendingOf('p1', ' do the thing ')]
const next = prunePendingSends(pending, [
userMessage('m1', 'do the thing'),
assistantMessage('m2', 'done')
])
expect(next).toEqual([])
})
it('drops an attachment pending send once a prefixed transcript prompt advances', () => {
const pending = [
{ ...pendingOf('p1', 'what do you see'), imagePaths: ['/Users/me/Downloads/3d.png'] }
]
const next = prunePendingSends(pending, [
userMessage('m1', '[Image #1] what do you see'),
assistantMessage('m2', 'an image')
])
expect(next).toEqual([])
})
it('keeps a pending send that has not landed yet', () => {
const pending = [pendingOf('p1', 'not yet')]
const next = prunePendingSends(pending, [assistantMessage('m1', 'working on it')])
expect(next).toBe(pending)
})
it('does not match an assistant message with the same text', () => {
const pending = [pendingOf('p1', 'echo me')]
const next = prunePendingSends(pending, [assistantMessage('m1', 'echo me')])
expect(next).toBe(pending)
})
it('prunes only the matched entry, keeping others', () => {
const pending = [pendingOf('p1', 'first'), pendingOf('p2', 'second')]
const next = prunePendingSends(pending, [
userMessage('m1', 'first'),
assistantMessage('m2', 'first answer')
])
expect(next).toEqual([pendingOf('p2', 'second')])
})
})
describe('pendingSendsAsMessages', () => {
it('maps pending sends to prefixed scrape-source user messages sorted by sentAt', () => {
const messages = pendingSendsAsMessages([{ id: 'p1', text: 'queued text', sentAt: 42 }])
expect(messages).toEqual([
{
id: 'pending:p1',
role: 'user',
blocks: [{ type: 'text', text: 'queued text' }],
timestamp: 42,
source: 'scrape'
}
])
})
it('includes image refs for pending attachment sends', () => {
const messages = pendingSendsAsMessages([
{ id: 'p1', text: 'what do you see?', imagePaths: ['/tmp/shot.png'], sentAt: 42 }
])
expect(messages[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/shot.png' },
{ type: 'text', text: 'what do you see?' }
])
})
it('hides a pending send while its real user turn is visible', () => {
const pending = [pendingOf('p1', 'first prompt')]
expect(pendingSendsAsMessages(pending, [userMessage('u1', 'first prompt')])).toEqual([])
expect(pendingSendsAsMessages(pending, [])).toHaveLength(1)
})
})
describe('pending send cache', () => {
it('persists optimistic sends for the same pane and agent', () => {
clearPendingSendCacheForTests()
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' }
const appended = appendPendingSendCache(scope, pendingOf('p1', 'first prompt'))
expect(appended).toEqual([pendingOf('p1', 'first prompt')])
expect(readPendingSendCache(scope)).toEqual(appended)
expect(readPendingSendCache({ ...scope, agent: 'claude' })).toEqual([])
})
it('clears cached pending sends when pruning removes all entries', () => {
clearPendingSendCacheForTests()
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex' }
appendPendingSendCache(scope, pendingOf('p1', 'first prompt'))
writePendingSendCache(scope, [])
expect(readPendingSendCache(scope)).toEqual([])
})
})
describe('isPendingMessageId', () => {
it('recognizes the pending id prefix', () => {
expect(isPendingMessageId('pending:p1')).toBe(true)
expect(isPendingMessageId('transcript-123')).toBe(false)
})
})
describe('commandMarkersAsMessages', () => {
it('renders a slash command as a system "Ran <cmd>" message', () => {
expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([
{
id: 'command:c1',
role: 'system',
blocks: [{ type: 'text', text: 'Ran /clear' }],
timestamp: 7,
source: 'scrape'
}
])
})
it('survives stripNoiseMessages (the "Ran" text is not a noise prefix)', () => {
const markers = commandMarkersAsMessages([{ id: 'c1', command: '/compact', sentAt: 1 }])
expect(stripNoiseMessages(markers)).toEqual(markers)
})
it('isCommandMarkerId recognizes the prefix', () => {
expect(isCommandMarkerId('command:c1')).toBe(true)
expect(isCommandMarkerId('pending:p1')).toBe(false)
})
})
describe('command marker cache', () => {
it('persists slash command markers for the same pane conversation', () => {
clearCommandMarkerCacheForTests()
const scope = { paneKey: 'tab-a:leaf-a', agent: 'codex', sessionId: 'session-1' }
const appended = appendCommandMarkerCache(scope, '/clear', 10)
expect(appended).toEqual([{ id: '10-1', command: '/clear', sentAt: 10 }])
expect(readCommandMarkerCache(scope)).toEqual(appended)
expect(readCommandMarkerCache({ ...scope, sessionId: 'session-2' })).toEqual([])
})
it('caps cached command markers to the latest eight', () => {
clearCommandMarkerCacheForTests()
const scope = { paneKey: 'tab-a:leaf-a', agent: 'claude', sessionId: 'session-1' }
for (let i = 0; i < 10; i += 1) {
appendCommandMarkerCache(scope, `/cmd-${i}`, i)
}
expect(readCommandMarkerCache(scope).map((marker) => marker.command)).toEqual([
'/cmd-2',
'/cmd-3',
'/cmd-4',
'/cmd-5',
'/cmd-6',
'/cmd-7',
'/cmd-8',
'/cmd-9'
])
})
})
describe('applyCommandMarkerBoundaries', () => {
it('hides existing transcript messages after a local /clear marker', () => {
const messages = [
userMessage('before', 'old prompt'),
{ ...assistantMessage('after', 'new answer'), timestamp: 20 }
]
expect(
applyCommandMarkerBoundaries(messages, [{ id: 'c1', command: '/clear', sentAt: 10 }])
).toEqual([{ ...assistantMessage('after', 'new answer'), timestamp: 20 }])
})
it('keeps messages for non-clear commands like /compact', () => {
const messages = [userMessage('before', 'old prompt')]
expect(
applyCommandMarkerBoundaries(messages, [{ id: 'c1', command: '/compact', sentAt: 10 }])
).toBe(messages)
})
it('uses the latest clear marker as the visible boundary', () => {
const messages = [
{ ...userMessage('old', 'old'), timestamp: 5 },
{ ...userMessage('middle', 'middle'), timestamp: 15 },
{ ...userMessage('new', 'new'), timestamp: 25 }
]
expect(
applyCommandMarkerBoundaries(messages, [
{ id: 'c1', command: '/clear', sentAt: 10 },
{ id: 'c2', command: '/clear', sentAt: 20 }
]).map((message) => message.id)
).toEqual(['new'])
})
})
@@ -0,0 +1,253 @@
// Pure logic for desktop optimistic "queued" composer sends (mobile parity).
// A sent prompt is echoed immediately as a queued entry and pruned once its real
// user turn lands in the transcript. Kept separate from the view so the prune
// rule (match on normalized user-message text) is unit-testable without React.
import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
/** An optimistic, not-yet-confirmed composer send. */
export type NativeChatPendingSend = {
/** Renderer-minted id, unique per send, used as the list key. */
id: string
/** The exact draft text the user submitted. */
text: string
/** Image paths that were sent through the TUI image attachment paste path. */
imagePaths?: string[]
/** Epoch ms when the send was issued, so the queued bubble sorts to the end. */
sentAt: number
}
export type NativeChatPendingSendScope = {
paneKey: string
agent: string
}
const PENDING_SEND_LIMIT = 8
const pendingSendCache = new Map<string, NativeChatPendingSend[]>()
function pendingSendScopeKey(scope: NativeChatPendingSendScope): string {
return `${scope.paneKey}\0${scope.agent}`
}
export function readPendingSendCache(scope: NativeChatPendingSendScope): NativeChatPendingSend[] {
return [...(pendingSendCache.get(pendingSendScopeKey(scope)) ?? [])]
}
export function writePendingSendCache(
scope: NativeChatPendingSendScope,
pending: NativeChatPendingSend[]
): NativeChatPendingSend[] {
const next = pending.slice(-PENDING_SEND_LIMIT)
const key = pendingSendScopeKey(scope)
if (next.length === 0) {
pendingSendCache.delete(key)
} else {
pendingSendCache.set(key, next)
}
return [...next]
}
export function appendPendingSendCache(
scope: NativeChatPendingSendScope,
entry: NativeChatPendingSend
): NativeChatPendingSend[] {
return writePendingSendCache(scope, [...readPendingSendCache(scope), entry])
}
export function clearPendingSendCacheForTests(): void {
pendingSendCache.clear()
}
function normalize(text: string): string {
return stripImagePromptMarker(text).trim().replace(/\s+/g, ' ')
}
/** The prose of a user message, normalized for matching against a pending send. */
function userMessageText(message: NativeChatMessage): string | null {
if (message.role !== 'user') {
return null
}
const text = message.blocks
.filter(isTextBlock)
.map((block) => block.text)
.join(' ')
return normalize(text)
}
function matchingUserMessageTexts(messages: NativeChatMessage[]): Set<string> {
const texts = new Set<string>()
for (const message of messages) {
const text = userMessageText(message)
if (text) {
texts.add(text)
}
}
return texts
}
function advancedPastUserMessageTexts(messages: NativeChatMessage[]): Set<string> {
const advanced = new Set<string>()
const waiting = new Set<string>()
for (const message of messages) {
if (message.role === 'user') {
const text = userMessageText(message)
if (text) {
waiting.add(text)
}
continue
}
for (const text of waiting) {
advanced.add(text)
}
}
return advanced
}
/**
* Drop any pending send only after the transcript has advanced beyond its real
* user turn. Keeping the echo through the user-only transcript phase prevents a
* first-turn empty-state flash if the live transcript briefly reports [] before
* the assistant response lands.
*/
export function prunePendingSends(
pending: NativeChatPendingSend[],
messages: NativeChatMessage[]
): NativeChatPendingSend[] {
if (pending.length === 0) {
return pending
}
const advanced = advancedPastUserMessageTexts(messages)
const next = pending.filter((entry) => !advanced.has(normalize(entry.text)))
return next.length === pending.length ? pending : next
}
/**
* Turn pending sends into chat messages so they render in the list as queued
* user bubbles. They carry the `scrape` source (lowest priority) so the real
* transcript turn always supersedes them if both are briefly present, and the
* send time as the timestamp so they sort to the end (most recent) of the list.
*/
export function pendingSendsAsMessages(
pending: NativeChatPendingSend[],
existingMessages: NativeChatMessage[] = []
): NativeChatMessage[] {
const represented = matchingUserMessageTexts(existingMessages)
return pending
.filter((entry) => !represented.has(normalize(entry.text)))
.map((entry) => ({
id: `pending:${entry.id}`,
role: 'user' as const,
blocks: [
...(entry.imagePaths ?? []).map((path) => ({ type: 'image-ref' as const, path })),
...(entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [])
],
timestamp: entry.sentAt,
source: 'scrape' as const
}))
}
/** True when a message id was minted for an optimistic pending send. */
export function isPendingMessageId(id: string): boolean {
return id.startsWith('pending:')
}
/** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to
* the agent's TUI and are not chat turns, so we surface a small system line as
* feedback that the command ran rather than echoing a user bubble. */
export type NativeChatCommandMarker = {
id: string
/** The command as typed, e.g. `/clear`. */
command: string
sentAt: number
}
export type NativeChatCommandMarkerScope = {
paneKey: string
agent: string
sessionId: string | null
}
const COMMAND_MARKER_LIMIT = 8
const commandMarkerCache = new Map<string, NativeChatCommandMarker[]>()
let commandMarkerCounter = 0
function commandMarkerScopeKey(scope: NativeChatCommandMarkerScope): string {
return `${scope.paneKey}\0${scope.agent}\0${scope.sessionId ?? ''}`
}
export function readCommandMarkerCache(
scope: NativeChatCommandMarkerScope
): NativeChatCommandMarker[] {
return [...(commandMarkerCache.get(commandMarkerScopeKey(scope)) ?? [])]
}
export function appendCommandMarkerCache(
scope: NativeChatCommandMarkerScope,
command: string,
sentAt = Date.now()
): NativeChatCommandMarker[] {
commandMarkerCounter += 1
const key = commandMarkerScopeKey(scope)
// Why: native/TUI view switches remount the chat surface, but slash commands
// are not transcript turns, so their local feedback needs a pane-scoped cache.
const next = [
...(commandMarkerCache.get(key) ?? []),
{ id: `${sentAt}-${commandMarkerCounter}`, command, sentAt }
].slice(-COMMAND_MARKER_LIMIT)
commandMarkerCache.set(key, next)
return [...next]
}
export function clearCommandMarkerCacheForTests(): void {
commandMarkerCache.clear()
commandMarkerCounter = 0
}
function isClearCommand(command: string): boolean {
return command.trim().toLowerCase().split(/\s+/)[0] === '/clear'
}
function latestClearSentAt(markers: readonly NativeChatCommandMarker[]): number | null {
let latest: number | null = null
for (const marker of markers) {
if (isClearCommand(marker.command) && (latest === null || marker.sentAt > latest)) {
latest = marker.sentAt
}
}
return latest
}
export function applyCommandMarkerBoundaries(
messages: readonly NativeChatMessage[],
markers: readonly NativeChatCommandMarker[]
): NativeChatMessage[] {
const clearSentAt = latestClearSentAt(markers)
if (clearSentAt === null) {
return messages as NativeChatMessage[]
}
// Why: `/clear` mutates the TUI/transcript asynchronously. Hide the current
// transcript immediately so native chat reflects the command before the agent
// writes a replacement session or truncates the file.
return messages.filter((message) => message.timestamp !== null && message.timestamp > clearSentAt)
}
/** 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. */
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}` }],
timestamp: marker.sentAt,
source: 'scrape' as const
}))
}
/** True when a message id was minted for a slash-command marker. */
export function isCommandMarkerId(id: string): boolean {
return id.startsWith('command:')
}
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the IO seam so the test stays pure: we only assert the write order and
// the inter-write delay, not the local-vs-remote pty branching.
const sendRuntimePtyInput = vi.fn()
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args)
}))
import {
sendNativeChatMessage,
sendNativeChatMessageWithImageAttachments,
sendNativeChatImageAttachments,
submitNativeChatPrompt,
sendNativeChatAnswer,
nativeChatQuestionOffsets,
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS,
NATIVE_CHAT_QUESTION_STEP_MS,
NATIVE_CHAT_ADVANCE_BUFFER_MS
} from './native-chat-runtime-send'
import {
buildNativeChatImagePasteBytes,
buildNativeChatPasteBytes,
NATIVE_CHAT_SUBMIT
} from './native-chat-send'
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
const PTY = 'pty-1'
describe('sendNativeChatMessage', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('writes the framed body immediately, before the Enter', () => {
sendNativeChatMessage(SETTINGS, PTY, 'hello world')
// Body lands synchronously; Enter is still pending on the timer.
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('hello world')
)
})
it('does not fire Enter before the proven 500ms gap (busy-agent safety)', () => {
sendNativeChatMessage(SETTINGS, PTY, 'hi')
// A short gap would fire Enter while a busy Codex has not yet landed the
// paste, submitting an empty box — so nothing must happen before 500ms.
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
})
it('writes the bare carriage-return Enter as a separate delayed write', () => {
sendNativeChatMessage(SETTINGS, PTY, 'hi')
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => {
expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
})
})
describe('sendNativeChatMessageWithImageAttachments', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => {
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [
'/tmp/orca-paste-image.png'
])
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
)
vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('what do you see?')
)
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
it('waits the normal submit gap for an attachment-only send', () => {
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, '', ['/tmp/orca-paste-image.png'])
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
})
describe('pre-pasted image attachment sends', () => {
beforeEach(() => {
sendRuntimePtyInput.mockClear()
})
it('pastes image attachments immediately without submitting the prompt', () => {
sendNativeChatImageAttachments(SETTINGS, PTY, ['/tmp/orca-paste-image.png'])
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')
)
})
it('submits a prompt whose image attachments were already pasted', () => {
submitNativeChatPrompt(SETTINGS, PTY)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
})
describe('nativeChatQuestionOffsets', () => {
it('paces each question a full step apart, Enter 500ms after its body', () => {
expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(800)
expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(300)
expect(nativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 })
expect(nativeChatQuestionOffsets(1)).toEqual({ bodyAt: 800, enterAt: 1300 })
expect(nativeChatQuestionOffsets(2)).toEqual({ bodyAt: 1600, enterAt: 2100 })
})
})
describe('sendNativeChatAnswer', () => {
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('single-line answer behaves exactly like sendNativeChatMessage', () => {
sendNativeChatAnswer(SETTINGS, PTY, ['only one'])
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('only one')
)
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
})
it('multi-line: 3 bodies + 3 Enters in order, each Enter 500ms after its body, next body only after prior Enter+buffer', () => {
const lines = ['answer one', 'answer two', 'answer three']
sendNativeChatAnswer(SETTINGS, PTY, lines)
// Nothing fires synchronously: even question 0's body is scheduled (setTimeout 0).
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0)
// t=0: question 0 body.
vi.advanceTimersByTime(0)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('answer one')
)
// t=500: question 0 Enter (500ms after its body); question 1 body NOT yet.
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
// Question 1 body must wait the advance buffer past question 0's Enter.
vi.advanceTimersByTime(NATIVE_CHAT_ADVANCE_BUFFER_MS - 1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2)
// t=800: question 1 body.
vi.advanceTimersByTime(1)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('answer two')
)
// t=1300: question 1 Enter.
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
// t=1600: question 2 body.
vi.advanceTimersByTime(NATIVE_CHAT_ADVANCE_BUFFER_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(5)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(
SETTINGS,
PTY,
buildNativeChatPasteBytes('answer three')
)
// t=2100: question 2 Enter — the final submit. No trailing writes after.
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT)
// Exactly 3 bodies + 3 Enters; running all timers adds nothing more.
vi.runAllTimers()
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
// Verify body/Enter ordering across the whole sequence.
const calls = sendRuntimePtyInput.mock.calls.map((c) => c[2])
expect(calls).toEqual([
buildNativeChatPasteBytes('answer one'),
NATIVE_CHAT_SUBMIT,
buildNativeChatPasteBytes('answer two'),
NATIVE_CHAT_SUBMIT,
buildNativeChatPasteBytes('answer three'),
NATIVE_CHAT_SUBMIT
])
})
})
@@ -0,0 +1,173 @@
// Runtime send for native chat: writes the framed message body, then the Enter
// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in
// native-chat-send.ts so those stay IO-free and unit-testable without aliases.
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import {
buildNativeChatImagePasteBytes,
buildNativeChatPasteBytes,
NATIVE_CHAT_SUBMIT
} from './native-chat-send'
// Why: agent TUIs swallow a `\r` bundled into the same pty write as a framed
// paste, so a one-shot send leaves the text sitting in the input box, unsent.
// Write the body first, then the Enter after a delay so the agent processes the
// paste before the submit. The gap must clear the agent's paste-handling latency
// even while it's BUSY (Codex): a short gap (60ms) fires Enter before a busy
// Codex has landed the paste into its input, so the submit hits an empty box and
// the message sits "Queued" forever. 500ms is orca-runtime's proven value in
// writeTerminalAction({enter:true}), so match it here.
export const NATIVE_CHAT_SUBMIT_DELAY_MS = 500
export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
// Why: Claude Code's AskUserQuestion is a MULTI-STEP prompt — it renders one
// question at a time and each Enter advances to the next (the final Enter
// submits the whole thing). After firing a question's Enter we must let the TUI
// render the next question before writing its body, or that body lands on the
// wrong (or no) active question. This buffer is added ON TOP of the body→Enter
// gap; a previous attempt that spaced Enters only ~350ms apart fired them faster
// than the next question rendered, leaking answers as fresh prompts.
export const NATIVE_CHAT_ADVANCE_BUFFER_MS = 300
/** Per-question wall-clock cadence: body→Enter gap plus the advance buffer that
* lets the next AskUserQuestion step render before its body is written. */
export const NATIVE_CHAT_QUESTION_STEP_MS =
NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS
/** Pure scheduling math for a per-question answer sequence. For question index
* `i` (0-based) returns the offsets (ms from the start of the send) at which to
* write its framed body and its Enter. Body for question 0 fires at 0; each
* later question starts a full step after the previous, so its body is never
* written until the previous question's Enter has fired plus the advance
* buffer. Exactly one Enter per question; the last Enter submits the prompt. */
export function nativeChatQuestionOffsets(index: number): {
bodyAt: number
enterAt: number
} {
const bodyAt = index * NATIVE_CHAT_QUESTION_STEP_MS
return { bodyAt, enterAt: bodyAt + NATIVE_CHAT_SUBMIT_DELAY_MS }
}
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
* later question bodies/Enters). Safe to call after the send completes. */
export type NativeChatSendHandle = { cancel: () => void }
/**
* Send a native-chat message through the verified runtime pty path: framed body
* first, then a separate delayed Enter. `sendRuntimePtyInput` branches local
* pty:write vs remote runtime RPC, so this works for SSH panes too. Returns a
* cancel handle so callers can drop the still-pending Enter on unmount/stop.
*/
export function sendNativeChatMessage(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
text: string
): NativeChatSendHandle {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
const timer = setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
}, NATIVE_CHAT_SUBMIT_DELAY_MS)
return { cancel: () => clearTimeout(timer) }
}
export function sendNativeChatMessageWithImageAttachments(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
text: string,
imagePaths: readonly string[]
): NativeChatSendHandle {
if (imagePaths.length === 0) {
return sendNativeChatMessage(settings, ptyId, text)
}
const timers: ReturnType<typeof setTimeout>[] = []
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
const trimmedText = text.trim()
if (trimmedText.length > 0) {
timers.push(
setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
}, NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS)
)
}
timers.push(
setTimeout(
() => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
},
trimmedText.length > 0
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
: NATIVE_CHAT_SUBMIT_DELAY_MS
)
)
return {
cancel: () => {
for (const timer of timers) {
clearTimeout(timer)
}
}
}
}
/** Paste image attachments into the hosted TUI immediately so switching back to
* the terminal shows the same image chips a direct terminal paste would show. */
export function sendNativeChatImageAttachments(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
imagePaths: readonly string[]
): void {
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
}
/** Submit a TUI prompt whose image attachments were already pasted earlier. */
export function submitNativeChatPrompt(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string
): void {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
}
/**
* Send an AskUserQuestion answer that may span multiple questions. Each line is
* one question's answer (exactly how `formatAskAnswer` builds it). A single line
* is just `sendNativeChatMessage` (no behavior change). For multiple lines we
* write each question's framed body then its Enter as a per-question sequence,
* paced by `NATIVE_CHAT_QUESTION_STEP_MS` so each Enter lands on its own
* rendered question and the LAST Enter submits — exactly N Enters for N lines,
* never a trailing one. Returns a cancel handle that clears every pending timer
* so a detached sequence can't keep writing PTY bytes after unmount/stop.
*/
export function sendNativeChatAnswer(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
lines: string[]
): NativeChatSendHandle {
if (lines.length <= 1) {
return sendNativeChatMessage(settings, ptyId, lines[0] ?? '')
}
const timers: ReturnType<typeof setTimeout>[] = []
lines.forEach((line, index) => {
const { bodyAt, enterAt } = nativeChatQuestionOffsets(index)
timers.push(
setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(line))
}, bodyAt)
)
timers.push(
setTimeout(() => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
}, enterAt)
)
})
return {
cancel: () => {
for (const timer of timers) {
clearTimeout(timer)
}
}
}
}
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import {
scrapeScrollbackToMessages,
scrapeNativeChatSession,
stripScrollbackAnsi
} from './native-chat-scrape-fallback'
const ESC = String.fromCharCode(27)
describe('scrapeScrollbackToMessages', () => {
it('strips ANSI escapes and segments into >1 ordered messages', () => {
const raw = [
`${ESC}[32m$ run the build${ESC}[0m`,
'',
`${ESC}[1mBuilding project...${ESC}[0m`,
'Done in 2s.'
].join('\n')
const messages = scrapeScrollbackToMessages(raw)
expect(messages.length).toBeGreaterThan(1)
for (const message of messages) {
for (const block of message.blocks) {
if (block.type === 'text') {
// ANSI is gone — no raw escape character survives.
expect(block.text).not.toContain(ESC)
}
}
}
// Order is preserved from the scrollback.
expect(messages[0].id).toBe('scrape-0')
expect(messages[1].id).toBe('scrape-1')
})
it('returns an empty list for empty / whitespace scrollback without throwing', () => {
expect(scrapeScrollbackToMessages('')).toEqual([])
expect(scrapeScrollbackToMessages(' \n\t \n')).toEqual([])
})
it('marks every produced message with source "scrape" and a null timestamp', () => {
const raw = '$ hello\n\nworld output\n\nmore output'
const messages = scrapeScrollbackToMessages(raw)
expect(messages.length).toBeGreaterThan(0)
for (const message of messages) {
expect(message.source).toBe('scrape')
expect(message.timestamp).toBeNull()
}
})
it('assigns roles per the prompt-marker heuristic', () => {
const raw = [
'$ deploy to staging',
'',
'Deploying to staging environment...',
'Deployed.'
].join('\n')
const messages = scrapeScrollbackToMessages(raw)
// First segment starts with a shell prompt marker -> user.
expect(messages[0].role).toBe('user')
// Plain output -> assistant.
expect(messages[1].role).toBe('assistant')
})
})
describe('stripScrollbackAnsi', () => {
it('removes escape sequences and normalizes carriage returns', () => {
const raw = `${ESC}[31mred\r\nnext`
expect(stripScrollbackAnsi(raw)).toBe('red\nnext')
})
})
describe('scrapeNativeChatSession', () => {
it('builds a ready, approximate session from non-empty scrollback', () => {
const { session, isApproximate } = scrapeNativeChatSession('$ ls\n\noutput here', 'claude')
expect(isApproximate).toBe(true)
expect(session.status).toBe('ready')
expect(session.sessionId).toBeNull()
expect(session.agent).toBe('claude')
expect(session.messages.length).toBeGreaterThan(0)
expect(session.messages.every((message) => message.source === 'scrape')).toBe(true)
})
it('builds an empty session from blank scrollback', () => {
const { session, isApproximate } = scrapeNativeChatSession(' \n ', 'claude')
expect(isApproximate).toBe(true)
expect(session.status).toBe('empty')
expect(session.messages).toEqual([])
})
})
@@ -0,0 +1,125 @@
// Degraded conversation source for panes with no on-disk transcript and no live
// agent-hook session id. We have nothing structured to work with — only the raw
// terminal scrollback — so we strip ANSI and best-effort segment it into coarse
// user/assistant turns. This is intentionally approximate: no per-agent TUI
// parsing happens here, and every produced message is marked `source:'scrape'`
// so the assembler ranks it below transcript/hook copies of the same turn. See
// docs/plans/2026-06-17-001-feat-native-chat-view-plan.md (U6).
import {
type AgentType,
type NativeChatMessage,
type NativeChatSession
} from '../../../../shared/native-chat-types'
import { assembleNativeChatSession } from './native-chat-session-assembler'
// Why: replicate (not import) the minimal ANSI/control-sequence strip used by
// agent-session-fork-context.ts so we don't modify that file. Same three
// patterns: CSI sequences, OSC sequences, and stray single-char escapes.
const ESC = String.fromCharCode(27)
const ANSI_ESCAPE_PATTERN = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
const OSC_SEQUENCE_PATTERN = new RegExp(`${ESC}\\][^\\u0007]*(?:\\u0007|${ESC}\\\\)`, 'g')
const SINGLE_ESCAPE_PATTERN = new RegExp(`${ESC}(?:[@-Z\\\\-_]|[()*+\\-./][0-~]|c)`, 'g')
function stripUnsupportedControlCharacters(value: string): string {
let result = ''
for (const char of value) {
const code = char.charCodeAt(0)
// Drop C0 control chars except tab (9) and newline (10); keep DEL (127) out.
if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) {
continue
}
result += char
}
return result
}
/** Strip ANSI/OSC escapes and normalize newlines so the raw scrollback reads as
* plain text. Pure; safe to reuse in tests. */
export function stripScrollbackAnsi(value: string): string {
return stripUnsupportedControlCharacters(
value
.replace(OSC_SEQUENCE_PATTERN, '')
.replace(ANSI_ESCAPE_PATTERN, '')
.replace(SINGLE_ESCAPE_PATTERN, '')
)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
}
// Why: a user prompt in a terminal almost always begins with a recognizable
// shell/agent prompt marker. We treat a segment whose first line starts with one
// of these as 'user'; everything else is assistant output. This is the
// documented role heuristic — coarse and deliberately conservative.
const USER_PROMPT_MARKERS = ['$', '%', '>', '#', '', '➜', '»']
function looksLikeUserPrompt(segment: string): boolean {
const firstLine = segment.split('\n', 1)[0]?.trimStart() ?? ''
if (firstLine.length === 0) {
return false
}
const firstChar = firstLine[0]
return USER_PROMPT_MARKERS.includes(firstChar)
}
/**
* Pure: strip ANSI from raw scrollback, then segment into coarse, ordered
* messages. Segmentation rule (intentionally approximate): split on runs of one
* or more blank lines — these are the most reliable visual turn boundary in a
* terminal without per-agent TUI parsing. Each non-empty segment becomes one
* message: role is best-effort via the prompt-marker heuristic, timestamp is
* null (scrollback carries no reliable wall-clock), source is always 'scrape',
* and the id is derived from the segment index so it's stable across re-scrapes.
*/
export function scrapeScrollbackToMessages(rawScrollback: string): NativeChatMessage[] {
const cleaned = stripScrollbackAnsi(rawScrollback)
if (cleaned.trim().length === 0) {
return []
}
const segments = cleaned
.split(/\n[ \t]*\n+/)
.map((segment) => segment.replace(/\s+$/g, '').replace(/^\n+/, ''))
.filter((segment) => segment.trim().length > 0)
return segments.map((segment, index) => ({
id: `scrape-${index}`,
role: looksLikeUserPrompt(segment) ? 'user' : 'assistant',
blocks: [{ type: 'text', text: segment }],
timestamp: null,
source: 'scrape'
}))
}
/** A scrape-derived session plus the always-true `isApproximate` flag the UI
* uses to render an "approximate view" banner. Scrape sessions can never be
* authoritative, so the flag is structural, not conditional. */
export type ScrapeNativeChatSession = {
session: NativeChatSession
isApproximate: true
}
/**
* Convenience that assembles a `NativeChatSession` from scrollback scrape.
* Status is the assembler's derived value: 'empty' for blank scrollback,
* 'ready' otherwise. `sessionId` is null because scrape has no provider id.
* Reuses `assembleNativeChatSession` read-only (no edits to the assembler).
*
* Remote/SSH: this entry takes an already-serialized scrollback string and is
* transport-agnostic. The caller obtains it via the runtime-appropriate API —
* `getMainBufferSnapshot`/serializer for local panes, or the remote serialize
* RPC (remote-runtime-terminal-multiplexer) for remote panes — so no remote
* branch is needed inside this fallback.
*/
export function scrapeNativeChatSession(
rawScrollback: string,
agent: AgentType
): ScrapeNativeChatSession {
const messages = scrapeScrollbackToMessages(rawScrollback)
const session = assembleNativeChatSession({
sources: { scrape: messages },
sessionId: null,
agent
})
return { session, isApproximate: true }
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import {
deriveNativeChatCanSend,
shouldChatTakeOverMobileSurface
} from './native-chat-send-eligibility'
describe('deriveNativeChatCanSend', () => {
it('blocks sends when a mobile client holds the pty (presence-lock active)', () => {
expect(deriveNativeChatCanSend({ kind: 'mobile', clientId: 'phone-1' })).toBe(false)
})
it('allows sends when the desktop drives the pty', () => {
expect(deriveNativeChatCanSend({ kind: 'desktop' })).toBe(true)
})
it('allows sends when the pty is idle', () => {
expect(deriveNativeChatCanSend({ kind: 'idle' })).toBe(true)
})
it('treats an unresolved driver (null/undefined) as unlocked', () => {
expect(deriveNativeChatCanSend(null)).toBe(true)
expect(deriveNativeChatCanSend(undefined)).toBe(true)
})
})
describe('shouldChatTakeOverMobileSurface', () => {
it('takes over the mobile surface when the tab is in chat view', () => {
expect(shouldChatTakeOverMobileSurface('chat')).toBe(true)
})
it('leaves the terminal mobile overlay in place in terminal view', () => {
expect(shouldChatTakeOverMobileSurface('terminal')).toBe(false)
})
})
@@ -0,0 +1,27 @@
import type { DriverState } from '@/lib/pane-manager/mobile-driver-state'
/**
* Pure derivation of the composer's `canSend` (R8). A pty held by a mobile
* client (`driver.kind === 'mobile'`) means the mobile presence-lock is active:
* the renderer already drops xterm input for that pty, so native-chat sends must
* be guarded identically rather than silently racing the mobile driver. Desktop
* and idle drivers leave the pty writable. A null driver (pty not yet resolved)
* is treated as unlocked so the composer stays usable while the lock state loads;
* the actual send still no-ops without a ptyId.
*/
export function deriveNativeChatCanSend(driver: DriverState | null | undefined): boolean {
return driver?.kind !== 'mobile'
}
/**
* Pure predicate for whether the native chat surface should take over the mobile
* driver surface for a pane. When a tab is in chat view, the chat view is the
* visible/active layer above the still-mounted terminal, so the terminal's own
* mobile-driver overlay (presence-lock banner / phone-fit hold) must not render
* on top of it — the composer's guarded `canSend` state communicates the lock
* inside the chat surface instead. Keeps the terminal mounted underneath either
* way (R2).
*/
export function shouldChatTakeOverMobileSurface(viewMode: 'terminal' | 'chat'): boolean {
return viewMode === 'chat'
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
buildNativeChatImagePasteBytes,
buildNativeChatPasteBytes,
buildNativeChatSendBytes,
isMultilineDraft,
NATIVE_CHAT_SUBMIT
} from './native-chat-send'
const BEGIN = '\x1b[200~'
const END = '\x1b[201~'
describe('NATIVE_CHAT_SUBMIT', () => {
it('is a bare carriage return so the Enter write is unambiguous', () => {
expect(NATIVE_CHAT_SUBMIT).toBe('\r')
})
})
describe('buildNativeChatPasteBytes', () => {
it('single-line text has no trailing submit (Enter is written separately)', () => {
expect(buildNativeChatPasteBytes('hello world')).toBe('hello world')
expect(buildNativeChatPasteBytes('hello world')).not.toContain('\r')
})
it('multi-line text is bracketed-paste wrapped with NO trailing submit', () => {
const text = 'line one\nline two'
expect(buildNativeChatPasteBytes(text)).toBe(`${BEGIN}${text}${END}`)
})
it('treats a trailing newline as multi-line', () => {
expect(buildNativeChatPasteBytes('a\n')).toBe(`${BEGIN}a\n${END}`)
})
it('sanitizes an embedded bracketed-paste end and bare ESC before framing', () => {
const malicious = 'before\nmid\x1b[201~ rm -rf /\x1b tail'
const bytes = buildNativeChatPasteBytes(malicious)
expect(bytes.startsWith(BEGIN)).toBe(true)
expect(bytes.endsWith(END)).toBe(true)
const inner = bytes.slice(BEGIN.length, bytes.length - END.length)
expect(inner).not.toContain('\x1b')
expect(inner).toContain('␛[201~')
})
it('neutralizes a stray ESC in the single-line branch', () => {
expect(buildNativeChatPasteBytes('hi\x1b there')).toBe('hi␛ there')
})
})
describe('buildNativeChatImagePasteBytes', () => {
it('always bracket-pastes the image path so agent TUIs attach it as an image', () => {
expect(buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png')).toBe(
`${BEGIN}/tmp/orca-paste-image.png${END}`
)
})
it('sanitizes embedded escape bytes before framing', () => {
expect(buildNativeChatImagePasteBytes('/tmp/before\x1b[201~after.png')).toBe(
`${BEGIN}/tmp/before␛[201~after.png${END}`
)
})
})
describe('buildNativeChatSendBytes', () => {
it('single-line text sends as text + carriage return', () => {
expect(buildNativeChatSendBytes('hello world')).toBe('hello world\r')
})
it('multi-line text is bracketed-paste wrapped then submitted', () => {
const text = 'line one\nline two'
expect(buildNativeChatSendBytes(text)).toBe(`${BEGIN}${text}${END}\r`)
})
it('treats a trailing newline as multi-line', () => {
expect(buildNativeChatSendBytes('a\n')).toBe(`${BEGIN}a\n${END}\r`)
})
it('handles CR-style line breaks as multi-line', () => {
expect(buildNativeChatSendBytes('a\rb')).toBe(`${BEGIN}a\rb${END}\r`)
})
it('sanitizes an embedded bracketed-paste end and bare ESC before framing', () => {
// A pasted scrollback line could carry its own `\x1b[201~` which would
// otherwise close the frame early and run the tail as keystrokes.
const malicious = 'before\nmid\x1b[201~ rm -rf /\x1b tail'
const bytes = buildNativeChatSendBytes(malicious)
// No raw ESC survives the sanitize, so the only `\x1b` bytes are the frame.
expect(bytes.startsWith(BEGIN)).toBe(true)
expect(bytes.endsWith(`${END}\r`)).toBe(true)
const inner = bytes.slice(BEGIN.length, bytes.length - END.length - 1)
expect(inner).not.toContain('\x1b')
expect(inner).toContain('␛[201~')
})
it('neutralizes a stray ESC in the single-line branch', () => {
const bytes = buildNativeChatSendBytes('hi\x1b there')
expect(bytes).toBe('hi␛ there\r')
expect(bytes).not.toContain('\x1b')
})
})
describe('isMultilineDraft', () => {
it('is false for single-line', () => {
expect(isMultilineDraft('one line')).toBe(false)
})
it('is true when a newline is present', () => {
expect(isMultilineDraft('a\nb')).toBe(true)
})
})
@@ -0,0 +1,66 @@
// Pure: turn raw composer text into the exact PTY bytes to write. Kept separate
// from the React composer so the byte rules are unit-testable without a DOM.
import { sanitizeBracketedPasteText } from '../terminal-pane/terminal-bracketed-paste'
// Why: bracketed-paste markers let modern agent TUIs (Claude / Codex / etc.)
// treat injected multi-line text as one atomic paste instead of running each
// embedded newline as a line-edit / submit. Mirrors agent-paste-draft.ts and
// terminal-bracketed-paste.ts so native input is byte-identical to a real paste.
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
// Why: carriage return (not \n) is what xterm/agent composers treat as the
// submit/Enter key over a PTY.
const SUBMIT = '\r'
/** True when the draft spans more than one line (so it needs bracketed-paste
* wrapping). A trailing newline alone still counts as multi-line. */
export function isMultilineDraft(text: string): boolean {
return /[\r\n]/.test(text)
}
/** The carriage-return submit byte, exported so send paths can write Enter as a
* SEPARATE pty write after the framed body (see buildNativeChatPasteBytes). */
export const NATIVE_CHAT_SUBMIT = SUBMIT
/**
* Compute the bytes for `text` WITHOUT the trailing submit:
* - single-line → `text`
* - multi-line → `\x1b[200~…\x1b[201~` (bracketed-paste wrapped, no submit)
*
* Why split the submit out: agent TUIs treat a framed paste that carries a
* trailing `\r` in the SAME pty write as part of the paste body rather than an
* Enter, so the text lands in the input box but never sends. Callers write this
* body first, then write `NATIVE_CHAT_SUBMIT` as a separate, slightly-delayed
* write (mirrors orca-runtime's writeTerminalAction Enter handling).
*/
export function buildNativeChatPasteBytes(text: string): string {
// Why: a stray ESC in the draft (e.g. pasted scrollback carrying its own
// `\x1b[201~`) would otherwise close the bracketed-paste frame early and run
// the tail as live keystrokes. Sanitize ESC on both branches before framing.
const safe = sanitizeBracketedPasteText(text)
if (isMultilineDraft(safe)) {
return `${BRACKETED_PASTE_BEGIN}${safe}${BRACKETED_PASTE_END}`
}
return safe
}
/** Image attachments must look like a real terminal image paste to Claude/Codex
* TUIs. A plain typed path (or @file mention) is treated as text/file-read. */
export function buildNativeChatImagePasteBytes(filePath: string): string {
return `${BRACKETED_PASTE_BEGIN}${sanitizeBracketedPasteText(filePath)}${BRACKETED_PASTE_END}`
}
/**
* Compute the bytes to write for `text` + Enter in ONE write:
* - single-line → `text\r`
* - multi-line → `\x1b[200~…\x1b[201~\r` (bracketed-paste wrapped, then submit)
*
* Prefer `buildNativeChatPasteBytes` + a separate `NATIVE_CHAT_SUBMIT` write for
* live sends; this combined form is kept for callers/tests that need the framed
* body and submit as a single string.
*/
export function buildNativeChatSendBytes(text: string): string {
return `${buildNativeChatPasteBytes(text)}${SUBMIT}`
}
@@ -0,0 +1,272 @@
import { describe, it, expect } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { assembleNativeChatSession } from './native-chat-session-assembler'
function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): NativeChatMessage {
return {
role: 'assistant',
blocks: [{ type: 'text', text: '' }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('assembleNativeChatSession', () => {
it('collapses the same turn from hook + transcript to one message, transcript wins', () => {
const hook = msg({
id: 'hook-1',
source: 'hook',
turnId: 't1',
blocks: [{ type: 'text', text: 'partial...' }],
timestamp: 100
})
const transcript = msg({
id: 'transcript-1',
source: 'transcript',
turnId: 't1',
blocks: [{ type: 'text', text: 'final answer' }],
timestamp: 100
})
const session = assembleNativeChatSession({
sources: { transcript: [transcript], hook: [hook] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages).toHaveLength(1)
expect(session.messages[0].source).toBe('transcript')
expect(session.messages[0].blocks).toEqual([{ type: 'text', text: 'final answer' }])
expect(session.status).toBe('ready')
})
it('sorts stably by timestamp then id for out-of-order appends', () => {
const a = msg({ id: 'b', timestamp: 200, blocks: [{ type: 'text', text: 'four' }] })
const b = msg({ id: 'a', timestamp: 100, blocks: [{ type: 'text', text: 'one' }] })
const c = msg({ id: 'a2', timestamp: 100, blocks: [{ type: 'text', text: 'three' }] })
const d = msg({ id: 'a1', timestamp: 100, blocks: [{ type: 'text', text: 'two' }] })
const session = assembleNativeChatSession({
sources: { transcript: [a, b, c, d] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages.map((m) => m.id)).toEqual(['a', 'a1', 'a2', 'b'])
})
it('drops a scrape message when a transcript message covers the same turn', () => {
const scrape = msg({
id: 'scrape-1',
source: 'scrape',
role: 'user',
blocks: [{ type: 'text', text: 'Run the tests' }],
timestamp: null
})
const transcript = msg({
id: 'transcript-1',
source: 'transcript',
role: 'user',
blocks: [{ type: 'text', text: 'run the tests' }],
timestamp: 50
})
const session = assembleNativeChatSession({
sources: { transcript: [transcript], scrape: [scrape] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages).toHaveLength(1)
expect(session.messages[0].source).toBe('transcript')
})
it('merges Claude image source marker records into the following user prompt', () => {
const imageSource = msg({
id: 'u-image-source',
role: 'user',
timestamp: 100,
blocks: [{ type: 'text', text: '[Image: source: /Users/me/Downloads/3d.png]' }]
})
const prompt = msg({
id: 'u-prompt',
role: 'user',
timestamp: 101,
blocks: [{ type: 'text', text: '[Image #1] what do you see' }]
})
const session = assembleNativeChatSession({
sources: { transcript: [imageSource, prompt] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages).toHaveLength(1)
expect(session.messages[0]).toMatchObject({ id: 'u-prompt', role: 'user' })
expect(session.messages[0].blocks).toEqual([
{ type: 'image-ref', path: '/Users/me/Downloads/3d.png' },
{ type: 'text', text: 'what do you see' }
])
})
it('drops a scrape duplicate even when scrape is processed first by id', () => {
const scrape = msg({
id: 'shared-id',
source: 'scrape',
turnId: 't9',
timestamp: 10
})
const transcript = msg({
id: 'shared-id',
source: 'transcript',
turnId: 't9',
timestamp: 10
})
const session = assembleNativeChatSession({
sources: { transcript: [transcript], scrape: [scrape] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages).toHaveLength(1)
expect(session.messages[0].source).toBe('transcript')
})
it('assembles an empty session to status empty without throwing', () => {
const session = assembleNativeChatSession({
sources: {},
sessionId: null,
agent: 'claude'
})
expect(session.messages).toEqual([])
expect(session.status).toBe('empty')
expect(session.sessionId).toBeNull()
})
it('honors an explicit status override', () => {
const session = assembleNativeChatSession({
sources: {},
sessionId: null,
agent: 'claude',
status: 'loading'
})
expect(session.status).toBe('loading')
})
it('keeps two distinct tool-call-only same-role messages (no turnId, no text)', () => {
const first = msg({
id: 'tc-1',
role: 'assistant',
timestamp: 100,
blocks: [{ type: 'tool-call', name: 'read', input: { path: 'a.txt' } }]
})
const second = msg({
id: 'tc-2',
role: 'assistant',
timestamp: 200,
blocks: [{ type: 'tool-call', name: 'read', input: { path: 'b.txt' } }]
})
const session = assembleNativeChatSession({
sources: { transcript: [first, second] },
sessionId: 's1',
agent: 'claude'
})
// Different tool inputs digest to different turn keys, so neither is dropped.
expect(session.messages.map((m) => m.id)).toEqual(['tc-1', 'tc-2'])
})
it('keeps two identical consecutive user prompts (distinct ids) — #10', () => {
// Same role, identical text, distinct ids: two genuinely distinct turns that
// happen to share a prompt. Same source (transcript), so the text fallback
// must NOT collapse them.
const first = msg({
id: 'u-1',
role: 'user',
timestamp: 100,
blocks: [{ type: 'text', text: 'run the tests' }]
})
const second = msg({
id: 'u-2',
role: 'user',
timestamp: 200,
blocks: [{ type: 'text', text: 'run the tests' }]
})
const session = assembleNativeChatSession({
sources: { transcript: [first, second] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages.map((m) => m.id)).toEqual(['u-1', 'u-2'])
})
it('keeps identical same-source prompts even at the SAME timestamp — #10', () => {
const first = msg({
id: 'u-1',
role: 'user',
timestamp: 100,
blocks: [{ type: 'text', text: 'go' }]
})
const second = msg({
id: 'u-2',
role: 'user',
timestamp: 100,
blocks: [{ type: 'text', text: 'go' }]
})
const session = assembleNativeChatSession({
sources: { transcript: [first, second] },
sessionId: 's1',
agent: 'claude'
})
// Same source, so the source-gate keeps both even though text+timestamp match.
expect(session.messages.map((m) => m.id).sort()).toEqual(['u-1', 'u-2'])
})
it('still collapses a cross-source same turn by text+timestamp, transcript wins', () => {
const hook = msg({
id: 'hook-1',
source: 'hook',
role: 'assistant',
timestamp: 100,
blocks: [{ type: 'text', text: 'the answer' }]
})
const transcript = msg({
id: 'transcript-1',
source: 'transcript',
role: 'assistant',
timestamp: 100,
blocks: [{ type: 'text', text: 'the answer' }]
})
const session = assembleNativeChatSession({
sources: { transcript: [transcript], hook: [hook] },
sessionId: 's1',
agent: 'claude'
})
expect(session.messages).toHaveLength(1)
expect(session.messages[0].source).toBe('transcript')
})
it('carries an error message when provided', () => {
const session = assembleNativeChatSession({
sources: {},
sessionId: null,
agent: 'claude',
status: 'error',
error: 'transcript unreadable'
})
expect(session.status).toBe('error')
expect(session.error).toBe('transcript unreadable')
})
})
@@ -0,0 +1,191 @@
import {
isTextBlock,
NATIVE_CHAT_SOURCE_PRIORITY,
type AgentType,
type NativeChatMessage,
type NativeChatSession,
type NativeChatSessionStatus
} from '../../../../shared/native-chat-types'
import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers'
/** Messages grouped by source. Higher-priority sources (transcript > hook >
* scrape) supersede lower ones when they describe the same turn. */
export type NativeChatSources = {
transcript?: NativeChatMessage[]
hook?: NativeChatMessage[]
scrape?: NativeChatMessage[]
}
export type AssembleNativeChatSessionInput = {
sources: NativeChatSources
sessionId: string | null
agent: AgentType
/** Overrides the derived status. The derived value is 'empty' when no
* messages survive merge, otherwise 'ready'. Callers pass 'loading',
* 'working', or 'error' when out-of-band signals apply. */
status?: NativeChatSessionStatus
error?: string
}
// Why: a turn can surface from several sources with different ids (a hook event
// and the transcript record for the same assistant reply rarely share an id).
// We dedup on an explicit `turnId` when present; otherwise fall back to
// role + normalized text so the same logical turn collapses to one message.
// Normalization lowercases and collapses whitespace so cosmetic ANSI/scrape
// differences don't defeat the match. The fallback only ever merges records of
// DIFFERENT sources (gated in mergeOne), so two identical SAME-source prompts
// stay distinct (#10). Timestamp is deliberately NOT folded into the key: a
// scrape copy of a turn often has a null timestamp while the transcript copy
// has a real one, and folding it would wrongly stop that legitimate
// cross-source pair from collapsing.
function turnKey(message: NativeChatMessage): string {
if (message.turnId) {
return `turn:${message.turnId}`
}
const text = message.blocks
.filter(isTextBlock)
.map((block) => block.text)
.join(' ')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim()
// Why: two same-role messages with no turnId and no text (e.g. distinct
// tool-call-only turns) would otherwise share `${role}:` and the second would
// be dropped. Fold a digest of the non-text blocks (tool name+input, result
// output) into the key so different tool turns stay distinct.
return `${message.role}:${text}:${nonTextBlockDigest(message)}`
}
function nonTextBlockDigest(message: NativeChatMessage): string {
const parts: string[] = []
for (const block of message.blocks) {
if (block.type === 'tool-call') {
parts.push(`call:${block.name}:${stableStringify(block.input)}`)
} else if (block.type === 'tool-result') {
parts.push(`result:${block.output}`)
} else if (block.type === 'image-ref') {
parts.push(`image:${block.path ?? block.url ?? block.alt ?? ''}`)
}
}
return parts.join('|')
}
function stableStringify(value: unknown): string {
try {
return typeof value === 'string' ? value : JSON.stringify(value)
} catch {
return String(value)
}
}
function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): boolean {
const candidateRank = NATIVE_CHAT_SOURCE_PRIORITY[candidate.source]
const existingRank = NATIVE_CHAT_SOURCE_PRIORITY[existing.source]
return candidateRank > existingRank
}
// Why: null timestamps (sources that can't supply one, e.g. scrape segments)
// sort before any real timestamp so they don't jump to the end. Ties break on
// id for a stable, deterministic order.
export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number {
const at = a.timestamp ?? Number.NEGATIVE_INFINITY
const bt = b.timestamp ?? Number.NEGATIVE_INFINITY
if (at !== bt) {
return at - bt
}
if (a.id < b.id) {
return -1
}
if (a.id > b.id) {
return 1
}
return 0
}
/**
* Pure merge of layered conversation sources into a single ordered, deduped
* `NativeChatSession`. Precedence: transcript > hook > scrape. Dedup happens on
* message id and on turn key (explicit turnId, else role + normalized text), so
* the same turn from multiple sources collapses to the highest-priority copy.
*/
export function assembleNativeChatSession(
input: AssembleNativeChatSessionInput
): NativeChatSession {
const { sources, sessionId, agent, status, error } = input
// Process highest priority first so a later, lower-priority duplicate is
// dropped rather than overwriting. Within a source, order is preserved.
const ordered: NativeChatMessage[] = [
...normalizeImageTranscriptMessages(sources.transcript ?? []),
...(sources.hook ?? []),
...(sources.scrape ?? [])
]
const byId = new Map<string, NativeChatMessage>()
const byTurn = new Map<string, NativeChatMessage>()
for (const message of ordered) {
mergeOne(byId, byTurn, message)
}
const messages = Array.from(byId.values()).sort(compareMessages)
const derivedStatus: NativeChatSessionStatus = messages.length === 0 ? 'empty' : 'ready'
return {
messages,
status: status ?? derivedStatus,
sessionId,
agent,
...(error ? { error } : {})
}
}
/**
* The single per-message merge rule, shared by the full rebuild and the
* incremental assembler so there is exactly one copy of the dedup logic.
* Dedups by `id`, then by `turnKey` — but the turnKey fallback only merges a
* candidate against an existing message of a DIFFERENT source (#10): the text
* fallback exists for cross-source dedup, so two distinct same-source records
* with identical text must never collapse. The explicit-`turnId` path is
* cross-source identity and is unaffected (it never collides within a source).
*/
export function mergeOne(
byId: Map<string, NativeChatMessage>,
byTurn: Map<string, NativeChatMessage>,
message: NativeChatMessage
): void {
const existingById = byId.get(message.id)
if (existingById) {
if (supersedes(message, existingById)) {
replace(byId, byTurn, existingById, message)
}
return
}
const key = turnKey(message)
const existingByTurn = byTurn.get(key)
if (existingByTurn && existingByTurn.source !== message.source) {
if (supersedes(message, existingByTurn)) {
replace(byId, byTurn, existingByTurn, message)
}
return
}
// No id match and no cross-source turn match: a distinct record. Indexing it
// under its turnKey may overwrite a same-source entry that shares the key —
// that's fine, the turn index only needs one representative per key for the
// cross-source pass; both distinct records still live in `byId`.
byId.set(message.id, message)
byTurn.set(key, message)
}
function replace(
byId: Map<string, NativeChatMessage>,
byTurn: Map<string, NativeChatMessage>,
old: NativeChatMessage,
next: NativeChatMessage
): void {
byId.delete(old.id)
byTurn.delete(turnKey(old))
byId.set(next.id, next)
byTurn.set(turnKey(next), next)
}
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import {
matchesNativeChatToggleShortcut,
nativeChatToggleShortcutLabel
} from './native-chat-shortcut'
type Combo = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>
function combo(overrides: Partial<Combo>): Combo {
return { key: 'j', metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...overrides }
}
describe('nativeChatToggleShortcutLabel', () => {
it('uses Cmd/Shift glyphs on Mac', () => {
expect(nativeChatToggleShortcutLabel(true)).toBe('⌘⇧J')
})
it('uses Ctrl+/Shift+ text elsewhere', () => {
expect(nativeChatToggleShortcutLabel(false)).toBe('Ctrl+Shift+J')
})
})
describe('matchesNativeChatToggleShortcut', () => {
it('matches Cmd+Shift+J on Mac', () => {
expect(matchesNativeChatToggleShortcut(combo({ metaKey: true, shiftKey: true }), true)).toBe(
true
)
})
it('does not match Ctrl+Shift+J on Mac (wrong primary modifier)', () => {
expect(matchesNativeChatToggleShortcut(combo({ ctrlKey: true, shiftKey: true }), true)).toBe(
false
)
})
it('matches Ctrl+Shift+J on Windows/Linux', () => {
expect(matchesNativeChatToggleShortcut(combo({ ctrlKey: true, shiftKey: true }), false)).toBe(
true
)
})
it('requires the shift modifier', () => {
expect(matchesNativeChatToggleShortcut(combo({ metaKey: true }), true)).toBe(false)
})
it('rejects when alt is held', () => {
expect(
matchesNativeChatToggleShortcut(combo({ metaKey: true, shiftKey: true, altKey: true }), true)
).toBe(false)
})
it('rejects a different key', () => {
expect(
matchesNativeChatToggleShortcut(combo({ key: 'k', metaKey: true, shiftKey: true }), true)
).toBe(false)
})
})
@@ -0,0 +1,33 @@
/** Platform-correct binding for the native-chat view toggle.
*
* Key: Cmd/Ctrl + Shift + J. The primary modifier follows AGENTS.md — metaKey
* on Mac, ctrlKey elsewhere — and the displayed label uses `⌘`/`⇧` on Mac and
* `Ctrl+`/`Shift+` on Linux/Windows.
*/
export function isMacPlatform(): boolean {
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
}
/** Human-readable label for the toggle shortcut, platform-correct. */
export function nativeChatToggleShortcutLabel(isMac: boolean): string {
return isMac ? '⌘⇧J' : 'Ctrl+Shift+J'
}
/** True when the event is the native-chat toggle chord for the given platform.
* Pure so it can be unit-tested without a DOM. */
export function matchesNativeChatToggleShortcut(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>,
isMac: boolean
): boolean {
if (e.altKey || !e.shiftKey) {
return false
}
// Primary modifier is Cmd on Mac, Ctrl on Linux/Windows — and must be the
// *only* primary modifier so this can't collide with Cmd+Ctrl chords.
const primary = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
if (!primary) {
return false
}
return e.key.toLowerCase() === 'j'
}
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { resolveNativeChatSession } from './native-chat-pane-resolution'
import { findTabAgentEntry } from './native-chat-tab-agent-entry'
function entry(
overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>
): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
stateHistory: [],
...overrides
}
}
/**
* #19 guard: the narrowed `useShallow(findTabAgentEntry(...))` selector must
* resolve to exactly the same pane entry — and thus the same
* resolveNativeChatSession result — as the old whole-map scan. These tests lock
* the selector's resolution semantics so the perf narrowing can't drift.
*/
describe('findTabAgentEntry (#19 selector)', () => {
it('returns the entry whose paneKey carries the tab id prefix', () => {
const target = entry({ paneKey: 'tab-1:leaf-a', agentType: 'claude' })
const map: Record<string, AgentStatusEntry> = {
'tab-0:leaf-z': entry({ paneKey: 'tab-0:leaf-z' }),
'tab-1:leaf-a': target,
'tab-2:leaf-b': entry({ paneKey: 'tab-2:leaf-b' })
}
expect(findTabAgentEntry(map, 'tab-1')).toBe(target)
})
it('returns undefined when no pane matches the tab id', () => {
const map: Record<string, AgentStatusEntry> = {
'tab-0:leaf-z': entry({ paneKey: 'tab-0:leaf-z' })
}
expect(findTabAgentEntry(map, 'tab-1')).toBeUndefined()
})
it('returns the first matching pane (deterministic insertion order)', () => {
const first = entry({ paneKey: 'tab-1:leaf-a' })
const second = entry({ paneKey: 'tab-1:leaf-b' })
const map: Record<string, AgentStatusEntry> = {
'tab-1:leaf-a': first,
'tab-1:leaf-b': second
}
expect(findTabAgentEntry(map, 'tab-1')).toBe(first)
})
it('does not match a tab id that is only a substring of another tab id', () => {
const map: Record<string, AgentStatusEntry> = {
'tab-10:leaf-a': entry({ paneKey: 'tab-10:leaf-a' })
}
// `tab-1:` prefix must not match `tab-10:` — the colon delimiter guards this.
expect(findTabAgentEntry(map, 'tab-1')).toBeUndefined()
})
it('resolves identically to the whole-map scan, including the empty-tabid fallback', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const target = entry({
paneKey,
agentType: 'claude',
providerSession: { key: 'session_id', id: 'sess-abc' }
})
const map: Record<string, AgentStatusEntry> = {
'tab-0:other': entry({ paneKey: 'tab-0:other', agentType: 'codex' }),
[paneKey]: target
}
// Old path: scan whole map, then fall back to `${tabId}:` when absent.
const oldEntry = findTabAgentEntry(map, 'tab-1')
const oldResolution = resolveNativeChatSession({
paneKey: oldEntry?.paneKey ?? 'tab-1:',
launchAgent: 'claude',
...(oldEntry ? { agentStatusEntry: oldEntry } : {}),
ptyId: null
})
// New path: narrowed selector returns the same entry; same resolution.
const newEntry = findTabAgentEntry(map, 'tab-1')
const newResolution = resolveNativeChatSession({
paneKey: newEntry?.paneKey ?? 'tab-1:',
launchAgent: 'claude',
...(newEntry ? { agentStatusEntry: newEntry } : {}),
ptyId: null
})
expect(newEntry).toBe(oldEntry)
expect(newResolution).toEqual(oldResolution)
})
it('falls back to `${terminalTabId}:` paneKey when the tab has no entry', () => {
const map: Record<string, AgentStatusEntry> = {}
const found = findTabAgentEntry(map, 'tab-9')
const paneKey = found?.paneKey ?? 'tab-9:'
expect(found).toBeUndefined()
expect(paneKey).toBe('tab-9:')
})
})
@@ -0,0 +1,21 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
/** Pick the live agent-status entry for this tab. A tab's panes are keyed
* `${tabId}:${leafId}`; the single active agent pane is the one whose paneKey
* carries this tab id. (Split-aware resolution refines per-leaf in U8/U9; the
* view today resolves the tab's agent pane.)
*
* Lives in its own module so the #19 selector (`useShallow(findTabAgentEntry)`)
* is unit-testable without importing the store-coupled view component. */
export function findTabAgentEntry(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
terminalTabId: string
): AgentStatusEntry | undefined {
const prefix = `${terminalTabId}:`
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (paneKey.startsWith(prefix)) {
return entry
}
}
return undefined
}
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { foldToolMessages, splitNativeChatBlocks } from './native-chat-tool-fold'
function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
): NativeChatMessage {
return {
role: 'assistant',
blocks: [],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('foldToolMessages', () => {
it('merges a tool-only message into the preceding assistant turn', () => {
const folded = foldToolMessages([
msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'running it' }] }),
msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'done' }] })
])
expect(folded).toHaveLength(1)
expect(folded[0]?.id).toBe('a')
expect(folded[0]?.blocks).toEqual([
{ type: 'text', text: 'running it' },
{ type: 'tool-result', output: 'done' }
])
})
it('merges a chain of tool-only assistant + tool messages into one turn', () => {
const folded = foldToolMessages([
msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'go' }] }),
msg({ id: 'c', role: 'assistant', blocks: [{ type: 'tool-call', name: 'Bash', input: {} }] }),
msg({ id: 'r', role: 'tool', blocks: [{ type: 'tool-result', output: 'ok' }] })
])
expect(folded).toHaveLength(1)
expect(folded[0]?.blocks).toHaveLength(3)
})
it('leaves an orphan tool message standalone when no assistant precedes it', () => {
const folded = foldToolMessages([
msg({ id: 'u', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }),
msg({ id: 't', role: 'tool', blocks: [{ type: 'tool-result', output: 'x' }] })
])
expect(folded.map((m) => m.id)).toEqual(['u', 't'])
})
it('does not fold a message carrying prose alongside a tool block', () => {
const folded = foldToolMessages([
msg({ id: 'a', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }),
msg({
id: 'b',
role: 'assistant',
blocks: [
{ type: 'text', text: 'more' },
{ type: 'tool-call', name: 'Read', input: {} }
]
})
])
expect(folded.map((m) => m.id)).toEqual(['a', 'b'])
})
})
describe('splitNativeChatBlocks', () => {
it('separates prose from tool blocks', () => {
const { prose, tools } = splitNativeChatBlocks([
{ type: 'text', text: 'hi' },
{ type: 'tool-call', name: 'Bash', input: {} },
{ type: 'tool-result', output: 'ok' },
{ type: 'image-ref', path: '/x.png' }
])
expect(prose.map((b) => b.type)).toEqual(['text', 'image-ref'])
expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result'])
})
})
@@ -0,0 +1,58 @@
// Pure folding logic for the native chat tool runs. Claude emits each tool call
// as its own assistant message and each result as a tool-role message; folding
// every tool-only message into the preceding assistant turn lets the view
// collapse a whole turn's tool activity under one "N tool calls" line. Kept out
// of the .tsx so the merge/split rules are unit-testable without rendering.
// Ported from the mobile foldToolMessages/splitNativeChatBlocks (mobile parity).
import {
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
/** True when a message carries nothing but tool calls/results — the shape Claude
* emits for the tool half of a turn. */
function isToolOnlyMessage(message: NativeChatMessage): boolean {
return (
message.blocks.length > 0 &&
message.blocks.every((block) => isToolCallBlock(block) || isToolResultBlock(block))
)
}
/** Fold a turn's tool activity into the assistant message it belongs to, so the
* view can collapse a whole turn's tools under one line. A tool-only message is
* merged into the preceding assistant turn when one exists; otherwise it stands
* on its own (e.g. an orphan result with no preceding assistant prose). */
export function foldToolMessages(messages: readonly NativeChatMessage[]): NativeChatMessage[] {
const out: NativeChatMessage[] = []
for (const message of messages) {
const prev = out.at(-1)
if (isToolOnlyMessage(message) && prev && prev.role === 'assistant') {
out[out.length - 1] = { ...prev, blocks: [...prev.blocks, ...message.blocks] }
} else {
out.push(message)
}
}
return out
}
/** Split a message's blocks into prose (text/image) and tool (call/result), so
* the view renders the agent's words first and folds the tool activity into a
* separate collapsible run beneath it. */
export function splitNativeChatBlocks(blocks: readonly NativeChatBlock[]): {
prose: NativeChatBlock[]
tools: NativeChatBlock[]
} {
const prose: NativeChatBlock[] = []
const tools: NativeChatBlock[] = []
for (const block of blocks) {
if (isToolCallBlock(block) || isToolResultBlock(block)) {
tools.push(block)
} else {
prose.push(block)
}
}
return { prose, tools }
}

Some files were not shown because too many files have changed in this diff Show More