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/README.md +41 -78
- package/package.json +7 -34
- package/src/compose.ts +43 -0
- package/src/features.ts +34 -0
- package/src/server.ts +13 -95
- package/src/tui.ts +13 -0
- package/src/connect.ts +0 -24
- package/src/tools/format.ts +0 -78
- package/src/tools/index.ts +0 -382
- package/src/tools/keys.ts +0 -33
- package/src/tui/badge.tsx +0 -17
- package/src/tui/console.tsx +0 -438
- package/src/tui/dock.tsx +0 -130
- package/src/tui/index.tsx +0 -264
- package/src/tui/keys.ts +0 -52
- package/src/tui/sidebar.tsx +0 -44
- package/src/tui/store.ts +0 -185
- package/src/tui/view.ts +0 -161
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
|
-
}
|
package/src/tui/sidebar.tsx
DELETED
|
@@ -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
|
-
}
|