openrune 0.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 (36) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +24 -0
  3. package/LICENSE +21 -0
  4. package/README.md +257 -0
  5. package/bin/rune.js +718 -0
  6. package/bootstrap.js +4 -0
  7. package/channel/rune-channel.ts +467 -0
  8. package/electron-builder.yml +61 -0
  9. package/finder-extension/FinderSync.swift +47 -0
  10. package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +27 -0
  11. package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
  12. package/finder-extension/main.swift +5 -0
  13. package/package.json +53 -0
  14. package/renderer/index.html +12 -0
  15. package/renderer/src/App.tsx +43 -0
  16. package/renderer/src/features/chat/activity-block.tsx +152 -0
  17. package/renderer/src/features/chat/chat-header.tsx +58 -0
  18. package/renderer/src/features/chat/chat-input.tsx +190 -0
  19. package/renderer/src/features/chat/chat-panel.tsx +150 -0
  20. package/renderer/src/features/chat/markdown-renderer.tsx +26 -0
  21. package/renderer/src/features/chat/message-bubble.tsx +79 -0
  22. package/renderer/src/features/chat/message-list.tsx +178 -0
  23. package/renderer/src/features/chat/types.ts +32 -0
  24. package/renderer/src/features/chat/use-chat.ts +251 -0
  25. package/renderer/src/features/terminal/terminal-panel.tsx +132 -0
  26. package/renderer/src/global.d.ts +29 -0
  27. package/renderer/src/globals.css +92 -0
  28. package/renderer/src/hooks/use-ipc.ts +24 -0
  29. package/renderer/src/lib/markdown.ts +83 -0
  30. package/renderer/src/lib/utils.ts +6 -0
  31. package/renderer/src/main.tsx +10 -0
  32. package/renderer/tsconfig.json +16 -0
  33. package/renderer/vite.config.ts +23 -0
  34. package/src/main.ts +782 -0
  35. package/src/preload.ts +58 -0
  36. package/tsconfig.json +14 -0
