@bibobonking/opensol 3.1.5 → 3.2.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 (53) hide show
  1. package/CHANGELOG.md +8 -2
  2. package/LICENSE +21 -0
  3. package/README.md +24 -20
  4. package/desktop/README.md +1 -1
  5. package/dist/assets/{index-C0yvs6Vl.js → index-BUVnnhRH.js} +1 -1
  6. package/dist/assets/index-CyxlAgKM.js +271 -0
  7. package/dist/assets/index-DFeF90S8.css +1 -0
  8. package/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/server/README.md +15 -68
  11. package/server/daemon.mjs +6 -1
  12. package/server/lib/deploy.mjs +5 -10
  13. package/server/lib/shell.mjs +149 -0
  14. package/server/lib/shell.test.mjs +72 -0
  15. package/server/lib/tools.mjs +8 -44
  16. package/src/agent/adapters/anthropic.ts +2 -3
  17. package/src/agent/adapters/gemini.ts +2 -3
  18. package/src/agent/adapters/openai-responses.ts +2 -3
  19. package/src/agent/adapters/openai.ts +2 -3
  20. package/src/agent/adapters/shared.ts +3 -0
  21. package/src/components/ChatView.tsx +17 -54
  22. package/src/components/Composer.tsx +143 -135
  23. package/src/components/ContextBar.tsx +2 -2
  24. package/src/components/HomeDashboard.tsx +7 -4
  25. package/src/components/MessageItem.tsx +33 -1
  26. package/src/components/ModelDeck.tsx +5 -27
  27. package/src/components/SettingsModal.tsx +8 -0
  28. package/src/components/ToolCallCard.tsx +2 -2
  29. package/src/components/TrajectoryView.tsx +1 -1
  30. package/src/components/composer/PlusMenu.tsx +117 -0
  31. package/src/components/composer/Popover.tsx +109 -0
  32. package/src/components/composer/WorkspacePicker.tsx +102 -0
  33. package/src/core/plugins/tools/shell.ts +2 -2
  34. package/src/core/plugins/tools/subagent.ts +18 -8
  35. package/src/core/services/agent-loop.ts +45 -43
  36. package/src/core/services/compaction.ts +289 -22
  37. package/src/core/services/llm.ts +6 -2
  38. package/src/core/services/subagents.ts +191 -3
  39. package/src/core/services/system-prompt.ts +35 -7
  40. package/src/core/services/tools.ts +2 -1
  41. package/src/core/types.ts +5 -0
  42. package/src/lib/compact-plan.test.mjs +62 -0
  43. package/src/lib/compact-plan.ts +188 -0
  44. package/src/lib/subagent-types.ts +54 -0
  45. package/src/lib/tool-meta.ts +40 -0
  46. package/src/store/settings.ts +3 -0
  47. package/src/store/ui.ts +12 -0
  48. package/src/types.ts +26 -0
  49. package/shared/plugin-builder.test.mjs +0 -43
  50. package/shared/plugin-package.d.mts +0 -34
  51. package/shared/plugin-package.mjs +0 -197
  52. package/shared/plugin-package.test.mjs +0 -94
  53. package/tsconfig.tsbuildinfo +0 -1
@@ -8,33 +8,20 @@ import {
8
8
  type DragEvent,
9
9
  type KeyboardEvent,
10
10
  } from 'react'
11
- import {
12
- Check,
13
- ChevronDown,
14
- FileText,
15
- FolderOpen,
16
- Globe2,
17
- ListChecks,
18
- Paperclip,
19
- ShieldCheck,
20
- Slash,
21
- Sparkles,
22
- Square,
23
- Upload,
24
- X,
25
- } from 'lucide-react'
11
+ import { Check, FileText, ListChecks, Sparkles, ShieldCheck, Slash, Square, Upload, X } from 'lucide-react'
26
12
  import type { Attachment } from '../types'
27
- import { useSkills } from '../store/skills'
28
13
  import { activeModelId, hasCredentials, useSettings } from '../store/settings'
29
14
  import { useActiveModel, useProviderModels } from '../store/models'
30
15
  import { useUi } from '../store/ui'
