cellix 0.5.5

The AI companion IDE inside your agent: a Claude Code mod, Codex plugin and Gemini CLI extension
Documentation
import type { Engine, ImageSource, Register } from 'claude-code'

const PANE = 'cellix'
const REFRESH_MS = 5000
const MAX_IMAGE_CELLS = 255
const INLINE_ROWS = 28
/** More than Claude Code allows, so the dock opens as wide as it can: 45% of the window. */
const DOCK_COLUMNS = 400
/** How long after the pane is first drawn to check that its pictures show. */
const CHECK_MS = 300
const START_TRIES = 40
const START_WAIT_MS = 100
/** What this mod and `cellix pane` say to each other; Cellix's `pane::PROTOCOL`. */
const PROTOCOL = '2'

type Comment = { file: string; line: number; text: string }
/** A frame as cells, for where pictures can't show: drawn as text. */
type Cells = { columns: number; rows: number; cells: string }
/** A frame as a picture a row, which Cellix sends over SSH and in tmux. Each row is named by
 * what it shows, and a picture (PNG, base64) comes only with a name not held yet. Drawn under
 * those names, a row that moves keeps its picture: Claude Code uploads each name once. */
type Rows = { columns: number; rows: number; names: string[]; pictures: Map<string, string> }

let source: ImageSource | undefined
let cells: Cells | undefined
let rows: Rows | undefined
/** Whether a drawn row has shown that pictures show here. */
let checked = false
/** Why Cellix stopped, when it stopped before it showed anything. */
let stopped: string | undefined
let running = false
let sent = { columns: 0, rows: 0 }
/** Input waiting to go. It goes in order, all that waits in one send, so a click never
 * queues behind a send for each move before it. */
let waiting: unknown[] = []
let sending = false
/** How Cellix is started: by name, or where setup found it, when Claude Code's PATH lacks it. */
let program = 'cellix'

function send($: Engine, inputs: unknown[]) {
  waiting.push(...inputs)
  if (sending) return
  sending = true
  void (async () => {
    try {
      while (waiting.length > 0) {
        const batch = waiting
        waiting = []
        // Cellix may still be starting, as when the session opens with a command, and may
        // yet be found somewhere other than the PATH.
        for (let tries = 0; tries < START_TRIES; tries++) {
          const done = await $.process.run([program, 'pane-send', JSON.stringify(batch)]).catch(() => undefined)
          if (done?.exitCode === 0) break
          await $.clock.sleep(START_WAIT_MS)
        }
      }
    } catch {
      waiting = []
    } finally {
      sending = false
    }
  })()
}

/** Whether Cellix has drawn anything yet. */
function shown(): boolean {
  return source !== undefined || cells !== undefined || rows !== undefined
}

/** Brings the pane up, tall enough to read when it sits above the prompt. */
async function openPane($: Engine) {
  await $.ui.open({ id: PANE, title: 'Cellix', focus: true, rows: INLINE_ROWS, columns: DOCK_COLUMNS })
}

/** The review comments waiting in Cellix, or nothing if no Cellix serves this folder. */
async function pending($: Engine): Promise<Comment[] | undefined> {
  const done = await $.process.run([program, 'comments']).catch(() => undefined)
  return done?.exitCode === 0 ? (JSON.parse(done.stdout) as Comment[]) : undefined
}

async function showCount($: Engine) {
  const count = (await pending($))?.length ?? 0
  const noun = count === 1 ? 'comment' : 'comments'
  $.ui.status(count > 0 ? `${count} ${noun} waiting` : undefined)
}

async function show($: Engine, next: ImageSource) {
  const first = source === undefined
  source = next
  cells = undefined
  rows = undefined
  if (first) {
    $.ui.invalidate('ui.render')
    return
  }
  await $.ui.blit({ requestId: PANE, key: 'view', source: next }).catch(() => undefined)
}

/** Repaints the cells in place, or draws the pane again when their size changed. */
async function showCells($: Engine, next: Cells) {
  const same = cells !== undefined && cells.columns === next.columns && cells.rows === next.rows
  cells = next
  source = undefined
  rows = undefined
  if (same) {
    const { deny } = await $.ui
      .blit({ requestId: PANE, key: 'view', cells: next.cells, columns: next.columns, rows: next.rows })
      .catch(() => ({ deny: 'failed' }))
    if (deny === undefined) return
  }
  $.ui.invalidate('ui.render')
}

/** Takes a frame of named rows: new pictures join those held, and what left the screen goes,
 * as Cellix expects. The pane is drawn again, and Claude Code sends the terminal only the
 * pictures it hasn't shown. */
