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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/bin/rune.js +718 -0
- package/bootstrap.js +4 -0
- package/channel/rune-channel.ts +467 -0
- package/electron-builder.yml +61 -0
- package/finder-extension/FinderSync.swift +47 -0
- package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +27 -0
- package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
- package/finder-extension/main.swift +5 -0
- package/package.json +53 -0
- package/renderer/index.html +12 -0
- package/renderer/src/App.tsx +43 -0
- package/renderer/src/features/chat/activity-block.tsx +152 -0
- package/renderer/src/features/chat/chat-header.tsx +58 -0
- package/renderer/src/features/chat/chat-input.tsx +190 -0
- package/renderer/src/features/chat/chat-panel.tsx +150 -0
- package/renderer/src/features/chat/markdown-renderer.tsx +26 -0
- package/renderer/src/features/chat/message-bubble.tsx +79 -0
- package/renderer/src/features/chat/message-list.tsx +178 -0
- package/renderer/src/features/chat/types.ts +32 -0
- package/renderer/src/features/chat/use-chat.ts +251 -0
- package/renderer/src/features/terminal/terminal-panel.tsx +132 -0
- package/renderer/src/global.d.ts +29 -0
- package/renderer/src/globals.css +92 -0
- package/renderer/src/hooks/use-ipc.ts +24 -0
- package/renderer/src/lib/markdown.ts +83 -0
- package/renderer/src/lib/utils.ts +6 -0
- package/renderer/src/main.tsx +10 -0
- package/renderer/tsconfig.json +16 -0
- package/renderer/vite.config.ts +23 -0
- package/src/main.ts +782 -0
- package/src/preload.ts +58 -0
- 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
|
+
}
|