@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,13 +1,11 @@
1
1
  /**
2
- * Session maintenance types and local evaluator.
2
+ * Session maintenance types.
3
3
  *
4
4
  * The canonical evaluator is the SDK version exported from @/runtime/index.ts
5
5
  * (via operations.evaluateSessionMaintenance), which reads from configManager.
6
- *
7
- * This module provides:
8
- * - The shared type surface used across TUI panels.
9
- * - A thin local evaluator kept in sync with the SDK signature so panel code
10
- * that passes configManager has a coherent call site.
6
+ * Import the evaluator from there — this module provides only the shared
7
+ * Panel* type surface used across TUI panels and tests; it intentionally does
8
+ * not re-implement the evaluator.
11
9
  */
12
10
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
13
11
 
@@ -58,119 +56,3 @@ export interface PanelSessionMaintenanceStatus {
58
56
  readonly lastCompactedAt?: number;
59
57
  readonly compactRecommended: boolean;
60
58
  }
61
-
62
- /**
63
- * Evaluate session maintenance from config-driven thresholds.
64
- *
65
- * behavior.autoCompactThreshold (percent integer, SDK schema range [10, 100], default 80):
66
- * - [10, 100] → threshold at that percent; autoCompactEnabled = true.
67
- * - 0 (defensive fallback for null/missing config only; not a valid schema value).
68
- *
69
- * NOTE: The SDK's evaluateSessionMaintenance (from @/runtime/index.ts) is the
70
- * canonical implementation used in production. This local version exists so
71
- * panel tests can import types without crossing the SDK boundary.
72
- */
73
- export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput): PanelSessionMaintenanceStatus {
74
- const guidanceMode: PanelGuidanceMode = (input.configManager.get('behavior.guidanceMode') as PanelGuidanceMode | undefined) ?? 'minimal';
75
- const rawThreshold = Number(input.configManager.get('behavior.autoCompactThreshold') ?? 0);
76
- const thresholdPct = Math.max(0, Number.isFinite(rawThreshold) ? rawThreshold : 0);
77
- const autoCompactEnabled = thresholdPct > 0;
78
-
79
- const usagePct = input.contextWindow > 0 ? Math.min(100, Math.round((Math.max(0, input.currentTokens) / input.contextWindow) * 100)) : 0;
80
- const remainingTokens = Math.max(0, input.contextWindow - input.currentTokens);
81
- const sessionMemoryCount = Math.max(0, input.sessionMemoryCount ?? 0);
82
- const compactionCount = Math.max(0, input.session?.lineage?.filter((entry) => entry.branchReason === 'compaction').length ?? 0);
83
- const lastCompactedAt = input.session?.lastCompactedAt;
84
- const messageCount = Math.max(0, input.messageCount ?? 0);
85
- const staleByMessageGrowth = (input.session?.compactionMessageCount ?? 0) > 0
86
- ? messageCount - (input.session?.compactionMessageCount ?? 0) >= 12
87
- : messageCount >= 24;
88
-
89
- if (input.contextWindow <= 0) {
90
- return {
91
- level: 'unknown',
92
- summary: 'Context window unavailable.',
93
- reasons: ['Current model does not expose a known context limit yet.'],
94
- nextSteps: ['/provider', '/context'],
95
- guidanceMode,
96
- usagePct,
97
- remainingTokens,
98
- thresholdPct,
99
- autoCompactEnabled,
100
- sessionMemoryCount,
101
- compactionCount,
102
- lastCompactedAt,
103
- compactRecommended: false,
104
- };
105
- }
106
-
107
- if (input.session?.compactionState === 'failed') {
108
- return {
109
- level: 'needs-repair',
110
- summary: 'Compaction needs operator repair.',
111
- reasons: ['Compaction failed and the session may need manual recovery.'],
112
- nextSteps: ['/compact', '/health review'],
113
- guidanceMode,
114
- usagePct,
115
- remainingTokens,
116
- thresholdPct,
117
- autoCompactEnabled,
118
- sessionMemoryCount,
119
- compactionCount,
120
- lastCompactedAt,
121
- compactRecommended: true,
122
- };
123
- }
124
-
125
- const reasons: string[] = [];
126
- const nextSteps: string[] = [];
127
- let summary = 'Session maintenance is stable.';
128
- let compactRecommended = false;
129
- let level: PanelSessionMaintenanceLevel = 'stable';
130
-
131
- const atThreshold = autoCompactEnabled ? usagePct >= thresholdPct : usagePct >= 80;
132
- if (atThreshold || remainingTokens <= 15_000) {
133
- level = 'suggest-compact';
134
- summary = `Compact now to recover context headroom (${usagePct}% used).`;
135
- reasons.push(`Context pressure is high at ${usagePct}% usage.`);
136
- nextSteps.push('/compact', '/panel tokens');
137
- compactRecommended = true;
138
- } else if (usagePct >= Math.max(70, autoCompactEnabled ? thresholdPct - 10 : 70) || staleByMessageGrowth) {
139
- level = 'watch';
140
- summary = staleByMessageGrowth
141
- ? `Conversation has grown ${messageCount.toLocaleString()} messages since the last maintenance checkpoint.`
142
- : `Watch context growth (${usagePct}% used, threshold ${thresholdPct}%).`;
143
- reasons.push(staleByMessageGrowth
144
- ? `Conversation has grown ${messageCount.toLocaleString()} messages since the last maintenance checkpoint.`
145
- : `Context usage is climbing toward the ${thresholdPct > 0 ? `${thresholdPct}% auto-compact threshold` : 'maintenance band'}.`);
146
- nextSteps.push('/panel tokens');
147
- } else {
148
- reasons.push('Context pressure is currently within the stable operating band.');
149
- }
150
-
151
- if (sessionMemoryCount > 0) {
152
- reasons.push(`${sessionMemoryCount} pinned session memor${sessionMemoryCount === 1 ? 'y is' : 'ies are'} preserved during compaction.`);
153
- }
154
- if (compactionCount > 0) {
155
- reasons.push(`Last compaction ran ${lastCompactedAt ? new Date(lastCompactedAt).toISOString() : 'recently'}.`);
156
- }
157
- if (!autoCompactEnabled) {
158
- reasons.push('Auto-compaction is disabled; maintenance stays fully manual.');
159
- }
160
-
161
- return {
162
- level,
163
- summary,
164
- reasons,
165
- nextSteps,
166
- guidanceMode,
167
- usagePct,
168
- remainingTokens,
169
- thresholdPct,
170
- autoCompactEnabled,
171
- sessionMemoryCount,
172
- compactionCount,
173
- lastCompactedAt,
174
- compactRecommended,
175
- };
176
- }
@@ -1,33 +1,68 @@
1
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
1
2
  import type { Line } from '../types/grid.ts';
