Add debug log panel, centered modals, and fix multiple UX issues
Features:
- Add /logs command to toggle debug log panel (20% width on right)
- Debug panel shows API calls, streaming events, tool calls, state changes
- Add /help submenus with detailed command explanations
- Center all modal dialogs in the terminal window
Bug Fixes:
- Fix streaming content not displaying (add fallback when streaming fails)
- Fix permission modal shortcut key mismatch ('a' → 'l' for local scope)
- Fix agent prompt accumulation when switching agents multiple times
- Fix permission modal using brittle index for "No" option
Improvements:
- Restrict git commands (add, commit, push, etc.) unless user explicitly requests
- Unify permission options across all UI components
- Add Ollama model selection when switching to Ollama provider
- Store base system prompt to prevent agent prompt stacking
New files:
- src/tui-solid/components/debug-log-panel.tsx
- src/tui-solid/components/centered-modal.tsx
- src/tui-solid/components/help-menu.tsx
- src/tui-solid/components/help-detail.tsx
- src/constants/help-content.ts
This commit is contained in:
@@ -256,7 +256,8 @@ function AppContent(props: AppProps) {
|
||||
allowed: boolean,
|
||||
scope?: PermissionScope,
|
||||
): void => {
|
||||
app.setMode("idle");
|
||||
// Don't set mode here - the resolve callback in permissions.ts
|
||||
// handles the mode transition to "tool_execution"
|
||||
props.onPermissionResponse(allowed, scope);
|
||||
};
|
||||
|
||||
@@ -265,7 +266,7 @@ function AppContent(props: AppProps) {
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
): void => {
|
||||
app.setMode("idle");
|
||||
// Don't set mode here - the resolve callback handles the mode transition
|
||||
props.onLearningResponse(save, scope, editedContent);
|
||||
};
|
||||
|
||||
|
||||
29
src/tui-solid/components/centered-modal.tsx
Normal file
29
src/tui-solid/components/centered-modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { JSXElement } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
|
||||
interface CenteredModalProps {
|
||||
children: JSXElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* A container that centers its children in the terminal window.
|
||||
* Uses absolute positioning with flexbox centering.
|
||||
*/
|
||||
export function CenteredModal(props: CenteredModalProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
bottom={0}
|
||||
right={0}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={theme.colors.background}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
200
src/tui-solid/components/debug-log-panel.tsx
Normal file
200
src/tui-solid/components/debug-log-panel.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { createMemo, 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";
|
||||
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,
|
||||
};
|
||||
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",
|
||||
};
|
||||
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"]}
|
||||
>
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
Debug Logs
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> ({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>
|
||||
);
|
||||
}
|
||||
115
src/tui-solid/components/help-detail.tsx
Normal file
115
src/tui-solid/components/help-detail.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { getTopicById } from "@/constants/help-content";
|
||||
|
||||
interface HelpDetailProps {
|
||||
topicId: string;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function HelpDetail(props: HelpDetailProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const topic = () => getTopicById(props.topicId);
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape" || evt.name === "backspace" || evt.name === "q") {
|
||||
props.onBack();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
const currentTopic = topic();
|
||||
|
||||
if (!currentTopic) {
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.error}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<text fg={theme.colors.error}>Topic not found</text>
|
||||
<text fg={theme.colors.textDim}>Press Esc to go back</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.info}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<text fg={theme.colors.info} attributes={TextAttributes.BOLD}>
|
||||
{currentTopic.name}
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg={theme.colors.text}>{currentTopic.fullDescription}</text>
|
||||
|
||||
<Show when={currentTopic.usage}>
|
||||
<box height={1} />
|
||||
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
|
||||
Usage
|
||||
</text>
|
||||
<text fg={theme.colors.success}> {currentTopic.usage}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={currentTopic.examples && currentTopic.examples.length > 0}>
|
||||
<box height={1} />
|
||||
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
|
||||
Examples
|
||||
</text>
|
||||
<For each={currentTopic.examples}>
|
||||
{(example) => (
|
||||
<text fg={theme.colors.text}> • {example}</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={currentTopic.shortcuts && currentTopic.shortcuts.length > 0}>
|
||||
<box height={1} />
|
||||
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
|
||||
Shortcuts
|
||||
</text>
|
||||
<For each={currentTopic.shortcuts}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.colors.primary}> {shortcut}</text>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg={theme.colors.textDim}>
|
||||
Esc/Backspace back | Enter close
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
161
src/tui-solid/components/help-menu.tsx
Normal file
161
src/tui-solid/components/help-menu.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createSignal, createMemo, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import {
|
||||
HELP_CATEGORIES,
|
||||
getTopicsByCategory,
|
||||
type HelpCategory,
|
||||
} from "@/constants/help-content";
|
||||
|
||||
interface HelpMenuProps {
|
||||
onSelectTopic: (topicId: string) => void;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface TopicItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: HelpCategory;
|
||||
categoryName: string;
|
||||
topics: TopicItem[];
|
||||
}
|
||||
|
||||
export function HelpMenu(props: HelpMenuProps) {
|
||||
const theme = useTheme();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
const groupedTopics = createMemo((): CategoryGroup[] => {
|
||||
return HELP_CATEGORIES.map((cat) => ({
|
||||
category: cat.id,
|
||||
categoryName: cat.name,
|
||||
topics: getTopicsByCategory(cat.id).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.shortDescription,
|
||||
})),
|
||||
})).filter((g) => g.topics.length > 0);
|
||||
});
|
||||
|
||||
const allTopics = createMemo((): TopicItem[] => {
|
||||
return groupedTopics().flatMap((g) => g.topics);
|
||||
});
|
||||
|
||||
const selectedTopic = createMemo(() => {
|
||||
const topics = allTopics();
|
||||
const idx = Math.min(selectedIndex(), topics.length - 1);
|
||||
return topics[idx];
|
||||
});
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
props.onClose();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const topic = selectedTopic();
|
||||
if (topic) {
|
||||
props.onSelectTopic(topic.id);
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
const total = allTopics().length;
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : total - 1));
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
const total = allTopics().length;
|
||||
setSelectedIndex((prev) => (prev < total - 1 ? prev + 1 : 0));
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
const isTopicSelected = (topicId: string): boolean => {
|
||||
return selectedTopic()?.id === topicId;
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.info}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.info} attributes={TextAttributes.BOLD}>
|
||||
Help - Select a topic
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<For each={groupedTopics()}>
|
||||
{(group) => (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box>
|
||||
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>
|
||||
{group.categoryName}
|
||||
</text>
|
||||
</box>
|
||||
<For each={group.topics}>
|
||||
{(topic) => {
|
||||
const selected = () => isTopicSelected(topic.id);
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={selected() ? theme.colors.primary : theme.colors.text}
|
||||
attributes={
|
||||
selected() ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{selected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
selected() ? theme.colors.primary : theme.colors.success
|
||||
}
|
||||
>
|
||||
{topic.name.padEnd(14).substring(0, 14)}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
{topic.description.substring(0, 40)}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
↑↓ navigate | Enter details | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ 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";
|
||||
|
||||
@@ -92,7 +92,9 @@ export function InputArea(props: InputAreaProps) {
|
||||
mode === "mcp_add" ||
|
||||
mode === "file_picker" ||
|
||||
mode === "permission_prompt" ||
|
||||
mode === "learning_prompt"
|
||||
mode === "learning_prompt" ||
|
||||
mode === "help_menu" ||
|
||||
mode === "help_detail"
|
||||
);
|
||||
});
|
||||
const placeholder = () =>
|
||||
|
||||
@@ -46,6 +46,9 @@ export function LearningModal(props: LearningModalProps) {
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
// Stop propagation for all events when modal is active
|
||||
evt.stopPropagation();
|
||||
|
||||
if (isEditing()) {
|
||||
if (evt.name === "escape") {
|
||||
setIsEditing(false);
|
||||
@@ -95,7 +98,8 @@ export function LearningModal(props: LearningModalProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1) {
|
||||
// Only handle known shortcut keys to avoid accidental triggers
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
const charLower = evt.name.toLowerCase();
|
||||
const optionIndex = SCOPE_OPTIONS.findIndex((o) => o.key === charLower);
|
||||
if (optionIndex !== -1) {
|
||||
|
||||
@@ -10,15 +10,19 @@ interface PermissionModalProps {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS: Array<{
|
||||
interface PermissionOption {
|
||||
key: string;
|
||||
label: string;
|
||||
scope: PermissionScope;
|
||||
}> = [
|
||||
{ key: "y", label: "Yes, this once", scope: "once" },
|
||||
{ key: "s", label: "Yes, for this session", scope: "session" },
|
||||
{ key: "a", label: "Always allow for this project", scope: "local" },
|
||||
{ key: "g", label: "Always allow globally", scope: "global" },
|
||||
scope: PermissionScope | "deny";
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_OPTIONS: PermissionOption[] = [
|
||||
{ key: "y", label: "Yes, this once", scope: "once", allowed: true },
|
||||
{ key: "s", label: "Yes, for this session", scope: "session", allowed: true },
|
||||
{ key: "l", label: "Yes, for this project", scope: "local", allowed: true },
|
||||
{ key: "g", label: "Yes, globally", scope: "global", allowed: true },
|
||||
{ key: "n", label: "No, deny this request", scope: "deny", allowed: false },
|
||||
];
|
||||
|
||||
export function PermissionModal(props: PermissionModalProps) {
|
||||
@@ -38,41 +42,55 @@ export function PermissionModal(props: PermissionModalProps) {
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
// Stop propagation for all events when modal is active
|
||||
evt.stopPropagation();
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : SCOPE_OPTIONS.length));
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : PERMISSION_OPTIONS.length - 1,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) => (prev < SCOPE_OPTIONS.length ? prev + 1 : 0));
|
||||
setSelectedIndex((prev) =>
|
||||
prev < PERMISSION_OPTIONS.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
if (selectedIndex() === SCOPE_OPTIONS.length) {
|
||||
handleResponse(false);
|
||||
const option = PERMISSION_OPTIONS[selectedIndex()];
|
||||
if (option.allowed && option.scope !== "deny") {
|
||||
handleResponse(true, option.scope as PermissionScope);
|
||||
} else {
|
||||
const option = SCOPE_OPTIONS[selectedIndex()];
|
||||
handleResponse(true, option.scope);
|
||||
handleResponse(false);
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || evt.name === "n") {
|
||||
if (evt.name === "escape") {
|
||||
handleResponse(false);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1) {
|
||||
// Handle shortcut keys
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
const charLower = evt.name.toLowerCase();
|
||||
const optionIndex = SCOPE_OPTIONS.findIndex((o) => o.key === charLower);
|
||||
const optionIndex = PERMISSION_OPTIONS.findIndex(
|
||||
(o) => o.key === charLower,
|
||||
);
|
||||
if (optionIndex !== -1) {
|
||||
const option = SCOPE_OPTIONS[optionIndex];
|
||||
handleResponse(true, option.scope);
|
||||
const option = PERMISSION_OPTIONS[optionIndex];
|
||||
if (option.allowed && option.scope !== "deny") {
|
||||
handleResponse(true, option.scope as PermissionScope);
|
||||
} else {
|
||||
handleResponse(false);
|
||||
}
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -114,9 +132,11 @@ export function PermissionModal(props: PermissionModalProps) {
|
||||
</box>
|
||||
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<For each={SCOPE_OPTIONS}>
|
||||
<For each={PERMISSION_OPTIONS}>
|
||||
{(option, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
const keyColor = () =>
|
||||
option.allowed ? theme.colors.success : theme.colors.error;
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
@@ -127,7 +147,7 @@ export function PermissionModal(props: PermissionModalProps) {
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.success}>[{option.key}] </text>
|
||||
<text fg={keyColor()}>[{option.key}] </text>
|
||||
<text fg={isSelected() ? theme.colors.primary : undefined}>
|
||||
{option.label}
|
||||
</text>
|
||||
@@ -135,32 +155,6 @@ export function PermissionModal(props: PermissionModalProps) {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
attributes={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{selectedIndex() === SCOPE_OPTIONS.length ? "> " : " "}
|
||||
</text>
|
||||
<text fg={theme.colors.error}>[n] </text>
|
||||
<text
|
||||
fg={
|
||||
selectedIndex() === SCOPE_OPTIONS.length
|
||||
? theme.colors.primary
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
No, deny this request
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ProviderOption {
|
||||
}
|
||||
|
||||
interface ProviderSelectProps {
|
||||
onSelect: (providerId: string) => void;
|
||||
onSelect: (providerId: string) => Promise<void> | void;
|
||||
onClose: () => void;
|
||||
onToggleCascade?: () => void;
|
||||
isActive?: boolean;
|
||||
@@ -97,8 +97,19 @@ export function ProviderSelect(props: ProviderSelectProps) {
|
||||
if (evt.name === "return") {
|
||||
const selected = providers()[selectedIndex()];
|
||||
if (selected && selected.status.available) {
|
||||
props.onSelect(selected.id);
|
||||
props.onClose();
|
||||
// For Ollama, let the handler manage the mode transition to model_select
|
||||
// For other providers, close after selection
|
||||
const result = props.onSelect(selected.id);
|
||||
if (result instanceof Promise) {
|
||||
result.then(() => {
|
||||
// Only close if not ollama (ollama opens model_select)
|
||||
if (selected.id !== "ollama") {
|
||||
props.onClose();
|
||||
}
|
||||
});
|
||||
} else if (selected.id !== "ollama") {
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -37,6 +37,7 @@ interface AppStore {
|
||||
availableModels: ProviderModel[];
|
||||
sessionStats: SessionStats;
|
||||
todosVisible: boolean;
|
||||
debugLogVisible: boolean;
|
||||
interruptPending: boolean;
|
||||
exitPending: boolean;
|
||||
isCompacting: boolean;
|
||||
@@ -70,6 +71,7 @@ interface AppContextValue {
|
||||
availableModels: Accessor<ProviderModel[]>;
|
||||
sessionStats: Accessor<SessionStats>;
|
||||
todosVisible: Accessor<boolean>;
|
||||
debugLogVisible: Accessor<boolean>;
|
||||
interruptPending: Accessor<boolean>;
|
||||
exitPending: Accessor<boolean>;
|
||||
isCompacting: Accessor<boolean>;
|
||||
@@ -136,6 +138,7 @@ interface AppContextValue {
|
||||
|
||||
// UI state actions
|
||||
toggleTodos: () => void;
|
||||
toggleDebugLog: () => void;
|
||||
setInterruptPending: (pending: boolean) => void;
|
||||
setExitPending: (pending: boolean) => void;
|
||||
setIsCompacting: (compacting: boolean) => void;
|
||||
@@ -212,6 +215,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
availableModels: [],
|
||||
sessionStats: createInitialSessionStats(),
|
||||
todosVisible: true,
|
||||
debugLogVisible: false,
|
||||
interruptPending: false,
|
||||
exitPending: false,
|
||||
isCompacting: false,
|
||||
@@ -254,6 +258,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
const availableModels = (): ProviderModel[] => store.availableModels;
|
||||
const sessionStats = (): SessionStats => store.sessionStats;
|
||||
const todosVisible = (): boolean => store.todosVisible;
|
||||
const debugLogVisible = (): boolean => store.debugLogVisible;
|
||||
const interruptPending = (): boolean => store.interruptPending;
|
||||
const exitPending = (): boolean => store.exitPending;
|
||||
const isCompacting = (): boolean => store.isCompacting;
|
||||
@@ -495,6 +500,10 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
setStore("todosVisible", !store.todosVisible);
|
||||
};
|
||||
|
||||
const toggleDebugLog = (): void => {
|
||||
setStore("debugLogVisible", !store.debugLogVisible);
|
||||
};
|
||||
|
||||
const setInterruptPending = (pending: boolean): void => {
|
||||
setStore("interruptPending", pending);
|
||||
};
|
||||
@@ -678,6 +687,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
availableModels,
|
||||
sessionStats,
|
||||
todosVisible,
|
||||
debugLogVisible,
|
||||
interruptPending,
|
||||
exitPending,
|
||||
isCompacting,
|
||||
@@ -746,6 +756,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
|
||||
// UI state actions
|
||||
toggleTodos,
|
||||
toggleDebugLog,
|
||||
setInterruptPending,
|
||||
setExitPending,
|
||||
setIsCompacting,
|
||||
@@ -799,6 +810,7 @@ export const appStore = {
|
||||
sessionStats: storeRef.sessionStats(),
|
||||
cascadeEnabled: storeRef.cascadeEnabled(),
|
||||
todosVisible: storeRef.todosVisible(),
|
||||
debugLogVisible: storeRef.debugLogVisible(),
|
||||
interruptPending: storeRef.interruptPending(),
|
||||
exitPending: storeRef.exitPending(),
|
||||
isCompacting: storeRef.isCompacting(),
|
||||
@@ -917,6 +929,11 @@ export const appStore = {
|
||||
storeRef.toggleTodos();
|
||||
},
|
||||
|
||||
toggleDebugLog: (): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.toggleDebugLog();
|
||||
},
|
||||
|
||||
setInterruptPending: (pending: boolean): void => {
|
||||
if (!storeRef) throw new Error("AppStore not initialized");
|
||||
storeRef.setInterruptPending(pending);
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { HOME_VARS } from "@constants/home";
|
||||
|
||||
interface HomeProps {
|
||||
@@ -67,7 +68,7 @@ export function Home(props: HomeProps) {
|
||||
|
||||
<Switch>
|
||||
<Match when={app.mode() === "command_menu"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<CommandMenu
|
||||
onSelect={(command) => {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
@@ -95,38 +96,38 @@ export function Home(props: HomeProps) {
|
||||
onCancel={() => app.closeCommandMenu()}
|
||||
isActive={app.mode() === "command_menu"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "model_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<ModelSelect
|
||||
onSelect={(model) => props.onModelSelect?.(model)}
|
||||
onClose={handleModelClose}
|
||||
isActive={app.mode() === "model_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "theme_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<ThemeSelect
|
||||
onSelect={(themeName) => props.onThemeSelect?.(themeName)}
|
||||
onClose={handleThemeClose}
|
||||
isActive={app.mode() === "theme_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "file_picker"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<FilePicker
|
||||
files={props.files ?? []}
|
||||
onSelect={(file) => props.onFileSelect?.(file)}
|
||||
onClose={handleFilePickerClose}
|
||||
isActive={app.mode() === "file_picker"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, Switch, Match } from "solid-js";
|
||||
import { Show, Switch, Match, createSignal } from "solid-js";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { Header } from "@tui-solid/components/header";
|
||||
@@ -16,7 +16,11 @@ 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 type { PermissionScope, LearningScope, InteractionMode } from "@/types/tui";
|
||||
import type { MCPAddFormData } from "@/types/mcp";
|
||||
|
||||
@@ -73,6 +77,11 @@ export function Session(props: SessionProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
|
||||
// Local state for help menu
|
||||
const [selectedHelpTopic, setSelectedHelpTopic] = createSignal<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleCommandSelect = (command: string): void => {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
// Handle menu-opening commands directly to avoid async timing issues
|
||||
@@ -100,6 +109,10 @@ export function Session(props: SessionProps) {
|
||||
app.transitionFromCommandMenu("provider_select");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "help" || lowerCommand === "h" || lowerCommand === "?") {
|
||||
app.transitionFromCommandMenu("help_menu");
|
||||
return;
|
||||
}
|
||||
// For other commands, close menu and process through handler
|
||||
app.closeCommandMenu();
|
||||
props.onCommand(command);
|
||||
@@ -141,9 +154,9 @@ export function Session(props: SessionProps) {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleProviderSelect = (providerId: string): void => {
|
||||
const handleProviderSelect = async (providerId: string): Promise<void> => {
|
||||
app.setProvider(providerId);
|
||||
props.onProviderSelect?.(providerId);
|
||||
await props.onProviderSelect?.(providerId);
|
||||
};
|
||||
|
||||
const handleProviderClose = (): void => {
|
||||
@@ -159,6 +172,26 @@ export function Session(props: SessionProps) {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleHelpTopicSelect = (topicId: string): void => {
|
||||
setSelectedHelpTopic(topicId);
|
||||
app.setMode("help_detail");
|
||||
};
|
||||
|
||||
const handleHelpMenuClose = (): void => {
|
||||
setSelectedHelpTopic(null);
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleHelpDetailBack = (): void => {
|
||||
setSelectedHelpTopic(null);
|
||||
app.setMode("help_menu");
|
||||
};
|
||||
|
||||
const handleHelpDetailClose = (): void => {
|
||||
setSelectedHelpTopic(null);
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -175,6 +208,10 @@ export function Session(props: SessionProps) {
|
||||
<Show when={app.todosVisible() && props.plan}>
|
||||
<TodoPanel plan={props.plan ?? null} visible={app.todosVisible()} />
|
||||
</Show>
|
||||
|
||||
<Show when={app.debugLogVisible()}>
|
||||
<DebugLogPanel />
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<StatusBar />
|
||||
@@ -182,37 +219,37 @@ export function Session(props: SessionProps) {
|
||||
|
||||
<Switch>
|
||||
<Match when={app.mode() === "command_menu"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<CommandMenu
|
||||
onSelect={handleCommandSelect}
|
||||
onCancel={handleCommandCancel}
|
||||
isActive={app.mode() === "command_menu"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "model_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<ModelSelect
|
||||
onSelect={props.onModelSelect}
|
||||
onClose={handleModelClose}
|
||||
isActive={app.mode() === "model_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "theme_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<ThemeSelect
|
||||
onSelect={props.onThemeSelect}
|
||||
onClose={handleThemeClose}
|
||||
isActive={app.mode() === "theme_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "agent_select" && props.agents}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<AgentSelect
|
||||
agents={props.agents ?? []}
|
||||
currentAgent={app.currentAgent()}
|
||||
@@ -223,11 +260,11 @@ export function Session(props: SessionProps) {
|
||||
onClose={handleAgentClose}
|
||||
isActive={app.mode() === "agent_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "mcp_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<MCPSelect
|
||||
servers={props.mcpServers ?? []}
|
||||
onSelect={props.onMCPSelect}
|
||||
@@ -235,31 +272,31 @@ export function Session(props: SessionProps) {
|
||||
onClose={handleMCPClose}
|
||||
isActive={app.mode() === "mcp_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "mcp_add"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<MCPAddForm
|
||||
onSubmit={props.onMCPAdd}
|
||||
onClose={handleMCPAddClose}
|
||||
isActive={app.mode() === "mcp_add"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "mode_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<ModeSelect
|
||||
onSelect={handleModeSelect}
|
||||
onClose={handleModeClose}
|
||||
isActive={app.mode() === "mode_select"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "provider_select"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<ProviderSelect
|
||||
onSelect={handleProviderSelect}
|
||||
onClose={handleProviderClose}
|
||||
@@ -269,40 +306,61 @@ export function Session(props: SessionProps) {
|
||||
providerStatuses={props.providerStatuses}
|
||||
providerScores={props.providerScores}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "file_picker"}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<FilePicker
|
||||
files={props.files ?? []}
|
||||
onSelect={props.onFileSelect}
|
||||
onClose={handleFilePickerClose}
|
||||
isActive={app.mode() === "file_picker"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match
|
||||
when={app.mode() === "permission_prompt" && app.permissionRequest()}
|
||||
>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<PermissionModal
|
||||
request={app.permissionRequest()!}
|
||||
onRespond={props.onPermissionResponse}
|
||||
isActive={app.mode() === "permission_prompt"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "learning_prompt" && app.learningPrompt()}>
|
||||
<box position="absolute" top={3} left={2}>
|
||||
<CenteredModal>
|
||||
<LearningModal
|
||||
prompt={app.learningPrompt()!}
|
||||
onRespond={props.onLearningResponse}
|
||||
isActive={app.mode() === "learning_prompt"}
|
||||
/>
|
||||
</box>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "help_menu"}>
|
||||
<CenteredModal>
|
||||
<HelpMenu
|
||||
onSelectTopic={handleHelpTopicSelect}
|
||||
onClose={handleHelpMenuClose}
|
||||
isActive={app.mode() === "help_menu"}
|
||||
/>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "help_detail" && selectedHelpTopic()}>
|
||||
<CenteredModal>
|
||||
<HelpDetail
|
||||
topicId={selectedHelpTopic()!}
|
||||
onBack={handleHelpDetailBack}
|
||||
onClose={handleHelpDetailClose}
|
||||
isActive={app.mode() === "help_detail"}
|
||||
/>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
@@ -35,6 +35,8 @@ export type AppMode =
|
||||
| "theme_select"
|
||||
| "mcp_select"
|
||||
| "file_picker"
|
||||
| "help_menu"
|
||||
| "help_detail"
|
||||
| "error";
|
||||
|
||||
export type ScreenMode = "home" | "session";
|
||||
|
||||
Reference in New Issue
Block a user