31
- import { useWorkspace } from '../store/workspace'
16
+ import { effortLabel, EFFORT_LABELS } from '../data/providers'
32
17
  import { effortOptions } from '../lib/reasoning'
33
18
  import { PERMISSION_MODES, permissionLabel, permissionMode, permissionTools } from '../lib/permissions'
34
- import { useClickOutside } from '../lib/hooks'
35
19
  import { cn, formatBytes, readFileAsDataUrl, readFileAsText } from '../lib/utils'
36
20
  import { ModelDeck, ModelDeckTrigger } from './ModelDeck'
37
21
  import { PixleSol } from './PixleSol'
22
+ import { Popover, MenuLabel, TextButton } from './composer/Popover'
23
+ import { PlusMenu } from './composer/PlusMenu'
24
+ import { WorkspacePicker } from './composer/WorkspacePicker'
38
25
  import type { PixleMood } from '../lib/pixlesol'
39
26
 
40
27
  const TEXT_EXTENSIONS = /\.(txt|md|markdown|ts|tsx|js|jsx|mjs|cjs|json|css|scss|html|vue|svelte|py|go|rs|rb|java|kt|cs|c|h|cpp|hpp|php|swift|sh|bash|zsh|yml|yaml|toml|ini|sql|csv|xml|gitignore|env)$/i
@@ -50,6 +37,7 @@ const SLASH_COMMANDS = [
50
37
  { name: 'workspace', usage: '/workspace', description: 'Open the folder panel', takesArgs: false },
51
38
  { name: 'deploy', usage: '/deploy', description: 'Deploy a project folder', takesArgs: false },
52
39
  { name: 'settings', usage: '/settings', description: 'Open settings', takesArgs: false },
40
+ { name: 'compact', usage: '/compact [focus]', description: 'Summarise older context to free up space', takesArgs: true },
53
41
  { name: 'clear', usage: '/clear', description: 'Clear this conversation', takesArgs: false },
54
42
  ]
55
43
 
