SLOPSHOPPER

imagine

Generate images with your ChatGPT login (through OpenAI's Codex CLI) and add them to your project

newpanecommandtoaststatusprocess
★ 1v0.2.3MITupdated 2026-10-07romanlucian/claude-imagine
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · imagine
│ ┃ Imagine ✕ › fix the failing auth test and add an audit log call │ ┃ ● ChatGPT logged in │ ┃ ⏺ Read(src/auth.ts) │ ┃ Describe an image: ⎿ Read 6 lines │ ┃ a cozy cafe logo, flat style (start with 2x ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No images yet. Describe one above, or type ⏺ Bash(bun test) │ ┃ /imagine and a description. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /imagine │ ⎿ imagine: Imagine pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Imagine
● ChatGPT logged in Describe an image: a cozy cafe logo, flat style (start with 2x for two) ⏎ gene No images yet. Describe one above, or type /imagine and a description.
README

imagine: ChatGPT images inside Claude Code

Imagine: how it works

A Claude Code mod that adds an Imagine pane and an /imagine command:

  1. Log in with ChatGPT. It uses OpenAI's own Codex CLI, so your ChatGPT plan pays and you need no API key.
  2. Generate images from a description: one, or up to 4 variations at once.
  3. Put them in your project. Select one or more, and they are copied into a project folder (public/images by default).

An unofficial community mod by Lucian Roman, not made or supported by Anthropic or OpenAI. Mods are an early-access Claude Code feature, so keep Claude Code up to date (claude update).

What you need

  • Claude Code in a terminal (Ghostty, kitty, iTerm2, Terminal.app, VS Code's terminal…).
  • Codex CLI: npm install -g @openai/codex. The pane also has an Install Codex button.
  • A ChatGPT login, on Plus or higher. Codex only offers its image tool when it's logged in with ChatGPT. It doesn't offer it with an API key login or on the Free plan. Images count against your plan's Codex usage limits, and they use those limits faster than text does.

Step by step: install it and make your first image

New to the terminal? Follow these steps in order. Every command is something you copy, paste, and press Enter.

1. Get the things it needs

  1. Node.js. Go to nodejs.org, download the LTS version, and run the installer like any other app.
  2. Claude Code. If you don't have it yet, follow Anthropic's install page.
  3. ChatGPT Plus (or higher). Making images doesn't work on the Free plan.

2. Open a terminal

  • Mac: press Cmd + Space, type Terminal, press Enter.
  • Ubuntu: press Ctrl + Alt + T.

3. Install the mod (only once)

Type claude and press Enter to start Claude Code. Then type this inside Claude Code:

/plugin install imagine --marketplace romanlucian/claude-imagine

Answer y to add the marketplace, then press Enter for the user scope (it then works in all your projects). A settings screen with an empty Codex command field may appear. Leave it empty (the mod then uses codex) and choose Save configuration. Type /exit to quit Claude Code.

Getting updates later. In a terminal, type these two lines (press Enter after each), then start Claude Code again:

claude plugin marketplace update imagine
claude plugin update imagine@imagine

4. Open your project in Claude Code

"Your project" is the app you want images for. Images are saved into the folder Claude Code was started in, so start it there.

  • In the terminal: type cd and a space, but don't press Enter yet. Drag your project's folder from Finder (or Files) into the terminal window: its path appears by itself. Now press Enter, then type claude.
  • In VS Code: open your project's folder, open the terminal (Ctrl + backtick), and type claude.

5. Open the pane and log in

  1. Type /imagine and press Enter. The Imagine pane opens.
  2. If it says Codex is not installed, press Install Codex (npm) and wait for the "Codex is installed" message.
  3. Press Log in with ChatGPT. Your browser opens. Sign in to ChatGPT and allow access, then come back to the terminal.

6. Make your first image

Type this and press Enter:

/imagine a cozy cafe logo, flat style

Wait about a minute. When it's ready, a message says the image is ready in the Imagine pane.

7. Where is my image?

  • In the pane. Ghostty and kitty show a small preview. Terminal.app and VS Code's terminal only show the file name: that's normal, the image is there. Press Open to see it.
  • In your Pictures folder. The mod keeps a copy of every image in Pictures → Imagine, named after your description (for example a-cozy-cafe-logo.png). Open and Show in folder use this copy.
  • Not in your project yet. The image goes into your project only when you press Add to project. That copies it into the folder shown as Project folder in the pane (for example public/images).
  • To drag it somewhere (VS Code, Figma, CapCut): press Show in folder. Finder (or Files) opens with the image selected, and you drag it from there.

After Add to project, press Mention it in my prompt and ask Claude something like "put this image in the hero section".

If something goes wrong

You seeWhat to do
/imagine is an unknown commandThe mod isn't loaded. Type /plugin, open imagine, and look at its Errors tab. If imagine isn't listed, do step 3 again. Then quit Claude Code (/exit) and start it again.
"npm was not found"Install Node.js (step 1), close the terminal, open a new one, and start Claude Code again.
"Codex CLI is not installed yet"Open the pane with /imagine and press Install Codex (npm). Then press Check again.
"Claude Code could not run codex"Codex is installed, but Claude Code can't find it. In a normal terminal type which codex and copy the line it prints. In Claude Code, type /plugin, open imagine, paste that line into the Codex command field, and choose Save configuration. Then quit and start Claude Code again.
No browser window opens when you log in (for example over SSH)Type /imagine login code. Open the link the pane shows, sign in, and type the code it shows.
"Codex is logged in with an API key…"Images only work with a ChatGPT login. In a terminal type codex logout, then press Log in with ChatGPT in the pane.
"no image" or "Codex finished without making an image"Check that your ChatGPT plan is Plus or higher. If you made many images today, you may have hit your plan's limit: wait and try later.
"is no longer on disk" when you press Add to project or OpenYou have an older version of the mod. Get the update (end of step 3).
The pane shows a file name but no pictureYour terminal can't show images (only Ghostty and kitty can). Press Open to see it.
"could not add …"The project folder may be wrong or not writable. Change Project folder in the pane, for example to public/images.

Install

In Claude Code, from any folder, type:

/plugin install imagine --marketplace romanlucian/claude-imagine

Answer y to add the marketplace, then press Enter for the user scope. If it asks for Codex command, keep codex and press Enter. The mod then works in every project.

This works because this repo is also a plugin marketplace: .claude-plugin/marketplace.json lists the mod.

To get a newer version later:

claude plugin marketplace update imagine
claude plugin update imagine@imagine

Developing the mod: clone this repo and run claude --plugin-dir . inside it to load your working copy instead.

Use it

TypeWhat happens
/imagineOpens the Imagine pane
/imagine loginLogs in with ChatGPT in your browser
/imagine login codeLogs in with a link and a one-time code. Use this when Claude Code runs over SSH or no browser opens.
/imagine a cozy cafe logo, flat styleGenerates one image
/imagine 3x a cozy cafe logo, flat styleGenerates 3 variations (up to 4)
/imagine edit make the background blueMakes a new version of the latest image with that change

You can also type the description straight into the pane's Describe an image field.

Images are added to the project Claude Code was started in. In VS Code: open your app's folder, open the terminal (Ctrl + ), and type claude`.

Each image in the pane has these buttons:

  • Select: tick several, then press Add N selected images to project.
  • Add to project: copies just this one. The file gets a name from your description, such as cozy-cafe-logo.png, and existing files are never overwritten (-2, -3…).
  • Open: opens the image in your image viewer.
  • Show in folder: opens Finder (Mac) or Files (Ubuntu) with the image selected. Drag and drop it from there into VS Code, Figma, CapCut, anywhere.
  • Edit: type what to change ("make the background blue") and press Enter. Codex continues the same conversation that drew the image, like asking ChatGPT for changes, and the new version appears in the list next to the old one. You can edit the new version again.

Project folder sets where images go. It's remembered per project.

After adding, Mention it in my prompt puts @public/images/… in your prompt, so you can ask Claude to use the image ("put this in the hero section").

Previews: in Ghostty and kitty the pane shows thumbnails. Other terminals show just the file name, so use Open to look.

Good to know

  • Every image is also saved in ~/Pictures/Imagine (Finder: Pictures → Imagine), named from your description. The buttons use this copy, because Codex's own file in ~/.codex/generated_images/ can disappear. Clear list only empties the pane.
  • A terminal can't drag files out of itself. That's why Show in folder exists: drag from Finder/Files instead.
  • Over SSH (for example VS Code Remote-SSH), Open and Show in folder open on the machine Claude Code runs on.
  • "Claude Code could not run codex" even though Codex is installed: Claude Code didn't get the same PATH as your terminal. Run which codex in a terminal, then put that full path in this plugin's Codex command option.

How it works

  • Login: codex login or codex login --device-auth. Codex keeps the login in ~/.codex/. This mod never sees your password or tokens.
  • Generating: codex exec --json --sandbox read-only -, asking Codex's built-in $imagegen skill for the image(s). The sandbox stops Codex from changing any files; its image tool still saves each picture. Codex's JSON output doesn't list the images, so the mod reads the request's id from it (thread.started) and watches that request's folder, ~/.codex/generated_images/<id>/. Each image shows up in the pane as soon as it's saved, and the mod copies it to ~/Pictures/Imagine right away (and again when Codex finishes).
  • Editing: codex exec ... resume <id> - continues the image's own Codex run, so Codex still has the image and the conversation. The new version lands in the same run folder; the mod skips the files that were already there.
  • Adding: mkdir -p and cp into the project folder.

Developing

claude plugin validate .   # what the engine will load, and anything it would refuse
claude plugin test .       # tests/imagine.test.ts, with Codex faked

License

MIT, see LICENSE.

Source 3 files
hooks/register.tsx 877 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImagineImage, ImagineJob, ImagineLogin } from '../types'
5import {
6  baseName,
7  cleanFolder,
8  codexPrompt,
9  dirName,
10  editPrompt,
11  extensionOf,
12  imageFolder,
13  isImageFile,
14  loginPrompt,
15  parseRequest,
16  readLine,
17  readLoginStatus,
18  slug,
19} from './codex'
20
21type Engine = EngineInterface
22
23const PANE = 'imagine'
24const TITLE = 'Imagine'
25const KEEP_IMAGES = 40
26const SHOWN_IMAGES = 12
27
28const images = atom({ plugin: 'imagine', key: 'images' } as const, [])
29const jobs = atom({ plugin: 'imagine', key: 'jobs' } as const, [])
30const login = atom({ plugin: 'imagine', key: 'login' } as const, { status: 'unknown', detail: '' })
31const dest = atom({ plugin: 'imagine', key: 'dest' } as const, '')
32const lastAdded = atom({ plugin: 'imagine', key: 'lastAdded' } as const, [])
33const previews = atom({ plugin: 'imagine', key: 'previews' } as const, false)
34const editing = atom({ plugin: 'imagine', key: 'editing' } as const, '')
35const editDraft = atom({ plugin: 'imagine', key: 'editDraft' } as const, '')
36const promptDraft = atom({ plugin: 'imagine', key: 'promptDraft' } as const, '')
37
38// The children still running (Codex runs, a login), so Cancel can stop them.
39// Module state on purpose: a reload ends them anyway.
40const running = new Map<string, () => void>()
41
42const short = (text: string, max = 40): string =>
43  text.length > max ? `${text.slice(0, max - 1)}…` : text
44
45const oneLine = (text: string): string => short(text.replace(/\s+/g, ' ').trim(), 200)
46
47const plural = (count: number, word: string): string =>
48  `${count} ${word}${count === 1 ? '' : 's'}`
49
50// How to run Codex: the `codexPath` option, read again at every load.
51let codex = 'codex'
52
53// ---- Codex: install, login --------------------------------------------
54
55async function checkLogin($: Engine, force = false): Promise<ImagineLogin> {
56  let found: ImagineLogin
57  try {
58    const ran = await $.process.run([codex, 'login', 'status'], { timeoutMs: 20_000 })
59    found = readLoginStatus(ran.exitCode, `${ran.stdout}\n${ran.stderr}`)
60  } catch {
61    found = { status: 'no-codex', detail: `Claude Code could not run \`${codex}\`.` }
62  }
63  // A check never interrupts a login in progress; the login checks when it ends.
64  await update($, login, current =>
65    current.status === 'logging-in' && !force && found.status !== 'logged-in' ? current : found,
66  )
67  return found
68}
69
70async function installCodex($: Engine): Promise<void> {
71  $.ui.status('imagine: installing Codex CLI…')
72  try {
73    const ran = await $.process.run(['npm', 'install', '-g', '@openai/codex'], {
74      timeoutMs: 300_000,
75    })
76    if (ran.exitCode !== 0) {
77      const tail = (ran.stderr || ran.stdout).trim().split('\n').slice(-2).join(' ')
78      $.ui.toast(
79        `imagine: npm could not install Codex (${oneLine(tail)}). In a terminal: npm install -g @openai/codex`,
80        { timeoutMs: 15_000 },
81      )
82    }
83  } catch {
84    $.ui.toast('imagine: npm was not found. Install Node.js, then: npm install -g @openai/codex', {
85      timeoutMs: 15_000,
86    })
87  } finally {
88    showWork($)
89  }
90  const after = await checkLogin($, true)
91  if (after.status !== 'no-codex') $.ui.toast('imagine: Codex is installed. Now log in with ChatGPT.')
92}
93
94async function logIn($: Engine, mode: 'browser' | 'device'): Promise<void> {
95  running.get('login')?.()
96  const argv = mode === 'device' ? [codex, 'login', '--device-auth'] : [codex, 'login']
97  await update($, login, () => ({
98    status: 'logging-in',
99    detail:
100      mode === 'device'
101        ? 'Open the link, sign in to ChatGPT, then enter the code.'
102        : 'Finish signing in to ChatGPT in the browser window that opened.',
103  }))
104  let printed = ''
105  let stop: (() => void) | undefined
106  try {
107    const stream = $.process.spawn({ argv })
108    stop = () => void stream.return({ code: null, signal: 'SIGTERM' })
109    running.set('login', stop)
110    for await (const chunk of stream) {
111      printed += chunk.text
112      const { url, code } = loginPrompt(printed)
113      await update($, login, current =>
114        current.status === 'logging-in' && (current.url !== url || current.code !== code)
115          ? { ...current, ...(url === undefined ? {} : { url }), ...(code === undefined ? {} : { code }) }
116          : current,
117      )
118    }
119  } catch {
120    // Not started, or stopped: the check below says where that left things.
121  }
122  // A login that was cancelled or replaced by a newer one leaves the state to that one.
123  const isCurrent = stop === undefined ? !running.has('login') : running.get('login') === stop
124  if (!isCurrent) return
125  running.delete('login')
126  const after = await checkLogin($, true)
127  if (after.status === 'logged-in') $.ui.toast(`imagine: logged in (${after.detail})`)
128}
129
130async function cancelLogin($: Engine): Promise<void> {
131  running.get('login')?.()
132  running.delete('login')
133  await checkLogin($, true)
134}
135
136// ---- Generating ---------------------------------------------------------
137
138function showWork($: Engine): void {
139  const busy = [...running.keys()].filter(key => key !== 'login').length
140  $.ui.status(busy === 0 ? undefined : `imagine: drawing ${plural(busy, 'request')}…`)
141}
142
143async function codexHome($: Engine): Promise<string> {
144  const custom = await $.env.get('CODEX_HOME')
145  if (custom !== undefined && custom !== '') return custom.replace(/\/+$/, '')
146  return `${(await $.env.get('HOME')) ?? ''}/.codex`
147}
148
149/** The image files in one folder, oldest first; none when it does not exist (yet). */
150async function imagesIn($: Engine, folder: string): Promise<string[]> {
151  let entries
152  try {
153    entries = await $.fs.list(folder)
154  } catch {
155    return []
156  }
157  return entries
158    .filter(entry => entry.kind === 'file' && isImageFile(entry.name))
159    .sort((a, b) => a.mtimeMs - b.mtimeMs)
160    .map(entry => `${folder}/${entry.name}`)
161}
162
163/** Images under `$CODEX_HOME/generated_images` written since `since`, oldest first. */
164async function recentImages($: Engine, since: number): Promise<string[]> {
165  const root = `${await codexHome($)}/generated_images`
166  const found: { path: string; at: number }[] = []
167  const walk = async (folder: string, depth: number): Promise<void> => {
168    let entries
169    try {
170      entries = await $.fs.list(folder)
171    } catch {
172      return
173    }
174    for (const entry of entries) {
175      const path = `${folder}/${entry.name}`
176      if (entry.kind === 'dir' && depth < 3) await walk(path, depth + 1)
177      else if (entry.kind === 'file' && isImageFile(entry.name) && entry.mtimeMs >= since - 5_000) {
178        found.push({ path, at: entry.mtimeMs })
179      }
180    }
181  }
182  await walk(root, 0)
183  return found.sort((a, b) => a.at - b.at).map(one => one.path)
184}
185
186async function saveImages($: Engine): Promise<void> {
187  await $.store.set('images', await read($, images))
188}
189
190/**
191 * Where the mod keeps its own copy of each image: `~/Pictures/Imagine`.
192 * Codex's file can be gone by the time the person presses a button, so the
193 * pane, Open, Show in folder and Add to project all use this copy.
194 */
195async function keptFolder($: Engine): Promise<string> {
196  return `${(await $.env.get('HOME')) ?? ''}/Pictures/Imagine`
197}
198
199/** Copies Codex's file into the kept folder; the copy's path, or the original when the copy fails. */
200async function keepCopy($: Engine, path: string, prompt: string, target?: string): Promise<string> {
201  try {
202    const folder = await keptFolder($)
203    await $.process.run(['mkdir', '-p', folder])
204    const copy = target ?? (await freeName($, folder, slug(prompt), extensionOf(path)))
205    const ran = await $.process.run(['cp', path, copy])
206    return ran.exitCode === 0 ? copy : path
207  } catch {
208    return path
209  }
210}
211
212async function addImage($: Engine, path: string, prompt: string, threadId?: string): Promise<void> {
213  const createdAt = await $.clock.now()
214  const image: ImagineImage = {
215    id: `img-${createdAt}-${Math.floor(Math.random() * 1e6)}`,
216    path,
217    name: baseName(path),
218    prompt,
219    createdAt,
220    isSelected: false,
221    ...(threadId === undefined ? {} : { threadId }),
222  }
223  await update($, images, list =>
224    list.some(one => one.path === path) ? list : [...list, image].slice(-KEEP_IMAGES),
225  )
226  await saveImages($)
227}
228
229async function setJob($: Engine, id: string, change: Partial<ImagineJob>): Promise<void> {
230  await update($, jobs, list => list.map(job => (job.id === id ? { ...job, ...change } : job)))
231}
232
233/**
234 * Starts a Codex run: a new image from `request`, or with `base` a new version
235 * of that image, `request` saying what to change, in the same Codex conversation.
236 */
237async function generate(
238  $: Engine,
239  request: string,
240  base?: ImagineImage,
241): Promise<{ isStarted: boolean; text: string }> {
242  const { count, prompt } = base === undefined ? parseRequest(request) : { count: 1, prompt: request.trim() }
243  if (prompt === '') {
244    return {
245      isStarted: false,
246      text:
247        base === undefined
248          ? 'Describe the image first, e.g. /imagine a cozy cafe logo, flat style'
249          : 'Say what to change first, e.g. /imagine edit make the background blue',
250    }
251  }
252  if (base !== undefined && base.threadId === undefined) {
253    return { isStarted: false, text: 'This image was made before Edit existed: generate it again to edit it.' }
254  }
255  const account = await checkLogin($)
256  if (account.status !== 'logged-in') {
257    void $.ui.open({ id: PANE, title: TITLE })
258    return {
259      isStarted: false,
260      text:
261        account.status === 'no-codex'
262          ? 'Codex CLI is not installed yet: the Imagine pane has an Install button.'
263          : 'Log in with ChatGPT first: the Imagine pane has the button.',
264    }
265  }
266  const startedAt = await $.clock.now()
267  const job: ImagineJob = {
268    id: `job-${startedAt}-${Math.floor(Math.random() * 1e6)}`,
269    prompt,
270    count,
271    status: 'running',
272    message: 'Starting Codex…',
273    startedAt,
274  }
275  await update($, jobs, list => [...list, job].slice(-6))
276  void runJob($, job, base)
277  return {
278    isStarted: true,
279    text:
280      base === undefined
281        ? `Generating ${count === 1 ? 'an image' : `${count} images`} of "${prompt}" with your ChatGPT account. They will appear in the Imagine pane.`
282        : `Editing ${base.name}: "${prompt}". The new version will appear in the Imagine pane.`,
283  }
284}
285
286async function runJob($: Engine, job: ImagineJob, base?: ImagineImage): Promise<void> {
287  const home = await codexHome($)
288  const found: string[] = []
289  // An edit continues the base image's Codex run, which saves into the same
290  // folder: the images already there are the earlier versions, not new ones.
291  const seen = new Set(base?.threadId === undefined ? [] : await imagesIn($, imageFolder(home, base.threadId)))
292  // The new version is named and described after the image it changes.
293  const described = base === undefined ? job.prompt : `${base.prompt}, ${job.prompt}`
294  const mentioned: string[] = []
295  let threadId: string | undefined
296  let failure: string | undefined
297  let reply: string | undefined
298  let isCancelled = false
299  let isSettled = false
300  let exitCode: number | null = null
301
302  // Codex's file → the mod's own copy of it.
303  const kept = new Map<string, string>()
304  const claim = async (paths: readonly string[]): Promise<void> => {
305    for (const path of paths) {
306      if (found.includes(path) || seen.has(path)) continue
307      found.push(path)
308      const copy = await keepCopy($, path, described)
309      kept.set(path, copy)
310      await addImage($, copy, described, threadId ?? base?.threadId)
311      // A look still under way when the run ended must not undo its final line.
312      if (!isSettled) await setJob($, job.id, { message: `${found.length} of ${job.count} ready…` })
313    }
314  }
315  // Codex writes each image into the run's folder as it finishes; look there
316  // every few seconds so images show up while the rest are still drawing.
317  const collect = async (): Promise<void> => {
318    if (threadId !== undefined) await claim(await imagesIn($, imageFolder(home, threadId)))
319  }
320  const take = async (line: string): Promise<void> => {
321    const said = readLine(line)
322    if (said.threadId !== undefined && threadId === undefined) {
323      threadId = said.threadId
324      await setJob($, job.id, { message: 'Codex is drawing… (an image can take a minute)' })
325    }
326    if (said.failure !== undefined) failure = said.failure
327    if (said.reply !== undefined) reply = said.reply
328    for (const path of said.mentioned) if (!mentioned.includes(path)) mentioned.push(path)
329  }
330
331  const poll = $.clock.every(4_000, () => void collect())
332  try {
333    const options = ['--json', '--skip-git-repo-check', '--sandbox', 'read-only', '-c', 'model_reasoning_effort="low"']
334    const stream = $.process.spawn({
335      argv:
336        base?.threadId === undefined
337          ? [codex, 'exec', ...options, '-']
338          : [codex, 'exec', ...options, 'resume', base.threadId, '-'],
339      input: base === undefined ? codexPrompt(job.prompt, job.count) : editPrompt(job.prompt),
340    })
341    running.set(job.id, () => {
342      isCancelled = true
343      void stream.return({ code: null, signal: 'SIGTERM' })
344    })
345    showWork($)
346    let pending = ''
347    for await (const chunk of stream) {
348      if (chunk.stream !== 'stdout') continue
349      pending += chunk.text
350      const lines = pending.split('\n')
351      pending = lines.pop() ?? ''
352      for (const line of lines) await take(line)
353    }
354    if (pending.trim() !== '') await take(pending)
355    exitCode = (await stream.result).code
356  } catch (error) {
357    if (!isCancelled && failure === undefined) {
358      failure = `Could not run Codex: ${error instanceof Error ? error.message : String(error)}`
359    }
360  } finally {
361    poll.cancel()
362    running.delete(job.id)
363    showWork($)
364  }
365
366  // Whatever finished before a Cancel is kept.
367  await collect()
368  if (found.length === 0) {
369    // A Codex that saves elsewhere: the files its reply names, and with no
370    // run id to go by, whatever appeared in its image folder meanwhile.
371    const existing: string[] = []
372    for (const path of mentioned) {
373      if (path.startsWith(`${home}/generated_images/`) && (await $.fs.exists(path))) existing.push(path)
374    }
375    await claim(existing)
376    if (found.length === 0 && (threadId === undefined || base !== undefined) && !isCancelled) {
377      await claim(await recentImages($, job.startedAt))
378    }
379  }
380
381  // Copy again now that Codex is done, in case a file was still being written
382  // when it was first seen.
383  for (const [path, copy] of kept) {
384    if (copy !== path && (await $.fs.exists(path))) await keepCopy($, path, described, copy)
385  }
386
387  isSettled = true
388  if (isCancelled) {
389    await setJob($, job.id, {
390      status: 'failed',
391      message: found.length === 0 ? 'Cancelled.' : `Cancelled after ${plural(found.length, 'image')}.`,
392    })
393    return
394  }
395  if (found.length > 0) {
396    await setJob($, job.id, { status: 'done', message: `${plural(found.length, 'image')} ready` })
397    $.ui.toast(`imagine: ${plural(found.length, 'image')} of "${short(job.prompt)}" ready in the Imagine pane`)
398    return
399  }
400  const why =
401    failure ??
402    (reply === undefined ? undefined : `Codex made no image. It said: ${reply}`) ??
403    (exitCode !== null && exitCode !== 0
404      ? `Codex exited with code ${exitCode}.`
405      : 'Codex finished without making an image.')
406  await setJob($, job.id, { status: 'failed', message: oneLine(why) })
407  $.ui.toast(`imagine: no image. ${oneLine(why)}`, { timeoutMs: 12_000 })
408}
409
410// ---- Into the project ---------------------------------------------------
411
412async function projectFolder($: Engine): Promise<string> {
413  const folder = cleanFolder(await read($, dest), 'assets/images')
414  if (!folder.startsWith('~/')) return folder
415  return `${(await $.env.get('HOME')) ?? ''}${folder.slice(1)}`
416}
417
418async function freeName($: Engine, folder: string, base: string, extension: string): Promise<string> {
419  for (let n = 1; n < 500; n++) {
420    const candidate = `${folder}/${base}${n === 1 ? '' : `-${n}`}${extension}`
421    if (!(await $.fs.exists(candidate))) return candidate
422  }
423  return `${folder}/${base}-${await $.clock.now()}${extension}`
424}
425
426async function addToProject($: Engine, chosen: readonly ImagineImage[]): Promise<void> {
427  if (chosen.length === 0) {
428    $.ui.toast('imagine: select an image first')
429    return
430  }
431  const folder = await projectFolder($)
432  const copied: string[] = []
433  const problems: string[] = []
434  try {
435    await $.process.run(['mkdir', '-p', folder])
436  } catch {
437    // cp below says what went wrong
438  }
439  for (const image of chosen) {
440    if (!(await $.fs.exists(image.path))) {
441      problems.push(`${image.name} is no longer on disk`)
442      continue
443    }
444    const target = await freeName($, folder, slug(image.prompt), extensionOf(image.path))
445    try {
446      const ran = await $.process.run(['cp', image.path, target])
447      if (ran.exitCode !== 0) {
448        problems.push(`${image.name}: ${oneLine(ran.stderr || `cp exited ${ran.exitCode}`)}`)
449        continue
450      }
451    } catch (error) {
452      problems.push(`${image.name}: ${error instanceof Error ? error.message : String(error)}`)
453      continue
454    }
455    copied.push(target)
456    await update($, images, list =>
457      list.map(one => (one.id === image.id ? { ...one, addedTo: target, isSelected: false } : one)),
458    )
459  }
460  await saveImages($)
461  if (copied.length > 0) {
462    await update($, lastAdded, () => copied)
463    $.ui.toast(`imagine: added ${copied.join(', ')}`, { timeoutMs: 8_000 })
464  }
465  if (problems.length > 0) {
466    $.ui.toast(`imagine: could not add ${problems.join('; ')}`, { timeoutMs: 12_000 })
467  }
468}
469
470async function setFolder($: Engine, value: string): Promise<void> {
471  const folder = cleanFolder(value, 'assets/images')
472  await update($, dest, () => folder)
473  await $.store.set(`dest:${await $.session.cwd()}`, folder)
474  $.ui.toast(`imagine: images will be added to ${folder}`)
475}
476
477async function reveal($: Engine, path: string, how: 'file' | 'folder'): Promise<void> {
478  let isMac = false
479  try {
480    isMac = (await $.process.run(['uname', '-s'])).stdout.trim() === 'Darwin'
481  } catch {
482    // not a POSIX host: try xdg-open below and report what happens
483  }
484  const argv = isMac
485    ? how === 'file'
486      ? ['open', path]
487      : ['open', '-R', path]
488    : ['xdg-open', how === 'file' ? path : dirName(path)]
489  let problem: string | undefined
490  try {
491    const ran = await $.process.run(argv, { timeoutMs: 15_000 })
492    if (ran.exitCode !== 0) problem = oneLine(ran.stderr) || `${argv[0]} exited ${ran.exitCode}`
493  } catch (error) {
494    problem = error instanceof Error ? error.message : String(error)
495  }
496  if (problem !== undefined) {
497    $.ui.toast(`imagine: could not open it on this machine (${problem}). The file is ${path}`, {
498      timeoutMs: 12_000,
499    })
500  }
501}
502
503async function mention($: Engine): Promise<void> {
504  const paths = await read($, lastAdded)
505  if (paths.length === 0) return
506  await $.prompt.fill({ text: `${paths.map(path => `@${path}`).join(' ')} `, mode: 'insert' })
507}
508
509async function restore($: Engine): Promise<void> {
510  const saved = await $.store.get('images')
511  const kept: ImagineImage[] = []
512  if (Array.isArray(saved)) {
513    for (const one of saved as ImagineImage[]) {
514      if (typeof one?.path === 'string' && (await $.fs.exists(one.path))) kept.push({ ...one, isSelected: false })
515    }
516  }
517  await update($, images, current => (current.length > 0 ? current : kept))
518
519  const cwd = await $.session.cwd()
520  const remembered = await $.store.get(`dest:${cwd}`)
521  const folder =
522    typeof remembered === 'string' && remembered !== ''
523      ? remembered
524      : (await $.fs.exists('public'))
525        ? 'public/images'
526        : (await $.fs.exists('src/assets'))
527          ? 'src/assets/images'
528          : 'assets/images'
529  await update($, dest, current => (current === '' ? folder : current))
530
531  const term = (await $.env.get('TERM')) ?? ''
532  const program = (await $.env.get('TERM_PROGRAM')) ?? ''
533  const isKitty = (await $.env.get('KITTY_WINDOW_ID')) !== undefined
534  const canDraw = isKitty || /kitty|ghostty/i.test(term) || /ghostty/i.test(program)
535  if (canDraw) await update($, previews, () => true)
536}
537
538export const register: Register = (on, options) => {
539  const configured = typeof options.codexPath === 'string' ? options.codexPath.trim() : ''
540  codex = configured === '' ? 'codex' : configured
541
542  // ---- Hooks ----------------------------------------------------------------
543
544  on('session.start', async ($, e, next) => {
545    await $.command.register({
546      name: 'imagine',
547      description: 'Generate images with your ChatGPT account (via Codex) and add them to the project',
548      argumentHint: '[2x] <description> | edit <change> | login | login code',
549    })
550    await restore($)
551    return next(e)
552  })
553
554  on('command.run', { command: 'imagine' }, async ($, e) => {
555    const args = e.args.trim()
556    await $.ui.open({ id: PANE, title: TITLE })
557    if (args === '') {
558      void checkLogin($)
559      return { text: 'Imagine pane opened.' }
560    }
561    if (/^login(\s+(code|--device-auth))?$/i.test(args)) {
562      const mode = /code|device/i.test(args) ? 'device' : 'browser'
563      void logIn($, mode)
564      return {
565        text:
566          mode === 'device'
567            ? 'Logging in with ChatGPT: the Imagine pane shows the link and the code.'
568            : 'Logging in with ChatGPT: finish in the browser window that opens.',
569      }
570    }
571    const edit = args.match(/^edit(?:\s+([\s\S]*))?$/i)
572    if (edit !== null) {
573      const latest = (await read($, images)).filter(one => one.threadId !== undefined).pop()
574      if (latest === undefined) return { text: 'Make an image first: /imagine edit changes the latest one.' }
575      const started = await generate($, edit[1] ?? '', latest)
576      return { text: started.text }
577    }
578    const started = await generate($, args)
579    return { text: started.text }
580  })
581
582  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
583    const table = $.ui.resolve(e)
584    const { Box, Text, Button, Link } = table
585    const Input = 'Input' in table ? table.Input : undefined
586    const Image = 'Image' in table ? table.Image : undefined
587
588    const account = await read($, login)
589    const list = await read($, images)
590    const work = await read($, jobs)
591    const folder = await read($, dest)
592    const added = await read($, lastAdded)
593    const isPreviewing = (await read($, previews)) && Image !== undefined
594    const editingId = await read($, editing)
595    const typedEdit = await read($, editDraft)
596    const typedPrompt = await read($, promptDraft)
597    const selected = list.filter(one => one.isSelected)
598    const isLoggedIn = account.status === 'logged-in'
599
600    const accountRows = (() => {
601      switch (account.status) {
602        case 'unknown':
603          return (
604            <Box gap={1}>
605              <Text dimColor>Checking your ChatGPT login…</Text>
606              <Button key="recheck" label="Check again" onPress={() => void checkLogin($, true)} />
607            </Box>
608          )
609        case 'no-codex':
610          return (
611            <Box flexDirection="column">
612              <Text>
613                This mod signs in with ChatGPT through OpenAI's free Codex CLI, which is not installed here yet.
614              </Text>
615              <Text dimColor>{account.detail}</Text>
616              <Box gap={1} flexWrap="wrap">
617                <Button key="install" variant="primary" label="Install Codex (npm)" onPress={() => void installCodex($)} />
618                <Button key="recheck" label="Check again" onPress={() => void checkLogin($, true)} />
619              </Box>
620              <Text dimColor>Or in a terminal: npm install -g @openai/codex</Text>
621              <Text dimColor>
622                Installed but still not found? Run `which codex` in a terminal and put that path in this plugin's
623                "Codex command" option.
624              </Text>
625            </Box>
626          )
627        case 'logged-out':
628          return (
629            <Box flexDirection="column">
630              <Text>{account.detail}</Text>
631              <Box gap={1} flexWrap="wrap">
632                <Button key="login" variant="primary" label="Log in with ChatGPT" onPress={() => void logIn($, 'browser')} />
633                <Button key="login-code" label="Use a code instead" onPress={() => void logIn($, 'device')} />
634              </Box>
635              <Text dimColor>
636                Use a code when Claude Code runs over SSH or no browser opens. Making images needs ChatGPT Plus or higher.
637              </Text>
638            </Box>
639          )
640        case 'logging-in':
641          return (
642            <Box flexDirection="column">
643              <Text>{account.detail}</Text>
644              {account.url !== undefined && (
645                <Box gap={1}>
646                  <Text>Sign-in page:</Text>
647                  <Link href={account.url} label={account.code === undefined ? 'open it' : account.url} />
648                </Box>
649              )}
650              {account.code !== undefined && (
651                <Box gap={1}>
652                  <Text>Code:</Text>
653                  <Text bold color="claude">
654                    {account.code}
655                  </Text>
656                  <Button
657                    key="copy-code"
658                    label="Copy"
659                    onPress={press => void $.ui.copy({ text: account.code ?? '', surface: press.surface })}
660                  />
661                </Box>
662              )}
663              <Box>
664                <Button key="cancel-login" label="Cancel" onPress={() => void cancelLogin($)} />
665              </Box>
666            </Box>
667          )
668        case 'logged-in':
669          return (
670            <Box gap={1}>
671              <Text color="success">● ChatGPT</Text>
672              <Text dimColor>{account.detail}</Text>
673            </Box>
674          )
675      }
676    })()
677
678    const promptRow = !isLoggedIn ? null : Input !== undefined ? (
679      <Box flexDirection="column">
680        <Text bold>Describe an image:</Text>
681        <Input
682          key="prompt"
683          placeholder="a cozy cafe logo, flat style  (start with 2x for two)"
684          value={typedPrompt}
685          submitLabel="generate"
686          onInput={value => void update($, promptDraft, () => value)}
687          onSubmit={value => {
688            void update($, promptDraft, () => '')
689            void generate($, value).then(result => {
690              if (!result.isStarted) $.ui.toast(`imagine: ${result.text}`)
691            })
692          }}
693        />
694      </Box>
695    ) : (
696      <Text dimColor>Type /imagine and a description to make an image.</Text>
697    )
698
699    const jobRows =
700      work.length === 0 ? null : (
701        <Box flexDirection="column">
702          {work
703            .slice()
704            .reverse()
705            .map(job => (
706              <Box key={job.id} flexDirection="column">
707                <Box gap={1}>
708                  <Text color={job.status === 'failed' ? 'error' : job.status === 'done' ? 'success' : 'warning'}>
709                    {job.status === 'running' ? '…' : job.status === 'done' ? '✓' : '✗'}
710                  </Text>
711                  <Text wrap="truncate-end">
712                    "{short(job.prompt, 36)}"{job.count > 1 ? ` ×${job.count}` : ''}
713                  </Text>
714                  {job.status === 'running' && (
715                    <Button key={`cancel-${job.id}`} label="Cancel" onPress={() => running.get(job.id)?.()} />
716                  )}
717                </Box>
718                <Text dimColor>  {job.message}</Text>
719              </Box>
720            ))}
721        </Box>
722      )
723
724    const imageRows =
725      list.length === 0 ? (
726        isLoggedIn ? <Text dimColor>No images yet. Describe one above, or type /imagine and a description.</Text> : null
727      ) : (
728        <Box flexDirection="column" gap={1}>
729          <Box gap={1}>
730            <Text bold>Images</Text>
731            <Text dimColor>({list.length})</Text>
732            {Image !== undefined && (
733              <Button
734                key="previews"
735                label={isPreviewing ? 'Hide previews' : 'Show previews'}
736                onPress={() => void update($, previews, value => !value)}
737              />
738            )}
739          </Box>
740          {list
741            .slice()
742            .reverse()
743            .slice(0, SHOWN_IMAGES)
744            .map(image => (
745              <Box key={image.id} flexDirection="column">
746              <Box gap={1}>
747                {isPreviewing && Image !== undefined && image.path.startsWith('/') && /\.png$/i.test(image.path) && (
748                  <Image source={{ file: image.path, format: 'png' }} columns={16} rows={8} alt={image.name} />
749                )}
750                <Box flexDirection="column" flexShrink={1}>
751                  <Text wrap="truncate-middle">
752                    {image.isSelected ? '☑ ' : '☐ '}
753                    {image.name}
754                  </Text>
755                  <Text dimColor wrap="truncate-end">
756                    "{short(image.prompt, 60)}"
757                  </Text>
758                  {image.addedTo !== undefined && (
759                    <Text color="success" wrap="truncate-middle">
760                      ✓ in project: {image.addedTo}
761                    </Text>
762                  )}
763                  <Box gap={1} flexWrap="wrap">
764                    <Button
765                      key={`select-${image.id}`}
766                      label={image.isSelected ? 'Unselect' : 'Select'}
767                      onPress={() =>
768                        void update($, images, current =>
769                          current.map(one => (one.id === image.id ? { ...one, isSelected: !one.isSelected } : one)),
770                        )
771                      }
772                    />
773                    <Button key={`add-${image.id}`} label="Add to project" onPress={() => void addToProject($, [image])} />
774                    <Button key={`open-${image.id}`} label="Open" onPress={() => void reveal($, image.path, 'file')} />
775                    <Button
776                      key={`show-${image.id}`}
777                      label="Show in folder"
778                      onPress={() => void reveal($, image.path, 'folder')}
779                    />
780                    {Input !== undefined && image.threadId !== undefined && (
781                      <Button
782                        key={`edit-${image.id}`}
783                        label={editingId === image.id ? 'Cancel edit' : 'Edit'}
784                        onPress={async () => {
785                          await update($, editDraft, () => '')
786                          await update($, editing, current => (current === image.id ? '' : image.id))
787                        }}
788                      />
789                    )}
790                  </Box>
791                </Box>
792              </Box>
793              {Input !== undefined && editingId === image.id && (
794                <Box flexDirection="column" marginTop={1}>
795                  <Text bold>What to change:</Text>
796                  <Input
797                    key={`change-${image.id}`}
798                    placeholder="make the background blue, bigger title"
799                    value={typedEdit}
800                    submitLabel="edit"
801                    onInput={value => void update($, editDraft, () => value)}
802                    onSubmit={value => {
803                      void generate($, value, image).then(async result => {
804                        if (!result.isStarted) $.ui.toast(`imagine: ${result.text}`)
805                        else {
806                          await update($, editDraft, () => '')
807                          await update($, editing, () => '')
808                        }
809                      })
810                    }}
811                  />
812                </Box>
813              )}
814              </Box>
815            ))}
816          {list.length > SHOWN_IMAGES && (
817            <Text dimColor>…and {plural(list.length - SHOWN_IMAGES, 'older image')} (Clear list to tidy up).</Text>
818          )}
819        </Box>
820      )
821
822    const projectRows =
823      list.length === 0 ? null : (
824        <Box flexDirection="column">
825          {Input !== undefined ? (
826            <Input
827              key="dest"
828              label="Project folder"
829              value={folder}
830              submitLabel="save"
831              onInput={value => void update($, dest, () => value)}
832              onSubmit={value => void setFolder($, value)}
833            />
834          ) : (
835            <Text dimColor>Project folder: {folder}</Text>
836          )}
837          <Box gap={1} flexWrap="wrap">
838            {selected.length > 0 && (
839              <Button
840                key="add-selected"
841                variant="primary"
842                label={`Add ${plural(selected.length, 'selected image')} to project`}
843                onPress={async () => addToProject($, (await read($, images)).filter(one => one.isSelected))}
844              />
845            )}
846            {added.length > 0 && (
847              <Button
848                key="mention"
849                label={added.length === 1 ? 'Mention it in my prompt' : 'Mention them in my prompt'}
850                onPress={() => void mention($)}
851              />
852            )}
853            <Button
854              key="clear"
855              label="Clear list"
856              dimColor
857              onPress={async () => {
858                await update($, images, () => [])
859                await saveImages($)
860              }}
861            />
862          </Box>
863        </Box>
864      )
865
866    return (
867      <Box flexDirection="column" gap={1}>
868        {accountRows}
869        {promptRow}
870        {jobRows}
871        {imageRows}
872        {projectRows}
873      </Box>
874    )
875  })
876}
877
hooks/codex.ts 163 lines
1// Pure helpers for talking to OpenAI's Codex CLI: what to send it, and how
2// to read what it prints. No `$` here, so the tests can call them directly.
3
4const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g
5const IMAGE_FILE = /\.(png|jpe?g|webp|gif)$/i
6
7export const MAX_IMAGES = 4
8
9export const stripAnsi = (text: string): string => text.replace(ANSI, '')
10
11export const isImageFile = (path: string): boolean => IMAGE_FILE.test(path)
12
13/**
14 * Splits what the person typed into how many images and the description:
15 * `3x a red fox logo` (or `x3 ...`) asks for three; anything else for one.
16 */
17export function parseRequest(text: string): { count: number; prompt: string } {
18  const trimmed = text.trim()
19  const match = trimmed.match(/^(?:(\d+)x|x(\d+))\s+([\s\S]+)$/i)
20  if (match === null) return { count: 1, prompt: trimmed }
21  const count = Number(match[1] ?? match[2])
22  return { count: Math.min(Math.max(count, 1), MAX_IMAGES), prompt: (match[3] ?? '').trim() }
23}
24
25/** The instructions `codex exec` gets: use its built-in image tool, touch nothing else. */
26export function codexPrompt(prompt: string, count: number): string {
27  const what =
28    count === 1
29      ? 'one image'
30      : `${count} different variations, one separate image_gen call for each,`
31  return [
32    `Use $imagegen to generate ${what} of: ${prompt}`,
33    '',
34    'This is for preview only: leave the generated files where image_gen saves them.',
35    'Do not copy, move or edit any files, and do not run shell commands.',
36  ].join('\n')
37}
38
39/** What a resumed `codex exec` gets: change the image it made last, in the same conversation. */
40export function editPrompt(change: string): string {
41  return [
42    `Use $imagegen to make a new version of the last image you generated, with this change: ${change}`,
43    'Keep everything else about it the same.',
44    '',
45    'This is for preview only: leave the generated files where image_gen saves them.',
46    'Do not copy, move or edit any files, and do not run shell commands.',
47  ].join('\n')
48}
49
50/**
51 * Where Codex saves one run's images: `$CODEX_HOME/generated_images/<thread id>/<call id>.png`,
52 * the id cleaned the way Codex cleans it (codex-rs/ext/image-generation/src/artifact.rs).
53 * `codex exec --json` names no image file, so this folder is how the mod finds them.
54 */
55export function imageFolder(codexHome: string, threadId: string): string {
56  const clean = threadId.replace(/[^A-Za-z0-9_-]/g, '_') || 'generated_image'
57  return `${codexHome}/generated_images/${clean}`
58}
59
60/** What one line of `codex exec --json` says that the mod cares about. */
61export type CodexLine = {
62  /** The run's id, from `thread.started`: its images are saved under it. */
63  threadId?: string
64  /** Why the run failed, when the line says it did. */
65  failure?: string
66  /** Codex's own reply, kept to explain a run that made no image. */
67  reply?: string
68  /** Image files the reply names, for a Codex that saves them elsewhere. */
69  mentioned: string[]
70}
71
72export function readLine(line: string): CodexLine {
73  const result: CodexLine = { mentioned: [] }
74  let event: unknown
75  try {
76    event = JSON.parse(line)
77  } catch {
78    return result
79  }
80  if (event === null || typeof event !== 'object') return result
81
82  const record = event as {
83    type?: unknown
84    thread_id?: unknown
85    error?: { message?: unknown }
86    item?: { type?: unknown; text?: unknown }
87  }
88  if (record.type === 'thread.started' && typeof record.thread_id === 'string') {
89    result.threadId = record.thread_id
90  }
91  if (record.type === 'turn.failed') {
92    const message = record.error?.message
93    result.failure = typeof message === 'string' ? message : 'Codex stopped with an error.'
94  }
95  const item = record.item
96  if (record.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string') {
97    result.reply = item.text
98    for (const [path] of item.text.matchAll(/\/[^\s'"`()<>]+/g)) {
99      const clean = path.replace(/[.,;:!?]+$/, '')
100      if (isImageFile(clean) && !result.mentioned.includes(clean)) result.mentioned.push(clean)
101    }
102  }
103  return result
104}
105
106/** The link to open and the one-time code, from what `codex login` printed so far. */
107export function loginPrompt(text: string): { url?: string; code?: string } {
108  const plain = stripAnsi(text)
109  const url = plain.match(/https:\/\/[^\s"'<>]+/)?.[0]
110  const code = plain.match(/\b[A-Z0-9]{3,6}-[A-Z0-9]{3,6}\b/)?.[0]
111  return { url, code }
112}
113
114/**
115 * What `codex login status` means for making images, without echoing any key.
116 * Codex offers its image tool only to a ChatGPT login (not an API key, not the
117 * Free plan), so an API-key login counts as not logged in here.
118 */
119export function readLoginStatus(
120  exitCode: number,
121  output: string,
122): { status: 'logged-in' | 'logged-out'; detail: string } {
123  if (exitCode !== 0) return { status: 'logged-out', detail: 'Not logged in to ChatGPT yet.' }
124  if (/chatgpt/i.test(output)) return { status: 'logged-in', detail: 'ChatGPT account' }
125  if (/api key/i.test(output)) {
126    return {
127      status: 'logged-out',
128      detail: 'Codex is logged in with an API key, but it makes images only with a ChatGPT login.',
129    }
130  }
131  return { status: 'logged-in', detail: 'logged in' }
132}
133
134/** A file name from the description: `A red fox, flat logo!` → `a-red-fox-flat-logo`. */
135export function slug(prompt: string): string {
136  const words = prompt
137    .toLowerCase()
138    .normalize('NFKD')
139    .replace(/[̀-ͯ]/g, '')
140    .replace(/[^a-z0-9]+/g, ' ')
141    .trim()
142    .split(' ')
143    .filter(word => word.length > 0)
144    .slice(0, 6)
145  return words.join('-').slice(0, 48).replace(/-+$/, '') || 'image'
146}
147
148export const extensionOf = (path: string): string =>
149  path.match(/\.[a-z0-9]+$/i)?.[0].toLowerCase() ?? '.png'
150
151export const baseName = (path: string): string => path.split('/').pop() ?? path
152
153export const dirName = (path: string): string => {
154  const cut = path.lastIndexOf('/')
155  return cut <= 0 ? '/' : path.slice(0, cut)
156}
157
158/** `public/images/` → `public/images`; empty → the fallback. */
159export function cleanFolder(folder: string, fallback: string): string {
160  const trimmed = folder.trim().replace(/\/+$/, '')
161  return trimmed === '' ? fallback : trimmed
162}
163
types/index.d.ts 52 lines
1/** One generated picture: where Codex saved it, and whether it was added to the project. */
2export type ImagineImage = {
3  id: string
4  /** Absolute path of the mod's copy in `~/Pictures/Imagine` (Codex's own file when the copy failed). */
5  path: string
6  name: string
7  prompt: string
8  createdAt: number
9  isSelected: boolean
10  /** The project-relative path it was copied to, once added. */
11  addedTo?: string
12  /** The Codex run that drew it: Edit continues that conversation. */
13  threadId?: string
14}
15
16/** One `/imagine` request while it runs, and how it ended. */
17export type ImagineJob = {
18  id: string
19  prompt: string
20  count: number
21  status: 'running' | 'done' | 'failed'
22  message: string
23  startedAt: number
24}
25
26/** Whether Codex is installed and logged in with ChatGPT; the link and code while logging in. */
27export type ImagineLogin = {
28  status: 'unknown' | 'no-codex' | 'logged-out' | 'logging-in' | 'logged-in'
29  detail: string
30  url?: string
31  code?: string
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    imagine: {
37      images: ImagineImage[]
38      jobs: ImagineJob[]
39      login: ImagineLogin
40      dest: string
41      lastAdded: string[]
42      /** Draw thumbnails (kitty and Ghostty terminals only). */
43      previews: boolean
44      /** The image whose Edit box is open, or empty. */
45      editing: string
46      /** What is typed in the Edit box and the describe box, shown wrapped above them. */
47      editDraft: string
48      promptDraft: string
49    }
50  }
51}
52