diff --git a/server/index.js b/server/index.js index 3208943..0766706 100755 --- a/server/index.js +++ b/server/index.js @@ -63,10 +63,6 @@ import { spawnOmp, abortOmpSession, } from './omp-cli.js'; -import { - spawnOmo, - abortOmoSession, -} from './omo-cli.js'; import { stripAnsiSequences, normalizeDetectedUrl, @@ -174,7 +170,14 @@ const wss = createWebSocketServer(server, { opencode: spawnOpenCode, gjc: spawnGjc, omp: spawnOmp, - omo: spawnOmo, + // omo is intentionally NOT registered. Its transcripts are indexed + // like any other session, so a session that is currently live in a + // tmux pane can also be opened here; spawning would start a second + // headless omo on the same --session-id and both processes would + // append to one transcript. Observed: a full turn landed in a live + // session that the running agent never saw. Live sessions must go + // through the tmux relay. Re-enable only behind a guard that + // refuses to spawn when the session is live. }, abortFns: { claude: abortClaudeSDKSession, @@ -183,7 +186,6 @@ const wss = createWebSocketServer(server, { opencode: abortOpenCodeSession, gjc: abortGjcSession, omp: abortOmpSession, - omo: abortOmoSession, }, resolveToolApproval: resolveProviderToolApproval, getPendingApprovalsForSession: getPendingProviderApprovalsForSession, diff --git a/server/modules/database/services/completion-target-identity.service.ts b/server/modules/database/services/completion-target-identity.service.ts index 1284b33..4a0e011 100644 --- a/server/modules/database/services/completion-target-identity.service.ts +++ b/server/modules/database/services/completion-target-identity.service.ts @@ -4,7 +4,7 @@ import type { ExternalCliSession } from '@/modules/providers/index.js'; const IDENTITY_VERSION = 'completion-target/v1'; const APP_ALIAS_PREFIX = 'ct_'; -const EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'opencode', 'omp']); +const EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'opencode', 'omp', 'omo']); export type CompletionAppIdentity = Readonly<{ provider: string; diff --git a/server/modules/notifications/services/external-turn-monitor.service.ts b/server/modules/notifications/services/external-turn-monitor.service.ts index 485e1e0..5fa5f63 100644 --- a/server/modules/notifications/services/external-turn-monitor.service.ts +++ b/server/modules/notifications/services/external-turn-monitor.service.ts @@ -34,7 +34,7 @@ type TerminalCompletionDecisionResult = ReturnType[2]; const DEFAULT_INTERVAL_MS = TURN_MONITOR_FALLBACK_MS; -const EVENT_DRIVEN_EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'omp', 'opencode']); +const EVENT_DRIVEN_EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'omp', 'omo', 'opencode']); type ResolvedActivity = Extract; type MonitorResolvedActivity = ResolvedActivity & { @@ -174,10 +174,12 @@ function completionPayload( const title = typeof session.tmuxName === 'string' && session.tmuxName.trim() ? session.tmuxName.trim() : 'ChatMux'; - const label = session.kind === 'omp' ? 'Oh My Pi' : ({ + const label = ({ claude: 'Claude', codex: 'Codex', opencode: 'OpenCode', + omp: 'Oh My Pi', + omo: 'omo', } as Record)[session.kind] ?? 'Assistant'; return { title, diff --git a/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts b/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts index 34fa1ba..5ade1b0 100644 --- a/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts +++ b/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts @@ -75,6 +75,24 @@ function extractGjcTextFromContent(content: unknown): string { * live on the top-level header line (`{"type":"session","id":..,"cwd":..}`), * unlike Codex which nests them under `payload`. */ +/** + * Per-provider home directories. These are exhaustive Records rather than + * `provider === 'x' ? … : …` on purpose: a ternary silently routes any new + * union member into its else branch, which is exactly how omo ended up + * scanning gjc's transcripts. A Record makes adding a provider a type error. + */ +export const PI_AGENT_ROOT_DIRS: Record = { + gjc: path.join(os.homedir(), '.gjc', 'agent'), + omp: path.join(os.homedir(), '.omp', 'agent'), + omo: path.join(os.homedir(), '.omo', 'agent'), +}; + +const PI_UNTITLED_SESSION_TITLES: Record = { + gjc: 'Untitled gjc Session', + omp: 'Untitled Oh My Pi Session', + omo: 'Untitled omo Session', +}; + export class GjcSessionSynchronizer implements IProviderSessionSynchronizer { private readonly provider: PiTranscriptProvider; private readonly sessionRoots: string[]; @@ -84,9 +102,7 @@ export class GjcSessionSynchronizer implements IProviderSessionSynchronizer { constructor(options: PiSessionSynchronizerOptions = {}) { this.provider = options.provider ?? 'gjc'; - const agentRoot = this.provider === 'omp' - ? path.join(os.homedir(), '.omp', 'agent') - : path.join(os.homedir(), '.gjc', 'agent'); + const agentRoot = PI_AGENT_ROOT_DIRS[this.provider]; const defaultAdditionalRoots = this.provider === 'gjc' ? [process.env.GJC_LIVE_SESSION_DIR || path.join(os.tmpdir(), 'gjc-live-sessions')] : []; @@ -94,9 +110,7 @@ export class GjcSessionSynchronizer implements IProviderSessionSynchronizer { options.sessionsDir ?? path.join(agentRoot, 'sessions'), ...(options.additionalSessionDirs ?? defaultAdditionalRoots), ])]; - this.untitledSession = this.provider === 'omp' - ? 'Untitled Oh My Pi Session' - : 'Untitled gjc Session'; + this.untitledSession = PI_UNTITLED_SESSION_TITLES[this.provider]; this.initialScanDoneKey = `${this.provider}_initial_scan_done`; this.pendingSessionFilesKey = `${this.provider}_pending_session_files`; } diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index bba91a8..9599bbc 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -959,7 +959,7 @@ router.post( statusCode: 400, }); } - const supportedClis: ExternalSpawnCli[] = ['claude', 'codex', 'cursor', 'opencode', 'omp']; + const supportedClis: ExternalSpawnCli[] = ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo']; if (body.cli !== undefined && !supportedClis.includes(body.cli as ExternalSpawnCli)) { throw new AppError(`cli must be one of: ${supportedClis.join(', ')}.`, { code: 'INVALID_CLI', diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 09a2e84..5992bcc 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -113,9 +113,10 @@ const PROVIDER_CAPABILITIES: Record = { provider: 'omo', permissionModes: ['default'], defaultPermissionMode: 'default', - // Mirrors the Oh My Pi posture: buildPiCliArgs forwards image paths as - // @ and createPiCliRuntime terminates the tracked child with SIGTERM - // (`server/pi-cli.ts`), but this matrix still hides both UI controls. + // omo is discovery, transcript, and tmux relay only. `server/omo-cli.ts` + // implements the send runtime, but it is deliberately not registered in + // `spawnFns` (see the comment there): spawning would put a second headless + // omo on a session that may already be live in a tmux pane. supportsImages: false, supportsAbort: false, // omo's TUI renders "↑↓ navigate • enter select • esc close" and has no diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 359d5e1..ece753a 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -36,6 +36,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'omp', rootPath: path.join(os.homedir(), '.omp', 'agent', 'sessions'), }, + { + provider: 'omo', + rootPath: path.join(os.homedir(), '.omo', 'agent', 'sessions'), + }, ]; const GJC_TERMINAL_RECEIPT_ROOT = path.join(os.homedir(), '.gjc', 'agent', 'terminal-sessions'); diff --git a/server/modules/providers/tests/pi-transcript-roots.test.ts b/server/modules/providers/tests/pi-transcript-roots.test.ts new file mode 100644 index 0000000..9cd7a1c --- /dev/null +++ b/server/modules/providers/tests/pi-transcript-roots.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { PI_AGENT_ROOT_DIRS } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js'; + +/** + * Regression lock for the omo mis-attribution: the root lookup used to be + * `provider === 'omp' ? '.omp' : '.gjc'`, so adding omo to the union silently + * pointed it at gjc's transcripts. 632 gjc sessions were indexed as omo and the + * whole suite still passed, because a ternary else branch is not a type error. + */ +test('every pi provider reads its own agent home, and no two share one', () => { + assert.deepEqual(PI_AGENT_ROOT_DIRS, { + gjc: path.join(os.homedir(), '.gjc', 'agent'), + omp: path.join(os.homedir(), '.omp', 'agent'), + omo: path.join(os.homedir(), '.omo', 'agent'), + }); + + const roots = Object.values(PI_AGENT_ROOT_DIRS); + assert.equal(new Set(roots).size, roots.length, 'two providers resolve to the same root'); + + for (const [provider, root] of Object.entries(PI_AGENT_ROOT_DIRS)) { + assert.ok( + root.includes(`/.${provider}/`), + `${provider} must read ~/.${provider}, got ${root}`, + ); + } +}); diff --git a/server/omo-cli.test.ts b/server/omo-cli.test.ts index 685c670..188d1fb 100644 --- a/server/omo-cli.test.ts +++ b/server/omo-cli.test.ts @@ -2,8 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildOmoArgs, normalizeOmoEvent } from './omo-cli.js'; +import { buildOmpArgs } from './omp-cli.js'; -test('buildOmoArgs preserves resume, model, thinking, images, and prompt as distinct argv', () => { +test('buildOmoArgs continues a session with --session-id, never --resume', () => { assert.deepEqual( buildOmoArgs('Explain this image', { sessionId: '019ff9fa-abab-78a3-83b0-67c261374f42', @@ -13,7 +14,7 @@ test('buildOmoArgs preserves resume, model, thinking, images, and prompt as dist }), [ '--mode', 'json', '--print', - '--resume', '019ff9fa-abab-78a3-83b0-67c261374f42', + '--session-id', '019ff9fa-abab-78a3-83b0-67c261374f42', '--model', 'anthropic/claude-opus-5', '--thinking', 'high', '@/tmp/shot.png', @@ -22,6 +23,20 @@ test('buildOmoArgs preserves resume, model, thinking, images, and prompt as dist ); }); +// omo's `--resume` takes no value and opens an interactive picker; under +// `--print` with no stdin it exits 13 without running the turn, so every +// follow-up message in a session failed. Oh My Pi's `--resume ` is the +// unrelated flag that happens to share the name. +test('omo and Oh My Pi do not share a session flag', () => { + const omo = buildOmoArgs('hi', { sessionId: 'S' }); + const omp = buildOmpArgs('hi', { sessionId: 'S' }); + + assert.ok(omo.includes('--session-id'), 'omo must use --session-id'); + assert.ok(!omo.includes('--resume'), 'omo must never receive --resume'); + assert.ok(omp.includes('--resume'), 'Oh My Pi keeps --resume'); + assert.ok(!omp.includes('--session-id')); +}); + test('buildOmoArgs omits placeholder model and effort selections', () => { assert.deepEqual( buildOmoArgs('hi', { model: 'default', effort: 'default' }), diff --git a/server/omo-cli.ts b/server/omo-cli.ts index 1646453..fdd1b39 100644 --- a/server/omo-cli.ts +++ b/server/omo-cli.ts @@ -17,7 +17,7 @@ const OMO: PiCliDescriptor = { const runtime = createPiCliRuntime(OMO); export function buildOmoArgs(command: string, options: PiCliRunOptions): string[] { - return buildPiCliArgs(command, options); + return buildPiCliArgs(command, options, OMO.provider); } export function normalizeOmoEvent( diff --git a/server/omp-cli.ts b/server/omp-cli.ts index 15cd8de..c1f0581 100644 --- a/server/omp-cli.ts +++ b/server/omp-cli.ts @@ -17,7 +17,7 @@ const OMP: PiCliDescriptor = { const runtime = createPiCliRuntime(OMP); export function buildOmpArgs(command: string, options: PiCliRunOptions): string[] { - return buildPiCliArgs(command, options); + return buildPiCliArgs(command, options, OMP.provider); } export function normalizeOmpEvent( diff --git a/server/pi-cli.test.ts b/server/pi-cli.test.ts new file mode 100644 index 0000000..83a1928 --- /dev/null +++ b/server/pi-cli.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { piCliFailureDetail } from './pi-cli.js'; + +// Verbatim stderr from a successful `omo --mode json --print` run (exit 0). +const SUCCESSFUL_RUN_STDERR = [ + "config-watch user config discovery requires reload { userConfigCreationDiscovery: 'reload_required' }", + "omo-senpi ulw-loop status ignored { reason: 'non-zero-exit', code: 1 }", + "omo-senpi start-work-continuation skipped { reason: 'not-continuable' }", +].join('\n'); + +test('a clean exit never reports its stderr as a failure', () => { + assert.equal(piCliFailureDetail(0, false, SUCCESSFUL_RUN_STDERR), null); + assert.equal( + piCliFailureDetail(0, false, 'Warning: Detected unsettled top-level await at file:///…/cli-main.js:17'), + null, + ); +}); + +test('a failing exit surfaces the buffered stderr', () => { + assert.equal(piCliFailureDetail(1, false, ' boom\n'), 'boom'); + assert.equal(piCliFailureDetail(null, false, 'killed mid-turn'), 'killed mid-turn'); +}); + +test('an aborted run stays silent, and a failure with no stderr adds nothing', () => { + assert.equal(piCliFailureDetail(143, true, 'terminated'), null); + assert.equal(piCliFailureDetail(1, false, ' \n '), null); +}); diff --git a/server/pi-cli.ts b/server/pi-cli.ts index c115edb..09008a6 100644 --- a/server/pi-cli.ts +++ b/server/pi-cli.ts @@ -43,6 +43,22 @@ export type PiCliRunOptions = { type ActivePiProcess = ReturnType & { aborted?: boolean }; +const MAX_BUFFERED_STDERR_BYTES = 64 * 1024; + +/** + * Buffered stderr is worth showing only when the run failed. An aborted run is + * a user gesture, and a clean exit means the lines were progress logs. + */ +export function piCliFailureDetail( + exitCode: number | null, + aborted: boolean, + stderr: string, +): string | null { + if (aborted || exitCode === 0) return null; + const detail = stderr.trim(); + return detail.length > 0 ? detail : null; +} + function readRecord(value: unknown): AnyRecord | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as AnyRecord @@ -61,9 +77,26 @@ function readContentText(value: unknown): string { .join('\n'); } -export function buildPiCliArgs(command: string, options: PiCliRunOptions): string[] { +/** + * The two CLIs spell session continuation differently, and the flag names + * overlap misleadingly. In Oh My Pi `--resume ` resumes that id; in omo + * `--resume` takes no value and opens an interactive picker, which under + * `--print` with no stdin exits 13 without ever running the turn. omo's + * equivalent is `--session-id`, which resumes an existing id and creates it + * when missing. + */ +const PI_SESSION_FLAGS: Record = { + omp: '--resume', + omo: '--session-id', +}; + +export function buildPiCliArgs( + command: string, + options: PiCliRunOptions, + provider: PiCliProvider = 'omp', +): string[] { const args = ['--mode', 'json', '--print']; - if (options.sessionId) args.push('--resume', options.sessionId); + if (options.sessionId) args.push(PI_SESSION_FLAGS[provider], options.sessionId); if (options.model && options.model !== 'default') args.push('--model', options.model); if (options.effort && options.effort !== 'default') args.push('--thinking', options.effort); @@ -179,6 +212,8 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { let capturedSessionId = options.sessionId ?? null; let child: ActivePiProcess | null = null; let settled = false; + const stderrChunks: string[] = []; + let stderrBytes = 0; const run = new Promise((resolve, reject) => { const finish = (error?: Error): void => { @@ -210,7 +245,7 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { try { // stdin must be closed: with an inherited stdin these CLIs wait for // interactive input and never emit their first event. - child = spawn(binary, buildPiCliArgs(command, options), { + child = spawn(binary, buildPiCliArgs(command, options, provider), { cwd: workingDir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], @@ -237,15 +272,15 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { for (const message of normalized.messages) writer.send(message); }); + // stderr is a log channel for these CLIs, not an error channel: a run + // that exits 0 still prints config notices and hook status there. + // Buffer it and surface it only when the process actually fails, so + // ordinary logging cannot masquerade as a failed turn in the chat. stderr.on('data', (chunk) => { - const content = String(chunk).trim(); - if (!content) return; - writer.send(createNormalizedMessage({ - kind: 'error', - content, - sessionId: capturedSessionId, - provider, - })); + if (stderrBytes >= MAX_BUFFERED_STDERR_BYTES) return; + const text = String(chunk); + stderrBytes += text.length; + stderrChunks.push(text); }); child.on('error', (error) => { @@ -266,6 +301,19 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { child.on('close', (code) => { activeProcesses.delete(processKey); if (capturedSessionId) activeProcesses.delete(capturedSessionId); + const failureDetail = piCliFailureDetail( + typeof code === 'number' ? code : null, + Boolean(child?.aborted), + stderrChunks.join(''), + ); + if (failureDetail) { + writer.send(createNormalizedMessage({ + kind: 'error', + content: failureDetail, + sessionId: capturedSessionId, + provider, + })); + } if (!child?.aborted) { writer.send(createCompleteMessage({ provider, sessionId: capturedSessionId, exitCode: code })); } diff --git a/server/routes/settings.js b/server/routes/settings.js index 7b662b2..f79c8fc 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -73,7 +73,7 @@ const validExternalDescriptorSession = (session) => isRecord(session) && boundedString(session.tmux.paneId, 128) && Number.isSafeInteger(session.agentPid) && session.agentPid > 0 && Number.isFinite(session.startedAtMs) && session.startedAtMs > 0; -const externalCompletionKinds = new Set(['claude', 'codex', 'opencode', 'omp']); +const externalCompletionKinds = new Set(['claude', 'codex', 'opencode', 'omp', 'omo']); const validDetailedExternalSession = (session) => isRecord(session) && boundedString(session.kind, 32) && (!externalCompletionKinds.has(session.kind) || validExternalDescriptorSession(session)); diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 0a34f8e..3f6dcc1 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -453,7 +453,6 @@ function ChatInterface({ showThinking={showThinking} showImagePreviews={showImagePreviews} selectedProject={selectedProject} - transcriptView={Boolean(liveSessionKind && liveSessionKind !== 'gjc')} pendingAskToolId={pendingRelayAsk?.toolId ?? null} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={handleAskChoiceSelect} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index c970d3e..62314e8 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -64,7 +64,6 @@ interface ChatMessagesPaneProps { showThinking?: boolean; showImagePreviews?: boolean; selectedProject: Project; - transcriptView?: boolean; pendingAskToolId?: string | null; suppressedAskToolId?: string | null; onAskChoiceSelect?: (choiceNumber: number) => void; @@ -115,7 +114,6 @@ function ChatMessagesPane({ showThinking, showImagePreviews = true, selectedProject, - transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect, @@ -270,7 +268,6 @@ function ChatMessagesPane({ showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} - transcriptView={transcriptView} pendingAskToolId={pendingAskToolId} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={onAskChoiceSelect} @@ -295,7 +292,6 @@ function ChatMessagesPane({ showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} - transcriptView={transcriptView} pendingAskToolId={pendingAskToolId} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={onAskChoiceSelect} diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 5e7c091..20173e9 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -37,7 +37,6 @@ type MessageComponentProps = { showImagePreviews?: boolean; selectedProject?: Project | null; provider: Provider | string; - transcriptView?: boolean; pendingAskToolId?: string | null; suppressedAskToolId?: string | null; onAskChoiceSelect?: (choiceNumber: number) => void; @@ -55,7 +54,7 @@ const compactErrorSummary = (content: string, fallback: string): string => { return firstLine.length > 160 ? `${firstLine.slice(0, 157)}...` : firstLine; }; -const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, showImagePreviews = true, selectedProject, provider, transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect }: MessageComponentProps) => { +const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, showImagePreviews = true, selectedProject, provider, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect }: MessageComponentProps) => { const { t } = useTranslation('chat'); const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || @@ -180,7 +179,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ) : ( /* Claude/Error/Tool messages on the left */
- {!isGrouped && message.type !== 'error' && !(transcriptView && message.type === 'assistant') && ( + {!isGrouped && message.type !== 'error' && (
{message.type === 'tool' ? (
@@ -204,7 +203,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ? t('messageTypes.gjc', { defaultValue: 'Gajae Code' }) : provider === 'omp' ? t('messageTypes.omp', { defaultValue: 'Oh My Pi' }) - : t('messageTypes.claude'))} + : provider === 'omo' + ? t('messageTypes.omo', { defaultValue: 'omo' }) + : t('messageTypes.claude'))}
)} diff --git a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx index 88c83b7..28fa4d7 100644 --- a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx +++ b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx @@ -27,7 +27,6 @@ interface ToolGroupContainerProps { showImagePreviews?: boolean; selectedProject?: Project | null; provider: Provider | string; - transcriptView?: boolean; pendingAskToolId?: string | null; suppressedAskToolId?: string | null; onAskChoiceSelect?: (choiceNumber: number) => void; @@ -75,7 +74,6 @@ export default function ToolGroupContainer({ showImagePreviews = true, selectedProject, provider, - transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect, @@ -146,7 +144,6 @@ export default function ToolGroupContainer({ showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} - transcriptView={transcriptView} pendingAskToolId={pendingAskToolId} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={onAskChoiceSelect} diff --git a/src/components/llm-logo-provider/OmoLogo.tsx b/src/components/llm-logo-provider/OmoLogo.tsx index bdc0f70..59596b8 100644 --- a/src/components/llm-logo-provider/OmoLogo.tsx +++ b/src/components/llm-logo-provider/OmoLogo.tsx @@ -1,34 +1,66 @@ -import { useId } from 'react'; - type OmoLogoProps = { className?: string; }; /** - * Placeholder mark: the omo package ships no brand asset, so this is a plain - * geometric "o" rather than an official logo. Swap it when one exists. + * Official omo mark from oh-my-openagent `.github/assets/omo-icon-light.svg`. + * + * Cropped to the cat itself rather than to the source canvas. Measured from a + * 512px render, the mark occupies x 190..832, y 220..794 of the 1024 canvas — + * only ~63% of the width — so the shipped viewBox renders it noticeably + * smaller than the other provider logos, which are full-bleed. This box is the + * mark centred (511, 507) with a ~10% margin; the light backing plate simply + * bleeds past the edges, which is what keeps a dark mark legible on dark UI. */ -const OmoLogo = ({ className = 'w-5 h-5' }: OmoLogoProps) => { - const gradientId = useId(); - - return ( - - - - - - - - - - - ); -}; +const OmoLogo = ({ className = 'w-5 h-5' }: OmoLogoProps) => ( + + + + + + + + +); export default OmoLogo; diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts index 7fa5d78..8ca6f1f 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.ts @@ -38,7 +38,7 @@ export function mergeExternalDiscoveryRows( session, ])); return rows - .filter((row) => row.lane === 'external' && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'ssh', 'shell'].includes(row.kind)) + .filter((row) => row.lane === 'external' && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo', 'ssh', 'shell'].includes(row.kind)) .map((row) => { const metadata = restSessions.get(tmuxPaneIdentityKey(row.tmux)) ?? previous.get(tmuxPaneIdentityKey(row.tmux)); const { connectionIssue: _staleConnectionIssue, ...stableMetadata } = metadata ?? {}; @@ -155,7 +155,7 @@ export function useExternalCliSessions( const applyRestSessions = useCallback((list: ExternalCliSession[], responseDiscoveryOk: boolean) => { const supported = list.filter((session) => ( session?.tmuxName - && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'ssh', 'shell'].includes(session.kind) + && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo', 'ssh', 'shell'].includes(session.kind) )); setDiscoveryOk(responseDiscoveryOk); if (!responseDiscoveryOk) {