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,1629 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createEffect, createMemo, createSignal, For, on, Show, onCleanup, type JSX } from "solid-js"
3
+ import { useTerminalDimensions } from "@opentui/solid"
4
+ import type { BoxRenderable } from "@opentui/core"
5
+ import type { TuiPluginApi, TuiTheme } from "@opencode-ai/plugin/tui"
6
+ import {
7
+ DraftFile,
8
+ emptyOmo,
9
+ listApprovals,
10
+ sessionForPlanFile,
11
+ } from "../pware.oc.omo/resolver/index.js"
12
+ import {
13
+ delegatesForSession,
14
+ groupDelegates,
15
+ type RuntimeSnapshot,
16
+ } from "../pware.oc.runtime/resolver/index.js"
17
+ import {
18
+ MY_WORK_ORDER,
19
+ approvalContinueHint,
20
+ dropDismissed,
21
+ groupMyWork,
22
+ myWorkLabel,
23
+ toApprovalItems,
24
+ toDraftDocItems,
25
+ toPlanItems,
26
+ toQuestionItems,
27
+ toSessionItems,
28
+ type MyWorkItem,
29
+ type MyWorkKind,
30
+ } from "../pware.oc.runtime/pware.oc.runtime.mywork.js"
31
+ import {
32
+ emptyDb,
33
+ emptyProjectFeed,
34
+ isRealSession,
35
+ mergeTools,
36
+ readProjectFeed,
37
+ type ProjectFeed,
38
+ } from "../pware.oc.opencode/resolver/index.js"
39
+ import { type DocView } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.doc.js"
40
+ import type { DelegateView } from "../pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.js"
41
+ import { enrichApprovalSessionStates } from "../pware.oc.runtime/pware.oc.runtime.mywork-enrich.js"
42
+ import {
43
+ ROW_LINE_FALLBACK,
44
+ ROW_LINE_RESERVE,
45
+ ROW_MIN,
46
+ ROW_RANK,
47
+ clampScrollOffset,
48
+ RT_ACTION_COL_WIDTH,
49
+ packSections,
50
+ panelRows,
51
+ rowsForPlan,
52
+ scrollByStep,
53
+ } from "../pware.oc.core/pware.oc.core.layout.js"
54
+ import { openReadonlyDb } from "../pware.oc.core/pware.oc.core.sqlite.js"
55
+ import {
56
+ MARK_QUEUED,
57
+ PULSE_LIVE,
58
+ PULSE_STALE,
59
+ } from "../pware.oc.core/constants/pware.oc.core.constants.pulse.js"
60
+ import {
61
+ STATUS_ARCHIVED,
62
+ TOOL_STATUS_RUNNING,
63
+ } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
64
+ import {
65
+ SESSION_STATUS_ARCHIVED,
66
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.js"
67
+ import {
68
+ ROW_KIND_AGENT,
69
+ ROW_KIND_DELEGATE,
70
+ ROW_KIND_FILE,
71
+ ROW_KIND_GROUP,
72
+ ROW_KIND_TOOL,
73
+ } from "../pware.oc.core/constants/pware.oc.core.constants.rowKind.js"
74
+ import {
75
+ QUESTION_KIND_ERROR,
76
+ QUESTION_KIND_INTERRUPTED,
77
+ QUESTION_KIND_QUESTION,
78
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.js"
79
+ import {
80
+ MY_WORK_GROUP_DISMISSED,
81
+ MY_WORK_GROUP_DRAFT_DOCS,
82
+ MY_WORK_GROUP_DRAFTING,
83
+ MY_WORK_GROUP_FINISHED,
84
+ MY_WORK_GROUP_PINNED,
85
+ MY_WORK_GROUP_PLANS,
86
+ MY_WORK_GROUP_READY_REVIEW,
87
+ MY_WORK_GROUP_READY_START,
88
+ MY_WORK_GROUP_SESSIONS,
89
+ } from "../pware.oc.core/constants/pware.oc.core.constants.myWork.js"
90
+ import { DOC_KIND_DRAFT, DOC_KIND_PLAN } from "../pware.oc.omo/constants/pware.oc.omo.constants.docKind.js"
91
+ import {
92
+ GROUP_GLYPH,
93
+ TAB_NEUTRAL_GLYPH,
94
+ tabAttentionGlyph,
95
+ engageDone,
96
+ engageFill,
97
+ fileLetterGlyph,
98
+ markTone,
99
+ myWorkGlyph,
100
+ reviewStateSuffix,
101
+ spinnerFrame,
102
+ type GlyphSpec,
103
+ type TabAttentionItem,
104
+ } from "./pware.oc.ui.glyphs.js"
105
+ import { dismissQuestion, kvRead, kvWrite, kvReadOne, kvWriteOne, readDismissedQuestions, ClickText, ContextActions, type ThemeColors } from "./pware.oc.ui.chrome.js"
106
+ import {
107
+ AgentLine,
108
+ FoldSection,
109
+ GroupSection,
110
+ RowList,
111
+ TabColumn,
112
+ OesStatusRow,
113
+ agentDisplayName,
114
+ useFold,
115
+ useReveal,
116
+ type RevealState,
117
+ type RowData,
118
+ } from "./pware.oc.ui.sections.js"
119
+ import { clipWidth } from "../pware.oc.core/pware.oc.core.width.js"
120
+ import { emptyPerf, readPerfSnapshot } from "../pware.oc.perf/pware.oc.perf.reader.js"
121
+ import { PerfPanel } from "../pware.oc.perf/pware.oc.perf.view.js"
122
+ import { shareBar } from "../pware.oc.perf/pware.oc.perf.charts.js"
123
+ import { StatRealtimeTimeline } from "../pware.oc.perf/pware.oc.perf.realtimeTimeline.js"
124
+ import { EventDriverSampler } from "../pware.oc.perf/pware.oc.perf.realtimeSampler.js"
125
+ import { readCpuRam } from "../pware.oc.perf/pware.oc.perf.realtimeCpuRam.js"
126
+ import { formatSelfLine, readRendererFps, readSelfStats, resetSelfStats, selfDiagActive, selfTime, setSelfFps } from "../pware.oc.perf/pware.oc.perf.self.js"
127
+ import {
128
+ decorateFiles,
129
+ fileFilter,
130
+ filesFromEvent,
131
+ mergeFiles,
132
+ sumDiff,
133
+ type FileView,
134
+ } from "../pware.oc.opencode/pware.oc.opencode.files.js"
135
+ import { onGitMarksChange } from "../pware.oc.core/git/pware.oc.core.git.js"
136
+ import { getOes } from "../pware.oc.core/pware.oc.core.oes.js"
137
+ import {
138
+ TAB_STATUS_SESSION_NOT_IN_DB,
139
+ tabStatus,
140
+ } from "../pware.oc.core/pware.oc.core.status.js"
141
+ import { createEventBus } from "../pware.oc.core/pware.oc.core.bus.js"
142
+ import { startRuntimeSource } from "../pware.oc.runtime/pware.oc.runtime.source.js"
143
+ import { openApprovalDialog, openDocDetail, openFileDetail, openFileListDialog, openQuestionDialog, openRealtimeCharts, openToolDetail } from "./pware.oc.ui.menudialogs.js"
144
+ import { startHostEventBridge } from "./pware.oc.ui.live.js"
145
+ import {
146
+ approvePlan,
147
+ openNewSessionPrompt,
148
+ openSessionSwitcher,
149
+ runStartWork,
150
+ selectSession,
151
+ } from "./pware.oc.ui.host.js"
152
+ import { dbg, debugActive, debugActiveDir, profile, profileActive, profileActiveDir, profileAsync, readScreenLines, subscribeScreenLines, writeProfileSummary } from "../pware.oc.core/pware.oc.core.debug.js"
153
+ import { getOpenCodeDbPath, samePath } from "../pware.oc.core/pware.oc.core.paths.js"
154
+ import {
155
+ FPS_READ_EVERY_TICKS,
156
+ GLYPH_TICK_MS,
157
+ MYWORK_BADGE_COOLDOWN_MS,
158
+ MYWORK_BADGE_MS,
159
+ NOW_MS,
160
+ SWITCH_TIMEOUT_MS,
161
+ TICK_MS,
162
+ TOKEN_RATE_WINDOW_MS,
163
+ } from "../pware.oc.core/pware.oc.core.timing.js"
164
+ import {
165
+ activeFlow,
166
+ applyFlow,
167
+ composeMark,
168
+ formatAge,
169
+ formatDuration,
170
+ hottestMark,
171
+ phaseAgeMs,
172
+ pulseAgeMs,
173
+ pushTokenTick,
174
+ tokenRate,
175
+ tokenRateBars,
176
+ toolFlow,
177
+ toolMark,
178
+ type AgentMark,
179
+ type FlowDir,
180
+ type FlowEntry,
181
+ type TokenTick,
182
+ type ToolHit,
183
+ } from "../pware.oc.core/pware.oc.core.pulse.js"
184
+ import {
185
+ EV_OES_SESSION_SELECT,
186
+ EV_OES_SNAPSHOT,
187
+ } from "../pware.oc.core/constants/pware.oc.core.constants.eventName.js"
188
+ import {
189
+ EV_OC_FILES_TOUCHED,
190
+ EV_OC_FLOW,
191
+ EV_OC_SESSION_ACTIVITY,
192
+ EV_OC_TOKENS_DELTA,
193
+ EV_OC_TOOL_HIT,
194
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.js"
195
+
196
+ export type SidebarProps = {
197
+ sessionId: string
198
+ api: TuiPluginApi
199
+ theme: TuiTheme
200
+ }
201
+
202
+ const KV_FOLD_AGENTS = "oes.fold.agents"
203
+ const KV_FOLD_DELEGATES = "oes.fold.delegates"
204
+ const KV_FOLD_TOOLS = "oes.fold.tools"
205
+ const KV_FOLD_FILES = "oes.fold.files"
206
+ const KV_FOLD_DRAFTS = "oes.fold.drafts"
207
+ const KV_TAB = "oes.tab"
208
+
209
+ const OES_TABS = ["mywork", "current", "sessions", "perf"] as const
210
+ type OesTab = (typeof OES_TABS)[number]
211
+
212
+ const TAB_LABELS: Record<string, string> = {
213
+ mywork: "My work",
214
+ sessions: "Project",
215
+ current: "Session",
216
+ perf: "Stats",
217
+ }
218
+
219
+ /** Event rows the bottom debug console shows at once (the ring keeps 200). */
220
+ const SCREEN_CONSOLE_VISIBLE = 5
221
+
222
+ /**
223
+ * The cold-start "engage" bar (and its success toast) plays once per plugin
224
+ * boot. The module latch survives sidebar remounts across route changes so a
225
+ * session switch never replays the animation.
226
+ */
227
+ let engageSeen = false
228
+
229
+ function emptyRuntime(): RuntimeSnapshot {
230
+ const dbPath = getOpenCodeDbPath()
231
+ return {
232
+ generatedAt: 0,
233
+ fingerprint: "",
234
+ scanStamp: "0",
235
+ db: emptyDb(dbPath),
236
+ omo: emptyOmo(),
237
+ omoConfig: { present: false, path: null, teamMode: null, agents: [] },
238
+ delegates: [],
239
+ openQuestions: [],
240
+ }
241
+ }
242
+
243
+ export function SidebarPanel(props: SidebarProps): JSX.Element {
244
+ const [snap, setSnap] = createSignal<RuntimeSnapshot>(emptyRuntime())
245
+ const [now, setNow] = createSignal(Date.now())
246
+ const [frame, setFrame] = createSignal(0)
247
+ const [glyphFrame, setGlyphFrame] = createSignal(0)
248
+ const [seen, setSeen] = createSignal<Record<string, number>>({})
249
+ const [busy, setBusy] = createSignal<Record<string, boolean>>({})
250
+ const [flow, setFlow] = createSignal<Record<string, FlowEntry>>({})
251
+ const [tab, setTab] = createSignal<OesTab>(kvReadOne(props.api, KV_TAB, "current", OES_TABS))
252
+ const dimensions = useTerminalDimensions()
253
+ /**
254
+ * Row width in columns, measured from the root box's computed layout width.
255
+ * `ref` fires before the first layout (width 0), so the oes-independent
256
+ * fallback holds until `onSizeChange` reports a real width. A scrollbar
257
+ * reserve is subtracted; the value is the same budget `lineMax` used to be.
258
+ */
259
+ const [lineWidth, setLineWidth] = createSignal(ROW_LINE_FALLBACK)
260
+ let rootBox: BoxRenderable | undefined
261
+ const rootRef = (node: BoxRenderable): void => {
262
+ rootBox = node
263
+ node.onSizeChange = () => {
264
+ const w = node.width
265
+ if (Number.isFinite(w) && w > 0) setLineWidth(Math.max(ROW_LINE_FALLBACK, w - ROW_LINE_RESERVE))
266
+ }
267
+ }
268
+ const lineMax = (): number => lineWidth()
269
+ const [liveTools, setLiveTools] = createSignal<Record<string, ToolHit>>({})
270
+ const [liveFiles, setLiveFiles] = createSignal<Record<string, FileView>>({})
271
+ const [tokenTicks, setTokenTicks] = createSignal<readonly TokenTick[]>([])
272
+ const [rtVersion, setRtVersion] = createSignal(0)
273
+ const [gitTick, setGitTick] = createSignal(0)
274
+ const [switching, setSwitching] = createSignal<{ id: string; at: number } | null>(null)
275
+ const [coldTab, setColdTab] = createSignal<string | null>(null)
276
+ const [consoleLines, setConsoleLines] = createSignal(readScreenLines())
277
+ const [consoleTop, setConsoleTop] = createSignal(0)
278
+ const consoleWindow = createMemo(() => {
279
+ const lines = consoleLines()
280
+ const top = clampScrollOffset(lines.length, SCREEN_CONSOLE_VISIBLE, consoleTop())
281
+ return lines.slice(top, top + SCREEN_CONSOLE_VISIBLE)
282
+ })
283
+ /** Console rows the sidebar must leave for the debug block: label + visible lines. */
284
+ const consoleRowSpan = () =>
285
+ selfDiagActive() ? 1 + Math.min(consoleLines().length, SCREEN_CONSOLE_VISIBLE) : 0
286
+
287
+ const engageBootFrame = glyphFrame()
288
+ const [engaging, setEngaging] = createSignal(!engageSeen)
289
+ const engageTick = (): number => Math.max(0, glyphFrame() - engageBootFrame)
290
+
291
+ const colors = (): ThemeColors => props.theme.current as unknown as ThemeColors
292
+
293
+ const requestRender = () => {
294
+ try {
295
+ profile("requestRender", () => props.api.renderer.requestRender())
296
+ } catch {
297
+ // teardown
298
+ }
299
+ }
300
+
301
+ const offConsole = subscribeScreenLines(() => {
302
+ setConsoleLines(readScreenLines())
303
+ if (consoleTop() > 0) setConsoleTop((top) => top + 1)
304
+ requestRender()
305
+ })
306
+ onCleanup(offConsole)
307
+
308
+ const onConsoleScroll = (e: { scroll?: { direction?: "up" | "down" | "left" | "right"; delta?: number } }) => {
309
+ const dir = e.scroll?.direction
310
+ if (dir !== "up" && dir !== "down") return
311
+ const step = Math.max(1, Math.round(Math.abs(e.scroll?.delta ?? 1)) || 1)
312
+ setConsoleTop((top) => scrollByStep(consoleLines().length, SCREEN_CONSOLE_VISIBLE, top, dir, step))
313
+ }
314
+
315
+ const bumpSeen = (id: string | null | undefined) => {
316
+ if (!id) return
317
+ setSeen((prev) => ({ ...prev, [id]: Date.now() }))
318
+ }
319
+
320
+ const apply = (next: RuntimeSnapshot) => {
321
+ setSnap(next)
322
+ requestRender()
323
+ }
324
+
325
+ /** Session switch with the tab status row shown until the target snapshot lands. */
326
+ const goSession = (id: string | null | undefined) => {
327
+ if (!id) return
328
+ selectSession(props.api, id)
329
+ if (id === props.sessionId) return
330
+ setSwitching({ id, at: Date.now() })
331
+ }
332
+
333
+ const projectDir = () => props.api.state.path.directory ?? null
334
+ const projectRoots = () => {
335
+ const dir = projectDir()
336
+ const tree = props.api.state.path.worktree ?? null
337
+ return [dir, tree].filter((p): p is string => Boolean(p))
338
+ }
339
+
340
+ const bus = createEventBus()
341
+ let watchedId = props.sessionId
342
+ let source = startRuntimeSource({
343
+ bus,
344
+ sessionId: watchedId,
345
+ projectRoot: projectDir(),
346
+ })
347
+
348
+ const refresh = () => profile("scan", () => selfTime("scan", () => source.refresh()))
349
+
350
+ const ingestFiles = (hits: FileView[]) => {
351
+ if (!hits.length) return
352
+ setLiveFiles((prev) => {
353
+ const next = { ...prev }
354
+ for (const f of hits) next[f.id] = f
355
+ return next
356
+ })
357
+ }
358
+
359
+ const hydrateDiff = () =>
360
+ profile("hydrate", () => {
361
+ try {
362
+ const fn = props.api.client?.session?.diff
363
+ if (typeof fn !== "function") return
364
+ const take = (res: unknown) => {
365
+ const o = res && typeof res === "object" ? (res as Record<string, unknown>) : null
366
+ const rows = o && (Array.isArray(o.data) ? o.data : Array.isArray(o.diff) ? o.diff : null)
367
+ const list = rows ?? (Array.isArray(res) ? res : null)
368
+ if (!list) return
369
+ ingestFiles(
370
+ filesFromEvent(
371
+ { type: "session.diff", properties: { sessionID: props.sessionId, diff: list } },
372
+ props.sessionId,
373
+ fileFilter(projectDir()),
374
+ ),
375
+ )
376
+ }
377
+ void profileAsync("rpc.diff", () =>
378
+ Promise.resolve()
379
+ .then(() => fn({ sessionID: props.sessionId }))
380
+ .then(take)
381
+ .catch(() => {}),
382
+ )
383
+ } catch {
384
+ // host client without session.diff
385
+ }
386
+ })
387
+
388
+ /** Rebind to the session the props point at. A stale select event only nudges a refresh. */
389
+ const remount = () =>
390
+ profile("remount", () => {
391
+ const id = props.sessionId
392
+ if (id === watchedId) {
393
+ refresh()
394
+ return
395
+ }
396
+ resetSelfStats()
397
+ watchedId = id
398
+ setLiveTools({})
399
+ setLiveFiles({})
400
+ setTokenTicks([])
401
+ source.setSession(id)
402
+ queueMicrotask(hydrateDiff)
403
+ })
404
+
405
+ createEffect(on(() => props.sessionId, remount, { defer: true }))
406
+
407
+ queueMicrotask(hydrateDiff)
408
+ // The monitor's initial forced emit (startRuntimeSource → startMonitor) runs
409
+ // before the EV_OES_SNAPSHOT listener below is registered, so the bus drops
410
+ // it. Re-emit once now that listeners exist — the snapshot was already
411
+ // computed and cached, so this is a cache hit, not a second full read.
412
+ queueMicrotask(refresh)
413
+
414
+ const offSnapshot = bus.on(EV_OES_SNAPSHOT, (evt) => {
415
+ const data = evt.data
416
+ if (!data || typeof data !== "object") return
417
+ const snapshot = (data as Record<string, unknown>).snapshot
418
+ if (!snapshot || typeof snapshot !== "object") return
419
+ apply(snapshot as RuntimeSnapshot)
420
+ const s = switching()
421
+ if (s) {
422
+ const rs = snapshot as RuntimeSnapshot
423
+ const landed = rs.db?.current?.id === s.id
424
+ const failed = Boolean(rs.db?.error) && rs.db?.error !== TAB_STATUS_SESSION_NOT_IN_DB
425
+ if (landed || failed || Date.now() - s.at > SWITCH_TIMEOUT_MS) setSwitching(null)
426
+ }
427
+ })
428
+
429
+ const offSessionActivity = bus.on(EV_OC_SESSION_ACTIVITY, (evt) => {
430
+ const data = evt.data
431
+ if (!data || typeof data !== "object") return
432
+ const payload = data as Record<string, unknown>
433
+ const busyFlag = payload.busy
434
+ if (typeof busyFlag !== "boolean") return
435
+ const id = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
436
+ if (busyFlag) bumpSeen(id)
437
+ setBusy((prev) => ({ ...prev, [id]: busyFlag }))
438
+ })
439
+
440
+ const offFlow = bus.on(EV_OC_FLOW, (evt) => {
441
+ const data = evt.data
442
+ if (!data || typeof data !== "object") return
443
+ const payload = data as Record<string, unknown>
444
+ const id = typeof payload.sessionId === "string" ? payload.sessionId : ""
445
+ const dir = payload.dir
446
+ if (!id || (dir !== "recv" && dir !== "wait" && dir !== "tool" && dir !== "clear")) return
447
+ if (dir !== "clear") bumpSeen(id)
448
+ const at = Date.now()
449
+ setFlow((prev) => applyFlow(prev, id, dir, at))
450
+ })
451
+
452
+ const offToolHit = bus.on(EV_OC_TOOL_HIT, (evt) => {
453
+ const data = evt.data
454
+ if (!data || typeof data !== "object") return
455
+ const payload = data as Record<string, unknown>
456
+ const hit = payload.hit
457
+ if (!hit || typeof hit !== "object") return
458
+ const h = hit as { id?: unknown }
459
+ if (typeof h.id !== "string" || !h.id) return
460
+ const toolHit = hit as ToolHit
461
+ bumpSeen(toolHit.sessionId ?? props.sessionId)
462
+ setLiveTools((prev) => ({ ...prev, [toolHit.id]: toolHit }))
463
+ })
464
+
465
+ const offFilesTouched = bus.on(EV_OC_FILES_TOUCHED, (evt) => {
466
+ const data = evt.data
467
+ if (!data || typeof data !== "object") return
468
+ const payload = data as Record<string, unknown>
469
+ const files = payload.files
470
+ if (!Array.isArray(files)) return
471
+ const sessionId = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
472
+ bumpSeen(sessionId)
473
+ ingestFiles(files as FileView[])
474
+ })
475
+
476
+ const offTokensDelta = bus.on(EV_OC_TOKENS_DELTA, (evt) => {
477
+ const data = evt.data
478
+ if (!data || typeof data !== "object") return
479
+ const payload = data as Record<string, unknown>
480
+ const tokens = payload.tokens
481
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return
482
+ const at = Date.now()
483
+ setTokenTicks((prev) => pushTokenTick(prev, at, tokens, TOKEN_RATE_WINDOW_MS))
484
+ const sessionId = typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : props.sessionId
485
+ const kind = payload.kind === "reasoning" ? "reasoning" : "out"
486
+ rtTimeline.ingestEstimate(sessionId, kind, tokens, at)
487
+ })
488
+
489
+ const offSessionSelect = bus.on(EV_OES_SESSION_SELECT, () => remount())
490
+
491
+ const bridge = startHostEventBridge({
492
+ api: props.api,
493
+ bus,
494
+ sessionId: () => props.sessionId,
495
+ projectRoot: projectDir,
496
+ onRender: requestRender,
497
+ })
498
+
499
+ // One shared realtime timeline (tokens / cache / CPU / RAM). Exact cumulative
500
+ // totals arrive on session.updated; estimated stream deltas arrive on the bus;
501
+ // the UI tick below folds everything into a grid sample every TICK_MS, so the
502
+ // chart's right edge always reaches now instead of waiting for an event.
503
+ const rtTimeline = StatRealtimeTimeline.build(null)
504
+ const rtSampler = EventDriverSampler.create(rtTimeline, (handler) => {
505
+ try {
506
+ const on = props.api.event.on as (
507
+ name: string,
508
+ cb: (evt: unknown, meta?: { directory?: unknown }) => void,
509
+ ) => unknown
510
+ const off = on("session.updated", (evt, meta) => {
511
+ const dir = projectDir()
512
+ if (dir && meta?.directory && !samePath(String(meta.directory), dir)) return
513
+ handler(evt)
514
+ })
515
+ return typeof off === "function" ? () => off() : () => {}
516
+ } catch {
517
+ return () => {}
518
+ }
519
+ })
520
+ rtSampler.start()
521
+
522
+ let tickCount = 0
523
+ const tick = setInterval(() => {
524
+ profile("tick", () => {
525
+ selfTime("tick", () => {
526
+ // Coarse: ages/marks recompute at most once a second; the spinner phase
527
+ // (`frame`) still animates every tick. Returning `prev` skips the cascade.
528
+ setNow((prev) => {
529
+ const n = Date.now()
530
+ return n - prev >= NOW_MS ? n : prev
531
+ })
532
+ setFrame((n) => n + 1)
533
+ // Realtime grid: CPU/RAM are read here and the timeline folds one sample.
534
+ const at = Date.now()
535
+ rtTimeline.ingestCpuRam(readCpuRam(), at)
536
+ rtTimeline.tick(at)
537
+ setRtVersion((n) => n + 1)
538
+ })
539
+ tickCount += 1
540
+ if (selfDiagActive() && tickCount % FPS_READ_EVERY_TICKS === 0) {
541
+ const r = readRendererFps(props.api.renderer)
542
+ setSelfFps(r.fps, r.frameMs)
543
+ }
544
+ })
545
+ // Periodic floor for the My-work tab light — outside `profile("tick")` so
546
+ // the scan never pollutes the tick budget; snapshot changes rescan sooner.
547
+ maybeScanBadge(MYWORK_BADGE_MS)
548
+ }, TICK_MS)
549
+
550
+ /** Fast glyph heartbeat — spinners and direction flows step at `GLYPH_TICK_MS`. */
551
+ const glyphTick = setInterval(() => setGlyphFrame((n) => n + 1), GLYPH_TICK_MS)
552
+
553
+ const offGit = onGitMarksChange(() => {
554
+ profile("git", () => {
555
+ setGitTick((n) => n + 1)
556
+ queueMicrotask(requestRender)
557
+ })
558
+ })
559
+
560
+ createEffect(() => {
561
+ profile("render", () => {
562
+ frame()
563
+ now()
564
+ flow()
565
+ liveTools()
566
+ liveFiles()
567
+ queueMicrotask(requestRender)
568
+ })
569
+ })
570
+
571
+ createEffect(() => {
572
+ profile("render", () => {
573
+ glyphFrame()
574
+ queueMicrotask(requestRender)
575
+ })
576
+ })
577
+
578
+ // Cold tabs show their loading row for one short beat, then compute for real.
579
+ createEffect(() => {
580
+ const t = coldTab()
581
+ if (!t) return
582
+ const timer = setTimeout(() => setColdTab(null), 100)
583
+ onCleanup(() => clearTimeout(timer))
584
+ })
585
+
586
+ // End the boot bar once the first real snapshot landed after the minimum
587
+ // show time (or the hard ceiling) — then toast exactly once per boot.
588
+ createEffect(() => {
589
+ if (!engaging()) return
590
+ if (engageDone(engageTick(), Boolean(snap().fingerprint))) {
591
+ setEngaging(false)
592
+ engageSeen = true
593
+ try {
594
+ props.api.ui.toast({
595
+ message: "OpenCode Extended Sidebar Loaded. Engage!",
596
+ variant: "success",
597
+ duration: 7000,
598
+ })
599
+ } catch {
600
+ // host without toast
601
+ }
602
+ requestRender()
603
+ }
604
+ })
605
+
606
+ onCleanup(() => {
607
+ clearInterval(tick)
608
+ clearInterval(glyphTick)
609
+ rtSampler.stop()
610
+ bridge.stop()
611
+ offSnapshot()
612
+ offSessionActivity()
613
+ offFlow()
614
+ offToolHit()
615
+ offFilesTouched()
616
+ offTokensDelta()
617
+ offSessionSelect()
618
+ source.stop()
619
+ offGit()
620
+ writeProfileSummary()
621
+ })
622
+
623
+ const rowMark = (
624
+ lifecycle: string | null | undefined,
625
+ archived: boolean,
626
+ isBusy: boolean,
627
+ ...stamps: Array<number | null | undefined>
628
+ ): AgentMark =>
629
+ composeMark({
630
+ lifecycle,
631
+ archived,
632
+ busy: isBusy,
633
+ ageMs: pulseAgeMs(now(), ...stamps),
634
+ })
635
+
636
+ const mainMark = createMemo(() => {
637
+ const m = snap().db.main
638
+ if (!m) return MARK_QUEUED
639
+ return rowMark(
640
+ m.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
641
+ m.status === SESSION_STATUS_ARCHIVED,
642
+ Boolean(busy()[m.id]),
643
+ m.timeUpdated,
644
+ seen()[m.id],
645
+ )
646
+ })
647
+
648
+ const rowFlow = (id: string | null | undefined, isBusy: boolean): FlowDir | null =>
649
+ activeFlow(id ? flow()[id] : undefined, now(), isBusy)
650
+
651
+ const mainFlow = createMemo(() => {
652
+ const m = snap().db.main
653
+ return rowFlow(m?.id, Boolean(m && busy()[m.id]))
654
+ })
655
+
656
+ const currentRow = createMemo(() => snap().db.current)
657
+
658
+ const currentMark = createMemo(() => {
659
+ const c = currentRow()
660
+ if (!c) return MARK_QUEUED
661
+ return rowMark(
662
+ c.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
663
+ c.status === SESSION_STATUS_ARCHIVED,
664
+ Boolean(busy()[c.id]),
665
+ c.timeUpdated,
666
+ seen()[c.id],
667
+ )
668
+ })
669
+
670
+ const currentFlow = createMemo(() => {
671
+ const c = currentRow()
672
+ return rowFlow(c?.id, Boolean(c && busy()[c.id]))
673
+ })
674
+
675
+ const sessionDelegates = createMemo(() => delegatesForSession(snap(), props.sessionId))
676
+
677
+ const delegateMark = (d: DelegateView): AgentMark =>
678
+ rowMark(
679
+ d.status,
680
+ d.archived,
681
+ Boolean(d.sessionId && busy()[d.sessionId]),
682
+ d.timeUpdated,
683
+ d.sessionId ? seen()[d.sessionId] : null,
684
+ )
685
+
686
+ const err = createMemo(() => {
687
+ const e = snap().db.error
688
+ if (!e || e === "session not in db yet") return null
689
+ return e
690
+ })
691
+
692
+ const delegatesLive = createMemo(() => {
693
+ let n = 0
694
+ for (const d of snap().delegates) {
695
+ const m = delegateMark(d)
696
+ if (m === PULSE_LIVE || m === PULSE_STALE) n += 1
697
+ }
698
+ return n
699
+ })
700
+
701
+ const foldAgents = useFold(props.api, KV_FOLD_AGENTS, { after: requestRender })
702
+ const foldDelegates = useFold(props.api, KV_FOLD_DELEGATES, { after: requestRender })
703
+ const foldTools = useFold(props.api, KV_FOLD_TOOLS, { after: requestRender })
704
+ const foldFiles = useFold(props.api, KV_FOLD_FILES, { after: requestRender })
705
+ const foldDrafts = useFold(props.api, KV_FOLD_DRAFTS, { after: requestRender })
706
+
707
+ const myWorkFold = {} as Record<MyWorkKind, ReturnType<typeof useFold>>
708
+ for (const kind of MY_WORK_ORDER) {
709
+ // Errors and Dismissed questions are noise until you want them — start collapsed.
710
+ myWorkFold[kind] = useFold(props.api, `oes.fold.mywork.${kind}`, {
711
+ after: requestRender,
712
+ defaultOpen: kind !== QUESTION_KIND_ERROR && kind !== MY_WORK_GROUP_DISMISSED,
713
+ })
714
+ }
715
+
716
+ // Reveal state is hoisted, not owned by GroupSection: the My work memos
717
+ // read the `now()` clock, so <For> reconciles them to fresh objects
718
+ // every second — a reveal signal created inside the component would be
719
+ // thrown away with it and the "… +N more" click would collapse on its own.
720
+ const myWorkReveal = {} as Record<MyWorkKind, RevealState>
721
+ for (const kind of MY_WORK_ORDER) {
722
+ myWorkReveal[kind] = useReveal(2)
723
+ }
724
+
725
+ const oes = () => getOes(projectDir())
726
+ const tools = createMemo(() => mergeTools(snap().db.tools, liveTools(), now(), oes().toolFetch))
727
+
728
+ /**
729
+ * Project-wide feed — tools/files from every main session. The DB queries run
730
+ * only while the Sessions tab is open; elsewhere the memo stays empty.
731
+ */
732
+ const projectFeed = createMemo<ProjectFeed>(() => {
733
+ if (tab() !== "sessions" || coldTab() === "sessions") return emptyProjectFeed()
734
+ const db = snap().db
735
+ return readProjectFeed({
736
+ dbPath: db.dbPath,
737
+ sessionIds: db.recent.map((s) => s.id),
738
+ toolLimit: oes().toolFetch,
739
+ filter: fileFilter(projectDir()),
740
+ })
741
+ })
742
+ const projectFilesStat = createMemo(() => sumDiff(projectFeed().files))
743
+
744
+ const filesAll = createMemo(() => {
745
+ gitTick()
746
+ return decorateFiles(mergeFiles(snap().db.files ?? [], liveFiles()), projectDir(), {
747
+ git: tab() === "current" && foldFiles.open(),
748
+ })
749
+ })
750
+ const filesStat = createMemo(() => sumDiff(filesAll()))
751
+
752
+ const pickTab = (next: string) => {
753
+ if (!(OES_TABS as readonly string[]).includes(next)) return
754
+ const picked = next as OesTab
755
+ if (picked !== tab()) {
756
+ setTab(picked)
757
+ kvWriteOne(props.api, KV_TAB, picked)
758
+ // Cold start: the tab's heavy reads run after the status row shows, so
759
+ // the loading line is actually visible instead of a one-frame flash.
760
+ if (picked === "mywork" || picked === "sessions" || picked === "perf") {
761
+ setColdTab(picked)
762
+ }
763
+ }
764
+ if (picked === "current" || picked === "perf") refresh()
765
+ requestRender()
766
+ }
767
+
768
+ const omoPresent = createMemo(() => snap().omo.present)
769
+
770
+ /** Drafts under `.omo/drafts/` that this session wrote — last five inline, the full list behind "view all". */
771
+ const draftsAll = createMemo<DocView[]>(() => {
772
+ if (tab() !== "current" || !omoPresent() || !props.sessionId) return []
773
+ now() // re-scan while open; the docs cache TTL gates the filesystem read
774
+ const db = openReadonlyDb(snap().db.dbPath)
775
+ if (!db) return [] // no writer attribution without the DB
776
+ try {
777
+ return DraftFile.list(projectDir(), props.sessionId, { db })
778
+ } catch (e) {
779
+ dbg("drafts", "error", String(e))
780
+ return []
781
+ }
782
+ })
783
+ const drafts = createMemo(() => draftsAll().slice(0, 5))
784
+
785
+ /** Open `question` tools anywhere in this project — the "answer me" queue. */
786
+ const myWorkQuestions = createMemo<MyWorkItem[]>(() => {
787
+ if (tab() !== "mywork" || coldTab() === "mywork") return []
788
+ try {
789
+ return dropDismissed(
790
+ toQuestionItems(snap().openQuestions),
791
+ readDismissedQuestions(props.api),
792
+ )
793
+ } catch (e) {
794
+ dbg("mywork.questions", "error", String(e))
795
+ return []
796
+ }
797
+ })
798
+
799
+ /** OMO plans/drafts in the action groups, enriched with planner-session state, plus the Draft docs archive. */
800
+ const myWorkApprovals = createMemo<MyWorkItem[]>(() => {
801
+ if (tab() !== "mywork" || coldTab() === "mywork" || !omoPresent()) return []
802
+ now() // re-scan while open; the plans cache TTL gates the filesystem read
803
+ const dir = projectDir()
804
+ try {
805
+ const buckets = listApprovals(dir)
806
+ return [
807
+ ...toApprovalItems(
808
+ enrichApprovalSessionStates(
809
+ [
810
+ ...buckets.readyReview,
811
+ ...buckets.readyStart,
812
+ ...buckets.finished,
813
+ ...buckets.drafting,
814
+ ],
815
+ {
816
+ dbPath: snap().db.dbPath,
817
+ projectRoot: dir,
818
+ },
819
+ ),
820
+ ),
821
+ // Draft docs / Plans are not queue items — no session enrichment needed.
822
+ ...toDraftDocItems(buckets.draftDocs),
823
+ ...toPlanItems(buckets.plans),
824
+ ]
825
+ } catch (e) {
826
+ dbg("mywork.approvals", "error", String(e))
827
+ return []
828
+ }
829
+ })
830
+
831
+ /** Recent main sessions (running and idle) — the "Sessions" group. */
832
+ const myWorkRunning = createMemo<MyWorkItem[]>(() => {
833
+ if (tab() !== "mywork" || coldTab() === "mywork") return []
834
+ return toSessionItems(snap().db.recent)
835
+ })
836
+
837
+ const myWorkItems = createMemo<MyWorkItem[]>(() => [
838
+ ...myWorkQuestions(),
839
+ ...myWorkRunning(),
840
+ ...myWorkApprovals(),
841
+ ])
842
+
843
+ const myWorkGroups = createMemo(() => groupMyWork(myWorkItems()))
844
+
845
+ /** My-work tab light — one scan pipeline for every tab (see `maybeScanBadge`). */
846
+ const [myWorkAttn, setMyWorkAttn] = createSignal<readonly TabAttentionItem[]>([])
847
+ let lastMyWorkScan = 0
848
+
849
+ const scanMyWorkBadge = () => {
850
+ const items: TabAttentionItem[] = []
851
+ try {
852
+ const dismissed = readDismissedQuestions(props.api)
853
+ const open = snap().openQuestions
854
+ for (const q of open) {
855
+ if (dismissed.has(q.partId)) continue
856
+ items.push({ kind: q.kind, ended: q.ended })
857
+ }
858
+ } catch (e) {
859
+ dbg("mywork.badge", "error", String(e))
860
+ }
861
+ if (omoPresent()) {
862
+ try {
863
+ const buckets = listApprovals(projectDir())
864
+ if (buckets.readyReview.length > 0) items.push({ kind: MY_WORK_GROUP_READY_REVIEW })
865
+ if (buckets.readyStart.length > 0) items.push({ kind: MY_WORK_GROUP_READY_START })
866
+ } catch (e) {
867
+ dbg("mywork.badge", "error", String(e))
868
+ }
869
+ }
870
+ setMyWorkAttn(items)
871
+ }
872
+
873
+ const maybeScanBadge = (minGapMs: number) => {
874
+ const at = Date.now()
875
+ if (at - lastMyWorkScan < minGapMs) return
876
+ lastMyWorkScan = at
877
+ scanMyWorkBadge()
878
+ }
879
+
880
+ const myWorkTabGlyph = createMemo<GlyphSpec>(() => tabAttentionGlyph(myWorkAttn()))
881
+
882
+ createEffect(() => {
883
+ snap()
884
+ maybeScanBadge(MYWORK_BADGE_COOLDOWN_MS)
885
+ })
886
+
887
+ /**
888
+ * Rows are handed out on every render: chrome that always costs a line is
889
+ * counted first, then `packSections` splits what is left.
890
+ */
891
+ const rowPlan = createMemo(() => {
892
+ try {
893
+ const o = oes()
894
+ const t = tab()
895
+ const omo = omoPresent()
896
+ const sections: { key: string; want: number; min: number; rank: number }[] = []
897
+ let fixed = 2 // OES status line + brand/tabs row
898
+ if (selfDiagActive()) fixed += 1 // self line — only while debug/profile is on
899
+ if (modeLine()) fixed += 1 // debug/profile flag row
900
+ if (modeDirLine()) fixed += 1
901
+ if (engaging()) fixed += 1 // cold-start engage bar row
902
+ fixed += consoleRowSpan() // bottom dbg console — label + visible event rows
903
+ let blocks = 0
904
+
905
+ const header = () => {
906
+ blocks += 1
907
+ fixed += 1
908
+ }
909
+ const section = (folded: boolean, key: string, want: number, min: number, rank: number) => {
910
+ header()
911
+ if (!folded) sections.push({ key, want, min, rank })
912
+ }
913
+
914
+ if (t === "sessions") {
915
+ header()
916
+ fixed += foldAgents.open() ? 1 : 0
917
+ const feed = projectFeed()
918
+ if (feed.tools.length > 0) section(!foldTools.open(), "tools", o.toolRows, ROW_MIN.tools, ROW_RANK.tools)
919
+ if (feed.files.length > 0) section(!foldFiles.open(), "files", o.fileRows, ROW_MIN.files, ROW_RANK.files)
920
+ if (omo) section(!foldDelegates.open(), "delegates", 6, ROW_MIN.delegates, ROW_RANK.delegates)
921
+ } else if (t === "current") {
922
+ header()
923
+ fixed += foldAgents.open() ? 1 : 0
924
+ section(!foldDelegates.open(), "delegates", 6, ROW_MIN.delegates, ROW_RANK.delegates)
925
+ section(!foldTools.open(), "tools", o.toolRows, ROW_MIN.tools, ROW_RANK.tools)
926
+ section(!foldFiles.open(), "files", o.fileRows, ROW_MIN.files, ROW_RANK.files)
927
+ if (omo && draftsAll().length > 0) {
928
+ section(!foldDrafts.open(), "drafts", Math.min(5, draftsAll().length), ROW_MIN.drafts, ROW_RANK.drafts)
929
+ }
930
+ } else if (t === "mywork") {
931
+ header()
932
+ for (const g of myWorkGroups()) {
933
+ const fold = myWorkFold[g.kind]
934
+ if (!fold) continue
935
+ section(!fold.open(), `mywork.${g.kind}`, g.items.length, ROW_MIN.mywork, ROW_RANK.mywork)
936
+ }
937
+ } else {
938
+ // Perf lays out its own sections and caps itself with `perfRows` in
939
+ // its own view; no row-plan sections are handed out here.
940
+ }
941
+ fixed += Math.max(0, blocks - 1) // one blank row between OES sections
942
+
943
+ const height = dimensions()?.height
944
+ return packSections(panelRows(typeof height === "number" ? height : 24), fixed, sections)
945
+ } catch (e) {
946
+ dbg("rowplan", "error", String(e))
947
+ return {}
948
+ }
949
+ }, {})
950
+
951
+ const rowsFor = (key: string, fallback: number): number => {
952
+ try {
953
+ return rowsForPlan(rowPlan(), key, fallback)
954
+ } catch {
955
+ return fallback
956
+ }
957
+ }
958
+
959
+ const projectToolsReveal = useReveal(Math.max(1, oes().toolRows))
960
+ const toolsReveal = useReveal(Math.max(1, oes().toolRows))
961
+ const delegateReveal = useReveal(4)
962
+ const currentDelegatesReveal = useReveal(4)
963
+
964
+ /**
965
+ * A browsable OMO document (Draft docs / Plans archive) opens the same
966
+ * picker the action rows get: Navigate to session first (the session that
967
+ * wrote the file), then the preview row. No Approve — an archive row is
968
+ * never a sign-off queue. Start work is offered only for the Plans
969
+ * archive (via the `startWork` flag); Draft docs stays browse-only.
970
+ */
971
+ const openDocPicker = (item: { name: string; rel: string }, doc: DocView, docsLabel: string, startWork: boolean = false) => {
972
+ const db = openReadonlyDb(snap().db.dbPath)
973
+ const sessionId = db ? sessionForPlanFile(db, item.rel) : null
974
+ openApprovalDialog(props.api, {
975
+ title: item.name,
976
+ sessionId,
977
+ continueHint: approvalContinueHint(sessionId, Boolean(db)),
978
+ onContinue: (sid) => selectSession(props.api, sid),
979
+ onStartWork: (mode) => runStartWork(props.api, props.sessionId, mode, item.name),
980
+ onDocs: () => openDocDetail(props.api, doc, projectRoots(), colors()),
981
+ showApprove: false,
982
+ showStartWork: startWork,
983
+ docsLabel,
984
+ })
985
+ }
986
+
987
+ const myWorkRow = (item: MyWorkItem): RowData => {
988
+ if (item.kind === MY_WORK_GROUP_SESSIONS || item.kind === MY_WORK_GROUP_PINNED) {
989
+ const isBusy = Boolean(item.sessionId && busy()[item.sessionId])
990
+ return {
991
+ kind: ROW_KIND_AGENT,
992
+ mark: rowMark(
993
+ item.status === SESSION_STATUS_ARCHIVED ? STATUS_ARCHIVED : null,
994
+ item.status === SESSION_STATUS_ARCHIVED,
995
+ isBusy,
996
+ item.timeUpdated,
997
+ item.sessionId ? seen()[item.sessionId] : null,
998
+ ),
999
+ dirSlot: true,
1000
+ flow: rowFlow(item.sessionId, isBusy),
1001
+ name: item.title,
1002
+ current: item.sessionId === props.sessionId,
1003
+ suffix: item.sessionId === props.sessionId ? "[C]" : undefined,
1004
+ onSelect: () => goSession(item.sessionId),
1005
+ // Pin affordance — placeholder until the pinned backend is wired.
1006
+ link: item.kind === MY_WORK_GROUP_SESSIONS ? { label: "P", onPick: () => {} } : undefined,
1007
+ }
1008
+ }
1009
+ if (item.kind === MY_WORK_GROUP_DRAFT_DOCS) {
1010
+ const doc: DocView = {
1011
+ kind: DOC_KIND_DRAFT,
1012
+ name: item.name,
1013
+ rel: item.rel,
1014
+ updatedAt: item.updatedAt,
1015
+ sizeBytes: 0,
1016
+ previewable: true,
1017
+ }
1018
+ return {
1019
+ kind: ROW_KIND_FILE,
1020
+ glyph: myWorkGlyph(item.kind),
1021
+ name: item.name,
1022
+ onSelect: () => openDocPicker(item, doc, "Preview draft file"),
1023
+ }
1024
+ }
1025
+ if (item.kind === MY_WORK_GROUP_PLANS) {
1026
+ const doc: DocView = {
1027
+ kind: DOC_KIND_PLAN,
1028
+ name: item.name,
1029
+ rel: item.rel,
1030
+ updatedAt: item.updatedAt,
1031
+ sizeBytes: 0,
1032
+ previewable: true,
1033
+ }
1034
+ return {
1035
+ kind: ROW_KIND_FILE,
1036
+ glyph: myWorkGlyph(item.kind),
1037
+ name: item.name,
1038
+ onSelect: () => openDocPicker(item, doc, "Preview plan file", true),
1039
+ }
1040
+ }
1041
+ if ("sessionId" in item) {
1042
+ const dismissible = item.kind === QUESTION_KIND_INTERRUPTED || item.kind === QUESTION_KIND_ERROR
1043
+ return {
1044
+ kind: ROW_KIND_AGENT,
1045
+ glyph: myWorkGlyph(item.kind),
1046
+ name: item.title,
1047
+ subline: item.reason ?? undefined,
1048
+ onSelect: dismissible
1049
+ ? () =>
1050
+ openQuestionDialog(props.api, {
1051
+ title: item.title,
1052
+ sessionId: item.sessionId,
1053
+ onNavigate: (sid) => goSession(sid),
1054
+ onDismiss: () => {
1055
+ dismissQuestion(props.api, item.partId)
1056
+ requestRender()
1057
+ },
1058
+ })
1059
+ : () => goSession(item.sessionId),
1060
+ }
1061
+ }
1062
+ const reviewLabel = reviewStateSuffix(item.review)
1063
+ const drafting = item.kind === MY_WORK_GROUP_DRAFTING
1064
+ const showApprove = item.kind === MY_WORK_GROUP_READY_REVIEW
1065
+ const showStartWork =
1066
+ item.kind === MY_WORK_GROUP_READY_REVIEW || item.kind === MY_WORK_GROUP_READY_START
1067
+ const doc: DocView = {
1068
+ kind: DOC_KIND_DRAFT,
1069
+ name: item.name,
1070
+ rel: item.rel,
1071
+ updatedAt: item.updatedAt,
1072
+ sizeBytes: 0,
1073
+ previewable: true,
1074
+ }
1075
+ return {
1076
+ kind: ROW_KIND_FILE,
1077
+ glyph: myWorkGlyph(item.kind),
1078
+ name: item.name,
1079
+ suffix: reviewLabel || undefined,
1080
+ onSelect: () => {
1081
+ const db = openReadonlyDb(snap().db.dbPath)
1082
+ const sessionId = db ? sessionForPlanFile(db, item.rel) : null
1083
+ const base: Parameters<typeof openApprovalDialog>[1] = {
1084
+ title: item.name,
1085
+ sessionId,
1086
+ continueHint: approvalContinueHint(sessionId, Boolean(db)),
1087
+ onContinue: (sid) => selectSession(props.api, sid),
1088
+ onApprove: (sid) => approvePlan(props.api, sid),
1089
+ onStartWork: (mode) => runStartWork(props.api, props.sessionId, mode, item.name),
1090
+ onDocs: () => openDocDetail(props.api, doc, projectRoots(), colors()),
1091
+ }
1092
+ if (drafting) {
1093
+ openApprovalDialog(props.api, {
1094
+ ...base,
1095
+ showApprove: false,
1096
+ showStartWork: false,
1097
+ docsLabel: "Preview plan file",
1098
+ })
1099
+ return
1100
+ }
1101
+ openApprovalDialog(props.api, { ...base, showApprove, showStartWork })
1102
+ },
1103
+ }
1104
+ }
1105
+
1106
+ /** Perf SQLite scan runs only while this tab is open. */
1107
+ const perf = createMemo(() => {
1108
+ if (tab() !== "perf" || coldTab() === "perf") return emptyPerf(props.sessionId)
1109
+ const o = oes()
1110
+ const history = o.perfHistory > 0
1111
+ ? snap()
1112
+ .db.recent.filter((s) => s.id !== props.sessionId)
1113
+ .slice(0, o.perfHistory)
1114
+ .map((s) => ({ id: s.id, title: s.title }))
1115
+ : []
1116
+ return readPerfSnapshot({
1117
+ dbPath: snap().db.dbPath,
1118
+ sessionId: props.sessionId,
1119
+ turns: o.perfTurns,
1120
+ history,
1121
+ cacheKey: `${props.sessionId}::${snap().scanStamp}::${o.perfTurns}`,
1122
+ })
1123
+ })
1124
+
1125
+ const tabStatusFor = (tab: OesTab) =>
1126
+ tabStatus({
1127
+ tab,
1128
+ currentId: snap().db.current?.id ?? null,
1129
+ dbError: snap().db.error,
1130
+ dbPresent: snap().db.present,
1131
+ switching: switching()?.id ?? null,
1132
+ cold: coldTab() === tab,
1133
+ perfError: tab === "perf" ? perf().error : null,
1134
+ perfTurns: tab === "perf" ? perf().totals.turns : 0,
1135
+ })
1136
+
1137
+ const selfFlow = createMemo(() =>
1138
+ rowFlow(props.sessionId, Boolean(busy()[props.sessionId])),
1139
+ )
1140
+ const selfPhaseMs = createMemo(() =>
1141
+ phaseAgeMs(flow()[props.sessionId], now(), selfFlow()),
1142
+ )
1143
+
1144
+ /** The one place that binds a row to theme, frame and the measured row width. */
1145
+ const Row = (row: RowData): JSX.Element => (
1146
+ <AgentLine
1147
+ {...row}
1148
+ lineMax={lineMax()}
1149
+ frame={frame}
1150
+ glyphFrame={glyphFrame}
1151
+ colors={colors()}
1152
+ />
1153
+ )
1154
+
1155
+ /** Flatten delegates to rows (agent headers + members) for `RowList`. */
1156
+ const delegateLines = (list: DelegateView[]): RowData[] => {
1157
+ const out: RowData[] = []
1158
+ for (const item of groupDelegates(list)) {
1159
+ if (item.kind === "header") {
1160
+ out.push({
1161
+ kind: ROW_KIND_GROUP,
1162
+ glyph: {
1163
+ char: GROUP_GLYPH,
1164
+ tone: markTone(hottestMark(item.members.map(delegateMark))),
1165
+ },
1166
+ name: `${agentDisplayName(item.agent)} (${item.count})`,
1167
+ })
1168
+ continue
1169
+ }
1170
+ const d = item.delegate
1171
+ const isBusy = Boolean(d.sessionId && busy()[d.sessionId])
1172
+ const mark = delegateMark(d)
1173
+ const dir = rowFlow(d.sessionId, isBusy)
1174
+ out.push({
1175
+ kind: item.grouped ? ROW_KIND_DELEGATE : ROW_KIND_AGENT,
1176
+ mark,
1177
+ name: item.grouped ? d.title || d.taskKey || "task" : d.agent || "agent",
1178
+ tokens: d.tokensTotal,
1179
+ title: item.grouped ? undefined : d.title,
1180
+ current: Boolean(d.sessionId && d.sessionId === props.sessionId),
1181
+ flow: dir,
1182
+ dirSlot: true,
1183
+ indent: item.grouped,
1184
+ onSelect: () => goSession(d.sessionId),
1185
+ })
1186
+ }
1187
+ return out
1188
+ }
1189
+
1190
+ /** A delegate list with its own "… +N more" revealer (state hoisted by the caller). */
1191
+ const DelegateList = (props: {
1192
+ list: DelegateView[]
1193
+ budget: number
1194
+ reveal: RevealState
1195
+ }): JSX.Element => (
1196
+ <RowList
1197
+ items={delegateLines(props.list)}
1198
+ budget={props.budget + props.reveal.more()}
1199
+ colors={colors()}
1200
+ renderItem={(r) => <Row {...r} />}
1201
+ more={{ onReveal: props.reveal.reveal }}
1202
+ />
1203
+ )
1204
+
1205
+ /** Live token rate from streaming deltas — recomputes on each delta and on the coarse now tick. */
1206
+ const liveTokenRate = createMemo(() => {
1207
+ now()
1208
+ return tokenRate(tokenTicks(), Date.now(), TOKEN_RATE_WINDOW_MS)
1209
+ })
1210
+ const liveRateBar = createMemo(() => {
1211
+ now()
1212
+ return tokenRateBars(tokenTicks(), Date.now(), TOKEN_RATE_WINDOW_MS)
1213
+ })
1214
+
1215
+ /** Live self-cost line — reads the tick clock so the Solid insert re-evaluates every tick. */
1216
+ const selfLine = createMemo(() => {
1217
+ if (!selfDiagActive()) return ""
1218
+ now()
1219
+ return formatSelfLine(readSelfStats())
1220
+ })
1221
+
1222
+ /** "debug mode" / "profile" flags — visible only while the logger is actually active. */
1223
+ const modeLine = () => {
1224
+ const modes: string[] = []
1225
+ if (debugActive()) modes.push("debug mode")
1226
+ if (profileActive()) modes.push("profile")
1227
+ return modes.join(" ")
1228
+ }
1229
+
1230
+ const modeDirLine = () => {
1231
+ const dirs = [debugActiveDir(), profileActiveDir()].filter((p): p is string => Boolean(p))
1232
+ if (dirs.length === 0) return ""
1233
+ const uniq = Array.from(new Set(dirs))
1234
+ return `logs ${clipWidth(uniq.join(" | "), 78)}`
1235
+ }
1236
+
1237
+ const openRealtimeModal = (): void =>
1238
+ openRealtimeCharts(props.api, colors(), {
1239
+ getTimeline: () => rtTimeline,
1240
+ initialTabId: "tokens",
1241
+ initialRowKey: "avg",
1242
+ })
1243
+
1244
+ return (
1245
+ <box ref={rootRef} flexDirection="column" gap={0}>
1246
+ <Show when={engaging()}>
1247
+ <box flexDirection="row" gap={1}>
1248
+ <text fg={colors().primary}>{`${spinnerFrame(engageTick())} engage`}</text>
1249
+ <text fg={colors().textMuted}>{shareBar(engageFill(engageTick()), 10)}</text>
1250
+ </box>
1251
+ </Show>
1252
+ <Show when={modeLine()}>
1253
+ <text fg={colors().warning || colors().text}>{modeLine()}</text>
1254
+ </Show>
1255
+ <Show when={modeDirLine()}>
1256
+ <text fg={colors().textMuted}>{modeDirLine()}</text>
1257
+ </Show>
1258
+ <box flexDirection="row" gap={1} width="100%">
1259
+ <box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={0}>
1260
+ <OesStatusRow
1261
+ status={tabStatusFor(tab())}
1262
+ colors={colors()}
1263
+ glyphFrame={glyphFrame}
1264
+ rateBar={liveRateBar()}
1265
+ rate={liveTokenRate()}
1266
+ />
1267
+ </box>
1268
+ <ContextActions
1269
+ actions={[{ label: "C", onPick: openRealtimeModal }]}
1270
+ colors={colors()}
1271
+ width={RT_ACTION_COL_WIDTH}
1272
+ />
1273
+ </box>
1274
+ <Show when={selfDiagActive()}>
1275
+ <text fg={colors().textMuted}>{selfLine()}</text>
1276
+ </Show>
1277
+ <TabColumn
1278
+ brand=""
1279
+ tabs={OES_TABS}
1280
+ labels={TAB_LABELS}
1281
+ active={tab()}
1282
+ colors={colors()}
1283
+ onPick={pickTab}
1284
+ gap={0}
1285
+ glyph={(key) => (key === "mywork" ? myWorkTabGlyph() : TAB_NEUTRAL_GLYPH)}
1286
+ panels={{
1287
+ mywork: () => {
1288
+ try {
1289
+ const groups = myWorkGroups()
1290
+ dbg("mywork.panel", "render", { groups: groups.map((g) => `${g.kind}:${g.items.length}`) })
1291
+ const status = tabStatusFor("mywork")
1292
+ if (!status && groups.length === 0) return <text fg={colors().textMuted}>• nothing</text>
1293
+ return (
1294
+ <box flexDirection="column" gap={1}>
1295
+ <For each={groups}>
1296
+ {(g) => (
1297
+ <GroupSection
1298
+ title={myWorkLabel(g.kind)}
1299
+ open={myWorkFold[g.kind].open()}
1300
+ onToggle={myWorkFold[g.kind].toggle}
1301
+ colors={colors()}
1302
+ items={g.items}
1303
+ budget={rowsFor(`mywork.${g.kind}`, 2)}
1304
+ reveal={myWorkReveal[g.kind]}
1305
+ actions={
1306
+ g.kind === MY_WORK_GROUP_SESSIONS
1307
+ ? [
1308
+ { label: "switch", onPick: () => openSessionSwitcher(props.api) },
1309
+ { label: "new", onPick: () => openNewSessionPrompt(props.api) },
1310
+ ]
1311
+ : undefined
1312
+ }
1313
+ renderItem={(item) => <Row {...myWorkRow(item)} />}
1314
+ />
1315
+ )}
1316
+ </For>
1317
+ </box>
1318
+ )
1319
+ } catch (e) {
1320
+ dbg("mywork.panel", "error", String(e))
1321
+ return <text fg={colors().error || colors().text}>• my work error</text>
1322
+ }
1323
+ },
1324
+ sessions: () => (
1325
+ <box flexDirection="column" gap={1}>
1326
+ <FoldSection title="Agents" open={foldAgents.open()} colors={colors()} onToggle={foldAgents.toggle}>
1327
+ {isRealSession(snap().db.main) ? (
1328
+ <Row
1329
+ kind={ROW_KIND_AGENT}
1330
+ mark={mainMark()}
1331
+ name={snap().db.main!.agent}
1332
+ tokens={snap().db.main!.tokensTotal}
1333
+ cost={snap().db.main!.cost}
1334
+ current={snap().db.main!.id === props.sessionId}
1335
+ flow={mainFlow()}
1336
+ dirSlot
1337
+ onSelect={() => goSession(snap().db.main?.id)}
1338
+ />
1339
+ ) : (
1340
+ <text fg={colors().textMuted}>
1341
+ {`• session · ${props.sessionId.slice(0, 14)}`}
1342
+ {err() ? ` · ${err()}` : ""}
1343
+ </text>
1344
+ )}
1345
+ </FoldSection>
1346
+
1347
+ <Show when={projectFeed().tools.length > 0}>
1348
+ <FoldSection title="Tool Calls" open={foldTools.open()} colors={colors()} onToggle={foldTools.toggle}>
1349
+ <RowList
1350
+ items={projectFeed().tools}
1351
+ budget={rowsFor("tools", oes().toolRows) + projectToolsReveal.more()}
1352
+ colors={colors()}
1353
+ renderItem={(t) => {
1354
+ const mark = toolMark(t.status)
1355
+ const dir = toolFlow(t.status)
1356
+ const dur =
1357
+ t.status === TOOL_STATUS_RUNNING && t.startedAt != null
1358
+ ? formatDuration(Math.max(0, now() - t.startedAt))
1359
+ : formatDuration(t.durationMs)
1360
+ return (
1361
+ <Row
1362
+ kind={ROW_KIND_TOOL}
1363
+ mark={mark}
1364
+ name={t.name}
1365
+ suffix={dur}
1366
+ flow={dir}
1367
+ dirSlot
1368
+ onSelect={() => openToolDetail(props.api, t, colors())}
1369
+ />
1370
+ )
1371
+ }}
1372
+ more={{ onReveal: projectToolsReveal.reveal }}
1373
+ />
1374
+ </FoldSection>
1375
+ </Show>
1376
+
1377
+ <Show when={projectFeed().files.length > 0}>
1378
+ <FoldSection
1379
+ title="Files"
1380
+ open={foldFiles.open()}
1381
+ count={projectFeed().files.length}
1382
+ diff={projectFilesStat()}
1383
+ actions={[
1384
+ {
1385
+ label: "view all",
1386
+ onPick: () =>
1387
+ openFileListDialog(
1388
+ props.api,
1389
+ "Files",
1390
+ projectFeed().files.map((f) => ({
1391
+ name: f.name,
1392
+ onSelect: () => openFileDetail(props.api, f, projectRoots(), colors()),
1393
+ })),
1394
+ ),
1395
+ },
1396
+ ]}
1397
+ colors={colors()}
1398
+ onToggle={foldFiles.toggle}
1399
+ >
1400
+ <RowList
1401
+ items={projectFeed().files}
1402
+ budget={rowsFor("files", oes().fileRows)}
1403
+ colors={colors()}
1404
+ renderItem={(f) => (
1405
+ <Row
1406
+ kind={ROW_KIND_FILE}
1407
+ glyph={fileLetterGlyph(f.letter)}
1408
+ name={f.name}
1409
+ diff={{ additions: f.additions, deletions: f.deletions }}
1410
+ onSelect={() => openFileDetail(props.api, f, projectRoots(), colors())}
1411
+ />
1412
+ )}
1413
+ />
1414
+ </FoldSection>
1415
+ </Show>
1416
+
1417
+ <Show when={snap().omo.present}>
1418
+ <FoldSection
1419
+ title="Delegates"
1420
+ open={foldDelegates.open()}
1421
+ count={snap().delegates.length}
1422
+ live={delegatesLive()}
1423
+ colors={colors()}
1424
+ onToggle={foldDelegates.toggle}
1425
+ >
1426
+ {snap().delegates.length === 0 ? (
1427
+ <text fg={colors().textMuted}>• none</text>
1428
+ ) : (
1429
+ <DelegateList list={snap().delegates} budget={rowsFor("delegates", 6)} reveal={delegateReveal} />
1430
+ )}
1431
+ </FoldSection>
1432
+ </Show>
1433
+ </box>
1434
+ ),
1435
+ current: () => (
1436
+ <box flexDirection="column" gap={1}>
1437
+ <FoldSection title="Agents" open={foldAgents.open()} colors={colors()} onToggle={foldAgents.toggle}>
1438
+ {isRealSession(currentRow()) ? (
1439
+ <Row
1440
+ kind={ROW_KIND_AGENT}
1441
+ mark={currentMark()}
1442
+ name={currentRow()!.agent}
1443
+ tokens={currentRow()!.tokensTotal}
1444
+ cost={currentRow()!.cost}
1445
+ current
1446
+ flow={currentFlow()}
1447
+ dirSlot
1448
+ onSelect={() => goSession(currentRow()?.id)}
1449
+ />
1450
+ ) : (
1451
+ <text fg={colors().textMuted}>
1452
+ {`• session · ${props.sessionId.slice(0, 14)}`}
1453
+ </text>
1454
+ )}
1455
+ </FoldSection>
1456
+
1457
+ <Show when={snap().omo.present || sessionDelegates().length > 0}>
1458
+ <FoldSection
1459
+ title="Delegates"
1460
+ open={foldDelegates.open()}
1461
+ count={sessionDelegates().length}
1462
+ live={sessionDelegates().filter((d) => {
1463
+ const m = delegateMark(d)
1464
+ return m === PULSE_LIVE || m === PULSE_STALE
1465
+ }).length}
1466
+ colors={colors()}
1467
+ onToggle={foldDelegates.toggle}
1468
+ >
1469
+ {sessionDelegates().length === 0 ? (
1470
+ <text fg={colors().textMuted}>• none</text>
1471
+ ) : (
1472
+ <DelegateList list={sessionDelegates()} budget={rowsFor("delegates", 6)} reveal={currentDelegatesReveal} />
1473
+ )}
1474
+ </FoldSection>
1475
+ </Show>
1476
+
1477
+ <FoldSection title="Tool Calls" open={foldTools.open()} colors={colors()} onToggle={foldTools.toggle}>
1478
+ {tools().length === 0 ? (
1479
+ <text fg={colors().textMuted}>• none</text>
1480
+ ) : (
1481
+ <RowList
1482
+ items={tools()}
1483
+ budget={rowsFor("tools", oes().toolRows) + toolsReveal.more()}
1484
+ colors={colors()}
1485
+ renderItem={(t) => {
1486
+ const mark = toolMark(t.status)
1487
+ const dir = toolFlow(t.status)
1488
+ const dur =
1489
+ t.status === TOOL_STATUS_RUNNING && t.startedAt != null
1490
+ ? formatDuration(Math.max(0, now() - t.startedAt))
1491
+ : formatDuration(t.durationMs)
1492
+ return (
1493
+ <Row
1494
+ kind={ROW_KIND_TOOL}
1495
+ mark={mark}
1496
+ name={t.name}
1497
+ suffix={dur}
1498
+ flow={dir}
1499
+ dirSlot
1500
+ onSelect={() => openToolDetail(props.api, t, colors())}
1501
+ />
1502
+ )
1503
+ }}
1504
+ more={{ onReveal: toolsReveal.reveal }}
1505
+ />
1506
+ )}
1507
+ </FoldSection>
1508
+
1509
+ <FoldSection
1510
+ title="Files"
1511
+ open={foldFiles.open()}
1512
+ count={filesAll().length > 0 ? filesAll().length : undefined}
1513
+ diff={filesStat()}
1514
+ actions={[
1515
+ {
1516
+ label: "view all",
1517
+ onPick: () =>
1518
+ openFileListDialog(
1519
+ props.api,
1520
+ "Files",
1521
+ filesAll().map((f) => ({
1522
+ name: f.name,
1523
+ onSelect: () => openFileDetail(props.api, f, projectRoots(), colors()),
1524
+ })),
1525
+ ),
1526
+ },
1527
+ ]}
1528
+ colors={colors()}
1529
+ onToggle={foldFiles.toggle}
1530
+ >
1531
+ {filesAll().length === 0 ? (
1532
+ <text fg={colors().textMuted}>• none</text>
1533
+ ) : (
1534
+ <RowList
1535
+ items={filesAll()}
1536
+ budget={rowsFor("files", oes().fileRows)}
1537
+ colors={colors()}
1538
+ renderItem={(f) => (
1539
+ <Row
1540
+ kind={ROW_KIND_FILE}
1541
+ glyph={fileLetterGlyph(f.letter)}
1542
+ name={f.name}
1543
+ diff={{ additions: f.additions, deletions: f.deletions }}
1544
+ onSelect={() => openFileDetail(props.api, f, projectRoots(), colors())}
1545
+ />
1546
+ )}
1547
+ />
1548
+ )}
1549
+ </FoldSection>
1550
+
1551
+ <Show when={drafts().length > 0}>
1552
+ <FoldSection
1553
+ title="Drafts"
1554
+ open={foldDrafts.open()}
1555
+ count={draftsAll().length}
1556
+ actions={[
1557
+ {
1558
+ label: "view all",
1559
+ onPick: () =>
1560
+ openFileListDialog(
1561
+ props.api,
1562
+ "Drafts",
1563
+ draftsAll().map((d) => ({
1564
+ name: d.name,
1565
+ description: d.rel,
1566
+ onSelect: () => openDocDetail(props.api, d, projectRoots(), colors()),
1567
+ })),
1568
+ ),
1569
+ },
1570
+ ]}
1571
+ colors={colors()}
1572
+ onToggle={foldDrafts.toggle}
1573
+ >
1574
+ <RowList
1575
+ items={drafts()}
1576
+ budget={rowsFor("drafts", 5)}
1577
+ colors={colors()}
1578
+ renderItem={(d) => {
1579
+ const age = pulseAgeMs(now(), d.updatedAt)
1580
+ return (
1581
+ <Row
1582
+ kind={ROW_KIND_FILE}
1583
+ glyph={{ char: "•", tone: markTone(composeMark({ ageMs: age })) }}
1584
+ name={d.name}
1585
+ suffix={formatAge(age)}
1586
+ onSelect={() => openDocDetail(props.api, d, projectRoots(), colors())}
1587
+ />
1588
+ )
1589
+ }}
1590
+ />
1591
+ </FoldSection>
1592
+ </Show>
1593
+ </box>
1594
+ ),
1595
+ perf: () => (
1596
+ <box flexDirection="column" gap={1}>
1597
+ <PerfPanel
1598
+ api={props.api}
1599
+ perf={perf()}
1600
+ colors={colors()}
1601
+ lineMax={lineMax()}
1602
+ rows={oes().perfRows}
1603
+ glyphFrame={glyphFrame}
1604
+ livePhase={selfFlow()}
1605
+ livePhaseMs={selfPhaseMs()}
1606
+ currentSessionId={props.sessionId}
1607
+ dbPath={snap().db.dbPath}
1608
+ turns={oes().perfTurns}
1609
+ onSelect={(id) => goSession(id)}
1610
+ />
1611
+ </box>
1612
+ ),
1613
+ }}
1614
+ />
1615
+
1616
+ {err() && snap().db.main ? (
1617
+ <text fg={colors().textMuted}>{err()}</text>
1618
+ ) : null}
1619
+ <Show when={selfDiagActive()}>
1620
+ <box flexDirection="column" gap={0} onMouseScroll={onConsoleScroll}>
1621
+ <text fg={colors().textMuted}>{`dbg · ${consoleLines().length}`}</text>
1622
+ <For each={consoleWindow()}>
1623
+ {(line) => <text fg={colors().textMuted}>{clipWidth(line.text, lineMax())}</text>}
1624
+ </For>
1625
+ </box>
1626
+ </Show>
1627
+ </box>
1628
+ )
1629
+ }