@geminilight/mindos 0.5.12 → 0.5.13

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.
@@ -4,7 +4,8 @@ import { NextRequest, NextResponse } from 'next/server';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
6
  import { getFileContent, getMindRoot } from '@/lib/fs';
7
- import { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT } from '@/lib/agent';
7
+ import { getModel, knowledgeBaseTools, truncate, AGENT_SYSTEM_PROMPT, estimateTokens, estimateStringTokens, getContextLimit, needsCompact, truncateToolOutputs, compactMessages, hardPrune } from '@/lib/agent';
8
+ import { effectiveAiConfig, readSettings } from '@/lib/settings';
8
9
  import type { Message as FrontendMessage, ToolCallPart as FrontendToolCallPart } from '@/lib/types';
9
10
 
10
11
  /**
@@ -62,6 +63,7 @@ function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
62
63
  completedToolCalls.push(part);
63
64
  }
64
65
  }
66
+ // 'reasoning' parts are display-only; not sent back to model
65
67
  }
66
68
 
67
69
  if (assistantContent.length > 0) {
@@ -124,8 +126,19 @@ export async function POST(req: NextRequest) {
124
126
  return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
125
127
  }
126
128
 
127
- const { messages, currentFile, attachedFiles, uploadedFiles, maxSteps } = body;
128
- const stepLimit = Number.isFinite(maxSteps) ? Math.min(30, Math.max(1, Number(maxSteps))) : 20;
129
+ const { messages, currentFile, attachedFiles, uploadedFiles } = body;
130
+
131
+ // Read agent config from settings
132
+ // NOTE: readSettings() is also called inside getModel() → effectiveAiConfig().
133
+ // Acceptable duplication — both are sync fs reads with identical results.
134
+ const serverSettings = readSettings();
135
+ const agentConfig = serverSettings.agent ?? {};
136
+ const stepLimit = Number.isFinite(body.maxSteps)
137
+ ? Math.min(30, Math.max(1, Number(body.maxSteps)))
138
+ : Math.min(30, Math.max(1, agentConfig.maxSteps ?? 20));
139
+ const enableThinking = agentConfig.enableThinking ?? false;
140
+ const thinkingBudget = agentConfig.thinkingBudget ?? 5000;
141
+ const contextStrategy = agentConfig.contextStrategy ?? 'auto';
129
142
 
130
143
  // Auto-load skill + bootstrap context for each request.
131
144
  const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
@@ -143,19 +156,21 @@ export async function POST(req: NextRequest) {
143
156
  target_config_md: targetDir ? readKnowledgeFile(`${targetDir}/CONFIG.md`) : null,
144
157
  };
145
158
 
146
- const initStatus = [
147
- `skill.mindos: ${skill.ok ? 'ok' : `failed (${skill.error})`} [${skillPath}]`,
148
- `bootstrap.instruction: ${bootstrap.instruction.ok ? 'ok' : `failed (${bootstrap.instruction.error})`}`,
149
- `bootstrap.index: ${bootstrap.index.ok ? 'ok' : `failed (${bootstrap.index.error})`}`,
150
- `bootstrap.config_json: ${bootstrap.config_json.ok ? 'ok' : `failed (${bootstrap.config_json.error})`}`,
151
- `bootstrap.config_md: ${bootstrap.config_md.ok ? 'ok' : `failed (${bootstrap.config_md.error})`}`,
152
- `bootstrap.target_dir: ${targetDir ?? '(none)'}`,
153
- `bootstrap.target_readme: ${bootstrap.target_readme ? (bootstrap.target_readme.ok ? 'ok' : `failed (${bootstrap.target_readme.error})`) : 'skipped'}`,
154
- `bootstrap.target_instruction: ${bootstrap.target_instruction ? (bootstrap.target_instruction.ok ? 'ok' : `failed (${bootstrap.target_instruction.error})`) : 'skipped'}`,
155
- `bootstrap.target_config_json: ${bootstrap.target_config_json ? (bootstrap.target_config_json.ok ? 'ok' : `failed (${bootstrap.target_config_json.error})`) : 'skipped'}`,
156
- `bootstrap.target_config_md: ${bootstrap.target_config_md ? (bootstrap.target_config_md.ok ? 'ok' : `failed (${bootstrap.target_config_md.error})`) : 'skipped'}`,
157
- `bootstrap.mind_root: ${getMindRoot()}`,
158
- ].join('\n');
159
+ // Only report failures — when everything loads fine, a single summary line suffices.
160
+ const initFailures: string[] = [];
161
+ if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
162
+ if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
163
+ if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
164
+ if (!bootstrap.config_json.ok) initFailures.push(`bootstrap.config_json: failed (${bootstrap.config_json.error})`);
165
+ if (!bootstrap.config_md.ok) initFailures.push(`bootstrap.config_md: failed (${bootstrap.config_md.error})`);
166
+ if (bootstrap.target_readme && !bootstrap.target_readme.ok) initFailures.push(`bootstrap.target_readme: failed (${bootstrap.target_readme.error})`);
167
+ if (bootstrap.target_instruction && !bootstrap.target_instruction.ok) initFailures.push(`bootstrap.target_instruction: failed (${bootstrap.target_instruction.error})`);
168
+ if (bootstrap.target_config_json && !bootstrap.target_config_json.ok) initFailures.push(`bootstrap.target_config_json: failed (${bootstrap.target_config_json.error})`);
169
+ if (bootstrap.target_config_md && !bootstrap.target_config_md.ok) initFailures.push(`bootstrap.target_config_md: failed (${bootstrap.target_config_md.error})`);
170
+
171
+ const initStatus = initFailures.length === 0
172
+ ? `All initialization contexts loaded successfully. mind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`
173
+ : `Initialization issues:\n${initFailures.join('\n')}\nmind_root=${getMindRoot()}${targetDir ? `, target_dir=${targetDir}` : ''}`;
159
174
 
160
175
  const initContextBlocks: string[] = [];
161
176
  if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
@@ -227,7 +242,34 @@ export async function POST(req: NextRequest) {
227
242
 
228
243
  try {
229
244
  const model = getModel();
230
- const modelMessages = convertToModelMessages(messages);
245
+ const cfg = effectiveAiConfig();
246
+ const modelName = cfg.provider === 'openai' ? cfg.openaiModel : cfg.anthropicModel;
247
+ let modelMessages = convertToModelMessages(messages);
248
+
249
+ // Phase 3: Context management pipeline
250
+ // 1. Truncate tool outputs in historical messages
251
+ modelMessages = truncateToolOutputs(modelMessages);
252
+
253
+ const preTokens = estimateTokens(modelMessages);
254
+ const sysTokens = estimateStringTokens(systemPrompt);
255
+ const ctxLimit = getContextLimit(modelName);
256
+ console.log(`[ask] Context: ~${preTokens + sysTokens} tokens (messages=${preTokens}, system=${sysTokens}), limit=${ctxLimit}`);
257
+
258
+ // 2. Compact if >70% context limit (skip if user disabled)
259
+ if (contextStrategy === 'auto' && needsCompact(modelMessages, systemPrompt, modelName)) {
260
+ console.log('[ask] Context >70% limit, compacting...');
261
+ const result = await compactMessages(modelMessages, model);
262
+ modelMessages = result.messages;
263
+ if (result.compacted) {
264
+ const postTokens = estimateTokens(modelMessages);
265
+ console.log(`[ask] After compact: ~${postTokens + sysTokens} tokens`);
266
+ } else {
267
+ console.log('[ask] Compact skipped (too few messages), hard prune will handle overflow if needed');
268
+ }
269
+ }
270
+
271
+ // 3. Hard prune if still >90% context limit
272
+ modelMessages = hardPrune(modelMessages, systemPrompt, modelName);
231
273
 
232
274
  // Phase 2: Step monitoring + loop detection
233
275
  const stepHistory: Array<{ tool: string; input: string }> = [];
@@ -240,6 +282,13 @@ export async function POST(req: NextRequest) {
240
282
  messages: modelMessages,
241
283
  tools: knowledgeBaseTools,
242
284
  stopWhen: stepCountIs(stepLimit),
285
+ ...(enableThinking && cfg.provider === 'anthropic' ? {
286
+ providerOptions: {
287
+ anthropic: {
288
+ thinking: { type: 'enabled', budgetTokens: thinkingBudget },
289
+ },
290
+ },
291
+ } : {}),
243
292
 
244
293
  onStepFinish: ({ toolCalls, usage }) => {
245
294
  if (toolCalls) {
@@ -6,28 +6,23 @@ export async function GET() {
6
6
  try {
7
7
  const settings = readSettings();
8
8
  const port = settings.mcpPort ?? 8781;
9
- const endpoint = `http://127.0.0.1:${port}/mcp`;
9
+ const baseUrl = `http://127.0.0.1:${port}`;
10
+ const endpoint = `${baseUrl}/mcp`;
10
11
  const authConfigured = !!settings.authToken;
11
12
 
12
- // Check if MCP server is running
13
13
  let running = false;
14
- let toolCount = 0;
14
+
15
15
  try {
16
+ // Use the health endpoint — avoids MCP handshake complexity
17
+ const healthUrl = `${baseUrl}/api/health`;
16
18
  const controller = new AbortController();
17
19
  const timeout = setTimeout(() => controller.abort(), 2000);
18
- const res = await fetch(endpoint, {
19
- method: 'POST',
20
- headers: { 'Content-Type': 'application/json' },
21
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
22
- signal: controller.signal,
23
- });
20
+ const res = await fetch(healthUrl, { signal: controller.signal, cache: 'no-store' });
24
21
  clearTimeout(timeout);
22
+
25
23
  if (res.ok) {
26
- running = true;
27
- try {
28
- const data = await res.json();
29
- if (data?.result?.tools) toolCount = data.result.tools.length;
30
- } catch { /* non-JSON response — still running */ }
24
+ const data = await res.json() as { ok?: boolean; service?: string };
25
+ running = data.ok === true && data.service === 'mindos';
31
26
  }
