@pellux/goodvibes-tui 0.18.23 → 0.19.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 (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +8 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/shell-core.ts +3 -2
  13. package/src/input/commands/skills-runtime.ts +2 -2
  14. package/src/input/commands/subscription-runtime.ts +4 -4
  15. package/src/input/handler.ts +8 -10
  16. package/src/input/panel-integration-actions.ts +2 -1
  17. package/src/input/settings-modal-types.ts +60 -0
  18. package/src/input/settings-modal.ts +83 -65
  19. package/src/panels/agent-inspector-panel.ts +10 -9
  20. package/src/panels/agent-logs-panel.ts +26 -6
  21. package/src/panels/approval-panel.ts +1 -0
  22. package/src/panels/automation-control-panel.ts +1 -0
  23. package/src/panels/base-panel.ts +108 -3
  24. package/src/panels/communication-panel.ts +1 -0
  25. package/src/panels/context-visualizer-panel.ts +2 -0
  26. package/src/panels/control-plane-panel.ts +1 -0
  27. package/src/panels/diff-panel.ts +2 -0
  28. package/src/panels/file-explorer-panel.ts +51 -31
  29. package/src/panels/file-preview-panel.ts +57 -35
  30. package/src/panels/git-panel.ts +12 -13
  31. package/src/panels/hooks-panel.ts +3 -1
  32. package/src/panels/incident-review-panel.ts +4 -2
  33. package/src/panels/knowledge-panel.ts +75 -107
  34. package/src/panels/local-auth-panel.ts +1 -0
  35. package/src/panels/marketplace-panel.ts +51 -69
  36. package/src/panels/mcp-panel.ts +3 -1
  37. package/src/panels/memory-panel.ts +90 -158
  38. package/src/panels/ops-control-panel.ts +1 -0
  39. package/src/panels/orchestration-panel.ts +70 -51
  40. package/src/panels/panel-list-panel.ts +5 -4
  41. package/src/panels/panel-manager.ts +3 -0
  42. package/src/panels/plan-dashboard-panel.ts +2 -0
  43. package/src/panels/plugins-panel.ts +1 -0
  44. package/src/panels/polish.ts +51 -2
  45. package/src/panels/provider-accounts-panel.ts +1 -0
  46. package/src/panels/provider-health-panel.ts +6 -8
  47. package/src/panels/routes-panel.ts +3 -1
  48. package/src/panels/schedule-panel.ts +7 -6
  49. package/src/panels/scrollable-list-panel.ts +19 -2
  50. package/src/panels/security-panel.ts +17 -15
  51. package/src/panels/services-panel.ts +6 -4
  52. package/src/panels/session-browser-panel.ts +19 -18
  53. package/src/panels/settings-sync-panel.ts +3 -1
  54. package/src/panels/skills-panel.ts +114 -230
  55. package/src/panels/subscription-panel.ts +1 -0
  56. package/src/panels/system-messages-panel.ts +147 -141
  57. package/src/panels/tasks-panel.ts +1 -0
  58. package/src/panels/token-budget-panel.ts +2 -0
  59. package/src/panels/watchers-panel.ts +1 -0
  60. package/src/panels/worktree-panel.ts +1 -0
  61. package/src/panels/wrfc-panel.ts +2 -0
  62. package/src/renderer/agent-detail-modal.ts +2 -2
  63. package/src/renderer/ansi-sanitize.ts +76 -0
  64. package/src/renderer/buffer.ts +12 -1
  65. package/src/renderer/help-overlay.ts +14 -3
  66. package/src/renderer/settings-modal-helpers.ts +27 -0
  67. package/src/renderer/settings-modal.ts +18 -1
  68. package/src/renderer/status-glyphs.ts +21 -0
  69. package/src/renderer/status-token.ts +4 -8
  70. package/src/renderer/tool-call.ts +4 -3
  71. package/src/runtime/bootstrap-core.ts +1 -1
  72. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  73. package/src/runtime/bootstrap.ts +7 -8
  74. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  75. package/src/shell/ui-openers.ts +1 -1
  76. package/src/version.ts +1 -1
@@ -3,6 +3,7 @@ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
4
4
  import { getSurfaceContentRows, getTrackedVisibleWindow, getVisibleWindow, type VisibleWindow } from '../renderer/surface-layout.ts';
5
5
  import { GLYPHS, UI_TONES } from '../renderer/ui-primitives.ts';
6
+ import { type StatusState, STATE_GLYPHS } from '../renderer/status-glyphs.ts';
6
7
 
7
8
  export interface PanelPalette {
8
9
  readonly label: string;
@@ -42,13 +43,36 @@ export const DEFAULT_PANEL_PALETTE: Readonly<Required<PanelPalette>> = {
42
43
  selectBg: UI_TONES.bg.selected,
43
44
  } as const;
44
45
 
46
+ /**
47
+ * Extend the base panel palette with domain-specific colors.
48
+ *
49
+ * Convention: raw hex colors may only live inside a palette constant declared
50
+ * at the top of a panel file, not inline in render calls.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * const C = extendPalette(DEFAULT_PANEL_PALETTE, {
55
+ * decision: '#38bdf8',
56
+ * incident: '#ef4444',
57
+ * });
58
+ * ```
59
+ */
60
+ export function extendPalette<T extends Record<string, string>>(
61
+ base: typeof DEFAULT_PANEL_PALETTE,
62
+ extras: T,
63
+ ): typeof DEFAULT_PANEL_PALETTE & T {
64
+ return { ...base, ...extras };
65
+ }
66
+
45
67
  export function buildPanelLine(
46
68
  width: number,
47
- segments: Array<[string, string, string?]>,
69
+ segments: Array<StyledPanelSegment | [string, string, string?]>,
48
70
  ): Line {
49
71
  return buildStyledPanelLine(
50
72
  width,
51
- segments.map(([text, fg, bg]) => ({ text, fg, bg })),
73
+ segments.map((seg) =>
74
+ Array.isArray(seg) ? { text: seg[0], fg: seg[1], bg: seg[2] } : seg,
75
+ ),
52
76
  );
53
77
  }
54
78
 
@@ -256,6 +280,31 @@ export function buildSearchInputLine(
256
280
  ], { fillBg: bg });
257
281
  }