@@ -0,0 +1,43 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import { Bot } from 'lucide-react'
3
+ import { ChatPanel } from './features/chat/chat-panel'
4
+ import { useChat } from './features/chat/use-chat'
5
+
6
+ export function App() {
7
+ const chat = useChat()
8
+ const [showTerminal, setShowTerminal] = useState(true)
9
+
10
+ // Auto-switch to chat when MCP channel connects
11
+ useEffect(() => {
12
+ const handler = (data: { port: number; connected: boolean }) => {
13
+ if (data.connected) setShowTerminal(false)
14
+ }
15
+ window.rune.on('rune:channelStatus', handler)
16
+ return () => window.rune.off('rune:channelStatus', handler)
17
+ }, [])
18
+
19
+ const toggleTerminal = useCallback(() => setShowTerminal(prev => !prev), [])
20
+
21
+ if (!chat.runeInfo) {
22
+ return (
23
+ <div className="flex h-screen items-center justify-center bg-background text-foreground">
24
+ <div className="flex flex-col items-center gap-4">
25
+ <Bot className="h-12 w-12 text-accent" />
26
+ <p className="text-sm text-muted">Open a .rune file to get started</p>
27
+ </div>
28
+ </div>
29
+ )
30
+ }
31
+
32
+ return (
33
+ <div className="flex h-screen bg-background text-foreground text-[13px] overflow-hidden">
34
+ <div className="flex-1 overflow-hidden">
35
+ <ChatPanel
36
+ chat={chat}
37
+ showTerminal={showTerminal}
38
+ onToggleTerminal={toggleTerminal}
39
+ />
40
+ </div>
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,152 @@
1
+ import { useState } from 'react'
2
+ import { Brain, Terminal, FileText, Pencil, Search, FolderOpen, ChevronRight, ChevronDown, Code, FileCode } from 'lucide-react'
3
+ import type { ContentBlock } from './types'
4
+
5
+ const TOOL_ICONS: Record<string, typeof Terminal> = {
6
+ Bash: Terminal,
7
+ Read: FileText,
8
+ Write: FileCode,
9
+ Edit: Pencil,
10
+ Grep: Search,
11
+ Glob: FolderOpen,
12
+ Task: Code,
13
+ }
14
+
15
+ function getToolIcon(tool?: string) {
16
+ if (!tool) return Terminal
17
+ return TOOL_ICONS[tool] || Terminal
18
+ }
19
+
20
+ function formatArgs(args?: Record<string, unknown>): string {
21
+ if (!args) return ''
22
+ const entries = Object.entries(args)
23
+ if (entries.length === 0) return ''
24
+ return entries
25
+ .map(([k, v]) => {
26
+ const val = typeof v === 'string' ? v : JSON.stringify(v)
27
+ const truncated = val.length > 120 ? val.slice(0, 120) + '...' : val
28
+ return `${k}: ${truncated}`
29
+ })
30
+ .join('\n')
31
+ }
32
+
33
+ function ThinkingBlock({ block }: { block: ContentBlock }) {
34
+ const [expanded, setExpanded] = useState(false)
35
+ const hasContent = block.content && block.content.trim().length > 0
36
+ const preview = block.content
37
+ ? block.content.length > 80
38
+ ? block.content.slice(0, 80) + '...'
39
+ : block.content
40
+ : ''
41
+
42
+ return (
43
+ <div className="activity-block activity-thinking">
44
+ <button
45
+ className="activity-header"
46
+ onClick={() => hasContent && setExpanded(!expanded)}
47
+ >
48
+ <Brain className="activity-icon h-3.5 w-3.5 text-purple-400 shrink-0" />
49
+ <span className="activity-label text-purple-400">Thinking</span>
50
+ {!expanded && preview && (
51
+ <span className="activity-preview">{preview}</span>
52
+ )}
53
+ {hasContent && (
54
+ expanded
55
+ ? <ChevronDown className="h-3 w-3 text-muted shrink-0 ml-auto" />
56
+ : <ChevronRight className="h-3 w-3 text-muted shrink-0 ml-auto" />
57
+ )}
58
+ </button>
59
+ {expanded && hasContent && (
60
+ <div className="activity-body activity-thinking-body">
61
+ {block.content}
62
+ </div>
63
+ )}
64
+ </div>
65
+ )
66
+ }
67
+
68
+ function ToolUseBlock({ block }: { block: ContentBlock }) {
69
+ const [expanded, setExpanded] = useState(false)
70
+ const Icon = getToolIcon(block.tool)
71
+ const argsText = formatArgs(block.args)
72
+
73
+ return (
74
+ <div className="activity-block activity-tool-use">
75
+ <button
76
+ className="activity-header"
77
+ onClick={() => argsText && setExpanded(!expanded)}
78
+ >
79
+ <Icon className="activity-icon h-3.5 w-3.5 text-accent shrink-0" />
80
+ <span className="activity-label text-accent">{block.tool || 'Tool'}</span>
81
+ {!expanded && block.args && (
82
+ <span className="activity-preview">
83
+ {Object.entries(block.args).map(([k, v]) => {
84
+ const val = typeof v === 'string' ? v : JSON.stringify(v)
85
+ return val.length > 60 ? val.slice(0, 60) + '...' : val
86
+ }).join(' ')}
87
+ </span>
88
+ )}
89
+ {argsText && (
90
+ expanded
91
+ ? <ChevronDown className="h-3 w-3 text-muted shrink-0 ml-auto" />
92
+ : <ChevronRight className="h-3 w-3 text-muted shrink-0 ml-auto" />
93
+ )}
94
+ </button>
95
+ {expanded && argsText && (
96
+ <div className="activity-body">
97
+ <pre className="activity-args">{argsText}</pre>
98
+ </div>
99
+ )}
100
+ </div>
101
+ )
102
+ }
103
+
104
+ function ToolResultBlock({ block }: { block: ContentBlock }) {
105
+ const [expanded, setExpanded] = useState(false)
106
+ const Icon = getToolIcon(block.tool)
107
+ const hasContent = block.content && block.content.trim().length > 0
108
+ const preview = block.content
109
+ ? block.content.length > 100
110
+ ? block.content.slice(0, 100) + '...'
111
+ : block.content
112
+ : ''
113
+
114
+ return (
115
+ <div className="activity-block activity-tool-result">
116
+ <button
117
+ className="activity-header"
118
+ onClick={() => hasContent && setExpanded(!expanded)}
119
+ >
120
+ <Icon className="activity-icon h-3.5 w-3.5 text-emerald-400 shrink-0" />
121
+ <span className="activity-label text-emerald-400">{block.tool || 'Result'}</span>
122
+ <span className="activity-result-badge">done</span>
123
+ {!expanded && preview && (
124
+ <span className="activity-preview">{preview}</span>
125
+ )}
126
+ {hasContent && (
127
+ expanded
128
+ ? <ChevronDown className="h-3 w-3 text-muted shrink-0 ml-auto" />
129
+ : <ChevronRight className="h-3 w-3 text-muted shrink-0 ml-auto" />
130
+ )}
131
+ </button>
132
+ {expanded && hasContent && (
133
+ <div className="activity-body">
134
+ <pre className="activity-result-content">{block.content}</pre>
135
+ </div>
136
+ )}
137
+ </div>
138
+ )
139
+ }
140
+
141
+ export function ActivityBlock({ block }: { block: ContentBlock }) {
142
+ switch (block.type) {
143
+ case 'thinking':
144
+ return <ThinkingBlock block={block} />
145
+ case 'tool_use':
146
+ return <ToolUseBlock block={block} />
147
+ case 'tool_result':
148
+ return <ToolResultBlock block={block} />
149
+ default:
150
+ return null
151
+ }
152
+ }
@@ -0,0 +1,58 @@
1
+ import { useState } from 'react'
2
+ import { Trash2, SquareTerminal } from 'lucide-react'
3
+ import { cn } from '@/lib/utils'
4
+ import { useIPCOn } from '@/hooks/use-ipc'
5
+
6
+ interface ChatHeaderProps {
7
+ name: string
8
+ role?: string
9
+ port: number
10
+ showTerminal?: boolean
11
+ onClearHistory: () => void
12
+ onToggleTerminal?: () => void
13
+ }
14
+
15
+ export function ChatHeader({ name, role, port, showTerminal, onClearHistory, onToggleTerminal }: ChatHeaderProps) {
16
+ const [connected, setConnected] = useState(false)
17
+ useIPCOn('rune:channelStatus', (data: { port: number; connected: boolean }) => {
18
+ if (data.port === port) setConnected(data.connected)
19
+ })
20
+
21
+ const dotColor = connected ? 'bg-accent' : 'bg-accent-red'
22
+
23
+ return (
24
+ <div
25
+ className="flex items-center justify-between px-4 h-[40px] border-b border-border shrink-0"
26
+ style={{ WebkitAppRegion: 'drag' as any }}
27
+ >
28
+ <div className="flex items-center gap-2" style={{ marginLeft: process.platform === 'darwin' ? 68 : 0 }}>
29
+ <span className="text-[13px] font-medium text-foreground">{name}</span>
30
+ <div className={cn('w-2 h-2 rounded-full', dotColor, !connected && 'animate-pulse')} />
31
+ </div>
32
+
33
+ <div className="flex items-center gap-1.5" style={{ WebkitAppRegion: 'no-drag' as any }}>
34
+ {onToggleTerminal && (
35
+ <button
36
+ className={cn(
37
+ 'inline-flex items-center justify-center rounded-md h-7 w-7 transition-colors',
38
+ showTerminal
39
+ ? 'text-accent bg-accent/10'
40
+ : 'text-muted hover:text-foreground hover:bg-border'
41
+ )}
42
+ title="Toggle terminal"
43
+ onClick={() => onToggleTerminal?.()}
44
+ >
45
+ <SquareTerminal className="h-3.5 w-3.5" />
46
+ </button>
47
+ )}
48
+ <button
49
+ className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted hover:text-foreground hover:bg-border transition-colors"
50
+ title="Clear history"
51
+ onClick={onClearHistory}
52
+ >
53
+ <Trash2 className="h-3.5 w-3.5" />
54
+ </button>
55
+ </div>
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,190 @@
1
+ import { useRef, useCallback, useState, type DragEvent } from 'react'
2
+ import { ArrowUp, Square, Paperclip, X } from 'lucide-react'
3
+ import { cn } from '@/lib/utils'
4
+
5
+ interface ChatInputProps {
6
+ isStreaming: boolean
7
+ disabled?: boolean
8
+ onSend: (content: string, files?: string[]) => void
9
+ onCancel: () => void
10
+ }
11
+
12
+ export function ChatInput({ isStreaming, disabled, onSend, onCancel }: ChatInputProps) {
13
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
14
+ const fileInputRef = useRef<HTMLInputElement>(null)
15
+ const [attachedFiles, setAttachedFiles] = useState<string[]>([])
16
+ const [isDragOver, setIsDragOver] = useState(false)
17
+
18
+ const handleSend = useCallback(() => {
19
+ if (disabled) return
20
+ const content = textareaRef.current?.value.trim()
21
+ if (!content && attachedFiles.length === 0) return
22
+ onSend(content || '', attachedFiles.length > 0 ? attachedFiles : undefined)
23
+ if (textareaRef.current) {
24
+ textareaRef.current.value = ''
25
+ textareaRef.current.style.height = 'auto'
26
+ }
27
+ setAttachedFiles([])
28
+ }, [disabled, onSend, attachedFiles])
29
+
30
+ const handleSendOrCancel = useCallback(() => {
31
+ const hasContent = textareaRef.current?.value.trim() || attachedFiles.length > 0
32
+ // If streaming but user typed something, send it (will auto-cancel current stream)
33
+ // If streaming with empty input, just cancel
34
+ if (isStreaming && !hasContent) onCancel()
35
+ else handleSend()
36
+ }, [isStreaming, onCancel, handleSend, attachedFiles.length])
37
+
38
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
39
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
40
+ e.preventDefault()
41
+ handleSend()
42
+ }
43
+ }, [handleSend])
44
+
45
+ const handleInput = useCallback(() => {
46
+ const el = textareaRef.current
47
+ if (el) {
48
+ el.style.height = 'auto'
49
+ el.style.height = `${Math.min(el.scrollHeight, 120)}px`
50
+ }
51
+ }, [])
52
+
53
+ const handleFileClick = useCallback(() => {
54
+ fileInputRef.current?.click()
55
+ }, [])
56
+
57
+ const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
58
+ const files = e.target.files
59
+ if (!files) return
60
+ const paths: string[] = []
61
+ for (let i = 0; i < files.length; i++) {
62
+ const f = files[i] as any
63
+ if (f.path) paths.push(f.path)
64
+ }
65
+ if (paths.length > 0) {
66
+ setAttachedFiles(prev => [...prev, ...paths])
67
+ }
68
+ // Reset input so same file can be re-selected
69
+ e.target.value = ''
70
+ }, [])
71
+
72
+ const removeFile = useCallback((index: number) => {
73
+ setAttachedFiles(prev => prev.filter((_, i) => i !== index))
74
+ }, [])
75
+
76
+ const handleDragOver = useCallback((e: DragEvent) => {
77
+ e.preventDefault()
78
+ e.stopPropagation()
79
+ setIsDragOver(true)
80
+ }, [])
81
+
82
+ const handleDragLeave = useCallback((e: DragEvent) => {
83
+ e.preventDefault()
84
+ e.stopPropagation()
85
+ // Only set false if leaving the container (not entering a child)
86
+ if (e.currentTarget && !e.currentTarget.contains(e.relatedTarget as Node)) {
87
+ setIsDragOver(false)
88
+ }
89
+ }, [])
90
+
91
+ const handleDrop = useCallback((e: DragEvent) => {
92
+ e.preventDefault()
93
+ e.stopPropagation()
94
+ setIsDragOver(false)
95
+ const files = e.dataTransfer?.files
96
+ if (!files || files.length === 0) return
97
+ const paths: string[] = []
98
+ for (let i = 0; i < files.length; i++) {
99
+ const f = files[i] as any
100
+ if (f.path) paths.push(f.path)
101
+ }
102
+ if (paths.length > 0) {
103
+ setAttachedFiles(prev => [...prev, ...paths])
104
+ }
105
+ }, [])
106
+
107
+ const getFileName = (filePath: string) => {
108
+ return filePath.split('/').pop() || filePath
109
+ }
110
+
111
+ return (
112
+ <div
113
+ className={cn(
114
+ 'flex flex-col gap-2 px-4 py-3.5 border-t shrink-0 relative transition-colors',
115
+ isDragOver ? 'border-accent bg-accent/5' : 'border-border'
116
+ )}
117
+ onDragOver={handleDragOver}
118
+ onDragLeave={handleDragLeave}
119
+ onDrop={handleDrop}
120
+ >
121
+ {isDragOver && (
122
+ <div className="absolute inset-0 flex items-center justify-center bg-accent/10 border-2 border-dashed border-accent rounded-lg z-10 pointer-events-none">
123
+ <span className="text-[12px] font-medium text-accent">Drop files here</span>
124
+ </div>
125
+ )}
126
+ {attachedFiles.length > 0 && (
127
+ <div className="flex flex-wrap gap-1.5">
128
+ {attachedFiles.map((file, i) => (
129
+ <div
130
+ key={i}
131
+ className="flex items-center gap-1.5 bg-muted/30 border border-border rounded-lg px-2.5 py-1.5 text-[11px] text-muted-foreground max-w-[200px]"
132
+ >
133
+ <Paperclip className="h-3 w-3 shrink-0 opacity-50" />
134
+ <span className="truncate">{getFileName(file)}</span>
135
+ <button
136
+ onClick={() => removeFile(i)}
137
+ className="shrink-0 hover:text-foreground transition-colors"
138
+ >
139
+ <X className="h-3 w-3" />
140
+ </button>
141
+ </div>
142
+ ))}
143
+ </div>
144
+ )}
145
+ <div className="flex gap-2.5 items-end">
146
+ <button
147
+ className="inline-flex items-center justify-center rounded-xl h-[38px] w-[38px] shrink-0 transition-colors text-muted-foreground hover:text-foreground hover:bg-muted/30"
148
+ onClick={handleFileClick}
149
+ title="Attach file"
150
+ >
151
+ <Paperclip className="h-4 w-4" />
152
+ </button>
153
+ <input
154
+ ref={fileInputRef}
155
+ type="file"
156
+ multiple
157
+ className="hidden"
158
+ onChange={handleFileChange}
159
+ />
160
+ <textarea
161
+ ref={textareaRef}
162
+ className={cn(
163
+ 'flex-1 rounded-xl border border-input bg-transparent px-3.5 py-2.5 text-[13px] text-foreground resize-none outline-none min-h-[42px] max-h-[120px] leading-[1.5] transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-0',
164
+ disabled && 'opacity-50 cursor-not-allowed'
165
+ )}
166
+ placeholder={disabled ? 'Waiting for channel connection...' : 'Type a message...'}
167
+ rows={1}
168
+ spellCheck={false}
169
+ disabled={disabled}
170
+ onKeyDown={handleKeyDown}
171
+ onInput={handleInput}
172
+ />
173
+ <button
174
+ className={cn(
175
+ 'inline-flex items-center justify-center rounded-xl h-[38px] w-[38px] shrink-0 transition-colors',
176
+ disabled
177
+ ? 'bg-muted text-muted-foreground cursor-not-allowed'
178
+ : isStreaming
179
+ ? 'bg-accent-red text-white hover:bg-accent-red/90'
180
+ : 'bg-accent text-accent-foreground hover:bg-accent/90'
181
+ )}
182
+ onClick={handleSendOrCancel}
183
+ disabled={disabled}
184
+ >
185
+ {isStreaming ? <Square className="h-3.5 w-3.5" /> : <ArrowUp className="h-4 w-4 stroke-[2.5]" />}
186
+ </button>
187
+ </div>
188
+ </div>
189
+ )
190
+ }
@@ -0,0 +1,150 @@
1
+ import { useEffect, useState, useCallback } from 'react'
2
+ import { Toaster, toast } from 'sonner'
3
+ import { ShieldAlert } from 'lucide-react'
4
+ import { ChatHeader } from './chat-header'
5
+ import { MessageList } from './message-list'
6
+ import { ChatInput } from './chat-input'
7
+ import { TerminalPanel } from '../terminal/terminal-panel'
8
+ import type { useChat } from './use-chat'
9
+
10
+ interface PermissionPrompt {
11
+ ptyId: string
12
+ context: string
13
+ }
14
+
15
+ interface ChatPanelProps {
16
+ chat: ReturnType<typeof useChat>
17
+ showTerminal?: boolean
18
+ onToggleTerminal?: () => void
19
+ }
20
+
21
+ export function ChatPanel({ chat, showTerminal, onToggleTerminal }: ChatPanelProps) {
22
+ const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null)
23
+
24
+ // Listen for permission prompts
25
+ useEffect(() => {
26
+ const handler = (data: { id: string; context: string }) => {
27
+ setPermissionPrompt({ ptyId: data.id, context: data.context || '' })
28
+ }
29
+ window.rune.on('rune:permissionNeeded', handler)
30
+ return () => window.rune.off('rune:permissionNeeded', handler)
31
+ }, [])
32
+
33
+ const handlePermissionResponse = useCallback((response: 'allow' | 'always' | 'deny') => {
34
+ if (!permissionPrompt) return
35
+ window.rune.send('rune:permissionRespond', { ptyId: permissionPrompt.ptyId, allow: response !== 'deny', response })
36
+ setPermissionPrompt(null)
37
+ }, [permissionPrompt])
38
+
39
+ // Toast notifications for channel status
40
+ useEffect(() => {
41
+ const handler = (data: { port: number; connected: boolean }) => {
42
+ if (data.connected) {
43
+ toast.success(`Connected to channel :${data.port}`, { duration: 3000 })
44
+ } else {
45
+ toast.error(`Disconnected from channel :${data.port}`, { duration: 4000 })
46
+ }
47
+ }
48
+ window.rune.on('rune:channelStatus', handler)
49
+ return () => window.rune.off('rune:channelStatus', handler)
50
+ }, [])
51
+
52
+ const channelCommand = chat.runeInfo
53
+ ? `RUNE_CHANNEL_PORT=${chat.runeInfo.port} RUNE_FOLDER_PATH=${chat.runeInfo.folderPath} claude --permission-mode auto --enable-auto-mode --dangerously-load-development-channels server:rune-channel`
54
+ : ''
55
+
56
+ return (
57
+ <div className="flex flex-col h-full bg-background text-foreground relative">
58
+ <ChatHeader
59
+ name={chat.runeInfo?.name || 'Rune'}
60
+ role={chat.runeInfo?.role}
61
+ port={chat.runeInfo?.port || 0}
62
+ showTerminal={showTerminal}
63
+ onClearHistory={chat.clearHistory}
64
+ onToggleTerminal={onToggleTerminal}
65
+ />
66
+ <div className="flex-1 overflow-hidden relative">
67
+ {/* Terminal */}
68
+ {chat.runeInfo && (
69
+ <div className={showTerminal ? 'absolute inset-0' : 'absolute inset-0 invisible'}>
70
+ <TerminalPanel
71
+ cwd={chat.runeInfo.folderPath}
72
+ autoCommand={channelCommand}
73
+ />
74
+ </div>
75
+ )}
76
+ {/* Chat view */}
77
+ <div className={showTerminal ? 'absolute inset-0 invisible' : 'absolute inset-0 flex flex-col'}>
78
+ <MessageList
79
+ messages={chat.messages}
80
+ isStreaming={chat.isStreaming}
81
+ streamingDisplayText={chat.streamingDisplayText}
82
+ streamingActivities={chat.streamingActivities}
83
+ port={chat.runeInfo?.port}
84
+ folderPath={chat.runeInfo?.folderPath}
85
+ connected={chat.connected}
86
+ visible={!showTerminal}
87
+ />
88
+
89
+ {/* Permission prompt banner */}
90
+ {permissionPrompt && !showTerminal && (
91
+ <div className="mx-3 mb-2 rounded-lg border border-amber-500/30 bg-amber-500/10 overflow-hidden">
92
+ <div className="flex items-center gap-2 px-3 py-2">
93
+ <ShieldAlert className="h-4 w-4 text-amber-400 shrink-0" />
94
+ <span className="text-[12px] font-medium text-amber-300">Permission Required</span>
95
+ </div>
96
+ {permissionPrompt.context && (
97
+ <pre className="px-3 pb-2 text-[11px] text-muted leading-relaxed max-h-[160px] overflow-y-auto whitespace-pre-wrap break-words">
98
+ {permissionPrompt.context.split('\n').slice(-12).join('\n')}
99
+ </pre>
100
+ )}
101
+ <div className="flex gap-2 px-3 pb-3">
102
+ <button
103
+ onClick={() => handlePermissionResponse('allow')}
104
+ className="flex-1 px-3 py-1.5 rounded-md text-[12px] font-medium bg-accent text-white hover:brightness-110 transition-all"
105
+ >
106
+ Allow
107
+ </button>
108
+ <button
109
+ onClick={() => handlePermissionResponse('always')}
110
+ className="flex-1 px-3 py-1.5 rounded-md text-[12px] font-medium bg-accent/20 text-accent hover:bg-accent/30 border border-accent/30 transition-all"
111
+ >
112
+ Always
113
+ </button>
114
+ <button
115
+ onClick={() => handlePermissionResponse('deny')}
116
+ className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30 transition-all"
117
+ >
118
+ Deny
119
+ </button>
120
+ </div>
121
+ </div>
122
+ )}
123
+
124
+ <Toaster
125
+ position="top-center"
126
+ style={{ top: '56px' }}
127
+ toastOptions={{
128
+ style: {
129
+ fontSize: '12px',
130
+ padding: '8px 14px',
131
+ background: 'oklch(0.25 0.065 300)',
132
+ color: 'oklch(0.93 0.015 300)',
133
+ border: '1px solid oklch(1 0.03 300 / 10%)',
134
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
135
+ },
136
+ }}
137
+ />
138
+ {(chat.messages.length > 0 || chat.isStreaming) && (
139
+ <ChatInput
140
+ isStreaming={chat.isStreaming}
141
+ disabled={!chat.connected}
142
+ onSend={chat.sendMessage}
143
+ onCancel={chat.cancelStream}
144
+ />
145
+ )}
146
+ </div>
147
+ </div>
148
+ </div>
149
+ )
150
+ }
@@ -0,0 +1,26 @@
1
+ import { useMemo } from 'react'
2
+ import { cn } from '@/lib/utils'
3
+ import { renderMarkdown } from '@/lib/markdown'
4
+
5
+ interface MarkdownRendererProps {
6
+ text: string
7
+ isStreaming?: boolean
8
+ }
9
+
10
+ export function MarkdownRenderer({ text, isStreaming }: MarkdownRendererProps) {
11
+ const html = useMemo(() => {
12
+ if (!text) return ''
13
+ return renderMarkdown(text)
14
+ }, [text])
15
+
16
+ return (
17
+ <div
18
+ className={cn(
19
+ 'msg-content rendered leading-[1.65] break-words text-[13px]',
20
+ isStreaming && 'streaming',
21
+ isStreaming && !text && 'empty-streaming'
22
+ )}
23
+ dangerouslySetInnerHTML={text ? { __html: html } : undefined}
24
+ />
25
+ )
26
+ }