opencode-cockpit 0.1.1 → 0.1.3
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 -32
- 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/console.tsx
DELETED
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import type { LogLine, ShellInfo } from "@opencode-cockpit/protocol/shell"
|
|
4
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
5
|
-
import { useBindings } from "@opentui/keymap/solid"
|
|
6
|
-
import { useTerminalDimensions } from "@opentui/solid"
|
|
7
|
-
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js"
|
|
8
|
-
import { Badge } from "./badge.tsx"
|
|
9
|
-
import { isReleaseKey, keyToBytes } from "./keys.ts"
|
|
10
|
-
import { type ShellStore, useScreen } from "./store.ts"
|
|
11
|
-
import {
|
|
12
|
-
displayCommand,
|
|
13
|
-
kindColor,
|
|
14
|
-
kindOf,
|
|
15
|
-
relativeCwd,
|
|
16
|
-
statusDetail,
|
|
17
|
-
tailLines,
|
|
18
|
-
truncate,
|
|
19
|
-
wrapText,
|
|
20
|
-
} from "./view.ts"
|
|
21
|
-
|
|
22
|
-
export interface ConsoleProps {
|
|
23
|
-
api: TuiPluginApi
|
|
24
|
-
store: ShellStore
|
|
25
|
-
/** Start in typing mode. */
|
|
26
|
-
typing?: boolean
|
|
27
|
-
onClose: () => void
|
|
28
|
-
onNewShell: () => void
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
type View = "screen" | "log" | "details"
|
|
32
|
-
type Notice = { text: string; tone: "info" | "success" | "error" }
|
|
33
|
-
|
|
34
|
-
/** Daemon errors name ids and internal states; say what happened instead. */
|
|
35
|
-
function friendlyError(err: unknown): string {
|
|
36
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
37
|
-
if (/is (exited|killed|failed)$/.test(message)) return "the shell is no longer running"
|
|
38
|
-
if (/not found$/.test(message)) return "that shell was already removed"
|
|
39
|
-
if (/connection|not running|did not start/.test(message)) return "lost connection to cockpitd, retrying"
|
|
40
|
-
return message
|
|
41
|
-
}
|
|
42
|
-
const COMMAND_LINES = 3
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Keyboard-first shell console in an overlay. Normal mode: single-key actions. Typing mode:
|
|
46
|
-
* every key goes to the program (ctrl+c included); ctrl+] returns to normal mode.
|
|
47
|
-
*/
|
|
48
|
-
export function Console(props: ConsoleProps) {
|
|
49
|
-
const theme = () => props.api.theme.current
|
|
50
|
-
const dims = useTerminalDimensions()
|
|
51
|
-
const shell = () => props.store.selected()
|
|
52
|
-
const [view, setView] = createSignal<View>("screen")
|
|
53
|
-
const [typing, setTyping] = createSignal(props.typing ?? false)
|
|
54
|
-
const [log, setLog] = createSignal<LogLine[]>([])
|
|
55
|
-
const [notice, setNotice] = createSignal<Notice>()
|
|
56
|
-
const { screen } = useScreen(props.store, () => shell()?.id)
|
|
57
|
-
let scroll: ScrollBoxRenderable | undefined
|
|
58
|
-
|
|
59
|
-
const running = () => shell()?.status === "running"
|
|
60
|
-
const client = props.store.client
|
|
61
|
-
|
|
62
|
-
// Layout budget. The host dialog starts at 1/4 of the height and is 116 columns wide (xlarge),
|
|
63
|
-
// capped by the screen; the body grows with content up to what the header leaves.
|
|
64
|
-
const frameRows = () => Math.max(10, dims().height - Math.floor(dims().height / 4) - 3)
|
|
65
|
-
const bodyCols = () => Math.max(20, Math.min(116, dims().width - 2) - 7)
|
|
66
|
-
const commandLines = createMemo(() => {
|
|
67
|
-
const s = shell()
|
|
68
|
-
return s ? wrapText(displayCommand(s), bodyCols() - 2, COMMAND_LINES) : []
|
|
69
|
-
})
|
|
70
|
-
const folder = createMemo(() => {
|
|
71
|
-
const s = shell()
|
|
72
|
-
return s ? relativeCwd(s.cwd, props.store.project()) : ""
|
|
73
|
-
})
|
|
74
|
-
const failure = createMemo(() => {
|
|
75
|
-
const s = shell()
|
|
76
|
-
if (!s || !s.summary) return undefined
|
|
77
|
-
const kind = kindOf(s)
|
|
78
|
-
return kind === "fail" || kind === "stop" ? s.summary : undefined
|
|
79
|
-
})
|
|
80
|
-
const bodyRows = () => {
|
|
81
|
-
const header = 1 + commandLines().length + (folder() ? 1 : 0) + (failure() ? 1 : 0)
|
|
82
|
-
return Math.max(3, frameRows() - header - 4) // margins (2) + footer (1) + bottom padding (1)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Transient footer message. Replaces the key hints while shown, then clears itself.
|
|
86
|
-
let noticeTimer: ReturnType<typeof setTimeout> | undefined
|
|
87
|
-
const flash = (text: string, tone: Notice["tone"], ms?: number) => {
|
|
88
|
-
clearTimeout(noticeTimer)
|
|
89
|
-
setNotice({ text, tone })
|
|
90
|
-
if (ms) noticeTimer = setTimeout(() => setNotice(undefined), ms)
|
|
91
|
-
}
|
|
92
|
-
onCleanup(() => clearTimeout(noticeTimer))
|
|
93
|
-
|
|
94
|
-
const act = (label: string, fn: () => Promise<unknown>) => {
|
|
95
|
-
flash(`${label}…`, "info")
|
|
96
|
-
fn()
|
|
97
|
-
.then((result) => (typeof result === "string" ? flash(result, "success", 2500) : setNotice(undefined)))
|
|
98
|
-
.catch((err) => flash(`${label} failed: ${friendlyError(err)}`, "error", 5000))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Actions that need a live process explain themselves instead of failing. */
|
|
102
|
-
const whileRunning = (label: string, fn: (id: string) => void) => () => {
|
|
103
|
-
const s = shell()
|
|
104
|
-
if (!s) return
|
|
105
|
-
if (s.status !== "running") {
|
|
106
|
-
flash(
|
|
107
|
-
`can't ${label}: this shell already ${kindOf(s) === "done" ? "finished" : "ended"} · r runs it again`,
|
|
108
|
-
"info",
|
|
109
|
-
3000,
|
|
110
|
-
)
|
|
111
|
-
return
|
|
112
|
-
}
|
|
113
|
-
fn(s.id)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Log view: reload on selection, view switch and new output.
|
|
117
|
-
createEffect(
|
|
118
|
-
on([() => shell()?.id, view, () => screen()], () => {
|
|
119
|
-
const id = shell()?.id
|
|
120
|
-
if (!id || view() !== "log") return
|
|
121
|
-
void client
|
|
122
|
-
.call("shell.read", { id, tail: 2000, limit: 2000 })
|
|
123
|
-
.then((page) => setLog(page.lines))
|
|
124
|
-
.catch(() => {})
|
|
125
|
-
}),
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
// Typing mode: size the PTY to what we show, then forward every key.
|
|
129
|
-
createEffect(
|
|
130
|
-
on(typing, (on) => {
|
|
131
|
-
const id = shell()?.id
|
|
132
|
-
if (!on || !id || !running()) return
|
|
133
|
-
setView("screen")
|
|
134
|
-
void client.call("shell.resize", { id, cols: bodyCols(), rows: bodyRows() }).catch(() => {})
|
|
135
|
-
}),
|
|
136
|
-
)
|
|
137
|
-
createEffect(() => {
|
|
138
|
-
if (typing() && !running()) setTyping(false)
|
|
139
|
-
})
|
|
140
|
-
const release = props.api.keymap.intercept(
|
|
141
|
-
"key",
|
|
142
|
-
(ctx) => {
|
|
143
|
-
if (!typing()) return
|
|
144
|
-
const event = ctx.event
|
|
145
|
-
ctx.consume({ preventDefault: true, stopPropagation: true })
|
|
146
|
-
if (isReleaseKey(event)) {
|
|
147
|
-
setTyping(false)
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
const bytes = keyToBytes(event)
|
|
151
|
-
const id = shell()?.id
|
|
152
|
-
if (bytes && id) void client.call("shell.write", { id, data: bytes }).catch(() => setTyping(false))
|
|
153
|
-
},
|
|
154
|
-
{ priority: 10_000 },
|
|
155
|
-
)
|
|
156
|
-
onCleanup(release)
|
|
157
|
-
|
|
158
|
-
const withShell = (fn: (id: string) => void) => () => {
|
|
159
|
-
const id = shell()?.id
|
|
160
|
-
if (id) fn(id)
|
|
161
|
-
}
|
|
162
|
-
const toggle = (next: View) => setView((v) => (v === next ? "screen" : next))
|
|
163
|
-
|
|
164
|
-
useBindings(() => ({
|
|
165
|
-
enabled: () => !typing(),
|
|
166
|
-
commands: [
|
|
167
|
-
{
|
|
168
|
-
name: "cockpit.console.type",
|
|
169
|
-
title: "Type into shell",
|
|
170
|
-
run: whileRunning("type", () => setTyping(true)),
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
name: "cockpit.console.interrupt",
|
|
174
|
-
title: "Send ctrl+c",
|
|
175
|
-
run: whileRunning("interrupt", (id) =>
|
|
176
|
-
act("interrupt", async () => {
|
|
177
|
-
await client.call("shell.write", { id, data: "\x03" })
|
|
178
|
-
return "sent ctrl+c"
|
|
179
|
-
}),
|
|
180
|
-
),
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
name: "cockpit.console.restart",
|
|
184
|
-
title: "Restart shell",
|
|
185
|
-
run: withShell((id) => act("restart", () => client.call("shell.restart", { id }))),
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
name: "cockpit.console.stop",
|
|
189
|
-
title: "Stop shell",
|
|
190
|
-
run: whileRunning("stop", (id) =>
|
|
191
|
-
act("stop", async () => {
|
|
192
|
-
await client.call("shell.stop", { id, signal: "SIGTERM", graceMs: 3000 })
|
|
193
|
-
return "stopped"
|
|
194
|
-
}),
|
|
195
|
-
),
|
|
196
|
-
},
|
|
197
|
-
{
|
|
198
|
-
name: "cockpit.console.remove",
|
|
199
|
-
title: "Remove shell",
|
|
200
|
-
run: withShell((id) => act("remove", () => client.call("shell.remove", { id }))),
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
name: "cockpit.console.clear",
|
|
204
|
-
title: "Clear finished shells",
|
|
205
|
-
run: () =>
|
|
206
|
-
act("clear", async () => {
|
|
207
|
-
const n = await props.store.clearFinished()
|
|
208
|
-
return `cleared ${n} finished shell${n === 1 ? "" : "s"}`
|
|
209
|
-
}),
|
|
210
|
-
},
|
|
211
|
-
{ name: "cockpit.console.all", title: "Show all / fewer shells", run: () => props.store.toggleAll() },
|
|
212
|
-
{
|
|
213
|
-
name: "cockpit.console.view",
|
|
214
|
-
title: "Toggle screen/log",
|
|
215
|
-
run: () => setView((v) => (v === "log" ? "screen" : "log")),
|
|
216
|
-
},
|
|
217
|
-
{ name: "cockpit.console.details", title: "Toggle details", run: () => toggle("details") },
|
|
218
|
-
{ name: "cockpit.console.next", title: "Next shell", run: () => props.store.step(1) },
|
|
219
|
-
{ name: "cockpit.console.prev", title: "Previous shell", run: () => props.store.step(-1) },
|
|
220
|
-
{ name: "cockpit.console.new", title: "New shell", run: () => props.onNewShell() },
|
|
221
|
-
{ name: "cockpit.console.down", title: "Scroll down", run: () => scroll?.scrollBy(3) },
|
|
222
|
-
{ name: "cockpit.console.up", title: "Scroll up", run: () => scroll?.scrollBy(-3) },
|
|
223
|
-
{
|
|
224
|
-
name: "cockpit.console.bottom",
|
|
225
|
-
title: "Scroll to end",
|
|
226
|
-
run: () => scroll?.scrollTo(scroll.scrollHeight),
|
|
227
|
-
},
|
|
228
|
-
{ name: "cockpit.console.top", title: "Scroll to top", run: () => scroll?.scrollTo(0) },
|
|
229
|
-
{ name: "cockpit.console.close", title: "Close console", run: () => props.onClose() },
|
|
230
|
-
],
|
|
231
|
-
bindings: [
|
|
232
|
-
{ key: "i,return", cmd: "cockpit.console.type", desc: "Type" },
|
|
233
|
-
{ key: "c", cmd: "cockpit.console.interrupt", desc: "^C" },
|
|
234
|
-
{ key: "r", cmd: "cockpit.console.restart", desc: "Restart" },
|
|
235
|
-
{ key: "x", cmd: "cockpit.console.stop", desc: "Stop" },
|
|
236
|
-
{ key: "d", cmd: "cockpit.console.remove", desc: "Remove" },
|
|
237
|
-
{ key: "shift+d", cmd: "cockpit.console.clear", desc: "Clear finished" },
|
|
238
|
-
{ key: "a", cmd: "cockpit.console.all", desc: "All" },
|
|
239
|
-
{ key: "tab", cmd: "cockpit.console.view", desc: "Screen/log" },
|
|
240
|
-
{ key: "?,shift+/", cmd: "cockpit.console.details", desc: "Details" },
|
|
241
|
-
{ key: "],l,right", cmd: "cockpit.console.next", desc: "Next" },
|
|
242
|
-
{ key: "[,h,left", cmd: "cockpit.console.prev", desc: "Prev" },
|
|
243
|
-
{ key: "n", cmd: "cockpit.console.new", desc: "New" },
|
|
244
|
-
{ key: "j,down", cmd: "cockpit.console.down", desc: "Down" },
|
|
245
|
-
{ key: "k,up", cmd: "cockpit.console.up", desc: "Up" },
|
|
246
|
-
{ key: "shift+g,end", cmd: "cockpit.console.bottom", desc: "End" },
|
|
247
|
-
{ key: "g,home", cmd: "cockpit.console.top", desc: "Top" },
|
|
248
|
-
{ key: "q", cmd: "cockpit.console.close", desc: "Close" },
|
|
249
|
-
],
|
|
250
|
-
}))
|
|
251
|
-
|
|
252
|
-
const screenText = createMemo(() => tailLines(screen()?.text, bodyRows(), bodyCols()))
|
|
253
|
-
const details = createMemo(() =>
|
|
254
|
-
shell() ? detailRows(shell() as ShellInfo, props.store.now(), bodyCols()) : [],
|
|
255
|
-
)
|
|
256
|
-
const bodyHeight = createMemo(() => {
|
|
257
|
-
if (typing()) return bodyRows()
|
|
258
|
-
const content =
|
|
259
|
-
view() === "log"
|
|
260
|
-
? log().length
|
|
261
|
-
: view() === "details"
|
|
262
|
-
? details().length
|
|
263
|
-
: screenText().split("\n").length
|
|
264
|
-
return Math.min(bodyRows(), Math.max(6, content))
|
|
265
|
-
})
|
|
266
|
-
const position = createMemo(() => {
|
|
267
|
-
const list = props.store.visible()
|
|
268
|
-
const index = list.findIndex((x) => x.id === shell()?.id)
|
|
269
|
-
const hidden = props.store.hidden().length
|
|
270
|
-
return list.length > 1 || hidden > 0 ? `${index + 1}/${list.length}${hidden ? ` +${hidden}` : ""}` : ""
|
|
271
|
-
})
|
|
272
|
-
// Only the keys that do something for the selected shell.
|
|
273
|
-
const hint = createMemo(() => {
|
|
274
|
-
if (typing()) return "TYPING: keys go to the shell (ctrl+c included) · ctrl+] stop typing"
|
|
275
|
-
const next = view() === "log" ? "screen" : "log"
|
|
276
|
-
const wide = dims().width >= 110
|
|
277
|
-
const live = running()
|
|
278
|
-
? wide
|
|
279
|
-
? ["i type", "c ^C", "r restart", "x stop"]
|
|
280
|
-
: ["i type", "c ^C", "r", "x"]
|
|
281
|
-
: [wide ? "r run again" : "r rerun", wide ? "d remove" : "d"]
|
|
282
|
-
const common = wide
|
|
283
|
-
? [`tab ${next}`, "? details", "[ ] switch", "D clear done", "a all", "esc"]
|
|
284
|
-
: [`tab ${next}`, "?", "[ ]", "D", "a", "esc"]
|
|
285
|
-
return [...(shell() ? live : ["n new"]), ...common].join(" · ")
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
return (
|
|
289
|
-
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingBottom={1} overflow="hidden">
|
|
290
|
-
<Show
|
|
291
|
-
when={shell()}
|
|
292
|
-
fallback={
|
|
293
|
-
<box flexDirection="column">
|
|
294
|
-
<text fg={theme().text} wrapMode="none">
|
|
295
|
-
<b>No shells in this project</b>
|
|
296
|
-
</text>
|
|
297
|
-
<text fg={theme().textMuted} wrapMode="none">
|
|
298
|
-
{truncate("Press n to start one. The agent starts its own with shell_start.", bodyCols())}
|
|
299
|
-
</text>
|
|
300
|
-
</box>
|
|
301
|
-
}
|
|
302
|
-
>
|
|
303
|
-
{(s) => (
|
|
304
|
-
<>
|
|
305
|
-
<box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
|
|
306
|
-
<Badge api={props.api} shell={s()} frame={props.store.frame()} />
|
|
307
|
-
<text fg={theme().text} wrapMode="none" flexShrink={1}>
|
|
308
|
-
{" "}
|
|
309
|
-
<b>{truncate(s().title, Math.max(10, bodyCols() - 50))}</b>
|
|
310
|
-
</text>
|
|
311
|
-
<box flexGrow={1} />
|
|
312
|
-
<text fg={theme().textMuted} wrapMode="none" flexShrink={0}>
|
|
313
|
-
{[statusDetail(s(), props.store.now()), s().run > 1 ? `run ${s().run}` : "", position()]
|
|
314
|
-
.filter(Boolean)
|
|
315
|
-
.join(" · ")}
|
|
316
|
-
</text>
|
|
317
|
-
</box>
|
|
318
|
-
<For each={commandLines()}>
|
|
319
|
-
{(line, index) => (
|
|
320
|
-
<text fg={theme().text} wrapMode="none" flexShrink={0}>
|
|
321
|
-
<span style={{ fg: theme().textMuted }}>{index() === 0 ? "$ " : " "}</span>
|
|
322
|
-
{line}
|
|
323
|
-
</text>
|
|
324
|
-
)}
|
|
325
|
-
</For>
|
|
326
|
-
<Show when={folder()}>
|
|
327
|
-
<text fg={theme().textMuted} wrapMode="none" flexShrink={0}>
|
|
328
|
-
{truncate(`in ${folder()}`, bodyCols())}
|
|
329
|
-
</text>
|
|
330
|
-
</Show>
|
|
331
|
-
<Show when={failure()}>
|
|
332
|
-
{(summary) => (
|
|
333
|
-
<text fg={kindColor(theme(), kindOf(s()))} wrapMode="none" flexShrink={0}>
|
|
334
|
-
{truncate(`${kindOf(s()) === "fail" ? "error" : "last output"}: ${summary()}`, bodyCols())}
|
|
335
|
-
</text>
|
|
336
|
-
)}
|
|
337
|
-
</Show>
|
|
338
|
-
<box
|
|
339
|
-
height={bodyHeight()}
|
|
340
|
-
flexShrink={0}
|
|
341
|
-
marginTop={1}
|
|
342
|
-
marginBottom={1}
|
|
343
|
-
border={["left"]}
|
|
344
|
-
borderColor={typing() ? theme().accent : theme().border}
|
|
345
|
-
paddingLeft={1}
|
|
346
|
-
overflow="hidden"
|
|
347
|
-
>
|
|
348
|
-
<Show when={view() === "screen"}>
|
|
349
|
-
<text fg={theme().text} wrapMode="none">
|
|
350
|
-
{screenText() || " "}
|
|
351
|
-
</text>
|
|
352
|
-
</Show>
|
|
353
|
-
<Show when={view() === "details"}>
|
|
354
|
-
<box flexDirection="column">
|
|
355
|
-
<For each={details()}>
|
|
356
|
-
{([key, value]) => (
|
|
357
|
-
<text fg={theme().text} wrapMode="none">
|
|
358
|
-
<span style={{ fg: theme().textMuted }}>{key.padEnd(10)}</span>
|
|
359
|
-
{value}
|
|
360
|
-
</text>
|
|
361
|
-
)}
|
|
362
|
-
</For>
|
|
363
|
-
</box>
|
|
364
|
-
</Show>
|
|
365
|
-
<Show when={view() === "log"}>
|
|
366
|
-
<scrollbox
|
|
367
|
-
ref={(el: ScrollBoxRenderable) => {
|
|
368
|
-
scroll = el
|
|
369
|
-
}}
|
|
370
|
-
flexGrow={1}
|
|
371
|
-
stickyScroll={true}
|
|
372
|
-
stickyStart="bottom"
|
|
373
|
-
verticalScrollbarOptions={{ visible: false }}
|
|
374
|
-
horizontalScrollbarOptions={{ visible: false }}
|
|
375
|
-
>
|
|
376
|
-
<For each={log()}>
|
|
377
|
-
{(line) => (
|
|
378
|
-
<text fg={theme().text} wrapMode="none">
|
|
379
|
-
<span style={{ fg: theme().textMuted }}>{String(line.n).padStart(5)} </span>
|
|
380
|
-
{truncate(line.text, bodyCols() - 7)}
|
|
381
|
-
</text>
|
|
382
|
-
)}
|
|
383
|
-
</For>
|
|
384
|
-
</scrollbox>
|
|
385
|
-
</Show>
|
|
386
|
-
</box>
|
|
387
|
-
</>
|
|
388
|
-
)}
|
|
389
|
-
</Show>
|
|
390
|
-
|
|
391
|
-
<box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
|
|
392
|
-
<Show
|
|
393
|
-
when={notice()}
|
|
394
|
-
fallback={
|
|
395
|
-
<text fg={typing() ? theme().accent : theme().textMuted} wrapMode="none">
|
|
396
|
-
{truncate(hint(), bodyCols())}
|
|
397
|
-
</text>
|
|
398
|
-
}
|
|
399
|
-
>
|
|
400
|
-
{(n) => (
|
|
401
|
-
<text
|
|
402
|
-
fg={
|
|
403
|
-
n().tone === "error"
|
|
404
|
-
? theme().error
|
|
405
|
-
: n().tone === "success"
|
|
406
|
-
? theme().success
|
|
407
|
-
: theme().warning
|
|
408
|
-
}
|
|
409
|
-
wrapMode="none"
|
|
410
|
-
>
|
|
411
|
-
{truncate(n().text, bodyCols())}
|
|
412
|
-
</text>
|
|
413
|
-
)}
|
|
414
|
-
</Show>
|
|
415
|
-
</box>
|
|
416
|
-
</box>
|
|
417
|
-
)
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Everything about a shell, with the full command wrapped rather than cut. */
|
|
421
|
-
function detailRows(s: ShellInfo, now: number, cols: number): [string, string][] {
|
|
422
|
-
const width = Math.max(10, cols - 11)
|
|
423
|
-
const rows: [string, string][] = []
|
|
424
|
-
for (const [i, line] of wrapText(displayCommand(s), width, 12).entries())
|
|
425
|
-
rows.push([i === 0 ? "command" : "", line])
|
|
426
|
-
rows.push(["folder", s.cwd])
|
|
427
|
-
rows.push([
|
|
428
|
-
"status",
|
|
429
|
-
`${s.status}${s.exitCode !== undefined ? ` (exit ${s.exitCode})` : ""}${s.signal ? ` (${s.signal})` : ""}`,
|
|
430
|
-
])
|
|
431
|
-
rows.push(["timing", statusDetail(s, now)])
|
|
432
|
-
rows.push(["started", new Date(s.startedAt).toLocaleString()])
|
|
433
|
-
if (s.summary) rows.push(["summary", truncate(s.summary, width)])
|
|
434
|
-
rows.push(["id", `${s.id} · run ${s.run}${s.pid ? ` · pid ${s.pid}` : ""}`])
|
|
435
|
-
rows.push(["owner", s.owner.session ? `agent session ${s.owner.session}` : "you"])
|
|
436
|
-
rows.push(["output", `${s.lines.last} lines · ${Math.round(s.bytes / 1024)} KiB`])
|
|
437
|
-
return rows
|
|
438
|
-
}
|
package/src/tui/dock.tsx
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import { useTerminalDimensions } from "@opentui/solid"
|
|
4
|
-
import { createMemo, For, Show } from "solid-js"
|
|
5
|
-
import { Badge } from "./badge.tsx"
|
|
6
|
-
import type { ShellStore } from "./store.ts"
|
|
7
|
-
import { useScreen } from "./store.ts"
|
|
8
|
-
import { displayCommand, kindColor, kindOf, statusDetail, tailLines, truncate } from "./view.ts"
|
|
9
|
-
|
|
10
|
-
export interface DockProps {
|
|
11
|
-
api: TuiPluginApi
|
|
12
|
-
store: ShellStore
|
|
13
|
-
height: number
|
|
14
|
-
hint: () => string
|
|
15
|
-
onOpenConsole: (id: string) => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Split pane under the chat: status tabs for shells and the selected shell's live screen. */
|
|
19
|
-
export function Dock(props: DockProps) {
|
|
20
|
-
const theme = () => props.api.theme.current
|
|
21
|
-
const dims = useTerminalDimensions()
|
|
22
|
-
const { screen } = useScreen(props.store, () => props.store.selected()?.id)
|
|
23
|
-
const bodyRows = () => Math.max(2, props.height - 3)
|
|
24
|
-
const body = createMemo(() => tailLines(screen()?.text, bodyRows(), Math.max(10, dims().width - 4)))
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<box
|
|
28
|
-
height={props.height}
|
|
29
|
-
flexShrink={0}
|
|
30
|
-
flexDirection="column"
|
|
31
|
-
border={["top"]}
|
|
32
|
-
borderColor={theme().border}
|
|
33
|
-
backgroundColor={theme().backgroundPanel}
|
|
34
|
-
>
|
|
35
|
-
<box
|
|
36
|
-
flexDirection="row"
|
|
37
|
-
gap={1}
|
|
38
|
-
paddingLeft={1}
|
|
39
|
-
paddingRight={1}
|
|
40
|
-
flexShrink={0}
|
|
41
|
-
height={1}
|
|
42
|
-
overflow="hidden"
|
|
43
|
-
>
|
|
44
|
-
<text fg={theme().text} flexShrink={0}>
|
|
45
|
-
<b>Shells</b>
|
|
46
|
-
</text>
|
|
47
|
-
<Show
|
|
48
|
-
when={props.store.shells().length > 0}
|
|
49
|
-
fallback={
|
|
50
|
-
<text fg={theme().textMuted} wrapMode="none">
|
|
51
|
-
none yet · the agent starts them, or /shell-new
|
|
52
|
-
</text>
|
|
53
|
-
}
|
|
54
|
-
>
|
|
55
|
-
<For each={props.store.visible()}>
|
|
56
|
-
{(shell) => {
|
|
57
|
-
const active = () => props.store.selected()?.id === shell.id
|
|
58
|
-
return (
|
|
59
|
-
<box
|
|
60
|
-
flexDirection="row"
|
|
61
|
-
flexShrink={0}
|
|
62
|
-
paddingRight={1}
|
|
63
|
-
backgroundColor={active() ? theme().backgroundElement : theme().backgroundPanel}
|
|
64
|
-
onMouseDown={() =>
|
|
65
|
-
active() ? props.onOpenConsole(shell.id) : props.store.select(shell.id)
|
|
66
|
-
}
|
|
67
|
-
>
|
|
68
|
-
<Badge api={props.api} shell={shell} frame={props.store.frame()} />
|
|
69
|
-
<text fg={active() ? theme().text : theme().textMuted} wrapMode="none">
|
|
70
|
-
{" "}
|
|
71
|
-
{active() ? <b>{truncate(shell.title, 22)}</b> : truncate(shell.title, 22)}
|
|
72
|
-
</text>
|
|
73
|
-
</box>
|
|
74
|
-
)
|
|
75
|
-
}}
|
|
76
|
-
</For>
|
|
77
|
-
<Show when={props.store.hidden().length > 0 || props.store.showAll()}>
|
|
78
|
-
<text
|
|
79
|
-
fg={theme().textMuted}
|
|
80
|
-
wrapMode="none"
|
|
81
|
-
flexShrink={0}
|
|
82
|
-
onMouseDown={() => props.store.toggleAll()}
|
|
83
|
-
>
|
|
84
|
-
{props.store.showAll() ? "▾ fewer" : `▸ ${props.store.hidden().length} more`}
|
|
85
|
-
</text>
|
|
86
|
-
</Show>
|
|
87
|
-
</Show>
|
|
88
|
-
<box flexGrow={1} />
|
|
89
|
-
<text fg={theme().textMuted} wrapMode="none" flexShrink={0}>
|
|
90
|
-
{props.hint()}
|
|
91
|
-
</text>
|
|
92
|
-
</box>
|
|
93
|
-
<Show when={props.store.selected()}>
|
|
94
|
-
{(shell) => (
|
|
95
|
-
<>
|
|
96
|
-
<box
|
|
97
|
-
flexGrow={1}
|
|
98
|
-
paddingLeft={2}
|
|
99
|
-
paddingRight={1}
|
|
100
|
-
minHeight={0}
|
|
101
|
-
overflow="hidden"
|
|
102
|
-
onMouseDown={() => props.onOpenConsole(shell().id)}
|
|
103
|
-
>
|
|
104
|
-
<text fg={theme().text} wrapMode="none">
|
|
105
|
-
{body() || " "}
|
|
106
|
-
</text>
|
|
107
|
-
</box>
|
|
108
|
-
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0} height={1} overflow="hidden">
|
|
109
|
-
<text fg={kindColor(theme(), kindOf(shell()))} wrapMode="none" flexShrink={0}>
|
|
110
|
-
{statusDetail(shell(), props.store.now())}
|
|
111
|
-
</text>
|
|
112
|
-
<Show
|
|
113
|
-
when={kindOf(shell()) === "fail" && shell().summary}
|
|
114
|
-
fallback={
|
|
115
|
-
<text fg={theme().textMuted} wrapMode="none">
|
|
116
|
-
{truncate(`$ ${displayCommand(shell())}`, Math.max(20, dims().width - 40))}
|
|
117
|
-
</text>
|
|
118
|
-
}
|
|
119
|
-
>
|
|
120
|
-
<text fg={theme().error} wrapMode="none">
|
|
121
|
-
{truncate(`${shell().summary}`, Math.max(20, dims().width - 40))}
|
|
122
|
-
</text>
|
|
123
|
-
</Show>
|
|
124
|
-
</box>
|
|
125
|
-
</>
|
|
126
|
-
)}
|
|
127
|
-
</Show>
|
|
128
|
-
</box>
|
|
129
|
-
)
|
|
130
|
-
}
|