@pheem49/mint 1.4.1 → 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 (61) hide show
  1. package/GUIDE_TH.md +113 -0
  2. package/README.md +214 -142
  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 +15 -8
  9. package/mint-cli.js +305 -195
  10. package/package.json +12 -4
  11. package/src/AI_Brain/Gemini_API.js +77 -20
  12. package/src/AI_Brain/agent_orchestrator.js +6 -6
  13. package/src/AI_Brain/autonomous_brain.js +10 -0
  14. package/src/AI_Brain/behavior_memory.js +26 -5
  15. package/src/AI_Brain/headless_agent.js +4 -0
  16. package/src/AI_Brain/knowledge_base.js +61 -8
  17. package/src/AI_Brain/memory_store.js +55 -7
  18. package/src/Automation_Layer/file_operations.js +14 -3
  19. package/src/CLI/chat_router.js +21 -7
  20. package/src/CLI/chat_ui.js +264 -710
  21. package/src/CLI/code_agent.js +370 -124
  22. package/src/CLI/gmail_auth.js +210 -0
  23. package/src/CLI/list_features.js +5 -1
  24. package/src/CLI/onboarding.js +307 -55
  25. package/src/CLI/updater.js +208 -0
  26. package/src/Channels/brave_search_bridge.js +35 -0
  27. package/src/Channels/discord_bridge.js +68 -0
  28. package/src/Channels/google_search_bridge.js +38 -0
  29. package/src/Channels/line_bridge.js +60 -0
  30. package/src/Channels/slack_bridge.js +53 -0
  31. package/src/Channels/telegram_bridge.js +49 -0
  32. package/src/Channels/whatsapp_bridge.js +55 -0
  33. package/src/Command_Parser/parser.js +12 -1
  34. package/src/Plugins/gmail.js +251 -0
  35. package/src/Plugins/google_calendar.js +245 -19
  36. package/src/Plugins/notion.js +256 -0
  37. package/src/System/action_executor.js +129 -0
  38. package/src/System/bridge_manager.js +76 -0
  39. package/src/System/chat_history_manager.js +23 -5
  40. package/src/System/config_manager.js +41 -7
  41. package/src/System/custom_workflows.js +31 -2
  42. package/src/System/google_tts_urls.js +51 -0
  43. package/src/System/ipc_handlers.js +238 -0
  44. package/src/System/proactive_loop.js +137 -0
  45. package/src/System/safety_manager.js +165 -0
  46. package/src/System/screen_capture.js +175 -0
  47. package/src/System/task_manager.js +15 -5
  48. package/src/System/window_manager.js +210 -0
  49. package/src/UI/renderer.js +33 -7
  50. package/src/UI/settings.html +24 -0
  51. package/src/UI/settings.js +14 -4
  52. package/src/UI/styles.css +14 -1
  53. package/tests/action_executor_safety.test.js +67 -0
  54. package/tests/gmail.test.js +135 -0
  55. package/tests/gmail_auth.test.js +129 -0
  56. package/tests/google_calendar.test.js +113 -0
  57. package/tests/google_tts_urls.test.js +24 -0
  58. package/tests/notion.test.js +121 -0
  59. package/tests/provider_routing.test.js +17 -1
  60. package/tests/safety_manager.test.js +40 -0
  61. package/tests/updater.test.js +32 -0
