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:
2026-01-29 04:53:39 -05:00
parent dfbbab2ecb
commit adfebda501
27 changed files with 1031 additions and 167 deletions

20
package-lock.json generated
View File

@@ -54,7 +54,7 @@
"vitest": "^4.0.17"
},
"engines": {
"node": ">=18.0.0"
"bun": ">=1.0.0"
}
},
"node_modules/@ampproject/remapping": {
@@ -2678,9 +2678,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz",
"integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3468,9 +3468,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
"integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -5938,9 +5938,9 @@
}
},
"node_modules/inquirer": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.2.1.tgz",
"integrity": "sha512-kjIN+joqgbSncQJ6GfN7gV9AbDQlMA+hJ96xcwkQUwP9KN/ZIusoJ2mAfdt0LPrZJQsEyk5i/YrgJQTxSgzlPw==",
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.2.2.tgz",
"integrity": "sha512-+hlN8I88JE9T3zjWHGnMhryniRDbSgFNJHJTyD2iKO5YNpMRyfghQ6wVoe+gV4ygMM4r4GzlsBxNa1g/UUZixA==",
"license": "MIT",
"dependencies": {
"@inquirer/ansi": "^2.0.3",

View File

@@ -1,6 +1,6 @@
import { saveSession } from "@services/session";
import { showHelp } from "@commands/components/chat/commands/show-help";
import { clearConversation } from "@commands/components/chat/history/clear-conversation";
import { appStore } from "@tui/index";
import { showContextFiles } from "@commands/components/chat/context/show-context-files";
import { removeFile } from "@commands/components/chat/context/remove-file";
import { showContext } from "@commands/components/chat/history/show-context";
@@ -24,8 +24,8 @@ const COMMAND_REGISTRY: Map<string, CommandHandler> = new Map<
string,
CommandHandler
>([
["help", () => showHelp()],
["h", () => showHelp()],
["help", () => appStore.setMode("help_menu")],
["h", () => appStore.setMode("help_menu")],
["clear", (ctx: CommandContext) => clearConversation(ctx.state)],
["c", (ctx: CommandContext) => clearConversation(ctx.state)],
["files", (ctx: CommandContext) => showContextFiles(ctx.state.contextFiles)],
@@ -106,6 +106,17 @@ const COMMAND_REGISTRY: Map<string, CommandHandler> = new Map<
},
],
["mcp", async (ctx: CommandContext) => handleMCP(ctx.args)],
[
"logs",
() => {
appStore.toggleDebugLog();
const { debugLogVisible } = appStore.getState();
appStore.addLog({
type: "system",
content: `Debug logs panel ${debugLogVisible ? "enabled" : "disabled"}`,
});
},
],
]);
export default COMMAND_REGISTRY;

View File

