@pellux/goodvibes-tui 1.1.0 → 1.9.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.
Files changed (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
@@ -1,517 +0,0 @@
1
- import type { Stats, FSWatcher } from 'node:fs';
2
- import { promises as fsPromises, readFileSync, statSync, watch } from 'node:fs';
3
- import * as path from 'node:path';
4
- import type { Line, Cell } from '../types/grid.ts';
5
- import { createStyledCell, createEmptyLine } from '../types/grid.ts';
6
- import { BasePanel } from './base-panel.ts';
7
- import { SyntaxHighlighter, type SyntaxToken } from '../renderer/syntax-highlighter.ts';
8
- import { getDisplayWidth } from '../utils/terminal-width.ts';
9
- import {
10
- buildEmptyState,
11
- buildKeyboardHints,
12
- buildPanelLine,
13
- buildPanelWorkspace,
14
- resolveScrollablePanelSection,
15
- DEFAULT_PANEL_PALETTE,
16
- } from './polish.ts';
17
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
-
19
- // ─── Constants ────────────────────────────────────────────────────────────────
20
-
21
- const MAX_FILE_SIZE = 100 * 1024; // 100 KB
22
- const BG = '#0d0d0d';
23
- const LINE_NUM_FG = '238';
24
-
25
- // ─── Language Detection (from file extension) ─────────────────────────────────
26
-
27
- function extToFenceTag(filePath: string): string {
28
- const ext = path.extname(filePath).slice(1).toLowerCase();
29
- const map: Record<string, string> = {
30
- ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx',
31
- py: 'python', python: 'python',
32
- sh: 'bash', bash: 'bash', zsh: 'bash',
33
- json: 'json',
34
- yaml: 'yaml', yml: 'yaml',
35
- html: 'html', htm: 'html', xml: 'xml',
36
- css: 'css', scss: 'scss', less: 'less',
37
- rs: 'rust',
38
- go: 'go',
39
- c: 'c', cpp: 'cpp', cc: 'cpp', h: 'c',
40
- java: 'java',
41
- rb: 'ruby',
42
- md: 'markdown', mdx: 'markdown',
43
- toml: 'toml',
44
- lua: 'lua',
45
- };
46
- return map[ext] ?? '';
47
- }
48
-
49
- // ─── Panel ────────────────────────────────────────────────────────────────────
50
-
51
- export class FilePreviewPanel extends BasePanel {
52
- private readonly syntaxHighlighter = new SyntaxHighlighter();
53
- private filePath: string | null = null;
54
- private fileLines: string[] = [];
55
- private fenceTag: string = '';
56
- private scrollOffset: number = 0;
57
- private oversized: boolean = false;
58
-
59
- /** Per-file scroll position memory: path -> scrollOffset */
60
- private readonly scrollMemory = new Map<string, number>();
61
-
62
- /**
63
- * 1-based line number to visibly highlight, set by goToLine() (e.g. a
64
- * symbol-outline jump). Cleared when a new file is opened.
65
- */
66
- private highlightedLine: number | null = null;
67
-
68
- /**
69
- * Monotonic counter bumped every time fileLines is replaced. Combined with
70
- * filePath/width/highlightedLine, forms the cache key for the built
71
- * preview lines so render() doesn't rebuild the whole file every frame.
72
- */
73
- private contentVersion = 0;
74
- private cachedPreviewLines: { key: string; lines: Line[] } | null = null;
75
-
76
- /** Set by the 'r' key; consumed by handlePanelIntegrationAction on the next
77
- * dispatch, which awaits the reload before re-syncing the symbol outline
78
- * (panel-integration-actions.ts owns the panelManager reference this
79
- * panel doesn't have). */
80
- private pendingReload = false;
81
-
82
- /** Optional live-refresh watcher on the currently open file. */
83
- private watcher: FSWatcher | null = null;
84
-
85
- constructor() {
86
- super('preview', 'Preview', '◑', 'development');
87
- }
88
-
89
- // ─── Public API ─────────────────────────────────────────────────────────────
90
-
91
- /**
92
- * Load a file into the preview. Reads asynchronously.
93
- * Files larger than 100 KB show a warning instead of content.
94
- */
95
- openFile(filePath: string): void {
96
- // Save scroll position for the current file before switching
97
- if (this.filePath !== null) {
98
- this.scrollMemory.set(this.filePath, this.scrollOffset);
99
- }
100
-
101
- this.filePath = filePath;
102
- this.oversized = false;
103
- this.fenceTag = extToFenceTag(filePath);
104
- this.highlightedLine = null;
105
-
106
- // Restore scroll position for this file, or start at top
107
- this.scrollOffset = this.scrollMemory.get(filePath) ?? 0;
108
-
109
- // Synchronously pre-populate fileLines for small files so that callers
110
- // (e.g. syncSymbolOutlineFromPreview) can read getSource() immediately.
111
- try {
112
- const stat = statSync(filePath);
113
- if (stat.size <= MAX_FILE_SIZE) {
114
- const content = readFileSync(filePath, 'utf-8');
115
- this.fileLines = content.split('\n');
116
- } else {
117
- this.fileLines = [];
118
- this.oversized = true;
119
- }
120
- } catch {
121
- this.fileLines = [`(cannot open: ${filePath})`];
122
- }
123
- this.contentVersion++;
124
-
125
- this._startWatching(filePath);
126
- void this._loadFileAsync(filePath);
127
- }
128
-
129
- /**
130
- * Re-read the current file from disk (the 'r' reload key). Returns the
131
- * pending load's promise so callers can chain follow-up work (e.g.
132
- * re-syncing the symbol outline) after it settles, or null if there is no
133
- * open file or no reload was queued via the 'r' key.
134
- */
135
- consumePendingReload(): Promise<void> | null {
136
- if (!this.pendingReload) return null;
137
- this.pendingReload = false;
138
- if (this.filePath === null) return null;
139
- return this._loadFileAsync(this.filePath);
140
- }
141
-
142
- /**
143
- * Start (or restart) an optional fs.watch auto-refresh on the given file.
144
- * Best-effort: some filesystems/platforms don't support watch, so failures
145
- * are swallowed and auto-refresh is simply unavailable for this file — the
146
- * explicit 'r' reload key always still works.
147
- */
148
- private _startWatching(filePath: string): void {
149
- this._stopWatching();
150
- try {
151
- const watcher = watch(filePath, { persistent: false }, (eventType) => {
152
- if (eventType !== 'change' && eventType !== 'rename') return;
153
- if (this.filePath !== filePath) return; // stale watcher from a since-replaced file
154
- void this._loadFileAsync(filePath);
155
- });
156
- watcher.on('error', () => this._stopWatching());
157
- this.watcher = watcher;
158
- } catch {
159
- this.watcher = null;
160
- }
161
- }
162
-
163
- private _stopWatching(): void {
164
- if (this.watcher) {
165
- try { this.watcher.close(); } catch { /* already closed */ }
166
- this.watcher = null;
167
- }
168
- }
169
-
170
- private async _loadFileAsync(filePath: string): Promise<void> {
171
- try {
172
- await this.withLoading('Loading…', async () => {
173
- let stat: Stats;
174
- try {
175
- stat = await fsPromises.stat(filePath);
176
- } catch {
177
- this.fileLines = [`(cannot open: ${filePath})`];
178
- return;
179
- }
180
-
181
- if (stat.size > MAX_FILE_SIZE) {
182
- this.oversized = true;
183
- return;
184
- }
185
-
186
- let content: string;
187
- try {
188
- content = await fsPromises.readFile(filePath, 'utf-8');
189
- } catch {
190
- this.fileLines = [`(read error: ${filePath})`];
191
- return;
192
- }
193
-
194
- this.fileLines = content.split('\n');
195
- // Strip trailing empty line from final newline
196
- if (this.fileLines.length > 0 && this.fileLines[this.fileLines.length - 1] === '') {
197
- this.fileLines.pop();
198
- }
199
-
200
- this.fenceTag = extToFenceTag(filePath);
201
-
202
- // Kick off async tree-sitter parse so subsequent renders get highlighting
203
- if (this.fenceTag) {
204
- this.syntaxHighlighter.highlight(content, this.fenceTag);
205
- }
206
-
207
- // Clamp scroll in case the new file is shorter
208
- this.clampScroll(0);
209
- });
210
- } catch (err) {
211
- this.setError(summarizeError(err));
212
- }
213
- this.contentVersion++;
214
- this.markDirty();
215
- }
216
-
217
- getCurrentFilePath(): string | null {
218
- return this.filePath;
219
- }
220
-
221
- getSource(): string | null {
222
- if (this.filePath === null || this.oversized) return null;
223
- return this.fileLines.join('\n');
224
- }
225
-
226
- goToLine(line: number): void {
227
- if (!Number.isFinite(line)) return;
228
- const target = Math.max(0, Math.min(Math.floor(line) - 1, Math.max(0, this.fileLines.length - 1)));
229
- this.scrollOffset = target;
230
- this.highlightedLine = target + 1;
231
- this.markDirty();
232
- }
233
-
234
- getScrollOffset(): number {
235
- return this.scrollOffset;
236
- }
237
-
238
- // ─── Lifecycle ───────────────────────────────────────────────────────────────
239
-
240
- override onActivate(): void {
241
- super.onActivate();
242
- }
243
-
244
- override onDeactivate(): void {
245
- // Persist current scroll position
246
- if (this.filePath !== null) {
247
- this.scrollMemory.set(this.filePath, this.scrollOffset);
248
- }
249
- }
250
-
251
- override onDestroy(): void {
252
- this._stopWatching();
253
- super.onDestroy();
254
- }
255
-
256
- // ─── Input handling ──────────────────────────────────────────────────────────
257
-
258
- handleInput(key: string): boolean {
259
- switch (key) {
260
- case 'up': return this.scroll(-1);
261
- case 'down': return this.scroll(1);
262
- case 'pageup': return this.scrollPage(-1);
263
- case 'pagedown': return this.scrollPage(1);
264
- case 'home': return this.scrollTo(0);
265
- case 'end': return this.scrollTo(Infinity);
266
- case 'r':
267
- if (this.filePath === null) return false;
268
- this.pendingReload = true;
269
- return true;
270
- case 'd':
271
- return this.filePath !== null;
272
- default: return false;
273
- }
274
- }
275
-
276
- // ─── Rendering ───────────────────────────────────────────────────────────────
277
-
278
- render(width: number, height: number): Line[] {
279
- const title = this.filePath === null
280
- ? ' Preview'
281
- : ` Preview / ${path.basename(this.filePath)}`;
282
- const intro = this.filePath
283
- ? `${this.filePath}${this.fenceTag ? ` [${this.fenceTag}]` : ''}`
284
- : 'Open a file to inspect its contents with line numbers and syntax highlighting.';
285
-
286
- if (this.filePath === null) {
287
- return buildPanelWorkspace(width, height, {
288
- title,
289
- intro,
290
- sections: [
291
- {
292
- lines: buildEmptyState(
293
- width,
294
- ' No file open',
295
- 'Use the explorer or a file-targeting command to load a file into the preview surface.',
296
- [],
297
- DEFAULT_PANEL_PALETTE,
298
- ),
299
- },
300
- ],
301
- palette: DEFAULT_PANEL_PALETTE,
302
- });
303
- }
304
-
305
- if (this.oversized) {
306
- return buildPanelWorkspace(width, height, {
307
- title,
308
- intro,
309
- sections: [
310
- {
311
- lines: buildEmptyState(
312
- width,
313
- ` File too large to preview`,
314
- `The selected file exceeds the 100 KB preview limit: ${path.basename(this.filePath)}.`,
315
- [],
316
- DEFAULT_PANEL_PALETTE,
317
- ),
318
- },
319
- ],
320
- palette: DEFAULT_PANEL_PALETTE,
321
- });
322
- }
323
-
324
- if (this.fileLines.length === 0) {
325
- return buildPanelWorkspace(width, height, {
326
- title,
327
- intro,
328
- sections: [
329
- {
330
- lines: buildEmptyState(
331
- width,
332
- ' Empty file',
333
- 'The selected file has no content.',
334
- [],
335
- DEFAULT_PANEL_PALETTE,
336
- ),
337
- },
338
- ],
339
- palette: DEFAULT_PANEL_PALETTE,
340
- });
341
- }
342
-
343
- const summarySection = {
344
- title: 'Summary',
345
- lines: [
346
- buildPanelLine(width, [
347
- [' Lines ', DEFAULT_PANEL_PALETTE.label],
348
- [String(this.fileLines.length), DEFAULT_PANEL_PALETTE.value],
349
- ]),
350
- ],
351
- } as const;
352
- const footerLines = [
353
- buildKeyboardHints(width, [
354
- { keys: '↑/↓', label: 'scroll' },
355
- { keys: 'PgUp/PgDn', label: 'page' },
356
- { keys: 'Home/End', label: 'top/bottom' },
357
- ], DEFAULT_PANEL_PALETTE),
358
- ];
359
- // Line-build cache keyed on (filePath, contentVersion, width, highlightedLine):
360
- // rebuilding every line on every frame showed up as an O(file-length)
361
- // cost per render even when nothing changed (e.g. while idly scrolling
362
- // within the same file). Only re-tokenize/re-render when the underlying
363
- // content, panel width, or highlighted row actually changes.
364
- const cacheKey = `${this.filePath ?? ''}${this.contentVersion}${width}${this.highlightedLine ?? ''}`;
365
- let previewLines: Line[];
366
- if (this.cachedPreviewLines && this.cachedPreviewLines.key === cacheKey) {
367
- previewLines = this.cachedPreviewLines.lines;
368
- } else {
369
- const fullCode = this.fileLines.join('\n');
370
- const hlLines = this.fenceTag
371
- ? this.syntaxHighlighter.highlight(fullCode, this.fenceTag)
372
- : null;
373
-
374
- const lineNumW = String(this.fileLines.length).length;
375
- const contentX = lineNumW + 2; // "NNN | "
376
- const built: Line[] = [];
377
- for (let fileIdx = 0; fileIdx < this.fileLines.length; fileIdx++) {
378
-
379
- const rawLine = this.fileLines[fileIdx];
380
- const tokens: SyntaxToken[] =
381
- hlLines && fileIdx < hlLines.length && hlLines[fileIdx].length > 0
382
- ? (hlLines[fileIdx] as SyntaxToken[])
383
- : [{ text: rawLine, fg: '' }];
384
-
385
- const highlighted = this.highlightedLine !== null && fileIdx === this.highlightedLine - 1;
386
- built.push(this.renderCodeLine(fileIdx, lineNumW, contentX, tokens, width, highlighted));
387
- }
388
- previewLines = built;
389
- this.cachedPreviewLines = { key: cacheKey, lines: previewLines };
390
- }
391
- // Once a goToLine target is set, track it with a selectedIndex so the
392
- // scrollable-section window keeps that row visible (with guard rows)
393
- // the same way list-cursor panels do; plain free-scroll (no active
394
- // highlight) keeps using the scrollOffset-only window it always has.
395
- const previewSection = resolveScrollablePanelSection(width, height, {
396
- intro,
397
- footerLines,
398
- palette: DEFAULT_PANEL_PALETTE,
399
- beforeSections: [summarySection],
400
- section: {
401
- title: 'Preview',
402
- scrollableLines: previewLines,
403
- scrollOffset: this.scrollOffset,
404
- selectedIndex: this.highlightedLine !== null ? this.highlightedLine - 1 : undefined,
405
- minRows: 8,
406
- },
407
- });
408
- this.scrollOffset = previewSection.scrollOffset;
409
- const window = previewSection.window;
410
-
411
- this.needsRender = false;
412
- return buildPanelWorkspace(width, height, {
413
- title,
414
- intro,
415
- sections: [
416
- {
417
- title: 'Summary',
418
- lines: [
419
- buildPanelLine(width, [
420
- [' Lines ', DEFAULT_PANEL_PALETTE.label],
421
- [String(this.fileLines.length), DEFAULT_PANEL_PALETTE.value],
422
- [' Viewing ', DEFAULT_PANEL_PALETTE.label],
423
- [`${window.start + 1}-${window.end}`, DEFAULT_PANEL_PALETTE.info],
424
- [' Lang ', DEFAULT_PANEL_PALETTE.label],
425
- [this.fenceTag || 'text', this.fenceTag ? DEFAULT_PANEL_PALETTE.value : DEFAULT_PANEL_PALETTE.dim],
426
- ]),
427
- ],
428
- },
429
- {
430
- title: 'Preview',
431
- lines: previewSection.section.lines,
432
- },
433
- ],
434
- footerLines,
435
- palette: DEFAULT_PANEL_PALETTE,
436
- });
437
- }
438
-
439
- private renderCodeLine(
440
- fileIdx: number,
441
- lineNumW: number,
442
- contentX: number,
443
- tokens: SyntaxToken[],
444
- width: number,
445
- highlighted = false,
446
- ): Line {
447
- // The goToLine target row (e.g. a symbol-outline jump) renders on the
448
- // theme's selection background instead of the plain code background so
449
- // it's visibly distinguishable from the rest of the file.
450
- const bg = highlighted ? DEFAULT_PANEL_PALETTE.selectBg : BG;
451
- const line: Cell[] = new Array(width).fill(null).map(() =>
452
- createStyledCell(' ', { bg }),
453
- );
454
-
455
- // Line number gutter
456
- const lineNum = String(fileIdx + 1).padStart(lineNumW);
457
- let cx = 0;
458
- for (const ch of lineNum) {
459
- if (cx >= lineNumW) break;
460
- line[cx++] = createStyledCell(ch, { fg: LINE_NUM_FG, bg, dim: true });
461
- }
462
- // Separator " | "
463
- line[cx++] = createStyledCell(' ', { bg });
464
- line[cx++] = createStyledCell('│', { fg: LINE_NUM_FG, bg, dim: true });
465
- line[cx++] = createStyledCell(' ', { bg });
466
-
467
- // Syntax tokens
468
- for (const token of tokens) {
469
- for (const ch of token.text) {
470
- if (cx >= width) break;
471
- const code = ch.charCodeAt(0);
472
- if (code < 32 || code === 127) { cx++; continue; }
473
- const cw = getDisplayWidth(ch);
474
- line[cx] = createStyledCell(ch, {
475
- fg: token.fg || '',
476
- bg,
477
- bold: token.bold,
478
- italic: token.italic,
479
- });
480
- if (cw === 2 && cx + 1 < width) line[cx + 1] = { ...line[cx], char: '' };
481
- cx += cw;
482
- }
483
- }
484
-
485
- return line;
486
- }
487
-
488
- private scroll(delta: number): boolean {
489
- const before = this.scrollOffset;
490
- this.scrollOffset = Math.max(0, this.scrollOffset + delta);
491
- if (this.scrollOffset !== before) this.markDirty();
492
- return true;
493
- }
494
-
495
- private scrollPage(direction: -1 | 1): boolean {
496
- // Page size is approximate — clamp happens in render()
497
- const pageSize = Math.max(1, this.fileLines.length > 0 ? 20 : 1);
498
- return this.scroll(direction * pageSize);
499
- }
500
-
501
- private scrollTo(target: number): boolean {
502
- const before = this.scrollOffset;
503
- this.scrollOffset = target === Infinity
504
- ? Math.max(0, this.fileLines.length - 1)
505
- : Math.max(0, target);
506
- if (this.scrollOffset !== before) this.markDirty();
507
- return true;
508
- }
509
-
510
- /** Clamp scrollOffset so content doesn't scroll past the last line. */
511
- private clampScroll(contentHeight: number): void {
512
- const maxScroll = Math.max(0, this.fileLines.length - Math.max(1, contentHeight));
513
- if (this.scrollOffset > maxScroll) {
514
- this.scrollOffset = maxScroll;
515
- }
516
- }
517
- }