clawport-ui 0.6.2 → 0.6.4

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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/clawport-ui.svg)](https://www.npmjs.com/package/clawport-ui)
10
10
  [![license](https://img.shields.io/npm/l/clawport-ui.svg)](LICENSE)
11
- [![tests](https://img.shields.io/badge/tests-501%20passed-brightgreen)](#testing)
11
+ [![tests](https://img.shields.io/badge/tests-536%20passed-brightgreen)](#testing)
12
12
 
13
13
  [Website](https://clawport.dev) | [Setup Guide](SETUP.md) | [API Docs](docs/API.md) | [npm](https://www.npmjs.com/package/clawport-ui)
14
14
 
package/bin/clawport.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { fileURLToPath } from 'node:url'
4
- import { dirname, resolve } from 'node:path'
4
+ import { dirname, resolve, join } from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { existsSync, readFileSync, accessSync, constants } from 'node:fs'
7
+ import { homedir } from 'node:os'
7
8
  import { execSync } from 'node:child_process'
8
9
  import { createServer } from 'node:net'
9
10
 
@@ -26,6 +27,40 @@ if (major < 22) {
26
27
  const __filename = fileURLToPath(import.meta.url)
27
28
  const PKG_ROOT = resolve(dirname(__filename), '..')
28
29
 
30
+ const USER_CONFIG_DIR = join(homedir(), '.config', 'clawport-ui')
31
+ const ENV_LOCAL_FILENAME = '.env.local'
32
+
33
+ /** Path to .env.local: package root (preferred) or user config dir (for global installs). */
34
+ function getEnvLocalPath() {
35
+ const pkgEnv = resolve(PKG_ROOT, ENV_LOCAL_FILENAME)
36
+ if (existsSync(pkgEnv)) return pkgEnv
37
+ const userEnv = resolve(USER_CONFIG_DIR, ENV_LOCAL_FILENAME)
38
+ if (existsSync(userEnv)) return userEnv
39
+ return null
40
+ }
41
+
42
+ /** Load .env.local into process.env so Next.js and status/doctor see the vars. */
43
+ function loadEnvLocal() {
44
+ const path = getEnvLocalPath()
45
+ if (!path) return
46
+ try {
47
+ const content = readFileSync(path, 'utf-8')
48
+ for (const line of content.split('\n')) {
49
+ const trimmed = line.trim()
50
+ if (trimmed && !trimmed.startsWith('#')) {
51
+ const eq = trimmed.indexOf('=')
52
+ if (eq > 0) {
53
+ const key = trimmed.slice(0, eq).trim()
54
+ const value = trimmed.slice(eq + 1).trim()
55
+ if (key) process.env[key] = value
56
+ }
57
+ }
58
+ }
59
+ } catch (_) {}
60
+ }
61
+
62
+ loadEnvLocal()
63
+
29
64
  // ---------------------------------------------------------------------------
30
65
  // Helpers
31
66
  // ---------------------------------------------------------------------------
@@ -153,10 +188,10 @@ async function cmdStatus() {
153
188
  console.log(` ${dim('Start it with: openclaw gateway run')}`)
154
189
  }
155
190
 
156
- // Check .env.local
157
- const envPath = resolve(PKG_ROOT, '.env.local')
191
+ // Check .env.local (package root or ~/.config/clawport-ui)
192
+ const envPath = getEnvLocalPath()
158
193
  console.log()
159
- if (existsSync(envPath)) {
194
+ if (envPath && existsSync(envPath)) {
160
195
  console.log(` ${green('+')} .env.local found`)
161
196
  const content = readFileSync(envPath, 'utf-8')
162
197
  const lines = content.split('\n').filter((l) => l && !l.startsWith('#'))
@@ -175,6 +210,9 @@ async function cmdStatus() {
175
210
  }
176
211
 
177
212
  console.log()
213
+ if (envPath) {
214
+ console.log(` ${dim(`Config: ${envPath}`)}`)
215
+ }
178
216
  console.log(` ${dim(`Package root: ${PKG_ROOT}`)}`)
179
217
  console.log()
180
218
  }
@@ -212,12 +250,12 @@ async function cmdDoctor() {
212
250
  const gatewayUp = await checkGateway()
213
251
  check(gatewayUp, 'Gateway reachable at localhost:18789', 'Start it with: openclaw gateway run')
214
252
 
215
- // 5. Configuration -- .env.local with required vars
216
- const envPath = resolve(PKG_ROOT, '.env.local')
253
+ // 5. Configuration -- .env.local with required vars (package root or ~/.config/clawport-ui)
254
+ const envPath = getEnvLocalPath()
217
255
  const requiredVars = ['WORKSPACE_PATH', 'OPENCLAW_BIN', 'OPENCLAW_GATEWAY_TOKEN']
218
256
  let envOk = false
219
257
  let envFix = 'Run: clawport setup'
220
- if (existsSync(envPath)) {
258
+ if (envPath && existsSync(envPath)) {
221
259
  const content = readFileSync(envPath, 'utf-8')
222
260
  const missing = requiredVars.filter((v) => !content.includes(`${v}=`))
223
261
  if (missing.length === 0) {
@@ -7,6 +7,8 @@ import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
7
7
  import { buildApiContent } from '@/lib/multimodal'
8
8
  import { generateId } from '@/lib/id'
9
9
  import { useSettings } from '@/app/settings-provider'
10
+ import { isSlashInput, matchCommands, parseSlashCommand, executeCommand } from '@/lib/slash-commands'
11
+ import type { SlashCommand } from '@/lib/slash-commands'
10
12
  import { FileAttachment } from './FileAttachment'
11
13
  import { MediaPreview } from './MediaPreview'
12
14
  import { AgentAvatar } from '@/components/AgentAvatar'
@@ -179,14 +181,22 @@ function formatTimestamp(ts: number): string {
179
181
  }
180
182
 
181
183
  function shouldShowTimestamp(messages: Message[], index: number): boolean {
182
- if (index === 0) return true
183
- const gap = messages[index].timestamp - messages[index - 1].timestamp
184
+ if (messages[index].role === 'system') return false
185
+ // Find previous non-system message for gap comparison
186
+ let prev = index - 1
187
+ while (prev >= 0 && messages[prev].role === 'system') prev--
188
+ if (prev < 0) return true
189
+ const gap = messages[index].timestamp - messages[prev].timestamp
184
190
  return gap > 5 * 60 * 1000 // 5 minutes
185
191
  }
186
192
 
187
193
  function shouldShowAvatar(messages: Message[], index: number): boolean {
188
- if (index === 0) return true
189
- return messages[index - 1].role !== messages[index].role
194
+ if (messages[index].role === 'system') return false
195
+ // Find previous non-system message for role comparison
196
+ let prev = index - 1
197
+ while (prev >= 0 && messages[prev].role === 'system') prev--
198
+ if (prev < 0) return true
199
+ return messages[prev].role !== messages[index].role
190
200
  }
191
201
 
192
202
  /* ── Helper: convert File to base64 MediaAttachment ────── */
@@ -293,6 +303,9 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
293
303
  const [isStreaming, setIsStreaming] = useState(false)
294
304
  const [pendingAttachments, setPendingAttachments] = useState<MediaAttachment[]>([])
295
305
  const [isDragOver, setIsDragOver] = useState(false)
306
+ const [slashMatches, setSlashMatches] = useState<SlashCommand[]>([])
307
+ const [slashIndex, setSlashIndex] = useState(0)
308
+ const slashMenuOpen = slashMatches.length > 0
296
309
  const bottomRef = useRef<HTMLDivElement>(null)
297
310
  const textareaRef = useRef<HTMLTextAreaElement>(null)
298
311
  const fileInputRef = useRef<HTMLInputElement>(null)
@@ -355,10 +368,12 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
355
368
  setIsStreaming(true)
356
369
 
357
370
  // Use ref to read latest messages (avoids stale closure)
358
- const apiMessages = [...messagesRef.current, userMsg].map(m => ({
359
- role: m.role,
360
- content: buildApiContent(m),
361
- }))
371
+ const apiMessages = [...messagesRef.current, userMsg]
372
+ .filter(m => m.role !== 'system')
373
+ .map(m => ({
374
+ role: m.role,
375
+ content: buildApiContent(m),
376
+ }))
362
377
 
363
378
  try {
364
379
  const res = await fetch(`/api/chat/${agent.id}`, {
@@ -404,7 +419,52 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
404
419
  }
405
420
  }, [input, pendingAttachments, isStreaming, agent.id, onUpdate])
406
421
 
422
+ function runSlashCommand(command: string) {
423
+ const result = executeCommand(command, agent)
424
+ if (result.action === 'clear') {
425
+ clearChat()
426
+ } else {
427
+ const sysMsg: Message = {
428
+ id: generateId(),
429
+ role: 'system',
430
+ content: result.content,
431
+ timestamp: Date.now(),
432
+ }
433
+ onUpdate(agent.id, prev => addMessage(prev, agent.id, sysMsg))
434
+ }
435
+ setInput('')
436
+ setSlashMatches([])
437
+ if (textareaRef.current) textareaRef.current.style.height = 'auto'
438
+ }
439
+
440
+ function handleSlashSelect(cmd: SlashCommand) {
441
+ runSlashCommand(cmd.name)
442
+ }
443
+
407
444
  function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
445
+ if (slashMenuOpen) {
446
+ if (e.key === 'ArrowDown') {
447
+ e.preventDefault()
448
+ setSlashIndex(i => (i + 1) % slashMatches.length)
449
+ return
450
+ }
451
+ if (e.key === 'ArrowUp') {
452
+ e.preventDefault()
453
+ setSlashIndex(i => (i - 1 + slashMatches.length) % slashMatches.length)
454
+ return
455
+ }
456
+ if (e.key === 'Enter' || e.key === 'Tab') {
457
+ e.preventDefault()
458
+ handleSlashSelect(slashMatches[slashIndex])
459
+ return
460
+ }
461
+ if (e.key === 'Escape') {
462
+ e.preventDefault()
463
+ setSlashMatches([])
464
+ return
465
+ }
466
+ }
467
+
408
468
  if (e.key === 'Escape') {
409
469
  e.preventDefault()
410
470
  textareaRef.current?.blur()
@@ -412,6 +472,11 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
412
472
  }
413
473
  if (e.key === 'Enter' && !e.shiftKey) {
414
474
  e.preventDefault()
475
+ const parsed = parseSlashCommand(input)
476
+ if (parsed) {
477
+ runSlashCommand(parsed.command)
478
+ return
479
+ }
415
480
  sendMessage()
416
481
  }
417
482
  }
@@ -747,13 +812,15 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
747
812
  const isUser = msg.role === 'user'
748
813
  const showAvatar = shouldShowAvatar(messages, i)
749
814
  const showTimestamp = shouldShowTimestamp(messages, i)
750
- const isLastAssistant = !isUser && i === messages.length - 1 && (isStreaming || msg.isStreaming)
815
+ // System messages render their own block skip user/assistant layout logic
816
+ const isSystem = msg.role === 'system'
817
+ const isLastAssistant = msg.role === 'assistant' && i === messages.length - 1 && (isStreaming || msg.isStreaming)
751
818
  const showTypingDots = isLastAssistant && !msg.content
752
- const media = msg.media || parseMedia(msg.content)
819
+ const media = isSystem ? [] : (msg.media || parseMedia(msg.content))
753
820
 
754
821
  // Strip media URLs from text for display
755
822
  let textContent = msg.content
756
- if (media.length > 0 && !msg.media) {
823
+ if (!isSystem && media.length > 0 && !msg.media) {
757
824
  media.forEach(m => {
758
825
  textContent = textContent.replace(m.url, '')
759
826
  textContent = textContent.replace(/!\[[^\]]*\]\([^\)]+\)/g, '')
@@ -761,7 +828,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
761
828
  textContent = textContent.trim()
762
829
  }
763
830
  // Hide auto-generated content labels for media-only messages
764
- if (msg.media && msg.media.length > 0) {
831
+ if (!isSystem && msg.media && msg.media.length > 0) {
765
832
  const isAutoLabel = textContent.startsWith('[') && textContent.endsWith(']')
766
833
  if (isAutoLabel) textContent = ''
767
834
  }
@@ -780,10 +847,13 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
780
847
  </div>
781
848
  )}
782
849
 
783
- {/* Spacing between role switches */}
784
- {!showTimestamp && i > 0 && (
785
- <div style={{ height: messages[i - 1].role !== msg.role ? 'var(--space-4)' : 'var(--space-1)' }} />
786
- )}
850
+ {/* Spacing between role switches (skip for system messages) */}
851
+ {!showTimestamp && i > 0 && msg.role !== 'system' && (() => {
852
+ let prev = i - 1
853
+ while (prev >= 0 && messages[prev].role === 'system') prev--
854
+ const prevRole = prev >= 0 ? messages[prev].role : msg.role
855
+ return <div style={{ height: prevRole !== msg.role ? 'var(--space-4)' : 'var(--space-1)' }} />
856
+ })()}
787
857
 
788
858
  {/* User message */}
789
859
  {isUser && (
@@ -817,8 +887,30 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
817
887
  </div>
818
888
  )}
819
889
 
890
+ {/* System message (slash command result) */}
891
+ {msg.role === 'system' && (
892
+ <div style={{
893
+ padding: '0 var(--space-4)',
894
+ marginBottom: 'var(--space-1)',
895
+ }}>
896
+ <div style={{
897
+ maxWidth: '85%',
898
+ margin: '0 auto',
899
+ padding: 'var(--space-3) var(--space-4)',
900
+ borderRadius: 'var(--radius-md)',
901
+ background: 'var(--fill-tertiary)',
902
+ borderLeft: '3px solid var(--accent)',
903
+ color: 'var(--text-secondary)',
904
+ fontSize: 'var(--text-footnote)',
905
+ lineHeight: 'var(--leading-relaxed)',
906
+ }}>
907
+ {formatMessage(msg.content)}
908
+ </div>
909
+ </div>
910
+ )}
911
+
820
912
  {/* Assistant message */}
821
- {!isUser && (
913
+ {msg.role === 'assistant' && (
822
914
  <div style={{
823
915
  display: 'flex',
824
916
  justifyContent: 'flex-start',
@@ -898,6 +990,64 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
898
990
  background: 'var(--material-regular)',
899
991
  flexShrink: 0,
900
992
  }}>
993
+ {/* Slash command autocomplete dropdown */}
994
+ {slashMenuOpen && (
995
+ <div
996
+ className="animate-slide-down"
997
+ style={{
998
+ marginBottom: 'var(--space-2)',
999
+ background: 'var(--material-thick)',
1000
+ border: '1px solid var(--separator)',
1001
+ borderRadius: 'var(--radius-md)',
1002
+ boxShadow: 'var(--shadow-overlay)',
1003
+ backdropFilter: 'blur(20px)',
1004
+ WebkitBackdropFilter: 'blur(20px)',
1005
+ overflow: 'hidden',
1006
+ }}
1007
+ >
1008
+ {slashMatches.map((cmd, i) => (
1009
+ <button
1010
+ key={cmd.name}
1011
+ onMouseDown={e => {
1012
+ e.preventDefault() // prevent textarea blur
1013
+ handleSlashSelect(cmd)
1014
+ }}
1015
+ onMouseEnter={() => setSlashIndex(i)}
1016
+ style={{
1017
+ display: 'flex',
1018
+ alignItems: 'center',
1019
+ gap: 'var(--space-3)',
1020
+ width: '100%',
1021
+ padding: 'var(--space-2) var(--space-3)',
1022
+ background: i === slashIndex ? 'var(--fill-secondary)' : 'transparent',
1023
+ border: 'none',
1024
+ cursor: 'pointer',
1025
+ textAlign: 'left',
1026
+ color: 'var(--text-primary)',
1027
+ fontSize: 'var(--text-subheadline)',
1028
+ transition: 'background 100ms',
1029
+ }}
1030
+ >
1031
+ <span style={{
1032
+ color: 'var(--accent)',
1033
+ fontWeight: 'var(--weight-semibold)',
1034
+ fontFamily: '"SF Mono", Menlo, monospace',
1035
+ fontSize: 'var(--text-footnote)',
1036
+ minWidth: 60,
1037
+ }}>
1038
+ {cmd.name}
1039
+ </span>
1040
+ <span style={{
1041
+ color: 'var(--text-tertiary)',
1042
+ fontSize: 'var(--text-caption1)',
1043
+ }}>
1044
+ {cmd.description}
1045
+ </span>
1046
+ </button>
1047
+ ))}
1048
+ </div>
1049
+ )}
1050
+
901
1051
  {/* Pending attachments preview */}
902
1052
  {pendingAttachments.length > 0 && (
903
1053
  <div style={{ marginBottom: 'var(--space-2)' }}>
@@ -948,7 +1098,17 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
948
1098
  <textarea
949
1099
  ref={textareaRef}
950
1100
  value={input}
951
- onChange={e => setInput(e.target.value)}
1101
+ onChange={e => {
1102
+ const val = e.target.value
1103
+ setInput(val)
1104
+ if (isSlashInput(val) && !val.includes(' ')) {
1105
+ const matches = matchCommands(val)
1106
+ setSlashMatches(matches)
1107
+ setSlashIndex(0)
1108
+ } else {
1109
+ setSlashMatches([])
1110
+ }
1111
+ }}
952
1112
  onKeyDown={handleKeyDown}
953
1113
  onPaste={handlePaste}
954
1114
  placeholder={`Message ${agent.name}...`}
@@ -1012,7 +1172,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
1012
1172
  textAlign: 'center',
1013
1173
  marginTop: 'var(--space-1)',
1014
1174
  }}>
1015
- Enter to send &middot; Shift+Enter for newline
1175
+ Enter to send &middot; Shift+Enter for newline &middot; / for commands
1016
1176
  </div>
1017
1177
  </div>
1018
1178
  </div>
@@ -17,7 +17,7 @@ export interface MediaAttachment {
17
17
 
18
18
  export interface Message {
19
19
  id: string
20
- role: 'user' | 'assistant'
20
+ role: 'user' | 'assistant' | 'system'
21
21
  content: string
22
22
  timestamp: number
23
23
  media?: MediaAttachment[]
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isSlashInput,
4
+ parseSlashCommand,
5
+ matchCommands,
6
+ executeCommand,
7
+ COMMANDS,
8
+ } from './slash-commands'
9
+ import type { Agent } from './types'
10
+
11
+ function makeAgent(overrides: Partial<Agent> = {}): Agent {
12
+ return {
13
+ id: 'test-agent',
14
+ name: 'TestBot',
15
+ title: 'Test Agent',
16
+ reportsTo: null,
17
+ directReports: [],
18
+ soulPath: null,
19
+ soul: null,
20
+ voiceId: null,
21
+ color: '#ff0000',
22
+ emoji: '🤖',
23
+ tools: [],
24
+ crons: [],
25
+ memoryPath: null,
26
+ description: 'A test agent for unit tests.',
27
+ ...overrides,
28
+ }
29
+ }
30
+
31
+ /* ── isSlashInput ─────────────────────────────────────── */
32
+
33
+ describe('isSlashInput', () => {
34
+ it('returns true for input starting with /', () => {
35
+ expect(isSlashInput('/help')).toBe(true)
36
+ })
37
+
38
+ it('returns true for input with leading whitespace before /', () => {
39
+ expect(isSlashInput(' /clear')).toBe(true)
40
+ })
41
+
42
+ it('returns true for bare /', () => {
43
+ expect(isSlashInput('/')).toBe(true)
44
+ })
45
+
46
+ it('returns false for empty string', () => {
47
+ expect(isSlashInput('')).toBe(false)
48
+ })
49
+
50
+ it('returns false for regular text', () => {
51
+ expect(isSlashInput('hello world')).toBe(false)
52
+ })
53
+
54
+ it('returns false for slash in middle of text', () => {
55
+ expect(isSlashInput('use /help command')).toBe(false)
56
+ })
57
+ })
58
+
59
+ /* ── parseSlashCommand ────────────────────────────────── */
60
+
61
+ describe('parseSlashCommand', () => {
62
+ it('parses a known command', () => {
63
+ expect(parseSlashCommand('/help')).toEqual({ command: '/help', args: '' })
64
+ })
65
+
66
+ it('parses a command with args', () => {
67
+ expect(parseSlashCommand('/help extra stuff')).toEqual({ command: '/help', args: 'extra stuff' })
68
+ })
69
+
70
+ it('is case insensitive', () => {
71
+ expect(parseSlashCommand('/CLEAR')).toEqual({ command: '/clear', args: '' })
72
+ })
73
+
74
+ it('handles leading whitespace', () => {
75
+ expect(parseSlashCommand(' /soul')).toEqual({ command: '/soul', args: '' })
76
+ })
77
+
78
+ it('returns null for unknown command', () => {
79
+ expect(parseSlashCommand('/unknown')).toBeNull()
80
+ })
81
+
82
+ it('returns null for empty string', () => {
83
+ expect(parseSlashCommand('')).toBeNull()
84
+ })
85
+
86
+ it('returns null for non-slash input', () => {
87
+ expect(parseSlashCommand('hello')).toBeNull()
88
+ })
89
+
90
+ it('parses all registered commands', () => {
91
+ for (const cmd of COMMANDS) {
92
+ const result = parseSlashCommand(cmd.name)
93
+ expect(result).not.toBeNull()
94
+ expect(result!.command).toBe(cmd.name)
95
+ }
96
+ })
97
+ })
98
+
99
+ /* ── matchCommands ────────────────────────────────────── */
100
+
101
+ describe('matchCommands', () => {
102
+ it('returns all commands for bare /', () => {
103
+ const matches = matchCommands('/')
104
+ expect(matches).toHaveLength(COMMANDS.length)
105
+ })
106
+
107
+ it('filters by partial match', () => {
108
+ const matches = matchCommands('/cl')
109
+ expect(matches).toHaveLength(1)
110
+ expect(matches[0].name).toBe('/clear')
111
+ })
112
+
113
+ it('returns multiple matches when prefix matches several', () => {
114
+ const matches = matchCommands('/c')
115
+ expect(matches.length).toBeGreaterThanOrEqual(2) // /clear, /crons
116
+ expect(matches.map(m => m.name)).toContain('/clear')
117
+ expect(matches.map(m => m.name)).toContain('/crons')
118
+ })
119
+
120
+ it('returns exact match', () => {
121
+ const matches = matchCommands('/help')
122
+ expect(matches).toHaveLength(1)
123
+ expect(matches[0].name).toBe('/help')
124
+ })
125
+
126
+ it('returns empty for non-matching input', () => {
127
+ expect(matchCommands('/xyz')).toHaveLength(0)
128
+ })
129
+
130
+ it('returns empty for non-slash input', () => {
131
+ expect(matchCommands('hello')).toHaveLength(0)
132
+ })
133
+
134
+ it('returns empty for empty string', () => {
135
+ expect(matchCommands('')).toHaveLength(0)
136
+ })
137
+
138
+ it('is case insensitive', () => {
139
+ const matches = matchCommands('/HEL')
140
+ expect(matches).toHaveLength(1)
141
+ expect(matches[0].name).toBe('/help')
142
+ })
143
+ })
144
+
145
+ /* ── executeCommand ───────────────────────────────────── */
146
+
147
+ describe('executeCommand', () => {
148
+ const agent = makeAgent({
149
+ name: 'VERA',
150
+ title: 'Chief Strategy Officer',
151
+ description: 'Oversees strategic planning.',
152
+ tools: ['web-search', 'file-read'],
153
+ soul: '# VERA\nI am the strategy lead.',
154
+ memoryPath: '/memory/vera',
155
+ crons: [
156
+ {
157
+ id: 'daily-report',
158
+ name: 'Daily Report',
159
+ schedule: '0 8 * * *',
160
+ scheduleDescription: 'Daily at 8 AM',
161
+ timezone: 'US/Eastern',
162
+ status: 'ok',
163
+ lastRun: '2025-01-01',
164
+ nextRun: '2025-01-02',
165
+ lastError: null,
166
+ agentId: 'vera',
167
+ description: 'Generate daily report',
168
+ enabled: true,
169
+ delivery: null,
170
+ lastDurationMs: 5000,
171
+ consecutiveErrors: 0,
172
+ lastDeliveryStatus: null,
173
+ },
174
+ ],
175
+ })
176
+
177
+ it('/clear returns action', () => {
178
+ const result = executeCommand('/clear', agent)
179
+ expect(result.content).toBe('Conversation cleared.')
180
+ expect(result.action).toBe('clear')
181
+ })
182
+
183
+ it('/help lists all commands', () => {
184
+ const result = executeCommand('/help', agent)
185
+ for (const cmd of COMMANDS) {
186
+ expect(result.content).toContain(cmd.name)
187
+ expect(result.content).toContain(cmd.description)
188
+ }
189
+ })
190
+
191
+ it('/info shows agent profile', () => {
192
+ const result = executeCommand('/info', agent)
193
+ expect(result.content).toContain('VERA')
194
+ expect(result.content).toContain('Chief Strategy Officer')
195
+ expect(result.content).toContain('web-search')
196
+ expect(result.content).toContain('Memory: /memory/vera')
197
+ })
198
+
199
+ it('/info shows "none" when agent has no tools', () => {
200
+ const bare = makeAgent()
201
+ const result = executeCommand('/info', bare)
202
+ expect(result.content).toContain('Tools: none')
203
+ })
204
+
205
+ it('/info shows "not configured" when no memory path', () => {
206
+ const bare = makeAgent()
207
+ const result = executeCommand('/info', bare)
208
+ expect(result.content).toContain('Memory: not configured')
209
+ })
210
+
211
+ it('/soul shows SOUL.md content', () => {
212
+ const result = executeCommand('/soul', agent)
213
+ expect(result.content).toBe('# VERA\nI am the strategy lead.')
214
+ })
215
+
216
+ it('/soul handles missing SOUL.md', () => {
217
+ const bare = makeAgent()
218
+ const result = executeCommand('/soul', bare)
219
+ expect(result.content).toContain('No SOUL.md found')
220
+ })
221
+
222
+ it('/tools lists tools', () => {
223
+ const result = executeCommand('/tools', agent)
224
+ expect(result.content).toContain('web-search')
225
+ expect(result.content).toContain('file-read')
226
+ })
227
+
228
+ it('/tools handles no tools', () => {
229
+ const bare = makeAgent()
230
+ const result = executeCommand('/tools', bare)
231
+ expect(result.content).toContain('no tools configured')
232
+ })
233
+
234
+ it('/crons lists cron jobs', () => {
235
+ const result = executeCommand('/crons', agent)
236
+ expect(result.content).toContain('Daily Report')
237
+ expect(result.content).toContain('Daily at 8 AM')
238
+ expect(result.content).toContain('ok')
239
+ })
240
+
241
+ it('/crons shows disabled status for disabled jobs', () => {
242
+ const agentWithDisabled = makeAgent({
243
+ crons: [{
244
+ id: 'test',
245
+ name: 'Test Job',
246
+ schedule: '0 0 * * *',
247
+ scheduleDescription: 'Daily at midnight',
248
+ timezone: null,
249
+ status: 'ok',
250
+ lastRun: null,
251
+ nextRun: null,
252
+ lastError: null,
253
+ agentId: null,
254
+ description: null,
255
+ enabled: false,
256
+ delivery: null,
257
+ lastDurationMs: null,
258
+ consecutiveErrors: 0,
259
+ lastDeliveryStatus: null,
260
+ }],
261
+ })
262
+ const result = executeCommand('/crons', agentWithDisabled)
263
+ expect(result.content).toContain('disabled')
264
+ })
265
+
266
+ it('/crons handles no cron jobs', () => {
267
+ const bare = makeAgent()
268
+ const result = executeCommand('/crons', bare)
269
+ expect(result.content).toContain('no cron jobs')
270
+ })
271
+
272
+ it('unknown command returns error message', () => {
273
+ const result = executeCommand('/bogus', agent)
274
+ expect(result.content).toContain('Unknown command')
275
+ })
276
+ })
@@ -0,0 +1,123 @@
1
+ import type { Agent } from './types'
2
+
3
+ export interface SlashCommand {
4
+ name: string
5
+ description: string
6
+ }
7
+
8
+ export const COMMANDS: SlashCommand[] = [
9
+ { name: '/clear', description: 'Clear conversation history' },
10
+ { name: '/help', description: 'Show available commands' },
11
+ { name: '/info', description: 'Show agent profile summary' },
12
+ { name: '/soul', description: "Show agent's SOUL.md persona" },
13
+ { name: '/tools', description: "List agent's available tools" },
14
+ { name: '/crons', description: "Show agent's scheduled jobs" },
15
+ ]
16
+
17
+ export interface ParsedCommand {
18
+ command: string
19
+ args: string
20
+ }
21
+
22
+ /** Returns true if input looks like the start of a slash command (leading `/`). */
23
+ export function isSlashInput(input: string): boolean {
24
+ return input.trimStart().startsWith('/')
25
+ }
26
+
27
+ /** Parse a complete slash command from input. Returns null if not a valid command. */
28
+ export function parseSlashCommand(input: string): ParsedCommand | null {
29
+ const trimmed = input.trimStart()
30
+ if (!trimmed.startsWith('/')) return null
31
+
32
+ const spaceIdx = trimmed.indexOf(' ')
33
+ const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase()
34
+ const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
35
+
36
+ const match = COMMANDS.find(c => c.name === command)
37
+ if (!match) return null
38
+
39
+ return { command: match.name, args }
40
+ }
41
+
42
+ /** Return commands matching a partial input (e.g. "/cl" matches "/clear"). */
43
+ export function matchCommands(partial: string): SlashCommand[] {
44
+ const trimmed = partial.trimStart().toLowerCase()
45
+ if (!trimmed.startsWith('/')) return []
46
+
47
+ // Show all commands for bare "/"
48
+ if (trimmed === '/') return [...COMMANDS]
49
+
50
+ return COMMANDS.filter(c => c.name.startsWith(trimmed))
51
+ }
52
+
53
+ /** Execute a slash command and return the formatted content string for a system message. */
54
+ export function executeCommand(command: string, agent: Agent): { content: string; action?: 'clear' } {
55
+ switch (command) {
56
+ case '/clear':
57
+ return { content: 'Conversation cleared.', action: 'clear' }
58
+
59
+ case '/help':
60
+ return {
61
+ content: [
62
+ '**Available commands**',
63
+ '',
64
+ ...COMMANDS.map(c => `\`${c.name}\` -- ${c.description}`),
65
+ '',
66
+ 'Type `/` to see the command menu.',
67
+ ].join('\n'),
68
+ }
69
+
70
+ case '/info':
71
+ return {
72
+ content: [
73
+ `**${agent.name}**`,
74
+ agent.title,
75
+ '',
76
+ agent.description,
77
+ '',
78
+ `Tools: ${agent.tools.length > 0 ? agent.tools.join(', ') : 'none'}`,
79
+ `Cron jobs: ${agent.crons.length}`,
80
+ agent.memoryPath ? `Memory: ${agent.memoryPath}` : 'Memory: not configured',
81
+ ].join('\n'),
82
+ }
83
+
84
+ case '/soul': {
85
+ if (!agent.soul) {
86
+ return { content: `No SOUL.md found for ${agent.name}.` }
87
+ }
88
+ return { content: agent.soul }
89
+ }
90
+
91
+ case '/tools': {
92
+ if (agent.tools.length === 0) {
93
+ return { content: `${agent.name} has no tools configured.` }
94
+ }
95
+ return {
96
+ content: [
97
+ `**${agent.name}'s tools**`,
98
+ '',
99
+ ...agent.tools.map(t => `- ${t}`),
100
+ ].join('\n'),
101
+ }
102
+ }
103
+
104
+ case '/crons': {
105
+ if (agent.crons.length === 0) {
106
+ return { content: `${agent.name} has no cron jobs.` }
107
+ }
108
+ return {
109
+ content: [
110
+ `**${agent.name}'s cron jobs**`,
111
+ '',
112
+ ...agent.crons.map(c => {
113
+ const status = c.enabled ? c.status : 'disabled'
114
+ return `- **${c.name}** (${c.scheduleDescription}) -- ${status}`
115
+ }),
116
+ ].join('\n'),
117
+ }
118
+ }
119
+
120
+ default:
121
+ return { content: `Unknown command: ${command}` }
122
+ }
123
+ }
package/next.config.mjs CHANGED
@@ -8,6 +8,7 @@ const nextConfig = {
8
8
  turbopack: {
9
9
  root: __dirname,
10
10
  },
11
+ allowedDevOrigins: ["*"],
11
12
  };
12
13
 
13
14
  export default nextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawport-ui",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
5
5
  "homepage": "https://clawport.dev",
6
6
  "repository": {
package/scripts/setup.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // Usage: npm run setup
5
5
 
6
6
  import { execSync } from 'node:child_process'
7
- import { readFileSync, writeFileSync, existsSync } from 'node:fs'
7
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, accessSync } from 'node:fs'
8
+ import { constants } from 'node:fs'
8
9
  import { resolve, join } from 'node:path'
9
10
  import { createInterface } from 'node:readline'
10
11
  import { homedir } from 'node:os'
@@ -42,8 +43,12 @@ function exec(cmd) {
42
43
  // ---------------------------------------------------------------------------
43
44
 
44
45
  function detectWorkspacePath() {
45
- const defaultPath = join(homedir(), '.openclaw', 'workspace')
46
- if (existsSync(defaultPath)) return defaultPath
46
+ // Current OpenClaw layout: ~/.openclaw/agents/main/workspace
47
+ const agentPath = join(homedir(), '.openclaw', 'agents', 'main', 'workspace')
48
+ if (existsSync(agentPath)) return agentPath
49
+ // Legacy layout: ~/.openclaw/workspace
50
+ const legacyPath = join(homedir(), '.openclaw', 'workspace')
51
+ if (existsSync(legacyPath)) return legacyPath
47
52
  return null
48
53
  }
49
54
 
@@ -210,7 +215,28 @@ async function main() {
210
215
 
211
216
  // Support --cwd flag for CLI usage (clawport setup writes .env.local into the package dir)
212
217
  const cwdFlag = process.argv.find((a) => a.startsWith('--cwd='))
213
- const targetDir = cwdFlag ? cwdFlag.split('=')[1] : process.cwd()
218
+ let targetDir = cwdFlag ? cwdFlag.split('=')[1] : process.cwd()
219
+
220
+ // When installed globally (e.g. /usr/lib/node_modules/clawport-ui), targetDir may not be writable.
221
+ // Use ~/.config/clawport-ui/.env.local in that case so setup works without sudo.
222
+ function canWriteToDir(dir) {
223
+ try {
224
+ accessSync(dir, constants.W_OK)
225
+ return true
226
+ } catch {
227
+ return false
228
+ }
229
+ }
230
+
231
+ if (!canWriteToDir(targetDir)) {
232
+ const userConfigDir = join(homedir(), '.config', 'clawport-ui')
233
+ if (!existsSync(userConfigDir)) {
234
+ mkdirSync(userConfigDir, { recursive: true })
235
+ }
236
+ targetDir = userConfigDir
237
+ console.log(` ${yellow('!')} Package directory is not writable; using ${dim(targetDir)} for .env.local`)
238
+ console.log()
239
+ }
214
240
 
215
241
  // Check if .env.local already exists
216
242
  const envPath = resolve(targetDir, '.env.local')
@@ -254,7 +280,7 @@ async function main() {
254
280
  writeFileSync(envPath, content, 'utf-8')
255
281
 
256
282
  console.log()
257
- console.log(` ${green('Done!')} .env.local written.`)
283
+ console.log(` ${green('Done!')} .env.local written${targetDir !== (cwdFlag ? cwdFlag.split('=')[1] : process.cwd()) ? ` to ${dim(targetDir)}` : ''}.`)
258
284
  console.log()
259
285
  const startCmd = cwdFlag ? 'clawport dev' : 'npm run dev'
260
286
  console.log(` Next steps:`)