@fresh-editor/fresh-editor 0.2.25 → 0.3.0

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.
@@ -0,0 +1,1785 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
4
+ // ═════════════════════════════════════════════════════════════════════
5
+ // DASHBOARD PLUGIN
6
+ //
7
+ // Shows a TUI dashboard with weather, git, GitHub PRs, and disk
8
+ // stats when there's no real work open — either at startup or
9
+ // after the user closes the last file buffer (instead of the
10
+ // default untitled scratch).
11
+ //
12
+ // Controlled via the standard plugin config flag
13
+ // (`plugins.dashboard.enabled` in config.json / Settings UI).
14
+ // When the plugin is enabled and loaded, it subscribes to the
15
+ // editor hooks that drive the dashboard's lifecycle; when disabled
16
+ // it is never loaded, so no buffers are created, no timers run,
17
+ // and no network fetches fire.
18
+ //
19
+ // A second flag `plugins.dashboard.auto-open` (default true) gates
20
+ // only the ambient open paths (startup + last-buffer-closed). When
21
+ // false the plugin still loads and the "Show Dashboard" command is
22
+ // still available — it just won't appear on its own.
23
+ //
24
+ // - Auto-centers both horizontally and vertically. Repaints when the
25
+ // viewport changes (terminal resize, file-explorer toggle, split
26
+ // reshape).
27
+ // - Auto-refreshes every 5 seconds while visible.
28
+ // - All colors are theme keys → repaints for free on theme switch.
29
+ // - Clickable rows (repo URL, branch name, PR numbers, review-branch
30
+ // action) route clicks through the mouse_click hook, so they work
31
+ // in terminals that swallow OSC-8 hyperlinks. The OSC-8 `url` span
32
+ // is still set as a fallback for terminals that do honor it.
33
+ // - Content is pushed to the buffer via `setVirtualBufferContent`, a
34
+ // single atomic command. Going through clearNamespace / deleteRange /
35
+ // insertText / addOverlay would let a render frame slip in between
36
+ // the delete and the insert — the plugin thread pushes each call as
37
+ // an independent message onto an MPSC channel that the editor drains
38
+ // non-blocking every tick, so a partial-batch render is possible and
39
+ // observably flickery. `setVirtualBufferContent` ships text + all
40
+ // inline overlays in one message, so the editor applies the whole
41
+ // replacement before the next frame.
42
+ // ═════════════════════════════════════════════════════════════════════
43
+
44
+ type Span = {
45
+ start: number;
46
+ end: number;
47
+ fg?: string;
48
+ bg?: string;
49
+ bold?: boolean;
50
+ underline?: boolean;
51
+ url?: string;
52
+ };
53
+ // Click action attached to whole rows — dispatched by the mouse_click
54
+ // handler, which looks up the clicked buffer row in Draw.rowActions.
55
+ // Since terminals that swallow OSC-8 hyperlinks are common, we can't
56
+ // rely on the `url` span alone; routing clicks through the editor
57
+ // guarantees that PR numbers / repo / custom section rows are always
58
+ // actionable. The `callback` variant lets third-party sections wire
59
+ // up arbitrary click handlers via the public `DashboardContext` API.
60
+ type ClickAction =
61
+ | { kind: "open-url"; url: string }
62
+ | { kind: "callback"; fn: () => void };
63
+ type Draw = {
64
+ text: string;
65
+ spans: Span[];
66
+ // currentRow / currentCol are maintained by `emit()` + `newline()` so
67
+ // click-action ranges land on the same cells the underlined text
68
+ // actually occupies. A click at (buffer_row, buffer_col) only fires
69
+ // if it falls inside one of the row's registered ranges — padding
70
+ // spaces, kv labels, and the frame border inside an inner row are
71
+ // not clickable, matching the visual affordance.
72
+ currentRow: number;
73
+ currentCol: number;
74
+ rowActions: Map<number, ClickActionRange[]>;
75
+ };
76
+
77
+ /** Column range within a row that carries a click target. */
78
+ type ClickActionRange = { colStart: number; colEnd: number; action: ClickAction };
79
+ const MAX_INNER = 72; // content width excluding frame + centering pad
80
+
81
+ const C = {
82
+ frame: "ui.popup_border_fg",
83
+ title: "syntax.keyword",
84
+ accent: "syntax.function",
85
+ value: "syntax.string",
86
+ number: "syntax.constant",
87
+ muted: "syntax.comment",
88
+ branch: "syntax.variable",
89
+ ok: "ui.file_status_added_fg",
90
+ warn: "syntax.constant",
91
+ err: "diagnostic.error_fg",
92
+ barFill: "syntax.function",
93
+ };
94
+
95
+ // ── Public section API ─────────────────────────────────────────────────
96
+ //
97
+ // Third-party plugins (and user init.ts) can add their own dashboard
98
+ // rows via the exported plugin API. See `editor.exportPluginApi` at
99
+ // the bottom of this file and the usage example in the init.ts
100
+ // starter template (`init_script.rs::STARTER_TEMPLATE`).
101
+
102
+ // Named palette colors exposed to section callbacks. Each maps to a
103
+ // theme key under the hood so sections follow the active theme
104
+ // without hard-coding RGB values.
105
+ export type DashboardColor =
106
+ | "muted"
107
+ | "accent"
108
+ | "value"
109
+ | "number"
110
+ | "ok"
111
+ | "warn"
112
+ | "err"
113
+ | "branch";
114
+
115
+ const COLOR_KEYS: Record<DashboardColor, string> = {
116
+ muted: C.muted,
117
+ accent: C.accent,
118
+ value: C.value,
119
+ number: C.number,
120
+ ok: C.ok,
121
+ warn: C.warn,
122
+ err: C.err,
123
+ branch: C.branch,
124
+ };
125
+
126
+ export type DashboardTextOpts = {
127
+ color?: DashboardColor;
128
+ bold?: boolean;
129
+ /** OSC-8 hyperlink target; terminals that honor it render the span as a
130
+ * clickable link. When only `onClick` is set (no `url`), the span is
131
+ * still routed through Fresh's own mouse-click dispatch. */
132
+ url?: string;
133
+ /** Invoked when the user clicks anywhere on the row carrying this text
134
+ * span, regardless of whether the terminal honors OSC-8. Multiple text
135
+ * spans on the same row share a single row-level click target; the
136
+ * first `onClick` emitted wins. */
137
+ onClick?: () => void;
138
+ };
139
+
140
+ export type DashboardContext = {
141
+ /** Emit a label/value row like " label value". The label
142
+ * column is padded to 10 cols so multi-row sections align. */
143
+ kv(label: string, value: string, color?: DashboardColor): void;
144
+ /** Emit a styled text segment on the current row. No newline is
145
+ * added — call `newline()` when the row is finished. */
146
+ text(s: string, opts?: DashboardTextOpts): void;
147
+ /** End the current row. */
148
+ newline(): void;
149
+ /** Shortcut for a single-row error message: " status why". */
150
+ error(message: string): void;
151
+ };
152
+
153
+ export type SectionRefresh = (ctx: DashboardContext) => Promise<void>;
154
+
155
+ /**
156
+ * Public surface of the bundled `dashboard` plugin, reachable through
157
+ * `editor.getPluginApi("dashboard")`. Third-party plugins and user
158
+ * init.ts can contribute their own rows via `registerSection`, and can
159
+ * tear them down again via `removeSection` / `clearAllSections`.
160
+ */
161
+ export type DashboardApi = {
162
+ registerSection(name: string, refresh: SectionRefresh): () => void;
163
+ /** Remove every registered section whose name matches `name`.
164
+ * Returns true if at least one section was removed. */
165
+ removeSection(name: string): boolean;
166
+ /** Remove every registered section, including the bundled
167
+ * built-ins (git, disk). */
168
+ clearAllSections(): void;
169
+ /** Toggle the ambient auto-open behaviour for this session.
170
+ * Equivalent to setting `plugins.dashboard.auto-open` in the
171
+ * user config, but scoped to the current process. */
172
+ setAutoOpen(enabled: boolean): void;
173
+ /** Refresh handlers for the built-in widgets that aren't
174
+ * registered by default (both hit the network). Pass one to
175
+ * `registerSection` from init.ts to enable it. */
176
+ builtinHandlers: {
177
+ weather: SectionRefresh;
178
+ github: SectionRefresh;
179
+ };
180
+ };
181
+
182
+ declare global {
183
+ interface FreshPluginRegistry {
184
+ dashboard: DashboardApi;
185
+ }
186
+ }
187
+
188
+ type RegisteredSection = {
189
+ id: number;
190
+ name: string;
191
+ refresh: SectionRefresh;
192
+ /** Rendered output from the most recent refresh. Re-used until the
193
+ * next refresh lands so the dashboard doesn't flash back to a
194
+ * "loading…" placeholder on every tick. */
195
+ draw: Draw;
196
+ };
197
+
198
+ // ── Internal state ─────────────────────────────────────────────────────
199
+
200
+ // State survives across open/close cycles so we don't pile up dashboards.
201
+ let dashboardBufferId: number | null = null;
202
+ let fetchToken = 0; // bumped each open; late fetches from a prior open no-op.
203
+
204
+ // Registered sections, in render order. Built-ins are registered at
205
+ // plugin load (see the bottom of this file); third-party plugins
206
+ // append via the exported `registerSection` API.
207
+ let nextSectionId = 1;
208
+ const registeredSections: RegisteredSection[] = [];
209
+
210
+ // Ordered list of focusable clickable targets on the currently-painted
211
+ // frame. Rebuilt every paint() from `currentRowActions`. One entry per
212
+ // row that carries at least one action — when a row holds multiple
213
+ // ranges (e.g. PR rows with both #num and title clickable), the highlight
214
+ // spans the union and the activation dispatches the first range's action
215
+ // so Enter matches the leftmost click target on that row.
216
+ type ClickTarget = {
217
+ bufferRow: number; // absolute buffer row (after topPad)
218
+ colStart: number; // visual col (inclusive)
219
+ colEnd: number; // visual col (exclusive)
220
+ action: ClickAction;
221
+ };
222
+ let clickableTargets: ClickTarget[] = [];
223
+ let focusedIndex = 0;
224
+
225
+ // ── Drawing primitives ─────────────────────────────────────────────────
226
+
227
+ function utf8Len(s: string): number {
228
+ return editor.utf8ByteLength(s);
229
+ }
230
+
231
+ function visualWidth(s: string): number {
232
+ // Approximation: wide (E. Asian / most emoji) = 2 cols, everything else = 1.
233
+ let w = 0;
234
+ for (const ch of s) {
235
+ const cp = ch.codePointAt(0) ?? 0;
236
+ if (cp === 0) continue;
237
+ if (cp < 0x80) { w += 1; continue; }
238
+ // CJK / wide ranges (coarse).
239
+ if (
240
+ (cp >= 0x1100 && cp <= 0x115f) ||
241
+ (cp >= 0x2e80 && cp <= 0x303e) ||
242
+ (cp >= 0x3041 && cp <= 0x33ff) ||
243
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
244
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
245
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
246
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
247
+ (cp >= 0xf900 && cp <= 0xfaff) ||
248
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
249
+ (cp >= 0xff00 && cp <= 0xff60) ||
250
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
251
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
252
+ (cp >= 0x1f900 && cp <= 0x1f9ff)
253
+ ) { w += 2; continue; }
254
+ w += 1;
255
+ }
256
+ return w;
257
+ }
258
+
259
+ function pad(s: string, width: number): string {
260
+ const missing = Math.max(0, width - visualWidth(s));
261
+ return s + " ".repeat(missing);
262
+ }
263
+
264
+ function emit(
265
+ d: Draw,
266
+ s: string,
267
+ opts?: { fg?: string; bold?: boolean; url?: string; action?: ClickAction },
268
+ ) {
269
+ if (!s) return;
270
+ const start = utf8Len(d.text);
271
+ d.text += s;
272
+ const width = visualWidth(s);
273
+ const startCol = d.currentCol;
274
+ d.currentCol += width;
275
+ // Anything the user can click gets underlined so it reads as a link
276
+ // even in terminals that don't render OSC-8 hyperlinks.
277
+ const clickable = !!(opts?.url || opts?.action);
278
+ if (opts?.fg || opts?.bold || opts?.url || clickable) {
279
+ d.spans.push({
280
+ start,
281
+ end: start + utf8Len(s),
282
+ fg: opts?.fg,
283
+ bold: opts?.bold,
284
+ underline: clickable || undefined,
285
+ url: opts?.url,
286
+ });
287
+ }
288
+ if (opts?.action) {
289
+ // Record a column-scoped range so the click handler can match
290
+ // clicks only on the text cells the underline actually covers,
291
+ // not on padding or kv labels sharing the row.
292
+ const ranges = d.rowActions.get(d.currentRow) ?? [];
293
+ ranges.push({ colStart: startCol, colEnd: startCol + width, action: opts.action });
294
+ d.rowActions.set(d.currentRow, ranges);
295
+ }
296
+ }
297
+
298
+ function newline(d: Draw) {
299
+ d.text += "\n";
300
+ d.currentRow++;
301
+ d.currentCol = 0;
302
+ }
303
+
304
+ function emptyDraw(): Draw {
305
+ return { text: "", spans: [], currentRow: 0, currentCol: 0, rowActions: new Map() };
306
+ }
307
+
308
+ // ── Sections (sentinel / placeholder factories) ────────────────────────
309
+
310
+ // Produce a one-line status row: " status text". Used for the
311
+ // initial "loading…" placeholder and for top-level error messages
312
+ // that replace a whole section's body.
313
+ function statusRowDraw(text: string, fg: string): Draw {
314
+ const d = emptyDraw();
315
+ const label = pad("status", 10);
316
+ emit(d, " " + label, { fg: C.muted });
317
+ emit(d, text, { fg });
318
+ newline(d);
319
+ return d;
320
+ }
321
+
322
+ function loadingDraw(): Draw {
323
+ return statusRowDraw("loading…", C.muted);
324
+ }
325
+
326
+ // ── Section registry + DashboardContext factory ────────────────────────
327
+
328
+ // Build a DashboardContext that accumulates drawing operations into a
329
+ // fresh Draw. After the caller's refresh callback resolves, the Draw
330
+ // is stashed on the section entry and the dashboard repaints with it.
331
+ function makeContext(): { ctx: DashboardContext; draw: Draw } {
332
+ const d = emptyDraw();
333
+ const ctx: DashboardContext = {
334
+ kv(label, value, color) {
335
+ const fg = color ? COLOR_KEYS[color] : C.value;
336
+ emit(d, " " + pad(label, 10), { fg: C.muted });
337
+ emit(d, value, { fg });
338
+ newline(d);
339
+ },
340
+ text(s, opts) {
341
+ const action: ClickAction | undefined = opts?.onClick
342
+ ? { kind: "callback", fn: opts.onClick }
343
+ : opts?.url
344
+ ? { kind: "open-url", url: opts.url }
345
+ : undefined;
346
+ emit(d, s, {
347
+ fg: opts?.color ? COLOR_KEYS[opts.color] : undefined,
348
+ bold: opts?.bold,
349
+ url: opts?.url,
350
+ action,
351
+ });
352
+ },
353
+ newline() {
354
+ newline(d);
355
+ },
356
+ error(message) {
357
+ const label = pad("status", 10);
358
+ emit(d, " " + label, { fg: C.muted });
359
+ emit(d, message, { fg: C.err });
360
+ newline(d);
361
+ },
362
+ };
363
+ return { ctx, draw: d };
364
+ }
365
+
366
+ // Register a dashboard section. `refresh` is invoked each tick (every
367
+ // 5s while the dashboard is visible) and on every open. Returns a
368
+ // function that unregisters the section when called — e.g. for
369
+ // plugins that want to remove their section on disable.
370
+ function registerSection(name: string, refresh: SectionRefresh): () => void {
371
+ const id = nextSectionId++;
372
+ const entry: RegisteredSection = {
373
+ id,
374
+ name,
375
+ refresh,
376
+ draw: loadingDraw(),
377
+ };
378
+ registeredSections.push(entry);
379
+ // Kick an immediate refresh so the initial frame isn't "loading…"
380
+ // for any longer than the callback actually takes.
381
+ void refreshSection(entry, fetchToken);
382
+ paint();
383
+ return () => {
384
+ const idx = registeredSections.findIndex((s) => s.id === id);
385
+ if (idx >= 0) {
386
+ registeredSections.splice(idx, 1);
387
+ paint();
388
+ }
389
+ };
390
+ }
391
+
392
+ // Remove every registered section whose name matches `name`. Returns
393
+ // true if at least one section was removed. Names are compared verbatim
394
+ // — no case folding or trimming — matching the name passed to
395
+ // `registerSection`. In-flight refreshes for removed sections resolve
396
+ // onto detached entry objects and no longer influence what's rendered.
397
+ function removeSection(name: string): boolean {
398
+ let removed = false;
399
+ for (let i = registeredSections.length - 1; i >= 0; i--) {
400
+ if (registeredSections[i].name === name) {
401
+ registeredSections.splice(i, 1);
402
+ removed = true;
403
+ }
404
+ }
405
+ if (removed) paint();
406
+ return removed;
407
+ }
408
+
409
+ // Clear every registered section, including the bundled built-ins.
410
+ // The dashboard frame is still drawn — only the section body between
411
+ // header and footer is empty until something registers a new section.
412
+ function clearAllSections(): void {
413
+ if (registeredSections.length === 0) return;
414
+ registeredSections.length = 0;
415
+ paint();
416
+ }
417
+
418
+ async function refreshSection(entry: RegisteredSection, myToken: number) {
419
+ const { ctx, draw } = makeContext();
420
+ try {
421
+ await entry.refresh(ctx);
422
+ } catch (e) {
423
+ // A thrown error becomes a one-line error row so a buggy
424
+ // third-party section can't blank the whole dashboard.
425
+ const { ctx: fallbackCtx, draw: fallbackDraw } = makeContext();
426
+ fallbackCtx.error(`failed — ${String(e).slice(0, 60)}`);
427
+ if (myToken !== fetchToken) return;
428
+ entry.draw = fallbackDraw;
429
+ paint();
430
+ return;
431
+ }
432
+ if (myToken !== fetchToken) return;
433
+ entry.draw = draw;
434
+ paint();
435
+ }
436
+
437
+ // ── Frame + section renderer ───────────────────────────────────────────
438
+
439
+ function clockNow(): string {
440
+ const d = new Date();
441
+ const hh = String(d.getHours()).padStart(2, "0");
442
+ const mm = String(d.getMinutes()).padStart(2, "0");
443
+ const ss = String(d.getSeconds()).padStart(2, "0");
444
+ return `${hh}:${mm}:${ss}`;
445
+ }
446
+
447
+ function frameWidth(viewportW: number): { inner: number; leftPad: number } {
448
+ const usable = Math.max(40, viewportW - 4);
449
+ const inner = Math.min(MAX_INNER, usable - 2); // subtract 2 for frame edges
450
+ const total = inner + 2;
451
+ const leftPad = Math.max(0, Math.floor((viewportW - total) / 2));
452
+ return { inner, leftPad };
453
+ }
454
+
455
+ function renderFrame(inner: number, leftPad: number): Draw {
456
+ const d: Draw = emptyDraw();
457
+ const lp = " ".repeat(leftPad);
458
+
459
+ const titleText = "FRESH";
460
+ const stamp = clockNow();
461
+ const titleSegment = ` ${titleText} `;
462
+ const stampSegment = ` ${stamp} `;
463
+ // Top frame: ╭── FRESH ────…──── HH:MM:SS ──╮
464
+ //
465
+ // `inner` is the column count between the two corner glyphs. The top
466
+ // row emits, between ╭ and ╮:
467
+ // "──" (2) + titleSegment (7) + dashRun (fillLen) + stampSegment (10) + "──" (2)
468
+ // so fillLen = inner - visualWidth(titleSegment) - visualWidth(stampSegment) - 4.
469
+ const fillLen =
470
+ inner - visualWidth(titleSegment) - visualWidth(stampSegment) - 4;
471
+ const dashRun = "─".repeat(Math.max(1, fillLen));
472
+
473
+ // top
474
+ emit(d, lp, undefined);
475
+ emit(d, "╭──", { fg: C.frame });
476
+ emit(d, titleSegment, { fg: C.title, bold: true });
477
+ emit(d, dashRun, { fg: C.frame });
478
+ emit(d, stampSegment, { fg: C.muted });
479
+ emit(d, "──╮", { fg: C.frame });
480
+ newline(d);
481
+
482
+ // blank row
483
+ emit(d, lp, undefined);
484
+ emit(d, "│", { fg: C.frame });
485
+ emit(d, " ".repeat(inner), undefined);
486
+ emit(d, "│", { fg: C.frame });
487
+ newline(d);
488
+
489
+ const sectionHeader = (name: string) => {
490
+ // Format: │ ▎ NAME ...
491
+ // Dropped per-section icons: their widths (☀ ⎇ ⚡ ◆) disagree with
492
+ // unicode-width depending on font/emoji-presentation, which
493
+ // silently misaligned the right frame edge.
494
+ const prefix = " ▎ ";
495
+ emit(d, lp, undefined);
496
+ emit(d, "│", { fg: C.frame });
497
+ emit(d, prefix, { fg: C.accent, bold: true });
498
+ emit(d, name, { fg: C.title, bold: true });
499
+ const consumed = visualWidth(prefix) + visualWidth(name);
500
+ emit(d, " ".repeat(Math.max(0, inner - consumed)), undefined);
501
+ emit(d, "│", { fg: C.frame });
502
+ newline(d);
503
+ };
504
+
505
+ const row = (
506
+ body: { text: string; spans: Span[] },
507
+ ranges?: ClickActionRange[],
508
+ ) => {
509
+ // Wraps a single logical row of section body in the frame.
510
+ emit(d, lp, undefined);
511
+ emit(d, "│", { fg: C.frame });
512
+ // body is already one line (no embedded newlines) — renderSection
513
+ // slices multi-line section output before calling row().
514
+ const line = body.text;
515
+ const used = visualWidth(line);
516
+ const startInDoc = utf8Len(d.text);
517
+ // Content starts in the outer draw at this visual column —
518
+ // section-body ranges are offset by it below so clicks hit
519
+ // the same cells the text actually lives in.
520
+ const contentStartCol = d.currentCol;
521
+ d.text += line;
522
+ d.currentCol += used;
523
+ for (const sp of body.spans) {
524
+ if (sp.start < utf8Len(line)) {
525
+ d.spans.push({
526
+ start: startInDoc + sp.start,
527
+ end: startInDoc + Math.min(sp.end, utf8Len(line)),
528
+ fg: sp.fg,
529
+ bold: sp.bold,
530
+ underline: sp.underline,
531
+ url: sp.url,
532
+ });
533
+ }
534
+ }
535
+ if (ranges && ranges.length > 0) {
536
+ const shifted = ranges.map((r) => ({
537
+ colStart: r.colStart + contentStartCol,
538
+ colEnd: r.colEnd + contentStartCol,
539
+ action: r.action,
540
+ }));
541
+ const existing = d.rowActions.get(d.currentRow) ?? [];
542
+ d.rowActions.set(d.currentRow, existing.concat(shifted));
543
+ }
544
+ emit(d, " ".repeat(Math.max(0, inner - used)), undefined);
545
+ emit(d, "│", { fg: C.frame });
546
+ newline(d);
547
+ };
548
+
549
+ const spacerRow = () => {
550
+ emit(d, lp, undefined);
551
+ emit(d, "│", { fg: C.frame });
552
+ emit(d, " ".repeat(inner), undefined);
553
+ emit(d, "│", { fg: C.frame });
554
+ newline(d);
555
+ };
556
+
557
+ const renderSection = (name: string, body: Draw) => {
558
+ sectionHeader(name);
559
+ const bodyLines = body.text.split("\n");
560
+ let cursor = 0;
561
+ for (let lineIdx = 0; lineIdx < bodyLines.length; lineIdx++) {
562
+ const ln = bodyLines[lineIdx];
563
+ if (ln.length === 0 && cursor + ln.length + 1 >= body.text.length) break;
564
+ // Slice the body's spans that fall inside this line's byte range.
565
+ const lineStart = cursor;
566
+ const lineEnd = cursor + utf8Len(ln);
567
+ const sliced: Span[] = body.spans
568
+ .filter((sp) => sp.start >= lineStart && sp.end <= lineEnd + 1)
569
+ .map((sp) => ({
570
+ start: sp.start - lineStart,
571
+ end: sp.end - lineStart,
572
+ fg: sp.fg,
573
+ bold: sp.bold,
574
+ underline: sp.underline,
575
+ url: sp.url,
576
+ }));
577
+ // Propagate the section-level row action (keyed by section-body
578
+ // line index) onto the outer frame row we're about to write.
579
+ row({ text: ln, spans: sliced }, body.rowActions.get(lineIdx));
580
+ cursor = lineEnd + 1;
581
+ }
582
+ spacerRow();
583
+ };
584
+
585
+ for (const entry of registeredSections) {
586
+ renderSection(entry.name.toUpperCase(), entry.draw);
587
+ }
588
+
589
+ // bottom
590
+ emit(d, lp, undefined);
591
+ emit(d, "╰" + "─".repeat(inner) + "╯", { fg: C.frame });
592
+ newline(d);
593
+
594
+ return d;
595
+ }
596
+
597
+ // ── Paint the buffer ───────────────────────────────────────────────────
598
+
599
+ // Convert the byte-indexed Draw model produced by renderFrame into per-line
600
+ // TextPropertyEntry[] with inlineOverlays. Spans are expected to stay within
601
+ // a single line (the renderer never emits a newline inside a styled span)
602
+ // but we clip defensively so a stray cross-line span doesn't misindex.
603
+ function drawToEntries(d: Draw): TextPropertyEntry[] {
604
+ const entries: TextPropertyEntry[] = [];
605
+ const lines = d.text.split("\n");
606
+ let lineByteStart = 0;
607
+ for (let i = 0; i < lines.length; i++) {
608
+ const line = lines[i];
609
+ const isLast = i === lines.length - 1;
610
+ if (isLast && line.length === 0) break; // trailing empty after final \n
611
+ const lineBytes = utf8Len(line);
612
+ const lineByteEnd = lineByteStart + lineBytes;
613
+ const ios: InlineOverlay[] = [];
614
+ for (const sp of d.spans) {
615
+ if (sp.end <= lineByteStart) continue;
616
+ if (sp.start >= lineByteEnd) continue;
617
+ const s = Math.max(sp.start, lineByteStart) - lineByteStart;
618
+ const e = Math.min(sp.end, lineByteEnd) - lineByteStart;
619
+ if (e <= s) continue;
620
+ const style: Partial<OverlayOptions> = {};
621
+ if (sp.fg) style.fg = sp.fg;
622
+ if (sp.bold) style.bold = true;
623
+ if (sp.underline) style.underline = true;
624
+ if (sp.url) style.url = sp.url;
625
+ ios.push({ start: s, end: e, style });
626
+ }
627
+ entries.push({
628
+ text: line + (isLast ? "" : "\n"),
629
+ inlineOverlays: ios.length > 0 ? ios : undefined,
630
+ });
631
+ lineByteStart = lineByteEnd + 1; // account for the \n byte we split on
632
+ }
633
+ return entries;
634
+ }
635
+
636
+ // Track the last viewport dims we painted for, so repeat viewport_changed
637
+ // events (e.g. scroll fires one every time) don't trigger redundant paints.
638
+ let lastPaintedW = -1;
639
+ let lastPaintedH = -1;
640
+
641
+ // Row-index → click-action ranges map, keyed by absolute buffer row
642
+ // (after adding topPad). Each range is a `[colStart, colEnd)` pair so
643
+ // the click handler can gate on buffer_col too. Rebuilt every paint.
644
+ let currentRowActions: Map<number, ClickActionRange[]> = new Map();
645
+
646
+ // Map a visual column (counted from the start of the line) to a UTF-8
647
+ // byte offset inside `text`. Used to translate the visual col ranges
648
+ // stored in ClickTarget into the byte-offset range an InlineOverlay
649
+ // needs. Walks chars with visualWidth/utf8Len so frame glyphs like
650
+ // `│` (3 bytes, 1 col) and future wide chars stay aligned.
651
+ function visualColToByteOffset(text: string, visualCol: number): number {
652
+ if (visualCol <= 0) return 0;
653
+ let col = 0;
654
+ let bytes = 0;
655
+ for (const ch of text) {
656
+ if (col >= visualCol) return bytes;
657
+ col += visualWidth(ch);
658
+ bytes += utf8Len(ch);
659
+ }
660
+ return bytes;
661
+ }
662
+
663
+ function paint(dims?: { width: number; height: number }) {
664
+ if (dashboardBufferId === null) return;
665
+ const bufferId = dashboardBufferId;
666
+ // Prefer explicit dims (from a viewport_changed event, which ships
667
+ // the just-resized width/height before the state snapshot catches
668
+ // up) and fall back to the snapshot. Without this, toggling the
669
+ // file explorer repaints against the stale pre-toggle width, so
670
+ // the frame stays anchored at the old position for one tick.
671
+ const vp = dims ?? editor.getViewport();
672
+ const width = vp?.width ?? 100;
673
+ const height = vp?.height ?? 24;
674
+ const { inner, leftPad } = frameWidth(width);
675
+ const drawn = renderFrame(inner, leftPad);
676
+
677
+ // Count newlines in the rendered frame to vertically center it. Pad
678
+ // above with blank lines; there's no need to pad below since the
679
+ // virtual buffer's empty trailing rows already render as blank.
680
+ let frameHeight = 0;
681
+ for (let i = 0; i < drawn.text.length; i++) {
682
+ if (drawn.text.charCodeAt(i) === 10) frameHeight++;
683
+ }
684
+ const topPad = Math.max(0, Math.floor((height - frameHeight) / 2));
685
+
686
+ const entries: TextPropertyEntry[] = [];
687
+ for (let i = 0; i < topPad; i++) entries.push({ text: "\n" });
688
+ for (const e of drawToEntries(drawn)) entries.push(e);
689
+
690
+ // Translate frame-relative row actions to absolute buffer rows by
691
+ // shifting by the vertical padding we just prepended. Columns are
692
+ // absolute already (the frame renderer placed them via currentCol).
693
+ const abs: Map<number, ClickActionRange[]> = new Map();
694
+ for (const [row, ranges] of drawn.rowActions) {
695
+ abs.set(row + topPad, ranges);
696
+ }
697
+ currentRowActions = abs;
698
+
699
+ // Rebuild the ordered focus targets in visual row order. A row with
700
+ // multiple ranges collapses into a single target whose highlight
701
+ // spans the union of its ranges; Enter dispatches the first range's
702
+ // action, matching the leftmost click target on that row.
703
+ const targets: ClickTarget[] = [];
704
+ const sortedRows = [...abs.keys()].sort((a, b) => a - b);
705
+ for (const row of sortedRows) {
706
+ const ranges = abs.get(row)!;
707
+ if (ranges.length === 0) continue;
708
+ let minCol = ranges[0].colStart;
709
+ let maxCol = ranges[0].colEnd;
710
+ for (const r of ranges) {
711
+ if (r.colStart < minCol) minCol = r.colStart;
712
+ if (r.colEnd > maxCol) maxCol = r.colEnd;
713
+ }
714
+ targets.push({
715
+ bufferRow: row,
716
+ colStart: minCol,
717
+ colEnd: maxCol,
718
+ action: ranges[0].action,
719
+ });
720
+ }
721
+ clickableTargets = targets;
722
+ if (targets.length === 0) {
723
+ focusedIndex = 0;
724
+ } else if (focusedIndex < 0 || focusedIndex >= targets.length) {
725
+ focusedIndex =
726
+ ((focusedIndex % targets.length) + targets.length) % targets.length;
727
+ }
728
+
729
+ // Paint the focus highlight by mutating the entry for the focused
730
+ // row: translate its visual col range into a byte range and push an
731
+ // inline overlay on top of whatever foreground/underline spans the
732
+ // frame renderer already added. Using `editor.selection_bg` keeps
733
+ // the highlight theme-aware — it follows theme switches for free
734
+ // and matches other selection-style highlights elsewhere in the UI.
735
+ if (targets.length > 0) {
736
+ const focus = targets[focusedIndex];
737
+ const entry = entries[focus.bufferRow];
738
+ if (entry) {
739
+ const lineText = entry.text.endsWith("\n")
740
+ ? entry.text.slice(0, -1)
741
+ : entry.text;
742
+ const byteStart = visualColToByteOffset(lineText, focus.colStart);
743
+ const byteEnd = visualColToByteOffset(lineText, focus.colEnd);
744
+ if (byteEnd > byteStart) {
745
+ const overlays: InlineOverlay[] = entry.inlineOverlays
746
+ ? [...entry.inlineOverlays]
747
+ : [];
748
+ overlays.push({
749
+ start: byteStart,
750
+ end: byteEnd,
751
+ style: { bg: "editor.selection_bg" },
752
+ });
753
+ entry.inlineOverlays = overlays;
754
+ }
755
+ }
756
+ }
757
+
758
+ editor.setVirtualBufferContent(bufferId, entries);
759
+ lastPaintedW = width;
760
+ lastPaintedH = height;
761
+ }
762
+
763
+ // Open a URL in the user's browser via the platform's "open" helper.
764
+ // Fires both xdg-open (Linux) and open (macOS) — only one exists per
765
+ // platform; the other exits immediately with ENOENT and causes no
766
+ // user-visible effect. Fire-and-forget: we don't await.
767
+ function openUrl(url: string) {
768
+ // spawnProcess returns a ProcessHandle; we intentionally discard it.
769
+ // The process runs to completion on its own; failures are silent.
770
+ editor.spawnProcess("xdg-open", [url]);
771
+ editor.spawnProcess("open", [url]);
772
+ }
773
+
774
+ function dispatchClickAction(action: ClickAction) {
775
+ switch (action.kind) {
776
+ case "open-url":
777
+ openUrl(action.url);
778
+ return;
779
+ case "callback":
780
+ try {
781
+ action.fn();
782
+ } catch (e) {
783
+ editor.debug(`dashboard click handler threw: ${String(e)}`);
784
+ }
785
+ return;
786
+ }
787
+ }
788
+
789
+ // ── Data fetchers ──────────────────────────────────────────────────────
790
+
791
+ async function run(
792
+ cmd: string,
793
+ args: string[],
794
+ cwd: string,
795
+ timeoutMs: number,
796
+ ): Promise<{ stdout: string; stderr: string; ok: boolean }> {
797
+ const handle = editor.spawnProcess(cmd, args, cwd);
798
+ const timeout = editor.delay(timeoutMs).then(() => "__timeout__");
799
+ const res = await Promise.race([(async () => await handle)(), timeout]);
800
+ if (res === "__timeout__") {
801
+ await handle.kill();
802
+ return { stdout: "", stderr: "timed out", ok: false };
803
+ }
804
+ const r = res as SpawnResult;
805
+ return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", ok: r.exit_code === 0 };
806
+ }
807
+
808
+ const trim = (s: string) => s.replace(/\s+$/, "");
809
+
810
+ // Truncate to at most `maxCols` visual columns. Adds an ellipsis when
811
+ // the string is shortened. Uses the same visualWidth estimator as the
812
+ // frame renderer so the result fits exactly.
813
+ function truncate(s: string, maxCols: number): string {
814
+ if (visualWidth(s) <= maxCols) return s;
815
+ let out = "";
816
+ let w = 0;
817
+ for (const ch of s) {
818
+ const cw = visualWidth(ch);
819
+ if (w + cw > Math.max(0, maxCols - 1)) break;
820
+ out += ch;
821
+ w += cw;
822
+ }
823
+ return out + "…";
824
+ }
825
+
826
+ // Max room for a `kv` value cell inside a standard row. The ` ` + 10-
827
+ // col padded key consume 14 cols, so the value must fit in inner - 14.
828
+ // With MAX_INNER = 72, that's 58 cols in the default case.
829
+ const VALUE_MAX = MAX_INNER - 14;
830
+
831
+ function bar(pct: number, width: number): string {
832
+ const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
833
+ return "━".repeat(filled) + "╌".repeat(width - filled);
834
+ }
835
+
836
+ // wttr.in's j1 response shape — only the fields we consume.
837
+ type WttrHour = {
838
+ time?: string; // "0", "300", …, "2100"
839
+ tempC?: string;
840
+ FeelsLikeC?: string;
841
+ windspeedKmph?: string;
842
+ humidity?: string;
843
+ weatherDesc?: { value?: string }[];
844
+ };
845
+ type WttrDay = {
846
+ date?: string;
847
+ maxtempC?: string;
848
+ mintempC?: string;
849
+ hourly?: WttrHour[];
850
+ };
851
+ type WttrCurrent = {
852
+ temp_C?: string;
853
+ FeelsLikeC?: string;
854
+ windspeedKmph?: string;
855
+ humidity?: string;
856
+ weatherDesc?: { value?: string }[];
857
+ };
858
+ type WttrJ1 = {
859
+ current_condition?: WttrCurrent[];
860
+ weather?: WttrDay[];
861
+ };
862
+
863
+ // Find the hourly entry whose `time` is closest to `targetHour` (0–23).
864
+ // wttr.in returns 3-hour samples encoded as "0", "300", "600", …, so a
865
+ // requested 17 (5pm) snaps to the 1800 sample. We round to the nearest
866
+ // (rather than floor) to avoid showing "morning" weather for 17:00 just
867
+ // because 1500 is numerically smaller.
868
+ function hourlyAt(hours: WttrHour[], targetHour: number): WttrHour | null {
869
+ let best: WttrHour | null = null;
870
+ let bestDelta = Infinity;
871
+ for (const h of hours) {
872
+ const raw = h.time ?? "";
873
+ const hh = Math.round((Number(raw) || 0) / 100);
874
+ const delta = Math.abs(hh - targetHour);
875
+ if (delta < bestDelta) {
876
+ best = h;
877
+ bestDelta = delta;
878
+ }
879
+ }
880
+ return best;
881
+ }
882
+
883
+ function formatCurrent(c: WttrCurrent | undefined): string | null {
884
+ if (!c) return null;
885
+ const cond = c.weatherDesc?.[0]?.value ?? "";
886
+ const temp = c.temp_C ? `${c.temp_C}°C` : "";
887
+ const feels = c.FeelsLikeC && c.FeelsLikeC !== c.temp_C
888
+ ? `feels ${c.FeelsLikeC}°C` : "";
889
+ const wind = c.windspeedKmph ? `${c.windspeedKmph} km/h` : "";
890
+ const hum = c.humidity ? `${c.humidity}%` : "";
891
+ const s = [cond, temp, feels, wind, hum]
892
+ .filter((x) => x.length > 0)
893
+ .join(" · ");
894
+ return s ? truncate(s, VALUE_MAX) : null;
895
+ }
896
+
897
+ function formatHour(h: WttrHour | null): string | null {
898
+ if (!h) return null;
899
+ const cond = h.weatherDesc?.[0]?.value ?? "";
900
+ const temp = h.tempC ? `${h.tempC}°C` : "";
901
+ const feels = h.FeelsLikeC && h.FeelsLikeC !== h.tempC
902
+ ? `feels ${h.FeelsLikeC}°C` : "";
903
+ const s = [cond, temp, feels]
904
+ .filter((x) => x.length > 0)
905
+ .join(" · ");
906
+ return s ? truncate(s, VALUE_MAX) : null;
907
+ }
908
+
909
+ function formatDaySummary(day: WttrDay | undefined): string | null {
910
+ if (!day) return null;
911
+ const midday = hourlyAt(day.hourly ?? [], 12);
912
+ const cond = midday?.weatherDesc?.[0]?.value ?? "";
913
+ const range = day.mintempC && day.maxtempC
914
+ ? `${day.mintempC}°..${day.maxtempC}°C`
915
+ : "";
916
+ const s = [range, cond].filter((x) => x.length > 0).join(" · ");
917
+ return s ? truncate(s, VALUE_MAX) : null;
918
+ }
919
+
920
+ const weatherRefresh: SectionRefresh = async (ctx) => {
921
+ let stdout: string;
922
+ let ok: boolean;
923
+ try {
924
+ // j1 = full JSON payload (current + 3-day forecast, 3-hour samples).
925
+ // Larger than the old %-format but gets us everything in one call.
926
+ const res = await run(
927
+ "curl",
928
+ ["-fsS", "--max-time", "5", "https://wttr.in/?format=j1"],
929
+ "",
930
+ 6000,
931
+ );
932
+ stdout = res.stdout;
933
+ ok = res.ok;
934
+ } catch {
935
+ ctx.error("fetch failed");
936
+ return;
937
+ }
938
+ if (!ok || !stdout.trim()) {
939
+ ctx.error("offline");
940
+ return;
941
+ }
942
+ let parsed: WttrJ1;
943
+ try {
944
+ parsed = JSON.parse(stdout) as WttrJ1;
945
+ } catch {
946
+ ctx.error("malformed response");
947
+ return;
948
+ }
949
+ const now = formatCurrent(parsed.current_condition?.[0]);
950
+ // "5pm": nearest 3-hour sample in today's forecast. wttr.in
951
+ // buckets at 0/3/6/…/21, so 17:00 picks the 18:00 bucket.
952
+ const todayHours = parsed.weather?.[0]?.hourly ?? [];
953
+ const evening = formatHour(hourlyAt(todayHours, 17));
954
+ const tomorrow = formatDaySummary(parsed.weather?.[1]);
955
+ ctx.kv("now", now ?? "–", now ? "accent" : "muted");
956
+ if (evening) ctx.kv("5pm", evening, "value");
957
+ if (tomorrow) ctx.kv("tomorrow", tomorrow, "value");
958
+ };
959
+
960
+ function normalizeRepoUrl(raw: string): string | null {
961
+ const s = trim(raw);
962
+ if (!s) return null;
963
+ // git@github.com:owner/repo(.git)? -> https://github.com/owner/repo
964
+ const sshMatch = s.match(/^git@([^:]+):(.+?)(\.git)?$/);
965
+ if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`;
966
+ // https://github.com/owner/repo(.git)? -> stripped
967
+ const httpsMatch = s.match(/^(https?:\/\/[^/]+\/.+?)(\.git)?$/);
968
+ if (httpsMatch) return httpsMatch[1];
969
+ return s;
970
+ }
971
+
972
+ // Extract "owner/repo" (GitHub "nwo" — name-with-owner) from a git
973
+ // remote URL. Returns null for non-GitHub hosts or unparseable URLs so
974
+ // fetchGithub can surface a clear reason instead of firing off a query
975
+ // against the wrong repo.
976
+ function parseGithubNwo(raw: string): string | null {
977
+ const s = trim(raw);
978
+ if (!s) return null;
979
+ // git@github.com:owner/repo(.git)?
980
+ const ssh = s.match(/^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/i);
981
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
982
+ // https://github.com/owner/repo(.git)? (allow optional user:token@ prefix)
983
+ const https = s.match(
984
+ /^https?:\/\/(?:[^@/]*@)?github\.com\/([^/]+)\/([^/]+?)(\.git)?\/?$/i,
985
+ );
986
+ if (https) return `${https[1]}/${https[2]}`;
987
+ return null;
988
+ }
989
+
990
+ // Parse the two numbers produced by `git rev-list --left-right --count`
991
+ // ("<ahead> <behind>"). Returns null on malformed output.
992
+ function parseLeftRight(stdout: string): { ahead: number; behind: number } | null {
993
+ const parts = trim(stdout).split(/\s+/);
994
+ const a = Number(parts[0]);
995
+ const b = Number(parts[1]);
996
+ if (isNaN(a) || isNaN(b)) return null;
997
+ return { ahead: a, behind: b };
998
+ }
999
+
1000
+ const gitRefresh: SectionRefresh = async (ctx) => {
1001
+ const cwd = editor.getCwd();
1002
+ let branch;
1003
+ let status;
1004
+ let ahead;
1005
+ let remote;
1006
+ try {
1007
+ [branch, status, ahead, remote] = await Promise.all([
1008
+ run("git", ["rev-parse", "--abbrev-ref", "HEAD"], cwd, 3000),
1009
+ run("git", ["status", "--porcelain"], cwd, 3000),
1010
+ run("git", ["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd, 3000),
1011
+ run("git", ["remote", "get-url", "origin"], cwd, 3000),
1012
+ ]);
1013
+ } catch {
1014
+ ctx.error("git failed");
1015
+ return;
1016
+ }
1017
+ if (!branch.ok) {
1018
+ ctx.error("not a git repo");
1019
+ return;
1020
+ }
1021
+ const modified = status.stdout
1022
+ .split("\n")
1023
+ .filter((l) => l.trim().length > 0).length;
1024
+ let trackStr = "no upstream";
1025
+ let trackColor: DashboardColor = "muted";
1026
+ if (ahead.ok) {
1027
+ const ab = parseLeftRight(ahead.stdout);
1028
+ if (ab) {
1029
+ trackStr = `↑ ${ab.ahead} ↓ ${ab.behind}`;
1030
+ trackColor = ab.ahead > 0 || ab.behind > 0 ? "accent" : "ok";
1031
+ }
1032
+ }
1033
+ const repoUrl = remote.ok ? normalizeRepoUrl(remote.stdout) : null;
1034
+ const branchName = trim(branch.stdout);
1035
+
1036
+ // "vs master" row: commits ahead/behind of master, or main as a
1037
+ // fallback for repos that use it as the default branch. Skipped
1038
+ // when the current branch IS master/main (self-comparison is 0/0
1039
+ // and not interesting), or when neither ref exists.
1040
+ let vsBase: { base: string; ahead: number; behind: number } | null = null;
1041
+ if (branchName !== "master" && branchName !== "main") {
1042
+ for (const base of ["origin/master", "origin/main", "master", "main"]) {
1043
+ const r = await run(
1044
+ "git",
1045
+ ["rev-list", "--left-right", "--count", `HEAD...${base}`],
1046
+ cwd,
1047
+ 3000,
1048
+ );
1049
+ if (r.ok) {
1050
+ const ab = parseLeftRight(r.stdout);
1051
+ if (ab) {
1052
+ vsBase = { base: base.replace(/^origin\//, ""), ...ab };
1053
+ break;
1054
+ }
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ // branch — whole row routes clicks to the branch page.
1060
+ const branchBranchUrl = repoUrl
1061
+ ? `${repoUrl}/tree/${encodeURIComponent(branchName)}`
1062
+ : undefined;
1063
+ ctx.text(" " + pad("branch", 10), { color: "muted" });
1064
+ ctx.text(branchName, {
1065
+ color: "branch",
1066
+ url: branchBranchUrl,
1067
+ onClick: branchBranchUrl
1068
+ ? () => openUrl(branchBranchUrl)
1069
+ : undefined,
1070
+ });
1071
+ ctx.newline();
1072
+
1073
+ // remote URL — displayed in full with scheme so that terminals
1074
+ // that auto-detect URLs (but ignore OSC-8) still recognize it.
1075
+ // The whole row is also click-routable via the mouse_click hook.
1076
+ if (repoUrl) {
1077
+ ctx.text(" " + pad("repo", 10), { color: "muted" });
1078
+ ctx.text(repoUrl, {
1079
+ color: "accent",
1080
+ url: repoUrl,
1081
+ onClick: () => openUrl(repoUrl),
1082
+ });
1083
+ ctx.newline();
1084
+ }
1085
+
1086
+ ctx.kv("tracking", trackStr, trackColor);
1087
+ if (vsBase) {
1088
+ const label = `vs ${vsBase.base}`;
1089
+ const str = `↑ ${vsBase.ahead} ↓ ${vsBase.behind}`;
1090
+ const color: DashboardColor =
1091
+ vsBase.ahead > 0 || vsBase.behind > 0 ? "accent" : "ok";
1092
+ ctx.kv(label, str, color);
1093
+ }
1094
+ ctx.kv(
1095
+ "changes",
1096
+ `${modified} file${modified === 1 ? "" : "s"}`,
1097
+ modified > 0 ? "warn" : "muted",
1098
+ );
1099
+
1100
+ // Clickable "review branch" action. Triggers the audit_mode
1101
+ // plugin's `start_review_branch` handler via the plugin-action
1102
+ // bridge — executeAction falls through to Action::PluginAction
1103
+ // for any name that's not a built-in, and the plugin manager
1104
+ // dispatches that to the registered handler by name.
1105
+ ctx.text(" " + pad("review", 10), { color: "muted" });
1106
+ ctx.text("▶ review branch", {
1107
+ color: "accent",
1108
+ bold: true,
1109
+ onClick: () => editor.executeAction("start_review_branch"),
1110
+ });
1111
+ ctx.newline();
1112
+ };
1113
+
1114
+ // PR row types — module-level so the last-good state can reference them.
1115
+ type GhRollup = { state?: string } | null;
1116
+ type GhCommit = { statusCheckRollup?: GhRollup };
1117
+ type GhCommitNode = { commit?: GhCommit };
1118
+ type GhThread = { isResolved?: boolean; comments?: { totalCount?: number } };
1119
+ type GhPR = {
1120
+ number?: number;
1121
+ title?: string;
1122
+ state?: string;
1123
+ repository?: { nameWithOwner?: string };
1124
+ commits?: { nodes?: GhCommitNode[] };
1125
+ reviewThreads?: { nodes?: GhThread[] };
1126
+ };
1127
+
1128
+ // Last-known-good GitHub state, preserved across refresh failures so
1129
+ // the panel doesn't jump between "data" and "error". `prs === null`
1130
+ // means we've never successfully fetched — in that case an error
1131
+ // replaces the section wholesale. Once we have PRs, a later failure
1132
+ // only adds a one-line banner at the top.
1133
+ let githubLastPrs: GhPR[] | null = null;
1134
+ let githubLastError: string | null = null;
1135
+
1136
+ // Column widths for PR rows. `num` covers `#` + up to 7 digits so PR
1137
+ // numbers in large repos (e.g. the node/k8s ranges) don't overflow and
1138
+ // push the state column out of alignment.
1139
+ const PR_COL_NUM = 8;
1140
+ const PR_COL_STATE = 5;
1141
+ const PR_COL_CHECK = 2;
1142
+ const PR_COL_CMTS = 6;
1143
+
1144
+ function renderPrRows(ctx: DashboardContext, prs: GhPR[]) {
1145
+ if (prs.length === 0) {
1146
+ ctx.kv("PRs", "no open PRs", "muted");
1147
+ return;
1148
+ }
1149
+ ctx.kv("PRs", `${prs.length} open`, "number");
1150
+ for (const pr of prs) {
1151
+ const state = (pr.state ?? "").toUpperCase();
1152
+ const stateTag =
1153
+ state === "OPEN"
1154
+ ? "open"
1155
+ : state === "MERGED"
1156
+ ? "mrgd"
1157
+ : state === "CLOSED"
1158
+ ? "clsd"
1159
+ : "???";
1160
+ const stateColor: DashboardColor =
1161
+ state === "OPEN"
1162
+ ? "ok"
1163
+ : state === "MERGED"
1164
+ ? "accent"
1165
+ : "muted";
1166
+
1167
+ const rollup = pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null;
1168
+ const checkGlyph =
1169
+ rollup === "SUCCESS"
1170
+ ? "✓"
1171
+ : rollup === "FAILURE" || rollup === "ERROR"
1172
+ ? "✗"
1173
+ : rollup === "PENDING" || rollup === "EXPECTED"
1174
+ ? "◌"
1175
+ : "–";
1176
+ const checkColor: DashboardColor =
1177
+ rollup === "SUCCESS"
1178
+ ? "ok"
1179
+ : rollup === "FAILURE" || rollup === "ERROR"
1180
+ ? "err"
1181
+ : rollup === "PENDING" || rollup === "EXPECTED"
1182
+ ? "warn"
1183
+ : "muted";
1184
+
1185
+ const threads = pr.reviewThreads?.nodes ?? [];
1186
+ const openCmts = threads
1187
+ .filter((t) => t.isResolved === false)
1188
+ .reduce((acc, t) => acc + (t.comments?.totalCount ?? 0), 0);
1189
+
1190
+ const num = `#${pr.number ?? "?"}`;
1191
+ const title = (pr.title ?? "").slice(0, 44);
1192
+ const repoName = pr.repository?.nameWithOwner ?? "";
1193
+ const prUrl =
1194
+ repoName && pr.number
1195
+ ? `https://github.com/${repoName}/pull/${pr.number}`
1196
+ : undefined;
1197
+
1198
+ // Whole PR row routes clicks to prUrl (set once on the first
1199
+ // action-bearing emit — subsequent emits on the same row would
1200
+ // overwrite with the same value). Emit the number text and
1201
+ // its padding in two separate spans so the underline (added
1202
+ // to clickable spans by `emit`) lands on the "#1234" text
1203
+ // only — trailing padding spaces stay plain.
1204
+ const onClickPr = prUrl ? () => openUrl(prUrl) : undefined;
1205
+ const numPad = " ".repeat(
1206
+ Math.max(0, PR_COL_NUM - visualWidth(num)),
1207
+ );
1208
+ ctx.text(" ");
1209
+ ctx.text(num, {
1210
+ color: "number",
1211
+ url: prUrl,
1212
+ onClick: onClickPr,
1213
+ });
1214
+ if (numPad) ctx.text(numPad);
1215
+ ctx.text(pad(stateTag, PR_COL_STATE), {
1216
+ color: stateColor,
1217
+ bold: true,
1218
+ });
1219
+ ctx.text(" ");
1220
+ ctx.text(pad(checkGlyph, PR_COL_CHECK), {
1221
+ color: checkColor,
1222
+ bold: true,
1223
+ });
1224
+ const cmtCell =
1225
+ openCmts > 0
1226
+ ? pad(`${openCmts} cmt`, PR_COL_CMTS)
1227
+ : pad("", PR_COL_CMTS);
1228
+ ctx.text(cmtCell, { color: openCmts > 0 ? "warn" : "muted" });
1229
+ ctx.text(" ");
1230
+ ctx.text(title, { color: "value", url: prUrl });
1231
+ ctx.newline();
1232
+ }
1233
+ }
1234
+
1235
+ function drawGithubState(ctx: DashboardContext) {
1236
+ // Stale-data banner: when we have previously-good PRs AND the
1237
+ // latest refresh failed, show both. Keeps the rest of the
1238
+ // section anchored — no row-count jumps between ticks.
1239
+ if (githubLastError && githubLastPrs !== null) {
1240
+ ctx.text(" " + pad("update", 10), { color: "muted" });
1241
+ ctx.text(`failed — ${githubLastError}`, { color: "err" });
1242
+ ctx.newline();
1243
+ renderPrRows(ctx, githubLastPrs);
1244
+ return;
1245
+ }
1246
+ if (githubLastPrs !== null) {
1247
+ renderPrRows(ctx, githubLastPrs);
1248
+ return;
1249
+ }
1250
+ if (githubLastError) {
1251
+ ctx.error(githubLastError);
1252
+ return;
1253
+ }
1254
+ ctx.kv("status", "loading…", "muted");
1255
+ }
1256
+
1257
+ // Detect the GitHub owner/repo for the working directory. Returns
1258
+ // either the nwo ("owner/repo") or a human-readable reason we couldn't
1259
+ // determine one — fetchGithub renders that reason in the UI instead of
1260
+ // fetching PRs against the wrong repo.
1261
+ async function detectGithubNwo(
1262
+ cwd: string,
1263
+ ): Promise<{ nwo: string } | { err: string }> {
1264
+ const inside = await run(
1265
+ "git",
1266
+ ["rev-parse", "--is-inside-work-tree"],
1267
+ cwd,
1268
+ 3000,
1269
+ );
1270
+ if (!inside.ok) return { err: "not a git repo" };
1271
+ const remote = await run("git", ["remote", "get-url", "origin"], cwd, 3000);
1272
+ if (!remote.ok) return { err: "no git remote" };
1273
+ const nwo = parseGithubNwo(remote.stdout);
1274
+ if (!nwo) return { err: "not a github repo" };
1275
+ return { nwo };
1276
+ }
1277
+
1278
+ const githubRefresh: SectionRefresh = async (ctx) => {
1279
+ const cwd = editor.getCwd();
1280
+ const detected = await detectGithubNwo(cwd);
1281
+ if ("err" in detected) {
1282
+ githubLastPrs = null;
1283
+ githubLastError = detected.err;
1284
+ drawGithubState(ctx);
1285
+ return;
1286
+ }
1287
+ const nwo = detected.nwo;
1288
+ // Recent PRs in THIS repo. One GraphQL round-trip fetches state
1289
+ // (OPEN / MERGED / CLOSED), combined check status from the tip
1290
+ // commit's rollup, and the list of review threads so we can count
1291
+ // *unresolved* comment threads per PR. `$owner`/`$name` are passed
1292
+ // as variables so the nwo is not interpolated into the query
1293
+ // string.
1294
+ const [owner, name] = nwo.split("/");
1295
+ const query = `
1296
+ query($owner: String!, $name: String!) {
1297
+ repository(owner: $owner, name: $name) {
1298
+ pullRequests(first: 6, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
1299
+ nodes {
1300
+ number
1301
+ title
1302
+ state
1303
+ repository { nameWithOwner }
1304
+ commits(last: 1) {
1305
+ nodes {
1306
+ commit {
1307
+ statusCheckRollup { state }
1308
+ }
1309
+ }
1310
+ }
1311
+ reviewThreads(first: 50) {
1312
+ nodes {
1313
+ isResolved
1314
+ comments { totalCount }
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ }
1320
+ }
1321
+ `;
1322
+ let failure: string | null = null;
1323
+ try {
1324
+ const res = await run(
1325
+ "gh",
1326
+ [
1327
+ "api",
1328
+ "graphql",
1329
+ "-f",
1330
+ `query=${query}`,
1331
+ "-F",
1332
+ `owner=${owner}`,
1333
+ "-F",
1334
+ `name=${name}`,
1335
+ ],
1336
+ "",
1337
+ 7000,
1338
+ );
1339
+ if (!res.ok) {
1340
+ const stderr = res.stderr.toLowerCase();
1341
+ failure =
1342
+ stderr.includes("not found") || stderr.includes("no such file")
1343
+ ? "gh not installed"
1344
+ : stderr.includes("auth")
1345
+ ? "gh not authenticated"
1346
+ : trim(res.stderr).split("\n")[0]?.slice(0, 40) || "gh failed";
1347
+ } else {
1348
+ try {
1349
+ const parsed = JSON.parse(res.stdout);
1350
+ const prs: GhPR[] =
1351
+ (
1352
+ parsed as {
1353
+ data?: {
1354
+ repository?: { pullRequests?: { nodes?: GhPR[] } };
1355
+ };
1356
+ }
1357
+ )?.data?.repository?.pullRequests?.nodes ?? [];
1358
+ githubLastPrs = prs;
1359
+ githubLastError = null;
1360
+ } catch {
1361
+ failure = "malformed response";
1362
+ }
1363
+ }
1364
+ } catch {
1365
+ failure = "gh failed";
1366
+ }
1367
+ if (failure !== null) githubLastError = failure;
1368
+ drawGithubState(ctx);
1369
+ };
1370
+
1371
+ const diskRefresh: SectionRefresh = async (ctx) => {
1372
+ const mounts = ["/", editor.getEnv("HOME") ?? "/home"];
1373
+ const seen = new Set<string>();
1374
+ const rows: { mount: string; pct: number; used: string; size: string }[] = [];
1375
+ try {
1376
+ for (const m of mounts) {
1377
+ const { stdout, ok } = await run("df", ["-hP", m], "", 3000);
1378
+ if (!ok) continue;
1379
+ const lns = stdout.split("\n").filter((l) => l.length > 0);
1380
+ if (lns.length < 2) continue;
1381
+ const cols = lns[1].split(/\s+/);
1382
+ if (cols.length < 6) continue;
1383
+ const mount = cols[5];
1384
+ if (seen.has(mount)) continue;
1385
+ seen.add(mount);
1386
+ rows.push({
1387
+ mount,
1388
+ pct: Number(cols[4].replace("%", "")) || 0,
1389
+ used: cols[2],
1390
+ size: cols[1],
1391
+ });
1392
+ }
1393
+ } catch {
1394
+ ctx.error("df failed");
1395
+ return;
1396
+ }
1397
+ if (rows.length === 0) {
1398
+ ctx.error("df failed");
1399
+ return;
1400
+ }
1401
+ for (const row of rows) {
1402
+ const color: DashboardColor =
1403
+ row.pct >= 90 ? "err" : row.pct >= 75 ? "warn" : "ok";
1404
+ ctx.text(" " + pad(row.mount, 10), { color: "muted" });
1405
+ ctx.text(bar(row.pct, 18), { color, bold: true });
1406
+ ctx.text(" " + String(row.pct).padStart(3) + "%", { color });
1407
+ ctx.text(` ${row.used} / ${row.size}`, { color: "muted" });
1408
+ ctx.newline();
1409
+ }
1410
+ };
1411
+
1412
+ // ── Lifecycle ──────────────────────────────────────────────────────────
1413
+
1414
+ // Fire-and-forget: refresh every 5s while the dashboard remains the
1415
+ // active dashboard. Each tick bumps `fetchToken` and re-kicks every
1416
+ // registered section's refresh callback; in-flight refreshes from a
1417
+ // previous tick become no-ops the moment their token stops matching.
1418
+ // Loop exits when the dashboard buffer is closed (dashboardBufferId
1419
+ // becomes null).
1420
+ async function refreshLoop(myBufferId: number) {
1421
+ while (dashboardBufferId === myBufferId) {
1422
+ await editor.delay(5000);
1423
+ if (dashboardBufferId !== myBufferId) return;
1424
+ paint(); // refresh clock even if fetches lag
1425
+ fetchToken++;
1426
+ const tok = fetchToken;
1427
+ for (const entry of registeredSections) {
1428
+ void refreshSection(entry, tok);
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ // Set for the duration of an in-flight openDashboard call. The
1434
+ // createVirtualBuffer round-trip is async, so a second openDashboard
1435
+ // invocation (e.g. from the `ready` hook firing while enable()'s own
1436
+ // call is still awaiting) would otherwise see dashboardBufferId === null
1437
+ // and create a second Dashboard tab.
1438
+ let dashboardOpening = false;
1439
+
1440
+ // Kick section refreshes and start the periodic refresh loop. Used
1441
+ // both by the ambient auto-open path and by the command-palette
1442
+ // "Show Dashboard" handler — the parts that differ (whether to bail
1443
+ // on real files, whether to sweep untitled scratch) live in the
1444
+ // callers.
1445
+ function bootstrapDashboard(bufferId: number) {
1446
+ // Reset section draws to "loading…" and kick a fresh refresh for
1447
+ // each registered section. Token guards against late resolvers
1448
+ // from a prior open clobbering the new one.
1449
+ //
1450
+ // GitHub's section callback reuses the last-good PR snapshot (if
1451
+ // any) on its first call post-open so a re-opened dashboard can
1452
+ // draw real data on the first frame while the refresh round-trip
1453
+ // is still in flight. Refresh failures surface via the in-panel
1454
+ // stale-data banner.
1455
+ fetchToken++;
1456
+ const myToken = fetchToken;
1457
+ for (const entry of registeredSections) {
1458
+ entry.draw = loadingDraw();
1459
+ void refreshSection(entry, myToken);
1460
+ }
1461
+ paint();
1462
+
1463
+ // Kick off the 5-second refresh loop. It stops itself when the
1464
+ // dashboard is closed.
1465
+ refreshLoop(bufferId);
1466
+ }
1467
+
1468
+ async function openDashboard() {
1469
+ if (dashboardBufferId !== null) return; // already open
1470
+ if (dashboardOpening) return; // another openDashboard is mid-await
1471
+ dashboardOpening = true;
1472
+
1473
+ const res = await editor.createVirtualBuffer({
1474
+ name: "Dashboard",
1475
+ mode: "dashboard",
1476
+ readOnly: true,
1477
+ showLineNumbers: false,
1478
+ showCursors: false,
1479
+ editingDisabled: true,
1480
+ });
1481
+ dashboardBufferId = res.bufferId;
1482
+ dashboardOpening = false;
1483
+ focusedIndex = 0;
1484
+
1485
+ // Re-check: while we were awaiting createVirtualBuffer, a real
1486
+ // file may have landed — e.g. a CLI file from `fresh my_file`
1487
+ // that was queued before our `ready` handler ran, or a file the
1488
+ // user opened from the explorer. If so, quietly close the buffer
1489
+ // we just created instead of showing it and stealing focus.
1490
+ const realFilesNow = editor.listBuffers().filter(
1491
+ (b) =>
1492
+ !b.is_virtual &&
1493
+ b.path &&
1494
+ b.path.length > 0 &&
1495
+ b.id !== dashboardBufferId,
1496
+ );
1497
+ if (realFilesNow.length > 0) {
1498
+ editor.closeBuffer(dashboardBufferId);
1499
+ dashboardBufferId = null;
1500
+ return;
1501
+ }
1502
+ editor.showBuffer(dashboardBufferId);
1503
+
1504
+ // Close any untitled scratch left over from the last-tab-closed event
1505
+ // or the initial launch — the dashboard should own the split.
1506
+ for (const b of editor.listBuffers()) {
1507
+ if (
1508
+ !b.is_virtual &&
1509
+ (!b.path || b.path.length === 0) &&
1510
+ b.id !== dashboardBufferId
1511
+ ) {
1512
+ editor.closeBuffer(b.id);
1513
+ }
1514
+ }
1515
+
1516
+ bootstrapDashboard(dashboardBufferId);
1517
+ }
1518
+
1519
+ // Command-palette handler: show the dashboard if it isn't open, or
1520
+ // bring it to the front of the current split if it is. Unlike the
1521
+ // ambient open path, this never closes real files or untitled scratch
1522
+ // — if the user has a file open and types "Show Dashboard", the
1523
+ // dashboard opens alongside it rather than replacing it.
1524
+ async function dashboardShowOrFocus() {
1525
+ if (dashboardBufferId !== null) {
1526
+ editor.showBuffer(dashboardBufferId);
1527
+ return;
1528
+ }
1529
+ if (dashboardOpening) return;
1530
+ dashboardOpening = true;
1531
+ const res = await editor.createVirtualBuffer({
1532
+ name: "Dashboard",
1533
+ mode: "dashboard",
1534
+ readOnly: true,
1535
+ showLineNumbers: false,
1536
+ showCursors: false,
1537
+ editingDisabled: true,
1538
+ });
1539
+ dashboardBufferId = res.bufferId;
1540
+ dashboardOpening = false;
1541
+ focusedIndex = 0;
1542
+ editor.showBuffer(dashboardBufferId);
1543
+ bootstrapDashboard(dashboardBufferId);
1544
+ }
1545
+ registerHandler("dashboardShowOrFocus", dashboardShowOrFocus);
1546
+
1547
+ // Auto-open resolution: the session override (set via the exported
1548
+ // plugin API from init.ts) wins over the user config. We read from
1549
+ // getUserConfig (raw file) rather than getConfig because unknown
1550
+ // fields are dropped when the Config struct reserializes. Default
1551
+ // is true.
1552
+ let autoOpenOverride: boolean | null = null;
1553
+
1554
+ function autoOpenEnabled(): boolean {
1555
+ if (autoOpenOverride !== null) return autoOpenOverride;
1556
+ const cfg = editor.getUserConfig() as Record<string, unknown> | null;
1557
+ const plugins = cfg?.plugins as Record<string, unknown> | undefined;
1558
+ const dashboard = plugins?.dashboard as Record<string, unknown> | undefined;
1559
+ return dashboard?.["auto-open"] !== false;
1560
+ }
1561
+
1562
+ function shouldShowDashboard(): boolean {
1563
+ if (dashboardBufferId !== null) return false;
1564
+ if (!autoOpenEnabled()) return false;
1565
+ const all = editor.listBuffers();
1566
+ const realFiles = all.filter(
1567
+ (b) => !b.is_virtual && b.path && b.path.length > 0,
1568
+ );
1569
+ return realFiles.length === 0;
1570
+ }
1571
+
1572
+ // ── Editor event handlers ─────────────────────────────────────────────
1573
+
1574
+ // Named handlers are registered up-front — the plugin runtime requires
1575
+ // handlers to exist before `editor.on(...)` subscribes to them. The
1576
+ // subscription itself happens at the bottom of the file, once all the
1577
+ // handlers below exist.
1578
+ registerHandler("dashboardOnReady", async () => {
1579
+ if (shouldShowDashboard()) await openDashboard();
1580
+ });
1581
+ registerHandler(
1582
+ "dashboardOnBufferClosed",
1583
+ async (e: { buffer_id: number }) => {
1584
+ // If the dashboard itself was closed, clear our handle so we'll
1585
+ // re-open on the next "last tab closed" event.
1586
+ if (dashboardBufferId !== null && e.buffer_id === dashboardBufferId) {
1587
+ dashboardBufferId = null;
1588
+ return;
1589
+ }
1590
+ if (shouldShowDashboard()) await openDashboard();
1591
+ },
1592
+ );
1593
+ // Step aside when a real file opens. This covers two flows:
1594
+ // 1. `fresh my_file` at the command line: the file is queued before
1595
+ // the event loop starts, so the dashboard's `ready` handler can
1596
+ // race ahead and open on top of the pending file. When the file
1597
+ // eventually lands here, we close the dashboard so the user's
1598
+ // file is the visible tab.
1599
+ // 2. Opening a file from the explorer / command palette while the
1600
+ // dashboard is the current tab. Same reasoning: the user asked
1601
+ // for a real buffer, so the dashboard shouldn't linger in front.
1602
+ registerHandler(
1603
+ "dashboardOnAfterFileOpen",
1604
+ (_e: { buffer_id: number; path: string }) => {
1605
+ if (dashboardBufferId === null) return;
1606
+ editor.closeBuffer(dashboardBufferId);
1607
+ dashboardBufferId = null;
1608
+ },
1609
+ );
1610
+ // viewport_changed fires whenever a split's dimensions change, which
1611
+ // covers terminal resize *and* file-explorer toggle (opening the explorer
1612
+ // shrinks the dashboard split's width; closing it grows it back). We
1613
+ // dedupe against the last-painted dims so scroll-only events (which also
1614
+ // fire this hook) don't cause gratuitous repaints.
1615
+ registerHandler(
1616
+ "dashboardOnViewportChanged",
1617
+ (data: { buffer_id: number; width: number; height: number }) => {
1618
+ if (dashboardBufferId === null) return;
1619
+ if (data.buffer_id !== dashboardBufferId) return;
1620
+ if (data.width === lastPaintedW && data.height === lastPaintedH) return;
1621
+ // Pass the fresh dims through so we center against the new
1622
+ // split width on this very tick — the getViewport() snapshot
1623
+ // is only updated on the next render pass.
1624
+ paint({ width: data.width, height: data.height });
1625
+ },
1626
+ );
1627
+ // Keyboard navigation. The dashboard buffer is `showCursors: false` +
1628
+ // `editingDisabled: true`, so there's no native cursor to drive
1629
+ // selection — we track focus ourselves via `focusedIndex` and repaint
1630
+ // to move the highlight. Wraparound in both directions so the user
1631
+ // can't walk off either end of the clickable list.
1632
+ function moveFocus(delta: number) {
1633
+ if (clickableTargets.length === 0) return;
1634
+ focusedIndex =
1635
+ (focusedIndex + delta + clickableTargets.length) %
1636
+ clickableTargets.length;
1637
+ paint();
1638
+ }
1639
+ registerHandler("dashboardFocusNext", () => moveFocus(1));
1640
+ registerHandler("dashboardFocusPrev", () => moveFocus(-1));
1641
+ registerHandler("dashboardActivate", () => {
1642
+ if (clickableTargets.length === 0) return;
1643
+ const target = clickableTargets[focusedIndex];
1644
+ if (!target) return;
1645
+ dispatchClickAction(target.action);
1646
+ });
1647
+
1648
+ // Mode bindings mirror the standard "list with selectable rows"
1649
+ // idiom: Tab / Down / j step forward, BackTab / Up / k step back,
1650
+ // Return activates. `inheritNormalBindings: false` because every
1651
+ // useful key on a read-only, no-cursor buffer is either bound above
1652
+ // or intentionally inert (we don't want j/k falling through to cursor
1653
+ // movement commands that would silently do nothing here).
1654
+ editor.defineMode(
1655
+ "dashboard",
1656
+ [
1657
+ ["Tab", "dashboardFocusNext"],
1658
+ ["Down", "dashboardFocusNext"],
1659
+ ["j", "dashboardFocusNext"],
1660
+ ["BackTab", "dashboardFocusPrev"],
1661
+ ["Up", "dashboardFocusPrev"],
1662
+ ["k", "dashboardFocusPrev"],
1663
+ ["Return", "dashboardActivate"],
1664
+ ],
1665
+ true, // read-only
1666
+ false, // allow_text_input
1667
+ false, // don't inherit Normal bindings — no cursor to move
1668
+ );
1669
+
1670
+ // Dispatch clicks on rows that carry an action. We don't trust the
1671
+ // terminal to honor OSC-8 hyperlinks on the `url` span — many strip
1672
+ // them silently — so every clickable element also registers a
1673
+ // row-based ClickAction and we route the click ourselves.
1674
+ registerHandler(
1675
+ "dashboardOnMouseClick",
1676
+ (data: {
1677
+ column: number;
1678
+ row: number;
1679
+ button: string;
1680
+ modifiers: string;
1681
+ content_x: number;
1682
+ content_y: number;
1683
+ buffer_id: number | null;
1684
+ buffer_row: number | null;
1685
+ buffer_col: number | null;
1686
+ }) => {
1687
+ if (data.button !== "left") return;
1688
+ if (dashboardBufferId === null) return;
1689
+ if (data.buffer_id !== dashboardBufferId) return;
1690
+ if (data.buffer_row === null) return;
1691
+ const ranges = currentRowActions.get(data.buffer_row);
1692
+ if (!ranges) return;
1693
+ // Gate on the visual column within the buffer's content area.
1694
+ // `buffer_col` is in UTF-8 bytes and doesn't line up with the
1695
+ // frame's multi-byte characters (`│`, `╭` etc.), so we derive
1696
+ // the visual column from `column - content_x` — the screen
1697
+ // column relative to the buffer panel's left edge. Our
1698
+ // registered ranges are stored in visual cells and reset to 0
1699
+ // at each newline, so the two units match. A click outside
1700
+ // every registered range is a no-op: whitespace, kv labels,
1701
+ // and the frame border inside an inner row stay unclickable,
1702
+ // matching the underline-as-affordance contract.
1703
+ const visualCol = data.column - data.content_x;
1704
+ const match = ranges.find(
1705
+ (r) => visualCol >= r.colStart && visualCol < r.colEnd,
1706
+ );
1707
+ if (!match) return;
1708
+ dispatchClickAction(match.action);
1709
+ },
1710
+ );
1711
+
1712
+ // Register the built-in sections. They use the same public
1713
+ // `DashboardContext` API that third-party plugins consume, so any
1714
+ // change to the context contract surfaces here first.
1715
+ //
1716
+ // `weather` and `github` are opt-in — they hit the network on every
1717
+ // refresh, so we only register `git` and `disk` by default. Users
1718
+ // wire the others up from init.ts via the exported plugin API; see
1719
+ // the init.ts starter template for a ready-to-paste example.
1720
+ registerSection("git", gitRefresh);
1721
+ registerSection("disk", diskRefresh);
1722
+
1723
+ // Expose the section-management entry points to other plugins and to
1724
+ // user init.ts. `registerSection(name, refresh)` adds a section and
1725
+ // returns an unregister callback; `removeSection(name)` tears sections
1726
+ // down by name; `clearAllSections()` removes every section, built-ins
1727
+ // included. The refresh callback receives a `DashboardContext` with
1728
+ // `kv`, `text`, `newline`, and `error` primitives — see the init.ts
1729
+ // starter template for an end-to-end example.
1730
+ editor.exportPluginApi("dashboard", {
1731
+ registerSection(name: string, refresh: SectionRefresh): () => void {
1732
+ if (typeof name !== "string" || name.length === 0) {
1733
+ throw new Error("dashboard.registerSection: name must be a non-empty string");
1734
+ }
1735
+ if (typeof refresh !== "function") {
1736
+ throw new Error("dashboard.registerSection: refresh must be a function");
1737
+ }
1738
+ return registerSection(name, refresh);
1739
+ },
1740
+ removeSection(name: string): boolean {
1741
+ if (typeof name !== "string" || name.length === 0) {
1742
+ throw new Error("dashboard.removeSection: name must be a non-empty string");
1743
+ }
1744
+ return removeSection(name);
1745
+ },
1746
+ clearAllSections(): void {
1747
+ clearAllSections();
1748
+ },
1749
+ setAutoOpen(enabled: boolean): void {
1750
+ autoOpenOverride = !!enabled;
1751
+ },
1752
+ builtinHandlers: {
1753
+ weather: weatherRefresh,
1754
+ github: githubRefresh,
1755
+ },
1756
+ });
1757
+
1758
+ // Subscribe to the hooks that drive the dashboard. Reaching this code
1759
+ // means the plugin has been loaded, which only happens when
1760
+ // `plugins.dashboard.enabled` is true in the resolved config — so the
1761
+ // standard settings UI is the single enable/disable surface.
1762
+ //
1763
+ // If the plugin loads mid-session (user toggles it on in Settings),
1764
+ // the `ready` hook has already fired, so we also run an immediate
1765
+ // check. At startup the `listBuffers().length > 0` guard keeps us
1766
+ // dormant until the workspace has actually restored: plugins load
1767
+ // before restore, and opening a buffer here would race with the
1768
+ // restore and leave a stray Dashboard tab even when real files exist.
1769
+ editor.on("ready", "dashboardOnReady");
1770
+ editor.on("buffer_closed", "dashboardOnBufferClosed");
1771
+ editor.on("viewport_changed", "dashboardOnViewportChanged");
1772
+ editor.on("mouse_click", "dashboardOnMouseClick");
1773
+ editor.on("after_file_open", "dashboardOnAfterFileOpen");
1774
+
1775
+ // Command-palette entry. No-op when the dashboard is already the
1776
+ // focused tab (showBuffer on a visible buffer is a cheap re-focus).
1777
+ editor.registerCommand(
1778
+ "Show Dashboard",
1779
+ "Open the dashboard, or bring it to the front if it's already open",
1780
+ "dashboardShowOrFocus",
1781
+ );
1782
+
1783
+ if (editor.listBuffers().length > 0 && shouldShowDashboard()) {
1784
+ openDashboard();
1785
+ }