32
27
  } catch {
33
28
  // Connection refused or timeout — not running
@@ -38,7 +33,7 @@ export async function GET() {
38
33
  transport: 'http',
39
34
  endpoint,
40
35
  port,
41
- toolCount,
36
+ toolCount: running ? 20 : 0,
42
37
  authConfigured,
43
38
  });
44
39
  } catch (err) {
@@ -49,6 +49,7 @@ export async function GET() {
49
49
  webPassword: settings.webPassword ? '***set***' : '',
50
50
  authToken: maskToken(settings.authToken),
51
51
  mcpPort: settings.mcpPort ?? 8781,
52
+ agent: settings.agent ?? {},
52
53
  envOverrides: {
53
54
  AI_PROVIDER: !!process.env.AI_PROVIDER,
54
55
  ANTHROPIC_API_KEY: !!process.env.ANTHROPIC_API_KEY,
@@ -110,6 +111,7 @@ export async function POST(req: NextRequest) {
110
111
  },
111
112
  },
112
113
  mindRoot: body.mindRoot ?? current.mindRoot,
114
+ agent: body.agent ?? current.agent,
113
115
  webPassword: resolvedWebPassword,
114
116
  authToken: resolvedAuthToken,
115
117
  port: typeof body.port === 'number' ? body.port : current.port,
@@ -1,6 +1,6 @@
1
1
  export const dynamic = 'force-dynamic';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
- import { execSync, exec } from 'child_process';
3
+ import { execSync, execFile } from 'child_process';
4
4
  import { existsSync, readFileSync, writeFileSync } from 'fs';
5
5
  import { join, resolve } from 'path';
6
6
  import { homedir } from 'os';
@@ -42,13 +42,11 @@ function getCliPath() {
42
42
  return resolve(process.cwd(), '..', 'bin', 'cli' + '.js');
43
43
  }
44
44
 
45
- /** Run CLI command via shell string — avoids Turbopack static analysis of file paths */
45
+ /** Run CLI command via execFile — avoids shell injection by passing args as array */
46
46
  function runCli(args: string[], timeoutMs = 30000): Promise<void> {
47
47
  const cliPath = getCliPath();
48
- const escaped = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
49
- const cmd = `${process.execPath} ${cliPath} ${escaped}`;
50
48
  return new Promise((res, rej) => {
51
- exec(cmd, { timeout: timeoutMs }, (err, _stdout, stderr) => {
49
+ execFile(process.execPath, [cliPath, ...args], { timeout: timeoutMs }, (err, _stdout, stderr) => {
52
50
  if (err) rej(new Error(stderr?.trim() || err.message));
53
51
  else res();
54
52
  });
@@ -28,7 +28,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
28
28
  const [isLoading, setIsLoading] = useState(false);
29
29
  const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
30
30
  const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
31
- const [maxSteps, setMaxSteps] = useState(20);
32
31
  const [showHistory, setShowHistory] = useState(false);
33
32
 
34
33
  const session = useAskSession(currentFile);
@@ -136,7 +135,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
136
135
  currentFile,
137
136
  attachedFiles,
138
137
  uploadedFiles: upload.localAttachments,
139
- maxSteps,
140
138
  }),
141
139
  signal: controller.signal,
142
140
  });
@@ -208,7 +206,7 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
208
206
  setIsLoading(false);
209
207
  abortRef.current = null;
210
208
  }
211
- }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, maxSteps, t.ask.errorNoResponse, t.ask.stopped]);
209
+ }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped]);
212
210
 
