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,280 @@
1
+ /**
2
+ * Optional file-based debug + profile loggers, plus a tiny on-screen event
3
+ * ring for the sidebar's read-only debug console.
4
+ *
5
+ * Debug: OES_DEBUG_OPENCODE=1 → writes to <plugin>/logs/
6
+ * Profile: OES_DEBUG_PROFILE=1 → writes to <plugin>/logs/
7
+ * Either OES_DEBUG_*=/some/dir → writes to that directory
8
+ *
9
+ * Each session appends a single oes-debug-<date>.log / oes-profile-<date>.log
10
+ * file. Lines are newline-delimited JSON:
11
+ * debug → { ts, tag, msg, data? }
12
+ * profile → { ts, tag, ms, data? }
13
+ * All writes are silent on error — the plugin never crashes because of logging.
14
+ *
15
+ * The screen ring (`pushScreenLine`) is a module-level 200-line buffer of short
16
+ * labels (e.g. `db open`) that the sidebar console subscribes to; it is only
17
+ * fed while a logger is active, so production pays nothing.
18
+ */
19
+ import fs from "node:fs"
20
+ import path from "node:path"
21
+ import { pluginRoot } from "./pware.oc.core.paths.js"
22
+
23
+ const DEBUG_ENV_KEY = "OES_DEBUG_OPENCODE"
24
+ const PROFILE_ENV_KEY = "OES_DEBUG_PROFILE"
25
+
26
+ function pluginLogsDir(): string {
27
+ return path.join(pluginRoot(), "logs")
28
+ }
29
+
30
+ function resolveLogDir(
31
+ envKey: string,
32
+ env: Record<string, string | undefined>,
33
+ defaultDir?: string,
34
+ ): string | null {
35
+ const val = env[envKey]
36
+ if (!val) return null
37
+ const trimmed = val.trim()
38
+ if (!trimmed) return null
39
+ const FALSY = new Set(["0", "false", "no", "off"])
40
+ if (FALSY.has(trimmed.toLowerCase())) return null
41
+ const TRUTHY = new Set(["1", "true", "yes", "on"])
42
+ if (TRUTHY.has(trimmed.toLowerCase())) return defaultDir ?? pluginLogsDir()
43
+ return trimmed
44
+ }
45
+
46
+ /** Returns the debug log directory, or null when debug is off. Exported for tests. */
47
+ export function debugLogDir(
48
+ env: Record<string, string | undefined> = process.env,
49
+ defaultDir?: string,
50
+ ): string | null {
51
+ return resolveLogDir(DEBUG_ENV_KEY, env, defaultDir)
52
+ }
53
+
54
+ /** Returns the profile log directory, or null when profiling is off. Exported for tests. */
55
+ export function profileLogDir(
56
+ env: Record<string, string | undefined> = process.env,
57
+ defaultDir?: string,
58
+ ): string | null {
59
+ return resolveLogDir(PROFILE_ENV_KEY, env, defaultDir)
60
+ }
61
+
62
+ /** True when the debug logger is active — env on AND its log dir resolved/writable. */
63
+ export function debugActive(): boolean {
64
+ return resolvedDir() !== null
65
+ }
66
+
67
+ export function debugActiveDir(): string | null {
68
+ return resolvedDir()
69
+ }
70
+
71
+ /** True when the profile logger is active — env on AND its log dir resolved/writable. */
72
+ export function profileActive(): boolean {
73
+ return profileResolvedDir() !== null
74
+ }
75
+
76
+ export function profileActiveDir(): string | null {
77
+ return profileResolvedDir()
78
+ }
79
+
80
+ function datedLogFile(prefix: string): string {
81
+ const d = new Date()
82
+ const pad = (n: number) => String(n).padStart(2, "0")
83
+ return `oes-${prefix}-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}.log`
84
+ }
85
+
86
+ let _dir: string | null | undefined = undefined // undefined = not resolved yet
87
+ let _pdir: string | null | undefined = undefined
88
+
89
+ function resolvedDir(): string | null {
90
+ if (_dir !== undefined) return _dir
91
+ _dir = debugLogDir()
92
+ if (_dir) {
93
+ try {
94
+ fs.mkdirSync(_dir, { recursive: true })
95
+ } catch {
96
+ _dir = null
97
+ }
98
+ }
99
+ return _dir
100
+ }
101
+
102
+ function profileResolvedDir(): string | null {
103
+ if (_pdir !== undefined) return _pdir
104
+ _pdir = profileLogDir()
105
+ if (_pdir) {
106
+ try {
107
+ fs.mkdirSync(_pdir, { recursive: true })
108
+ } catch {
109
+ _pdir = null
110
+ }
111
+ }
112
+ return _pdir
113
+ }
114
+
115
+ /** Reset cached state (test helper). */
116
+ export function resetDebug(): void {
117
+ _dir = undefined
118
+ _pdir = undefined
119
+ profileStats = {}
120
+ screenLines = []
121
+ }
122
+
123
+ /** One on-screen console line: a timestamp plus a short label (e.g. `db open`). */
124
+ export type ScreenLine = { at: number; text: string }
125
+
126
+ /** Ring cap — the console shows a 5-line window over these, scrolled. */
127
+ const SCREEN_MAX = 200
128
+
129
+ let screenLines: ScreenLine[] = []
130
+ const screenListeners = new Set<() => void>()
131
+
132
+ /**
133
+ * Append one short label (e.g. `db open`) to the sidebar console's screen ring
134
+ * and notify subscribers. No-op unless a debug or profile logger is active, so
135
+ * a normal session pays nothing for it.
136
+ */
137
+ export function pushScreenLine(text: string): void {
138
+ if (!debugActive() && !profileActive()) return
139
+ screenLines.push({ at: Date.now(), text })
140
+ if (screenLines.length > SCREEN_MAX) screenLines.splice(0, screenLines.length - SCREEN_MAX)
141
+ for (const listener of screenListeners) {
142
+ try {
143
+ listener()
144
+ } catch {
145
+ // a subscriber must never break the ring feed
146
+ }
147
+ }
148
+ }
149
+
150
+ /** Newest-first copy of the screen ring (empty when nothing was pushed). */
151
+ export function readScreenLines(): ScreenLine[] {
152
+ return screenLines.slice().reverse()
153
+ }
154
+
155
+ /** Subscribe to screen-ring pushes; returns an unsubscribe fn. */
156
+ export function subscribeScreenLines(listener: () => void): () => void {
157
+ screenListeners.add(listener)
158
+ return () => {
159
+ screenListeners.delete(listener)
160
+ }
161
+ }
162
+
163
+ function appendLine(dir: string, file: string, line: Record<string, unknown>): void {
164
+ try {
165
+ const entry = { ts: new Date().toISOString(), ...line }
166
+ fs.appendFileSync(path.join(dir, file), JSON.stringify(entry) + "\n", "utf8")
167
+ } catch {
168
+ // silent — logging must never crash the plugin
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Append one JSON-line entry to the debug log.
174
+ * No-op when OES_DEBUG_OPENCODE is not set or falsy.
175
+ */
176
+ export function dbg(tag: string, msg: string, data?: unknown): void {
177
+ const dir = resolvedDir()
178
+ if (!dir) return
179
+ appendLine(dir, datedLogFile("debug"), {
180
+ tag,
181
+ msg,
182
+ ...(data !== undefined ? { data } : {}),
183
+ })
184
+ }
185
+
186
+ export type ProfileStats = { n: number; sum: number; max: number }
187
+
188
+ /** In-memory per-tag totals, updated only while OES_DEBUG_PROFILE is on. */
189
+ let profileStats: Record<string, ProfileStats> = {}
190
+
191
+ /** Lazily-computed or plain profile data — a factory keeps the call site free when off. */
192
+ type ProfileData = Record<string, unknown> | (() => Record<string, unknown>)
193
+
194
+ function resolveData(data: ProfileData | undefined): Record<string, unknown> | undefined {
195
+ const d = typeof data === "function" ? data() : data
196
+ return d && Object.keys(d).length > 0 ? d : undefined
197
+ }
198
+
199
+ function recordProfile(tag: string, ms: number, data?: ProfileData): void {
200
+ const dir = profileResolvedDir()
201
+ if (!dir) return
202
+ const s = (profileStats[tag] ??= { n: 0, sum: 0, max: 0 })
203
+ s.n += 1
204
+ s.sum += ms
205
+ if (ms > s.max) s.max = ms
206
+ const d = resolveData(data)
207
+ appendLine(dir, datedLogFile("profile"), {
208
+ tag,
209
+ ms,
210
+ ...(d !== undefined ? { data: d } : {}),
211
+ })
212
+ }
213
+
214
+ /**
215
+ * Measure fn with performance.now() and append one { tag, ms, data? } line to
216
+ * the profile log. No-op when OES_DEBUG_PROFILE is not set or falsy. Returns
217
+ * fn's result; throws propagate.
218
+ */
219
+ export function profile<T>(tag: string, fn: () => T, data?: ProfileData): T {
220
+ if (!profileResolvedDir()) return fn()
221
+ const t0 = performance.now()
222
+ try {
223
+ return fn()
224
+ } finally {
225
+ recordProfile(tag, performance.now() - t0, data)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Same as profile(), but for an async fn — measures until the returned promise
231
+ * settles. The fn is invoked immediately (the promise is what is timed).
232
+ */
233
+ export function profileAsync<T>(
234
+ tag: string,
235
+ fn: () => Promise<T>,
236
+ data?: ProfileData,
237
+ ): Promise<T> {
238
+ if (!profileResolvedDir()) return fn()
239
+ const t0 = performance.now()
240
+ return fn().then(
241
+ (value) => {
242
+ recordProfile(tag, performance.now() - t0, data)
243
+ return value
244
+ },
245
+ (err) => {
246
+ recordProfile(tag, performance.now() - t0, data)
247
+ throw err
248
+ },
249
+ )
250
+ }
251
+
252
+ /** Shallow copy of the per-tag profile totals. Empty when profiling is off. */
253
+ export function readProfileStats(): Record<string, ProfileStats> {
254
+ const out: Record<string, ProfileStats> = {}
255
+ for (const [tag, s] of Object.entries(profileStats)) out[tag] = { ...s }
256
+ return out
257
+ }
258
+
259
+ /**
260
+ * Append one `summary` line with the per-tag totals (n, total ms, avg, max) to
261
+ * the profile log. No-op when profiling is off or nothing was measured.
262
+ */
263
+ export function writeProfileSummary(): void {
264
+ const dir = profileResolvedDir()
265
+ if (!dir) return
266
+ const stats = readProfileStats()
267
+ const tags = Object.keys(stats)
268
+ if (tags.length === 0) return
269
+ const summary: Record<string, { n: number; total: number; avg: number; max: number }> = {}
270
+ for (const tag of tags) {
271
+ const s = stats[tag]
272
+ summary[tag] = {
273
+ n: s.n,
274
+ total: Math.round(s.sum * 10) / 10,
275
+ avg: Math.round((s.sum / s.n) * 10) / 10,
276
+ max: Math.round(s.max * 10) / 10,
277
+ }
278
+ }
279
+ appendLine(dir, datedLogFile("profile"), { tag: "summary", data: summary })
280
+ }
@@ -0,0 +1,79 @@
1
+ /** Host event type + kind. Never reads request or response bodies. */
2
+ import {
3
+ EVENT_FILE_EDITED,
4
+ EVENT_MESSAGE_PART_UPDATED,
5
+ EVENT_PART_DELTA,
6
+ EVENT_REASONING_DELTA,
7
+ EVENT_REASONING_STARTED,
8
+ EVENT_SESSION_CREATED,
9
+ EVENT_SESSION_DIFF,
10
+ EVENT_SESSION_IDLE,
11
+ EVENT_SESSION_STATUS,
12
+ EVENT_SESSION_UPDATED,
13
+ EVENT_STEP_ENDED,
14
+ EVENT_STEP_FAILED,
15
+ EVENT_STEP_STARTED,
16
+ EVENT_TEXT_DELTA,
17
+ EVENT_TOOL_CALLED,
18
+ EVENT_TOOL_ENDED,
19
+ EVENT_TOOL_FAILED,
20
+ EVENT_TOOL_SUCCESS,
21
+ } from "./constants/pware.oc.core.constants.eventType.js"
22
+
23
+ import {
24
+ EVENT_KIND_DB_REFRESH,
25
+ EVENT_KIND_FILE,
26
+ EVENT_KIND_FLOW,
27
+ EVENT_KIND_TOOL,
28
+ type EventKind,
29
+ } from "./constants/pware.oc.core.constants.eventKind.js"
30
+
31
+ export type { EventKind }
32
+
33
+ export function eventType(evt: unknown): string {
34
+ if (!evt || typeof evt !== "object") return ""
35
+ return String((evt as Record<string, unknown>).type ?? "").toLowerCase()
36
+ }
37
+
38
+ export function eventKind(type: string): EventKind | null {
39
+ const t = (type || "").toLowerCase()
40
+ if (!t) return null
41
+ if (t.includes(EVENT_FILE_EDITED) || t.includes(EVENT_SESSION_DIFF)) return EVENT_KIND_FILE
42
+ if (
43
+ t.includes(EVENT_TOOL_CALLED) ||
44
+ t.includes(EVENT_TOOL_SUCCESS) ||
45
+ t.includes(EVENT_TOOL_FAILED) ||
46
+ t.includes(EVENT_TOOL_ENDED)
47
+ ) {
48
+ return EVENT_KIND_TOOL
49
+ }
50
+ if (
51
+ t.includes(EVENT_SESSION_STATUS) ||
52
+ t.includes(EVENT_SESSION_IDLE) ||
53
+ t.includes(EVENT_SESSION_CREATED) ||
54
+ t.includes(EVENT_SESSION_UPDATED) ||
55
+ t.includes(EVENT_MESSAGE_PART_UPDATED) ||
56
+ t.includes(EVENT_STEP_STARTED) ||
57
+ t.includes(EVENT_STEP_ENDED) ||
58
+ t.includes(EVENT_STEP_FAILED)
59
+ ) {
60
+ return EVENT_KIND_DB_REFRESH
61
+ }
62
+ if (
63
+ t.includes(EVENT_TEXT_DELTA) ||
64
+ t.includes(EVENT_REASONING_DELTA) ||
65
+ t.includes(EVENT_REASONING_STARTED) ||
66
+ t.includes(EVENT_PART_DELTA) ||
67
+ t.endsWith(".delta")
68
+ ) {
69
+ return EVENT_KIND_FLOW
70
+ }
71
+ return null
72
+ }
73
+
74
+ export function shouldRefreshDb(type: string): boolean {
75
+ const t = (type || "").toLowerCase()
76
+ if (!t || t.includes(".delta")) return false
77
+ const kind = eventKind(t)
78
+ return kind === EVENT_KIND_TOOL || kind === EVENT_KIND_FILE || kind === EVENT_KIND_DB_REFRESH
79
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * pware.oc.core.glyph
3
+ *
4
+ * Every visual glyph as one complete definition: a character plus a semantic
5
+ * tone (and an optional blink flag). A glyph's char and colour can never drift
6
+ * apart because they come from the same switch. `markTone` / `stateGlyph` /
7
+ * `directionGlyph` are pure and theme-independent — the ui layer resolves a
8
+ * `ToneKey` to an actual RGBA through `toneColor` in
9
+ * `pware.oc.ui/pware.oc.ui.chrome.tsx`.
10
+ */
11
+ import { BLINK_TICKS } from "./pware.oc.core.timing.js"
12
+ import {
13
+ FLOW_RECV,
14
+ FLOW_TOOL,
15
+ FLOW_WAIT,
16
+ MARK_QUEUED,
17
+ PULSE_LIVE,
18
+ PULSE_STALE,
19
+ type AgentMark,
20
+ type FlowDir,
21
+ } from "./constants/pware.oc.core.constants.pulse.js"
22
+ import { STATUS_ERROR } from "./constants/pware.oc.core.constants.status.js"
23
+ import {
24
+ ROW_KIND_FILE,
25
+ ROW_KIND_GROUP,
26
+ type RowKind,
27
+ } from "./constants/pware.oc.core.constants.rowKind.js"
28
+
29
+ /** Semantic colour of a glyph or body — no theme values, just meaning. */
30
+ export type ToneKey = "text" | "textMuted" | "success" | "primary" | "warning" | "error"
31
+
32
+ /** One glyph as a complete definition: the character, its tone, and blink. */
33
+ export type GlyphSpec = {
34
+ char: string
35
+ tone: ToneKey
36
+ blink?: boolean
37
+ }
38
+
39
+ /** Same braille set as OpenCode TUI thinking spinner (`opentui-spinner`). */
40
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
41
+
42
+ /** Queued — waiting for a slot: the hourglass (the wait flow owns ◷). */
43
+ export const QUEUED_GLYPH = "⧗"
44
+
45
+ export function spinnerFrame(frame: number): string {
46
+ const i = ((frame % SPINNER_FRAMES.length) + SPINNER_FRAMES.length) % SPINNER_FRAMES.length
47
+ return SPINNER_FRAMES[i] ?? "⠋"
48
+ }
49
+
50
+ /** Cold-start "engage" bar length in glyph frames (GLYPH_TICK_MS × 12 ≈ 1s). */
51
+ export const ENGAGE_MIN_FRAMES = 12
52
+ /** Hard ceiling so the boot bar can never strand (GLYPH_TICK_MS × 50 = 4s). */
53
+ export const ENGAGE_MAX_FRAMES = 50
54
+
55
+ /** Fill ratio of the cold-start engage bar: linear to 1 at `ENGAGE_MIN_FRAMES`. */
56
+ export function engageFill(frame: number): number {
57
+ return Math.min(1, Math.max(0, frame) / ENGAGE_MIN_FRAMES)
58
+ }
59
+
60
+ /**
61
+ * The cold-start engage bar ends once the first real snapshot has landed
62
+ * (`ready`) after its minimum show time — or on the hard ceiling (no DB / no
63
+ * snapshot ever) so the row can never strand. Negative frames count as 0.
64
+ */
65
+ export function engageDone(frame: number, ready: boolean): boolean {
66
+ const n = Math.max(0, frame)
67
+ return (ready && n >= ENGAGE_MIN_FRAMES) || n >= ENGAGE_MAX_FRAMES
68
+ }
69
+
70
+ /** Options for {@link sinPulseAlpha} — one full fade cycle spans `periodFrames` tick frames. */
71
+ export type SinPulseAlphaOpts = {
72
+ periodFrames?: number
73
+ min?: number
74
+ max?: number
75
+ }
76
+
77
+ /**
78
+ * Sinusoidal fade pulse for glyph opacity: alpha eases `min → max → min` over one
79
+ * period. Defaults to `BLINK_TICKS * 2` frames (same wall-clock span as
80
+ * {@link flowBlinkOn}). Pure — inject the UI tick frame; the ui layer maps the
81
+ * result to OpenTUI `opacity` or a colour lerp.
82
+ */
83
+ export function sinPulseAlpha(frame: number, opts: SinPulseAlphaOpts = {}): number {
84
+ const period = Math.max(1, Math.round(opts.periodFrames ?? BLINK_TICKS * 2))
85
+ const lo = Math.min(opts.min ?? 0.25, opts.max ?? 1)
86
+ const hi = Math.max(opts.min ?? 0.25, opts.max ?? 1)
87
+ const phase = (Math.abs(frame) % period) / period
88
+ const wave = Math.sin(phase * Math.PI)
89
+ return lo + wave * (hi - lo)
90
+ }
91
+
92
+ /** ↑/↓ blink half-period in ticks (TICK_MS × BLINK_TICKS = 100ms). */
93
+ export function flowBlinkOn(frame: number): boolean {
94
+ return Math.floor(Math.abs(frame) / BLINK_TICKS) % 2 === 0
95
+ }
96
+
97
+ /** The tone a state mark carries: error red, queued/stale warning, live success, else muted. */
98
+ export function markTone(mark: AgentMark): ToneKey {
99
+ if (mark === STATUS_ERROR) return "error"
100
+ if (mark === MARK_QUEUED || mark === PULSE_STALE) return "warning"
101
+ if (mark === PULSE_LIVE) return "success"
102
+ return "textMuted"
103
+ }
104
+
105
+ /** The state glyph for an agent mark — spinner, cross, hourglass or dot, in one spec. */
106
+ export function stateGlyph(mark: AgentMark, frame = 0): GlyphSpec {
107
+ const char =
108
+ mark === STATUS_ERROR
109
+ ? "×"
110
+ : mark === MARK_QUEUED
111
+ ? QUEUED_GLYPH
112
+ : mark === PULSE_LIVE || mark === PULSE_STALE
113
+ ? spinnerFrame(frame)
114
+ : "•"
115
+ return { char, tone: markTone(mark) }
116
+ }
117
+
118
+ /** The direction glyph for a flow — ◷ waiting, ← receiving, → tool; null when quiet. */
119
+ export function directionGlyph(flow: FlowDir | null): GlyphSpec | null {
120
+ if (!flow) return null
121
+ if (flow === FLOW_WAIT) return { char: "◷", tone: "warning", blink: true }
122
+ if (flow === FLOW_RECV) return { char: "←", tone: "success", blink: true }
123
+ return { char: "→", tone: "primary", blink: true }
124
+ }
125
+
126
+ /** Body text tone for a row kind: quiet for files/groups, otherwise the mark tone. */
127
+ export function defaultBodyTone(
128
+ kind: RowKind,
129
+ mark: AgentMark | undefined,
130
+ current: boolean,
131
+ ): ToneKey {
132
+ if (kind === ROW_KIND_FILE || kind === ROW_KIND_GROUP) return "textMuted"
133
+ const tone = mark ? markTone(mark) : "textMuted"
134
+ return current && tone === "textMuted" ? "primary" : tone
135
+ }