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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -261,3 +261,4 @@ npm-debug.log*
|
||||
# Other
|
||||
.vscode-test/
|
||||
coverage-final.json
|
||||
.codetyper-backup
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@ export const onToolCall = (call: ToolCallParams): void => {
|
||||
toolName: call.name,
|
||||
toolStatus: "running",
|
||||
toolDescription: call.description,
|
||||
toolArgs: call.args,
|
||||
quiet: isQuiet,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,7 +98,8 @@ const createAgentOptionsWithTUI = (
|
||||
metadata: {
|
||||
toolName: toolCall.name,
|
||||
toolStatus: "running",
|
||||
toolDescription: JSON.stringify(toolCall.arguments),
|
||||
toolDescription: `Executing ${toolCall.name}`,
|
||||
toolArgs: toolCall.arguments,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
108
src/utils/ansi.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user