@pellux/goodvibes-tui 0.19.62 → 0.19.63

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,465 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { createReadStream } from 'node:fs';
11
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
12
+ import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '@pellux/goodvibes-sdk/platform/config';
13
+
14
+ export type HomeFileOwner = 'tui' | 'daemon' | 'foreign-goodvibes-product' | 'unknown-root';
15
+
16
+ export type SettingsKeyClassification =
17
+ | 'current-schema'
18
+ | 'known-dynamic'
19
+ | 'default-config-dynamic'
20
+ | 'unknown-stale-candidate';
21
+
22
+ export interface HomeAuditOptions {
23
+ readonly homeDir: string;
24
+ readonly includeHashes?: boolean;
25
+ }
26
+
27
+ export interface HomeFileSummary {
28
+ readonly owner: HomeFileOwner;
29
+ readonly files: number;
30
+ readonly directories: number;
31
+ readonly bytes: number;
32
+ }
33
+
34
+ export interface HomeFileRecord {
35
+ readonly relativePath: string;
36
+ readonly owner: HomeFileOwner;
37
+ readonly bytes: number;
38
+ readonly mode: string;
39
+ readonly mtimeIso: string;
40
+ readonly sha256?: string;
41
+ }
42
+
43
+ export interface SettingsKeyAudit {
44
+ readonly key: string;
45
+ readonly classification: SettingsKeyClassification;
46
+ }
47
+
48
+ export interface SettingsAudit {
49
+ readonly path: string;
50
+ readonly exists: boolean;
51
+ readonly schemaKeyCount: number;
52
+ readonly leafKeyCount: number;
53
+ readonly recognizedKeyCount: number;
54
+ readonly missingSchemaKeys: readonly string[];
55
+ readonly keys: readonly SettingsKeyAudit[];
56
+ readonly staleCandidates: readonly string[];
57
+ }
58
+
59
+ export interface HomeAuditFinding {
60
+ readonly severity: 'info' | 'warn' | 'error';
61
+ readonly code: string;
62
+ readonly path?: string;
63
+ readonly message: string;
64
+ }
65
+
66
+ export interface DuplicateProfilePattern {
67
+ readonly normalizedName: string;
68
+ readonly count: number;
69
+ }
70
+
71
+ export interface GoodVibesHomeAudit {
72
+ readonly homeDir: string;
73
+ readonly generatedAt: string;
74
+ readonly summaries: readonly HomeFileSummary[];
75
+ readonly files: readonly HomeFileRecord[];
76
+ readonly settings: SettingsAudit;
77
+ readonly duplicateProfilePatterns: readonly DuplicateProfilePattern[];
78
+ readonly findings: readonly HomeAuditFinding[];
79
+ readonly allowedWriteRoots: readonly string[];
80
+ readonly readOnlyRoots: readonly string[];
81
+ }
82
+
83
+ export interface HomeSnapshotEntry {
84
+ readonly relativePath: string;
85
+ readonly bytes: number;
86
+ readonly mode: string;
87
+ readonly sha256: string;
88
+ }
89
+
90
+ export type HomeSnapshot = Readonly<Record<string, HomeSnapshotEntry>>;
91
+
92
+ export interface HomeSnapshotDiff {
93
+ readonly added: readonly string[];
94
+ readonly removed: readonly string[];
95
+ readonly changed: readonly string[];
96
+ }
97
+
98
+ const KNOWN_FOREIGN_ROOTS = new Set([
99
+ '.backups',
100
+ '.exec-output',
101
+ 'archive',
102
+ 'companion-chat',
103
+ 'events',
104
+ 'full-suite',
105
+ 'hooks',
106
+ 'logs',
107
+ 'memory',
108
+ 'providers',
109
+ 'scripts',
110
+ 'sdk',
111
+ 'skills',
112
+ 'state',
113
+ 'telemetry',
114
+ ]);
115
+
116
+ const KNOWN_DYNAMIC_KEYS = [
117
+ /^featureFlags(?:\.|$)/,
118
+ /^notifications\.webhookUrls$/,
119
+ /^wrfc\.gates$/,
120
+ ];
121
+
122
+ export const GOODVIBES_ALLOWED_WRITE_ROOTS = ['tui/', 'daemon/'] as const;
123
+ export const GOODVIBES_READ_ONLY_ROOTS = [
124
+ '*.api.json',
125
+ 'archive/',
126
+ 'sdk/',
127
+ 'state/',
128
+ 'events/',
129
+ 'companion-chat/',
130
+ 'full-suite/',
131
+ ] as const;
132
+
133
+ function toPosixRelative(path: string): string {
134
+ return path.split(sep).join('/');
135
+ }
136
+
137
+ function flattenObject(value: unknown, prefix = '', out: Record<string, unknown> = {}): Record<string, unknown> {
138
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
139
+ if (prefix) out[prefix] = value;
140
+ return out;
141
+ }
142
+
143
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
144
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
145
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
146
+ flattenObject(entry, nextPrefix, out);
147
+ } else {
148
+ out[nextPrefix] = entry;
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function classifyFile(relativePath: string): HomeFileOwner {
155
+ if (relativePath.startsWith('tui/')) return 'tui';
156
+ if (relativePath.startsWith('daemon/')) return 'daemon';
157
+ if (!relativePath.includes('/')) return 'foreign-goodvibes-product';
158
+ if (basename(relativePath).endsWith('.api.json')) return 'foreign-goodvibes-product';
159
+ const firstSegment = relativePath.split('/')[0] ?? '';
160
+ if (KNOWN_FOREIGN_ROOTS.has(firstSegment)) return 'foreign-goodvibes-product';
161
+ return 'unknown-root';
162
+ }
163
+
164
+ function isKnownDynamicKey(key: string): boolean {
165
+ return KNOWN_DYNAMIC_KEYS.some((pattern) => pattern.test(key));
166
+ }
167
+
168
+ function classifySettingsKey(
169
+ key: string,
170
+ schemaKeys: ReadonlySet<string>,
171
+ defaultKeys: ReadonlySet<string>,
172
+ ): SettingsKeyClassification {
173
+ if (schemaKeys.has(key)) return 'current-schema';
174
+ if (isKnownDynamicKey(key)) return 'known-dynamic';
175
+ if (defaultKeys.has(key)) return 'default-config-dynamic';
176
+ return 'unknown-stale-candidate';
177
+ }
178
+
179
+ function walkFiles(root: string): { files: string[]; directoryCountByOwner: Map<HomeFileOwner, number> } {
180
+ const files: string[] = [];
181
+ const directoryCountByOwner = new Map<HomeFileOwner, number>();
182
+ if (!existsSync(root)) return { files, directoryCountByOwner };
183
+
184
+ const visit = (dir: string): void => {
185
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
186
+ const absolute = join(dir, entry.name);
187
+ const rel = toPosixRelative(relative(root, absolute));
188
+ if (entry.isDirectory()) {
189
+ const owner = classifyFile(`${rel}/`);
190
+ directoryCountByOwner.set(owner, (directoryCountByOwner.get(owner) ?? 0) + 1);
191
+ visit(absolute);
192
+ } else if (entry.isFile()) {
193
+ files.push(absolute);
194
+ }
195
+ }
196
+ };
197
+
198
+ visit(root);
199
+ files.sort((a, b) => a.localeCompare(b));
200
+ return { files, directoryCountByOwner };
201
+ }
202
+
203
+ async function hashFile(path: string): Promise<string> {
204
+ const hash = createHash('sha256');
205
+ await new Promise<void>((resolvePromise, reject) => {
206
+ const stream = createReadStream(path);
207
+ stream.on('data', (chunk) => hash.update(chunk));
208
+ stream.on('error', reject);
209
+ stream.on('end', resolvePromise);
210
+ });
211
+ return hash.digest('hex');
212
+ }
213
+
214
+ function readJsonObject(path: string): Record<string, unknown> | null {
215
+ if (!existsSync(path)) return null;
216
+ try {
217
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
218
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
219
+ ? parsed as Record<string, unknown>
220
+ : null;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ function auditSettings(homeDir: string): SettingsAudit {
227
+ const path = join(homeDir, 'tui', 'settings.json');
228
+ const settings = readJsonObject(path);
229
+ const flatSettings = flattenObject(settings ?? {});
230
+ const schemaKeys = new Set(CONFIG_SCHEMA.map((entry) => entry.key));
231
+ const defaultKeys = new Set(Object.keys(flattenObject(DEFAULT_CONFIG)));
232
+ const keys = Object.keys(flatSettings)
233
+ .sort((a, b) => a.localeCompare(b))
234
+ .map((key): SettingsKeyAudit => ({
235
+ key,
236
+ classification: classifySettingsKey(key, schemaKeys, defaultKeys),
237
+ }));
238
+ const missingSchemaKeys = [...schemaKeys]
239
+ .filter((key) => !(key in flatSettings))
240
+ .sort((a, b) => a.localeCompare(b));
241
+ const staleCandidates = keys
242
+ .filter((entry) => entry.classification === 'unknown-stale-candidate')
243
+ .map((entry) => entry.key);
244
+
245
+ return {
246
+ path,
247
+ exists: settings !== null,
248
+ schemaKeyCount: schemaKeys.size,
249
+ leafKeyCount: keys.length,
250
+ recognizedKeyCount: keys.filter((entry) => entry.classification === 'current-schema').length,
251
+ missingSchemaKeys,
252
+ keys,
253
+ staleCandidates,
254
+ };
255
+ }
256
+
257
+ function modeString(path: string): string {
258
+ return (statSync(path).mode & 0o777).toString(8).padStart(3, '0');
259
+ }
260
+
261
+ function collectPermissionFindings(homeDir: string): HomeAuditFinding[] {
262
+ const findings: HomeAuditFinding[] = [];
263
+ const sensitiveFiles = [
264
+ join(homeDir, 'tui', 'secrets.enc'),
265
+ join(homeDir, 'tui', 'auth-users.json'),
266
+ join(homeDir, 'daemon', 'operator-tokens.json'),
267
+ join(homeDir, 'daemon', '.goodvibes', 'daemon', 'operator-tokens.json'),
268
+ join(homeDir, 'daemon', '.goodvibes', 'tui', 'auth-users.json'),
269
+ join(homeDir, 'daemon', '.goodvibes', 'tui', 'auth-bootstrap.txt'),
270
+ ];
271
+
272
+ for (const path of sensitiveFiles) {
273
+ if (!existsSync(path)) continue;
274
+ const mode = modeString(path);
275
+ if (mode !== '600') {
276
+ findings.push({
277
+ severity: 'warn',
278
+ code: 'sensitive-file-permissions',
279
+ path,
280
+ message: `Sensitive file mode is ${mode}; expected 600.`,
281
+ });
282
+ }
283
+ }
284
+
285
+ return findings;
286
+ }
287
+
288
+ function collectDuplicateProfilePatterns(homeDir: string): DuplicateProfilePattern[] {
289
+ const profilesDir = join(homeDir, 'tui', 'profiles');
290
+ if (!existsSync(profilesDir)) return [];
291
+
292
+ const counts = new Map<string, number>();
293
+ for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
294
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
295
+ const normalizedName = entry.name.replace(/^(team-)+/, 'team-');
296
+ counts.set(normalizedName, (counts.get(normalizedName) ?? 0) + 1);
297
+ }
298
+
299
+ return [...counts.entries()]
300
+ .filter(([, count]) => count > 1)
301
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
302
+ .map(([normalizedName, count]) => ({ normalizedName, count }));
303
+ }
304
+
305
+ export async function auditGoodVibesHome(options: HomeAuditOptions): Promise<GoodVibesHomeAudit> {
306
+ const homeDir = resolve(options.homeDir);
307
+ const { files, directoryCountByOwner } = walkFiles(homeDir);
308
+ const summaries = new Map<HomeFileOwner, { files: number; directories: number; bytes: number }>();
309
+ const records: HomeFileRecord[] = [];
310
+
311
+ for (const file of files) {
312
+ const stats = statSync(file);
313
+ const relativePath = toPosixRelative(relative(homeDir, file));
314
+ const owner = classifyFile(relativePath);
315
+ const summary = summaries.get(owner) ?? { files: 0, directories: directoryCountByOwner.get(owner) ?? 0, bytes: 0 };
316
+ summary.files += 1;
317
+ summary.bytes += stats.size;
318
+ summaries.set(owner, summary);
319
+ records.push({
320
+ relativePath,
321
+ owner,
322
+ bytes: stats.size,
323
+ mode: (stats.mode & 0o777).toString(8).padStart(3, '0'),
324
+ mtimeIso: stats.mtime.toISOString(),
325
+ sha256: options.includeHashes ? await hashFile(file) : undefined,
326
+ });
327
+ }
328
+
329
+ const settings = auditSettings(homeDir);
330
+ const findings = [
331
+ ...collectPermissionFindings(homeDir),
332
+ ...settings.staleCandidates.map((key): HomeAuditFinding => ({
333
+ severity: 'warn',
334
+ code: 'stale-settings-key',
335
+ path: settings.path,
336
+ message: `Setting '${key}' is not in CONFIG_SCHEMA, DEFAULT_CONFIG, or known dynamic config.`,
337
+ })),
338
+ ];
339
+
340
+ return {
341
+ homeDir,
342
+ generatedAt: new Date().toISOString(),
343
+ summaries: [...summaries.entries()]
344
+ .sort(([a], [b]) => a.localeCompare(b))
345
+ .map(([owner, summary]) => ({ owner, ...summary })),
346
+ files: records,
347
+ settings,
348
+ duplicateProfilePatterns: collectDuplicateProfilePatterns(homeDir),
349
+ findings,
350
+ allowedWriteRoots: [...GOODVIBES_ALLOWED_WRITE_ROOTS],
351
+ readOnlyRoots: [...GOODVIBES_READ_ONLY_ROOTS],
352
+ };
353
+ }
354
+
355
+ export async function snapshotGoodVibesHome(homeDir: string): Promise<HomeSnapshot> {
356
+ const root = resolve(homeDir);
357
+ const { files } = walkFiles(root);
358
+ const entries: Record<string, HomeSnapshotEntry> = {};
359
+ for (const file of files) {
360
+ const stats = statSync(file);
361
+ const relativePath = toPosixRelative(relative(root, file));
362
+ entries[relativePath] = {
363
+ relativePath,
364
+ bytes: stats.size,
365
+ mode: (stats.mode & 0o777).toString(8).padStart(3, '0'),
366
+ sha256: await hashFile(file),
367
+ };
368
+ }
369
+ return entries;
370
+ }
371
+
372
+ export function diffHomeSnapshots(before: HomeSnapshot, after: HomeSnapshot): HomeSnapshotDiff {
373
+ const beforeKeys = new Set(Object.keys(before));
374
+ const afterKeys = new Set(Object.keys(after));
375
+ const added = [...afterKeys].filter((key) => !beforeKeys.has(key)).sort((a, b) => a.localeCompare(b));
376
+ const removed = [...beforeKeys].filter((key) => !afterKeys.has(key)).sort((a, b) => a.localeCompare(b));
377
+ const changed = [...afterKeys]
378
+ .filter((key) => beforeKeys.has(key))
379
+ .filter((key) => {
380
+ const prior = before[key];
381
+ const next = after[key];
382
+ return prior.sha256 !== next.sha256 || prior.mode !== next.mode || prior.bytes !== next.bytes;
383
+ })
384
+ .sort((a, b) => a.localeCompare(b));
385
+ return { added, removed, changed };
386
+ }
387
+
388
+ export function findDisallowedHomeMutations(
389
+ diff: HomeSnapshotDiff,
390
+ allowedRoots: readonly string[] = GOODVIBES_ALLOWED_WRITE_ROOTS,
391
+ ): string[] {
392
+ const touched = [...diff.added, ...diff.removed, ...diff.changed];
393
+ return touched
394
+ .filter((relativePath) => !allowedRoots.some((root) => relativePath.startsWith(root)))
395
+ .sort((a, b) => a.localeCompare(b));
396
+ }
397
+
398
+ export function renderGoodVibesHomeAuditMarkdown(audit: GoodVibesHomeAudit): string {
399
+ const lines: string[] = [
400
+ '# GoodVibes Home Audit',
401
+ '',
402
+ `Generated: ${audit.generatedAt}`,
403
+ `Home: \`${audit.homeDir}\``,
404
+ '',
405
+ '## Ownership Summary',
406
+ '',
407
+ '| Owner | Files | Directories | Bytes |',
408
+ '|---|---:|---:|---:|',
409
+ ...audit.summaries.map((summary) => (
410
+ `| ${summary.owner} | ${summary.files} | ${summary.directories} | ${summary.bytes} |`
411
+ )),
412
+ '',
413
+ '## Settings',
414
+ '',
415
+ `Path: \`${audit.settings.path}\``,
416
+ '',
417
+ '| Metric | Count |',
418
+ '|---|---:|',
419
+ `| Schema keys | ${audit.settings.schemaKeyCount} |`,
420
+ `| Persisted leaf keys | ${audit.settings.leafKeyCount} |`,
421
+ `| Current-schema keys | ${audit.settings.recognizedKeyCount} |`,
422
+ `| Missing schema keys | ${audit.settings.missingSchemaKeys.length} |`,
423
+ `| Stale candidates | ${audit.settings.staleCandidates.length} |`,
424
+ '',
425
+ ];
426
+
427
+ if (audit.settings.missingSchemaKeys.length > 0) {
428
+ lines.push('### Missing Schema Keys', '');
429
+ for (const key of audit.settings.missingSchemaKeys) lines.push(`- \`${key}\``);
430
+ lines.push('');
431
+ }
432
+
433
+ if (audit.settings.staleCandidates.length > 0) {
434
+ lines.push('### Stale Setting Candidates', '');
435
+ for (const key of audit.settings.staleCandidates) lines.push(`- \`${key}\``);
436
+ lines.push('');
437
+ }
438
+
439
+ if (audit.duplicateProfilePatterns.length > 0) {
440
+ lines.push('## Duplicate Profile Patterns', '');
441
+ for (const pattern of audit.duplicateProfilePatterns) {
442
+ lines.push(`- \`${pattern.normalizedName}\`: ${pattern.count}`);
443
+ }
444
+ lines.push('');
445
+ }
446
+
447
+ lines.push('## Findings', '');
448
+ if (audit.findings.length === 0) {
449
+ lines.push('No findings.');
450
+ } else {
451
+ for (const finding of audit.findings) {
452
+ const path = finding.path ? ` \`${finding.path}\`` : '';
453
+ lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}:${path} ${finding.message}`);
454
+ }
455
+ }
456
+ lines.push('');
457
+
458
+ return `${lines.join('\n')}\n`;
459
+ }
460
+
461
+ export function writeAuditReportFiles(audit: GoodVibesHomeAudit, outputDir: string): void {
462
+ mkdirSync(outputDir, { recursive: true });
463
+ writeFileSync(join(outputDir, 'goodvibes-home-audit.json'), `${JSON.stringify(audit, null, 2)}\n`, 'utf8');
464
+ writeFileSync(join(outputDir, 'goodvibes-home-audit.md'), renderGoodVibesHomeAuditMarkdown(audit), 'utf8');
465
+ }
@@ -37,6 +37,7 @@ import type { Panel } from '../panels/types.ts';
37
37
  import type { PanelManager } from '../panels/panel-manager.ts';
38
38
  import type { KeybindingsManager } from './keybindings.ts';
39
39
  import type { ModelPickerTarget } from './model-picker.ts';
40
+ import type { PanelMouseLayout } from './handler-feed-routes.ts';
40
41
 
41
42
  /**
42
43
  * Initial mutable scalar values for InputFeedContext.
@@ -67,6 +68,7 @@ export interface FeedContextMutableInit {
67
68
  mouseDownRow: number;
68
69
  mouseDownCol: number;
69
70
  contentWidth: number;
71
+ panelMouseLayout: PanelMouseLayout | null;
70
72
  selectionCallback: ((result: SelectionResult | null) => void) | null;
71
73
  }
72
74
 
@@ -241,4 +243,5 @@ export function syncFeedContextMutableFields(
241
243
  ctx.mouseDownRow = fields.mouseDownRow;
242
244
  ctx.mouseDownCol = fields.mouseDownCol;
243
245
  ctx.contentWidth = fields.contentWidth;
246
+ ctx.panelMouseLayout = fields.panelMouseLayout;
244
247
  }
@@ -481,6 +481,8 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
481
481
  export type MouseRouteState = {
482
482
  conversationManager: ConversationManager | null;
483
483
  selection: SelectionManager;
484
+ panelManager: PanelManager;
485
+ panelMouseLayout: PanelMouseLayout | null;
484
486
  mouseDownRow: number;
485
487
  mouseDownCol: number;
486
488
  scrollTop: number;
@@ -492,6 +494,71 @@ export type MouseRouteState = {
492
494
  handleCopy: () => void;
493
495
  };
494
496
 
497
+ export type PanelMouseLayout = {
498
+ x: number;
499
+ y: number;
500
+ width: number;
501
+ height: number;
502
+ hasBottomPane: boolean;
503
+ verticalSplitRatio: number;
504
+ };
505
+
506
+ function clampRatio(value: number): number {
507
+ return Math.max(0.2, Math.min(0.8, value));
508
+ }
509
+
510
+ function getActivePanelInPane(panelManager: PanelManager, pane: 'top' | 'bottom') {
511
+ const target = pane === 'top' ? panelManager.getTopPane() : panelManager.getBottomPane();
512
+ return target.panels[target.activeIndex] ?? null;
513
+ }
514
+
515
+ function getPanelUnderMouse(
516
+ panelManager: PanelManager,
517
+ layout: PanelMouseLayout | null,
518
+ row: number,
519
+ col: number,
520
+ ) {
521
+ if (
522
+ layout === null
523
+ || !panelManager.isVisible()
524
+ || panelManager.getAllOpen().length === 0
525
+ || col < layout.x
526
+ || col >= layout.x + layout.width
527
+ || row < layout.y
528
+ || row >= layout.y + layout.height
529
+ ) {
530
+ return null;
531
+ }
532
+
533
+ const panelRow = row - layout.y;
534
+ if (!layout.hasBottomPane) {
535
+ return getActivePanelInPane(panelManager, 'top');
536
+ }
537
+
538
+ const panelAreaRows = Math.max(0, layout.height - 1);
539
+ const contentRows = Math.max(0, panelAreaRows - 3);
540
+ const topContentRows = contentRows <= 1
541
+ ? contentRows
542
+ : Math.max(1, Math.floor(contentRows * clampRatio(layout.verticalSplitRatio)));
543
+ const topLastRow = 2 + topContentRows;
544
+
545
+ return panelRow <= topLastRow
546
+ ? getActivePanelInPane(panelManager, 'top')
547
+ : getActivePanelInPane(panelManager, 'bottom');
548
+ }
549
+
550
+ function scrollPanelUnderMouse(
551
+ state: MouseRouteState,
552
+ token: Extract<InputToken, { type: 'mouse' }>,
553
+ deltaRows: number,
554
+ ): boolean {
555
+ const panel = getPanelUnderMouse(state.panelManager, state.panelMouseLayout, token.row, token.col);
556
+ if (!panel?.handleScroll) return false;
557
+ const consumed = panel.handleScroll(deltaRows);
558
+ if (consumed) state.requestRender();
559
+ return true;
560
+ }
561
+
495
562
  export function handleMouseToken(state: MouseRouteState, token: InputToken): {
496
563
  handled: boolean;
497
564
  mouseDownRow: number;
@@ -507,10 +574,16 @@ export function handleMouseToken(state: MouseRouteState, token: InputToken): {
507
574
  const viewportRow = token.row - headerH;
508
575
 
509
576
  if (token.button === 64) {
577
+ if (scrollPanelUnderMouse(state, token, -3)) {
578
+ return { handled: true, mouseDownRow, mouseDownCol };
579
+ }
510
580
  state.scroll(-3);
511
581
  return { handled: true, mouseDownRow, mouseDownCol };
512
582
  }
513
583
  if (token.button === 65) {
584
+ if (scrollPanelUnderMouse(state, token, 3)) {
585
+ return { handled: true, mouseDownRow, mouseDownCol };
586
+ }
514
587
  state.scroll(3);
515
588
  return { handled: true, mouseDownRow, mouseDownCol };
516
589
  }
@@ -31,6 +31,7 @@ import {
31
31
  handlePanelFocusToken,
32
32
  handlePromptKeyToken,
33
33
  handlePromptTextToken,
34
+ type PanelMouseLayout,
34
35
  } from './handler-feed-routes.ts';
35
36
  import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
36
37
  import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
@@ -119,6 +120,7 @@ export interface InputFeedContext {
119
120
  readonly blockActionsMenu: BlockActionsMenu;
120
121
  readonly searchManager: SearchManager;
121
122
  readonly panelManager: PanelManager;
123
+ panelMouseLayout: PanelMouseLayout | null;
122
124
  readonly keybindingsManager: KeybindingsManager;
123
125
  readonly modalStack: string[];
124
126
  inputHistory: InputHistory | null;
@@ -385,6 +387,8 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
385
387
  const mouseRoute = handleMouseToken({
386
388
  conversationManager: context.conversationManager,
387
389
  selection: context.selection,
390
+ panelManager: context.panelManager,
391
+ panelMouseLayout: context.panelMouseLayout,
388
392
  mouseDownRow: context.mouseDownRow,
389
393
  mouseDownCol: context.mouseDownCol,
390
394
  scrollTop,
@@ -54,10 +54,12 @@ export function handleGlobalShortcutToken(
54
54
 
55
55
  // Fast-path: bare pageup/pagedown have no keybinding entry.
56
56
  if (token.logicalName === 'pageup') {
57
+ if (state.panelFocused) return false;
57
58
  state.scroll(-Math.max(1, viewportHeight - 2));
58
59
  return true;
59
60
  }
60
61
  if (token.logicalName === 'pagedown') {
62
+ if (state.panelFocused) return false;
61
63
  state.scroll(Math.max(1, viewportHeight - 2));
62
64
  return true;
63
65
  }
@@ -64,6 +64,7 @@ import {
64
64
  handlePanelFocusToken,
65
65
  handlePromptKeyToken,
66
66
  handlePromptTextToken,
67
+ type PanelMouseLayout,
67
68
  } from './handler-feed-routes.ts';
68
69
  import {
69
70
  ensureInputCursorVisible,
@@ -263,7 +264,8 @@ export class InputHandler {
263
264
  shortcutsOverlayActive: this.shortcutsOverlayActive, shortcutsScrollOffset: this.shortcutsScrollOffset,
264
265
  nextPasteId: this.nextPasteId, nextImageId: this.nextImageId,
265
266
  mouseDownRow: this.mouseDownRow, mouseDownCol: this.mouseDownCol,
266
- contentWidth: this.contentWidth, selectionCallback: this.selectionCallback,
267
+ contentWidth: this.contentWidth, panelMouseLayout: this.panelMouseLayout,
268
+ selectionCallback: this.selectionCallback,
267
269
  },
268
270
  {
269
271
  selection: this.selection,
@@ -341,7 +343,8 @@ export class InputHandler {
341
343
  helpScrollOffset: h.helpScrollOffset, shortcutsOverlayActive: h.shortcutsOverlayActive,
342
344
  shortcutsScrollOffset: h.shortcutsScrollOffset, selectionCallback: h.selectionCallback,
343
345
  nextPasteId: h.nextPasteId, nextImageId: h.nextImageId, mouseDownRow: h.mouseDownRow,
344
- mouseDownCol: h.mouseDownCol, contentWidth: h.contentWidth }, this.feedContext);
346
+ mouseDownCol: h.mouseDownCol, contentWidth: h.contentWidth,
347
+ panelMouseLayout: h.panelMouseLayout }, this.feedContext);
345
348
  }
346
349
 
347
350
  /** Wire in the InputHistory instance. Optional; disables history navigation if unset. */
@@ -455,6 +458,7 @@ export class InputHandler {
455
458
  context.mouseDownRow = this.mouseDownRow;
456
459
  context.mouseDownCol = this.mouseDownCol;
457
460
  context.contentWidth = this.contentWidth;
461
+ context.panelMouseLayout = this.panelMouseLayout;
458
462
  // Sync semi-stable refs that may be wired after construction.
459
463
  context.commandRegistry = this.commandRegistry;
460
464
  context.commandContext = this.commandContext;
@@ -516,12 +520,17 @@ export class InputHandler {
516
520
 
517
521
  /** Content width for wrapping — set by main.ts via setContentWidth(). */
518
522
  public contentWidth = 76;
523
+ public panelMouseLayout: PanelMouseLayout | null = null;
519
524
 
520
525
  /** Set the content width used for wrapping calculations. Call from main.ts. */
521
526
  public setContentWidth(w: number): void {
522
527
  this.contentWidth = w;
523
528
  }
524
529
 
530
+ public setPanelMouseLayout(layout: PanelMouseLayout | null): void {
531
+ this.panelMouseLayout = layout;
532
+ }
533
+
525
534
  /**
526
535
  * Move cursor up or down by one WRAPPED line.
527
536
  * Uses the segment table to navigate visual lines, not raw \n lines.