3
+ import { createEmptyLine } from '../types/grid.ts';
4
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
2
5
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
6
  import {
4
7
  buildDetailBlock,
5
- buildGuidanceLine,
8
+ buildKeyboardHints,
6
9
  buildPanelListRow,
7
10
  buildPanelLine,
11
+ buildPanelWorkspace,
8
12
  buildStatusPill,
9
13
  buildSummaryBlock,
10
14
  DEFAULT_PANEL_PALETTE,
15
+ resolveScrollablePanelSection,
11
16
  type PanelPalette,
17
+ type PanelWorkspaceSection,
12
18
  } from './polish.ts';
13
19
  import { getSettingsControlPlaneSnapshot } from '@/runtime/index.ts';
14
20
  import type { ConfigManager } from '../config/index.ts';
21
+ import type { PanelIntegrationContext } from './types.ts';
15
22
 
16
- const C = {
17
- ...DEFAULT_PANEL_PALETTE,
18
- dim: '#475569',
19
- info: '#38bdf8',
20
- ok: '#22c55e',
21
- warn: '#eab308',
22
- error: '#ef4444',
23
- } as const;
23
+ // Base chrome only — state colors and text tokens come straight from
24
+ // DEFAULT_PANEL_PALETTE (WO-002).
25
+ const C = DEFAULT_PANEL_PALETTE;
24
26
 
