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,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
+ }