opencode-extended-sidebar 0.2.90 → 0.2.91

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 (108) hide show
  1. package/.oesignore +17 -17
  2. package/CHANGELOG.md +96 -95
  3. package/LICENSE +21 -21
  4. package/README.md +150 -235
  5. package/oes.json +13 -11
  6. package/package.json +100 -100
  7. package/scripts/install-git-hooks.mjs +29 -29
  8. package/src/pware.oc.core/constants/index.ts +18 -18
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -29
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +15 -13
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -241
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -55
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -65
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -58
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -63
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -34
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -74
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -69
  19. package/src/pware.oc.core/git/index.ts +7 -7
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -237
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -87
  22. package/src/pware.oc.core/index.ts +19 -18
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -42
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -48
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -97
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -280
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -79
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -135
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -234
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +99 -89
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -240
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -74
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -726
  34. package/src/pware.oc.core/pware.oc.core.runtimeConfig.ts +41 -0
  35. package/src/pware.oc.core/pware.oc.core.sqlite.ts +164 -175
  36. package/src/pware.oc.core/pware.oc.core.sqliteGateway.ts +89 -0
  37. package/src/pware.oc.core/pware.oc.core.status.ts +214 -214
  38. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -63
  39. package/src/pware.oc.core/pware.oc.core.width.ts +215 -215
  40. package/src/pware.oc.omo/constants/index.ts +15 -15
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -18
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -49
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -29
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -11
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -60
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -49
  47. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -25
  48. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -38
  49. package/src/pware.oc.omo/index.ts +7 -7
  50. package/src/pware.oc.omo/resolver/index.ts +19 -19
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -179
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -116
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -34
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -503
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -40
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -269
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -18
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -18
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -155
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +363 -361
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -18
  62. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -11
  63. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -14
  64. package/src/pware.oc.opencode/constants/index.ts +10 -10
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -15
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -18
  67. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -45
  68. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -58
  69. package/src/pware.oc.opencode/index.ts +8 -8
  70. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -182
  71. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -399
  72. package/src/pware.oc.opencode/resolver/index.ts +223 -170
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +150 -162
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +149 -185
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +248 -211
  76. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +22 -26
  77. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +224 -236
  78. package/src/pware.oc.perf/index.ts +14 -14
  79. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -15
  80. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -284
  81. package/src/pware.oc.perf/pware.oc.perf.reader.ts +962 -927
  82. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -88
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -118
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -79
  85. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -71
  86. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -214
  87. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -179
  88. package/src/pware.oc.perf/pware.oc.perf.view.tsx +463 -462
  89. package/src/pware.oc.runtime/index.ts +13 -13
  90. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -142
  91. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +87 -79
  92. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +282 -253
  93. package/src/pware.oc.runtime/pware.oc.runtime.omoRead.ts +37 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +83 -95
  95. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +130 -129
  96. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -105
  97. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +44 -43
  98. package/src/pware.oc.runtime/resolver/index.ts +268 -224
  99. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -146
  100. package/src/pware.oc.ui/index.ts +12 -12
  101. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +381 -339
  102. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -144
  103. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -111
  104. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -82
  105. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -843
  106. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +552 -526
  107. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1718 -1629
  108. package/src/pware.oc.ui.tsx +98 -98
