Add BRAIN_DISABLED flag and fix Ollama tool call formatting
Features: - Add BRAIN_DISABLED feature flag to hide all Brain functionality - When enabled, hides Brain banner, status indicator, menu, and commands - Flag location: src/constants/brain.ts Fixes: - Fix Ollama 400 error by properly formatting tool_calls in messages - Update OllamaMessage type to include tool_calls field - Fix Brain menu keyboard not working (add missing modes to isMenuOpen) UI Changes: - Remove "^Tab toggle mode" hint from status bar - Remove "ctrl+t to hide todos" hint from status bar Files modified: - src/constants/brain.ts (add BRAIN_DISABLED flag) - src/types/ollama.ts (add tool_calls to OllamaMessage) - src/providers/ollama/chat.ts (format tool_calls in messages) - src/tui-solid/components/header.tsx (hide Brain UI when disabled) - src/tui-solid/components/status-bar.tsx (remove hints) - src/tui-solid/components/command-menu.tsx (filter brain command) - src/tui-solid/components/input-area.tsx (fix isMenuOpen modes) - src/tui-solid/routes/session.tsx (skip brain menu when disabled) - src/services/brain.ts (early return when disabled) - src/services/chat-tui/initialize.ts (skip brain init when disabled)
This commit is contained in:
@@ -62,6 +62,9 @@ interface AppProps extends TuiInput {
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
) => void;
|
||||
onBrainSetJwtToken?: (jwtToken: string) => Promise<void>;
|
||||
onBrainSetApiKey?: (apiKey: string) => Promise<void>;
|
||||
onBrainLogout?: () => Promise<void>;
|
||||
plan?: {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -139,9 +142,14 @@ function AppContent(props: AppProps) {
|
||||
app.setCascadeEnabled(props.cascadeEnabled);
|
||||
}
|
||||
|
||||
// Navigate to session if resuming
|
||||
if (props.sessionId) {
|
||||
route.goToSession(props.sessionId);
|
||||
// Always navigate to session view (skip home page)
|
||||
// Use existing sessionId or create a new one
|
||||
if (!route.isSession()) {
|
||||
const sessionId = props.sessionId ?? `session-${Date.now()}`;
|
||||
batch(() => {
|
||||
app.setSessionInfo(sessionId, app.provider(), app.model());
|
||||
route.goToSession(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
if (props.availableModels && props.availableModels.length > 0) {
|
||||
@@ -375,6 +383,9 @@ function AppContent(props: AppProps) {
|
||||
onCascadeToggle={handleCascadeToggle}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
onLearningResponse={handleLearningResponse}
|
||||
onBrainSetJwtToken={props.onBrainSetJwtToken}
|
||||
onBrainSetApiKey={props.onBrainSetApiKey}
|
||||
onBrainLogout={props.onBrainLogout}
|
||||
plan={props.plan}
|
||||
agents={props.agents}
|
||||
currentAgent={props.currentAgent}
|
||||
@@ -429,6 +440,9 @@ export interface TuiRenderOptions extends TuiInput {
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
) => void;
|
||||
onBrainSetJwtToken?: (jwtToken: string) => Promise<void>;
|
||||
onBrainSetApiKey?: (apiKey: string) => Promise<void>;
|
||||
onBrainLogout?: () => Promise<void>;
|
||||
plan?: {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
411
src/tui-solid/components/brain-menu.tsx
Normal file
411
src/tui-solid/components/brain-menu.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { createSignal, Show, For } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { BRAIN_BANNER } from "@constants/brain";
|
||||
|
||||
interface BrainMenuProps {
|
||||
onSetJwtToken: (jwtToken: string) => Promise<void>;
|
||||
onSetApiKey: (apiKey: string) => Promise<void>;
|
||||
onLogout: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
type MenuView = "main" | "login_url" | "jwt_input" | "apikey";
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function BrainMenu(props: BrainMenuProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const [view, setView] = createSignal<MenuView>("main");
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [jwtToken, setJwtToken] = createSignal("");
|
||||
const [apiKey, setApiKey] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const isConnected = () => app.brain().status === "connected";
|
||||
|
||||
const menuItems = (): MenuItem[] => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (!isConnected()) {
|
||||
items.push({
|
||||
id: "login",
|
||||
label: "Login with Email",
|
||||
description: "Get JWT token from the web portal",
|
||||
action: () => {
|
||||
setView("login_url");
|
||||
setSelectedIndex(0);
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
id: "apikey",
|
||||
label: "Use API Key",
|
||||
description: "Enter your API key directly",
|
||||
action: () => {
|
||||
setView("apikey");
|
||||
setSelectedIndex(0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
id: "logout",
|
||||
label: "Logout",
|
||||
description: "Disconnect from CodeTyper Brain",
|
||||
action: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await props.onLogout();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Logout failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: "close",
|
||||
label: "Close",
|
||||
description: "Return to session",
|
||||
action: () => props.onClose(),
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const handleJwtSubmit = async (): Promise<void> => {
|
||||
if (!jwtToken()) {
|
||||
setError("JWT token is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await props.onSetJwtToken(jwtToken());
|
||||
setView("main");
|
||||
setJwtToken("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to set JWT token");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKey = async (): Promise<void> => {
|
||||
if (!apiKey()) {
|
||||
setError("API key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await props.onSetApiKey(apiKey());
|
||||
setView("main");
|
||||
setApiKey("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to set API key");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
if (evt.name === "escape") {
|
||||
if (view() !== "main") {
|
||||
setView("main");
|
||||
setError(null);
|
||||
setSelectedIndex(0);
|
||||
} else {
|
||||
props.onClose();
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Main menu navigation
|
||||
if (view() === "main") {
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : menuItems().length - 1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
setSelectedIndex((prev) => (prev < menuItems().length - 1 ? prev + 1 : 0));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return") {
|
||||
const item = menuItems()[selectedIndex()];
|
||||
if (item && !item.disabled) {
|
||||
item.action();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Login URL view - press Enter to go to JWT input
|
||||
if (view() === "login_url") {
|
||||
if (evt.name === "return") {
|
||||
setView("jwt_input");
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// JWT token input handling
|
||||
if (view() === "jwt_input") {
|
||||
if (evt.name === "return") {
|
||||
handleJwtSubmit();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace") {
|
||||
setJwtToken((prev) => prev.slice(0, -1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setJwtToken((prev) => prev + evt.name);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// API key form handling
|
||||
if (view() === "apikey") {
|
||||
if (evt.name === "return") {
|
||||
handleApiKey();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "backspace") {
|
||||
setApiKey((prev) => prev.slice(0, -1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setApiKey((prev) => prev + evt.name);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusColor = (): string => {
|
||||
const status = app.brain().status;
|
||||
const colorMap: Record<string, string> = {
|
||||
connected: theme.colors.success,
|
||||
connecting: theme.colors.warning,
|
||||
disconnected: theme.colors.textDim,
|
||||
error: theme.colors.error,
|
||||
};
|
||||
return colorMap[status] ?? theme.colors.textDim;
|
||||
};
|
||||
|
||||
const getStatusText = (): string => {
|
||||
const status = app.brain().status;
|
||||
const textMap: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting...",
|
||||
disconnected: "Not connected",
|
||||
error: "Connection error",
|
||||
};
|
||||
return textMap[status] ?? "Unknown";
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.accent}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={60}
|
||||
>
|
||||
{/* Header */}
|
||||
<box marginBottom={1} flexDirection="row" gap={1}>
|
||||
<text fg="#ff69b4" attributes={TextAttributes.BOLD}>
|
||||
{BRAIN_BANNER.EMOJI_CONNECTED}
|
||||
</text>
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper Brain
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Status */}
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Status: </text>
|
||||
<text fg={getStatusColor()}>{getStatusText()}</text>
|
||||
<Show when={isConnected()}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}({app.brain().knowledgeCount}K / {app.brain().memoryCount}M)
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show when={isConnected() && app.brain().user}>
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>User: </text>
|
||||
<text fg={theme.colors.info}>
|
||||
{app.brain().user?.display_name ?? app.brain().user?.email}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Error message */}
|
||||
<Show when={error()}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.error}>{error()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Main menu view */}
|
||||
<Show when={view() === "main"}>
|
||||
<box flexDirection="column">
|
||||
<For each={menuItems()}>
|
||||
{(item, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.accent : undefined}
|
||||
attributes={isSelected() ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{isSelected() ? "> " : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected() ? theme.colors.accent : undefined}
|
||||
attributes={isSelected() ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
</box>
|
||||
<box marginLeft={4}>
|
||||
<text fg={theme.colors.textDim}>{item.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<text fg={theme.colors.info}>{BRAIN_BANNER.CTA}: {BRAIN_BANNER.URL}</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
Arrow keys navigate | Enter select | Esc close
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Login URL view - shows where to login */}
|
||||
<Show when={view() === "login_url"}>
|
||||
<box flexDirection="column">
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.text}>1. Go to this page to login:</text>
|
||||
</box>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
{BRAIN_BANNER.LOGIN_URL}
|
||||
</text>
|
||||
</box>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.text}>2. After logging in, copy your JWT token</text>
|
||||
</box>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.text}>3. Press Enter to input your token</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
Enter continue | Esc back
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* JWT token input view */}
|
||||
<Show when={view() === "jwt_input"}>
|
||||
<box flexDirection="column">
|
||||
<box marginBottom={1} flexDirection="column">
|
||||
<text fg={theme.colors.accent}>JWT Token:</text>
|
||||
<box
|
||||
borderColor={theme.colors.accent}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={theme.colors.text}>
|
||||
{jwtToken() ? "*".repeat(Math.min(jwtToken().length, 40)) : " "}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<Show when={loading()}>
|
||||
<text fg={theme.colors.warning}>Saving token...</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>Enter save | Esc back</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* API key form view */}
|
||||
<Show when={view() === "apikey"}>
|
||||
<box flexDirection="column">
|
||||
<box marginBottom={1} flexDirection="column">
|
||||
<text fg={theme.colors.accent}>API Key:</text>
|
||||
<box
|
||||
borderColor={theme.colors.accent}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={theme.colors.text}>
|
||||
{apiKey() ? "*".repeat(Math.min(apiKey().length, 40)) : " "}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<Show when={loading()}>
|
||||
<text fg={theme.colors.warning}>Setting API key...</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>Enter save | Esc back</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { BRAIN_DISABLED } from "@constants/brain";
|
||||
import type { SlashCommand, CommandCategory } from "@/types/tui";
|
||||
import { SLASH_COMMANDS, COMMAND_CATEGORIES } from "@constants/tui-components";
|
||||
|
||||
@@ -22,9 +23,14 @@ const filterCommands = (
|
||||
commands: readonly SlashCommand[],
|
||||
filter: string,
|
||||
): SlashCommand[] => {
|
||||
if (!filter) return [...commands];
|
||||
// Filter out brain command when Brain is disabled
|
||||
let availableCommands = BRAIN_DISABLED
|
||||
? commands.filter((cmd) => cmd.name !== "brain")
|
||||
: [...commands];
|
||||
|
||||
if (!filter) return availableCommands;
|
||||
const query = filter.toLowerCase();
|
||||
return commands.filter(
|
||||
return availableCommands.filter(
|
||||
(cmd) =>
|
||||
cmd.name.toLowerCase().includes(query) ||
|
||||
cmd.description.toLowerCase().includes(query),
|
||||
|
||||
@@ -156,11 +156,11 @@ export function DebugLogPanel() {
|
||||
paddingRight={1}
|
||||
borderColor={theme.colors.border}
|
||||
border={["bottom"]}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={theme.colors.accent} attributes={TextAttributes.BOLD}>
|
||||
Debug Logs
|
||||
Debug Logs ({entries().length})
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> ({entries().length})</text>
|
||||
</box>
|
||||
|
||||
<scrollbox
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Show, createMemo } from "solid-js";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { BRAIN_BANNER, BRAIN_DISABLED } from "@constants/brain";
|
||||
import {
|
||||
TOKEN_WARNING_THRESHOLD,
|
||||
TOKEN_CRITICAL_THRESHOLD,
|
||||
} from "@constants/token";
|
||||
|
||||
interface HeaderProps {
|
||||
showBanner?: boolean;
|
||||
@@ -25,6 +30,30 @@ const MODE_COLORS = {
|
||||
"code-review": "success",
|
||||
} as const;
|
||||
|
||||
const BRAIN_STATUS_COLORS = {
|
||||
connected: "success",
|
||||
connecting: "warning",
|
||||
disconnected: "textDim",
|
||||
error: "error",
|
||||
} as const;
|
||||
|
||||
const TOKEN_STATUS_COLORS = {
|
||||
normal: "textDim",
|
||||
warning: "warning",
|
||||
critical: "error",
|
||||
compacting: "info",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Format token count for display (e.g., 45.2K)
|
||||
*/
|
||||
const formatTokenCount = (tokens: number): string => {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
export function Header(props: HeaderProps) {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
@@ -35,57 +64,162 @@ export function Header(props: HeaderProps) {
|
||||
return theme.colors[colorKey];
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
borderColor={theme.colors.border}
|
||||
border={["bottom"]}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={showBanner()}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>v{app.version()}</text>
|
||||
<text fg={theme.colors.textDim}>|</text>
|
||||
<box flexDirection="row">
|
||||
<text fg={modeColor()} attributes={TextAttributes.BOLD}>
|
||||
[{MODE_LABELS[app.interactionMode()]}]
|
||||
</text>
|
||||
<Show when={app.currentAgent() !== "default"}>
|
||||
<text fg={theme.colors.secondary} attributes={TextAttributes.BOLD}>
|
||||
{" "}
|
||||
@{app.currentAgent()}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
- {MODE_DESCRIPTIONS[app.interactionMode()]}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
const brainColor = createMemo(() => {
|
||||
const brain = app.brain();
|
||||
const colorKey = BRAIN_STATUS_COLORS[brain.status];
|
||||
return theme.colors[colorKey];
|
||||
});
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Provider: </text>
|
||||
<text fg={theme.colors.secondary}>{app.provider()}</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Model: </text>
|
||||
<text fg={theme.colors.accent}>{app.model() || "auto"}</text>
|
||||
</box>
|
||||
<Show when={app.sessionId()}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Session: </text>
|
||||
<text fg={theme.colors.info}>
|
||||
{app.sessionId()?.replace("session-", "").slice(-5)}
|
||||
const shouldShowBrainBanner = createMemo(() => {
|
||||
if (BRAIN_DISABLED) return false;
|
||||
const brain = app.brain();
|
||||
return brain.showBanner && brain.status === "disconnected";
|
||||
});
|
||||
|
||||
// Context window usage calculation
|
||||
const contextUsage = createMemo(() => {
|
||||
const stats = app.sessionStats();
|
||||
const totalTokens = stats.inputTokens + stats.outputTokens;
|
||||
const maxTokens = stats.contextMaxTokens;
|
||||
const usagePercent = maxTokens > 0 ? (totalTokens / maxTokens) * 100 : 0;
|
||||
|
||||
let status: "normal" | "warning" | "critical" | "compacting" = "normal";
|
||||
if (app.isCompacting()) {
|
||||
status = "compacting";
|
||||
} else if (usagePercent >= TOKEN_CRITICAL_THRESHOLD * 100) {
|
||||
status = "critical";
|
||||
} else if (usagePercent >= TOKEN_WARNING_THRESHOLD * 100) {
|
||||
status = "warning";
|
||||
}
|
||||
|
||||
return {
|
||||
current: totalTokens,
|
||||
max: maxTokens,
|
||||
percent: usagePercent,
|
||||
status,
|
||||
};
|
||||
});
|
||||
|
||||
const tokenColor = createMemo(() => {
|
||||
const colorKey = TOKEN_STATUS_COLORS[contextUsage().status];
|
||||
return theme.colors[colorKey];
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
{/* Brain Banner - shown when not connected */}
|
||||
<Show when={shouldShowBrainBanner()}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor="#1a1a2e"
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="#ff69b4" attributes={TextAttributes.BOLD}>
|
||||
{BRAIN_BANNER.EMOJI_CONNECTED}
|
||||
</text>
|
||||
<text fg="#ffffff" attributes={TextAttributes.BOLD}>
|
||||
{BRAIN_BANNER.TITLE}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>-</text>
|
||||
<text fg={theme.colors.textDim}>{BRAIN_BANNER.CTA}:</text>
|
||||
<text fg={theme.colors.info} attributes={TextAttributes.UNDERLINE}>
|
||||
{BRAIN_BANNER.URL}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>[Ctrl+B dismiss]</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Main Header */}
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
borderColor={theme.colors.border}
|
||||
border={["bottom"]}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={showBanner()}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>v{app.version()}</text>
|
||||
<text fg={theme.colors.textDim}>|</text>
|
||||
<box flexDirection="row">
|
||||
<text fg={modeColor()} attributes={TextAttributes.BOLD}>
|
||||
[{MODE_LABELS[app.interactionMode()]}]
|
||||
</text>
|
||||
<Show when={app.currentAgent() !== "default"}>
|
||||
<text fg={theme.colors.secondary} attributes={TextAttributes.BOLD}>
|
||||
{" "}
|
||||
@{app.currentAgent()}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
- {MODE_DESCRIPTIONS[app.interactionMode()]}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
{/* Context Window Usage */}
|
||||
<Show when={contextUsage().max > 0}>
|
||||
<box flexDirection="row">
|
||||
<text fg={tokenColor()}>
|
||||
{formatTokenCount(contextUsage().current)}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>/</text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{formatTokenCount(contextUsage().max)}
|
||||
</text>
|
||||
<Show when={contextUsage().status === "compacting"}>
|
||||
<text fg={theme.colors.info}> [compacting]</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Brain Status Indicator - hidden when BRAIN_DISABLED */}
|
||||
<Show when={!BRAIN_DISABLED}>
|
||||
<box flexDirection="row">
|
||||
<text fg={brainColor()}>
|
||||
{app.brain().status === "connected"
|
||||
? BRAIN_BANNER.EMOJI_CONNECTED
|
||||
: app.brain().status === "connecting"
|
||||
? "..."
|
||||
: BRAIN_BANNER.EMOJI_DISCONNECTED}
|
||||
</text>
|
||||
<Show when={app.brain().status === "connected"}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
{app.brain().knowledgeCount}K/{app.brain().memoryCount}M
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Provider: </text>
|
||||
<text fg={theme.colors.secondary}>{app.provider()}</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Model: </text>
|
||||
<text fg={theme.colors.accent}>{app.model() || "auto"}</text>
|
||||
</box>
|
||||
<Show when={app.sessionId()}>
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.colors.textDim}>Session: </text>
|
||||
<text fg={theme.colors.info}>
|
||||
{app.sessionId()?.replace("session-", "").slice(-5)}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,11 @@ export function InputArea(props: InputAreaProps) {
|
||||
mode === "permission_prompt" ||
|
||||
mode === "learning_prompt" ||
|
||||
mode === "help_menu" ||
|
||||
mode === "help_detail"
|
||||
mode === "help_detail" ||
|
||||
mode === "brain_menu" ||
|
||||
mode === "brain_login" ||
|
||||
mode === "provider_select" ||
|
||||
mode === "mcp_browse"
|
||||
);
|
||||
});
|
||||
const placeholder = () =>
|
||||
@@ -108,10 +112,10 @@ export function InputArea(props: InputAreaProps) {
|
||||
|
||||
// Handle "/" to open command menu when input is empty
|
||||
// Handle Enter to submit (backup in case onSubmit doesn't fire)
|
||||
// Handle Ctrl+Tab to toggle interaction mode
|
||||
// Handle Ctrl+M to toggle interaction mode (Ctrl+Tab doesn't work in most terminals)
|
||||
useKeyboard((evt) => {
|
||||
// Ctrl+Tab works even when locked or menus are open
|
||||
if (evt.ctrl && evt.name === "tab") {
|
||||
// Ctrl+M works even when locked or menus are open
|
||||
if (evt.ctrl && evt.name === "m") {
|
||||
app.toggleInteractionMode();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -141,10 +142,22 @@ export function LogPanel() {
|
||||
<Show
|
||||
when={hasContent()}
|
||||
fallback={
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<text fg={theme.colors.textDim}>
|
||||
No messages yet. Type your prompt below.
|
||||
</text>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -155,11 +155,6 @@ export function StatusBar() {
|
||||
const hints = createMemo(() => {
|
||||
const result: string[] = [];
|
||||
|
||||
// Show mode toggle hint when idle
|
||||
if (!isProcessing()) {
|
||||
result.push("^Tab toggle mode");
|
||||
}
|
||||
|
||||
if (isProcessing()) {
|
||||
result.push(
|
||||
app.interruptPending()
|
||||
@@ -168,10 +163,6 @@ export function StatusBar() {
|
||||
);
|
||||
}
|
||||
|
||||
if (app.todosVisible()) {
|
||||
result.push(STATUS_HINTS.TOGGLE_TODOS);
|
||||
}
|
||||
|
||||
result.push(formatDuration(elapsed()));
|
||||
|
||||
if (totalTokens() > 0) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SuggestionState,
|
||||
} from "@/types/tui";
|
||||
import type { ProviderModel } from "@/types/providers";
|
||||
import type { BrainConnectionStatus, BrainUser } from "@/types/brain";
|
||||
|
||||
interface AppStore {
|
||||
mode: AppMode;
|
||||
@@ -44,6 +45,13 @@ interface AppStore {
|
||||
streamingLog: StreamingLogState;
|
||||
suggestions: SuggestionState;
|
||||
cascadeEnabled: boolean;
|
||||
brain: {
|
||||
status: BrainConnectionStatus;
|
||||
user: BrainUser | null;
|
||||
knowledgeCount: number;
|
||||
memoryCount: number;
|
||||
showBanner: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface AppContextValue {
|
||||
@@ -81,6 +89,13 @@ interface AppContextValue {
|
||||
streamingLogIsActive: Accessor<boolean>;
|
||||
suggestions: Accessor<SuggestionState>;
|
||||
cascadeEnabled: Accessor<boolean>;
|
||||
brain: Accessor<{
|
||||
status: BrainConnectionStatus;
|
||||
user: BrainUser | null;
|
||||
knowledgeCount: number;
|
||||
memoryCount: number;
|
||||
showBanner: boolean;
|
||||
}>;
|
||||
|
||||
// Mode actions
|
||||
setMode: (mode: AppMode) => void;
|
||||
@@ -138,6 +153,7 @@ interface AppContextValue {
|
||||
stopThinking: () => void;
|
||||
addTokens: (input: number, output: number) => void;
|
||||
resetSessionStats: () => void;
|
||||
setContextMaxTokens: (maxTokens: number) => void;
|
||||
|
||||
// UI state actions
|
||||
toggleTodos: () => void;
|
||||
@@ -161,6 +177,13 @@ interface AppContextValue {
|
||||
hideSuggestions: () => void;
|
||||
showSuggestions: () => void;
|
||||
|
||||
// Brain actions
|
||||
setBrainStatus: (status: BrainConnectionStatus) => void;
|
||||
setBrainUser: (user: BrainUser | null) => void;
|
||||
setBrainCounts: (knowledge: number, memory: number) => void;
|
||||
setBrainShowBanner: (show: boolean) => void;
|
||||
dismissBrainBanner: () => void;
|
||||
|
||||
// Computed
|
||||
isInputLocked: () => boolean;
|
||||
}
|
||||
@@ -174,6 +197,7 @@ const createInitialSessionStats = (): SessionStats => ({
|
||||
outputTokens: 0,
|
||||
thinkingStartTime: null,
|
||||
lastThinkingDuration: 0,
|
||||
contextMaxTokens: 128000, // Default, updated when model is selected
|
||||
});
|
||||
|
||||
const createInitialStreamingState = (): StreamingLogState => ({
|
||||
@@ -225,6 +249,13 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
streamingLog: createInitialStreamingState(),
|
||||
suggestions: createInitialSuggestionState(),
|
||||
cascadeEnabled: true,
|
||||
brain: {
|
||||
status: "disconnected" as BrainConnectionStatus,
|
||||
user: null,
|
||||
knowledgeCount: 0,
|
||||
memoryCount: 0,
|
||||
showBanner: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Input insert function (set by InputArea)
|
||||
@@ -272,6 +303,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
const streamingLogIsActive = (): boolean => store.streamingLog.isStreaming;
|
||||
const suggestions = (): SuggestionState => store.suggestions;
|
||||
const cascadeEnabled = (): boolean => store.cascadeEnabled;
|
||||
const brain = () => store.brain;
|
||||
|
||||
// Mode actions
|
||||
const setMode = (newMode: AppMode): void => {
|
||||
@@ -469,6 +501,27 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
setStore("cascadeEnabled", !store.cascadeEnabled);
|
||||
};
|
||||
|
||||
// Brain actions
|
||||
const setBrainStatus = (status: BrainConnectionStatus): void => {
|
||||
setStore("brain", { ...store.brain, status });
|
||||
};
|
||||
|
||||
const setBrainUser = (user: BrainUser | null): void => {
|
||||
setStore("brain", { ...store.brain, user });
|
||||
};
|
||||
|
||||
const setBrainCounts = (knowledgeCount: number, memoryCount: number): void => {
|
||||
setStore("brain", { ...store.brain, knowledgeCount, memoryCount });
|
||||
};
|
||||
|
||||
const setBrainShowBanner = (showBanner: boolean): void => {
|
||||
setStore("brain", { ...store.brain, showBanner });
|
||||
};
|
||||
|
||||
const dismissBrainBanner = (): void => {
|
||||
setStore("brain", { ...store.brain, showBanner: false });
|
||||
};
|
||||
|
||||
// Session stats actions
|
||||
const startThinking = (): void => {
|
||||
setStore("sessionStats", {
|
||||
@@ -502,6 +555,13 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
setStore("sessionStats", createInitialSessionStats());
|
||||
};
|
||||
|
||||
const setContextMaxTokens = (maxTokens: number): void => {
|
||||
setStore("sessionStats", {
|
||||
...store.sessionStats,
|
||||
contextMaxTokens: maxTokens,
|
||||
});
|
||||
};
|
||||
|
||||
// UI state actions
|
||||
const toggleTodos = (): void => {
|
||||
setStore("todosVisible", !store.todosVisible);
|
||||
@@ -698,6 +758,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
streamingLogIsActive,
|
||||
suggestions,
|
||||
cascadeEnabled,
|
||||
brain,
|
||||
|
||||
// Mode actions
|
||||
setMode,
|
||||
@@ -752,11 +813,19 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
setCascadeEnabled,
|
||||
toggleCascadeEnabled,
|
||||
|
||||
// Brain actions
|
||||
setBrainStatus,
|
||||
setBrainUser,
|
||||
setBrainCounts,
|
||||
setBrainShowBanner,
|
||||
dismissBrainBanner,
|
||||
|
||||
// Session stats actions
|
||||
startThinking,
|
||||
stopThinking,
|
||||
addTokens,
|
||||
resetSessionStats,
|
||||
setContextMaxTokens,
|
||||
|
||||
// UI state actions
|
||||
toggleTodos,
|
||||
@@ -818,6 +887,13 @@ const defaultAppState = {
|
||||
isCompacting: false,
|
||||
streamingLog: createInitialStreamingState(),
|
||||
suggestions: createInitialSuggestionState(),
|
||||
brain: {
|
||||
status: "disconnected" as BrainConnectionStatus,
|
||||
user: null,
|
||||
knowledgeCount: 0,
|
||||
memoryCount: 0,
|
||||
showBanner: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const appStore = {
|
||||
@@ -850,6 +926,7 @@ export const appStore = {
|
||||
isCompacting: storeRef.isCompacting(),
|
||||
streamingLog: storeRef.streamingLog(),
|
||||
suggestions: storeRef.suggestions(),
|
||||
brain: storeRef.brain(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -958,6 +1035,11 @@ export const appStore = {
|
||||
storeRef.resetSessionStats();
|
||||
},
|
||||
|
||||
setContextMaxTokens: (maxTokens: number): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.setContextMaxTokens(maxTokens);
|
||||
},
|
||||
|
||||
toggleTodos: (): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.toggleTodos();
|
||||
@@ -1027,4 +1109,29 @@ export const appStore = {
|
||||
if (!storeRef) return;
|
||||
storeRef.toggleCascadeEnabled();
|
||||
},
|
||||
|
||||
setBrainStatus: (status: BrainConnectionStatus): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.setBrainStatus(status);
|
||||
},
|
||||
|
||||
setBrainUser: (user: BrainUser | null): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.setBrainUser(user);
|
||||
},
|
||||
|
||||
setBrainCounts: (knowledge: number, memory: number): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.setBrainCounts(knowledge, memory);
|
||||
},
|
||||
|
||||
setBrainShowBanner: (show: boolean): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.setBrainShowBanner(show);
|
||||
},
|
||||
|
||||
dismissBrainBanner: (): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.dismissBrainBanner();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,8 +53,7 @@ export function Home(props: HomeProps) {
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<box marginTop={2} flexDirection="column" alignItems="center">
|
||||
<text fg={theme.colors.textDim}>{HOME_VARS.title}</text>
|
||||
<box marginTop={2}>
|
||||
<text fg={theme.colors.textDim}>{HOME_VARS.subTitle}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -21,6 +21,8 @@ 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 { BRAIN_DISABLED } from "@constants/brain";
|
||||
import type { PermissionScope, LearningScope, InteractionMode } from "@/types/tui";
|
||||
import type { MCPAddFormData } from "@/types/mcp";
|
||||
|
||||
@@ -60,6 +62,9 @@ interface SessionProps {
|
||||
scope?: LearningScope,
|
||||
editedContent?: string,
|
||||
) => void;
|
||||
onBrainSetJwtToken?: (jwtToken: string) => Promise<void>;
|
||||
onBrainSetApiKey?: (apiKey: string) => Promise<void>;
|
||||
onBrainLogout?: () => Promise<void>;
|
||||
plan?: {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -113,6 +118,10 @@ export function Session(props: SessionProps) {
|
||||
app.transitionFromCommandMenu("help_menu");
|
||||
return;
|
||||
}
|
||||
if (lowerCommand === "brain" && !BRAIN_DISABLED) {
|
||||
app.transitionFromCommandMenu("brain_menu");
|
||||
return;
|
||||
}
|
||||
// For other commands, close menu and process through handler
|
||||
app.closeCommandMenu();
|
||||
props.onCommand(command);
|
||||
@@ -192,6 +201,22 @@ export function Session(props: SessionProps) {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleBrainMenuClose = (): void => {
|
||||
app.setMode("idle");
|
||||
};
|
||||
|
||||
const handleBrainSetJwtToken = async (jwtToken: string): Promise<void> => {
|
||||
await props.onBrainSetJwtToken?.(jwtToken);
|
||||
};
|
||||
|
||||
const handleBrainSetApiKey = async (apiKey: string): Promise<void> => {
|
||||
await props.onBrainSetApiKey?.(apiKey);
|
||||
};
|
||||
|
||||
const handleBrainLogout = async (): Promise<void> => {
|
||||
await props.onBrainLogout?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -362,6 +387,18 @@ export function Session(props: SessionProps) {
|
||||
/>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
|
||||
<Match when={app.mode() === "brain_menu" && !BRAIN_DISABLED}>
|
||||
<CenteredModal>
|
||||
<BrainMenu
|
||||
onSetJwtToken={handleBrainSetJwtToken}
|
||||
onSetApiKey={handleBrainSetApiKey}
|
||||
onLogout={handleBrainLogout}
|
||||
onClose={handleBrainMenuClose}
|
||||
isActive={app.mode() === "brain_menu"}
|
||||
/>
|
||||
</CenteredModal>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user