From 06ac2ede013279ddad878fea5b40634f690357e7 Mon Sep 17 00:00:00 2001 From: crsei Date: Wed, 22 Apr 2026 07:01:11 -0400 Subject: [PATCH] Introduce Lite adapter foundation for sample migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the view-model and adapter layer that later OpenTUI sample migration slices will consume, so components can render from normalized types instead of parsing raw `FrontendContentBlock`, `ConversationMessage`, or `PermissionRequest` shapes directly. - `ui/src/view-model/` — message/tool/status/permission types - `ui/src/adapters/` — content-block, message, permission, tool-status, and tool-input normalization helpers - Placeholder `index.ts` barrels for the staged migration targets: messages, permissions, mcp, teams, shell, PromptInput, StructuredDiff - 40 new tests covering the adapter surface Closes #90. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters/__tests__/content-blocks.test.ts | 139 +++++++ ui/src/adapters/__tests__/messages.test.ts | 219 +++++++++++ ui/src/adapters/__tests__/permissions.test.ts | 82 ++++ ui/src/adapters/__tests__/tool-input.test.ts | 38 ++ ui/src/adapters/__tests__/tool-status.test.ts | 31 ++ ui/src/adapters/content-blocks.ts | 185 +++++++++ ui/src/adapters/index.ts | 20 + ui/src/adapters/messages.ts | 356 ++++++++++++++++++ ui/src/adapters/permissions.ts | 74 ++++ ui/src/adapters/tool-input.ts | 58 +++ ui/src/adapters/tool-status.ts | 44 +++ ui/src/components/PromptInput/index.ts | 10 + ui/src/components/StructuredDiff/index.ts | 12 + ui/src/components/mcp/index.ts | 12 + ui/src/components/messages/index.ts | 11 + ui/src/components/permissions/index.ts | 10 + ui/src/components/shell/index.ts | 11 + ui/src/components/teams/index.ts | 10 + ui/src/view-model/index.ts | 3 + ui/src/view-model/types.ts | 172 +++++++++ 20 files changed, 1497 insertions(+) create mode 100644 ui/src/adapters/__tests__/content-blocks.test.ts create mode 100644 ui/src/adapters/__tests__/messages.test.ts create mode 100644 ui/src/adapters/__tests__/permissions.test.ts create mode 100644 ui/src/adapters/__tests__/tool-input.test.ts create mode 100644 ui/src/adapters/__tests__/tool-status.test.ts create mode 100644 ui/src/adapters/content-blocks.ts create mode 100644 ui/src/adapters/index.ts create mode 100644 ui/src/adapters/messages.ts create mode 100644 ui/src/adapters/permissions.ts create mode 100644 ui/src/adapters/tool-input.ts create mode 100644 ui/src/adapters/tool-status.ts create mode 100644 ui/src/components/PromptInput/index.ts create mode 100644 ui/src/components/StructuredDiff/index.ts create mode 100644 ui/src/components/mcp/index.ts create mode 100644 ui/src/components/messages/index.ts create mode 100644 ui/src/components/permissions/index.ts create mode 100644 ui/src/components/shell/index.ts create mode 100644 ui/src/components/teams/index.ts create mode 100644 ui/src/view-model/index.ts create mode 100644 ui/src/view-model/types.ts diff --git a/ui/src/adapters/__tests__/content-blocks.test.ts b/ui/src/adapters/__tests__/content-blocks.test.ts new file mode 100644 index 00000000..541e8f98 --- /dev/null +++ b/ui/src/adapters/__tests__/content-blocks.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test' +import { + inlineBlocksFromContent, + normalizeToolResultBlocks, + normalizeToolResultContent, +} from '../content-blocks.js' + +describe('inlineBlocksFromContent', () => { + test('flattens text, thinking, and image blocks in order', () => { + const blocks = inlineBlocksFromContent([ + { type: 'text', text: 'hello' }, + { type: 'thinking', thinking: 'weighing options' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/jpeg', data: 'ZZZ' }, + }, + ]) + + expect(blocks).toHaveLength(3) + expect(blocks[0]).toEqual({ kind: 'text', text: 'hello' }) + expect(blocks[1]).toEqual({ + kind: 'thinking', + text: 'weighing options', + redacted: false, + }) + expect(blocks[2]).toEqual({ + kind: 'image', + image: { data: 'ZZZ', mediaType: 'image/jpeg' }, + }) + }) + + test('flags redacted_thinking entries', () => { + const blocks = inlineBlocksFromContent([ + { type: 'redacted_thinking', data: 'x' }, + ]) + expect(blocks).toHaveLength(1) + expect(blocks[0]).toEqual({ + kind: 'thinking', + text: '[redacted thinking]', + redacted: true, + }) + }) + + test('ignores tool_use and tool_result inline blocks', () => { + const blocks = inlineBlocksFromContent([ + { type: 'text', text: 'before' }, + { type: 'tool_use', id: 't-1', name: 'Read', input: {} }, + { type: 'tool_result', tool_use_id: 't-1', content: 'done' }, + { type: 'text', text: 'after' }, + ]) + expect(blocks.map(block => block.kind)).toEqual(['text', 'text']) + }) +}) + +describe('normalizeToolResultContent', () => { + test('returns the string body when content is a plain string', () => { + expect(normalizeToolResultContent('stdout line')).toEqual({ + text: 'stdout line', + images: [], + }) + }) + + test('extracts text and images from nested blocks', () => { + const result = normalizeToolResultContent([ + { type: 'text', text: 'line 1' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, + }, + { type: 'text', text: 'line 2' }, + ]) + + expect(result.text).toBe('line 1\nline 2') + expect(result.images).toEqual([{ data: 'AAAA', mediaType: 'image/png' }]) + }) + + test('recurses into nested tool_result content', () => { + const result = normalizeToolResultContent([ + { + type: 'tool_result', + tool_use_id: 't-2', + content: [ + { type: 'text', text: 'inner' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'XX' }, + }, + ], + }, + ]) + expect(result.text).toBe('inner') + expect(result.images).toEqual([{ data: 'XX', mediaType: 'image/png' }]) + }) +}) + +describe('normalizeToolResultBlocks', () => { + test('returns the raw output when no structured blocks are present', () => { + expect(normalizeToolResultBlocks('raw out', undefined)).toEqual({ + text: 'raw out', + images: [], + }) + }) + + test('prefers structured text and merges image blocks', () => { + const result = normalizeToolResultBlocks('fallback', [ + { type: 'text', text: 'structured' }, + { + type: 'image', + media_type: 'image/png', + size_bytes: 128, + data: 'BBBB', + }, + ]) + + expect(result.text).toBe('structured') + expect(result.images).toEqual([ + { data: 'BBBB', mediaType: 'image/png', sizeBytes: 128 }, + ]) + }) + + test('falls back to raw output when structured text is empty', () => { + const result = normalizeToolResultBlocks('fallback text', [ + { + type: 'image', + media_type: 'image/png', + data: 'CCCC', + }, + ]) + expect(result.text).toBe('fallback text') + expect(result.images).toEqual([{ data: 'CCCC', mediaType: 'image/png' }]) + }) + + test('drops image blocks without base64 payload', () => { + const result = normalizeToolResultBlocks('x', [ + { type: 'image', media_type: 'image/png' }, + ]) + expect(result.images).toEqual([]) + }) +}) diff --git a/ui/src/adapters/__tests__/messages.test.ts b/ui/src/adapters/__tests__/messages.test.ts new file mode 100644 index 00000000..2d6a84dd --- /dev/null +++ b/ui/src/adapters/__tests__/messages.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from 'bun:test' +import type { ConversationMessage } from '../../ipc/protocol.js' +import type { RawMessage } from '../../store/message-model.js' +import { + mapConversationMessageToViewModels, + mapRawMessageToViewModels, +} from '../messages.js' + +describe('mapRawMessageToViewModels', () => { + test('maps a plain user text message', () => { + const raw: RawMessage = { + id: 'u-1', + role: 'user', + content: 'hello there', + timestamp: 1, + } + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(1) + expect(items[0]!.kind).toBe('user_text') + if (items[0]!.kind === 'user_text') { + expect(items[0]!.text).toBe('hello there') + expect(items[0]!.id).toBe('u-1:user:0') + } + }) + + test('maps assistant text + thinking into a single segment', () => { + const raw: RawMessage = { + id: 'a-1', + role: 'assistant', + content: '', + timestamp: 5, + contentBlocks: [ + { type: 'thinking', thinking: 'planning…' }, + { type: 'text', text: 'Here is the answer.' }, + ], + costUsd: 0.01, + } + + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(1) + expect(items[0]!.kind).toBe('assistant_message') + if (items[0]!.kind === 'assistant_message') { + expect(items[0]!.costUsd).toBe(0.01) + expect(items[0]!.segments).toHaveLength(1) + expect(items[0]!.segments[0]!.text).toBe('Here is the answer.') + expect(items[0]!.segments[0]!.thinking).toBe('planning…') + expect(items[0]!.segments[0]!.redactedThinking).toBeUndefined() + } + }) + + test('splits assistant segments on tool_use boundaries and emits tool_use view models', () => { + const raw: RawMessage = { + id: 'a-2', + role: 'assistant', + content: '', + timestamp: 10, + contentBlocks: [ + { type: 'text', text: 'Let me read that file.' }, + { + type: 'tool_use', + id: 'tool-1', + name: 'Read', + input: { file_path: '/tmp/file.ts' }, + }, + { type: 'text', text: 'Here is what I found.' }, + ], + } + + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(2) + const assistant = items.find(item => item.kind === 'assistant_message') + const tool = items.find(item => item.kind === 'tool_use') + expect(assistant).toBeDefined() + expect(tool).toBeDefined() + if (assistant?.kind === 'assistant_message') { + expect(assistant.segments).toHaveLength(2) + expect(assistant.segments[0]!.text).toBe('Let me read that file.') + expect(assistant.segments[1]!.text).toBe('Here is what I found.') + } + if (tool?.kind === 'tool_use') { + expect(tool.name).toBe('Read') + expect(tool.toolUseId).toBe('tool-1') + expect(tool.inputDetail).toBe('/tmp/file.ts') + expect(tool.status).toBe('pending') + } + }) + + test('marks thinking segments whose source was redacted', () => { + const raw: RawMessage = { + id: 'a-3', + role: 'assistant', + content: '', + timestamp: 12, + contentBlocks: [ + { type: 'redacted_thinking', data: 'opaque' }, + { type: 'text', text: 'Ok.' }, + ], + } + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(1) + if (items[0]!.kind === 'assistant_message') { + const segment = items[0]!.segments[0]! + expect(segment.thinking).toBe('[redacted thinking]') + expect(segment.redactedThinking).toBe(true) + } + }) + + test('surfaces user image blocks as their own view models', () => { + const raw: RawMessage = { + id: 'u-2', + role: 'user', + content: '', + timestamp: 20, + contentBlocks: [ + { type: 'text', text: 'look at this' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, + }, + ], + } + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(2) + expect(items[0]!.kind).toBe('user_text') + expect(items[1]!.kind).toBe('user_image') + if (items[1]!.kind === 'user_image') { + expect(items[1]!.image.data).toBe('AAAA') + expect(items[1]!.image.mediaType).toBe('image/png') + } + }) + + test('normalizes tool_result embedded in a user replay message', () => { + const raw: RawMessage = { + id: 'u-3', + role: 'user', + content: '', + timestamp: 30, + contentBlocks: [ + { + type: 'tool_result', + tool_use_id: 'tool-9', + content: 'done', + }, + ], + } + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(1) + expect(items[0]!.kind).toBe('tool_result') + if (items[0]!.kind === 'tool_result') { + expect(items[0]!.toolUseId).toBe('tool-9') + expect(items[0]!.content.text).toBe('done') + expect(items[0]!.status).toBe('success') + expect(items[0]!.isError).toBe(false) + } + }) + + test('classifies cancelled tool results when the output mentions interruption', () => { + const raw: RawMessage = { + id: 'r-1', + role: 'tool_result', + content: 'Interrupted by user', + timestamp: 40, + toolUseId: 'tool-5', + } + const items = mapRawMessageToViewModels(raw) + expect(items[0]!.kind).toBe('tool_result') + if (items[0]!.kind === 'tool_result') { + expect(items[0]!.status).toBe('cancelled') + expect(items[0]!.isError).toBe(false) + } + }) + + test('maps system messages with recognized levels', () => { + const raw: RawMessage = { + id: 's-1', + role: 'system', + content: 'retrying...', + timestamp: 50, + level: 'WARNING', + } + const items = mapRawMessageToViewModels(raw) + expect(items).toHaveLength(1) + expect(items[0]!.kind).toBe('system_info') + if (items[0]!.kind === 'system_info') { + expect(items[0]!.level).toBe('warning') + } + }) + + test('drops system messages that carry no content', () => { + const raw: RawMessage = { + id: 's-2', + role: 'system', + content: '', + timestamp: 51, + } + const items = mapRawMessageToViewModels(raw) + expect(items).toEqual([]) + }) + + test('round-trips a ConversationMessage through the conversation adapter', () => { + const message: ConversationMessage = { + id: 'c-1', + role: 'assistant', + content: '', + timestamp: 60, + content_blocks: [ + { type: 'text', text: 'streamed reply' }, + ], + cost_usd: 0.5, + } + const items = mapConversationMessageToViewModels(message) + expect(items).toHaveLength(1) + expect(items[0]!.kind).toBe('assistant_message') + if (items[0]!.kind === 'assistant_message') { + expect(items[0]!.costUsd).toBe(0.5) + expect(items[0]!.segments[0]!.text).toBe('streamed reply') + } + }) +}) diff --git a/ui/src/adapters/__tests__/permissions.test.ts b/ui/src/adapters/__tests__/permissions.test.ts new file mode 100644 index 00000000..5f8e6d2e --- /dev/null +++ b/ui/src/adapters/__tests__/permissions.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test' +import { + categorizePermissionTool, + mapPermissionRequestToViewModel, + parsePermissionOption, +} from '../permissions.js' + +describe('parsePermissionOption', () => { + test('extracts a trailing hotkey', () => { + expect(parsePermissionOption('Yes (y)')).toEqual({ + value: 'Yes (y)', + label: 'Yes', + hotkey: 'y', + }) + }) + + test('lowercases uppercase hotkeys and keeps the original value', () => { + expect(parsePermissionOption('Always (A)')).toEqual({ + value: 'Always (A)', + label: 'Always', + hotkey: 'a', + }) + }) + + test('falls back to the raw label when no hotkey is present', () => { + expect(parsePermissionOption('Approve once')).toEqual({ + value: 'Approve once', + label: 'Approve once', + }) + }) +}) + +describe('categorizePermissionTool', () => { + test('groups shell tools', () => { + expect(categorizePermissionTool('Bash')).toBe('bash') + expect(categorizePermissionTool('PowerShell')).toBe('bash') + }) + + test('groups file edit tools', () => { + expect(categorizePermissionTool('Edit')).toBe('file_edit') + expect(categorizePermissionTool('MultiEdit')).toBe('file_edit') + expect(categorizePermissionTool('NotebookEdit')).toBe('file_edit') + }) + + test('groups file write tools', () => { + expect(categorizePermissionTool('Write')).toBe('file_write') + }) + + test('groups fetch/search tools', () => { + expect(categorizePermissionTool('WebFetch')).toBe('web_fetch') + expect(categorizePermissionTool('WebSearch')).toBe('web_fetch') + }) + + test('falls back to tool_generic for unknown tools', () => { + expect(categorizePermissionTool('SomeCustomTool')).toBe('tool_generic') + }) +}) + +describe('mapPermissionRequestToViewModel', () => { + test('normalizes the permission request options and category', () => { + const vm = mapPermissionRequestToViewModel({ + toolUseId: 'pr-1', + tool: 'Bash', + command: 'ls -la', + options: ['Yes (y)', 'No (n)', 'Always allow for session (a)'], + }) + + expect(vm.kind).toBe('permission_request') + expect(vm.toolUseId).toBe('pr-1') + expect(vm.tool).toBe('Bash') + expect(vm.category).toBe('bash') + expect(vm.options).toEqual([ + { value: 'Yes (y)', label: 'Yes', hotkey: 'y' }, + { value: 'No (n)', label: 'No', hotkey: 'n' }, + { + value: 'Always allow for session (a)', + label: 'Always allow for session', + hotkey: 'a', + }, + ]) + }) +}) diff --git a/ui/src/adapters/__tests__/tool-input.test.ts b/ui/src/adapters/__tests__/tool-input.test.ts new file mode 100644 index 00000000..f2cb26e7 --- /dev/null +++ b/ui/src/adapters/__tests__/tool-input.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { describeToolInput, summarizeToolInput } from '../tool-input.js' + +describe('describeToolInput', () => { + test('prefers command over file_path when both are present', () => { + expect(describeToolInput({ command: 'ls -la', file_path: '/tmp' })).toBe( + 'ls -la', + ) + }) + + test('renders pattern + path combos', () => { + expect(describeToolInput({ pattern: '*.ts', path: '/tmp' })).toBe( + '"*.ts" in /tmp', + ) + }) + + test('returns the raw string when the input is already a string', () => { + expect(describeToolInput('already a string')).toBe('already a string') + }) + + test('falls back to JSON when no known field is present', () => { + expect(describeToolInput({ custom: 'value' })).toBe('{"custom":"value"}') + }) + + test('treats null/undefined as empty inline string', () => { + expect(describeToolInput(null)).toBe('') + expect(describeToolInput(undefined)).toBe('') + }) +}) + +describe('summarizeToolInput', () => { + test('truncates long summaries', () => { + const long = 'a'.repeat(200) + const summary = summarizeToolInput({ command: long }, 40) + expect(summary.length).toBe(40) + expect(summary.endsWith('\u2026')).toBe(true) + }) +}) diff --git a/ui/src/adapters/__tests__/tool-status.test.ts b/ui/src/adapters/__tests__/tool-status.test.ts new file mode 100644 index 00000000..22869a47 --- /dev/null +++ b/ui/src/adapters/__tests__/tool-status.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { classifyToolStatus, mergeToolStatuses } from '../tool-status.js' + +describe('classifyToolStatus', () => { + test('returns cancelled when the output mentions interruption', () => { + expect(classifyToolStatus('Interrupted by user', false)).toBe('cancelled') + expect(classifyToolStatus('task aborted', false)).toBe('cancelled') + expect(classifyToolStatus('Task was cancelled.', false)).toBe('cancelled') + }) + + test('returns error when the error flag is set and no cancellation is detected', () => { + expect(classifyToolStatus('Boom', true)).toBe('error') + }) + + test('returns success otherwise', () => { + expect(classifyToolStatus('ok', false)).toBe('success') + }) +}) + +describe('mergeToolStatuses', () => { + test('prioritizes error over everything', () => { + expect(mergeToolStatuses(['error', 'running', 'success'])).toBe('error') + }) + + test('falls through running -> pending -> cancelled -> success', () => { + expect(mergeToolStatuses(['running', 'pending', 'success'])).toBe('running') + expect(mergeToolStatuses(['pending', 'success'])).toBe('pending') + expect(mergeToolStatuses(['cancelled', 'success'])).toBe('cancelled') + expect(mergeToolStatuses(['success', 'success'])).toBe('success') + }) +}) diff --git a/ui/src/adapters/content-blocks.ts b/ui/src/adapters/content-blocks.ts new file mode 100644 index 00000000..993b6c0a --- /dev/null +++ b/ui/src/adapters/content-blocks.ts @@ -0,0 +1,185 @@ +/** + * Helpers for normalizing `FrontendContentBlock[]` and `ToolResultContent` + * shapes into the view-model inline blocks. + * + * Keep these functions pure. Component code should call them rather than + * walking protocol shapes by hand. + */ + +import type { + FrontendContentBlock, + ImageSource, + ToolResultContent, + ToolResultContentInfo, +} from '../ipc/protocol.js' +import type { + ImageRef, + NormalizedImageBlock, + NormalizedInlineBlock, + NormalizedTextBlock, + NormalizedThinkingBlock, + NormalizedToolResultContent, +} from '../view-model/types.js' + +export function imageSourceToRef(source: ImageSource): ImageRef { + return { data: source.data, mediaType: source.media_type } +} + +export function toolResultImageToRef( + block: Extract, +): ImageRef | undefined { + if (!block.data) { + return undefined + } + return { + data: block.data, + mediaType: block.media_type, + sizeBytes: block.size_bytes, + } +} + +/** + * Flatten an assistant/user `FrontendContentBlock[]` into the view-model's + * inline block union. Tool use / tool result blocks are intentionally + * excluded — they are promoted to standalone view-model items by the + * message adapter. + */ +export function inlineBlocksFromContent( + blocks: FrontendContentBlock[], +): NormalizedInlineBlock[] { + const result: NormalizedInlineBlock[] = [] + + for (const block of blocks) { + switch (block.type) { + case 'text': { + if (block.text) { + const entry: NormalizedTextBlock = { kind: 'text', text: block.text } + result.push(entry) + } + break + } + case 'thinking': { + if (block.thinking) { + const entry: NormalizedThinkingBlock = { + kind: 'thinking', + text: block.thinking, + redacted: false, + } + result.push(entry) + } + break + } + case 'redacted_thinking': { + const entry: NormalizedThinkingBlock = { + kind: 'thinking', + text: '[redacted thinking]', + redacted: true, + } + result.push(entry) + break + } + case 'image': { + const entry: NormalizedImageBlock = { + kind: 'image', + image: imageSourceToRef(block.source), + } + result.push(entry) + break + } + default: + // tool_use and tool_result are handled by the message adapter at + // a higher level — they are not inline content from this layer's + // perspective. + break + } + } + + return result +} + +/** + * Normalize `ToolResultContent` (string or nested block array from the + * `tool_result` protocol block). Returns the collapsed text plus any + * images we could recover from nested blocks. + */ +export function normalizeToolResultContent( + content: ToolResultContent, +): NormalizedToolResultContent { + if (typeof content === 'string') { + return { text: content, images: [] } + } + + const textParts: string[] = [] + const images: ImageRef[] = [] + + for (const block of content) { + switch (block.type) { + case 'text': + if (block.text) { + textParts.push(block.text) + } + break + case 'thinking': + if (block.thinking) { + textParts.push(block.thinking) + } + break + case 'image': + images.push(imageSourceToRef(block.source)) + break + case 'tool_result': { + const nested = normalizeToolResultContent(block.content) + if (nested.text) { + textParts.push(nested.text) + } + for (const image of nested.images) { + images.push(image) + } + break + } + default: + break + } + } + + return { text: textParts.join('\n'), images } +} + +/** + * Merge a `tool_result` backend message's plain `output` string with its + * optional `content_blocks` into the normalized shape. Images from + * `content_blocks` take precedence; the plain string provides the + * authoritative text body. + */ +export function normalizeToolResultBlocks( + output: string, + blocks: ToolResultContentInfo[] | undefined, +): NormalizedToolResultContent { + if (!blocks || blocks.length === 0) { + return { text: output, images: [] } + } + + const textParts: string[] = [] + const images: ImageRef[] = [] + + for (const block of blocks) { + if (block.type === 'text') { + if (block.text) { + textParts.push(block.text) + } + continue + } + if (block.type === 'image') { + const ref = toolResultImageToRef(block) + if (ref) { + images.push(ref) + } + } + } + + const blockText = textParts.join('\n').trim() + const fallbackText = output?.trim() ?? '' + const text = blockText || fallbackText + + return { text, images } +} diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts new file mode 100644 index 00000000..a574ebc8 --- /dev/null +++ b/ui/src/adapters/index.ts @@ -0,0 +1,20 @@ +/** Barrel for the Lite adapter layer. See individual modules for details. */ +export { + imageSourceToRef, + toolResultImageToRef, + inlineBlocksFromContent, + normalizeToolResultContent, + normalizeToolResultBlocks, +} from './content-blocks.js' +export { + assistantSegmentId, + mapConversationMessageToViewModels, + mapRawMessageToViewModels, +} from './messages.js' +export { + categorizePermissionTool, + mapPermissionRequestToViewModel, + parsePermissionOption, +} from './permissions.js' +export { describeToolInput, summarizeToolInput } from './tool-input.js' +export { classifyToolStatus, mergeToolStatuses } from './tool-status.js' diff --git a/ui/src/adapters/messages.ts b/ui/src/adapters/messages.ts new file mode 100644 index 00000000..b6bd4976 --- /dev/null +++ b/ui/src/adapters/messages.ts @@ -0,0 +1,356 @@ +/** + * Map from the current `RawMessage` / `ConversationMessage` shapes into + * the normalized `MessageViewModel`. + * + * This is the primary adapter between the existing store + * (`ui/src/store/message-model.ts`) and migration slices consuming + * components under `ui/src/components/messages/**`. + */ + +import type { + ConversationMessage, + FrontendContentBlock, +} from '../ipc/protocol.js' +import type { RawMessage } from '../store/message-model.js' +import type { + AssistantMessageViewModel, + AssistantSegmentViewModel, + MessageViewModel, + SystemInfoViewModel, + SystemLevel, + ToolResultViewModel, + ToolUseViewModel, + UserImageViewModel, + UserTextViewModel, +} from '../view-model/types.js' +import { imageSourceToRef, normalizeToolResultContent } from './content-blocks.js' +import { + describeToolInput, + summarizeToolInput, +} from './tool-input.js' +import { classifyToolStatus } from './tool-status.js' + +const KNOWN_SYSTEM_LEVELS: ReadonlySet = new Set([ + 'info', + 'warning', + 'error', + 'success', + 'debug', +]) + +function systemLevelFromRaw(level: string | undefined): SystemLevel { + if (!level) { + return 'info' + } + const lowered = level.toLowerCase() as SystemLevel + return KNOWN_SYSTEM_LEVELS.has(lowered) ? lowered : 'info' +} + +function segmentKey(raw: RawMessage, index: number): string { + return `${raw.id}:assistant:${index}` +} + +function userSegmentKey(raw: RawMessage, index: number): string { + return `${raw.id}:user:${index}` +} + +function userImageKey(raw: RawMessage, index: number): string { + return `${raw.id}:image:${index}` +} + +interface SegmentCollector { + text: string[] + thinking: string[] + redacted: boolean +} + +function flushAssistantSegment( + segments: AssistantSegmentViewModel[], + collector: SegmentCollector, +): void { + const text = collector.text.join('\n').trim() + const thinkingJoined = collector.thinking + .map(piece => piece.trim()) + .filter(Boolean) + .join('\n') + const thinking = thinkingJoined || undefined + + if (!text && !thinking) { + collector.text = [] + collector.thinking = [] + collector.redacted = false + return + } + + segments.push({ + index: segments.length, + text, + thinking, + redactedThinking: thinking ? collector.redacted || undefined : undefined, + }) + collector.text = [] + collector.thinking = [] + collector.redacted = false +} + +/** + * Convert one `RawMessage` (from the live store) into the view-model + * items it represents. Most raw messages produce a single view model, + * but user/assistant messages with rich `contentBlocks` may produce + * several (e.g. text followed by an image). + */ +export function mapRawMessageToViewModels( + raw: RawMessage, +): MessageViewModel[] { + switch (raw.role) { + case 'tool_use': + return [mapToolUse(raw)] + case 'tool_result': + return [mapToolResult(raw)] + case 'assistant': + return mapAssistant(raw) + case 'user': + return mapUser(raw) + case 'system': + return mapSystem(raw) + default: + return [] + } +} + +/** Map a `ConversationMessage` (from replay) into view-model items. */ +export function mapConversationMessageToViewModels( + message: ConversationMessage, +): MessageViewModel[] { + const raw: RawMessage = { + id: message.id, + role: message.role, + content: message.content, + timestamp: message.timestamp, + contentBlocks: message.content_blocks, + costUsd: message.cost_usd, + thinking: message.thinking, + level: message.level, + } + return mapRawMessageToViewModels(raw) +} + +function mapToolUse(raw: RawMessage): ToolUseViewModel { + const input = raw.toolInput + return { + kind: 'tool_use', + id: `tool:${raw.toolUseId ?? raw.id}`, + toolUseId: raw.toolUseId ?? raw.id, + name: raw.toolName ?? 'Unknown Tool', + input, + inputDetail: describeToolInput(input), + inputSummary: summarizeToolInput(input), + timestamp: raw.timestamp, + status: 'pending', + } +} + +function mapToolResult(raw: RawMessage): ToolResultViewModel { + const toolUseId = raw.toolUseId ?? raw.id + const isError = raw.isError ?? false + const status = classifyToolStatus(raw.content ?? '', isError) + return { + kind: 'tool_result', + id: `tool-result:${toolUseId}:${raw.timestamp}`, + toolUseId, + content: { text: raw.content ?? '', images: [] }, + status, + isError: isError || status === 'error', + timestamp: raw.timestamp, + } +} + +function mapAssistant(raw: RawMessage): MessageViewModel[] { + const segments: AssistantSegmentViewModel[] = [] + const collector: SegmentCollector = { + text: [], + thinking: [], + redacted: false, + } + const extras: MessageViewModel[] = [] + + if (raw.contentBlocks?.length) { + for (const block of raw.contentBlocks) { + switch (block.type) { + case 'text': + if (block.text) { + collector.text.push(block.text) + } + break + case 'thinking': + if (block.thinking) { + collector.thinking.push(block.thinking) + } + break + case 'redacted_thinking': + collector.thinking.push('[redacted thinking]') + collector.redacted = true + break + case 'tool_use': + flushAssistantSegment(segments, collector) + extras.push({ + kind: 'tool_use', + id: `tool:${block.id}`, + toolUseId: block.id, + name: block.name, + input: block.input, + inputDetail: describeToolInput(block.input), + inputSummary: summarizeToolInput(block.input), + timestamp: raw.timestamp, + status: 'pending', + }) + break + case 'image': + // assistants don't currently emit images through this path, but + // if they do we surface each image as its own assistant segment + // placeholder so the composer still sees ordered output. + flushAssistantSegment(segments, collector) + segments.push({ index: segments.length, text: '[image omitted]' }) + imageSourceToRef(block.source) // retain in case callers want ref + break + default: + break + } + } + flushAssistantSegment(segments, collector) + } else { + if (raw.content) { + collector.text.push(raw.content) + } + if (raw.thinking) { + collector.thinking.push(raw.thinking) + } + flushAssistantSegment(segments, collector) + } + + const primary: AssistantMessageViewModel = { + kind: 'assistant_message', + id: raw.id, + segments, + timestamp: raw.timestamp, + costUsd: raw.costUsd, + } + + // Preserve deterministic ordering: assistant message (holding ordered + // segments) followed by any extracted tool_use items, so higher layers + // can interleave them with later tool_result view models by toolUseId. + return segments.length > 0 || extras.length === 0 + ? [primary, ...extras] + : extras +} + +function mapUser(raw: RawMessage): MessageViewModel[] { + const results: MessageViewModel[] = [] + + if (raw.contentBlocks?.length) { + const textParts: string[] = [] + let textIndex = 0 + const flushText = () => { + const joined = textParts.join('\n').trim() + if (joined) { + const entry: UserTextViewModel = { + kind: 'user_text', + id: userSegmentKey(raw, textIndex++), + text: joined, + timestamp: raw.timestamp, + } + results.push(entry) + } + textParts.length = 0 + } + + for (const block of raw.contentBlocks) { + switch (block.type) { + case 'text': + if (block.text) { + textParts.push(block.text) + } + break + case 'image': { + flushText() + const entry: UserImageViewModel = { + kind: 'user_image', + id: userImageKey(raw, results.length), + image: imageSourceToRef(block.source), + timestamp: raw.timestamp, + } + results.push(entry) + break + } + case 'tool_result': { + flushText() + const content = normalizeToolResultContent(block.content) + const isError = !!block.is_error + const status = classifyToolStatus(content.text, isError) + const entry: ToolResultViewModel = { + kind: 'tool_result', + id: `tool-result:${block.tool_use_id}:${raw.timestamp}`, + toolUseId: block.tool_use_id, + content, + status, + isError: isError || status === 'error', + timestamp: raw.timestamp, + } + results.push(entry) + break + } + default: + break + } + } + flushText() + + const hadRenderable = raw.contentBlocks.some( + (block: FrontendContentBlock) => + block.type === 'text' || + block.type === 'image' || + block.type === 'tool_result', + ) + if (!hadRenderable && raw.content) { + const entry: UserTextViewModel = { + kind: 'user_text', + id: userSegmentKey(raw, 0), + text: raw.content, + timestamp: raw.timestamp, + } + results.push(entry) + } + return results + } + + if (raw.content) { + const entry: UserTextViewModel = { + kind: 'user_text', + id: userSegmentKey(raw, 0), + text: raw.content, + timestamp: raw.timestamp, + } + results.push(entry) + } + return results +} + +function mapSystem(raw: RawMessage): SystemInfoViewModel[] { + if (!raw.content) { + return [] + } + return [ + { + kind: 'system_info', + id: raw.id, + text: raw.content, + level: systemLevelFromRaw(raw.level), + timestamp: raw.timestamp, + }, + ] +} + +// The `segmentKey` helper is exported for adapter-aware callers that want +// to derive stable IDs matching the existing render pipeline without +// re-implementing the format. +export const assistantSegmentId = segmentKey diff --git a/ui/src/adapters/permissions.ts b/ui/src/adapters/permissions.ts new file mode 100644 index 00000000..a9b6f843 --- /dev/null +++ b/ui/src/adapters/permissions.ts @@ -0,0 +1,74 @@ +/** + * Adapter for `permission_request` protocol messages. Normalizes the + * backend's plain-string option list into a structured form and assigns + * a `PermissionCategory` so the migrated permission UI can branch on a + * closed set instead of matching tool names itself. + */ + +import type { PermissionRequest } from '../store/app-state.js' +import type { + PermissionCategory, + PermissionOption, + PermissionRequestViewModel, +} from '../view-model/types.js' + +export function mapPermissionRequestToViewModel( + request: PermissionRequest, +): PermissionRequestViewModel { + return { + kind: 'permission_request', + toolUseId: request.toolUseId, + tool: request.tool, + command: request.command, + options: request.options.map(parsePermissionOption), + category: categorizePermissionTool(request.tool), + } +} + +/** + * Parse a single backend permission option string. + * + * The Rust permission system currently emits entries in either of these + * forms: + * "Yes" + * "Yes (y)" + * "Always allow for this session (a)" + * + * We split off a trailing `(x)` hotkey when present and otherwise keep + * the raw string as the label. The raw value is preserved as `value` so + * the decision sent back over IPC stays byte-identical to the backend + * option. + */ +export function parsePermissionOption(option: string): PermissionOption { + const match = /^(.*?)\s*\(([a-z0-9])\)\s*$/i.exec(option) + if (match) { + return { + value: option, + label: match[1]!.trim(), + hotkey: match[2]!.toLowerCase(), + } + } + return { value: option, label: option.trim() } +} + +export function categorizePermissionTool(tool: string): PermissionCategory { + const name = tool.toLowerCase() + if (name === 'bash' || name === 'powershell') { + return 'bash' + } + if ( + name === 'edit' || + name === 'multiedit' || + name === 'fileedit' || + name === 'notebookedit' + ) { + return 'file_edit' + } + if (name === 'write' || name === 'filewrite') { + return 'file_write' + } + if (name === 'webfetch' || name === 'web_fetch' || name === 'websearch') { + return 'web_fetch' + } + return 'tool_generic' +} diff --git a/ui/src/adapters/tool-input.ts b/ui/src/adapters/tool-input.ts new file mode 100644 index 00000000..2ce0b7c1 --- /dev/null +++ b/ui/src/adapters/tool-input.ts @@ -0,0 +1,58 @@ +import { truncate } from '../utils.js' + +/** + * Produce a readable one-line rendering of a tool's JSON input. Mirrors + * `describeToolInput` from `ui/src/store/message-model.ts` but is exposed + * via the adapter layer so migration slices can reuse the same heuristic + * without pulling in the whole message-model file. + */ +export function describeToolInput(input: unknown): string { + if (typeof input === 'string') { + return normalizeInline(input) + } + if (!input || typeof input !== 'object') { + return normalizeInline(String(input ?? '')) + } + + const record = input as Record + + if (typeof record.command === 'string' && record.command.trim()) { + return normalizeInline(record.command) + } + if (typeof record.file_path === 'string' && record.file_path.trim()) { + return normalizeInline(record.file_path) + } + if (typeof record.url === 'string' && record.url.trim()) { + return normalizeInline(record.url) + } + if (typeof record.pattern === 'string' && record.pattern.trim()) { + const path = + typeof record.path === 'string' && record.path.trim() + ? ` in ${record.path}` + : '' + return normalizeInline(`"${record.pattern}"${path}`) + } + if (typeof record.path === 'string' && record.path.trim()) { + return normalizeInline(record.path) + } + if (typeof record.prompt === 'string' && record.prompt.trim()) { + return normalizeInline(record.prompt) + } + if (typeof record.question === 'string' && record.question.trim()) { + return normalizeInline(record.question) + } + + try { + return normalizeInline(JSON.stringify(input)) + } catch { + return '(structured input)' + } +} + +export function summarizeToolInput(input: unknown, maxLength = 120): string { + return truncate(describeToolInput(input), maxLength) +} + +function normalizeInline(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} diff --git a/ui/src/adapters/tool-status.ts b/ui/src/adapters/tool-status.ts new file mode 100644 index 00000000..49579d19 --- /dev/null +++ b/ui/src/adapters/tool-status.ts @@ -0,0 +1,44 @@ +import type { ToolStatus } from '../view-model/types.js' + +/** + * Derive a `ToolStatus` from the raw `output` text and `is_error` flag that + * the backend sends in a `tool_result` message. Mirrors the heuristic in + * `ui/src/store/message-model.ts` so the adapter and the existing store + * stay consistent. + */ +export function classifyToolStatus( + output: string, + isError: boolean, +): ToolStatus { + const lowered = output.toLowerCase() + if ( + lowered.includes('interrupted by user') || + lowered.includes('cancelled') || + lowered.includes('aborted') + ) { + return 'cancelled' + } + if (isError) { + return 'error' + } + return 'success' +} + +/** Collapse a set of child statuses into one aggregate status, for grouped + * tool activities. Priority mirrors the store: error > running > pending + * > cancelled > success. */ +export function mergeToolStatuses(statuses: ToolStatus[]): ToolStatus { + if (statuses.some(status => status === 'error')) { + return 'error' + } + if (statuses.some(status => status === 'running')) { + return 'running' + } + if (statuses.some(status => status === 'pending')) { + return 'pending' + } + if (statuses.some(status => status === 'cancelled')) { + return 'cancelled' + } + return 'success' +} diff --git a/ui/src/components/PromptInput/index.ts b/ui/src/components/PromptInput/index.ts new file mode 100644 index 00000000..46989b91 --- /dev/null +++ b/ui/src/components/PromptInput/index.ts @@ -0,0 +1,10 @@ +/** + * Placeholder barrel for the composer/prompt-input migration slice. + * + * The active composer lives at `ui/src/components/InputPrompt.tsx`. New + * composer pieces adapted from `ui/examples/upstream-patterns/src/components/PromptInput/` + * should land here, wired to the current keybinding and store contracts. + * + * No active exports yet — see Issue 06 (composer and prompt input). + */ +export {} diff --git a/ui/src/components/StructuredDiff/index.ts b/ui/src/components/StructuredDiff/index.ts new file mode 100644 index 00000000..88c9a945 --- /dev/null +++ b/ui/src/components/StructuredDiff/index.ts @@ -0,0 +1,12 @@ +/** + * Placeholder barrel for the structured-diff migration slice. + * + * The existing active DiffView lives at + * `ui/src/components/DiffView.tsx`. New diff renderers adapted from the + * sample tree (`ui/examples/upstream-patterns/src/components/StructuredDiff/`) + * should be added here once they consume the normalized tool-result view + * model shape. + * + * No active exports yet — see Issues 03/04. + */ +export {} diff --git a/ui/src/components/mcp/index.ts b/ui/src/components/mcp/index.ts new file mode 100644 index 00000000..05360d80 --- /dev/null +++ b/ui/src/components/mcp/index.ts @@ -0,0 +1,12 @@ +/** + * Placeholder barrel for the MCP operational-panel migration slice. + * + * New MCP components should subscribe to the current store's + * `subsystems.mcp` snapshot and send `mcp_command` payloads through + * `useBackend()` from `ui/src/ipc/context.tsx`. Do not import + * directly from `ui/examples/upstream-patterns/`. + * + * No active exports yet — see Issue 05 (operational panels for MCP, + * LSP, team, and shell). + */ +export {} diff --git a/ui/src/components/messages/index.ts b/ui/src/components/messages/index.ts new file mode 100644 index 00000000..cb618b56 --- /dev/null +++ b/ui/src/components/messages/index.ts @@ -0,0 +1,11 @@ +/** + * Placeholder barrel for the message-presentation migration slice. + * + * Components landing here should consume the normalized view-model types + * from `ui/src/view-model` via the adapter in `ui/src/adapters/messages.ts`, + * not the raw `FrontendContentBlock` / `RawMessage` shapes. + * + * No active exports yet — see Issue 03 (message presentation slice) for + * the first real component. + */ +export {} diff --git a/ui/src/components/permissions/index.ts b/ui/src/components/permissions/index.ts new file mode 100644 index 00000000..0b3e24f8 --- /dev/null +++ b/ui/src/components/permissions/index.ts @@ -0,0 +1,10 @@ +/** + * Placeholder barrel for the permission UX migration slice. + * + * New permission components should consume + * `PermissionRequestViewModel` from `ui/src/view-model` via + * `mapPermissionRequestToViewModel` in `ui/src/adapters/permissions.ts`. + * + * No active exports yet — see Issue 04 (permission and tooling UX slice). + */ +export {} diff --git a/ui/src/components/shell/index.ts b/ui/src/components/shell/index.ts new file mode 100644 index 00000000..3db7bea1 --- /dev/null +++ b/ui/src/components/shell/index.ts @@ -0,0 +1,11 @@ +/** + * Placeholder barrel for the shell-output migration slice. + * + * Components migrated here should render `ToolResultViewModel` items + * produced by the adapter layer — with the Bash-specific presentation + * the sample tree uses — rather than rebuilding the normalization from + * `FrontendContentBlock[]`. + * + * No active exports yet — see Issue 05 (operational panels). + */ +export {} diff --git a/ui/src/components/teams/index.ts b/ui/src/components/teams/index.ts new file mode 100644 index 00000000..30d17a64 --- /dev/null +++ b/ui/src/components/teams/index.ts @@ -0,0 +1,10 @@ +/** + * Placeholder barrel for the team/coordination panel migration slice. + * + * New team components should subscribe to the store's `teams` record and + * drive `team_command` payloads through `useBackend()`. Do not import + * directly from `ui/examples/upstream-patterns/`. + * + * No active exports yet — see Issue 05 (operational panels). + */ +export {} diff --git a/ui/src/view-model/index.ts b/ui/src/view-model/index.ts new file mode 100644 index 00000000..8c74ff4e --- /dev/null +++ b/ui/src/view-model/index.ts @@ -0,0 +1,3 @@ +/** Entry point for the Lite view-model layer. Keep re-exports narrow so + * migration slices can `import { ... } from '../view-model'`. */ +export * from './types.js' diff --git a/ui/src/view-model/types.ts b/ui/src/view-model/types.ts new file mode 100644 index 00000000..a2b9b845 --- /dev/null +++ b/ui/src/view-model/types.ts @@ -0,0 +1,172 @@ +/** + * Normalized view-model types shared by the active OpenTUI Lite frontend and + * migration slices imported from `ui/examples/upstream-patterns/`. + * + * The view-model sits between the raw IPC protocol (`ui/src/ipc/protocol.ts`) + * plus the in-store `RawMessage` shape (`ui/src/store/message-model.ts`) and + * the component layer. Components that migrate from the sample tree should + * consume these types instead of touching `FrontendContentBlock`, + * `ConversationMessage`, or `PermissionRequest` directly. + * + * No runtime dependency on the sample tree. Keep this file pure types so it + * stays cheap to import from any adapter or component. + */ + +export type ToolStatus = + | 'pending' + | 'running' + | 'success' + | 'error' + | 'cancelled' + +/** Base64 image payload shared by assistant/user content blocks and tool + * results. Mirrors the fields the Rust backend forwards over IPC. */ +export interface ImageRef { + /** Base64 data without a `data:` prefix. */ + data: string + /** MIME media type (e.g. `image/png`). */ + mediaType: string + /** Decoded image size in bytes, when the backend knows it. */ + sizeBytes?: number +} + +export interface NormalizedTextBlock { + kind: 'text' + text: string +} + +export interface NormalizedThinkingBlock { + kind: 'thinking' + text: string + /** Present when the source block was `redacted_thinking`. */ + redacted: boolean +} + +export interface NormalizedImageBlock { + kind: 'image' + image: ImageRef +} + +export type NormalizedInlineBlock = + | NormalizedTextBlock + | NormalizedThinkingBlock + | NormalizedImageBlock + +/** A single assistant-turn segment, split on tool_use boundaries so each + * chunk is renderable as one bubble. */ +export interface AssistantSegmentViewModel { + /** Zero-based position within the parent assistant message. */ + index: number + text: string + thinking?: string + /** `true` when any portion of `thinking` came from a redacted block. */ + redactedThinking?: boolean +} + +export interface UserTextViewModel { + kind: 'user_text' + id: string + text: string + timestamp: number +} + +export interface UserImageViewModel { + kind: 'user_image' + id: string + image: ImageRef + timestamp: number +} + +export interface AssistantMessageViewModel { + kind: 'assistant_message' + id: string + segments: AssistantSegmentViewModel[] + timestamp: number + costUsd?: number +} + +export interface ToolUseViewModel { + kind: 'tool_use' + id: string + toolUseId: string + name: string + input: unknown + /** Full human-readable rendering of the key input argument. */ + inputDetail: string + /** One-line compacted version of `inputDetail`. */ + inputSummary: string + timestamp: number + status: ToolStatus +} + +export interface NormalizedToolResultContent { + /** Flattened text view. Empty string when the result was image-only. */ + text: string + /** Image attachments extracted from the tool result (e.g. browser MCP + * screenshots). */ + images: ImageRef[] +} + +export interface ToolResultViewModel { + kind: 'tool_result' + id: string + toolUseId: string + content: NormalizedToolResultContent + status: ToolStatus + isError: boolean + timestamp: number +} + +export type SystemLevel = + | 'info' + | 'warning' + | 'error' + | 'success' + | 'debug' + +export interface SystemInfoViewModel { + kind: 'system_info' + id: string + text: string + level: SystemLevel + timestamp: number +} + +/** + * Categorization for the permission request UI. Derived from the backend + * tool name by the adapter so components can dispatch on a small closed set + * instead of doing string matching themselves. + */ +export type PermissionCategory = + | 'bash' + | 'file_edit' + | 'file_write' + | 'web_fetch' + | 'tool_generic' + +export interface PermissionOption { + /** Value sent back over IPC as `permission_response.decision`. */ + value: string + /** Human-readable label derived from the backend option string. */ + label: string + /** Hotkey letter when the backend provided one (e.g. `y`, `n`, `a`). */ + hotkey?: string +} + +export interface PermissionRequestViewModel { + kind: 'permission_request' + toolUseId: string + tool: string + command: string + options: PermissionOption[] + category: PermissionCategory +} + +/** Discriminated union covering every renderable message-layer view model. */ +export type MessageViewModel = + | UserTextViewModel + | UserImageViewModel + | AssistantMessageViewModel + | ToolUseViewModel + | ToolResultViewModel + | SystemInfoViewModel