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:
2026-02-14 06:39:08 -05:00
parent ddbdb5eb3e
commit 6111530c08
84 changed files with 5643 additions and 1574 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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