opencode-cockpit 0.1.1 → 0.1.2

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/src/tui/index.tsx DELETED
@@ -1,264 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import {
3
- createBindingLookup,
4
- type TuiPlugin,
5
- type TuiPluginApi,
6
- type TuiPluginModule,
7
- } from "@opencode-ai/plugin/tui"
8
- import { createSignal } from "solid-js"
9
- import { createClient } from "../connect.ts"
10
- import { Console } from "./console.tsx"
11
- import { Dock } from "./dock.tsx"
12
- import { SidebarShells } from "./sidebar.tsx"
13
- import { createShellStore, type ShellStore } from "./store.ts"
14
- import { BADGE_LABEL, displayCommand, kindOf, order } from "./view.ts"
15
-
16
- const DEFAULT_KEYS = {
17
- "cockpit.shells.dock": "<leader>o",
18
- "cockpit.shells.console": "<leader>i",
19
- }
20
-
21
- interface Options {
22
- dockHeight?: number
23
- /** Failures stay visible this long after they end (default 30). */
24
- historyMinutes?: number
25
- dockOpen?: boolean
26
- keybinds?: Record<string, string>
27
- }
28
-
29
- const tui: TuiPlugin = async (api, rawOptions) => {
30
- const options = (rawOptions ?? {}) as Options
31
- const client = createClient("opencode-cockpit/tui")
32
- const store = createShellStore(api, client, { historyMinutes: options.historyMinutes })
33
- const keys = createBindingLookup({ ...DEFAULT_KEYS, ...options.keybinds })
34
-
35
- const [dockOpen, setDockOpen] = createSignal<boolean>(
36
- api.kv.get("cockpit.dock.open", options.dockOpen ?? false),
37
- )
38
- const toggleDock = () => {
39
- const next = !dockOpen()
40
- setDockOpen(next)
41
- api.kv.set("cockpit.dock.open", next)
42
- }
43
- const shortcut = (command: string) => {
44
- const bindings = api.keymap.getCommandBindings({ visibility: "registered", commands: [command] })
45
- return api.keys.formatBindings(bindings.get(command)) ?? ""
46
- }
47
-
48
- const openConsole = (id?: string, typing = false) => {
49
- if (id) store.select(id)
50
- api.ui.dialog.replace(
51
- () => (
52
- <Console
53
- api={api}
54
- store={store}
55
- typing={typing}
56
- onClose={() => api.ui.dialog.clear()}
57
- onNewShell={() => newShell(api, store, openConsole)}
58
- />
59
- ),
60
- () => {},
61
- )
62
- api.ui.dialog.setSize("xlarge")
63
- }
64
-
65
- api.keymap.registerLayer({
66
- commands: [
67
- {
68
- name: "cockpit.shells.dock",
69
- title: "Toggle shells panel",
70
- category: "Shells",
71
- namespace: "palette",
72
- slashName: "shells",
73
- run: () => toggleDock(),
74
- },
75
- {
76
- name: "cockpit.shells.console",
77
- title: "Open shell console",
78
- category: "Shells",
79
- namespace: "palette",
80
- slashName: "shell",
81
- run: () => openConsole(),
82
- },
83
- {
84
- name: "cockpit.shells.new",
85
- title: "New background shell",
86
- category: "Shells",
87
- namespace: "palette",
88
- slashName: "shell-new",
89
- run: () => newShell(api, store, openConsole),
90
- },
91
- {
92
- name: "cockpit.shells.clear",
93
- title: "Clear finished shells",
94
- category: "Shells",
95
- namespace: "palette",
96
- slashName: "shells-clear",
97
- run: () => {
98
- void store
99
- .clearFinished()
100
- .then((n) =>
101
- api.ui.toast({
102
- variant: "success",
103
- title: "Shells",
104
- message: `Cleared ${n} finished shell${n === 1 ? "" : "s"}`,
105
- }),
106
- )
107
- .catch((err) => api.ui.toast({ variant: "error", title: "Shells", message: String(err) }))
108
- },
109
- },
110
- {
111
- name: "cockpit.shells.restartDaemon",
112
- title: "Restart shell daemon",
113
- category: "Shells",
114
- namespace: "palette",
115
- slashName: "shells-restart-daemon",
116
- run: () => restartDaemon(api, store),
117
- },
118
- {
119
- name: "cockpit.shells.pick",
120
- title: "Switch shell",
121
- category: "Shells",
122
- namespace: "palette",
123
- run: () => pickShell(api, store, openConsole),
124
- },
125
- ],
126
- bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS)),
127
- })
128
-
129
- const height = () => Math.max(6, Math.min(options.dockHeight ?? 14, Math.floor(api.renderer.height * 0.45)))
130
-
131
- api.slots.register({
132
- order: 150,
133
- slots: {
134
- app_bottom() {
135
- return (
136
- <>
137
- {dockOpen() ? (
138
- <Dock
139
- api={api}
140
- store={store}
141
- height={height()}
142
- hint={() =>
143
- `${shortcut("cockpit.shells.console")} console · ${shortcut("cockpit.shells.dock")} hide`
144
- }
145
- onOpenConsole={(id) => openConsole(id)}
146
- />
147
- ) : null}
148
- </>
149
- )
150
- },
151
- sidebar_content() {
152
- return <SidebarShells api={api} store={store} onOpen={(id) => openConsole(id)} />
153
- },
154
- },
155
- })
156
-
157
- // A daemon from older plugin code is kept only while it runs shells; say so once.
158
- const offOutdated = client.onOutdated((info) => {
159
- if (!info) return
160
- api.ui.toast({
161
- variant: "warning",
162
- title: "Shells",
163
- message:
164
- "The shell daemon is running older code because shells are still running. Run /shells-restart-daemon when convenient.",
165
- duration: 8000,
166
- })
167
- })
168
-
169
- api.lifecycle.onDispose(() => {
170
- offOutdated()
171
- store.dispose()
172
- client.close()
173
- })
174
- }
175
-
176
- function newShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void) {
177
- const DialogPrompt = api.ui.DialogPrompt
178
- api.ui.dialog.replace(() => (
179
- <DialogPrompt
180
- title="New background shell"
181
- placeholder="npm run dev"
182
- onConfirm={(value) => {
183
- const command = value.trim()
184
- if (!command) return api.ui.dialog.clear()
185
- const shell =
186
- process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash"
187
- store.client
188
- .call("shell.start", {
189
- command: shell,
190
- args: ["-c", command],
191
- cwd: store.project(),
192
- title: command.slice(0, 60),
193
- owner: { project: store.project() },
194
- reuse: true,
195
- })
196
- .then((info) => {
197
- void store.refresh()
198
- open(info.id)
199
- })
200
- .catch((err) => {
201
- api.ui.dialog.clear()
202
- api.ui.toast({
203
- variant: "error",
204
- title: "Shell",
205
- message: err instanceof Error ? err.message : String(err),
206
- })
207
- })
208
- }}
209
- onCancel={() => api.ui.dialog.clear()}
210
- />
211
- ))
212
- }
213
-
214
- function restartDaemon(api: TuiPluginApi, store: ShellStore) {
215
- const running = store.shells().filter((s) => s.status === "running").length
216
- const restart = (force: boolean) => {
217
- api.ui.dialog.clear()
218
- store.client
219
- .restartDaemon({ force })
220
- .then((ok) => {
221
- void store.refresh()
222
- api.ui.toast({
223
- variant: ok ? "success" : "warning",
224
- title: "Shells",
225
- message: ok ? "Shell daemon restarted" : "Shells are running; restart was not forced",
226
- })
227
- })
228
- .catch((err) => api.ui.toast({ variant: "error", title: "Shells", message: String(err) }))
229
- }
230
- if (running === 0) return restart(false)
231
- const DialogConfirm = api.ui.DialogConfirm
232
- api.ui.dialog.replace(() => (
233
- <DialogConfirm
234
- title="Restart shell daemon?"
235
- message={`${running} running shell${running === 1 ? "" : "s"} will be stopped.`}
236
- onConfirm={() => restart(true)}
237
- onCancel={() => api.ui.dialog.clear()}
238
- />
239
- ))
240
- }
241
-
242
- function pickShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void) {
243
- const DialogSelect = api.ui.DialogSelect
244
- const shells = order(store.shells())
245
- if (shells.length === 0) {
246
- api.ui.toast({ variant: "info", title: "Shells", message: "No shells in this project yet" })
247
- return
248
- }
249
- api.ui.dialog.replace(() => (
250
- <DialogSelect
251
- title="Shells"
252
- current={store.selected()?.id}
253
- options={shells.map((s) => ({
254
- title: s.title,
255
- value: s.id,
256
- description: `${BADGE_LABEL[kindOf(s)]} · ${displayCommand(s).slice(0, 60)}`,
257
- }))}
258
- onSelect={(option) => open(option.value as string)}
259
- />
260
- ))
261
- }
262
-
263
- const plugin: TuiPluginModule & { id: string } = { id: "opencode-cockpit", tui }
264
- export default plugin
package/src/tui/keys.ts DELETED
@@ -1,52 +0,0 @@
1
- import type { KeyEvent } from "@opentui/core"
2
-
3
- const NAMED: Record<string, string> = {
4
- return: "\r",
5
- enter: "\r",
6
- linefeed: "\n",
7
- tab: "\t",
8
- backspace: "\x7f",
9
- escape: "\x1b",
10
- space: " ",
11
- up: "\x1b[A",
12
- down: "\x1b[B",
13
- right: "\x1b[C",
14
- left: "\x1b[D",
15
- home: "\x1b[H",
16
- end: "\x1b[F",
17
- pageup: "\x1b[5~",
18
- pagedown: "\x1b[6~",
19
- delete: "\x1b[3~",
20
- insert: "\x1b[2~",
21
- }
22
-
23
- /**
24
- * Re-encodes a parsed key as legacy terminal bytes. The TUI may receive keys via the kitty
25
- * protocol, whose raw form programs in the PTY would not understand.
26
- */
27
- export function keyToBytes(e: KeyEvent): string | undefined {
28
- if (e.eventType === "release") return undefined
29
- const name = e.name?.toLowerCase() ?? ""
30
- if (e.name === "tab" && e.shift) return "\x1b[Z"
31
- const named = NAMED[name]
32
- if (named !== undefined) return e.meta || e.option ? `\x1b${named}` : named
33
- if (e.ctrl && name.length === 1) {
34
- const code = name.toUpperCase().charCodeAt(0)
35
- if (code >= 64 && code <= 95) return String.fromCharCode(code - 64)
36
- }
37
- const text =
38
- e.sequence && !e.sequence.startsWith("\x1b")
39
- ? e.sequence
40
- : name.length === 1
41
- ? e.shift
42
- ? name.toUpperCase()
43
- : name
44
- : undefined
45
- if (!text) return undefined
46
- return e.meta || e.option ? `\x1b${text}` : text
47
- }
48
-
49
- /** ctrl+] releases typing mode (the telnet escape), since esc and ctrl+c belong to the program. */
50
- export function isReleaseKey(e: KeyEvent): boolean {
51
- return e.ctrl && (e.name === "]" || e.sequence === "\x1d")
52
- }
@@ -1,44 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
3
- import { createMemo, For, Show } from "solid-js"
4
- import { Badge } from "./badge.tsx"
5
- import type { ShellStore } from "./store.ts"
6
- import { kindOf, shortDetail, truncate } from "./view.ts"
7
-
8
- export function SidebarShells(props: { api: TuiPluginApi; store: ShellStore; onOpen: (id: string) => void }) {
9
- const theme = () => props.api.theme.current
10
- const counts = createMemo(() => {
11
- const list = props.store.shells()
12
- const running = list.filter((s) => kindOf(s) === "run").length
13
- const failed = props.store.visible().filter((s) => kindOf(s) === "fail").length
14
- return [running ? `${running} running` : "", failed ? `${failed} failed` : ""].filter(Boolean).join(" · ")
15
- })
16
-
17
- return (
18
- <Show when={props.store.shells().length > 0}>
19
- <box>
20
- <text fg={theme().text} wrapMode="none">
21
- <b>Shells</b>
22
- <span style={{ fg: theme().textMuted }}> {counts()}</span>
23
- </text>
24
- <For each={props.store.visible()}>
25
- {(shell) => (
26
- <box flexDirection="row" onMouseDown={() => props.onOpen(shell.id)}>
27
- <Badge api={props.api} shell={shell} frame={props.store.frame()} />
28
- <text fg={theme().text} wrapMode="none">
29
- {" "}
30
- {truncate(shell.title, 20)}{" "}
31
- <span style={{ fg: theme().textMuted }}>{shortDetail(shell, props.store.now())}</span>
32
- </text>
33
- </box>
34
- )}
35
- </For>
36
- <Show when={props.store.hidden().length > 0 || props.store.showAll()}>
37
- <text fg={theme().textMuted} onMouseDown={() => props.store.toggleAll()}>
38
- {props.store.showAll() ? "▾ show fewer" : `▸ ${props.store.hidden().length} more finished`}
39
- </text>
40
- </Show>
41
- </box>
42
- </Show>
43
- )
44
- }
package/src/tui/store.ts DELETED
@@ -1,185 +0,0 @@
1
- import { existsSync } from "node:fs"
2
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
3
- import type { CockpitClient } from "@opencode-cockpit/client"
4
- import type { ScreenResult, ShellInfo } from "@opencode-cockpit/protocol/shell"
5
- import { type Accessor, createEffect, createMemo, createRoot, createSignal, on, onCleanup } from "solid-js"
6
- import { createStore, reconcile } from "solid-js/store"
7
- import { order, partition } from "./view.ts"
8
-
9
- export interface ShellStore {
10
- client: CockpitClient
11
- project: () => string
12
- /** Every shell in the project, unordered. */
13
- shells: Accessor<ShellInfo[]>
14
- /** Ordered and folded for display: running, recent failures, plus the selection. */
15
- visible: Accessor<ShellInfo[]>
16
- hidden: Accessor<ShellInfo[]>
17
- showAll: Accessor<boolean>
18
- toggleAll(): void
19
- connected: Accessor<boolean>
20
- now: Accessor<number>
21
- /** Spinner frame index; advances only while something is running. */
22
- frame: Accessor<number>
23
- selected: Accessor<ShellInfo | undefined>
24
- select(id: string): void
25
- step(delta: number): void
26
- refresh(): Promise<void>
27
- clearFinished(): Promise<number>
28
- dispose(): void
29
- }
30
-
31
- export interface StoreOptions {
32
- /** Failures stay in the default view this long after they end. */
33
- historyMinutes?: number
34
- }
35
-
36
- export function createShellStore(
37
- api: TuiPluginApi,
38
- client: CockpitClient,
39
- options: StoreOptions = {},
40
- ): ShellStore {
41
- return createRoot((dispose) => {
42
- const [state, setState] = createStore<{ list: ShellInfo[] }>({ list: [] })
43
- const [connected, setConnected] = createSignal(false)
44
- const [now, setNow] = createSignal(Date.now())
45
- const [frame, setFrame] = createSignal(0)
46
- const [selectedId, setSelectedId] = createSignal<string>()
47
- const [showAll, setShowAll] = createSignal<boolean>(api.kv.get("cockpit.shells.showAll", false))
48
- const project = () => api.state.path.directory
49
- const historyMs = (options.historyMinutes ?? 30) * 60_000
50
-
51
- const refresh = async () => {
52
- try {
53
- const list = await client.call("shell.list", { owner: { project: project() } })
54
- setState("list", reconcile(list, { key: "id" }))
55
- } catch {
56
- // daemon not running yet; the reconnect loop will pick it up
57
- }
58
- }
59
-
60
- const offs = [
61
- client.on("shell.started", () => void refresh()),
62
- client.on("shell.exited", () => void refresh()),
63
- client.on("shell.removed", () => void refresh()),
64
- client.onState((s) => {
65
- setConnected(s === "connected")
66
- if (s === "connected") void refresh()
67
- }),
68
- ]
69
-
70
- // Connect only to a daemon that already exists; starting one is left to explicit actions.
71
- const probe = () => {
72
- if (!client.connected && existsSync(client.paths.socket)) void client.connect().catch(() => {})
73
- }
74
- probe()
75
- const probeTimer = setInterval(probe, 3000)
76
- const tick = setInterval(() => setNow(Date.now()), 1000)
77
- const spin = setInterval(() => {
78
- if (state.list.some((s) => s.status === "running")) setFrame((f) => f + 1)
79
- }, 120)
80
- onCleanup(() => {
81
- clearInterval(probeTimer)
82
- clearInterval(tick)
83
- clearInterval(spin)
84
- for (const off of offs) off()
85
- })
86
-
87
- const pick = createMemo(() => {
88
- const ordered = order(state.list)
89
- return ordered.find((s) => s.id === selectedId()) ?? ordered[0]
90
- })
91
- const folded = createMemo(() =>
92
- partition(state.list, { showAll: showAll(), historyMs, now: now(), keep: pick()?.id }),
93
- )
94
-
95
- return {
96
- client,
97
- project,
98
- shells: () => state.list,
99
- visible: () => folded().visible,
100
- hidden: () => folded().hidden,
101
- showAll,
102
- toggleAll() {
103
- const next = !showAll()
104
- setShowAll(next)
105
- api.kv.set("cockpit.shells.showAll", next)
106
- },
107
- connected,
108
- now,
109
- frame,
110
- selected: pick,
111
- select: (id) => setSelectedId(id),
112
- step(delta) {
113
- const list = folded().visible
114
- if (list.length === 0) return
115
- const index = Math.max(
116
- 0,
117
- list.findIndex((s) => s.id === pick()?.id),
118
- )
119
- const next = list[(index + delta + list.length) % list.length]
120
- if (next) setSelectedId(next.id)
121
- },
122
- refresh,
123
- async clearFinished() {
124
- const { removed } = await client.call("shell.clear", { owner: { project: project() } })
125
- await refresh()
126
- return removed.length
127
- },
128
- dispose,
129
- }
130
- })
131
- }
132
-
133
- /**
134
- * Live terminal view of one shell: attaches for change notifications and re-renders the daemon's
135
- * emulated screen, throttled. Must be called inside a reactive owner.
136
- */
137
- export function useScreen(store: ShellStore, id: Accessor<string | undefined>) {
138
- const [screen, setScreen] = createSignal<ScreenResult>()
139
- let timer: ReturnType<typeof setTimeout> | undefined
140
- let current: string | undefined
141
-
142
- const fetch = (shellId: string) => {
143
- timer = undefined
144
- void store.client
145
- .call("shell.screen", { id: shellId })
146
- .then((s) => {
147
- if (current === shellId) setScreen(s)
148
- })
149
- .catch(() => {})
150
- }
151
- const schedule = (shellId: string) => {
152
- timer ??= setTimeout(() => fetch(shellId), 80)
153
- }
154
-
155
- const offOutput = store.client.on("shell.output", (e) => {
156
- if (e.id === current) schedule(e.id)
157
- })
158
- const offExit = store.client.on("shell.exited", (info) => {
159
- if (info.id === current) schedule(info.id)
160
- })
161
-
162
- const attach = (next: string | undefined) => {
163
- if (next === current) return
164
- if (current) void store.client.call("shell.detach", { id: current }).catch(() => {})
165
- current = next
166
- setScreen(undefined)
167
- if (!next) return
168
- const info = store.shells().find((s) => s.id === next)
169
- void store.client
170
- .call("shell.attach", { id: next, fromOffset: info?.bytes ?? Number.MAX_SAFE_INTEGER })
171
- .catch(() => {})
172
- fetch(next)
173
- }
174
-
175
- createEffect(on(id, (next) => attach(next)))
176
-
177
- onCleanup(() => {
178
- clearTimeout(timer)
179
- offOutput()
180
- offExit()
181
- if (current) void store.client.call("shell.detach", { id: current }).catch(() => {})
182
- })
183
-
184
- return { screen, refetch: () => current && fetch(current) }
185
- }