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,79 @@
|
|
|
1
|
+
import { Paperclip, FileText } from 'lucide-react'
|
|
2
|
+
import { MarkdownRenderer } from './markdown-renderer'
|
|
3
|
+
|
|
4
|
+
interface MessageBubbleProps {
|
|
5
|
+
role: 'user' | 'assistant' | 'system'
|
|
6
|
+
text: string
|
|
7
|
+
files?: string[]
|
|
8
|
+
isStreaming?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'])
|
|
12
|
+
|
|
13
|
+
function isImage(filePath: string): boolean {
|
|
14
|
+
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
|
|
15
|
+
return IMAGE_EXTS.has(ext)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getFileName(filePath: string) {
|
|
19
|
+
return filePath.split('/').pop() || filePath
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function FileAttachment({ file }: { file: string }) {
|
|
23
|
+
if (isImage(file)) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="rounded-lg overflow-hidden border border-accent/10 max-w-[200px]">
|
|
26
|
+
<img
|
|
27
|
+
src={`file://${file}`}
|
|
28
|
+
alt={getFileName(file)}
|
|
29
|
+
className="max-w-full max-h-[160px] object-contain bg-black/20"
|
|
30
|
+
draggable={false}
|
|
31
|
+
/>
|
|
32
|
+
<div className="flex items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground truncate">
|
|
33
|
+
<Paperclip className="h-2.5 w-2.5 shrink-0 opacity-50" />
|
|
34
|
+
{getFileName(file)}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span className="inline-flex items-center gap-1 bg-accent/10 rounded-md px-2 py-0.5 text-[11px] text-accent">
|
|
42
|
+
<FileText className="h-2.5 w-2.5" />
|
|
43
|
+
{getFileName(file)}
|
|
44
|
+
</span>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function MessageBubble({ role, text, files, isStreaming }: MessageBubbleProps) {
|
|
49
|
+
if (role === 'system') {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex justify-center">
|
|
52
|
+
<span className="text-[11px] text-muted/50">{text}</span>
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (role === 'user') {
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex justify-end">
|
|
60
|
+
<div className="leading-[1.7] whitespace-pre-wrap break-words text-[13px] bg-user-bg border border-accent/15 rounded-2xl rounded-br-md px-4 py-3 max-w-[85%]">
|
|
61
|
+
{files && files.length > 0 && (
|
|
62
|
+
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
63
|
+
{files.map((file, i) => (
|
|
64
|
+
<FileAttachment key={i} file={file} />
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
{text}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col">
|
|
76
|
+
<MarkdownRenderer text={text} isStreaming={isStreaming} />
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'
|
|
2
|
+
import { Zap, Loader2 } from 'lucide-react'
|
|
3
|
+
import { MessageBubble } from './message-bubble'
|
|
4
|
+
import { ActivityBlock } from './activity-block'
|
|
5
|
+
import { useIPCOn } from '@/hooks/use-ipc'
|
|
6
|
+
import type { ChatMessage, ContentBlock } from './types'
|
|
7
|
+
|
|
8
|
+
interface ToolActivity {
|
|
9
|
+
tool: string
|
|
10
|
+
status: 'running' | 'done'
|
|
11
|
+
preview?: string
|
|
12
|
+
ts: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface MessageListProps {
|
|
16
|
+
messages: ChatMessage[]
|
|
17
|
+
isStreaming: boolean
|
|
18
|
+
streamingDisplayText: string
|
|
19
|
+
streamingActivities?: ContentBlock[]
|
|
20
|
+
port?: number
|
|
21
|
+
folderPath?: string
|
|
22
|
+
connected?: boolean
|
|
23
|
+
visible?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MessageList({ messages, isStreaming, streamingDisplayText, streamingActivities, port, folderPath, connected = false, visible }: MessageListProps) {
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
29
|
+
const shouldAutoScrollRef = useRef(true)
|
|
30
|
+
const [toolActivities, setToolActivities] = useState<ToolActivity[]>([])
|
|
31
|
+
|
|
32
|
+
useIPCOn('rune:toolActivity', (data: { port: number; type: 'tool_start' | 'tool_end'; tool: string; preview?: string }) => {
|
|
33
|
+
if (data.port !== port) return
|
|
34
|
+
if (data.type === 'tool_start') {
|
|
35
|
+
setToolActivities(prev => [...prev.slice(-4), { tool: data.tool, status: 'running', ts: Date.now() }])
|
|
36
|
+
} else {
|
|
37
|
+
setToolActivities(prev => {
|
|
38
|
+
const idx = prev.findIndex(a => a.tool === data.tool && a.status === 'running')
|
|
39
|
+
if (idx === -1) return prev
|
|
40
|
+
const updated = [...prev]
|
|
41
|
+
updated[idx] = { ...updated[idx], status: 'done', preview: data.preview }
|
|
42
|
+
return updated
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Clear old done activities
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (toolActivities.length === 0) return
|
|
50
|
+
const timer = setInterval(() => {
|
|
51
|
+
const cutoff = Date.now() - 5000
|
|
52
|
+
setToolActivities(prev => prev.filter(a => a.status === 'running' || a.ts > cutoff))
|
|
53
|
+
}, 1000)
|
|
54
|
+
return () => clearInterval(timer)
|
|
55
|
+
}, [toolActivities.length])
|
|
56
|
+
|
|
57
|
+
const handleScroll = useCallback(() => {
|
|
58
|
+
const el = containerRef.current
|
|
59
|
+
if (!el) return
|
|
60
|
+
shouldAutoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!shouldAutoScrollRef.current) return
|
|
65
|
+
requestAnimationFrame(() => {
|
|
66
|
+
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
|
|
67
|
+
})
|
|
68
|
+
}, [messages.length, streamingDisplayText, streamingActivities?.length])
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
shouldAutoScrollRef.current = true
|
|
72
|
+
requestAnimationFrame(() => {
|
|
73
|
+
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
|
|
74
|
+
})
|
|
75
|
+
}, [messages.length])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (visible === false) return
|
|
79
|
+
shouldAutoScrollRef.current = true
|
|
80
|
+
requestAnimationFrame(() => {
|
|
81
|
+
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
|
|
82
|
+
})
|
|
83
|
+
}, [visible])
|
|
84
|
+
|
|
85
|
+
const loadingMessages = useMemo(() => [
|
|
86
|
+
'Preparing your assistant...',
|
|
87
|
+
'Waking up the agent...',
|
|
88
|
+
'Getting things ready...',
|
|
89
|
+
'Waiting for assistant response...',
|
|
90
|
+
'Setting up the workspace...',
|
|
91
|
+
'Almost there...',
|
|
92
|
+
'Connecting the dots...',
|
|
93
|
+
'Brewing some intelligence...',
|
|
94
|
+
], [])
|
|
95
|
+
|
|
96
|
+
const [loadingMsgIdx, setLoadingMsgIdx] = useState(() => Math.floor(Math.random() * 8))
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (messages.length > 0 || isStreaming) return
|
|
100
|
+
const timer = setInterval(() => {
|
|
101
|
+
setLoadingMsgIdx(prev => {
|
|
102
|
+
let next: number
|
|
103
|
+
do { next = Math.floor(Math.random() * loadingMessages.length) } while (next === prev)
|
|
104
|
+
return next
|
|
105
|
+
})
|
|
106
|
+
}, 3000)
|
|
107
|
+
return () => clearInterval(timer)
|
|
108
|
+
}, [messages.length, isStreaming, loadingMessages])
|
|
109
|
+
|
|
110
|
+
if (messages.length === 0 && !isStreaming) {
|
|
111
|
+
return (
|
|
112
|
+
<div ref={containerRef} className="flex-1 overflow-y-auto flex flex-col items-center justify-center gap-3 px-10">
|
|
113
|
+
<Loader2 className="h-6 w-6 text-muted animate-spin" />
|
|
114
|
+
<p className="text-[12px] text-muted transition-opacity duration-500">
|
|
115
|
+
{!connected ? 'Connecting to channel...' : loadingMessages[loadingMsgIdx]}
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div ref={containerRef} className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5 min-h-0" onScroll={handleScroll}>
|
|
123
|
+
{!connected && (
|
|
124
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-accent-red/8 border border-accent-red/20 rounded-lg text-[12px] text-accent-red shrink-0">
|
|
125
|
+
<Zap className="h-3.5 w-3.5 shrink-0" />
|
|
126
|
+
<span>Channel disconnected — waiting for Claude CLI on port :{port}</span>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
{messages.map((msg, i) => {
|
|
130
|
+
const isStreamingMsg = isStreaming && msg.role === 'assistant' && i === messages.length - 1
|
|
131
|
+
return (
|
|
132
|
+
<div key={i} className="flex flex-col gap-1.5">
|
|
133
|
+
{/* Show activity blocks for completed messages */}
|
|
134
|
+
{!isStreamingMsg && msg.blocks && msg.blocks.length > 0 && (
|
|
135
|
+
<div className="flex flex-col gap-1">
|
|
136
|
+
{msg.blocks.map((block, bi) => (
|
|
137
|
+
<ActivityBlock key={`${block.type}-${block.ts}-${bi}`} block={block} />
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
{/* Show streaming activity blocks */}
|
|
142
|
+
{isStreamingMsg && streamingActivities && streamingActivities.length > 0 && (
|
|
143
|
+
<div className="flex flex-col gap-1">
|
|
144
|
+
{streamingActivities.map((block, bi) => (
|
|
145
|
+
<ActivityBlock key={`${block.type}-${block.ts}-${bi}`} block={block} />
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
<MessageBubble
|
|
150
|
+
role={msg.role}
|
|
151
|
+
text={isStreamingMsg ? streamingDisplayText : msg.text}
|
|
152
|
+
files={msg.files}
|
|
153
|
+
isStreaming={isStreamingMsg}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
})}
|
|
158
|
+
{toolActivities.length > 0 && (
|
|
159
|
+
<div className="flex flex-col gap-1 px-1">
|
|
160
|
+
{toolActivities.map((activity, i) => (
|
|
161
|
+
<div key={`${activity.tool}-${activity.ts}-${i}`} className="flex items-center gap-2 text-[11px] text-muted animate-in fade-in duration-200">
|
|
162
|
+
{activity.status === 'running' ? (
|
|
163
|
+
<Loader2 className="h-3 w-3 text-accent animate-spin shrink-0" />
|
|
164
|
+
) : (
|
|
165
|
+
<div className="h-3 w-3 flex items-center justify-center shrink-0">
|
|
166
|
+
<div className="h-1.5 w-1.5 rounded-full bg-accent/50" />
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
<span className={activity.status === 'running' ? 'text-accent' : 'text-muted'}>{activity.tool}</span>
|
|
170
|
+
{activity.preview && <span className="text-muted truncate max-w-[280px]">— {activity.preview}</span>}
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
<div ref={bottomRef} />
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ContentBlock {
|
|
2
|
+
type: 'thinking' | 'tool_use' | 'tool_result'
|
|
3
|
+
content?: string
|
|
4
|
+
tool?: string
|
|
5
|
+
args?: Record<string, unknown>
|
|
6
|
+
ts: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ChatMessage {
|
|
10
|
+
role: 'user' | 'assistant' | 'system'
|
|
11
|
+
text: string
|
|
12
|
+
files?: string[]
|
|
13
|
+
blocks?: ContentBlock[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RuneInfo {
|
|
17
|
+
filePath: string
|
|
18
|
+
folderPath: string
|
|
19
|
+
port: number
|
|
20
|
+
name: string
|
|
21
|
+
role: string
|
|
22
|
+
icon?: string
|
|
23
|
+
history: { role: 'user' | 'assistant'; text: string; ts: number }[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChatState {
|
|
27
|
+
messages: ChatMessage[]
|
|
28
|
+
isStreaming: boolean
|
|
29
|
+
shownText: string
|
|
30
|
+
typeQueue: string
|
|
31
|
+
activityBlocks: ContentBlock[]
|
|
32
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
+
import { useIPCOn, useIPCSend } from '@/hooks/use-ipc'
|
|
3
|
+
import type { ChatState, ChatMessage, ContentBlock, RuneInfo } from './types'
|
|
4
|
+
|
|
5
|
+
export function useChat() {
|
|
6
|
+
const [runeInfo, setRuneInfo] = useState<RuneInfo | null>(null)
|
|
7
|
+
const [connected, setConnected] = useState(false)
|
|
8
|
+
const [state, setState] = useState<ChatState>({
|
|
9
|
+
messages: [],
|
|
10
|
+
isStreaming: false,
|
|
11
|
+
shownText: '',
|
|
12
|
+
typeQueue: '',
|
|
13
|
+
activityBlocks: [],
|
|
14
|
+
})
|
|
15
|
+
const typeTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
16
|
+
const send = useIPCSend()
|
|
17
|
+
|
|
18
|
+
// Track channel connection status
|
|
19
|
+
useIPCOn('rune:channelStatus', (data: { port: number; connected: boolean }) => {
|
|
20
|
+
if (runeInfo && data.port === runeInfo.port) setConnected(data.connected)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// Initialize from main process
|
|
24
|
+
useIPCOn('rune:init', (data: RuneInfo) => {
|
|
25
|
+
setRuneInfo(data)
|
|
26
|
+
// Restore history
|
|
27
|
+
if (data.history && data.history.length > 0) {
|
|
28
|
+
setState(prev => ({
|
|
29
|
+
...prev,
|
|
30
|
+
messages: data.history.map(h => ({ role: h.role, text: h.text })),
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Typewriter engine
|
|
36
|
+
const startTyping = useCallback(() => {
|
|
37
|
+
if (typeTimerRef.current) return
|
|
38
|
+
typeTimerRef.current = setInterval(() => {
|
|
39
|
+
setState(prev => {
|
|
40
|
+
if (!prev.typeQueue) {
|
|
41
|
+
if (typeTimerRef.current) {
|
|
42
|
+
clearInterval(typeTimerRef.current)
|
|
43
|
+
typeTimerRef.current = null
|
|
44
|
+
}
|
|
45
|
+
return prev
|
|
46
|
+
}
|
|
47
|
+
const batch = Math.min(prev.typeQueue.length, 4)
|
|
48
|
+
return {
|
|
49
|
+
...prev,
|
|
50
|
+
shownText: prev.shownText + prev.typeQueue.slice(0, batch),
|
|
51
|
+
typeQueue: prev.typeQueue.slice(batch),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}, 10)
|
|
55
|
+
}, [])
|
|
56
|
+
|
|
57
|
+
const appendChunk = useCallback((text: string) => {
|
|
58
|
+
setState(prev => ({ ...prev, typeQueue: prev.typeQueue + text }))
|
|
59
|
+
startTyping()
|
|
60
|
+
}, [startTyping])
|
|
61
|
+
|
|
62
|
+
const finishStream = useCallback(() => {
|
|
63
|
+
if (typeTimerRef.current) {
|
|
64
|
+
clearInterval(typeTimerRef.current)
|
|
65
|
+
typeTimerRef.current = null
|
|
66
|
+
}
|
|
67
|
+
setState(prev => {
|
|
68
|
+
const fullText = prev.shownText + prev.typeQueue
|
|
69
|
+
const messages = [...prev.messages]
|
|
70
|
+
if (messages.length > 0 && messages[messages.length - 1].role === 'assistant') {
|
|
71
|
+
messages[messages.length - 1] = {
|
|
72
|
+
role: 'assistant',
|
|
73
|
+
text: fullText,
|
|
74
|
+
blocks: prev.activityBlocks.length > 0 ? [...prev.activityBlocks] : undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ...prev, messages, isStreaming: false, shownText: '', typeQueue: '', activityBlocks: [] }
|
|
78
|
+
})
|
|
79
|
+
}, [])
|
|
80
|
+
|
|
81
|
+
// IPC event listeners
|
|
82
|
+
useIPCOn('rune:streamStart', () => {
|
|
83
|
+
setState(prev => ({
|
|
84
|
+
...prev,
|
|
85
|
+
isStreaming: true,
|
|
86
|
+
shownText: '',
|
|
87
|
+
typeQueue: '',
|
|
88
|
+
activityBlocks: [],
|
|
89
|
+
messages: [...prev.messages, { role: 'assistant', text: '' }],
|
|
90
|
+
}))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
useIPCOn('rune:activity', (data: { port: number; activityType: string; content?: string; tool?: string; args?: Record<string, unknown> }) => {
|
|
94
|
+
const block: ContentBlock = {
|
|
95
|
+
type: data.activityType as ContentBlock['type'],
|
|
96
|
+
content: data.content,
|
|
97
|
+
tool: data.tool,
|
|
98
|
+
args: data.args,
|
|
99
|
+
ts: Date.now(),
|
|
100
|
+
}
|
|
101
|
+
setState(prev => ({
|
|
102
|
+
...prev,
|
|
103
|
+
activityBlocks: [...prev.activityBlocks, block],
|
|
104
|
+
}))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
useIPCOn('rune:streamChunk', (data: { text: string }) => {
|
|
108
|
+
const clean = data.text
|
|
109
|
+
.replace(/[\x00-\x08\x0e-\x1f\x7f]/g, '')
|
|
110
|
+
.replace(/\^[A-Z@\[\\\]\^_]/g, '')
|
|
111
|
+
appendChunk(clean)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
useIPCOn('rune:streamEnd', () => {
|
|
115
|
+
finishStream()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
useIPCOn('rune:streamError', (data: { error: string }) => {
|
|
119
|
+
if (typeTimerRef.current) {
|
|
120
|
+
clearInterval(typeTimerRef.current)
|
|
121
|
+
typeTimerRef.current = null
|
|
122
|
+
}
|
|
123
|
+
setState(prev => {
|
|
124
|
+
const messages = [...prev.messages]
|
|
125
|
+
if (messages.length > 0 && messages[messages.length - 1].role === 'assistant' && !messages[messages.length - 1].text) {
|
|
126
|
+
messages[messages.length - 1] = { role: 'assistant', text: data.error }
|
|
127
|
+
} else {
|
|
128
|
+
messages.push({ role: 'assistant', text: data.error })
|
|
129
|
+
}
|
|
130
|
+
return { ...prev, messages, isStreaming: false, shownText: '', typeQueue: '' }
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
useIPCOn('rune:pushMessage', (data: { text: string }) => {
|
|
135
|
+
setState(prev => ({
|
|
136
|
+
...prev,
|
|
137
|
+
messages: [...prev.messages, { role: 'assistant', text: data.text }],
|
|
138
|
+
}))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Handle Claude Code hook events
|
|
142
|
+
useIPCOn('rune:hook', (data: { event: string; tool_name?: string; tool_input?: Record<string, unknown>; tool_output?: string; prompt?: string; permission_prompt_reason?: string; notification_type?: string; message?: string }) => {
|
|
143
|
+
let block: ContentBlock | null = null
|
|
144
|
+
|
|
145
|
+
if (data.event === 'PreToolUse' && data.tool_name) {
|
|
146
|
+
block = {
|
|
147
|
+
type: 'tool_use',
|
|
148
|
+
tool: data.tool_name,
|
|
149
|
+
args: data.tool_input,
|
|
150
|
+
ts: Date.now(),
|
|
151
|
+
}
|
|
152
|
+
} else if (data.event === 'PostToolUse' && data.tool_name) {
|
|
153
|
+
block = {
|
|
154
|
+
type: 'tool_result',
|
|
155
|
+
tool: data.tool_name,
|
|
156
|
+
content: data.tool_output ? (data.tool_output.length > 500 ? data.tool_output.slice(0, 500) + '…' : data.tool_output) : 'done',
|
|
157
|
+
ts: Date.now(),
|
|
158
|
+
}
|
|
159
|
+
} else if (data.event === 'PermissionRequest') {
|
|
160
|
+
block = {
|
|
161
|
+
type: 'thinking',
|
|
162
|
+
content: `⚠️ Permission needed: ${data.tool_name} — ${data.permission_prompt_reason || ''}`,
|
|
163
|
+
tool: data.tool_name,
|
|
164
|
+
ts: Date.now(),
|
|
165
|
+
}
|
|
166
|
+
} else if (data.event === 'Notification') {
|
|
167
|
+
block = {
|
|
168
|
+
type: 'thinking',
|
|
169
|
+
content: `📢 ${data.message || data.notification_type || 'notification'}`,
|
|
170
|
+
ts: Date.now(),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (block) {
|
|
175
|
+
setState(prev => ({
|
|
176
|
+
...prev,
|
|
177
|
+
activityBlocks: [...prev.activityBlocks, block!],
|
|
178
|
+
}))
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Handle file rename
|
|
183
|
+
useIPCOn('rune:fileRenamed', (data: { name: string; newPath: string }) => {
|
|
184
|
+
setRuneInfo(prev => prev ? { ...prev, name: data.name, filePath: data.newPath } : prev)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Actions
|
|
188
|
+
const sendMessage = useCallback((content: string, files?: string[]) => {
|
|
189
|
+
if (!runeInfo || (!content.trim() && (!files || files.length === 0))) return
|
|
190
|
+
|
|
191
|
+
// If streaming, cancel current response and finalize partial text
|
|
192
|
+
if (state.isStreaming) {
|
|
193
|
+
send('rune:cancelStream', { port: runeInfo.port })
|
|
194
|
+
finishStream()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Build the actual content sent to channel: prepend file paths
|
|
198
|
+
let channelContent = content
|
|
199
|
+
if (files && files.length > 0) {
|
|
200
|
+
const fileSection = files.map(f => `[Attached file: ${f}]`).join('\n')
|
|
201
|
+
channelContent = fileSection + (content ? '\n\n' + content : '')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setState(prev => ({
|
|
205
|
+
...prev,
|
|
206
|
+
messages: [...prev.messages, { role: 'user', text: content, files }],
|
|
207
|
+
}))
|
|
208
|
+
send('rune:sendMessage', { content: channelContent, port: runeInfo.port })
|
|
209
|
+
}, [runeInfo, state.isStreaming, send, finishStream])
|
|
210
|
+
|
|
211
|
+
const cancelStream = useCallback(() => {
|
|
212
|
+
send('rune:cancelStream', { port: runeInfo?.port })
|
|
213
|
+
}, [send, runeInfo?.port])
|
|
214
|
+
|
|
215
|
+
const clearHistory = useCallback(() => {
|
|
216
|
+
if (!runeInfo) return
|
|
217
|
+
setState(prev => ({ ...prev, messages: [], shownText: '', typeQueue: '' }))
|
|
218
|
+
send('rune:clearHistory', { port: runeInfo.port })
|
|
219
|
+
}, [runeInfo, send])
|
|
220
|
+
|
|
221
|
+
// Connect channel on mount
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (runeInfo) {
|
|
224
|
+
send('rune:connectChannel', { port: runeInfo.port })
|
|
225
|
+
}
|
|
226
|
+
}, [runeInfo?.port]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
227
|
+
|
|
228
|
+
// Cleanup
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
return () => {
|
|
231
|
+
if (typeTimerRef.current) clearInterval(typeTimerRef.current)
|
|
232
|
+
}
|
|
233
|
+
}, [])
|
|
234
|
+
|
|
235
|
+
const getDisplayText = useCallback((): string => {
|
|
236
|
+
if (!state.isStreaming || !state.shownText) return ''
|
|
237
|
+
return state.shownText
|
|
238
|
+
}, [state.isStreaming, state.shownText])
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
runeInfo,
|
|
242
|
+
connected,
|
|
243
|
+
messages: state.messages,
|
|
244
|
+
isStreaming: state.isStreaming,
|
|
245
|
+
streamingDisplayText: getDisplayText(),
|
|
246
|
+
streamingActivities: state.activityBlocks,
|
|
247
|
+
sendMessage,
|
|
248
|
+
cancelStream,
|
|
249
|
+
clearHistory,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { Terminal } from '@xterm/xterm'
|
|
3
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
4
|
+
import '@xterm/xterm/css/xterm.css'
|
|
5
|
+
|
|
6
|
+
interface TerminalPanelProps {
|
|
7
|
+
cwd?: string
|
|
8
|
+
autoCommand?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const THEME = {
|
|
12
|
+
background: '#18222d',
|
|
13
|
+
foreground: '#d4d8de',
|
|
14
|
+
cursor: '#d4d8de',
|
|
15
|
+
cursorAccent: '#18222d',
|
|
16
|
+
selectionBackground: '#2B5278',
|
|
17
|
+
black: '#1a2634',
|
|
18
|
+
red: '#ef5350',
|
|
19
|
+
green: '#26a69a',
|
|
20
|
+
yellow: '#ffb74d',
|
|
21
|
+
blue: '#42a5f5',
|
|
22
|
+
magenta: '#ab47bc',
|
|
23
|
+
cyan: '#26c6da',
|
|
24
|
+
white: '#d4d8de',
|
|
25
|
+
brightBlack: '#546e7a',
|
|
26
|
+
brightRed: '#ef5350',
|
|
27
|
+
brightGreen: '#26a69a',
|
|
28
|
+
brightYellow: '#ffb74d',
|
|
29
|
+
brightBlue: '#64b5f6',
|
|
30
|
+
brightMagenta: '#ab47bc',
|
|
31
|
+
brightCyan: '#26c6da',
|
|
32
|
+
brightWhite: '#ffffff',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function TerminalPanel({ cwd, autoCommand }: TerminalPanelProps) {
|
|
36
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
37
|
+
const termRef = useRef<Terminal | null>(null)
|
|
38
|
+
const fitRef = useRef<FitAddon | null>(null)
|
|
39
|
+
const ptyIdRef = useRef<string | null>(null)
|
|
40
|
+
const fittedRef = useRef(false)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!containerRef.current) return
|
|
44
|
+
|
|
45
|
+
const term = new Terminal({
|
|
46
|
+
theme: THEME,
|
|
47
|
+
fontSize: 13,
|
|
48
|
+
fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
|
|
49
|
+
cursorBlink: true,
|
|
50
|
+
allowProposedApi: true,
|
|
51
|
+
scrollback: 5000,
|
|
52
|
+
})
|
|
53
|
+
const fit = new FitAddon()
|
|
54
|
+
term.loadAddon(fit)
|
|
55
|
+
term.open(containerRef.current)
|
|
56
|
+
|
|
57
|
+
termRef.current = term
|
|
58
|
+
fitRef.current = fit
|
|
59
|
+
|
|
60
|
+
const doFit = () => {
|
|
61
|
+
const el = containerRef.current
|
|
62
|
+
if (!el || el.clientWidth === 0 || el.clientHeight === 0) return
|
|
63
|
+
fit.fit()
|
|
64
|
+
if (!fittedRef.current) {
|
|
65
|
+
fittedRef.current = true
|
|
66
|
+
term.focus()
|
|
67
|
+
}
|
|
68
|
+
if (ptyIdRef.current) {
|
|
69
|
+
window.rune.send('terminal:resize', {
|
|
70
|
+
id: ptyIdRef.current,
|
|
71
|
+
cols: term.cols,
|
|
72
|
+
rows: term.rows,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ResizeObserver fires on initial observe + any resize
|
|
78
|
+
const ro = new ResizeObserver(() => requestAnimationFrame(doFit))
|
|
79
|
+
ro.observe(containerRef.current)
|
|
80
|
+
|
|
81
|
+
window.rune.invoke('terminal:spawn', { cwd: cwd || undefined }).then(({ id }: { id: string }) => {
|
|
82
|
+
ptyIdRef.current = id
|
|
83
|
+
// Sync size now that pty is ready
|
|
84
|
+
window.rune.send('terminal:resize', { id, cols: term.cols, rows: term.rows })
|
|
85
|
+
|
|
86
|
+
const onOutput = (msg: { id: string; data: string }) => {
|
|
87
|
+
if (msg.id !== id) return
|
|
88
|
+
term.write(msg.data)
|
|
89
|
+
}
|
|
90
|
+
window.rune.on('terminal:output', onOutput)
|
|
91
|
+
|
|
92
|
+
const onExit = (msg: { id: string; exitCode: number }) => {
|
|
93
|
+
if (msg.id === id) {
|
|
94
|
+
term.write(`\r\n\x1b[90m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`)
|
|
95
|
+
ptyIdRef.current = null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
window.rune.on('terminal:exit', onExit)
|
|
99
|
+
|
|
100
|
+
term.onData((data: string) => {
|
|
101
|
+
if (ptyIdRef.current) {
|
|
102
|
+
window.rune.send('terminal:input', { id: ptyIdRef.current, data })
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if (autoCommand) {
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
window.rune.send('terminal:input', { id, data: autoCommand + '\n' })
|
|
109
|
+
}, 500)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
;(term as any).__cleanupOutput = onOutput
|
|
113
|
+
;(term as any).__cleanupExit = onExit
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
ro.disconnect()
|
|
118
|
+
if ((term as any).__cleanupOutput) window.rune.off('terminal:output', (term as any).__cleanupOutput)
|
|
119
|
+
if ((term as any).__cleanupExit) window.rune.off('terminal:exit', (term as any).__cleanupExit)
|
|
120
|
+
if (ptyIdRef.current) window.rune.send('terminal:kill', { id: ptyIdRef.current })
|
|
121
|
+
term.dispose()
|
|
122
|
+
}
|
|
123
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
ref={containerRef}
|
|
128
|
+
className="h-full w-full"
|
|
129
|
+
style={{ backgroundColor: '#18222d', padding: '4px 0 0 4px' }}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type RuneSendChannel =
|
|
2
|
+
| 'rune:sendMessage' | 'rune:cancelStream' | 'rune:connectChannel' | 'rune:clearHistory'
|
|
3
|
+
| 'rune:permissionRespond'
|
|
4
|
+
| 'terminal:input' | 'terminal:resize' | 'terminal:kill'
|
|
5
|
+
|
|
6
|
+
type RuneOnChannel =
|
|
7
|
+
| 'rune:init'
|
|
8
|
+
| 'rune:streamStart' | 'rune:streamChunk' | 'rune:streamStatus'
|
|
9
|
+
| 'rune:streamEnd' | 'rune:streamError'
|
|
10
|
+
| 'rune:pushMessage' | 'rune:channelStatus'
|
|
11
|
+
| 'rune:toolActivity'
|
|
12
|
+
| 'rune:activity'
|
|
13
|
+
| 'rune:permissionNeeded'
|
|
14
|
+
| 'terminal:output' | 'terminal:exit'
|
|
15
|
+
|
|
16
|
+
type RuneInvokeChannel =
|
|
17
|
+
| 'terminal:spawn'
|
|
18
|
+
| 'rune:createFile'
|
|
19
|
+
|
|
20
|
+
interface RuneAPI {
|
|
21
|
+
send(channel: RuneSendChannel, data?: unknown): void
|
|
22
|
+
on(channel: RuneOnChannel, callback: (data: any) => void): void
|
|
23
|
+
off(channel: RuneOnChannel, callback: (data: any) => void): void
|
|
24
|
+
invoke(channel: RuneInvokeChannel, data?: unknown): Promise<any>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Window {
|
|
28
|
+
rune: RuneAPI
|
|
29
|
+
}
|