@pellux/goodvibes-tui 0.20.3 → 0.21.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 (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  39. package/src/input/commands/recall-review.ts +26 -2
  40. package/src/input/commands/services-runtime.ts +2 -2
  41. package/src/input/commands/session-workflow.ts +3 -3
  42. package/src/input/commands/share-runtime.ts +99 -12
  43. package/src/input/commands/tts-runtime.ts +30 -4
  44. package/src/input/commands.ts +2 -2
  45. package/src/input/delete-key-policy.ts +46 -0
  46. package/src/input/feed-context-factory.ts +2 -0
  47. package/src/input/handler-feed.ts +3 -0
  48. package/src/input/handler-interactions.ts +2 -15
  49. package/src/input/handler-modal-routes.ts +91 -12
  50. package/src/input/handler-modal-token-routes.ts +3 -0
  51. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  52. package/src/input/handler-onboarding.ts +55 -69
  53. package/src/input/handler-types.ts +163 -0
  54. package/src/input/handler.ts +5 -2
  55. package/src/input/input-history.ts +76 -6
  56. package/src/input/model-picker-filter.ts +265 -0
  57. package/src/input/model-picker-items.ts +208 -0
  58. package/src/input/model-picker.ts +92 -325
  59. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  60. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  61. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  62. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  63. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  64. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  65. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  66. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  67. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  68. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  69. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  70. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  71. package/src/input/settings-modal-data.ts +304 -0
  72. package/src/input/settings-modal-mutations.ts +154 -0
  73. package/src/input/settings-modal.ts +182 -220
  74. package/src/main.ts +57 -57
  75. package/src/panels/builtin/agent.ts +4 -1
  76. package/src/panels/builtin/development.ts +4 -1
  77. package/src/panels/confirm-state.ts +27 -12
  78. package/src/panels/cost-tracker-panel.ts +23 -67
  79. package/src/panels/eval-panel.ts +10 -9
  80. package/src/panels/knowledge-panel.ts +3 -5
  81. package/src/panels/local-auth-panel.ts +124 -4
  82. package/src/panels/project-planning-panel.ts +42 -4
  83. package/src/panels/search-focus.ts +11 -5
  84. package/src/panels/subscription-panel.ts +33 -25
  85. package/src/panels/types.ts +28 -1
  86. package/src/panels/wrfc-panel.ts +224 -41
  87. package/src/renderer/agent-detail-modal.ts +11 -10
  88. package/src/renderer/code-block.ts +10 -2
  89. package/src/renderer/compositor.ts +18 -4
  90. package/src/renderer/context-inspector.ts +1 -5
  91. package/src/renderer/diff.ts +94 -21
  92. package/src/renderer/markdown.ts +29 -13
  93. package/src/renderer/settings-modal-helpers.ts +1 -1
  94. package/src/renderer/settings-modal.ts +77 -8
  95. package/src/renderer/syntax-highlighter.ts +10 -3
  96. package/src/renderer/term-caps.ts +318 -0
  97. package/src/renderer/theme.ts +158 -0
  98. package/src/renderer/tool-call.ts +12 -2
  99. package/src/renderer/ui-factory.ts +50 -6
  100. package/src/runtime/bootstrap-command-context.ts +1 -0
  101. package/src/runtime/bootstrap-command-parts.ts +14 -0
  102. package/src/runtime/bootstrap-core.ts +121 -13
  103. package/src/runtime/bootstrap.ts +2 -0
  104. package/src/runtime/onboarding/apply.ts +4 -6
  105. package/src/runtime/onboarding/index.ts +1 -0
  106. package/src/runtime/onboarding/markers.ts +42 -49
  107. package/src/runtime/onboarding/progress.ts +148 -0
  108. package/src/runtime/onboarding/state.ts +133 -55
  109. package/src/runtime/onboarding/types.ts +20 -0
  110. package/src/runtime/services.ts +21 -0
  111. package/src/runtime/wrfc-persistence.ts +237 -0
  112. package/src/shell/blocking-input.ts +20 -5
  113. package/src/tools/wrfc-agent-guard.ts +64 -3
  114. package/src/utils/format-elapsed.ts +30 -0
  115. package/src/utils/terminal-width.ts +45 -0
  116. package/src/version.ts +1 -1
  117. package/src/work-plans/work-plan-store.ts +4 -6
  118. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -11,8 +11,11 @@ import {
11
11
  DEFAULT_PANEL_PALETTE,
12
12
  type PanelPalette,
13
13
  } from './polish.ts';
14
- import type { LocalAuthSnapshot } from '@pellux/goodvibes-sdk/platform/security';
14
+ import type { LocalAuthSnapshot, UserAuthManager } from '@pellux/goodvibes-sdk/platform/security';
15
15
  import type { LocalAuthInspectionQuery } from '../runtime/ui-service-queries.ts';
16
+ import type { KeyName } from './types.ts';
17
+ import { isTextBackspace } from '../input/delete-key-policy.ts';
18
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
16
19
 
17
20
  const C = {
18
21
  ...DEFAULT_PANEL_PALETTE,
@@ -28,8 +31,19 @@ function formatRoles(roles: readonly string[]): string {
28
31
 
29
32
  type LocalAuthUser = LocalAuthSnapshot['users'][number];
30
33
 
34
+ /** Action kind for the masked password entry mode. */
35
+ export type MaskedEntryKind = 'add-user' | 'rotate-password';
36
+
37
+ interface MaskedEntryState {
38
+ readonly kind: MaskedEntryKind;
39
+ readonly username: string;
40
+ readonly auth: UserAuthManager;
41
+ buffer: string;
42
+ }
43
+
31
44
  export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
32
45
  private readonly authManager: LocalAuthInspectionQuery;
46
+ private maskedState: MaskedEntryState | null = null;
33
47
 
34
48
  public constructor(authManager: LocalAuthInspectionQuery) {
35
49
  super('local-auth', 'Local Auth', 'U', 'monitoring');
@@ -37,6 +51,81 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
37
51
  this.authManager = authManager;
38
52
  }
39
53
 
54
+ /**
55
+ * Activate masked password-entry mode for the given operation.
56
+ * The panel's handleInput will capture keystrokes into a private buffer;
57
+ * no plaintext is ever recorded in input history, transcript, or logs.
58
+ */
59
+ public openMaskedEntry(kind: MaskedEntryKind, username: string, auth: UserAuthManager): void {
60
+ this.maskedState = { kind, username, auth, buffer: '' };
61
+ this.invalidate();
62
+ }
63
+
64
+ /** Returns true when the panel is in masked-entry mode. */
65
+ public get isMaskedEntryActive(): boolean {
66
+ return this.maskedState !== null;
67
+ }
68
+
69
+ 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;
73
+ }
74
+
75
+ // Masked entry is active — capture all keystrokes.
76
+ const state = this.maskedState;
77
+
78
+ if (key === 'escape') {
79
+ // Cancel: discard buffer, exit masked mode without persisting.
80
+ this.maskedState = null;
81
+ this.invalidate();
82
+ return true;
83
+ }
84
+
85
+ if (key === 'enter' || key === 'return') {
86
+ // Submit: call the auth API if a non-empty password was entered.
87
+ if (state.buffer.length === 0) {
88
+ return true; // no-op — require at least one character
89
+ }
90
+ const password = state.buffer;
91
+ const { kind, username, auth } = state;
92
+ // Clear the mutable state *before* the auth call so the secret
93
+ // never lingers in the buffer after an exception.
94
+ this.maskedState = null;
95
+ try {
96
+ if (kind === 'add-user') {
97
+ auth.addUser(username, password, ['admin']);
98
+ } else {
99
+ auth.rotatePassword(username, password);
100
+ }
101
+ } catch (_error) {
102
+ // Surface errors via the panel's error facility rather than logging.
103
+ this.setError(summarizeError(_error));
104
+ }
105
+ this.invalidate();
106
+ return true;
107
+ }
108
+
109
+ if (isTextBackspace(key)) {
110
+ // Remove last character (Ink 6.8.0: raw-stdin raw 'backspace' only).
111
+ if (state.buffer.length > 0) {
112
+ state.buffer = state.buffer.slice(0, -1);
113
+ this.invalidate();
114
+ }
115
+ return true;
116
+ }
117
+
118
+ // Single printable character: append to buffer.
119
+ if (key.length === 1) {
120
+ state.buffer += key;
121
+ this.invalidate();
122
+ return true;
123
+ }
124
+
125
+ // All other named keys are consumed (ignored) while masked mode is active.
126
+ return true;
127
+ }
128
+
40
129
  protected override getPalette(): PanelPalette {
41
130
  return C;
42
131
  }
@@ -57,6 +146,12 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
57
146
  }
