@pheem49/mint 1.4.0 → 1.4.1

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.
@@ -90,11 +90,10 @@ function createChatUI({ onSubmit, onExit }) {
90
90
  style: { bg: 'default' }
91
91
  });
92
92
 
93
- // ─── Input area ───────────────────────────────────────────────────────────
94
93
  const inputBox = blessed.textbox({
95
94
  bottom: 3, left: 1, width: '100%-2', height: 3,
96
95
  tags: false,
97
- inputOnFocus: true,
96
+ inputOnFocus: false, // We'll manage this manually for stability
98
97
  keys: true,
99
98
  style: {
100
99
  bg: INPUT_BG,
@@ -111,6 +110,16 @@ function createChatUI({ onSubmit, onExit }) {
111
110
  label: ' Message '
112
111
  });
113
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
+
114
123
  // ─── Placeholder (SIBLING widget floating over input content area) ─────────
115
124
  // inputBox: bottom=3, height=3, border=1 → content row at bottom=4, left=2
116
125
  const placeholderWidget = blessed.text({
@@ -236,6 +245,7 @@ function createChatUI({ onSubmit, onExit }) {
236
245
 
237
246
  /** Update model name in status bar (called after /models switch) */
238
247
  function updateStatusModel(newModel) {
248
+ if (!newModel) return;
239
249
  statusRight.setContent(`{#88e0b0-fg}${newModel}{/}`);
240
250
  screen.render();
241
251
  }
@@ -284,7 +294,7 @@ function createChatUI({ onSubmit, onExit }) {
284
294
  border: { fg: '#88e0b0' }
285
295
  },
286
296
  width: '80%',
287
- height: 'shrink',
297
+ height: 12, // Fixed height to avoid 'shrink' miscalculation with buttons
288
298
  top: 'center',
289
299
  left: 'center',
290
300
  label: ' Approval ',
@@ -381,26 +391,28 @@ function createChatUI({ onSubmit, onExit }) {
381
391
 
382
392
 
383
393
  // Submit or Select Suggestion on Enter
384
- inputBox.key(['enter'], () => {
394
+ inputBox.on('submit', (value) => {
385
395
  if (!commandList.hidden) {
386
396
  const selected = activeSuggestions[commandList.selected];
387
397
  if (selected) {
388
398
  inputBox.setValue(selected.name + ' ');
389
399
  commandList.hide();
390
400
  hidePlaceholder();
391
- inputBox.focus();
401
+ inputBox.focus();
402
+ inputBox.readInput(); // Re-focus to continue typing
392
403
  refreshInputStyles();
393
404
  screen.render();
394
405
  return; // Don't submit yet, let user add args or press enter again
395
406
  }
396
407
  }
397
408
 
398
- const raw = (inputBox.getValue ? inputBox.getValue() : inputBox.value) || '';
409
+ const raw = value || '';
399
410
  const text = raw.trim();
400
411
  if (!text) {
401
412
  inputBox.clearValue();
402
413
  showPlaceholder();
403
- inputBox.focus();
414
+ inputBox.focus();
415
+ inputBox.readInput(); // Re-focus to continue typing
404
416
  refreshInputStyles();
405
417
  screen.render();
406
418
  return;
@@ -409,7 +421,8 @@ function createChatUI({ onSubmit, onExit }) {
409
421
  // Clear input and restore placeholder
410
422
  inputBox.clearValue();
411
423
  showPlaceholder();
412
- inputBox.focus();
424
+ inputBox.focus();
425
+ inputBox.readInput(); // Explicitly restart reading
413
426
  refreshInputStyles();
414
427
  screen.render();
415
428
 
@@ -486,6 +499,7 @@ function createChatUI({ onSubmit, onExit }) {
486
499
 
487
500
  // ─── Initial render ───────────────────────────────────────────────────────
488
501
  inputBox.focus();
502
+ inputBox.readInput(); // Initial start
489
503
  refreshInputStyles();
490
504
  screen.render();
491
505
 
@@ -691,18 +705,28 @@ function createChatUI({ onSubmit, onExit }) {
691
705
  ? 'Shell Command'
692
706
  : request.type === 'patch'
693
707
  ? 'Patch Edit'
694
- : 'File Write';
708
+ : request.type === 'code_mode'
709
+ ? 'Enter Code Mode'
710
+ : 'File Write';
695
711
  const preview = request.preview || request.label || '';
696
712
  const message = [
697
713
  `{bold}${typeLabel}{/bold}`,
698
714
  '',
699
715
  preview,
700
716
  '',
701
- 'Approve this action?'
717
+ 'Approve this action?',
718
+ '', // Extra lines to push buttons down and avoid overlapping
719
+ ''
702
720
  ].join('\n');
703
721
 
722
+ // Temporarily stop reading input so the dialog can receive keys
723
+ if (inputBox._reading) {
724
+ inputBox.cancel();
725
+ }
726
+
704
727
  approvalDialog.ask(message, (approved) => {
705
728
  inputBox.focus();
729
+ inputBox.readInput(); // Ensure we resume reading after dialog
706
730
  refreshInputStyles();
707
731
  screen.render();
708
732
  resolve(Boolean(approved));
@@ -11,6 +11,8 @@ const execFileAsync = promisify(execFile);
11
11
  const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash';
12
12
  const MAX_TOOL_OUTPUT = 12000;
13
13
  const MAX_AGENT_STEPS = 16;
14
+ const MAX_JSON_REPAIR_ATTEMPTS = 2;
15
+ const SUPPORTED_CODE_PROVIDERS = ['gemini', 'anthropic', 'openai', 'local_openai'];
14
16
 
15
17
  const CODE_AGENT_PROMPT = `You are Mint Code Mode, a careful coding agent for a local workspace.
16
18
 
@@ -31,10 +33,11 @@ Rules:
31
33
  Response format:
32
34
  {
33
35
  "thought": "short reasoning",
34
- "action": "list_files" | "read_file" | "search_code" | "run_shell" | "apply_patch" | "write_file" | "finish",
36
+ "action": "list_files" | "read_file" | "search_code" | "find_path" | "run_shell" | "apply_patch" | "write_file" | "finish",
35
37
  "input": {
36
38
  "path": "relative/path",
37
39
  "query": "search text",
40
+ "type": "file" | "dir" | "any",
38
41
  "command": "shell command",
39
42
  "startLine": 1,
40
43
  "endLine": 120,
@@ -58,6 +61,7 @@ Tool notes:
58
61
  - "list_files": inspect the workspace or a subdirectory.
59
62
  - "read_file": read a file, optionally with startLine/endLine.
60
63
  - "search_code": search by text or regex-like pattern.
64
+ - "find_path": find files or directories by path/name when the user is looking for a folder, filename, or location.
61
65
  - "run_shell": run a non-destructive command in the workspace.
62
66
  - "apply_patch": update an existing file using one or more exact replacement hunks.
63
67
  - "write_file": create a new file or fully rewrite a file when replacement is not practical.
@@ -81,6 +85,22 @@ function extractJson(text) {
81
85
  }
82
86
  }
83
87
 
88
+ function selectSupportedCodeProvider(config, availableProviders = getAvailableProviders(config || {})) {
89
+ const requestedProvider = (config && config.aiProvider) || 'gemini';
90
+ if (SUPPORTED_CODE_PROVIDERS.includes(requestedProvider) && availableProviders.includes(requestedProvider)) {
91
+ return requestedProvider;
92
+ }
93
+
94
+ const priority = ['anthropic', 'openai', 'gemini', 'local_openai'];
95
+ for (const provider of priority) {
96
+ if (availableProviders.includes(provider)) {
97
+ return provider;
98
+ }
99
+ }
100
+
101
+ return 'gemini';
102
+ }
103
+
84
104
  function resolveWorkspacePath(workspaceRoot, targetPath = '.') {
85
105
  const resolved = path.resolve(workspaceRoot, targetPath);
86
106
  const relative = path.relative(workspaceRoot, resolved);
@@ -160,6 +180,40 @@ async function searchCode(workspaceRoot, query) {
160
180
  }
161
181
  }
162
182
 
183
+ async function findPaths(workspaceRoot, query, type = 'any') {
184
+ if (!query || !query.trim()) {
185
+ throw new Error('Path search query is required.');
186
+ }
187
+
188
+ const normalizedType = ['file', 'dir', 'any'].includes(type) ? type : 'any';
189
+ const loweredQuery = query.trim().toLowerCase();
190
+ const results = [];
191
+
192
+ function visit(currentPath) {
193
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
194
+ for (const entry of entries) {
195
+ const absoluteEntryPath = path.join(currentPath, entry.name);
196
+ const relativeEntryPath = path.relative(workspaceRoot, absoluteEntryPath) || '.';
197
+ const entryType = entry.isDirectory() ? 'dir' : 'file';
198
+ const matchesType = normalizedType === 'any' || normalizedType === entryType;
199
+ const matchesQuery = entry.name.toLowerCase().includes(loweredQuery) || relativeEntryPath.toLowerCase().includes(loweredQuery);
200
+
201
+ if (matchesType && matchesQuery) {
202
+ results.push(`${entryType === 'dir' ? '[dir]' : '[file]'} ${relativeEntryPath}`);
203
+ if (results.length >= 200) return;
204
+ }
205
+
206
+ if (entry.isDirectory() && results.length < 200) {
207
+ visit(absoluteEntryPath);
208
+ if (results.length >= 200) return;
209
+ }
210
+ }
211
+ }
212
+
213
+ visit(workspaceRoot);
214
+ return results.length > 0 ? results.join('\n') : '(no matching paths)';
215
+ }
216
+
163
217
  function assertSafeShell(command) {
164
218
  const blockedPatterns = [
165
219
  /\brm\s+-rf\b/,
@@ -247,7 +301,7 @@ function writeFile(workspaceRoot, targetPath, content) {
247
301
 
248
302
  class UnifiedAgentClient {
249
303
  constructor(provider, config) {
250
- this.provider = provider;
304
+ this.provider = SUPPORTED_CODE_PROVIDERS.includes(provider) ? provider : 'gemini';
251
305
  this.config = config;
252
306
  this.history = [];
253
307
  this.systemInstruction = CODE_AGENT_PROMPT;
@@ -342,6 +396,29 @@ class UnifiedAgentClient {
342
396
  }
343
397
  }
344
398
 
399
+ async function getAgentDecision(client, observation, options = {}) {
400
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
401
+ const step = options.step || 0;
402
+
403
+ let rawText = await client.sendMessage(observation);
404
+ for (let attempt = 0; attempt <= MAX_JSON_REPAIR_ATTEMPTS; attempt++) {
405
+ try {
406
+ return extractJson(rawText);
407
+ } catch (error) {
408
+ if (attempt === MAX_JSON_REPAIR_ATTEMPTS) {
409
+ throw new Error(`Agent returned invalid JSON after ${MAX_JSON_REPAIR_ATTEMPTS + 1} attempts: ${error.message}`);
410
+ }
411
+
412
+ onProgress(`Step ${step}: invalid JSON response, requesting repair (${attempt + 1}/${MAX_JSON_REPAIR_ATTEMPTS})`);
413
+ rawText = await client.sendMessage([
414
+ 'Your previous response was not valid JSON for Code Mode.',
415
+ 'Reply again with valid JSON only, following the required schema exactly.',
416
+ `Previous response:\n${truncate(rawText, 4000)}`
417
+ ].join('\n'));
418
+ }
419
+ }
420
+ }
421
+
345
422
  function detectPackageManager(workspaceRoot) {
346
423
  if (fs.existsSync(path.join(workspaceRoot, 'package-lock.json'))) return 'npm';
347
424
  if (fs.existsSync(path.join(workspaceRoot, 'pnpm-lock.yaml'))) return 'pnpm';
@@ -384,12 +461,17 @@ async function getGitContext(workspaceRoot) {
384
461
  return { isRepo: true, branch, status, diffSummary };
385
462
  }
386
463
 
387
- async function buildInitialObservation(task, workspaceRoot) {
464
+ async function buildInitialObservation(task, workspaceRoot, history = []) {
388
465
  const session = readWorkspaceSession(workspaceRoot);
389
466
  const gitContext = await getGitContext(workspaceRoot);
390
467
  const testCommands = detectTestCommands(workspaceRoot);
391
468
 
469
+ const contextStr = history.length > 0
470
+ ? `Recent Context:\n${history.slice(-10).map(m => `${m.sender}: ${m.text}`).join('\n')}\n`
471
+ : '';
472
+
392
473
  return [
474
+ contextStr,
393
475
  `Task: ${task}`,
394
476
  `Workspace: ${workspaceRoot}`,
395
477
  `Git branch: ${gitContext.branch}`,
@@ -409,24 +491,26 @@ async function buildInitialObservation(task, workspaceRoot) {
409
491
 
410
492
  async function executeCodeTask(task, options = {}) {
411
493
  const workspaceRoot = path.resolve(options.cwd || process.cwd());
494
+ const history = options.history || [];
412
495
  const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
413
496
  const requestApproval = typeof options.requestApproval === 'function'
414
497
  ? options.requestApproval
415
498
  : async () => true;
416
499
  const config = readConfig();
417
- const provider = options.provider || 'gemini';
500
+ const provider = options.provider || selectSupportedCodeProvider(config);
418
501
  const client = new UnifiedAgentClient(provider, config);
419
502
 
420
- let observation = await buildInitialObservation(task, workspaceRoot);
503
+ let observation = await buildInitialObservation(task, workspaceRoot, history);
421
504
 
422
505
  let finalSummary = '';
423
506
  let finalVerification = '';
424
507
  let finalSessionSummary = '';
508
+ let executedSteps = 0;
425
509
 
426
510
  for (let step = 1; step <= MAX_AGENT_STEPS; step++) {
511
+ executedSteps = step;
427
512
  onProgress(`Step ${step}: thinking`);
428
- const text = await client.sendMessage(observation);
429
- const decision = extractJson(text);
513
+ const decision = await getAgentDecision(client, observation, { onProgress, step });
430
514
  const action = decision.action;
431
515
  const input = decision.input || {};
432
516
 
@@ -455,6 +539,9 @@ async function executeCodeTask(task, options = {}) {
455
539
  case 'search_code':
456
540
  toolResult = await searchCode(workspaceRoot, input.query);
457
541
  break;
542
+ case 'find_path':
543
+ toolResult = await findPaths(workspaceRoot, input.query, input.type);
544
+ break;
458
545
  case 'run_shell': {
459
546
  const approved = await requestApproval({
460
547
  type: 'shell',
@@ -510,9 +597,15 @@ async function executeCodeTask(task, options = {}) {
510
597
  // Check for Agent Collaboration (Review)
511
598
  if (config.enableAgentCollaboration !== false) {
512
599
  const availableProviders = getAvailableProviders(config);
513
- const altProviders = availableProviders.filter(p => p !== provider && p !== 'ollama' && p !== 'huggingface');
514
- if (altProviders.length > 0 && finalSummary) {
515
- const reviewerProvider = altProviders[0];
600
+ // Exclude providers that often need special local setup or are slow/unreliable for tiny reviews
601
+ const altProviders = availableProviders.filter(p => p !== provider && p !== 'ollama' && p !== 'huggingface' && p !== 'local_openai');
602
+
603
+ // Fallback to provider itself if no other good ones exist, or pick the best available
604
+ const reviewerProvider = altProviders.length > 0
605
+ ? altProviders[0]
606
+ : (availableProviders.includes('gemini') ? 'gemini' : availableProviders[0]);
607
+
608
+ if (reviewerProvider && finalSummary) {
516
609
  onProgress(`Invoking Reviewer Agent (${reviewerProvider})...`);
517
610
 
518
611
  const reviewerClient = new UnifiedAgentClient(reviewerProvider, config);
@@ -536,7 +629,7 @@ async function executeCodeTask(task, options = {}) {
536
629
  return {
537
630
  summary: finalSummary,
538
631
  verification: finalVerification,
539
- steps: MAX_AGENT_STEPS
632
+ steps: executedSteps
540
633
  };
541
634
  }
542
635
 
@@ -549,8 +642,15 @@ async function executeCodeTask(task, options = {}) {
549
642
  return {
550
643
  summary: 'Stopped after reaching the maximum number of agent steps.',
551
644
  verification: 'Agent limit reached before explicit completion.',
552
- steps: MAX_AGENT_STEPS
645
+ steps: executedSteps || MAX_AGENT_STEPS
553
646
  };
554
647
  }
555
648
 
556
- module.exports = { executeCodeTask };
649
+ module.exports = {
650
+ executeCodeTask,
651
+ _helpers: {
652
+ extractJson,
653
+ selectSupportedCodeProvider,
654
+ findPaths
655
+ }
656
+ };
@@ -9,26 +9,35 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
11
 
12
- const WORKSPACE_FILE = path.join(os.homedir(), '.config', 'mint', 'workspaces.json');
12
+ function getWorkspaceFile() {
13
+ return process.env.MINT_WORKSPACE_FILE || path.join(os.homedir(), '.config', 'mint', 'workspaces.json');
14
+ }
13
15
 
14
16
  function ensureDir() {
15
- const dir = path.dirname(WORKSPACE_FILE);
17
+ const dir = path.dirname(getWorkspaceFile());
16
18
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
17
19
  }
18
20
 
19
21
  function loadWorkspaces() {
22
+ const workspaceFile = getWorkspaceFile();
20
23
  ensureDir();
21
- if (!fs.existsSync(WORKSPACE_FILE)) return {};
24
+ if (!fs.existsSync(workspaceFile)) return {};
22
25
  try {
23
- return JSON.parse(fs.readFileSync(WORKSPACE_FILE, 'utf8'));
26
+ return JSON.parse(fs.readFileSync(workspaceFile, 'utf8'));
24
27
  } catch (e) {
25
28
  return {};
26
29
  }
27
30
  }
28
31
 
29
32
  function saveWorkspaces(data) {
33
+ const workspaceFile = getWorkspaceFile();
30
34
  ensureDir();
31
- fs.writeFileSync(WORKSPACE_FILE, JSON.stringify(data, null, 2));
35
+ fs.writeFileSync(workspaceFile, JSON.stringify(data, null, 2));
36
+ }
37
+
38
+ function isPathInsideWorkspace(currentPath, workspacePath) {
39
+ const relative = path.relative(workspacePath, currentPath);
40
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
32
41
  }
33
42
 
34
43
  function addWorkspace(name, rootPath, instructions = '') {
@@ -62,7 +71,7 @@ function getWorkspaceByPath(currentPath) {
62
71
  // Find workspace where current path is inside or equal to workspace path
63
72
  for (const name in workspaces) {
64
73
  const ws = workspaces[name];
65
- if (absoluteCurrent.startsWith(ws.path)) {
74
+ if (isPathInsideWorkspace(absoluteCurrent, ws.path)) {
66
75
  return ws;
67
76
  }
68
77
  }
@@ -1,4 +1,4 @@
1
- const { exec } = require('child_process');
1
+ const { execFile } = require('child_process');
2
2
 
3
3
  module.exports = {
4
4
  name: 'docker',
@@ -8,28 +8,30 @@ module.exports = {
8
8
  return new Promise((resolve) => {
9
9
  console.log(`[Docker Plugin] Executing command: ${target}`);
10
10
 
11
- const [action, ...args] = target.toLowerCase().split(' ');
11
+ const rawTarget = (target || '').trim();
12
+ const [rawAction, ...args] = rawTarget.split(/\s+/);
13
+ const action = (rawAction || '').toLowerCase();
12
14
  const containerName = args.join(' ');
13
-
14
- let cmd = '';
15
+ let commandArgs = [];
15
16
 
16
17
  if (action === 'list') {
17
- cmd = 'docker ps --format "{{.Names}} ({{.Status}})"';
18
+ commandArgs = ['ps', '--format', '{{.Names}} ({{.Status}})'];
18
19
  } else if (['start', 'stop', 'restart'].includes(action) && containerName) {
19
- cmd = `docker ${action} ${containerName}`;
20
+ commandArgs = [action, containerName];
20
21
  } else {
21
22
  return resolve(`Invalid docker command or missing container name: ${target}`);
22
23
  }
23
24
 
24
- exec(cmd, (error, stdout, stderr) => {
25
+ execFile('docker', commandArgs, (error, stdout, stderr) => {
25
26
  if (error) {
26
- if (error.code === 127 || stderr.includes('not found')) {
27
+ const stderrText = stderr || '';
28
+ if (error.code === 127 || stderrText.includes('not found') || error.code === 'ENOENT') {
27
29
  return resolve('Error: Docker is not installed or not in PATH.');
28
30
  }
29
- if (stderr.includes('permission denied')) {
31
+ if (stderrText.toLowerCase().includes('permission denied')) {
30
32
  return resolve('Error: Permission denied. You might need to add your user to the "docker" group.');
31
33
  }
32
- return resolve(`Docker Error: ${stderr || error.message}`);
34
+ return resolve(`Docker Error: ${stderrText || error.message}`);
33
35
  }
34
36
 
35
37
  if (action === 'list') {
@@ -3,6 +3,10 @@ const path = require('path');
3
3
  const { app, shell } = require('electron');
4
4
  const { exec } = require('child_process');
5
5
 
6
+ function escapeRegExp(text) {
7
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
9
+
6
10
  class CustomWorkflows {
7
11
  constructor() {
8
12
  this.configPath = path.join(app.getPath('userData'), 'workflows.json');
@@ -86,7 +90,7 @@ class CustomWorkflows {
86
90
  if (wf.trigger && wf.trigger.type === 'process_running' && wf.trigger.processName) {
87
91
  const targetName = wf.trigger.processName.toLowerCase();
88
92
  // simplistic exact-word match to avoid partial matches
89
- const regex = new RegExp(`^${targetName}$`, 'm');
93
+ const regex = new RegExp(`^${escapeRegExp(targetName)}$`, 'm');
90
94
  const isRunning = regex.test(runningProcesses);
91
95
 
92
96
  if (isRunning) {
@@ -124,4 +128,7 @@ class CustomWorkflows {
124
128
  }
125
129
  }
126
130
 
127
- module.exports = new CustomWorkflows();
131
+ const instance = new CustomWorkflows();
132
+ instance._helpers = { escapeRegExp };
133
+
134
+ module.exports = instance;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tests: chat_router routing helpers
3
+ */
4
+
5
+ jest.mock('@google/genai', () => ({
6
+ GoogleGenAI: jest.fn()
7
+ }));
8
+
9
+ jest.mock('../src/CLI/code_agent', () => ({
10
+ executeCodeTask: jest.fn(),
11
+ _helpers: {
12
+ selectSupportedCodeProvider: jest.fn(() => 'gemini')
13
+ }
14
+ }));
15
+
16
+ jest.mock('../src/System/config_manager', () => ({
17
+ readConfig: jest.fn(() => ({})),
18
+ getAvailableProviders: jest.fn(() => ['gemini'])
19
+ }));
20
+
21
+ describe('chat_router helpers', () => {
22
+ test('recognizes direct folder open request as chat task', () => {
23
+ const { _helpers } = require('../src/CLI/chat_router');
24
+ expect(_helpers.isDirectFilesystemActionRequest('เปิดโฟลเดอร์ xidaidai ให้หน่อย')).toBe(true);
25
+ });
26
+
27
+ test('does not classify direct folder open request as code intent', () => {
28
+ const { _helpers } = require('../src/CLI/chat_router');
29
+ const route = _helpers.detectCodeIntentHeuristic('open folder xidaidai', process.cwd());
30
+ expect(route).toBe(false);
31
+ });
32
+
33
+ test('treats small file-related request as normal chat', () => {
34
+ const { _helpers } = require('../src/CLI/chat_router');
35
+ expect(_helpers.isLargeCodeTaskRequest('ดูไฟล์ package.json ให้หน่อย', process.cwd())).toBe(false);
36
+ });
37
+
38
+ test('treats substantial project fix request as code task', () => {
39
+ const { _helpers } = require('../src/CLI/chat_router');
40
+ expect(_helpers.isLargeCodeTaskRequest('fix the failing tests in this project and verify the result', process.cwd())).toBe(true);
41
+ });
42
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Tests: code_agent helpers
3
+ */
4
+
5
+ jest.mock('@google/genai', () => ({
6
+ GoogleGenAI: jest.fn()
7
+ }));
8
+
9
+ jest.mock('axios', () => ({}));
10
+
11
+ jest.mock('../src/System/config_manager', () => ({
12
+ readConfig: jest.fn(() => ({})),
13
+ getAvailableProviders: jest.fn(() => ['ollama', 'gemini'])
14
+ }));
15
+
16
+ jest.mock('../src/CLI/code_session_memory', () => ({
17
+ readWorkspaceSession: jest.fn(() => ({
18
+ summary: '',
19
+ lastTask: '',
20
+ lastVerification: '',
21
+ updatedAt: null
22
+ })),
23
+ writeWorkspaceSession: jest.fn()
24
+ }));
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+
30
+ describe('code_agent helpers', () => {
31
+ test('extractJson recovers JSON embedded in surrounding text', () => {
32
+ const { _helpers } = require('../src/CLI/code_agent');
33
+ const parsed = _helpers.extractJson('note\n{"action":"finish","input":{"summary":"ok"}}\nthanks');
34
+ expect(parsed.action).toBe('finish');
35
+ expect(parsed.input.summary).toBe('ok');
36
+ });
37
+
38
+ test('selectSupportedCodeProvider falls back away from unsupported code providers', () => {
39
+ const { _helpers } = require('../src/CLI/code_agent');
40
+ const selected = _helpers.selectSupportedCodeProvider(
41
+ { aiProvider: 'ollama' },
42
+ ['ollama', 'openai', 'gemini']
43
+ );
44
+ expect(selected).toBe('openai');
45
+ });
46
+
47
+ test('selectSupportedCodeProvider keeps configured supported provider when available', () => {
48
+ const { _helpers } = require('../src/CLI/code_agent');
49
+ const selected = _helpers.selectSupportedCodeProvider(
50
+ { aiProvider: 'anthropic' },
51
+ ['anthropic', 'gemini']
52
+ );
53
+ expect(selected).toBe('anthropic');
54
+ });
55
+
56
+ test('findPaths can locate directories by partial name', async () => {
57
+ const { _helpers } = require('../src/CLI/code_agent');
58
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mint-code-agent-'));
59
+ const targetDir = path.join(tempDir, 'projects', 'xidaidai');
60
+ fs.mkdirSync(targetDir, { recursive: true });
61
+
62
+ try {
63
+ const result = await _helpers.findPaths(tempDir, 'xidaidai', 'dir');
64
+ expect(result).toContain('[dir] projects/xidaidai');
65
+ } finally {
66
+ fs.rmSync(tempDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tests: docker.js plugin
3
+ */
4
+
5
+ jest.mock('child_process', () => ({
6
+ execFile: jest.fn()
7
+ }));
8
+
9
+ let docker;
10
+ let execFile;
11
+
12
+ beforeEach(() => {
13
+ jest.resetModules();
14
+ ({ execFile } = require('child_process'));
15
+ execFile.mockReset();
16
+ docker = require('../src/Plugins/docker');
17
+ });
18
+
19
+ describe('Docker Plugin', () => {
20
+ test('lists running containers', async () => {
21
+ execFile.mockImplementation((command, args, callback) => {
22
+ callback(null, 'web (Up 2 hours)\n', '');
23
+ });
24
+
25
+ const result = await docker.execute('list');
26
+ expect(execFile).toHaveBeenCalledWith('docker', ['ps', '--format', '{{.Names}} ({{.Status}})'], expect.any(Function));
27
+ expect(result).toContain('Running Containers');
28
+ expect(result).toContain('web (Up 2 hours)');
29
+ });
30
+
31
+ test('starts a named container without shell interpolation', async () => {
32
+ execFile.mockImplementation((command, args, callback) => {
33
+ callback(null, '', '');
34
+ });
35
+
36
+ const result = await docker.execute('start my-app');
37
+ expect(execFile).toHaveBeenCalledWith('docker', ['start', 'my-app'], expect.any(Function));
38
+ expect(result).toContain('Successfully executed "docker start"');
39
+ });
40
+
41
+ test('returns helpful message when command is invalid', async () => {
42
+ const result = await docker.execute('remove my-app');
43
+ expect(result).toContain('Invalid docker command');
44
+ expect(execFile).not.toHaveBeenCalled();
45
+ });
46
+ });