@soyjuanpiece/jpcode 1.0.0 → 2.0.0
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 +34 -24
- package/bin/jpcode.mjs +278 -54
- package/package.json +16 -17
- package/bin/install-plugin.mjs +0 -30
- package/plugin/package.json +0 -21
- package/plugin/themes/claude.json +0 -250
- package/plugin/tui.tsx +0 -389
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
# JpCode
|
|
1
|
+
# JpCode v2
|
|
2
2
|
|
|
3
|
-
**JpCode** es
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
- `/new` — sesión nueva
|
|
29
|
+
- `/clear` — limpiar pantalla
|
|
30
|
+
- `/help` — ayuda
|
|
31
|
+
- `/exit` — salir
|
|
27
32
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
54
|
+
MIT
|
package/bin/jpcode.mjs
CHANGED
|
@@ -1,75 +1,299 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// JpCode
|
|
3
|
-
//
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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
|
+
import { fileURLToPath } from "node:url"
|
|
10
|
+
import { createOpencodeClient } from "@opencode-ai/sdk"
|
|
9
11
|
|
|
10
|
-
const
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
11
13
|
|
|
12
|
-
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
const BANNER = [
|
|
28
|
+
" ▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄",
|
|
29
|
+
" ██║██╔══██╗██╔════╝██╔══██╗██╔════╝",
|
|
30
|
+
" ██║██████╔╝██║ ██╔══██╗█████╗ ",
|
|
31
|
+
"██ ██║██╔═══╝ ██║ ██║ ██║██╔══╝ ",
|
|
32
|
+
"╚█████╔╝██║ ╚██████╔╝██║ ██║███████╗",
|
|
33
|
+
" ╚════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝",
|
|
34
|
+
]
|
|
35
|
+
|
|
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)
|
|
13
50
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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)
|
|
17
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))
|
|
88
|
+
}
|
|
89
|
+
console.error(C.red("✗ El motor opencode no respondió a tiempo."))
|
|
90
|
+
child.kill()
|
|
91
|
+
process.exit(1)
|
|
18
92
|
}
|
|
19
93
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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() })
|
|
25
101
|
}
|
|
26
102
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
35
132
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
plugins.add(`file:${pluginDir}`)
|
|
39
|
-
const next = { ...cfg, plugin: [...plugins] }
|
|
40
|
-
if (!next.theme) next.theme = "claude"
|
|
41
|
-
mkdirSync(path.dirname(cfgPath), { recursive: true })
|
|
42
|
-
writeFileSync(cfgPath, JSON.stringify(next, null, 2) + "\n")
|
|
43
|
-
} catch {
|
|
44
|
-
// nunca bloquear el launch por un problema de config
|
|
45
|
-
}
|
|
133
|
+
})()
|
|
134
|
+
return ctrl
|
|
46
135
|
}
|
|
47
136
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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)
|
|
60
236
|
}
|
|
61
237
|
}
|
|
62
238
|
|
|
63
|
-
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Modo one-shot: jpcode "pregunta"
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
64
242
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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("")
|
|
267
|
+
}
|
|
268
|
+
|
|
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)
|
|
69
294
|
}
|
|
70
295
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
296
|
+
main().catch((e) => {
|
|
297
|
+
console.error(C.red(`✗ ${e?.message ?? e}`))
|
|
298
|
+
process.exit(1)
|
|
74
299
|
})
|
|
75
|
-
process.exit(result.status ?? 0)
|
package/package.json
CHANGED
|
@@ -1,37 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soyjuanpiece/jpcode",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "JpCode —
|
|
3
|
+
"version": "2.0.0",
|
|
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
|
-
"
|
|
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
|
}
|
package/bin/install-plugin.mjs
DELETED
|
@@ -1,30 +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
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
5
|
-
import path from "node:path"
|
|
6
|
-
import os from "node:os"
|
|
7
|
-
|
|
8
|
-
try {
|
|
9
|
-
const base =
|
|
10
|
-
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
|
|
11
|
-
const cfgPath = path.join(base, "opencode", "tui.json")
|
|
12
|
-
let cfg = {}
|
|
13
|
-
if (existsSync(cfgPath)) {
|
|
14
|
-
cfg = JSON.parse(readFileSync(cfgPath, "utf8"))
|
|
15
|
-
}
|
|
16
|
-
const pluginDir = path.join(
|
|
17
|
-
path.dirname(new URL(import.meta.url).pathname),
|
|
18
|
-
"..",
|
|
19
|
-
"plugin",
|
|
20
|
-
)
|
|
21
|
-
const plugins = new Set(Array.isArray(cfg.plugin) ? cfg.plugin : [])
|
|
22
|
-
plugins.add(`file:${pluginDir}`)
|
|
23
|
-
const next = { ...cfg, plugin: [...plugins] }
|
|
24
|
-
if (!next.theme) next.theme = "claude"
|
|
25
|
-
mkdirSync(path.dirname(cfgPath), { recursive: true })
|
|
26
|
-
writeFileSync(cfgPath, JSON.stringify(next, null, 2) + "\n")
|
|
27
|
-
console.log("jpcode: plugin y tema claude registrados en tui.json ✓")
|
|
28
|
-
} catch {
|
|
29
|
-
// silencioso: el launcher también lo registra al arrancar
|
|
30
|
-
}
|
package/plugin/package.json
DELETED
|
@@ -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
|