Add command autocomplete and stream spacing updates

This commit is contained in:
Mohamed Boudra
2026-01-11 22:36:34 +07:00
parent 0ec2fc62db
commit f81fd1c01a
25 changed files with 949 additions and 93 deletions

18
.tasks/5eee8cb7.md Normal file
View File

@@ -0,0 +1,18 @@
---
id: 5eee8cb7
title: Fix inverted FlatList spacing
status: done
deps: []
created: 2026-01-11T12:31:33.219Z
---
## Notes
**2026-01-11T12:31:44.968Z**
Need consistent 16px gaps between different content types in inverted FlatList; 4px gaps within groups (user messages, tool calls). Inverted list: marginBottom renders above, marginTop below. Check message.tsx + agent-stream-view.tsx, fix spacing, run typecheck.
**2026-01-11T12:45:33.340Z**
Centralized chat item spacing in AgentStreamView with inverted-list gap calculation (4px within user/tool groups, 16px otherwise). Added disableOuterSpacing prop to message components to keep other views unchanged. Typecheck passed.

15
.tasks/89c1d14c.md Normal file
View File

@@ -0,0 +1,15 @@
---
id: 89c1d14c
title: Commit all current changes
status: in_progress
deps: []
created: 2026-01-11T15:33:56.640Z
---
## Goal\n- Stage and commit all current working tree changes, even if not authored by me.\n\n
## Notes
**2026-01-11T15:34:06.250Z**
User requested: stage and commit all current changes, even if not authored by me.

View File

@@ -1,8 +1,14 @@
---
id: 933d0b5b
title: Extend daemon client to support all daemon actions
status: open
status: in_progress
deps: []
created: 2026-01-08T16:15:09.344Z
---
## Notes
**2026-01-11T15:33:31.213Z**
User goal: build fully featured daemon client (no WS knowledge in app), support all RPC calls, app uses client, expand daemon e2e coverage to include all client actions.

22
.tasks/947a21cd.md Normal file
View File

@@ -0,0 +1,22 @@
---
id: 947a21cd
title: Simplify agent stream spacing props
status: done
deps: []
created: 2026-01-11T15:24:58.595Z
---
## Notes
**2026-01-11T15:25:05.187Z**
User wants simplified spacing logic; reduce prop drilling without losing functionality.
**2026-01-11T15:28:57.437Z**
Added MessageOuterSpacingProvider context so stream view no longer passes disableOuterSpacing into each message; components now read spacing override from context.
**2026-01-11T15:29:14.746Z**
Simplified spacing by context: message components read disableOuterSpacing from MessageOuterSpacingProvider; AgentStreamView wraps FlatList and no longer prop-drills.

22
.tasks/a1e6eec8.md Normal file
View File

@@ -0,0 +1,22 @@
---
id: a1e6eec8
title: Fix inverted chat spacing
status: done
deps: []
created: 2026-01-11T13:15:47.498Z
---
## Notes
**2026-01-11T13:15:52.529Z**
User requires inverted FlatList spacing: only marginBottom on wrapper in agent-stream-view, child components zero margins when disableOuterSpacing. marginBottom based on next item: 4px if same type group (user-user, tool/thought-tool/thought), else 16px; first item marginBottom 0.
**2026-01-11T13:39:59.545Z**
Fix spacing by computing inverted-list gap from the item above (index + 1) instead of below; index 0 is bottom, topmost item gets 0 marginBottom.
**2026-01-11T13:40:18.974Z**
Adjusted inverted list spacing to compute marginBottom based on the item above (index + 1) so gaps match 4px/16px rules; ran typecheck.

BIN
01-main-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
02-agent-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
02-sidebar-open.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
03-after-slash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

BIN
03-agent-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
04-after-slash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

BIN
05-autocomplete-visible.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

View File

