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.
- package/.oesignore +17 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/oes.json +11 -0
- package/package.json +100 -0
- package/scripts/install-git-hooks.mjs +29 -0
- package/src/pware.oc.core/constants/index.ts +18 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
- package/src/pware.oc.core/git/index.ts +7 -0
- package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
- package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
- package/src/pware.oc.core/index.ts +18 -0
- package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
- package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
- package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
- package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
- package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
- package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
- package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
- package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
- package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
- package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
- package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
- package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
- package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
- package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
- package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
- package/src/pware.oc.omo/constants/index.ts +15 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
- package/src/pware.oc.omo/index.ts +7 -0
- package/src/pware.oc.omo/resolver/index.ts +19 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
- package/src/pware.oc.opencode/constants/index.ts +10 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
- package/src/pware.oc.opencode/index.ts +8 -0
- package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
- package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
- package/src/pware.oc.opencode/resolver/index.ts +170 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
- package/src/pware.oc.perf/index.ts +14 -0
- package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
- package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
- package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
- package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
- package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
- package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
- package/src/pware.oc.runtime/index.ts +13 -0
- package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
- package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
- package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
- package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
- package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
- package/src/pware.oc.runtime/resolver/index.ts +224 -0
- package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
- package/src/pware.oc.ui/index.ts +12 -0
- package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
- package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
- package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
- package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
- package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
- package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
- package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
- 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
|
+
}
|