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,526 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * pware.oc.ui.sections
4
+ *
5
+ * Shared sidebar primitives on top of chrome: kv-persisted fold state, the
6
+ * foldable section scaffold every tab reuses, the base row renderer and its
7
+ * budget-sliced list (`RowList`), and the brand + tabs + active-panel column.
8
+ *
9
+ * Row budgets stay in the caller — these components render, they do not
10
+ * re-decide how many rows fit (`layout.packSections` still owns that).
11
+ */
12
+ import { createMemo, createSignal, Show, For, type Accessor, type JSX } from "solid-js"
13
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
14
+ import {
15
+ BrandTabs,
16
+ ClickText,
17
+ DiffStat,
18
+ FoldHeader,
19
+ kvRead,
20
+ kvWrite,
21
+ toneColor,
22
+ type ContextAction,
23
+ type ThemeColors,
24
+ } from "./pware.oc.ui.chrome.js"
25
+ import {
26
+ defaultBodyTone,
27
+ directionGlyph,
28
+ sinPulseAlpha,
29
+ spinnerFrame,
30
+ stateGlyph,
31
+ type GlyphSpec,
32
+ type ToneKey,
33
+ } from "./pware.oc.ui.glyphs.js"
34
+ import { profile } from "../pware.oc.core/pware.oc.core.debug.js"
35
+ import { moreRevealVisible, sliceShown } from "../pware.oc.core/pware.oc.core.layout.js"
36
+ import { clipWidth, strWidth } from "../pware.oc.core/pware.oc.core.width.js"
37
+ import { oesBarParts, statusBarLine, type TabStatus } from "../pware.oc.core/pware.oc.core.status.js"
38
+ import {
39
+ formatTokens,
40
+ formatUsd,
41
+ type AgentMark,
42
+ type FlowDir,
43
+ } from "../pware.oc.core/pware.oc.core.pulse.js"
44
+ import {
45
+ ROW_KIND_AGENT,
46
+ ROW_KIND_FILE,
47
+ ROW_KIND_GROUP,
48
+ type RowKind,
49
+ } from "../pware.oc.core/constants/pware.oc.core.constants.rowKind.js"
50
+ import { MARK_READY } from "../pware.oc.core/constants/pware.oc.core.constants.pulse.js"
51
+ import { formatDiffStat, shortFileName } from "../pware.oc.opencode/pware.oc.opencode.files.js"
52
+
53
+ export type { RowKind }
54
+
55
+ /** Fold state persisted in the host kv store. `open` is true when unfolded. */
56
+ export function useFold(
57
+ api: TuiPluginApi,
58
+ key: string,
59
+ opts?: { after?: () => void; defaultOpen?: boolean },
60
+ ): { open: Accessor<boolean>; toggle: () => void } {
61
+ // The kv store keeps the legacy "folded" flag (true = folded), so existing
62
+ // user state survives the refactor; `open` is its inverse. `defaultOpen`
63
+ // decides the state when the user has never toggled this fold (no kv entry) —
64
+ // e.g. the My work "Errors" group starts collapsed.
65
+ const defaultOpen = opts?.defaultOpen ?? true
66
+ const [open, setOpen] = createSignal(defaultOpen ? !kvRead(api, key, false) : kvRead(api, key, true))
67
+ const toggle = (): void => {
68
+ setOpen((prev) => {
69
+ const next = !prev
70
+ kvWrite(api, key, !next)
71
+ return next
72
+ })
73
+ opts?.after?.()
74
+ }
75
+ return { open, toggle }
76
+ }
77
+
78
+ /** Reveal state for a `RowList` "… +N more" line. */
79
+ export type RevealState = { more: Accessor<number>; reveal: () => void }
80
+
81
+ /** Rows revealed per click of a "… +N more" line. */
82
+ export function useReveal(step: number): RevealState {
83
+ const [more, setMore] = createSignal(0)
84
+ return { more, reveal: () => setMore((n) => n + step) }
85
+ }
86
+
87
+ /** The inline trend is a background hint, and so is a reading of zero. */
88
+ const RATE_BAR_OPACITY = 0.3
89
+
90
+ /**
91
+ * The global OES status bar: `OES {glyph} {label}` or, when quiet,
92
+ * `OES {8-col bar} {tok/s}`. The trend always renders at
93
+ * {@link RATE_BAR_OPACITY}, and an idle `0 tok/s` fades with it — only a rate
94
+ * that is actually moving gets full contrast. Loading reads `glyphFrame` only
95
+ * in this leaf.
96
+ */
97
+ export function OesStatusRow(props: {
98
+ status: TabStatus
99
+ colors: ThemeColors
100
+ glyphFrame: () => number
101
+ /** 8-column bar from the live token-rate window — dropped while a status label shows. */
102
+ rateBar?: string
103
+ /** Live tokens per second; `0` / `null` render faded, `undefined` hides the reading. */
104
+ rate?: number | null
105
+ }): JSX.Element {
106
+ const line = createMemo(() => statusBarLine(props.status))
107
+ const glyph = createMemo(() => {
108
+ if (line().tone === "loading") return spinnerFrame(props.glyphFrame())
109
+ if (line().tone === "error") return "×"
110
+ return line().label ? "•" : ""
111
+ })
112
+ const fg = createMemo(() =>
113
+ line().tone === "error"
114
+ ? props.colors.error || props.colors.text
115
+ : line().tone === "loading"
116
+ ? props.colors.primary || props.colors.text
117
+ : props.colors.text,
118
+ )
119
+ const parts = createMemo(() => oesBarParts(line().label, glyph(), props.rateBar, props.rate))
120
+ return (
121
+ <box flexDirection="row">
122
+ <text fg={fg()}>{parts().head}</text>
123
+ <Show when={parts().bar}>
124
+ <text fg={fg()} opacity={RATE_BAR_OPACITY}>
125
+ {parts().bar}
126
+ </text>
127
+ </Show>
128
+ <Show when={parts().rate}>
129
+ <text fg={fg()} opacity={parts().rateIdle ? RATE_BAR_OPACITY : 1}>
130
+ {parts().rate}
131
+ </text>
132
+ </Show>
133
+ </box>
134
+ )
135
+ }
136
+
137
+ /** Clickable truncation line: "… +N more", or "… less" in expand-all (toggle) mode. */
138
+ export function MoreReveal(props: {
139
+ hidden: number
140
+ colors: ThemeColors
141
+ onReveal: () => void
142
+ /** Expand-all mode: true keeps the line visible as "… less" after expanding. */
143
+ expanded?: boolean
144
+ onToggle?: () => void
145
+ }): JSX.Element | null {
146
+ return (
147
+ <Show when={moreRevealVisible(props.hidden, props.expanded)}>
148
+ <Show
149
+ when={props.expanded}
150
+ fallback={
151
+ <box onMouseUp={props.onReveal}>
152
+ <ClickText fg={props.colors.textMuted} underline>
153
+ {`… +${props.hidden} more`}
154
+ </ClickText>
155
+ </box>
156
+ }
157
+ >
158
+ <box onMouseUp={props.onToggle}>
159
+ <ClickText fg={props.colors.textMuted} underline>
160
+ … less
161
+ </ClickText>
162
+ </box>
163
+ </Show>
164
+ </Show>
165
+ )
166
+ }
167
+
168
+ /** What a "… +N more" / "… less" line should do. */
169
+ export type RowListMore = {
170
+ onReveal: () => void
171
+ expanded?: boolean
172
+ onToggle?: () => void
173
+ }
174
+
175
+ /**
176
+ * The one place a row list is cut to a budget and the revealer is drawn —
177
+ * `sliceShown` never spends a content row on the note. Every list in the panel
178
+ * (sections, groups, OMO, Perf) renders through here.
179
+ */
180
+ export function RowList<T>(props: {
181
+ items: readonly T[]
182
+ /** Content rows shown before the "… +N more" revealer. */
183
+ budget: number
184
+ colors: ThemeColors
185
+ renderItem: (item: T, index: Accessor<number>) => JSX.Element
186
+ more?: RowListMore
187
+ }): JSX.Element {
188
+ const cut = () => sliceShown(props.items, props.budget)
189
+ return (
190
+ <>
191
+ <For each={cut().rows}>{(item, i) => props.renderItem(item, i)}</For>
192
+ <MoreReveal
193
+ hidden={cut().hidden}
194
+ colors={props.colors}
195
+ onReveal={props.more?.onReveal ?? (() => {})}
196
+ expanded={props.more?.expanded}
197
+ onToggle={props.more?.onToggle}
198
+ />
199
+ </>
200
+ )
201
+ }
202
+
203
+ export type RowData = {
204
+ kind: RowKind
205
+ /** Live state source — drives the default state glyph and body tone. */
206
+ mark?: AgentMark
207
+ name: string
208
+ /** Overrides the primary glyph entirely (char + tone). */
209
+ glyph?: GlyphSpec
210
+ /** Future tertiary slot, rendered after the direction column. */
211
+ glyph2?: GlyphSpec
212
+ tokens?: number | null
213
+ cost?: number | null
214
+ title?: string
215
+ /** Right-side trailing text — status, age, review; rendered faded. */
216
+ suffix?: string
217
+ /** Muted second line drawn under the row — e.g. a question's dismissal/error reason. */
218
+ subline?: string
219
+ diff?: { additions: number; deletions: number }
220
+ current?: boolean
221
+ flow?: FlowDir | null
222
+ /** Reserve the direction column so busy and idle rows align. */
223
+ dirSlot?: boolean
224
+ /** Indent one glyph column — a nested child under a group header. */
225
+ indent?: boolean
226
+ /** Override the body text tone (defaults from `defaultBodyTone`). */
227
+ bodyTone?: ToneKey | null
228
+ /** A clickable trailing link (e.g. a pin affordance) at the far right. */
229
+ link?: { label: string; onPick: () => void }
230
+ onSelect?: () => void
231
+ }
232
+
233
+ export function agentDisplayName(name: string): string {
234
+ return (name || "agent").replace(/\s*-\s*/g, " ").trim() || "agent"
235
+ }
236
+
237
+ function shortName(name: string, kind: RowKind, max: number): string {
238
+ if (kind === ROW_KIND_FILE) return shortFileName(name, max)
239
+ if (kind === ROW_KIND_AGENT || kind === ROW_KIND_GROUP) return clipWidth(agentDisplayName(name), max)
240
+ return clipWidth(name || "agent", max)
241
+ }
242
+
243
+ export type ComposedRow = {
244
+ /** Identity + meta (+ optional title), clipped once to the available room. */
245
+ body: string
246
+ /** Trailing text (status/age/review), clipped to its own right budget. */
247
+ suffix: string
248
+ /** True when the body was clipped — the caller renders it faded. */
249
+ truncated: boolean
250
+ }
251
+
252
+ /** Fraction of the line reserved for the right-side suffix; the name never outruns it. */
253
+ const SUFFIX_BUDGET_FRACTION = 0.4
254
+
255
+ /**
256
+ * Compose one row line from its parts against a column budget. The trailing
257
+ * suffix wins a capped share of the line (`SUFFIX_BUDGET_FRACTION`) so a long
258
+ * status can't starve the name; the name and suffix are each clipped to their
259
+ * own room with a terminal `…`. Pure — unit-tested directly.
260
+ */
261
+ export function composeRow(
262
+ opts: Pick<
263
+ RowData,
264
+ "name" | "kind" | "tokens" | "cost" | "title" | "suffix" | "diff"
265
+ >,
266
+ lineMax: number,
267
+ ): ComposedRow {
268
+ const max = Math.max(0, lineMax - 2)
269
+ const suffixMax = Math.max(0, Math.floor(max * SUFFIX_BUDGET_FRACTION))
270
+ const suffix = clipWidth(opts.suffix?.trim() ?? "", suffixMax)
271
+ const diffW = opts.diff
272
+ ? strWidth(formatDiffStat(opts.diff.additions, opts.diff.deletions))
273
+ : 0
274
+ const extra = (suffix ? strWidth(suffix) + 1 : 0) + (diffW ? diffW + 1 : 0)
275
+ const room = Math.max(0, max - extra)
276
+ const tok = opts.tokens === undefined ? "" : formatTokens(opts.tokens)
277
+ const usd = formatUsd(opts.cost)
278
+ const meta = [tok, usd].filter(Boolean).join(" ")
279
+ const metaW = meta ? strWidth(meta) + 1 : 0
280
+ const title = opts.title?.replace(/\s+/g, " ").trim()
281
+ const nameBudget = title
282
+ ? Math.max(4, Math.floor((room - metaW) / 2))
283
+ : Math.max(1, room - metaW)
284
+ const agent = shortName(opts.name, opts.kind, nameBudget)
285
+ const head = [agent, tok, usd].filter(Boolean).join(" ")
286
+ const titleDropped = Boolean(title) && room - strWidth(head) < 9
287
+ const full = !title || titleDropped ? head : `${head} ${title}`
288
+ const body = clipWidth(full, room)
289
+ // The name is clipped inside `shortName` (middle/end `…`); a dropped title is
290
+ // the other truncation path. Either way the caller fades the row.
291
+ const truncated = agent.includes("…") || titleDropped
292
+ return { body, suffix, truncated }
293
+ }
294
+
295
+ /** Single renderer for every sidebar row: glyph(s), name, tokens, title, suffix, diff. */
296
+ export function AgentLine(props: RowData & {
297
+ lineMax: number
298
+ /** Tick accessor — read only inside the glyph bindings so rows are not rebuilt per tick. */
299
+ frame?: () => number
300
+ /** Fast glyph tick — spinners and direction flows step at `GLYPH_TICK_MS`. */
301
+ glyphFrame?: () => number
302
+ colors: ThemeColors
303
+ }): JSX.Element {
304
+ const mark = () => props.mark ?? MARK_READY
305
+ const frame = () => props.glyphFrame?.() ?? props.frame?.() ?? 0
306
+ const primary = () => props.glyph ?? stateGlyph(mark(), frame())
307
+ const dir = () =>
308
+ props.dirSlot
309
+ ? directionGlyph(props.flow ?? null) ?? { char: " ", tone: "textMuted" as ToneKey }
310
+ : null
311
+ const primaryTone = (): ToneKey => {
312
+ const spec = primary()
313
+ const tone = spec.tone
314
+ if (props.glyph) return tone
315
+ return props.current && tone === "textMuted" ? "primary" : tone
316
+ }
317
+ const dirOpacity = () => {
318
+ const spec = dir()
319
+ if (!spec?.blink) return 1
320
+ return sinPulseAlpha(props.frame?.() ?? 0)
321
+ }
322
+ const dirFg = () => {
323
+ const spec = dir()
324
+ if (!spec) return undefined
325
+ return toneColor(spec.tone, props.colors)
326
+ }
327
+ const bodyTone = () => props.bodyTone ?? defaultBodyTone(props.kind, props.mark, Boolean(props.current))
328
+ const row = () => composeRow(props, props.lineMax)
329
+ return profile("row", () => (
330
+ <box flexDirection="column" gap={0}>
331
+ <box flexDirection="row" onMouseUp={props.onSelect}>
332
+ <Show when={props.indent}>
333
+ <text>{` `}</text>
334
+ </Show>
335
+ <text fg={toneColor(primaryTone(), props.colors)}>{`${primary().char} `}</text>
336
+ <Show when={props.dirSlot}>
337
+ <text opacity={dirOpacity()} fg={dirFg()}>{`${dir()?.char ?? " "} `}</text>
338
+ </Show>
339
+ <Show when={props.glyph2}>
340
+ <text fg={toneColor(props.glyph2!.tone, props.colors)}>{`${props.glyph2!.char} `}</text>
341
+ </Show>
342
+ <ClickText
343
+ fg={toneColor(bodyTone(), props.colors)}
344
+ bold={Boolean(props.current)}
345
+ underline={Boolean(props.onSelect)}
346
+ opacity={row().truncated ? 0.5 : undefined}
347
+ >
348
+ {row().body}
349
+ </ClickText>
350
+ <Show when={row().suffix}>
351
+ <text fg={props.colors.textMuted} opacity={0.5}>
352
+ {` ${row().suffix}`}
353
+ </text>
354
+ </Show>
355
+ <Show when={Boolean(props.diff && (props.diff.additions > 0 || props.diff.deletions > 0))}>
356
+ <text> </text>
357
+ <DiffStat
358
+ additions={props.diff?.additions ?? 0}
359
+ deletions={props.diff?.deletions ?? 0}
360
+ colors={props.colors}
361
+ />
362
+ </Show>
363
+ <Show when={props.link}>
364
+ <box flexGrow={1} />
365
+ <ClickText fg={props.colors.primary || props.colors.text} underline onMouseUp={props.link!.onPick}>
366
+ {props.link!.label}
367
+ </ClickText>
368
+ </Show>
369
+ </box>
370
+ <Show when={props.subline}>
371
+ <text fg={props.colors.textMuted}>{` ${clipWidth(props.subline ?? "", Math.max(0, props.lineMax - 2))}`}</text>
372
+ </Show>
373
+ </box>
374
+ ))
375
+ }
376
+
377
+ /**
378
+ * Fold header + padded content box. The `open`/`onToggle` pair usually comes
379
+ * from `useFold`; the content is whatever rows the section owns.
380
+ */
381
+ export function FoldSection(props: {
382
+ title: string
383
+ open: boolean
384
+ onToggle: () => void
385
+ count?: number
386
+ live?: number
387
+ countLabel?: string
388
+ suffix?: string
389
+ diff?: { additions: number; deletions: number }
390
+ actions?: readonly ContextAction[]
391
+ onDetail?: () => void
392
+ colors: ThemeColors
393
+ children: JSX.Element
394
+ }): JSX.Element {
395
+ return (
396
+ <box flexDirection="column" gap={0}>
397
+ <FoldHeader
398
+ title={props.title}
399
+ open={props.open}
400
+ count={props.count}
401
+ live={props.live}
402
+ countLabel={props.countLabel}
403
+ suffix={props.suffix}
404
+ diff={props.diff}
405
+ actions={props.actions}
406
+ onDetail={props.onDetail}
407
+ colors={props.colors}
408
+ onToggle={props.onToggle}
409
+ />
410
+ <Show when={props.open}>
411
+ <box flexDirection="column" gap={0} paddingLeft={1}>
412
+ {props.children}
413
+ </box>
414
+ </Show>
415
+ </box>
416
+ )
417
+ }
418
+
419
+ /**
420
+ * A foldable data group: a FoldSection whose content is a `RowList` cut to a
421
+ * budget. The budget holds content rows only — the header is counted in the
422
+ * caller's `fixed` rows. Each group owns its own "… +N more" revealer, or the
423
+ * caller hands one in via `reveal` so the state survives `<For>` reconciliation
424
+ * (My work and Docs re-scan on a clock, so their groups are recreated each tick).
425
+ */
426
+ export function GroupSection<T>(props: {
427
+ title: string
428
+ open: boolean
429
+ onToggle: () => void
430
+ colors: ThemeColors
431
+ items: readonly T[]
432
+ budget: number
433
+ renderItem: (item: T, index: Accessor<number>) => JSX.Element
434
+ /** Rows revealed per click of the "… +N more" line. Defaults to `budget`. */
435
+ revealStep?: number
436
+ /** Hoisted reveal state — used instead of an internal one when provided. */
437
+ reveal?: RevealState
438
+ /** Context actions in the shared right rail. */
439
+ actions?: readonly ContextAction[]
440
+ }): JSX.Element {
441
+ const reveal = props.reveal ?? useReveal(Math.max(1, props.revealStep ?? props.budget))
442
+ return (
443
+ <FoldSection
444
+ title={props.title}
445
+ open={props.open}
446
+ onToggle={props.onToggle}
447
+ count={props.items.length}
448
+ actions={props.actions}
449
+ colors={props.colors}
450
+ >
451
+ <RowList
452
+ items={props.items}
453
+ budget={props.budget + reveal.more()}
454
+ colors={props.colors}
455
+ renderItem={props.renderItem}
456
+ more={{ onReveal: reveal.reveal }}
457
+ />
458
+ </FoldSection>
459
+ )
460
+ }
461
+
462
+ /**
463
+ * Brand + clickable tabs + the active panel. The plain form (no `onBrand`)
464
+ * is the OES column; the OMO group passes `onBrand` (foldable brand),
465
+ * `indentContent` and a tighter `gap`, plus the `collapsed`/`summary` pair
466
+ * that replaces the whole column with one summary line.
467
+ */
468
+ export function TabColumn(props: {
469
+ brand: string
470
+ tabs: readonly string[]
471
+ labels: Record<string, string>
472
+ active: string | null
473
+ colors: ThemeColors
474
+ onPick: (tab: string) => void
475
+ onBrand?: () => void
476
+ /** Per-tab status light replacing the `|` separators (see `BrandTabs`). */
477
+ glyph?: (tab: string) => GlyphSpec
478
+ panels: Record<string, () => JSX.Element>
479
+ indentContent?: boolean
480
+ gap?: number
481
+ collapsed?: boolean
482
+ summary?: string
483
+ }): JSX.Element {
484
+ const chevron = props.onBrand ? "▼ " : ""
485
+ return (
486
+ <Show
487
+ when={!props.collapsed}
488
+ fallback={
489
+ <box flexDirection="row" onMouseUp={props.onBrand}>
490
+ <ClickText
491
+ fg={props.colors.primary || props.colors.text}
492
+ bold
493
+ underline={Boolean(props.onBrand)}
494
+ >
495
+ {`▶ ${props.brand}`}
496
+ </ClickText>
497
+ <Show when={props.summary}>
498
+ <text fg={props.colors.textMuted}>{` ${props.summary}`}</text>
499
+ </Show>
500
+ </box>
501
+ }
502
+ >
503
+ <box flexDirection="column" gap={props.gap ?? 1}>
504
+ <BrandTabs
505
+ brand={`${chevron}${props.brand}`}
506
+ tabs={props.tabs}
507
+ labels={props.labels}
508
+ active={props.active}
509
+ colors={props.colors}
510
+ onPick={props.onPick}
511
+ onBrand={props.onBrand}
512
+ glyph={props.glyph}
513
+ />
514
+ <box flexDirection="column" gap={0} paddingLeft={props.indentContent ? 1 : 0}>
515
+ <For each={props.tabs}>
516
+ {(key) => (
517
+ <Show when={props.active === key}>
518
+ {props.panels[key]?.()}
519
+ </Show>
520
+ )}
521
+ </For>
522
+ </box>
523
+ </box>
524
+ </Show>
525
+ )
526
+ }