@@ -0,0 +1,57 @@
appId: com.moboudra.paseo
---
- launchApp:
clearState: false
- runFlow: flows/launch.yaml
# Take screenshot of main screen
- takeScreenshot: 01-main-screen
# Swipe to open sidebar
- swipe:
direction: RIGHT
duration: 300
# Wait for sidebar to open and show agents
- extendedWaitUntil:
visible: ".*Wire POC Commands.*"
timeout: 5000
# Take screenshot of sidebar
- takeScreenshot: 02-sidebar-open
# Tap on the agent
- tapOn:
text: ".*Wire POC Commands.*"
# Wait for agent screen to load
- extendedWaitUntil:
visible: "Message agent..."
timeout: 5000
# Take screenshot of agent screen
- takeScreenshot: 03-agent-screen
# Tap on the message input to focus it
- tapOn: "Message agent..."
# Type a slash to trigger autocomplete
- inputText: "/"
# Wait a moment for autocomplete
- waitForAnimationToEnd
# Take screenshot after typing /
- takeScreenshot: 04-after-slash
# Wait for autocomplete to appear (use regex to match command name)
- extendedWaitUntil:
visible: ".*orchestrator-mode.*"
timeout: 5000
# Take screenshot showing autocomplete
- takeScreenshot: 05-autocomplete-visible
# Assert commands are visible (use regex to match)
- assertVisible: ".*orchestrator-mode.*"
- assertVisible: ".*prompt-engineer.*"

View File

