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,234 @@
1
+ /**
2
+ * Vertical row budget for the sidebar. Pure — no OpenTUI, unit tested directly.
3
+ * Same idea as `packChips` in pulse.ts, one axis over: under pressure the worst
4
+ * rank gives up rows first, and nothing drops below its `min` until every
5
+ * section has already reached it.
6
+ */
7
+
8
+ /**
9
+ * Rows OpenCode's own TUI takes around the `sidebar_content` slot. Measured
10
+ * from the opencode TUI source (`packages/tui/src/routes/session/sidebar.tsx`,
11
+ * stable across v1.18.27 = HEAD): the sidebar column has no border — its frame
12
+ * is `paddingTop` 1 + a fixed footer (`sidebar_footer`, outside the scrollbox:
13
+ * 1 spacer + path row + 1 gap + version row) + `paddingBottom` 1.
14
+ */
15
+ const HOST_SIDEBAR_CHROME = 6
16
+
17
+ /**
18
+ * Rows the host's `sidebar_title` fallback block (same scrollbox, above the
19
+ * content slot) takes before the plugin's first content row: a typical session
20
+ * shows a bold title + workspace (2 rows) plus the 1-row `gap` separator, and
21
+ * a wrapping title or the optional channel/share lines add more. Kept at 4 so
22
+ * `PANEL_CHROME` still covers the old flat-10 headroom.
23
+ */
24
+ const HOST_SIDEBAR_TITLE_RESERVE = 4
25
+
26
+ /** Fixed chrome the sidebar slot's own host takes above the content rows. */
27
+ const PANEL_CHROME = HOST_SIDEBAR_CHROME + HOST_SIDEBAR_TITLE_RESERVE
28
+ const PANEL_MIN_ROWS = 8
29
+
30
+ /** Shared right rail for context actions (`switch new`, `view all`, …). */
31
+ export const CONTEXT_ACTION_COL_WIDTH = 10
32
+
33
+ /**
34
+ * Row width budget before the root box reports its measured layout width.
35
+ * `lineMax` used to be an oes.json option; it is now measured, and this is the
36
+ * pre-layout fallback (matches the old default).
37
+ */
38
+ export const ROW_LINE_FALLBACK = 31
39
+
40
+ /** Columns subtracted from the measured root width for the scrollbar reserve. */
41
+ export const ROW_LINE_RESERVE = 1
42
+
43
+ /** Realtime charts link on the OES row (`C` → fullscreen dialog). */
44
+ export const RT_ACTION_COL_WIDTH = 1
45
+
46
+ /** Compact sparkline body rows — sidebar inline chart removed; kept for docs/tests. */
47
+ export const RT_CHART_ROWS = 3
48
+
49
+ /** Per-series sparkline body rows in the realtime fullscreen dialog. */
50
+ export const RT_DIALOG_CHART_ROWS = 8
51
+
52
+ /**
53
+ * Host `api.ui.Dialog` inner box widths (`packages/opencode/.../dialog.tsx`).
54
+ * `maxWidth` is `terminal − 2`. `xlarge` is 116 on hosts that implement it
55
+ * (#44754); older builds only switch `large` (80) vs else (60).
56
+ */
57
+ export const HOST_DIALOG_OUTER_WIDTH = { medium: 60, large: 80, xlarge: 116 } as const
58
+ export type HostDialogSize = keyof typeof HOST_DIALOG_OUTER_WIDTH
59
+
60
+ /** `maxWidth={dimensions().width - 2}` on the host inner box. */
61
+ const HOST_DIALOG_MAX_INSET = 2
62
+ /** `DialogPad` left + right, matching the host dialog bodies (`paddingLeft/Right 2`). */
63
+ const DIALOG_PAD_X = 4
64
+
65
+ /**
66
+ * Columns inside the padded dialog body. Subtracts the host box cap, the
67
+ * `maxWidth` inset, and DialogPad — this is the line budget a chart may use,
68
+ * including the asciiTrend min/max labels on the left of each row.
69
+ */
70
+ export function dialogInnerWidth(termWidth: number, size: HostDialogSize): number {
71
+ const w = Number.isFinite(termWidth) && termWidth > 0 ? Math.floor(termWidth) : 80
72
+ const outer = Math.min(HOST_DIALOG_OUTER_WIDTH[size], Math.max(1, w - HOST_DIALOG_MAX_INSET))
73
+ return Math.max(8, outer - DIALOG_PAD_X)
74
+ }
75
+
76
+ /**
77
+ * One right-aligned action line padded to {@link CONTEXT_ACTION_COL_WIDTH}.
78
+ * Long labels keep their trailing characters so `view all` still fits the rail.
79
+ */
80
+ export function contextActionsLine(labels: readonly string[]): string {
81
+ const text = labels.join(" ")
82
+ if (text.length >= CONTEXT_ACTION_COL_WIDTH) return text.slice(-CONTEXT_ACTION_COL_WIDTH)
83
+ return text.padStart(CONTEXT_ACTION_COL_WIDTH)
84
+ }
85
+
86
+ /** Usable rows in the sidebar slot for a terminal that tall. */
87
+ export function panelRows(termHeight: number): number {
88
+ const h = Number.isFinite(termHeight) && termHeight > 0 ? termHeight : 24
89
+ return Math.max(PANEL_MIN_ROWS, Math.floor(h) - PANEL_CHROME)
90
+ }
91
+
92
+ /** Rows a section keeps before it is worth folding instead. */
93
+ export const ROW_MIN = {
94
+ delegates: 2,
95
+ drafts: 2,
96
+ files: 3,
97
+ mywork: 2,
98
+ tools: 3,
99
+ } as const
100
+
101
+ /**
102
+ * Higher gives up rows first. Live activity outranks history: on Current the
103
+ * tool feed survives longest. My work is a queue of things awaiting the user,
104
+ * so it ties with the live feed.
105
+ */
106
+ export const ROW_RANK = {
107
+ delegates: 3,
108
+ drafts: 3,
109
+ files: 2,
110
+ mywork: 1,
111
+ tools: 1,
112
+ } as const
113
+
114
+ export type Section<K extends string = string> = {
115
+ key: K
116
+ /** Rows the section would like — usually the matching `oes.json` value. */
117
+ want: number
118
+ min: number
119
+ rank: number
120
+ }
121
+
122
+ /**
123
+ * Split `budget - fixed` rows across the sections. Ties are broken by whoever
124
+ * currently holds the most rows, so equal ranks shrink together. A section that
125
+ * hits `min` while the budget is still short is handed `0` — the caller folds
126
+ * it rather than rendering a stub.
127
+ */
128
+ export function packSections<K extends string>(
129
+ budget: number,
130
+ fixed: number,
131
+ sections: readonly Section<K>[],
132
+ ): Record<K, number> {
133
+ const live = sections.map((s) => {
134
+ const min = Math.max(0, Math.round(s.min))
135
+ return { key: s.key, min, rank: s.rank, rows: Math.max(min, Math.max(0, Math.round(s.want))) }
136
+ })
137
+ const available = Math.max(0, Math.round(budget) - Math.round(fixed))
138
+ const total = () => live.reduce((sum, s) => sum + s.rows, 0)
139
+
140
+ const worstAbove = (floor: (s: (typeof live)[number]) => number): number => {
141
+ let worst = -1
142
+ for (let i = 0; i < live.length; i += 1) {
143
+ const s = live[i]!
144
+ if (s.rows <= floor(s)) continue
145
+ const best = worst < 0 ? null : live[worst]!
146
+ if (!best || s.rank > best.rank || (s.rank === best.rank && s.rows > best.rows)) worst = i
147
+ }
148
+ return worst
149
+ }
150
+
151
+ while (total() > available) {
152
+ const i = worstAbove((s) => s.min)
153
+ if (i < 0) break
154
+ live[i]!.rows -= 1
155
+ }
156
+
157
+ // Everyone is at their minimum and it still does not fit: fold, worst first.
158
+ while (total() > available) {
159
+ const i = worstAbove(() => 0)
160
+ if (i < 0) break
161
+ live[i]!.rows = 0
162
+ }
163
+
164
+ const out = {} as Record<K, number>
165
+ for (const s of live) out[s.key] = s.rows
166
+ return out
167
+ }
168
+
169
+ /**
170
+ * Look up a packed section. A missing plan (Solid memo still computing,
171
+ * previous compute threw, owner disposed) or a missing / non-finite key
172
+ * returns `fallback` — the sidebar slot must not die on `plan[key]`.
173
+ */
174
+ export function rowsForPlan(
175
+ plan: Readonly<Record<string, number>> | null | undefined,
176
+ key: string,
177
+ fallback: number,
178
+ ): number {
179
+ if (!plan || typeof plan !== "object") return fallback
180
+ const v = plan[key]
181
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback
182
+ }
183
+
184
+ /**
185
+ * Show `shown` rows and report how many a "more" control can still reveal.
186
+ * The expander is a separate clickable line (`RowList`) the caller renders
187
+ * below the list — no row is spent on the note.
188
+ */
189
+ export function sliceShown<T>(
190
+ rows: readonly T[],
191
+ shown: number,
192
+ ): { rows: T[]; hidden: number } {
193
+ const s = Math.max(0, Math.round(shown))
194
+ const kept = rows.slice(0, s)
195
+ return { rows: kept, hidden: Math.max(0, rows.length - kept.length) }
196
+ }
197
+
198
+ /**
199
+ * Whether the "… +N more" / "… less" line is drawn at all. The expanded toggle
200
+ * keeps its "… less" line even when nothing is hidden; otherwise the line
201
+ * vanishes the moment there is nothing left to reveal.
202
+ */
203
+ export function moreRevealVisible(hidden: number, expanded?: boolean): boolean {
204
+ return expanded === true || hidden > 0
205
+ }
206
+
207
+ /**
208
+ * Clamp a scroll offset into the window a `visible`-row view can show over
209
+ * `total` rows: the offset never goes below 0 or past the last full window.
210
+ */
211
+ export function clampScrollOffset(total: number, visible: number, offset: number): number {
212
+ const t = Math.max(0, Math.round(total))
213
+ const v = Math.max(1, Math.round(visible))
214
+ const max = Math.max(0, t - v)
215
+ const o = Math.max(0, Math.round(offset))
216
+ return Math.min(o, max)
217
+ }
218
+
219
+ /**
220
+ * Move a scroll offset by one wheel `step`. Newest-first lists count the offset
221
+ * from the newest row, so "down" goes toward older rows and "up" back to the
222
+ * newest. The result stays inside the same clamped window as `clampScrollOffset`.
223
+ */
224
+ export function scrollByStep(
225
+ total: number,
226
+ visible: number,
227
+ offset: number,
228
+ direction: "up" | "down",
229
+ step: number,
230
+ ): number {
231
+ const s = Math.max(1, Math.round(step))
232
+ const next = direction === "down" ? offset + s : offset - s
233
+ return clampScrollOffset(total, visible, next)
234
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * oes.json — OpenCode Extended Sidebar options.
3
+ * Merge: plugin defaults < ~/.config/opencode/oes.json < <project>/oes.json
4
+ */
5
+ import path from "node:path"
6
+ import { fileStamp, getOpenCodeConfigDir, pluginRoot, readJson } from "./pware.oc.core.paths.js"
7
+ import { createStampCache } from "./pware.oc.core.cache.js"
8
+
9
+ export type OesOptions = {
10
+ fileRows: number
11
+ /** Sessions compared under Perf → History. 0 hides the section. */
12
+ perfHistory: number
13
+ /** Rows per Perf section (models, tools, history). */
14
+ perfRows: number
15
+ /** How many recent turns Perf scans. Higher = longer history, slower read. */
16
+ perfTurns: number
17
+ /** Seconds between full open-question rescans when no event-driven hint arrives. */
18
+ questionReconcileSec: number
19
+ /** Sessions fetched for the recent-sessions window. */
20
+ sessionFetch: number
21
+ /** Hide Files that match the project's root .gitignore. Off by default. */
22
+ skipGitignore: boolean
23
+ toolRows: number
24
+ /** Tool-call history kept for the feed's `… +N more` revealer. Distinct from `toolRows`. */
25
+ toolFetch: number
26
+ }
27
+
28
+ export const OES_DEFAULTS: OesOptions = {
29
+ fileRows: 8,
30
+ perfHistory: 3,
31
+ perfRows: 5,
32
+ perfTurns: 120,
33
+ questionReconcileSec: 15,
34
+ sessionFetch: 20,
35
+ skipGitignore: false,
36
+ toolRows: 5,
37
+ toolFetch: 20,
38
+ }
39
+
40
+ function pluginOesPath(): string {
41
+ return path.join(pluginRoot(), "oes.json")
42
+ }
43
+
44
+ function clamp(n: unknown, min: number, max: number, fallback: number): number {
45
+ if (typeof n !== "number" || !Number.isFinite(n)) return fallback
46
+ return Math.round(Math.min(max, Math.max(min, n)))
47
+ }
48
+
49
+ /** Merge one oes.json object onto a base. Exported for tests. */
50
+ export function pick(raw: Record<string, unknown> | null, base: OesOptions): OesOptions {
51
+ if (!raw) return base
52
+ const toolRows = clamp(raw.toolRows, 3, 20, base.toolRows)
53
+ return {
54
+ fileRows: clamp(raw.fileRows, 3, 20, base.fileRows),
55
+ perfHistory: clamp(raw.perfHistory, 0, 10, base.perfHistory),
56
+ perfRows: clamp(raw.perfRows, 3, 20, base.perfRows),
57
+ perfTurns: clamp(raw.perfTurns, 20, 500, base.perfTurns),
58
+ questionReconcileSec: clamp(raw.questionReconcileSec, 5, 300, base.questionReconcileSec),
59
+ sessionFetch: clamp(raw.sessionFetch, 2, 80, base.sessionFetch),
60
+ skipGitignore: typeof raw.skipGitignore === "boolean" ? raw.skipGitignore : base.skipGitignore,
61
+ toolRows,
62
+ toolFetch: clamp(raw.toolFetch, toolRows, 80, base.toolFetch),
63
+ }
64
+ }
65
+
66
+ export function oesPaths(projectRoot?: string | null): string[] {
67
+ const out = [pluginOesPath(), path.join(getOpenCodeConfigDir(), "oes.json")]
68
+ if (projectRoot) out.push(path.join(projectRoot, "oes.json"))
69
+ return out
70
+ }
71
+
72
+ export function oesStamp(projectRoot?: string | null): string {
73
+ return oesPaths(projectRoot).map(fileStamp).join("|")
74
+ }
75
+
76
+ const oesCache = createStampCache<OesOptions>()
77
+
78
+ /** Drop the merged-options cache so the next getOes re-reads files. */
79
+ export function resetOesCache(): void {
80
+ oesCache.reset()
81
+ }
82
+
83
+ export function getOes(projectRoot?: string | null): OesOptions {
84
+ return oesCache.get(oesStamp(projectRoot), () => {
85
+ let next = { ...OES_DEFAULTS }
86
+ for (const p of oesPaths(projectRoot)) next = pick(readJson(p), next)
87
+ return next
88
+ })
89
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Resolve OpenCode data paths (XDG + Windows + OPENCODE_DB_PATH).
3
+ * Zero deps — node:fs / path / os only.
4
+ */
5
+ import fs from "node:fs"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import { fileURLToPath } from "node:url"
9
+
10
+ export type Env = Record<string, string | undefined>
11
+
12
+ /** Absolute path to the plugin's root directory (one level above `src/`). */
13
+ export function pluginRoot(): string {
14
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
15
+ }
16
+
17
+ export function getDataDir(env: Env = process.env, homedir = os.homedir()): string {
18
+ let dataDir = env.XDG_DATA_HOME
19
+ if (dataDir) {
20
+ if (dataDir === "~") dataDir = homedir
21
+ else if (dataDir.startsWith("~/") || dataDir.startsWith("~\\")) {
22
+ dataDir = path.join(homedir, dataDir.slice(2).replace(/[\\/]+/g, path.sep))
23
+ }
24
+ }
25
+ return dataDir ?? path.join(homedir, ".local", "share")
26
+ }
27
+
28
+ export function getOpenCodeConfigDir(env: Env = process.env, homedir = os.homedir()): string {
29
+ const xdg = env.XDG_CONFIG_HOME
30
+ if (xdg) {
31
+ const root = xdg === "~" ? homedir : xdg.startsWith("~/") || xdg.startsWith("~\\")
32
+ ? path.join(homedir, xdg.slice(2).replace(/[\\/]+/g, path.sep))
33
+ : xdg
34
+ return path.join(root, "opencode")
35
+ }
36
+ return path.join(homedir, ".config", "opencode")
37
+ }
38
+
39
+ export function getOpenCodeRoot(env: Env = process.env, homedir = os.homedir()): string {
40
+ return path.join(getDataDir(env, homedir), "opencode")
41
+ }
42
+
43
+ /** Local agentize DB path relative to a project root, or null when absent. */
44
+ export function localAgentizeDbPath(projectRoot: string): string | null {
45
+ const p = path.join(projectRoot, ".agentize", "opencode", "opencode.db")
46
+ return fs.existsSync(p) ? p : null
47
+ }
48
+
49
+ /**
50
+ * Resolve the opencode.db path — mirrors opencode's own resolution
51
+ * (`packages/opencode/src/storage/db.ts`): `OPENCODE_DB` first, then the
52
+ * XDG / home-based global path. agentize pins the exact DB with
53
+ * `OPENCODE_DB` (an absolute path), so it must win over the heuristics.
54
+ *
55
+ * Priority:
56
+ * 1. OPENCODE_DB env var (absolute → verbatim; relative → under the data dir)
57
+ * 2. OPENCODE_DB_PATH env var (legacy alias)
58
+ * 3. .agentize/opencode/opencode.db under projectRoot (when supplied)
59
+ * 4. XDG / home-based global path
60
+ */
61
+ export function getOpenCodeDbPath(
62
+ env: Env = process.env,
63
+ homedir = os.homedir(),
64
+ projectRoot?: string | null,
65
+ ): string {
66
+ const db = env.OPENCODE_DB
67
+ if (db && db !== ":memory:") {
68
+ return path.isAbsolute(db)
69
+ ? path.resolve(db)
70
+ : path.join(getOpenCodeRoot(env, homedir), db)
71
+ }
72
+ if (env.OPENCODE_DB_PATH) return path.resolve(env.OPENCODE_DB_PATH)
73
+ if (projectRoot) {
74
+ const local = localAgentizeDbPath(projectRoot)
75
+ if (local) return local
76
+ }
77
+ return path.join(getOpenCodeRoot(env, homedir), "opencode.db")
78
+ }
79
+
80
+ export function getSessionDiffPath(
81
+ sessionId: string,
82
+ env: Env = process.env,
83
+ homedir = os.homedir(),
84
+ ): string {
85
+ return path.join(getOpenCodeRoot(env, homedir), "storage", "session_diff", `${sessionId}.json`)
86
+ }
87
+
88
+ export function realpathSafe(p: string): string | null {
89
+ try {
90
+ return fs.realpathSync(p)
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /** Last path segment. Never a directory. */
97
+ export function basenameOf(p: string): string {
98
+ const t = p.replace(/\\/g, "/").replace(/\/+$/, "").trim()
99
+ if (!t) return "file"
100
+ const i = t.lastIndexOf("/")
101
+ const base = i >= 0 ? t.slice(i + 1) : t
102
+ return base || "file"
103
+ }
104
+
105
+ /** Positive finite number, else 0. */
106
+ export function finiteNum(v: unknown): number {
107
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0
108
+ }
109
+
110
+ /** Trimmed non-empty string, else null. */
111
+ export function str(v: unknown): string | null {
112
+ if (typeof v === "string" && v.trim()) return v.trim()
113
+ return null
114
+ }
115
+
116
+ export function canonicalizePath(p: string): string {
117
+ const resolved = path.resolve(p)
118
+ return path.normalize(realpathSafe(resolved) ?? resolved)
119
+ }
120
+
121
+ /**
122
+ * OpenCode/git often emit posix paths (`D:/a/b` or `/d/a/b`).
123
+ * Fold those onto the host path so Windows `D:\` matches.
124
+ */
125
+ export function normalizeIncomingPath(p: string): string {
126
+ let t = p.trim().replace(/\\/g, "/")
127
+ const m = t.match(/^\/([A-Za-z])\/(.*)$/)
128
+ if (m) t = `${m[1]}:/${m[2]}`
129
+ return t
130
+ }
131
+
132
+ /**
133
+ * Fold an absolute path/directory into one comparison key so two spellings of
134
+ * the same location compare equal: posix separators (`D:/` and `D:\`), a
135
+ * git-bash drive prefix (`/d/…`), trailing slashes, and drive-letter case
136
+ * (`d:/` from git-bash vs `D:/`). Path case is folded only on Windows, where
137
+ * the filesystem is case-insensitive.
138
+ */
139
+ export function foldPathKey(p: string | null | undefined): string {
140
+ if (!p) return ""
141
+ let t = normalizeIncomingPath(p).replace(/\/+$/, "")
142
+ const drive = t.match(/^([a-z]):\//)
143
+ if (drive) t = `${drive[1]!.toUpperCase()}:${t.slice(2)}`
144
+ return process.platform === "win32" ? t.toLowerCase() : t
145
+ }
146
+
147
+ /** True when two directory/file paths point at the same location. */
148
+ export function samePath(a: string | null | undefined, b: string | null | undefined): boolean {
149
+ return foldPathKey(a) === foldPathKey(b)
150
+ }
151
+
152
+ function isInsideRoot(root: string, abs: string): boolean {
153
+ const rel = path.relative(root, abs)
154
+ if (!rel || path.isAbsolute(rel)) return false
155
+ const n = rel.replace(/\\/g, "/")
156
+ if (n === ".." || n.startsWith("../")) return false
157
+ return true
158
+ }
159
+
160
+ function asRootList(roots: string | readonly string[] | null | undefined): string[] {
161
+ if (!roots) return []
162
+ const list = typeof roots === "string" ? [roots] : [...roots]
163
+ const out: string[] = []
164
+ const seen = new Set<string>()
165
+ for (const r of list) {
166
+ if (!r || typeof r !== "string") continue
167
+ let key = r
168
+ try {
169
+ key = canonicalizePath(r)
170
+ } catch {
171
+ key = r
172
+ }
173
+ const fold = process.platform === "win32" ? key.toLowerCase() : key
174
+ if (seen.has(fold)) continue
175
+ seen.add(fold)
176
+ out.push(r)
177
+ }
178
+ return out
179
+ }
180
+
181
+ /** Absolute path + project-relative posix path, or null if outside / missing. */
182
+ export function resolveProjectFile(
183
+ roots: string | readonly string[] | null | undefined,
184
+ filePath: string | null | undefined,
185
+ ): { abs: string; rel: string } | null {
186
+ if (!filePath) return null
187
+ const incoming = normalizeIncomingPath(filePath)
188
+ for (const rawRoot of asRootList(roots)) {
189
+ try {
190
+ const root = canonicalizePath(rawRoot)
191
+ const abs = path.isAbsolute(incoming)
192
+ ? canonicalizePath(incoming)
193
+ : canonicalizePath(path.join(root, incoming))
194
+ if (!isInsideRoot(root, abs)) continue
195
+ if (!fs.existsSync(abs)) continue
196
+ const rel = path.relative(root, abs).replace(/\\/g, "/")
197
+ if (!rel) continue
198
+ return { abs, rel }
199
+ } catch {
200
+ // next root
201
+ }
202
+ }
203
+ return null
204
+ }
205
+
206
+ /** Project-relative path for detail dialogs — never escapes above project root. */
207
+ export function relativeProjectPath(
208
+ projectRoot: string | null | undefined,
209
+ filePath: string | null | undefined,
210
+ ): string | null {
211
+ return resolveProjectFile(projectRoot, filePath)?.rel ?? null
212
+ }
213
+
214
+ /** Read a JSON file as a plain object, soft-failing to null (missing / invalid / non-object). */
215
+ export function readJson(p: string): Record<string, unknown> | null {
216
+ try {
217
+ if (!fs.existsSync(p)) return null
218
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"))
219
+ return raw && typeof raw === "object" && !Array.isArray(raw)
220
+ ? (raw as Record<string, unknown>)
221
+ : null
222
+ } catch {
223
+ return null
224
+ }
225
+ }
226
+
227
+ export function fileStamp(p: string | null | undefined): string {
228
+ if (!p) return "0"
229
+ try {
230
+ const st = fs.statSync(p)
231
+ return `${st.mtimeMs}:${st.size}`
232
+ } catch {
233
+ return "0"
234
+ }
235
+ }
236
+
237
+ /** DB + WAL/SHM stamps — WAL writers change companion files, not always .db mtime. */
238
+ export function dbStamp(dbPath: string): string {
239
+ return [dbPath, `${dbPath}-wal`, `${dbPath}-shm`].map(fileStamp).join("|")
240
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * File preview helpers (no OpenTUI). Unit tests import this, not the dialogs
3
+ * module (pware.oc.ui.menudialogs.tsx).
4
+ */
5
+ import fs from "node:fs"
6
+ import path from "node:path"
7
+
8
+ const PREVIEW_MAX_LINES = 200
9
+ const PREVIEW_MAX_BYTES = 128_000
10
+ /** DialogPad bottom 1 + header 2 + footer 2 + slack 1. Extra is the optional relative-path line. */
11
+ const PREVIEW_CHROME = 6
12
+ const PREVIEW_MIN_ROWS = 8
13
+
14
+ const PREVIEW_EXT = new Set([
15
+ ".md",
16
+ ".txt",
17
+ ".json",
18
+ ".jsonc",
19
+ ".yaml",
20
+ ".yml",
21
+ ".toml",
22
+ ".xml",
23
+ ".html",
24
+ ".css",
25
+ ".scss",
26
+ ".ts",
27
+ ".tsx",
28
+ ".js",
29
+ ".jsx",
30
+ ".mjs",
31
+ ".cjs",
32
+ ".sh",
33
+ ".py",
34
+ ".rs",
35
+ ".go",
36
+ ".sql",
37
+ ".csv",
38
+ ])
39
+
40
+ export function previewViewportRows(termHeight: number, extraChrome = 0, tall = false): number {
41
+ const h = Number.isFinite(termHeight) && termHeight > 0 ? termHeight : 24
42
+ const ratio = tall ? 0.9 : 0.75
43
+ const min = tall ? 12 : PREVIEW_MIN_ROWS
44
+ return Math.max(min, Math.floor(h * ratio) - PREVIEW_CHROME - extraChrome)
45
+ }
46
+
47
+ export function canPreviewPath(filePath: string): boolean {
48
+ const ext = path.extname(filePath).toLowerCase()
49
+ return PREVIEW_EXT.has(ext)
50
+ }
51
+
52
+ export function isMarkdownPath(filePath: string): boolean {
53
+ return path.extname(filePath).toLowerCase() === ".md"
54
+ }
55
+
56
+ export function readTextPreview(absPath: string): { text: string; truncated: boolean } | null {
57
+ try {
58
+ const st = fs.statSync(absPath)
59
+ if (!st.isFile()) return null
60
+ const buf = fs.readFileSync(absPath)
61
+ const slice = buf.subarray(0, Math.min(buf.length, PREVIEW_MAX_BYTES))
62
+ if (slice.includes(0)) return null
63
+ let text = slice.toString("utf8")
64
+ const lines = text.split(/\r?\n/)
65
+ let truncated = buf.length > PREVIEW_MAX_BYTES
66
+ if (lines.length > PREVIEW_MAX_LINES) {
67
+ text = lines.slice(0, PREVIEW_MAX_LINES).join("\n")
68
+ truncated = true
69
+ }
70
+ return { text, truncated }
71
+ } catch {
72
+ return null
73
+ }
74
+ }