clawport-ui 0.6.2 → 0.6.3

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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawport-ui",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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": {