25
- type ResolvedEntry = ReturnType<typeof getSettingsControlPlaneSnapshot>['resolvedEntries'][number];
27
+ type SettingsSnapshot = ReturnType<typeof getSettingsControlPlaneSnapshot>;
28
+ type ResolvedEntry = SettingsSnapshot['resolvedEntries'][number];
29
+
30
+ // Tab-toggled browse modes (RemotePanel pattern): 'keys' is the primary
31
+ // selectable list (kept at its own scroll viewport); the rest were
32
+ // previously dumped unconditionally into the header, squeezing the key
33
+ // list out of view.
34
+ const BROWSE_MODES = ['keys', 'events', 'locks', 'failures', 'conflicts', 'rollback'] as const;
35
+ type BrowseMode = typeof BROWSE_MODES[number];
36
+
37
+ interface BrowseModeContent {
38
+ readonly title: string;
39
+ readonly rows: Line[];
40
+ readonly emptyMessage: string;
41
+ }
26
42
 
27
43
  export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
44
+ private browseMode: BrowseMode = 'keys';
45
+ private browseIndex = 0;
46
+ private browseScrollOffset = 0;
47
+
48
+ // Inline local/synced picker for a conflicted entry (Enter on the keys
49
+ // list). Resolved via handlePanelIntegrationAction, the only place
50
+ // executeCommand is available.
51
+ private _resolvePrompt: { readonly key: string } | null = null;
52
+ private _pendingResolve: { readonly key: string; readonly choice: 'local' | 'synced' } | null = null;
53
+ private _pendingManagedReview = false;
54
+
28
55
  public constructor(private readonly configManager: ConfigManager) {
29
- super('settings-sync', 'Settings Sync', 'S', 'monitoring');
56
+ super('settings-sync', 'Settings Sync', '', 'security-policy');
30
57
  this.showSelectionGutter = true; // I5: non-color selection affordance
58
+ this.filterEnabled = true;
59
+ this.filterLabel = 'Filter settings';
60
+ }
61
+
62
+ protected override filterMatches(entry: ResolvedEntry, q: string): boolean {
63
+ return entry.key.toLowerCase().includes(q)
64
+ || String(entry.effectiveSource).toLowerCase().includes(q)
65
+ || String(entry.effectiveValue).toLowerCase().includes(q);
31
66
  }
32
67
 
