SLOPSHOPPER

sleight

Unofficial: lets Claude drive macOS apps in the background through the computer-use engine bundled with the ChatGPT desktop app, without taking over your…

newpaneguardcommandtoaststatus
★ 2v1.1.0MITupdated 2026-10-09Land-o-Clusters/sleight/plugins/sleight
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sleight
│ ┃ sleight ✕ › fix the failing auth test and add an audit log call │ ┃ No picture yet. It appears after Claude uses │ ┃ an app, or press Refresh. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Refresh ][ Stop ] ⏺ Update(src/auth.ts) │ ┃ Actions ⎿ Added 2 lines, removed 1 line │ ┃ None yet. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sleight │ ⎿ sleight: sleight pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sleight
No picture yet. It appears after Claude uses an app, or press Refresh. [ Refresh ][ Stop ] Actions None yet.
README

<h1 align="center">sleight</h1>

<a href="https://github.com/Land-o-Clusters/sleight/releases/latest"><img alt="latest release" src="https://img.shields.io/github/v/release/Land-o-Clusters/sleight?label=release&color=E8622C"></a> <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-E8622C"> <img alt="platform macOS Apple Silicon" src="https://img.shields.io/badge/platform-macOS%20Apple%20Silicon-3E4A56"> <img alt="status unofficial" src="https://img.shields.io/badge/status-unofficial-3E4A56">

<a href="#install">Install</a> · <a href="#why-sleight">Why sleight</a> · <a href="#watch-and-stop-it">Watch and stop it</a> · <a href="docs/settings.md">Settings</a> · <a href="docs/how-it-works.md">How it works</a> · <a href="docs/known-problems.md">Known problems</a> · <a href="docs/benchmark.md">Benchmark</a>

[!IMPORTANT] sleight is unofficial. OpenAI and Anthropic don't endorse or support it. It drives an undocumented runtime that comes with the ChatGPT app, so a ChatGPT update can break it at any time. It contains no OpenAI code. It starts the copy already installed on your Mac.

Most computer-use tools borrow your screen. The pointer jumps around, windows pop to the front, and you sit on your hands until it's done. The engine inside the ChatGPT desktop app sends clicks, drags and keystrokes straight to the target app instead. The app can be behind your other windows the whole time, and you keep working. sleight gives that engine to Claude Code.

Install

You need macOS on Apple Silicon, Claude Code 2.1.275 or later, and the ChatGPT desktop app with Computer Use turned on in Codex at least once. That first run installs the engine's helper and gets macOS to grant it Accessibility and Screen Recording. You can sign out of Codex afterwards.

claude plugin marketplace add Land-o-Clusters/sleight
claude plugin install sleight@sleight

Or, inside a Claude Code session, /plugin marketplace add Land-o-Clusters/sleight and then /plugin install sleight@sleight. Start a new session, then check the engine:

~/.claude/plugins/marketplaces/sleight/plugins/sleight/bin/sleight-mcp --doctor

And try it:

Use sleight to open Calculator in the background and work out 12 × 12 by clicking its buttons.

The first time Claude touches an app, you get a prompt like Allow Computer Use to use "Calculator"? A yes covers that app for the rest of the session. Apps you marked "Always allow" in Codex or ChatGPT don't ask, because the engine approves them itself (settings).

To update, run claude plugin marketplace update sleight and claude plugin update sleight@sleight, then start a new session. A running session keeps the version it started with.

Why sleight

  • Your Mac stays yours. Clicks, typing and drags go to the app itself, behind your other windows. Only a few fallbacks borrow the pointer, and each asks first.
  • Several agents can share one Mac. Input leases give one session a window at a time and tell the others who holds it. With two sessions typing into one document, text doubled in 5/5 trials without leases and appeared once in 5/5 with them.
  • It refuses to click a stale element. When a window's element numbers shift, sleight stops actions on numbers Claude hasn't seen since, including later clicks in a batch that an earlier click renumbered. In the benchmark this caught two clicks that would have hit the menu item next to the intended one.
  • It's light on context. After each action Claude gets only what changed. On the CNN front page, scrolling down five times sent 39,655 characters instead of 203,160.
  • It asks once per app per session, except for apps you marked "Always allow" in Codex or ChatGPT, which the engine approves itself. A list you write yourself can pre-approve apps. Terminals and OpenAI's apps stay off until you opt in, and sleight shows you each terminal command first unless you turn that off too.
  • The benchmark results are published with their failures. Its tasks in Calculator, TextEdit, Chess and an iPhone simulator run three times each, and the 1.1.0 release pass passed 20/21. Every pass on the way is published too, including one at 13/21 run while the apps were on another Space, and head-to-heads with native Codex computer use, including the Chess one sleight lost 0/3 to 2/3.

