agentvibes 5.6.0 → 5.6.2

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 (101) hide show
  1. package/.agentvibes/config.json +3 -38
  2. package/.claude/config/audio-effects.cfg +1 -1
  3. package/.claude/config/background-music-enabled.txt +1 -1
  4. package/.claude/config/background-music-position.txt +6 -6
  5. package/.claude/github-star-reminder.txt +1 -1
  6. package/.claude/hooks/play-tts-ssh-remote.sh +119 -42
  7. package/.claude/hooks/play-tts-windows-receiver.sh +31 -0
  8. package/.claude/hooks/stop.sh +2 -27
  9. package/.claude/hooks-windows/play-tts-windows-sapi.ps1 +108 -108
  10. package/.claude/hooks-windows/play-tts.ps1 +58 -8
  11. package/.claude/piper-voices-dir.txt +1 -1
  12. package/.clawdbot/skill/README.md +326 -0
  13. package/.mcp.json +17 -27
  14. package/README.md +15 -2
  15. package/RELEASE_NOTES.md +64 -0
  16. package/bin/agent-vibes +39 -39
  17. package/package.json +1 -1
  18. package/src/bmad-detector.js +71 -71
  19. package/src/cli/list-personalities.js +110 -110
  20. package/src/cli/list-voices.js +114 -114
  21. package/src/commands/bmad-voices.js +394 -394
  22. package/src/commands/install-mcp.js +476 -476
  23. package/src/console/brand-colors.js +13 -13
  24. package/src/console/constants/personalities.js +44 -44
  25. package/src/console/modals/modal-overlay.js +247 -247
  26. package/src/console/navigation.js +5 -1
  27. package/src/console/tabs/agents-tab.js +5 -5
  28. package/src/console/tabs/help-tab.js +314 -314
  29. package/src/console/tabs/readme-tab.js +272 -272
  30. package/src/console/tabs/setup-tab.js +32 -17
  31. package/src/console/tabs/voices-tab.js +2 -2
  32. package/src/console/widgets/destroy-list.js +25 -25
  33. package/src/console/widgets/notice.js +55 -55
  34. package/src/console/widgets/personality-picker.js +213 -213
  35. package/src/console/widgets/reverb-picker.js +97 -97
  36. package/src/console/widgets/track-picker.js +1 -1
  37. package/src/i18n/de.js +202 -202
  38. package/src/i18n/es.js +202 -202
  39. package/src/i18n/fr.js +202 -202
  40. package/src/i18n/hi.js +202 -202
  41. package/src/i18n/ja.js +202 -202
  42. package/src/i18n/ko.js +202 -202
  43. package/src/i18n/pt.js +202 -202
  44. package/src/i18n/strings.js +54 -54
  45. package/src/i18n/zh-CN.js +202 -202
  46. package/src/installer/language-screen.js +31 -31
  47. package/src/installer/music-file-input.js +304 -304
  48. package/src/services/agent-voice-store.js +420 -423
  49. package/src/services/config-service.js +264 -264
  50. package/src/services/language-service.js +47 -47
  51. package/src/services/llm-provider-service.js +11 -4
  52. package/src/services/navigation-service.js +34 -10
  53. package/src/services/provider-service.js +143 -143
  54. package/src/utils/audio-duration-validator.js +298 -298
  55. package/src/utils/audio-format-validator.js +277 -277
  56. package/src/utils/dependency-checker.js +469 -469
  57. package/src/utils/file-ownership-verifier.js +358 -358
  58. package/src/utils/list-formatter.js +194 -194
  59. package/src/utils/music-file-validator.js +285 -285
  60. package/src/utils/preview-list-prompt.js +136 -136
  61. package/src/utils/secure-music-storage.js +412 -412
  62. package/.agentvibes/LITE-MODE.md +0 -236
  63. package/.agentvibes/README.md +0 -136
  64. package/.agentvibes/backup/session-start-tts.sh.20251210_212814 +0 -141
  65. package/.agentvibes/backups/agents/analyst_20260204_144958.md +0 -78
  66. package/.agentvibes/backups/agents/architect_20260204_144958.md +0 -72
  67. package/.agentvibes/backups/agents/dev_20260204_144958.md +0 -74
  68. package/.agentvibes/backups/agents/pm_20260204_144958.md +0 -72
  69. package/.agentvibes/backups/agents/quick-flow-solo-dev_20260204_144958.md +0 -64
  70. package/.agentvibes/backups/agents/sm_20260204_144958.md +0 -87
  71. package/.agentvibes/backups/agents/tea_20260204_144958.md +0 -79
  72. package/.agentvibes/backups/agents/tech-writer_20260204_144958.md +0 -82
  73. package/.agentvibes/backups/agents/ux-designer_20260204_144958.md +0 -80
  74. package/.agentvibes/config/README-personality-defaults.md +0 -162
  75. package/.agentvibes/config/agentvibes.json +0 -1
  76. package/.agentvibes/config/mode.txt +0 -1
  77. package/.agentvibes/config/personality-voice-defaults.default.json +0 -21
  78. package/.agentvibes/config/save-audio.txt +0 -1
  79. package/.agentvibes/config/voice-metadata.json +0 -160
  80. package/.agentvibes/hooks/help.sh +0 -191
  81. package/.agentvibes/hooks/post-tool-use-lite.sh +0 -111
  82. package/.agentvibes/hooks/save-audio-manager.sh +0 -162
  83. package/.agentvibes/hooks/session-start-full-optimized.sh +0 -102
  84. package/.agentvibes/hooks/session-start-full.sh +0 -142
  85. package/.agentvibes/hooks/session-start-lite-v2.sh +0 -34
  86. package/.agentvibes/hooks/session-start-lite.sh +0 -29
  87. package/.agentvibes/hooks/stop-lite.sh +0 -115
  88. package/.agentvibes/hooks/switch-mode.sh +0 -215
  89. package/.agentvibes/output-styles/audio-summary.md +0 -30
  90. package/.claude/audio/voice-samples/piper/alan.wav +0 -0
  91. package/.claude/audio/voice-samples/piper/amy.wav +0 -0
  92. package/.claude/audio/voice-samples/piper/charlotte.wav +0 -0
  93. package/.claude/audio/voice-samples/piper/joe.wav +0 -0
  94. package/.claude/audio/voice-samples/piper/john.wav +0 -0
  95. package/.claude/audio/voice-samples/piper/katherine.wav +0 -0
  96. package/.claude/audio/voice-samples/piper/kristin.wav +0 -0
  97. package/.claude/audio/voice-samples/piper/linda.wav +0 -0
  98. package/.claude/audio/voice-samples/piper/marcus.wav +0 -0
  99. package/.claude/audio/voice-samples/piper/ryan.wav +0 -0
  100. package/.claude/hooks/post-response.sh +0 -41
  101. package/bin/ensure-soprano-running.sh +0 -43
