diff --git a/docs/TASKS.md b/docs/TASKS.md index 6db2945..30a0309 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -76,8 +76,8 @@ | P7-021 | done | Phase 7 | Verify Phase 7 — feature-complete platform E2E | — | #132 W10 done | | P8-005 | done | Phase 8 | CLI command architecture — DB schema + brain repo + gateway endpoints | #158 | | | P8-006 | done | Phase 8 | CLI command architecture — agent, mission, prdy commands + TUI mods | #158 | | -| P8-007 | not-started | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | — | #160 | -| P8-008 | not-started | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | — | #161 | +| P8-007 | done | Phase 8 | DB migrations — preferences.mutable + teams + team_members + projects.teamId | #175 | #160 | +| P8-008 | done | Phase 8 | @mosaic/types — CommandDef, CommandManifest, new socket events | #174 | #161 | | P8-009 | not-started | Phase 8 | TUI Phase 1 — slash command parsing, local commands, system message rendering, InputBar wiring | — | #162 | | P8-010 | not-started | Phase 8 | Gateway Phase 2 — CommandRegistryService, CommandExecutorService, socket + REST commands | — | #163 | | P8-011 | not-started | Phase 8 | Gateway Phase 3 — PreferencesService, /preferences REST, /system Valkey override, prompt injection | — | #164 | diff --git a/docs/scratchpads/p8-009-tui-slash-commands.md b/docs/scratchpads/p8-009-tui-slash-commands.md new file mode 100644 index 0000000..7e6cb93 --- /dev/null +++ b/docs/scratchpads/p8-009-tui-slash-commands.md @@ -0,0 +1,40 @@ +# P8-009: TUI Phase 1 — Slash Command Parsing + +## Task Reference + +- Issue: #162 +- Branch: feat/p8-009-tui-slash-commands + +## Scope + +- New files: parse.ts, registry.ts, local/help.ts, local/status.ts, commands/index.ts +- Modified files: use-socket.ts, input-bar.tsx, message-list.tsx, app.tsx + +## Key Observations + +- CommandDef in @mosaic/types does NOT have `category` field — will omit from LOCAL_COMMANDS +- CommandDef.args is `CommandArgDef[] | undefined`, not `{ usage: string }` — help.ts args rendering needs adjustment +- Message role union currently: 'user' | 'assistant' | 'thinking' | 'tool' — adding 'system' +- InputBar currently takes `onSubmit: (value: string) => void` — need to add slash command interception +- app.tsx passes `onSubmit={socket.sendMessage}` directly — needs command-aware handler + +## Assumptions + +- ASSUMPTION: `category` field not in CommandDef type — will skip category grouping in help output, or add it only to registry (not to CommandDef type) +- ASSUMPTION: For the `args` field display in help, will use `CommandArgDef.name` and `CommandArgDef.description` +- ASSUMPTION: `commands:manifest` event type may not be in ServerToClientEvents — will handle via socket.on with casting if needed + +## Status + +- [ ] Create commands directory structure +- [ ] Implement parse.ts +- [ ] Implement registry.ts +- [ ] Implement local/help.ts +- [ ] Implement local/status.ts +- [ ] Implement commands/index.ts +- [ ] Modify use-socket.ts +- [ ] Modify input-bar.tsx +- [ ] Modify message-list.tsx +- [ ] Modify app.tsx +- [ ] Run quality gates +- [ ] Commit + Push + PR + CI diff --git a/packages/cli/src/tui/app.tsx b/packages/cli/src/tui/app.tsx index 046aa92..09a60a8 100644 --- a/packages/cli/src/tui/app.tsx +++ b/packages/cli/src/tui/app.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { Box, useApp, useInput } from 'ink'; +import type { ParsedCommand } from '@mosaic/types'; import { TopBar } from './components/top-bar.js'; import { BottomBar } from './components/bottom-bar.js'; import { MessageList } from './components/message-list.js'; @@ -12,6 +13,7 @@ import { useViewport } from './hooks/use-viewport.js'; import { useAppMode } from './hooks/use-app-mode.js'; import { useConversations } from './hooks/use-conversations.js'; import { useSearch } from './hooks/use-search.js'; +import { executeHelp, executeStatus } from './commands/index.js'; export interface TuiAppProps { gatewayUrl: string; @@ -71,6 +73,63 @@ export function TuiApp({ const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0); + const handleLocalCommand = useCallback( + (parsed: ParsedCommand) => { + switch (parsed.command) { + case 'help': + case 'h': { + const result = executeHelp(parsed); + socket.addSystemMessage(result); + break; + } + case 'status': + case 's': { + const result = executeStatus(parsed, { + connected: socket.connected, + model: socket.modelName, + provider: socket.providerName, + sessionId: socket.conversationId ?? null, + tokenCount: socket.tokenUsage.total, + }); + socket.addSystemMessage(result); + break; + } + case 'clear': + socket.clearMessages(); + break; + case 'stop': + // Currently no stop mechanism exposed — show feedback + socket.addSystemMessage('Stop is not available for the current session.'); + break; + case 'cost': { + const u = socket.tokenUsage; + socket.addSystemMessage( + `Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`, + ); + break; + } + default: + socket.addSystemMessage(`Local command not implemented: /${parsed.command}`); + } + }, + [socket], + ); + + const handleGatewayCommand = useCallback( + (parsed: ParsedCommand) => { + if (!socket.socketRef.current?.connected || !socket.conversationId) { + socket.addSystemMessage('Not connected to gateway. Command cannot be executed.'); + return; + } + socket.socketRef.current.emit('command:execute', { + conversationId: socket.conversationId, + command: parsed.command, + args: parsed.args ?? undefined, + }); + }, + [socket], + ); + const handleSwitchConversation = useCallback( (id: string) => { socket.switchConversation(id); @@ -202,6 +261,9 @@ export function TuiApp({ 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : ''; + const argsStr = + cmd.args && cmd.args.length > 0 + ? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ') + : ''; + lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`); + } + + return lines.join('\n').trimEnd(); +} diff --git a/packages/cli/src/tui/commands/local/status.ts b/packages/cli/src/tui/commands/local/status.ts new file mode 100644 index 0000000..e136a51 --- /dev/null +++ b/packages/cli/src/tui/commands/local/status.ts @@ -0,0 +1,20 @@ +import type { ParsedCommand } from '@mosaic/types'; + +export interface StatusContext { + connected: boolean; + model: string | null; + provider: string | null; + sessionId: string | null; + tokenCount: number; +} + +export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string { + const lines = [ + `Connection: ${ctx.connected ? 'connected' : 'disconnected'}`, + `Model: ${ctx.model ?? 'unknown'}`, + `Provider: ${ctx.provider ?? 'unknown'}`, + `Session: ${ctx.sessionId ?? 'none'}`, + `Tokens (session): ${ctx.tokenCount}`, + ]; + return lines.join('\n'); +} diff --git a/packages/cli/src/tui/commands/parse.ts b/packages/cli/src/tui/commands/parse.ts new file mode 100644 index 0000000..17f2eaa --- /dev/null +++ b/packages/cli/src/tui/commands/parse.ts @@ -0,0 +1,11 @@ +import type { ParsedCommand } from '@mosaic/types'; + +export function parseSlashCommand(input: string): ParsedCommand | null { + const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i); + if (!match) return null; + return { + command: match[1]!, + args: match[2]?.trim() || null, + raw: input, + }; +} diff --git a/packages/cli/src/tui/commands/registry.ts b/packages/cli/src/tui/commands/registry.ts new file mode 100644 index 0000000..79fbe7a --- /dev/null +++ b/packages/cli/src/tui/commands/registry.ts @@ -0,0 +1,98 @@ +import type { CommandDef, CommandManifest } from '@mosaic/types'; + +// Local-only commands (work even when gateway is disconnected) +const LOCAL_COMMANDS: CommandDef[] = [ + { + name: 'help', + description: 'Show available commands', + aliases: ['h'], + args: undefined, + execution: 'local', + available: true, + scope: 'core', + }, + { + name: 'stop', + description: 'Cancel current streaming response', + aliases: [], + args: undefined, + execution: 'local', + available: true, + scope: 'core', + }, + { + name: 'cost', + description: 'Show token usage and cost for current session', + aliases: [], + args: undefined, + execution: 'local', + available: true, + scope: 'core', + }, + { + name: 'status', + description: 'Show connection and session status', + aliases: ['s'], + args: undefined, + execution: 'local', + available: true, + scope: 'core', + }, + { + name: 'clear', + description: 'Clear the current conversation display', + aliases: [], + args: undefined, + execution: 'local', + available: true, + scope: 'core', + }, +]; + +const ALIASES: Record = { + n: 'new', + m: 'model', + t: 'thinking', + a: 'agent', + s: 'status', + h: 'help', + pref: 'preferences', +}; + +export class CommandRegistry { + private gatewayManifest: CommandManifest | null = null; + + updateManifest(manifest: CommandManifest): void { + this.gatewayManifest = manifest; + } + + resolveAlias(command: string): string { + return ALIASES[command] ?? command; + } + + find(command: string): CommandDef | null { + const resolved = this.resolveAlias(command); + // Search local first, then gateway manifest + const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved)); + if (local) return local; + if (this.gatewayManifest) { + return ( + this.gatewayManifest.commands.find( + (c) => c.name === resolved || c.aliases.includes(resolved), + ) ?? null + ); + } + return null; + } + + getAll(): CommandDef[] { + const gateway = this.gatewayManifest?.commands ?? []; + return [...LOCAL_COMMANDS, ...gateway]; + } + + getLocalCommands(): CommandDef[] { + return LOCAL_COMMANDS; + } +} + +export const commandRegistry = new CommandRegistry(); diff --git a/packages/cli/src/tui/components/input-bar.tsx b/packages/cli/src/tui/components/input-bar.tsx index ea40a4e..6feb9a4 100644 --- a/packages/cli/src/tui/components/input-bar.tsx +++ b/packages/cli/src/tui/components/input-bar.tsx @@ -1,9 +1,14 @@ import React, { useState, useCallback } from 'react'; import { Box, Text } from 'ink'; import TextInput from 'ink-text-input'; +import type { ParsedCommand } from '@mosaic/types'; +import { parseSlashCommand, commandRegistry } from '../commands/index.js'; export interface InputBarProps { onSubmit: (value: string) => void; + onSystemMessage?: (message: string) => void; + onLocalCommand?: (parsed: ParsedCommand) => void; + onGatewayCommand?: (parsed: ParsedCommand) => void; isStreaming: boolean; connected: boolean; placeholder?: string; @@ -11,6 +16,9 @@ export interface InputBarProps { export function InputBar({ onSubmit, + onSystemMessage, + onLocalCommand, + onGatewayCommand, isStreaming, connected, placeholder: placeholderOverride, @@ -20,10 +28,39 @@ export function InputBar({ const handleSubmit = useCallback( (value: string) => { if (!value.trim() || isStreaming || !connected) return; + + const trimmed = value.trim(); + + if (trimmed.startsWith('/')) { + const parsed = parseSlashCommand(trimmed); + if (!parsed) { + onSystemMessage?.(`Unknown command format: ${trimmed}`); + setInput(''); + return; + } + const def = commandRegistry.find(parsed.command); + if (!def) { + onSystemMessage?.( + `Unknown command: /${parsed.command}. Type /help for available commands.`, + ); + setInput(''); + return; + } + if (def.execution === 'local') { + onLocalCommand?.(parsed); + setInput(''); + return; + } + // Gateway-executed commands + onGatewayCommand?.(parsed); + setInput(''); + return; + } + onSubmit(value); setInput(''); }, - [onSubmit, isStreaming, connected], + [onSubmit, onSystemMessage, onLocalCommand, onGatewayCommand, isStreaming, connected], ); const placeholder = diff --git a/packages/cli/src/tui/components/message-list.tsx b/packages/cli/src/tui/components/message-list.tsx index beb09fb..659fba1 100644 --- a/packages/cli/src/tui/components/message-list.tsx +++ b/packages/cli/src/tui/components/message-list.tsx @@ -24,6 +24,17 @@ function formatTime(date: Date): string { }); } +function SystemMessageBubble({ msg }: { msg: Message }) { + return ( + + {'⚙ '} + + {msg.content} + + + ); +} + function MessageBubble({ msg, highlight, @@ -31,6 +42,10 @@ function MessageBubble({ msg: Message; highlight?: 'match' | 'current' | undefined; }) { + if (msg.role === 'system') { + return ; + } + const isUser = msg.role === 'user'; const prefix = isUser ? '❯' : '◆'; const color = isUser ? 'green' : 'cyan'; diff --git a/packages/cli/src/tui/hooks/use-socket.ts b/packages/cli/src/tui/hooks/use-socket.ts index 93229fa..1bc5600 100644 --- a/packages/cli/src/tui/hooks/use-socket.ts +++ b/packages/cli/src/tui/hooks/use-socket.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react'; import { io, type Socket } from 'socket.io-client'; import type { ServerToClientEvents, @@ -11,7 +11,9 @@ import type { ToolEndPayload, SessionInfoPayload, ErrorPayload, + CommandManifestPayload, } from '@mosaic/types'; +import { commandRegistry } from '../commands/index.js'; export interface ToolCall { toolCallId: string; @@ -20,7 +22,7 @@ export interface ToolCall { } export interface Message { - role: 'user' | 'assistant' | 'thinking' | 'tool'; + role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system'; content: string; timestamp: Date; toolCalls?: ToolCall[]; @@ -46,6 +48,8 @@ export interface UseSocketOptions { agentId?: string; } +type TypedSocket = Socket; + export interface UseSocketReturn { connected: boolean; connecting: boolean; @@ -61,14 +65,14 @@ export interface UseSocketReturn { thinkingLevel: string; availableThinkingLevels: string[]; sendMessage: (content: string) => void; + addSystemMessage: (content: string) => void; setThinkingLevel: (level: string) => void; switchConversation: (id: string) => void; clearMessages: () => void; connectionError: string | null; + socketRef: MutableRefObject; } -type TypedSocket = Socket; - const EMPTY_USAGE: TokenUsage = { input: 0, output: 0, @@ -222,6 +226,10 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { setIsStreaming(false); }); + socket.on('commands:manifest', (data: CommandManifestPayload) => { + commandRegistry.updateManifest(data.manifest); + }); + return () => { socket.disconnect(); }; @@ -245,6 +253,10 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { [conversationId, isStreaming], ); + const addSystemMessage = useCallback((content: string) => { + setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]); + }, []); + const setThinkingLevel = useCallback((level: string) => { const cid = conversationIdRef.current; if (!socketRef.current?.connected || !cid) return; @@ -285,9 +297,11 @@ export function useSocket(opts: UseSocketOptions): UseSocketReturn { thinkingLevel, availableThinkingLevels, sendMessage, + addSystemMessage, setThinkingLevel, switchConversation, clearMessages, connectionError, + socketRef, }; }