feat(cli): TUI slash command parsing + local commands (P8-009) (#176)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #176.
This commit is contained in:
2026-03-16 01:58:56 +00:00
committed by jason.woltje
parent 5a1991924c
commit f0741e045f
11 changed files with 328 additions and 7 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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({
<InputBar
onSubmit={socket.sendMessage}
onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand}
onGatewayCommand={handleGatewayCommand}
isStreaming={socket.isStreaming}
connected={socket.connected}
placeholder={inputPlaceholder}

View File

@@ -0,0 +1,5 @@
export { parseSlashCommand } from './parse.js';
export { commandRegistry, CommandRegistry } from './registry.js';
export { executeHelp } from './local/help.js';
export { executeStatus } from './local/status.js';
export type { StatusContext } from './local/status.js';

View File

@@ -0,0 +1,19 @@
import type { ParsedCommand } from '@mosaic/types';
import { commandRegistry } from '../registry.js';
export function executeHelp(_parsed: ParsedCommand): string {
const commands = commandRegistry.getAll();
const lines = ['Available commands:', ''];
for (const cmd of commands) {
const aliases =
cmd.aliases.length > 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();
}

View File

@@ -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');
}

View File

@@ -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,
};
}

View File

@@ -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<string, string> = {
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();

View File

@@ -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 =

View File

@@ -24,6 +24,17 @@ function formatTime(date: Date): string {
});
}
function SystemMessageBubble({ msg }: { msg: Message }) {
return (
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
<Text dimColor>{'⚙ '}</Text>
<Text dimColor wrap="wrap">
{msg.content}
</Text>
</Box>
);
}
function MessageBubble({
msg,
highlight,
@@ -31,6 +42,10 @@ function MessageBubble({
msg: Message;
highlight?: 'match' | 'current' | undefined;
}) {
if (msg.role === 'system') {
return <SystemMessageBubble msg={msg} />;
}
const isUser = msg.role === 'user';
const prefix = isUser ? '' : '◆';
const color = isUser ? 'green' : 'cyan';

View File

@@ -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<ServerToClientEvents, ClientToServerEvents>;
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<TypedSocket | null>;
}
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
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,
};
}