Restructure src/ modules with consistent internal organization

Reorganize major src/ directories to follow a consistent pattern with
core/, menu/, submenu/, inputs/, logs/, layout/, feedback/ subdirectories.

Changes by module:

- stores/: Move 5 store files to stores/core/
- utils/: Create core/ (terminal, tools, etc.) and menu/ (progress-bar)
- api/: Create copilot/core/, copilot/auth/, ollama/core/
- providers/: Create core/, copilot/core/, copilot/auth/, ollama/core/, login/core/
- ui/: Create core/, banner/core/, banner/menu/, spinner/core/,
       input-editor/core/, components/core/, components/menu/
- tools/: Create core/ for registry.ts and types.ts
- tui-solid/: Reorganize components/ into menu/, submenu/, inputs/,
              logs/, modals/, panels/, layout/, feedback/
- commands/: Create core/ for runner.ts and handlers.ts
- services/: Create core/ for agent.ts, permissions.ts, session.ts,
             executor.ts, config.ts

All imports updated to use new paths. TypeScript compilation verified.
This commit is contained in:
2026-02-04 18:47:03 -05:00
parent c1b4384890
commit f0609e423e
191 changed files with 3162 additions and 824 deletions

View File

@@ -1,202 +0,0 @@
import { For, createSignal, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { TextAttributes } from "@opentui/core";
import type { ScrollBoxRenderable } from "@opentui/core";
import { useTheme } from "@tui-solid/context/theme";
import { useAppStore } from "@tui-solid/context/app";
const SCROLL_LINES = 2;
interface DebugEntry {
id: string;
timestamp: number;
type: "api" | "stream" | "tool" | "state" | "error" | "info" | "render";
message: string;
}
// Global debug log store
let debugEntries: DebugEntry[] = [];
let debugIdCounter = 0;
let listeners: Array<() => void> = [];
const notifyListeners = (): void => {
for (const listener of listeners) {
listener();
}
};
export const addDebugLog = (
type: DebugEntry["type"],
message: string,
): void => {
const entry: DebugEntry = {
id: `debug-${++debugIdCounter}`,
timestamp: Date.now(),
type,
message,
};
debugEntries.push(entry);
// Keep only last 500 entries
if (debugEntries.length > 500) {
debugEntries = debugEntries.slice(-500);
}
notifyListeners();
};
export const clearDebugLogs = (): void => {
debugEntries = [];
debugIdCounter = 0;
notifyListeners();
};
export function DebugLogPanel() {
const theme = useTheme();
const app = useAppStore();
let scrollboxRef: ScrollBoxRenderable | undefined;
const [entries, setEntries] = createSignal<DebugEntry[]>([...debugEntries]);
const [stickyEnabled, setStickyEnabled] = createSignal(true);
const isActive = () => app.debugLogVisible();
onMount(() => {
const updateEntries = (): void => {
setEntries([...debugEntries]);
if (stickyEnabled() && scrollboxRef) {
scrollboxRef.scrollTo(Infinity);
}
};
listeners.push(updateEntries);
onCleanup(() => {
listeners = listeners.filter((l) => l !== updateEntries);
});
});
const getTypeColor = (type: DebugEntry["type"]): string => {
const colorMap: Record<DebugEntry["type"], string> = {
api: theme.colors.info,
stream: theme.colors.success,
tool: theme.colors.warning,
state: theme.colors.accent,
error: theme.colors.error,
info: theme.colors.textDim,
render: theme.colors.primary,
};
return colorMap[type];
};
const getTypeLabel = (type: DebugEntry["type"]): string => {
const labelMap: Record<DebugEntry["type"], string> = {
api: "API",
stream: "STR",
tool: "TUL",
state: "STA",
error: "ERR",
info: "INF",
render: "RND",
};
return labelMap[type];
};
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
};
const scrollUp = (): void => {
if (!scrollboxRef) return;
setStickyEnabled(false);
scrollboxRef.scrollBy(-SCROLL_LINES);
};
const scrollDown = (): void => {
if (!scrollboxRef) return;
scrollboxRef.scrollBy(SCROLL_LINES);
const isAtBottom =
scrollboxRef.scrollTop >=
scrollboxRef.content.height - scrollboxRef.viewport.height - 1;
if (isAtBottom) {
setStickyEnabled(true);
}
};
useKeyboard((evt) => {
if (!isActive()) return;
if (evt.shift && evt.name === "pageup") {
scrollUp();
evt.preventDefault();
evt.stopPropagation();
return;
}
if (evt.shift && evt.name === "pagedown") {
scrollDown();
evt.preventDefault();
evt.stopPropagation();
}
});
const truncateMessage = (msg: string, maxLen: number): string => {
if (msg.length <= maxLen) return msg;
return msg.substring(0, maxLen - 3) + "...";
};
return (
<box
flexDirection="column"
width="20%"
borderColor={theme.colors.border}
border={["top", "bottom", "left", "right"]}
backgroundColor={theme.colors.background}
>
<box
paddingLeft={1}
paddingRight={1}
borderColor={theme.colors.border}
border={["bottom"]}
flexDirection="row"
>
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
Debug Logs ({entries().length})
</text>
</box>
<scrollbox
ref={scrollboxRef}
stickyScroll={stickyEnabled()}
stickyStart="bottom"
flexGrow={1}
paddingLeft={1}
paddingRight={1}
>
<box flexDirection="column">
<For each={entries()}>
{(entry) => (
<box flexDirection="row">
<text fg={theme.colors.textDim}>
{formatTime(entry.timestamp)}{" "}
</text>
<text fg={getTypeColor(entry.type)}>
[{getTypeLabel(entry.type)}]{" "}
</text>
<text fg={theme.colors.text} wrapMode="word">
{truncateMessage(entry.message, 50)}
</text>
</box>
)}
</For>
</box>
</scrollbox>
<box
paddingLeft={1}
borderColor={theme.colors.border}
border={["top"]}
>
<text fg={theme.colors.textDim}>Shift+PgUp/PgDn scroll</text>
</box>
</box>
);
}

