@pellux/goodvibes-tui 0.28.0 → 1.0.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 (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -5,6 +5,7 @@ import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
5
  import {
6
6
  buildDetailBlock,
7
7
  buildGuidanceLine,
8
+ buildKeyboardHints,
8
9
  buildPanelListRow,
9
10
  buildPanelLine,
10
11
  buildSummaryBlock,
@@ -17,14 +18,11 @@ import type { LocalAuthInspectionQuery } from '../runtime/ui-service-queries.ts'
17
18
  import type { KeyName } from './types.ts';
18
19
  import { isTextBackspace } from '../input/delete-key-policy.ts';
19
20
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
21
+ import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
20
22
 
21
- const C = {
22
- ...DEFAULT_PANEL_PALETTE,
23
- info: '#38bdf8',
24
- warn: '#eab308',
25
- error: '#ef4444',
26
- selectBg: '#1e293b',
27
- } 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;
28
26
 
29
27
  function formatRoles(roles: readonly string[]): string {
30
28
  return roles.length > 0 ? roles.join(', ') : '(none)';
@@ -42,16 +40,47 @@ interface MaskedEntryState {
42
40
  buffer: string;
43
41
  }
44
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
+
45
54
  export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
46
55
  private readonly authManager: LocalAuthInspectionQuery;
47
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;
48
64
 
49
65
  public constructor(authManager: LocalAuthInspectionQuery) {
50
- super('local-auth', 'Local Auth', 'U', 'monitoring');
66
+ super('local-auth', 'Local Auth', 'U', 'security-policy');
51
67
  this.showSelectionGutter = true; // I5: non-color selection affordance
52
68
  this.authManager = authManager;
53
69
  }
54
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
+
55
84
  /**
56
85
  * Activate masked password-entry mode for the given operation.
57
86
  * The panel's handleInput will capture keystrokes into a private buffer;
@@ -68,13 +97,143 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
68
97
  }
69
98
 
70
99
  public override handleInput(key: KeyName): boolean {
71
- if (this.maskedState === null) {
72
- // Delegate scroll/selection to ScrollableListPanel when not in masked mode.
73
- 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;
74
137
  }
75
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 {
76
235
  // Masked entry is active — capture all keystrokes.
77
- const state = this.maskedState;
236
+ const state = this.maskedState!;
78
237
 
79
238
  if (key === 'escape') {
80
239
  // Cancel: discard buffer, exit masked mode without persisting.
@@ -132,7 +291,10 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
132
291
  }
133
292
 
134
293
  protected getItems(): readonly LocalAuthUser[] {
135
- 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;
136
298
  }
137
299
 
138
300
  protected renderItem(user: LocalAuthUser, _index: number, selected: boolean, width: number): Line {
@@ -147,19 +309,26 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
147
309
  }
148
310
 
149
311
  public render(width: number, height: number): Line[] {
150
- // When masked entry is active, render a dedicated prompt instead of the
151
- // 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.
152
315
  if (this.maskedState !== null) {
153
316
  return this.renderMaskedPrompt(width, height);
154
317
  }
318
+ if (this.usernameEntry !== null) {
319
+ return this.renderUsernameEntryPrompt(width, height);
320
+ }
155
321
 
156
322
  const intro = 'Manage local daemon and HTTP-listener auth users, bootstrap state, and active sessions.';
157
- 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;
158
328
  const users = this.getItems();
159
329
 
160
330
  const issueMessages: string[] = [];
161
- if (snapshot.bootstrapCredentialPresent) issueMessages.push('Bootstrap credential file still exists and should be cleared after password rotation.');
162
- 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.');
163
332
  if (snapshot.sessionCount === 0) issueMessages.push('No active local auth sessions are currently tracked.');
164
333
 
165
334
  const headerLines: Line[] = [
@@ -177,7 +346,7 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
177
346
  ...(issueMessages.length > 0
178
347
  ? issueMessages.map((issue) => buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.warn]]))
179
348
  : [buildPanelLine(width, [[' local auth posture looks healthy.', C.good]])]),
180
- 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),
181
350
  ], C),
182
351
  ];
183
352
 
@@ -186,22 +355,25 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
186
355
  title: 'Local Auth Control Room',
187
356
  intro,
188
357
  sections: [{ lines: headerLines }],
358
+ footerLines: [this.buildHintsLine(width, null)],
189
359
  palette: C,
190
360
  });
191
361
  while (workspace.length < height) workspace.push(createEmptyLine(width));
192
- return workspace;
362
+ return workspace.slice(0, height);
193
363
  }
194
364
 
195
365
  this.clampSelection();
196
- const selected = users[this.selectedIndex];
366
+ const selected = this.getSelectedItem();
197
367
 
198
368
  const footerLines: Line[] = [];
199
369
  if (selected) {
370
+ const deleteActive = this.deleteConfirm?.subject === selected.username;
200
371
  footerLines.push(
201
372
  ...buildDetailBlock(width, 'Selected user', [
202
373
  buildPanelLine(width, [[' username ', C.label], [selected.username, C.value], [' roles ', C.label], [truncateDisplay(formatRoles(selected.roles), Math.max(0, width - 23)), C.info]]),
203
- buildPanelLine(width, [[truncateDisplay(` next: /auth local rotate-password ${selected.username}`, width), C.dim]]),
204
- buildPanelLine(width, [[truncateDisplay(` next: /auth local delete-user ${selected.username}`, width), C.dim]]),
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]]),
205
377
  ], C),
206
378
  );
207
379
  }
@@ -216,19 +388,59 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
216
388
  ])),
217
389
  );
218
390
  }
219
- footerLines.push(buildPanelLine(width, [[' /auth local review /auth local add-user <user> /auth local rotate-password <user> (omit password for masked entry) ', C.dim]]));
220
391
 
221
392
  return this.renderList(width, height, {
222
393
  title: 'Local Auth Control Room',
223
394
  header: headerLines,
224
395
  footer: footerLines,
225
- hints: [
226
- { keys: '↑/↓', label: 'select user' },
227
- { keys: '/auth local rotate-password', label: 'masked entry' },
228
- ],
396
+ hints: this.buildHints(selected ?? null, snapshot.bootstrapCredentialPresent),
229
397
  });
230
398
  }
231
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
+
232
444
  private renderMaskedPrompt(width: number, height: number): Line[] {
233
445
  const state = this.maskedState!;
234
446
  const actionLabel = state.kind === 'add-user' ? 'Add user' : 'Rotate password';