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 +1 -1
- package/components/chat/ConversationView.tsx +179 -19
- package/lib/conversations.ts +1 -1
- package/lib/slash-commands.test.ts +276 -0
- package/lib/slash-commands.ts +123 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/clawport-ui)
|
|
10
10
|
[](LICENSE)
|
|
11
|
-
[](#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 ===
|
|
183
|
-
|
|
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 ===
|
|
189
|
-
|
|
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]
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 =>
|
|
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 · Shift+Enter for newline
|
|
1175
|
+
Enter to send · Shift+Enter for newline · / for commands
|
|
1016
1176
|
</div>
|
|
1017
1177
|
</div>
|
|
1018
1178
|
</div>
|
package/lib/conversations.ts
CHANGED
|
@@ -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
|
+
}
|