58
147
 
59
148
  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.
151
+ if (this.maskedState !== null) {
152
+ return this.renderMaskedPrompt(width, height);
153
+ }
154
+
60
155
  const intro = 'Manage local daemon and HTTP-listener auth users, bootstrap state, and active sessions.';
61
156
  const snapshot = this.authManager.inspect();
62
157
  const users = this.getItems();
@@ -81,7 +176,7 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
81
176
  ...(issueMessages.length > 0
82
177
  ? issueMessages.map((issue) => buildPanelLine(width, [[` issue: ${issue}`.slice(0, Math.max(0, width)), C.warn]]))
83
178
  : [buildPanelLine(width, [[' local auth posture looks healthy.', C.good]])]),
84
- buildGuidanceLine(width, '/auth local rotate-password <user> <password>', 'rotate bootstrap/default credentials and revoke older sessions as needed', C),
179
+ buildGuidanceLine(width, '/auth local rotate-password <user>', 'open masked password entry for the selected user (no plaintext in history)', C),
85
180
  ], C),
86
181
  ];
87
182
 
@@ -104,7 +199,7 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
104
199
  footerLines.push(
105
200
  ...buildDetailBlock(width, 'Selected user', [
106
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]]),
107
- buildPanelLine(width, [[` next: /auth local rotate-password ${selected.username} <password>`.slice(0, Math.max(0, width)), C.dim]]),
202
+ buildPanelLine(width, [[` next: /auth local rotate-password ${selected.username}`.slice(0, Math.max(0, width)), C.dim]]),
108
203
  buildPanelLine(width, [[` next: /auth local delete-user ${selected.username}`.slice(0, Math.max(0, width)), C.dim]]),
