@pheem49/mint 1.2.4 → 1.4.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.
@@ -6,14 +6,18 @@ const blessed = require('blessed');
6
6
  const path = require('path');
7
7
  const { execSync } = require('child_process');
8
8
  const { readConfig } = require('../System/config_manager');
9
- const fs = require('fs');
10
9
 
11
10
  const SLASH_COMMANDS = [
11
+ { name: '/code', desc: 'Force workspace code mode for a task' },
12
12
  { name: '/models', desc: 'List or switch Gemini models' },
13
13
  { name: '/config', desc: 'Show current configuration' },
14
14
  { name: '/copy', desc: 'Copy last response to clipboard' },
15
15
  { name: '/clear', desc: 'Clear conversation history' },
16
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)' },
17
21
  { name: '/help', desc: 'Show help information' },
18
22
  { name: '/exit', desc: 'Exit Mint' }
19
23
  ];
@@ -29,6 +33,9 @@ function createChatUI({ onSubmit, onExit }) {
29
33
  const config = readConfig();
30
34
  const modelName = config.geminiModel || 'gemini';
31
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';
32
39
 
33
40
  // ─── Screen ───────────────────────────────────────────────────────────────
34
41
  const screen = blessed.screen({
@@ -40,82 +47,80 @@ function createChatUI({ onSubmit, onExit }) {
40
47
 
41
48
  // ─── Banner ───────────────────────────────────────────────────────────────
42
49
  const banner = blessed.box({
43
- top: 0, left: 0, width: '100%', height: 9,
50
+ top: 0, left: 1, width: '100%-2', height: 4,
44
51
  tags: true,
45
- style: { bg: 'default' }
52
+ padding: { left: 1, right: 1 },
53
+ style: { bg: 'default', fg: '#d7dde8' }
46
54
  });
47
55
  banner.setContent([
48
- `{bold}{#88e0b0-fg} __ __ _ _ _____ _ _____ {/}`,
49
- `{bold}{#88e0b0-fg} | \\/ (_) | | / ____| | |_ _|{/}`,
50
- `{bold}{#88e0b0-fg} | \\ / |_ _ __ | |_ | | | | | | {/}`,
51
- `{bold}{#88e0b0-fg} | |\\/| | | '_ \\| __| | | | | | | {/}`,
52
- `{bold}{#88e0b0-fg} | | | | | | | | |_ | |____| |____ _| |_ {/}`,
53
- `{bold}{#88e0b0-fg} |_| |_|_|_| |_|\\__| \\_____|______|_____|{/}`,
54
- ``,
55
- `{bold} Welcome to Mint Interactive AI!{/} {gray-fg}Type '/help' for commands · 'exit' or Esc to quit{/}`
56
+ `{#88e0b0-fg} __ __ _ _ ___ _ ___ {/}`,
57
+ `{#88e0b0-fg}| \\/ (_)_ __ | |_ / __| | |_ _|{/}`,
58
+ `{#88e0b0-fg}| |\\/| | | '_ \\| _| (__| |__ | | {/}`,
59
+ `{#88e0b0-fg}|_| |_|_|_| |_|\\__|\\___|____|___|{/}`
56
60
  ].join('\n'));
57
61
 
58
- // ─── Divider under banner ─────────────────────────────────────────────────
59
- const divider1 = blessed.line({
60
- top: 9, left: 0, width: '100%',
61
- orientation: 'horizontal',
62
- style: { fg: '#333333' }
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' }
63
67
  });
64
68
 
65
69
  // ─── Chat log (scrollable) ────────────────────────────────────────────────
66
70
  const chatBox = blessed.log({
67
- top: 10, left: 0, width: '100%',
68
- bottom: 8, // statusbar(3) + hint(1) + inputBox(3) + divider(1)
71
+ top: 6, left: 1, width: '100%-2',
72
+ bottom: 8,
69
73
  tags: true,
70
74
  scrollable: true,
71
75
  alwaysScroll: true,
72
- scrollbar: { ch: '', style: { fg: '#334433' } },
73
- style: { bg: 'default', fg: '#ffffff' },
76
+ scrollbar: { ch: '', style: { fg: '#335d52' } },
77
+ style: { bg: '#171b24', fg: '#ffffff', border: { fg: '#2f3747' } },
74
78
  mouse: true,
75
- scrollable: true
76
- });
77
-
78
- // ─── Divider above input ──────────────────────────────────────────────────
79
- const divider2 = blessed.line({
80
- bottom: 7, left: 0, width: '100%',
81
- orientation: 'horizontal',
82
- style: { fg: '#333333' }
79
+ scrollable: true,
80
+ border: { type: 'line' },
81
+ padding: { left: 1, right: 1, top: 0, bottom: 0 },
82
+ label: ' Conversation '
83
83
  });
84
84
 
85
85
  // ─── Hint bar ─────────────────────────────────────────────────────────────
86
86
  const hintBar = blessed.box({
87
- bottom: 6, left: 0, width: '100%', height: 1,
87
+ bottom: 6, left: 1, width: '100%-2', height: 1,
88
88
  tags: true,
89
- content: `{gray-fg} Shift+Drag to select text · Scroll to view history · /help for commands{/}`,
89
+ content: HINT_DEFAULT,
90
90
  style: { bg: 'default' }
91
91
  });
92
92
 
93
93
  // ─── Input area ───────────────────────────────────────────────────────────
94
- const inputBox = blessed.textarea({
95
- bottom: 3, left: 0, width: '100%', height: 3,
94
+ const inputBox = blessed.textbox({
95
+ bottom: 3, left: 1, width: '100%-2', height: 3,
96
96
  tags: false,
97
97
  inputOnFocus: true,
98
98
  keys: true,
99
99
  style: {
100
- bg: '#111111',
101
- fg: '#ffffff',
102
- border: { fg: '#334433' },
103
- focus: { border: { fg: '#88e0b0' } }
100
+ bg: INPUT_BG,
101
+ fg: INPUT_FG,
102
+ border: { fg: '#335d52' },
103
+ focus: {
104
+ fg: INPUT_FG,
105
+ bg: INPUT_BG,
106
+ border: { fg: '#88e0b0' }
107
+ }
104
108
  },
105
109
  border: { type: 'line' },
106
- padding: { left: 1 }
110
+ padding: { left: 1 },
111
+ label: ' Message '
107
112
  });
108
113
 
109
114
  // ─── Placeholder (SIBLING widget floating over input content area) ─────────
110
115
  // inputBox: bottom=3, height=3, border=1 → content row at bottom=4, left=2
111
116
  const placeholderWidget = blessed.text({
112
117
  bottom: 4, // inside input content area (border offset)
113
- left: 2, // border(1) + padding(1)
114
- width: '100%-4', // minus borders and padding
118
+ left: 3,
119
+ width: '100%-6',
115
120
  height: 1,
116
- content: '> Type your message or @path/to/file',
121
+ content: '> Ask anything, or describe a coding task for this workspace',
117
122
  tags: false,
118
- style: { fg: '#555555', bg: '#111111' }
123
+ style: { fg: '#5d6678', bg: '#10141c' }
119
124
  });
120
125
 
121
126
  let placeholderVisible = true;
@@ -136,12 +141,40 @@ function createChatUI({ onSubmit, onExit }) {
136
141
  }
137
142
  }
138
143
 
144
+ function refreshInputStyles() {
145
+ inputBox.style.fg = INPUT_FG;
146
+ inputBox.style.bg = INPUT_BG;
147
+ if (inputBox.style.focus) {
148
+ inputBox.style.focus.fg = INPUT_FG;
149
+ inputBox.style.focus.bg = INPUT_BG;
150
+ }
151
+ if (Array.isArray(inputBox.children)) {
152
+ inputBox.children.forEach((child) => {
153
+ if (child.style) {
154
+ child.style.fg = INPUT_FG;
155
+ child.style.bg = INPUT_BG;
156
+ }
157
+ });
158
+ }
159
+ applyTerminalInputAttrs();
160
+ }
161
+
162
+ function applyTerminalInputAttrs() {
163
+ try {
164
+ if (!screen || !screen.program || typeof inputBox.sattr !== 'function' || typeof screen.codeAttr !== 'function') {
165
+ return;
166
+ }
167
+ const attr = inputBox.sattr(inputBox.style);
168
+ screen.program.write(screen.codeAttr(attr));
169
+ } catch (_) {}
170
+ }
171
+
139
172
  // ─── Status bar (3 columns: left / center / right) ──────────────────────
140
173
  const statusBar = blessed.box({
141
- bottom: 0, left: 0, width: '100%', height: 3,
174
+ bottom: 0, left: 1, width: '100%-2', height: 3,
142
175
  tags: true,
143
- style: { bg: '#111111', fg: '#888888' },
144
- border: { type: 'line', fg: '#222222' }
176
+ style: { bg: '#10141c', fg: '#888888' },
177
+ border: { type: 'line', fg: '#222c38' }
145
178
  });
146
179
 
147
180
  // Left: workspace info
@@ -152,20 +185,20 @@ function createChatUI({ onSubmit, onExit }) {
152
185
  height: 1,
153
186
  tags: true,
154
187
  content: ` workspace {bold}(${workspaceName}){/bold}`,
155
- style: { bg: '#111111', fg: '#888888' }
188
+ style: { bg: '#10141c', fg: '#93a0b7' }
156
189
  });
157
190
 
158
- // Center: sandbox status
191
+ // Center: mode + status
159
192
  const statusCenter = blessed.text({
160
193
  parent: statusBar,
161
194
  top: 0,
162
195
  left: 'center',
163
- width: '34%',
196
+ width: '44%',
164
197
  height: 1,
165
198
  align: 'center',
166
199
  tags: true,
167
- content: `{#cc4444-fg}no sandbox{/}`,
168
- style: { bg: '#111111', fg: '#888888' }
200
+ content: `{#88aaff-fg}[Chat]{/} {#cc4444-fg}no sandbox{/}`,
201
+ style: { bg: '#10141c', fg: '#888888' }
169
202
  });
170
203
 
171
204
  // Right: current model
@@ -177,18 +210,30 @@ function createChatUI({ onSubmit, onExit }) {
177
210
  align: 'right',
178
211
  tags: true,
179
212
  content: `{#88e0b0-fg}${modelName}{/}`,
180
- style: { bg: '#111111', fg: '#888888' }
213
+ style: { bg: '#10141c', fg: '#88e0b0' }
181
214
  });
182
215
 
216
+ let activeMode = 'Chat';
217
+
218
+ function formatModeTag(mode) {
219
+ if (mode === 'Code') return `{#ffd166-fg}[Code]{/}`;
220
+ return `{#88aaff-fg}[Chat]{/}`;
221
+ }
222
+
183
223
  function updateStatusBar(thinkingText = null) {
184
224
  if (thinkingText) {
185
- statusCenter.setContent(`{#88e0b0-fg}${thinkingText}{/}`);
225
+ statusCenter.setContent(`${formatModeTag(activeMode)} {#88e0b0-fg}${thinkingText}{/}`);
186
226
  } else {
187
- statusCenter.setContent(`{#cc4444-fg}no sandbox{/}`);
227
+ statusCenter.setContent(`${formatModeTag(activeMode)} {#cc4444-fg}no sandbox{/}`);
188
228
  }
189
229
  screen.render();
190
230
  }
191
231
 
232
+ function setMode(mode) {
233
+ activeMode = mode === 'Code' ? 'Code' : 'Chat';
234
+ updateStatusBar(null);
235
+ }
236
+
192
237
  /** Update model name in status bar (called after /models switch) */
193
238
  function updateStatusModel(newModel) {
194
239
  statusRight.setContent(`{#88e0b0-fg}${newModel}{/}`);
@@ -198,9 +243,8 @@ function createChatUI({ onSubmit, onExit }) {
198
243
 
199
244
  // ─── Append widgets to screen ─────────────────────────────────────────────
200
245
  screen.append(banner);
201
- screen.append(divider1);
246
+ screen.append(subBanner);
202
247
  screen.append(chatBox);
203
- screen.append(divider2);
204
248
  screen.append(hintBar);
205
249
  screen.append(inputBox);
206
250
  screen.append(statusBar);
@@ -209,9 +253,9 @@ function createChatUI({ onSubmit, onExit }) {
209
253
  // ─── Suggestion List ──────────────────────────────────────────────────────
210
254
  const commandList = blessed.list({
211
255
  parent: screen,
212
- bottom: 6, // Above hintBar
213
- left: 2,
214
- width: '70%',
256
+ bottom: 6,
257
+ left: 3,
258
+ width: '64%',
215
259
  height: 8,
216
260
  tags: true,
217
261
  keys: false, // We will handle keys manually to keep focus on input
@@ -219,10 +263,10 @@ function createChatUI({ onSubmit, onExit }) {
219
263
  hidden: true,
220
264
  border: { type: 'line', fg: '#88e0b0' },
221
265
  style: {
222
- bg: '#111111',
266
+ bg: '#10141c',
223
267
  fg: '#ffffff',
224
268
  selected: {
225
- bg: '#334433',
269
+ bg: '#22352f',
226
270
  fg: '#88e0b0',
227
271
  bold: true
228
272
  }
@@ -230,6 +274,22 @@ function createChatUI({ onSubmit, onExit }) {
230
274
  });
231
275
 
232
276
  let activeSuggestions = [];
277
+ const approvalDialog = blessed.question({
278
+ parent: screen,
279
+ tags: true,
280
+ border: { type: 'line', fg: '#88e0b0' },
281
+ style: {
282
+ bg: '#10141c',
283
+ fg: '#ffffff',
284
+ border: { fg: '#88e0b0' }
285
+ },
286
+ width: '80%',
287
+ height: 'shrink',
288
+ top: 'center',
289
+ left: 'center',
290
+ label: ' Approval ',
291
+ hidden: true
292
+ });
233
293
 
234
294
  function updateSuggestions(filter = '') {
235
295
  activeSuggestions = SLASH_COMMANDS.filter(cmd =>
@@ -260,6 +320,7 @@ function createChatUI({ onSubmit, onExit }) {
260
320
 
261
321
  // Consolidated key handling
262
322
  inputBox.on('element keypress', (el, ch, key) => {
323
+ refreshInputStyles();
263
324
  // 1. Handle placeholder visibility
264
325
  if (!key.ctrl && !key.meta && key.name !== 'enter' && key.name !== 'tab') {
265
326
  if (ch) hidePlaceholder();
@@ -287,6 +348,7 @@ function createChatUI({ onSubmit, onExit }) {
287
348
 
288
349
  // 3. Logic for suggestions and placeholder after key is processed
289
350
  setImmediate(() => {
351
+ refreshInputStyles();
290
352
  const val = (inputBox.getValue ? inputBox.getValue() : inputBox.value) || '';
291
353
  const isCommand = val.startsWith('/') && !val.includes(' ');
292
354
 
@@ -308,6 +370,15 @@ function createChatUI({ onSubmit, onExit }) {
308
370
  });
309
371
  });
310
372
 
373
+ inputBox.on('focus', () => {
374
+ refreshInputStyles();
375
+ screen.render();
376
+ });
377
+
378
+ inputBox.on('keypress', () => {
379
+ applyTerminalInputAttrs();
380
+ });
381
+
311
382
 
312
383
  // Submit or Select Suggestion on Enter
313
384
  inputBox.key(['enter'], () => {
@@ -318,6 +389,7 @@ function createChatUI({ onSubmit, onExit }) {
318
389
  commandList.hide();
319
390
  hidePlaceholder();
320
391
  inputBox.focus();
392
+ refreshInputStyles();
321
393
  screen.render();
322
394
  return; // Don't submit yet, let user add args or press enter again
323
395
  }
@@ -325,12 +397,20 @@ function createChatUI({ onSubmit, onExit }) {
325
397
 
326
398
  const raw = (inputBox.getValue ? inputBox.getValue() : inputBox.value) || '';
327
399
  const text = raw.trim();
328
- if (!text) return;
400
+ if (!text) {
401
+ inputBox.clearValue();
402
+ showPlaceholder();
403
+ inputBox.focus();
404
+ refreshInputStyles();
405
+ screen.render();
406
+ return;
407
+ }
329
408
 
330
409
  // Clear input and restore placeholder
331
410
  inputBox.clearValue();
332
411
  showPlaceholder();
333
412
  inputBox.focus();
413
+ refreshInputStyles();
334
414
  screen.render();
335
415
 
336
416
  if (text.toLowerCase() === 'exit' || text.toLowerCase() === 'quit') {
@@ -342,18 +422,9 @@ function createChatUI({ onSubmit, onExit }) {
342
422
  });
343
423
 
344
424
  // Shift+Enter = newline in input
345
- inputBox.key(['S-enter'], () => {
346
- hidePlaceholder();
347
- const val = (inputBox.getValue ? inputBox.getValue() : inputBox.value) || '';
348
- inputBox.setValue(val + '\n');
349
- screen.render();
350
- });
351
-
352
425
  // Ctrl+C — double-press to exit
353
426
  let ctrlCPressed = false;
354
427
  let ctrlCTimer = null;
355
- const HINT_DEFAULT = `{gray-fg} Shift+Drag to select text · Ctrl+Y to copy · /help for commands{/}`;
356
-
357
428
  screen.key(['C-c'], () => {
358
429
  if (ctrlCPressed) {
359
430
  clearTimeout(ctrlCTimer);
@@ -415,6 +486,7 @@ function createChatUI({ onSubmit, onExit }) {
415
486
 
416
487
  // ─── Initial render ───────────────────────────────────────────────────────
417
488
  inputBox.focus();
489
+ refreshInputStyles();
418
490
  screen.render();
419
491
 
420
492
  // ─── Public API ───────────────────────────────────────────────────────────
@@ -427,63 +499,177 @@ function createChatUI({ onSubmit, onExit }) {
427
499
  * @param {string} text
428
500
  * @param {string} timestamp - ISO string or Date object
429
501
  */
430
- function appendMessage(role, text, timestamp = null) {
431
- const now = timestamp ? new Date(timestamp) : new Date();
432
- const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
502
+ function wrapLineSmart(line, width) {
503
+ if (line.length <= width) return [line];
504
+ if (!line.includes(' ')) {
505
+ const pieces = [];
506
+ for (let index = 0; index < line.length; index += width) {
507
+ pieces.push(line.slice(index, index + width));
508
+ }
509
+ return pieces;
510
+ }
433
511
 
434
- // Helper to wrap text manually since blessed.log doesn't support indenting wrapped lines
435
- const wrapText = (str, width) => {
436
- const lines = [];
437
- const originalLines = str.split('\n');
438
-
439
- for (let line of originalLines) {
440
- if (line.length === 0) {
441
- lines.push('');
442
- continue;
512
+ const words = line.split(/\s+/);
513
+ const lines = [];
514
+ let current = '';
515
+ for (const word of words) {
516
+ if (word.length > width) {
517
+ if (current) {
518
+ lines.push(current);
519
+ current = '';
443
520
  }
444
-
445
- let current = '';
446
- for (let i = 0; i < line.length; i++) {
447
- current += line[i];
448
- // Simple wrap based on character count.
449
- // Note: This is an approximation for Thai, but better than terminal auto-wrap.
450
- if (current.length >= width) {
451
- lines.push(current);
452
- current = '';
521
+ for (let index = 0; index < word.length; index += width) {
522
+ const slice = word.slice(index, index + width);
523
+ if (slice.length === width) {
524
+ lines.push(slice);
525
+ } else {
526
+ current = slice;
453
527
  }
454
528
  }
455
- if (current) lines.push(current);
529
+ continue;
530
+ }
531
+
532
+ if (!current) {
533
+ current = word;
534
+ continue;
535
+ }
536
+
537
+ if (`${current} ${word}`.length <= width) {
538
+ current += ` ${word}`;
539
+ } else {
540
+ lines.push(current);
541
+ current = word;
542
+ }
543
+ }
544
+ if (current) lines.push(current);
545
+ return lines;
546
+ }
547
+
548
+ function wrapText(str, width) {
549
+ const lines = [];
550
+ const originalLines = String(str).split('\n');
551
+ for (const line of originalLines) {
552
+ if (line.length === 0) {
553
+ lines.push('');
554
+ continue;
456
555
  }
457
- return lines;
458
- };
556
+ lines.push(...wrapLineSmart(line, width));
557
+ }
558
+ return lines;
559
+ }
459
560
 
460
- const maxLineWidth = Math.max(screen.width - 15, 40);
561
+ function appendMessage(role, text, timestamp = null) {
562
+ const now = timestamp ? new Date(timestamp) : new Date();
563
+ const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
564
+ const maxLineWidth = Math.max(screen.width - 20, 36);
565
+ const lines = wrapText(text, maxLineWidth);
461
566
 
462
567
  if (role === 'user') {
463
- chatBox.log(`\n {bold}{#88e0b0-fg}● You{/}`);
464
- const lines = wrapText(text, maxLineWidth);
465
- lines.forEach(l => chatBox.log(` {#ffffff-fg}${l}{/}`));
466
- chatBox.log(` {gray-fg}${timeStr}{/}`);
568
+ chatBox.log(``);
569
+ chatBox.log(` {bold}{#88e0b0-fg}You{/} {gray-fg}${timeStr}{/}`);
570
+ lines.forEach(l => chatBox.log(` {#88e0b0-fg}▏{/} {#ffffff-fg}${l}{/}`));
467
571
  } else if (role === 'assistant') {
468
572
  lastAssistantResponse = text;
469
- chatBox.log(`\n {bold}{#d4a8ff-fg}● Mint{/}`);
470
- const lines = wrapText(text, maxLineWidth);
471
- lines.forEach(l => chatBox.log(` {#444444-fg}{/} {#ffffff-fg}${l}{/}`));
472
- chatBox.log(` {#444444-fg}┕${'─'.repeat(4)}{/} {gray-fg}${timeStr}{/}`);
573
+ chatBox.log(``);
574
+ chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
575
+ lines.forEach(l => chatBox.log(` {#5a456d-fg}{/} {#ffffff-fg}${l}{/}`));
473
576
  } else if (role === 'system') {
474
- const displayTag = text.startsWith('Action:') ? '{#88e0b0-fg}✦ Action:{/}' : '{#888888-fg}ℹ System:{/}';
577
+ const displayTag = text.startsWith('Action:')
578
+ ? '{#88e0b0-fg}Action{/}'
579
+ : text.startsWith('[Code]')
580
+ ? '{#ffd166-fg}Code{/}'
581
+ : '{#8ba0ff-fg}System{/}';
475
582
  const cleanText = text.replace(/^(Action:|System:)\s*/, '');
476
- chatBox.log(`\n ${displayTag}`);
477
- const lines = wrapText(cleanText, maxLineWidth - 2);
478
- lines.forEach(l => chatBox.log(` {#ffffff-fg}${l}{/}`));
583
+ const systemLines = wrapText(cleanText, maxLineWidth - 4);
584
+ chatBox.log(``);
585
+ chatBox.log(` {bold}${displayTag}{/}`);
586
+ systemLines.forEach(l => chatBox.log(` {#95a2b8-fg}${l}{/}`));
479
587
  } else if (role === 'error') {
480
- chatBox.log(`\n {#ff5555-fg}✖ Error:{/}`);
481
- const lines = wrapText(text, maxLineWidth - 2);
482
- lines.forEach(l => chatBox.log(` {#ff5555-fg}${l}{/}`));
588
+ chatBox.log(``);
589
+ chatBox.log(` {bold}{#ff6b6b-fg}Error{/} {gray-fg}${timeStr}{/}`);
590
+ lines.forEach(l => chatBox.log(` {#7a2e2e-fg}▏{/} {#ff7d7d-fg}${l}{/}`));
483
591
  }
484
592
  screen.render();
485
593
  }
486
594
 
595
+ /**
596
+ * Opens a streaming message bubble for the assistant.
597
+ * Returns { appendChunk(text), finalize(timestamp) } for typewriter rendering.
598
+ * Usage:
599
+ * const stream = streamMessage('assistant');
600
+ * stream.appendChunk('Hello'); stream.appendChunk(' World');
601
+ * stream.finalize(timestamp);
602
+ */
603
+ function streamMessage(role = 'assistant') {
604
+ const now = new Date();
605
+ const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit', hour12: false });
606
+ const maxLineWidth = Math.max(screen.width - 20, 36);
607
+
608
+ // Print the header bubble once
609
+ chatBox.log('');
610
+ if (role === 'assistant') {
611
+ chatBox.log(` {bold}{#d4a8ff-fg}Mint{/} {gray-fg}${timeStr}{/}`);
612
+ }
613
+
614
+ let buffer = ''; // accumulates the full response text
615
+ let lineBuffer = ''; // current partial line being built
616
+ let lineRendered = false; // whether we already pushed the first line prefix
617
+
618
+ function flushLine(force = false) {
619
+ // Flush content that fits on one line-width or when forced
620
+ if (!lineBuffer && !force) return;
621
+ if (!lineRendered) {
622
+ chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
623
+ lineRendered = true;
624
+ } else {
625
+ // Overwrite the last line by popping + re-pushing (blessed.log limitation)
626
+ // We can't truly overwrite, so we just keep appending new lines for each chunk.
627
+ // For large chunks, split on newline and emit per-line.
628
+ chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${lineBuffer}{/}`);
629
+ }
630
+ screen.render();
631
+ }
632
+
633
+ function appendChunk(text) {
634
+ if (!text) return;
635
+ buffer += text;
636
+ const segments = text.split('\n');
637
+ for (let i = 0; i < segments.length; i++) {
638
+ lineBuffer += segments[i];
639
+ if (i < segments.length - 1) {
640
+ // Newline boundary — emit current line
641
+ const lines = wrapLineSmart(lineBuffer, maxLineWidth);
642
+ lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
643
+ lineBuffer = '';
644
+ lineRendered = true;
645
+ screen.render();
646
+ } else if (lineBuffer.length >= maxLineWidth) {
647
+ // Line overflow — auto-wrap
648
+ const lines = wrapLineSmart(lineBuffer, maxLineWidth);
649
+ lines.slice(0, -1).forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
650
+ lineBuffer = lines[lines.length - 1] || '';
651
+ lineRendered = true;
652
+ screen.render();
653
+ }
654
+ // Otherwise keep buffering the partial line
655
+ }
656
+ }
657
+
658
+ function finalize(timestamp = null) {
659
+ // Flush remaining buffer
660
+ if (lineBuffer) {
661
+ const lines = wrapLineSmart(lineBuffer, maxLineWidth);
662
+ lines.forEach(l => chatBox.log(` {#5a456d-fg}▏{/} {#ffffff-fg}${l}{/}`));
663
+ lineBuffer = '';
664
+ }
665
+ // Track last response for clipboard
666
+ lastAssistantResponse = buffer;
667
+ screen.render();
668
+ }
669
+
670
+ return { appendChunk, finalize };
671
+ }
672
+
487
673
  /** Show/hide thinking indicator in status bar */
488
674
  function setThinking(active, secondsElapsed = 0) {
489
675
  if (active) {
@@ -499,7 +685,32 @@ function createChatUI({ onSubmit, onExit }) {
499
685
  return copyToClipboard(lastAssistantResponse);
500
686
  }
501
687
 
502
- return { screen, appendMessage, setThinking, updateStatusModel, copyLastResponse };
688
+ function requestApproval(request) {
689
+ return new Promise((resolve) => {
690
+ const typeLabel = request.type === 'shell'
691
+ ? 'Shell Command'
692
+ : request.type === 'patch'
693
+ ? 'Patch Edit'
694
+ : 'File Write';
695
+ const preview = request.preview || request.label || '';
696
+ const message = [
697
+ `{bold}${typeLabel}{/bold}`,
698
+ '',
699
+ preview,
700
+ '',
701
+ 'Approve this action?'
702
+ ].join('\n');
703
+
704
+ approvalDialog.ask(message, (approved) => {
705
+ inputBox.focus();
706
+ refreshInputStyles();
707
+ screen.render();
708
+ resolve(Boolean(approved));
709
+ });
710
+ });
711
+ }
712
+
713
+ return { screen, appendMessage, streamMessage, setThinking, updateStatusModel, copyLastResponse, requestApproval, setMode };
503
714
  }
504
715
 
505
716
  module.exports = { createChatUI };