258
282
 
283
+ /**
284
+ * Build a status pill segment (glyph + label) for use in buildPanelLine.
285
+ *
286
+ * Returns StyledPanelSegment[] — spread directly into a buildPanelLine segments
287
+ * array:
288
+ * buildPanelLine(width, [[' count ', C.label], ...buildStatusPill('bad', '3')])
289
+ *
290
+ * Convention: raw hex colors may only live inside a palette constant declared at
291
+ * the top of a panel file; buildStatusPill derives its color from the palette.
292
+ */
293
+ export function buildStatusPill(
294
+ state: StatusState,
295
+ label: string,
296
+ opts?: { glyph?: string; bg?: string; count?: number },
297
+ ): StyledPanelSegment[] {
298
+ const glyph = opts?.glyph ?? STATE_GLYPHS[state];
299
+ const color = state === 'good' ? DEFAULT_PANEL_PALETTE.good
300
+ : state === 'warn' ? DEFAULT_PANEL_PALETTE.warn
301
+ : state === 'bad' ? DEFAULT_PANEL_PALETTE.bad
302
+ : DEFAULT_PANEL_PALETTE.info;
303
+ const bg = opts?.bg;
304
+ const text = opts?.count !== undefined ? `${glyph} ${label} (${opts.count})` : `${glyph} ${label}`;
305
+ return [{ text, fg: color, bg }];
306
+ }
307
+
259
308
  export function buildStatPill(
260
309
  label: string,
261
310
  value: string,
@@ -36,6 +36,7 @@ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRe
36
36
 
37
37
  public constructor(deps: ProviderAccountsPanelDeps) {
38
38
  super('accounts', 'Accounts', 'Q', 'monitoring');
39
+ this.showSelectionGutter = true; // I5: non-color selection affordance
39
40
  this.providerAccounts = deps.providerAccounts;
40
41
  void this.refresh();
41
42
  }
@@ -362,7 +362,7 @@ function buildProviderRuntimeRecord(
362
362
  */
363
363
  export class ProviderHealthPanel extends BasePanel {
364
364
  private _unsubs: Array<() => void> = [];
365
- private _refreshTimer: ReturnType<typeof setInterval> | null = null;
365
+ private _refreshTimerId: ReturnType<typeof setInterval> | null = null;
366
366
  private _selectedIndex = 0;
367
367
  private _scrollOffset = 0;
368
368
  private _accountRecords = new Map<string, ProviderRuntimeRecord>();
@@ -461,23 +461,21 @@ export class ProviderHealthPanel extends BasePanel {
461
461
  }
462
462
 
463
463
  override onDestroy(): void {
464
- if (this._refreshTimer !== null) {
465
- clearInterval(this._refreshTimer);
466
- this._refreshTimer = null;
467
- }
464
+ super.onDestroy();
465
+ this._refreshTimerId = null;
468
466
  for (const unsub of this._unsubs) unsub();
469
467
  this._unsubs = [];
470
468
  }
471
469
 
472
470
  private _ensureRefreshTimer(): void {
473
- if (this._refreshTimer !== null) return;
474
- this._refreshTimer = setInterval(() => {
471
+ if (this._refreshTimerId !== null) return;
472
+ this._refreshTimerId = this.registerTimer(setInterval(() => {
475
473
  if (Date.now() - this._accountRefreshAt > 30_000) {
476
474
  void this._refreshAccountPosture();
477
475
  }
478
476
  this.markDirty();
479
477
  this.requestRender();
480
- }, 1_000);
478
+ }, 1_000));
481
479
  }
482
480
 
483
481
  handleInput(key: string): boolean {
@@ -9,6 +9,7 @@ import {
9
9
  buildKeyValueLine,
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
+ buildStatusPill,
12
13
  DEFAULT_PANEL_PALETTE,
13
14
  type PanelPalette,
14
15
  } from './polish.ts';
@@ -37,6 +38,7 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
37
38
 
38
39
  public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
39
40
  super('routes', 'Routes', 'R', 'monitoring');
41
+ this.showSelectionGutter = true; // I5: non-color selection affordance
40
42
  this.readModel = readModel;
41
43
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
42
44
  }
@@ -60,7 +62,7 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
60
62
  [' ', C.label, bg],
61
63
  [binding.surfaceKind.padEnd(9), C.info, bg],
62
64
  [` ${truncateDisplay(binding.title ?? binding.externalId, 22).padEnd(22)}`, C.value, bg],
63
- [` ${truncateDisplay(binding.sessionId ?? binding.runId ?? 'unbound', 18).padEnd(18)}`, binding.sessionId ? C.ok : C.warn, bg],
65
+ ...buildStatusPill(binding.sessionId ? 'good' : 'warn', ` ${truncateDisplay(binding.sessionId ?? binding.runId ?? 'unbound', 18).padEnd(18)}`, { bg }),
64
66
  [` ${truncateDisplay(formatTime(binding.lastSeenAt), Math.max(0, width - 54))}`, C.dim, bg],
65
67
  ]);
66
68
  }
