@geminilight/mindos 0.5.11 → 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.
Files changed (42) hide show
  1. package/README.md +9 -9
  2. package/README_zh.md +9 -9
  3. package/app/README.md +2 -2
  4. package/app/app/api/ask/route.ts +191 -19
  5. package/app/app/api/mcp/install/route.ts +1 -1
  6. package/app/app/api/mcp/status/route.ts +11 -16
  7. package/app/app/api/settings/route.ts +3 -1
  8. package/app/app/api/setup/route.ts +7 -7
  9. package/app/app/api/sync/route.ts +18 -15
  10. package/app/components/AskModal.tsx +28 -32
  11. package/app/components/SettingsModal.tsx +7 -3
  12. package/app/components/ask/MessageList.tsx +65 -3
  13. package/app/components/ask/ThinkingBlock.tsx +55 -0
  14. package/app/components/ask/ToolCallBlock.tsx +97 -0
  15. package/app/components/settings/AiTab.tsx +76 -2
  16. package/app/components/settings/types.ts +8 -0
  17. package/app/components/setup/StepReview.tsx +31 -25
  18. package/app/components/setup/index.tsx +6 -3
  19. package/app/lib/agent/context.ts +317 -0
  20. package/app/lib/agent/index.ts +4 -0
  21. package/app/lib/agent/prompt.ts +46 -31
  22. package/app/lib/agent/stream-consumer.ts +212 -0
  23. package/app/lib/agent/tools.ts +159 -4
  24. package/app/lib/i18n.ts +28 -0
  25. package/app/lib/settings.ts +22 -0
  26. package/app/lib/types.ts +23 -0
  27. package/app/package.json +2 -3
  28. package/bin/cli.js +41 -21
  29. package/bin/lib/build.js +6 -2
  30. package/bin/lib/gateway.js +24 -3
  31. package/bin/lib/mcp-install.js +2 -2
  32. package/bin/lib/mcp-spawn.js +3 -3
  33. package/bin/lib/stop.js +1 -1
  34. package/bin/lib/sync.js +81 -40
  35. package/mcp/README.md +5 -5
  36. package/mcp/src/index.ts +2 -2
  37. package/package.json +3 -2
  38. package/scripts/setup.js +17 -12
  39. package/scripts/upgrade-prompt.md +6 -6
  40. package/skills/mindos/SKILL.md +47 -183
  41. package/skills/mindos-zh/SKILL.md +47 -183
  42. package/app/package-lock.json +0 -15615
@@ -11,6 +11,7 @@ import MessageList from '@/components/ask/MessageList';
11
11
  import MentionPopover from '@/components/ask/MentionPopover';
12
12
  import SessionHistory from '@/components/ask/SessionHistory';
13
13
  import FileChip from '@/components/ask/FileChip';
14
+ import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
14
15
 
15
16
  interface AskModalProps {
16
17
  open: boolean;
@@ -27,7 +28,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
27
28
  const [isLoading, setIsLoading] = useState(false);
28
29
  const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
29
30
  const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
30
- const [maxSteps, setMaxSteps] = useState(20);
31
31
  const [showHistory, setShowHistory] = useState(false);
32
32
 
33
33
  const session = useAskSession(currentFile);
@@ -135,7 +135,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
135
135
  currentFile,
136
136
  attachedFiles,
137
137
  uploadedFiles: upload.localAttachments,
138
- maxSteps,
139
138
  }),
140
139
  signal: controller.signal,
141
140
  });
@@ -151,25 +150,22 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
151
150
 
152
151
  if (!res.body) throw new Error('No response body');
153
152
 
154
- const reader = res.body.getReader();
155
- const decoder = new TextDecoder();
156
- let assistantContent = '';
157
153
  setLoadingPhase('thinking');
158
154
 