sleight also does what the engine can't. It drags text, which the engine's own drag fails to move, and reaches menu bar icons and notification banners. Each of these needs its own approval.

Compared with Claude's own computer use

Claude Code has a built-in computer use server, and the Claude desktop app has the same engine. Anthropic's documentation (read 2026-10-04) says it controls your screen: other visible apps are hidden while Claude works and come back when the turn ends. Only one session can use the computer at a time, and it holds the lock until the session exits. Claude sees the screen through screenshots.

sleight sends events to the app itself, which can be behind your other windows. Your other apps stay visible, and you keep using the Mac while Claude works. Several sessions can use sleight at once: we've run two sleight sessions together, and sleight next to Codex. Claude reads each app's accessibility tree as well as screenshots. Foreground drag fallback, hover and some menu_bar actions briefly take the pointer.

Claude's own computer use is supported by Anthropic and also runs on Windows in the desktop app. sleight is unofficial and depends on the ChatGPT app's engine.

Watch and stop it

The apps sleight drives stay in the background, which also means you can't see them. On Claude Code v2.1.287 or later you get three ways to keep an eye on things.

/sleight opens a pane with the app's latest picture and a log of every action Claude took. Anything after it goes to Claude as a prompt, so /sleight play chess in the background opens the pane and starts the task. The picture refreshes after each turn that used sleight, or when you press Refresh (r) while Claude is idle. A terminal draws it in colored half-blocks. The desktop app's Code tab shows the screenshot itself.

The status line shows which app Claude is working in and how many actions it has taken.

/sleight stop, or Stop (s) in the pane, works mid-turn. It ends the engine's turn and refuses every further sleight call until your next message. Press Esc too if you want the rest of Claude's turn gone.

The pane waits for Claude to finish before it takes a picture. The engine reports UI changes as a diff against the latest read of an app, no matter who made that read, so a snapshot mid-turn could hide a change from Claude. When the pane does read an app, your next message tells Claude to take a full read before relying on a diff.

Replay a run

A run Claude finished can run again with no model, through the same guards:

~/.claude/plugins/marketplaces/sleight/plugins/sleight/bin/sleight-mcp record <session id> task.json
~/.claude/plugins/marketplaces/sleight/plugins/sleight/bin/sleight-mcp replay task.json

The session id is the transcript's file name under ~/.claude/projects. Replay stops at the first step the app isn't ready for, and asks in the terminal before using an app you haven't pre-approved. How it works.

Safety

  • js runs JavaScript as you, so treat it like Bash. Claude Code asks before each call unless you allow mcp__plugin_sleight_computer__js, and allowing it means Claude can send any code without asking.
  • Per-app approvals apply however you've set up the js tool. An accepted approval lasts for the session (approval scope).
  • Foreground drag fallback, hover and the menu_bar fallback for SwiftUI icons move your pointer briefly, and each waits until you've stopped typing and using the mouse for 2 s. drag moves text in an app's text field or a TextEdit window you've covered through Accessibility first, without the pointer. drag refuses points outside the chosen window's visible content; foreground also refuses covered endpoints, after bringing the app forward. hover refuses points another window covers, including another window of the same app, and takes about two seconds with the default dwell.
  • The repo is small enough to read before you install it. It holds a launcher, a relay, a mod, a skill, two manifests and the macOS scripts for the approval panel, menu bar tools, drag and hover.

Troubleshooting