33
68
  protected override getPalette(): PanelPalette {
@@ -40,9 +75,9 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
40
75
 
41
76
  protected renderItem(entry: ResolvedEntry, _index: number, selected: boolean, width: number): Line {
42
77
  return buildPanelListRow(width, [
43
- { text: entry.key.padEnd(32), fg: C.value },
44
- { text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.ok : entry.effectiveSource === 'local' ? C.info : C.dim },
45
- { text: `${String(entry.effectiveValue)}`.slice(0, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
78
+ { text: fitDisplay(entry.key, 32).padEnd(32), fg: C.value },
79
+ { text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.good : entry.effectiveSource === 'local' ? C.info : C.dim },
80
+ { text: truncateDisplay(`${String(entry.effectiveValue)}`, Math.max(0, width - 47)), fg: entry.conflict ? C.bad : entry.locked ? C.warn : C.dim },
46
81
  ], C, { selected });
47
82
  }
48
83
 
@@ -50,71 +85,314 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
50
85
  return ' No resolved settings entries.';
51
86
  }
52
87
 
53
- public render(width: number, height: number): Line[] {
88
+ /** Enter on a conflicted entry opens the inline local/synced picker. */
89
+ protected override onSelect(entry: ResolvedEntry): void {
90
+ if (!entry.conflict) return;
91
+ this._resolvePrompt = { key: entry.key };
92
+ this.needsRender = true;
93
+ }
94
+
95
+ public override handleInput(key: string): boolean {
96
+ if (this.lastError !== null) this.clearError();
97
+
98
+ if (this._resolvePrompt) {
99
+ if (key === 'l') {
100
+ this._pendingResolve = { key: this._resolvePrompt.key, choice: 'local' };
101
+ this._resolvePrompt = null;
102
+ this.needsRender = true;
103
+ return true;
104
+ }
105
+ if (key === 's') {
106
+ this._pendingResolve = { key: this._resolvePrompt.key, choice: 'synced' };
107
+ this._resolvePrompt = null;
108
+ this.needsRender = true;
109
+ return true;
110
+ }
111
+ if (key === 'escape' || key === 'n') {
112
+ this._resolvePrompt = null;
113
+ this.needsRender = true;
114
+ return true;
115
+ }
116
+ return true; // absorbed — keep the picker pending
117
+ }
118
+
119
+ // The inline `/`-filter capture must win over browse-mode/managed-review
120
+ // keys while it is actively collecting a query (e.g. typing "m").
121
+ if (this.filterActive) {
122
+ return super.handleInput(key);
123
+ }
124
+
125
+ if (key === 'tab') {
126
+ const idx = BROWSE_MODES.indexOf(this.browseMode);
127
+ this.browseMode = BROWSE_MODES[(idx + 1) % BROWSE_MODES.length]!;
128
+ this.browseIndex = 0;
129
+ this.browseScrollOffset = 0;
130
+ this.needsRender = true;
131
+ return true;
132
+ }
133
+
134
+ if (key === 'm') {
135
+ const snapshot = getSettingsControlPlaneSnapshot(this.configManager);
136
+ if (snapshot.stagedManagedBundle) {
137
+ this._pendingManagedReview = true;
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ if (this.browseMode !== 'keys') {
144
+ return this._handleBrowseInput(key);
145
+ }
146
+
147
+ return super.handleInput(key);
148
+ }
149
+
150
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
151
+ if (this._pendingResolve) {
152
+ const { key, choice } = this._pendingResolve;
153
+ this._pendingResolve = null;
154
+ void ctx.executeCommand?.('settings-sync', ['resolve', key, choice]).catch((err) => {
155
+ logger.debug('settings-sync resolve dispatch failed', { err });
156
+ });
157
+ return true;
158
+ }
159
+ if (this._pendingManagedReview) {
160
+ this._pendingManagedReview = false;
161
+ void ctx.executeCommand?.('managed', ['review']).catch((err) => {
162
+ logger.debug('managed review dispatch failed', { err });
163
+ });
164
+ return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ private _browseItemCount(): number {
54
170
  const snapshot = getSettingsControlPlaneSnapshot(this.configManager);
171
+ switch (this.browseMode) {
172
+ case 'events': return snapshot.recentEvents.length;
173
+ case 'locks': return snapshot.managedLocks.length;
174
+ case 'failures': return snapshot.recentFailures.length;
175
+ case 'conflicts': return snapshot.conflicts.length;
176
+ case 'rollback': return snapshot.rollbackHistory.length;
177
+ default: return 0;
178
+ }
179
+ }
55
180
 
56
- const postureLines: Line[] = [
57
- buildPanelLine(width, [[' resolved keys ', C.label], [String(snapshot.resolvedEntries.length), C.value], [' conflicts ', C.label], ...buildStatusPill(snapshot.conflicts.length > 0 ? 'bad' : 'good', String(snapshot.conflicts.length)), [' failures ', C.label], ...buildStatusPill(snapshot.recentFailures.length > 0 ? 'warn' : 'good', String(snapshot.recentFailures.length))]),
58
- buildPanelLine(width, [[' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim], [' staged bundle ', C.label], [snapshot.stagedManagedBundle ? snapshot.stagedManagedBundle.profileName : 'none', snapshot.stagedManagedBundle ? C.info : C.dim]]),
59
- buildGuidanceLine(width, '/settings-sync conflicts', 'review conflicting synced values before they silently shape effective configuration', C),
60
- buildGuidanceLine(width, '/managed review', 'inspect staged managed changes, risk posture, and rollback records', C),
181
+ private _handleBrowseInput(key: string): boolean {
182
+ const count = this._browseItemCount();
183
+ if (count === 0) return false;
184
+ switch (key) {
185
+ case 'up':
186
+ case 'k':
187
+ this.browseIndex = Math.max(0, this.browseIndex - 1);
188
+ this.needsRender = true;
189
+ return true;
190
+ case 'down':
191
+ case 'j':
192
+ this.browseIndex = Math.min(count - 1, this.browseIndex + 1);
193
+ this.needsRender = true;
194
+ return true;
195
+ case 'pageup':
196
+ this.browseIndex = Math.max(0, this.browseIndex - this.getPageSize());
197
+ this.needsRender = true;
198
+ return true;
199
+ case 'pagedown':
200
+ this.browseIndex = Math.min(count - 1, this.browseIndex + this.getPageSize());
201
+ this.needsRender = true;
202
+ return true;
203
+ case 'home':
204
+ case 'g':
205
+ this.browseIndex = 0;
206
+ this.needsRender = true;
207
+ return true;
208
+ case 'end':
209
+ case 'G':
210
+ this.browseIndex = count - 1;
211
+ this.needsRender = true;
212
+ return true;
213
+ default:
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /** ≤5 fixed rows total (1 summary title + 4 rows), shared by every mode. */
219
+ private _buildPostureHeader(width: number, snapshot: SettingsSnapshot): Line[] {
220
+ const rows: Line[] = [
221
+ buildPanelLine(width, [
222
+ [' resolved keys ', C.label], [String(snapshot.resolvedEntries.length), C.value],
223
+ [' conflicts ', C.label], ...buildStatusPill(snapshot.conflicts.length > 0 ? 'bad' : 'good', String(snapshot.conflicts.length)),
224
+ [' failures ', C.label], ...buildStatusPill(snapshot.recentFailures.length > 0 ? 'warn' : 'good', String(snapshot.recentFailures.length)),
225
+ ]),
226
+ buildPanelLine(width, [
227
+ [' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim],
228
+ [' staged bundle ', C.label], [snapshot.stagedManagedBundle ? snapshot.stagedManagedBundle.profileName : 'none', snapshot.stagedManagedBundle ? C.info : C.dim],
229
+ ]),
230
+ buildPanelLine(width, [
231
+ [' effective local ', C.label], [String(snapshot.resolvedCounts.local), C.info],
232
+ [' synced ', C.label], [String(snapshot.resolvedCounts.synced), snapshot.resolvedCounts.synced > 0 ? C.good : C.dim],
233
+ [' managed ', C.label], [String(snapshot.resolvedCounts.managed), snapshot.resolvedCounts.managed > 0 ? C.warn : C.dim],
234
+ ]),
235
+ buildPanelLine(width, [
236
+ [' last sync ', C.label], [snapshot.lastSync ? `${snapshot.lastSync.surface}/${snapshot.lastSync.direction}` : 'none', snapshot.lastSync ? C.good : C.dim],
237
+ [' mode ', C.label], [this.browseMode, C.info],
238
+ ]),
61
239
  ];
240
+ return buildSummaryBlock(width, 'Settings posture', rows, C);
241
+ }
62
242
 
63
- const headerLines: Line[] = [
64
- ...buildSummaryBlock(width, 'Settings posture', postureLines, C),
65
- buildPanelLine(width, [[' local typed config ', C.label], [String(snapshot.liveKeyCount), C.value], [' saved profiles ', C.label], [String(snapshot.profileCount), C.info], [' managed locks ', C.label], [String(snapshot.managedLockCount), snapshot.managedLockCount > 0 ? C.warn : C.dim]]),
66
- buildPanelLine(width, [[' effective local ', C.label], [String(snapshot.resolvedCounts.local), C.info], [' synced ', C.label], [String(snapshot.resolvedCounts.synced), snapshot.resolvedCounts.synced > 0 ? C.ok : C.dim], [' managed ', C.label], [String(snapshot.resolvedCounts.managed), snapshot.resolvedCounts.managed > 0 ? C.warn : C.dim]]),
67
- buildPanelLine(width, [[' last sync ', C.label], [snapshot.lastSync ? `${snapshot.lastSync.surface}/${snapshot.lastSync.direction}` : 'none', snapshot.lastSync ? C.ok : C.dim], [' when ', C.label], [snapshot.lastSync ? new Date(snapshot.lastSync.timestamp).toLocaleString() : 'n/a', C.dim]]),
68
- // Staged Bundle
69
- ...(snapshot.stagedManagedBundle
70
- ? [
71
- buildPanelLine(width, [[' profile ', C.label], [snapshot.stagedManagedBundle.profileName, C.value], [' risk ', C.label], [snapshot.stagedManagedBundle.risk, snapshot.stagedManagedBundle.risk === 'high' ? C.error : snapshot.stagedManagedBundle.risk === 'medium' ? C.warn : C.ok], [' changes ', C.label], [String(snapshot.stagedManagedBundle.changeCount), C.info]]),
72
- buildPanelLine(width, [[' path ', C.label], [snapshot.stagedManagedBundle.path.slice(0, Math.max(0, width - 9)), C.dim]]),
73
- ]
74
- : [buildPanelLine(width, [[' No staged managed settings bundle.', C.dim]])]),
75
- // Recent Events
76
- ...(snapshot.recentEvents.length > 0
77
- ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[` ${event.surface}/${event.direction}`.padEnd(18), C.info], [` ${event.detail}`.slice(0, Math.max(0, width - 20)), C.dim]]))
78
- : [buildPanelLine(width, [[' No sync or managed-setting events recorded yet.', C.dim]])]),
79
- // Managed Locks
80
- ...(snapshot.managedLocks.length > 0
81
- ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[` ${lock.key}`.padEnd(30), C.value], [` source=${lock.source}`.padEnd(24), C.info], [` ${lock.reason}`.slice(0, Math.max(0, width - 56)), C.dim]]))
82
- : [buildPanelLine(width, [[' No managed locks are currently active.', C.dim]])]),
83
- // Failures
84
- ...(snapshot.recentFailures.length > 0
85
- ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [` ${failure.message}`.slice(0, Math.max(0, width - 12)), C.dim]]))
86
- : [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])]),
87
- // Conflicts
88
- ...(snapshot.conflicts.length > 0
89
- ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settings-sync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
90
- : [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])]),
91
- // Rollback History
92
- ...(snapshot.rollbackHistory.length > 0
93
- ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[` ${entry.token}`.padEnd(18), C.info], [` ${entry.profileName}`.padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [` ${new Date(entry.appliedAt).toLocaleString()}`.slice(0, Math.max(0, width - 46)), C.dim]]))
94
- : [buildPanelLine(width, [[' No managed rollback records yet.', C.dim]])]),
243
+ private _buildResolvePromptLines(width: number, key: string): Line[] {
244
+ return [
245
+ buildPanelLine(width, [[` Resolve conflict for "${key}"?`, C.warn]]),
246
+ buildPanelLine(width, [
247
+ [' l', C.info], [' keep local', C.dim],
248
+ [' s', C.info], [' use synced', C.dim],
249
+ [' Esc', C.info], [' cancel', C.dim],
250
+ ]),
95
251
  ];
252
+ }
253
+
254
+ private _buildBrowseModeContent(width: number, snapshot: SettingsSnapshot): BrowseModeContent {
255
+ switch (this.browseMode) {
256
+ case 'events':
257
+ return {
258
+ title: 'Recent Sync & Managed-Setting Events',
259
+ rows: snapshot.recentEvents.map((event, index) => buildPanelListRow(width, [
260
+ { text: fitDisplay(` ${event.surface}/${event.direction}`, 18).padEnd(18), fg: C.info },
261
+ { text: truncateDisplay(` ${event.detail}`, Math.max(0, width - 20)), fg: C.dim },
262
+ ], C, { selected: index === this.browseIndex })),
263
+ emptyMessage: ' No sync or managed-setting events recorded yet.',
264
+ };
265
+ case 'locks':
266
+ return {
267
+ title: 'Managed Locks',
268
+ rows: snapshot.managedLocks.map((lock, index) => buildPanelListRow(width, [
269
+ { text: fitDisplay(` ${lock.key}`, 30).padEnd(30), fg: C.value },
270
+ { text: fitDisplay(` source=${lock.source}`, 24).padEnd(24), fg: C.info },
271
+ { text: truncateDisplay(` ${lock.reason}`, Math.max(0, width - 56)), fg: C.dim },
272
+ ], C, { selected: index === this.browseIndex })),
273
+ emptyMessage: ' No managed locks are currently active.',
274
+ };
275
+ case 'failures':
276
+ return {
277
+ title: 'Sync & Managed-Setting Failures',
278
+ rows: snapshot.recentFailures.map((failure, index) => buildPanelListRow(width, [
279
+ { text: ` ${failure.surface}`.padEnd(10), fg: C.bad },
280
+ { text: truncateDisplay(` ${failure.message}`, Math.max(0, width - 12)), fg: C.dim },
281
+ ], C, { selected: index === this.browseIndex })),
282
+ emptyMessage: ' No recent sync or managed-setting failures.',
283
+ };
284
+ case 'conflicts':
285
+ return {
286
+ title: 'Settings Conflicts',
287
+ rows: snapshot.conflicts.map((conflict, index) => buildPanelListRow(width, [
288
+ { text: fitDisplay(` ${conflict.key}`, 30).padEnd(30), fg: C.value },
289
+ { text: fitDisplay(` ${conflict.source}`, 10).padEnd(10), fg: C.warn },
290
+ { text: truncateDisplay(` local=${String(conflict.localValue)} incoming=${String(conflict.incomingValue)}`, Math.max(0, width - 42)), fg: C.dim },
291
+ ], C, { selected: index === this.browseIndex })),
292
+ emptyMessage: ' No settings conflicts detected. Resolve conflicts from the keys list (Enter on a conflicted entry).',
293
+ };
294
+ case 'rollback':
295
+ return {
296
+ title: 'Managed Rollback History',
297
+ rows: snapshot.rollbackHistory.map((entry, index) => buildPanelListRow(width, [
298
+ { text: fitDisplay(` ${entry.token}`, 18).padEnd(18), fg: C.info },
299
+ { text: fitDisplay(` ${entry.profileName}`, 18).padEnd(18), fg: C.value },
300
+ { text: ` restored=${String(entry.restoredKeys.length).padEnd(4)}`, fg: C.warn },
301
+ { text: truncateDisplay(` ${new Date(entry.appliedAt).toLocaleString()}`, Math.max(0, width - 46)), fg: C.dim },
302
+ ], C, { selected: index === this.browseIndex })),
303
+ emptyMessage: ' No managed rollback records yet.',
304
+ };
305
+ default:
306
+ return { title: '', rows: [], emptyMessage: '' };
307
+ }
308
+ }
309
+
310
+ private _renderKeysMode(width: number, height: number, snapshot: SettingsSnapshot): Line[] {
311
+ const headerLines = this._buildPostureHeader(width, snapshot);
96
312
 
97
313
  this.clampSelection();
98
- const selectedEntry = snapshot.resolvedEntries[this.selectedIndex];
99
- const footerLines: Line[] = [
100
- ...(selectedEntry
101
- ? buildDetailBlock(width, 'Selected setting', [
102
- buildPanelLine(width, [[' key ', C.label], [selectedEntry.key, C.value], [' category ', C.label], [selectedEntry.category, C.info]]),
103
- buildPanelLine(width, [[' effective ', C.label], [selectedEntry.effectiveSource, selectedEntry.effectiveSource === 'managed' ? C.warn : selectedEntry.effectiveSource === 'synced' ? C.ok : selectedEntry.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selectedEntry.locked ? 'yes' : 'no', selectedEntry.locked ? C.warn : C.dim], [' conflict ', C.label], [selectedEntry.conflict ? 'yes' : 'no', selectedEntry.conflict ? C.error : C.good]]),
104
- buildPanelLine(width, [[' source ', C.label], [(selectedEntry.sourceLabel ?? 'local/default').slice(0, Math.max(0, width - 10)), C.dim]]),
105
- buildPanelLine(width, [[' overrides ', C.label], [(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none').slice(0, Math.max(0, width - 13)), C.dim]]),
106
- buildPanelLine(width, [[' local ', C.label], [String(selectedEntry.localValue).slice(0, Math.max(0, width - 9)), C.dim]]),
107
- buildPanelLine(width, [[' synced ', C.label], [String(selectedEntry.syncedValue ?? '(unset)').slice(0, Math.max(0, width - 10)), C.ok]]),
108
- buildPanelLine(width, [[' managed ', C.label], [String(selectedEntry.managedValue ?? '(unset)').slice(0, Math.max(0, width - 11)), C.warn]]),
109
- ], C)
110
- : []),
111
- buildPanelLine(width, [[' ↑/↓ browse /settings-sync show <key> /settings-sync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]]),
112
- ];
314
+ // Detail must describe the row the (possibly filtered) list highlights —
315
+ // raw resolvedEntries desyncs selectedIndex under an applied '/' filter.
316
+ const selectedEntry = this.getSelectedItem();
317
+ const detailLines: Line[] = this._resolvePrompt
318
+ ? this._buildResolvePromptLines(width, this._resolvePrompt.key)
319
+ : (selectedEntry
320
+ ? buildDetailBlock(width, 'Selected setting', [
321
+ buildPanelLine(width, [[' key ', C.label], [selectedEntry.key, C.value], [' category ', C.label], [selectedEntry.category, C.info]]),
322
+ buildPanelLine(width, [[' effective ', C.label], [selectedEntry.effectiveSource, selectedEntry.effectiveSource === 'managed' ? C.warn : selectedEntry.effectiveSource === 'synced' ? C.good : selectedEntry.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selectedEntry.locked ? 'yes' : 'no', selectedEntry.locked ? C.warn : C.dim], [' conflict ', C.label], [selectedEntry.conflict ? 'yes' : 'no', selectedEntry.conflict ? C.bad : C.good]]),
323
+ buildPanelLine(width, [[' source ', C.label], [truncateDisplay(selectedEntry.sourceLabel ?? 'local/default', Math.max(0, width - 10)), C.dim]]),
324
+ buildPanelLine(width, [[' overrides ', C.label], [truncateDisplay(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none', Math.max(0, width - 13)), C.dim]]),
325
+ buildPanelLine(width, [[' local ', C.label], [truncateDisplay(String(selectedEntry.localValue), Math.max(0, width - 9)), C.dim]]),
326
+ buildPanelLine(width, [[' synced ', C.label], [truncateDisplay(String(selectedEntry.syncedValue ?? '(unset)'), Math.max(0, width - 10)), C.good]]),
327
+ buildPanelLine(width, [[' managed ', C.label], [truncateDisplay(String(selectedEntry.managedValue ?? '(unset)'), Math.max(0, width - 11)), C.warn]]),
328
+ ], C)
329
+ : [buildPanelLine(width, [[' Select a setting above to inspect its effective value, source, and overrides.', C.dim]])]);
113
330
 
114
331
  return this.renderList(width, height, {
115
332
  title: 'Settings Sync',
116
333
  header: headerLines,
117
- footer: footerLines,
334
+ // Keyboard hints are placed BEFORE the detail block (rather than after,
335
+ // which is the usual convention) so that if the fixed-row budget is
336
+ // tight, the tail of the detail block is what gets clipped by the
337
+ // panel-workspace row budget — not the hints row itself.
338
+ footer: [
339
+ buildKeyboardHints(width, [
340
+ { keys: '↑/↓', label: 'browse' },
341
+ { keys: '/', label: 'filter' },
342
+ { keys: 'enter', label: 'resolve conflict' },
343
+ { keys: 'tab', label: 'events/locks/failures/conflicts/rollback' },
344
+ { keys: 'm', label: 'managed review' },
345
+ ], C),
346
+ ...detailLines,
347
+ ],
348
+ });
349
+ }
350
+
351
+ private _renderBrowseMode(width: number, height: number, snapshot: SettingsSnapshot): Line[] {
352
+ this.needsRender = false;
353
+ const postureSection: PanelWorkspaceSection = { lines: this._buildPostureHeader(width, snapshot) };
354
+ const content = this._buildBrowseModeContent(width, snapshot);
355
+ const itemCount = content.rows.length;
356
+ this.browseIndex = itemCount > 0 ? Math.min(this.browseIndex, itemCount - 1) : 0;
357
+ const scrollableLines = itemCount > 0 ? content.rows : [buildPanelLine(width, [[content.emptyMessage, C.dim]])];
358
+
359
+ const hintsLine = buildKeyboardHints(width, [
360
+ { keys: '↑/↓', label: 'browse' },
361
+ { keys: 'tab', label: 'next mode' },
362
+ { keys: 'm', label: 'managed review' },
363
+ ], C);
364
+
365
+ const resolved = resolveScrollablePanelSection(width, height, {
366
+ palette: C,
367
+ beforeSections: [postureSection],
368
+ footerLines: [hintsLine],
369
+ section: {
370
+ title: content.title,
371
+ scrollableLines,
372
+ selectedIndex: itemCount > 0 ? this.browseIndex : undefined,
373
+ scrollOffset: this.browseScrollOffset,
374
+ guardRows: 1,
375
+ minRows: 4,
376
+ appendWindowSummary: { dimColor: C.dim },
377
+ },
378
+ });
379
+ this.browseScrollOffset = resolved.scrollOffset;
380
+
381
+ const lines = buildPanelWorkspace(width, height, {
382
+ title: 'Settings Sync',
383
+ sections: [postureSection, resolved.section],
384
+ footerLines: [hintsLine],
385
+ palette: C,
118
386
  });
387
+ while (lines.length < height) lines.push(createEmptyLine(width));
388
+ return lines.slice(0, height);
389
+ }
390
+
391
+ public render(width: number, height: number): Line[] {
392
+ const snapshot = getSettingsControlPlaneSnapshot(this.configManager);
393
+ if (this.browseMode !== 'keys') {
394
+ return this._renderBrowseMode(width, height, snapshot);
395
+ }
396
+ return this._renderKeysMode(width, height, snapshot);
119
397
  }
120
398
  }