@@ -87,7 +87,7 @@ export class SchedulePanel extends BasePanel {
87
87
  private items: ViewItem[] = [];
88
88
  private selectedIndex = 0;
89
89
  private scrollOffset = 0;
90
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
90
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
91
91
  private readonly automationManager: ScheduleAutomationManager;
92
92
 
93
93
  constructor(automationManager: ScheduleAutomationManager) {
@@ -106,21 +106,22 @@ export class SchedulePanel extends BasePanel {
106
106
  this.markDirty();
107
107
  });
108
108
  this.rebuild();
109
- this.refreshTimer = setInterval(() => {
109
+ this.refreshTimerId = this.registerTimer(setInterval(() => {
110
110
  this.rebuild();
111
111
  this.markDirty();
112
- }, 5_000);
112
+ }, 5_000));
113
113
  }
114
114
 
115
115
  override onDeactivate(): void {
116
- if (this.refreshTimer !== null) {
117
- clearInterval(this.refreshTimer);
118
- this.refreshTimer = null;
116
+ if (this.refreshTimerId !== null) {
117
+ this.clearTimer(this.refreshTimerId);
118
+ this.refreshTimerId = null;
119
119
  }
120
120
  }
121
121
 
122
122
  override onDestroy(): void {
123
123
  this.onDeactivate();
124
+ super.onDestroy();
124
125
  }
125
126
 
126
127
  // -------------------------------------------------------------------------
@@ -122,9 +122,26 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
122
122
  // Navigation — consistent across ALL panels
123
123
  // -------------------------------------------------------------------------
124
124
 
125
+ /**
126
+ * Handle keyboard input for list navigation.
127
+ *
128
+ * **Auto-clearError contract**: At the top of this method, `lastError` is cleared if
129
+ * non-null. This means any transient error set via `setError()` is dismissed on the
130
+ * very next keystroke the user presses. Subclasses that override `handleInput()` should
131
+ * either:
132
+ * 1. Call `super.handleInput(key)` as a fallback (preferred), which will clear the
133
+ * error when navigation keys are pressed, or
134
+ * 2. Manually call `this.clearError()` at the top of their override to maintain
135
+ * the same contract for their handled keys.
136
+ *
137
+ * Returns `true` if the key was consumed, `false` to let the panel manager try another
138
+ * handler.
139
+ */
125
140
  handleInput(key: string): boolean {
126
- // I2: auto-clear error on next keypress
127
- if (this.lastError) this.clearError();
141
+ // I2: auto-clear transient errors on the next keystroke so stale errors don't linger.
142
+ // Subclasses that override handleInput should call super.handleInput(key) OR manually
143
+ // call this.clearError() at the start of their handler.
144
+ if (this.lastError !== null) this.clearError();
128
145
 
129
146
  const items = this.getItems();
130
147
  const total = items.length;
@@ -7,6 +7,7 @@ import {
7
7
  buildGuidanceLine,
8
8
  buildPanelLine,
9
9
  buildPanelWorkspace,
10
+ buildStatusPill,
10
11
  DEFAULT_PANEL_PALETTE,
11
12
  } from './polish.ts';
12
13
  import { createEmptyLine } from '../types/grid.ts';
@@ -61,6 +62,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
61
62
 
62
63
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
63
64
  super('security', 'Security', 'U', 'monitoring');
65
+ this.showSelectionGutter = true; // I5: non-color selection affordance
64
66
  this.unsub = this.readModel.subscribe(() => this.markDirty());
65
67
  }
66
68
 
@@ -125,46 +127,46 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
125
127
  [' tokens ', C.label],
126
128
  [String(view.totalTokens), C.value],
127
129
  [' blocked ', C.label],
128
- [String(view.blocked.length), view.blocked.length > 0 ? C.error : C.ok],
130
+ ...buildStatusPill(view.blocked.length > 0 ? 'bad' : 'good', String(view.blocked.length)),
129
131
  [' scope violations ', C.label],
130
- [String(view.scopeViolations.length), view.scopeViolations.length > 0 ? C.error : C.ok],
132
+ ...buildStatusPill(view.scopeViolations.length > 0 ? 'bad' : 'good', String(view.scopeViolations.length)),
131
133
  [' overdue ', C.label],
132
- [String(view.rotationOverdue.length), view.rotationOverdue.length > 0 ? C.error : C.ok],
134
+ ...buildStatusPill(view.rotationOverdue.length > 0 ? 'bad' : 'good', String(view.rotationOverdue.length)),
133
135
  [' warnings ', C.label],
134
- [String(view.rotationWarnings.length), view.rotationWarnings.length > 0 ? C.warn : C.ok],
136
+ ...buildStatusPill(view.rotationWarnings.length > 0 ? 'warn' : 'good', String(view.rotationWarnings.length)),
135
137
  ]),