109
204
  ], C),
110
205
  );
@@ -119,7 +214,7 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
119
214
  ])),
120
215
  );
121
216
  }
122
- footerLines.push(buildPanelLine(width, [[' /auth local review /auth local add-user /auth local rotate-password /auth local revoke-session ', C.dim]]));
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]]));
123
218
 
124
219
  return this.renderList(width, height, {
125
220
  title: 'Local Auth Control Room',
@@ -127,4 +222,29 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
127
222
  footer: footerLines,
128
223
  });
129
224
  }
225
+
226
+ private renderMaskedPrompt(width: number, height: number): Line[] {
227
+ const state = this.maskedState!;
228
+ const actionLabel = state.kind === 'add-user' ? 'Add user' : 'Rotate password';
229
+ const dots = '•'.repeat(Math.min(32, state.buffer.length));
230
+ const cursor = '█'; // block cursor
231
+ const maskedDisplay = state.buffer.length > 0 ? `${dots}${cursor}` : cursor;
232
+
233
+ const promptLines: Line[] = [
234
+ buildPanelLine(width, [[` ${actionLabel}: ${state.username}`, C.value]]),
235
+ buildPanelLine(width, [['', C.label]]),
236
+ buildPanelLine(width, [[' Password ', C.label], [maskedDisplay.slice(0, Math.max(0, width - 12)), C.info]]),
237
+ buildPanelLine(width, [['', C.label]]),
238
+ buildPanelLine(width, [[' [Enter] Confirm [Esc] Cancel [Backspace] Delete char', C.dim]]),
239
+ ];
240
+
241
+ const workspace = buildPanelWorkspace(width, height, {
242
+ title: 'Local Auth — Password Entry',
243
+ intro: `Type a password for ${state.username}. The value is never echoed in plaintext or stored in history.`,
244
+ sections: [{ lines: promptLines }],
245
+ palette: C,
246
+ });
247
+ while (workspace.length < height) workspace.push(createEmptyLine(width));
248
+ return workspace;
249
+ }
130
250
  }
@@ -9,6 +9,8 @@ import type {
9
9
  } from '@pellux/goodvibes-sdk/platform/knowledge';
10
10
  import type { Line } from '../types/grid.ts';
11
11
  import { BasePanel } from './base-panel.ts';
