From 152426a6332f7aa619c887572830ace9dbdc15f3 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Sun, 30 Aug 2026 13:42:40 +0530 Subject: [PATCH 1/2] feat(tui): ingest image attachments through Runtime Host (#4171) Add a self-contained TUI image-attachment ingestion path that mirrors the Desktop composer's shape: a /attach pick stages only a client-local descriptor, the message-submit boundary owns Artifact ingestion, and the committed Message's AttachmentRef is the durable identity. - new tui-attachments.ts: draft-scoped staging (local descriptors and retained refs) plus the client-side capped file reader - /attach (TUI surface) and /detach : stage and remove client- locally with MIME/count guards; staged images render as a separate composer strip beside the pending queues - name + media type only, never a local path, and never inside the editable draft text - submit boundary: staged descriptors are read (byte-capped) and ingested through the Runtime Host artifact.ingest authority (begin/chunk/commit, abort on failure) in staging order, then ride content.attachments on turn.message.submit / turn.start; Send stays enabled the whole time - failed dispatch or refusal keeps the already-committed refs staged as retained items, so a retry reuses the exact Artifacts instead of orphaning a second copy; a mid-batch ingest failure deletes only the freshly committed refs and leaves staging untouched - Alt+Up / interrupt retraction: retracted queue entries return their attachments and are restaged as retained refs - resubmission reuses the same Artifacts with no re-ingest - cleanup semantics: local descriptors drop with the draft (editor clear, session switch, /new, graceful exit) with nothing to clean; retained refs are deleted best-effort via artifact.delete - transcript rendering: stored user messages, steering echoes, and the transient row render committed attachments as name + MIME + size chips - never a local path - tests: staging unit semantics and TUI flows (stage-then-submit, exact- ref dispatch, ingest failure, dispatch retry, /detach, retraction reuse, session-switch cleanup + recovery rendering, authority and non-image refusals, count cap) plus driver-level artifact.ingest and artifact.delete wire coverage Refs #4171 Refs #4079 Refs #4080 --- .../runtime-host-session-driver.test.ts | 298 ++++++- .../__tests__/tui-image-attachments.test.ts | 726 ++++++++++++++++++ packages/cli/src/pi-transcript.ts | 81 +- packages/cli/src/pi-tui-runner.ts | 218 +++++- .../cli/src/runtime-host-session-driver.ts | 109 ++- packages/cli/src/session-driver.ts | 39 +- packages/cli/src/tui-attachments.ts | 181 +++++ packages/cli/src/tui-copy-catalog.ts | 4 + packages/core/src/slash-command-catalog.ts | 2 + 9 files changed, 1595 insertions(+), 63 deletions(-) create mode 100644 packages/cli/src/__tests__/tui-image-attachments.test.ts create mode 100644 packages/cli/src/tui-attachments.ts diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 571afe359e..1d201752bf 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -35,7 +35,9 @@ import { RuntimeHostSubscriptionError, } from '@maka/runtime-host/client'; import { + ARTIFACT_INGEST_CHUNK_MAX_BYTES, SESSION_CONTINUITY_SCHEMA_VERSION, + type ArtifactIngestResult, type GoalProjection, type InteractionPendingSnapshot, type OperationInput, @@ -1691,6 +1693,7 @@ describe('Runtime Host Maka Session driver', () => { assert.deepEqual(await driver.retractQueued!(), { text: 'Later', messageIds: ['message-1'], + entries: [{ text: 'Later', attachments: [] }], }); assert.deepEqual( connection.requests.filter( @@ -1885,6 +1888,208 @@ describe('Runtime Host Maka Session driver', () => { ); }); + test('ingests an attachment through begin/chunk/commit and resolves the committed ref', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const content = new Uint8Array(ARTIFACT_INGEST_CHUNK_MAX_BYTES + 10).fill(7); + const committed = { + kind: 'image', + name: 'shot.png', + mimeType: 'image/png', + bytes: content.byteLength, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-1' }, + } as const; + connection.artifactIngestOutcomes.push( + { kind: 'upload_opened', uploadId: 'upload-1', nextOffset: 0 }, + { kind: 'chunk_accepted', uploadId: 'upload-1', nextOffset: ARTIFACT_INGEST_CHUNK_MAX_BYTES }, + { + kind: 'chunk_accepted', + uploadId: 'upload-1', + nextOffset: ARTIFACT_INGEST_CHUNK_MAX_BYTES + 10, + }, + { kind: 'committed', uploadId: 'upload-1', attachment: { ...committed } }, + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + const ref = await driver.ingestAttachment!({ + name: 'shot.png', + mimeType: 'image/png', + content, + }); + + assert.deepEqual(ref, committed); + const ingestRequests = connection.requests.filter( + ({ operation }) => operation === 'artifact.ingest', + ); + // begin → two chunks (one full window, one 10-byte tail) → commit. + assert.deepEqual( + ingestRequests.map(({ input }) => (input as { kind: string }).kind), + ['begin', 'chunk', 'chunk', 'commit'], + ); + const begin = ingestRequests[0]!.input as { + sessionId: string; + name: string; + mimeType: string; + totalBytes: number; + contentSha256: string; + }; + assert.equal(begin.sessionId, 'session-1'); + assert.equal(begin.name, 'shot.png'); + assert.equal(begin.totalBytes, content.byteLength); + assert.match(begin.contentSha256, /^sha256:[0-9a-f]{64}$/); + const firstChunk = ingestRequests[1]!.input as { offset: number; chunkBase64: string }; + assert.equal(firstChunk.offset, 0); + assert.equal( + Buffer.from(firstChunk.chunkBase64, 'base64').byteLength, + ARTIFACT_INGEST_CHUNK_MAX_BYTES, + ); + const tailChunk = ingestRequests[2]!.input as { offset: number }; + assert.equal(tailChunk.offset, ARTIFACT_INGEST_CHUNK_MAX_BYTES); + }); + + test('aborts a staged attachment upload when the Host refuses a chunk', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + connection.artifactIngestOutcomes.push( + { kind: 'upload_opened', uploadId: 'upload-1', nextOffset: 0 }, + new RuntimeHostOperationError('artifact.ingest', 'operation_conflict', 'bad offset'), + ); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + await assert.rejects( + driver.ingestAttachment!({ + name: 'shot.png', + mimeType: 'image/png', + content: new Uint8Array([1]), + }), + /bad offset/, + ); + assert.deepEqual( + connection.requests + .filter(({ operation }) => operation === 'artifact.ingest') + .map(({ input }) => (input as { kind: string }).kind), + ['begin', 'chunk', 'abort'], + ); + }); + + test('deletes an abandoned attachment artifact by its exact reference, tolerating not-found', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + const ref = { + kind: 'image', + name: 'gone.png', + mimeType: 'image/png', + bytes: 2, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-9' }, + } as const; + await driver.deleteAttachment!(ref); + const deleteRequest = connection.requests.find( + ({ operation }) => operation === 'artifact.delete', + ); + assert.deepEqual(deleteRequest?.input, { + sessionId: 'session-1', + artifactId: 'attachment-9', + }); + + connection.artifactDeleteOutcome = new RuntimeHostOperationError( + 'artifact.delete', + 'not_found', + 'Artifact was not found', + ); + await assert.doesNotReject(() => driver.deleteAttachment!(ref)); + assert.equal( + connection.requests.filter(({ operation }) => operation === 'artifact.delete').length, + 2, + ); + }); + + test('carries message attachments on the wire and groups retracted entries per message', async () => { + const subscription = new FakeSubscription(continuitySnapshot(), Promise.resolve([])); + const connection = new FakeConnection([subscription]); + const driver = createRuntimeHostMakaSessionDriver({ + connection: connection.value, + cwd: '/tmp', + llmConnectionSlug: 'openai-main', + model: 'gpt-5', + }); + await driver.switchSession('session-1'); + + const ref = { + kind: 'image', + name: 'wire.png', + mimeType: 'image/png', + bytes: 5, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-2' }, + } as const; + await driver.submitMessage('look [image 1]', { + messageId: 'message-attached', + placement: 'current_turn', + attachments: [ref], + }); + const submitRequest = connection.requests.find( + ({ operation }) => operation === 'turn.message.submit', + ); + assert.deepEqual( + (submitRequest?.input as { content: { attachments?: unknown[] } } | undefined)?.content + .attachments, + [ref], + ); + + connection.queueRetractOutcome = { + hostEpoch: 'host-1', + queueRevision: 4, + retracted: [ + { + entryId: 'entry-1', + messageId: 'message-attached', + content: { + text: 'look [image 1]', + attachments: [ref], + }, + placement: 'current_turn', + }, + { + entryId: 'entry-2', + messageId: 'message-plain', + content: { text: 'plain text' }, + placement: 'next_turn', + }, + ], + }; + const retracted = await driver.retractQueued!(); + assert.equal(retracted.text, 'look [image 1]\n\nplain text'); + assert.deepEqual( + retracted.entries?.map((entry) => ({ + text: entry.text, + attachments: entry.attachments, + })), + [ + { text: 'look [image 1]', attachments: [ref] }, + { text: 'plain text', attachments: [] }, + ], + ); + }); + test('projects the acknowledgement that releases a question answered through the Host', async () => { const subscription = new FakeSubscription( continuitySnapshot({ interactions: { pending: [pendingQuestion()] } }), @@ -2528,6 +2733,12 @@ class FakeConnection { /** Scripted goal.query results, shifted per call; defaults to null (no goal). */ readonly goalQueryResults: Array = []; readonly messageSubmitOutcomes: Array | Error> = []; + /** Scripted artifact.ingest results, shifted per call (begin → chunk… → commit/abort). */ + readonly artifactIngestOutcomes: Array = []; + /** The one scripted artifact.delete result (or error); defaults to a deletion. */ + artifactDeleteOutcome: { kind: 'deleted'; artifact: Record } | Error | undefined; + /** When set, replaces the default queue.retract projection. */ + queueRetractOutcome: unknown | undefined; /** * Operations held open by a test. The request is recorded on entry and then * waits, so a test can hold one round trip and observe what the driver does @@ -2701,7 +2912,7 @@ class FakeConnection { ); })() : operation === 'queue.retract' - ? { + ? (this.queueRetractOutcome ?? { hostEpoch: 'host-1', queueRevision: 3, retracted: [ @@ -2712,43 +2923,56 @@ class FakeConnection { placement: 'next_turn', }, ], - } - : operation === 'interaction.answer' - ? { - ...pendingQuestion(), - revision: 2, - status: 'answered', - outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, - } - : operation === 'interaction.query' - ? this.interactionQuery - : operation === 'turn.start' - ? this.skillStartBlocked - ? { - kind: 'blocked', - skillInvocation: { - loaded: [], - failed: [{ request: 'missing', reason: 'not_found' }], - receipts: [], - }, - } - : { - kind: 'started', - turn: { - sessionId: turnInput.sessionId, - turnId: turnInput.turnId, - runId: 'run-1', - status: 'running', - }, - skillInvocation: turnInput.content.text.includes('/skill:') - ? { - loaded: [{ id: 'alpha', name: 'Alpha' }], - failed: [], + }) + : operation === 'artifact.ingest' + ? (() => { + const outcome = this.artifactIngestOutcomes.shift(); + if (outcome instanceof Error) throw outcome; + if (!outcome) throw new Error('No scripted artifact.ingest outcome'); + return outcome; + })() + : operation === 'artifact.delete' + ? (() => { + const outcome = this.artifactDeleteOutcome; + if (outcome instanceof Error) throw outcome; + return outcome ?? { kind: 'deleted', artifact: {} }; + })() + : operation === 'interaction.answer' + ? { + ...pendingQuestion(), + revision: 2, + status: 'answered', + outcome: { kind: 'question_answer', answers: ['Yes'], committedAt: 75 }, + } + : operation === 'interaction.query' + ? this.interactionQuery + : operation === 'turn.start' + ? this.skillStartBlocked + ? { + kind: 'blocked', + skillInvocation: { + loaded: [], + failed: [{ request: 'missing', reason: 'not_found' }], receipts: [], - } - : { loaded: [], failed: [], receipts: [] }, - } - : undefined; + }, + } + : { + kind: 'started', + turn: { + sessionId: turnInput.sessionId, + turnId: turnInput.turnId, + runId: 'run-1', + status: 'running', + }, + skillInvocation: turnInput.content.text.includes('/skill:') + ? { + loaded: [{ id: 'alpha', name: 'Alpha' }], + failed: [], + receipts: [], + } + : { loaded: [], failed: [], receipts: [] }, + } + : undefined; if (result === undefined) throw new Error(`Unexpected fake operation: ${operation}`); return result as OperationOutput; } diff --git a/packages/cli/src/__tests__/tui-image-attachments.test.ts b/packages/cli/src/__tests__/tui-image-attachments.test.ts new file mode 100644 index 0000000000..76496be4db --- /dev/null +++ b/packages/cli/src/__tests__/tui-image-attachments.test.ts @@ -0,0 +1,726 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { describe, test } from 'node:test'; +import type { AttachmentRef, SessionEvent } from '@maka/core/events'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; +import type { TurnMessageSubmitResult } from '@maka/runtime-host/protocol'; +import { + isImageMimeType, + readFileCapped, + resolveLocalImageFile, + stagedImageLabel, + TuiImageStaging, +} from '../tui-attachments.js'; +import type { + MakaAttachedSessionTurn, + MakaPreparePromptOptions, + MakaPreparedSessionTurn, + MakaRetractedMessages, + MakaSessionDriver, + MakaSessionSwitchResult, + MakaSubmitMessageOptions, +} from '../session-driver.js'; +import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { PermissionMode } from '@maka/core/permission'; +import type { RewindTarget } from '../session-driver.js'; +import { runMakaPiTui } from '../pi-tui-runner.js'; +import { + FakeTerminal, + WAIT_BUDGET_MS, + findInputSurfaceRows, + plainTerminalOutput, + waitFor, + waitForTuiPaint, +} from './tui-terminal-mock.js'; + +const CLOSE_BUDGET_MS = Math.max(WAIT_BUDGET_MS, 500); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function fakeSessionSummary(sessionId: string, cwd = '/repo'): SessionSummary { + return { + id: sessionId, + cwd, + name: 'Attachment session', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'claude-subscription', + connectionLocked: false, + model: 'claude-sonnet-4-5', + permissionMode: 'ask', + }; +} + +function imageAttachmentRef(name: string, bytes: number, index: number): AttachmentRef { + return { + kind: 'image', + name, + mimeType: 'image/png', + bytes, + ref: { + kind: 'session_file', + sessionId: 'session-1', + relativePath: `attachment-${index}`, + }, + }; +} + +/** + * The one image-attachment driver for this file: it records what the TUI + * submits and answers exactly like the Runtime Host does — committed + * AttachmentRefs from ingest, per-entry attachment grouping on retraction. + */ +class AttachmentDriver implements MakaSessionDriver { + protected sessionId = 'session-1'; + private turnSeq = 0; + private ingestSeq = 0; + readonly ingests: Array<{ name: string; mimeType: string; content: Uint8Array }> = []; + readonly submits: Array<{ text: string; options: MakaSubmitMessageOptions }> = []; + readonly deleted: AttachmentRef[] = []; + readonly switchCalls: string[] = []; + /** Scripted ingest outcomes, shifted per call; empty means every ingest commits. */ + readonly ingestScript: Array = []; + /** Holds every ingest open until released, so tests can race Send against staging. */ + ingestGate: Promise | undefined; + /** When set, the next submit rejects once (a refused dispatch, not a lost one). */ + nextSubmitError: Error | undefined; + retracted: MakaRetractedMessages = { text: '', messageIds: [], entries: [] }; + switchedInMessages: StoredMessage[] = []; + startedTurnListener: ((turn: MakaAttachedSessionTurn) => void) | undefined; + + async ingestAttachment(input: { + name: string; + mimeType: string; + content: Uint8Array; + }): Promise { + this.ingests.push({ ...input }); + await this.ingestGate; + const outcome = this.ingestScript.shift(); + if (outcome) throw outcome; + this.ingestSeq += 1; + return imageAttachmentRef(input.name, input.content.byteLength, this.ingestSeq); + } + + async deleteAttachment(attachment: AttachmentRef): Promise { + this.deleted.push(attachment); + } + + async retractQueued(): Promise { + return this.retracted; + } + + preparePrompt( + prompt: string, + options: MakaPreparePromptOptions = {}, + ): Promise { + const turnId = options.turnId ?? `turn-${++this.turnSeq}`; + return Promise.resolve({ + sessionId: this.sessionId, + turnId, + events: this.turnEvents(turnId), + }); + } + + async submitMessage( + text: string, + options: MakaSubmitMessageOptions, + ): Promise { + if (this.nextSubmitError) { + const error = this.nextSubmitError; + this.nextSubmitError = undefined; + throw error; + } + this.submits.push({ text, options }); + const turn = await this.preparePrompt(text, { + turnId: options.messageId, + ...(options.modelText !== undefined ? { modelText: options.modelText } : {}), + ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), + }); + queueMicrotask(() => + this.startedTurnListener?.({ + ...turn, + messages: [], + summary: fakeSessionSummary(turn.sessionId), + }), + ); + return { disposition: 'turn_started', turnId: turn.turnId }; + } + + subscribeStartedTurns(listener: (turn: MakaAttachedSessionTurn) => void): () => void { + this.startedTurnListener = listener; + return () => { + if (this.startedTurnListener === listener) this.startedTurnListener = undefined; + }; + } + + async *turnEvents(turnId: string): AsyncIterable { + yield { type: 'complete', id: `complete-${turnId}`, turnId, ts: 1, stopReason: 'end_turn' }; + } + + async switchSession(sessionId: string): Promise { + this.switchCalls.push(sessionId); + return { + summary: fakeSessionSummary(sessionId), + messages: this.switchedInMessages, + }; + } + + async queryCancelledMessages(): Promise<{ cancelledMessageIds: string[] }> { + return { cancelledMessageIds: [] }; + } + + async listSessions(): Promise { + return []; + } + + async *compactSession(): AsyncIterable {} + + async stop(): Promise {} + async respondToSandboxBoundary(_response: SandboxBoundaryResponse): Promise {} + async renameSession(_name: string): Promise {} + async setModel(_model: string, _connectionSlug?: string, _connectionId?: string): Promise {} + async setPermissionMode(_mode: PermissionMode): Promise {} + async setThinkingLevel(_level: ThinkingLevel | undefined): Promise {} + async listRewindTargets(): Promise { + return []; + } + async rewindToTurn(_turnId: string): Promise { + throw new Error('rewind not supported in this fake'); + } + startNewSession(): Promise { + return Promise.resolve(); + } + getSessionId(): string | null { + return this.sessionId; + } +} + +function exitMaka(_terminal: FakeTerminal): void { + const previousExitCode = process.exitCode; + process.emit('SIGTERM'); + process.exitCode = previousExitCode; +} + +async function closeRunner(run: Promise): Promise { + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); +} + +function editorText(terminal: FakeTerminal): string { + const lines = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/); + // No settled input surface yet (the TUI may still be painting its first + // frames): an empty read keeps this safe to poll inside waitFor. + const rows = findInputSurfaceRows(lines); + if (!rows) return ''; + return lines + .slice(rows[0] + 1, rows[1]) + .join('\n') + .trim(); +} + +function startTui( + driver: MakaSessionDriver, + cwd: string, +): { + terminal: FakeTerminal; + run: Promise; +} { + const terminal = new FakeTerminal(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd, + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + // The platform default disables taskbar progress on Windows, which would + // leave progressStates empty and turn-lifecycle waits blind. + taskbarProgress: true, + turnActivity: { activities: new SessionActivityRegistry() }, + }); + return { terminal, run }; +} + +describe('TUI image staging logic', () => { + test('staging keeps order; descriptors become retained refs in place', () => { + const staging = new TuiImageStaging(); + staging.stageFile({ name: 'a.png', mimeType: 'image/png', path: '/repo/a.png', bytes: 10 }); + staging.stageFile({ name: 'b.jpg', mimeType: 'image/jpeg', path: '/repo/b.jpg', bytes: 20 }); + assert.deepEqual( + staging.list().map((item) => (item.kind === 'local' ? item.name : item.attachment.name)), + ['a.png', 'b.jpg'], + ); + + const first = staging.list()[0]!; + const ref = imageAttachmentRef('a.png', 10, 1); + staging.replace(first.stagingKey, ref); + assert.deepEqual( + staging.list().map((item) => item.kind), + ['retained', 'local'], + ); + const replaced = staging.list()[0]!; + assert.ok(replaced.kind === 'retained' && replaced.attachment === ref); + + const removed = staging.remove(staging.list()[1]!.stagingKey); + assert.equal(removed?.kind, 'local'); + assert.equal(staging.size, 1); + + const cleared = staging.clear(); + assert.equal(cleared.length, 1); + assert.equal(staging.size, 0); + }); + + test('stageRetained registers an already-committed ref for reuse', () => { + const staging = new TuiImageStaging(); + const ref = imageAttachmentRef('kept.png', 9, 1); + staging.stageRetained(ref); + assert.equal(staging.size, 1); + const item = staging.list()[0]!; + assert.equal(item.kind, 'retained'); + assert.ok(item.kind === 'retained' && item.attachment === ref); + }); + + test('strip labels carry name, media type, and size — never a path', () => { + const staging = new TuiImageStaging(); + staging.stageFile({ + name: 'a.png', + mimeType: 'image/png', + path: '/secret/dir/a.png', + bytes: 2048, + }); + const item = staging.list()[0]!; + assert.equal(stagedImageLabel(item), '📎 a.png · image/png · 2.0 KB'); + assert.ok(!stagedImageLabel(item).includes('/secret')); + }); + + test('readFileCapped reads a small file and rejects an oversized one', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + try { + const path = join(dir, 'small.png'); + await writeFile(path, new Uint8Array([1, 2, 3, 4])); + const bytes = await readFileCapped(path, 4); + assert.equal(bytes.byteLength, 4); + + await assert.rejects(readFileCapped(path, 3), /attachment limit/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test('resolveLocalImageFile resolves relative paths and derives name and MIME', () => { + const file = resolveLocalImageFile('shot.PNG', '/repo'); + // Path shape is platform-dependent (Windows resolves a drive root); the + // contract is: the draft's folder wins, and the file name + MIME derive + // from the path itself. + assert.ok(file.absolutePath.endsWith('shot.PNG'), file.absolutePath); + assert.equal(file.name, 'shot.PNG'); + assert.equal(file.mimeType, 'image/png'); + assert.equal(isImageMimeType(file.mimeType, file.name), true); + assert.equal(isImageMimeType('text/plain', 'notes.txt'), false); + }); +}); + +describe('TUI image attachments through Runtime Host', () => { + test('/attach stages a local descriptor only: no ingest, no editor text, strip chip', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const imagePath = join(dir, 'photo.png'); + await writeFile(imagePath, new Uint8Array([137, 80, 78, 71])); + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${imagePath}`); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Staged:')); + // Ingest belongs to the submit boundary: attaching must not touch the Host. + assert.deepEqual(driver.ingests, []); + assert.deepEqual(driver.submits, []); + const screen = plainTerminalOutput(terminal.screenOutput()); + assert.ok(screen.includes('📎 photo.png')); + assert.ok(!screen.includes(dir), 'no local path may leak into the UI'); + // The editable draft text stays empty — staged images render separately. + assert.equal(editorText(terminal), ''); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('submit ingests at the submit boundary and dispatches the exact committed refs', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const firstPath = join(dir, 'one.png'); + const secondPath = join(dir, 'two.png'); + await writeFile(firstPath, new Uint8Array([1, 2])); + await writeFile(secondPath, new Uint8Array([3, 4, 5])); + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${secondPath}`); + terminal.input('\r'); + terminal.input(`/attach ${firstPath}`); + terminal.input('\r'); + await waitFor( + () => plainTerminalOutput(terminal.screenOutput()).split('Staged:').length >= 3, + ); + + terminal.input('what are these'); + terminal.input('\r'); + await waitFor(() => driver.ingests.length === 2); + // Ingest order follows staging order. + assert.deepEqual( + driver.ingests.map(({ name }) => name), + ['two.png', 'one.png'], + ); + await waitFor(() => driver.submits.length === 1); + const submit = driver.submits[0]!; + assert.deepEqual(submit.options.attachments, [ + imageAttachmentRef('two.png', 3, 1), + imageAttachmentRef('one.png', 2, 2), + ]); + assert.equal(submit.text, 'what are these'); + // Success consumes staging: the Message owns the refs now. + await waitFor(() => !plainTerminalOutput(terminal.screenOutput()).includes('Staged:')); + assert.deepEqual(driver.deleted, []); + // The transient row renders the committed refs as chips. + assert.ok(plainTerminalOutput(terminal.screenOutput()).includes('📎 two.png')); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('a failed ingest inside the submit boundary dispatches nothing and orphans nothing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const firstPath = join(dir, 'good.png'); + const secondPath = join(dir, 'bad.png'); + await writeFile(firstPath, new Uint8Array([1])); + await writeFile(secondPath, new Uint8Array([2])); + const driver = new AttachmentDriver(); + driver.ingestScript.push(undefined, new Error('Host rejected the upload')); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${firstPath}`); + terminal.input('\r'); + terminal.input(`/attach ${secondPath}`); + terminal.input('\r'); + await waitFor( + () => plainTerminalOutput(terminal.screenOutput()).split('Staged:').length >= 3, + ); + + terminal.input('send both'); + terminal.input('\r'); + await waitFor(() => driver.ingests.length === 2); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Host rejected the upload'), + ); + assert.deepEqual(driver.submits, [], 'the Message must not dispatch half-ingested'); + // The artifact committed by the first ingest would be orphaned: it is + // deleted best-effort, and staging keeps both items for a retry. + await waitFor(() => driver.deleted.length === 1); + assert.deepEqual(driver.deleted, [imageAttachmentRef('good.png', 1, 1)]); + const screen = plainTerminalOutput(terminal.screenOutput()); + assert.ok(screen.split('Staged:').length - 1 >= 2); + // The failed text is recoverable from editor history; the draft text and + // both staged items survive for the retry. + assert.ok(screen.includes('Host rejected the upload')); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('a failed dispatch keeps the retained refs, and the retry reuses the same Artifacts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const imagePath = join(dir, 'retry.png'); + await writeFile(imagePath, new Uint8Array([1])); + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${imagePath}`); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Staged:')); + + driver.nextSubmitError = new Error('Host refused the message'); + terminal.input(' send me'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Host refused the message'), + ); + await waitFor(() => driver.ingests.length === 1); + assert.equal(driver.submits.length, 0); + const committedRef = imageAttachmentRef('retry.png', 1, 1); + // The ingested ref is now retained in staging — nothing was orphaned, and + // the strip still shows it. + assert.ok(plainTerminalOutput(terminal.screenOutput()).includes('📎 retry.png')); + + driver.nextSubmitError = undefined; + terminal.input('\x1b[A'); // Up: recall the failed draft from history + await waitFor(() => editorText(terminal).includes('send me')); + terminal.input('\r'); + await waitFor(() => driver.submits.length === 1); + // The retry reuses the exact committed Artifact — no second ingest. + assert.equal(driver.ingests.length, 1); + assert.deepEqual(driver.submits[0]?.options.attachments, [committedRef]); + assert.deepEqual(driver.deleted, []); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('/detach removes a staged image client-side without touching the Host', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const firstPath = join(dir, 'a.png'); + const secondPath = join(dir, 'b.png'); + await writeFile(firstPath, new Uint8Array([1])); + await writeFile(secondPath, new Uint8Array([2])); + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${firstPath}`); + terminal.input('\r'); + terminal.input(`/attach ${secondPath}`); + terminal.input('\r'); + await waitFor( + () => plainTerminalOutput(terminal.screenOutput()).split('Staged:').length >= 3, + ); + + terminal.input('/detach 1'); + terminal.input('\r'); + await waitFor( + () => plainTerminalOutput(terminal.screenOutput()).split('Staged:').length - 1 === 1, + ); + const screen = plainTerminalOutput(terminal.screenOutput()); + assert.ok(screen.includes('📎 b.png')); + assert.ok(!screen.includes('📎 a.png')); + assert.deepEqual(driver.deleted, [], 'local descriptors leave nothing to clean'); + + terminal.input('/detach 9'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('/detach '), + ); + + terminal.input('text only'); + terminal.input('\r'); + await waitFor(() => driver.submits.length === 1); + assert.deepEqual(driver.submits[0]?.options.attachments, [imageAttachmentRef('b.png', 1, 1)]); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('a retracted queued message restages its committed attachments and resubmission reuses them', async () => { + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, '/repo'); + try { + await waitForTuiPaint(terminal); + // Simulate the Host: a queued message carrying a committed image comes + // back with Alt+↑. + const ref = imageAttachmentRef('queued.png', 5, 1); + driver.retracted = { + text: 'look at this', + messageIds: ['m-1'], + entries: [{ text: 'look at this', attachments: [ref] }], + }; + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('📎 queued.png')); + await waitFor(() => editorText(terminal).includes('look at this')); + + terminal.input('\r'); + await waitFor(() => driver.submits.length === 1); + // The retained ref rides the new Message untouched — no re-ingest. + assert.deepEqual(driver.ingests, []); + assert.deepEqual(driver.submits[0]?.options.attachments, [ref]); + } finally { + exitMaka(terminal); + await closeRunner(run); + } + }); + + test('switching sessions abandons the staged draft and deletes retained Artifacts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const imagePath = join(dir, 'stale.png'); + await writeFile(imagePath, new Uint8Array([1, 2])); + const driver = new AttachmentDriver(); + const committed = imageAttachmentRef('kept.png', 9, 1); + driver.switchedInMessages = [ + { + type: 'user', + id: 'stored-1', + turnId: 'turn-stored', + ts: 1, + text: 'earlier photo', + attachments: [committed], + } satisfies StoredMessage, + ]; + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${imagePath}`); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Staged:')); + + // A retracted image is also staged before the switch: its committed + // Artifact must be cleaned up, the local descriptor just drops. + const retained = imageAttachmentRef('retracted.png', 4, 2); + driver.retracted = { + text: 'recalled', + messageIds: ['m-1'], + entries: [{ text: 'recalled', attachments: [retained] }], + }; + terminal.input('\x1b[1;3A'); // Alt+Up + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('📎 retracted.png'), + ); + + terminal.input('\x03'); // Ctrl+C: destroy the draft + await waitFor(() => driver.deleted.length === 1); + assert.deepEqual(driver.deleted, [retained]); + + terminal.input('/session session-2'); + terminal.input('\r'); + await waitFor(() => driver.switchCalls.includes('session-2')); + // Recovery: the resumed transcript renders the committed attachment as a + // chip — name and media type, never a local path. + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('📎 kept.png')); + const screen = plainTerminalOutput(terminal.screenOutput()); + assert.ok(!screen.includes(dir), 'no local path may leak into the transcript'); + + // A fresh submit in the adopted session carries no stale references. + terminal.input('after switch'); + terminal.input('\r'); + await waitFor(() => driver.submits.length === 1); + assert.equal(driver.submits[0]?.options.attachments, undefined); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('a driver without an attachment authority refuses /attach up front', async () => { + const driver = new AttachmentDriver(); + // A connection without the artifact authority: the optional methods are + // simply absent from the surface it exposes. + const stripped: MakaSessionDriver = new Proxy(driver, { + get(target, property, receiver) { + if (property === 'ingestAttachment' || property === 'deleteAttachment') return undefined; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const { terminal, run } = startTui(stripped, '/repo'); + try { + await waitForTuiPaint(terminal); + terminal.input('/attach /tmp/whatever.png'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('attachment authority'), + ); + assert.equal(stripped.ingestAttachment, undefined); + } finally { + exitMaka(terminal); + await closeRunner(run); + } + }); + + test('a non-image path is refused before anything is staged', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const notesPath = join(dir, 'notes.txt'); + await writeFile(notesPath, 'plain text'); + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + terminal.input(`/attach ${notesPath}`); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.screenOutput()).includes('Only image files'), + ); + assert.deepEqual(driver.ingests, []); + assert.ok(!plainTerminalOutput(terminal.screenOutput()).includes('Staged:')); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); + + test('at most MAX_ATTACHMENT_COUNT images can ride one draft', async () => { + const dir = await mkdtemp(join(tmpdir(), 'maka-tui-attach-')); + const paths: string[] = []; + for (let index = 0; index < 9; index += 1) { + const path = join(dir, `img${index}.png`); + await writeFile(path, new Uint8Array([index])); + paths.push(path); + } + const driver = new AttachmentDriver(); + const { terminal, run } = startTui(driver, dir); + try { + await waitForTuiPaint(terminal); + for (const path of paths) { + terminal.input(`/attach ${path}`); + terminal.input('\r'); + } + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('At most 8')); + const screen = plainTerminalOutput(terminal.screenOutput()); + assert.equal(screen.split('Staged:').length - 1, 8); + } finally { + exitMaka(terminal); + await closeRunner(run); + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index bbbec37545..9f807ad8f6 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -19,6 +19,7 @@ import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; import type { + AttachmentRef, ProviderRetryEvent, ProviderRetryScheduledEvent, SandboxBoundaryRequestEvent, @@ -113,6 +114,12 @@ export interface MakaPiTranscriptState { */ steering: string[]; followup: string[]; + /** + * Client-local mirror of the draft's staged images (#4171), rendered as + * `Staged:` chips beside the pending bar. Labels carry name + media type — + * never a local path. + */ + stagedImages: string[]; /** Current non-durable provider retry progress for the activity strip. */ providerRetry?: ProviderRetryCountdown; } @@ -161,7 +168,14 @@ const LIVE_TOOL_BUFFER_MAX_CHARS = 64 * 1024; const LIVE_TOOL_BUFFER_MAX_CHUNKS = 512; export type MakaPiTranscriptEntry = - | { kind: 'user'; messageId: string; text: string; transient?: boolean } + | { + kind: 'user'; + messageId: string; + text: string; + transient?: boolean; + /** Committed Session attachments this message carries; rendered as chips, never paths. */ + attachments?: readonly AttachmentRef[]; + } | { kind: 'legacy_automation'; text: string } | { kind: 'goal_continuation'; text: string } | { kind: 'assistant'; messageId: string; text: string } @@ -237,6 +251,7 @@ export function createMakaPiTranscriptState(): MakaPiTranscriptState { usage: { costUsd: 0, cacheHitInput: 0, cacheMissInput: 0 }, steering: [], followup: [], + stagedImages: [], }; } @@ -266,12 +281,14 @@ export function appendUserPrompt( text: string, messageId: string, transient = false, + attachments?: readonly AttachmentRef[], ): void { const entry = { kind: 'user', messageId, text, ...(transient ? { transient: true } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), } as const; const existingIndex = state.entries.findIndex( (candidate) => candidate.kind === 'user' && candidate.messageId === messageId, @@ -970,6 +987,7 @@ export function applyMakaSessionEventToTranscript( entry.messageId === event.messageId && entry.transient === true, ), + event.content.attachments, ); break; @@ -1073,6 +1091,9 @@ function storedMessagesToTranscriptEntries( kind: 'user', messageId: message.id, text: message.displayText ?? message.text, + ...(message.attachments && message.attachments.length > 0 + ? { attachments: message.attachments } + : {}), }); } break; @@ -1516,7 +1537,7 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) const lines = (() => { switch (entry.kind) { case 'user': - return renderUserBlock(entry.text, contentWidth); + return renderUserBlock(entry.text, contentWidth, entry.attachments); case 'legacy_automation': return renderLegacyAutomationBlock(entry.text, contentWidth); case 'goal_continuation': @@ -1549,9 +1570,10 @@ function isBlankTranscriptLine(line: string): boolean { function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): string { switch (entry.kind) { - // User text is immutable, so length is a safe change key. + // User text is immutable, so length is a safe change key; attachments are + // immutable per message too, so their committed identity participates once. case 'user': - return `user|${width}|${entry.text.length}`; + return `user|${width}|${entry.text.length}|${entry.attachments?.length ?? 0}`; case 'legacy_automation': return `legacy_automation|${width}|${entry.text}`; case 'goal_continuation': @@ -1876,19 +1898,30 @@ export function renderMakaPiPendingQueue( width: number, platform: NodeJS.Platform = process.platform, ): string[] { - if (state.steering.length === 0 && state.followup.length === 0) { + if ( + state.stagedImages.length === 0 && + state.steering.length === 0 && + state.followup.length === 0 + ) { return []; } const safeWidth = Math.max(1, width); - const steering = state.steering; - const followup = state.followup; const lines: string[] = []; - for (const text of steering) { + // Staged images render beside the pending queues: they belong to the same + // "about to be submitted" surface, and the strip is separate from the + // editable draft text (matching the Desktop composer's staged cards). + for (const staged of state.stagedImages) { + lines.push(fitLine(`${ansi.accent('Staged:')} ${ansi.dim(staged)}`, safeWidth)); + } + if (state.stagedImages.length > 0) { + lines.push(fitLine(ansi.dim('/detach 移除一张已附加的图片'), safeWidth)); + } + for (const text of state.steering) { lines.push( fitLine(`${ansi.accent('Steering:')} ${ansi.dim(firstLinePreview(text))}`, safeWidth), ); } - for (const text of followup) { + for (const text of state.followup) { lines.push(fitLine(`${ansi.dim('Queued:')} ${ansi.dim(firstLinePreview(text))}`, safeWidth)); } lines.push( @@ -2127,13 +2160,35 @@ function pushShellRunSettledNotice(state: MakaPiTranscriptState, entry: MakaPiTo }); } -/** A user turn: a dim `>` quote prefix per line, no speaker label. */ -function renderUserBlock(text: string, width: number): string[] { - if (!text.trim()) return []; +/** A user turn: a dim `>` quote prefix per line, no speaker label. Committed + * Session attachments render as a dim chip line per attachment — names and + * media types only, never a local path or a bytes payload. */ +function renderUserBlock( + text: string, + width: number, + attachments?: readonly AttachmentRef[], +): string[] { + if (!text.trim() && !(attachments && attachments.length > 0)) return []; const prefix = ansi.dim('>'); // renderIndented reserves a 2-column gutter; reuse it and swap the two // leading spaces for `> ` so wrapped lines stay aligned under the prefix. - return renderIndented(text, width, 2).map((line) => fitLine(`${prefix} ${line.slice(2)}`, width)); + const body = text.trim() + ? renderIndented(text, width, 2).map((line) => fitLine(`${prefix} ${line.slice(2)}`, width)) + : []; + const chips = (attachments ?? []).map((attachment) => + fitLine(`${prefix} ${ansi.dim(userAttachmentChipLabel(attachment))}`, width), + ); + return [...body, ...chips]; +} + +function userAttachmentChipLabel(attachment: AttachmentRef): string { + return `📎 ${attachment.name} · ${attachment.mimeType} · ${formatAttachmentBytes(attachment.bytes)}`; +} + +function formatAttachmentBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** Provenance header + indented body for non-human-authored prompts. */ diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 48dfa5908d..ab4c27b0c7 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -46,7 +46,8 @@ import { slashCommandsForSurface, type SlashCommandIdForSurface, } from '@maka/core/slash-command-catalog'; -import { type ShellRunUpdate } from '@maka/core/events'; +import { type AttachmentRef, type ShellRunUpdate } from '@maka/core/events'; +import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import { latestAssistantModelId, type SessionSummary, @@ -88,10 +89,18 @@ import { inspectSessionResumeAvailability, type MakaAttachedSessionTurn, type MakaPreparedSessionTurn, + type MakaRetractedMessages, type MakaSessionDriver, type MakaSideConversationParentStatus, type MakaSessionSwitchResult, } from './session-driver.js'; +import { + isImageMimeType, + readFileCapped, + resolveLocalImageFile, + stagedImageLabel, + TuiImageStaging, +} from './tui-attachments.js'; import { SafeBoundaryResumeParkedError } from './runtime-host-session-driver.js'; import { appendExpansionCollapseConfirmation, @@ -879,6 +888,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (closed) return; closed = true; restoreTerminal(); + // Abandoned-draft cleanup: retained refs (retracted-queue artifacts) leave + // no orphans behind on a graceful exit. Best-effort — the connection may + // already be tearing down, and the Host's artifact list remains the + // authority for anything that slips through. + resetImageStaging(); if (error) rejectClosed(error); else resolveClosed(); // Runtime stop is best-effort after the shell has its terminal back. A @@ -958,6 +972,106 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { restoreDraft(joined); }; + // Draft-scoped staging for image attachments (#4171), mirroring the Desktop + // composer: a `/attach` pick stages only a client-local descriptor (the + // editable draft text is never touched); the bytes are read and ingested + // through the Runtime Host artifact authority inside the submit boundary, + // and the committed Message's AttachmentRef is the durable identity. The + // staged list renders as a separate strip — never as text in the draft. + const imageAttachments = new TuiImageStaging(); + const deleteAttachmentBestEffort = (attachment: AttachmentRef): void => { + input.driver.deleteAttachment?.(attachment).catch(() => undefined); + }; + /** Mirror the staged list into the composer strip above the editor. */ + const syncStagedImagesStrip = (): void => { + state.stagedImages = imageAttachments.list().map(stagedImageLabel); + }; + /** + * Abandon every staged image. Nothing was ingested at stage time, so only + * retained refs (retracted-queue artifacts) hold committed bytes — those are + * deleted best-effort; local descriptors just drop. + */ + const resetImageStaging = (): void => { + for (const staged of imageAttachments.clear()) { + if (staged.kind === 'retained') deleteAttachmentBestEffort(staged.attachment); + } + syncStagedImagesStrip(); + }; + + // `/attach `: the explicit image attachment action. Stage-time work is + // descriptor-only (resolve + MIME check); reading bytes and ingesting happen + // at submit, so abandoning the draft needs no Host round trip. + const attachImage = (rawPath: string): void => { + const path = rawPath.trim(); + if (!path) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Usage: /attach — the image rides the next message you send.', + }); + requestRender(); + return; + } + if (!input.driver.ingestAttachment) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Image attachments need a Runtime Host connection; this session has no attachment authority.', + }); + requestRender(); + return; + } + if (imageAttachments.size >= MAX_ATTACHMENT_COUNT) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: `At most ${MAX_ATTACHMENT_COUNT} attachments can ride one message.`, + }); + requestRender(); + return; + } + const file = resolveLocalImageFile(path, cwd); + if (!isImageMimeType(file.mimeType, file.name)) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: `Only image files can be attached in the terminal (png, jpg, gif, webp, bmp): ${file.name}`, + }); + requestRender(); + return; + } + imageAttachments.stageFile({ + name: file.name, + mimeType: file.mimeType, + path: file.absolutePath, + bytes: 0, + }); + syncStagedImagesStrip(); + requestRender(); + }; + + // `/detach `: remove one staged image from the draft. Nothing was + // ingested yet, so removal is purely client-local. + const detachImage = (rawTail: string): void => { + const index = Number.parseInt(rawTail.trim(), 10); + if (!Number.isInteger(index) || index < 1 || index > imageAttachments.size) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: + imageAttachments.size === 0 + ? 'No images are staged on the current draft.' + : `Usage: /detach — 1..${imageAttachments.size}.`, + }); + requestRender(); + return; + } + const removed = imageAttachments.list()[index - 1]!; + imageAttachments.remove(removed.stagingKey); + syncStagedImagesStrip(); + requestRender(); + }; + const pendingEnqueueTasks = new Set>(); const trackEnqueue = (task: Promise): void => { pendingEnqueueTasks.add(task); @@ -1062,8 +1176,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (index >= 0) state.entries.splice(index, 1); }; - const acceptRetraction = (retracted: { text: string; messageIds: readonly string[] }) => { + const acceptRetraction = (retracted: MakaRetractedMessages) => { for (const messageId of retracted.messageIds) removeTransientUserMessage(messageId); + // Retracted messages carry their committed attachments back: re-stage each + // reference as a retained item, so resubmitting reuses the exact Artifact + // instead of re-ingesting the same bytes into a second copy. + for (const entry of retracted.entries ?? []) { + for (const attachment of entry.attachments) imageAttachments.stageRetained(attachment); + } + if ((retracted.entries ?? []).some((entry) => entry.attachments.length > 0)) { + syncStagedImagesStrip(); + } refillEditorFromQueues(retracted.text); }; @@ -1081,9 +1204,51 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const messageId = randomUUID(); appendUserPrompt(state, text, messageId, true); requestRender(); - const task = input.driver - .submitMessage(text, { messageId, placement, ...options }) - .then((result) => { + const task = (async () => { + // The message-submit boundary owns ingestion, exactly like the Desktop + // composer: staged descriptors are read and ingested here, in staging + // order, before the Message dispatches. Each committed ref replaces its + // descriptor in staging, so a failed dispatch or refusal retries with the + // same Artifact instead of orphaning a second copy of the bytes. + const attachments: AttachmentRef[] = []; + const freshlyIngested: AttachmentRef[] = []; + for (const item of imageAttachments.list()) { + if (item.kind === 'retained') { + attachments.push(item.attachment); + continue; + } + try { + const content = await readFileCapped(item.path, MAX_ATTACHMENT_BYTES); + const attachment = await input.driver.ingestAttachment!({ + name: item.name, + mimeType: item.mimeType, + content, + }); + imageAttachments.replace(item.stagingKey, attachment); + attachments.push(attachment); + freshlyIngested.push(attachment); + } catch (error) { + // Nothing dispatched, so this attempt's Artifacts would be orphaned: + // delete them and leave staging untouched for the retry. + for (const ref of freshlyIngested) deleteAttachmentBestEffort(ref); + removeTransientUserMessage(messageId); + reportError(error); + requestRender(); + return; + } + } + if (attachments.length > 0) { + appendUserPrompt(state, text, messageId, true, attachments); + syncStagedImagesStrip(); + requestRender(); + } + try { + const result = await input.driver.submitMessage(text, { + messageId, + placement, + ...(attachments.length > 0 ? { attachments } : {}), + ...options, + }); // Runtime Host resolved the Skills this Message named and refused it. // Retire the row it belongs to and report the failure in its place. if (result?.disposition === 'blocked') { @@ -1091,6 +1256,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { showSkillInvocation(result.skillInvocation); return; } + // The Message is the attachments' durable owner now. + imageAttachments.clear(); + syncStagedImagesStrip(); // It admitted them instead. The receipt says what was loaded and what // was dropped, and the submit answer is the only place it appears: the // Turn arrives through the started-Turn subscription, which carries @@ -1099,16 +1267,17 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const { loaded, failed } = result.skillInvocation; if (loaded.length > 0 || failed.length > 0) showSkillInvocation(result.skillInvocation); } - }) - .catch((error) => { + } catch (error) { // The Message never became anything, so its row goes with the failure - // notice that replaces it. The text stays in editor history for a retry. + // notice that replaces it. The text stays in editor history for a + // retry, and staging keeps the retained refs so that retry reuses the + // exact Artifacts it already committed. removeTransientUserMessage(messageId); reportError(error); - }) - .finally(() => { + } finally { requestRender(); - }); + } + })(); trackEnqueue(task); }; @@ -1607,6 +1776,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { messages, activeTurn, }: MakaSessionSwitchResult): Promise => { + // The draft's staged images belong to the Session they were staged into: + // a switch abandons them rather than submitting another Session's Artifact + // references, which the Host would refuse. Retained refs (retracted-queue + // artifacts) are deleted best-effort; descriptors just drop. + resetImageStaging(); adoptSessionMetadata(summary, false); replaceTranscript(messages); if (connectionIdentityNotice) { @@ -2641,6 +2815,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { permissionMode = input.driver.getPermissionMode?.() ?? input.permissionMode; attention.setBaseTitle(input.title); shellRunHydration.reset(); + resetImageStaging(); // Fresh transcript for the fresh session; the next prompt creates it on disk. // Leave the transcript empty (no confirmation notice) so /new opens on the // same welcome block as a cold start — the welcome block is the "fresh @@ -3232,6 +3407,25 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }, }, + attach: { + description: primaryGuidance.commands.attach, + // Staging is client-local (resolve + MIME check + list entry); it + // neither races the running Turn nor enters runControl, so an image can + // be staged mid-turn and ride the next steering message. + midTurn: 'local', + run: (_parts: string[], rawTail: string | undefined) => { + attachImage(rawTail ?? ''); + }, + }, + detach: { + description: primaryGuidance.commands.detach, + // Same client-local staging surface as /attach: dropping a staged image + // is a list mutation with no Host round trip. + midTurn: 'local', + run: (_parts: string[], rawTail: string | undefined) => { + detachImage(rawTail ?? ''); + }, + }, compact: { description: primaryGuidance.commands.compact, midTurn: 'refuse', @@ -3827,6 +4021,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (!turnRunning && matchesKey(data, Key.ctrl('c')) && editor.getText().length > 0) { lastIdleCtrlCAt = 0; editor.setText(''); + // The draft is destroyed by choice: staged images abandon with it. + resetImageStaging(); requestRender(); return { consume: true }; } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 36ba9707c8..c3a9caac75 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -17,7 +17,7 @@ * under the License. */ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { join } from 'node:path'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; @@ -31,6 +31,7 @@ import { import { markPersisted } from '@maka/core/persisted-value'; import { type ActiveInteractionRequestEvent, + type AttachmentRef, type SessionEvent, type ShellRunSnapshotResult, type ShellRunUpdate, @@ -71,6 +72,7 @@ import { SessionUpdateResult, SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, WorkspaceTarget, + ARTIFACT_INGEST_CHUNK_MAX_BYTES, type GoalControlAction, type GoalProjection, type SessionContinuitySnapshot, @@ -353,6 +355,9 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { content: { text: modelText, ...(modelText === prompt ? {} : { displayText: prompt }), + ...(options.attachments && options.attachments.length > 0 + ? { attachments: [...options.attachments] } + : {}), }, ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), ...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}), @@ -535,6 +540,9 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { content: { text: modelText, ...(modelText === text ? {} : { displayText: text }), + ...(options.attachments && options.attachments.length > 0 + ? { attachments: [...options.attachments] } + : {}), }, placement: options.placement, ...(options.turnOrchestration ? { turnOrchestration: options.turnOrchestration } : {}), @@ -557,6 +565,101 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return this.#request('turn.message.query', { sessionId, messageIds }); } + /** + * Ingest one attachment through the Host-owned artifact authority + * (`artifact.ingest`): the bytes are staged, chunked, and committed as a + * canonical Session Artifact, and the returned AttachmentRef is the exact + * durable reference turns must carry. The Host stays the only store — local + * and remote Hosts are reached through the same protocol path, so this + * client never writes to an ArtifactStore directly. + * + * A failed ingest aborts the staged upload; a Host whose outcome stayed + * unknown rethrows without retrying (command-mode semantics), and the Host's + * own upload TTL reclaims any staging that never committed. + */ + async ingestAttachment(input: { + name: string; + mimeType: string; + content: Uint8Array; + }): Promise { + const sessionId = await this.#ensureSession(); + const uploadId = this.#newId(); + const contentSha256 = + `sha256:${createHash('sha256').update(input.content).digest('hex')}` as const; + let opened = false; + try { + const begin = await this.#request('artifact.ingest', { + kind: 'begin', + sessionId, + uploadId, + name: input.name, + mimeType: input.mimeType, + totalBytes: input.content.byteLength, + contentSha256, + }); + if (begin.kind === 'committed') return begin.attachment; + if (begin.kind !== 'upload_opened') { + throw new Error('Runtime Host did not open the Attachment upload'); + } + opened = true; + let offset = begin.nextOffset; + while (offset < input.content.byteLength) { + const chunk = input.content.subarray( + offset, + Math.min(input.content.byteLength, offset + ARTIFACT_INGEST_CHUNK_MAX_BYTES), + ); + const accepted = await this.#request('artifact.ingest', { + kind: 'chunk', + sessionId, + uploadId, + offset, + chunkBase64: Buffer.from(chunk).toString('base64'), + }); + if (accepted.kind !== 'chunk_accepted' || accepted.nextOffset <= offset) { + throw new Error('Runtime Host did not advance the Attachment upload'); + } + offset = accepted.nextOffset; + } + const committed = await this.#request('artifact.ingest', { + kind: 'commit', + sessionId, + uploadId, + }); + if (committed.kind !== 'committed') { + throw new Error('Runtime Host did not commit the Attachment upload'); + } + return committed.attachment; + } catch (error) { + if (opened) { + await this.#request('artifact.ingest', { + kind: 'abort', + sessionId, + uploadId, + }).catch(() => undefined); + } + throw error; + } + } + + /** + * Best-effort deletion of one user-upload Artifact by its exact reference. + * The reference carries the owning Session, so cleanup stays correct even + * after this client attached a different Session. Not-found is success: the + * abandoned-draft cleanup only needs the artifact gone. + */ + async deleteAttachment(attachment: AttachmentRef): Promise { + if (attachment.ref.kind !== 'session_file') return; + try { + await this.#request('artifact.delete', { + sessionId: attachment.ref.sessionId, + artifactId: attachment.ref.relativePath, + }); + } catch (error) { + if (error instanceof RuntimeHostOperationError && error.code === 'not_found') return; + throw error; + } + } + async retractQueued(): Promise { if (!this.#sessionId) return { text: '', messageIds: [] }; const result = await this.#request('queue.retract', { @@ -567,6 +670,10 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return { text: result.retracted.map((entry) => entry.content.text).join('\n\n'), messageIds: result.retracted.map((entry) => entry.messageId), + entries: result.retracted.map((entry) => ({ + text: entry.content.displayText ?? entry.content.text, + attachments: entry.content.attachments ?? [], + })), }; } diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 4dac719577..82ee5732dc 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -18,7 +18,12 @@ */ import { realpath } from 'node:fs/promises'; -import type { SessionEvent, ShellRunSnapshotResult, ShellRunUpdate } from '@maka/core/events'; +import type { + AttachmentRef, + SessionEvent, + ShellRunSnapshotResult, + ShellRunUpdate, +} from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -99,6 +104,8 @@ export interface MakaAttachedSessionTurn extends MakaPreparedSessionTurn { export interface MakaPreparePromptOptions { turnId?: string; modelText?: string; + /** Committed Session AttachmentRefs carried on the opening user message. */ + attachments?: readonly AttachmentRef[]; turnOrchestration?: TurnOrchestration; maxSteps?: number; } @@ -107,6 +114,8 @@ export interface MakaSubmitMessageOptions { messageId: string; placement: 'current_turn' | 'next_turn'; modelText?: string; + /** Committed Session AttachmentRefs carried alongside the message text. */ + attachments?: readonly AttachmentRef[]; /** Exact-Turn intent carried to Runtime Host, which decides how to admit it. */ turnOrchestration?: TurnOrchestration; } @@ -114,6 +123,18 @@ export interface MakaSubmitMessageOptions { export interface MakaRetractedMessages { text: string; messageIds: readonly string[]; + /** + * One restorable entry per retracted message, in queue order, with the + * committed attachments each message carried. Present only on drivers whose + * queue authority can prove per-message grouping; `text` stays the joined + * fallback for surfaces that do not re-stage attachments. + */ + entries?: readonly MakaRetractedMessageEntry[]; +} + +export interface MakaRetractedMessageEntry { + text: string; + attachments: readonly AttachmentRef[]; } /** @@ -163,6 +184,22 @@ export interface MakaSessionDriver { compactSession(): AsyncIterable; resumeLatest?(): AsyncIterable; retractQueued?(): Promise; + /** + * Ingest raw bytes through the Runtime Host artifact authority and resolve to + * the exact committed Session AttachmentRef. Optional: drivers without a + * Host-backed artifact authority reject attachment staging up front. + */ + ingestAttachment?(input: { + name: string; + mimeType: string; + content: Uint8Array; + }): Promise; + /** + * Delete a committed user-upload Artifact by its exact reference. Best-effort + * cleanup for abandoned drafts; the Host remains the deletion authority and + * refuses protected runtime evidence. Optional: mirrors `ingestAttachment`. + */ + deleteAttachment?(attachment: AttachmentRef): Promise; respondToSandboxBoundary(response: SandboxBoundaryResponse): Promise; respondToUserQuestion?(response: UserQuestionResponse): Promise; setModel(model: string, connectionSlug?: string, connectionId?: string): Promise; diff --git a/packages/cli/src/tui-attachments.ts b/packages/cli/src/tui-attachments.ts new file mode 100644 index 0000000000..061ba04d73 --- /dev/null +++ b/packages/cli/src/tui-attachments.ts @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { open } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { basename, isAbsolute, resolve } from 'node:path'; +import { attachmentKindFromMimeType, guessMimeFromName } from '@maka/core/attachments'; +import type { AttachmentRef } from '@maka/core/events'; + +/** + * Draft-scoped staging for TUI image attachments (issue #4171). + * + * The Runtime Host artifact authority is the only durable store. A `/attach` + * pick stages nothing but a client-local descriptor; the bytes are read and + * ingested through `artifact.ingest` inside the message-submit boundary, and + * the committed Message's AttachmentRef is the durable identity from then on. + * A staged item can also be a retained ref (an already-committed artifact that + * came back with a retracted queued message) — resubmitting reuses it instead + * of re-ingesting. Nothing here is an authority over stored bytes; every entry + * points at (or waits on) a Host-owned Artifact. + */ + +/** A client-local image the user picked. `path` never serializes into content. */ +export interface LocalImageDescriptor { + readonly kind: 'local'; + readonly stagingKey: string; + readonly name: string; + readonly mimeType: string; + readonly path: string; + /** File size at stage time, for display only. */ + readonly bytes: number; +} + +/** An already-committed Session Artifact carried back by a retracted message. */ +export interface RetainedImageAttachment { + readonly kind: 'retained'; + readonly stagingKey: string; + readonly attachment: AttachmentRef; +} + +export type StagedImage = LocalImageDescriptor | RetainedImageAttachment; + +/** + * One live draft's staged images, ordered. Submit consumes the list in order; + * a descriptor that finished its ingest is replaced in place by its retained + * ref so a failed dispatch (or a refusal) retries with the same Artifact + * instead of orphaning a second copy of the bytes. + */ +export class TuiImageStaging { + readonly #items: StagedImage[] = []; + + stageFile(descriptor: Omit): LocalImageDescriptor { + const item: LocalImageDescriptor = { kind: 'local', stagingKey: randomUUID(), ...descriptor }; + this.#items.push(item); + return item; + } + + stageRetained(attachment: AttachmentRef): RetainedImageAttachment { + const item: RetainedImageAttachment = { + kind: 'retained', + stagingKey: randomUUID(), + attachment, + }; + this.#items.push(item); + return item; + } + + /** Swap a just-ingested descriptor for its committed ref, keeping its order. */ + replace(key: string, attachment: AttachmentRef): void { + const index = this.#items.findIndex((item) => item.stagingKey === key); + if (index < 0) return; + this.#items[index] = { + kind: 'retained', + stagingKey: key, + attachment, + }; + } + + remove(key: string): StagedImage | undefined { + const index = this.#items.findIndex((item) => item.stagingKey === key); + return index < 0 ? undefined : this.#items.splice(index, 1)[0]; + } + + list(): readonly StagedImage[] { + return [...this.#items]; + } + + get size(): number { + return this.#items.length; + } + + clear(): readonly StagedImage[] { + return this.#items.splice(0, this.#items.length); + } +} + +/** The composer-strip label for one staged image: name and media type only — never a path. */ +export function stagedImageLabel(item: StagedImage): string { + const bytes = item.kind === 'local' ? item.bytes : item.attachment.bytes; + const name = item.kind === 'local' ? item.name : item.attachment.name; + const mimeType = item.kind === 'local' ? item.mimeType : item.attachment.mimeType; + return `📎 ${name} · ${mimeType} · ${formatAttachmentBytes(bytes)}`; +} + +export function formatAttachmentBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ============================================================================ +// Client-side file helpers (bytes stay client-side until the submit boundary) +// ============================================================================ + +export interface LocalImageFile { + readonly absolutePath: string; + readonly name: string; + readonly mimeType: string; +} + +export function expandHomePath(rawPath: string): string { + const trimmed = rawPath + .trim() + .replace(/^"(.*)"$/s, '$1') + .replace(/^'(.*)'$/s, '$1'); + if (trimmed === '~') return homedir(); + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return resolve(homedir(), trimmed.slice(2)); + } + return trimmed; +} + +export function resolveLocalImageFile(rawPath: string, cwd: string): LocalImageFile { + const expanded = expandHomePath(rawPath); + const absolutePath = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); + const name = basename(absolutePath); + const mimeType = guessMimeFromName(name); + return { absolutePath, name, mimeType }; +} + +export function isImageMimeType(mimeType: string, fileName: string): boolean { + return attachmentKindFromMimeType(mimeType, fileName) === 'image'; +} + +/** + * Read at most `maxBytes` bytes, rejecting a larger file before it loads. Reads + * one extra byte so a TOCTOU where the file grows between stat and read cannot + * smuggle an oversized buffer into the ingest path. + */ +export async function readFileCapped(path: string, maxBytes: number): Promise { + const handle = await open(path, 'r'); + try { + const buffer = new Uint8Array(maxBytes + 1); + const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1, 0); + if (bytesRead > maxBytes) { + throw new Error( + `Image exceeds the ${Math.floor(maxBytes / (1024 * 1024))}MB attachment limit.`, + ); + } + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 7576f96b38..7d030e63f4 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -267,8 +267,10 @@ export const TUI_COPY_RESOURCES = { setup: 'Set up a model provider', }, commands: { + attach: 'Attach an image to the draft (/attach )', compact: 'Compact session context', context: 'Show latest request context usage', + detach: 'Remove a staged image (/detach )', exit: 'Exit Maka', goal: 'Show autonomous goal status', graph: 'Show, enable, disable, or run one Graph turn', @@ -317,8 +319,10 @@ export const TUI_COPY_RESOURCES = { setup: '配置模型提供商', }, commands: { + attach: '附加一张图片到草稿(/attach <路径>)', compact: '压缩会话上下文', context: '查看最近一次请求的上下文用量', + detach: '移除一张已附加的图片(/detach <编号>)', exit: '退出 Maka', goal: '查看自主目标状态', graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务', diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index 8bbdd428f6..279a28ed41 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -28,8 +28,10 @@ export interface SlashCommandSpec { } export const SLASH_COMMAND_CATALOG = [ + { id: 'attach', session: 'required', surfaces: ['tui'] }, { id: 'compact', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'context', session: 'required', surfaces: ['tui'] }, + { id: 'detach', session: 'required', surfaces: ['tui'] }, { id: 'exit', aliases: ['quit'], session: 'none', surfaces: ['tui'] }, { id: 'goal', session: 'required', surfaces: ['tui'] }, { id: 'graph', session: 'none', surfaces: ['desktop', 'tui'] }, From 197968aeeb57c7d0e51a995f5d67d7b0ee6ac5db Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Mon, 31 Aug 2026 10:32:41 +0530 Subject: [PATCH 2/2] test(tui): point autocomplete cursor assertions at the new first command /attach now sorts ahead of /compact, so the initial slash-autocomplete cursor lands on it; the resize-visibility assertions must wait for the cursor on /attach. Counter, wrap, and resize behavior are unchanged. --- packages/cli/src/__tests__/pi-tui-runner.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index b791c793bf..45532a20b2 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -623,7 +623,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('/'); await waitFor(() => { const screen = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/); - return autocompleteSuggestionLines(screen).some((line) => line.includes('→ /compact')); + return autocompleteSuggestionLines(screen).some((line) => line.includes('→ /attach')); }); let screen = plainTerminalOutput(terminal.screenOutput()).split(/\r?\n/); @@ -636,7 +636,7 @@ describe('Maka Pi TUI runner', () => { const shortVisibleCommands = autocompleteSuggestionLines(screen).length; assert.ok(totalCommands > shortVisibleCommands); assert.equal(bottomBorder, terminal.rows - 2); - assert.ok(autocompleteSuggestionLines(screen).some((line) => line.includes('→ /compact'))); + assert.ok(autocompleteSuggestionLines(screen).some((line) => line.includes('→ /attach'))); terminal.input('\x1b[A'); await waitFor(() => {