136
138
  buildPanelLine(width, [
137
139
  [' preflight ', C.label],
138
- [preflightStatus.toUpperCase(), preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : preflightStatus === 'pass' ? C.ok : C.dim],
140
+ ...buildStatusPill(preflightStatus === 'block' ? 'bad' : preflightStatus === 'warn' ? 'warn' : preflightStatus === 'pass' ? 'good' : 'info', preflightStatus.toUpperCase()),
139
141
  [' issues ', C.label],
140
- [String(preflightIssueCount), preflightIssueCount > 0 ? C.warn : C.ok],
142
+ ...buildStatusPill(preflightIssueCount > 0 ? 'warn' : 'good', String(preflightIssueCount)),
141
143
  [' lint ', C.label],
142
- [String(lintFindingCount), lintFindingCount > 0 ? C.warn : C.ok],
144
+ ...buildStatusPill(lintFindingCount > 0 ? 'warn' : 'good', String(lintFindingCount)),
143
145
  [' denied permissions ', C.label],
144
- [String(snapshot.deniedPermissions), snapshot.deniedPermissions > 0 ? C.warn : C.ok],
146
+ ...buildStatusPill(snapshot.deniedPermissions > 0 ? 'warn' : 'good', String(snapshot.deniedPermissions)),
145
147
  ]),
