@bibobonking/opensol 3.0.3 → 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 (71) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +18 -0
  3. package/desktop/README.md +39 -0
  4. package/desktop/loading/index.html +36 -0
  5. package/desktop/src-tauri/Cargo.lock +4301 -0
  6. package/desktop/src-tauri/Cargo.toml +23 -0
  7. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  8. package/desktop/src-tauri/build.rs +3 -0
  9. package/desktop/src-tauri/capabilities/default.json +7 -0
  10. package/desktop/src-tauri/icons/128x128.png +0 -0
  11. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  12. package/desktop/src-tauri/icons/32x32.png +0 -0
  13. package/desktop/src-tauri/icons/64x64.png +0 -0
  14. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  15. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  23. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  24. package/desktop/src-tauri/icons/icon.icns +0 -0
  25. package/desktop/src-tauri/icons/icon.ico +0 -0
  26. package/desktop/src-tauri/icons/icon.png +0 -0
  27. package/desktop/src-tauri/src/lib.rs +210 -0
  28. package/desktop/src-tauri/src/main.rs +6 -0
  29. package/desktop/src-tauri/tauri.conf.json +48 -0
  30. package/dist/assets/index-DdI1_iPD.css +1 -0
  31. package/dist/assets/index-egG5Bgfo.js +312 -0
  32. package/dist/index.html +2 -2
  33. package/package.json +2 -1
  34. package/server/daemon.mjs +64 -2
  35. package/server/lib/mcp.mjs +532 -0
  36. package/server/lib/mcp.test.mjs +46 -0
  37. package/server/lib/store.mjs +22 -0
  38. package/server/lib/tools.mjs +59 -1
  39. package/src/agent/tools.ts +6 -5
  40. package/src/components/Composer.tsx +22 -38
  41. package/src/components/DiffView.tsx +98 -0
  42. package/src/components/McpTab.tsx +337 -0
  43. package/src/components/MessageItem.tsx +62 -13
  44. package/src/components/ModelDeck.tsx +652 -0
  45. package/src/components/SessionsTab.tsx +206 -0
  46. package/src/components/SettingsModal.tsx +106 -69
  47. package/src/components/Sidebar.tsx +78 -24
  48. package/src/components/ToolCallCard.tsx +29 -7
  49. package/src/components/WorkspacePanel.tsx +16 -1
  50. package/src/core/kernel.ts +2 -0
  51. package/src/core/mcp-tools.ts +63 -0
  52. package/src/core/plugins/tools/fs.ts +24 -3
  53. package/src/core/services/agent-loop.ts +3 -1
  54. package/src/core/services/approval.ts +2 -1
  55. package/src/core/services/titles.ts +135 -0
  56. package/src/core/services/tools.ts +11 -8
  57. package/src/core/types.ts +4 -0
  58. package/src/index.css +97 -1
  59. package/src/lib/daemon.ts +29 -0
  60. package/src/lib/diff.ts +62 -0
  61. package/src/lib/permissions.ts +6 -6
  62. package/src/store/chats.ts +9 -1
  63. package/src/store/mcp.ts +67 -0
  64. package/src/store/settings.ts +9 -6
  65. package/src/store/ui.ts +13 -0
  66. package/src/store/workspace.ts +26 -0
  67. package/src/types.ts +51 -2
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/dist/assets/index-BmN7pBji.css +0 -1
  70. package/dist/assets/index-DgNDjkOz.js +0 -301
  71. package/src/components/ModelPicker.tsx +0 -487
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mkdtemp, writeFile } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import test from 'node:test'
6
+ import { McpManager, functionName, sanitizeServerConfig } from './mcp.mjs'
7
+ import { lineDiffStats } from './tools.mjs'
8
+
9
+ const SERVER = `
10
+ import readline from 'node:readline'
11
+ const send = (m) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...m }) + '\\n')
12
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
13
+ const m = JSON.parse(line)
14
+ if (m.method === 'initialize') send({ id: m.id, result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'echo', version: '1' } } })
15
+ else if (m.method === 'tools/list') send({ id: m.id, result: { tools: [{ name: 'echo', description: 'Echo', inputSchema: { type: 'object', properties: {} } }] } })
16
+ else if (m.method === 'tools/call') send({ id: m.id, result: { content: [{ type: 'text', text: 'echo: ' + m.params.arguments.text }] } })
17
+ })`
18
+
19
+ test('an stdio MCP server connects, lists tools and answers a call', async () => {
20
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'opensol-mcp-'))
21
+ const file = path.join(dir, 'server.mjs')
22
+ await writeFile(file, SERVER)
23
+ const manager = new McpManager()
24
+ const config = sanitizeServerConfig({ name: 'Echo Server', command: process.execPath, args: [file] })
25
+ await manager.setConfigs([config])
26
+ const view = await manager.connect(config.id)
27
+ assert.equal(view.status, 'connected', view.error)
28
+ assert.equal(view.tools[0].fn, functionName(config.id, 'echo'))
29
+ const result = await manager.callTool(config.id, 'echo', { text: 'hi' })
30
+ assert.deepEqual(result, { text: 'echo: hi', isError: false })
31
+ await manager.shutdown()
32
+ })
33
+
34
+ test('secrets are masked in the public view and kept on save', () => {
35
+ const manager = new McpManager()
36
+ const config = sanitizeServerConfig({ name: 'x', command: 'node', env: { TOKEN: 'secret' } })
37
+ manager.configs.set(config.id, config)
38
+ assert.equal(manager.list()[0].env.TOKEN.includes('secret'), false)
39
+ const again = sanitizeServerConfig({ ...manager.list()[0] }, config)
40
+ assert.equal(again.env.TOKEN, 'secret')
41
+ })
42
+
43
+ test('line diff stats count added and removed lines', () => {
44
+ assert.deepEqual(lineDiffStats('a\nb\nc', 'a\nB\nc\nd'), { added: 2, removed: 1 })
45
+ assert.deepEqual(lineDiffStats('same', 'same'), { added: 0, removed: 0 })
46
+ })
@@ -31,6 +31,7 @@ const DEFAULT_STATE = () => ({
31
31
  chats: { sessions: [], activeId: null },
32
32
  workspaces: [],
33
33
  activeWorkspace: null,
34
+ mcpServers: [],
34
35
  legacyMigratedAt: null,
35
36
  })