@@ -1,339 +1,381 @@
1
- /** @jsxImportSource @opentui/solid */
2
- /** Shared sidebar chrome: brand tabs, theme colours, fold headers, diff stats, kv persistence. */
3
- import { createSignal, For, Show, type JSX } from "solid-js"
4
- import { TextAttributes } from "@opentui/core"
5
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
6
- import type { GlyphSpec, ToneKey } from "../pware.oc.core/pware.oc.core.glyph.js"
7
- import { CONTEXT_ACTION_COL_WIDTH } from "../pware.oc.core/pware.oc.core.layout.js"
8
- import { formatDismissed, parseDismissed } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
9
-
10
- /** One clickable context action in the shared right rail. */
11
- export type ContextAction = {
12
- label: string
13
- onPick: () => void
14
- bold?: boolean
15
- }
16
-
17
- /** OpenTUI has no bold/underline props on `<text>` only the `attributes` bitmask. */
18
- export function textAttrs(bold?: boolean, underline?: boolean): number {
19
- let a = 0
20
- if (bold) a |= TextAttributes.BOLD
21
- if (underline) a |= TextAttributes.UNDERLINE
22
- return a
23
- }
24
-
25
- /**
26
- * Clickable label: underlined only while the pointer hovers it.
27
- * OpenTUI fires `over`/`out` on hit-test change, so the underline
28
- * tracks the mouse instead of being baked in. `underline` marks
29
- * clickability; without it the label never underlines.
30
- */
31
- export function ClickText(props: {
32
- fg?: string
33
- bold?: boolean
34
- /** Clickable the underline appears while the pointer is over the label. */
35
- underline?: boolean
36
- opacity?: number
37
- onMouseUp?: () => void
38
- children: JSX.Element
39
- }): JSX.Element {
40
- const [hovered, setHovered] = createSignal(false)
41
- return (
42
- <text
43
- fg={props.fg}
44
- opacity={props.opacity}
45
- attributes={textAttrs(Boolean(props.bold), Boolean(props.underline) && hovered())}
46
- onMouseUp={props.onMouseUp}
47
- onMouseOver={() => setHovered(true)}
48
- onMouseOut={() => setHovered(false)}
49
- >
50
- {props.children}
51
- </text>
52
- )
53
- }
54
-
55
- export type ThemeColors = {
56
- text: string
57
- textMuted: string
58
- success: string
59
- primary?: string
60
- warning?: string
61
- error?: string
62
- diffAdded?: string
63
- diffRemoved?: string
64
- }
65
-
66
- export function kvRead(api: TuiPluginApi, key: string, fallback: boolean): boolean {
67
- try {
68
- const kv = (api as TuiPluginApi & { kv?: { get: (k: string, d?: boolean) => boolean } }).kv
69
- return kv?.get(key, fallback) ?? fallback
70
- } catch {
71
- return fallback
72
- }
73
- }
74
-
75
- export function kvWrite(api: TuiPluginApi, key: string, value: boolean): void {
76
- try {
77
- const kv = (api as TuiPluginApi & { kv?: { set: (k: string, v: boolean) => void } }).kv
78
- kv?.set(key, value)
79
- } catch {
80
- // no kv in older hosts
81
- }
82
- }
83
-
84
- /** Same store, string value. Anything outside `allowed` falls back. */
85
- export function kvReadOne<T extends string>(
86
- api: TuiPluginApi,
87
- key: string,
88
- fallback: T,
89
- allowed: readonly T[],
90
- ): T {
91
- try {
92
- const kv = (api as TuiPluginApi & { kv?: { get: (k: string, d?: string) => unknown } }).kv
93
- const raw = kv?.get(key, fallback)
94
- return allowed.includes(raw as T) ? (raw as T) : fallback
95
- } catch {
96
- return fallback
97
- }
98
- }
99
-
100
- export function kvWriteOne(api: TuiPluginApi, key: string, value: string): void {
101
- try {
102
- const kv = (api as TuiPluginApi & { kv?: { set: (k: string, v: string) => void } }).kv
103
- kv?.set(key, value)
104
- } catch {
105
- // no kv in older hosts
106
- }
107
- }
108
-
109
- const KV_DISMISSED_QUESTIONS = "oes.dismissed.questions"
110
-
111
- export function readDismissedQuestions(api: TuiPluginApi): ReadonlySet<string> {
112
- try {
113
- const kv = (api as TuiPluginApi & { kv?: { get: (k: string, d?: unknown) => unknown } }).kv
114
- const raw = kv?.get(KV_DISMISSED_QUESTIONS, null)
115
- return parseDismissed(typeof raw === "string" ? raw : null)
116
- } catch {
117
- return new Set()
118
- }
119
- }
120
-
121
- export function dismissQuestion(api: TuiPluginApi, partId: string): void {
122
- try {
123
- const ids = new Set(readDismissedQuestions(api))
124
- ids.add(partId)
125
- const kv = (api as TuiPluginApi & { kv?: { set: (k: string, v: string) => void } }).kv
126
- kv?.set(KV_DISMISSED_QUESTIONS, formatDismissed(ids))
127
- } catch {
128
- // no kv in older hosts
129
- }
130
- }
131
-
132
- /** The single mapping from a semantic tone to a concrete theme colour. */
133
- export function toneColor(tone: ToneKey, colors: ThemeColors): string {
134
- switch (tone) {
135
- case "success":
136
- return colors.success
137
- case "warning":
138
- return colors.warning || colors.text
139
- case "primary":
140
- return colors.primary || colors.text
141
- case "error":
142
- return colors.error || colors.text
143
- case "text":
144
- return colors.text
145
- default:
146
- return colors.textMuted
147
- }
148
- }
149
-
150
- export function diffAddFg(colors: ThemeColors): string {
151
- return colors.diffAdded || colors.success
152
- }
153
-
154
- export function diffDelFg(colors: ThemeColors): string {
155
- return colors.diffRemoved || colors.error || colors.text
156
- }
157
-
158
- export function DiffStat(props: {
159
- additions: number
160
- deletions: number
161
- colors: ThemeColors
162
- }): JSX.Element {
163
- const lead = () => (props.additions > 0 && props.deletions > 0 ? " " : "")
164
- return (
165
- <box flexDirection="row">
166
- <Show when={props.additions > 0}>
167
- <text fg={diffAddFg(props.colors)}>{`+${props.additions}`}</text>
168
- </Show>
169
- <Show when={props.deletions > 0}>
170
- <text fg={diffDelFg(props.colors)}>{`${lead()}−${props.deletions}`}</text>
171
- </Show>
172
- </box>
173
- )
174
- }
175
-
176
- /**
177
- * Right-rail action labels — fold headers, realtime `F`, and any future row
178
- * actions share {@link CONTEXT_ACTION_COL_WIDTH} so the column lines up.
179
- */
180
- export function ContextActions(props: {
181
- actions: readonly ContextAction[]
182
- colors: ThemeColors
183
- width?: number
184
- }): JSX.Element {
185
- return (
186
- <box
187
- flexDirection="row"
188
- width={props.width ?? CONTEXT_ACTION_COL_WIDTH}
189
- justifyContent="flex-end"
190
- flexShrink={0}
191
- >
192
- <For each={props.actions}>
193
- {(action, i) => (
194
- <>
195
- <Show when={i() > 0}>
196
- <text> </text>
197
- </Show>
198
- <ClickText
199
- fg={props.colors.primary || props.colors.text}
200
- bold={action.bold}
201
- underline
202
- onMouseUp={action.onPick}
203
- >
204
- {action.label}
205
- </ClickText>
206
- </>
207
- )}
208
- </For>
209
- </box>
210
- )
211
- }
212
-
213
- /** Header text for a fold row: title, optional `(N)` / `(live/N)` / `(countLabel)` parenthetical, optional suffix. */
214
- export function foldHeaderTitle(
215
- title: string,
216
- opts: { count?: number; live?: number; countLabel?: string; suffix?: string } = {},
217
- ): string {
218
- const { count, live = 0, countLabel, suffix } = opts
219
- const stat = suffix ? ` ${suffix}` : ""
220
- if (typeof count !== "number") return `${title}${stat}`
221
- const extra = countLabel
222
- ? ` (${countLabel})`
223
- : live > 0
224
- ? ` (${live}/${count})`
225
- : ` (${count})`
226
- return `${title}${extra}${stat}`
227
- }
228
-
229
- export function FoldHeader(props: {
230
- title: string
231
- open: boolean
232
- count?: number
233
- live?: number
234
- /** Overrides the `(N)` / `(live/N)` parenthetical, e.g. `last 4`. */
235
- countLabel?: string
236
- suffix?: string
237
- diff?: { additions: number; deletions: number }
238
- colors: ThemeColors
239
- onToggle: () => void
240
- /** Title click opens a detail (fold stays on the chevron). */
241
- onDetail?: () => void
242
- /** Context actions in the shared right rail (`switch` / `new`, `view all`, …). */
243
- actions?: readonly ContextAction[]
244
- }): JSX.Element {
245
- const rest = () => foldHeaderTitle(props.title, props)
246
- const hasDiff = () =>
247
- Boolean(props.diff && (props.diff.additions > 0 || props.diff.deletions > 0))
248
- const chevron = () => (props.open ? "▼" : "▶")
249
- const actions = () => props.actions ?? []
250
- const split = Boolean(props.onDetail || actions().length > 0)
251
- return (
252
- <box flexDirection="row" width="100%" onMouseUp={split ? undefined : props.onToggle}>
253
- <box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0}>
254
- <ClickText
255
- fg={props.colors.text}
256
- underline={!props.onDetail}
257
- onMouseUp={split ? props.onToggle : undefined}
258
- >
259
- {props.onDetail ? `${chevron()} ` : `${chevron()} ${rest()}`}
260
- </ClickText>
261
- <Show when={props.onDetail}>
262
- <ClickText fg={props.colors.text} underline onMouseUp={props.onDetail}>
263
- {rest()}
264
- </ClickText>
265
- </Show>
266
- <Show when={hasDiff()}>
267
- <text> </text>
268
- <DiffStat
269
- additions={props.diff?.additions ?? 0}
270
- deletions={props.diff?.deletions ?? 0}
271
- colors={props.colors}
272
- />
273
- </Show>
274
- </box>
275
- <Show when={actions().length > 0}>
276
- <ContextActions actions={actions()} colors={props.colors} />
277
- </Show>
278
- </box>
279
- )
280
- }
281
-
282
- /**
283
- * Brand + clickable tabs. Active tab is bold + primary; clickable labels
284
- * underline on hover. `onBrand` makes the brand itself a control the OMO
285
- * group folds on it.
286
- */
287
- export function BrandTabs(props: {
288
- brand: string
289
- tabs: readonly string[]
290
- labels: Record<string, string>
291
- active: string | null
292
- colors: ThemeColors
293
- onPick: (tab: string) => void
294
- onBrand?: () => void
295
- /**
296
- * Per-tab leading glyph replacing the `|` separator between tabs — the tab's
297
- * status light. The glyph doubles as spacing, so headers read `• My work • Session …`.
298
- */
299
- glyph?: (tab: string) => GlyphSpec
300
- }): JSX.Element {
301
- const brand = () => props.colors.primary || props.colors.text
302
- const tabFg = (tab: string) =>
303
- props.active === tab ? props.colors.primary || props.colors.text : props.colors.textMuted
304
- return (
305
- <box flexDirection="row" gap={1}>
306
- <Show when={props.brand}>
307
- <box flexDirection="row" onMouseUp={props.onBrand}>
308
- <ClickText fg={brand()} bold underline={Boolean(props.onBrand)}>
309
- {props.brand}
310
- </ClickText>
311
- </box>
312
- </Show>
313
- <For each={props.tabs}>
314
- {(tab: string, i) => {
315
- // Read the glyph through an accessor invoked inside the JSX, never a
316
- // plain body const: SolidJS only re-runs JSX expressions, so the
317
- // My-work tab light (a live memo) updates only when it is read at the
318
- // expression site. A body-level `const glyph = props.glyph(tab)` would
319
- // freeze the header at its first render.
320
- const glyph = () => (props.glyph ? props.glyph(tab) : null)
321
- return (
322
- <box flexDirection="row" gap={1} onMouseUp={() => props.onPick(tab)}>
323
- {glyph() ? (
324
- <text fg={toneColor(glyph()!.tone, props.colors)}>{`${glyph()!.char} `}</text>
325
- ) : (
326
- <Show when={Boolean(props.brand) || i() > 0}>
327
- <text fg={props.colors.textMuted}>|</text>
328
- </Show>
329
- )}
330
- <ClickText fg={tabFg(tab)} bold={props.active === tab} underline>
331
- {props.labels[tab] ?? tab}
332
- </ClickText>
333
- </box>
334
- )
335
- }}
336
- </For>
337
- </box>
338
- )
339
- }
1
+ /** @jsxImportSource @opentui/solid */
2
+ /** Shared sidebar chrome: brand tabs, theme colours, fold headers, diff stats, kv persistence. */
3
+ import { createSignal, For, Show, type JSX } from "solid-js"
4
+ import { TextAttributes } from "@opentui/core"
5
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
6
+ import type { GlyphSpec, ToneKey } from "../pware.oc.core/pware.oc.core.glyph.js"
7
+ import { CONTEXT_ACTION_COL_WIDTH } from "../pware.oc.core/pware.oc.core.layout.js"
8
+ import {
9
+ emptyRuntimeConfig,
10
+ parseRuntimeConfig,
11
+ pinSession,
12
+ unpinSession,
13
+ type RuntimeConfigDto,
14
+ } from "../pware.oc.core/pware.oc.core.runtimeConfig.js"
15
+ import { formatDismissed, parseDismissed } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
16
+
17
+ /** One clickable context action in the shared right rail. */
18
+ export type ContextAction = {
19
+ label: string
20
+ onPick: () => void
21
+ bold?: boolean
22
+ }
23
+
24
+ /** OpenTUI has no bold/underline props on `<text>` — only the `attributes` bitmask. */
25
+ export function textAttrs(bold?: boolean, underline?: boolean): number {
26
+ let a = 0
27
+ if (bold) a |= TextAttributes.BOLD
28
+ if (underline) a |= TextAttributes.UNDERLINE
29
+ return a
30
+ }
31
+
32
+ /**
33
+ * Clickable label: underlined only while the pointer hovers it.
34
+ * OpenTUI fires `over`/`out` on hit-test change, so the underline
35
+ * tracks the mouse instead of being baked in. `underline` marks
36
+ * clickability; without it the label never underlines.
37
+ */
38
+ export function ClickText(props: {
39
+ fg?: string
40
+ bold?: boolean
41
+ /** Clickable — the underline appears while the pointer is over the label. */
42
+ underline?: boolean
43
+ opacity?: number
44
+ onMouseUp?: () => void
45
+ children: JSX.Element
46
+ }): JSX.Element {
47
+ const [hovered, setHovered] = createSignal(false)
48
+ return (
49
+ <text
50
+ fg={props.fg}
51
+ opacity={props.opacity}
52
+ attributes={textAttrs(Boolean(props.bold), Boolean(props.underline) && hovered())}
53
+ onMouseUp={props.onMouseUp}
54
+ onMouseOver={() => setHovered(true)}
55
+ onMouseOut={() => setHovered(false)}
56
+ >
57
+ {props.children}
58
+ </text>
59
+ )
60
+ }
61
+
62
+ export type ThemeColors = {
63
+ text: string
64
+ textMuted: string
65
+ success: string
66
+ primary?: string
67
+ warning?: string
68
+ error?: string
69
+ diffAdded?: string
70
+ diffRemoved?: string
71
+ }
72
+
73
+ export function kvRead(api: TuiPluginApi, key: string, fallback: boolean): boolean {
74
+ try {
75
+ const kv = (api as TuiPluginApi & { kv?: { get: (k: string, d?: boolean) => boolean } }).kv
76
+ return kv?.get(key, fallback) ?? fallback
77
+ } catch {
78
+ return fallback
79
+ }
80
+ }
81
+
82
+ export function kvWrite(api: TuiPluginApi, key: string, value: boolean): void {
83
+ try {
84
+ const kv = (api as TuiPluginApi & { kv?: { set: (k: string, v: boolean) => void } }).kv
85
+ kv?.set(key, value)
86
+ } catch {
87
+ // no kv in older hosts
88
+ }
89
+ }
90
+
91
+ /** Same store, string value. Anything outside `allowed` falls back. */
92
+ export function kvReadOne<T extends string>(
93
+ api: TuiPluginApi,
94
+ key: string,
95
+ fallback: T,
96
+ allowed: readonly T[],
97
+ ): T {
98
+ try {
99
+ const kv = (api as TuiPluginApi & { kv?: { get: (k: string, d?: string) => unknown } }).kv
100
+ const raw = kv?.get(key, fallback)
101
+ return allowed.includes(raw as T) ? (raw as T) : fallback
102
+ } catch {
103
+ return fallback
104
+ }
105
+ }
106
+
107
+ export function kvWriteOne(api: TuiPluginApi, key: string, value: string): void {
108
+ try {
109
+ const kv = (api as TuiPluginApi & { kv?: { set: (k: string, v: string) => void } }).kv
110
+ kv?.set(key, value)
111
+ } catch {
112
+ // no kv in older hosts
113
+ }
114
+ }
115
+
116
+ const KV_DISMISSED_QUESTIONS = "oes.dismissed.questions"
117
+
118
+ export function readDismissedQuestions(api: TuiPluginApi): ReadonlySet<string> {
119
+ try {
120
+ const kv = (api as TuiPluginApi & { kv?: { get: (k: string, d?: unknown) => unknown } }).kv
121
+ const raw = kv?.get(KV_DISMISSED_QUESTIONS, null)
122
+ return parseDismissed(typeof raw === "string" ? raw : null)
123
+ } catch {
124
+ return new Set()
125
+ }
126
+ }
127
+
128
+ export function dismissQuestion(api: TuiPluginApi, partId: string): void {
129
+ try {
130
+ const ids = new Set(readDismissedQuestions(api))
131
+ ids.add(partId)
132
+ const kv = (api as TuiPluginApi & { kv?: { set: (k: string, v: string) => void } }).kv
133
+ kv?.set(KV_DISMISSED_QUESTIONS, formatDismissed(ids))
134
+ } catch {
135
+ // no kv in older hosts
136
+ }
137
+ }
138
+
139
+ const KV_RUNTIME_CONFIG = "oes.config"
140
+
141
+ type TuiKv = {
142
+ get: <T>(key: string, fallback?: T) => T
143
+ set: (key: string, value: unknown) => void
144
+ }
145
+
146
+ /** Host kv-backed pinned-sessions manager: pin/unpin mutate the `oes.config` DTO. */
147
+ export function createPinnedSessions(api: TuiPluginApi): {
148
+ list: () => string[]
149
+ pin: (sessionId: string) => void
150
+ unpin: (sessionId: string) => void
151
+ } {
152
+ const kv = () => (api as TuiPluginApi & { kv?: TuiKv }).kv
153
+ const read = (): RuntimeConfigDto => {
154
+ try {
155
+ return parseRuntimeConfig(kv()?.get(KV_RUNTIME_CONFIG, undefined))
156
+ } catch {
157
+ return emptyRuntimeConfig()
158
+ }
159
+ }
160
+ const write = (config: RuntimeConfigDto): void => {
161
+ try {
162
+ kv()?.set(KV_RUNTIME_CONFIG, config)
163
+ } catch {
164
+ // no kv in older hosts
165
+ }
166
+ }
167
+ return {
168
+ list: () => read().pinned_sessions,
169
+ pin: (sessionId) => write(pinSession(read(), sessionId)),
170
+ unpin: (sessionId) => write(unpinSession(read(), sessionId)),
171
+ }
172
+ }
173
+
174
+ /** The single mapping from a semantic tone to a concrete theme colour. */
175
+ export function toneColor(tone: ToneKey, colors: ThemeColors): string {
176
+ switch (tone) {
177
+ case "success":
178
+ return colors.success
179
+ case "warning":
180
+ return colors.warning || colors.text
181
+ case "primary":
182
+ return colors.primary || colors.text
183
+ case "error":
184
+ return colors.error || colors.text
185
+ case "text":
186
+ return colors.text
187
+ default:
188
+ return colors.textMuted
189
+ }
190
+ }
191
+
192
+ export function diffAddFg(colors: ThemeColors): string {
193
+ return colors.diffAdded || colors.success
194
+ }
195
+
196
+ export function diffDelFg(colors: ThemeColors): string {
197
+ return colors.diffRemoved || colors.error || colors.text
198
+ }
199
+
200
+ export function DiffStat(props: {
201
+ additions: number
202
+ deletions: number
203
+ colors: ThemeColors
204
+ }): JSX.Element {
205
+ const lead = () => (props.additions > 0 && props.deletions > 0 ? " " : "")
206
+ return (
207
+ <box flexDirection="row">
208
+ <Show when={props.additions > 0}>
209
+ <text fg={diffAddFg(props.colors)}>{`+${props.additions}`}</text>
210
+ </Show>
211
+ <Show when={props.deletions > 0}>
212
+ <text fg={diffDelFg(props.colors)}>{`${lead()}−${props.deletions}`}</text>
213
+ </Show>
214
+ </box>
215
+ )
216
+ }
217
+
218
+ /**
219
+ * Right-rail action labels fold headers, realtime `F`, and any future row
220
+ * actions share {@link CONTEXT_ACTION_COL_WIDTH} so the column lines up.
221
+ */
222
+ export function ContextActions(props: {
223
+ actions: readonly ContextAction[]
224
+ colors: ThemeColors
225
+ width?: number
226
+ }): JSX.Element {
227
+ return (
228
+ <box
229
+ flexDirection="row"
230
+ width={props.width ?? CONTEXT_ACTION_COL_WIDTH}
231
+ justifyContent="flex-end"
232
+ flexShrink={0}
233
+ >
234
+ <For each={props.actions}>
235
+ {(action, i) => (
236
+ <>
237
+ <Show when={i() > 0}>
238
+ <text> </text>
239
+ </Show>
240
+ <ClickText
241
+ fg={props.colors.primary || props.colors.text}
242
+ bold={action.bold}
243
+ underline
244
+ onMouseUp={action.onPick}
245
+ >
246
+ {action.label}
247
+ </ClickText>
248
+ </>
249
+ )}
250
+ </For>
251
+ </box>
252
+ )
253
+ }
254
+
255
+ /** Header text for a fold row: title, optional `(N)` / `(live/N)` / `(countLabel)` parenthetical, optional suffix. */
256
+ export function foldHeaderTitle(
257
+ title: string,
258
+ opts: { count?: number; live?: number; countLabel?: string; suffix?: string } = {},
259
+ ): string {
260
+ const { count, live = 0, countLabel, suffix } = opts
261
+ const stat = suffix ? ` ${suffix}` : ""
262
+ if (typeof count !== "number") return `${title}${stat}`
263
+ const extra = countLabel
264
+ ? ` (${countLabel})`
265
+ : live > 0
266
+ ? ` (${live}/${count})`
267
+ : ` (${count})`
268
+ return `${title}${extra}${stat}`
269
+ }
270
+
271
+ export function FoldHeader(props: {
272
+ title: string
273
+ open: boolean
274
+ count?: number
275
+ live?: number
276
+ /** Overrides the `(N)` / `(live/N)` parenthetical, e.g. `last 4`. */
277
+ countLabel?: string
278
+ suffix?: string
279
+ diff?: { additions: number; deletions: number }
280
+ colors: ThemeColors
281
+ onToggle: () => void
282
+ /** Title click opens a detail (fold stays on the chevron). */
283
+ onDetail?: () => void
284
+ /** Context actions in the shared right rail (`switch` / `new`, `view all`, …). */
285
+ actions?: readonly ContextAction[]
286
+ }): JSX.Element {
287
+ const rest = () => foldHeaderTitle(props.title, props)
288
+ const hasDiff = () =>
289
+ Boolean(props.diff && (props.diff.additions > 0 || props.diff.deletions > 0))
290
+ const chevron = () => (props.open ? "▼" : "▶")
291
+ const actions = () => props.actions ?? []
292
+ const split = Boolean(props.onDetail || actions().length > 0)
293
+ return (
294
+ <box flexDirection="row" width="100%" onMouseUp={split ? undefined : props.onToggle}>
295
+ <box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0}>
296
+ <ClickText
297
+ fg={props.colors.text}
298
+ underline={!props.onDetail}
299
+ onMouseUp={split ? props.onToggle : undefined}
300
+ >
301
+ {props.onDetail ? `${chevron()} ` : `${chevron()} ${rest()}`}
302
+ </ClickText>
303
+ <Show when={props.onDetail}>
304
+ <ClickText fg={props.colors.text} underline onMouseUp={props.onDetail}>
305
+ {rest()}
306
+ </ClickText>
307
+ </Show>
308
+ <Show when={hasDiff()}>
309
+ <text> </text>
310
+ <DiffStat
311
+ additions={props.diff?.additions ?? 0}
312
+ deletions={props.diff?.deletions ?? 0}
313
+ colors={props.colors}
314
+ />
315
+ </Show>
316
+ </box>
317
+ <Show when={actions().length > 0}>
318
+ <ContextActions actions={actions()} colors={props.colors} />
319
+ </Show>
320
+ </box>
321
+ )
322
+ }
323
+
324
+ /**
325
+ * Brand + clickable tabs. Active tab is bold + primary; clickable labels
326
+ * underline on hover. `onBrand` makes the brand itself a control — the OMO
327
+ * group folds on it.
328
+ */
329
+ export function BrandTabs(props: {
330
+ brand: string
331
+ tabs: readonly string[]
332
+ labels: Record<string, string>
333
+ active: string | null
334
+ colors: ThemeColors
335
+ onPick: (tab: string) => void
336
+ onBrand?: () => void
337
+ /**
338
+ * Per-tab leading glyph replacing the `|` separator between tabs — the tab's
339
+ * status light. The glyph doubles as spacing, so headers read `• My work • Session …`.
340
+ */
341
+ glyph?: (tab: string) => GlyphSpec
342
+ }): JSX.Element {
343
+ const brand = () => props.colors.primary || props.colors.text
344
+ const tabFg = (tab: string) =>
345
+ props.active === tab ? props.colors.primary || props.colors.text : props.colors.textMuted
346
+ return (
347
+ <box flexDirection="row" gap={1}>
348
+ <Show when={props.brand}>
349
+ <box flexDirection="row" onMouseUp={props.onBrand}>
350
+ <ClickText fg={brand()} bold underline={Boolean(props.onBrand)}>
351
+ {props.brand}
352
+ </ClickText>
353
+ </box>
354
+ </Show>
355
+ <For each={props.tabs}>
356
+ {(tab: string, i) => {
357
+ // Read the glyph through an accessor invoked inside the JSX, never a
358
+ // plain body const: SolidJS only re-runs JSX expressions, so the
359
+ // My-work tab light (a live memo) updates only when it is read at the
360
+ // expression site. A body-level `const glyph = props.glyph(tab)` would
361
+ // freeze the header at its first render.
362
+ const glyph = () => (props.glyph ? props.glyph(tab) : null)
363
+ return (
364
+ <box flexDirection="row" gap={1} onMouseUp={() => props.onPick(tab)}>
365
+ {glyph() ? (
366
+ <text fg={toneColor(glyph()!.tone, props.colors)}>{`${glyph()!.char} `}</text>
367
+ ) : (
368
+ <Show when={Boolean(props.brand) || i() > 0}>
369
+ <text fg={props.colors.textMuted}>|</text>
370
+ </Show>
371
+ )}
372
+ <ClickText fg={tabFg(tab)} bold={props.active === tab} underline>
373
+ {props.labels[tab] ?? tab}
374
+ </ClickText>
375
+ </box>
376
+ )
377
+ }}
378
+ </For>
379
+ </box>
380
+ )
381
+ }