@bibobonking/opensol 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +15 -12
  4. package/desktop/README.md +39 -0
  5. package/desktop/loading/index.html +36 -0
  6. package/desktop/src-tauri/Cargo.lock +4301 -0
  7. package/desktop/src-tauri/Cargo.toml +23 -0
  8. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  9. package/desktop/src-tauri/build.rs +3 -0
  10. package/desktop/src-tauri/capabilities/default.json +7 -0
  11. package/desktop/src-tauri/icons/128x128.png +0 -0
  12. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  13. package/desktop/src-tauri/icons/32x32.png +0 -0
  14. package/desktop/src-tauri/icons/64x64.png +0 -0
  15. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  25. package/desktop/src-tauri/icons/icon.icns +0 -0
  26. package/desktop/src-tauri/icons/icon.ico +0 -0
  27. package/desktop/src-tauri/icons/icon.png +0 -0
  28. package/desktop/src-tauri/src/lib.rs +210 -0
  29. package/desktop/src-tauri/src/main.rs +6 -0
  30. package/desktop/src-tauri/tauri.conf.json +48 -0
  31. package/dist/assets/index-DdI1_iPD.css +1 -0
  32. package/dist/assets/index-egG5Bgfo.js +312 -0
  33. package/dist/index.html +2 -2
  34. package/package.json +2 -1
  35. package/server/daemon.mjs +64 -2
  36. package/server/lib/mcp.mjs +532 -0
  37. package/server/lib/mcp.test.mjs +46 -0
  38. package/server/lib/store.mjs +22 -0
  39. package/server/lib/tools.mjs +59 -1
  40. package/src/agent/tools.ts +6 -5
  41. package/src/components/Composer.tsx +22 -38
  42. package/src/components/DiffView.tsx +98 -0
  43. package/src/components/McpTab.tsx +337 -0
  44. package/src/components/MessageItem.tsx +62 -13
  45. package/src/components/ModelDeck.tsx +652 -0
  46. package/src/components/SessionsTab.tsx +206 -0
  47. package/src/components/SettingsModal.tsx +106 -69
  48. package/src/components/Sidebar.tsx +78 -24
  49. package/src/components/ToolCallCard.tsx +29 -7
  50. package/src/components/WorkspacePanel.tsx +16 -1
  51. package/src/core/kernel.ts +2 -0
  52. package/src/core/mcp-tools.ts +63 -0
  53. package/src/core/plugins/tools/fs.ts +24 -3
  54. package/src/core/services/agent-loop.ts +3 -1
  55. package/src/core/services/approval.ts +2 -1
  56. package/src/core/services/titles.ts +135 -0
  57. package/src/core/services/tools.ts +11 -8
  58. package/src/core/types.ts +4 -0
  59. package/src/index.css +97 -1
  60. package/src/lib/daemon.ts +29 -0
  61. package/src/lib/diff.ts +62 -0
  62. package/src/lib/permissions.ts +6 -6
  63. package/src/store/chats.ts +9 -1
  64. package/src/store/mcp.ts +67 -0
  65. package/src/store/settings.ts +9 -6
  66. package/src/store/ui.ts +13 -0
  67. package/src/store/workspace.ts +26 -0
  68. package/src/types.ts +51 -2
  69. package/tsconfig.tsbuildinfo +1 -1
  70. package/dist/assets/index-BmN7pBji.css +0 -1
  71. package/dist/assets/index-DgNDjkOz.js +0 -301
  72. package/src/components/ModelPicker.tsx +0 -487
@@ -1,10 +1,23 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { ArrowRight } from 'lucide-react'
3
- import type { ToolMessage, ToolName } from '../types'
3
+ import type { FileDiff, ToolMessage, ToolName } from '../types'
4
4
  import { toolLabel, toolSummary } from '../agent/tools'
5
5
  import { resolveApproval } from '../agent/approval'
6
6
  import { useUi } from '../store/ui'
7
7
  import { cn } from '../lib/utils'