function showRows($: Engine, columns: number, height: number, list: string, parts: string[]) {
  const names = list.split(',')
  const pictures = rows !== undefined && rows.columns === columns ? rows.pictures : new Map<string, string>()
  for (const part of parts) {
    const at = part.indexOf(':')
    pictures.set(part.slice(0, at), part.slice(at + 1))
  }
  const onScreen = new Set(names)
  for (const name of [...pictures.keys()]) if (!onScreen.has(name)) pictures.delete(name)
  rows = { columns, rows: height, names, pictures }
  source = undefined
  cells = undefined
  $.ui.invalidate('ui.render')
  if (!checked) {
    checked = true
    $.clock.after(CHECK_MS, () => void check($))
  }
}

/** Swaps a drawn row for itself, which sends the terminal nothing, to learn whether pictures
 * show here. Where they can't, Cellix is asked for cells and told why. */
async function check($: Engine) {
  const frame = rows
  const name = frame?.names[0]
  const png = name === undefined ? undefined : frame?.pictures.get(name)
  if (png === undefined) return
  const { deny } = await $.ui
    .blit({ requestId: PANE, key: `${name}.0`, source: { png } })
    .catch(() => ({ deny: undefined }))
  if (deny?.includes('draws its alt')) {
    send($, [{ type: 'tier', tier: 'cells', why: deny.replace(/^.*draws its alt here: /, '') }])
  } else if (deny !== undefined) {
    // Not drawn yet, as when the pane isn't open: the next frame asks again.
    checked = false
  }
}

async function line($: Engine, text: string) {
  const [kind, ...rest] = text.split(' ')
  if (kind === 'frame') {
    const [file, width, height, generation] = rest
    await show($, {
      file,
      format: 'rgb',
      width: Number(width),
      height: Number(height),
      generation: Number(generation),
    })
  } else if (kind === 'rows') {
    const [columns, height, list, ...parts] = rest
    showRows($, Number(columns), Number(height), list, parts)
  } else if (kind === 'cells') {
    const [columns, rows, packed] = rest
    await showCells($, { columns: Number(columns), rows: Number(rows), cells: packed })
  } else if (kind === 'fill') {
    // Ask Agent: what the selection is about, in the prompt box for the person to finish.
    const text = JSON.parse(rest.join(' ')) as string
    await $.prompt.fill({ text, mode: 'insert' }).catch(() => undefined)
  } else if (kind === 'prompt') {
    // Cellix asking the agent something, as Check my review does: a turn of its own.
    const prompt = JSON.parse(rest.join(' ')) as string
    $.clock.after(0, () => {
      void $.prompt.submit({ text: prompt }).catch(() => undefined)
    })
  } else if (kind === 'attention' && rest[0] === 'send') {
    await sendComments($)
  } else if (kind === 'attention') {
    await openPane($)
  }
}

/** Starts a turn for the comments: the prompt hook adds them, as it does to any prompt. */
async function sendComments($: Engine): Promise<number> {
  const count = (await pending($))?.length ?? 0
  // A command holds its own turn until it returns, so the turn starts just after.
  if (count > 0) {
    $.clock.after(0, () => {
      void $.prompt.submit({ text: 'Address the review comments I left in Cellix.' }).catch(() => undefined)
    })
  }
  return count
}

/** A path as typed after a command: the folder's, unless it's already absolute. */
async function resolve($: Engine, typed: string): Promise<string> {
  return typed.startsWith('/') ? typed : `${await $.session.cwd()}/${typed}`
}

/** The ways to start Cellix, in order: by name, then where setup found it. */
async function programs($: Engine): Promise<string[]> {
  const found = await $.env.get('CELLIX_BIN').catch(() => undefined)
  return found && found !== 'cellix' ? ['cellix', found] : ['cellix']
}

/** Runs Cellix for the life of the session. Without it, Claude Code just carries on. */
async function bridge($: Engine) {
  if (running) return
  running = true
  stopped = undefined
  let buffer = ''
  let errors = ''
  /** Why Cellix couldn't be started at all, as when it isn't on Claude Code's PATH. */
  let unstarted: string | undefined
  try {
    for (const argv0 of await programs($)) {
      let started = false
      try {
        const env = { CELLIX_MOD: PROTOCOL }
        for await (const piece of $.process.spawn({ argv: [argv0, 'pane'], env })) {
          if (!started) {
            started = true
            program = argv0
            unstarted = undefined
          }
          if (!('stream' in piece)) continue
          if (piece.stream === 'stderr') errors += piece.text
          if (piece.stream !== 'stdout') continue
          buffer += piece.text
          const lines = buffer.split('\n')
          buffer = lines.pop() ?? ''
          for (const text of lines) await line($, text)
        }
        break
      } catch (error) {
        if (started) throw error
        unstarted = error instanceof Error ? error.message : String(error)
      }
    }
  } catch (error) {
    errors ||= error instanceof Error ? error.message : String(error)
  } finally {
    running = false
  }
  // Ended before it drew: the pane says why, where it would otherwise wait for good.
  if (!shown()) {
    stopped = errors.includes('already running')
      ? 'Cellix is already open for this folder in another session. Close it there, then run /cellix again.'
      : unstarted !== undefined
        ? `${/ENOENT|not found/i.test(unstarted) ? "Claude Code can't find cellix on the PATH it was started with" : `Claude Code couldn't start cellix: ${unstarted}`}. Run \`cellix setup claude\` in a shell where \`cellix\` works, then restart Claude Code.`
        : `Cellix stopped: ${errors.trim() || 'it exited before it started'}. Run /cellix to try again.`
    $.ui.invalidate('ui.render')
  }
}