213
211
  const handleResetSession = useCallback(() => {
214
212
  if (isLoading) return;
@@ -287,7 +285,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
287
285
  emptyPrompt={t.ask.emptyPrompt}
288
286
  suggestions={t.ask.suggestions}
289
287
  onSuggestionClick={setInput}
290
- maxSteps={maxSteps}
291
288
  labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
292
289
  />
293
290
 
@@ -389,14 +386,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
389
386
  <div className="hidden md:flex px-4 pb-2 items-center gap-3 text-xs text-muted-foreground/50 shrink-0">
390
387
  <span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
391
388
  <span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
392
- <span className="inline-flex items-center gap-1">
393
- <span>Agent steps</span>
394
- <select value={maxSteps} onChange={(e) => setMaxSteps(Number(e.target.value))} disabled={isLoading} className="bg-transparent border border-border rounded px-1.5 py-0.5 text-xs text-foreground">
395
- <option value={10}>10</option>
396
- <option value={20}>20</option>
397
- <option value={30}>30</option>
398
- </select>
399
- </span>
400
389
  <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>
401
390
  </div>
402
391
  </div>
@@ -6,7 +6,7 @@ import { useLocale } from '@/lib/LocaleContext';
6
6
  import { getAllRenderers, loadDisabledState, isRendererEnabled } from '@/lib/renderers/registry';
7
7
  import { apiFetch } from '@/lib/api';
8
8
  import '@/lib/renderers/index';
9
- import type { AiSettings, SettingsData, Tab } from './settings/types';
9
+ import type { AiSettings, AgentSettings, SettingsData, Tab } from './settings/types';
10
10
  import { FONTS } from './settings/types';
11
11
  import { AiTab } from './settings/AiTab';
12
12
  import { AppearanceTab } from './settings/AppearanceTab';
@@ -88,7 +88,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
88
88
  await apiFetch('/api/settings', {
89
89
  method: 'POST',
90
90
  headers: { 'Content-Type': 'application/json' },
91
- body: JSON.stringify({ ai: data.ai, mindRoot: data.mindRoot, webPassword: data.webPassword, authToken: data.authToken }),
91
+ body: JSON.stringify({ ai: data.ai, agent: data.agent, mindRoot: data.mindRoot, webPassword: data.webPassword, authToken: data.authToken }),
92
92
  });
93
93
  setStatus('saved');
94
94
  setTimeout(() => setStatus('idle'), 2500);
@@ -104,6 +104,10 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
104
104
  setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
105
105
  }, []);