SymptomLikely causeFix
no Codex computer-use plugin at …Computer Use never enabled in ChatGPTOpen ChatGPT → Codex, turn on Computer Use, and run one task
--doctor shows MISSING computer-use helperThe helper app was removed or never installedSame as above
Repeated timeoutReachedAn app or the helper stopped answering readsStop retries. sleight checks native Accessibility and a fresh read of another previously acquired app. Follow the resulting app advice; if evidence is incomplete, report that uncertainty
The helper diagnosis reports two responsive AX apps and a timed-out control readThe engine's read path appears stuckTell the user. Only they should restart ChatGPT, which ends Codex sessions. Doctor's inventory result alone cannot distinguish an app hang
Approval prompt never appearsClaude Code too old for form elicitationUpdate Claude Code
"Not approved" right away in the desktop app, with no panelThe session started before sleight 0.1.1Start a new session
A desktop session still shows as busy after Claude has finishedBefore 0.3.1, nothing ended the engine's turn in the desktop appUpdate sleight and start a new session
"Sky Computer Use service startup request failed"macOS kept the helper's old launchd job and won't start a new oneRun --doctor; it prints the launchctl remove command that clears the job without restarting ChatGPT
Tool calls fail after a ChatGPT updateRuntime API changedOpen an issue with the --doctor output

More in Known problems.

Development

npm test               # relay unit tests
npm run validate       # claude plugin validate, marketplace and plugin
npm run test:mod       # the mod's tests, against the engine (claude plugin test)
npm run typecheck      # needs the types Claude Code writes when it loads the mod
npm run lint:prose     # Vale with the ai-tells style pack, over the docs
SLEIGHT_TRACE=1 claude --plugin-dir plugins/sleight   # logs every relayed message to ~/Library/Logs/sleight/

Update watch

scripts/watch.sh runs --doctor and saves the engine's runtime API docs to ~/Library/Logs/sleight/engine-api-<version>.md. The capture calls getApp with a bundle ID that doesn't exist, so it doesn't touch an app or prompt for approval. When the version changes, it writes engine-api-<version>.diff against the previous snapshot and runs one benchmark task, with the diff path in the macOS notification (the first run saves a baseline). It logs to ~/Library/Logs/sleight/watch.log and also notifies when a check fails; an older watch without a previous snapshot reports that the diff is unavailable.

npm run watch            # check now
npm run watch:install    # run it every Monday at 9:00 (a launchd job)
npm run watch:remove     # remove the job

The benchmark task auto-approves Calculator, like any benchmark run.

Roadmap

The full plan, in order, is in docs/status/ROADMAP.md. Features, done and open:

  • ☑ MCP server that survives ChatGPT updates
  • ☑ Session and turn ids, so the engine can scope approvals and cleanup
  • ☑ Approvals that last for the session, as in Codex
  • ☑ Hide or block the engine's internal tools for Claude
  • ☑ A skill that tells Claude when to use sleight and when to fall back to a pointer-moving tool, delivered with the engine's first result so it costs no turn
  • ☑ Per-turn cleanup through the mod's turn.complete hook
  • ☑ Live pane with the app's latest picture and an action log
  • ☑ Status line entry and /sleight stop
  • ☑ Hover workarounds in the skill
  • ☑ A reproducible task benchmark
  • ☑ Weekly update watch
  • ☑ Approvals in the desktop app's Code tab, through sleight's own panel
  • ☑ Menu bar icons and notification banners, which the engine leaves out
  • ☑ A fair benchmark against LCU, with each arm checked to load only its own tool
  • ☑ Text drags: a drag of sleight's own that holds the mouse down and moves in steps
  • ☑ TextEdit drags in the background, with verified text readback and foreground fallback
  • ☑ The pane and status line in the desktop app's Code tab
  • ☐ /sleight stop checked in the desktop app's Code tab
  • ☐ Windows. Codex computer use reached Windows on 2026-05-29 (foreground only); a port is unchecked
  • ☑ Approve one document instead of a whole app (SLEIGHT_APPROVAL_SCOPE=document, a guard against mistakes rather than a security boundary)
  • ☑ Review saved-file changes and choose Keep or Undo through a user prompt
  • ☐ Review unsaved changes and app state without backing files
  • ☑ Rules for what data may move from one app to another (SLEIGHT_FLOW_RULES=1, a guard against mistakes rather than a security boundary)