159
- while (true) {
160
- const { done, value } = await reader.read();
161
- if (done) break;
162
- const chunk = decoder.decode(value, { stream: true });
163
- if (chunk) setLoadingPhase('streaming');
164
- assistantContent += chunk;
165
- session.setMessages(prev => {
166
- const updated = [...prev];
167
- updated[updated.length - 1] = { role: 'assistant', content: assistantContent };
168
- return updated;
169
- });
170
- }
155
+ const finalMessage = await consumeUIMessageStream(
156
+ res.body,
157
+ (msg) => {
158
+ setLoadingPhase('streaming');
159
+ session.setMessages(prev => {
160
+ const updated = [...prev];
161
+ updated[updated.length - 1] = msg;
162
+ return updated;
163
+ });
164
+ },
165
+ controller.signal,
166
+ );
171
167
 
172
- if (!assistantContent.trim()) {
168
+ if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
173
169
  session.setMessages(prev => {
174
170
  const updated = [...prev];
175
171
  updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
@@ -181,8 +177,12 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
181
177
  session.setMessages(prev => {
182
178
  const updated = [...prev];
183
179
  const lastIdx = updated.length - 1;
184
- if (lastIdx >= 0 && updated[lastIdx].role === 'assistant' && !updated[lastIdx].content.trim()) {
185
- updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
180
+ if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
181
+ const last = updated[lastIdx];
182
+ const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
183
+ if (!hasContent) {
184
+ updated[lastIdx] = { role: 'assistant', content: `__error__${t.ask.stopped}` };
185
+ }
186
186
  }
187
187
  return updated;
188
188
  });
@@ -191,9 +191,13 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
191
191
  session.setMessages(prev => {
192
192
  const updated = [...prev];
193
193
  const lastIdx = updated.length - 1;
194
- if (lastIdx >= 0 && updated[lastIdx].role === 'assistant' && !updated[lastIdx].content.trim()) {
195
- updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
196
- return updated;
194
+ if (lastIdx >= 0 && updated[lastIdx].role === 'assistant') {
195
+ const last = updated[lastIdx];
196
+ const hasContent = last.content.trim() || (last.parts && last.parts.length > 0);
197
+ if (!hasContent) {
198
+ updated[lastIdx] = { role: 'assistant', content: `__error__${errMsg}` };
199
+ return updated;
200
+ }
197
201
  }
198
202
  return [...updated, { role: 'assistant', content: `__error__${errMsg}` }];
199
203
  });
@@ -202,7 +206,7 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
202
206
  setIsLoading(false);
203
207
  abortRef.current = null;
204
208
  }
205
- }, [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]);
206
210
 
207
211
  const handleResetSession = useCallback(() => {
208
212
  if (isLoading) return;
@@ -382,14 +386,6 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
382
386
  <div className="hidden md:flex px-4 pb-2 items-center gap-3 text-xs text-muted-foreground/50 shrink-0">
383
387
  <span><kbd className="font-mono">↵</kbd> {t.ask.send}</span>
384
388
  <span><kbd className="font-mono">@</kbd> {t.ask.attachFile}</span>
385
- <span className="inline-flex items-center gap-1">
386
- <span>Agent steps</span>
387
- <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">
388
- <option value={10}>10</option>
389
- <option value={20}>20</option>
390
- <option value={30}>30</option>
391
- </select>
392
- </span>
393
389
  <span><kbd className="font-mono">ESC</kbd> {t.search.close}</span>
394
390
  </div>
395
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} />}
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useRef, useEffect } from 'react';
4
- import { Sparkles, Loader2, AlertCircle } from 'lucide-react';
4
+ import { Sparkles, Loader2, AlertCircle, Wrench } from 'lucide-react';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
  import remarkGfm from 'remark-gfm';
7
7
  import type { Message } from '@/lib/types';
8
+ import ToolCallBlock from './ToolCallBlock';
9
+ import ThinkingBlock from './ThinkingBlock';
8
10
 