36
37
 
@@ -130,6 +131,7 @@ export async function loadState() {
130
131
  .slice(0, 20)
131
132
  : [],
132
133
  activeWorkspace: typeof parsed.activeWorkspace === 'string' ? parsed.activeWorkspace : null,
134
+ mcpServers: Array.isArray(parsed.mcpServers) ? parsed.mcpServers.filter((item) => item && typeof item.id === 'string') : [],
133
135
  }
134
136
  return stateCache
135
137
  }
@@ -348,6 +350,26 @@ export async function addWorkspace(rawPath) {
348
350
  return entry
349
351
  }
350
352
 
353
+ /** Drop one folder from the saved list. The folder itself is never touched on disk. */
354
+ export async function removeWorkspace(rawPath) {
355
+ const state = await loadState()
356
+ const resolved = path.resolve(rawPath)
357
+ const workspaces = state.workspaces.filter((item) => item.path !== resolved)
358
+ const activeWorkspace = state.activeWorkspace === resolved ? null : state.activeWorkspace
359
+ await saveState({ ...state, workspaces, activeWorkspace })
360
+ return workspaces.length !== state.workspaces.length
361
+ }
362
+
363
+ export async function getMcpServers() {
364
+ const state = await loadState()
365
+ return state.mcpServers ?? []
366
+ }
367
+
368
+ export async function saveMcpServers(mcpServers) {
369
+ const state = await loadState()
370
+ await saveState({ ...state, mcpServers })
371
+ }
372
+
351
373
  export async function forgetWorkspace() {
352
374
  const state = await loadState()
353
375
  await saveState({ ...state, activeWorkspace: null })
@@ -207,6 +207,45 @@ export async function readWorkspaceFile(input, { maxBytes = MAX_READ } = {}) {
207
207
  }
208
208
  }
209
209
 