Credits

  • @argofowl showed that the ChatGPT app's computer-use server works outside Codex.
  • LCU, started by 0xpolarzero, takes the same idea across several harnesses, and its notes mapped out how the runtime's lifecycle works.
  • The icon started as an image from ChatGPT's image generation.

License

MIT. The ChatGPT app and its computer-use runtime belong to OpenAI, under OpenAI's terms. sleight doesn't include or redistribute either.

Source 3 files
hooks/register.tsx 299 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Frame, LogEntry, ViewStatus } from '../types'
5import { FRAME_MARKER, snapshotCode } from './snapshot'
6
7// The server's own name in plugin.json; the session lists it as
8// plugin:sleight:computer, and its tools as mcp__plugin_sleight_computer__*.
9const SERVER = 'computer'
10const JS_TOOL = 'mcp__plugin_sleight_computer__js'
11const TURN_END_TOOL = 'mcp__plugin_sleight_computer__turn_ended'
12const PANE = 'sleight'
13const LOG_LIMIT = 200
14
15const log = atom({ plugin: 'sleight', key: 'log' } as const, [] as LogEntry[])
16const frame = atom({ plugin: 'sleight', key: 'frame' } as const, null as Frame | null)
17const view = atom({ plugin: 'sleight', key: 'view' } as const, { kind: 'idle' } as ViewStatus)
18const stopped = atom({ plugin: 'sleight', key: 'stopped' } as const, false)
19
20// Not drawn from, so plain module variables (a reload resets them, harmlessly).
21let usedThisTurn = false
22let turnRunning = false
23let actions = 0
24let lastApp: string | undefined
25// Set while one of this mod's own calls is in flight, so its tool.call hooks
26// let the call through and don't log it.
27let ownCall = false
28// The exact snapshot code in flight, for the tool.check below.
29let snapshotInFlight: string | undefined
30// Apps the pane snapshotted since Claude last used sleight. The engine diffs
31// UI state against the latest read of an app, whoever made it, so Claude's
32// next diff would be against the pane's read; the next prompt says so.
33const snapshottedApps = new Set<string>()
34// The pane's last drawn size, for sizing the next snapshot.
35let paneColumns = 48
36let paneRows = 20
37// The surface that last drew the pane; a snapshot carries only what it draws.
38let paneSurface = 'terminal'
39
40const now = () => new Date().toTimeString().slice(0, 8)
41
42function appFrom(code: string, text: string | undefined): string | undefined {
43  return code.match(/getApp\(\s*["'`]([^"'`]+)["'`]/)?.[1] ?? text?.match(/App: ([^.\n]+)\./)?.[1]
44}
45
46function setStatus($: { ui: { status: (text: string | undefined) => void } }) {
47  $.ui.status(lastApp ? `sleight · ${lastApp} · ${actions} action${actions === 1 ? '' : 's'}` : undefined)
48}
49
50// Runs one call on the engine as this mod.
51async function call($: any, tool: string, args: Record<string, unknown>) {
52  const conn = await $.mcp.connect(SERVER)
53  if (!conn.isConnected) throw new Error(conn.message)
54  ownCall = true
55  try {
56    return await $.mcp.call(conn.server, tool, args)
57  } finally {
58    ownCall = false
59  }
60}
61
62// Tells the engine the turn is over so it releases what it holds, as Codex
63// does at the end of each turn. The relay fills in the session and turn ids.
64async function endEngineTurn($: any, event: 'Stop' | 'Interrupt') {
65  try {
66    const ended = await call($, 'turn_ended', { hook_event_name: event })
67    $.ui.log(`sleight: turn_ended ${ended.isError ? `failed: ${JSON.stringify(ended.content)}` : 'sent'}`, { to: 'debug' })
68    // The relay names an app sleight drove that holds a keyboard event tap, once per session.
69    for (const block of ended.content ?? []) {
70      const text = typeof block?.text === 'string' ? block.text : ''
71      if (text.startsWith('sleight-warning: ')) $.ui.toast(text.slice('sleight-warning: '.length))
72    }
73  } catch (err) {
74    $.ui.log(`sleight: could not end the turn: ${(err as Error).message}`, { to: 'debug' })
75  }
76}
77
78async function snapshot($: any, app: string) {
79  await update($, view, () => ({ kind: 'snapshotting', app }) as ViewStatus)
80  try {
81    // Leave rows for the status line, buttons, "Actions" and three log lines.
82    const code = snapshotCode(app, Math.max(8, paneColumns - 2), Math.max(4, paneRows - 7), paneSurface === 'terminal')
83    snapshotInFlight = code
84    const result = await call($, 'js', { code, title: 'sleight pane snapshot' }).finally(() => { snapshotInFlight = undefined })
85    const text = result.content.map((block: { text?: string }) => block.text ?? '').join('\n')
86    const line = text.split('\n').find((l: string) => l.startsWith(FRAME_MARKER))
87    if (result.isError || !line) {
88      throw new Error(result.isError ? text.slice(0, 200) : 'no frame in the result')
89    }
90    const shot = JSON.parse(line.slice(FRAME_MARKER.length))
91    snapshottedApps.add(app)
92    await update($, frame, () => ({ ...shot, at: now() }) as Frame)
93    await update($, view, () => ({ kind: 'idle' }) as ViewStatus)
94  } catch (err) {
95    await update($, view, () => ({ kind: 'error', message: (err as Error).message }) as ViewStatus)
96  }
97}
98
99async function isPaneOpen($: any): Promise<boolean> {
100  const panes = await $.ui.panes()
101  return panes.some((pane: { id: string }) => pane.id === PANE)
102}
103
104// In Auto mode the classifier refused the pane's own snapshot, since no request of the person's
105// asked for it (2026-10-08). This mod allows exactly two calls: its snapshot, by the code it just
106// built, and turn_ended. next.origin is set by the host, so only this plugin's own calls pass;
107// Claude's calls (origin engine) go to the normal check.
108function ownCallVerdict(e: { tool: string; input: unknown }, origin: { plugin: string } | undefined) {
109  if (origin?.plugin !== 'sleight') return undefined
110  if (e.tool === TURN_END_TOOL) return { decision: 'allow', reason: 'sleight ends the engine turn' } as const
111  const code = (e.input as { code?: unknown } | undefined)?.code
112  if (e.tool === JS_TOOL && snapshotInFlight !== undefined && code === snapshotInFlight) {
113    return { decision: 'allow', reason: 'sleight pane snapshot' } as const
114  }
115  return undefined
116}
117
118export const register: Register = on => {
119  on('tool.check', async ($, e, next) => ownCallVerdict(e, next.origin) ?? next(e))
120    // Failing open to the normal check only ever tightens. A call from inside this mod's own
121    // frame lands here as a re-entry, decided from e and next alone.
122    .catch(($, e, next) => (next.error?.kind === 're-entry' ? ownCallVerdict(e, next.origin) : undefined) ?? next(e))
123  on('session.start', async ($, e, next) => {
124    await $.command.register({
125      name: 'sleight',
126      description: 'Open the sleight pane, and send any text after it to Claude; `stop` halts its computer use',
127      argumentHint: '[stop | prompt]',
128      immediate: true,
129    })
130    return next(e)
131  })
132
133  // A new prompt lifts a stop, and tells Claude about pane snapshots that
134  // moved the engine's diff baseline.
135  on('prompt.submit', async ($, e, next) => {
136    await update($, stopped, () => false)
137    if (snapshottedApps.size === 0) return next(e)
138    const apps = [...snapshottedApps].join(', ')
139    snapshottedApps.clear()
140    const note =
141      `sleight: its pane re-read the UI state of ${apps} after your last sleight call. ` +
142      'The engine diffs against the latest read, so pass { disableDiffing: true } to your next ' +
143      'getAXState() for that app before relying on a diff.'
144    return next({ ...e, context: [...(e.context ?? []), note] })
145  })
146
147  on('turn.start', async ($, e, next) => {
148    turnRunning = true
149    return next(e)
150  })
151
152  on('tool.call', { tool: JS_TOOL }, async ($, e, next) => {
153    if (ownCall) return next(e)
154    if (await read($, stopped)) {
155      return { deny: 'The user stopped sleight with /sleight stop. Do not use it again until they ask.' }
156    }
157    usedThisTurn = true
158    snapshottedApps.clear()
159    const code = String(e.code ?? '')
160    const entry: LogEntry = {
161      id: e.tool_use_id,
162      title: String(e.title ?? '') || (code.split('\n')[0] ?? '').slice(0, 80),
163      status: 'running',
164      at: now(),
165    }
166    await update($, log, list => [...list, entry].slice(-LOG_LIMIT))
167    const ran = await next(e)
168    const app = appFrom(code, ran.text)
169    if (app) lastApp = app
170    actions++
171    const status: LogEntry['status'] = ran.deny !== undefined ? 'refused' : ran.isError ? 'error' : 'done'
172    await update($, log, list => list.map(one => (one.id === entry.id ? { ...one, status, app: app ?? one.app } : one)))
173    setStatus($)
174    return ran
175  })
176
177  // turn_ended stays listed so this mod can call it; Claude may not.
178  on('tool.call', { tool: TURN_END_TOOL }, async ($, e, next) =>
179    ownCall ? next(e) : { deny: 'turn_ended is internal to sleight; its hooks call it when a turn ends.' },
180  )
181
182  on('turn.complete', async ($, e, next) => {
183    const result = await next(e)
184    if (e.agentId !== undefined) return result
185    turnRunning = false
186    if (!usedThisTurn) return result
187    usedThisTurn = false
188    // Refresh the pane while the engine turn is still open, then end it.
189    if (lastApp && !e.isAborted && (await isPaneOpen($))) await snapshot($, lastApp)
190    await endEngineTurn($, e.isAborted ? 'Interrupt' : 'Stop')
191    return result
192  })
193
194  on('command.run', { command: 'sleight' }, async ($, e) => {
195    if (e.args.trim() === 'stop') {
196      await update($, stopped, () => true)
197      await endEngineTurn($, 'Interrupt')
198      $.ui.status('sleight · stopped')
199      return { text: 'sleight stopped: Claude can’t use it again until your next message. Press Esc to stop the rest of the turn.' }
200    }
201    await $.ui.open({ id: PANE, title: 'sleight' })
202    // `/sleight do this` opens the pane and sends "do this" to Claude, as if
203    // typed on its own line. The engine refuses a submit from inside this hook
204    // (the prompt would wait on the command submitting it), so it goes out
205    // just after; it waits for any running turn to finish.
206    const prompt = e.args.trim()
207    if (!prompt) return { text: 'sleight pane opened.' }
208    $.clock.after(0, async () => {
209      // A new session may not have connected sleight yet. Sent first, the prompt ran with no
210      // sleight tools and Claude used the shell instead (2026-10-08).
211      await $.mcp.connect(SERVER).catch(() => undefined)
212      $.prompt.submit({ text: prompt, asUser: true }).catch(err => {
213        $.ui.toast(`sleight: couldn't send your prompt (${(err as Error).message}). Send it on its own line.`)
214      })
215    })
216    return { text: 'sleight pane opened. Sending your prompt to Claude.' }
217  })
218
219  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220    const els = $.ui.resolve(e)
221    const { Box, Text, Button } = els
222    paneSurface = e.surface
223    paneColumns = Math.max(8, Math.min(120, e.props.bodyColumns ?? 48))
224    // The rows the surface gave the pane: about a third of the terminal inline,
225    // its full height docked. A picture sized to more pushes the buttons and
226    // log out of view.
227    paneRows = Math.max(10, Math.min(40, e.props.scroll?.bodyRows ?? Math.floor((e.viewport?.rows ?? 30) / 2)))
228
229    const [entries, shot, status, isStopped] = await Promise.all([read($, log), read($, frame), read($, view), read($, stopped)])
230
231    const picture = (() => {
232      if (!shot) return <Text dimColor>No picture yet. It appears after Claude uses an app, or press Refresh.</Text>
233      if (e.surface === 'terminal' && 'Raster' in els && shot.cells) {
234        const { Raster } = els as any
235        return <Raster key="frame" columns={shot.columns} rows={shot.rows} cells={shot.cells} />
236      }
237      if ('Svg' in els && shot.image) {
238        const { Svg } = els as any
239        // Not `h`: JSX compiles to calls of the global h, and a local h broke the drawing.
240        const width = 320
241        const height = Math.round((shot.height / shot.width) * width)
242        const source =
243          `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
244          `<image href="data:${shot.image.mime};base64,${shot.image.base64}" width="${width}" height="${height}"/></svg>`
245        return <Svg source={source} alt={`Screenshot of ${shot.app}`} width={width} height={height} />
246      }
247      return <Text dimColor>{`${shot.app}: picture not shown on this surface. Press Refresh.`}</Text>
248    })()
249
250    const statusLine =
251      status.kind === 'snapshotting' ? `Refreshing ${status.app}…`
252      : status.kind === 'error' ? `Couldn't refresh: ${status.message}`
253      : shot ? `${shot.app} at ${shot.at}`
254      : ''
255    // Size the log to the rows the pane shows, not the whole terminal: picture,
256    // status line, buttons and the "Actions" heading take the rest.
257    const bodyRows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30
258    const room = Math.max(3, bodyRows - (shot?.rows ?? 1) - 4)
259    const mark = { running: '…', done: '✓', error: '✗', refused: '⊘' } as const
260
261    return (
262      <Box flexDirection="column">
263        {picture}
264        <Text dimColor>{isStopped ? 'Stopped until your next message.' : statusLine}</Text>
265        <Box flexDirection="row">
266          <Button
267            key="refresh"
268            label={turnRunning ? 'Refresh (after this turn)' : 'Refresh'}
269            hotkey="r"
270            onPress={() => {
271              if (turnRunning || !lastApp) return
272              const app = lastApp
273              void snapshot($, app).then(() => endEngineTurn($, 'Stop'))
274            }}
275          />
276          <Button
277            key="stop"
278            label="Stop"
279            hotkey="s"
280            onPress={() => {
281              void update($, stopped, () => true)
282              void endEngineTurn($, 'Interrupt')
283              $.ui.status('sleight · stopped')
284            }}
285          />
286        </Box>
287        <Text bold>Actions</Text>
288        {entries.length === 0 && <Text dimColor>None yet.</Text>}
289        {/* Newest first, so the latest action stays visible however short the pane is. */}
290        {entries.slice(-room).reverse().map(entry => (
291          <Text dimColor={entry.status === 'done'} wrap="truncate-end">
292            {`${mark[entry.status]} ${entry.at} ${entry.app ? `${entry.app}: ` : ''}${entry.title}`}
293          </Text>
294        ))}
295      </Box>
296    )
297  })
298}
299
hooks/snapshot.ts 81 lines
1// The JavaScript the mod runs in the computer-use engine's session (through
2// the `js` tool) to draw its pane. It must stay self-contained: it runs in the
3// engine, not here. snapshotCode() fills in the app and the frame size.
4//
5// It writes one line, `SLEIGHT_FRAME <json>`: the app's screenshot as
6// terminal cells (upper half blocks, two pixels per cell) for a terminal pane,
7// or else as an image small enough to embed in an SVG. Only one, to keep the
8// result small. The engine's sandbox can't load `sharp`, so it decodes with
9// the bundled pure-JS jpeg-js and pngjs.
10
11const SNAPSHOT_JS = `await (async () => {
12  // Claude Code replaces an MCP result over its token limit (25,000 by default)
13  // with a notice, and base64 counts heavily: a 51,135-character image never
14  // reached the desktop pane (2026-10-08). So the image stays near 16,000.
15  const APP = __APP__, COLS = __COLS__, ROWS = __ROWS__, TERMINAL = __TERMINAL__, MAX_IMAGE_B64 = 16000;
16  const app = await cua.getApp(APP);
17  const shot = Buffer.from(await app.getScreenshot({ emit: false }));
18  const isJpeg = shot[0] === 0xff && shot[1] === 0xd8;
19  const decoded = isJpeg
20    ? (await import('jpeg-js')).default.decode(shot, { useTArray: true })
21    : (await import('pngjs')).PNG.sync.read(shot);
22  const { width, height, data } = decoded;
23
24  // Fit the screenshot into COLS x ROWS*2 pixels, keeping its aspect ratio,
25  // averaging each source block.
26  const scale = Math.max(width / COLS, height / (ROWS * 2));
27  const outW = Math.max(1, Math.min(COLS, Math.round(width / scale)));
28  const outH = Math.max(2, Math.min(ROWS * 2, Math.round(height / scale)));
29  const px = (x, y) => {
30    const x0 = Math.floor(x * width / outW), x1 = Math.max(x0 + 1, Math.floor((x + 1) * width / outW));
31    const y0 = Math.floor(y * height / outH), y1 = Math.max(y0 + 1, Math.floor((y + 1) * height / outH));
32    let r = 0, g = 0, b = 0, n = 0;
33    for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
34      const i = (yy * width + xx) * 4;
35      r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
36    }
37    return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n);
38  };
39  const rows = Math.ceil(outH / 2);
40  const cells = Buffer.alloc(TERMINAL ? outW * rows * 12 : 0);
41  if (TERMINAL) for (let r = 0; r < rows; r++) for (let c = 0; c < outW; c++) {
42    const o = (r * outW + c) * 12;
43    cells.writeUInt32LE(0x2580, o);
44    cells.writeUInt32LE(px(c, r * 2), o + 4);
45    cells.writeUInt32LE(r * 2 + 1 < outH ? px(c, r * 2 + 1) : 0x01000000, o + 8);
46  }
47
48  let image = { mime: isJpeg ? 'image/jpeg' : 'image/png', base64: shot.toString('base64') };
49  if (TERMINAL) image = undefined;
50  else if (image.base64.length > MAX_IMAGE_B64) {
51    // Too big to embed: re-encode as a JPEG, from 1.5 times the 320 px the desktop
52    // pane draws, smaller and rougher until it fits.
53    const { encode } = (await import('jpeg-js')).default;
54    for (const [maxW, quality] of [[480, 70], [400, 60], [320, 55], [240, 50], [160, 45]]) {
55      const w = Math.min(width, maxW), h = Math.max(1, Math.round(height * w / width));
56      const small = Buffer.alloc(w * h * 4);
57      for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
58        const si = (Math.floor(y * height / h) * width + Math.floor(x * width / w)) * 4, di = (y * w + x) * 4;
59        small[di] = data[si]; small[di + 1] = data[si + 1]; small[di + 2] = data[si + 2]; small[di + 3] = 255;
60      }
61      image = { mime: 'image/jpeg', base64: Buffer.from(encode({ data: small, width: w, height: h }, quality).data).toString('base64') };
62      if (image.base64.length <= MAX_IMAGE_B64) break;
63    }
64  }
65
66  nodeRepl.write('SLEIGHT_FRAME ' + JSON.stringify({
67    app: APP, width, height, columns: outW, rows, cells: TERMINAL ? cells.toString('base64') : undefined, image,
68  }));
69})()
70`
71
72export const FRAME_MARKER = 'SLEIGHT_FRAME '
73
74export function snapshotCode(app: string, columns: number, rows: number, terminal: boolean): string {
75  return SNAPSHOT_JS
76    .replace('__APP__', JSON.stringify(app))
77    .replace('__TERMINAL__', String(terminal))
78    .replace('__COLS__', String(columns))
79    .replace('__ROWS__', String(rows))
80}
81
types/index.d.ts 43 lines
1// What the sleight mod keeps in $.state for its pane.
2
3/** One action Claude took through sleight, newest last. */
4export type LogEntry = {
5  id: string
6  /** The short description Claude gave the call, or the start of its code. */
7  title: string
8  app?: string
9  status: 'running' | 'done' | 'error' | 'refused'
10  /** Local time, HH:MM:SS. */
11  at: string
12}
13
14/** The pane's latest picture of the app, from lib's snapshot script. */
15export type Frame = {
16  app: string
17  /** Terminal cells for a Raster: columns * rows little-endian u32 triplets, base64. Terminal panes only. */
18  columns: number
19  rows: number
20  cells?: string
21  /** The screenshot, small enough to embed in an SVG. Other surfaces only. */
22  image?: { mime: string; base64: string }
23  width: number
24  height: number
25  at: string
26}
27
28export type ViewStatus =
29  | { kind: 'idle' }
30  | { kind: 'snapshotting'; app: string }
31  | { kind: 'error'; message: string }
32
33declare module 'claude-code' {
34  interface PluginState {
35    sleight: {
36      log: LogEntry[]
37      frame: Frame | null
38      view: ViewStatus
39      stopped: boolean
40    }
41  }
42}
43