@pheem49/mint 1.4.2 → 1.5.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 (60) hide show
  1. package/GUIDE_TH.md +113 -0
  2. package/README.md +239 -76
  3. package/assets/CLI_Screen.png +0 -0
  4. package/docs/assets/CLI_Screen.png +0 -0
  5. package/docs/guide.html +632 -0
  6. package/docs/index.html +5 -4
  7. package/main.js +66 -894
  8. package/mint-cli-logic.js +13 -1
  9. package/mint-cli.js +100 -9
  10. package/package.json +12 -4
  11. package/src/AI_Brain/Gemini_API.js +77 -20
  12. package/src/AI_Brain/autonomous_brain.js +10 -0
  13. package/src/AI_Brain/behavior_memory.js +26 -5
  14. package/src/AI_Brain/headless_agent.js +4 -0
  15. package/src/AI_Brain/knowledge_base.js +61 -8
  16. package/src/AI_Brain/memory_store.js +55 -7
  17. package/src/Automation_Layer/file_operations.js +1 -1
  18. package/src/CLI/chat_router.js +3 -2
  19. package/src/CLI/chat_ui.js +263 -838
  20. package/src/CLI/code_agent.js +144 -42
  21. package/src/CLI/gmail_auth.js +210 -0
  22. package/src/CLI/list_features.js +2 -0
  23. package/src/CLI/onboarding.js +307 -55
  24. package/src/CLI/updater.js +208 -0
  25. package/src/Channels/brave_search_bridge.js +35 -0
  26. package/src/Channels/discord_bridge.js +68 -0
  27. package/src/Channels/google_search_bridge.js +38 -0
  28. package/src/Channels/line_bridge.js +60 -0
  29. package/src/Channels/slack_bridge.js +53 -0
  30. package/src/Channels/telegram_bridge.js +49 -0
  31. package/src/Channels/whatsapp_bridge.js +55 -0
  32. package/src/Command_Parser/parser.js +12 -1
  33. package/src/Plugins/gmail.js +251 -0
  34. package/src/Plugins/google_calendar.js +245 -19
  35. package/src/Plugins/notion.js +256 -0
  36. package/src/System/action_executor.js +129 -0
  37. package/src/System/bridge_manager.js +76 -0
  38. package/src/System/chat_history_manager.js +23 -5
  39. package/src/System/config_manager.js +41 -7
  40. package/src/System/custom_workflows.js +31 -2
  41. package/src/System/google_tts_urls.js +51 -0
  42. package/src/System/ipc_handlers.js +238 -0
  43. package/src/System/proactive_loop.js +137 -0
  44. package/src/System/safety_manager.js +165 -0
  45. package/src/System/screen_capture.js +175 -0
  46. package/src/System/task_manager.js +15 -5
  47. package/src/System/window_manager.js +210 -0
  48. package/src/UI/renderer.js +33 -7
  49. package/src/UI/settings.html +24 -0
  50. package/src/UI/settings.js +14 -4
  51. package/src/UI/styles.css +14 -1
  52. package/tests/action_executor_safety.test.js +67 -0
  53. package/tests/gmail.test.js +135 -0
  54. package/tests/gmail_auth.test.js +129 -0
  55. package/tests/google_calendar.test.js +113 -0
  56. package/tests/google_tts_urls.test.js +24 -0
  57. package/tests/notion.test.js +121 -0
  58. package/tests/provider_routing.test.js +17 -1
  59. package/tests/safety_manager.test.js +40 -0
  60. package/tests/updater.test.js +32 -0
@@ -1,869 +1,294 @@
1
1
  /**
2
- * Mint CLI - Gemini-style TUI using blessed
3
- * Provides a rich terminal UI with chat history, input box, and status bar
2
+ * Mint CLI - Ink-based UI (ESM-compatible Version)
3
+ * A modern, React-based terminal UI for a better chat experience.
4
+ * Uses dynamic imports to handle ESM dependencies (Ink).
4
5
  */
5
- const blessed = require('blessed');
6
+ const React = require('react');
6
7
  const path = require('path');
7
- const { execSync } = require('child_process');
8
8
  const { readConfig } = require('../System/config_manager');
9
9
 
10
+ // Helper to make element creation less verbose
11
+ const h = React.createElement;
12
+
10
13
  const SLASH_COMMANDS = [
11
- { name: '/code', desc: 'Force workspace code mode for a task' },
12
- { name: '/models', desc: 'List or switch Gemini models' },
13
- { name: '/config', desc: 'Show current configuration' },
14
- { name: '/copy', desc: 'Copy last response to clipboard' },
15
- { name: '/clear', desc: 'Clear conversation history' },
16
- { name: '/reset', desc: 'Reset conversation history' },
17
- { name: '/agent', desc: 'Switch AI personas (coder, researcher, etc)' },
18
- { name: '/workspace', desc: 'Manage project-specific contexts' },
19
- { name: '/review', desc: 'Request a second-pass review of the last response' },
20
- { name: '/stats', desc: 'Show system health stats (CPU/RAM/Disk)' },
21
- { name: '/help', desc: 'Show help information' },
22
- { name: '/exit', desc: 'Exit Mint' }
14
+ { cmd: '/help', desc: 'Show available commands' },
15
+ { cmd: '/code', desc: 'Force workspace Code Mode' },
16
+ { cmd: '/cd', desc: 'Change current working directory' },
17
+ { cmd: '/models', desc: 'List or switch Gemini models' },
18
+ { cmd: '/config', desc: 'Show current configuration' },
19
+ { cmd: '/copy', desc: 'Copy last response to clipboard' },
20
+ { cmd: '/clear', desc: 'Clear conversation history' },
21
+ { cmd: '/reset', desc: 'Reset conversation history' },
22
+ { cmd: '/agent', desc: 'Switch AI agents (e.g. /agent code)' },
23
+ { cmd: '/workspace', desc: 'Manage registered workspaces' },
24
+ { cmd: '/stats', desc: 'Show system statistics' },
25
+ { cmd: '/review', desc: 'Request second-pass review' },
26
+ { cmd: '/exit', desc: 'Exit Mint' }
23
27
  ];
