Files
orca/src/cli/format.ts
T
e623372cdb feat(cli): add tab profile controls and automation primitives (#1396)
* feat(cli): add browser tab profile controls

* feat(cli): add tab profile automation primitives

* refactor(cli): narrow tab profile automation scope

* chore: retrigger PR checks

* review: harden tab profile automation CLI

- Wait for tab re-registration after browser.tabSetProfile so a follow-up tab list --show-profile reads the new sessionProfileId from BrowserManager instead of the stale one from the previous webview
- Wait for tab registration after browser.tabProfileClone, matching browser.tabCreate, so the cloned browserPageId is operable when the CLI returns
- Short-circuit browser.tabSetProfile when the tab is already on the requested profile so we do not tear down and remount the webview for a no-op switch
- Switch TabShow.worktree from OptionalPlainString to OptionalString to match every other tab schema; empty --worktree should fall back to the active worktree, not pass through as the empty string
- Add max-lines disable to browser.test.ts (file grew past 300 lines after adding the new tab-profile and tab-show tests)

* review: fix useIpcEvents test setup for tab profile API

CI failure: useIpcEvents.test.ts threw at module load with TypeError: window.addEventListener is not a function. The chain: the rebased useIpcEvents.ts imports destroyPersistentWebview from webview-registry, which calls window.addEventListener at module load. The test stubs window via vi.stubGlobal as a plain object without addEventListener, so the typeof window check passes but the call throws.

- webview-registry.ts: tighten the module-load guard to also check that window.addEventListener is callable, so importing this module from a non-DOM-ish test env (vitest node env with stubbed window) does not throw at module load
- useIpcEvents.test.ts: add the new onRequestTabSetProfile and replyTabSetProfile stubs to all 8 window.api.ui mocks so the new IPC subscription registered by useIpcEvents resolves

* review: restore profile CRUD lost during rebase onto 1397-merged main

The rebase brought commit 3242aa27 (refactor: narrow tab profile automation scope) onto a main that already had the lifecycle CRUD from 1397. The refactor commit removes BrowserProfileList/Create/Delete types, runtime methods, RPC registrations and schemas, plus the help/specs entries, because those were the precursor versions in commit 1 of this branch. Post-rebase those removals land on the hardened versions inherited from main, breaking 1397.

Restore:
- runtime-types.ts: BrowserSessionProfile import; ProfileList/Create/Delete result types
- orca-runtime.ts: ProfileList/Create/Delete result type imports; browserProfileList/Create/Delete methods
- browser-core.ts: ProfileCreate, ProfileDelete schema imports; browser.profileList/profileCreate/profileDelete RPC registrations
- browser-schemas.ts: ProfileCreate, ProfileDelete zod schemas
- help.ts: list/create/delete subcommand lines under Browser Automation
- specs/browser-basic.ts: list/create/delete spec entries

---------

Co-authored-by: Nikolatesla-lj <Nikolatesla-lj@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-05-04 23:46:23 -07:00

301 lines
10 KiB
TypeScript

import type {
BrowserProfileListResult,
BrowserTabCurrentResult,
BrowserScreenshotResult,
BrowserSnapshotResult,
BrowserTabListResult,
BrowserTabProfileCloneResult,
BrowserTabProfileShowResult,
BrowserTabShowResult,
CliStatusResult,
RuntimeRepoList,
RuntimeRepoSearchRefs,
RuntimeTerminalClose,
RuntimeTerminalCreate,
RuntimeTerminalFocus,
RuntimeTerminalListResult,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait,
RuntimeWorktreeListResult,
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
export function printResult<TResult>(
response: RuntimeRpcSuccess<TResult>,
json: boolean,
formatter: (value: TResult) => string
): void {
if (json) {
console.log(JSON.stringify(response, null, 2))
return
}
console.log(formatter(response.result))
}
export function formatCliError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
if (
error instanceof RuntimeRpcFailureError &&
error.response.error.code === 'runtime_unavailable'
) {
return `${message}\nOrca is not running. Run 'orca open' first.`
}
return message
}
export function reportCliError(error: unknown, json: boolean): void {
if (json) {
if (error instanceof RuntimeRpcFailureError) {
console.log(JSON.stringify(error.response, null, 2))
} else {
const response: RuntimeRpcFailure = {
id: 'local',
ok: false,
error: {
code: error instanceof RuntimeClientError ? error.code : 'runtime_error',
message: formatCliError(error)
},
_meta: {
runtimeId: null
}
}
console.log(JSON.stringify(response, null, 2))
}
} else {
console.error(formatCliError(error))
}
}
export function formatCliStatus(status: CliStatusResult): string {
return [
`appRunning: ${status.app.running}`,
`pid: ${status.app.pid ?? 'none'}`,
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
].join('\n')
}
export function formatStatus(status: CliStatusResult): string {
return formatCliStatus(status)
}
export function formatTerminalList(result: RuntimeTerminalListResult): string {
if (result.terminals.length === 0) {
return 'No live terminals.'
}
const body = result.terminals
.map(
(terminal) =>
`${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: <empty>'}`
)
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.terminals.length} of ${result.totalCount}`
: body
}
export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): string {
const terminal = result.terminal
return [
`handle: ${terminal.handle}`,
`title: ${terminal.title ?? '(untitled)'}`,
`worktree: ${terminal.worktreePath}`,
`branch: ${terminal.branch}`,
`leaf: ${terminal.leafId}`,
`ptyId: ${terminal.ptyId ?? 'none'}`,
`connected: ${terminal.connected}`,
`writable: ${terminal.writable}`,
`preview: ${terminal.preview || '<empty>'}`
].join('\n')
}
export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string {
const terminal = result.terminal
const header = [
`handle: ${terminal.handle}`,
`status: ${terminal.status}`,
...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : [])
]
return [...header, '', ...terminal.tail].join('\n')
}
export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
}
export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string {
return result.rename.title
? `Renamed terminal ${result.rename.handle} to "${result.rename.title}".`
: `Cleared title for terminal ${result.rename.handle}.`
}
export function formatTerminalCreate(result: { terminal: RuntimeTerminalCreate }): string {
const titleNote = result.terminal.title ? ` (title: "${result.terminal.title}")` : ''
return `Created terminal ${result.terminal.handle}${titleNote}`
}
export function formatTerminalSplit(result: { split: RuntimeTerminalSplit }): string {
return `Split pane ${result.split.handle} in tab ${result.split.tabId}`
}
export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): string {
return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).`
}
export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string {
const ptyNote = result.close.ptyKilled ? ' PTY killed.' : ''
return `Closed terminal ${result.close.handle}.${ptyNote}`
}
export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
return [
`handle: ${result.wait.handle}`,
`condition: ${result.wait.condition}`,
`satisfied: ${result.wait.satisfied}`,
`status: ${result.wait.status}`,
`exitCode: ${result.wait.exitCode ?? 'null'}`
].join('\n')
}
export function formatWorktreePs(result: RuntimeWorktreePsResult): string {
if (result.worktrees.length === 0) {
return 'No worktrees found.'
}
const body = result.worktrees
.map(
(worktree) =>
`${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}`
)
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
: body
}
export function formatRepoList(result: RuntimeRepoList): string {
if (result.repos.length === 0) {
return 'No repos found.'
}
return result.repos.map((repo) => `${repo.id} ${repo.displayName} ${repo.path}`).join('\n')
}
export function formatRepoShow(result: { repo: Record<string, unknown> }): string {
return Object.entries(result.repo)
.map(
([key, value]) =>
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
)
.join('\n')
}
export function formatRepoRefs(result: RuntimeRepoSearchRefs): string {
if (result.refs.length === 0) {
return 'No refs found.'
}
return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n')
}
export function formatWorktreeList(result: RuntimeWorktreeListResult): string {
if (result.worktrees.length === 0) {
return 'No worktrees found.'
}
const body = result.worktrees
.map(
(worktree) =>
`${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
)
.join('\n\n')
return result.truncated
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
: body
}
export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string {
const worktree = result.worktree
return Object.entries(worktree)
.map(
([key, value]) =>
`${key}: ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`
)
.join('\n')
}
export function formatSnapshot(result: BrowserSnapshotResult): string {
const header = `page: ${result.browserPageId}\n${result.title} — ${result.url}\n`
return header + result.snapshot
}
export function formatScreenshot(result: BrowserScreenshotResult): string {
return `Screenshot captured (${result.format}, ${Math.round(result.data.length * 0.75)} bytes)`
}
export function formatTabList(result: BrowserTabListResult): string {
return formatTabListWithProfiles(result, false)
}
export function formatTabListWithProfiles(
result: BrowserTabListResult,
showProfile: boolean
): string {
if (result.tabs.length === 0) {
return 'No browser tabs open.'
}
return result.tabs
.map((t) => {
const marker = t.active ? '* ' : ' '
const profile = showProfile ? ` [${t.profileLabel ?? t.profileId ?? 'Unknown'}]` : ''
return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}${profile}`
})
.join('\n')
}
export function formatBrowserProfileList(result: BrowserProfileListResult): string {
if (result.profiles.length === 0) {
return 'No browser profiles found.'
}
return result.profiles
.map((profile) => {
const marker = profile.scope === 'default' ? '* ' : ' '
const source = profile.source?.browserFamily ?? 'none'
return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}`
})
.join('\n')
}
export function formatTabShow(result: BrowserTabShowResult | BrowserTabCurrentResult): string {
const tab = result.tab
return [
`page: ${tab.browserPageId}`,
`title: ${tab.title}`,
`url: ${tab.url}`,
`active: ${tab.active}`,
`worktree: ${tab.worktreeId ?? 'unknown'}`,
`profile: ${tab.profileLabel ?? tab.profileId ?? 'unknown'}`
].join('\n')
}
export function formatTabProfileShow(result: BrowserTabProfileShowResult): string {
return [
`page: ${result.browserPageId}`,
`worktree: ${result.worktreeId ?? 'unknown'}`,
`profileId: ${result.profileId ?? 'default'}`,
`profile: ${result.profileLabel ?? result.profileId ?? 'default'}`
].join('\n')
}
export function formatTabProfileClone(result: BrowserTabProfileCloneResult): string {
return `Cloned ${result.sourceBrowserPageId} to ${result.browserPageId} (${result.profileLabel ?? result.profileId ?? 'default'})`
}