9
11
  function AssistantMessage({ content, isStreaming }: { content: string; isStreaming: boolean }) {
10
12
  return (
@@ -28,6 +30,61 @@ function AssistantMessage({ content, isStreaming }: { content: string; isStreami
28
30
  );
29
31
  }
30
32
 
33
+ function AssistantMessageWithParts({ message, isStreaming }: { message: Message; isStreaming: boolean }) {
34
+ const parts = message.parts;
35
+ if (!parts || parts.length === 0) {
36
+ // Fallback to plain text rendering
37
+ return message.content ? (
38
+ <AssistantMessage content={message.content} isStreaming={isStreaming} />
39
+ ) : null;
40
+ }
41
+
42
+ // Check if the last part is a running tool call — show a spinner after it
43
+ const lastPart = parts[parts.length - 1];
44
+ const showTrailingSpinner = isStreaming && lastPart.type === 'tool-call' && (lastPart.state === 'running' || lastPart.state === 'pending');
45
+
46
+ return (
47
+ <div>
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
+ }
53
+ if (part.type === 'text') {
54
+ const isLastTextPart = isStreaming && idx === parts.length - 1;
55
+ return part.text ? (
56
+ <AssistantMessage key={idx} content={part.text} isStreaming={isLastTextPart} />
57
+ ) : null;
58
+ }
59
+ if (part.type === 'tool-call') {
60
+ return <ToolCallBlock key={part.toolCallId} part={part} />;
61
+ }
62
+ return null;
63
+ })}
64
+ {showTrailingSpinner && (
65
+ <div className="flex items-center gap-2 py-1 mt-1">
66
+ <Loader2 size={12} className="animate-spin" style={{ color: 'var(--amber)' }} />
67
+ <span className="text-xs text-muted-foreground animate-pulse">Executing tool…</span>
68
+ </div>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ function StepCounter({ parts }: { parts: Message['parts'] }) {
75
+ if (!parts) return null;
76
+ const toolCalls = parts.filter(p => p.type === 'tool-call');
77
+ if (toolCalls.length === 0) return null;
78
+ const lastToolCall = toolCalls[toolCalls.length - 1];
79
+ const toolLabel = lastToolCall.type === 'tool-call' ? lastToolCall.toolName : '';
80
+ return (
81
+ <div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground/70">
82
+ <Wrench size={10} />
83
+ <span>Step {toolCalls.length}{toolLabel ? ` — ${toolLabel}` : ''}</span>
84
+ </div>
85
+ );
86
+ }
87
+
31
88
  interface MessageListProps {
32
89
  messages: Message[];
33
90
  isLoading: boolean;
@@ -102,8 +159,13 @@ export default function MessageList({
102
159
  </div>
103
160
  ) : (
104
161
  <div className="max-w-[85%] px-3 py-2 rounded-xl rounded-bl-sm bg-muted text-foreground text-sm">
105
- {m.content ? (
106
- <AssistantMessage content={m.content} isStreaming={isLoading && i === messages.length - 1} />
162
+ {(m.parts && m.parts.length > 0) || m.content ? (
163
+ <>
164
+ <AssistantMessageWithParts message={m} isStreaming={isLoading && i === messages.length - 1} />
165
+ {isLoading && i === messages.length - 1 && (
166
+ <StepCounter parts={m.parts} />
167
+ )}
168
+ </>
107
169
  ) : isLoading && i === messages.length - 1 ? (
108
170
  <div className="flex items-center gap-2 py-1">
109
171
  <Loader2 size={14} className="animate-spin" style={{ color: 'var(--amber)' }} />
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { ChevronRight, ChevronDown, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
5
+ import type { ToolCallPart } from '@/lib/types';
6
+
7
+ const DESTRUCTIVE_TOOLS = new Set(['delete_file', 'move_file', 'rename_file', 'write_file']);
8
+
9
+ const TOOL_ICONS: Record<string, string> = {
10
+ search: '🔍',
11
+ list_files: '📂',
12
+ read_file: '📖',
13
+ write_file: '✏️',
14
+ create_file: '📄',
15
+ append_to_file: '📝',
16
+ insert_after_heading: '📌',
17
+ update_section: '✏️',
18
+ delete_file: '🗑️',
19
+ rename_file: '📝',
20
+ move_file: '📦',
21
+ get_backlinks: '🔗',
22
+ get_history: '📜',
23
+ get_file_at_version: '⏪',
24
+ get_recent: '🕐',
25
+ append_csv: '📊',
26
+ };
27
+
28
+ function formatInput(input: unknown): string {
29
+ if (!input || typeof input !== 'object') return String(input ?? '');
30
+ const obj = input as Record<string, unknown>;
31
+ const parts: string[] = [];
32
+ for (const val of Object.values(obj)) {
33
+ if (typeof val === 'string') {
34
+ parts.push(val.length > 60 ? `${val.slice(0, 60)}…` : val);
35
+ } else if (Array.isArray(val)) {
36
+ parts.push(`[${val.length} items]`);
37
+ } else if (val !== undefined && val !== null) {
38
+ parts.push(String(val));
39
+ }
40
+ }
41
+ return parts.join(', ');
42
+ }
43
+
44
+ function truncateOutput(output: string, maxLen = 200): string {
45
+ if (output.length <= maxLen) return output;
46
+ return output.slice(0, maxLen) + '…';
47
+ }
48
+
49
+ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
50
+ const [expanded, setExpanded] = useState(false);
51
+ const icon = TOOL_ICONS[part.toolName] ?? '🔧';
52
+ const inputSummary = formatInput(part.input);
53
+ const isDestructive = DESTRUCTIVE_TOOLS.has(part.toolName);
54
+
55
+ return (
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
+ }`}>
61
+ <button
62
+ type="button"
63
+ onClick={() => setExpanded(v => !v)}
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"
65
+ >
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" />}
68
+ <span>{icon}</span>
69
+ <span className={`font-medium ${isDestructive ? 'text-amber-600 dark:text-amber-400' : 'text-foreground'}`}>{part.toolName}</span>
70
+ <span className="text-muted-foreground truncate flex-1">({inputSummary})</span>
71
+ <span className="shrink-0 ml-auto">
72
+ {part.state === 'pending' || part.state === 'running' ? (
73
+ <Loader2 size={12} className="animate-spin text-amber-500" />
74
+ ) : part.state === 'done' ? (
75
+ <CheckCircle2 size={12} className="text-success" />
76
+ ) : (
77
+ <XCircle size={12} className="text-error" />
78
+ )}
79
+ </span>
80
+ </button>
81
+ {expanded && (
82
+ <div className="px-2 pb-2 pt-0.5 border-t border-border/30 space-y-1">
83
+ <div className="text-muted-foreground">
84
+ <span className="font-semibold">Input: </span>
85
+ <span className="break-all whitespace-pre-wrap">{JSON.stringify(part.input, null, 2)}</span>
86
+ </div>
87
+ {part.output !== undefined && (
88
+ <div className="text-muted-foreground">
89
+ <span className="font-semibold">Output: </span>
90
+ <span className="break-all whitespace-pre-wrap">{truncateOutput(part.output)}</span>
91
+ </div>
92
+ )}
93
+ </div>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -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
@@ -7,7 +7,24 @@ import {
7
7
  import type { SetupState, SetupMessages, AgentInstallStatus } from './types';
8
8
 
9
9
  // ─── Restart Block ────────────────────────────────────────────────────────────
10
- function RestartBlock({ s, newPort }: { s: SetupMessages; newPort: number }) {
10
+
11
+ /** Restart warning banner — shown in the content area */
12
+ export function RestartBanner({ s }: { s: SetupMessages }) {
13
+ return (
14
+ <div className="space-y-2">
15
+ <div className="p-3 rounded-lg text-sm flex items-center gap-2"
16
+ style={{ background: 'color-mix(in srgb, var(--amber) 10%, transparent)', color: 'var(--amber)' }}>
17
+ <AlertTriangle size={14} /> {s.restartRequired}
18
+ </div>
19
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
20
+ {s.restartManual} <code className="font-mono">mindos start</code>
21
+ </p>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ /** Restart button — shown in the bottom navigation bar (same position as Complete/Saving button) */
27
+ export function RestartButton({ s, newPort }: { s: SetupMessages; newPort: number }) {
11
28
  const [restarting, setRestarting] = useState(false);
12
29
  const [done, setDone] = useState(false);
13
30
  const pollRef = useRef<ReturnType<typeof setInterval>>(undefined);
@@ -40,34 +57,23 @@ function RestartBlock({ s, newPort }: { s: SetupMessages; newPort: number }) {
40
57
 
41
58
  if (done) {
42
59
  return (
43
- <div className="p-3 rounded-lg text-sm flex items-center gap-2"
44
- style={{ background: 'color-mix(in srgb, var(--success) 10%, transparent)', color: 'var(--success)' }}>
60
+ <span className="flex items-center gap-1.5 px-5 py-2 text-sm font-medium rounded-lg"
61
+ style={{ background: 'color-mix(in srgb, var(--success) 15%, transparent)', color: 'var(--success)' }}>
45
62
  <CheckCircle2 size={14} /> {s.restartDone}
46
- </div>
63
+ </span>
47
64
  );
48
65
  }
49
66
 
50
67
  return (
51
- <div className="space-y-3">
52
- <div className="p-3 rounded-lg text-sm flex items-center gap-2"
53
- style={{ background: 'color-mix(in srgb, var(--amber) 10%, transparent)', color: 'var(--amber)' }}>
54
- <AlertTriangle size={14} /> {s.restartRequired}
55
- </div>
56
- <div className="flex items-center gap-3">
57
- <button
58
- type="button"
59
- onClick={handleRestart}
60
- disabled={restarting}
61
- className="flex items-center gap-1.5 px-4 py-2 text-sm rounded-lg transition-colors disabled:opacity-50"
62
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
63
- {restarting ? <Loader2 size={13} className="animate-spin" /> : null}
64
- {restarting ? s.restarting : s.restartNow}
65
- </button>
66
- <span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
67
- {s.restartManual} <code className="font-mono">mindos start</code>
68
- </span>
69
- </div>
70
- </div>
68
+ <button
69
+ type="button"
70
+ onClick={handleRestart}
71
+ disabled={restarting}
72
+ className="flex items-center gap-1.5 px-5 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
73
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
74
+ {restarting ? <Loader2 size={13} className="animate-spin" /> : null}
75
+ {restarting ? s.restarting : s.restartNow}
76
+ </button>
71
77
  );
72
78
  }
73
79
 
@@ -205,7 +211,7 @@ export default function StepReview({
205
211
  {s.completeFailed}: {error}
206
212
  </div>
207
213
  )}
208
- {needsRestart && setupPhase === 'done' && <RestartBlock s={s} newPort={state.webPort} />}
214
+ {needsRestart && setupPhase === 'done' && <RestartBanner s={s} />}
209
215
  </div>
210
216
  );
211
217
  }
@@ -11,6 +11,7 @@ import StepPorts from './StepPorts';
11
11
  import StepSecurity from './StepSecurity';
12
12
  import StepAgents from './StepAgents';
13
13
  import StepReview from './StepReview';
14
+ import { RestartButton } from './StepReview';
14
15
  import StepDots from './StepDots';
15
16
 
16
17
  // ─── Helpers (shared by handleComplete + retryAgent) ─────────────────────────
@@ -439,14 +440,16 @@ export default function SetupWizard() {
439
440
  {s.next} <ChevronRight size={14} />
440
441
  </button>
441
442
  ) : completed ? (
442
- // After completing: show Done link (no restart needed) or nothing (RestartBlock handles it)
443
- !needsRestart ? (
443
+ // After completing: show Done link or Restart button in the same position
444
+ needsRestart ? (
445
+ <RestartButton s={s} newPort={state.webPort} />
446
+ ) : (
444
447
  <a href="/?welcome=1"
445
448
  className="flex items-center gap-1 px-5 py-2 text-sm font-medium rounded-lg transition-colors"
446
449
  style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
447
450
  {s.completeDone} &rarr;
448
451
  </a>
449
- ) : null
452
+ )
450
453
  ) : (
451
454
  <button
452
455
  onClick={handleComplete}