feat: inline permission prompt and improve TUI layout

- Render permission modal inline below log panel instead of floating CenteredModal overlay
- Hide input area when permission prompt is active
- Add dev:debug and dev:debug-brk scripts for Bun inspector debugging
- Add background color to header to prevent content bleeding through
- Add margin spacing between header and log panel
- Change permission modal border to top-only for cleaner inline appearance
This commit is contained in:
2026-02-06 09:09:21 -05:00
parent f2641d6ab0
commit 8adf48abd3
9 changed files with 152 additions and 20 deletions

1
.gitignore vendored
View File

@@ -261,3 +261,4 @@ npm-debug.log*
# Other
.vscode-test/
coverage-final.json
.codetyper-backup

View File

@@ -11,6 +11,8 @@
"dev": "bun src/index.ts",
"dev:nobump": "bun scripts/build.ts && npm link",
"dev:watch": "bun scripts/dev-watch.ts",
"dev:debug": "bun --inspect=localhost:6499 src/index.ts",
"dev:debug-brk": "bun --inspect-brk=localhost:6499 src/index.ts",
"build": "bun scripts/build.ts",
"sync-version": "bun scripts/sync-version.ts",
"start": "bun src/index.ts",

View File

@@ -19,6 +19,7 @@ export const onToolCall = (call: ToolCallParams): void => {
toolName: call.name,
toolStatus: "running",
toolDescription: call.description,
toolArgs: call.args,
quiet: isQuiet,
},
});

View File

@@ -98,7 +98,8 @@ const createAgentOptionsWithTUI = (
metadata: {
toolName: toolCall.name,
toolStatus: "running",
toolDescription: JSON.stringify(toolCall.arguments),
toolDescription: `Executing ${toolCall.name}`,
toolArgs: toolCall.arguments,
},
});

View File