View File

@@ -0,0 +1,6 @@
/**
* Feedback Components
*/
export * from "./thinking-indicator";
export * from "./bouncing-loader";

View File

@@ -1,27 +1,47 @@
export { StatusBar } from "./status-bar";
export { Logo } from "./logo";
export { ThinkingIndicator } from "./thinking-indicator";
export { BouncingLoader } from "./bouncing-loader";
export { LogPanel } from "./log-panel";
export { LogEntryDisplay } from "./log-entry";
export { StreamingMessage } from "./streaming-message";
export { InputArea } from "./input-area";
export { Header } from "./header";
export { CommandMenu, SLASH_COMMANDS } from "./command-menu";
export { ModelSelect } from "./model-select";
export { AgentSelect } from "./agent-select";
export { ThemeSelect } from "./theme-select";
export { MCPSelect } from "./mcp-select";
export { MCPAddForm } from "./mcp-add-form";
export { ModeSelect } from "./mode-select";
export { ProviderSelect } from "./provider-select";
export { FilePicker } from "./file-picker";
export { SelectMenu } from "./select-menu";
export type { SelectOption } from "./select-menu";
export { PermissionModal } from "./permission-modal";
export { LearningModal } from "./learning-modal";
export { HelpMenu } from "./help-menu";
export { HelpDetail } from "./help-detail";
export { TodoPanel } from "./todo-panel";
export type { TodoItem, Plan } from "./todo-panel";
export { DiffView, parseDiffOutput, isDiffContent } from "./diff-view";
// Layout components
export { StatusBar } from "./layout/status-bar";
export { Logo } from "./layout/logo";
export { StreamingMessage } from "./layout/streaming-message";
export { Header } from "./layout/header";
// Feedback components
export { ThinkingIndicator } from "./feedback/thinking-indicator";
export { BouncingLoader } from "./feedback/bouncing-loader";
// Log components
export { LogPanel } from "./logs/log-panel";
export { LogEntryDisplay } from "./logs/log-entry";
export { addDebugLog, DebugLogPanel } from "./logs/debug-log-panel";
// Input components
export { InputArea } from "./inputs/input-area";
export { FilePicker } from "./inputs/file-picker";
export { MCPAddForm } from "./inputs/mcp-add-form";
// Menu components
export { CommandMenu, SLASH_COMMANDS } from "./menu/command-menu";
export { SelectMenu } from "./menu/select-menu";
export type { SelectOption } from "./menu/select-menu";
export { HelpMenu } from "./menu/help-menu";
export { BrainMenu } from "./menu/brain-menu";
// Submenu components
export { ModelSelect } from "./submenu/model-select";
export { AgentSelect } from "./submenu/agent-select";
export { ThemeSelect } from "./submenu/theme-select";
export { MCPSelect } from "./submenu/mcp-select";
export { ModeSelect } from "./submenu/mode-select";
export { ProviderSelect } from "./submenu/provider-select";
// Modal components
export { PermissionModal } from "./modals/permission-modal";
export { LearningModal } from "./modals/learning-modal";
export { CenteredModal } from "./modals/centered-modal";
export { ConflictResolver, ConflictIndicator } from "./modals/conflict-resolver";
// Panel components
export { HelpDetail } from "./panels/help-detail";
export { TodoPanel } from "./panels/todo-panel";
export type { TodoItem, Plan } from "./panels/todo-panel";
export { DiffView, parseDiffOutput, isDiffContent } from "./panels/diff-view";
export { MultiAgentPanel } from "./panels/multi-agent-panel";