@@ -1,13 +1,13 @@
1
- /**
2
- * AgentVibes TUI — Brand Color Constants
3
- *
4
- * Single source of truth for the two primary brand colors.
5
- * Change BRAND_PINK or BRAND_BLUE here to update every modal title,
6
- * button, and the "Vibes" logotype across the entire TUI.
7
- */
8
-
9
- /** Magenta-pink used for modal titles and the "Vibes" logotype. */
10
- export const BRAND_PINK = '#f06292'; // Light magenta — Pink 300
11
-
12
- /** Indigo blue used for default button backgrounds and primary accents. */
13
- export const BRAND_BLUE = '#3949ab';
1
+ /**
2
+ * AgentVibes TUI — Brand Color Constants
3
+ *
4
+ * Single source of truth for the two primary brand colors.
5
+ * Change BRAND_PINK or BRAND_BLUE here to update every modal title,
6
+ * button, and the "Vibes" logotype across the entire TUI.
7
+ */
8
+
9
+ /** Magenta-pink used for modal titles and the "Vibes" logotype. */
10
+ export const BRAND_PINK = '#f06292'; // Light magenta — Pink 300
11
+
12
+ /** Indigo blue used for default button backgrounds and primary accents. */
13
+ export const BRAND_BLUE = '#3949ab';
@@ -1,44 +1,44 @@
1
- /**
2
- * AgentVibes — Canonical personality constants.
3
- *
4
- * Single source of truth for personality names and their associated emoji
5
- * glyphs. All TUI modules (settings-tab, agents-tab, personality-picker …)
6
- * import from here; src/installer.js maintains its own copy because it
7
- * predates the TUI and uses a different module-load path.
8
- *
9
- * Exported:
10
- * PERSONALITY_EMOJIS — Map of personality name → emoji string
11
- * PERSONALITIES — Ordered array of personality names (canonical order
12
- * used for picker lists)
13
- */
14
-
15
- export const PERSONALITY_EMOJIS = Object.freeze({
16
- angry: '😠',
17
- annoying: '😤',
18
- crass: '🤬',
19
- dramatic: '🎭',
20
- 'dry-humor': '😐',
21
- flirty: '😘',
22
- funny: '😂',
23
- grandpa: '👴',
24
- millennial: '🙄',
25
- moody: '😒',
26
- none: '😊',
27
- normal: '😊',
28
- pirate: '⚓',
29
- poetic: '📜',
30
- professional: '👔',
31
- rapper: '🎤',
32
- robot: '🤖',
33
- sarcastic: '😏',
34
- sassy: '💁',
35
- 'surfer-dude':'🏄',
36
- zen: '🧘',
37
- });
38
-
39
- export const PERSONALITIES = Object.freeze([
40
- 'none', 'angry', 'annoying', 'crass', 'dramatic', 'dry-humor',
41
- 'flirty', 'funny', 'grandpa', 'millennial', 'moody', 'normal',
42
- 'pirate', 'poetic', 'professional', 'rapper', 'robot', 'sarcastic',
43
- 'sassy', 'surfer-dude', 'zen',
44
- ]);
1
+ /**
2
+ * AgentVibes — Canonical personality constants.
3
+ *
4
+ * Single source of truth for personality names and their associated emoji
5
+ * glyphs. All TUI modules (settings-tab, agents-tab, personality-picker …)
6
+ * import from here; src/installer.js maintains its own copy because it
7
+ * predates the TUI and uses a different module-load path.
8
+ *
9
+ * Exported:
10
+ * PERSONALITY_EMOJIS — Map of personality name → emoji string
11
+ * PERSONALITIES — Ordered array of personality names (canonical order
12
+ * used for picker lists)
13
+ */
14
+
15
+ export const PERSONALITY_EMOJIS = Object.freeze({
16
+ angry: '😠',
17
+ annoying: '😤',
18
+ crass: '🤬',
19
+ dramatic: '🎭',
20
+ 'dry-humor': '😐',
21
+ flirty: '😘',
22
+ funny: '😂',
23
+ grandpa: '👴',
24
+ millennial: '🙄',
25
+ moody: '😒',
26
+ none: '😊',
27
+ normal: '😊',
28
+ pirate: '⚓',
29
+ poetic: '📜',
30
+ professional: '👔',
31
+ rapper: '🎤',
32
+ robot: '🤖',
33
+ sarcastic: '😏',
34
+ sassy: '💁',
35
+ 'surfer-dude':'🏄',
36
+ zen: '🧘',
37
+ });
38
+
39
+ export const PERSONALITIES = Object.freeze([
40
+ 'none', 'angry', 'annoying', 'crass', 'dramatic', 'dry-humor',
41
+ 'flirty', 'funny', 'grandpa', 'millennial', 'moody', 'normal',
42
+ 'pirate', 'poetic', 'professional', 'rapper', 'robot', 'sarcastic',
43
+ 'sassy', 'surfer-dude', 'zen',
44
+ ]);
@@ -1,247 +1,247 @@
1
- /**
2
- * AgentVibes TUI Console — Modal Overlay Component
3
- * Story 6.4: Modal Overlay Component
4
- *
5
- * Provides a reusable dim-overlay + centered-container modal for the TUI.
6
- * Used by stories 7.7 (Personality), 7.8 (Voice), 7.9 (Music) selector modals.
7
- *
8
- * Integration with NavigationService:
9
- * - openModal() sets isModalOpen() = true (blocks S/V/M/A/R/H/I/T shortcuts)
10
- * - closeModal() restores modal-closed state
11
- * - pushFocus/popFocus manage keyboard focus across open/close
12
- */
13
-
14
- import blessed from 'blessed';
15
-
16
- // Brand colours consistent with app.js and UX design plan
17
- const COLORS = {
18
- headerBg: '#1a237e',
19
- contentBg: '#0a0e1a',
20
- activeTab: '#3949ab',
21
- textWhite: 'white',
22
- overlayBg: '#000000',
23
- };
24
-
25
- export class ModalOverlay {
26
- /**
27
- * @param {object} screen - Blessed screen instance (or stub in tests)
28
- * @param {NavigationService} nav - Navigation service for modal state management
29
- * @param {object} [opts={}]
30
- * @param {string} [opts.title='Modal'] - Default modal title
31
- */
32
- constructor(screen, nav, opts = {}) {
33
- this._screen = screen;
34
- this._nav = nav;
35
- this._onApply = null;
36
- this._focusedButton = 'apply'; // M1: tracks which footer button has focus ('apply'|'cancel')
37
-
38
- const title = opts.title ?? 'Modal';
39
- this._title = title; // L1: stored for external access
40
-
41
- // Detect test/stub environment: real blessed screens have a .program property.
42
- // When using a stub screen (no .program), create lightweight object stubs
43
- // instead of real blessed widgets to avoid "No active screen." errors.
44
- const isTestMode = !screen.program;
45
-
46
- if (isTestMode) {
47
- this._overlay = { hidden: true };
48
- this._container = { hidden: true, key: () => {}, focus: () => {} };
49
- this._titleBar = { setContent: () => {} };
50
- this._contentBox = { hidden: true };
51
- this._footerBar = { hidden: true, setContent: () => {} }; // setContent needed by _updateFooter
52
- } else {
53
- // Full-screen dim overlay — sits beneath the modal container
54
- this._overlay = blessed.box({
55
- top: 0,
56
- left: 0,
57
- width: '100%',
58
- height: '100%',
59
- style: { bg: COLORS.overlayBg },
60
- hidden: true,
61
- });
62
-
63
- // Centered modal container with border
64
- this._container = blessed.box({
65
- top: 'center',
66
- left: 'center',
67
- width: '60%',
68
- height: '60%',
69
- border: { type: 'line' },
70
- style: {
71
- fg: COLORS.textWhite,
72
- bg: COLORS.contentBg,
73
- border: { fg: COLORS.activeTab },
74
- },
75
- hidden: true,
76
- keys: true,
77
- mouse: true,
78
- });
79
-
80
- // Title bar — top row of container
81
- this._titleBar = blessed.box({
82
- parent: this._container,
83
- top: 0,
84
- left: 0,
85
- width: '100%',
86
- height: 1,
87
- content: ` ${title}`,
88
- tags: true,
89
- style: {
90
- fg: COLORS.textWhite,
91
- bg: COLORS.headerBg,
92
- },
93
- });
94
-
95
- // Scrollable content area — callers append their UI here via getContentBox()
96
- this._contentBox = blessed.box({
97
- parent: this._container,
98
- top: 1,
99
- left: 0,
100
- width: '100%',
101
- bottom: 2,
102
- scrollable: true,
103
- alwaysScroll: true,
104
- keys: true,
105
- style: {
106
- fg: COLORS.textWhite,
107
- bg: COLORS.contentBg,
108
- },
109
- });
110
-
111
- // Footer strip with focusable Apply / Cancel buttons
112
- this._footerBar = blessed.box({
113
- parent: this._container,
114
- bottom: 0,
115
- left: 0,
116
- width: '100%',
117
- height: 2,
118
- content: ' ► {bold}[Apply]{/bold} [Cancel]',
119
- tags: true,
120
- style: {
121
- fg: COLORS.textWhite,
122
- bg: COLORS.headerBg,
123
- },
124
- });
125
-
126
- // Append to screen in z-order: overlay first (bottom), container on top
127
- this._screen.append(this._overlay);
128
- this._screen.append(this._container);
129
-
130
- // M1: Tab cycles focus indicator between Apply and Cancel
131
- this._container.key(['tab'], () => {
132
- this._focusedButton = this._focusedButton === 'apply' ? 'cancel' : 'apply';
133
- this._updateFooter();
134
- this._screen.render();
135
- });
136
-
137
- // Enter activates the currently focused footer button (M1: checks _focusedButton)
138
- this._container.key(['enter'], () => {
139
- if (this._focusedButton === 'apply') {
140
- this._handleApply();
141
- } else {
142
- this.close();
143
- }
144
- });
145
-
146
- this._container.key(['escape'], () => this.close());
147
- }
148
- }
149
-
150
- // ---------------------------------------------------------------------------
151
- // Public API
152
-
153
- /**
154
- * Open the modal overlay.
155
- * Shows the dim overlay + container, updates NavigationService modal state,
156
- * pushes current focus to the focus stack, and renders the screen.
157
- * @param {Function|null} [onApply] - Called when user activates Apply
158
- */
159
- open(onApply = null) {
160
- // H2: Guard against double-open — prevents stacked pushFocus calls
161
- if (this._nav.isModalOpen()) return;
162
-
163
- this._onApply = onApply;
164
- this._focusedButton = 'apply'; // Reset focus to Apply on each open
165
- this._overlay.hidden = false;
166
- this._container.hidden = false;
167
-
168
- this._nav.openModal(() => {
169
- // Push current focus so we can restore on close
170
- this._nav.pushFocus(this._screen.focused);
171
- this._container.focus();
172
- });
173
-
174
- this._updateFooter();
175
- this._screen.render();
176
- }
177
-
178
- /**
179
- * Close the modal overlay.
180
- * Hides overlay + container, restores NavigationService modal state,
181
- * pops saved focus, and renders the screen.
182
- *
183
- * Guard: uses widget-state (overlay.hidden) rather than isModalOpen().
184
- * Reason: Blessed.js fires multiple Esc handlers in registration order.
185
- * navigation.js registers its handler first and calls nav.closeModal()
186
- * before app.js calls modal.close() — so isModalOpen() is already false
187
- * when we arrive here, causing the old guard to return early and leave
188
- * the overlay/container visible on screen.
189
- */
190
- close() {
191
- // H1: Widget-state guard — safe against dual-handler Esc ordering
192
- if (this._overlay.hidden) return;
193
- this._overlay.hidden = true;
194
- this._container.hidden = true;
195
- // H1: navigation.js may have already called closeModal() — only call if still open
196
- if (this._nav.isModalOpen()) this._nav.closeModal();
197
- const prev = this._nav.popFocus();
198
- if (prev && typeof prev.focus === 'function') prev.focus();
199
- this._screen.render();
200
- }
201
-
202
- /**
203
- * Returns the inner content box so callers (7.7-7.9) can append their UI.
204
- * @returns {object} Blessed box (or stub in tests)
205
- */
206
- getContentBox() {
207
- return this._contentBox;
208
- }
209
-
210
- /**
211
- * Update the modal title bar text.
212
- * @param {string} text
213
- */
214
- setTitle(text) {
215
- this._titleBar.setContent(` ${text}`);
216
- }
217
-
218
- // ---------------------------------------------------------------------------
219
- // Private
220
-
221
- /**
222
- * M1: Render the footer with a ► cursor on the currently focused button.
223
- */
224
- _updateFooter() {
225
- const applyLabel = this._focusedButton === 'apply' ? '► {bold}[Apply]{/bold}' : ' [Apply]';
226
- const cancelLabel = this._focusedButton === 'cancel' ? '► {bold}[Cancel]{/bold}' : ' [Cancel]';
227
- this._footerBar.setContent(` ${applyLabel} ${cancelLabel}`);
228
- }
229
-
230
- _handleApply() {
231
- if (typeof this._onApply === 'function') {
232
- this._onApply();
233
- }
234
- this.close();
235
- }
236
- }
237
-
238
- /**
239
- * Factory function for creating a ModalOverlay.
240
- * @param {object} screen - Blessed screen
241
- * @param {NavigationService} nav - Navigation service
242
- * @param {object} [opts={}]
243
- * @returns {ModalOverlay}
244
- */
245
- export function createModalOverlay(screen, nav, opts = {}) {
246
- return new ModalOverlay(screen, nav, opts);
247
- }
1
+ /**
2
+ * AgentVibes TUI Console — Modal Overlay Component
3
+ * Story 6.4: Modal Overlay Component
4
+ *
5
+ * Provides a reusable dim-overlay + centered-container modal for the TUI.
6
+ * Used by stories 7.7 (Personality), 7.8 (Voice), 7.9 (Music) selector modals.
7
+ *
8
+ * Integration with NavigationService:
9
+ * - openModal() sets isModalOpen() = true (blocks S/V/M/A/R/H/I/T shortcuts)
10
+ * - closeModal() restores modal-closed state
11
+ * - pushFocus/popFocus manage keyboard focus across open/close
12
+ */
13
+
14
+ import blessed from 'blessed';
15
+
16
+ // Brand colours consistent with app.js and UX design plan
17
+ const COLORS = {
18
+ headerBg: '#1a237e',
19
+ contentBg: '#0a0e1a',
20
+ activeTab: '#3949ab',
21
+ textWhite: 'white',
22
+ overlayBg: '#000000',
23
+ };
24
+
25
+ export class ModalOverlay {
26
+ /**
27
+ * @param {object} screen - Blessed screen instance (or stub in tests)
28
+ * @param {NavigationService} nav - Navigation service for modal state management
29
+ * @param {object} [opts={}]
30
+ * @param {string} [opts.title='Modal'] - Default modal title
31
+ */
32
+ constructor(screen, nav, opts = {}) {
33
+ this._screen = screen;
34
+ this._nav = nav;
35
+ this._onApply = null;
36
+ this._focusedButton = 'apply'; // M1: tracks which footer button has focus ('apply'|'cancel')
37
+
38
+ const title = opts.title ?? 'Modal';
39
+ this._title = title; // L1: stored for external access
40
+
41
+ // Detect test/stub environment: real blessed screens have a .program property.
42
+ // When using a stub screen (no .program), create lightweight object stubs
43
+ // instead of real blessed widgets to avoid "No active screen." errors.
44
+ const isTestMode = !screen.program;
45
+
46
+ if (isTestMode) {
47
+ this._overlay = { hidden: true };
48
+ this._container = { hidden: true, key: () => {}, focus: () => {} };
49
+ this._titleBar = { setContent: () => {} };
50
+ this._contentBox = { hidden: true };
51
+ this._footerBar = { hidden: true, setContent: () => {} }; // setContent needed by _updateFooter
52
+ } else {
53
+ // Full-screen dim overlay — sits beneath the modal container
54
+ this._overlay = blessed.box({
55
+ top: 0,
56
+ left: 0,
57
+ width: '100%',
58
+ height: '100%',
59
+ style: { bg: COLORS.overlayBg },
60
+ hidden: true,
61
+ });
62
+
63
+ // Centered modal container with border
64
+ this._container = blessed.box({
65
+ top: 'center',
66
+ left: 'center',
67
+ width: '60%',
68
+ height: '60%',
69
+ border: { type: 'line' },
70
+ style: {
71
+ fg: COLORS.textWhite,
72
+ bg: COLORS.contentBg,
73
+ border: { fg: COLORS.activeTab },
74
+ },
75
+ hidden: true,
76
+ keys: true,
77
+ mouse: true,
78
+ });
79
+
80
+ // Title bar — top row of container
81
+ this._titleBar = blessed.box({
82
+ parent: this._container,
83
+ top: 0,
84
+ left: 0,
85
+ width: '100%',
86
+ height: 1,
87
+ content: ` ${title}`,
88
+ tags: true,
89
+ style: {
90
+ fg: COLORS.textWhite,
91
+ bg: COLORS.headerBg,
92
+ },
93
+ });
94
+
95
+ // Scrollable content area — callers append their UI here via getContentBox()
96
+ this._contentBox = blessed.box({
97
+ parent: this._container,
98
+ top: 1,
99
+ left: 0,
100
+ width: '100%',
101
+ bottom: 2,
102
+ scrollable: true,
103
+ alwaysScroll: true,
104
+ keys: true,
105
+ style: {
106
+ fg: COLORS.textWhite,
107
+ bg: COLORS.contentBg,
108
+ },
109
+ });
110
+
111
+ // Footer strip with focusable Apply / Cancel buttons
112
+ this._footerBar = blessed.box({
113
+ parent: this._container,
114
+ bottom: 0,
115
+ left: 0,
116
+ width: '100%',
117
+ height: 2,
118
+ content: ' ► {bold}[Apply]{/bold} [Cancel]',
119
+ tags: true,
120
+ style: {
121
+ fg: COLORS.textWhite,
122
+ bg: COLORS.headerBg,
123
+ },
124
+ });
125
+
126
+ // Append to screen in z-order: overlay first (bottom), container on top
127
+ this._screen.append(this._overlay);
128
+ this._screen.append(this._container);
129
+
130
+ // M1: Tab cycles focus indicator between Apply and Cancel
131
+ this._container.key(['tab'], () => {
132
+ this._focusedButton = this._focusedButton === 'apply' ? 'cancel' : 'apply';
133
+ this._updateFooter();
134
+ this._screen.render();
135
+ });
136
+
137
+ // Enter activates the currently focused footer button (M1: checks _focusedButton)
138
+ this._container.key(['enter'], () => {
139
+ if (this._focusedButton === 'apply') {
140
+ this._handleApply();
141
+ } else {
142
+ this.close();
143
+ }
144
+ });
145
+
146
+ this._container.key(['escape'], () => this.close());
147
+ }
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Public API
152
+
153
+ /**
154
+ * Open the modal overlay.
155
+ * Shows the dim overlay + container, updates NavigationService modal state,
156
+ * pushes current focus to the focus stack, and renders the screen.
157
+ * @param {Function|null} [onApply] - Called when user activates Apply
158
+ */
159
+ open(onApply = null) {
160
+ // H2: Guard against double-open — prevents stacked pushFocus calls
161
+ if (this._nav.isModalOpen()) return;
162
+
163
+ this._onApply = onApply;
164
+ this._focusedButton = 'apply'; // Reset focus to Apply on each open
165
+ this._overlay.hidden = false;
166
+ this._container.hidden = false;
167
+
168
+ this._nav.openModal(() => {
169
+ // Push current focus so we can restore on close
170
+ this._nav.pushFocus(this._screen.focused);
171
+ this._container.focus();
172
+ }, () => this.close());
173
+
174
+ this._updateFooter();
175
+ this._screen.render();
176
+ }
177
+
178
+ /**
179
+ * Close the modal overlay.
180
+ * Hides overlay + container, restores NavigationService modal state,
181
+ * pops saved focus, and renders the screen.
182
+ *
183
+ * Guard: uses widget-state (overlay.hidden) rather than isModalOpen().
184
+ * Reason: Blessed.js fires multiple Esc handlers in registration order.
185
+ * navigation.js registers its handler first and calls nav.closeModal()
186
+ * before app.js calls modal.close() — so isModalOpen() is already false
187
+ * when we arrive here, causing the old guard to return early and leave
188
+ * the overlay/container visible on screen.
189
+ */
190
+ close() {
191
+ // H1: Widget-state guard — safe against dual-handler Esc ordering
192
+ if (this._overlay.hidden) return;
193
+ this._overlay.hidden = true;
194
+ this._container.hidden = true;
195
+ // H1: navigation.js may have already called closeModal() — only call if still open
196
+ if (this._nav.isModalOpen()) this._nav.closeModal();
197
+ const prev = this._nav.popFocus();
198
+ if (prev && typeof prev.focus === 'function') prev.focus();
199
+ this._screen.render();
200
+ }
201
+
202
+ /**
203
+ * Returns the inner content box so callers (7.7-7.9) can append their UI.
204
+ * @returns {object} Blessed box (or stub in tests)
205
+ */
206
+ getContentBox() {
207
+ return this._contentBox;
208
+ }
209
+
210
+ /**
211
+ * Update the modal title bar text.
212
+ * @param {string} text
213
+ */
214
+ setTitle(text) {
215
+ this._titleBar.setContent(` ${text}`);
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Private
220
+
221
+ /**
222
+ * M1: Render the footer with a ► cursor on the currently focused button.
223
+ */
224
+ _updateFooter() {
225
+ const applyLabel = this._focusedButton === 'apply' ? '► {bold}[Apply]{/bold}' : ' [Apply]';
226
+ const cancelLabel = this._focusedButton === 'cancel' ? '► {bold}[Cancel]{/bold}' : ' [Cancel]';
227
+ this._footerBar.setContent(` ${applyLabel} ${cancelLabel}`);
228
+ }
229
+
230
+ _handleApply() {
231
+ if (typeof this._onApply === 'function') {
232
+ this._onApply();
233
+ }
234
+ this.close();
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Factory function for creating a ModalOverlay.
240
+ * @param {object} screen - Blessed screen
241
+ * @param {NavigationService} nav - Navigation service
242
+ * @param {object} [opts={}]
243
+ * @returns {ModalOverlay}
244
+ */
245
+ export function createModalOverlay(screen, nav, opts = {}) {
246
+ return new ModalOverlay(screen, nav, opts);
247
+ }
@@ -39,9 +39,13 @@ const KEY_TO_TAB = {
39
39
  */
40
40
  export function setupNavigation(screen, navigationService, focusMainTabBar) {
41
41
  // Tab switching shortcuts — one handler per key (both cases)
42
+ // When a modal is open, force-close all modals then switch tabs.
42
43
  for (const [key, tabId] of Object.entries(KEY_TO_TAB)) {
43
44
  screen.key([key], () => {
44
- if (!navigationService.isModalOpen()) {
45
+ if (navigationService.isModalOpen()) {
46
+ navigationService.forceCloseAll();
47
+ setTimeout(() => navigationService.switchTab(tabId), 0);
48
+ } else {
45
49
  navigationService.switchTab(tabId);
46
50
  }
47
51
  });
@@ -618,7 +618,7 @@ ${_tl('bmadDesc')}
618
618
  };
619
619
 
620
620
  let _closed = false;
621
- navigationService?.openModal();
621
+ navigationService?.openModal(null, _closeModal);
622
622
 
623
623
  const modal = blessed.box({
624
624
  parent: screen,
@@ -865,10 +865,10 @@ ${_tl('bmadDesc')}
865
865
  });
866
866
 
867
867
  // Escape = close
868
- fieldList.key(['escape', 'q'], _closeModal);
869
- previewBtn.key(['escape'], _closeModal);
870
- resetBtn.key(['escape'], _closeModal);
871
- closeBtn.key(['escape'], _closeModal);
868
+ fieldList.key(['escape', 'q', 'Q'], _closeModal);
869
+ previewBtn.key(['escape', 'q', 'Q'], _closeModal);
870
+ resetBtn.key(['escape', 'q', 'Q'], _closeModal);
871
+ closeBtn.key(['escape', 'q', 'Q'], _closeModal);
872
872
 
873
873
  // Tab + arrow navigation within modal
874
874
  fieldList.key(['tab'], () => { previewBtn.focus(); screen.render(); });