210
+
211
+ /** Line-level diff counts (LCS) used to report +added / -removed after a file change. */
212
+ export function lineDiffStats(before, after) {
213
+ if (before === after) return { added: 0, removed: 0 }
214
+ const a = before === '' ? [] : before.split('\n')
215
+ const b = after === '' ? [] : after.split('\n')
216
+ let start = 0
217
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1
218
+ let endA = a.length
219
+ let endB = b.length
220
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
221
+ endA -= 1
222
+ endB -= 1
223
+ }
224
+ const x = a.slice(start, endA)
225
+ const y = b.slice(start, endB)
226
+ if (x.length === 0 || y.length === 0 || x.length * y.length > 4_000_000) {
227
+ return { added: y.length, removed: x.length }
228
+ }
229
+ let previous = new Uint32Array(y.length + 1)
230
+ for (let i = 1; i <= x.length; i += 1) {
231
+ const row = new Uint32Array(y.length + 1)
232
+ for (let j = 1; j <= y.length; j += 1) {
233
+ row[j] = x[i - 1] === y[j - 1] ? previous[j - 1] + 1 : Math.max(previous[j], row[j - 1])
234
+ }
235
+ previous = row
236
+ }
237
+ const common = previous[y.length]
238
+ return { added: y.length - common, removed: x.length - common }
239
+ }
240
+
241
+ /** 1-based line and column of a character offset. */
242
+ function positionOf(text, offset) {
243
+ const head = text.slice(0, offset)
244
+ const line = head.split('\n').length
245
+ const column = offset - (head.lastIndexOf('\n') + 1) + 1
246
+ return { line, column }
247
+ }
248
+
210
249
  export async function writeWorkspaceFile(input, content, { create = true } = {}) {
211
250
  const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
212
251
  if (typeof content !== 'string') throw new HttpError(400, 'File content must be text.', 'bad_content')
@@ -216,6 +255,10 @@ export async function writeWorkspaceFile(input, content, { create = true } = {})
216
255
  const existing = await stat(absolute).catch(() => null)
217
256
  if (!existing && !create) throw new HttpError(404, `File not found: ${relative}`, 'not_found')
218
257
  if (existing?.isDirectory()) throw new HttpError(400, `${relative} is a folder.`, 'is_directory')
258
+ let previousText = ''
259
+ if (existing && existing.size <= MAX_READ) {
260
+ previousText = await readFile(absolute, 'utf8').catch(() => '')
261
+ }
219
262
  await mkdir(path.dirname(absolute), { recursive: true })
220
263
  const temp = `${absolute}.${process.pid}.maxcode.tmp`
221
264
  await writeFile(temp, content, 'utf8')
@@ -235,9 +278,17 @@ export async function writeWorkspaceFile(input, content, { create = true } = {})
235
278
  created: !existing,
236
279
  bytes: info.size,
237
280
  hash: snapshot(Buffer.from(content, 'utf8')).hash,
281
+ diff: { ...lineDiffStats(previousText, content), ...positionOfFirstChange(previousText, content) },
238
282
  }
239
283
  }
240
284
 
