feat: add pink-purple theme, fix image paste race condition, allow @/commands anywhere in input
- Add Pink Purple theme (hot pink/purple/magenta on dark plum background) - Fix race condition where clearPastedImages() in input-area ran before the async message handler could read the images, silently dropping them - Allow @ file picker and / command menu to trigger at any cursor position, not just when the input is empty - Update CHANGELOG and README with new changes
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
advanceStep,
|
||||
getExecutionState,
|
||||
} from "@services/chat-tui-service";
|
||||
import { matchesAction } from "@services/keybind-resolver";
|
||||
import { TERMINAL_RESET } from "@constants/terminal";
|
||||
import { formatExitMessage } from "@services/exit-message";
|
||||
import { copyToClipboard } from "@services/clipboard/text-clipboard";
|
||||
@@ -185,15 +186,15 @@ function AppContent(props: AppProps) {
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
// Ctrl+Y copies selected text to clipboard
|
||||
if (evt.ctrl && evt.name === "y") {
|
||||
// Clipboard: copy selected text
|
||||
if (matchesAction(evt, "clipboard_copy")) {
|
||||
copySelectionToClipboard();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC aborts current operation
|
||||
if (evt.name === "escape") {
|
||||
// Session interrupt (ESC) — abort current operation
|
||||
if (matchesAction(evt, "session_interrupt")) {
|
||||
abortCurrentOperation(false).then((aborted) => {
|
||||
if (aborted) {
|
||||
toast.info("Operation cancelled");
|
||||
@@ -203,8 +204,8 @@ function AppContent(props: AppProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+P toggles pause/resume during execution
|
||||
if (evt.ctrl && evt.name === "p") {
|
||||
// Pause/resume execution
|
||||
if (matchesAction(evt, "session_pause_resume")) {
|
||||
const toggled = togglePauseResume();
|
||||
if (toggled) {
|
||||
const state = getExecutionState();
|
||||
@@ -218,8 +219,8 @@ function AppContent(props: AppProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Z aborts with rollback
|
||||
if (evt.ctrl && evt.name === "z") {
|
||||
// Abort with rollback
|
||||
if (matchesAction(evt, "session_abort_rollback")) {
|
||||
const state = getExecutionState();
|
||||
if (state.state !== "idle") {
|
||||
abortCurrentOperation(true).then((aborted) => {
|
||||
@@ -234,8 +235,8 @@ function AppContent(props: AppProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+S toggles step mode
|
||||
if (evt.ctrl && evt.shift && evt.name === "s") {
|
||||
// Toggle step mode
|
||||
if (matchesAction(evt, "session_step_toggle")) {
|
||||
const state = getExecutionState();
|
||||
if (state.state !== "idle") {
|
||||
const isStepMode = state.state === "stepping";
|
||||
@@ -248,8 +249,8 @@ function AppContent(props: AppProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Enter advances step when waiting for step confirmation
|
||||
if (evt.name === "return" && !evt.ctrl && !evt.shift) {
|
||||
// Advance one step when waiting for step confirmation
|
||||
if (matchesAction(evt, "session_step_advance")) {
|
||||
const state = getExecutionState();
|
||||
if (state.waitingForStep) {
|
||||
advanceStep();
|
||||
@@ -258,8 +259,8 @@ function AppContent(props: AppProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+C exits the application (with confirmation)
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
// App exit (Ctrl+C with confirmation)
|
||||
if (matchesAction(evt, "app_exit")) {
|
||||
// First try to abort current operation
|
||||
const state = getExecutionState();
|
||||
if (state.state !== "idle") {
|
||||
@@ -285,7 +286,8 @@ function AppContent(props: AppProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "/" && app.mode() === "idle" && !app.inputBuffer()) {
|
||||
// Command menu trigger from "/" when input is empty
|
||||
if (matchesAction(evt, "command_menu") && app.mode() === "idle" && !app.inputBuffer()) {
|
||||
app.openCommandMenu();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
@@ -378,8 +380,11 @@ function AppContent(props: AppProps) {
|
||||
const handlePlanApprovalResponse = (
|
||||
response: PlanApprovalPromptResponse,
|
||||
): void => {
|
||||
// Don't set mode here - the resolve callback in plan-approval.ts
|
||||
// handles the mode transition
|
||||
// Resolve the blocking promise stored on the prompt
|
||||
const prompt = app.planApprovalPrompt();
|
||||
if (prompt?.resolve) {
|
||||
prompt.resolve(response);
|
||||
}
|
||||
props.onPlanApprovalResponse(response);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { createMemo, Show, onMount, onCleanup } from "solid-js";
|
||||
import { createMemo, Show, For, onMount, onCleanup } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextareaRenderable, type PasteEvent } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import { useAppStore } from "@tui-solid/context/app";
|
||||
import { matchesAction } from "@services/keybind-resolver";
|
||||
import {
|
||||
readClipboardImage,
|
||||
formatImageSize,
|
||||
getImageSizeFromBase64,
|
||||
} from "@services/clipboard-service";
|
||||
|
||||
/** Minimum lines to trigger paste summary */
|
||||
const MIN_PASTE_LINES = 3;
|
||||
@@ -110,12 +116,40 @@ export function InputArea(props: InputAreaProps) {
|
||||
return theme.colors.border;
|
||||
});
|
||||
|
||||
// Handle "/" to open command menu when input is empty
|
||||
// Handle Enter to submit (backup in case onSubmit doesn't fire)
|
||||
// Handle Ctrl+M to toggle interaction mode (Ctrl+Tab doesn't work in most terminals)
|
||||
/**
|
||||
* Try to read an image from the clipboard and add it as a pasted image.
|
||||
* Called on Ctrl+V — if an image is found it gets inserted as a placeholder;
|
||||
* otherwise the default text-paste behavior takes over.
|
||||
*/
|
||||
const handleImagePaste = async (): Promise<boolean> => {
|
||||
try {
|
||||
const image = await readClipboardImage();
|
||||
if (!image) return false;
|
||||
|
||||
// Store the image in app state
|
||||
app.addPastedImage(image);
|
||||
|
||||
// Insert a visual placeholder into the input
|
||||
const size = formatImageSize(getImageSizeFromBase64(image.data));
|
||||
const placeholder = `[Image: ${image.mediaType.split("/")[1].toUpperCase()} ${size}]`;
|
||||
|
||||
if (inputRef) {
|
||||
inputRef.insertText(placeholder + " ");
|
||||
app.setInputBuffer(inputRef.plainText);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard handler using configurable keybinds from keybind-resolver.
|
||||
// Keybinds are loaded from ~/.config/codetyper/keybindings.json on startup.
|
||||
// See DEFAULT_KEYBINDS in constants/keybinds.ts for all available actions.
|
||||
useKeyboard((evt) => {
|
||||
// Ctrl+M works even when locked or menus are open
|
||||
if (evt.ctrl && evt.name === "m") {
|
||||
// Mode toggle — works even when locked or menus are open
|
||||
if (matchesAction(evt, "mode_toggle")) {
|
||||
app.toggleInteractionMode();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
@@ -126,21 +160,46 @@ export function InputArea(props: InputAreaProps) {
|
||||
// Don't capture keys when any menu/modal is open
|
||||
if (isMenuOpen()) return;
|
||||
|
||||
if (evt.name === "/" && !app.inputBuffer()) {
|
||||
// Ctrl+V: attempt clipboard image paste first, then fall through to text paste
|
||||
if (matchesAction(evt, "input_paste")) {
|
||||
handleImagePaste().then((handled) => {
|
||||
// If an image was found, the placeholder is already inserted.
|
||||
// If not, the default terminal paste (text) has already fired.
|
||||
if (handled) {
|
||||
// Image was pasted — nothing else to do
|
||||
}
|
||||
});
|
||||
// Don't preventDefault — let the terminal's native text paste
|
||||
// fire in parallel. If the clipboard has an image, handleImagePaste
|
||||
// will insert its own placeholder; the text paste will be empty/no-op.
|
||||
return;
|
||||
}
|
||||
|
||||
// Command menu from "/" — works at any point in the input
|
||||
if (matchesAction(evt, "command_menu")) {
|
||||
app.insertText("/");
|
||||
app.openCommandMenu();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "@") {
|
||||
// File picker from "@" — works at any point in the input
|
||||
if (matchesAction(evt, "file_picker")) {
|
||||
app.insertText("@");
|
||||
app.setMode("file_picker");
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "return" && !evt.shift && !evt.ctrl && !evt.meta) {
|
||||
// Submit input
|
||||
if (
|
||||
matchesAction(evt, "input_submit") &&
|
||||
!evt.shift &&
|
||||
!evt.ctrl &&
|
||||
!evt.meta
|
||||
) {
|
||||
handleSubmit();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
@@ -157,9 +216,14 @@ export function InputArea(props: InputAreaProps) {
|
||||
if (inputRef) inputRef.clear();
|
||||
app.setInputBuffer("");
|
||||
clearPastedBlocks();
|
||||
// NOTE: Do NOT clear pasted images here — the message handler reads them
|
||||
// asynchronously and clears them after consuming. Clearing here would race
|
||||
// and cause images to be silently dropped.
|
||||
}
|
||||
};
|
||||
|
||||
const imageCount = createMemo(() => app.pastedImages().length);
|
||||
|
||||
/**
|
||||
* Handle paste events - summarize large pastes
|
||||
*/
|
||||
@@ -238,21 +302,42 @@ export function InputArea(props: InputAreaProps) {
|
||||
onKeyDown={(evt) => {
|
||||
// Don't capture keys when any menu/modal is open
|
||||
if (isMenuOpen()) return;
|
||||
if (evt.name === "return" && !evt.shift && !evt.ctrl && !evt.meta) {
|
||||
if (
|
||||
matchesAction(evt, "input_submit") &&
|
||||
!evt.shift &&
|
||||
!evt.ctrl &&
|
||||
!evt.meta
|
||||
) {
|
||||
handleSubmit();
|
||||
evt.preventDefault();
|
||||
}
|
||||
if (evt.name === "/" && !app.inputBuffer()) {
|
||||
if (matchesAction(evt, "command_menu")) {
|
||||
app.insertText("/");
|
||||
app.openCommandMenu();
|
||||
evt.preventDefault();
|
||||
}
|
||||
if (evt.name === "@") {
|
||||
if (matchesAction(evt, "file_picker")) {
|
||||
app.insertText("@");
|
||||
app.setMode("file_picker");
|
||||
evt.preventDefault();
|
||||
}
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<Show when={imageCount() > 0}>
|
||||
<box flexDirection="row" paddingTop={0}>
|
||||
<For each={app.pastedImages()}>
|
||||
{(img) => (
|
||||
<text fg={theme.colors.accent}>
|
||||
{` [${img.mediaType.split("/")[1].toUpperCase()} ${formatImageSize(getImageSizeFromBase64(img.data))}]`}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{` (${imageCount()} image${imageCount() > 1 ? "s" : ""} attached)`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { createSignal, createMemo, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
import type { MCPAddFormData } from "@/types/mcp";
|
||||
import type { MCPAddFormData, MCPTransportType } from "@/types/mcp";
|
||||
|
||||
interface MCPAddFormProps {
|
||||
onSubmit: (data: MCPAddFormData) => void;
|
||||
@@ -10,21 +10,26 @@ interface MCPAddFormProps {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
type FormField = "name" | "command" | "args" | "scope";
|
||||
/** All fields in order. The visible set depends on the selected transport. */
|
||||
type FormField = "name" | "type" | "command" | "args" | "url" | "scope";
|
||||
|
||||
const FIELD_ORDER: FormField[] = ["name", "command", "args", "scope"];
|
||||
const TRANSPORT_OPTIONS: MCPTransportType[] = ["stdio", "http", "sse"];
|
||||
|
||||
const FIELD_LABELS: Record<FormField, string> = {
|
||||
name: "Server Name",
|
||||
type: "Transport",
|
||||
command: "Command",
|
||||
args: "Arguments (use quotes for paths with spaces)",
|
||||
args: "Arguments",
|
||||
url: "URL",
|
||||
scope: "Scope",
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDERS: Record<FormField, string> = {
|
||||
name: "e.g., filesystem",
|
||||
name: "e.g., figma",
|
||||
type: "",
|
||||
command: "e.g., npx",
|
||||
args: 'e.g., -y @modelcontextprotocol/server-filesystem "/path/to/dir"',
|
||||
args: 'e.g., -y @modelcontextprotocol/server-filesystem "/path"',
|
||||
url: "e.g., https://mcp.figma.com/mcp",
|
||||
scope: "",
|
||||
};
|
||||
|
||||
@@ -34,65 +39,109 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
|
||||
const [currentField, setCurrentField] = createSignal<FormField>("name");
|
||||
const [name, setName] = createSignal("");
|
||||
const [transport, setTransport] = createSignal<MCPTransportType>("http");
|
||||
const [command, setCommand] = createSignal("");
|
||||
const [args, setArgs] = createSignal("");
|
||||
const [url, setUrl] = createSignal("");
|
||||
const [isGlobal, setIsGlobal] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
/** Visible fields depend on the selected transport */
|
||||
const fieldOrder = createMemo((): FormField[] => {
|
||||
if (transport() === "stdio") {
|
||||
return ["name", "type", "command", "args", "scope"];
|
||||
}
|
||||
return ["name", "type", "url", "scope"];
|
||||
});
|
||||
|
||||
const getFieldValue = (field: FormField): string => {
|
||||
const fieldGetters: Record<FormField, () => string> = {
|
||||
name: name,
|
||||
command: command,
|
||||
args: args,
|
||||
const getters: Record<FormField, () => string> = {
|
||||
name,
|
||||
type: transport,
|
||||
command,
|
||||
args,
|
||||
url,
|
||||
scope: () => (isGlobal() ? "global" : "local"),
|
||||
};
|
||||
return fieldGetters[field]();
|
||||
return getters[field]();
|
||||
};
|
||||
|
||||
const setFieldValue = (field: FormField, value: string): void => {
|
||||
const fieldSetters: Record<FormField, (v: string) => void> = {
|
||||
const setters: Record<FormField, (v: string) => void> = {
|
||||
name: setName,
|
||||
type: (v) => setTransport(v as MCPTransportType),
|
||||
command: setCommand,
|
||||
args: setArgs,
|
||||
url: setUrl,
|
||||
scope: () => setIsGlobal(value === "global"),
|
||||
};
|
||||
fieldSetters[field](value);
|
||||
setters[field](value);
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
setError(null);
|
||||
const n = name().trim();
|
||||
|
||||
if (!name().trim()) {
|
||||
if (!n) {
|
||||
setError("Server name is required");
|
||||
setCurrentField("name");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command().trim()) {
|
||||
setError("Command is required");
|
||||
setCurrentField("command");
|
||||
return;
|
||||
if (transport() === "stdio") {
|
||||
if (!command().trim()) {
|
||||
setError("Command is required for stdio transport");
|
||||
setCurrentField("command");
|
||||
return;
|
||||
}
|
||||
props.onSubmit({
|
||||
name: n,
|
||||
type: "stdio",
|
||||
command: command().trim(),
|
||||
args: args().trim() || undefined,
|
||||
isGlobal: isGlobal(),
|
||||
});
|
||||
} else {
|
||||
if (!url().trim()) {
|
||||
setError("URL is required for http/sse transport");
|
||||
setCurrentField("url");
|
||||
return;
|
||||
}
|
||||
props.onSubmit({
|
||||
name: n,
|
||||
type: transport(),
|
||||
url: url().trim(),
|
||||
isGlobal: isGlobal(),
|
||||
});
|
||||
}
|
||||
|
||||
props.onSubmit({
|
||||
name: name().trim(),
|
||||
command: command().trim(),
|
||||
args: args().trim(),
|
||||
isGlobal: isGlobal(),
|
||||
});
|
||||
};
|
||||
|
||||
const moveToNextField = (): void => {
|
||||
const currentIndex = FIELD_ORDER.indexOf(currentField());
|
||||
if (currentIndex < FIELD_ORDER.length - 1) {
|
||||
setCurrentField(FIELD_ORDER[currentIndex + 1]);
|
||||
const order = fieldOrder();
|
||||
const idx = order.indexOf(currentField());
|
||||
if (idx < order.length - 1) {
|
||||
setCurrentField(order[idx + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const moveToPrevField = (): void => {
|
||||
const currentIndex = FIELD_ORDER.indexOf(currentField());
|
||||
if (currentIndex > 0) {
|
||||
setCurrentField(FIELD_ORDER[currentIndex - 1]);
|
||||
const order = fieldOrder();
|
||||
const idx = order.indexOf(currentField());
|
||||
if (idx > 0) {
|
||||
setCurrentField(order[idx - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
/** Cycle through transport options */
|
||||
const cycleTransport = (direction: 1 | -1): void => {
|
||||
const idx = TRANSPORT_OPTIONS.indexOf(transport());
|
||||
const next =
|
||||
(idx + direction + TRANSPORT_OPTIONS.length) % TRANSPORT_OPTIONS.length;
|
||||
setTransport(TRANSPORT_OPTIONS[next]);
|
||||
|
||||
// If the current field is no longer visible after switching, jump to next valid
|
||||
if (!fieldOrder().includes(currentField())) {
|
||||
setCurrentField("type");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,6 +157,7 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
|
||||
const field = currentField();
|
||||
|
||||
// Enter — submit on last field, otherwise advance
|
||||
if (evt.name === "return") {
|
||||
if (field === "scope") {
|
||||
handleSubmit();
|
||||
@@ -118,54 +168,64 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab navigation
|
||||
if (evt.name === "tab") {
|
||||
if (evt.shift) {
|
||||
moveToPrevField();
|
||||
} else {
|
||||
moveToNextField();
|
||||
}
|
||||
if (evt.shift) moveToPrevField();
|
||||
else moveToNextField();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up / Down
|
||||
if (evt.name === "up") {
|
||||
if (field === "scope") {
|
||||
setIsGlobal(!isGlobal());
|
||||
} else {
|
||||
moveToPrevField();
|
||||
}
|
||||
if (field === "scope") setIsGlobal(!isGlobal());
|
||||
else if (field === "type") cycleTransport(-1);
|
||||
else moveToPrevField();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "down") {
|
||||
if (field === "scope") setIsGlobal(!isGlobal());
|
||||
else if (field === "type") cycleTransport(1);
|
||||
else moveToNextField();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "down") {
|
||||
if (field === "scope") {
|
||||
setIsGlobal(!isGlobal());
|
||||
} else {
|
||||
moveToNextField();
|
||||
// Left / Right / Space on selector fields
|
||||
if (field === "type") {
|
||||
if (
|
||||
evt.name === "space" ||
|
||||
evt.name === "left" ||
|
||||
evt.name === "right"
|
||||
) {
|
||||
cycleTransport(evt.name === "left" ? -1 : 1);
|
||||
evt.preventDefault();
|
||||
}
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "scope") {
|
||||
if (evt.name === "space" || evt.name === "left" || evt.name === "right") {
|
||||
if (
|
||||
evt.name === "space" ||
|
||||
evt.name === "left" ||
|
||||
evt.name === "right"
|
||||
) {
|
||||
setIsGlobal(!isGlobal());
|
||||
evt.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (evt.name === "backspace" || evt.name === "delete") {
|
||||
const currentValue = getFieldValue(field);
|
||||
if (currentValue.length > 0) {
|
||||
setFieldValue(field, currentValue.slice(0, -1));
|
||||
}
|
||||
const val = getFieldValue(field);
|
||||
if (val.length > 0) setFieldValue(field, val.slice(0, -1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle space key
|
||||
// Space
|
||||
if (evt.name === "space") {
|
||||
setFieldValue(field, getFieldValue(field) + " ");
|
||||
setError(null);
|
||||
@@ -173,14 +233,10 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle paste (Ctrl+V) - terminal paste usually comes as sequence of characters
|
||||
// but some terminals send the full pasted text as a single event
|
||||
if (evt.ctrl && evt.name === "v") {
|
||||
// Let the terminal handle paste - don't prevent default
|
||||
return;
|
||||
}
|
||||
// Ctrl+V
|
||||
if (evt.ctrl && evt.name === "v") return;
|
||||
|
||||
// Handle regular character input
|
||||
// Single char
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFieldValue(field, getFieldValue(field) + evt.name);
|
||||
setError(null);
|
||||
@@ -188,7 +244,7 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-character input (e.g., pasted text from terminal)
|
||||
// Pasted text
|
||||
if (evt.sequence && evt.sequence.length > 1 && !evt.ctrl && !evt.meta) {
|
||||
setFieldValue(field, getFieldValue(field) + evt.sequence);
|
||||
setError(null);
|
||||
@@ -196,57 +252,20 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
}
|
||||
});
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
const isCurrentField = currentField() === field;
|
||||
// ──────────── Renderers ────────────
|
||||
|
||||
const renderTextField = (field: FormField) => {
|
||||
const isCurrent = currentField() === field;
|
||||
const value = getFieldValue(field);
|
||||
const placeholder = FIELD_PLACEHOLDERS[field];
|
||||
|
||||
if (field === "scope") {
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={isCurrentField ? theme.colors.primary : theme.colors.text}
|
||||
attributes={
|
||||
isCurrentField ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
{isCurrentField ? "> " : " "}
|
||||
{FIELD_LABELS[field]}:{" "}
|
||||
</text>
|
||||
<text
|
||||
fg={!isGlobal() ? theme.colors.success : theme.colors.textDim}
|
||||
attributes={
|
||||
!isGlobal() && isCurrentField
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
[Local]
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> / </text>
|
||||
<text
|
||||
fg={isGlobal() ? theme.colors.warning : theme.colors.textDim}
|
||||
attributes={
|
||||
isGlobal() && isCurrentField
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
[Global]
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={isCurrentField ? theme.colors.primary : theme.colors.text}
|
||||
attributes={
|
||||
isCurrentField ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
fg={isCurrent ? theme.colors.primary : theme.colors.text}
|
||||
attributes={isCurrent ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{isCurrentField ? "> " : " "}
|
||||
{isCurrent ? "> " : " "}
|
||||
{FIELD_LABELS[field]}:{" "}
|
||||
</text>
|
||||
<Show
|
||||
@@ -254,19 +273,93 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
fallback={
|
||||
<text fg={theme.colors.textDim}>
|
||||
{placeholder}
|
||||
{isCurrentField ? "_" : ""}
|
||||
{isCurrent ? "_" : ""}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={theme.colors.text}>
|
||||
{value}
|
||||
{isCurrentField ? "_" : ""}
|
||||
{isCurrent ? "_" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTransportField = () => {
|
||||
const isCurrent = currentField() === "type";
|
||||
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={isCurrent ? theme.colors.primary : theme.colors.text}
|
||||
attributes={isCurrent ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{isCurrent ? "> " : " "}
|
||||
{FIELD_LABELS.type}:{" "}
|
||||
</text>
|
||||
{TRANSPORT_OPTIONS.map((opt) => (
|
||||
<>
|
||||
<text
|
||||
fg={
|
||||
transport() === opt
|
||||
? theme.colors.success
|
||||
: theme.colors.textDim
|
||||
}
|
||||
attributes={
|
||||
transport() === opt && isCurrent
|
||||
? TextAttributes.BOLD
|
||||
: TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
[{opt}]
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> </text>
|
||||
</>
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScopeField = () => {
|
||||
const isCurrent = currentField() === "scope";
|
||||
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text
|
||||
fg={isCurrent ? theme.colors.primary : theme.colors.text}
|
||||
attributes={isCurrent ? TextAttributes.BOLD : TextAttributes.NONE}
|
||||
>
|
||||
{isCurrent ? "> " : " "}
|
||||
{FIELD_LABELS.scope}:{" "}
|
||||
</text>
|
||||
<text
|
||||
fg={!isGlobal() ? theme.colors.success : theme.colors.textDim}
|
||||
attributes={
|
||||
!isGlobal() && isCurrent ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
[Local]
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> / </text>
|
||||
<text
|
||||
fg={isGlobal() ? theme.colors.warning : theme.colors.textDim}
|
||||
attributes={
|
||||
isGlobal() && isCurrent ? TextAttributes.BOLD : TextAttributes.NONE
|
||||
}
|
||||
>
|
||||
[Global]
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
if (field === "type") return renderTransportField();
|
||||
if (field === "scope") return renderScopeField();
|
||||
return renderTextField(field);
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -290,14 +383,11 @@ export function MCPAddForm(props: MCPAddFormProps) {
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{renderField("name")}
|
||||
{renderField("command")}
|
||||
{renderField("args")}
|
||||
{renderField("scope")}
|
||||
{fieldOrder().map((f) => renderField(f))}
|
||||
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<text fg={theme.colors.textDim}>
|
||||
Tab/Enter next | Shift+Tab prev | ↑↓ navigate | Esc cancel
|
||||
Tab/Enter next | Shift+Tab prev | ←→ switch option | Esc cancel
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}>Enter on Scope to submit</text>
|
||||
</box>
|
||||
|
||||
@@ -25,9 +25,16 @@ const PERMISSION_OPTIONS: PermissionOption[] = [
|
||||
{ key: "n", label: "No, deny this request", scope: "deny", allowed: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default to "Yes, for this session" (index 1) instead of "Yes, this once" (index 0).
|
||||
* This prevents the user from having to re-approve the same pattern every time
|
||||
* (e.g. for curl, web_search, etc.) which is the most common intended behavior.
|
||||
*/
|
||||
const DEFAULT_SELECTION = 1;
|
||||
|
||||
export function PermissionModal(props: PermissionModalProps) {
|
||||
const theme = useTheme();
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(DEFAULT_SELECTION);
|
||||
const isActive = () => props.isActive ?? true;
|
||||
|
||||
const handleResponse = (allowed: boolean, scope?: PermissionScope): void => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useKeyboard } from "@opentui/solid";
|
||||
import { TextAttributes } from "@opentui/core";
|
||||
import { useTheme } from "@tui-solid/context/theme";
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PLAN_APPROVAL_OPTIONS,
|
||||
PLAN_APPROVAL_FOOTER_TEXT,
|
||||
} from "@constants/plan-approval";
|
||||
import type { PlanApprovalOption, PlanEditMode } from "@constants/plan-approval";
|
||||
import type { PlanApprovalOption } from "@constants/plan-approval";
|
||||
|
||||
interface PlanApprovalModalProps {
|
||||
prompt: PlanApprovalPrompt;
|
||||
@@ -63,33 +63,54 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
useKeyboard((evt) => {
|
||||
if (!isActive()) return;
|
||||
|
||||
evt.stopPropagation();
|
||||
|
||||
// Feedback mode: handle text input
|
||||
if (feedbackMode()) {
|
||||
if (evt.name === "return") {
|
||||
handleFeedbackSubmit();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "escape") {
|
||||
handleCancel();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (evt.name === "backspace") {
|
||||
if (evt.name === "backspace" || evt.name === "delete") {
|
||||
setFeedbackText((prev) => prev.slice(0, -1));
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Handle space key (name is "space", not " ")
|
||||
if (evt.name === "space") {
|
||||
setFeedbackText((prev) => prev + " ");
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Handle regular character input
|
||||
if (evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
setFeedbackText((prev) => prev + evt.name);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Handle multi-character input (e.g., pasted text)
|
||||
if (evt.sequence && evt.sequence.length > 1 && !evt.ctrl && !evt.meta) {
|
||||
setFeedbackText((prev) => prev + evt.sequence);
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: navigate options
|
||||
if (evt.name === "escape") {
|
||||
handleCancel();
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "up") {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : optionCount - 1,
|
||||
@@ -112,12 +133,6 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.name === "escape") {
|
||||
handleCancel();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Tab shortcut for option 1 (auto-accept clear)
|
||||
if (evt.name === "tab" && evt.shift) {
|
||||
handleApproval(PLAN_APPROVAL_OPTIONS[0]);
|
||||
@@ -139,23 +154,23 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderColor={theme.colors.borderModal}
|
||||
border={["top", "bottom", "left", "right"]}
|
||||
border={["top"]}
|
||||
backgroundColor={theme.colors.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
width="100%"
|
||||
>
|
||||
{/* Header */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
CodeTyper has written up a plan and is ready to execute. Would you
|
||||
like to proceed?
|
||||
</text>
|
||||
</box>
|
||||
{/* Plan content (shrinkable to fit available space) */}
|
||||
<Show when={props.prompt.planContent}>
|
||||
<box marginBottom={1} flexDirection="column" flexShrink={1} overflow="hidden">
|
||||
<text fg={theme.colors.text}>{props.prompt.planContent}</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Plan info */}
|
||||
<Show when={props.prompt.planTitle}>
|
||||
{/* Fallback: title + summary when no full content */}
|
||||
<Show when={!props.prompt.planContent && props.prompt.planTitle}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.text} attributes={TextAttributes.BOLD}>
|
||||
{props.prompt.planTitle}
|
||||
@@ -163,15 +178,22 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={props.prompt.planSummary}>
|
||||
<Show when={!props.prompt.planContent && props.prompt.planSummary}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.textDim}>{props.prompt.planSummary}</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Approval prompt */}
|
||||
<box marginBottom={1} flexShrink={0}>
|
||||
<text fg={theme.colors.primary} attributes={TextAttributes.BOLD}>
|
||||
Would you like to proceed with this plan?
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Options */}
|
||||
<Show when={!feedbackMode()}>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
<For each={PLAN_APPROVAL_OPTIONS}>
|
||||
{(option, index) => {
|
||||
const isSelected = () => index() === selectedIndex();
|
||||
@@ -205,7 +227,7 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
|
||||
{/* Feedback input mode */}
|
||||
<Show when={feedbackMode()}>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<box flexDirection="column" marginTop={1} flexShrink={0}>
|
||||
<text fg={theme.colors.text}>
|
||||
Tell CodeTyper what to change:
|
||||
</text>
|
||||
@@ -228,7 +250,7 @@ export function PlanApprovalModal(props: PlanApprovalModalProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<Show when={!feedbackMode()}>
|
||||
<box marginTop={1} flexDirection="row">
|
||||
<box marginTop={1} flexDirection="row" flexShrink={0}>
|
||||
<Show when={props.prompt.planFilePath}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{PLAN_APPROVAL_FOOTER_TEXT} - {props.prompt.planFilePath}
|
||||
|
||||
182
src/tui-solid/components/panels/activity-panel.tsx
Normal file
182
src/tui-solid/components/panels/activity-panel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Activity Panel
|
||||
*
|
||||
* Right sidebar showing session summary: context usage, modified files, etc.
|
||||
* Inspired by OpenCode's clean summary view.
|
||||
*/
|
||||
|
||||
import { For, 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 {
|
||||
TOKEN_WARNING_THRESHOLD,
|
||||
TOKEN_CRITICAL_THRESHOLD,
|
||||
} from "@constants/token";
|
||||
|
||||
/** Extract filename from a path without importing node:path */
|
||||
const getFileName = (filePath: string): string => {
|
||||
const parts = filePath.split("/");
|
||||
return parts[parts.length - 1] ?? filePath;
|
||||
};
|
||||
|
||||
const PANEL_WIDTH = 36;
|
||||
|
||||
const formatTokenCount = (tokens: number): string => {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
const formatPercent = (value: number): string =>
|
||||
`${Math.round(value)}%`;
|
||||
|
||||
export function ActivityPanel() {
|
||||
const theme = useTheme();
|
||||
const app = useAppStore();
|
||||
|
||||
const contextUsage = createMemo(() => {
|
||||
const stats = app.sessionStats();
|
||||
const totalTokens = stats.inputTokens + stats.outputTokens;
|
||||
const maxTokens = stats.contextMaxTokens;
|
||||
const percent = maxTokens > 0 ? (totalTokens / maxTokens) * 100 : 0;
|
||||
|
||||
let status: "normal" | "warning" | "critical" = "normal";
|
||||
if (percent >= TOKEN_CRITICAL_THRESHOLD * 100) {
|
||||
status = "critical";
|
||||
} else if (percent >= TOKEN_WARNING_THRESHOLD * 100) {
|
||||
status = "warning";
|
||||
}
|
||||
|
||||
return { total: totalTokens, max: maxTokens, percent, status };
|
||||
});
|
||||
|
||||
const tokenColor = createMemo(() => {
|
||||
const s = contextUsage().status;
|
||||
if (s === "critical") return theme.colors.error;
|
||||
if (s === "warning") return theme.colors.warning;
|
||||
return theme.colors.textDim;
|
||||
});
|
||||
|
||||
const modifiedFiles = createMemo(() => {
|
||||
return [...app.modifiedFiles()].sort(
|
||||
(a, b) => b.lastModified - a.lastModified,
|
||||
);
|
||||
});
|
||||
|
||||
const totalChanges = createMemo(() => {
|
||||
const files = app.modifiedFiles();
|
||||
return {
|
||||
additions: files.reduce((sum, f) => sum + f.additions, 0),
|
||||
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width={PANEL_WIDTH}
|
||||
border={["left"]}
|
||||
borderColor={theme.colors.border}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
{/* Context Section */}
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text
|
||||
fg={theme.colors.text}
|
||||
attributes={TextAttributes.BOLD}
|
||||
>
|
||||
Context
|
||||
</text>
|
||||
<box flexDirection="row" marginTop={1}>
|
||||
<text fg={tokenColor()}>
|
||||
{formatTokenCount(contextUsage().total)}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> / </text>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{formatTokenCount(contextUsage().max)}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> tokens</text>
|
||||
</box>
|
||||
<text fg={tokenColor()}>
|
||||
{formatPercent(contextUsage().percent)} used
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Separator */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.border}>
|
||||
{"─".repeat(PANEL_WIDTH - 2)}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Modified Files Section */}
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text
|
||||
fg={theme.colors.text}
|
||||
attributes={TextAttributes.BOLD}
|
||||
>
|
||||
Modified Files
|
||||
</text>
|
||||
<Show when={modifiedFiles().length > 0}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{modifiedFiles().length}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={modifiedFiles().length > 0}
|
||||
fallback={
|
||||
<text fg={theme.colors.textDim}>No files modified yet</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<For each={modifiedFiles()}>
|
||||
{(file) => (
|
||||
<box flexDirection="row" marginBottom={0}>
|
||||
<text fg={theme.colors.text}>
|
||||
{getFileName(file.filePath)}
|
||||
</text>
|
||||
<text fg={theme.colors.textDim}> </text>
|
||||
<Show when={file.additions > 0}>
|
||||
<text fg={theme.colors.success}>
|
||||
+{file.additions}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={file.deletions > 0}>
|
||||
<text fg={theme.colors.textDim}> </text>
|
||||
<text fg={theme.colors.error}>
|
||||
-{file.deletions}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
{/* Totals */}
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>Total: </text>
|
||||
<Show when={totalChanges().additions > 0}>
|
||||
<text fg={theme.colors.success}>
|
||||
+{totalChanges().additions}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={totalChanges().deletions > 0}>
|
||||
<text fg={theme.colors.textDim}> </text>
|
||||
<text fg={theme.colors.error}>
|
||||
-{totalChanges().deletions}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,12 @@ import type {
|
||||
StreamingLogState,
|
||||
SuggestionState,
|
||||
MCPServerDisplay,
|
||||
ModifiedFileEntry,
|
||||
} from "@/types/tui";
|
||||
import type { ProviderModel } from "@/types/providers";
|
||||
import type { BrainConnectionStatus, BrainUser } from "@/types/brain";
|
||||
import type { PastedImage } from "@/types/image";
|
||||
import { stripMarkdown } from "@/utils/markdown/strip";
|
||||
|
||||
interface AppStore {
|
||||
mode: AppMode;
|
||||
@@ -49,6 +52,8 @@ interface AppStore {
|
||||
suggestions: SuggestionState;
|
||||
cascadeEnabled: boolean;
|
||||
mcpServers: MCPServerDisplay[];
|
||||
modifiedFiles: ModifiedFileEntry[];
|
||||
pastedImages: PastedImage[];
|
||||
brain: {
|
||||
status: BrainConnectionStatus;
|
||||
user: BrainUser | null;
|
||||
@@ -95,6 +100,7 @@ interface AppContextValue {
|
||||
suggestions: Accessor<SuggestionState>;
|
||||
cascadeEnabled: Accessor<boolean>;
|
||||
mcpServers: Accessor<MCPServerDisplay[]>;
|
||||
modifiedFiles: Accessor<ModifiedFileEntry[]>;
|
||||
brain: Accessor<{
|
||||
status: BrainConnectionStatus;
|
||||
user: BrainUser | null;
|
||||
@@ -201,6 +207,16 @@ interface AppContextValue {
|
||||
status: MCPServerDisplay["status"],
|
||||
) => void;
|
||||
|
||||
// Modified file tracking
|
||||
addModifiedFile: (entry: ModifiedFileEntry) => void;
|
||||
clearModifiedFiles: () => void;
|
||||
|
||||
// Pasted image tracking
|
||||
pastedImages: Accessor<PastedImage[]>;
|
||||
addPastedImage: (image: PastedImage) => void;
|
||||
clearPastedImages: () => void;
|
||||
removePastedImage: (id: string) => void;
|
||||
|
||||
// Computed
|
||||
isInputLocked: () => boolean;
|
||||
}
|
||||
@@ -268,6 +284,8 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
suggestions: createInitialSuggestionState(),
|
||||
cascadeEnabled: true,
|
||||
mcpServers: [],
|
||||
modifiedFiles: [],
|
||||
pastedImages: [],
|
||||
brain: {
|
||||
status: "disconnected" as BrainConnectionStatus,
|
||||
user: null,
|
||||
@@ -326,6 +344,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
const suggestions = (): SuggestionState => store.suggestions;
|
||||
const cascadeEnabled = (): boolean => store.cascadeEnabled;
|
||||
const mcpServers = (): MCPServerDisplay[] => store.mcpServers;
|
||||
const modifiedFiles = (): ModifiedFileEntry[] => store.modifiedFiles;
|
||||
const brain = () => store.brain;
|
||||
|
||||
// Mode actions
|
||||
@@ -590,6 +609,52 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
);
|
||||
};
|
||||
|
||||
// Modified file tracking
|
||||
const addModifiedFile = (entry: ModifiedFileEntry): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const existing = s.modifiedFiles.find(
|
||||
(f) => f.filePath === entry.filePath,
|
||||
);
|
||||
if (existing) {
|
||||
existing.additions += entry.additions;
|
||||
existing.deletions += entry.deletions;
|
||||
existing.lastModified = entry.lastModified;
|
||||
} else {
|
||||
s.modifiedFiles.push({ ...entry });
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clearModifiedFiles = (): void => {
|
||||
setStore("modifiedFiles", []);
|
||||
};
|
||||
|
||||
// Pasted image tracking
|
||||
const addPastedImage = (image: PastedImage): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.pastedImages.push({ ...image });
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clearPastedImages = (): void => {
|
||||
setStore("pastedImages", []);
|
||||
};
|
||||
|
||||
const removePastedImage = (id: string): void => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const idx = s.pastedImages.findIndex((img) => img.id === id);
|
||||
if (idx !== -1) {
|
||||
s.pastedImages.splice(idx, 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Session stats actions
|
||||
const startThinking = (): void => {
|
||||
setStore("sessionStats", {
|
||||
@@ -703,6 +768,12 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
const logIndex = store.logs.findIndex((l) => l.id === logId);
|
||||
|
||||
batch(() => {
|
||||
// Strip markdown from the fully accumulated content before finalizing
|
||||
if (logIndex !== -1) {
|
||||
const rawContent = store.logs[logIndex].content;
|
||||
setStore("logs", logIndex, "content", stripMarkdown(rawContent));
|
||||
}
|
||||
|
||||
setStore("streamingLog", createInitialStreamingState());
|
||||
if (logIndex !== -1) {
|
||||
const currentMetadata = store.logs[logIndex].metadata ?? {};
|
||||
@@ -829,6 +900,7 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
suggestions,
|
||||
cascadeEnabled,
|
||||
mcpServers,
|
||||
modifiedFiles,
|
||||
brain,
|
||||
|
||||
// Mode actions
|
||||
@@ -899,6 +971,16 @@ export const { provider: AppStoreProvider, use: useAppStore } =
|
||||
addMcpServer,
|
||||
updateMcpServerStatus,
|
||||
|
||||
// Modified file tracking
|
||||
addModifiedFile,
|
||||
clearModifiedFiles,
|
||||
|
||||
// Pasted image tracking
|
||||
pastedImages: () => store.pastedImages,
|
||||
addPastedImage,
|
||||
clearPastedImages,
|
||||
removePastedImage,
|
||||
|
||||
// Session stats actions
|
||||
startThinking,
|
||||
stopThinking,
|
||||
@@ -968,6 +1050,7 @@ const defaultAppState = {
|
||||
streamingLog: createInitialStreamingState(),
|
||||
suggestions: createInitialSuggestionState(),
|
||||
mcpServers: [] as MCPServerDisplay[],
|
||||
pastedImages: [] as PastedImage[],
|
||||
brain: {
|
||||
status: "disconnected" as BrainConnectionStatus,
|
||||
user: null,
|
||||
@@ -1009,6 +1092,7 @@ export const appStore = {
|
||||
streamingLog: storeRef.streamingLog(),
|
||||
suggestions: storeRef.suggestions(),
|
||||
mcpServers: storeRef.mcpServers(),
|
||||
pastedImages: storeRef.pastedImages(),
|
||||
brain: storeRef.brain(),
|
||||
};
|
||||
},
|
||||
@@ -1240,4 +1324,34 @@ export const appStore = {
|
||||
if (!storeRef) return;
|
||||
storeRef.updateMcpServerStatus(id, status);
|
||||
},
|
||||
|
||||
addModifiedFile: (entry: ModifiedFileEntry): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.addModifiedFile(entry);
|
||||
},
|
||||
|
||||
clearModifiedFiles: (): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.clearModifiedFiles();
|
||||
},
|
||||
|
||||
addPastedImage: (image: PastedImage): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.addPastedImage(image);
|
||||
},
|
||||
|
||||
clearPastedImages: (): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.clearPastedImages();
|
||||
},
|
||||
|
||||
removePastedImage: (id: string): void => {
|
||||
if (!storeRef) return;
|
||||
storeRef.removePastedImage(id);
|
||||
},
|
||||
|
||||
getPastedImages: (): PastedImage[] => {
|
||||
if (!storeRef) return [];
|
||||
return storeRef.pastedImages();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import { HelpDetail } from "@tui-solid/components/panels/help-detail";
|
||||
import { TodoPanel } from "@tui-solid/components/panels/todo-panel";
|
||||
import { CenteredModal } from "@tui-solid/components/modals/centered-modal";
|
||||
import { DebugLogPanel } from "@tui-solid/components/logs/debug-log-panel";
|
||||
import { ActivityPanel } from "@tui-solid/components/panels/activity-panel";
|
||||
import { BrainMenu } from "@tui-solid/components/menu/brain-menu";
|
||||
import { BRAIN_DISABLED } from "@constants/brain";
|
||||
import { initializeMCP, getServerInstances } from "@services/mcp/manager";
|
||||
@@ -281,6 +282,8 @@ export function Session(props: SessionProps) {
|
||||
<LogPanel />
|
||||
</box>
|
||||
|
||||
<ActivityPanel />
|
||||
|
||||
<Show when={app.todosVisible() && props.plan}>
|
||||
<TodoPanel plan={props.plan ?? null} visible={app.todosVisible()} />
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user