Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ agent session start -e backend "..." # run a prompt in a saved environment
agent session start --config-file f.json # ...or from an inline config
agent session start --template ellipsis-helper # ...or from a maintained template
agent session start --budget 5 "..." # cap this session's spend, in dollars
agent session start --image shot.png "..." # the agent sees the picture on its first turn
agent session start --watch "..." # start and immediately stream it
agent session list --limit 20 # list recent sessions (filter by --source, --author, --since, …)
agent session get <session-id> # inspect one session (prints a dashboard link)
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@ellipsis-dev/sdk": "^0.25.0",
"@ellipsis-dev/sdk": "^0.27.0",
"chalk": "^5.6.2",
"cli-table3": "^0.6.5",
"commander": "^12.1.0",
Expand Down
14 changes: 14 additions & 0 deletions src/commands/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { repoFromCwd } from '../lib/git'
import { openBrowser } from '../lib/auth'
import { registerConnect, runConnect } from './connect'
import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch'
import { readImageAttachment } from '../lib/images'
import { formatStepLine, oneLine, recordText } from '../lib/steps'
import {
parseRepo,
Expand Down Expand Up @@ -133,6 +134,12 @@ export function registerSession(program: Command): void {
'-p, --prompt <text>',
"the session prompt, appended to the agent's initial user query (or pass it positionally)",
)
.option(
'--image <path>',
'attach an image (PNG, JPEG, GIF, or WebP, up to 5 MiB) to the prompt; the agent sees it on its first turn (repeatable)',
collect,
[] as string[],
)
.option(
'-m, --metadata <key=value>',
'attach metadata (repeatable)',
Expand Down Expand Up @@ -171,6 +178,7 @@ export function registerSession(program: Command): void {
rebuild?: boolean
budget?: number
prompt?: string
image: string[]
metadata: Record<string, string>
detach?: boolean
watch?: boolean
Expand Down Expand Up @@ -249,6 +257,12 @@ export function registerSession(program: Command): void {
// Appended to the initial user query at build time; gives this
// session instructions on top of the config's shared system prompt.
if (promptText) req.prompt = promptText
// Pictures ride the first message inline, the way a paste into a
// local `claude` does: the prompt gains an `[Image #N]` placeholder
// per file and the model sees each as a content block on turn 0.
if (opts.image.length > 0) {
req.images = opts.image.map((path) => readImageAttachment(path).attachment)
}
// Run settings ride top-level: --rebuild skips the image cache for
// the initial provision (wakes cache as usual; the fresh build's
// snapshot refreshes the cache).
Expand Down
63 changes: 63 additions & 0 deletions src/lib/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import type { ImageAttachment } from './types'

// Images on a message — the Claude Code paste model. `agent session start
// --image shot.png` and the connect composer's `/image shot.png` read the
// file here, and it rides the request inline (base64) as `images`; the server
// appends one `[Image #N]` placeholder per image to the message body and the
// model sees the picture as a content block on the turn that message opens.
//
// Client-side mirrors of the server limits (session_message_image.py), so a
// wrong file fails fast with a clear message instead of a base64-inflated
// round trip to a 400. The server re-validates; these are UX, not enforcement.
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
export const MAX_IMAGES_PER_MESSAGE = 10

type ImageMediaType = ImageAttachment['media_type']

// What the bytes prove themselves to be; the extension is never consulted.
export function sniffImageType(bytes: Buffer): ImageMediaType | null {
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
return 'image/png'
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return 'image/jpeg'
const head6 = bytes.subarray(0, 6).toString('ascii')
if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif'
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
)
return 'image/webp'
return null
}

// One attachment from a file's bytes, throwing the fast client-side errors
// (empty, oversized, not an image we take). Exported for tests.
export function buildImageAttachment(path: string, bytes: Buffer): ImageAttachment {
if (bytes.length === 0) throw new Error(`${path} is empty`)
if (bytes.length > MAX_IMAGE_BYTES) {
throw new Error(
`${path} is ${(bytes.length / (1024 * 1024)).toFixed(1)} MiB; the limit is 5 MiB per image`,
)
}
const media_type = sniffImageType(bytes)
if (media_type === null) {
throw new Error(`${path} is not a PNG, JPEG, GIF, or WebP image`)
}
return { media_type, data: bytes.toString('base64') }
}

export type StagedImage = { name: string; attachment: ImageAttachment }

export function readImageAttachment(path: string): StagedImage {
return { name: basename(path), attachment: buildImageAttachment(path, readFileSync(path)) }
}

// The `[Image #N]` placeholders the server appends to the stored body, mirrored
// here so a local echo reads exactly like the row that replaces it.
export function withImagePlaceholders(text: string, count: number): string {
if (count === 0) return text
const placeholders = Array.from({ length: count }, (_, i) => `[Image #${i + 1}]`).join(' ')
return text ? `${text}\n\n${placeholders}` : placeholders
}
1 change: 1 addition & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type ListAgentSessionsResponse = S['SessionsListResponse']
export type StartAgentSessionRequest = NonNullable<Parameters<Ellipsis['sessions']['start']>[0]>
export type SessionResponse = S['SessionResponse']
export type SendSessionMessageRequest = S['SendSessionMessageRequest']
export type ImageAttachment = S['ImageAttachment']
export type SessionLogSegment = S['SessionLogSegment']
export type GetSessionLogResponse = S['GetSessionLogResponse']

Expand Down
65 changes: 50 additions & 15 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,26 +35,27 @@ import type { Ellipsis, SdkRecord } from '@ellipsis-dev/sdk'
import { hyperlink } from '../lib/urls'
import { usdNumberFromMillicents } from '../lib/output'
import { applyEditShortcut } from '../lib/editing'
import { readImageAttachment, withImagePlaceholders, type StagedImage } from '../lib/images'
import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC'
import { fitLines, visibleWidth } from '../lib/markdown'
import { SELECTION_GLYPH } from '../lib/sessions'
import { inputSurface, theme } from '../lib/theme'
import {
commandArgument,
completedText,
isCommandInput,
matchCommands,
resolveCommand,
type SlashCommand,
} from './commands'
import { MAX_IMAGES_PER_MESSAGE } from '../lib/images'
import { VERSION } from '../lib/constants'
import {
activityRows,
BRANCH_GLYPH,
BRANCH_TEXT_PAD,
contentWidth,
GUTTER_COLS,
isAgentSpeech,
isToolActivity,
itemRows,
layOutItems,
LIVE_GLYPH,
Expand Down Expand Up @@ -286,6 +287,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// and once the agent consumes the message its echo record lands as the
// real (full-colour) transcript row.
const [queued, setQueued] = useState<QueuedSend[]>([])
// Images attached with /image, waiting for the next send — the Claude Code
// paste model, minus the paste (a terminal cannot carry image bytes, so the
// file path stands in). Sent inline on that message and cleared with it.
const [staged, setStaged] = useState<StagedImage[]>([])

// Whether the sandbox ever reached a connectable state, so a terminal status
// *before* that (a preflight/budget gate) is reported as a failure, not idle.
Expand Down Expand Up @@ -595,7 +600,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
(raw: string): void => {
const text = raw.trim()
setComposer({ text: '', cursor: 0 })
if (!text) return
// An empty line sends nothing — unless pictures are waiting, in which
// case the message is the pictures (an image-only paste).
if (!text && staged.length === 0) return
setUndisplayedSeen(undisplayed)
// A leading slash claims the line for the CLI. An unknown one is REFUSED,
// not forwarded: a typo'd command sent on as prose is a message you did
Expand All @@ -615,6 +622,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
else setNotice('no other sessions here, this is a single-session connect')
return
}
if (command.id === 'image') {
const path = commandArgument(text)
if (!path) {
setNotice('usage: /image <path> — attaches the file to your next message')
return
}
if (staged.length >= MAX_IMAGES_PER_MESSAGE) {
setNotice(`✗ at most ${MAX_IMAGES_PER_MESSAGE} images per message`)
return
}
try {
const image = readImageAttachment(path)
const next = [...staged, image]
setStaged(next)
setNotice(
`${next.map((s, i) => `[Image #${i + 1}] ${s.name}`).join(', ')} — sends with your next message`,
)
} catch (err) {
setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`)
}
return
}
}
// /stop is the one command that talks to the server, so it rides the async
// path below with the sends.
Expand All @@ -636,14 +665,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// Show the message as queued, then post it. The POST returns the
// created SessionMessage (protocol v2 §4.2): stamp the chip with its
// id so the first messages frame / user-echo record carrying that id
// retires it in favour of the server's own row.
setQueued((prev) => [...prev, { text, messageId: null }])
// retires it in favour of the server's own row. The chip reads as
// the server stores it — the text, then one `[Image #N]` placeholder
// per attached image — so it reconciles with that row by text.
const images = staged.map((s) => s.attachment)
const shown = withImagePlaceholders(text, images.length)
setStaged([])
setQueued((prev) => [...prev, { text: shown, messageId: null }])
setNotice(null)
const { message: created } = await api.sessions.sendMessage(sessionId, { message: text })
const { message: created } = await api.sessions.sendMessage(sessionId, {
message: text,
images,
})
setQueued((prev) => {
let stamped = false
return prev.map((q) => {
if (!stamped && q.text === text && q.messageId === null) {
if (!stamped && q.text === shown && q.messageId === null) {
stamped = true
return { text: q.text, messageId: created.id }
}
Expand All @@ -654,14 +691,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
pump()
} catch (err) {
setQueued((prev) => {
const j = prev.findIndex((q) => q.text === text && q.messageId === null)
const j = prev.findIndex((q) => q.messageId === null)
return j < 0 ? prev : [...prev.slice(0, j), ...prev.slice(j + 1)]
})
setNotice(`✗ ${errorDetail(err)}`)
}
})()
},
[api, exit, pump, sessionId, undisplayed, props.onFocusNav],
[api, exit, pump, sessionId, staged, undisplayed, props.onFocusNav],
)

// The composer renders whenever sending is possible.
Expand Down Expand Up @@ -805,18 +842,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
pendingTools.length === 1
? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…`
: `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…`
// A running tool call nests under the message that made it, in the same
// place its ⎿ result will land — so it takes the SAME predicate
// layOutItems uses for that result. Any disagreement here shows up as the
// live line sitting flat and then jumping a level when the result lands.
const said = visible.filter((i) => !isToolActivity(i)).pop()
// A running tool call sits flat, exactly where its ⎿ result will land:
// every row stands on its own (layOutItems), so the live line never
// jumps a level when the result arrives.
return {
text: '',
label,
tick: 'tool' as const,
suffix: '',
hug,
nested: said != null && isAgentSpeech(said),
nested: false,
}
}
if (working && !infraActivity && (awaitingAgent !== null || sendPending)) {
Expand Down
28 changes: 24 additions & 4 deletions src/ui/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// forwarded. Silently sending "/stpo" to the agent as prose is the failure mode
// that rule exists to prevent.

export type CommandId = 'stop' | 'sessions' | 'exit'
export type CommandId = 'stop' | 'image' | 'sessions' | 'exit'

export type SlashCommand = {
id: CommandId
Expand All @@ -16,12 +16,21 @@ export type SlashCommand = {
aliases?: readonly string[]
// The one-line description in the menu, lowercase and imperative.
detail: string
// Whether the rest of the line after the name is the command's argument
// (`/image shot.png`). Without it, anything after the name is a typo.
takesArgument?: boolean
}

// Every command, in menu order: the one that acts on the session first, then the
// screen, then the way out.
export const SLASH_COMMANDS: readonly SlashCommand[] = [
{ id: 'stop', name: 'stop', detail: 'interrupt the agent, keeping the conversation' },
{
id: 'image',
name: 'image',
detail: 'attach an image file to your next message: /image <path>',
takesArgument: true,
},
{ id: 'sessions', name: 'sessions', detail: 'switch sessions (esc)' },
{ id: 'exit', name: 'exit', aliases: ['quit'], detail: 'leave the CLI; the session keeps running' },
]
Expand Down Expand Up @@ -58,10 +67,21 @@ export function matchCommands(text: string): SlashCommand[] {
// wrong command is worse than being told to finish typing. Pure, for tests.
export function resolveCommand(text: string): SlashCommand | null {
if (!isCommandInput(text)) return null
const typed = text.slice(1).trim().toLowerCase()
return (
const trimmed = text.slice(1).trim()
const [word, ...rest] = trimmed.split(/\s+/)
const typed = (word ?? '').toLowerCase()
const command =
SLASH_COMMANDS.find((c) => [c.name, ...(c.aliases ?? [])].includes(typed)) ?? null
)
if (command === null) return null
// Trailing words are only meaningful to a command that takes an argument.
if (rest.length > 0 && !command.takesArgument) return null
return command
}

// The argument after a command's name (`/image shot.png` → `shot.png`), or
// '' when there is none. Pure, for tests.
export function commandArgument(text: string): string {
return text.slice(1).trim().replace(/^\S+\s*/, '')
}

// The text the composer holds after tab/enter completes `highlighted`: the whole
Expand Down
1 change: 0 additions & 1 deletion src/ui/transcriptRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
export {
BRANCH_GLYPH,
gutterFor,
isAgentSpeech,
isToolActivity,
layOutItems,
LIVE_GLYPH,
Expand Down
15 changes: 15 additions & 0 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
matchCommands,
resolveCommand,
SLASH_COMMANDS,
commandArgument,
} from '../src/ui/commands'

describe('isCommandInput', () => {
Expand Down Expand Up @@ -101,3 +102,17 @@ describe('the command list itself', () => {
}
})
})

describe('commands with an argument', () => {
it('resolves /image with its path, and refuses trailing words elsewhere', () => {
expect(resolveCommand('/image shot.png')?.id).toBe('image')
expect(resolveCommand('/image')?.id).toBe('image')
expect(resolveCommand('/stop now')).toBeNull()
})

it('returns the argument after the name', () => {
expect(commandArgument('/image ~/Desktop/shot.png')).toBe('~/Desktop/shot.png')
expect(commandArgument('/image')).toBe('')
expect(commandArgument('/image a b.png')).toBe('a b.png')
})
})
Loading