@@ -30,6 +30,8 @@ import {
} from "./message-input";
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
type QueuedMessage = {
id: string;
@@ -127,6 +129,33 @@ export function AgentInputArea({
const [isProcessing, setIsProcessing] = useState(false);
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
// Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
// Prefetch commands when agent is available (not on new agent screen)
const isRealAgent = agentId && !agentId.startsWith("__");
const { commands } = useAgentCommandsQuery({
serverId,
agentId,
enabled: !!isRealAgent && !!serverId,
});
// Filter commands for keyboard navigation
const filteredCommands = useMemo(() => {
if (!showCommandAutocomplete) return [];
const filterLower = commandFilter.toLowerCase();
return commands.filter((cmd) =>
cmd.name.toLowerCase().includes(filterLower)
);
}, [commands, commandFilter, showCommandAutocomplete]);
// Reset selection when filter changes
useEffect(() => {
setCommandSelectedIndex(0);
}, [commandFilter]);
const { pickImages } = useImageAttachmentPicker();
const agentIdRef = useRef(agentId);
@@ -369,6 +398,64 @@ export function AgentInputArea({
queueMessage(payload.text, payload.images);
}, []);
// Handle command selection from autocomplete
const handleCommandSelect = useCallback(
(cmd: { name: string; description: string; argumentHint: string }) => {
setUserInput(`/${cmd.name} `);
messageInputRef.current?.focus();
},
[setUserInput]
);
// Handle keyboard navigation for command autocomplete
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
if (!showCommandAutocomplete || filteredCommands.length === 0) {
return false;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setCommandSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredCommands.length - 1
);
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setCommandSelectedIndex((prev) =>
prev < filteredCommands.length - 1 ? prev + 1 : 0
);
return true;
}
if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault();
const selected = filteredCommands[commandSelectedIndex];
if (selected) {
handleCommandSelect(selected);
}
return true;
}
if (event.key === "Escape") {
event.preventDefault();
setUserInput("");
return true;
}
return false;
},
[
showCommandAutocomplete,
filteredCommands,
commandSelectedIndex,
handleCommandSelect,
setUserInput,
]
);
const rightContent = (
<>
<Pressable
@@ -472,6 +559,17 @@ export function AgentInputArea({
</View>
)}
{/* Command autocomplete dropdown */}
{showCommandAutocomplete && isRealAgent && (
<CommandAutocomplete
serverId={serverId}
agentId={agentId}
filter={commandFilter}
selectedIndex={commandSelectedIndex}
onSelect={handleCommandSelect}
/>
)}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
ref={messageInputRef}
@@ -492,6 +590,7 @@ export function AgentInputArea({
rightContent={rightContent}
isAgentRunning={isAgentRunning}
onQueue={handleQueue}
onKeyPress={handleCommandKeyPress}
/>
</View>
</View>

View File

@@ -36,6 +36,7 @@ import {
ToolCall,
AgentThoughtMessage,
TodoListCard,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { DiffViewer } from "./diff-viewer";
@@ -55,6 +56,10 @@ import {
import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought";
type PermissionResolvedMessage = Extract<
SessionOutboundMessage,
{ type: "agent_permission_resolved" }
@@ -75,6 +80,7 @@ export function AgentStreamView({
pendingPermissions,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
@@ -257,43 +263,90 @@ export function AgentStreamView({
setIsNearBottom(true);
}
const renderStreamItem = useCallback(
({ item }: ListRenderItemInfo<StreamItem>) => {
let content: React.ReactNode = null;
const flatListData = useMemo(() => {
return [...streamItems].reverse();
}, [streamItems]);
const tightGap = theme.spacing[1]; // 4px
const looseGap = theme.spacing[4]; // 16px
// In inverted lists, marginBottom renders as visual gap ABOVE the item.
// Index 0 is visually at the bottom, so use the item ABOVE (index + 1) to compute spacing.
const getGapAbove = useCallback(
(item: StreamItem, index: number) => {
const aboveItem = flatListData[index + 1];
if (!aboveItem) {
// This is the topmost item; nothing above it visually.
return 0;
}
// Same type groups get tight gap (4px)
if (isUserMessageItem(item) && isUserMessageItem(aboveItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(aboveItem)) {
return tightGap;
}
// Different types get loose gap (16px)
return looseGap;
},
[flatListData, looseGap, tightGap]
);
const renderStreamItemContent = useCallback(
(item: StreamItem, index: number) => {
switch (item.kind) {
case "user_message":
content = (
case "user_message": {
// In inverted list: index+1 is the item above, index-1 is below.
const prevItem = flatListData[index + 1];
const nextItem = flatListData[index - 1];
const isFirstInGroup = prevItem?.kind !== "user_message";
const isLastInGroup = nextItem?.kind !== "user_message";
return (
<UserMessage
message={item.text}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
break;
}
case "assistant_message":
content = (
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
);
break;
case "thought":
console.log("[AgentStreamView] renderStreamItem", { item });
content = (
<AgentThoughtMessage message={item.text} status={item.status} />
case "thought": {
// In inverted list: index+1 is the item above, index-1 is below.
const nextItem = flatListData[index - 1];
const isLastInSequence =
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<AgentThoughtMessage
message={item.text}
status={item.status}
isLastInSequence={isLastInSequence}
/>
);
break;
}
case "tool_call": {
const { payload } = item;
// In inverted list: index+1 is the item above, index-1 is below.
const nextItem = flatListData[index - 1];
const isLastInSequence =
nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
if (payload.source === "agent") {
const data = payload.data;
content = (
return (
<ToolCall
toolName={data.name}
args={data.input}
@@ -301,24 +354,25 @@ export function AgentStreamView({
error={data.error}
status={data.status as "executing" | "completed" | "failed"}
cwd={agent.cwd}
/>
);
} else {
const data = payload.data;
content = (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
/>
);
}
break;
const data = payload.data;
return (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
/>
);
}
case "activity_log":
content = (
return (
<ActivityLog
type={item.activityType}
message={item.message}
@@ -326,29 +380,44 @@ export function AgentStreamView({
metadata={item.metadata}
/>
);
break;
case "todo_list":
content = (
return (
<TodoListCard
provider={item.provider}
timestamp={item.timestamp.getTime()}
items={item.items}
/>
);
break;
default:
content = null;
return null;
}
},
[handleInlinePathPress, agent.cwd, flatListData]
);
const renderStreamItem = useCallback(
({ item, index }: ListRenderItemInfo<StreamItem>) => {
const content = renderStreamItemContent(item, index);
if (!content) {
return null;
}
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
const gap = getGapAbove(item, index);
return (
<View
style={[
stylesheet.streamItemWrapper,
gap ? { marginBottom: gap } : null,
]}
>
{content}
</View>
);
},
[handleInlinePathPress, agent.cwd]
[getGapAbove, renderStreamItemContent]
);
const pendingPermissionItems = useMemo(
@@ -391,18 +460,12 @@ export function AgentStreamView({
) : null}
{hasHeadItems
? [...streamHead].reverse().map((item) => {
const rendered = renderStreamItem({
item,
index: 0,
separators: {
highlight: () => {},
unhighlight: () => {},
updateProps: () => {},
},
});
? [...streamHead].reverse().map((item, index) => {
const rendered = renderStreamItemContent(item, index);
return rendered ? (
<View key={item.id}>{rendered}</View>
<View key={item.id} style={stylesheet.streamItemWrapper}>
{rendered}
</View>
) : null;
})
: null}
@@ -414,13 +477,9 @@ export function AgentStreamView({
showWorkingIndicator,
wsOrInert,
streamHead,
renderStreamItem,
renderStreamItemContent,
]);
const flatListData = useMemo(() => {
return [...streamItems].reverse();
}, [streamItems]);
const flatListExtraData = useMemo(
() => ({
pendingPermissionCount: pendingPermissionItems.length,
@@ -432,39 +491,41 @@ export function AgentStreamView({
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<FlatList
ref={flatListRef}
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 40,
}}
initialNumToRender={12}
windowSize={10}
inverted
/>
<MessageOuterSpacingProvider disableOuterSpacing>
<FlatList
ref={flatListRef}
data={flatListData}
renderItem={renderStreamItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{
paddingVertical: 0,
flexGrow: 1,
}}
style={stylesheet.list}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 40,
}}
initialNumToRender={12}
windowSize={10}
inverted
/>
</MessageOuterSpacingProvider>
{/* Scroll to bottom button */}
{!isNearBottom && (

View File

@@ -0,0 +1,164 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { Fonts } from "@/constants/theme";
import { Theme } from "@/styles/theme";
interface AgentSlashCommand {
name: string;
description: string;
argumentHint: string;
}
interface CommandAutocompleteProps {
serverId: string;
agentId: string;
filter: string;
selectedIndex: number;
onSelect: (command: AgentSlashCommand) => void;
}
export function CommandAutocomplete({
serverId,
agentId,
filter,
selectedIndex,
onSelect,
}: CommandAutocompleteProps) {
const { theme } = useUnistyles();
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
serverId,
agentId,
enabled: true,
});
// Filter commands based on input after /
const filterLower = filter.toLowerCase();
const filteredCommands = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(filterLower)
);
if (isLoading) {
return (
<View style={styles.container}>
<View style={styles.loadingItem}>
<Text style={styles.loadingText}>Loading commands...</Text>
</View>
</View>
);
}
if (isError) {
return (
<View style={styles.container}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {error?.message ?? "Failed to load"}</Text>
</View>
</View>
);
}
if (filteredCommands.length === 0) {
return (
<View style={styles.container}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>No commands found</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="always">
{filteredCommands.map((cmd, index) => {
const isSelected = index === selectedIndex;
return (
<Pressable
key={cmd.name}
onPress={() => onSelect(cmd)}
style={[
styles.commandItem,
isSelected && {
backgroundColor: theme.colors.accent,
},
]}
>
<View style={styles.commandHeader}>
<Text style={styles.commandName}>/{cmd.name}</Text>
{cmd.argumentHint && (
<Text style={styles.commandArgs}>{cmd.argumentHint}</Text>
)}
</View>
<Text style={styles.commandDescription} numberOfLines={1}>
{cmd.description}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: string) {
const filterLower = filter.toLowerCase();
return commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
backgroundColor: theme.colors.card,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
maxHeight: 200,
},
scrollView: {
flexGrow: 0,
flexShrink: 1,
},
commandItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
commandHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
commandName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontFamily: Fonts.mono,
},
commandArgs: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
},
commandDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
loadingItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
loadingText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
emptyText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>;

View File

@@ -64,6 +64,8 @@ export interface MessageInputProps {
isAgentRunning?: boolean;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
}
export interface MessageInputRef {
@@ -110,6 +112,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
rightContent,
isAgentRunning = false,
onQueue,
onKeyPress: onKeyPressCallback,
},
ref
) {
@@ -383,6 +386,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleDesktopSubmit) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
key: event.nativeEvent.key,
preventDefault: () => event.preventDefault(),
});
if (handled) return;
}
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
// Cmd+B or Ctrl+B: toggle sidebar

View File

@@ -1,5 +1,14 @@
import { View, Text, Pressable, Animated } from "react-native";
import { useState, useEffect, useRef, memo, useMemo, useCallback } from "react";
import {
useState,
useEffect,
useRef,
memo,
useMemo,
useCallback,
createContext,
useContext,
} from "react";
import type { ReactNode, ComponentType } from "react";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
@@ -46,23 +55,53 @@ import {
interface UserMessageProps {
message: string;
timestamp: number;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
}
const MessageOuterSpacingContext = createContext(false);
export function MessageOuterSpacingProvider({
disableOuterSpacing,
children,
}: {
disableOuterSpacing: boolean;
children: ReactNode;
}) {
return (
<MessageOuterSpacingContext.Provider value={disableOuterSpacing}>
{children}
</MessageOuterSpacingContext.Provider>
);
}
function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
const contextValue = useContext(MessageOuterSpacingContext);
return disableOuterSpacing ?? contextValue;
}
const userMessageStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
justifyContent: "flex-end",
marginBottom: theme.spacing[4],
marginTop: theme.spacing[4],
paddingHorizontal: theme.spacing[2],
},
containerSpacing: {
marginBottom: theme.spacing[1],
},
containerFirstInGroup: {
marginTop: theme.spacing[4],
},
containerLastInGroup: {
marginBottom: theme.spacing[4],
},
bubble: {
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
maxWidth: "80%",
},
text: {
color: theme.colors.foreground,
@@ -90,7 +129,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
export const UserMessage = memo(function UserMessage({
message,
timestamp,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -121,7 +165,19 @@ export const UserMessage = memo(function UserMessage({
}, []);
return (
<View style={userMessageStylesheet.container}>
<View
style={[
userMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
userMessageStylesheet.containerSpacing,
!resolvedDisableOuterSpacing &&
isFirstInGroup &&
userMessageStylesheet.containerFirstInGroup,
!resolvedDisableOuterSpacing &&
isLastInGroup &&
userMessageStylesheet.containerLastInGroup,
]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel="Copy message"
@@ -157,14 +213,17 @@ interface AssistantMessageProps {
message: string;
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget) => void;
disableOuterSpacing?: boolean;
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
containerSpacing: {
marginBottom: theme.spacing[4],
},
// Used in custom markdownRules for inline code styling
markdownCodeInline: {
backgroundColor: theme.colors.secondary,
@@ -194,7 +253,12 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
containerSpacing: {
marginBottom: theme.spacing[1],
},
containerLastInSequence: {
marginBottom: theme.spacing[4],
},
pressable: {
borderRadius: theme.borderRadius.lg,
@@ -347,8 +411,11 @@ export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
onInlinePathPress,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const lastPathRef = useRef<string | null>(null);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
@@ -499,7 +566,13 @@ export const AssistantMessage = memo(function AssistantMessage({
}, [onInlinePathPress]);
return (
<View style={assistantMessageStylesheet.container}>
<View
style={[
assistantMessageStylesheet.container,
!resolvedDisableOuterSpacing &&
assistantMessageStylesheet.containerSpacing,
]}
>
<Markdown style={markdownStyles} rules={markdownRules}>
{message}
</Markdown>
@@ -516,15 +589,18 @@ interface ActivityLogProps {
artifactType?: string;
title?: string;
onArtifactClick?: (artifactId: string) => void;
disableOuterSpacing?: boolean;
}
const activityLogStylesheet = StyleSheet.create((theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderRadius: theme.borderRadius.md,
overflow: "hidden",
},
pressableSpacing: {
marginBottom: theme.spacing[1],
},
pressableActive: {
opacity: 0.7,
},
@@ -597,7 +673,10 @@ export const ActivityLog = memo(function ActivityLog({
artifactType,
title,
onArtifactClick,
disableOuterSpacing,
}: ActivityLogProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
@@ -648,6 +727,8 @@ export const ActivityLog = memo(function ActivityLog({
disabled={!isInteractive}
style={[
activityLogStylesheet.pressable,
!resolvedDisableOuterSpacing &&
activityLogStylesheet.pressableSpacing,
config.bg,
isInteractive && activityLogStylesheet.pressableActive,
]}
@@ -694,6 +775,7 @@ interface TodoListCardProps {
provider: AgentProvider;
timestamp: number;
items: TodoEntry[];
disableOuterSpacing?: boolean;
}
function formatPlanTimestamp(timestamp: number): string {
@@ -711,6 +793,8 @@ function formatPlanTimestamp(timestamp: number): string {
const todoListCardStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
},
containerSpacing: {
marginBottom: theme.spacing[2],
},
card: {
@@ -799,7 +883,10 @@ export const TodoListCard = memo(function TodoListCard({
provider,
timestamp,
items,
disableOuterSpacing,
}: TodoListCardProps) {
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const providerLabel = useMemo(() => {
const definition = getAgentProviderDefinition(provider);
return definition?.label ?? provider;
@@ -818,7 +905,13 @@ export const TodoListCard = memo(function TodoListCard({
const iconColor = theme.colors.background;
return (
<View style={todoListCardStylesheet.container}>
<View
style={[
todoListCardStylesheet.container,
!resolvedDisableOuterSpacing &&
todoListCardStylesheet.containerSpacing,
]}
>
<View style={todoListCardStylesheet.card}>
<View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}>
@@ -877,6 +970,8 @@ export const TodoListCard = memo(function TodoListCard({
interface AgentThoughtMessageProps {
message: string;
status?: ThoughtStatus;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
}
interface ExpandableBadgeProps {
@@ -888,6 +983,8 @@ interface ExpandableBadgeProps {
renderDetails?: () => ReactNode;
isLoading?: boolean;
isError?: boolean;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
}
const ExpandableBadge = memo(function ExpandableBadge({
@@ -899,8 +996,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
renderDetails,
isLoading = false,
isError = false,
isLastInSequence = false,
disableOuterSpacing,
}: ExpandableBadgeProps) {
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing =
useDisableOuterSpacing(disableOuterSpacing);
const hasDetails = Boolean(renderDetails);
const detailContent = hasDetails && isExpanded ? renderDetails?.() : null;
@@ -956,7 +1057,16 @@ const ExpandableBadge = memo(function ExpandableBadge({
}
return (
<View style={expandableBadgeStylesheet.container}>
<View
style={[
expandableBadgeStylesheet.container,
!resolvedDisableOuterSpacing &&
expandableBadgeStylesheet.containerSpacing,
!resolvedDisableOuterSpacing &&
isLastInSequence &&
expandableBadgeStylesheet.containerLastInSequence,
]}
>
<Pressable
onPress={hasDetails ? onToggle : undefined}
disabled={!hasDetails}
@@ -1016,6 +1126,8 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
message,
status = "ready",
isLastInSequence = false,
disableOuterSpacing,
}: AgentThoughtMessageProps) {
const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
@@ -1171,6 +1283,8 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
onToggle={toggle}
renderDetails={renderDetails}
isLoading={status !== "ready"}
isLastInSequence={isLastInSequence}
disableOuterSpacing={disableOuterSpacing}
/>
);
});
@@ -1182,6 +1296,8 @@ interface ToolCallProps {
error?: any;
status: "executing" | "completed" | "failed";
cwd?: string;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
}
// Icon mapping for tool kinds
@@ -1212,6 +1328,8 @@ export const ToolCall = memo(function ToolCall({
error,
status,
cwd,
isLastInSequence = false,
disableOuterSpacing,
}: ToolCallProps) {
const { openToolCall } = useToolCallSheet();
const [isExpanded, setIsExpanded] = useState(false);
@@ -1284,6 +1402,8 @@ export const ToolCall = memo(function ToolCall({
}
isLoading={status === "executing"}
isError={status === "failed"}
isLastInSequence={isLastInSequence}
disableOuterSpacing={disableOuterSpacing}
/>
);
});

View File

@@ -0,0 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { sendRpcRequest } from "@/lib/send-rpc-request";
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
interface AgentSlashCommand {
name: string;
description: string;
argumentHint: string;
}
function commandsQueryKey(serverId: string, agentId: string) {
return ["agentCommands", serverId, agentId] as const;
}
interface UseAgentCommandsQueryOptions {
serverId: string;
agentId: string;
enabled?: boolean;
}
export function useAgentCommandsQuery({
serverId,
agentId,
enabled = true,
}: UseAgentCommandsQueryOptions) {
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
// Use getConnectionState for more reliable connection check
const isConnected = ws?.getConnectionState
? ws.getConnectionState().isConnected
: ws?.isConnected ?? false;
const query = useQuery({
queryKey: commandsQueryKey(serverId, agentId),
queryFn: async () => {
if (!ws) {
throw new Error("WebSocket not available");
}
const response = await sendRpcRequest(ws, {
type: "list_commands_request",
agentId,
});
return response.commands as AgentSlashCommand[];
},
enabled: enabled && !!ws && isConnected && !!agentId,
staleTime: COMMANDS_STALE_TIME,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
});
// isPending is true when the query has never run yet (no cached data and not fetching)
// isLoading is true when fetching and no data yet
const isLoading = query.isPending || query.isLoading;
return {
commands: query.data ?? [],
isLoading,
isError: query.isError,
error: query.error,
};
}

View File

@@ -105,6 +105,7 @@ const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
git_repo_info_request: "git_repo_info_response",
list_provider_models_request: "list_provider_models_response",
list_conversations_request: "list_conversations_response",
list_commands_request: "list_commands_response",
create_agent_request: "agent_state",
refresh_agent_request: "agent_state",
initialize_agent_request: "initialize_agent_request",

View File

@@ -0,0 +1,62 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../../test-utils/index.js";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
describe("claude agent commands E2E", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60000);
test("lists available slash commands for a claude agent", async () => {
// Create a Claude agent
const agent = await ctx.client.createAgent({
provider: "claude",
cwd: "/tmp",
title: "Commands Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.provider).toBe("claude");
expect(agent.status).toBe("idle");
// List commands
const result = await ctx.client.listCommands(agent.id);
// Should have no error
expect(result.error).toBeNull();
// Should have commands
expect(result.commands.length).toBeGreaterThan(0);
// Each command should have required fields
for (const cmd of result.commands) {
expect(cmd.name).toBeTruthy();
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
}
// Should include some well-known commands
const commandNames = result.commands.map((c) => c.name);
// These are skills that come from CLAUDE.md configurations
// At minimum we should have some commands available
console.log("Available commands:", commandNames);
expect(commandNames.length).toBeGreaterThan(0);
}, 120000);
test("returns error for non-existent agent", async () => {
const result = await ctx.client.listCommands("non-existent-agent-id");
expect(result.error).toBeTruthy();
expect(result.error).toContain("Agent not found");
expect(result.commands).toEqual([]);
}, 30000);
});

View File

@@ -523,6 +523,12 @@ export const ClearAgentAttentionMessageSchema = z.object({
agentId: z.union([z.string(), z.array(z.string())]),
});
export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema,
RealtimeAudioChunkMessageSchema,
@@ -550,6 +556,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenRequestSchema,
GitRepoInfoRequestMessageSchema,
ClearAgentAttentionMessageSchema,
ListCommandsRequestSchema,
]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -805,6 +812,22 @@ export const ListProviderModelsResponseMessageSchema = z.object({
}),
});
const AgentSlashCommandSchema = z.object({
name: z.string(),
description: z.string(),
argumentHint: z.string(),
});
export const ListCommandsResponseSchema = z.object({
type: z.literal("list_commands_response"),
payload: z.object({
agentId: z.string(),
commands: z.array(AgentSlashCommandSchema),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
@@ -830,6 +853,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenResponseSchema,
GitRepoInfoResponseSchema,
ListProviderModelsResponseMessageSchema,
ListCommandsResponseSchema,
]);
export type SessionOutboundMessage = z.infer<
@@ -888,6 +912,8 @@ export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSc
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>;
export type ListCommandsResponse = z.infer<typeof ListCommandsResponseSchema>;
// ============================================================================
// WebSocket Level Messages (wraps session messages)

View File

@@ -794,6 +794,10 @@ export class Session {
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId);
break;
case "list_commands_request":
await this.handleListCommandsRequest(msg.agentId, msg.requestId);
break;
}
} catch (error: any) {
console.error(
@@ -1818,6 +1822,73 @@ export class Session {
}
}
/**
* Handle list commands request for an agent
*/
private async handleListCommandsRequest(agentId: string, requestId?: string): Promise<void> {
console.log(
`[Session ${this.clientId}] Handling list commands request for agent ${agentId}`
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (!agent) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Agent not found: ${agentId}`,
requestId,
},
});
return;
}
const session = agent.session;
if (!session || !session.listCommands) {
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: `Agent does not support listing commands`,
requestId,
},
});
return;
}
const commands = await session.listCommands();
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands,
error: null,
requestId,
},
});
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to list commands:`,
error
);
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: error.message,
requestId,
},
});
}
}
/**
* Handle agent permission response from user
*/

View File

@@ -12,6 +12,7 @@ import type {
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
AgentSlashCommand,
} from "../agent/agent-sdk-types.js";
import { getAgentProviderDefinition } from "../agent/provider-registry.js";
@@ -616,6 +617,41 @@ export class DaemonClient {
);
}
// ============================================================================
// Commands
// ============================================================================
async listCommands(agentId: string): Promise<{
agentId: string;
commands: AgentSlashCommand[];
error: string | null;
}> {
const startPosition = this.messageQueue.length;
this.send({
type: "list_commands_request",
agentId,
});
return this.waitFor(
(msg) => {
if (
msg.type === "list_commands_response" &&
msg.payload.agentId === agentId
) {
return {
agentId: msg.payload.agentId,
commands: msg.payload.commands ?? [],
error: msg.payload.error ?? null,
};
}
return null;
},
30000,
{ skipQueueBefore: startPosition }
);
}
// ============================================================================
// Permissions
// ============================================================================