@soyjuanpiece/jpcode 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
- # JpCode
1
+ # JpCode v2
2
2
 
3
- **JpCode** es la CLI de [opencode](https://opencode.ai) con identidad visual propia:
4
- banner JPCODE, tema oscuro estilo Claude, y comandos extra. No es un fork: el motor
5
- es opencode real (siempre actualizado), este paquete solo cambia cómo se ve y añade
6
- funciones. Por eso el free tier y todos los providers funcionan normal.
3
+ **JpCode** es un CLI propio con diseño inspirado en Claude Code, potenciado
4
+ nativamente por el motor de [opencode](https://opencode.ai): tu interfaz, su
5
+ inteligencia.
7
6
 
8
- ![jpcode](https://img.shields.io/badge/CLI-JpCode-blueviolet) ![npm](https://img.shields.io/npm/v/@soyjuanpiece/jpcode)
7
+ A diferencia de la v1 (que era un tema sobre el TUI de opencode), la v2 es un
8
+ **cliente independiente**: lanza el motor opencode como backend local y le habla
9
+ por su API/SDK oficial. Sin `tui.json`, sin parches — funciona igual en Windows,
10
+ macOS y Linux.
9
11
 
10
12
  ## Instalación
11
13
 
@@ -13,32 +15,40 @@ funciones. Por eso el free tier y todos los providers funcionan normal.
13
15
  npm install -g @soyjuanpiece/jpcode
14
16
  ```
15
17
 
16
- Después corre:
18
+ ## Uso
17
19
 
18
20
  ```bash
19
- jpcode
21
+ jpcode # REPL interactivo
22
+ jpcode "explica esto" # respuesta one-shot
23
+ jpcode --version
20
24
  ```
21
25
 
22
- El comando `jpcode` queda disponible globalmente (los paquetes con scope instalan
23
- sus bins sin el prefijo `@scope/`). También puedes seguir invocando `opencode`
24
- directamente: el plugin y el tema quedan registrados en tu `tui.json`.
26
+ Comandos dentro del REPL:
25
27
 
26
- ## Qué añade sobre opencode
28
+ - `/new` — sesión nueva
29
+ - `/clear` — limpiar pantalla
30
+ - `/help` — ayuda
31
+ - `/exit` — salir
27
32
 
28
- - **Banner JPCODE** — wordmark de 6 filas con gradiente vertical + panel de info
29
- (versión, modelo, directorio).
30
- - **Layout apilado** — en terminales estrechas (<80 columnas) el wordmark pasa
31
- encima del panel en vez de ocultarse; <100 columnas se oculta el hint de `/model`.
32
- - **Tema `claude`** — tema oscuro de alto contraste incluido.
33
- - **`/cost`** — coste y tokens agregados (sesión actual o todas): total, input,
34
- output, reasoning, cache read/write, duración y número de sesiones.
35
- - **`/memory`** — vista con preview de `AGENTS.md` y `CLAUDE.md` del proyecto.
33
+ ## Requisitos
36
34
 
37
- ## Actualizaciones
35
+ El motor opencode se instala automáticamente como dependencia (npm `opencode-ai`).
36
+ También funciona si ya lo tienes global. La sesión usa tus providers configurados
37
+ en opencode (`opencode.json` / `~/.config/opencode/`), incluyendo el free tier de
38
+ OpenCode Zen con `OPENCODE_API_KEY=public`.
38
39
 
39
- Como el motor es opencode real, `opencode upgrade` (o reinstalar este paquete)
40
- actualiza todo sin perder tu diseño ni tus comandos.
40
+ ## Arquitectura
41
+
42
+ ```
43
+ ┌──────────────────┐ SDK/HTTP nativo ┌──────────────────┐
44
+ │ jpcode CLI │ ────────────────────▶ │ motor opencode │──▶ providers
45
+ │ (tu diseño) │ ◀──────────────────── │ (server local) │ (zen, etc.)
46
+ └──────────────────┘ eventos SSE └──────────────────┘
47
+ ```
48
+
49
+ - Interfaz: 100% jpcode (banner, prompt, streaming de texto)
50
+ - Motor: 100% opencode (sesiones, tools, providers, free tier)
41
51
 
42
52
  ## Licencia
43
53
 
44
- MIT — opencode es MIT y este paquete también.
54
+ MIT
package/bin/jpcode.mjs CHANGED
@@ -1,88 +1,299 @@
1
1
  #!/usr/bin/env node
2
- // JpCode launcher: asegura que el plugin/tema estén registrados y executa el
3
- // motor opencode (instalado como dependencia de este paquete).
4
- import { createRequire } from "node:module"
5
- import { existsSync } from "node:fs"
6
- import { spawnSync } from "node:child_process"
2
+ // JpCode v2 — CLI propio con diseño estilo Claude Code.
3
+ // Motor: opencode real, arrancado como backend local y consumido por SDK nativo.
4
+ import readline from "node:readline"
5
+ import { spawn, spawnSync } from "node:child_process"
7
6
  import path from "node:path"
8
7
  import os from "node:os"
8
+ import { existsSync } from "node:fs"
9
9
  import { fileURLToPath } from "node:url"
10
+ import { createOpencodeClient } from "@opencode-ai/sdk"
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Branding
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const C = {
19
+ accent: (s) => `\x1b[38;5;214m${s}\x1b[0m`, // naranja Claude
20
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
21
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
22
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
23
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
24
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
25
+ }
10
26
 
11
- const require = createRequire(import.meta.url)
12
- const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
13
- const pluginDir = path.join(pkgRoot, "plugin")
27
+ const BANNER = [
28
+ " ▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄",
29
+ " ██║██╔══██╗██╔════╝██╔═══██╗██╔══██╗██╔════╝",
30
+ " ██║██████╔╝██║ ██║ ██║██║ ██║█████╗",
31
+ "██ ██║██╔═══╝ ██║ ██║ ██║██║ ██║██╔══╝",
32
+ "╚█████╔╝██║ ╚██████╔╝██████╔╝██████╔╝███████╗",
33
+ " ╚════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
34
+ ]
14
35
 
15
- function packageDir() {
36
+ function printBanner(sub) {
37
+ console.log("")
38
+ for (const [i, line] of BANNER.entries()) {
39
+ console.log(C.accent(line) + C.dim(i === 1 ? ` >_ JpCode ${sub ?? ""}` : ""))
40
+ }
41
+ console.log("")
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Motor opencode como backend
46
+ // ---------------------------------------------------------------------------
47
+
48
+ async function findEngineBinary() {
49
+ // 1) junto a este paquete (dependencia npm opencode-ai)
16
50
  try {
17
- return path.dirname(require.resolve("opencode-ai/package.json"))
18
- } catch {
19
- return null
51
+ const req = (await import("node:module")).createRequire(import.meta.url)
52
+ const pkgDir = path.dirname(req.resolve("opencode-ai/package.json"))
53
+ const exe = process.platform === "win32" ? "opencode.exe" : "opencode"
54
+ const p = path.join(pkgDir, "bin", exe)
55
+ if (existsSync(p)) return p
56
+ } catch {}
57
+ // 2) binario global en PATH
58
+ const which = spawnSync(process.platform === "win32" ? "where" : "which", ["opencode"], { encoding: "utf8" })
59
+ if (which.status === 0 && which.stdout.trim().split(/\r?\n/)[0]) return which.stdout.trim().split(/\r?\n/)[0]
60
+ return null
61
+ }
62
+
63
+ async function startEngine() {
64
+ const bin = await findEngineBinary()
65
+ if (!bin) {
66
+ console.error(C.red("✗ No encontré el motor opencode."))
67
+ console.error(" Instálalo con: npm i -g opencode-ai (o reinstala @soyjuanpiece/jpcode)")
68
+ process.exit(1)
69
+ }
70
+
71
+ const port = 14000 + Math.floor(Math.random() * 20000)
72
+ const child = spawn(bin, ["serve", "--port", String(port), "--hostname", "127.0.0.1"], {
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ env: { ...process.env },
75
+ })
76
+ child.stdout.on("data", () => {}) // "opencode server listening on ..."
77
+ child.stderr.on("data", (d) => process.env.JPCODE_DEBUG && console.error(C.dim(String(d))))
78
+
79
+ const base = `http://127.0.0.1:${port}`
80
+ // esperar a que responda. /global/health es un endpoint finito (los events
81
+ // son SSE infinitos y colgarían el fetch sin timeout).
82
+ for (let i = 0; i < 120; i++) {
83
+ try {
84
+ const r = await fetch(`${base}/global/health`, { signal: AbortSignal.timeout(2000) }).catch(() => null)
85
+ if (r?.ok) return { base, child }
86
+ } catch {}
87
+ await new Promise((r) => setTimeout(r, 250))
20
88
  }
89
+ console.error(C.red("✗ El motor opencode no respondió a tiempo."))
90
+ child.kill()
91
+ process.exit(1)
21
92
  }
22
93
 
23
- function tuiConfigPath() {
24
- const base =
25
- process.env.XDG_CONFIG_HOME ||
26
- path.join(os.homedir(), ".config")
27
- return path.join(base, "opencode", "tui.json")
94
+ // ---------------------------------------------------------------------------
95
+ // Cliente SDK hacia el motor
96
+ // ---------------------------------------------------------------------------
97
+
98
+ async function makeClient(base) {
99
+ const { createOpencodeClient } = await import("@opencode-ai/sdk")
100
+ return createOpencodeClient({ baseUrl: base, directory: process.cwd() })
28
101
  }
29
102
 
30
- // Registra el plugin embebido de JpCode en el tui.json del usuario (una vez).
31
- // Usa ruta absoluta plana: es el formato de path-plugin que opencode reconoce
32
- // en todos los OS (file:/X o file:C:\ no cumplen el check file:// del loader).
33
- // Limpia entradas rotas de versiones anteriores y respeta plugins del usuario.
34
- function ensurePluginRegistered() {
35
- try {
36
- const { readFileSync, writeFileSync, mkdirSync } = require("node:fs")
37
- const cfgPath = tuiConfigPath()
38
- let cfg = {}
39
- if (existsSync(cfgPath)) {
40
- cfg = JSON.parse(readFileSync(cfgPath, "utf8"))
103
+ // ---------------------------------------------------------------------------
104
+ // Streaming: escucha eventos del server y pinta deltas de texto
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function watchEvents(base, handlers) {
108
+ const ctrl = new AbortController()
109
+ ;(async () => {
110
+ try {
111
+ const res = await fetch(`${base}/event`, { signal: ctrl.signal })
112
+ const reader = res.body.getReader()
113
+ const dec = new TextDecoder()
114
+ let buf = ""
115
+ while (true) {
116
+ const { done, value } = await reader.read()
117
+ if (done) break
118
+ buf += dec.decode(value, { stream: true })
119
+ const lines = buf.split("\n")
120
+ buf = lines.pop() ?? ""
121
+ for (const line of lines) {
122
+ const t = line.trim()
123
+ if (!t.startsWith("data:")) continue
124
+ try {
125
+ const evt = JSON.parse(t.slice(5).trim())
126
+ handlers.onEvent?.(evt)
127
+ } catch {}
128
+ }
129
+ }
130
+ } catch (e) {
131
+ if (!ctrl.signal.aborted) handlers.onError?.(e)
41
132
  }
42
- const stale = new Set([
43
- `file:${pluginDir}`,
44
- `file:${pluginDir.replace(/\\/g, "/")}`,
45
- `file://${pluginDir}`,
46
- `file:///${pluginDir.replace(/\\/g, "/")}`,
47
- ])
48
- const plugins = (Array.isArray(cfg.plugin) ? cfg.plugin : []).filter(
49
- (p) => typeof p !== "string" || !stale.has(p),
50
- )
51
- if (!plugins.includes(pluginDir)) plugins.push(pluginDir)
52
- const next = { ...cfg, plugin: plugins }
53
- if (!next.theme) next.theme = "claude"
54
- mkdirSync(path.dirname(cfgPath), { recursive: true })
55
- writeFileSync(cfgPath, JSON.stringify(next, null, 2) + "\n")
56
- } catch {
57
- // nunca bloquear el launch por un problema de config
58
- }
133
+ })()
134
+ return ctrl
59
135
  }
60
136
 
61
- const dir = packageDir()
62
- const binName = process.platform === "win32" ? "opencode.exe" : "opencode"
63
- const candidates = dir
64
- ? [path.join(dir, "bin", binName)]
65
- : []
66
-
67
- let bin = candidates.find((p) => existsSync(p))
68
- if (!bin) {
69
- // fallback: opencode en PATH (instalado por otros medios)
70
- const which = spawnSync("which", ["opencode"], { encoding: "utf8" })
71
- if (which.status === 0 && which.stdout.trim()) {
72
- bin = which.stdout.trim()
137
+ // ---------------------------------------------------------------------------
138
+ // Modo interactivo
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function interactive(client, base) {
142
+ printBanner("v2.0.0")
143
+ console.log(C.dim(" motor: opencode nativo · ctrl+c para salir · /help para comandos"))
144
+ console.log("")
145
+
146
+ let sessionID = null
147
+ let busy = false
148
+ let currentText = ""
149
+ let lastRendered = 0
150
+
151
+ const events = watchEvents(base, {
152
+ onEvent: (evt) => {
153
+ const type = evt?.type
154
+ if (type === "message.part.updated") {
155
+ const part = evt.properties?.part
156
+ if (part?.type === "text" && part.sessionID === sessionID) {
157
+ currentText = part.text ?? ""
158
+ // render incremental: solo la última línea crece
159
+ const sinceLast = currentText.slice(lastRendered)
160
+ if (sinceLast) {
161
+ process.stdout.write(sinceLast)
162
+ lastRendered = currentText.length
163
+ }
164
+ }
165
+ }
166
+ if (type === "session.idle" && evt.properties?.sessionID === sessionID) {
167
+ busy = false
168
+ }
169
+ },
170
+ })
171
+
172
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: C.cyan("❯ ") })
173
+ rl.prompt(true)
174
+
175
+ rl.on("line", async (line) => {
176
+ const input = line.trim()
177
+ if (busy) { console.log(C.dim(" (esperando respuesta del modelo…)")); rl.prompt(true); return }
178
+ if (!input) { rl.prompt(true); return }
179
+
180
+ // comandos
181
+ if (input === "/help") {
182
+ console.log(C.dim(" /new sesión nueva · /model ver modelo · /clear limpiar · /exit salir"))
183
+ rl.prompt(true)
184
+ return
185
+ }
186
+ if (input === "/new") {
187
+ sessionID = null
188
+ console.log(C.dim(" ✓ sesión nueva"))
189
+ rl.prompt(true)
190
+ return
191
+ }
192
+ if (input === "/clear") {
193
+ console.clear()
194
+ printBanner("v2.0.0")
195
+ rl.prompt(true)
196
+ return
197
+ }
198
+ if (input === "/exit" || input === "/quit") { cleanup(); return }
199
+ if (input.startsWith("/model")) {
200
+ console.log(C.dim(" usa el comando /models dentro del TUI de opencode o define default_model en opencode.json"))
201
+ rl.prompt(true)
202
+ return
203
+ }
204
+
205
+ // prompt normal
206
+ busy = true
207
+ currentText = ""
208
+ lastRendered = 0
209
+ try {
210
+ if (!sessionID) {
211
+ const s = await client.session.create()
212
+ sessionID = s.data?.id
213
+ }
214
+ await client.session.prompt({
215
+ path: { id: sessionID },
216
+ body: { parts: [{ type: "text", text: input }] },
217
+ })
218
+ // el texto llegó vía eventos; esperar a idle
219
+ await new Promise((resolve) => {
220
+ const iv = setInterval(() => { if (!busy) { clearInterval(iv); resolve() } }, 100)
221
+ })
222
+ console.log("")
223
+ } catch (e) {
224
+ console.log(C.red(` ✗ ${e?.message ?? e}`))
225
+ }
226
+ busy = false
227
+ rl.prompt(true)
228
+ })
229
+
230
+ rl.on("close", () => cleanup())
231
+ function cleanup() {
232
+ events.abort()
233
+ try { client.child?.kill?.() } catch {}
234
+ try { process.kill(process.pid, "SIGTERM") } catch {}
235
+ process.exit(0)
73
236
  }
74
237
  }
75
238
 
76
- ensurePluginRegistered()
239
+ // ---------------------------------------------------------------------------
240
+ // Modo one-shot: jpcode "pregunta"
241
+ // ---------------------------------------------------------------------------
77
242
 
78
- if (!bin) {
79
- console.error("jpcode: no se encontró el motor opencode.")
80
- console.error("Instálalo con: npm i -g opencode-ai (o reinstala @soyjuanpiece/jpcode)")
81
- process.exit(1)
243
+ async function oneShot(client, base, prompt) {
244
+ const s = await client.session.create()
245
+ const sessionID = s.data?.id
246
+
247
+ let done
248
+ const events = watchEvents(base, {
249
+ onEvent: (evt) => {
250
+ if (evt?.type === "message.part.updated") {
251
+ const part = evt.properties?.part
252
+ if (part?.type === "text" && part.sessionID === sessionID) process.stdout.write(part.text ?? "")
253
+ }
254
+ if (evt?.type === "session.idle" && evt.properties?.sessionID === sessionID) done?.()
255
+ },
256
+ })
257
+
258
+ const idle = new Promise((resolve) => { done = resolve })
259
+ const response = await client.session.prompt({
260
+ path: { id: sessionID },
261
+ body: { parts: [{ type: "text", text: prompt }] },
262
+ })
263
+ // el SDK responde cuando el turno termina; los events ya pintaron el texto.
264
+ await Promise.race([idle, new Promise((r) => setTimeout(r, 3000))])
265
+ events.abort()
266
+ console.log("")
82
267
  }
83
268
 
84
- const result = spawnSync(bin, process.argv.slice(2), {
85
- stdio: "inherit",
86
- env: process.env,
269
+ // ---------------------------------------------------------------------------
270
+ // main
271
+ // ---------------------------------------------------------------------------
272
+
273
+ async function main() {
274
+ const args = process.argv.slice(2)
275
+ if (args.includes("--help") || args.includes("-h")) {
276
+ printBanner("v2.0.0")
277
+ console.log(" jpcode REPL interactivo")
278
+ console.log(" jpcode \"pregunta\" respuesta one-shot")
279
+ console.log(" jpcode --version versión")
280
+ process.exit(0)
281
+ }
282
+ if (args.includes("--version") || args.includes("-v")) {
283
+ console.log("2.0.0")
284
+ process.exit(0)
285
+ }
286
+
287
+ const engine = await startEngine()
288
+ const client = await makeClient(engine.base)
289
+ client.child = engine.child
290
+
291
+ const prompt = args.filter((a) => !a.startsWith("-")).join(" ").trim()
292
+ if (prompt) await oneShot(client, engine.base, prompt)
293
+ else await interactive(client, engine.base)
294
+ }
295
+
296
+ main().catch((e) => {
297
+ console.error(C.red(`✗ ${e?.message ?? e}`))
298
+ process.exit(1)
87
299
  })
88
- process.exit(result.status ?? 0)
package/package.json CHANGED
@@ -1,37 +1,36 @@
1
1
  {
2
2
  "name": "@soyjuanpiece/jpcode",
3
- "version": "1.0.1",
4
- "description": "JpCode — la CLI de opencode con identidad visual propia: banner JPCODE, tema claude, /cost, /memory y layout adaptativo. Motor opencode real, solo rediseñado por fuera.",
3
+ "version": "2.0.1",
4
+ "description": "JpCode — CLI propio con diseño estilo Claude Code, potenciado nativamente por el motor opencode. Tu interfaz, su inteligencia.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "SoyJuanPiece",
8
- "keywords": [
9
- "opencode",
10
- "opencode-plugin",
11
- "jpcode",
12
- "tui",
13
- "cli",
14
- "ai",
15
- "coding-agent",
16
- "terminal"
17
- ],
18
8
  "bin": {
19
9
  "jpcode": "./bin/jpcode.mjs"
20
10
  },
21
11
  "files": [
22
12
  "bin/",
23
- "plugin/",
13
+ "src/",
24
14
  "README.md",
25
15
  "LICENSE"
26
16
  ],
27
- "scripts": {
28
- "postinstall": "node bin/install-plugin.mjs"
29
- },
30
17
  "dependencies": {
18
+ "@opencode-ai/sdk": "^1.18.32",
31
19
  "opencode-ai": "^1.18.32"
32
20
  },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
33
24
  "repository": {
34
25
  "type": "git",
35
26
  "url": "git+https://github.com/SoyJuanPiece/jpcode.git"
36
- }
27
+ },
28
+ "keywords": [
29
+ "jpcode",
30
+ "cli",
31
+ "ai",
32
+ "coding-agent",
33
+ "opencode",
34
+ "terminal"
35
+ ]
37
36
  }
@@ -1,38 +0,0 @@
1
- #!/usr/bin/env node
2
- // Postinstall: registra el plugin JpCode en el tui.json del usuario para que
3
- // `opencode` también lo cargue si se invoca directamente. Idempotente.
4
- // Usa ruta absoluta plana (formato que el loader de opencode reconoce como
5
- // path-plugin en todos los OS) y limpia entradas rotas de versiones previas.
6
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
7
- import path from "node:path"
8
- import os from "node:os"
9
- import { fileURLToPath } from "node:url"
10
-
11
- try {
12
- const base =
13
- process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
14
- const cfgPath = path.join(base, "opencode", "tui.json")
15
- let cfg = {}
16
- if (existsSync(cfgPath)) {
17
- cfg = JSON.parse(readFileSync(cfgPath, "utf8"))
18
- }
19
- const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
20
- const pluginDir = path.join(pkgRoot, "plugin")
21
- const stale = new Set([
22
- `file:${pluginDir}`,
23
- `file:${pluginDir.replace(/\\/g, "/")}`,
24
- `file://${pluginDir}`,
25
- `file:///${pluginDir.replace(/\\/g, "/")}`,
26
- ])
27
- const plugins = (Array.isArray(cfg.plugin) ? cfg.plugin : []).filter(
28
- (p) => typeof p !== "string" || !stale.has(p),
29
- )
30
- if (!plugins.includes(pluginDir)) plugins.push(pluginDir)
31
- const next = { ...cfg, plugin: plugins }
32
- if (!next.theme) next.theme = "claude"
33
- mkdirSync(path.dirname(cfgPath), { recursive: true })
34
- writeFileSync(cfgPath, JSON.stringify(next, null, 2) + "\n")
35
- console.log("jpcode: plugin y tema claude registrados en tui.json ✓")
36
- } catch {
37
- // silencioso: el launcher también lo registra al arrancar
38
- }
@@ -1,21 +0,0 @@
1
- {
2
- "name": "jpcode-plugin",
3
- "version": "1.0.0",
4
- "description": "Identidad visual JpCode y funciones extra para el cliente opencode: banner, tema, /cost, /memory y modo apilado en terminales estrechos.",
5
- "type": "module",
6
- "license": "MIT",
7
- "exports": {
8
- "./tui": {
9
- "import": "./tui.tsx"
10
- }
11
- },
12
- "oc-themes": [
13
- "themes/*.json"
14
- ],
15
- "peerDependencies": {
16
- "@opencode-ai/plugin": ">=1.17.0",
17
- "@opentui/core": ">=0.3.4",
18
- "@opentui/solid": ">=0.3.4",
19
- "solid-js": ">=1.9.0"
20
- }
21
- }
@@ -1,250 +0,0 @@
1
- {
2
- "$schema": "https://opencode.ai/theme.json",
3
- "defs": {
4
- "claudeBg": "#262624",
5
- "claudeBgPanel": "#1f1e1d",
6
- "claudeBgElement": "#33322f",
7
- "claudeText": "#f4f3ee",
8
- "claudeTextMuted": "#9a9a94",
9
- "claudeOrange": "#d97757",
10
- "claudeOrangeLight": "#e8a487",
11
- "claudeOrangeDark": "#bd5d3a",
12
- "claudeSecondary": "#7b9bbf",
13
- "claudeAccent": "#c3b7a5",
14
- "claudeRed": "#e5484d",
15
- "claudeOrangeWarning": "#d9a558",
16
- "claudeGreen": "#5bb98b",
17
- "claudeCyan": "#6ba5a0",
18
- "claudeYellow": "#d9a558",
19
- "claudeBorder": "#4a4a46",
20
- "claudeBorderActive": "#6b6b64",
21
- "claudeBorderSubtle": "#3a3a36",
22
- "claudeStep8": "#8a8a82",
23
- "claudeDiffAdded": "#5bb98b",
24
- "claudeDiffRemoved": "#e5484d",
25
- "claudeDiffContext": "#8a8a82",
26
- "claudeDiffAddedBg": "#22322a",
27
- "claudeDiffRemovedBg": "#372426",
28
- "claudeDiffContextBg": "#1f1e1d",
29
- "claudeDiffLineNumber": "#6b6b64",
30
- "claudeDiffAddedLineNumberBg": "#1c2b23",
31
- "claudeDiffRemovedLineNumberBg": "#2e1f21",
32
- "claudeLightBg": "#faf9f5",
33
- "claudeLightBgPanel": "#f0eee6",
34
- "claudeLightBgElement": "#e5e2d9",
35
- "claudeLightText": "#1f1e1d",
36
- "claudeLightTextMuted": "#74726b",
37
- "claudeLightOrange": "#bd5d3a",
38
- "claudeLightSecondary": "#4a7295",
39
- "claudeLightAccent": "#8b6f47",
40
- "claudeLightRed": "#c53030",
41
- "claudeLightGreen": "#2d7a55",
42
- "claudeLightCyan": "#3a7a75",
43
- "claudeLightYellow": "#8b6f2f",
44
- "claudeLightBorder": "#c9c6ba",
45
- "claudeLightBorderActive": "#a5a296",
46
- "claudeLightBorderSubtle": "#d9d6ca"
47
- },
48
- "theme": {
49
- "primary": {
50
- "dark": "claudeOrange",
51
- "light": "claudeLightOrange"
52
- },
53
- "secondary": {
54
- "dark": "claudeSecondary",
55
- "light": "claudeLightSecondary"
56
- },
57
- "accent": {
58
- "dark": "claudeAccent",
59
- "light": "claudeLightAccent"
60
- },
61
- "error": {
62
- "dark": "claudeRed",
63
- "light": "claudeLightRed"
64
- },
65
- "warning": {
66
- "dark": "claudeOrangeWarning",
67
- "light": "claudeLightYellow"
68
- },
69
- "success": {
70
- "dark": "claudeGreen",
71
- "light": "claudeLightGreen"
72
- },
73
- "info": {
74
- "dark": "claudeCyan",
75
- "light": "claudeLightCyan"
76
- },
77
- "text": {
78
- "dark": "claudeText",
79
- "light": "claudeLightText"
80
- },
81
- "textMuted": {
82
- "dark": "claudeTextMuted",
83
- "light": "claudeLightTextMuted"
84
- },
85
- "background": {
86
- "dark": "claudeBg",
87
- "light": "claudeLightBg"
88
- },
89
- "backgroundPanel": {
90
- "dark": "claudeBgPanel",
91
- "light": "claudeLightBgPanel"
92
- },
93
- "backgroundElement": {
94
- "dark": "claudeBgElement",
95
- "light": "claudeLightBgElement"
96
- },
97
- "border": {
98
- "dark": "claudeBorder",
99
- "light": "claudeLightBorder"
100
- },
101
- "borderActive": {
102
- "dark": "claudeBorderActive",
103
- "light": "claudeLightBorderActive"
104
- },
105
- "borderSubtle": {
106
- "dark": "claudeBorderSubtle",
107
- "light": "claudeLightBorderSubtle"
108
- },
109
- "diffAdded": {
110
- "dark": "claudeDiffAdded",
111
- "light": "claudeLightGreen"
112
- },
113
- "diffRemoved": {
114
- "dark": "claudeDiffRemoved",
115
- "light": "claudeLightRed"
116
- },
117
- "diffContext": {
118
- "dark": "claudeDiffContext",
119
- "light": "claudeLightTextMuted"
120
- },
121
- "diffHunkHeader": {
122
- "dark": "claudeDiffContext",
123
- "light": "claudeLightTextMuted"
124
- },
125
- "diffHighlightAdded": {
126
- "dark": "#7fd4a8",
127
- "light": "#2d7a55"
128
- },
129
- "diffHighlightRemoved": {
130
- "dark": "#f07178",
131
- "light": "#c53030"
132
- },
133
- "diffAddedBg": {
134
- "dark": "claudeDiffAddedBg",
135
- "light": "#dcedd5"
136
- },
137
- "diffRemovedBg": {
138
- "dark": "claudeDiffRemovedBg",
139
- "light": "#f7d8db"
140
- },
141
- "diffContextBg": {
142
- "dark": "claudeDiffContextBg",
143
- "light": "claudeLightBgPanel"
144
- },
145
- "diffLineNumber": {
146
- "dark": "claudeDiffLineNumber",
147
- "light": "#a5a296"
148
- },
149
- "diffAddedLineNumberBg": {
150
- "dark": "claudeDiffAddedLineNumberBg",
151
- "light": "#c5d5c5"
152
- },
153
- "diffRemovedLineNumberBg": {
154
- "dark": "claudeDiffRemovedLineNumberBg",
155
- "light": "#e7c8cb"
156
- },
157
- "markdownText": {
158
- "dark": "claudeText",
159
- "light": "claudeLightText"
160
- },
161
- "markdownHeading": {
162
- "dark": "claudeOrange",
163
- "light": "claudeLightOrange"
164
- },
165
- "markdownLink": {
166
- "dark": "claudeOrangeLight",
167
- "light": "claudeLightOrange"
168
- },
169
- "markdownLinkText": {
170
- "dark": "claudeCyan",
171
- "light": "claudeLightCyan"
172
- },
173
- "markdownCode": {
174
- "dark": "claudeGreen",
175
- "light": "claudeLightGreen"
176
- },
177
- "markdownBlockQuote": {
178
- "dark": "claudeYellow",
179
- "light": "claudeLightYellow"
180
- },
181
- "markdownEmph": {
182
- "dark": "claudeYellow",
183
- "light": "claudeLightYellow"
184
- },
185
- "markdownStrong": {
186
- "dark": "claudeOrange",
187
- "light": "claudeLightOrange"
188
- },
189
- "markdownHorizontalRule": {
190
- "dark": "claudeTextMuted",
191
- "light": "claudeLightTextMuted"
192
- },
193
- "markdownListItem": {
194
- "dark": "claudeOrange",
195
- "light": "claudeLightOrange"
196
- },
197
- "markdownListEnumeration": {
198
- "dark": "claudeCyan",
199
- "light": "claudeLightCyan"
200
- },
201
- "markdownImage": {
202
- "dark": "claudeOrange",
203
- "light": "claudeLightOrange"
204
- },
205
- "markdownImageText": {
206
- "dark": "claudeCyan",
207
- "light": "claudeLightCyan"
208
- },
209
- "markdownCodeBlock": {
210
- "dark": "claudeText",
211
- "light": "claudeLightText"
212
- },
213
- "syntaxComment": {
214
- "dark": "#7a7970",
215
- "light": "#8a887d"
216
- },
217
- "syntaxKeyword": {
218
- "dark": "#c3b7a5",
219
- "light": "#8b6f47"
220
- },
221
- "syntaxFunction": {
222
- "dark": "claudeOrangeLight",
223
- "light": "claudeLightOrange"
224
- },
225
- "syntaxVariable": {
226
- "dark": "#e8c4a8",
227
- "light": "#9a5030"
228
- },
229
- "syntaxString": {
230
- "dark": "#a3c9a8",
231
- "light": "#2d7a55"
232
- },
233
- "syntaxNumber": {
234
- "dark": "#d9a558",
235
- "light": "#8b6f2f"
236
- },
237
- "syntaxType": {
238
- "dark": "claudeSecondary",
239
- "light": "claudeLightSecondary"
240
- },
241
- "syntaxOperator": {
242
- "dark": "claudeCyan",
243
- "light": "claudeLightCyan"
244
- },
245
- "syntaxPunctuation": {
246
- "dark": "claudeText",
247
- "light": "claudeLightText"
248
- }
249
- }
250
- }
package/plugin/tui.tsx DELETED
@@ -1,389 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import { RGBA, TextAttributes } from "@opentui/core"
3
- import { useTerminalDimensions } from "@opentui/solid"
4
- import { For, Show, createMemo, createResource, createSignal, onMount } from "solid-js"
5
- import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiTheme } from "@opencode-ai/plugin/tui"
6
- import os from "node:os"
7
- import path from "node:path"
8
-
9
- // JPCODE wordmark, ported from jpcode packages/cli/src/ui/components/AsciiArt.ts
10
- const LEFT = ["", "", "", "", "", ""]
11
- const RIGHT = [
12
- " ▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄",
13
- " ██║██╔══██╗██╔════╝██╔═══██╗██╔══██╗██╔════╝",
14
- " ██║██████╔╝██║ ██║ ██║██║ ██║█████╗",
15
- "██ ██║██╔═══╝ ██║ ██║ ██║██║ ██║██╔══╝",
16
- "╚█████╔╝██║ ╚██████╗╚██████╔╝██████╔╝███████╗",
17
- " ╚════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
18
- ]
19
-
20
- // jpcode appends U+FE0E (VS15) to every East Asian Ambiguous glyph so CJK
21
- // terminals measure them as 1 column (packages/cli/src/ui/constants.ts). That
22
- // trick is Ink-specific: OpenTUI counts the variation selector as its own cell
23
- // and the wordmark gains a space between every glyph. Verified empirically, so
24
- // the glyphs ship bare and width handling is left to the renderer.
25
- function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
26
- return RGBA.fromInts(
27
- Math.round((base.r + (overlay.r - base.r) * alpha) * 255),
28
- Math.round((base.g + (overlay.g - base.g) * alpha) * 255),
29
- Math.round((base.b + (overlay.b - base.b) * alpha) * 255),
30
- )
31
- }
32
-
33
- const STACK_WIDTH = 80
34
- const HINT_MIN_WIDTH = 100
35
-
36
- function Logo(props: { api: TuiPluginApi }) {
37
- const rowColor = (index: number, fg: RGBA): RGBA => {
38
- const top = tint(fg, RGBA.fromInts(255, 255, 255), 0.35)
39
- const bottom = tint(fg, RGBA.fromInts(0, 0, 0), 0.4)
40
- return tint(top, bottom, index / (LEFT.length - 1))
41
- }
42
-
43
- const line = (source: string, fg: RGBA, bold: boolean) => {
44
- const shadow = tint(props.api.theme.current.background, fg, 0.25)
45
- const attrs = bold ? TextAttributes.BOLD : undefined
46
- return Array.from(source).map((char) => {
47
- if (char === "_") return <text fg={fg} bg={shadow} attributes={attrs} selectable={false}> </text>
48
- if (char === "^") return <text fg={fg} bg={shadow} attributes={attrs} selectable={false}>▀</text>
49
- if (char === "~") return <text fg={shadow} attributes={attrs} selectable={false}>▀</text>
50
- if (char === ",") return <text fg={shadow} attributes={attrs} selectable={false}>▄</text>
51
- return <text fg={fg} attributes={attrs} selectable={false}>{char}</text>
52
- })
53
- }
54
-
55
- return (
56
- <box flexShrink={0}>
57
- <For each={LEFT}>
58
- {(row, index) => (
59
- <box flexDirection="row" gap={1}>
60
- <box flexDirection="row">{line(row, rowColor(index(), props.api.theme.current.textMuted), false)}</box>
61
- <box flexDirection="row">{line(RIGHT[index()], rowColor(index(), props.api.theme.current.text), true)}</box>
62
- </box>
63
- )}
64
- </For>
65
- </box>
66
- )
67
- }
68
-
69
- type ModelInfo = { provider: string; model: string }
70
-
71
- type ModelRef = { providerID?: string; modelID?: string }
72
-
73
- // api.state.session.messages() only returns messages for the *active* session
74
- // (verified: a non-active session with 4633 messages in the db yields 0), so on
75
- // the home route we read the model store the TUI itself writes. recent[0] is the
76
- // most recently used model, matching the model shown on the prompt line.
77
- async function readModelStore(): Promise<ModelRef | undefined> {
78
- const file = path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state"), "opencode", "model.json")
79
- const raw = await Bun.file(file)
80
- .text()
81
- .catch(() => "")
82
- if (!raw) return undefined
83
- try {
84
- const recent = (JSON.parse(raw) as { recent?: ModelRef[] }).recent
85
- const first = recent?.[0]
86
- return first?.providerID && first.modelID ? { providerID: first.providerID, modelID: first.modelID } : undefined
87
- } catch {
88
- return undefined
89
- }
90
- }
91
-
92
- function useModelInfo(api: TuiPluginApi) {
93
- const [store] = createResource(readModelStore)
94
-
95
- return createMemo<ModelInfo | undefined>(() => {
96
- const resolve = (ref: ModelRef | undefined) => {
97
- if (!ref?.providerID || !ref.modelID) return undefined
98
- const provider = api.state.provider.find((p) => p.id === ref.providerID)
99
- return {
100
- provider: ref.providerID === "opencode" ? "JpCode Zen" : (provider?.name ?? ref.providerID),
101
- model: provider?.models?.[ref.modelID]?.name ?? ref.modelID,
102
- }
103
- }
104
-
105
- const route = api.route.current
106
- if (route.name === "session") {
107
- const direct = resolve(
108
- api.state.session.messages(route.params.sessionID).findLast((m) => m.role === "assistant") as
109
- | ModelRef
110
- | undefined,
111
- )
112
- if (direct) return direct
113
- }
114
-
115
- const configured = api.state.config.model ?? api.state.config.agent?.build?.model
116
- if (configured) {
117
- const slash = configured.indexOf("/")
118
- if (slash !== -1) return resolve({ providerID: configured.slice(0, slash), modelID: configured.slice(slash + 1) })
119
- return { provider: "", model: configured }
120
- }
121
-
122
- return resolve(store())
123
- })
124
- }
125
-
126
- function InfoPanel(props: { api: TuiPluginApi; width: number }) {
127
- const info = useModelInfo(props.api)
128
- return (
129
- <box
130
- flexGrow={1}
131
- flexShrink={1}
132
- flexDirection="column"
133
- border
134
- borderStyle="single"
135
- borderColor={props.api.theme.current.border}
136
- paddingLeft={1}
137
- paddingRight={1}
138
- maxWidth={60}
139
- >
140
- <box flexDirection="row">
141
- <text attributes={TextAttributes.BOLD} fg={props.api.theme.current.primary}>{">_ JpCode"}</text>
142
- <text fg={props.api.theme.current.textMuted}> (v{props.api.app.version})</text>
143
- </box>
144
- <text fg={props.api.theme.current.textMuted}> </text>
145
- <box flexDirection="row">
146
- <Show when={info()}>
147
- <text fg={props.api.theme.current.textMuted}>{info()!.provider} | {info()!.model}</text>
148
- </Show>
149
- <Show when={props.width === 0 || props.width >= HINT_MIN_WIDTH}>
150
- <text fg={props.api.theme.current.textMuted}> (/model to change)</text>
151
- </Show>
152
- </box>
153
- <text fg={props.api.theme.current.textMuted}>{props.api.state.path.directory}</text>
154
- </box>
155
- )
156
- }
157
-
158
- // home_logo slot. Stacks the wordmark above the info panel on narrow terminals
159
- // (same row -> column flip as jpcode Header.tsx) instead of hiding the logo.
160
- function Header(props: { api: TuiPluginApi; theme: TuiTheme }) {
161
- const dims = useTerminalDimensions()
162
- const width = () => dims().width
163
- const stacked = () => width() > 0 && width() < STACK_WIDTH
164
- return (
165
- <Show when={props.theme.ready} fallback={<box height={6} />}>
166
- <Show
167
- when={stacked()}
168
- fallback={
169
- <box flexDirection="row" justifyContent="flex-start" alignItems="center" width="100%" gap={2}>
170
- <Logo api={props.api} />
171
- <InfoPanel api={props.api} width={width()} />
172
- </box>
173
- }
174
- >
175
- <box flexDirection="column" width="100%">
176
- <Logo api={props.api} />
177
- <InfoPanel api={props.api} width={width()} />
178
- </box>
179
- </Show>
180
- </Show>
181
- )
182
- }
183
-
184
- const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
185
-
186
- function duration(ms: number) {
187
- if (ms < 1000) return `${ms}ms`
188
- const s = Math.floor(ms / 1000)
189
- if (s < 60) return `${s}s`
190
- const m = Math.floor(s / 60)
191
- if (m < 60) return `${m}m ${s % 60}s`
192
- return `${Math.floor(m / 60)}h ${m % 60}m`
193
- }
194
-
195
- type Totals = {
196
- cost: number
197
- input: number
198
- output: number
199
- reasoning: number
200
- cacheRead: number
201
- cacheWrite: number
202
- span: number
203
- count: number
204
- }
205
-
206
- function openCost(api: TuiPluginApi) {
207
- const Dialog = api.ui.Dialog
208
- const route = api.route.current
209
-
210
- const [data] = createResource(async () => {
211
- const all = (await api.client.session.list({}).then((r) => r.data ?? [])) as {
212
- id: string
213
- cost?: number
214
- tokens?: { input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } }
215
- time: { created: number; updated: number }
216
- }[]
217
- const sessions = route.name === "session" ? all.filter((s) => s.id === route.params.sessionID) : all
218
- const totals: Totals = {
219
- cost: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, span: 0, count: sessions.length,
220
- }
221
- for (const s of sessions) {
222
- totals.cost += s.cost ?? 0
223
- totals.input += s.tokens?.input ?? 0
224
- totals.output += s.tokens?.output ?? 0
225
- totals.reasoning += s.tokens?.reasoning ?? 0
226
- totals.cacheRead += s.tokens?.cache?.read ?? 0
227
- totals.cacheWrite += s.tokens?.cache?.write ?? 0
228
- }
229
- if (sessions.length) {
230
- totals.span = Math.max(...sessions.map((s) => s.time.updated)) - Math.min(...sessions.map((s) => s.time.created))
231
- }
232
- return totals
233
- })
234
-
235
- const rows = createMemo(() => {
236
- const t = data()
237
- if (!t) return []
238
- return [
239
- { label: "Total cost", value: money.format(t.cost) },
240
- { label: "Input tokens", value: t.input.toLocaleString() },
241
- { label: "Output tokens", value: t.output.toLocaleString() },
242
- { label: "Reasoning tokens", value: t.reasoning.toLocaleString() },
243
- { label: "Cache read", value: t.cacheRead.toLocaleString() },
244
- { label: "Cache write", value: t.cacheWrite.toLocaleString() },
245
- { label: "Duration", value: duration(t.span) },
246
- { label: "Sessions", value: t.count.toLocaleString() },
247
- ]
248
- })
249
-
250
- api.ui.dialog.setSize("medium")
251
- api.ui.dialog.replace(() => (
252
- <Dialog onClose={() => api.ui.dialog.clear()}>
253
- <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1} width="100%">
254
- <box flexDirection="row" justifyContent="space-between">
255
- <text attributes={TextAttributes.BOLD} fg={api.theme.current.text}>Cost</text>
256
- <text fg={api.theme.current.textMuted} onMouseUp={() => api.ui.dialog.clear()}>esc</text>
257
- </box>
258
- <Show when={data()} fallback={<text fg={api.theme.current.textMuted}>Loading...</text>}>
259
- <For each={rows()}>
260
- {(row) => (
261
- <box flexDirection="row" justifyContent="space-between" width="100%">
262
- <text fg={api.theme.current.textMuted}>{row.label}</text>
263
- <text fg={api.theme.current.text}>{row.value}</text>
264
- </box>
265
- )}
266
- </For>
267
- </Show>
268
- </box>
269
- </Dialog>
270
- ))
271
- }
272
-
273
- type MemoryFile = { label: string; filepath: string }
274
-
275
- function openMemory(api: TuiPluginApi) {
276
- const Dialog = api.ui.Dialog
277
- const cwd = api.state.path.directory
278
- const worktree = api.state.path.worktree
279
-
280
- const files = createMemo<MemoryFile[]>(() => {
281
- const result: MemoryFile[] = [
282
- { label: "Project rules", filepath: path.join(cwd, "AGENTS.md") },
283
- { label: "Project memory", filepath: path.join(cwd, "CLAUDE.md") },
284
- { label: "User memory", filepath: path.join(os.homedir(), ".config", "opencode", "AGENTS.md") },
285
- ]
286
- if (worktree && worktree !== cwd) {
287
- result.unshift({ label: "Worktree rules", filepath: path.join(worktree, "AGENTS.md") })
288
- }
289
- return result
290
- })
291
-
292
- const [contents, setContents] = createSignal<Record<string, string>>({})
293
- const [selected, setSelected] = createSignal(0)
294
-
295
- onMount(async () => {
296
- const next: Record<string, string> = {}
297
- for (const file of files()) {
298
- next[file.filepath] = await api.client.file
299
- .read({ path: file.filepath })
300
- .then((res) => res.data?.content ?? "")
301
- .catch(() => "")
302
- }
303
- setContents(next)
304
- })
305
-
306
- const preview = createMemo(() => {
307
- const file = files()[selected()]
308
- if (!file) return ""
309
- const content = contents()[file.filepath]
310
- if (content === undefined) return "Loading..."
311
- if (content.trim() === "") return "(empty - edit this file to add project instructions)"
312
- return content
313
- })
314
-
315
- api.ui.dialog.setSize("large")
316
- api.ui.dialog.replace(() => (
317
- <Dialog onClose={() => api.ui.dialog.clear()}>
318
- <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1} width="100%">
319
- <box flexDirection="row" justifyContent="space-between">
320
- <text attributes={TextAttributes.BOLD} fg={api.theme.current.text}>Memory</text>
321
- <text fg={api.theme.current.textMuted} onMouseUp={() => api.ui.dialog.clear()}>esc</text>
322
- </box>
323
- <box flexDirection="row" gap={2}>
324
- <box flexDirection="column" flexShrink={0}>
325
- <For each={files()}>
326
- {(file, index) => (
327
- <box flexDirection="row" gap={1}>
328
- <text fg={index() === selected() ? api.theme.current.primary : api.theme.current.textMuted}>
329
- {index() === selected() ? "●" : "○"}
330
- </text>
331
- <text
332
- fg={index() === selected() ? api.theme.current.text : api.theme.current.textMuted}
333
- onMouseUp={() => setSelected(index())}
334
- >
335
- {file.label}
336
- </text>
337
- </box>
338
- )}
339
- </For>
340
- </box>
341
- <box
342
- flexGrow={1}
343
- backgroundColor={api.theme.current.backgroundPanel}
344
- paddingTop={1}
345
- paddingBottom={1}
346
- paddingLeft={2}
347
- paddingRight={2}
348
- >
349
- <text fg={api.theme.current.text}>{preview()}</text>
350
- </box>
351
- </box>
352
- </box>
353
- </Dialog>
354
- ))
355
- }
356
-
357
- const tui: TuiPlugin = async (api) => {
358
- api.slots.register({
359
- order: 10,
360
- slots: {
361
- home_logo: (ctx) => <Header api={api} theme={ctx.theme} />,
362
- },
363
- })
364
-
365
- api.keymap.registerLayer({
366
- commands: [
367
- {
368
- name: "jpcode.cost",
369
- title: "Show cost and usage",
370
- category: "JpCode",
371
- namespace: "palette",
372
- slashName: "cost",
373
- run: () => openCost(api),
374
- },
375
- {
376
- name: "jpcode.memory",
377
- title: "Show project memory files",
378
- category: "JpCode",
379
- namespace: "palette",
380
- slashName: "memory",
381
- run: () => openMemory(api),
382
- },
383
- ],
384
- })
385
- }
386
-
387
- const plugin: TuiPluginModule & { id: string } = { id: "jpcode", tui }
388
-
389
- export default plugin