@soyjuanpiece/jpcode 1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/bin/install-plugin.mjs +30 -0
- package/bin/jpcode.mjs +75 -0
- package/package.json +37 -0
- package/plugin/package.json +21 -0
- package/plugin/themes/claude.json +250 -0
- package/plugin/tui.tsx +389 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jpcode contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# JpCode
|
|
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.
|
|
7
|
+
|
|
8
|
+
 
|
|
9
|
+
|
|
10
|
+
## Instalación
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @soyjuanpiece/jpcode
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Después corre:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
jpcode
|
|
20
|
+
```
|
|
21
|
+
|
|
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`.
|
|
25
|
+
|
|
26
|
+
## Qué añade sobre opencode
|
|
27
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
## Actualizaciones
|
|
38
|
+
|
|
39
|
+
Como el motor es opencode real, `opencode upgrade` (o reinstalar este paquete)
|
|
40
|
+
actualiza todo sin perder tu diseño ni tus comandos.
|
|
41
|
+
|
|
42
|
+
## Licencia
|
|
43
|
+
|
|
44
|
+
MIT — opencode es MIT y este paquete también.
|
|
@@ -0,0 +1,30 @@
|
|
|
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/bin/jpcode.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
6
|
+
import { spawnSync } from "node:child_process"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import os from "node:os"
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
|
|
12
|
+
function packageDir() {
|
|
13
|
+
try {
|
|
14
|
+
return path.dirname(require.resolve("opencode-ai/package.json"))
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function tuiConfigPath() {
|
|
21
|
+
const base =
|
|
22
|
+
process.env.XDG_CONFIG_HOME ||
|
|
23
|
+
path.join(os.homedir(), ".config")
|
|
24
|
+
return path.join(base, "opencode", "tui.json")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Registra el plugin embebido de JpCode en el tui.json del usuario (una vez).
|
|
28
|
+
// No toca ninguna otra clave: si el usuario ya tiene plugins/tema, se respetan.
|
|
29
|
+
function ensurePluginRegistered() {
|
|
30
|
+
try {
|
|
31
|
+
const cfgPath = tuiConfigPath()
|
|
32
|
+
let cfg = {}
|
|
33
|
+
if (existsSync(cfgPath)) {
|
|
34
|
+
cfg = JSON.parse(readFileSync(cfgPath, "utf8"))
|
|
35
|
+
}
|
|
36
|
+
const pluginDir = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "plugin")
|
|
37
|
+
const plugins = new Set(Array.isArray(cfg.plugin) ? cfg.plugin : [])
|
|
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
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dir = packageDir()
|
|
49
|
+
const binName = process.platform === "win32" ? "opencode.exe" : "opencode"
|
|
50
|
+
const candidates = dir
|
|
51
|
+
? [path.join(dir, "bin", binName)]
|
|
52
|
+
: []
|
|
53
|
+
|
|
54
|
+
let bin = candidates.find((p) => existsSync(p))
|
|
55
|
+
if (!bin) {
|
|
56
|
+
// fallback: opencode en PATH (instalado por otros medios)
|
|
57
|
+
const which = spawnSync("which", ["opencode"], { encoding: "utf8" })
|
|
58
|
+
if (which.status === 0 && which.stdout.trim()) {
|
|
59
|
+
bin = which.stdout.trim()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ensurePluginRegistered()
|
|
64
|
+
|
|
65
|
+
if (!bin) {
|
|
66
|
+
console.error("jpcode: no se 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 result = spawnSync(bin, process.argv.slice(2), {
|
|
72
|
+
stdio: "inherit",
|
|
73
|
+
env: process.env,
|
|
74
|
+
})
|
|
75
|
+
process.exit(result.status ?? 0)
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@soyjuanpiece/jpcode",
|
|
3
|
+
"version": "1.0.0",
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
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
|
+
"bin": {
|
|
19
|
+
"jpcode": "./bin/jpcode.mjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin/",
|
|
23
|
+
"plugin/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"postinstall": "node bin/install-plugin.mjs"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"opencode-ai": "^1.18.32"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/SoyJuanPiece/jpcode.git"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
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
|