@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,9 +1,11 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import {
5
6
  buildDetailBlock,
6
7
  buildGuidanceLine,
8
+ buildKeyboardHints,
7
9
  buildPanelListRow,
8
10
  buildPanelLine,
9
11
  buildSummaryBlock,
@@ -16,14 +18,11 @@ import type { LocalAuthInspectionQuery } from '../runtime/ui-service-queries.ts'
16
18
  import type { KeyName } from './types.ts';
17
19
  import { isTextBackspace } from '../input/delete-key-policy.ts';
18
20
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
21
+ import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
19
22
 
20
- const C = {
21
- ...DEFAULT_PANEL_PALETTE,
22
- info: '#38bdf8',
23
- warn: '#eab308',
24
- error: '#ef4444',
25
- selectBg: '#1e293b',
26
- } 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;
27
26
 
28
27
  function formatRoles(roles: readonly string[]): string {
29
28
  return roles.length > 0 ? roles.join(', ') : '(none)';
@@ -41,16 +40,47 @@ interface MaskedEntryState {
41
40
  buffer: string;
42
41
  }
43
42
 
43
+ /** Mutation surface the panel needs for p/a/d/b — a subset of UserAuthManager. */
44
+ type LocalAuthMutations = Pick<UserAuthManager, 'addUser' | 'deleteUser' | 'rotatePassword' | 'clearBootstrapCredentialFile'>;
45
+
46
+ function hasLocalAuthMutations(value: LocalAuthInspectionQuery): value is LocalAuthInspectionQuery & LocalAuthMutations {
47
+ const candidate = value as Partial<LocalAuthMutations>;
48
+ return typeof candidate.addUser === 'function'
49
+ && typeof candidate.deleteUser === 'function'
50
+ && typeof candidate.rotatePassword === 'function'
51
+ && typeof candidate.clearBootstrapCredentialFile === 'function';
52
+ }
53
+
44
54
  export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
45
55
  private readonly authManager: LocalAuthInspectionQuery;
46
56
  private maskedState: MaskedEntryState | null = null;
57
+ /** Pending delete-user confirmation — project-standard ConfirmState contract. */
58
+ private deleteConfirm: ConfirmState<string> | null = null;
59
+ /** Draft username buffer for the 'a' add-user flow's first step (username,
60
+ * then openMaskedEntry for the password). Not masked — usernames aren't secret. */
61
+ private usernameEntry: string | null = null;
62
+ /** Cached inspect() snapshot for the current render pass — see render(). */
63
+ private cachedSnapshot: LocalAuthSnapshot | null = null;
47
64
 
48
65
  public constructor(authManager: LocalAuthInspectionQuery) {
49
- super('local-auth', 'Local Auth', 'U', 'monitoring');
66
+ super('local-auth', 'Local Auth', 'U', 'security-policy');
50
67
  this.showSelectionGutter = true; // I5: non-color selection affordance
51
68
  this.authManager = authManager;
52
69
  }
53
70
 
71
+ /**
72
+ * The full mutation surface (addUser/deleteUser/rotatePassword/
73
+ * clearBootstrapCredentialFile), when the constructor's authManager
74
+ * happens to be the real UserAuthManager rather than the narrower
75
+ * read-only LocalAuthInspectionQuery some callers (and tests) supply.
76
+ * Real production wiring (builtin/operations.ts) always passes the full
77
+ * manager; test doubles that implement only inspect() correctly get null
78
+ * here, and the p/a/d/b keys surface a clear error instead of throwing.
79
+ */
80
+ private get mutations(): LocalAuthMutations | null {
81
+ return hasLocalAuthMutations(this.authManager) ? this.authManager : null;
82
+ }
83
+
54
84
  /**
55
85
  * Activate masked password-entry mode for the given operation.
56
86
  * The panel's handleInput will capture keystrokes into a private buffer;
@@ -67,13 +97,143 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
67
97
  }
68
98
 
69
99
  public override handleInput(key: KeyName): boolean {
70
- if (this.maskedState === null) {
71
- // Delegate scroll/selection to ScrollableListPanel when not in masked mode.
72
- return super.handleInput?.(key) ?? false;
100
+ // Masked entry takes priority when active — it must capture every
101
+ // keystroke (including letters that would otherwise be p/a/d/b actions)
102
+ // as part of the password being typed.
103
+ if (this.maskedState !== null) {
104
+ return this.handleMaskedEntryInput(key);
105
+ }
106
+
107
+ if (this.usernameEntry !== null) {
108
+ return this.handleUsernameEntryInput(key);
109
+ }
110
+
111
+ if (this.deleteConfirm !== null) {
112
+ return this.handleDeleteConfirmInput(key);
113
+ }
114
+
115
+ const selected = this.getSelectedItem();
116
+
117
+ if (key === 'p') {
118
+ if (!selected) return false;
119
+ const mutations = this.mutations;
120
+ if (!mutations) {
121
+ this.setError('Local auth mutations are not available in this session.');
122
+ return true;
123
+ }
124
+ this.openMaskedEntry('rotate-password', selected.username, mutations as unknown as UserAuthManager);
125
+ return true;
126
+ }
127
+
128
+ if (key === 'a') {
129
+ const mutations = this.mutations;
130
+ if (!mutations) {
131
+ this.setError('Local auth mutations are not available in this session.');
132
+ return true;
133
+ }
134
+ this.usernameEntry = '';
135
+ this.invalidate();
136
+ return true;
73
137
  }
74
138
 
139
+ if (key === 'd') {
140
+ if (!selected) return false;
141
+ this.deleteConfirm = { subject: selected.username, label: selected.username };
142
+ this.invalidate();
143
+ return true;
144
+ }
145
+
146
+ if (key === 'b') {
147
+ const snapshot = this.cachedSnapshot ?? this.authManager.inspect();
148
+ if (!snapshot.bootstrapCredentialPresent) return false;
149
+ const mutations = this.mutations;
150
+ if (!mutations) {
151
+ this.setError('Local auth mutations are not available in this session.');
152
+ return true;
153
+ }
154
+ const cleared = mutations.clearBootstrapCredentialFile();
155
+ if (cleared) {
156
+ this.cachedSnapshot = null; // force a fresh inspect() on the next render
157
+ } else {
158
+ this.setError('No bootstrap credential file was present.');
159
+ }
160
+ this.invalidate();
161
+ return true;
162
+ }
163
+
164
+ // Delegate scroll/selection to ScrollableListPanel for everything else.
165
+ return super.handleInput?.(key) ?? false;
166
+ }
167
+
168
+ private handleDeleteConfirmInput(key: KeyName): boolean {
169
+ const result = handleConfirmInput(this.deleteConfirm, key);
170
+ if (result === 'confirmed') {
171
+ const username = this.deleteConfirm!.subject;
172
+ this.deleteConfirm = null;
173
+ const mutations = this.mutations;
174
+ if (mutations) {
175
+ try {
176
+ const deleted = mutations.deleteUser(username);
177
+ if (deleted) this.cachedSnapshot = null; // force a fresh inspect() on the next render
178
+ else this.setError(`Unknown local auth user: ${username}`);
179
+ } catch (error) {
180
+ this.setError(summarizeError(error));
181
+ }
182
+ } else {
183
+ this.setError('Local auth mutations are not available in this session.');
184
+ }
185
+ this.invalidate();
186
+ return true;
187
+ }
188
+ if (result === 'cancelled') {
189
+ this.deleteConfirm = null;
190
+ this.invalidate();
191
+ return true;
192
+ }
193
+ // 'absorbed' (or defensively, 'inactive' — deleteConfirm is non-null here so this never fires)
194
+ return true;
195
+ }
196
+
197
+ private handleUsernameEntryInput(key: KeyName): boolean {
198
+ if (key === 'escape') {
199
+ this.usernameEntry = null;
200
+ this.invalidate();
201
+ return true;
202
+ }
203
+ if (key === 'enter' || key === 'return') {
204
+ const username = (this.usernameEntry ?? '').trim();
205
+ this.usernameEntry = null;
206
+ if (username.length === 0) {
207
+ this.invalidate();
208
+ return true; // no-op on empty username, mirroring the masked-entry empty-buffer no-op
209
+ }
210
+ const mutations = this.mutations;
211
+ if (mutations) {
212
+ this.openMaskedEntry('add-user', username, mutations as unknown as UserAuthManager);
213
+ } else {
214
+ this.setError('Local auth mutations are not available in this session.');
215
+ this.invalidate();
216
+ }
217
+ return true;
218
+ }
219
+ if (isTextBackspace(key)) {
220
+ if (this.usernameEntry && this.usernameEntry.length > 0) {
221
+ this.usernameEntry = this.usernameEntry.slice(0, -1);
222
+ this.invalidate();
223
+ }
224
+ return true;
225
+ }
226
+ if (key.length === 1) {
227
+ this.usernameEntry = (this.usernameEntry ?? '') + key;
228
+ this.invalidate();
229
+ return true;
230
+ }
231
+ return true; // absorb everything else while username entry is active
232
+ }
233
+
234
+ private handleMaskedEntryInput(key: KeyName): boolean {
75
235
  // Masked entry is active — capture all keystrokes.
76
- const state = this.maskedState;
236
+ const state = this.maskedState!;
77
237
 
78
238
  if (key === 'escape') {
79
239
  // Cancel: discard buffer, exit masked mode without persisting.
@@ -131,13 +291,16 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
131
291
  }
132
292
 
133
293
  protected getItems(): readonly LocalAuthUser[] {
134
- return this.authManager.inspect().users;
294
+ // Reads the render-pass cache populated by render() below (single
295
+ // inspect() per render). Falls back to a fresh inspect() only when
296
+ // called before the first render (e.g. directly from a test).
297
+ return (this.cachedSnapshot ?? this.authManager.inspect()).users;
135
298
  }
136
299
 
137
300
  protected renderItem(user: LocalAuthUser, _index: number, selected: boolean, width: number): Line {
138
301
  return buildPanelListRow(width, [
139
- { text: user.username.padEnd(20), fg: C.value },
140
- { text: ` roles=${formatRoles(user.roles)}`.slice(0, Math.max(0, width - 24)), fg: C.info },
302
+ { text: fitDisplay(user.username, 20), fg: C.value },
303
+ { text: truncateDisplay(` roles=${formatRoles(user.roles)}`, Math.max(0, width - 24)), fg: C.info },
141
304
  ], C, { selected });
142
305
  }
143
306
 
@@ -146,19 +309,26 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
146
309
  }
147
310
 
148
311
  public render(width: number, height: number): Line[] {
149
- // When masked entry is active, render a dedicated prompt instead of the
150
- // normal panel content. No plaintext password appears anywhere in output.
312
+ // When masked entry or username entry is active, render a dedicated
313
+ // prompt instead of the normal panel content. No plaintext password
314
+ // appears anywhere in output.
151
315
  if (this.maskedState !== null) {
152
316
  return this.renderMaskedPrompt(width, height);
153
317
  }
318
+ if (this.usernameEntry !== null) {
319
+ return this.renderUsernameEntryPrompt(width, height);
320
+ }
154
321
 
155
322
  const intro = 'Manage local daemon and HTTP-listener auth users, bootstrap state, and active sessions.';
156
- const snapshot = this.authManager.inspect();
323
+ // Single inspect() call for this whole render pass — getItems() (called
324
+ // both directly below and again inside renderList()'s internal
325
+ // getVisibleItems()) reads from this cache instead of re-inspecting.
326
+ this.cachedSnapshot = this.authManager.inspect();
327
+ const snapshot = this.cachedSnapshot;
157
328
  const users = this.getItems();
158
329
 
159
330
  const issueMessages: string[] = [];
160
- if (snapshot.bootstrapCredentialPresent) issueMessages.push('Bootstrap credential file still exists and should be cleared after password rotation.');
161
- if (snapshot.userCount <= 1) issueMessages.push('Only one local auth user is configured.');
331
+ if (snapshot.bootstrapCredentialPresent) issueMessages.push('Bootstrap credential file still exists press b to clear it after password rotation.');
162
332
  if (snapshot.sessionCount === 0) issueMessages.push('No active local auth sessions are currently tracked.');
163
333
 
164
334
  const headerLines: Line[] = [
@@ -171,12 +341,12 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
171
341
  [' bootstrap ', C.label],
172
342
  [snapshot.bootstrapCredentialPresent ? 'present' : 'cleared', snapshot.bootstrapCredentialPresent ? C.warn : C.good],
173
343
  ]),
174
- buildPanelLine(width, [[' user store ', C.label], [snapshot.userStorePath.slice(0, Math.max(0, width - 13)), C.dim]]),
175
- buildPanelLine(width, [[' bootstrap file ', C.label], [snapshot.bootstrapCredentialPath.slice(0, Math.max(0, width - 18)), C.dim]]),
344
+ buildPanelLine(width, [[' user store ', C.label], [truncateDisplay(snapshot.userStorePath, Math.max(0, width - 13)), C.dim]]),
345
+ buildPanelLine(width, [[' bootstrap file ', C.label], [truncateDisplay(snapshot.bootstrapCredentialPath, Math.max(0, width - 18)), C.dim]]),
176
346
  ...(issueMessages.length > 0
177
- ? issueMessages.map((issue) => buildPanelLine(width, [[` issue: ${issue}`.slice(0, Math.max(0, width)), C.warn]]))
347
+ ? issueMessages.map((issue) => buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.warn]]))
178
348
  : [buildPanelLine(width, [[' local auth posture looks healthy.', C.good]])]),
179
- buildGuidanceLine(width, '/auth local rotate-password <user>', 'open masked password entry for the selected user (no plaintext in history)', C),
349
+ buildGuidanceLine(width, 'p / a / d', 'rotate password, add user, or delete the selected user via masked in-panel entry', C),
180
350
  ], C),
181
351
  ];
182
352
 
@@ -185,44 +355,92 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
185
355
  title: 'Local Auth Control Room',
186
356
  intro,
187
357
  sections: [{ lines: headerLines }],
358
+ footerLines: [this.buildHintsLine(width, null)],
188
359
  palette: C,
189
360
  });
190
361
  while (workspace.length < height) workspace.push(createEmptyLine(width));
191
- return workspace;
362
+ return workspace.slice(0, height);
192
363
  }
193
364
 
194
365
  this.clampSelection();
195
- const selected = users[this.selectedIndex];
366
+ const selected = this.getSelectedItem();
196
367
 
197
368
  const footerLines: Line[] = [];
198
369
  if (selected) {
370
+ const deleteActive = this.deleteConfirm?.subject === selected.username;
199
371
  footerLines.push(
200
372
  ...buildDetailBlock(width, 'Selected user', [
201
- buildPanelLine(width, [[' username ', C.label], [selected.username, C.value], [' roles ', C.label], [formatRoles(selected.roles).slice(0, Math.max(0, width - 23)), C.info]]),
202
- buildPanelLine(width, [[` next: /auth local rotate-password ${selected.username}`.slice(0, Math.max(0, width)), C.dim]]),
203
- buildPanelLine(width, [[` next: /auth local delete-user ${selected.username}`.slice(0, Math.max(0, width)), C.dim]]),
373
+ buildPanelLine(width, [[' username ', C.label], [selected.username, C.value], [' roles ', C.label], [truncateDisplay(formatRoles(selected.roles), Math.max(0, width - 23)), C.info]]),
374
+ deleteActive
375
+ ? buildPanelLine(width, [[` Delete ${selected.username}? Press y or Enter to confirm, n or Esc to cancel.`, C.warn]])
376
+ : buildPanelLine(width, [[' p: rotate password a: add user d: delete user', C.dim]]),
204
377
  ], C),
205
378
  );
206
379
  }
207
380
 
208
381
  if (snapshot.sessions.length > 0) {
382
+ footerLines.push(buildPanelLine(width, [[` Active sessions (${snapshot.sessions.length})`, C.label]]));
209
383
  footerLines.push(
210
384
  ...snapshot.sessions.slice(0, 8).map((session) => buildPanelLine(width, [
211
385
  [' ', C.label],
212
- [session.username.padEnd(18), C.value],
213
- [` expires ${new Date(session.expiresAt).toLocaleString()}`.slice(0, Math.max(0, width - 20)), C.dim],
386
+ [fitDisplay(session.username, 18), C.value],
387
+ [truncateDisplay(` expires ${new Date(session.expiresAt).toLocaleString()}`, Math.max(0, width - 20)), C.dim],
214
388
  ])),
215
389
  );
216
390
  }
217
- footerLines.push(buildPanelLine(width, [[' /auth local review /auth local add-user <user> /auth local rotate-password <user> (omit password for masked entry) ', C.dim]]));
218
391
 
219
392
  return this.renderList(width, height, {
220
393
  title: 'Local Auth Control Room',
221
394
  header: headerLines,
222
395
  footer: footerLines,
396
+ hints: this.buildHints(selected ?? null, snapshot.bootstrapCredentialPresent),
223
397
  });
224
398
  }
225
399
 
400
+ /** Footer keyboard hints, adapted to the current state — confirm-pending
401
+ * vs. normal browsing, and whether there's a bootstrap credential to clear. */
402
+ private buildHints(
403
+ selected: LocalAuthUser | null,
404
+ bootstrapCredentialPresent: boolean,
405
+ ): ReadonlyArray<{ keys: string; label: string }> {
406
+ if (this.deleteConfirm) {
407
+ return [
408
+ { keys: 'y/Enter', label: 'confirm delete' },
409
+ { keys: 'n/Esc', label: 'cancel' },
410
+ ];
411
+ }
412
+ const hints: Array<{ keys: string; label: string }> = [{ keys: '↑/↓', label: 'select user' }];
413
+ if (selected) {
414
+ hints.push({ keys: 'p', label: 'rotate password' }, { keys: 'd', label: 'delete user' });
415
+ }
416
+ hints.push({ keys: 'a', label: 'add user' });
417
+ if (bootstrapCredentialPresent) hints.push({ keys: 'b', label: 'clear bootstrap credential' });
418
+ return hints;
419
+ }
420
+
421
+ private buildHintsLine(width: number, selected: LocalAuthUser | null): Line {
422
+ return buildKeyboardHints(width, this.buildHints(selected, false), C);
423
+ }
424
+
425
+ private renderUsernameEntryPrompt(width: number, height: number): Line[] {
426
+ const draft = this.usernameEntry ?? '';
427
+ const promptLines: Line[] = [
428
+ buildPanelLine(width, [[' Add local auth user — enter a username, then set a masked password.', C.value]]),
429
+ buildPanelLine(width, [['', C.label]]),
430
+ buildPanelLine(width, [[' Username ', C.label], [`${draft}█`, C.info]]),
431
+ buildPanelLine(width, [['', C.label]]),
432
+ buildPanelLine(width, [[' [Enter] Continue to password entry [Esc] Cancel [Backspace] Delete char', C.dim]]),
433
+ ];
434
+ const workspace = buildPanelWorkspace(width, height, {
435
+ title: 'Local Auth — Add User',
436
+ intro: 'Type the new username and press Enter to continue to masked password entry.',
437
+ sections: [{ lines: promptLines }],
438
+ palette: C,
439
+ });
440
+ while (workspace.length < height) workspace.push(createEmptyLine(width));
441
+ return workspace.slice(0, height);
442
+ }
443
+
226
444
  private renderMaskedPrompt(width: number, height: number): Line[] {
227
445
  const state = this.maskedState!;
228
446
  const actionLabel = state.kind === 'add-user' ? 'Add user' : 'Rotate password';
@@ -233,7 +451,7 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
233
451
  const promptLines: Line[] = [
234
452
  buildPanelLine(width, [[` ${actionLabel}: ${state.username}`, C.value]]),
235
453
  buildPanelLine(width, [['', C.label]]),
236
- buildPanelLine(width, [[' Password ', C.label], [maskedDisplay.slice(0, Math.max(0, width - 12)), C.info]]),
454
+ buildPanelLine(width, [[' Password ', C.label], [truncateDisplay(maskedDisplay, Math.max(0, width - 12)), C.info]]),
237
455
  buildPanelLine(width, [['', C.label]]),
238
456
  buildPanelLine(width, [[' [Enter] Confirm [Esc] Cancel [Backspace] Delete char', C.dim]]),
239
457
  ];