opencode-extended-sidebar 0.2.90

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.
Files changed (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,843 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * All popups for the panel: detail dialogs for Files, Tools, OMO
4
+ * documents, Perf logs, the pending-approval menu, and the text/markdown
5
+ * preview. Every popup goes through the single `openDialog()` choke-point —
6
+ * nothing outside this module touches `api.ui.dialog`.
7
+ * Read-only metadata + optional preview.
8
+ */
9
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js"
10
+ import { SyntaxStyle } from "@opentui/core"
11
+ import { useTerminalDimensions } from "@opentui/solid"
12
+ import type { TuiDialogSelectOption, TuiPluginApi, TuiTheme } from "@opencode-ai/plugin/tui"
13
+ import { copyText } from "../pware.oc.core/pware.oc.core.clipboard.js"
14
+ import { ClickText, textAttrs, type ThemeColors } from "./pware.oc.ui.chrome.js"
15
+ import type { ToolView } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.js"
16
+ import type { FileView } from "../pware.oc.opencode/pware.oc.opencode.files.js"
17
+ import { readPerfLog, type PerfLogKind, type PerfSnapshot } from "../pware.oc.perf/pware.oc.perf.reader.js"
18
+ import { asciiTrend, perfStatLine, realtimeSeriesLines, shareDonut, shareGauge, smoothSeries, waitHistogram } from "../pware.oc.perf/pware.oc.perf.charts.js"
19
+ import { STAT_REALTIME_BLOCK, realtimeRow, realtimeTab, seriesValues, type StatRealtimeSeriesKey, type StatRealtimeTabId } from "../pware.oc.perf/pware.oc.perf.realtimeBlock.js"
20
+ import type { StatRealtimeTimeline } from "../pware.oc.perf/pware.oc.perf.realtimeTimeline.js"
21
+ import { RT_DIALOG_CHART_ROWS, dialogInnerWidth, type HostDialogSize } from "../pware.oc.core/pware.oc.core.layout.js"
22
+ import { TICK_MS } from "../pware.oc.core/pware.oc.core.timing.js"
23
+ import { formatDiffStat } from "../pware.oc.opencode/pware.oc.opencode.files.js"
24
+ import type { DocView } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.doc.js"
25
+ import { DOC_KIND_LABEL } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.doc.js"
26
+ import type { StartWorkMode } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
27
+ import { startWorkCommand } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
28
+ import { TOOL_STATUS_RUNNING } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
29
+ import {
30
+ START_WORK_MAKE_PR,
31
+ START_WORK_PLAIN,
32
+ START_WORK_SHIP,
33
+ } from "../pware.oc.omo/constants/pware.oc.omo.constants.startWork.js"
34
+ import { resolveProjectFile } from "../pware.oc.core/pware.oc.core.paths.js"
35
+ import {
36
+ canPreviewPath,
37
+ isMarkdownPath,
38
+ previewViewportRows,
39
+ readTextPreview,
40
+ } from "../pware.oc.core/pware.oc.core.preview.js"
41
+ import { formatAge, formatDuration, formatRate, formatWhen } from "../pware.oc.core/pware.oc.core.pulse.js"
42
+
43
+ function hostDialogSize(api: TuiPluginApi): HostDialogSize {
44
+ try {
45
+ const s = api.ui.dialog.size
46
+ if (s === "medium" || s === "large" || s === "xlarge") return s
47
+ } catch {
48
+ // host without a size getter
49
+ }
50
+ return "xlarge"
51
+ }
52
+
53
+ function closeDialog(api: TuiPluginApi): void {
54
+ try {
55
+ api.ui.dialog.clear()
56
+ } catch {
57
+ // host without dialog stack
58
+ }
59
+ }
60
+
61
+ function toast(
62
+ api: TuiPluginApi,
63
+ message: string,
64
+ variant: "info" | "success" | "warning" | "error" = "info",
65
+ ): void {
66
+ try {
67
+ api.ui.toast({ variant, message })
68
+ } catch {
69
+ // no toast
70
+ }
71
+ }
72
+
73
+ function divider(colors: ThemeColors): JSX.Element {
74
+ return <text fg={colors.textMuted}>{"────────────────────────────────────"}</text>
75
+ }
76
+
77
+ function DetailLine(props: { text: string; colors: ThemeColors; muted?: boolean }): JSX.Element {
78
+ return <text fg={props.muted ? props.colors.textMuted : props.colors.text}>{props.text}</text>
79
+ }
80
+
81
+ function ActionRow(props: {
82
+ label: string
83
+ colors: ThemeColors
84
+ disabled?: boolean
85
+ onPick: () => void
86
+ }): JSX.Element {
87
+ const fg = () =>
88
+ props.disabled ? props.colors.textMuted : props.colors.primary || props.colors.text
89
+ return (
90
+ <box
91
+ flexDirection="row"
92
+ onMouseUp={() => {
93
+ if (!props.disabled) props.onPick()
94
+ }}
95
+ >
96
+ <ClickText fg={fg()} underline={!props.disabled}>
97
+ {props.label}
98
+ </ClickText>
99
+ </box>
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Body chrome of a host dialog. Matches `DialogAlert` / `DialogConfirm` /
105
+ * `DialogHelp`: `paddingLeft/Right 2` and a bottom row. No top padding — the
106
+ * host panel box already adds `paddingTop 1` around the stack element. Our
107
+ * rows are dense line lists, so the column keeps `gap 0` and the one-row
108
+ * breathing space under the title lives on {@link DialogHeader}.
109
+ */
110
+ function DialogPad(props: { children: JSX.Element }): JSX.Element {
111
+ return (
112
+ <box flexDirection="column" gap={0} paddingLeft={2} paddingRight={2} paddingBottom={1}>
113
+ {props.children}
114
+ </box>
115
+ )
116
+ }
117
+
118
+ /**
119
+ * The header every host dialog wears: bold title left, muted clickable `esc`
120
+ * right. The host `DialogProvider` binds the Escape key itself — this row is
121
+ * the affordance plus a mouse target, exactly like the native dialogs.
122
+ */
123
+ function DialogHeader(props: { title: string; colors: ThemeColors; onClose: () => void }): JSX.Element {
124
+ return (
125
+ <box flexDirection="row" justifyContent="space-between" gap={1} paddingBottom={1}>
126
+ <text fg={props.colors.text} attributes={textAttrs(true)}>
127
+ {props.title}
128
+ </text>
129
+ <text fg={props.colors.textMuted} onMouseUp={props.onClose}>
130
+ esc
131
+ </text>
132
+ </box>
133
+ )
134
+ }
135
+
136
+ /**
137
+ * Sets the dialog stack size AFTER render so it survives the replace()
138
+ * size-reset (host bug #44754).
139
+ *
140
+ * Deliberately NOT wrapped in `api.ui.Dialog`. That component *is* the frame:
141
+ * the host maps it straight onto its own `ui/dialog.tsx` `Dialog`, and
142
+ * `DialogProvider` already renders one around `stack.at(-1)` — a full-screen
143
+ * absolute backdrop plus the centred panel box. A second one nests another
144
+ * backdrop and another `paddingTop = height / 4` inside a 60-column box.
145
+ * Every native content dialog (`DialogSelect`, `DialogAlert`, `DialogConfirm`,
146
+ * `DialogHelp`) is a bare `<box>` handed to `replace()` for the same reason;
147
+ * {@link DialogPad} + {@link DialogHeader} reproduce their chrome.
148
+ */
149
+ function SizedDialog(props: {
150
+ api: TuiPluginApi
151
+ size: "medium" | "large" | "xlarge"
152
+ children: JSX.Element
153
+ }): JSX.Element {
154
+ createEffect(() => {
155
+ props.api.ui.dialog.setSize(props.size)
156
+ })
157
+ return <>{props.children}</>
158
+ }
159
+
160
+ /**
161
+ * The single entry point for every popup. All openers below route through
162
+ * this; nothing else in the plugin calls api.ui.dialog directly.
163
+ */
164
+ function openDialog(
165
+ api: TuiPluginApi,
166
+ size: "medium" | "large" | "xlarge",
167
+ render: () => JSX.Element,
168
+ ): void {
169
+ try {
170
+ api.ui.dialog.replace(() => <SizedDialog api={api} size={size}>{render()}</SizedDialog>)
171
+ } catch {
172
+ // host without dialog stack
173
+ }
174
+ }
175
+
176
+ /** OpenTUI markdown styles from the host theme — same markup.* keys OpenCode uses. */
177
+ function markdownStyleFromTheme(theme: TuiTheme): ReturnType<typeof SyntaxStyle.fromStyles> | null {
178
+ try {
179
+ const c = theme.current
180
+ return SyntaxStyle.fromStyles({
181
+ default: { fg: c.markdownText || c.text },
182
+ "markup.heading": { fg: c.markdownHeading, bold: true },
183
+ "markup.heading.1": { fg: c.markdownHeading, bold: true, underline: true },
184
+ "markup.heading.2": { fg: c.markdownHeading, bold: true },
185
+ "markup.heading.3": { fg: c.markdownHeading, bold: true },
186
+ "markup.heading.4": { fg: c.markdownHeading, bold: true },
187
+ "markup.heading.5": { fg: c.markdownHeading, bold: true },
188
+ "markup.heading.6": { fg: c.markdownHeading, bold: true },
189
+ "markup.bold": { fg: c.markdownStrong, bold: true },
190
+ "markup.strong": { fg: c.markdownStrong, bold: true },
191
+ "markup.italic": { fg: c.markdownEmph, italic: true },
192
+ "markup.list": { fg: c.markdownListItem },
193
+ "markup.list.checked": { fg: c.success },
194
+ "markup.list.unchecked": { fg: c.textMuted },
195
+ "markup.quote": { fg: c.markdownBlockQuote, italic: true },
196
+ "markup.raw": { fg: c.markdownCode },
197
+ "markup.raw.block": { fg: c.markdownCodeBlock || c.markdownCode },
198
+ "markup.raw.inline": { fg: c.markdownCode },
199
+ "markup.link": { fg: c.markdownLink, underline: true },
200
+ "markup.link.label": { fg: c.markdownLinkText, underline: true },
201
+ "markup.link.url": { fg: c.markdownLink, underline: true },
202
+ "markup.strikethrough": { fg: c.textMuted },
203
+ "markup.underline": { fg: c.text, underline: true },
204
+ conceal: { fg: c.textMuted },
205
+ keyword: { fg: c.syntaxKeyword, bold: true },
206
+ string: { fg: c.syntaxString },
207
+ comment: { fg: c.syntaxComment, italic: true },
208
+ function: { fg: c.syntaxFunction },
209
+ variable: { fg: c.syntaxVariable },
210
+ number: { fg: c.syntaxNumber },
211
+ type: { fg: c.syntaxType },
212
+ operator: { fg: c.syntaxOperator },
213
+ punctuation: { fg: c.syntaxPunctuation },
214
+ })
215
+ } catch {
216
+ return null
217
+ }
218
+ }
219
+
220
+ function PreviewBody(props: {
221
+ text: string
222
+ pretty: boolean
223
+ colors: ThemeColors
224
+ syntaxStyle: ReturnType<typeof SyntaxStyle.fromStyles> | null
225
+ }): JSX.Element {
226
+ if (props.pretty && props.syntaxStyle) {
227
+ return (
228
+ <markdown syntaxStyle={props.syntaxStyle} conceal content={props.text} width="100%" />
229
+ )
230
+ }
231
+ return (
232
+ <For each={props.text.split("\n")}>
233
+ {(line) => <text fg={props.colors.text}>{line || " "}</text>}
234
+ </For>
235
+ )
236
+ }
237
+
238
+ function PreviewDialog(props: {
239
+ api: TuiPluginApi
240
+ colors: ThemeColors
241
+ heading?: string
242
+ title: string
243
+ subtitle: string | null
244
+ pretty: boolean
245
+ text: string
246
+ truncated: boolean
247
+ copyLabel?: string
248
+ copyValue?: string | null
249
+ syntaxStyle: ReturnType<typeof SyntaxStyle.fromStyles> | null
250
+ }): JSX.Element {
251
+ const dimensions = useTerminalDimensions()
252
+ const showPath = () => Boolean(props.subtitle && props.subtitle !== props.title)
253
+ const extra = () => (showPath() ? 1 : 0)
254
+ // Host dialog has paddingTop=height/4, leaving 3/4 of height. The 0.75
255
+ // ratio in previewViewportRows matches exactly — do not pass tall=true here.
256
+ const bodyHeight = createMemo(() =>
257
+ previewViewportRows(dimensions().height, extra()),
258
+ )
259
+ return (
260
+ <DialogPad>
261
+ <box flexDirection="column" gap={0} flexShrink={0}>
262
+ <DialogHeader
263
+ title={[props.heading ?? "Preview", props.title].filter(Boolean).join(" · ")}
264
+ colors={props.colors}
265
+ onClose={() => closeDialog(props.api)}
266
+ />
267
+ <Show when={showPath()}>
268
+ <DetailLine text={props.subtitle!} colors={props.colors} muted />
269
+ </Show>
270
+ </box>
271
+ <scrollbox scrollY focused height={bodyHeight()} maxHeight={bodyHeight()}>
272
+ <PreviewBody
273
+ text={props.text}
274
+ pretty={props.pretty}
275
+ colors={props.colors}
276
+ syntaxStyle={props.syntaxStyle}
277
+ />
278
+ <Show when={props.truncated}>
279
+ <DetailLine text="… truncated" colors={props.colors} muted />
280
+ </Show>
281
+ </scrollbox>
282
+ <box flexDirection="column" gap={0} paddingTop={1} flexShrink={0}>
283
+ <ActionRow
284
+ label={props.copyLabel ?? "Copy path"}
285
+ colors={props.colors}
286
+ disabled={!((props.copyValue ?? props.subtitle) || "").trim()}
287
+ onPick={() => {
288
+ if (props.copyValue != null) copyPlain(props.api, props.copyValue)
289
+ else copyRelativePath(props.api, props.subtitle)
290
+ }}
291
+ />
292
+ </box>
293
+ </DialogPad>
294
+ )
295
+ }
296
+
297
+ function copyRelativePath(api: TuiPluginApi, rel: string | null): void {
298
+ if (!rel) {
299
+ toast(api, "No path to copy", "warning")
300
+ return
301
+ }
302
+ void copyText(rel).then((ok) => {
303
+ if (ok) toast(api, "Copied relative path", "success")
304
+ else toast(api, "Copy failed — select path in the dialog manually", "warning")
305
+ })
306
+ }
307
+
308
+ function copyPlain(api: TuiPluginApi, text: string): void {
309
+ void copyText(text).then((ok) => {
310
+ if (ok) toast(api, "Copied log", "success")
311
+ else toast(api, "Copy failed — select the log in the dialog manually", "warning")
312
+ })
313
+ }
314
+
315
+ export function openTextPreview(
316
+ api: TuiPluginApi,
317
+ colors: ThemeColors,
318
+ title: string,
319
+ subtitle: string | null,
320
+ absPath: string,
321
+ ): void {
322
+ const preview = readTextPreview(absPath)
323
+ if (!preview) {
324
+ toast(api, "Cannot preview this file", "warning")
325
+ return
326
+ }
327
+ const pretty = isMarkdownPath(absPath)
328
+ const syntaxStyle = pretty ? markdownStyleFromTheme(api.theme) : null
329
+ openDialog(api, "xlarge", () => (
330
+ <PreviewDialog
331
+ api={api}
332
+ colors={colors}
333
+ title={title}
334
+ subtitle={subtitle}
335
+ pretty={pretty}
336
+ text={preview.text}
337
+ truncated={preview.truncated}
338
+ syntaxStyle={syntaxStyle}
339
+ />
340
+ ))
341
+ }
342
+
343
+ /**
344
+ * A touched file's actions as a native host DialogSelect: Preview opens the
345
+ * file when the type can be rendered (markdown straight to a preview, other
346
+ * previewable types through `canPreviewPath`), Copy relative path copies the
347
+ * project-relative path. The row's letter + diff stats stay in the picker
348
+ * title; disabled options carry the reason as their description.
349
+ */
350
+ export function openFileDetail(
351
+ api: TuiPluginApi,
352
+ file: FileView,
353
+ projectRoot: string | readonly string[] | null | undefined,
354
+ colors: ThemeColors,
355
+ ): void {
356
+ const found = resolveProjectFile(projectRoot, file.id)
357
+ const rel = found?.rel ?? null
358
+ const abs = found?.abs ?? null
359
+ if (isMarkdownPath(file.id) || isMarkdownPath(file.name)) {
360
+ if (abs) {
361
+ openTextPreview(api, colors, file.name, rel, abs)
362
+ return
363
+ }
364
+ toast(api, "File not found on disk", "warning")
365
+ }
366
+ const canPreview = Boolean(abs && canPreviewPath(abs))
367
+ const letter = file.letter ? `[${file.letter}]` : ""
368
+ const diff = formatDiffStat(file.additions, file.deletions)
369
+ const header = diff ? `${file.name} ${letter} ${diff}`.trim() : `${file.name} ${letter}`.trim()
370
+
371
+ type FileAction = "preview" | "copy"
372
+ const options: TuiDialogSelectOption<FileAction>[] = [
373
+ {
374
+ title: "Preview",
375
+ value: "preview",
376
+ disabled: !canPreview,
377
+ description: canPreview ? (rel ?? undefined) : (abs ? "Cannot preview this file type" : "File not found on disk"),
378
+ onSelect: () => {
379
+ if (abs) openTextPreview(api, colors, file.name, rel, abs)
380
+ },
381
+ },
382
+ {
383
+ title: "Copy relative path",
384
+ value: "copy",
385
+ disabled: !rel,
386
+ description: rel ?? "File not found on disk",
387
+ onSelect: () => copyRelativePath(api, rel),
388
+ },
389
+ ]
390
+ openDialog(api, "medium", () => <api.ui.DialogSelect title={header} options={options} />)
391
+ }
392
+
393
+ export function openToolDetail(api: TuiPluginApi, tool: ToolView, colors: ThemeColors): void {
394
+ const dur =
395
+ tool.status === TOOL_STATUS_RUNNING && tool.startedAt != null
396
+ ? formatDuration(Math.max(0, Date.now() - tool.startedAt))
397
+ : formatDuration(tool.durationMs)
398
+ const header = [tool.tool, dur, tool.status].filter(Boolean).join(" · ")
399
+ openDialog(api, "medium", () => (
400
+ <DialogPad>
401
+ <DialogHeader title={header} colors={colors} onClose={() => closeDialog(api)} />
402
+ {divider(colors)}
403
+ <DetailLine text={`Label: ${tool.name}`} colors={colors} />
404
+ <DetailLine text={`Tool: ${tool.tool}`} colors={colors} muted />
405
+ <DetailLine text={`Started: ${formatWhen(tool.startedAt)}`} colors={colors} muted />
406
+ <DetailLine text={`Duration: ${dur || "—"}`} colors={colors} muted />
407
+ <DetailLine text={`Status: ${tool.status}`} colors={colors} muted />
408
+ </DialogPad>
409
+ ))
410
+ }
411
+
412
+ /**
413
+ * Pending-approval menu as a native host DialogSelect: Navigate to session
414
+ * jumps to the session that wrote the plan, and the Plan options group holds
415
+ * Approve — sending an `ok` reply to that same session — plus the three
416
+ * start-work rows that launch the OMO plan in the current session
417
+ * (plain / --make-pr / --ship); Docs opens the draft as a preview. A muted
418
+ * hint is shown under the session-bound rows when no session is found.
419
+ * Searchable + keyboard-navigable — the same picker the host uses. The
420
+ * drafting rows use the same picker with the plan options hidden and the
421
+ * docs row relabelled via `docsLabel` ("Preview plan file").
422
+ */
423
+ type ApprovalAction = "continue" | "approve" | "docs" | StartWorkMode
424
+
425
+ export function openApprovalDialog(
426
+ api: TuiPluginApi,
427
+ opts: {
428
+ title: string
429
+ sessionId: string | null
430
+ /** Muted reason shown under the session-bound rows when it is disabled. */
431
+ continueHint?: string | null
432
+ onContinue: (sessionId: string) => void
433
+ onApprove?: (sessionId: string) => void
434
+ onStartWork?: (mode: StartWorkMode) => void
435
+ onDocs: () => void
436
+ /** Hide the Approve row (a ready-to-start / finished plan is not approvable). */
437
+ showApprove?: boolean
438
+ /** Hide the three start-work rows (a finished plan is not startable). */
439
+ showStartWork?: boolean
440
+ /** Label for the Docs row — a draft picker shows "Preview plan file". */
441
+ docsLabel?: string
442
+ },
443
+ ): void {
444
+ const showApprove = opts.showApprove ?? true
445
+ const showStartWork = opts.showStartWork ?? true
446
+ const docsLabel = opts.docsLabel ?? "Docs"
447
+ const options: TuiDialogSelectOption<ApprovalAction>[] = [
448
+ {
449
+ title: "Navigate to session",
450
+ value: "continue",
451
+ description: opts.sessionId ? undefined : (opts.continueHint ?? "Navigate to session unavailable"),
452
+ disabled: !opts.sessionId,
453
+ onSelect: () => {
454
+ if (opts.sessionId) {
455
+ closeDialog(api)
456
+ opts.onContinue(opts.sessionId)
457
+ }
458
+ },
459
+ },
460
+ { title: docsLabel, value: "docs", onSelect: opts.onDocs },
461
+ ]
462
+ if (showApprove) {
463
+ options.push({
464
+ title: "Approve",
465
+ value: "approve",
466
+ category: "Plan options",
467
+ description: opts.sessionId ? undefined : (opts.continueHint ?? "Approve unavailable"),
468
+ disabled: !opts.sessionId,
469
+ onSelect: () => {
470
+ if (opts.sessionId && opts.onApprove) {
471
+ closeDialog(api)
472
+ opts.onApprove(opts.sessionId)
473
+ toast(api, "Approved — ok sent", "success")
474
+ }
475
+ },
476
+ })
477
+ }
478
+ if (showStartWork && opts.onStartWork) {
479
+ options.push(
480
+ {
481
+ title: startWorkCommand(START_WORK_PLAIN, opts.title),
482
+ value: START_WORK_PLAIN,
483
+ category: "Plan options",
484
+ onSelect: () => {
485
+ closeDialog(api)
486
+ opts.onStartWork?.(START_WORK_PLAIN)
487
+ },
488
+ },
489
+ {
490
+ title: startWorkCommand(START_WORK_MAKE_PR, opts.title),
491
+ value: START_WORK_MAKE_PR,
492
+ category: "Plan options",
493
+ onSelect: () => {
494
+ closeDialog(api)
495
+ opts.onStartWork?.(START_WORK_MAKE_PR)
496
+ },
497
+ },
498
+ {
499
+ title: startWorkCommand(START_WORK_SHIP, opts.title),
500
+ value: START_WORK_SHIP,
501
+ category: "Plan options",
502
+ onSelect: () => {
503
+ closeDialog(api)
504
+ opts.onStartWork?.(START_WORK_SHIP)
505
+ },
506
+ },
507
+ )
508
+ }
509
+ openDialog(api, "medium", () => <api.ui.DialogSelect title={opts.title} options={options} />)
510
+ }
511
+
512
+ type QuestionAction = "continue" | "dismiss"
513
+
514
+ export function openQuestionDialog(
515
+ api: TuiPluginApi,
516
+ opts: {
517
+ title: string
518
+ sessionId: string
519
+ onNavigate: (sessionId: string) => void
520
+ onDismiss: () => void
521
+ },
522
+ ): void {
523
+ const options: TuiDialogSelectOption<QuestionAction>[] = [
524
+ {
525
+ title: "Navigate to session",
526
+ value: "continue",
527
+ onSelect: () => {
528
+ closeDialog(api)
529
+ opts.onNavigate(opts.sessionId)
530
+ },
531
+ },
532
+ {
533
+ title: "Dismiss",
534
+ value: "dismiss",
535
+ description: "Hide this question — remembered for this project.",
536
+ onSelect: () => {
537
+ closeDialog(api)
538
+ opts.onDismiss()
539
+ toast(api, "Dismissed", "success")
540
+ },
541
+ },
542
+ ]
543
+ openDialog(api, "medium", () => <api.ui.DialogSelect title={opts.title} options={options} />)
544
+ }
545
+
546
+ function formatBytes(n: number): string {
547
+ if (!Number.isFinite(n) || n < 0) return "—"
548
+ if (n < 1024) return `${n} B`
549
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
550
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
551
+ }
552
+
553
+ /**
554
+ * An OMO document. Text opens straight as a preview — the same one a Files
555
+ * `.md` row gets. Anything else (a screenshot in evidence) stays a metadata
556
+ * sheet: we list proof, we do not render it.
557
+ */
558
+ export function openDocDetail(
559
+ api: TuiPluginApi,
560
+ doc: DocView,
561
+ projectRoot: string | readonly string[] | null | undefined,
562
+ colors: ThemeColors,
563
+ ): void {
564
+ const found = resolveProjectFile(projectRoot, doc.rel)
565
+ if (found && canPreviewPath(found.abs)) {
566
+ openTextPreview(api, colors, doc.name, found.rel, found.abs)
567
+ return
568
+ }
569
+ if (!found) toast(api, "File not found on disk", "warning")
570
+ const age = doc.updatedAt != null ? formatAge(Math.max(0, Date.now() - doc.updatedAt)) : ""
571
+
572
+ openDialog(api, "medium", () => (
573
+ <DialogPad>
574
+ <DialogHeader title={doc.name} colors={colors} onClose={() => closeDialog(api)} />
575
+ {divider(colors)}
576
+ <DetailLine text={`Kind: ${DOC_KIND_LABEL[doc.kind]}`} colors={colors} />
577
+ <DetailLine
578
+ text={`Size: ${formatBytes(doc.sizeBytes)} · Updated: ${age ? `${age} ago` : "—"}`}
579
+ colors={colors}
580
+ muted
581
+ />
582
+ <DetailLine text={doc.rel} colors={colors} muted />
583
+ <box flexDirection="column" gap={0} paddingTop={1}>
584
+ <ActionRow
585
+ label="Copy path"
586
+ colors={colors}
587
+ onPick={() => copyRelativePath(api, doc.rel)}
588
+ />
589
+ </box>
590
+ </DialogPad>
591
+ ))
592
+ }
593
+
594
+ export type FileListEntry = {
595
+ name: string
596
+ description?: string
597
+ onSelect: () => void
598
+ }
599
+
600
+ /**
601
+ * A searchable list of files in a native host DialogSelect — the "view all"
602
+ * action for a file group. Each entry opens its own detail/preview on pick;
603
+ * the entry owns the type (a draft, a plan, a touched file), the dialog only
604
+ * lists a name and a muted description.
605
+ */
606
+ export function openFileListDialog(
607
+ api: TuiPluginApi,
608
+ title: string,
609
+ entries: readonly FileListEntry[],
610
+ ): void {
611
+ const options: TuiDialogSelectOption<string>[] = entries.map((e, i) => ({
612
+ title: e.name,
613
+ value: String(i),
614
+ description: e.description,
615
+ onSelect: e.onSelect,
616
+ }))
617
+ openDialog(api, "medium", () => <api.ui.DialogSelect title={title} options={options} />)
618
+ }
619
+
620
+ /** Dated column log from opencode.db — writes a sidecar file, then shows it. */
621
+ export function openPerfLog(
622
+ api: TuiPluginApi,
623
+ colors: ThemeColors,
624
+ opts: { dbPath: string; sessionId: string; turns: number; kind: PerfLogKind; now?: number; toolFilter?: string },
625
+ ): void {
626
+ const log = readPerfLog({ ...opts, now: opts.now ?? Date.now(), toolFilter: opts.toolFilter })
627
+ if (!log) {
628
+ toast(api, "No perf stats to log", "warning")
629
+ return
630
+ }
631
+ try {
632
+ openDialog(api, "xlarge", () => (
633
+ <PreviewDialog
634
+ api={api}
635
+ colors={colors}
636
+ heading="Log"
637
+ title={log.title}
638
+ subtitle={log.fileName}
639
+ pretty={false}
640
+ text={log.text}
641
+ truncated={false}
642
+ copyLabel="Copy log"
643
+ copyValue={log.text}
644
+ syntaxStyle={null}
645
+ />
646
+ ))
647
+ } catch {
648
+ toast(api, "Cannot open perf log", "warning")
649
+ }
650
+ }
651
+
652
+ /**
653
+ * "Perf charts" popup: a stats line, wait histogram, tool-share gauge,
654
+ * cache-hit donut, and two taller trend charts. Every chart string from the
655
+ * charts module is ANSI-free (colours are stripped there), so they render as
656
+ * plain `<text fg>` lines. Soft-fails when there is no trend to chart.
657
+ */
658
+ export function openPerfCharts(
659
+ api: TuiPluginApi,
660
+ colors: ThemeColors,
661
+ opts: { perf: PerfSnapshot; currentSessionId: string },
662
+ ): void {
663
+ const wait = opts.perf.trend.map((p) => p.waitMs)
664
+ const rate = opts.perf.trend.map((p) => p.tokensPerSec)
665
+ const allNull = (xs: Array<number | null>): boolean => xs.every((v) => v == null)
666
+ if (opts.perf.trend.length === 0 || (allNull(wait) && allNull(rate))) {
667
+ toast(api, "No perf trend to chart", "warning")
668
+ return
669
+ }
670
+ const toolShare =
671
+ opts.perf.totals.wallMs > 0 ? opts.perf.totals.phases.tool / opts.perf.totals.wallMs : null
672
+ const cacheHit = Math.max(0, Math.min(1, Number(opts.perf.totals.cacheHit) || 0))
673
+ const title = opts.perf.models[0]?.model ?? ""
674
+ const subtitle = opts.currentSessionId
675
+ const showSubtitle = Boolean(subtitle) && subtitle !== title
676
+
677
+ openDialog(api, "xlarge", () => (
678
+ <DialogPad>
679
+ <DialogHeader
680
+ title={["Perf charts", title].filter(Boolean).join(" · ")}
681
+ colors={colors}
682
+ onClose={() => closeDialog(api)}
683
+ />
684
+ <Show when={showSubtitle}>
685
+ <DetailLine text={subtitle} colors={colors} muted />
686
+ </Show>
687
+ <text fg={colors.text}>{perfStatLine("wait", wait, formatDuration)}</text>
688
+ <text fg={colors.text}>{perfStatLine("tok/s", rate, formatRate)}</text>
689
+ {divider(colors)}
690
+ <For each={waitHistogram(wait).split("\n")}>
691
+ {(line) => <text fg={colors.text}>{line || " "}</text>}
692
+ </For>
693
+ <For each={shareGauge(toolShare, { label: "tool" }).split("\n")}>
694
+ {(line) => <text fg={colors.primary || colors.text}>{line || " "}</text>}
695
+ </For>
696
+ <For each={shareDonut(cacheHit, { label: "cache" }).split("\n")}>
697
+ {(line) => <text fg={colors.primary || colors.text}>{line || " "}</text>}
698
+ </For>
699
+ <DetailLine text="wait" colors={colors} muted />
700
+ <For each={asciiTrend(wait, { width: 60, height: 6 }).split("\n")}>
701
+ {(line) => <text fg={colors.warning || colors.text}>{line || " "}</text>}
702
+ </For>
703
+ <DetailLine text="tok/s" colors={colors} muted />
704
+ <For each={asciiTrend(rate, { width: 60, height: 6 }).split("\n")}>
705
+ {(line) => <text fg={colors.success}>{line || " "}</text>}
706
+ </For>
707
+ </DialogPad>
708
+ ))
709
+ }
710
+
711
+ /**
712
+ * The fullscreen realtime Perf modal. Liveness does not rely on Solid
713
+ * reactivity crossing the `api.ui.dialog.replace` boundary: a `TICK_MS`
714
+ * interval bumps a `version` signal and calls `api.renderer.requestRender()`,
715
+ * and the `createMemo(version)` recomputes the chart lines every tick — the
716
+ * `version()` read inside the memo is what re-renders the body. Category
717
+ * selection is dialog-local (never persisted to kv). One ASCII trend at a
718
+ * time (`asciiTrend`, `RT_DIALOG_CHART_ROWS` tall): the category tabs pick
719
+ * the family, the row labels under them pick the series, same as the sidebar.
720
+ * The sidebar's `C` on the OES row opens this dialog; the dialog itself has no `C`.
721
+ */
722
+ function RealtimeChartsDialog(props: {
723
+ api: TuiPluginApi
724
+ colors: ThemeColors
725
+ getTimeline: () => StatRealtimeTimeline
726
+ initialTabId: StatRealtimeTabId
727
+ initialRowKey: StatRealtimeSeriesKey
728
+ }): JSX.Element {
729
+ const dimensions = useTerminalDimensions()
730
+ const [selectedTab, setSelectedTab] = createSignal<StatRealtimeTabId>(props.initialTabId)
731
+ const [selectedRow, setSelectedRow] = createSignal<StatRealtimeSeriesKey>(props.initialRowKey)
732
+ const [version, setVersion] = createSignal(0)
733
+
734
+ const interval = setInterval(() => {
735
+ setVersion((v) => v + 1)
736
+ try {
737
+ props.api.renderer.requestRender()
738
+ } catch {
739
+ // teardown
740
+ }
741
+ }, TICK_MS)
742
+ onCleanup(() => clearInterval(interval))
743
+
744
+ const dialogChartWidth = () => dialogInnerWidth(dimensions().width, hostDialogSize(props.api))
745
+ const activeTab = () => realtimeTab(STAT_REALTIME_BLOCK.tabs, selectedTab())
746
+ const activeRow = () => realtimeRow(activeTab(), selectedRow())
747
+
748
+ const pickTab = (id: StatRealtimeTabId): void => {
749
+ setSelectedTab(id)
750
+ setSelectedRow(realtimeTab(STAT_REALTIME_BLOCK.tabs, id).rows[0]!.key)
751
+ }
752
+
753
+ const lines = createMemo(() => {
754
+ version()
755
+ const history = props.getTimeline().getTimeline()
756
+ const row = activeRow()
757
+ return realtimeSeriesLines(
758
+ [
759
+ {
760
+ key: row.key,
761
+ label: row.label,
762
+ unit: row.unit,
763
+ values:
764
+ row.key === "avg"
765
+ ? smoothSeries(seriesValues(history, row.read), 5)
766
+ : seriesValues(history, row.read),
767
+ },
768
+ ],
769
+ { width: dialogChartWidth(), height: RT_DIALOG_CHART_ROWS },
770
+ )
771
+ })
772
+
773
+ const entry = () => lines()[0]
774
+ const fg = () => props.colors.success || props.colors.text
775
+
776
+ return (
777
+ <DialogPad>
778
+ <DialogHeader
779
+ title="Realtime charts"
780
+ colors={props.colors}
781
+ onClose={() => closeDialog(props.api)}
782
+ />
783
+ <box flexDirection="row" gap={1}>
784
+ <For each={STAT_REALTIME_BLOCK.tabs}>
785
+ {(tab) => (
786
+ <ClickText
787
+ fg={tab.id === selectedTab() ? props.colors.primary || props.colors.text : props.colors.textMuted}
788
+ bold={tab.id === selectedTab()}
789
+ underline
790
+ onMouseUp={() => pickTab(tab.id)}
791
+ >
792
+ {tab.label}
793
+ </ClickText>
794
+ )}
795
+ </For>
796
+ </box>
797
+ <box flexDirection="row" gap={1}>
798
+ <For each={activeTab().rows}>
799
+ {(row) => (
800
+ <ClickText
801
+ fg={row.key === activeRow().key ? props.colors.primary || props.colors.text : props.colors.textMuted}
802
+ bold={row.key === activeRow().key}
803
+ underline
804
+ onMouseUp={() => setSelectedRow(row.key)}
805
+ >
806
+ {row.label}
807
+ </ClickText>
808
+ )}
809
+ </For>
810
+ </box>
811
+ <Show when={entry()}>
812
+ <box flexDirection="column" gap={0} flexShrink={0}>
813
+ <text fg={props.colors.textMuted}>{`${entry()!.label} · ${entry()!.unit}`}</text>
814
+ <box flexDirection="column" height={RT_DIALOG_CHART_ROWS} flexShrink={0}>
815
+ <For each={entry()!.lines}>
816
+ {(line) => <text fg={fg()}>{line || " "}</text>}
817
+ </For>
818
+ </box>
819
+ </box>
820
+ </Show>
821
+ </DialogPad>
822
+ )
823
+ }
824
+
825
+ export function openRealtimeCharts(
826
+ api: TuiPluginApi,
827
+ colors: ThemeColors,
828
+ opts: {
829
+ getTimeline: () => StatRealtimeTimeline
830
+ initialTabId: StatRealtimeTabId
831
+ initialRowKey: StatRealtimeSeriesKey
832
+ },
833
+ ): void {
834
+ openDialog(api, "xlarge", () => (
835
+ <RealtimeChartsDialog
836
+ api={api}
837
+ colors={colors}
838
+ getTimeline={opts.getTimeline}
839
+ initialTabId={opts.initialTabId}
840
+ initialRowKey={opts.initialRowKey}
841
+ />
842
+ ))
843
+ }