146
148
  buildPanelLine(width, [
147
149
  [' quarantined MCP ', C.label],
148
- [String(quarantinedMcp.length), quarantinedMcp.length > 0 ? C.error : C.ok],
150
+ ...buildStatusPill(quarantinedMcp.length > 0 ? 'bad' : 'good', String(quarantinedMcp.length)),
149
151
  [' elevated MCP ', C.label],
150
- [String(elevatedMcp.length), elevatedMcp.length > 0 ? C.warn : C.ok],
152
+ ...buildStatusPill(elevatedMcp.length > 0 ? 'warn' : 'good', String(elevatedMcp.length)),
151
153
  [' quarantined plugins ', C.label],
152
- [String(quarantinedPlugins.length), quarantinedPlugins.length > 0 ? C.error : C.ok],
154
+ ...buildStatusPill(quarantinedPlugins.length > 0 ? 'bad' : 'good', String(quarantinedPlugins.length)),
153
155
  [' untrusted plugins ', C.label],
154
- [String(untrustedPlugins.length), untrustedPlugins.length > 0 ? C.warn : C.ok],
156
+ ...buildStatusPill(untrustedPlugins.length > 0 ? 'warn' : 'good', String(untrustedPlugins.length)),
155
157
  ]),
156
158
  buildPanelLine(width, [
157
159
  [' incidents ', C.label],
158
- [String(incidents.length), incidents.length > 0 ? C.warn : C.ok],
160
+ ...buildStatusPill(incidents.length > 0 ? 'warn' : 'good', String(incidents.length)),
159
161
  ]),
160
162
  ];
161
163
 
162
164
  const attackPathLines: Line[] = [
163
165
  buildPanelLine(width, [
164
166
  [' attack paths ', C.label],
165
- [String(attackPathReview.criticalFindings), attackPathReview.criticalFindings > 0 ? C.error : C.ok],
167
+ ...buildStatusPill(attackPathReview.criticalFindings > 0 ? 'bad' : 'good', String(attackPathReview.criticalFindings)),
166
168
  [' critical ', C.label],
167
- [String(attackPathReview.incoherentFindings), attackPathReview.incoherentFindings > 0 ? C.warn : C.ok],
169
+ ...buildStatusPill(attackPathReview.incoherentFindings > 0 ? 'warn' : 'good', String(attackPathReview.incoherentFindings)),
168
170
  [' review ', C.label],
169
171
  [attackPathReview.summary.slice(0, Math.max(0, width - 36)), C.dim],
170
172
  ]),
@@ -11,6 +11,7 @@ import {
11
11
  buildEmptyState,
12
12
  buildPanelLine,
13
13
  buildPanelWorkspace,
14
+ buildStatusPill,
14
15
  DEFAULT_PANEL_PALETTE,
15
16
  } from './polish.ts';
16
17
 
@@ -78,6 +79,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
78
79
  subscriptionManager: SubscriptionAccessQuery,
79
80
  ) {
80
81
  super('services', 'Services', 'V', 'monitoring');
82
+ this.showSelectionGutter = true; // I5: non-color selection affordance
81
83
  this.registry = registry;
82
84
  this.subscriptionManager = subscriptionManager;
83
85
  void this.refresh();
@@ -188,16 +190,16 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
188
190
  ]));