12
+ import { handleConfirmInput, renderConfirmLines, type ConfirmState } from './confirm-state.ts';
13
+ import { isTextBackspace, isTextForwardDelete } from '../input/delete-key-policy.ts';
12
14
  import {
13
15
  buildBodyText,
14
16
  buildEmptyState,
@@ -69,6 +71,8 @@ export class ProjectPlanningPanel extends BasePanel {
69
71
  private scrollOffset = 0;
70
72
  private selectedActionIndex = 0;
71
73
  private draftAnswer = '';
74
+ // Pending confirmation for Delete (clear draft). Null when inactive.
75
+ private clearDraftConfirm: ConfirmState<'clear-draft'> | null = null;
72
76
 
73
77
  public constructor(options: ProjectPlanningPanelOptions) {
74
78
  super('project-planning', 'Planning', 'P', 'agent');
@@ -86,6 +90,26 @@ export class ProjectPlanningPanel extends BasePanel {
86
90
 
87
91
  public handleInput(key: string): boolean {
88
92
  if (this.lastError !== null) this.clearError();
93
+
94
+ // ConfirmState gate: Delete (clear draft) requires y/n confirmation.
95
+ // handleConfirmInput absorbs all keys while a confirmation is pending.
96
+ const confirmResult = handleConfirmInput(this.clearDraftConfirm, key);
97
+ if (confirmResult === 'confirmed') {
98
+ this.draftAnswer = '';
99
+ this.clearDraftConfirm = null;
100
+ this.markDirty();
101
+ return true;
102
+ }
103
+ if (confirmResult === 'cancelled') {
104
+ this.clearDraftConfirm = null;
105
+ this.markDirty();
106
+ return true;
107
+ }
108
+ if (confirmResult === 'absorbed') {
109
+ return true;
110
+ }
111
+ // confirmResult === 'inactive': proceed with normal dispatch.
112
+
89
113
  const question = this.getCurrentQuestion();
90
114
  if (question) {
91
115
  const actions = this.getAnswerActions(question);
@@ -104,13 +128,15 @@ export class ProjectPlanningPanel extends BasePanel {
104
128
  this.submitSelectedAction(question, actions);
105
129
  return true;
106
130
  }
107
- if (key === 'backspace') {
131
+ if (isTextBackspace(key)) {
108
132
  this.draftAnswer = this.draftAnswer.slice(0, -1);
109
133
  this.markDirty();
110
134
  return true;
111
135
  }
112
- if (key === 'delete') {
113
- this.draftAnswer = '';
136
+ // 'delete' opens the clear-draft confirmation gate (per delete-key policy).
137
+ // The draft is not wiped until the user confirms with y/Enter.
138
+ if (isTextForwardDelete(key)) {
139
+ this.clearDraftConfirm = { subject: 'clear-draft', label: 'draft answer' };
114
140
  this.markDirty();
115
141
  return true;
116
142
  }
@@ -244,8 +270,10 @@ export class ProjectPlanningPanel extends BasePanel {
244
270
  [' choose answer ', C.dim],
245
271
  ['type', C.info],
246
272
  [' draft ', C.dim],
247
- ['Backspace/Delete', C.info],
273
+ ['Backspace', C.info],
248
274
  [' edit ', C.dim],
275
+ ['Del', C.info],
276
+ [' clear draft ', C.dim],
249
277
  ['Enter', C.info],
250
278
  [' submit Esc prompt focus Ctrl+X close panel', C.dim],
251
279
  ]),
@@ -284,6 +312,16 @@ export class ProjectPlanningPanel extends BasePanel {
284
312
  private buildQuestionSection(width: number, question: ProjectPlanningQuestion): RenderedPlanningSection {
285
313
  const actions = this.getAnswerActions(question);
286
314
  this.selectedActionIndex = this.clampActionIndex(actions.length);
315
+ // When a clear-draft confirmation is pending, show the confirm prompt
316
+ // inline above the draft line instead of the normal content.
317
+ if (this.clearDraftConfirm) {
318
+ const confirmLines = renderConfirmLines(width, this.clearDraftConfirm);
319
+ const lines: Line[] = [
320
+ ...buildBodyText(width, question.prompt, C, C.planning),
321
+ ...confirmLines,
322
+ ];
323
+ return { title: 'Answer Current Question', lines, selectedLineIndex: undefined };
324
+ }
287
325
  const lines: Line[] = [
288
326
  ...buildBodyText(width, question.prompt, C, C.planning),
289
327
  ];
@@ -1,3 +1,5 @@
1
+ import { isTextBackspace } from '../input/delete-key-policy.ts';
2
+
1
3
  export type PanelSearchFocusTransition = 'focus-search' | 'focus-list' | null;
2
4
 
3
5
  export function getPanelSearchFocusTransition(
@@ -6,25 +8,29 @@ export function getPanelSearchFocusTransition(
6
8
  ): PanelSearchFocusTransition {
7
9
  const focusKeys = options.focusKeys ?? ['/'];
8
10
  if (focusKeys.includes(key)) return 'focus-search';
9
- if ((key === 'up' || key === 'ArrowUp') && options.selectedIndex <= 0) {
11
+ if (key === 'up' && options.selectedIndex <= 0) {
10
12
  return 'focus-search';
11
13
  }
12
- if ((key === 'down' || key === 'ArrowDown') && options.itemCount > 0) {
14
+ if (key === 'down' && options.itemCount > 0) {
13
15
  return 'focus-list';
14
16
  }
15
17
  return null;
16
18
  }
17
19
 
20
+ // Panel search filters are end-anchored (no moveable cursor).
21
+ // Per the delete-key policy (src/input/delete-key-policy.ts):
22
+ // 'backspace' removes the last character.
23
+ // 'delete' is a no-op — there is no cursor, so forward-delete is meaningless.
18
24
  export function isPanelSearchBackspace(key: string): boolean {
19
- return key === 'backspace' || key === 'delete' || key === 'Backspace' || key === 'Delete';
25
+ return isTextBackspace(key);
20
26
  }
21
27
 
22
28
  export function isPanelSearchCancel(key: string): boolean {
23
- return key === 'escape' || key === 'Escape';
29
+ return key === 'escape';
24
30
  }
25
31
 
26
32
  export function isPanelSearchCommit(key: string): boolean {
27
- return key === 'return' || key === 'enter' || key === 'Enter';
33
+ return key === 'return' || key === 'enter';
28
34
  }
29
35
 
30
36
  export function isPanelSearchPrintable(key: string): boolean {
@@ -1,8 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
+ import type { KeyName } from './types.ts';
4
5
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
5
6
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
7
+ import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
6
8
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
7
9
  import {
8
10
  buildEmptyState,
@@ -57,7 +59,8 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
57
59
  private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
58
60
  private readonly subscriptionManager: SubscriptionAccessQuery;
59
61
  private rows: SubscriptionRow[] = [];
60
- private logoutConfirmationTarget: string | null = null;
62
+ /** Pending logout confirmation uses project-standard ConfirmState contract. */
63
+ private confirm: ConfirmState<string> | null = null;
61
64
 
62
65
  public constructor(
63
66
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
@@ -98,42 +101,47 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
98
101
  ], C, { selected, selectedBg: C.selectedBg });
99
102
  }
100
103
 
101
- public handleInput(key: string): boolean {
104
+ public handleInput(key: KeyName): boolean {
105
+ // Project-standard confirm contract: Enter/y confirm; n/Esc cancel; other absorbed.
106
+ const confirmResult = handleConfirmInput(this.confirm, key);
107
+ if (confirmResult === 'confirmed') {
108
+ const provider = this.confirm!.subject;
109
+ this.confirm = null;
110
+ this.subscriptionManager.logout(provider);
111
+ this.refresh();
112
+ this.markDirty();
113
+ return true;
114
+ }
115
+ if (confirmResult === 'cancelled') {
116
+ this.confirm = null;
117
+ this.markDirty();
118
+ return true;
119
+ }
120
+ if (confirmResult === 'absorbed') return true;
121
+
102
122
  if (this.rows.length === 0) return false;
103
123
  const selected = this.rows[this.selectedIndex] ?? null;
104
- if (key === 'ArrowUp' || key === 'k') {
124
+ if (key === 'up' || key === 'k') {
105
125
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
106
- this.logoutConfirmationTarget = null;
126
+ this.confirm = null;
107
127
  this.markDirty();
108
128
  return true;
109
129
  }
110
- if (key === 'ArrowDown' || key === 'j') {
130
+ if (key === 'down' || key === 'j') {
111
131
  this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + 1);
112
- this.logoutConfirmationTarget = null;
132
+ this.confirm = null;
113
133
  this.markDirty();
114
134
  return true;
115
135
  }
116
- if (key === 'enter' || key === 'x') {
136
+ if (key === 'enter' || key === 'return') {
117
137
  if (!selected?.subscription) return false;
118
- if (this.logoutConfirmationTarget === null || this.logoutConfirmationTarget !== selected.provider) {
119
- this.logoutConfirmationTarget = selected.provider;
120
- this.markDirty();
121
- return true;
122
- }
123
- this.subscriptionManager.logout(selected.provider);
124
- this.logoutConfirmationTarget = null;
125
- this.refresh();
126
- this.markDirty();
127
- return true;
128
- }
129
- if ((key === 'n' || key === 'escape') && this.logoutConfirmationTarget) {
130
- this.logoutConfirmationTarget = null;
138
+ this.confirm = { subject: selected.provider, label: selected.provider };
131
139
  this.markDirty();
132
140
  return true;
133
141
  }
134
142
  if (key === 'r') {
135
143
  this.refresh();
136
- this.logoutConfirmationTarget = null;
144
+ this.confirm = null;
137
145
  this.markDirty();
138
146
  return true;
139
147
  }
@@ -206,7 +214,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
206
214
  sections: [{ lines: [...summaryLines, ...emptyLines] }],
207
215
  footerLines: [
208
216
  buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
209
- buildPanelLine(width, [[' Up/Down move Enter/X sign out selected provider r refresh', C.dim]]),
217
+ buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
210
218
  ],
211
219
  palette: C,
212
220
  });
@@ -236,8 +244,8 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
236
244
  : 'Stored for subscription-backed flows. Ambient API-key resolution remains unchanged.'}`,
237
245
  C.dim,
238
246
  ]]));
239
- if (this.logoutConfirmationTarget === selectedRow.provider) {
240
- detailRows.push(buildPanelLine(width, [[` Press Enter or X again to sign out ${selectedRow.provider}.`, C.warn]]));
247
+ if (this.confirm?.subject === selectedRow.provider) {
248
+ detailRows.push(buildPanelLine(width, [[` Sign out ${selectedRow.provider}? Press y or Enter to confirm, n or Esc to cancel.`, C.warn]]));
241
249
  }
242
250
  } else if (selectedRow.pending) {
243
251
  detailRows.push(buildPanelLine(width, [[' Login is pending. Finish with /subscription login <provider> finish <code>.', C.warn]]));
@@ -256,7 +264,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
256
264
  footer: [
257
265
  ...detailRows,
258
266
  buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
259
- buildPanelLine(width, [[' Up/Down move Enter/X sign out selected provider r refresh', C.dim]]),
267
+ buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
260
268
  ],
261
269
  });
262
270
  }
@@ -1,6 +1,33 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import type { ComponentResourceContract, ComponentHealthState } from '../runtime/perf/panel-contracts.ts';
3
3
 
4
+ /**
5
+ * Named logical key identifiers emitted by the input tokenizer.
6
+ * These are the ONLY key names that will appear in `handleInput` calls;
7
+ * the tokenizer never emits DOM/browser-style names like 'ArrowUp' or 'Enter'.
8
+ *
9
+ * Printable single-character input is passed through as-is; the `string & {}`
10
+ * fallback preserves that handling while making named-key completions discoverable
11
+ * in editors and keeping non-null single-character values compatible.
12
+ */
13
+ export type NamedKey =
14
+ | 'up' | 'down' | 'left' | 'right'
15
+ | 'home' | 'end' | 'pageup' | 'pagedown'
16
+ | 'insert' | 'delete' | 'backspace'
17
+ | 'enter' | 'return' | 'escape' | 'space' | 'tab'
18
+ | 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
19
+ | 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12';
20
+
21
+ /**
22
+ * The full key type accepted by `Panel.handleInput`.
23
+ *
24
+ * Named keys (arrow keys, modifiers, function keys) are represented by
25
+ * lowercase `NamedKey` members. Single printable characters are passed
26
+ * verbatim via the `string & {}` escape hatch so panels can handle 'j', 'k',
27
+ * 'r', etc. without losing type safety on the named members.
28
+ */
29
+ export type KeyName = NamedKey | (string & {});
30
+
4
31
  export type PanelCategory = 'development' | 'agent' | 'monitoring' | 'session' | 'ai';
5
32
 
6
33
  export interface Panel {
@@ -35,7 +62,7 @@ export interface Panel {
35
62
  healthState?: Readonly<ComponentHealthState>;
36
63
 
37
64
  // Input (optional)
38
- handleInput?(key: string): boolean;
65
+ handleInput?(key: KeyName): boolean;
39
66
 
40
67
  // Scroll input (optional)
41
68
  // Positive delta scrolls down; negative delta scrolls up.