@bibobonking/opensol 3.0.3 → 3.1.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.
Files changed (83) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +2 -2
  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-CeN54A2N.css +1 -0
  32. package/dist/assets/index-fhIO9a7_.js +318 -0
  33. package/dist/index.html +4 -4
  34. package/index.html +2 -2
  35. package/package.json +2 -1
  36. package/server/README.md +2 -2
  37. package/server/daemon.mjs +138 -2
  38. package/server/lib/mcp-oauth.mjs +219 -0
  39. package/server/lib/mcp-oauth.test.mjs +175 -0
  40. package/server/lib/mcp.mjs +899 -0
  41. package/server/lib/mcp.test.mjs +46 -0
  42. package/server/lib/store.mjs +22 -0
  43. package/server/lib/tools.mjs +59 -1
  44. package/src/App.tsx +2 -0
  45. package/src/agent/tools.ts +6 -5
  46. package/src/components/CommandPalette.tsx +14 -1
  47. package/src/components/Composer.tsx +22 -38
  48. package/src/components/DiffView.tsx +98 -0
  49. package/src/components/Logo.tsx +1 -1
  50. package/src/components/McpPanel.tsx +687 -0
  51. package/src/components/MessageItem.tsx +62 -13
  52. package/src/components/ModelDeck.tsx +661 -0
  53. package/src/components/SessionsTab.tsx +206 -0
  54. package/src/components/SettingsModal.tsx +146 -71
  55. package/src/components/Sidebar.tsx +79 -24
  56. package/src/components/Splash.tsx +1 -1
  57. package/src/components/ToolCallCard.tsx +29 -7
  58. package/src/components/WorkspacePanel.tsx +16 -1
  59. package/src/core/kernel.ts +2 -0
  60. package/src/core/mcp-tools.ts +63 -0
  61. package/src/core/plugins/tools/fs.ts +24 -3
  62. package/src/core/services/agent-loop.ts +3 -1
  63. package/src/core/services/approval.ts +2 -1
  64. package/src/core/services/titles.ts +135 -0
  65. package/src/core/services/tools.ts +11 -8
  66. package/src/core/types.ts +4 -0
  67. package/src/data/providers.ts +1 -1
  68. package/src/index.css +97 -1
  69. package/src/lib/daemon.ts +38 -0
  70. package/src/lib/diff.ts +62 -0
  71. package/src/lib/mcp-config.test.mjs +47 -0
  72. package/src/lib/mcp-config.ts +203 -0
  73. package/src/lib/permissions.ts +6 -6
  74. package/src/store/chats.ts +9 -1
  75. package/src/store/mcp.ts +94 -0
  76. package/src/store/settings.ts +10 -7
  77. package/src/store/ui.ts +17 -0
  78. package/src/store/workspace.ts +26 -0
  79. package/src/types.ts +63 -2
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/dist/assets/index-BmN7pBji.css +0 -1
  82. package/dist/assets/index-DgNDjkOz.js +0 -301
  83. package/src/components/ModelPicker.tsx +0 -487