View File

@@ -0,0 +1,7 @@
/**
* Input Components
*/
export * from "./input-area";
export * from "./file-picker";
export * from "./mcp-add-form";

View File

@@ -0,0 +1,8 @@
/**
* Layout Components
*/
export * from "./header";
export * from "./status-bar";
export * from "./logo";
export * from "./streaming-message";

View File

@@ -4,7 +4,7 @@ import { useTheme } from "@tui-solid/context/theme";
import { useAppStore } from "@tui-solid/context/app";
import type { LogEntry } from "@/types/tui";
import { Spinner } from "@tui-solid/ui/spinner";
import { addDebugLog } from "@tui-solid/components/debug-log-panel";
import { addDebugLog } from "@tui-solid/components/logs/debug-log-panel";
interface StreamingMessageProps {
entry: LogEntry;

View File

@@ -1,236 +0,0 @@
import { Show, Switch, Match, For } from "solid-js";
import { TextAttributes } from "@opentui/core";
import { useTheme } from "@tui-solid/context/theme";
import type { LogEntry, ToolStatus } from "@/types/tui";
import {
TOOL_STATUS_ICONS,
TOOL_STATUS_COLORS,
} from "@constants/tui-components";
import { DiffView } from "@tui-solid/components/diff-view";
import { StreamingMessage } from "@tui-solid/components/streaming-message";
import { parseDiffOutput, isDiffContent } from "@utils/diff/index";
interface LogEntryDisplayProps {
entry: LogEntry;
}
function UserEntry(props: { entry: LogEntry }) {
const theme = useTheme();
return (
<box flexDirection="column" marginBottom={1}>
<text fg={theme.colors.roleUser} attributes={TextAttributes.BOLD}>
You
</text>
<box marginLeft={2}>
<text>{props.entry.content}</text>
</box>
</box>
);
}
function AssistantEntry(props: { entry: LogEntry }) {
const theme = useTheme();
return (
<box flexDirection="column" marginBottom={1}>
<text fg={theme.colors.roleAssistant} attributes={TextAttributes.BOLD}>
CodeTyper
</text>
<box marginLeft={2}>
<text wrapMode="word">{props.entry.content}</text>
</box>
</box>
);
}
function ErrorEntry(props: { entry: LogEntry }) {
const theme = useTheme();
return (
<box marginBottom={1}>
<text fg={theme.colors.error}> Error: {props.entry.content}</text>
</box>
);
}
function SystemEntry(props: { entry: LogEntry }) {
const theme = useTheme();
return (
<box marginBottom={1}>
<text fg={theme.colors.textDim}> {props.entry.content}</text>
</box>
);
}
function ThinkingEntry(props: { entry: LogEntry }) {
const theme = useTheme();
return (
<box marginBottom={1}>
<text fg={theme.colors.modeThinking}> {props.entry.content}</text>
</box>
);
}
function ToolEntry(props: { entry: LogEntry }) {
const theme = useTheme();
const toolStatus = (): ToolStatus =>
props.entry.metadata?.toolStatus ?? "pending";
const statusIcon = () => TOOL_STATUS_ICONS[toolStatus()];
const statusColor = (): string => {
const colorKey = TOOL_STATUS_COLORS[toolStatus()];
const color = theme.colors[colorKey as keyof typeof theme.colors];
if (typeof color === "string") return color;
return theme.colors.textDim;
};
const hasDiff = () =>
props.entry.metadata?.diffData?.isDiff ||
isDiffContent(props.entry.content);
const isMultiline = () => props.entry.content.includes("\n");
const lines = () => props.entry.content.split("\n");
return (
<Switch
fallback={
<DefaultToolEntry
{...props}
statusIcon={statusIcon()}
statusColor={statusColor()}
/>
}
>
<Match when={hasDiff() && props.entry.metadata?.toolStatus === "success"}>
<DiffToolEntry
{...props}
statusIcon={statusIcon()}
statusColor={statusColor()}
/>
</Match>
<Match when={isMultiline()}>
<MultilineToolEntry
{...props}
statusIcon={statusIcon()}
statusColor={statusColor()}
lines={lines()}
/>
</Match>
</Switch>
);
}
function DiffToolEntry(props: {
entry: LogEntry;
statusIcon: string;
statusColor: string;
}) {
const theme = useTheme();
const diffData = () => parseDiffOutput(props.entry.content);
return (
<box flexDirection="column" marginBottom={1}>
<box flexDirection="row">
<text fg={props.statusColor}>{props.statusIcon} </text>
<text fg={theme.colors.roleTool}>
{props.entry.metadata?.toolName ?? "tool"}
</text>
<Show when={props.entry.metadata?.toolDescription}>
<text fg={theme.colors.textDim}>: </text>
<text fg={theme.colors.textDim}>
{props.entry.metadata?.toolDescription}
</text>
</Show>
</box>
<box marginLeft={2}>
<DiffView
lines={diffData().lines}
filePath={diffData().filePath}
additions={diffData().additions}
deletions={diffData().deletions}
/>
</box>
</box>
);
}
function MultilineToolEntry(props: {
entry: LogEntry;
statusIcon: string;
statusColor: string;
lines: string[];
}) {
const theme = useTheme();
const hasDescription = () => Boolean(props.entry.metadata?.toolDescription);
return (
<box flexDirection="column" marginBottom={1}>
<box flexDirection="row">
<text fg={props.statusColor}>{props.statusIcon} </text>
<text fg={theme.colors.roleTool}>
{props.entry.metadata?.toolName ?? "tool"}
</text>
<text fg={theme.colors.textDim}>: </text>
<text fg={theme.colors.textDim}>
{props.entry.metadata?.toolDescription ?? props.lines[0]}
</text>
</box>
<box flexDirection="column" marginLeft={2}>
<For each={hasDescription() ? props.lines : props.lines.slice(1)}>
{(line) => <text fg={theme.colors.textDim}>{line}</text>}
</For>
</box>
</box>
);
}
function DefaultToolEntry(props: {
entry: LogEntry;
statusIcon: string;
statusColor: string;
}) {
const theme = useTheme();
return (
<box marginBottom={1} flexDirection="row">
<text fg={props.statusColor}>{props.statusIcon} </text>
<text fg={theme.colors.roleTool}>
{props.entry.metadata?.toolName ?? "tool"}
</text>
<text fg={theme.colors.textDim}>: </text>
<text fg={theme.colors.textDim}>{props.entry.content}</text>
</box>
);
}
function DefaultEntry(props: { entry: LogEntry }) {
return (
<box marginBottom={1}>
<text>{props.entry.content}</text>
</box>
);
}
export function LogEntryDisplay(props: LogEntryDisplayProps) {
return (
<Switch fallback={<DefaultEntry entry={props.entry} />}>
<Match when={props.entry.type === "user"}>
<UserEntry entry={props.entry} />
</Match>
<Match when={props.entry.type === "assistant"}>
<AssistantEntry entry={props.entry} />
</Match>
<Match when={props.entry.type === "assistant_streaming"}>
<StreamingMessage entry={props.entry} />
</Match>
<Match when={props.entry.type === "tool"}>
<ToolEntry entry={props.entry} />
</Match>
<Match when={props.entry.type === "error"}>
<ErrorEntry entry={props.entry} />
</Match>
<Match when={props.entry.type === "system"}>
<SystemEntry entry={props.entry} />
</Match>
<Match when={props.entry.type === "thinking"}>
<ThinkingEntry entry={props.entry} />
</Match>
</Switch>
);
}

View File

@@ -1,179 +0,0 @@
import { createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import type { ScrollBoxRenderable } from "@opentui/core";
import { useTheme } from "@tui-solid/context/theme";
import { useAppStore } from "@tui-solid/context/app";
import { LogEntryDisplay } from "@tui-solid/components/log-entry";
import { ASCII_LOGO, ASCII_LOGO_GRADIENT, HOME_VARS } from "@constants/home";
const SCROLL_LINES = 3;
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1006l";
const SGR_MOUSE_PATTERN = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
const parseMouseScroll = (data: string): "up" | "down" | null => {
const match = data.match(SGR_MOUSE_PATTERN);
if (!match) return null;
const button = parseInt(match[1], 10);
if (button === 64) return "up";
if (button === 65) return "down";
return null;
};
export function LogPanel() {
const theme = useTheme();
const app = useAppStore();
let scrollboxRef: ScrollBoxRenderable | undefined;
const [stickyEnabled, setStickyEnabled] = createSignal(true);
const logs = createMemo(() => {
return app.logs().filter((entry) => {
if (entry.type !== "tool") return true;
if (entry.metadata?.quiet) return false;
return true;
});
});
const hasContent = createMemo(() => logs().length > 0);
const canScroll = createMemo(() => {
const mode = app.mode();
return (
mode === "idle" ||
mode === "thinking" ||
mode === "tool_execution" ||
mode === "editing"
);
});
const scrollUp = (): void => {
if (!scrollboxRef) return;
setStickyEnabled(false);
scrollboxRef.scrollBy(-SCROLL_LINES);
};
const scrollDown = (): void => {
if (!scrollboxRef) return;
scrollboxRef.scrollBy(SCROLL_LINES);
const isAtBottom =
scrollboxRef.scrollTop >=
scrollboxRef.content.height - scrollboxRef.viewport.height - 1;
if (isAtBottom) {
setStickyEnabled(true);
}
};
const scrollToBottom = (): void => {
if (!scrollboxRef) return;
scrollboxRef.scrollTo(Infinity);
setStickyEnabled(true);
};
useKeyboard((evt) => {
if (!canScroll()) return;
if (evt.shift && evt.name === "up") {
scrollUp();
evt.preventDefault();
evt.stopPropagation();
return;
}
if (evt.shift && evt.name === "down") {
scrollDown();
evt.preventDefault();
evt.stopPropagation();
return;
}
if (evt.shift && evt.name === "home") {
if (scrollboxRef) {
setStickyEnabled(false);
scrollboxRef.scrollTo(0);
}
evt.preventDefault();
evt.stopPropagation();
return;
}
if (evt.shift && evt.name === "end") {
scrollToBottom();
evt.preventDefault();
evt.stopPropagation();
return;
}
});
onMount(() => {
process.stdout.write(MOUSE_ENABLE);
const handleData = (data: Buffer): void => {
if (!canScroll()) return;
const str = data.toString();
const direction = parseMouseScroll(str);
if (direction === "up") {
scrollUp();
} else if (direction === "down") {
scrollDown();
}
};
process.stdin.on("data", handleData);
onCleanup(() => {
process.stdout.write(MOUSE_DISABLE);
process.stdin.off("data", handleData);
});
});
return (
<box
flexDirection="column"
flexGrow={1}
paddingLeft={1}
paddingRight={1}
borderColor={theme.colors.border}
border={["top", "bottom", "left", "right"]}
>
<Show
when={hasContent()}
fallback={
<box
flexGrow={1}
alignItems="center"
justifyContent="center"
flexDirection="column"
>
<For each={ASCII_LOGO}>
{(line, index) => (
<text fg={ASCII_LOGO_GRADIENT[index()] ?? theme.colors.primary}>
{line}
</text>
)}
</For>
<box marginTop={2}>
<text fg={theme.colors.textDim}>{HOME_VARS.subTitle}</text>
</box>
</box>
}
>
<scrollbox
ref={scrollboxRef}
stickyScroll={stickyEnabled()}
stickyStart="bottom"
flexGrow={1}
>
<box flexDirection="column">
<For each={logs()}>
{(entry) => <LogEntryDisplay entry={entry} />}
</For>
</box>
</scrollbox>
</Show>
</box>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Menu Components
*/
export * from "./select-menu";
export * from "./command-menu";
export * from "./help-menu";
export * from "./brain-menu";

View File

@@ -0,0 +1,188 @@
/**
* Conflict Resolver
*
* UI component for displaying and resolving file conflicts between agents.
*/
import { For, Show, createSignal, createMemo, onMount, onCleanup } from "solid-js";
import { TextAttributes } from "@opentui/core";
import { useTheme } from "@tui-solid/context/theme";
import { multiAgentStore } from "@stores/core/multi-agent-store";
import type { FileConflict, ConflictStrategy } from "@/types/multi-agent";
import { CONFLICT_STRATEGY_DESCRIPTIONS } from "@constants/multi-agent";
interface ConflictResolverProps {
visible?: boolean;
onResolve?: (filePath: string, strategy: ConflictStrategy) => void;
onDismiss?: () => void;
}
const STRATEGY_OPTIONS: Array<{ value: ConflictStrategy; label: string }> = [
{ value: "serialize", label: "Wait" },
{ value: "abort-newer", label: "Abort Newer" },
{ value: "merge-results", label: "Merge" },
{ value: "isolated", label: "Isolate" },
];
export function ConflictResolver(props: ConflictResolverProps) {
const theme = useTheme();
const visible = () => props.visible ?? true;
const [conflicts, setConflicts] = createSignal<FileConflict[]>([]);
const [selectedConflictIndex, setSelectedConflictIndex] = createSignal(0);
const [selectedStrategyIndex] = createSignal(0);
onMount(() => {
const unsubscribe = multiAgentStore.subscribe((state) => {
const unresolvedConflicts = state.conflicts.filter((c) => !c.resolution);
setConflicts(unresolvedConflicts);
// Reset selection if current conflict was resolved
if (selectedConflictIndex() >= unresolvedConflicts.length) {
setSelectedConflictIndex(Math.max(0, unresolvedConflicts.length - 1));
}
});
onCleanup(unsubscribe);
});
const currentConflict = createMemo(() => conflicts()[selectedConflictIndex()]);
const getAgentNames = (agentIds: string[]): string[] => {
const state = multiAgentStore.getState();
return agentIds.map((id) => {
const instance = state.instances.get(id);
return instance?.definition.name ?? id;
});
};
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const selectedStrategy = createMemo(() => STRATEGY_OPTIONS[selectedStrategyIndex()]);
return (
<Show when={visible() && conflicts().length > 0}>
<box
flexDirection="column"
borderColor={theme.colors.warning}
border={["top", "bottom", "left", "right"]}
padding={1}
backgroundColor={theme.colors.background}
>
{/* Header */}
<box flexDirection="row" justifyContent="space-between" marginBottom={1}>
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
File Conflict Detected
</text>
<text fg={theme.colors.textDim}>
{selectedConflictIndex() + 1}/{conflicts().length}
</text>
</box>
{/* Conflict Details */}
<Show when={currentConflict()}>
<box flexDirection="column" marginBottom={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.colors.textDim}>File:</text>
<text fg={theme.colors.text} attributes={TextAttributes.BOLD}>
{currentConflict()!.filePath}
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.colors.textDim}>Agents:</text>
<text fg={theme.colors.text}>
{getAgentNames(currentConflict()!.conflictingAgentIds).join(" vs ")}
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.colors.textDim}>Detected:</text>
<text fg={theme.colors.text}>
{formatTime(currentConflict()!.detectedAt)}
</text>
</box>
</box>
</Show>
{/* Resolution Options */}
<box flexDirection="column" marginBottom={1}>
<text fg={theme.colors.primary} marginBottom={1}>
Resolution Strategy:
</text>
<For each={STRATEGY_OPTIONS}>
{(option, index) => (
<box
flexDirection="row"
gap={1}
backgroundColor={index() === selectedStrategyIndex() ? theme.colors.bgHighlight : undefined}
paddingLeft={1}
>
<text fg={index() === selectedStrategyIndex() ? theme.colors.primary : theme.colors.textDim}>
{index() === selectedStrategyIndex() ? "▸" : " "}
</text>
<text
fg={index() === selectedStrategyIndex() ? theme.colors.text : theme.colors.textDim}
attributes={index() === selectedStrategyIndex() ? TextAttributes.BOLD : TextAttributes.NONE}
>
{option.label}
</text>
</box>
)}
</For>
</box>
{/* Strategy Description */}
<box
flexDirection="column"
marginBottom={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={theme.colors.backgroundPanel}
>
<text fg={theme.colors.textDim} wrapMode="word">
{CONFLICT_STRATEGY_DESCRIPTIONS[selectedStrategy().value]}
</text>
</box>
{/* Actions */}
<box flexDirection="row" gap={2} justifyContent="flex-end">
<text fg={theme.colors.textDim}>
[/] Select [Enter] Resolve [Esc] Dismiss
</text>
</box>
</box>
</Show>
);
}
/**
* Compact conflict indicator for status bar
*/
export function ConflictIndicator() {
const theme = useTheme();
const [conflictCount, setConflictCount] = createSignal(0);
onMount(() => {
const unsubscribe = multiAgentStore.subscribe((state) => {
const unresolvedCount = state.conflicts.filter((c) => !c.resolution).length;
setConflictCount(unresolvedCount);
});
onCleanup(unsubscribe);
});
return (
<Show when={conflictCount() > 0}>
<box flexDirection="row" gap={1}>
<text fg={theme.colors.warning}>
{conflictCount()} conflict{conflictCount() > 1 ? "s" : ""}
</text>
</box>
</Show>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Modal Components
*/
export * from "./centered-modal";
export * from "./permission-modal";
export * from "./learning-modal";
export * from "./conflict-resolver";

View File

@@ -0,0 +1,8 @@
/**
* Panel Components
*/
export * from "./todo-panel";
export * from "./diff-view";
export * from "./multi-agent-panel";
export * from "./help-detail";

View File

@@ -0,0 +1,202 @@
/**
* Multi-Agent Panel
*
* Displays active agents, their status, and execution progress.
*/
import { For, Show, createMemo, createSignal, onMount, onCleanup } from "solid-js";
import { TextAttributes } from "@opentui/core";
import { useTheme } from "@tui-solid/context/theme";
import { multiAgentStore } from "@stores/core/multi-agent-store";
import type { AgentInstance, AgentInstanceStatus } from "@/types/multi-agent";
interface MultiAgentPanelProps {
visible?: boolean;
onSelectAgent?: (agentId: string) => void;
}
const STATUS_ICONS: Record<AgentInstanceStatus, string> = {
pending: "◯",
running: "●",
waiting_conflict: "⏸",
completed: "✓",
error: "✗",
cancelled: "⊘",
};
export function MultiAgentPanel(props: MultiAgentPanelProps) {
const theme = useTheme();
const visible = () => props.visible ?? true;
const [instances, setInstances] = createSignal<AgentInstance[]>([]);
const [selectedIndex] = createSignal(0);
onMount(() => {
const unsubscribe = multiAgentStore.subscribe((state) => {
setInstances(Array.from(state.instances.values()));
});
onCleanup(unsubscribe);
});
const stats = createMemo(() => {
const all = instances();
return {
running: all.filter((i) => i.status === "running").length,
waiting: all.filter((i) => i.status === "waiting_conflict").length,
completed: all.filter((i) => i.status === "completed").length,
failed: all.filter((i) => i.status === "error" || i.status === "cancelled").length,
total: all.length,
};
});
const getStatusColor = (status: AgentInstanceStatus): string => {
const colorMap: Record<AgentInstanceStatus, keyof typeof theme.colors> = {
pending: "textDim",
running: "info",
waiting_conflict: "warning",
completed: "success",
error: "error",
cancelled: "textDim",
};
return theme.colors[colorMap[status]] as string;
};
const getDuration = (instance: AgentInstance): string => {
const end = instance.completedAt ?? Date.now();
const duration = end - instance.startedAt;
const seconds = Math.floor(duration / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
const truncateText = (text: string, maxLen: number): string => {
if (text.length <= maxLen) return text;
return text.substring(0, maxLen - 3) + "...";
};
return (
<Show when={visible() && instances().length > 0}>
<box
flexDirection="column"
width={35}
borderColor={theme.colors.border}
border={["top", "bottom", "left", "right"]}
paddingLeft={1}
paddingRight={1}
>
{/* Header */}
<box
marginBottom={1}
flexDirection="row"
justifyContent="space-between"
>
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
Agents
</text>
<text fg={theme.colors.textDim}>
{stats().running}/{stats().total}
</text>
</box>
{/* Status Summary */}
<box flexDirection="row" gap={1} marginBottom={1}>
<Show when={stats().running > 0}>
<text fg={theme.colors.info}>
{stats().running}
</text>
</Show>
<Show when={stats().waiting > 0}>
<text fg={theme.colors.warning}>
{stats().waiting}
</text>
</Show>
<Show when={stats().completed > 0}>
<text fg={theme.colors.success}>
{stats().completed}
</text>
</Show>
<Show when={stats().failed > 0}>
<text fg={theme.colors.error}>
{stats().failed}
</text>
</Show>
</box>
{/* Agent List */}
<scrollbox stickyScroll={false} flexGrow={1}>
<box flexDirection="column">
<For each={instances()}>
{(instance, index) => (
<box
flexDirection="column"
marginBottom={1}
backgroundColor={index() === selectedIndex() ? theme.colors.bgHighlight : undefined}
paddingLeft={1}
paddingRight={1}
>
<box flexDirection="row" gap={1}>
<text fg={getStatusColor(instance.status)}>
{STATUS_ICONS[instance.status]}
</text>
<text
fg={theme.colors.text}
attributes={TextAttributes.BOLD}
flexGrow={1}
>
{instance.definition.name}
</text>
<text fg={theme.colors.textDim}>
{getDuration(instance)}
</text>
</box>
<box flexDirection="row" marginLeft={2}>
<text fg={theme.colors.textDim}>
{truncateText(instance.config.task, 25)}
</text>
</box>
<Show when={instance.status === "error" && instance.error}>
<box flexDirection="row" marginLeft={2}>
<text fg={theme.colors.error}>
{truncateText(instance.error ?? "", 30)}
</text>
</box>
</Show>
<Show when={instance.modifiedFiles.length > 0}>
<box flexDirection="row" marginLeft={2}>
<text fg={theme.colors.textDim}>
{instance.modifiedFiles.length} file(s) modified
</text>
</box>
</Show>
</box>
)}
</For>
</box>
</scrollbox>
{/* Footer with conflicts */}
<Show when={stats().waiting > 0}>
<box
marginTop={1}
paddingTop={1}
borderColor={theme.colors.border}
border={["top"]}
>
<text fg={theme.colors.warning}>
{stats().waiting} agent(s) waiting on conflicts
</text>
</box>
</Show>
</box>
</Show>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Submenu Components
*/
export * from "./agent-select";
export * from "./model-select";
export * from "./provider-select";
export * from "./theme-select";
export * from "./mode-select";
export * from "./mcp-select";

View File

@@ -1,13 +1,13 @@
import { Match, Switch } from "solid-js";
import { useTheme } from "@tui-solid/context/theme";
import { useAppStore } from "@tui-solid/context/app";
import { Logo } from "@tui-solid/components/logo";
import { InputArea } from "@tui-solid/components/input-area";
import { CommandMenu } from "@tui-solid/components/command-menu";
import { ModelSelect } from "@tui-solid/components/model-select";
import { ThemeSelect } from "@tui-solid/components/theme-select";
import { FilePicker } from "@tui-solid/components/file-picker";
import { CenteredModal } from "@tui-solid/components/centered-modal";
import { Logo } from "@tui-solid/components/layout/logo";
import { InputArea } from "@tui-solid/components/inputs/input-area";
import { CommandMenu } from "@tui-solid/components/menu/command-menu";
import { ModelSelect } from "@tui-solid/components/submenu/model-select";
import { ThemeSelect } from "@tui-solid/components/submenu/theme-select";
import { FilePicker } from "@tui-solid/components/inputs/file-picker";
import { CenteredModal } from "@tui-solid/components/modals/centered-modal";
import { HOME_VARS } from "@constants/home";
interface HomeProps {

View File

@@ -1,27 +1,27 @@
import { Show, Switch, Match, createSignal, createMemo, onMount } from "solid-js";
import { useTheme } from "@tui-solid/context/theme";
import { useAppStore } from "@tui-solid/context/app";
import { Header } from "@tui-solid/components/header";
import { LogPanel } from "@tui-solid/components/log-panel";
import { InputArea } from "@tui-solid/components/input-area";
import { StatusBar } from "@tui-solid/components/status-bar";
import { CommandMenu } from "@tui-solid/components/command-menu";
import { ModelSelect } from "@tui-solid/components/model-select";
import { ThemeSelect } from "@tui-solid/components/theme-select";
import { AgentSelect } from "@tui-solid/components/agent-select";
import { MCPSelect } from "@tui-solid/components/mcp-select";
import { MCPAddForm } from "@tui-solid/components/mcp-add-form";
import { ModeSelect } from "@tui-solid/components/mode-select";
import { ProviderSelect } from "@tui-solid/components/provider-select";
import { FilePicker } from "@tui-solid/components/file-picker";
import { PermissionModal } from "@tui-solid/components/permission-modal";
import { LearningModal } from "@tui-solid/components/learning-modal";
import { HelpMenu } from "@tui-solid/components/help-menu";
import { HelpDetail } from "@tui-solid/components/help-detail";
import { TodoPanel } from "@tui-solid/components/todo-panel";
import { CenteredModal } from "@tui-solid/components/centered-modal";
import { DebugLogPanel } from "@tui-solid/components/debug-log-panel";
import { BrainMenu } from "@tui-solid/components/brain-menu";
import { Header } from "@tui-solid/components/layout/header";
import { LogPanel } from "@tui-solid/components/logs/log-panel";
import { InputArea } from "@tui-solid/components/inputs/input-area";
import { StatusBar } from "@tui-solid/components/layout/status-bar";
import { CommandMenu } from "@tui-solid/components/menu/command-menu";
import { ModelSelect } from "@tui-solid/components/submenu/model-select";
import { ThemeSelect } from "@tui-solid/components/submenu/theme-select";
import { AgentSelect } from "@tui-solid/components/submenu/agent-select";
import { MCPSelect } from "@tui-solid/components/submenu/mcp-select";
import { MCPAddForm } from "@tui-solid/components/inputs/mcp-add-form";
import { ModeSelect } from "@tui-solid/components/submenu/mode-select";
import { ProviderSelect } from "@tui-solid/components/submenu/provider-select";
import { FilePicker } from "@tui-solid/components/inputs/file-picker";
import { PermissionModal } from "@tui-solid/components/modals/permission-modal";
import { LearningModal } from "@tui-solid/components/modals/learning-modal";
import { HelpMenu } from "@tui-solid/components/menu/help-menu";
import { HelpDetail } from "@tui-solid/components/panels/help-detail";
import { TodoPanel } from "@tui-solid/components/panels/todo-panel";
import { CenteredModal } from "@tui-solid/components/modals/centered-modal";
import { DebugLogPanel } from "@tui-solid/components/logs/debug-log-panel";
import { BrainMenu } from "@tui-solid/components/menu/brain-menu";
import { BRAIN_DISABLED } from "@constants/brain";
import { initializeMCP, getServerInstances } from "@services/mcp";
import type { PermissionScope, LearningScope, InteractionMode, MCPServerDisplay } from "@/types/tui";