106
106
 
107
+ const updateAgent = useCallback((patch: Partial<AgentSettings>) => {
108
+ setData(d => d ? { ...d, agent: { ...(d.agent ?? {}), ...patch } } : d);
109
+ }, []);
110
+
107
111
  const restoreFromEnv = useCallback(async () => {
108
112
  if (!data) return;
109
113
  const defaults: AiSettings = {
@@ -198,7 +202,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
198
202
  </div>
199
203
  ) : (
200
204
  <>
201
- {tab === 'ai' && data?.ai && <AiTab data={data} updateAi={updateAi} t={t} />}
205
+ {tab === 'ai' && data?.ai && <AiTab data={data} updateAi={updateAi} updateAgent={updateAgent} t={t} />}
202
206
  {tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
203
207
  {tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
204
208
  {tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
@@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown';
6
6
  import remarkGfm from 'remark-gfm';
7
7
  import type { Message } from '@/lib/types';
8
8
  import ToolCallBlock from './ToolCallBlock';
9
+ import ThinkingBlock from './ThinkingBlock';
9
10
 
10
11
  function AssistantMessage({ content, isStreaming }: { content: string; isStreaming: boolean }) {
11
12
  return (
@@ -45,6 +46,10 @@ function AssistantMessageWithParts({ message, isStreaming }: { message: Message;
45
46
  return (
46
47
  <div>
47
48
  {parts.map((part, idx) => {
49
+ if (part.type === 'reasoning') {
50
+ const isLastPart = isStreaming && idx === parts.length - 1;
51
+ return <ThinkingBlock key={`reasoning-${idx}`} text={part.text} isStreaming={isLastPart} />;
52
+ }
48
53
  if (part.type === 'text') {
49
54
  const isLastTextPart = isStreaming && idx === parts.length - 1;
50
55
  return part.text ? (
@@ -66,7 +71,7 @@ function AssistantMessageWithParts({ message, isStreaming }: { message: Message;
66
71
  );
67
72
  }
68
73
 
69
- function StepCounter({ parts, maxSteps }: { parts: Message['parts']; maxSteps?: number }) {
74
+ function StepCounter({ parts }: { parts: Message['parts'] }) {
70
75
  if (!parts) return null;
71
76
  const toolCalls = parts.filter(p => p.type === 'tool-call');
72
77
  if (toolCalls.length === 0) return null;
@@ -75,7 +80,7 @@ function StepCounter({ parts, maxSteps }: { parts: Message['parts']; maxSteps?:
75
80
  return (
76
81
  <div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground/70">
77
82
  <Wrench size={10} />
78
- <span>Step {toolCalls.length}{maxSteps ? `/${maxSteps}` : ''}{toolLabel ? ` — ${toolLabel}` : ''}</span>
83
+ <span>Step {toolCalls.length}{toolLabel ? ` — ${toolLabel}` : ''}</span>
79
84
  </div>
80
85
  );
81
86
  }
@@ -87,7 +92,6 @@ interface MessageListProps {
87
92
  emptyPrompt: string;
88
93
  suggestions: readonly string[];
89
94
  onSuggestionClick: (text: string) => void;
90
- maxSteps?: number;
91
95
  labels: {
92
96
  connecting: string;
93
97
  thinking: string;
@@ -102,7 +106,6 @@ export default function MessageList({
102
106
  emptyPrompt,
103
107
  suggestions,
104
108
  onSuggestionClick,
105
- maxSteps,
106
109
  labels,
107
110
  }: MessageListProps) {
108
111
  const endRef = useRef<HTMLDivElement>(null);
@@ -160,7 +163,7 @@ export default function MessageList({
160
163
  <>
161
164
  <AssistantMessageWithParts message={m} isStreaming={isLoading && i === messages.length - 1} />
162
165
  {isLoading && i === messages.length - 1 && (
163
- <StepCounter parts={m.parts} maxSteps={maxSteps} />
166
+ <StepCounter parts={m.parts} />
164
167
  )}
165
168
  </>
166
169
  ) : isLoading && i === messages.length - 1 ? (
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { ChevronRight, ChevronDown, Brain } from 'lucide-react';
5
+ import { useLocale } from '@/lib/LocaleContext';
6
+
7
+ interface ThinkingBlockProps {
8
+ text: string;
9
+ isStreaming?: boolean;
10
+ }
11
+
12
+ export default function ThinkingBlock({ text, isStreaming }: ThinkingBlockProps) {
13
+ const [expanded, setExpanded] = useState(false);
14
+ const { t } = useLocale();
15
+
16
+ if (!text && !isStreaming) return null;
17
+
18
+ return (
19
+ <div className="my-1 rounded-md border border-border/40 bg-muted/20 text-xs">
20
+ <button
21
+ type="button"
22
+ onClick={() => setExpanded(v => !v)}
23
+ className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/30 transition-colors rounded-md"
24
+ >
25
+ {expanded ? (
26
+ <ChevronDown size={12} className="shrink-0 text-muted-foreground" />
27
+ ) : (
28
+ <ChevronRight size={12} className="shrink-0 text-muted-foreground" />
29
+ )}
30
+ <Brain size={12} className="shrink-0 text-muted-foreground" />
31
+ <span className="text-muted-foreground font-medium">
32
+ {t.ask.thinkingLabel}
33
+ {isStreaming && !expanded && (
34
+ <span className="ml-1 animate-pulse">...</span>
35
+ )}
36
+ </span>
37
+ {!expanded && text && (
38
+ <span className="text-muted-foreground/60 truncate flex-1 ml-1">
39
+ {text.slice(0, 80)}{text.length > 80 ? '...' : ''}
40
+ </span>
41
+ )}
42
+ </button>
43
+ {expanded && (
44
+ <div className="px-2 pb-2 pt-0.5 border-t border-border/30">
45
+ <div className="text-muted-foreground whitespace-pre-wrap leading-relaxed">
46
+ {text}
47
+ {isStreaming && (
48
+ <span className="inline-block w-1 h-3 bg-muted-foreground/40 ml-0.5 align-middle animate-pulse rounded-sm" />
49
+ )}
50
+ </div>
51
+ </div>
52
+ )}
53
+ </div>
54
+ );
55
+ }
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { ChevronRight, ChevronDown, Loader2, CheckCircle2, XCircle } from 'lucide-react';
4
+ import { ChevronRight, ChevronDown, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
5
5
  import type { ToolCallPart } from '@/lib/types';
6
6
 
7
+ const DESTRUCTIVE_TOOLS = new Set(['delete_file', 'move_file', 'rename_file', 'write_file']);
8
+
7
9
  const TOOL_ICONS: Record<string, string> = {
8
10
  search: '🔍',
9
11
  list_files: '📂',
@@ -48,17 +50,23 @@ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
48
50
  const [expanded, setExpanded] = useState(false);
49
51
  const icon = TOOL_ICONS[part.toolName] ?? '🔧';
50
52
  const inputSummary = formatInput(part.input);
53
+ const isDestructive = DESTRUCTIVE_TOOLS.has(part.toolName);
51
54
 
52
55
  return (
53
- <div className="my-1 rounded-md border border-border/50 bg-muted/30 text-xs font-mono">
56
+ <div className={`my-1 rounded-md border text-xs font-mono ${
57
+ isDestructive
58
+ ? 'border-amber-500/30 bg-amber-500/5'
59
+ : 'border-border/50 bg-muted/30'
60
+ }`}>
54
61
  <button
55
62
  type="button"
56
63
  onClick={() => setExpanded(v => !v)}
57
64
  className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/50 transition-colors rounded-md"
58
65
  >
59
66
  {expanded ? <ChevronDown size={12} className="shrink-0 text-muted-foreground" /> : <ChevronRight size={12} className="shrink-0 text-muted-foreground" />}
67
+ {isDestructive && <AlertTriangle size={11} className="shrink-0 text-amber-500" />}
60
68
  <span>{icon}</span>
61
- <span className="text-foreground font-medium">{part.toolName}</span>
69
+ <span className={`font-medium ${isDestructive ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>{part.toolName}</span>
62
70
  <span className="text-muted-foreground truncate flex-1">({inputSummary})</span>
63
71
  <span className="shrink-0 ml-auto">
64
72
  {part.state === 'pending' || part.state === 'running' ? (
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useRef, useCallback, useEffect } from 'react';
4
4
  import { AlertCircle, Loader2 } from 'lucide-react';
5
- import type { AiSettings, ProviderConfig, SettingsData } from './types';
5
+ import type { AiSettings, AgentSettings, ProviderConfig, SettingsData } from './types';
6
6
  import { Field, Select, Input, EnvBadge, ApiKeyInput } from './Primitives';
7
7
 
8
8
  type TestState = 'idle' | 'testing' | 'ok' | 'error';
@@ -28,10 +28,11 @@ function errorMessage(t: any, code?: ErrorCode): string {
28
28
  interface AiTabProps {
29
29
  data: SettingsData;
30
30
  updateAi: (patch: Partial<AiSettings>) => void;
31
+ updateAgent: (patch: Partial<AgentSettings>) => void;
31
32
  t: any;
32
33
  }
33
34
 
34
- export function AiTab({ data, updateAi, t }: AiTabProps) {
35
+ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
35
36
  const env = data.envOverrides ?? {};
36
37
  const envVal = data.envValues ?? {};
37
38
  const provider = data.ai.provider;
@@ -224,6 +225,79 @@ export function AiTab({ data, updateAi, t }: AiTabProps) {
224
225
  <span>{t.settings.ai.envHint}</span>
225
226
  </div>
226
227
  )}
228
+
229
+ {/* Agent Behavior */}
230
+ <div className="pt-3 border-t border-border">
231
+ <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">{t.settings.agent.title}</h3>
232
+
233
+ <div className="space-y-4">
234
+ <Field label={t.settings.agent.maxSteps} hint={t.settings.agent.maxStepsHint}>
235
+ <Select
236
+ value={String(data.agent?.maxSteps ?? 20)}
237
+ onChange={e => updateAgent({ maxSteps: Number(e.target.value) })}
238
+ >
239
+ <option value="5">5</option>
240
+ <option value="10">10</option>
241
+ <option value="15">15</option>
242
+ <option value="20">20</option>
243
+ <option value="25">25</option>
244
+ <option value="30">30</option>
245
+ </Select>
246
+ </Field>
247
+
248
+ <Field label={t.settings.agent.contextStrategy} hint={t.settings.agent.contextStrategyHint}>
249
+ <Select
250
+ value={data.agent?.contextStrategy ?? 'auto'}
251
+ onChange={e => updateAgent({ contextStrategy: e.target.value as 'auto' | 'off' })}
252
+ >
253
+ <option value="auto">{t.settings.agent.contextStrategyAuto}</option>
254
+ <option value="off">{t.settings.agent.contextStrategyOff}</option>
255
+ </Select>
256
+ </Field>
257
+
258
+ {provider === 'anthropic' && (
259
+ <>
260
+ <div className="flex items-center justify-between">
261
+ <div>
262
+ <div className="text-sm text-foreground">{t.settings.agent.thinking}</div>
263
+ <div className="text-xs text-muted-foreground mt-0.5">{t.settings.agent.thinkingHint}</div>
264
+ </div>
265
+ <button
266
+ type="button"
267
+ role="switch"
268
+ aria-checked={data.agent?.enableThinking ?? false}
269
+ onClick={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })}
270
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
271
+ data.agent?.enableThinking ? 'bg-amber-500' : 'bg-muted'
272
+ }`}
273
+ >
274
+ <span
275
+ className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
276
+ data.agent?.enableThinking ? 'translate-x-4' : 'translate-x-0'
277
+ }`}
278
+ />
279
+ </button>
280
+ </div>
281
+
282
+ {data.agent?.enableThinking && (
283
+ <Field label={t.settings.agent.thinkingBudget} hint={t.settings.agent.thinkingBudgetHint}>
284
+ <Input
285
+ type="number"
286
+ value={String(data.agent?.thinkingBudget ?? 5000)}
287
+ onChange={e => {
288
+ const v = parseInt(e.target.value, 10);
289
+ if (!isNaN(v)) updateAgent({ thinkingBudget: Math.max(1000, Math.min(50000, v)) });
290
+ }}
291
+ min={1000}
292
+ max={50000}
293
+ step={1000}
294
+ />
295
+ </Field>
296
+ )}
297
+ </>
298
+ )}
299
+ </div>
300
+ </div>
227
301
  </div>
228
302
  );
229
303
  }
@@ -14,8 +14,16 @@ export interface AiSettings {
14
14
  };
15
15
  }
16
16
 
17
+ export interface AgentSettings {
18
+ maxSteps?: number;
19
+ enableThinking?: boolean;
20
+ thinkingBudget?: number;
21
+ contextStrategy?: 'auto' | 'off';
22
+ }
23
+
17
24
  export interface SettingsData {
18
25
  ai: AiSettings;
26
+ agent?: AgentSettings;
19
27
  mindRoot: string;
20
28
  webPassword?: string;
21
29
  authToken?: string; // masked: first-xxxx-••••-last pattern