opencode-cockpit 0.1.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.
@@ -0,0 +1,438 @@
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
+ }
@@ -0,0 +1,130 @@
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
+ }