@@ -12,6 +12,7 @@ import {
12
12
  Plus,
13
13
  Rocket,
14
14
  Search,
15
+ Plug,
15
16
  Settings,
16
17
  Sparkles,
17
18
  Trash2,
@@ -22,6 +23,7 @@ import { hasCredentials, useSettings } from '../store/settings'
22
23
  import { useRuntime, useUi } from '../store/ui'
23
24
  import { useWorkspace } from '../store/workspace'
24
25
  import { stopRun } from '../agent'
26
+ import { kernel } from '../core/kernel'
25
27
  import { getProviderName } from '../data/providers'
26
28
  import { cn, downloadText, formatRelativeTime, groupChatsByDate } from '../lib/utils'
27
29
  import { LogoLockup } from './Logo'
@@ -46,6 +48,7 @@ export function Sidebar() {
46
48
  const settings = useSettings()
47
49
  const workspace = useWorkspace((state) => state.info)
48
50
  const addWorkspace = useWorkspace((state) => state.addWorkspace)
51
+ const removeWorkspace = useWorkspace((state) => state.removeWorkspace)
49
52
  const sidebarCollapsed = useUi((state) => state.sidebarCollapsed)
50
53
  const toggleSidebar = useUi((state) => state.toggleSidebar)
51
54
  const mobileOpen = useUi((state) => state.mobileSidebarOpen)
@@ -53,10 +56,12 @@ export function Sidebar() {
53
56
  const openSettings = useUi((state) => state.openSettings)
54
57
  const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
55
58
  const setDeployOpen = useUi((state) => state.setDeployOpen)
59
+ const setMcpOpen = useUi((state) => state.setMcpOpen)
56
60
  const askConfirm = useUi((state) => state.askConfirm)
57
61
  const notify = useUi((state) => state.notify)
58
62
  const focusComposer = useUi((state) => state.focusComposer)
59
63
  const streaming = useRuntime((state) => state.streaming)
64
+ const titling = useRuntime((state) => state.titling)
60
65
 
61
66
  const [query, setQuery] = useState('')
62
67
  const [menu, setMenu] = useState<ChatMenu | null>(null)
@@ -108,6 +113,28 @@ export function Sidebar() {
108
113
  }
109
114
  }
110
115
 
116
+ const deleteWorkspace = async (path: string, name: string) => {
117
+ const confirmed = await askConfirm({
118
+ title: 'Remove this workspace?',
119
+ body: `“${name}” is removed from OpenSol's list. The folder and its files stay on your disk; conversations started there are kept.`,
120
+ confirmLabel: 'Remove workspace',
121
+ tone: 'danger',
122
+ })
123
+ if (!confirmed) return
124
+ try {
125
+ await removeWorkspace(path)
126
+ notify(`Removed ${name} from your workspaces.`, 'success')
127
+ } catch (error) {
128
+ notify(error instanceof Error ? error.message : 'Could not remove that workspace.', 'error')
129
+ }
130
+ }
131
+
132
+ const aiRename = async (id: string) => {
133
+ setMenu(null)
134
+ const title = await kernel.titles?.generate(id, { force: true })
135
+ if (!title) notify('The model could not suggest a title right now.', 'error')
136
+ }
137
+
111
138
  const beginRename = (id: string, title: string) => {
112
139
  setEditingId(id)
113
140
  setDraftTitle(title)
@@ -225,6 +252,17 @@ export function Sidebar() {
225
252
  <Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
226
253
  Plugins
227
254
  </button>
255
+ <button
256
+ type="button"
257
+ onClick={() => {
258
+ setMobileOpen(false)
259
+ setMcpOpen(true)
260
+ }}
261
+ className="nav-item"
262
+ >
263
+ <Plug size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
264
+ MCP
265
+ </button>
228
266
  <button
229
267
  type="button"
230
268
  onClick={() => {
@@ -286,28 +324,38 @@ export function Sidebar() {
286
324
  {workspaces.map((entry) => {
287
325
  const active = entry.path === workspace.root
288
326
  return (
289
- <button
290
- key={entry.path}
291
- type="button"
292
- onClick={() => void switchWorkspace(entry.path)}
293
- title={entry.path}
294
- className={cn(
295
- 'flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] transition-all duration-150',
296
- active ? 'bg-raised text-ink' : 'text-muted hover:bg-raised/80 hover:text-ink',
297
- )}
298
- >
299
- <Folder size={15} strokeWidth={1.6} className={cn('shrink-0', active ? 'text-accent' : 'text-faint')} />
300
- <span className="min-w-0 flex-1 truncate">{entry.name}</span>
301
- {switching === entry.path ? (
302
- <LoaderCircle size={11} className="shrink-0 animate-spin text-faint" />
303
- ) : active ? (
304
- <Check size={11} className="shrink-0 text-accent" />
305
- ) : (
306
- <span className="shrink-0 text-[10px] tabular-nums text-faint">
307
- {formatRelativeTime(entry.lastOpened)}
308
- </span>
309
- )}
310
- </button>
327
+ <div key={entry.path} className="group/ws relative">
328
+ <button
329
+ type="button"
330
+ onClick={() => void switchWorkspace(entry.path)}
331
+ title={entry.path}
332
+ className={cn(
333
+ 'flex h-8 w-full items-center gap-2.5 rounded-lg px-2.5 pr-8 text-left text-[13px] transition-all duration-150 active:scale-[0.985]',
334
+ active ? 'bg-raised text-ink' : 'text-muted hover:bg-raised/80 hover:text-ink',
335
+ )}
336
+ >
337
+ <Folder size={15} strokeWidth={1.6} className={cn('shrink-0 transition-transform duration-200 group-hover/ws:-rotate-6 group-hover/ws:scale-110', active ? 'text-accent' : 'text-faint')} />
338
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
339
+ {switching === entry.path ? (
340
+ <LoaderCircle size={11} className="shrink-0 animate-spin text-faint" />
341
+ ) : active ? (
342
+ <Check size={11} className="shrink-0 text-accent" />
343
+ ) : (
344
+ <span className="shrink-0 text-[10px] tabular-nums text-faint transition-opacity group-hover/ws:opacity-0">
345
+ {formatRelativeTime(entry.lastOpened)}
346
+ </span>
347
+ )}
348
+ </button>
349
+ <button
350
+ type="button"
351
+ onClick={() => void deleteWorkspace(entry.path, entry.name)}
352
+ className="absolute right-1 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-faint opacity-0 transition-all hover:bg-danger/10 hover:text-danger focus:opacity-100 group-hover/ws:opacity-100"
353
+ title="Remove workspace from the list"
354
+ aria-label={`Remove workspace ${entry.name}`}
355
+ >
356
+ <Trash2 size={12} />
357
+ </button>
358
+ </div>
311
359
  )
312
360
  })}
313
361
  </div>
@@ -360,7 +408,7 @@ export function Sidebar() {
360
408
  title={`${session.title} · ${formatRelativeTime(session.updatedAt)}`}
361
409
  className={cn('sidebar-item pr-9', activeId === session.id && 'sidebar-item-active')}
362
410
  >
363
- <span className="min-w-0 flex-1 truncate">{session.title}</span>
411
+ <span className={cn('min-w-0 flex-1 truncate', titling[session.id] && 'text-shimmer')}>{session.title}</span>
364
412
  {streaming[session.id] && (
365
413
  <span
366
414
  className="flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-accent group-hover/item:hidden"
@@ -381,7 +429,7 @@ export function Sidebar() {
381
429
  }
382
430
  const rect = event.currentTarget.getBoundingClientRect()
383
431
  const width = 176
384
- const height = 138
432
+ const height = 166
385
433
  const x = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8))
386
434
  const y =
387
435
  rect.bottom + height + 8 > window.innerHeight
@@ -470,6 +518,13 @@ export function Sidebar() {
470
518
  >
471
519
  <Pencil size={12} /> Rename
472
520
  </button>
521
+ <button
522
+ type="button"
523
+ onClick={() => void aiRename(session.id)}
524
+ className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-left text-[11.5px] text-muted hover:bg-raised hover:text-ink"
525
+ >
526
+ <Sparkles size={12} /> Name with AI
527
+ </button>
473
528
  <button
474
529
  type="button"
475
530
  onClick={() => exportChat(session.id)}
@@ -62,7 +62,7 @@ export function Splash({
62
62
  ))}
63
63
  </h1>
64
64
  <p className="mt-2 animate-fade text-[11px] font-medium uppercase tracking-[0.22em] text-faint [animation-delay:1s]">
65
- by OpenSoft
65
+ by OpenSoftX
66
66
  </p>
67
67
 
68
68
  {phase === 'error' ? (
@@ -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
+ }