285
+ function positionOfFirstChange(before, after) {
286
+ let index = 0
287
+ const limit = Math.min(before.length, after.length)
288
+ while (index < limit && before[index] === after[index]) index += 1
289
+ return positionOf(after, index)
290
+ }
291
+
241
292
  export async function editWorkspaceFile(input, oldText, newText, { replaceAll = false } = {}) {
242
293
  if (typeof oldText !== 'string' || !oldText) {
243
294
  throw new HttpError(400, 'The text to replace is empty.', 'bad_edit')
@@ -261,7 +312,14 @@ export async function editWorkspaceFile(input, oldText, newText, { replaceAll =
261
312
  ? file.content.split(oldText).join(newText)
262
313
  : file.content.replace(oldText, () => newText)
263
314
  const written = await writeWorkspaceFile(input, next)
264
- return { ...written, replacements: replaceAll ? occurrences : 1 }
315
+ const count = replaceAll ? occurrences : 1
316
+ const per = lineDiffStats(oldText, newText)
317
+ const first = positionOf(file.content, file.content.indexOf(oldText))
318
+ return {
319
+ ...written,
320
+ replacements: count,
321
+ diff: { added: per.added * count, removed: per.removed * count, line: first.line, column: first.column },
322
+ }
265
323
  }
266
324
 
267
325
  export async function createWorkspaceEntry(input, type = 'file', content = '') {
@@ -1,4 +1,4 @@
1
- import type { AgentMode, AppSettings, ToolName } from '../types'
1
+ import type { AgentMode, AppSettings, BuiltinToolName, ToolName } from '../types'
2
2
  import { executeDaemonTool } from '../lib/daemon'
3
3
  import { useWorkspace } from '../store/workspace'
4
4
  import { kernel } from '../core/kernel'
@@ -239,7 +239,7 @@ export function getToolSchemas(
239
239
  }
240
240
 
241
241
  return tools.filter((tool) => {
242
- if (!settings.tools[tool.name]?.enabled) return false
242
+ if (!settings.tools[tool.name as BuiltinToolName]?.enabled) return false
243
243
  if (!toolAllowedInMode(tool.name, mode)) return false
244
244
  if ((tool.name === 'web_search' || tool.name === 'read_url') && !settings.webSearchEnabled) return false
245
245
  return true
@@ -481,7 +481,7 @@ export async function executeTool(
481
481
  if (kernel.tools) {
482
482
  return await kernel.tools.execute(name, args, settings, mode, signal)
483
483
  }
484
- if (!settings.tools[name]?.enabled) {
484
+ if (!settings.tools[name as BuiltinToolName]?.enabled) {
485
485
  return { output: 'This tool is disabled in Settings → Tools.', isError: true }
486
486
  }
487
487
  if (!toolAllowedInMode(name, mode)) {
@@ -506,7 +506,8 @@ export async function executeTool(
506
506
  }
507
507
 
508
508
  export function toolLabel(name: ToolName): string {
509
- const labels: Record<ToolName, string> = {
509
+ if (name.startsWith('mcp__')) return name.split('__').slice(2).join(' ').replace(/_/g, ' ') || 'MCP tool'
510
+ const labels: Record<BuiltinToolName, string> = {
510
511
  shell: 'Shell',
511
512
  read_file: 'Read file',
512
513
  list_files: 'List files',
@@ -519,7 +520,7 @@ export function toolLabel(name: ToolName): string {
519
520
  git: 'Git',
520
521
  subagent: 'Subagent',
521
522
  }
522
- return labels[name] ?? 'Tool'
523
+ return labels[name as BuiltinToolName] ?? 'Tool'
523
524
  }
524
525
 
525
526
  export function toolSummary(name: ToolName, args: Record<string, unknown>): string {
@@ -9,10 +9,8 @@ import {
9
9
  type KeyboardEvent,
10
10
  } from 'react'
11
11
  import {
12
- Brain,
13
12
  Check,
14
13
  ChevronDown,
15
- Cpu,
16
14
  FileText,
17
15
  FolderOpen,
18
16
  Globe2,
@@ -25,17 +23,16 @@ import {
25
23
  Upload,
26
24
  X,
27
25
  } from 'lucide-react'
28
- import type { Attachment, Effort } from '../types'
26
+ import type { Attachment } from '../types'
29
27
  import { activeModelId, hasCredentials, useSettings } from '../store/settings'
30
28
  import { useActiveModel, useProviderModels } from '../store/models'
31
29
  import { useUi } from '../store/ui'
32
30
  import { useWorkspace } from '../store/workspace'
33
- import { effortLabel } from '../data/providers'
34
31
  import { effortOptions } from '../lib/reasoning'
35
32
  import { PERMISSION_MODES, permissionLabel, permissionMode, permissionTools } from '../lib/permissions'
36
33
  import { useClickOutside } from '../lib/hooks'
37
34
  import { cn, formatBytes, readFileAsDataUrl, readFileAsText } from '../lib/utils'
38
- import { ModelPicker } from './ModelPicker'
35
+ import { ModelDeck, ModelDeckTrigger } from './ModelDeck'
39
36
  import { PixleSol } from './PixleSol'
40
37
  import type { PixleMood } from '../lib/pixlesol'
41
38
 
@@ -109,6 +106,17 @@ export function Composer({
109
106
  const permissionRef = useRef<HTMLDivElement>(null)
110
107
  const permissionTriggerRef = useRef<HTMLButtonElement>(null)
111
108
 
109
+ useEffect(() => {
110
+ const onKey = (event: globalThis.KeyboardEvent) => {
111
+ if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'm') {
112
+ event.preventDefault()
113
+ setPickerOpen((open) => !open)
114
+ }
115
+ }
116
+ window.addEventListener('keydown', onKey)
117
+ return () => window.removeEventListener('keydown', onKey)
118
+ }, [])
119
+
112
120
  useClickOutside(permissionRef, () => setPermissionOpen(false), permissionOpen, permissionTriggerRef)
113
121
 
114
122
  const modelId = activeModelId(settings)
@@ -341,7 +349,7 @@ export function Composer({
341
349
 
342
350
  return (
343
351
  <div className="relative w-full">
344
- <ModelPicker open={pickerOpen && connected} onClose={() => setPickerOpen(false)} triggerRef={modelTriggerRef} />
352
+ <ModelDeck open={pickerOpen && connected} onClose={() => setPickerOpen(false)} />
345
353
 
346
354
  {commandMatches.length > 0 && (
347
355
  <div className="absolute bottom-[calc(100%+8px)] left-0 z-40 w-[min(94vw,360px)] overflow-hidden rounded-xl border border-line surface-lift shadow-float">
@@ -401,25 +409,6 @@ export function Composer({
401
409
  <Globe2 size={12} />
402
410
  Search
403
411
  </button>
404
- <label
405
- className={cn('chip h-7 cursor-pointer gap-1 px-2', !efforts.length && 'cursor-not-allowed opacity-45')}
406
- title={efforts.length ? 'Reasoning effort sent to the model' : 'This model advertises no effort levels'}
407
- >
408
- <Brain size={12} />
409
- <select
410
- value={settings.effort}
411
- disabled={!efforts.length}
412
- onChange={(event) => update({ effort: event.target.value as Effort })}
413
- className="bg-transparent text-[11px] font-medium text-ink outline-none disabled:cursor-not-allowed"
414
- aria-label="Reasoning effort"
415
- >
416
- {(efforts.length ? efforts : [settings.effort]).map((effort) => (
417
- <option key={effort} value={effort}>
418
- {effortLabel(effort)}
419
- </option>
420
- ))}
421
- </select>
422
- </label>
423
412
  {activeSkills > 0 && (
424
413
  <button
425
414
  type="button"
@@ -592,21 +581,16 @@ export function Composer({
592
581
  {contextPct}%
593
582
  </span>
594
583
  )}
595
- <button
596
- ref={modelTriggerRef}
597
- type="button"
584
+ <ModelDeckTrigger
585
+ triggerRef={modelTriggerRef}
586
+ label={modelLabel}
587
+ open={pickerOpen}
598
588
  disabled={!connected}
599
589
  onClick={() => setPickerOpen((open) => !open)}
600
- className="flex h-8 max-w-[190px] items-center gap-1.5 rounded-lg px-2 text-[11px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink disabled:cursor-not-allowed disabled:opacity-45 sm:max-w-[260px]"
601
- title={connected ? 'Choose model and reasoning effort' : 'Add a provider key in Settings first'}
602
- >
603
- <Cpu size={12} className="shrink-0 text-faint" />
604
- <span className="truncate">{modelLabel}</span>
605
- <ChevronDown
606
- size={11}
607
- className={cn('shrink-0 text-faint transition-transform duration-150', pickerOpen && 'rotate-180')}
608
- />
609
- </button>
590
+ efforts={efforts}
591
+ effort={settings.effort}
592
+ title={connected ? 'Model deck: choose model and reasoning effort (Ctrl M)' : 'Add a provider key in Settings first'}
593
+ />
610
594
  {streaming ? (
611
595
  <button
612
596
  type="button"
@@ -0,0 +1,98 @@
1
+ import { useMemo } from 'react'
2
+ import { diffLines } from '../lib/diff'
3
+ import { cn } from '../lib/utils'
4
+
5
+ const MAX_ROWS = 120
6
+
7
+ /** Compact unified diff: only the changed lines plus two lines of context, numbered from `startLine`. */
8
+ export function DiffView({ before, after, startLine = 1 }: { before: string; after: string; startLine?: number }) {
9
+ const rows = useMemo(() => {
10
+ const ops = diffLines(before, after)
11
+ const keep = new Array<boolean>(ops.length).fill(false)
12
+ ops.forEach((op, index) => {
13
+ if (op.type === 'ctx') return
14
+ for (let k = Math.max(0, index - 2); k <= Math.min(ops.length - 1, index + 2); k += 1) keep[k] = true
15
+ })
16
+ let oldNo = startLine
17
+ let newNo = startLine
18
+ const out: Array<{ key: number; type: string; text: string; no: number | null; gap?: boolean }> = []
19
+ let skipped = false
20
+ ops.forEach((op, index) => {
21
+ const no = op.type === 'add' ? newNo : oldNo
22
+ if (op.type !== 'add') oldNo += 1
23
+ if (op.type !== 'del') newNo += 1
24
+ if (!keep[index]) {
25
+ skipped = true
26
+ return
27
+ }
28
+ if (skipped && out.length > 0) out.push({ key: -index, type: 'gap', text: '', no: null, gap: true })
29
+ skipped = false
30
+ out.push({ key: index, type: op.type, text: op.text, no })
31
+ })
32
+ return out
33
+ }, [before, after, startLine])
34
+
35
+ const shown = rows.slice(0, MAX_ROWS)
36
+ return (
37
+ <div className="diff-view thin-scroll max-h-64 overflow-auto rounded-lg border border-line bg-canvas/60 font-mono text-[11px] leading-[1.6]">
38
+ {shown.map((row, index) =>
39
+ row.gap ? (
40
+ <div key={row.key} className="px-2 text-center text-[10px] text-faint">⋯</div>
41
+ ) : (
42
+ <div
43
+ key={row.key}
44
+ style={{ animationDelay: `${Math.min(index * 14, 260)}ms` }}
45
+ className={cn(
46
+ 'diff-row flex min-w-max',
47
+ row.type === 'add' && 'bg-success/[0.11] text-ink',
48
+ row.type === 'del' && 'bg-danger/[0.10] text-ink',
49
+ row.type === 'ctx' && 'text-faint',
50
+ )}
51
+ >
52
+ <span className="w-9 shrink-0 select-none pr-2 text-right text-[10px] tabular-nums text-faint/70">{row.no}</span>
53
+ <span
54
+ className={cn(
55
+ 'w-4 shrink-0 select-none text-center',
56
+ row.type === 'add' && 'text-success',
57
+ row.type === 'del' && 'text-danger',
58
+ )}
59
+ >
60
+ {row.type === 'add' ? '+' : row.type === 'del' ? '−' : ' '}
61
+ </span>
62
+ <span className="whitespace-pre pr-3">{row.text || ' '}</span>
63
+ </div>
64
+ ),
65
+ )}
66
+ {rows.length > MAX_ROWS && (
67
+ <div className="px-3 py-1 text-[10px] text-faint">… {rows.length - MAX_ROWS} more rows</div>
68
+ )}
69
+ </div>
70
+ )
71
+ }
72
+
73
+ export function DiffBadge({
74
+ added,
75
+ removed,
76
+ line,
77
+ column,
78
+ className,
79
+ }: {
80
+ added: number
81
+ removed: number
82
+ line?: number
83
+ column?: number
84
+ className?: string
85
+ }) {
86
+ return (
87
+ <span className={cn('diff-badge inline-flex items-center gap-1.5 font-mono text-[11px] tabular-nums', className)}>
88
+ <span className="text-success">+{added}</span>
89
+ <span className="text-danger">−{removed}</span>
90
+ {line ? (
91
+ <span className="text-faint">
92
+ L{line}
93
+ {column ? `:${column}` : ''}
94
+ </span>
95
+ ) : null}
96
+ </span>
97
+ )
98
+ }