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.
- package/.oesignore +17 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/oes.json +11 -0
- package/package.json +100 -0
- package/scripts/install-git-hooks.mjs +29 -0
- package/src/pware.oc.core/constants/index.ts +18 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
- package/src/pware.oc.core/git/index.ts +7 -0
- package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
- package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
- package/src/pware.oc.core/index.ts +18 -0
- package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
- package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
- package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
- package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
- package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
- package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
- package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
- package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
- package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
- package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
- package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
- package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
- package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
- package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
- package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
- package/src/pware.oc.omo/constants/index.ts +15 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
- package/src/pware.oc.omo/index.ts +7 -0
- package/src/pware.oc.omo/resolver/index.ts +19 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
- package/src/pware.oc.opencode/constants/index.ts +10 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
- package/src/pware.oc.opencode/index.ts +8 -0
- package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
- package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
- package/src/pware.oc.opencode/resolver/index.ts +170 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
- package/src/pware.oc.perf/index.ts +14 -0
- package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
- package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
- package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
- package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
- package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
- package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
- package/src/pware.oc.runtime/index.ts +13 -0
- package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
- package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
- package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
- package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
- package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
- package/src/pware.oc.runtime/resolver/index.ts +224 -0
- package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
- package/src/pware.oc.ui/index.ts +12 -0
- package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
- package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
- package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
- package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
- package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
- package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
- package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
- 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
|
+
}
|