mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
* 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>
301 lines
10 KiB
TypeScript
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'})`
|
|
}
|