24
28
 
25
29
  /**
26
- * Creates and returns the Mint chat TUI screen
27
- * @param {Object} options
28
- * @param {Function} options.onSubmit - Called with (userInput: string) when user sends a message
29
- * @param {Function} options.onExit - Called when user exits
30
- * @returns {{ screen, appendMessage, setThinking }}
30
+ * We wrap everything in an async function to load ESM modules
31
31
  */
32
- function createChatUI({ onSubmit, onExit }) {
33
- const config = readConfig();
34
- const modelName = config.geminiModel || 'gemini';
35
- const workspacePath = process.cwd();
36
- const HINT_DEFAULT = `{gray-fg} Enter send · Ctrl+Y copy · /help commands{/}`;
37
- const INPUT_FG = '#f8fafc';
38
- const INPUT_BG = '#10141c';
39
-
40
- // ─── Screen ───────────────────────────────────────────────────────────────
41
- const screen = blessed.screen({
42
- smartCSR: true,
43
- fullUnicode: true,
44
- title: 'Mint CLI',
45
- mouse: true
46
- });
47
-
48
- // ─── Banner ───────────────────────────────────────────────────────────────
49
- const banner = blessed.box({
50
- top: 0, left: 1, width: '100%-2', height: 4,
51
- tags: true,
52
- padding: { left: 1, right: 1 },
53
- style: { bg: 'default', fg: '#d7dde8' }
54
- });
55
- banner.setContent([
56
- `{#88e0b0-fg} __ __ _ _ ___ _ ___ {/}`,
57
- `{#88e0b0-fg}| \\/ (_)_ __ | |_ / __| | |_ _|{/}`,
58
- `{#88e0b0-fg}| |\\/| | | '_ \\| _| (__| |__ | | {/}`,
59
- `{#88e0b0-fg}|_| |_|_|_| |_|\\__|\\___|____|___|{/}`
60
- ].join('\n'));
61
-
62
- const subBanner = blessed.box({
63
- top: 4, left: 2, width: '100%-4', height: 2,
64
- tags: true,
65
- content: `{gray-fg}Type naturally to chat. Coding requests can auto-enter {/}{#ffd166-fg}Code Mode{/}{gray-fg}. Use {/}{#88e0b0-fg}/help{/}{gray-fg}, {/}{#88e0b0-fg}/code{/}{gray-fg}, or {/}{#88e0b0-fg}Esc{/}{gray-fg}.{/}`,
66
- style: { bg: 'default', fg: '#9aa6bf' }
67
- });
68
-
69
- // ─── Chat log (scrollable) ────────────────────────────────────────────────
70
- const chatBox = blessed.log({
71
- top: 6, left: 1, width: '100%-2',
72
- bottom: 8,
73
- tags: true,
74
- scrollable: true,
75
- alwaysScroll: true,
76
- scrollbar: { ch: '┃', style: { fg: '#335d52' } },
77
- style: { bg: '#171b24', fg: '#ffffff', border: { fg: '#2f3747' } },
78
- mouse: true,
79
- scrollable: true,
80
- border: { type: 'line' },
81
- padding: { left: 1, right: 1, top: 0, bottom: 0 },
82
- label: ' Conversation '
83
- });
84
-
85
- // ─── Hint bar ─────────────────────────────────────────────────────────────
86
- const hintBar = blessed.box({
87
- bottom: 6, left: 1, width: '100%-2', height: 1,
88
- tags: true,
89
- content: HINT_DEFAULT,
90
- style: { bg: 'default' }
91
- });
92
-
93
- const inputBox = blessed.textbox({
94
- bottom: 3, left: 1, width: '100%-2', height: 3,
95
- tags: false,
96
- inputOnFocus: false, // We'll manage this manually for stability
97
- keys: true,
98
- style: {
99
- bg: INPUT_BG,
100
- fg: INPUT_FG,
101
- border: { fg: '#335d52' },
102
- focus: {
103
- fg: INPUT_FG,
104
- bg: INPUT_BG,
105
- border: { fg: '#88e0b0' }
106
- }
107
- },
108
- border: { type: 'line' },
109
- padding: { left: 1 },
110
- label: ' Message '
111
- });
112
-
113
- // --- SAFETY PATCH ---
114
- // Prevent "TypeError: done is not a function" if a listener survives a blur/focus cycle.
115
- const originalListener = inputBox._listener;
116
- inputBox._listener = function(ch, key) {
117
- if (typeof this._done !== 'function') return;
118
- return originalListener.call(this, ch, key);
119
- };
120
-
121
-
122
-
123
- // ─── Placeholder (SIBLING widget floating over input content area) ─────────
124
- // inputBox: bottom=3, height=3, border=1 → content row at bottom=4, left=2
125
- const placeholderWidget = blessed.text({
126
- bottom: 4, // inside input content area (border offset)
127
- left: 3,
128
- width: '100%-6',
129
- height: 1,
130
- content: '> Ask anything, or describe a coding task for this workspace',
131
- tags: false,
132
- style: { fg: '#5d6678', bg: '#10141c' }
133
- });
134
-
135
- let placeholderVisible = true;
136
-
137
- function hidePlaceholder() {
138
- if (placeholderVisible) {
139
- placeholderVisible = false;
140
- placeholderWidget.hide();
141
- screen.render();
142
- }
143
- }
144
-
145
- function showPlaceholder() {
146
- if (!placeholderVisible) {
147
- placeholderVisible = true;
148
- placeholderWidget.show();
149
- screen.render();
150
- }
151
- }
152
-
153
- function refreshInputStyles() {
154
- inputBox.style.fg = INPUT_FG;
155
- inputBox.style.bg = INPUT_BG;
156
- if (inputBox.style.focus) {
157
- inputBox.style.focus.fg = INPUT_FG;
158
- inputBox.style.focus.bg = INPUT_BG;
159
- }
160
- if (Array.isArray(inputBox.children)) {
161
- inputBox.children.forEach((child) => {
162
- if (child.style) {
163
- child.style.fg = INPUT_FG;
164
- child.style.bg = INPUT_BG;
32
+ async function createChatUI(options) {
33
+ // Dynamic imports for ESM modules
34
+ const { render, Box, Text, useInput, useApp, Static } = await import('ink');
35
+ const TextInput = (await import('ink-text-input')).default;
36
+ const { useState, useImperativeHandle, forwardRef, createRef, useEffect, useMemo } = React;
37
+
38
+ const App = forwardRef(({ onSubmit, onExit, initialHistory = [] }, ref) => {
39
+ const config = readConfig();
40
+ const { exit } = useApp();
41
+ const [input, setInput] = useState('');
42
+ const [history, setHistory] = useState(initialHistory);
43
+ const [thinking, setThinking] = useState(false);
44
+ const [mode, setMode] = useState('Chat');
45
+ const [model, setModel] = useState('');
46
+ const [workspace, setWorkspace] = useState(process.cwd());
47
+
48
+ // Suggestions State
49
+ const [selectedIndex, setSelectedIndex] = useState(0);
50
+ const inputRef = React.useRef(input);
51
+ const selectedIndexRef = React.useRef(selectedIndex);
52
+
53
+ useEffect(() => {
54
+ inputRef.current = input;
55
+ }, [input]);
56
+
57
+ useEffect(() => {
58
+ selectedIndexRef.current = selectedIndex;
59
+ }, [selectedIndex]);
60
+
61
+ const showSuggestions = input.startsWith('/') && !input.includes(' ');
62
+ const suggestions = useMemo(() => {
63
+ if (!showSuggestions) return [];
64
+ const query = input.toLowerCase();
65
+ return SLASH_COMMANDS.filter(s => s.cmd.startsWith(query));
66
+ }, [input, showSuggestions]);
67
+
68
+ // Reset index when suggestions change
69
+ useEffect(() => {
70
+ setSelectedIndex(0);
71
+ }, [suggestions.length]);
72
+
73
+ const lastSystemMessage = React.useRef('');
74
+
75
+ // Export methods to the outside world via ref
76
+ useImperativeHandle(ref, () => ({
77
+ appendMessage: (role, text, metadata = {}) => {
78
+ setHistory(prev => [...prev, { role, text, time: new Date(), ...metadata }]);
79
+ if (metadata.providerInfo) {
80
+ const { provider, model } = metadata.providerInfo;
81
+ setModel(model ? `${provider} ${model}` : provider);
165
82
  }
166
- });
167
- }
168
- applyTerminalInputAttrs();
169
- }
170
-
171
- function applyTerminalInputAttrs() {
172
- try {
173
- if (!screen || !screen.program || typeof inputBox.sattr !== 'function' || typeof screen.codeAttr !== 'function') {
174
- return;
175
- }
176
- const attr = inputBox.sattr(inputBox.style);
177
- screen.program.write(screen.codeAttr(attr));
178
- } catch (_) {}
179
- }
180
-
181
- // ─── Status bar (2 lines as per screenshot) ──────────────────────
182
- const statusBar = blessed.box({
183
- bottom: 0, left: 1, width: '100%-2', height: 3,
184
- tags: true,
185
- style: { bg: '#10141c', fg: '#888888' },
186
- border: { type: 'line', fg: '#222c38' }
187
- });
188
-
189
- // Line 1: Thinking / Status (Left) and Shortcut (Right)
190
- const statusLine1 = blessed.text({
191
- parent: statusBar,
192
- top: 0, left: 1, right: 1,
193
- height: 1,
194
- tags: true,
195
- content: `{#88aaff-fg}[Chat]{/} {#cc4444-fg}no sandbox{/}`,
196
- style: { bg: '#10141c' }
197
- });
198
-
199
- const shortcutHint = blessed.text({
200
- parent: statusBar,
201
- top: 0, right: 1,
202
- height: 1,
203
- tags: true,
204
- content: `{gray-fg}? for shortcuts{/}`,
205
- style: { bg: '#10141c' }
206
- });
207
-
208
- // Line 2: Action Hint (Left) and File Info (Right)
209
- const statusLine2 = blessed.text({
210
- parent: statusBar,
211
- top: 1, left: 1, right: 1,
212
- height: 1,
213
- tags: true,
214
- content: `{gray-fg}Shift+Tab to accept edits{/}`,
215
- style: { bg: '#10141c' }
216
- });
217
-
218
- const fileInfo = blessed.text({
219
- parent: statusBar,
220
- top: 1, right: 1,
221
- height: 1,
222
- tags: true,
223
- content: `{gray-fg}path: ${workspacePath}{/}`,
224
- style: { bg: '#10141c' }
225
- });
226
-
227
- let activeMode = 'Chat';
228
- let spinnerIdx = 0;
229
- const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
230
-
231
- function formatTime(seconds) {
232
- if (seconds < 60) return `${seconds}s`;
233
- const mins = Math.floor(seconds / 60);
234
- const secs = seconds % 60;
235
- return `${mins}m ${secs}s`;
236
- }
237
-
238
- function updateStatusBar(thinkingText = null, secondsElapsed = 0) {
239
- if (thinkingText) {
240
- const char = spinnerChars[spinnerIdx % spinnerChars.length];
241
- spinnerIdx++;
242
- statusLine1.setContent(`{#88e0b0-fg}${char}{/} Thinking... {gray-fg}(esc to cancel, ${formatTime(secondsElapsed)}){/}`);
243
- } else {
244
- statusLine1.setContent(`${activeMode === 'Code' ? '{#ffd166-fg}[Code]{/}' : '{#88aaff-fg}[Chat]{/}'} {#cc4444-fg}no sandbox{/}`);
245
- }
246
- screen.render();
247
- }
248
-
249
- function setMode(mode) {
250
- activeMode = mode === 'Code' ? 'Code' : 'Chat';
251
- updateStatusBar(null);
252
- }
253
-
254
- function updateStatusModel(newModel) {
255
- if (!newModel) return;
256
- shortcutHint.setContent(`{gray-fg}${newModel} · ? for shortcuts{/}`);
257
- screen.render();
258
- }
259
-
260
- /** Update workspace name in status bar */
261
- function updateWorkspace(newPath) {
262
- if (!newPath) return;
263
- fileInfo.setContent(`{gray-fg}path: ${newPath}{/}`);
264
- screen.render();
265
- }
266
- updateStatusBar();
267
-
268
- // ─── Append widgets to screen ─────────────────────────────────────────────
269
- screen.append(banner);
270
- screen.append(subBanner);
271
- screen.append(chatBox);
272
- screen.append(hintBar);
273
- screen.append(inputBox);
274
- screen.append(statusBar);
275
- screen.append(placeholderWidget); // sibling on top of inputBox
276
-
277
- // Suggestion List and Approval Dialog remain same ...
278
-
279
- // ─── Suggestion List ──────────────────────────────────────────────────────
280
- const commandList = blessed.list({
281
- parent: screen,
282
- bottom: 6,
283
- left: 3,
284
- width: '64%',
285
- height: 8,
286
- tags: true,
287
- keys: false, // We will handle keys manually to keep focus on input
288
- vi: false,
289
- hidden: true,
290
- border: { type: 'line', fg: '#88e0b0' },
291
- style: {
292
- bg: '#10141c',
293
- fg: '#ffffff',
294
- selected: {
295
- bg: '#22352f',
296
- fg: '#88e0b0',
297
- bold: true
298
- }
299
- }
300
- });
301
-
302
- let activeSuggestions = [];
303
- const approvalDialog = blessed.question({
304
- parent: screen,
305
- tags: true,
306
- border: { type: 'line', fg: '#88e0b0' },
307
- style: {
308
- bg: '#10141c',
309
- fg: '#ffffff',
310
- border: { fg: '#88e0b0' }
311
- },
312
- width: '80%',
313
- height: 12, // Fixed height to avoid 'shrink' miscalculation with buttons
314
- top: 'center',
315
- left: 'center',
316
- label: ' Approval ',
317
- hidden: true
318
- });
319
-
320
- function updateSuggestions(filter = '') {
321
- activeSuggestions = SLASH_COMMANDS.filter(cmd =>
322
- cmd.name.toLowerCase().startsWith(filter.toLowerCase())
323
- );
324
-
325
- if (activeSuggestions.length === 0) {
326
- commandList.hide();
327
- screen.render();
328
- return;
329
- }
330
-
331
- const items = activeSuggestions.map(cmd =>
332
- ` {bold}${cmd.name}{/} {gray-fg}${cmd.desc}{/}`
333
- );
334
- commandList.setItems(items);
335
- commandList.select(0);
336
- commandList.show();
337
- commandList.setFront();
338
- screen.render();
339
- }
340
-
341
-
342
- // ─── Input events ─────────────────────────────────────────────────────────
343
-
344
- // ─── Input events ─────────────────────────────────────────────────────────
345
- let lastListVisible = false;
346
-
347
- // Consolidated key handling
348
- inputBox.on('element keypress', (el, ch, key) => {
349
- refreshInputStyles();
350
- // 1. Handle placeholder visibility
351
- if (!key.ctrl && !key.meta && key.name !== 'enter' && key.name !== 'tab') {
352
- if (ch) hidePlaceholder();
353
- }
354
-
355
- // 2. Handle suggestion list navigation
356
- if (!commandList.hidden) {
357
- if (key.name === 'up') {
358
- commandList.up();
359
- screen.render();
360
- return false;
361
- }
362
- if (key.name === 'down') {
363
- commandList.down();
364
- screen.render();
365
- return false;
366
- }
367
- if (key.name === 'escape') {
368
- commandList.hide();
369
- lastListVisible = false;
370
- screen.render();
371
- return false;
372
- }
373
- }
374
-
375
- // 3. Logic for suggestions and placeholder after key is processed
376
- setImmediate(() => {
377
- refreshInputStyles();
378
- const val = (inputBox.getValue ? inputBox.getValue() : inputBox.value) || '';
379
- const isCommand = val.startsWith('/') && !val.includes(' ');
380
-
381
- // Only render if visibility changed or list is updated
382
- if (isCommand) {
383
- updateSuggestions(val);
384
- lastListVisible = true;
385
- } else if (lastListVisible) {
386
- commandList.hide();
387
- lastListVisible = false;
388
- screen.render();
389
- }
390
-
391
- if (!val.trim()) {
392
- showPlaceholder();
393
- } else {
394
- hidePlaceholder();
395
- }
396
- });
397
- });
398
-
399
- inputBox.on('focus', () => {
400
- refreshInputStyles();
401
- screen.render();
402
- });
403
-
404
- inputBox.on('keypress', () => {
405
- applyTerminalInputAttrs();
406
- });
407
-
408
- // Restore focus to inputBox when clicked or when screen is clicked
409
- screen.on('click', () => {
410
- if (!approvalDialog.visible) {
411
- inputBox.focus();
412
- screen.render();
413
- }
414
- });
415
-
416
- inputBox.on('click', () => {
417
- inputBox.focus();
418
- screen.render();
419
- });
420
-
421
-
422
- // Submit or Select Suggestion on Enter
423
- inputBox.on('submit', (value) => {
424
- if (!commandList.hidden) {
425
- const selected = activeSuggestions[commandList.selected];
426
- if (selected) {
427
- inputBox.setValue(selected.name + ' ');
428
- commandList.hide();
429
- hidePlaceholder();
430
- inputBox.focus();
431
- inputBox.readInput(); // Re-focus to continue typing
432
- refreshInputStyles();
433
- screen.render();
434
- return; // Don't submit yet, let user add args or press enter again
435
- }
436
- }
437
-
438
- const raw = value || '';
439
- const text = raw.trim();
440
- if (!text) {
441
- inputBox.clearValue();
442
- showPlaceholder();
443
- inputBox.focus();
444
- inputBox.readInput(); // Re-focus to continue typing
445
- refreshInputStyles();
446
- screen.render();
447
- return;
448
- }
449
-
450
- // Clear input and restore placeholder
451
- inputBox.clearValue();
452
- showPlaceholder();
453
- inputBox.focus();
454
- inputBox.readInput(); // Explicitly restart reading
455
- refreshInputStyles();
456
- screen.render();
457
-
458
- if (text.toLowerCase() === 'exit' || text.toLowerCase() === 'quit') {
459
- onExit();
460
- return;
461
- }
462
-
463
- onSubmit(text);
464
- });
465
-
466
- // Shift+Enter = newline in input
467
- // Ctrl+C — double-press to exit
468
- let ctrlCPressed = false;
469
- let ctrlCTimer = null;
470
- screen.key(['C-c'], () => {
471
- if (ctrlCPressed) {
472
- clearTimeout(ctrlCTimer);
473
- onExit();
474
- } else {
475
- ctrlCPressed = true;
476
- hintBar.setContent(`{bold}{yellow-fg} Press Ctrl+C again to exit.{/} {gray-fg}(or type 'exit'){/}`);
477
- screen.render();
478
- ctrlCTimer = setTimeout(() => {
479
- ctrlCPressed = false;
480
- hintBar.setContent(HINT_DEFAULT);
481
- screen.render();
482
- }, 2000);
483
- }
484
- });
485
-
486
- // ESC — exit immediately
487
- screen.key(['escape'], () => {
488
- onExit();
489
- });
490
-
491
- // ─── Clipboard copy (Ctrl+Y) ──────────────────────────────────────────────
492
- function copyToClipboard(text) {
493
- // Try xclip first, then xsel as fallback
494
- const tools = [
495
- `echo ${JSON.stringify(text)} | xclip -selection clipboard`,
496
- `echo ${JSON.stringify(text)} | xsel --clipboard --input`
497
- ];
498
- for (const cmd of tools) {
499
- try {
500
- execSync(cmd, { stdio: 'pipe' });
501
- return true;
502
- } catch (_) {}
503
- }
504
- return false;
505
- }
506
-
507
- function flashHint(msg, durationMs = 2000) {
508
- hintBar.setContent(msg);
509
- screen.render();
510
- setTimeout(() => {
511
- hintBar.setContent(HINT_DEFAULT);
512
- screen.render();
513
- }, durationMs);
514
- }
515
-
516
- screen.key(['C-y'], () => {
517
- if (!lastAssistantResponse) {
518
- flashHint(`{yellow-fg} No response to copy yet.{/}`);
519
- return;
520
- }
521
- const ok = copyToClipboard(lastAssistantResponse);
522
- if (ok) {
523
- flashHint(`{#88e0b0-fg} ✓ Copied to clipboard!{/}`);
524
- } else {
525
- flashHint(`{red-fg} ✖ Copy failed. Install xclip: sudo apt install xclip{/}`, 3000);
526
- }
527
- });
528
-
529
- // ─── Initial render ───────────────────────────────────────────────────────
530
- inputBox.focus();
531
- inputBox.readInput(); // Initial start
532
- refreshInputStyles();
533
- screen.render();
534
-
535
- // ─── Public API ───────────────────────────────────────────────────────────
536
-
537
- // Track last assistant response for clipboard copy
538
- let lastAssistantResponse = '';
539
-
540
- /**
541
- * @param {'user'|'assistant'|'system'|'error'} role
542
- * @param {string} text
543
- * @param {string} timestamp - ISO string or Date object
544
- */
545
- function wrapLineSmart(line, width) {
546
- if (line.length <= width) return [line];
547
- if (!line.includes(' ')) {
548
- const pieces = [];
549
- for (let index = 0; index < line.length; index += width) {
550
- pieces.push(line.slice(index, index + width));
551
- }
552
- return pieces;
553
- }
554
-
555
- const words = line.split(/\s+/);
556
- const lines = [];
557
- let current = '';
558
- for (const word of words) {
559
- if (word.length > width) {
560
- if (current) {
561
- lines.push(current);
562
- current = '';
563
- }
564
- for (let index = 0; index < word.length; index += width) {
565
- const slice = word.slice(index, index + width);
566
- if (slice.length === width) {
567
- lines.push(slice);
83
+ },
84
+ setThinking: (val) => setThinking(val),
85
+ setMode: (val) => setMode(val),
86
+ updateStatusModel: (val) => setModel(val),
87
+ updateWorkspace: (val) => setWorkspace(val),
88
+ appendCodeStep: (info) => {
89
+ let text = '';
90
+ let label = 'System';
91
+ let labelColor = 'blueBright';
92
+ let isThought = false;
93
+
94
+ if (typeof info === 'string') {
95
+ text = info;
96
+ } else {
97
+ const { action, phase, target, message, thought } = info;
98
+ if (thought) {
99
+ text = thought;
100
+ label = 'Thinking';
101
+ labelColor = 'gray';
102
+ isThought = true;
103
+ } else if (action === 'thinking' || phase === 'thinking') {
104
+ return;
568
105
  } else {
569
- current = slice;
106
+ label = action || phase || 'Action';
107
+ text = target || message || '';
108
+ if (!text) return;
109
+
110
+ // Color coding for specific actions
111
+ if (label.includes('search')) labelColor = 'yellowBright';
112
+ else if (label.includes('file') || label.includes('path')) labelColor = 'cyanBright';
113
+ else if (label.includes('write') || label.includes('edit') || label.includes('patch')) labelColor = 'greenBright';
114
+ else if (label.includes('shell') || label.includes('run')) labelColor = 'magentaBright';
570
115
  }
571
116
  }
572
- continue;
573
- }
574
117
 
575
- if (!current) {
576
- current = word;
577
- continue;
118
+ const fullText = `[${label}] ${text}`;
119
+ if (fullText === lastSystemMessage.current) return;
120
+ lastSystemMessage.current = fullText;
121
+
122
+ setHistory(prev => [...prev, {
123
+ role: 'system',
124
+ label,
125
+ labelColor,
126
+ text,
127
+ isThought,
128
+ time: new Date()
129
+ }]);
578
130
  }
131
+ }));
579
132
 
580
- if (`${current} ${word}`.length <= width) {
581
- current += ` ${word}`;
582
- } else {
583
- lines.push(current);
584
- current = word;
585
- }
586
- }
587
- if (current) lines.push(current);
588
- return lines;
589
- }
590
-
591
- function wrapText(str, width) {
592
- const lines = [];
593
- const originalLines = String(str).split('\n');
594
- for (const line of originalLines) {
595
- if (line.length === 0) {
596
- lines.push('');
597
- continue;
598
- }
599
- lines.push(...wrapLineSmart(line, width));
600
- }
601
- return lines;
602
- }
603
-
604
- function appendMessage(role, text, timestamp = null) {
605
- const now = timestamp ? new Date(timestamp) : new Date();
606
- const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
607
- const maxLineWidth = Math.max(screen.width - 20, 36);
608
- const lines = wrapText(text, maxLineWidth);
609
-
610
- if (role === 'user') {
611
- chatBox.log(``);
612
- chatBox.log(` {bold}{#88e0b0-fg}You{/} {gray-fg}${timeStr}{/}`);
613
- lines.forEach(l => chatBox.log(` {#88e0b0-fg}▏{/} {#ffffff-fg}${l}{/}`));
614
- } else if (role === 'assistant') {
615
- lastAssistantResponse = text;
616
- chatBox.log(``);
617
- chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
618
- lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
619
- } else if (role === 'system') {
620
- const displayTag = text.startsWith('Action:')
621
- ? '{#88e0b0-fg}Action{/}'
622
- : text.startsWith('[Code]')
623
- ? '{#ffd166-fg}Code{/}'
624
- : '{#8ba0ff-fg}System{/}';
625
- const cleanText = text.replace(/^(Action:|System:)\s*/, '');
626
- const systemLines = wrapText(cleanText, maxLineWidth - 4);
627
- chatBox.log(``);
628
- chatBox.log(` {bold}${displayTag}{/}`);
629
- systemLines.forEach(l => chatBox.log(` {#95a2b8-fg}${l}{/}`));
630
- } else if (role === 'error') {
631
- chatBox.log(``);
632
- chatBox.log(` {bold}{#ff6b6b-fg}Error{/} {gray-fg}${timeStr}{/}`);
633
- lines.forEach(l => chatBox.log(` {#7a2e2e-fg}▏{/} {#ff7d7d-fg}${l}{/}`));
634
- }
635
- screen.render();
636
- }
637
-
638
- /**
639
- * Opens a streaming message bubble for the assistant.
640
- * Returns { appendChunk(text), finalize(timestamp) } for typewriter rendering.
641
- * Usage:
642
- * const stream = streamMessage('assistant');
643
- * stream.appendChunk('Hello'); stream.appendChunk(' World');
644
- * stream.finalize(timestamp);
645
- */
646
- function streamMessage(role = 'assistant') {
647
- const now = new Date();
648
- const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
649
- const maxLineWidth = Math.max(screen.width - 20, 36);
650
-
651
- // Print the header bubble once
652
- chatBox.log('');
653
- if (role === 'assistant') {
654
- chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
655
- }
656
-
657
- let buffer = ''; // accumulates the full response text
658
- let lineBuffer = ''; // current partial line being built
659
- let lineRendered = false; // whether we already pushed the first line prefix
660
-
661
- function flushLine(force = false) {
662
- // Flush content that fits on one line-width or when forced
663
- if (!lineBuffer && !force) return;
664
- if (!lineRendered) {
665
- chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
666
- lineRendered = true;
667
- } else {
668
- // Overwrite the last line by popping + re-pushing (blessed.log limitation)
669
- // We can't truly overwrite, so we just keep appending new lines for each chunk.
670
- // For large chunks, split on newline and emit per-line.
671
- chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
133
+ // Handle exiting and keyboard navigation
134
+ useInput((inputStr, key) => {
135
+ if (key.escape || (key.ctrl && inputStr === 'c')) {
136
+ onExit();
137
+ exit();
672
138
  }
673
- screen.render();
674
- }
675
139
 
676
- function appendChunk(text) {
677
- if (!text) return;
678
- buffer += text;
679
- const segments = text.split('\n');
680
- for (let i = 0; i < segments.length; i++) {
681
- lineBuffer += segments[i];
682
- if (i < segments.length - 1) {
683
- // Newline boundary emit current line
684
- const lines = wrapLineSmart(lineBuffer, maxLineWidth);
685
- lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
686
- lineBuffer = '';
687
- lineRendered = true;
688
- screen.render();
689
- } else if (lineBuffer.length >= maxLineWidth) {
690
- // Line overflow — auto-wrap
691
- const lines = wrapLineSmart(lineBuffer, maxLineWidth);
692
- lines.slice(0, -1).forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
693
- lineBuffer = lines[lines.length - 1] || '';
694
- lineRendered = true;
695
- screen.render();
696
- }
697
- // Otherwise keep buffering the partial line
698
- }
699
- }
700
-
701
- function finalize(timestamp = null) {
702
- // Flush remaining buffer
703
- if (lineBuffer) {
704
- const lines = wrapLineSmart(lineBuffer, maxLineWidth);
705
- lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
706
- lineBuffer = '';
707
- }
708
- // Track last response for clipboard
709
- lastAssistantResponse = buffer;
710
- screen.render();
711
- }
712
-
713
- return { appendChunk, finalize };
714
- }
715
-
716
- function appendCodeStep(info) {
717
- if (typeof info === 'string') {
718
- appendMessage('system', `[Code] ${info}`);
719
- return;
720
- }
721
-
722
- const { step, phase, action, target, message, thought } = info;
723
- const maxLineWidth = Math.max(screen.width - 20, 36);
724
-
725
- // Special handling for ask_user which needs a box style
726
- if (action === 'ask_user') {
727
- chatBox.log('');
728
- chatBox.log(` {#88e0b0-fg}✓{/} {bold}Ask User{/}`);
729
- const questionLines = wrapText(target || message || '', maxLineWidth - 6);
730
- questionLines.forEach(l => chatBox.log(` {#95a2b8-fg}${l}{/}`));
731
- screen.render();
732
- return;
733
- }
734
-
735
- let icon = '{#88e0b0-fg}✓{/}';
736
- let label = action || phase;
737
- let color = '{#ffffff-fg}';
738
-
739
- // Map internal action names to display names seen in the screenshot
740
- switch (action) {
741
- case 'thinking':
742
- if (phase === 'thinking' && !thought) {
743
- // Initial "Thinking..." without a bubble
744
- chatBox.log('');
745
- chatBox.log(` {#ffd166-fg}* {bold}Thinking{/}`);
746
- } else if (thought) {
747
- // Show reasoning bubble
748
- const thoughtLines = wrapText(thought, maxLineWidth - 6);
749
- thoughtLines.forEach(l => chatBox.log(` {gray-fg}> ${l}{/}`));
140
+ const currentInput = inputRef.current;
141
+ const currentShowSuggestions = currentInput.startsWith('/') && !currentInput.includes(' ');
142
+
143
+ if (currentShowSuggestions) {
144
+ const query = currentInput.toLowerCase();
145
+ const currentSuggestions = SLASH_COMMANDS.filter(s => s.cmd.startsWith(query));
146
+
147
+ if (currentSuggestions.length > 0) {
148
+ if (key.upArrow) {
149
+ setSelectedIndex(prev => (prev > 0 ? prev - 1 : currentSuggestions.length - 1));
150
+ } else if (key.downArrow) {
151
+ setSelectedIndex(prev => (prev < currentSuggestions.length - 1 ? prev + 1 : 0));
152
+ } else if (key.tab || (key.return && currentInput.startsWith('/'))) {
153
+ const picked = currentSuggestions[selectedIndexRef.current];
154
+ if (picked) {
155
+ setInput(picked.cmd + ' ');
156
+ }
157
+ }
750
158
  }
751
- screen.render();
752
- return;
753
- case 'ask_user': label = 'AskUser'; break;
754
- case 'open_url': label = 'OpenURL'; break;
755
- case 'open_app': label = 'OpenApp'; break;
756
- case 'open_file': label = 'OpenFile'; break;
757
- case 'open_folder': label = 'OpenFolder'; break;
758
- case 'create_folder': label = 'CreateFolder'; break;
759
- case 'system_info': label = 'SystemInfo'; break;
760
- case 'system_automation': label = 'SystemAction'; break;
761
- case 'web_search': label = 'WebSearch'; break;
762
- case 'list_files':
763
- case 'find_path': label = 'Explored'; break;
764
- case 'read_file': label = 'ReadFile'; break;
765
- case 'search_code': label = 'SearchText'; break;
766
- case 'apply_patch':
767
- case 'write_file': label = 'Edited'; break;
768
- case 'run_shell': label = 'Ran command'; break;
769
- case 'json_repair': icon = '*'; label = 'Repairing JSON'; color = '{#ffd166-fg}'; break;
770
- case 'reviewer_start': label = 'Reviewing'; break;
771
- }
772
-
773
- const content = target || message || '';
774
- chatBox.log(` ${icon} {bold}${label}{/} ${color}${content}{/}`);
775
- screen.render();
776
- }
777
-
778
- /** Show/hide thinking indicator in status bar */
779
- function setThinking(active, secondsElapsed = 0) {
780
- if (active) {
781
- updateStatusBar('Thinking...', secondsElapsed);
782
- } else {
783
- updateStatusBar(null);
784
- }
785
- }
786
-
787
- /** Copy last assistant response to clipboard */
788
- function copyLastResponse() {
789
- if (!lastAssistantResponse) return false;
790
- return copyToClipboard(lastAssistantResponse);
791
- }
792
-
793
- function requestApproval(request) {
794
- return new Promise((resolve) => {
795
- const typeLabel = request.type === 'shell'
796
- ? 'Shell Command'
797
- : request.type === 'patch'
798
- ? 'Patch Edit'
799
- : request.type === 'code_mode'
800
- ? 'Enter Code Mode'
801
- : 'File Write';
802
- const preview = request.preview || request.label || '';
803
- const message = [
804
- `{bold}${typeLabel}{/bold}`,
805
- '',
806
- preview,
807
- '',
808
- 'Approve this action?',
809
- '', // Extra lines to push buttons down and avoid overlapping
810
- ''
811
- ].join('\n');
812
-
813
- // Temporarily stop reading input so the dialog can receive keys
814
- if (inputBox._reading) {
815
- inputBox.cancel();
816
159
  }
817
-
818
- approvalDialog.ask(message, (approved) => {
819
- inputBox.focus();
820
- inputBox.readInput(); // Ensure we resume reading after dialog
821
- refreshInputStyles();
822
- screen.render();
823
- resolve(Boolean(approved));
824
- });
825
160
  });
826
- }
827
161
 
828
- function askUser(question) {
829
- return new Promise((resolve) => {
830
- // Temporarily stop reading input so we can capture the answer
831
- if (inputBox._reading) {
832
- inputBox.cancel();
162
+ const handleSubmit = (value) => {
163
+ const text = value.trim();
164
+ if (!text) return;
165
+
166
+ if (showSuggestions && suggestions.length > 0) {
167
+ const picked = suggestions[selectedIndex];
168
+ if (picked && text !== picked.cmd) {
169
+ setInput(picked.cmd + ' ');
170
+ return;
171
+ }
833
172
  }
834
173
 
835
- // We use a simple textbox floating over the chat or reuse the main input?
836
- // Reusing the main input is cleaner for CLI.
837
- // But we need to change the label to ' Answer '
838
- const oldLabel = inputBox._label.content;
839
- inputBox._label.setContent(' Answer ');
840
-
841
- // Clear input for the answer
842
- inputBox.clearValue();
843
- hidePlaceholder();
844
- inputBox.focus();
845
- inputBox.readInput();
846
- screen.render();
847
-
848
- const submitHandler = (value) => {
849
- inputBox.removeListener('submit', submitHandler);
850
- inputBox._label.setContent(oldLabel);
174
+ setInput('');
175
+ onSubmit(text);
176
+ };
177
+
178
+ return h(Box, { flexDirection: 'column', paddingX: 1, width: '100%' },
179
+ // Static History: Messages
180
+ h(Static, { items: history }, (msg, index) => {
181
+ if (msg.isThought) {
182
+ return h(Box, { key: index, flexDirection: 'row', marginBottom: 0, paddingLeft: 2 },
183
+ h(Text, { color: 'gray', dimColor: true }, `Thinking: ${msg.text}`)
184
+ );
185
+ }
186
+
187
+ let name = 'Mint';
188
+ let nameColor = 'greenBright';
851
189
 
852
- const answer = value || '';
853
- chatBox.log('');
854
- chatBox.log(` {bold}User answered:{/}`);
855
- const lines = wrapText(answer, screen.width - 20);
856
- lines.forEach(l => chatBox.log(` {#95a2b8-fg}${l}{/}`));
857
- screen.render();
858
-
859
- resolve(answer);
860
- };
190
+ if (msg.role === 'user') {
191
+ name = 'You';
192
+ nameColor = 'cyanBright';
193
+ } else if (msg.role === 'error') {
194
+ name = 'Error';
195
+ nameColor = 'redBright';
196
+ } else if (msg.role === 'system') {
197
+ name = msg.label || 'System';
198
+ nameColor = msg.labelColor || 'blueBright';
199
+ }
861
200
 
862
- inputBox.on('submit', submitHandler);
863
- });
864
- }
201
+ return h(Box, { key: index, flexDirection: 'column', marginBottom: 0 },
202
+ h(Box, null,
203
+ h(Text, { bold: true, color: nameColor }, name),
204
+ h(Text, { color: 'gray' }, ` ${msg.time instanceof Date ? msg.time.toLocaleTimeString() : ''}`)
205
+ ),
206
+ h(Box, { paddingLeft: 2, marginBottom: 1 },
207
+ h(Text, null, msg.text)
208
+ )
209
+ );
210
+ }),
211
+
212
+ // Floating (Persistent) UI part
213
+ h(Box, { flexDirection: 'column' },
214
+ thinking && h(Box, { marginBottom: 1 },
215
+ h(Text, { color: 'yellow' }, '● Mint is thinking...')
216
+ ),
217
+
218
+ // Suggestions Menu
219
+ showSuggestions && suggestions.length > 0 && h(Box, {
220
+ flexDirection: 'column',
221
+ borderStyle: 'single',
222
+ borderColor: 'gray',
223
+ paddingX: 1,
224
+ marginBottom: 0
225
+ },
226
+ suggestions.map((s, i) => h(Box, { key: s.cmd, flexDirection: 'row' },
227
+ h(Text, {
228
+ backgroundColor: i === selectedIndex ? 'green' : undefined,
229
+ color: i === selectedIndex ? 'white' : 'greenBright'
230
+ }, s.cmd.padEnd(12)),
231
+ h(Text, { color: 'gray' }, ` ${s.desc}`)
232
+ ))
233
+ ),
234
+
235
+ // Compact Input Area
236
+ h(Box, { borderStyle: 'round', borderColor: 'greenBright', paddingX: 1, flexDirection: 'row' },
237
+ h(Text, { bold: true, color: 'greenBright' }, '› '),
238
+ h(TextInput, {
239
+ value: input,
240
+ onChange: setInput,
241
+ onSubmit: handleSubmit,
242
+ placeholder: 'Ask anything...'
243
+ })
244
+ ),
245
+
246
+ // Status Bar
247
+ h(Box, { justifyContent: 'space-between' },
248
+ h(Box, null,
249
+ h(Text, { color: 'cyan' }, `[${mode}] `),
250
+ h(Text, { color: 'magentaBright' }, (model || config.geminiModel || 'gemini').slice(0, 46))
251
+ ),
252
+ h(Box, null,
253
+ h(Text, { color: 'gray' }, `path: ...${workspace.slice(-20)}`)
254
+ )
255
+ )
256
+ )
257
+ );
258
+ });
865
259
 
866
- return { screen, appendMessage, streamMessage, setThinking, updateStatusModel, copyLastResponse, requestApproval, setMode, appendCodeStep, updateWorkspace, askUser };
260
+ // Print banner once before rendering the main app-
261
+ console.log(`\x1b[38;5;121m\x1b[1m __ __ _ _ ___ _ ___ \x1b[0m`);
262
+ console.log(`\x1b[38;5;121m\x1b[1m| \\/ (_)_ __ | |_ / __| | |_ _|\x1b[0m`);
263
+ console.log(`\x1b[38;5;121m\x1b[1m| |\\/| | | '_ \\| _| (__| |__ | | \x1b[0m`);
264
+ console.log(`\x1b[38;5;121m\x1b[1m|_| |_|_|_| |_|\\__|\\___|____|___|\x1b[0m`);
265
+ console.log(`\x1b[90mType naturally to chat. Esc to exit.\x1b[0m\n`);
266
+
267
+ const ref = createRef();
268
+ render(h(App, { ref, ...options }));
269
+
270
+ return {
271
+ appendMessage: (role, text, metadata) => ref.current?.appendMessage(role, text, metadata),
272
+ setThinking: (val) => ref.current?.setThinking(val),
273
+ setMode: (val) => ref.current?.setMode(val),
274
+ updateStatusModel: (val) => ref.current?.updateStatusModel(val),
275
+ updateWorkspace: (val) => ref.current?.updateWorkspace(val),
276
+ appendCodeStep: (info) => ref.current?.appendCodeStep(info),
277
+ streamMessage: () => {
278
+ let fullText = '';
279
+ return {
280
+ appendChunk: (chunk) => {
281
+ fullText += chunk;
282
+ },
283
+ finalize: () => {
284
+ ref.current?.appendMessage('assistant', fullText);
285
+ }
286
+ };
287
+ },
288
+ copyLastResponse: () => false,
289
+ requestApproval: () => Promise.resolve(true),
290
+ askUser: () => Promise.resolve('')
291
+ };
867
292
  }
868
293
 
869
294
  module.exports = { createChatUI };