@@ -90,22 +78,19 @@ export function Composer({
90
78
  const update = useSettings((state) => state.update)
91
79
  const setMode = useSettings((state) => state.setMode)
92
80
  const notify = useUi((state) => state.notify)
93
- const openSettings = useUi((state) => state.openSettings)
94
81
  const focusToken = useUi((state) => state.composerFocus)
95
- const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
96
- const workspace = useWorkspace((state) => state.info)
97
82
  const [attachments, setAttachments] = useState<Attachment[]>([])
98
83
  const [focused, setFocused] = useState(false)
99
84
  const [dragging, setDragging] = useState(false)
100
85
  const [pickerOpen, setPickerOpen] = useState(false)
101
86
  const [permissionOpen, setPermissionOpen] = useState(false)
87
+ const [effortOpen, setEffortOpen] = useState(false)
88
+ const [modeOpen, setModeOpen] = useState(false)
102
89
  const [submitting, setSubmitting] = useState(false)
103
90
  const [commandIndex, setCommandIndex] = useState(0)
104
91
  const textareaRef = useRef<HTMLTextAreaElement>(null)
105
92
  const fileRef = useRef<HTMLInputElement>(null)
106
93
  const modelTriggerRef = useRef<HTMLButtonElement>(null)
107
- const permissionRef = useRef<HTMLDivElement>(null)
108
- const permissionTriggerRef = useRef<HTMLButtonElement>(null)
109
94
 
110
95
  useEffect(() => {
111
96
  const onKey = (event: globalThis.KeyboardEvent) => {
@@ -118,14 +103,11 @@ export function Composer({
118
103
  return () => window.removeEventListener('keydown', onKey)
119
104
  }, [])
120
105
 
121
- useClickOutside(permissionRef, () => setPermissionOpen(false), permissionOpen, permissionTriggerRef)
122
-
123
106
  const modelId = activeModelId(settings)
124
107
  const providerModels = useProviderModels(settings.provider)
125
108
  const activeModel = useActiveModel()
126
109
  const connected = hasCredentials(settings)
127
110
  const efforts = effortOptions(activeModel)
128
- const activeSkills = useSkills((state) => state.skills.filter((skill) => skill.enabled).length)
129
111
  const permission = permissionMode(settings.tools)
130
112
  const modelLabel = !connected
131
113
  ? 'No provider'
@@ -153,7 +135,7 @@ export function Composer({
153
135
  const node = textareaRef.current
154
136
  if (!node) return
155
137
  node.style.height = 'auto'
156
- node.style.height = `${Math.min(Math.max(node.scrollHeight, 52), 220)}px`
138
+ node.style.height = `${Math.min(Math.max(node.scrollHeight, 36), 220)}px`
157
139
  }, [value])
158
140
 
159
141
  useEffect(() => {
@@ -377,49 +359,8 @@ export function Composer({
377
359
  </div>
378
360
  )}
379
361
 
380
- <div className="mb-2 flex flex-wrap items-center gap-1.5">
381
- <button
382
- type="button"
383
- onClick={() => setWorkspaceOpen(true)}
384
- className="chip h-7 max-w-[220px] gap-1.5 px-2.5 text-[11px]"
385
- title={workspace.available ? workspace.root : 'Choose a project folder'}
386
- >
387
- <FolderOpen size={12} className="shrink-0 text-faint" />
388
- <span className="truncate">{workspace.available ? workspace.name : 'No folder'}</span>
389
- </button>
390
- <button
391
- type="button"
392
- onClick={() => setMode(settings.mode === 'plan' ? 'build' : 'plan')}
393
- className="chip h-7 gap-1.5 px-2.5 text-[11px]"
394
- title={settings.mode === 'plan' ? 'Switch to Build mode' : 'Switch to Plan mode (read-only)'}
395
- >
396
- {settings.mode === 'plan' ? (
397
- <ListChecks size={12} className="text-faint" />
398
- ) : (
399
- <Sparkles size={12} className="text-faint" />
400
- )}
401
- {settings.mode === 'plan' ? 'Plan mode' : 'Build mode'}
402
- </button>
403
- <button
404
- type="button"
405
- onClick={() => update({ webSearchEnabled: !settings.webSearchEnabled })}
406
- className={cn('chip h-7 gap-1.5 px-2.5 text-[11px]', settings.webSearchEnabled && 'chip-active')}
407
- title="Allow OpenSol to search the web and read pages"
408
- aria-pressed={settings.webSearchEnabled}
409
- >
410
- <Globe2 size={12} />
411
- Search
412
- </button>
413
- {activeSkills > 0 && (
414
- <button
415
- type="button"
416
- onClick={() => openSettings('skills')}
417
- className="chip h-7 gap-1 px-2.5 text-[11px]"
418
- title="Manage loaded skills"
419
- >
420
- {activeSkills} skill{activeSkills === 1 ? '' : 's'}
421
- </button>
422
- )}
362
+ <div className="mb-1 flex flex-wrap items-center gap-x-1 px-0.5">
363
+ <WorkspacePicker />
423
364
  </div>
424
365
 
425
366
  <div
@@ -433,17 +374,17 @@ export function Composer({
433
374
  }}
434
375
  onDrop={onDrop}
435
376
  className={cn(
436
- 'surface-lift composer-shadow relative rounded-2xl border transition-all duration-200',
377
+ 'surface-lift composer-shadow relative rounded-xl border transition-all duration-200',
437
378
  focused || dragging ? 'border-accent/35' : 'border-line',
438
379
  streaming && 'composer-glow border-accent/30',
439
380
  dragging && 'ring-4 ring-accent/10',
440
381
  )}
441
382
  >
442
383
  {focused && !dragging && (
443
- <div className="pointer-events-none absolute inset-0 rounded-2xl ring-4 ring-accent/[0.03]" aria-hidden="true" />
384
+ <div className="pointer-events-none absolute inset-0 rounded-xl ring-4 ring-accent/[0.03]" aria-hidden="true" />
444
385
  )}
445
386
 
446
- <div className="relative px-3.5 pb-1 pt-3">
387
+ <div className="relative px-3 pb-0 pt-2">
447
388
  {attachments.length > 0 && (
448
389
  <div className="flex flex-wrap gap-2 px-1 pb-2">
449
390
  {attachments.map((attachment) =>
@@ -496,69 +437,138 @@ export function Composer({
496
437
  placeholder={placeholder}
497
438
  rows={1}
498
439
  aria-label="Message OpenSol"
499
- className="block w-full resize-none bg-transparent px-2 py-1.5 text-[15.5px] leading-[1.6] text-ink outline-none placeholder:text-faint"
500
- style={{ minHeight: 52, maxHeight: 220 }}
440
+ className="block w-full resize-none bg-transparent px-1.5 py-1 text-[14.5px] leading-[1.55] text-ink outline-none placeholder:text-faint"
441
+ style={{ minHeight: 36, maxHeight: 220 }}
501
442
  />
502
443
  </div>
503
444
 
504
- <div className="flex items-end justify-between gap-2 px-2.5 pb-2 pt-1">
445
+ <div className="flex items-center justify-between gap-2 px-1.5 pb-1.5 pt-0.5">
505
446
  <div className="flex min-w-0 items-center gap-0.5">
506
- <div className="relative">
507
- <button
508
- ref={permissionTriggerRef}
509
- type="button"
510
- onClick={() => setPermissionOpen((open) => !open)}
511
- className="flex h-8 items-center gap-1.5 rounded-lg px-2 text-[11px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink"
512
- title="Choose how much OpenSol can do without asking"
513
- aria-expanded={permissionOpen}
514
- >
515
- <ShieldCheck size={13} className="text-faint" />
516
- {permissionLabel(permission)}
517
- <ChevronDown size={11} className={cn('text-faint transition-transform', permissionOpen && 'rotate-180')} />
518
- </button>
519
- {permissionOpen && (
520
- <div
521
- ref={permissionRef}
522
- className="surface-lift absolute bottom-[calc(100%+8px)] left-0 z-40 w-[290px] overflow-hidden rounded-2xl border border-line p-1.5 shadow-float"
447
+ <PlusMenu onAddFiles={() => fileRef.current?.click()} />
448
+ <Popover
449
+ open={modeOpen}
450
+ onClose={() => setModeOpen(false)}
451
+ width={230}
452
+ trigger={
453
+ <TextButton
454
+ open={modeOpen}
455
+ onClick={() => {
456
+ setPermissionOpen(false)
457
+ setEffortOpen(false)
458
+ setModeOpen((open) => !open)
459
+ }}
460
+ title="Build edits files and runs commands; Plan is read-only"
523
461
  >
524
- <p className="px-2 pb-1 pt-1.5 text-[10px] font-medium uppercase tracking-[0.1em] text-faint">
525
- Permissions
526
- </p>
527
- {PERMISSION_MODES.map((entry) => (
528
- <button
529
- key={entry.value}
530
- type="button"
531
- onClick={() => {
532
- update({ tools: permissionTools(settings.tools, entry.value) })
533
- setPermissionOpen(false)
534
- }}
535
- className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised"
536
- >
537
- <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
538
- {permission === entry.value ? (
539
- <Check size={12} className="text-accent" />
540
- ) : (
541
- <span className="h-1.5 w-1.5 rounded-full bg-line" />
542
- )}
543
- </span>
544
- <span className="min-w-0">
545
- <span className="block text-[11.5px] font-medium text-ink">{entry.label}</span>
546
- <span className="mt-0.5 block text-[10px] leading-relaxed text-faint">{entry.description}</span>
547
- </span>
548
- </button>
549
- ))}
550
- </div>
551
- )}
552
- </div>
553
- <button
554
- type="button"
555
- className="icon-button h-8 w-8"
556
- onClick={() => fileRef.current?.click()}
557
- title="Attach images or text files"
558
- aria-label="Attach files"
462
+ {settings.mode === 'plan' ? 'Plan' : 'Build'}
463
+ </TextButton>
464
+ }
559
465
  >
560
- <Paperclip size={15} />
561
- </button>
466
+ <MenuLabel>Mode</MenuLabel>
467
+ {(
468
+ [
469
+ { value: 'build', label: 'Build', hint: 'Edit files and run commands', icon: Sparkles },
470
+ { value: 'plan', label: 'Plan', hint: 'Read-only investigation', icon: ListChecks },
471
+ ] as const
472
+ ).map((entry) => (
473
+ <button
474
+ key={entry.value}
475
+ type="button"
476
+ onClick={() => {
477
+ setMode(entry.value)
478
+ setModeOpen(false)
479
+ }}
480
+ className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised"
481
+ >
482
+ <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
483
+ {settings.mode === entry.value ? <Check size={12} className="text-accent" /> : <entry.icon size={12} className="text-faint" />}
484
+ </span>
485
+ <span className="min-w-0">
486
+ <span className="block text-[11.5px] font-medium text-ink">{entry.label}</span>
487
+ <span className="mt-0.5 block text-[10px] leading-relaxed text-faint">{entry.hint}</span>
488
+ </span>
489
+ </button>
490
+ ))}
491
+ </Popover>
492
+ <Popover
493
+ open={permissionOpen}
494
+ onClose={() => setPermissionOpen(false)}
495
+ width={290}
496
+ trigger={
497
+ <TextButton
498
+ open={permissionOpen}
499
+ onClick={() => {
500
+ setEffortOpen(false)
501
+ setModeOpen(false)
502
+ setPermissionOpen((open) => !open)
503
+ }}
504
+ title="Choose how much OpenSol can do without asking"
505
+ >
506
+ {permissionLabel(permission)}
507
+ </TextButton>
508
+ }
509
+ >
510
+ <MenuLabel>Permissions</MenuLabel>
511
+ {PERMISSION_MODES.map((entry) => (
512
+ <button
513
+ key={entry.value}
514
+ type="button"
515
+ onClick={() => {
516
+ update({ tools: permissionTools(settings.tools, entry.value) })
517
+ setPermissionOpen(false)
518
+ }}
519
+ className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised"
520
+ >
521
+ <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
522
+ {permission === entry.value ? <Check size={12} className="text-accent" /> : <span className="h-1.5 w-1.5 rounded-full bg-line" />}
523
+ </span>
524
+ <span className="min-w-0">
525
+ <span className="block text-[11.5px] font-medium text-ink">{entry.label}</span>
526
+ <span className="mt-0.5 block text-[10px] leading-relaxed text-faint">{entry.description}</span>
527
+ </span>
528
+ </button>
529
+ ))}
530
+ </Popover>
531
+ {efforts.length > 1 && (
532
+ <Popover
533
+ open={effortOpen}
534
+ onClose={() => setEffortOpen(false)}
535
+ width={250}
536
+ trigger={
537
+ <TextButton
538
+ open={effortOpen}
539
+ onClick={() => {
540
+ setPermissionOpen(false)
541
+ setModeOpen(false)
542
+ setEffortOpen((open) => !open)
543
+ }}
544
+ title="Reasoning effort for this model"
545
+ >
546
+ {effortLabel(settings.effort)}
547
+ </TextButton>
548
+ }
549
+ >
550
+ <MenuLabel>Effort</MenuLabel>
551
+ {efforts.map((effort) => (
552
+ <button
553
+ key={effort}
554
+ type="button"
555
+ onClick={() => {
556
+ update({ effort })
557
+ setEffortOpen(false)
558
+ }}
559
+ className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised"
560
+ >
561
+ <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
562
+ {settings.effort === effort ? <Check size={12} className="text-accent" /> : <span className="h-1.5 w-1.5 rounded-full bg-line" />}
563
+ </span>
564
+ <span className="min-w-0">
565
+ <span className="block text-[11.5px] font-medium text-ink">{EFFORT_LABELS[effort].label}</span>
566
+ <span className="mt-0.5 block text-[10px] leading-relaxed text-faint">{EFFORT_LABELS[effort].description}</span>
567
+ </span>
568
+ </button>
569
+ ))}
570
+ </Popover>
571
+ )}
562
572
  <input
563
573
  ref={fileRef}
564
574
  type="file"
@@ -588,9 +598,7 @@ export function Composer({
588
598
  open={pickerOpen}
589
599
  disabled={!connected}
590
600
  onClick={() => setPickerOpen((open) => !open)}
591
- efforts={efforts}
592
- effort={settings.effort}
593
- title={connected ? 'Model deck: choose model and reasoning effort (Ctrl M)' : 'Add a provider key in Settings first'}
601
+ title={connected ? 'Choose a model (Ctrl M)' : 'Add a provider key in Settings first'}
594
602
  />
595
603
  {streaming ? (
596
604
  <button
@@ -625,14 +633,14 @@ export function Composer({
625
633
  </div>
626
634
 
627
635
  {dragging && (
628
- <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1.5 rounded-2xl border-2 border-dashed border-accent/45 bg-panel/95">
636
+ <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1.5 rounded-xl border-2 border-dashed border-accent/45 bg-panel/95">
629
637
  <Upload size={18} className="text-accent" />
630
638
  <p className="text-[12px] font-medium text-ink">Drop files to attach</p>
631
639
  <p className="text-[10.5px] text-faint">Images up to 5 MB · text up to 512 KB</p>
632
640
  </div>
633
641
  )}
634
642
 
635
- {pixleMood && (
643
+ {pixleMood && settings.showMascot && (
636
644
  <PixleSol
637
645
  mood={pixleMood}
638
646
  size={44}
@@ -9,7 +9,7 @@
9
9
  import { useMemo } from 'react'
10
10
  import type { ModelInfo, Message } from '../types'
11
11
  import { getContextUsage, AUTO_COMPACT_THRESHOLD } from '../lib/context'
12
- import { isCompacting } from '../agent/compact'
12
+ import { useRuntime } from '../store/ui'
13
13
  import { formatCredits, sumCost } from '../lib/credits'
14
14
  import { cn } from '../lib/utils'
15
15
 
@@ -28,7 +28,7 @@ function formatTokens(n: number): string {
28
28
  export function ContextBar({ messages, model, sessionId }: ContextBarProps) {
29
29
  const usage = useMemo(() => getContextUsage(messages, model), [messages, model])
30
30
  const spent = useMemo(() => sumCost(messages), [messages])
31
- const compacting = sessionId ? isCompacting(sessionId) : false
31
+ const compacting = useRuntime((state) => (sessionId ? Boolean(state.compacting[sessionId]) : false))
32
32
 
33
33
  if (messages.length === 0) return null
34
34
 
@@ -44,15 +44,18 @@ function Stat({ icon: Icon, label, value }: { icon: typeof Activity; label: stri
44
44
 
45
45
  export function HomeGreeting() {
46
46
  const daemon = useSettings((state) => state.daemon)
47
+ const showMascot = useSettings((state) => state.showMascot)
47
48
  const workspace = useWorkspace((state) => state.info)
48
49
  const home = daemon?.home ?? ''
49
50
  const name = home.split(/[\\/]/).filter(Boolean).pop() || workspace.name || 'there'
50
51
  return (
51
52
  <div className="flex flex-col items-center gap-4 text-center">
52
- <div className="relative">
53
- <span className="absolute inset-0 -z-10 m-auto h-16 w-16 rounded-full bg-accent/20 blur-2xl" aria-hidden="true" />
54
- <PixleSol mood="wave" size={60} />
55
- </div>
53
+ {showMascot && (
54
+ <div className="relative">
55
+ <span className="absolute inset-0 -z-10 m-auto h-16 w-16 rounded-full bg-accent/20 blur-2xl" aria-hidden="true" />
56
+ <PixleSol mood="wave" size={60} />
57
+ </div>
58
+ )}
56
59
  <h1 className="text-[28px] font-semibold leading-tight tracking-[-0.035em] text-ink sm:text-[34px]">
57
60
  What should we build, <span className="text-solar">{name}</span>?
58
61
  </h1>
@@ -18,7 +18,7 @@ import { regenerateLastTurn, runAgent } from '../agent'
18
18
  import { restoreCheckpoint } from '../lib/daemon'
19
19
  import { useWorkspace } from '../store/workspace'
20
20
  import { createId, useChats } from '../store/chats'
21
- import { toolLabel } from '../agent/tools'
21
+ import { toolLabel } from '../lib/tool-meta'
22
22
  import { formatBytes, cn } from '../lib/utils'
23
23
  import { Markdown } from './Markdown'
24
24
  import { ToolCallCard, diffForTool } from './ToolCallCard'
@@ -375,6 +375,37 @@ export function UsedGroup({ tools, streaming = false }: { tools: ToolMessage[];
375
375
  return <TaskCompletedBox tools={tools} streaming={streaming} />
376
376
  }
377
377
 
378
+ const COMPACTION_PREFIX = /^\[Summary of the earlier conversation[^\n]*\]\n\n/
379
+
380
+ /** The summary that replaced older messages: a quiet divider that opens to the full text. */
381
+ function CompactionNote({ message }: { message: AssistantMessage }) {
382
+ const [open, setOpen] = useState(false)
383
+ const meta = message.compaction
384
+ if (!meta) return null
385
+ return (
386
+ <div className="my-1">
387
+ <button
388
+ type="button"
389
+ onClick={() => setOpen((value) => !value)}
390
+ className="group/compact flex w-full items-center gap-3 text-[11.5px] text-faint"
391
+ aria-expanded={open}
392
+ >
393
+ <span className="h-px flex-1 bg-line" />
394
+ <span className="shrink-0 transition-colors group-hover/compact:text-ink">
395
+ Context compacted · {meta.turns} turn{meta.turns === 1 ? '' : 's'}, {meta.toolCalls} tool call{meta.toolCalls === 1 ? '' : 's'} summarised
396
+ {meta.fallback ? ' · without the model' : ''}
397
+ </span>
398
+ <span className="h-px flex-1 bg-line" />
399
+ </button>
400
+ {open && (
401
+ <div className="animate-fade mt-2 rounded-xl border border-line bg-raised/40 px-4 py-3">
402
+ <Markdown content={message.content.replace(COMPACTION_PREFIX, '')} />
403
+ </div>
404
+ )}
405
+ </div>
406
+ )
407
+ }
408
+
378
409
  export function AssistantBlock({
379
410
  message,
380
411
  sessionId,
@@ -402,6 +433,7 @@ export function AssistantBlock({
402
433
  // Thoughts stay closed until the person opens them, even while the model is still thinking.
403
434
  const [reasoningOpen, setReasoningOpen] = useState(false)
404
435
  const hasBody = Boolean(message.content.trim() || message.reasoning.trim() || message.error)
436
+ if (message.compaction) return <CompactionNote message={message} />
405
437
  const modelName =
406
438
  providerModels?.find((model) => model.id === message.model)?.name ?? message.model ?? ''
407
439
  const needsConfig = Boolean(message.error && /api key|base url|settings|credential|403|401/i.test(message.error))
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { AlertCircle, Brain, Check, Eye, KeyRound, Plus, RefreshCw, Search, Settings2, X, Zap } from 'lucide-react'
3
+ import { AlertCircle, Brain, Check, ChevronDown, Eye, KeyRound, Plus, RefreshCw, Search, Settings2, X, Zap } from 'lucide-react'
4
4
  import { PROVIDERS, PROVIDER_MAP, EFFORT_LABELS, effortLabel } from '../data/providers'
5
5
  import type { Effort, ModelInfo, ProviderId } from '../types'
6
6
  import { activeModelId, providerHasCredentials, useSettings } from '../store/settings'
@@ -33,25 +33,6 @@ function monogram(name: string) {
33
33
  return (words.length > 1 ? words[0][0] + words[1][0] : name.slice(0, 2)).toUpperCase()
34
34
  }
35
35
 
36
- /** Signal-bar style effort meter used on the composer trigger. */
37
- export function EffortBars({ efforts, value, className }: { efforts: Effort[]; value: Effort; className?: string }) {
38
- const index = Math.max(0, efforts.indexOf(value))
39
- return (
40
- <span className={cn('flex h-3.5 items-end gap-[2px]', className)} aria-hidden>
41
- {efforts.map((effort, i) => (
42
- <span
43
- key={effort}
44
- style={{ height: `${38 + (i / Math.max(1, efforts.length - 1)) * 62}%` }}
45
- className={cn(
46
- 'w-[3px] rounded-[1px] transition-all duration-300 ease-spring',
47
- i <= index ? 'bg-accent' : 'bg-line',
48
- )}
49
- />
50
- ))}
51
- </span>
52
- )
53
- }
54
-
55
36
  function EffortThrottle({
56
37
  efforts,
57
38
  value,
@@ -628,14 +609,13 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
628
609
  }
629
610
 
630
611
  /** Composer trigger: model name with live effort bars, no dropdown chevron. */
612
+ /** Plain-text model name for the composer; opens the deck. */
631
613
  export function ModelDeckTrigger({
632
614
  label,
633
615
  open,
634
616
  disabled,
635
617
  onClick,
636
618
  triggerRef,
637
- efforts,
638
- effort,
639
619
  title,
640
620
  }: {
641
621
  label: string
@@ -643,8 +623,6 @@ export function ModelDeckTrigger({
643
623
  disabled: boolean
644
624
  onClick: () => void
645
625
  triggerRef: React.RefObject<HTMLButtonElement | null>
646
- efforts: Effort[]
647
- effort: Effort
648
626
  title: string
649
627
  }) {
650
628
  return (
@@ -657,12 +635,12 @@ export function ModelDeckTrigger({
657
635
  aria-expanded={open}
658
636
  title={title}
659
637
  className={cn(
660
- 'deck-trigger group/deck flex h-8 max-w-[200px] items-center gap-2 rounded-full border border-transparent pl-2.5 pr-2.5 text-[11px] font-medium text-muted transition-all duration-200 hover:border-line hover:bg-raised hover:text-ink active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45 sm:max-w-[270px]',
661
- open && 'border-accent/40 bg-accent-soft text-accent',
638
+ 'deck-trigger flex h-8 max-w-[180px] items-center gap-1 rounded-lg px-2 text-[12px] font-medium text-muted transition-colors hover:text-ink disabled:cursor-not-allowed disabled:opacity-45 sm:max-w-[260px]',
639
+ open && 'text-ink',
662
640
  )}
663
641
  >
664
642
  <span className="truncate">{label}</span>
665
- {efforts.length > 1 && <EffortBars efforts={efforts} value={effort} />}
643
+ <ChevronDown size={11} className={cn('shrink-0 text-faint transition-transform', open && 'rotate-180')} />
666
644
  </button>
667
645
  )
668
646
  }
@@ -1602,6 +1602,14 @@ function GeneralTab() {
1602
1602
  />
1603
1603
  </div>
1604
1604
 
1605
+ <div className="settings-row">
1606
+ <div className="min-w-0">
1607
+ <p className="settings-label">Show mascot</p>
1608
+ <p className="settings-hint">The pixel mascot on the home screen and above the message box. Off by default.</p>
1609
+ </div>
1610
+ <Switch checked={settings.showMascot} onChange={(showMascot) => update({ showMascot })} label="Show mascot" />
1611
+ </div>
1612
+
1605
1613
  <div className="settings-row">
1606
1614
  <div className="min-w-0">
1607
1615
  <p className="settings-label">Show token estimates</p>
@@ -1,8 +1,8 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import { ArrowRight } from 'lucide-react'
3
3
  import type { FileDiff, ToolMessage, ToolName } from '../types'
4
- import { toolLabel, toolSummary } from '../agent/tools'
5
- import { resolveApproval } from '../agent/approval'
4
+ import { toolLabel, toolSummary } from '../lib/tool-meta'
5
+ import { resolveApproval } from '../core/services/approval'
6
6
  import { useUi } from '../store/ui'
7
7
  import { cn } from '../lib/utils'
8
8
  import { diffLines, diffStats } from '../lib/diff'
@@ -9,7 +9,7 @@ import {
9
9
  Wrench,
10
10
  } from 'lucide-react'
11
11
  import type { ChatSession, Message } from '../types'
12
- import { toolLabel, toolSummary } from '../agent/tools'
12
+ import { toolLabel, toolSummary } from '../lib/tool-meta'
13
13
  import { cn } from '../lib/utils'
14
14
  import { LogoMark } from './Logo'
15
15