@@ -1,740 +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 workspaceName = path.basename(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);
82
+ }
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;
105
+ } else {
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';
115
+ }
165
116
  }
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 (3 columns: left / center / right) ──────────────────────
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
- // Left: workspace info
190
- const statusLeft = blessed.text({
191
- parent: statusBar,
192
- top: 0, left: 1,
193
- width: '33%',
194
- height: 1,
195
- tags: true,
196
- content: ` workspace {bold}(${workspaceName}){/bold}`,
197
- style: { bg: '#10141c', fg: '#93a0b7' }
198
- });
199
-
200
- // Center: mode + status
201
- const statusCenter = blessed.text({
202
- parent: statusBar,
203
- top: 0,
204
- left: 'center',
205
- width: '44%',
206
- height: 1,
207
- align: 'center',
208
- tags: true,
209
- content: `{#88aaff-fg}[Chat]{/} {#cc4444-fg}no sandbox{/}`,
210
- style: { bg: '#10141c', fg: '#888888' }
211
- });
212
-
213
- // Right: current model
214
- const statusRight = blessed.text({
215
- parent: statusBar,
216
- top: 0, right: 1,
217
- width: '33%',
218
- height: 1,
219
- align: 'right',
220
- tags: true,
221
- content: `{#88e0b0-fg}${modelName}{/}`,
222
- style: { bg: '#10141c', fg: '#88e0b0' }
223
- });
224
117
 
225
- let activeMode = 'Chat';
226
-
227
- function formatModeTag(mode) {
228
- if (mode === 'Code') return `{#ffd166-fg}[Code]{/}`;
229
- return `{#88aaff-fg}[Chat]{/}`;
230
- }
231
-
232
- function updateStatusBar(thinkingText = null) {
233
- if (thinkingText) {
234
- statusCenter.setContent(`${formatModeTag(activeMode)} {#88e0b0-fg}${thinkingText}{/}`);
235
- } else {
236
- statusCenter.setContent(`${formatModeTag(activeMode)} {#cc4444-fg}no sandbox{/}`);
237
- }
238
- screen.render();
239
- }
240
-
241
- function setMode(mode) {
242
- activeMode = mode === 'Code' ? 'Code' : 'Chat';
243
- updateStatusBar(null);
244
- }
245
-
246
- /** Update model name in status bar (called after /models switch) */
247
- function updateStatusModel(newModel) {
248
- if (!newModel) return;
249
- statusRight.setContent(`{#88e0b0-fg}${newModel}{/}`);
250
- screen.render();
251
- }
252
- updateStatusBar();
253
-
254
- // ─── Append widgets to screen ─────────────────────────────────────────────
255
- screen.append(banner);
256
- screen.append(subBanner);
257
- screen.append(chatBox);
258
- screen.append(hintBar);
259
- screen.append(inputBox);
260
- screen.append(statusBar);
261
- screen.append(placeholderWidget); // sibling on top of inputBox
262
-
263
- // ─── Suggestion List ──────────────────────────────────────────────────────
264
- const commandList = blessed.list({
265
- parent: screen,
266
- bottom: 6,
267
- left: 3,
268
- width: '64%',
269
- height: 8,
270
- tags: true,
271
- keys: false, // We will handle keys manually to keep focus on input
272
- vi: false,
273
- hidden: true,
274
- border: { type: 'line', fg: '#88e0b0' },
275
- style: {
276
- bg: '#10141c',
277
- fg: '#ffffff',
278
- selected: {
279
- bg: '#22352f',
280
- fg: '#88e0b0',
281
- bold: true
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
+ }]);
282
130
  }
283
- }
284
- });
285
-
286
- let activeSuggestions = [];
287
- const approvalDialog = blessed.question({
288
- parent: screen,
289
- tags: true,
290
- border: { type: 'line', fg: '#88e0b0' },
291
- style: {
292
- bg: '#10141c',
293
- fg: '#ffffff',
294
- border: { fg: '#88e0b0' }
295
- },
296
- width: '80%',
297
- height: 12, // Fixed height to avoid 'shrink' miscalculation with buttons
298
- top: 'center',
299
- left: 'center',
300
- label: ' Approval ',
301
- hidden: true
302
- });
131
+ }));
303
132
 