8
+ import { diffLines, diffStats } from '../lib/diff'
9
+ import { DiffBadge, DiffView } from './DiffView'
10
+
11
+ const FILE_TOOLS = new Set(['write_file', 'edit_file', 'rewrite_file'])
12
+
13
+ /** The diff the daemon reported, or one worked out from the call itself for older chats. */
14
+ export function diffForTool(message: ToolMessage): FileDiff | undefined {
15
+ if (message.diff) return message.diff
16
+ if (message.status !== 'done' || message.call.name !== 'edit_file') return undefined
17
+ const { oldText, newText } = message.call.args
18
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return undefined
19
+ return diffStats(diffLines(oldText, newText))
20
+ }
8
21
 
9
22
  function statusMeta(status: ToolMessage['status']) {
10
23
  switch (status) {
@@ -55,6 +68,8 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
55
68
  const status = statusMeta(message.status)
56
69
  const summary = toolSummary(message.call.name, message.call.args)
57
70
  const args = message.call.args
71
+ const diff = diffForTool(message)
72
+ const isFileTool = FILE_TOOLS.has(message.call.name)
58
73
  const command = typeof args.command === 'string' ? args.command : ''
59
74
  const content =
60
75
  typeof args.content === 'string'
@@ -87,7 +102,7 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
87
102
  : status.label.toLowerCase()
88
103
 
89
104
  return (
90
- <div className="text-[12.5px]">
105
+ <div className="animate-slideleft text-[12.5px]">
91
106
  <button
92
107
  type="button"
93
108
  onClick={() => setOpen((value) => !value)}
@@ -104,10 +119,14 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
104
119
  />
105
120
  <span className="shrink-0 font-medium text-ink">{toolLabel(message.call.name)}</span>
106
121
  <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] text-muted">{summary || '—'}</span>
107
- {trailing && (
108
- <span className={cn('shrink-0 text-[11px]', message.status === 'running' ? 'text-shimmer' : message.status === 'done' ? 'text-faint' : status.className)}>
109
- {trailing}
110
- </span>
122
+ {isFileTool && diff && message.status === 'done' ? (
123
+ <DiffBadge added={diff.added} removed={diff.removed} line={diff.line} column={diff.column} className="shrink-0" />
124
+ ) : (
125
+ trailing && (
126
+ <span className={cn('shrink-0 text-[11px]', message.status === 'running' ? 'text-shimmer' : message.status === 'done' ? 'text-faint' : status.className)}>
127
+ {trailing}
128
+ </span>
129
+ )
111
130
  )}
112
131
  </button>
113
132
 
@@ -140,7 +159,10 @@ export function ToolCallCard({ message, compact = false }: { message: ToolMessag
140
159
  $ {command}
141
160
  </pre>
142
161
  )}
143
- {content && (
162
+ {message.call.name === 'edit_file' && typeof args.oldText === 'string' && typeof args.newText === 'string' && (
163
+ <DiffView before={args.oldText} after={args.newText} startLine={diff?.line ?? 1} />
164
+ )}
165
+ {content && message.call.name !== 'edit_file' && (
144
166
  <pre className="thin-scroll max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-muted">
145
167
  {content.length > 4000 ? `${content.slice(0, 4000)}\n… ${content.length - 4000} more characters` : content}
146
168
  </pre>
@@ -26,6 +26,8 @@ import { cn, formatBytes } from '../lib/utils'
26
26
  import { useUi } from '../store/ui'
27
27
  import { useWorkspace } from '../store/workspace'
28
28
  import { PreviewPane } from './PreviewPane'
29
+ import { SessionsTab } from './SessionsTab'
30
+ import { useChats } from '../store/chats'
29
31
  import { Spinner } from './ui'
30
32
 
31
33
  interface TreeNode {
@@ -230,7 +232,8 @@ export function WorkspacePanel() {
230
232
 
231
233
  const [query, setQuery] = useState('')
232
234
  const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
233
- const [tab, setTab] = useState<'files' | 'preview' | 'git'>('files')
235
+ const sessionCount = useChats((state) => state.sessions.length)
236
+ const [tab, setTab] = useState<'files' | 'preview' | 'git' | 'sessions'>('files')
234
237
  const [choosing, setChoosing] = useState(!info.available)
235
238
  const [editor, setEditor] = useState<{ mode: 'create-file' | 'create-dir' | 'rename'; value: string } | null>(
236
239
  null,
@@ -457,6 +460,16 @@ export function WorkspacePanel() {
457
460
  >
458
461
  Preview
459
462
  </button>
463
+ <button
464
+ type="button"
465
+ onClick={() => setTab('sessions')}
466
+ className={cn(
467
+ 'h-7 rounded-md px-2.5 text-[11px] font-medium',
468
+ tab === 'sessions' ? 'bg-raised text-ink' : 'text-muted hover:text-ink',
469
+ )}
470
+ >
471
+ Sessions <span className="text-faint">· {sessionCount}</span>
472
+ </button>
460
473
  <button
461
474
  type="button"
462
475
  onClick={() => setTab('git')}
@@ -639,6 +652,8 @@ export function WorkspacePanel() {
639
652
  </>
640
653
  ) : tab === 'preview' ? (
641
654
  <PreviewPane />
655
+ ) : tab === 'sessions' ? (
656
+ <SessionsTab />
642
657
  ) : (
643
658
  <div className="thin-scroll min-h-0 flex-1 overflow-y-auto">
644
659
  <div className="flex items-center gap-2 border-b border-line px-3 py-3">
@@ -9,6 +9,7 @@ import { CompactionService } from './services/compaction'
9
9
  import { SubagentService } from './services/subagents'
10
10
  import { UiService } from './services/ui'
11
11
  import { AgentLoopService } from './services/agent-loop'
12
+ import { TitleService } from './services/titles'
12
13
 
13
14
  import * as toolFs from './plugins/tools/fs'
14
15
  import * as toolShell from './plugins/tools/shell'
@@ -30,6 +31,7 @@ export function createAppKernel(): Context {
30
31
  ctx.plugin(SubagentService)
31
32
  ctx.plugin(UiService)
32
33
  ctx.plugin(AgentLoopService)
34
+ ctx.plugin(TitleService)
33
35
 
34
36
  // 2. Mount First-Party Tool Plugins
35
37
  ctx.plugin(toolFs)
@@ -0,0 +1,63 @@
1
+ import type { AppSettings, ToolName, ToolSetting } from '../types'
2
+ import { mcpCall } from '../lib/daemon'
3
+ import { isMcpToolName, useMcp } from '../store/mcp'
4
+ import type { ToolDefinition, ToolSchema } from './types'
5
+
6
+ const MAX_OUTPUT = 28_000
7
+
8
+ function toParameters(schema: Record<string, unknown>): ToolSchema['parameters'] {
9
+ const properties =
10
+ schema.properties && typeof schema.properties === 'object'
11
+ ? (schema.properties as Record<string, unknown>)
12
+ : {}
13
+ const required = Array.isArray(schema.required)
14
+ ? schema.required.filter((item): item is string => typeof item === 'string')
15
+ : undefined
16
+ return { type: 'object', properties, ...(required?.length ? { required } : {}) }
17
+ }
18
+
19
+ /** A connected, enabled MCP tool as a regular tool definition; null when it is not callable now. */
20
+ export function mcpDefinition(name: ToolName): ToolDefinition | undefined {
21
+ if (!isMcpToolName(name)) return undefined
22
+ const hit = useMcp.getState().lookup(name)
23
+ if (!hit) return undefined
24
+ const { server, tool } = hit
25
+ if (!server.enabled || server.status !== 'connected' || server.disabledTools.includes(tool.name)) return undefined
26
+ return {
27
+ name,
28
+ description: `[MCP server “${server.name}”] ${tool.description || tool.name}`.slice(0, 1200),
29
+ parameters: toParameters(tool.inputSchema),
30
+ allowedInMode: (mode) => mode === 'build' || tool.readOnly,
31
+ async execute(args, exec) {
32
+ const { result } = await mcpCall(server.id, tool.name, args, exec.signal)
33
+ const text = result.text.trim() || '(the tool returned no output)'
34
+ return {
35
+ output: text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n… truncated (${text.length - MAX_OUTPUT} more characters)` : text,
36
+ isError: result.isError,
37
+ }
38
+ },
39
+ }
40
+ }
41
+
42
+ export function mcpDefinitions(): ToolDefinition[] {
43
+ const out: ToolDefinition[] = []
44
+ for (const server of useMcp.getState().servers) {
45
+ for (const tool of server.tools) {
46
+ const definition = mcpDefinition(tool.fn)
47
+ if (definition) out.push(definition)
48
+ }
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** Enabled/approval for any tool: built-ins come from Settings → Tools, MCP tools from their server. */
54
+ export function resolveToolSetting(settings: AppSettings, name: ToolName): ToolSetting | undefined {
55
+ if (!isMcpToolName(name)) return settings.tools[name]
56
+ const hit = useMcp.getState().lookup(name)
57
+ if (!hit) return undefined
58
+ const { server, tool } = hit
59
+ return {
60
+ enabled: server.enabled && server.status === 'connected' && !server.disabledTools.includes(tool.name),
61
+ approval: server.approval,
62
+ }
63
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Context } from 'cordis'
2
2
  import { executeDaemonTool } from '../../../lib/daemon'
3
3
  import { useWorkspace } from '../../../store/workspace'
4
+ import type { FileDiff } from '../../../types'
4
5
  import type { ToolResult } from '../../types'
5
6
 
6
7
  const MAX_TOOL_OUTPUT = 28_000
@@ -54,6 +55,20 @@ function formatList(result: Record<string, unknown>): string {
54
55
  return `${textValue(result.root) ? `Root: ${textValue(result.root)}\n` : ''}${lines.join('\n')}${suffix}`
55
56
  }
56
57
 
58
+ function diffOf(result: Record<string, unknown>): FileDiff | undefined {
59
+ const raw = record(result.diff)
60
+ const added = numberValue(raw.added)
61
+ const removed = numberValue(raw.removed)
62
+ if (added === undefined || removed === undefined) return undefined
63
+ return { added, removed, line: numberValue(raw.line), column: numberValue(raw.column) }
64
+ }
65
+
66
+ function diffLabel(diff?: FileDiff) {
67
+ if (!diff) return ''
68
+ const where = diff.line ? ` at L${diff.line}${diff.column ? `:${diff.column}` : ''}` : ''
69
+ return ` · +${diff.added} −${diff.removed}${where}`
70
+ }
71
+
57
72
  async function refreshWorkspace() {
58
73
  await useWorkspace.getState().refresh().catch(() => undefined)
59
74
  }
@@ -143,9 +158,11 @@ export function apply(ctx: Context) {
143
158
  const result = record((await executeDaemonTool('write_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
144
159
  await refreshWorkspace()
145
160
  const verb = result.created === true ? 'Created' : 'Updated'
161
+ const diff = diffOf(result)
146
162
  return {
147
- output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
163
+ output: `${verb} ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes${diffLabel(diff)}.`,
148
164
  isError: false,
165
+ diff,
149
166
  }
150
167
  },
151
168
  }),
@@ -168,9 +185,11 @@ export function apply(ctx: Context) {
168
185
  const content = requireString(args, 'content')
169
186
  const result = record((await executeDaemonTool('rewrite_file', { path, content }, exec.mode, exec.signal, exec.turnId)).result)
170
187
  await refreshWorkspace()
188
+ const diff = diffOf(result)
171
189
  return {
172
- output: `Rewrote ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes.`,
190
+ output: `Rewrote ${textValue(result.path) || path} · ${content.split('\n').length} lines, ${numberValue(result.size) ?? content.length} bytes${diffLabel(diff)}.`,
173
191
  isError: false,
192
+ diff,
174
193
  }
175
194
  },
176
195
  }),
@@ -207,9 +226,11 @@ export function apply(ctx: Context) {
207
226
  ).result,
208
227
  )
209
228
  await refreshWorkspace()
229
+ const diff = diffOf(result)
210
230
  return {
211
- output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}.`,
231
+ output: `Edited ${textValue(result.path) || path} · ${numberValue(result.replacements) ?? 1} replacement${numberValue(result.replacements) === 1 ? '' : 's'}${diffLabel(diff)}.`,
212
232
  isError: false,
233
+ diff,
213
234
  }
214
235
  },
215
236
  }),
@@ -8,6 +8,7 @@ import { getProviderName } from '../../data/providers'
8
8
  import { describeRequestError } from '../../lib/sse'
9
9
  import type { AdapterResult } from '../../agent/adapters/shared'
10
10
  import { createTurnQueue, lastUserMessageId } from '../../agent/queue'
11
+ import { resolveToolSetting } from '../mcp-tools'
11
12
  import type { StepContext, StepResult, TurnResult } from '../types'
12
13
 
13
14
  const MAX_ROUNDS = 16
@@ -276,7 +277,7 @@ export class AgentLoopService extends Service {
276
277
 
277
278
  for (const call of result.toolCalls) {
278
279
  if (controller.signal.aborted) break
279
- const toolSetting = settings.tools[call.name]
280
+ const toolSetting = resolveToolSetting(settings, call.name)
280
281
  if (!toolSetting?.enabled) {
281
282
  const message: ToolMessage = {
282
283
  id: createId('tool'),
@@ -332,6 +333,7 @@ export class AgentLoopService extends Service {
332
333
  status: output.isError ? 'error' : 'done',
333
334
  result: output.output,
334
335
  durationMs: Date.now() - toolStarted,
336
+ diff: output.diff,
335
337
  }
336
338
  this.ctx.sessions.updateMessage(sessionId, toolMessageId, finalToolMessage)
337
339
  toolMessages.push(finalToolMessage)
@@ -1,5 +1,6 @@
1
1
  import { Service, type Context } from 'cordis'
2
2
  import type { ToolExecutionContext, ToolPreExecuteResult } from '../types'
3
+ import { resolveToolSetting } from '../mcp-tools'
3
4
 
4
5
  interface PendingApproval {
5
6
  toolCallId: string
@@ -18,7 +19,7 @@ export class ApprovalService extends Service {
18
19
 
19
20
  // Attach to tools/pre-execute waterfall gate
20
21
  this.ctx.on('tools/pre-execute', async (exec: ToolExecutionContext, next: () => Promise<ToolPreExecuteResult>) => {
21
- const toolSetting = exec.settings.tools[exec.name]
22
+ const toolSetting = resolveToolSetting(exec.settings, exec.name)
22
23
  if (toolSetting?.approval === 'ask' && exec.callId) {
23
24
  const approved = await this.waitForApproval(exec.callId, exec.signal)
24
25
  if (!approved) {
@@ -0,0 +1,135 @@
1
+ import { Service, type Context } from 'cordis'
2
+ import type { AppSettings, BuiltinToolName, Message } from '../../types'
3
+ import { activeModelId, useSettings } from '../../store/settings'
4
+ import { useChats } from '../../store/chats'
5
+ import { useRuntime } from '../../store/ui'
6
+ import { findModel } from '../../store/models'
7
+ import { effortOptions } from '../../lib/reasoning'
8
+ import { createId } from '../../store/chats'
9
+ import type { TurnResult } from '../types'
10
+
11
+ const TITLE_SYSTEM =
12
+ 'You name chat sessions for a coding assistant. Reply with the title only: 2 to 6 words, in the same language as the user, no quotes, no markdown, no trailing punctuation.'
13
+
14
+ function clip(value: string, max: number) {
15
+ const clean = value.replace(/\s+/g, ' ').trim()
16
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean
17
+ }
18
+
19
+ export function cleanTitle(raw: string): string {
20
+ const first = raw
21
+ .replace(/<think>[\s\S]*?<\/think>/gi, '')
22
+ .split('\n')
23
+ .map((line) => line.trim())
24
+ .find(Boolean)
25
+ if (!first) return ''
26
+ const title = first
27
+ .replace(/^(title|tiêu đề)\s*[::-]\s*/i, '')
28
+ .replace(/^[#>*\-\s"'“”‘’`]+|[\s"'“”‘’`.。!]+$/g, '')
29
+ .trim()
30
+ return title.length > 64 ? `${title.slice(0, 63).trim()}…` : title
31
+ }
32
+
33
+ function excerpt(messages: Message[]) {
34
+ const user = messages.find((message) => message.role === 'user')
35
+ const assistant = [...messages].reverse().find((message) => message.role === 'assistant' && message.content.trim())
36
+ return {
37
+ user: user && user.role === 'user' ? clip(user.content, 700) : '',
38
+ assistant: assistant && assistant.role === 'assistant' ? clip(assistant.content, 700) : '',
39
+ }
40
+ }
41
+
42
+ /** Lets the model name a session after its first answer, or on demand from the sidebar. */
43
+ export class TitleService extends Service {
44
+ static provide = 'titles'
45
+ static inject = ['llm', 'sessions']
46
+
47
+ private inflight = new Set<string>()
48
+
49
+ constructor(ctx: Context) {
50
+ super(ctx, 'titles')
51
+ this.ctx.on('agent/turn-finish', (turn: TurnResult) => {
52
+ if (turn.error || turn.stopped) return
53
+ const settings = useSettings.getState()
54
+ if (!settings.aiSessionTitles) return
55
+ const session = this.ctx.sessions.current(turn.sessionId)
56
+ // Only a title that is still the first-message excerpt may be replaced; names the person typed stay.
57
+ if (!session || (session.titleSource ?? 'auto') !== 'auto') return
58
+ void this.generate(turn.sessionId)
59
+ })
60
+ }
61
+
62
+ async generate(sessionId: string, { force = false } = {}): Promise<string | null> {
63
+ if (this.inflight.has(sessionId)) return null
64
+ const session = this.ctx.sessions.current(sessionId)
65
+ if (!session) return null
66
+ if (!force && (session.titleSource ?? 'auto') !== 'auto') return null
67
+ const { user, assistant } = excerpt(session.messages)
68
+ if (!user) return null
69
+
70
+ const settings = useSettings.getState()
71
+ const model = findModel(settings.provider, activeModelId(settings))
72
+ // No tools, no web, the lowest reasoning effort the model offers: a title is not worth a thinking budget.
73
+ const tools = Object.fromEntries(
74
+ Object.entries(settings.tools).map(([name, value]) => [name, { ...value, enabled: false }]),
75
+ ) as Record<BuiltinToolName, AppSettings['tools'][BuiltinToolName]>
76
+ const efforts = effortOptions(model)
77
+ const titleSettings: AppSettings = {
78
+ ...settings,
79
+ tools,
80
+ webSearchEnabled: false,
81
+ temperature: 0.3,
82
+ maxTokens: 512,
83
+ effort: efforts[0] ?? settings.effort,
84
+ }
85
+ const history: Message[] = [
86
+ {
87
+ id: createId('title'),
88
+ role: 'user',
89
+ createdAt: Date.now(),
90
+ content: `Name this conversation.\n\nUser: ${user}\n\nAssistant: ${assistant || '(no answer yet)'}`,
91
+ },
92
+ ]
93
+
94
+ this.inflight.add(sessionId)
95
+ useRuntime.getState().setTitling(sessionId, true)
96
+ try {
97
+ const controller = new AbortController()
98
+ const timer = window.setTimeout(() => controller.abort(), 25_000)
99
+ let streamed = ''
100
+ const result = await this.ctx.llm
101
+ .stream({
102
+ settings: titleSettings,
103
+ model,
104
+ history,
105
+ system: TITLE_SYSTEM,
106
+ mode: 'plan',
107
+ signal: controller.signal,
108
+ handlers: {
109
+ onText: (delta) => {
110
+ streamed += delta
111
+ },
112
+ onReasoning: () => undefined,
113
+ onToolCalls: () => undefined,
114
+ onUsage: () => undefined,
115
+ },
116
+ })
117
+ .finally(() => window.clearTimeout(timer))
118
+ const title = cleanTitle(result.content || streamed)
119
+ const latest = this.ctx.sessions.current(sessionId)
120
+ if (!title || !latest) return null
121
+ if (!force && (latest.titleSource ?? 'auto') !== 'auto') return null
122
+ this.ctx.sessions.updateSession(sessionId, { title, titleSource: 'ai' })
123
+ return title
124
+ } catch {
125
+ return null
126
+ } finally {
127
+ this.inflight.delete(sessionId)
128
+ useRuntime.getState().setTitling(sessionId, false)
129
+ }
130
+ }
131
+ }
132
+
133
+ export function sessionTitleSource(sessionId: string) {
134
+ return useChats.getState().sessions.find((session) => session.id === sessionId)?.titleSource
135
+ }
@@ -1,5 +1,6 @@
1
1
  import { Service, type Context } from 'cordis'
2
2
  import type { AgentMode, AppSettings, ToolName } from '../../types'
3
+ import { mcpDefinition, mcpDefinitions, resolveToolSetting } from '../mcp-tools'
3
4
  import type {
4
5
  ToolDefinition,
5
6
  ToolExecutionContext,
@@ -39,11 +40,11 @@ export class ToolService extends Service {
39
40
  }
40
41
 
41
42
  get(name: ToolName): ToolDefinition | undefined {
42
- return this.definitions.get(name)
43
+ return this.definitions.get(name) ?? mcpDefinition(name)
43
44
  }
44
45
 
45
46
  list(): ToolDefinition[] {
46
- return [...this.definitions.values()]
47
+ return [...this.definitions.values(), ...mcpDefinitions()]
47
48
  }
48
49
 
49
50
  schemas(
@@ -52,9 +53,11 @@ export class ToolService extends Service {
52
53
  { subagent = false }: { subagent?: boolean } = {},
53
54
  ): ToolSchema[] {
54
55
  const list: ToolSchema[] = []
55
- for (const def of this.definitions.values()) {
56
+ // Subagents stay on the built-in read-only tools; MCP tools are not offered to them.
57
+ const all = subagent ? [...this.definitions.values()] : this.list()
58
+ for (const def of all) {
56
59
  if (def.name === 'subagent' && subagent) continue
57
- if (!settings.tools[def.name]?.enabled) continue
60
+ if (!resolveToolSetting(settings, def.name)?.enabled) continue
58
61
  const allowed = def.allowedInMode ? def.allowedInMode(mode) : toolAllowedInMode(def.name, mode)
59
62
  if (!allowed) continue
60
63
  if ((def.name === 'web_search' || def.name === 'read_url') && !settings.webSearchEnabled) continue
@@ -83,10 +86,10 @@ export class ToolService extends Service {
83
86
  'tools/pre-execute',
84
87
  exec,
85
88
  async () => {
86
- if (!settings.tools[name]?.enabled) {
87
- return { decision: 'deny', reason: 'This tool is disabled in Settings → Tools.' }
89
+ if (!resolveToolSetting(settings, name)?.enabled) {
90
+ return { decision: 'deny', reason: 'This tool is disabled or its MCP server is not connected (Settings → Tools / MCP).' }
88
91
  }
89
- const def = this.definitions.get(name)
92
+ const def = this.get(name)
90
93
  const allowed = def?.allowedInMode ? def.allowedInMode(mode) : toolAllowedInMode(name, mode)
91
94
  if (!allowed) {
92
95
  return {
@@ -113,7 +116,7 @@ export class ToolService extends Service {
113
116
 
114
117
  // 2. tools/execute waterfall
115
118
  const result: ToolResult = await this.ctx.events.waterfall('tools/execute', exec, async () => {
116
- const def = this.definitions.get(name)
119
+ const def = this.get(name)
117
120
  if (!def) {
118
121
  return { output: `Tool "${name}" is not registered.`, isError: true }
119
122
  }
package/src/core/types.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  AgentMode,
3
3
  AppSettings,
4
4
  AssistantMessage,
5
+ FileDiff,
5
6
  Message,
6
7
  ModelInfo,
7
8
  ToolCall,
@@ -32,6 +33,8 @@ export interface ToolSchema {
32
33
  export interface ToolResult {
33
34
  output: string
34
35
  isError: boolean
36
+ /** Set by file tools so the UI can show +added / −removed lines. */
37
+ diff?: FileDiff
35
38
  }
36
39
 
37
40
  export interface ToolExecutionContext {
@@ -120,6 +123,7 @@ declare module 'cordis' {
120
123
  subagents: import('./services/subagents').SubagentService
121
124
  agentLoop: import('./services/agent-loop').AgentLoopService
122
125
  ui: import('./services/ui').UiService
126
+ titles: import('./services/titles').TitleService
123
127
  }
124
128
 
125
129
  interface Events {
package/src/index.css CHANGED
@@ -222,7 +222,15 @@
222
222
  }
223
223
 
224
224
  .settings-row {
225
- @apply flex flex-col gap-2 border-b border-line py-4 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-6;
225
+ @apply -mx-3 flex flex-col gap-2 rounded-2xl border-b border-transparent px-3 py-4 transition-colors duration-200 hover:bg-raised/50 sm:flex-row sm:items-center sm:justify-between sm:gap-6;
226
+ }
227
+
228
+ .settings-card {
229
+ @apply relative rounded-2xl border border-line bg-panel p-4 shadow-pill transition-all duration-300 sm:p-5;
230
+ }
231
+
232
+ .settings-card:hover {
233
+ border-color: rgb(var(--accent) / 0.25);
226
234
  }
227
235
 
228
236
  .settings-label {
@@ -774,3 +782,91 @@
774
782
  33% { transform: translateX(0); }
775
783
  66% { transform: translateX(1px); }
776
784
  }
785
+
786
+
787
+ /* ───────── v3.1 motion & animation ───────── */
788
+
789
+ .settings-enter > * {
790
+ animation: rise 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
791
+ }
792
+ .settings-enter > *:nth-child(2) { animation-delay: 60ms; }
793
+ .settings-enter > *:nth-child(3) { animation-delay: 120ms; }
794
+ .settings-enter > *:nth-child(4) { animation-delay: 180ms; }
795
+ .settings-enter > *:nth-child(n + 5) { animation-delay: 240ms; }
796
+
797
+ .settings-title-in {
798
+ animation: blurin 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
799
+ }
800
+
801
+ .settings-shell {
802
+ animation: shell-in 0.42s cubic-bezier(0.16, 1, 0.3, 1) backwards;
803
+ }
804
+
805
+ .settings-glow {
806
+ background:
807
+ radial-gradient(120% 100% at 0% 0%, rgb(var(--accent) / 0.16), transparent 65%),
808
+ radial-gradient(80% 80% at 100% 0%, rgb(var(--accent) / 0.06), transparent 70%);
809
+ }
810
+
811
+ @keyframes shell-in {
812
+ from { opacity: 0; transform: translateY(18px) scale(0.975); filter: blur(6px); }
813
+ to { opacity: 1; transform: none; filter: blur(0); }
814
+ }
815
+
816
+ /* Model deck */
817
+ .deck-scrim { animation: fade 0.25s ease-out both; }
818
+ .deck-panel { animation: deck-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
819
+ .deck-row { animation: deck-row 0.4s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
820
+ .deck-pop { animation: scalein 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
821
+ .deck-aurora {
822
+ background:
823
+ radial-gradient(70% 120% at 12% 0%, rgb(var(--accent) / 0.18), transparent 70%),
824
+ radial-gradient(50% 100% at 95% 0%, rgb(var(--accent) / 0.07), transparent 70%);
825
+ }
826
+ .deck-throttle {
827
+ background-image: radial-gradient(rgb(var(--line)) 1px, transparent 1px);
828
+ background-size: 14px 14px;
829
+ background-color: rgb(var(--canvas) / 0.7);
830
+ }
831
+ .throttle-knob { box-shadow: 0 0 0 4px rgb(var(--accent) / 0.16), 0 6px 18px -6px rgb(var(--accent) / 0.55); }
832
+ .throttle-knob::after {
833
+ content: '';
834
+ position: absolute;
835
+ inset: -6px;
836
+ border-radius: 9999px;
837
+ border: 1px solid rgb(var(--accent) / 0.5);
838
+ animation: ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
839
+ }
840
+ .deck-trigger:active { transform: scale(0.96); }
841
+
842
+ @keyframes deck-in {
843
+ from { opacity: 0; transform: translateY(24px) scale(0.96); filter: blur(8px); }
844
+ to { opacity: 1; transform: none; filter: blur(0); }
845
+ }
846
+ @keyframes deck-row {
847
+ from { opacity: 0; transform: translateX(-10px); }
848
+ to { opacity: 1; transform: none; }
849
+ }
850
+
851
+ /* Diff rows slide in as a tool card opens. */
852
+ .diff-row { animation: deck-row 0.35s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
853
+ .diff-badge { animation: scalein 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
854
+
855
+ /* Messages and tool rows arrive with a soft rise. */
856
+ .msg-enter { animation: rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
857
+
858
+ /* Buttons feel physical. */
859
+ button:not(:disabled) { -webkit-tap-highlight-color: transparent; }
860
+ .chip, .nav-item, .sidebar-item { will-change: transform; }
861
+
862
+ /* Streaming caret and send-button pulse while the agent works. */
863
+ .session-card:hover { transform: translateX(2px); }
864
+
865
+ @media (prefers-reduced-motion: reduce) {
866
+ *, *::before, *::after {
867
+ animation-duration: 0.001ms !important;
868
+ animation-iteration-count: 1 !important;
869
+ transition-duration: 0.001ms !important;
870
+ scroll-behavior: auto !important;
871
+ }
872
+ }