@@ -16,6 +16,7 @@ import {
advanceStep,
getExecutionState,
} from "@services/chat-tui-service";
import { DISABLE_MOUSE_TRACKING } from "@constants/terminal";
import versionData from "@/version.json";
import { ExitProvider, useExit } from "@tui-solid/context/exit";
import { RouteProvider, useRoute } from "@tui-solid/context/route";
@@ -179,7 +180,11 @@ function AppContent(props: AppProps) {
const toggled = togglePauseResume();
if (toggled) {
const state = getExecutionState();
toast.info(state.state === "paused" ? "⏸ Execution paused" : "▶ Execution resumed");
toast.info(
state.state === "paused"
? "⏸ Execution paused"
: "▶ Execution resumed",
);
evt.preventDefault();
return;
}
@@ -191,7 +196,9 @@ function AppContent(props: AppProps) {
if (state.state !== "idle") {
abortCurrentOperation(true).then((aborted) => {
if (aborted) {
toast.info(`Aborted with rollback of ${state.rollbackCount} action(s)`);
toast.info(
`Aborted with rollback of ${state.rollbackCount} action(s)`,
);
}
});
evt.preventDefault();
@@ -205,7 +212,9 @@ function AppContent(props: AppProps) {
if (state.state !== "idle") {
const isStepMode = state.state === "stepping";
setStepMode(!isStepMode);
toast.info(isStepMode ? "🏃 Step mode disabled" : "🚶 Step mode enabled");
toast.info(
isStepMode ? "🏃 Step mode disabled" : "🚶 Step mode enabled",
);
evt.preventDefault();
return;
}
@@ -511,11 +520,16 @@ export interface TuiRenderOptions extends TuiInput {
export function tui(options: TuiRenderOptions): Promise<TuiOutput> {
return new Promise<TuiOutput>((resolve) => {
render(() => <App {...options} onExit={resolve} />, {
const handleExit = (output: TuiOutput): void => {
process.stdout.write(DISABLE_MOUSE_TRACKING);
resolve(output);
};
render(() => <App {...options} onExit={handleExit} />, {
targetFps: 60,
exitOnCtrlC: false,
useKittyKeyboard: {},
useMouse: false,
useMouse: true,
});
});
}

View File

@@ -139,8 +139,10 @@ export function Header(props: HeaderProps) {
justifyContent="space-between"
paddingLeft={1}
paddingRight={1}
marginBottom={1}
borderColor={theme.colors.border}
border={["bottom"]}
backgroundColor={theme.colors.background}
>
<box flexDirection="row" gap={1}>
<Show when={showBanner()}>

View File

@@ -100,12 +100,15 @@ export function PermissionModal(props: PermissionModalProps) {
<box
flexDirection="column"
borderColor={theme.colors.borderWarning}
border={["top", "bottom", "left", "right"]}
border={["top"]}
backgroundColor={theme.colors.background}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
width="100%"
height="auto"
flexShrink={0}
>
<box marginBottom={1}>
<text fg={theme.colors.warning} attributes={TextAttributes.BOLD}>

View File

@@ -287,8 +287,20 @@ export function Session(props: SessionProps) {
</Show>
</box>
<Show
when={app.mode() === "permission_prompt" && app.permissionRequest()}
>
<PermissionModal
request={app.permissionRequest()!}
onRespond={props.onPermissionResponse}
isActive={app.mode() === "permission_prompt"}
/>
</Show>
<StatusBar />
<InputArea onSubmit={props.onSubmit} />
<Show when={app.mode() !== "permission_prompt"}>
<InputArea onSubmit={props.onSubmit} />
</Show>
<Switch>
<Match when={app.mode() === "command_menu"}>
@@ -393,18 +405,6 @@ export function Session(props: SessionProps) {
</CenteredModal>
</Match>
<Match
when={app.mode() === "permission_prompt" && app.permissionRequest()}
>
<CenteredModal>
<PermissionModal
request={app.permissionRequest()!}
onRespond={props.onPermissionResponse}
isActive={app.mode() === "permission_prompt"}
/>
</CenteredModal>
</Match>
<Match when={app.mode() === "learning_prompt" && app.learningPrompt()}>
<CenteredModal>
<LearningModal

108
src/utils/ansi.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* ANSI Escape Code Utilities
*
* Functions for handling ANSI escape codes in terminal output.
*/
/**
* Regex to match ANSI escape sequences
*/
const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07/g;
/**
* Strip all ANSI escape codes from a string
*/
export const stripAnsi = (text: string): string => {
return text.replace(ANSI_REGEX, "");
};
/**
* Check if a string contains ANSI escape codes
*/
export const hasAnsi = (text: string): boolean => {
return ANSI_REGEX.test(text);
};
/**
* Truncate text to a maximum number of lines
*/
export const truncateLines = (
text: string,
maxLines: number,
showCount = true,
): { text: string; truncated: boolean; hiddenCount: number } => {
const lines = text.split("\n");
if (lines.length <= maxLines) {
return { text, truncated: false, hiddenCount: 0 };
}
const visibleLines = lines.slice(0, maxLines);
const hiddenCount = lines.length - maxLines;
const suffix = showCount ? `\n... (${hiddenCount} more lines)` : "";
return {
text: visibleLines.join("\n") + suffix,
truncated: true,
hiddenCount,
};
};
/**
* Format a tool name with optional truncated args for display
* Like: Bash(git add -A && git commit...)
*/
export const formatToolCall = (
toolName: string,
args?: Record<string, unknown>,
maxLength = 60,
): string => {
if (!args) {
return toolName;
}
// Get the main argument (command, file_path, filePath, query, etc.)
const mainArg =
args.command ??
args.file_path ??
args.filePath ??
args.query ??
args.pattern ??
args.url;
if (!mainArg || typeof mainArg !== "string") {
return toolName;
}
// Clean and truncate the argument
const cleanArg = stripAnsi(mainArg).replace(/\n/g, " ").trim();
const truncatedArg =
cleanArg.length > maxLength
? cleanArg.substring(0, maxLength) + "..."
: cleanArg;
return `${toolName}(${truncatedArg})`;
};
/**
* Format a file operation tool name
* Like: Write(src/utils/ansi.ts), Edit(package.json), Read(1 file)
*/
export const formatFileToolCall = (
toolName: string,
filePath?: string,
fileCount?: number,
): string => {
if (fileCount && fileCount > 1) {
return `${toolName}(${fileCount} files)`;
}
if (filePath) {
// Just show the filename or last path segment
const filename = filePath.split("/").pop() || filePath;
return `${toolName}(${filename})`;
}
return toolName;
};