304
- function updateSuggestions(filter = '') {
305
- activeSuggestions = SLASH_COMMANDS.filter(cmd =>
306
- cmd.name.toLowerCase().startsWith(filter.toLowerCase())
307
- );
308
-
309
- if (activeSuggestions.length === 0) {
310
- commandList.hide();
311
- screen.render();
312
- return;
313
- }
314
-
315
- const items = activeSuggestions.map(cmd =>
316
- ` {bold}${cmd.name}{/} {gray-fg}${cmd.desc}{/}`
317
- );
318
- commandList.setItems(items);
319
- commandList.select(0);
320
- commandList.show();
321
- commandList.setFront();
322
- screen.render();
323
- }
324
-
325
-
326
- // ─── Input events ─────────────────────────────────────────────────────────
327
-
328
- // ─── Input events ─────────────────────────────────────────────────────────
329
- let lastListVisible = false;
330
-
331
- // Consolidated key handling
332
- inputBox.on('element keypress', (el, ch, key) => {
333
- refreshInputStyles();
334
- // 1. Handle placeholder visibility
335
- if (!key.ctrl && !key.meta && key.name !== 'enter' && key.name !== 'tab') {
336
- if (ch) hidePlaceholder();
337
- }
338
-
339
- // 2. Handle suggestion list navigation
340
- if (!commandList.hidden) {
341
- if (key.name === 'up') {
342
- commandList.up();
343
- screen.render();
344
- return false;
345
- }
346
- if (key.name === 'down') {
347
- commandList.down();
348
- screen.render();
349
- return false;
350
- }
351
- if (key.name === 'escape') {
352
- commandList.hide();
353
- lastListVisible = false;
354
- screen.render();
355
- return false;
356
- }
357
- }
358
-
359
- // 3. Logic for suggestions and placeholder after key is processed
360
- setImmediate(() => {
361
- refreshInputStyles();
362
- const val = (inputBox.getValue ? inputBox.getValue() : inputBox.value) || '';
363
- const isCommand = val.startsWith('/') && !val.includes(' ');
364
-
365
- // Only render if visibility changed or list is updated
366
- if (isCommand) {
367
- updateSuggestions(val);
368
- lastListVisible = true;
369
- } else if (lastListVisible) {
370
- commandList.hide();
371
- lastListVisible = false;
372
- screen.render();
133
+ // Handle exiting and keyboard navigation
134
+ useInput((inputStr, key) => {
135
+ if (key.escape || (key.ctrl && inputStr === 'c')) {
136
+ onExit();
137
+ exit();
373
138
  }
374
139
 
375
- if (!val.trim()) {
376
- showPlaceholder();
377
- } else {
378
- hidePlaceholder();
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
+ }
158
+ }
379
159
  }
380
160
  });
381
- });
382
-
383
- inputBox.on('focus', () => {
384
- refreshInputStyles();
385
- screen.render();
386
- });
387
-
388
- inputBox.on('keypress', () => {
389
- applyTerminalInputAttrs();
390
- });
391
-
392
161
 
393
- // Submit or Select Suggestion on Enter
394
- inputBox.on('submit', (value) => {
395
- if (!commandList.hidden) {
396
- const selected = activeSuggestions[commandList.selected];
397
- if (selected) {
398
- inputBox.setValue(selected.name + ' ');
399
- commandList.hide();
400
- hidePlaceholder();
401
- inputBox.focus();
402
- inputBox.readInput(); // Re-focus to continue typing
403
- refreshInputStyles();
404
- screen.render();
405
- return; // Don't submit yet, let user add args or press enter again
406
- }
407
- }
408
-
409
- const raw = value || '';
410
- const text = raw.trim();
411
- if (!text) {
412
- inputBox.clearValue();
413
- showPlaceholder();
414
- inputBox.focus();
415
- inputBox.readInput(); // Re-focus to continue typing
416
- refreshInputStyles();
417
- screen.render();
418
- return;
419
- }
420
-
421
- // Clear input and restore placeholder
422
- inputBox.clearValue();
423
- showPlaceholder();
424
- inputBox.focus();
425
- inputBox.readInput(); // Explicitly restart reading
426
- refreshInputStyles();
427
- screen.render();
428
-
429
- if (text.toLowerCase() === 'exit' || text.toLowerCase() === 'quit') {
430
- onExit();
431
- return;
432
- }
433
-
434
- onSubmit(text);
435
- });
436
-
437
- // Shift+Enter = newline in input
438
- // Ctrl+C — double-press to exit
439
- let ctrlCPressed = false;
440
- let ctrlCTimer = null;
441
- screen.key(['C-c'], () => {
442
- if (ctrlCPressed) {
443
- clearTimeout(ctrlCTimer);
444
- onExit();
445
- } else {
446
- ctrlCPressed = true;
447
- hintBar.setContent(`{bold}{yellow-fg} Press Ctrl+C again to exit.{/} {gray-fg}(or type 'exit'){/}`);
448
- screen.render();
449
- ctrlCTimer = setTimeout(() => {
450
- ctrlCPressed = false;
451
- hintBar.setContent(HINT_DEFAULT);
452
- screen.render();
453
- }, 2000);
454
- }
455
- });
456
-
457
- // ESC — exit immediately
458
- screen.key(['escape'], () => {
459
- onExit();
460
- });
461
-
462
- // ─── Clipboard copy (Ctrl+Y) ──────────────────────────────────────────────
463
- function copyToClipboard(text) {
464
- // Try xclip first, then xsel as fallback
465
- const tools = [
466
- `echo ${JSON.stringify(text)} | xclip -selection clipboard`,
467
- `echo ${JSON.stringify(text)} | xsel --clipboard --input`
468
- ];
469
- for (const cmd of tools) {
470
- try {
471
- execSync(cmd, { stdio: 'pipe' });
472
- return true;
473
- } catch (_) {}
474
- }
475
- return false;
476
- }
477
-
478
- function flashHint(msg, durationMs = 2000) {
479
- hintBar.setContent(msg);
480
- screen.render();
481
- setTimeout(() => {
482
- hintBar.setContent(HINT_DEFAULT);
483
- screen.render();
484
- }, durationMs);
485
- }
486
-
487
- screen.key(['C-y'], () => {
488
- if (!lastAssistantResponse) {
489
- flashHint(`{yellow-fg} No response to copy yet.{/}`);
490
- return;
491
- }
492
- const ok = copyToClipboard(lastAssistantResponse);
493
- if (ok) {
494
- flashHint(`{#88e0b0-fg} ✓ Copied to clipboard!{/}`);
495
- } else {
496
- flashHint(`{red-fg} ✖ Copy failed. Install xclip: sudo apt install xclip{/}`, 3000);
497
- }
498
- });
162
+ const handleSubmit = (value) => {
163
+ const text = value.trim();
164
+ if (!text) return;
499
165
 
500
- // ─── Initial render ───────────────────────────────────────────────────────
501
- inputBox.focus();
502
- inputBox.readInput(); // Initial start
503
- refreshInputStyles();
504
- screen.render();
505
-
506
- // ─── Public API ───────────────────────────────────────────────────────────
507
-
508
- // Track last assistant response for clipboard copy
509
- let lastAssistantResponse = '';
510
-
511
- /**
512
- * @param {'user'|'assistant'|'system'|'error'} role
513
- * @param {string} text
514
- * @param {string} timestamp - ISO string or Date object
515
- */
516
- function wrapLineSmart(line, width) {
517
- if (line.length <= width) return [line];
518
- if (!line.includes(' ')) {
519
- const pieces = [];
520
- for (let index = 0; index < line.length; index += width) {
521
- pieces.push(line.slice(index, index + width));
522
- }
523
- return pieces;
524
- }
525
-
526
- const words = line.split(/\s+/);
527
- const lines = [];
528
- let current = '';
529
- for (const word of words) {
530
- if (word.length > width) {
531
- if (current) {
532
- lines.push(current);
533
- current = '';
166
+ if (showSuggestions && suggestions.length > 0) {
167
+ const picked = suggestions[selectedIndex];
168
+ if (picked && text !== picked.cmd) {
169
+ setInput(picked.cmd + ' ');
170
+ return;
534
171
  }
535
- for (let index = 0; index < word.length; index += width) {
536
- const slice = word.slice(index, index + width);
537
- if (slice.length === width) {
538
- lines.push(slice);
539
- } else {
540
- current = slice;
541
- }
542
- }
543
- continue;
544
- }
545
-
546
- if (!current) {
547
- current = word;
548
- continue;
549
172
  }
550
173
 
551
- if (`${current} ${word}`.length <= width) {
552
- current += ` ${word}`;
553
- } else {
554
- lines.push(current);
555
- current = word;
556
- }
557
- }
558
- if (current) lines.push(current);
559
- return lines;
560
- }
561
-
562
- function wrapText(str, width) {
563
- const lines = [];
564
- const originalLines = String(str).split('\n');
565
- for (const line of originalLines) {
566
- if (line.length === 0) {
567
- lines.push('');
568
- continue;
569
- }
570
- lines.push(...wrapLineSmart(line, width));
571
- }
572
- return lines;
573
- }
574
-
575
- function appendMessage(role, text, timestamp = null) {
576
- const now = timestamp ? new Date(timestamp) : new Date();
577
- const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
578
- const maxLineWidth = Math.max(screen.width - 20, 36);
579
- const lines = wrapText(text, maxLineWidth);
580
-
581
- if (role === 'user') {
582
- chatBox.log(``);
583
- chatBox.log(` {bold}{#88e0b0-fg}You{/} {gray-fg}${timeStr}{/}`);
584
- lines.forEach(l => chatBox.log(` {#88e0b0-fg}▏{/} {#ffffff-fg}${l}{/}`));
585
- } else if (role === 'assistant') {
586
- lastAssistantResponse = text;
587
- chatBox.log(``);
588
- chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
589
- lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
590
- } else if (role === 'system') {
591
- const displayTag = text.startsWith('Action:')
592
- ? '{#88e0b0-fg}Action{/}'
593
- : text.startsWith('[Code]')
594
- ? '{#ffd166-fg}Code{/}'
595
- : '{#8ba0ff-fg}System{/}';
596
- const cleanText = text.replace(/^(Action:|System:)\s*/, '');
597
- const systemLines = wrapText(cleanText, maxLineWidth - 4);
598
- chatBox.log(``);
599
- chatBox.log(` {bold}${displayTag}{/}`);
600
- systemLines.forEach(l => chatBox.log(` {#95a2b8-fg}${l}{/}`));
601
- } else if (role === 'error') {
602
- chatBox.log(``);
603
- chatBox.log(` {bold}{#ff6b6b-fg}Error{/} {gray-fg}${timeStr}{/}`);
604
- lines.forEach(l => chatBox.log(` {#7a2e2e-fg}▏{/} {#ff7d7d-fg}${l}{/}`));
605
- }
606
- screen.render();
607
- }
608
-
609
- /**
610
- * Opens a streaming message bubble for the assistant.
611
- * Returns { appendChunk(text), finalize(timestamp) } for typewriter rendering.
612
- * Usage:
613
- * const stream = streamMessage('assistant');
614
- * stream.appendChunk('Hello'); stream.appendChunk(' World');
615
- * stream.finalize(timestamp);
616
- */
617
- function streamMessage(role = 'assistant') {
618
- const now = new Date();
619
- const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
620
- const maxLineWidth = Math.max(screen.width - 20, 36);
621
-
622
- // Print the header bubble once
623
- chatBox.log('');
624
- if (role === 'assistant') {
625
- chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
626
- }
627
-
628
- let buffer = ''; // accumulates the full response text
629
- let lineBuffer = ''; // current partial line being built
630
- let lineRendered = false; // whether we already pushed the first line prefix
631
-
632
- function flushLine(force = false) {
633
- // Flush content that fits on one line-width or when forced
634
- if (!lineBuffer && !force) return;
635
- if (!lineRendered) {
636
- chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
637
- lineRendered = true;
638
- } else {
639
- // Overwrite the last line by popping + re-pushing (blessed.log limitation)
640
- // We can't truly overwrite, so we just keep appending new lines for each chunk.
641
- // For large chunks, split on newline and emit per-line.
642
- chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
643
- }
644
- screen.render();
645
- }
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
+ }
646
186
 
647
- function appendChunk(text) {
648
- if (!text) return;
649
- buffer += text;
650
- const segments = text.split('\n');
651
- for (let i = 0; i < segments.length; i++) {
652
- lineBuffer += segments[i];
653
- if (i < segments.length - 1) {
654
- // Newline boundary — emit current line
655
- const lines = wrapLineSmart(lineBuffer, maxLineWidth);
656
- lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
657
- lineBuffer = '';
658
- lineRendered = true;
659
- screen.render();
660
- } else if (lineBuffer.length >= maxLineWidth) {
661
- // Line overflow — auto-wrap
662
- const lines = wrapLineSmart(lineBuffer, maxLineWidth);
663
- lines.slice(0, -1).forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
664
- lineBuffer = lines[lines.length - 1] || '';
665
- lineRendered = true;
666
- screen.render();
187
+ let name = 'Mint';
188
+ let nameColor = 'greenBright';
189
+
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';
667
199
  }
668
- // Otherwise keep buffering the partial line
669
- }
670
- }
671
-
672
- function finalize(timestamp = null) {
673
- // Flush remaining buffer
674
- if (lineBuffer) {
675
- const lines = wrapLineSmart(lineBuffer, maxLineWidth);
676
- lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
677
- lineBuffer = '';
678
- }
679
- // Track last response for clipboard
680
- lastAssistantResponse = buffer;
681
- screen.render();
682
- }
683
-
684
- return { appendChunk, finalize };
685
- }
686
-
687
- /** Show/hide thinking indicator in status bar */
688
- function setThinking(active, secondsElapsed = 0) {
689
- if (active) {
690
- updateStatusBar(`Thinking... {gray-fg}(esc to cancel, ${secondsElapsed}s){/}`);
691
- } else {
692
- updateStatusBar(null);
693
- }
694
- }
695
-
696
- /** Copy last assistant response to clipboard */
697
- function copyLastResponse() {
698
- if (!lastAssistantResponse) return false;
699
- return copyToClipboard(lastAssistantResponse);
700
- }
701
-
702
- function requestApproval(request) {
703
- return new Promise((resolve) => {
704
- const typeLabel = request.type === 'shell'
705
- ? 'Shell Command'
706
- : request.type === 'patch'
707
- ? 'Patch Edit'
708
- : request.type === 'code_mode'
709
- ? 'Enter Code Mode'
710
- : 'File Write';
711
- const preview = request.preview || request.label || '';
712
- const message = [
713
- `{bold}${typeLabel}{/bold}`,
714
- '',
715
- preview,
716
- '',
717
- 'Approve this action?',
718
- '', // Extra lines to push buttons down and avoid overlapping
719
- ''
720
- ].join('\n');
721
-
722
- // Temporarily stop reading input so the dialog can receive keys
723
- if (inputBox._reading) {
724
- inputBox.cancel();
725
- }
726
200
 
727
- approvalDialog.ask(message, (approved) => {
728
- inputBox.focus();
729
- inputBox.readInput(); // Ensure we resume reading after dialog
730
- refreshInputStyles();
731
- screen.render();
732
- resolve(Boolean(approved));
733
- });
734
- });
735
- }
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
+ });
736
259
 
737
- return { screen, appendMessage, streamMessage, setThinking, updateStatusModel, copyLastResponse, requestApproval, setMode };
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
+ };
738
292
  }
739
293
 
740
294
  module.exports = { createChatUI };