export const register: Register = on => {
  on('session.start', async ($, e, next) => {
    await $.command.register({ name: 'cellix', description: 'Open Cellix beside the conversation' })
    await $.command.register({
      name: 'cellix-comments',
      description: 'List the review comments waiting in Cellix',
    })
    await $.command.register({ name: 'cellix-open', description: 'Open a file in Cellix' })
    await $.command.register({
      name: 'cellix-send',
      description: 'Send the review comments waiting in Cellix to Claude now',
    })
    $.clock.every(REFRESH_MS, () => showCount($))
    const started = await next(e)
    void bridge($).catch(() => undefined)
    return started
  })

  on('command.run', { command: 'cellix' }, async ($, e) => {
    if (!running && !shown()) void bridge($).catch(() => undefined)
    await openPane($)
    const typed = e.args.trim()
    if (typed !== '') {
      send($, [{ type: 'open', path: await resolve($, typed) }])
      return { text: `Opened ${typed} in Cellix. Click it to type there, and press Esc to come back.` }
    }
    return { text: 'Cellix is open. Click it to type there, and press Esc to come back.' }
  })

  on('command.run', { command: 'cellix-send' }, async $ => {
    if ((await pending($)) === undefined) return { text: 'Cellix is not running in this folder.' }
    const count = await sendComments($)
    if (count === 0) return { text: 'No comments are waiting.' }
    return { text: `Sending ${count} ${count === 1 ? 'comment' : 'comments'} to Claude.` }
  })

  on('command.run', { command: 'cellix-comments' }, async $ => {
    const comments = await pending($)
    if (comments === undefined) return { text: 'Cellix is not running in this folder.' }
    if (comments.length === 0) return { text: 'No comments are waiting.' }
    return { text: comments.map(c => `${c.file}:${c.line}: ${c.text}`).join('\n') }
  })

  on('command.run', { command: 'cellix-open' }, async ($, e) => {
    const typed = e.args.trim()
    if (typed === '') return { text: 'Name a file: /cellix-open docs/plan.md' }
    await openPane($)
    send($, [{ type: 'open', path: await resolve($, typed) }])
    return { text: `Opened ${typed} in Cellix.` }
  })

  on('ui.message', async ($, e, next) => {
    if (e.requestId !== PANE) return next(e)
    send($, e.data as unknown[])
    return {}
  })

  on('ui.scroll', { requestId: PANE }, ($, e) => {
    send($, [{ type: 'scroll', by: e.by ?? 0 }])
    return {}
  })

  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
    const { Box, Client, Image, Raster, Text } = $.ui.resolve(e)
    const width = Math.min(e.props.bodyColumns, MAX_IMAGE_CELLS)
    const height = Math.min(e.props.scroll.bodyRows, MAX_IMAGE_CELLS)
    if (width !== sent.columns || height !== sent.rows) {
      sent = { columns: width, rows: height }
      send($, [{ type: 'resize', columns: width, rows: height }])
    }
    if (!shown()) return <Text dimColor>{stopped ?? 'Starting Cellix…'}</Text>
    const frame = rows
    // A name on screen twice, as blank lines are, is drawn under one key each.
    const seen = new Map<string, number>()
    const view =
      frame !== undefined ? (
        <Box flexDirection="column">
          {frame.names.map(name => {
            const nth = seen.get(name) ?? 0
            seen.set(name, nth + 1)
            const png = frame.pictures.get(name)
            const key = `${name}.${nth}`
            return png === undefined ? (
              <Box key={key} width={frame.columns} height={1} />
            ) : (
              <Image key={key} source={{ png }} columns={frame.columns} rows={1} alt=" " />
            )
          })}
        </Box>
      ) : cells !== undefined ? (
        <Raster key="view" columns={cells.columns} rows={cells.rows} cells={cells.cells} />
      ) : (
        <Image key="view" source={source!} columns={width} rows={height} alt="Cellix" />
      )
    return (
      <Box position="relative" width={width} height={height}>
        {view}
        <Box position="absolute" top={0} left={0}>
          <Client key="input" module="./pane-input.tsx" width={width} height={height} />
        </Box>
      </Box>
    )
  })
}