@@ -26,6 +26,7 @@ import { agentLoader } from "@services/agent-loader";
interface ExecuteContext {
state: ChatServiceState | null;
baseSystemPrompt: string | null;
}
const createHandleExit = (): (() => void) => (): void => {
@@ -51,16 +52,22 @@ const createHandleAgentSelect =
(ctx.state as ChatServiceState & { currentAgent?: string }).currentAgent =
agentId;
if (agent.prompt) {
const basePrompt = ctx.state.systemPrompt;
ctx.state.systemPrompt = `${agent.prompt}\n\n${basePrompt}`;
// Use the stored base prompt to avoid accumulation when switching agents
const basePrompt = ctx.baseSystemPrompt ?? ctx.state.systemPrompt;
if (
ctx.state.messages.length > 0 &&
ctx.state.messages[0].role === "system"
) {
ctx.state.messages[0].content = ctx.state.systemPrompt;
}
if (agent.prompt) {
ctx.state.systemPrompt = `${agent.prompt}\n\n${basePrompt}`;
} else {
// Reset to base prompt if agent has no custom prompt
ctx.state.systemPrompt = basePrompt;
}
// Update the system message in the conversation
if (
ctx.state.messages.length > 0 &&
ctx.state.messages[0].role === "system"
) {
ctx.state.messages[0].content = ctx.state.systemPrompt;
}
};
@@ -81,6 +88,15 @@ const createHandleProviderSelect =
const config = await getConfig();
config.set("provider", providerId as "copilot" | "ollama");
await config.save();
// Load models for the new provider and update the store
const models = await loadModels(providerId as "copilot" | "ollama");
appStore.setAvailableModels(models);
// If Ollama is selected and has models, open model selector
if (providerId === "ollama" && models.length > 0) {
appStore.setMode("model_select");
}
};
const createHandleCascadeToggle =
@@ -131,10 +147,13 @@ const createHandleSubmit =
const execute = async (options: ChatTUIOptions): Promise<void> => {
const ctx: ExecuteContext = {
state: null,
baseSystemPrompt: null,
};
const { state, session } = await initializeChatService(options);
ctx.state = state;
// Store the original system prompt before any agent modifications
ctx.baseSystemPrompt = state.systemPrompt;
if (options.printMode && options.initialPrompt) {
await executePrintMode(state, options.initialPrompt);

View File

@@ -103,4 +103,5 @@ export type CommandName =
| "p"
| "status"
| "remember"
| "learnings";
| "learnings"
| "logs";

View File

@@ -0,0 +1,182 @@
/**
* Help Content Constants
*
* Detailed help information for commands and features
*/
export interface HelpTopic {
id: string;
name: string;
shortDescription: string;
fullDescription: string;
usage?: string;
examples?: string[];
shortcuts?: string[];
category: HelpCategory;
}
export type HelpCategory = "commands" | "files" | "shortcuts";
export const HELP_CATEGORIES: Array<{
id: HelpCategory;
name: string;
description: string;
}> = [
{
id: "commands",
name: "Commands",
description: "Slash commands for controlling the assistant",
},
{
id: "files",
name: "File References",
description: "How to reference and work with files",
},
{
id: "shortcuts",
name: "Shortcuts",
description: "Keyboard shortcuts",
},
];
export const HELP_TOPICS: HelpTopic[] = [
// Commands
{
id: "help",
name: "/help",
shortDescription: "Show this help menu",
fullDescription:
"Opens the help menu where you can browse commands and features.",
usage: "/help",
category: "commands",
},
{
id: "clear",
name: "/clear",
shortDescription: "Clear conversation",
fullDescription: "Clears the conversation history from the screen.",
usage: "/clear",
category: "commands",
},
{
id: "save",
name: "/save",
shortDescription: "Save session",
fullDescription: "Saves the current conversation session.",
usage: "/save",
category: "commands",
},
{
id: "model",
name: "/model",
shortDescription: "Select AI model",
fullDescription: "Opens menu to select which AI model to use.",
usage: "/model",
category: "commands",
},
{
id: "provider",
name: "/provider",
shortDescription: "Switch provider",
fullDescription: "Switch between LLM providers (Copilot, Ollama).",
usage: "/provider",
category: "commands",
},
{
id: "mode",
name: "/mode",
shortDescription: "Switch mode",
fullDescription:
"Switch between Agent (full access), Ask (read-only), and Code Review modes.",
usage: "/mode",
shortcuts: ["Ctrl+Tab"],
category: "commands",
},
{
id: "theme",
name: "/theme",
shortDescription: "Change theme",
fullDescription: "Opens menu to select a color theme.",
usage: "/theme",
category: "commands",
},
{
id: "exit",
name: "/exit",
shortDescription: "Exit application",
fullDescription: "Exits CodeTyper. You can also use Ctrl+C twice.",
usage: "/exit",
shortcuts: ["Ctrl+C twice"],
category: "commands",
},
{
id: "logs",
name: "/logs",
shortDescription: "Toggle debug logs",
fullDescription:
"Toggles the debug log panel on the right side of the screen. Shows API calls, streaming events, tool calls, and internal state changes for debugging.",
usage: "/logs",
category: "commands",
},
// Files
{
id: "file-ref",
name: "@file",
shortDescription: "Reference a file",
fullDescription:
"Type @ to open file picker and include file content in context.",
usage: "@filename",
examples: ["@src/index.ts", "@package.json"],
category: "files",
},
{
id: "file-glob",
name: "@pattern",
shortDescription: "Reference multiple files",
fullDescription: "Use glob patterns to reference multiple files.",
usage: "@pattern",
examples: ["@src/**/*.ts", "@*.json"],
category: "files",
},
// Shortcuts
{
id: "shortcut-slash",
name: "/",
shortDescription: "Open command menu",
fullDescription: "Press / when input is empty to open command menu.",
shortcuts: ["/"],
category: "shortcuts",
},
{
id: "shortcut-at",
name: "@",
shortDescription: "Open file picker",
fullDescription: "Press @ to open the file picker.",
shortcuts: ["@"],
category: "shortcuts",
},
{
id: "shortcut-ctrlc",
name: "Ctrl+C",
shortDescription: "Cancel/Exit",
fullDescription: "Press once to cancel, twice to exit.",
shortcuts: ["Ctrl+C"],
category: "shortcuts",
},
{
id: "shortcut-ctrltab",
name: "Ctrl+Tab",
shortDescription: "Cycle modes",
fullDescription: "Cycle through interaction modes.",
shortcuts: ["Ctrl+Tab"],
category: "shortcuts",
},
];
export const getTopicsByCategory = (category: HelpCategory): HelpTopic[] =>
HELP_TOPICS.filter((topic) => topic.category === category);
export const getTopicById = (id: string): HelpTopic | undefined =>
HELP_TOPICS.find((topic) => topic.id === id);

View File

@@ -47,6 +47,8 @@ export const MODE_DISPLAY_CONFIG: Record<string, ModeDisplayConfig> = {
mode_select: { text: "Select Mode", color: "magenta" },
provider_select: { text: "Select Provider", color: "magenta" },
learning_prompt: { text: "Save Learning?", color: "cyan" },
help_menu: { text: "Help", color: "cyan" },
help_detail: { text: "Help Detail", color: "cyan" },
} as const;
export const DEFAULT_MODE_DISPLAY: ModeDisplayConfig = {
@@ -199,6 +201,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
},
{ name: "theme", description: "Change color theme", category: "settings" },
{ name: "mcp", description: "Manage MCP servers", category: "settings" },
{ name: "logs", description: "Toggle debug log panel", category: "settings" },
// Account commands
{

View File

@@ -1,11 +1,9 @@
#!/usr/bin/env node
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { Command } from "commander";
import { handleCommand } from "@commands/handlers";
import { readFile } from "fs/promises";
import { execute } from "@commands/chat-tui";
import versionData from "@/version.json";
import {
initializeProviders,
loginProvider,
@@ -37,14 +35,8 @@ import { createPlan, displayPlan, approvePlan } from "@services/planner";
import { ensureXdgDirectories } from "@utils/ensure-directories";
import chalk from "chalk";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Read version from package.json
const packageJson = JSON.parse(
await readFile(join(__dirname, "../package.json"), "utf-8"),
);
const { version } = packageJson;
// Read version from version.json
const { version } = versionData;
// Ensure XDG directories exist
await ensureXdgDirectories();

View File

@@ -150,7 +150,25 @@ assistant: Errors are handled in src/services/error-handler.ts:42.
# Git Operations
Only commit when requested. When creating commits:
CRITICAL: Git commands that modify the repository are FORBIDDEN unless the user EXPLICITLY requests them.
## Forbidden by Default (require explicit user request):
- \`git add\` - NEVER run git add (including \`git add .\` or \`git add -A\`)
- \`git commit\` - NEVER run git commit
- \`git push\` - NEVER run git push
- \`git merge\` - NEVER run git merge
- \`git rebase\` - NEVER run git rebase
- \`git reset\` - NEVER run git reset
- \`git checkout -- .\` or \`git restore\` - NEVER discard changes
## Allowed without asking:
- \`git status\` - checking current state
- \`git diff\` - viewing changes
- \`git log\` - viewing history
- \`git branch\` - listing branches
- \`git show\` - viewing commits
## When user requests a commit:
- NEVER use destructive commands (push --force, reset --hard) unless explicitly asked
- NEVER skip hooks unless explicitly asked
- Use clear, concise commit messages focusing on "why" not "what"

View File

@@ -23,6 +23,7 @@ import type {
ChatCompletionResponse,
StreamChunk,
} from "@/types/providers";
import { addDebugLog } from "@tui-solid/components/debug-log-panel";
interface FormattedMessage {
role: string;
@@ -267,6 +268,7 @@ export const chatStream = async (
options: ChatCompletionOptions | undefined,
onChunk: (chunk: StreamChunk) => void,
): Promise<void> => {
addDebugLog("api", `Copilot stream request: ${messages.length} messages`);
const token = await refreshToken();
const endpoint = getEndpoint(token);
const originalModel =
@@ -274,6 +276,7 @@ export const chatStream = async (
? options.model
: getDefaultModel();
const body = buildRequestBody(messages, options, true);
addDebugLog("api", `Copilot model: ${body.model}`);
let lastError: unknown;
let switchedToUnlimited = false;

View File

@@ -13,6 +13,7 @@ import type {
StreamChunk,
} from "@/types/providers";
import type { OllamaChatResponse } from "@/types/ollama";
import { addDebugLog } from "@tui-solid/components/debug-log-panel";
const parseStreamLine = (
line: string,
@@ -67,6 +68,7 @@ export const ollamaChatStream = async (
): Promise<void> => {
const baseUrl = getOllamaBaseUrl();
const body = buildChatRequest(messages, options, true);
addDebugLog("api", `Ollama stream request: ${messages.length} msgs, model=${body.model}`);
const stream = got.stream.post(`${baseUrl}${OLLAMA_ENDPOINTS.CHAT}`, {
json: body,

View File

@@ -4,11 +4,7 @@
import { saveSession as saveSessionSession } from "@services/session";
import { appStore } from "@tui/index";
import {
CHAT_MESSAGES,
HELP_TEXT,
type CommandName,
} from "@constants/chat-service";
import { CHAT_MESSAGES, type CommandName } from "@constants/chat-service";
import { handleLogin, handleLogout, showWhoami } from "@services/chat-tui/auth";
import {
handleRememberCommand,
@@ -31,8 +27,8 @@ type CommandHandler = (
callbacks: ChatServiceCallbacks,
) => Promise<void> | void;
const showHelp: CommandHandler = (_, callbacks) => {
callbacks.onLog("system", HELP_TEXT);
const showHelp: CommandHandler = () => {
appStore.setMode("help_menu");
};
const clearConversation: CommandHandler = (state, callbacks) => {
@@ -112,6 +108,15 @@ const selectMode: CommandHandler = () => {
appStore.setMode("mode_select");
};
const toggleDebugLogs: CommandHandler = (_, callbacks) => {
appStore.toggleDebugLog();
const { debugLogVisible } = appStore.getState();
callbacks.onLog(
"system",
`Debug logs panel ${debugLogVisible ? "enabled" : "disabled"}`,
);
};
const COMMAND_HANDLERS: Record<CommandName, CommandHandler> = {
help: showHelp,
h: showHelp,
@@ -137,6 +142,7 @@ const COMMAND_HANDLERS: Record<CommandName, CommandHandler> = {
status: showStatus,
remember: handleRememberCommand,
learnings: (_, callbacks) => handleLearningsCommand(callbacks),
logs: toggleDebugLogs,
};
export const executeCommand = async (

View File

@@ -54,6 +54,7 @@ import type {
ChatServiceCallbacks,
ToolCallInfo,
} from "@/types/chat-service";
import { addDebugLog } from "@tui-solid/components/debug-log-panel";
// Track last response for feedback learning
let lastResponseContext: {
@@ -116,47 +117,58 @@ const createToolResultHandler =
/**
* Create streaming callbacks for TUI integration
*/
const createStreamCallbacks = (): StreamCallbacks => ({
onContentChunk: (content: string) => {
appStore.appendStreamContent(content);
},
const createStreamCallbacks = (): StreamCallbacks => {
let chunkCount = 0;
onToolCallStart: (toolCall) => {
appStore.setCurrentToolCall({
id: toolCall.id,
name: toolCall.name,
description: `Calling ${toolCall.name}...`,
status: "pending",
});
},
return {
onContentChunk: (content: string) => {
chunkCount++;
addDebugLog("stream", `Chunk #${chunkCount}: "${content.substring(0, 30)}${content.length > 30 ? "..." : ""}"`);
appStore.appendStreamContent(content);
},
onToolCallComplete: (toolCall) => {
appStore.updateToolCall({
id: toolCall.id,
name: toolCall.name,
status: "running",
});
},
onToolCallStart: (toolCall) => {
addDebugLog("tool", `Tool start: ${toolCall.name} (${toolCall.id})`);
appStore.setCurrentToolCall({
id: toolCall.id,
name: toolCall.name,
description: `Calling ${toolCall.name}...`,
status: "pending",
});
},
onModelSwitch: (info) => {
appStore.addLog({
type: "system",
content: `Model switched: ${info.from}${info.to} (${info.reason})`,
});
},
onToolCallComplete: (toolCall) => {
addDebugLog("tool", `Tool complete: ${toolCall.name}`);
appStore.updateToolCall({
id: toolCall.id,
name: toolCall.name,
status: "running",
});
},
onComplete: () => {
appStore.completeStreaming();
},
onModelSwitch: (info) => {
addDebugLog("api", `Model switch: ${info.from}${info.to}`);
appStore.addLog({
type: "system",
content: `Model switched: ${info.from}${info.to} (${info.reason})`,
});
},
onError: (error: string) => {
appStore.cancelStreaming();
appStore.addLog({
type: "error",
content: error,
});
},
});
onComplete: () => {
addDebugLog("stream", `Stream complete (${chunkCount} chunks)`);
appStore.completeStreaming();
},
onError: (error: string) => {
addDebugLog("error", `Stream error: ${error}`);
appStore.cancelStreaming();
appStore.addLog({
type: "error",
content: error,
});
},
};
};
/**
* Run audit with Copilot on Ollama's response
@@ -386,9 +398,12 @@ export const handleMessage = async (
}
// Start streaming UI
addDebugLog("state", `Starting request: provider=${effectiveProvider}, model=${state.model}`);
addDebugLog("state", `Mode: ${appStore.getState().interactionMode}, Cascade: ${cascadeEnabled}`);
appStore.setMode("thinking");
appStore.startThinking();
appStore.startStreaming();
addDebugLog("state", "Streaming started");
const streamCallbacks = createStreamCallbacks();
const agent = createStreamingAgent(
@@ -412,12 +427,15 @@ export const handleMessage = async (
);
try {
addDebugLog("api", `Agent.run() started with ${state.messages.length} messages`);
const result = await agent.run(state.messages);
addDebugLog("api", `Agent.run() completed: success=${result.success}, iterations=${result.iterations}`);
// Stop thinking timer
appStore.stopThinking();
if (result.finalResponse) {
addDebugLog("info", `Final response length: ${result.finalResponse.length} chars`);
let finalResponse = result.finalResponse;
// Run audit if cascade mode with Ollama
@@ -450,8 +468,18 @@ export const handleMessage = async (
role: "assistant",
content: finalResponse,
});
// Note: Don't call callbacks.onLog here - streaming already added the log entry
// via appendStreamContent/completeStreaming
// Check if streaming content was received - if not, add the response as a log
// This handles cases where streaming didn't work or content was all in final response
const streamingState = appStore.getState().streamingLog;
if (!streamingState.content && finalResponse) {
// Streaming didn't receive content, manually add the response
appStore.cancelStreaming(); // Remove empty streaming log
appStore.addLog({
type: "assistant",
content: finalResponse,
});
}
addMessage("user", message);
addMessage("assistant", finalResponse);

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,8 @@ export type AppMode =
| "theme_select"
| "mcp_select"
| "file_picker"
| "help_menu"
| "help_detail"
| "error";
export type ScreenMode = "home" | "session";

View File

@@ -25,7 +25,9 @@ export type AppMode =
| "mcp_add"
| "file_picker"
| "provider_select"
| "learning_prompt";
| "learning_prompt"
| "help_menu"
| "help_detail";
/** Screen mode for determining which view to show */
export type ScreenMode = "home" | "session";