189
191
  detailLines.push(buildPanelLine(width, [
190
192
  [' Primary credential: ', C.label],
191
- [inspect.hasPrimaryCredential ? 'present' : 'missing', inspect.hasPrimaryCredential ? C.ok : C.error],
193
+ ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
192
194
  [' Webhook URL: ', C.label],
193
- [inspect.hasWebhookUrl ? 'present' : 'missing', inspect.hasWebhookUrl ? C.ok : C.dim],
195
+ ...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
194
196
  [' Signing secret: ', C.label],
195
- [inspect.hasSigningSecret ? 'present' : 'missing', inspect.hasSigningSecret ? C.ok : C.dim],
197
+ ...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
196
198
  ]));
197
199
  if (selected.lastTest) {
198
200
  detailLines.push(buildPanelLine(width, [
199
201
  [' Last test: ', C.label],
200
- [selected.lastTest.ok ? 'ok' : 'failed', selected.lastTest.ok ? C.ok : C.error],
202
+ ...buildStatusPill(selected.lastTest.ok ? 'good' : 'bad', selected.lastTest.ok ? 'ok' : 'failed'),
201
203
  [' Status: ', C.label],
202
204
  [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
203
205
  [' URL: ', C.label],
@@ -26,6 +26,7 @@ import {
26
26
  isPanelSearchCommit,
27
27
  isPanelSearchPrintable,
28
28
  } from './search-focus.ts';
29
+ import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
29
30
 
30
31
  const C = {
31
32
  headerBg: '#1a1a2e',
@@ -79,7 +80,7 @@ function formatReturnContextLines(returnContext: SessionInfo['returnContext']):
79
80
  // ---------------------------------------------------------------------------
80
81
  // Confirmation state for deletion
81
82
  // ---------------------------------------------------------------------------
82
- type ConfirmState = { sessionName: string } | null;
83
+ // ConfirmState<string> subject holds the session name to delete
83
84
 
84
85
  export class SessionBrowserPanel extends BasePanel {
85
86
  private sessions: SessionInfo[] = [];
@@ -88,10 +89,10 @@ export class SessionBrowserPanel extends BasePanel {
88
89
  private searching = false; // true when user is actively typing a search
89
90
  private cursorIndex = 0;
90
91
  private scrollOffset = 0;
91
- private confirm: ConfirmState = null;
92
+ private confirm: ConfirmState<string> | null = null;
92
93
  private deleteError = '';
93
94
  private loadError = '';
94
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
95
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
95
96
 
96
97
  constructor(
97
98
  private readonly sessionManager: SessionBrowserQuery,
@@ -103,29 +104,29 @@ export class SessionBrowserPanel extends BasePanel {
103
104
  override onActivate(): void {
104
105
  super.onActivate();
105
106
  this._load();
106
- this.refreshTimer = setInterval(() => { this._load(); }, 5000);
107
+ this.refreshTimerId = this.registerTimer(setInterval(() => { this._load(); }, 5000));
107
108
  }
108
109
 
109
110
  override onDeactivate(): void {
110
- if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; }
111
+ if (this.refreshTimerId !== null) { this.clearTimer(this.refreshTimerId); this.refreshTimerId = null; }
111
112
  this.searching = false;
112
113
  this.confirm = null;
113
114
  super.onDeactivate();
114
115
  }
115
116
 
116
117
  handleInput(key: string): boolean {
117
- // Confirmation dialog
118
- if (this.confirm) {
119
- if (key === 'y') {
120
- this._deleteConfirmed();
121
- return true;
122
- } else if (key === 'n' || key === 'escape') {
123
- this.confirm = null;
124
- this.markDirty();
125
- return true;
126
- }
118
+ // Confirmation dialog — use shared handleConfirmInput for y/n/Esc UX
119
+ const confirmResult = handleConfirmInput(this.confirm, key);
120
+ if (confirmResult === 'confirmed') {
121
+ this._deleteConfirmed();
122
+ return true;
123
+ }
124
+ if (confirmResult === 'cancelled') {
125
+ this.confirm = null;
126
+ this.markDirty();
127
127
  return true;
128
128
  }
129
+ if (confirmResult === 'absorbed') return true;
129
130
 
130
131
  // Search mode
131
132
  if (this.searching) {
@@ -200,7 +201,7 @@ export class SessionBrowserPanel extends BasePanel {
200
201
  {
201
202
  title: 'Confirmation',
202
203
  lines: [
203
- buildPanelLine(width, [[` Delete "${this.confirm.sessionName}"?`, DEFAULT_PANEL_PALETTE.warn]]),
204
+ buildPanelLine(width, [[` Delete "${this.confirm.subject}"?`, DEFAULT_PANEL_PALETTE.warn]]),
204
205
  buildPanelLine(width, [[' y', DEFAULT_PANEL_PALETTE.info], [' confirm delete', DEFAULT_PANEL_PALETTE.dim], [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim]]),
205
206
  ],
206
207
  },
@@ -378,13 +379,13 @@ export class SessionBrowserPanel extends BasePanel {
378
379
  private _promptDelete(): void {
379
380
  const sess = this.filtered[this.cursorIndex];
380
381
  if (!sess) return;
381
- this.confirm = { sessionName: sess.name };
382
+ this.confirm = { subject: sess.name, label: sess.name };
382
383
  this.markDirty();
383
384
  }
384
385
 
385
386
  private _deleteConfirmed(): void {
386
387
  if (!this.confirm) return;
387
- const name = this.confirm.sessionName;
388
+ const name = this.confirm.subject;
388
389
  this.confirm = null;
389
390
  try {
390
391
  this.sessionManager.delete(name);
@@ -5,6 +5,7 @@ import {
5
5
  buildGuidanceLine,
6
6
  buildPanelListRow,
7
7
  buildPanelLine,
8
+ buildStatusPill,
8
9
  buildSummaryBlock,
9
10
  DEFAULT_PANEL_PALETTE,
10
11
  type PanelPalette,
@@ -26,6 +27,7 @@ type ResolvedEntry = ReturnType<typeof getSettingsControlPlaneSnapshot>['resolve
26
27
  export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
27
28
  public constructor(private readonly configManager: ConfigManager) {
28
29
  super('settings-sync', 'Settings Sync', 'S', 'monitoring');
30
+ this.showSelectionGutter = true; // I5: non-color selection affordance
29
31
  }
30
32
 
31
33
  protected override getPalette(): PanelPalette {
@@ -52,7 +54,7 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
52
54
  const snapshot = getSettingsControlPlaneSnapshot(this.configManager);
53
55
 
54
56
  const postureLines: Line[] = [
55
- buildPanelLine(width, [[' resolved keys ', C.label], [String(snapshot.resolvedEntries.length), C.value], [' conflicts ', C.label], [String(snapshot.conflicts.length), snapshot.conflicts.length > 0 ? C.error : C.good], [' failures ', C.label], [String(snapshot.recentFailures.length), snapshot.recentFailures.length > 0 ? C.warn : C.good]]),
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))]),
56
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]]),
57
59
  buildGuidanceLine(width, '/settingssync conflicts', 'review conflicting synced values before they silently shape effective configuration', C),
58
60
  buildGuidanceLine(width, '/managed review', 'inspect staged managed changes, risk posture, and rollback records', C),