Merge main into bash-mode

This commit is contained in:
Mario Zechner
2025-12-08 21:39:01 +01:00
91 changed files with 6083 additions and 1554 deletions

View File

@@ -7,10 +7,13 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
*/
export class AssistantMessageComponent extends Container {
private contentContainer: Container;
private hideThinkingBlock: boolean;
constructor(message?: AssistantMessage) {
constructor(message?: AssistantMessage, hideThinkingBlock = false) {
super();
this.hideThinkingBlock = hideThinkingBlock;
// Container for text/thinking content
this.contentContainer = new Container();
this.addChild(this.contentContainer);
@@ -20,6 +23,10 @@ export class AssistantMessageComponent extends Container {
}
}
setHideThinkingBlock(hide: boolean): void {
this.hideThinkingBlock = hide;
}
updateContent(message: AssistantMessage): void {
// Clear content container
this.contentContainer.clear();
@@ -34,21 +41,33 @@ export class AssistantMessageComponent extends Container {
}
// Render content in order
for (const content of message.content) {
for (let i = 0; i < message.content.length; i++) {
const content = message.content[i];
if (content.type === "text" && content.text.trim()) {
// Assistant text messages with no background - trim the text
// Set paddingY=0 to avoid extra spacing before tool executions
this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
} else if (content.type === "thinking" && content.thinking.trim()) {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
italic: true,
}),
);
this.contentContainer.addChild(new Spacer(1));
// Check if there's text content after this thinking block
const hasTextAfter = message.content.slice(i + 1).some((c) => c.type === "text" && c.text.trim());
if (this.hideThinkingBlock) {
// Show static "Thinking..." label when hidden
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
if (hasTextAfter) {
this.contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
italic: true,
}),
);
this.contentContainer.addChild(new Spacer(1));
}
}
}

View File

@@ -25,10 +25,10 @@ export class CompactionComponent extends Container {
private updateDisplay(): void {
this.clear();
this.addChild(new Spacer(1));
if (this.expanded) {
// Show header + summary as markdown (like user message)
this.addChild(new Spacer(1));
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
this.addChild(
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
@@ -36,17 +36,17 @@ export class CompactionComponent extends Container {
color: (text: string) => theme.fg("userMessageText", text),
}),
);
this.addChild(new Spacer(1));
} else {
// Collapsed: just show the header line with user message styling
// Collapsed: simple text in warning color with token count
const tokenStr = this.tokensBefore.toLocaleString();
this.addChild(
new Text(
theme.fg("userMessageText", `--- Earlier messages compacted (CTRL+O to expand) ---`),
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
1,
1,
(text: string) => theme.bg("userMessageBg", text),
),
);
}
this.addChild(new Spacer(1));
}
}

View File

@@ -9,8 +9,15 @@ export class CustomEditor extends Editor {
public onShiftTab?: () => void;
public onCtrlP?: () => void;
public onCtrlO?: () => void;
public onCtrlT?: () => void;
handleInput(data: string): void {
// Intercept Ctrl+T for thinking block visibility toggle
if (data === "\x14" && this.onCtrlT) {
this.onCtrlT();
return;
}
// Intercept Ctrl+O for tool output expansion
if (data === "\x0f" && this.onCtrlO) {
this.onCtrlO();

View File

@@ -145,7 +145,9 @@ export class FooterComponent implements Component {
const formatTokens = (count: number): string => {
if (count < 1000) return count.toString();
if (count < 10000) return (count / 1000).toFixed(1) + "k";
return Math.round(count / 1000) + "k";
if (count < 1000000) return Math.round(count / 1000) + "k";
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
return Math.round(count / 1000000) + "M";
};
// Replace home directory with ~
@@ -186,16 +188,17 @@ export class FooterComponent implements Component {
// Colorize context percentage based on usage
let contextPercentStr: string;
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
if (contextPercentValue > 90) {
contextPercentStr = theme.fg("error", `${contextPercent}%${autoIndicator}`);
contextPercentStr = theme.fg("error", contextPercentDisplay);
} else if (contextPercentValue > 70) {
contextPercentStr = theme.fg("warning", `${contextPercent}%${autoIndicator}`);
contextPercentStr = theme.fg("warning", contextPercentDisplay);
} else {
contextPercentStr = `${contextPercent}%${autoIndicator}`;
contextPercentStr = contextPercentDisplay;
}
statsParts.push(contextPercentStr);
const statsLeft = statsParts.join(" ");
let statsLeft = statsParts.join(" ");
// Add model name on the right side, plus thinking level if model supports it
const modelName = this.state.model?.id || "no-model";
@@ -209,9 +212,17 @@ export class FooterComponent implements Component {
}
}
const statsLeftWidth = visibleWidth(statsLeft);
let statsLeftWidth = visibleWidth(statsLeft);
const rightSideWidth = visibleWidth(rightSide);
// If statsLeft is too wide, truncate it
if (statsLeftWidth > width) {
// Truncate statsLeft to fit width (no room for right side)
const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
statsLeft = plainStatsLeft.substring(0, width - 3) + "...";
statsLeftWidth = visibleWidth(statsLeft);
}
// Calculate available space for padding (minimum 2 spaces between stats and model)
const minPadding = 2;
const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;

View File

@@ -1,5 +1,6 @@
import type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { fuzzyFilter } from "../fuzzy.js";
import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js";
import { theme } from "../theme/theme.js";
@@ -114,19 +115,7 @@ export class ModelSelectorComponent extends Container {
}
private filterModels(query: string): void {
if (!query.trim()) {
this.filteredModels = this.allModels;
} else {
const searchTokens = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t);
this.filteredModels = this.allModels.filter(({ provider, id, model }) => {
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
this.filteredModels = fuzzyFilter(this.allModels, query, ({ provider, id }) => `${provider} ${id}`);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
this.updateList();
}

View File

@@ -1,4 +1,5 @@
import { type Component, Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { fuzzyFilter } from "../fuzzy.js";
import type { SessionManager } from "../session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@@ -42,20 +43,7 @@ class SessionList implements Component {
}
private filterSessions(query: string): void {
if (!query.trim()) {
this.filteredSessions = this.allSessions;
} else {
const searchTokens = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t);
this.filteredSessions = this.allSessions.filter((session) => {
// Search through all messages in the session
const searchText = session.allMessagesText.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}

View File

@@ -85,7 +85,17 @@ export class ToolExecutionComponent extends Container {
// Strip ANSI codes and carriage returns from raw output
// (bash may emit colors/formatting, and Windows may include \r)
let output = textBlocks.map((c: any) => stripAnsi(c.text || "").replace(/\r/g, "")).join("\n");
let output = textBlocks
.map((c: any) => {
let text = stripAnsi(c.text || "").replace(/\r/g, "");
// stripAnsi misses some escape sequences like standalone ESC \ (String Terminator)
// and leaves orphaned fragments from malformed sequences (e.g. TUI output captured to file)
// Clean up: remove ESC + any following char, and control chars except newline/tab
text = text.replace(/\x1b./g, "");
text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
return text;
})
.join("\n");
// Add indicator for images
if (imageBlocks.length > 0) {
@@ -105,7 +115,6 @@ export class ToolExecutionComponent extends Container {
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
if (this.result) {
// Show output without code fences - more minimal
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
@@ -118,17 +127,36 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const truncation = this.result.details?.truncation;
const fullOutputPath = this.result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(`Truncated: ${truncation.outputLines} lines shown (30KB limit)`);
}
}
text += "\n" + theme.fg("warning", `[${warnings.join(". ")}]`);
}
}
} else if (this.toolName === "read") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
const offset = this.args?.offset;
const limit = this.args?.limit;
// Build path display with offset/limit suffix
// Build path display with offset/limit suffix (in warning color if offset/limit used)
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined) {
const endLine = limit !== undefined ? offset + limit : "";
pathDisplay += theme.fg("toolOutput", `:${offset}${endLine ? `-${endLine}` : ""}`);
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
text = theme.fg("toolTitle", theme.bold("read")) + " " + pathDisplay;
@@ -136,6 +164,7 @@ export class ToolExecutionComponent extends Container {
if (this.result) {
const output = this.getTextOutput();
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
@@ -144,6 +173,23 @@ export class ToolExecutionComponent extends Container {
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
// Show truncation warning at the bottom (outside collapsed area)
const truncation = this.result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text += "\n" + theme.fg("warning", `[First line exceeds 30KB limit]`);
} else if (truncation.truncatedBy === "lines") {
text +=
"\n" +
theme.fg(
"warning",
`[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`,
);
} else {
text += "\n" + theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (30KB limit)]`);
}
}
}
} else if (this.toolName === "write") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
@@ -221,6 +267,20 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const entryLimit = this.result.details?.entryLimitReached;
const truncation = this.result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) {
warnings.push(`${entryLimit} entries limit`);
}
if (truncation?.truncated) {
warnings.push("30KB limit");
}
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
}
}
} else if (this.toolName === "find") {
const pattern = this.args?.pattern || "";
@@ -249,6 +309,20 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const resultLimit = this.result.details?.resultLimitReached;
const truncation = this.result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) {
warnings.push(`${resultLimit} results limit`);
}
if (truncation?.truncated) {
warnings.push("30KB limit");
}
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
}
}
} else if (this.toolName === "grep") {
const pattern = this.args?.pattern || "";
@@ -281,6 +355,24 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const matchLimit = this.result.details?.matchLimitReached;
const truncation = this.result.details?.truncation;
const linesTruncated = this.result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) {
warnings.push(`${matchLimit} matches limit`);
}
if (truncation?.truncated) {
warnings.push("30KB limit");
}
if (linesTruncated) {
warnings.push("some lines truncated");
}
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
}
}
} else {
// Generic tool

View File

@@ -32,7 +32,7 @@ import {
SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { getShellConfig } from "../shell-config.js";
import { getShellConfig, killProcessTree } from "../shell.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
@@ -43,6 +43,7 @@ import { FooterComponent } from "./footer.js";
import { ModelSelectorComponent } from "./model-selector.js";
import { OAuthSelectorComponent } from "./oauth-selector.js";
import { QueueModeSelectorComponent } from "./queue-mode-selector.js";
import { SessionSelectorComponent } from "./session-selector.js";
import { ThemeSelectorComponent } from "./theme-selector.js";
import { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
@@ -69,8 +70,9 @@ export class TuiRenderer {
private loadingAnimation: Loader | null = null;
private lastSigintTime = 0;
private lastEscapeTime = 0;
private changelogMarkdown: string | null = null;
private newVersion: string | null = null;
private collapseChangelog = false;
// Message queueing
private queuedMessages: string[] = [];
@@ -96,6 +98,9 @@ export class TuiRenderer {
// User message selector (for branching)
private userMessageSelector: UserMessageSelectorComponent | null = null;
// Session selector (for resume)
private sessionSelector: SessionSelectorComponent | null = null;
// OAuth selector
private oauthSelector: any | null = null;
@@ -108,6 +113,9 @@ export class TuiRenderer {
// Tool output expansion state
private toolOutputExpanded = false;
// Thinking block visibility state
private hideThinkingBlock = false;
// Agent subscription unsubscribe function
private unsubscribe?: () => void;
@@ -126,7 +134,7 @@ export class TuiRenderer {
settingsManager: SettingsManager,
version: string,
changelogMarkdown: string | null = null,
newVersion: string | null = null,
collapseChangelog = false,
scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [],
fdPath: string | null = null,
) {
@@ -134,8 +142,8 @@ export class TuiRenderer {
this.sessionManager = sessionManager;
this.settingsManager = settingsManager;
this.version = version;
this.newVersion = newVersion;
this.changelogMarkdown = changelogMarkdown;
this.collapseChangelog = collapseChangelog;
this.scopedModels = scopedModels;
this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container();
@@ -218,6 +226,14 @@ export class TuiRenderer {
description: "Toggle automatic context compaction",
};
const resumeCommand: SlashCommand = {
name: "resume",
description: "Resume a different session",
};
// Load hide thinking block setting
this.hideThinkingBlock = settingsManager.getHideThinkingBlock();
// Load file-based slash commands
this.fileCommands = loadSlashCommands();
@@ -244,6 +260,7 @@ export class TuiRenderer {
clearCommand,
compactCommand,
autocompactCommand,
resumeCommand,
...fileSlashCommands,
],
process.cwd(),
@@ -279,6 +296,9 @@ export class TuiRenderer {
theme.fg("dim", "ctrl+o") +
theme.fg("muted", " to expand tools") +
"\n" +
theme.fg("dim", "ctrl+t") +
theme.fg("muted", " to toggle thinking") +
"\n" +
theme.fg("dim", "/") +
theme.fg("muted", " for commands") +
"\n" +
@@ -294,29 +314,21 @@ export class TuiRenderer {
this.ui.addChild(header);
this.ui.addChild(new Spacer(1));
// Add new version notification if available
if (this.newVersion) {
this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.addChild(
new Text(
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
theme.fg("muted", `New version ${this.newVersion} is available. Run: `) +
theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
1,
0,
),
);
this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
}
// Add changelog if provided
if (this.changelogMarkdown) {
this.ui.addChild(new DynamicBorder());
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
this.ui.addChild(new Spacer(1));
if (this.collapseChangelog) {
// Show condensed version with hint to use /changelog
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
const latestVersion = versionMatch ? versionMatch[1] : this.version;
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
this.ui.addChild(new Text(condensedText, 1, 0));
} else {
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
this.ui.addChild(new Spacer(1));
}
this.ui.addChild(new DynamicBorder());
}
@@ -364,6 +376,15 @@ export class TuiRenderer {
this.editor.setText("");
this.isBashMode = false;
this.updateEditorBorderColor();
} else if (!this.editor.getText().trim()) {
// Double-escape with empty editor triggers /branch
const now = Date.now();
if (now - this.lastEscapeTime < 500) {
this.showUserMessageSelector();
this.lastEscapeTime = 0; // Reset to prevent triple-escape
} else {
this.lastEscapeTime = now;
}
}
};
@@ -383,6 +404,10 @@ export class TuiRenderer {
this.toggleToolOutputExpansion();
};
this.editor.onCtrlT = () => {
this.toggleThinkingBlockVisibility();
};
// Handle editor text changes for bash mode detection
this.editor.onChange = (text: string) => {
const wasBashMode = this.isBashMode;
@@ -505,6 +530,13 @@ export class TuiRenderer {
return;
}
// Check for /resume command
if (text === "/resume") {
this.showSessionSelector();
this.editor.setText("");
return;
}
// Check for bash command (!<command>)
if (text.startsWith("!")) {
const command = text.slice(1).trim();
@@ -559,6 +591,9 @@ export class TuiRenderer {
// Update pending messages display
this.updatePendingMessagesDisplay();
// Add to history for up/down arrow navigation
this.editor.addToHistory(text);
// Clear editor
this.editor.setText("");
this.ui.requestRender();
@@ -569,6 +604,9 @@ export class TuiRenderer {
if (this.onInputCallback) {
this.onInputCallback(text);
}
// Add to history for up/down arrow navigation
this.editor.addToHistory(text);
};
// Start the UI
@@ -691,7 +729,7 @@ export class TuiRenderer {
this.ui.requestRender();
} else if (event.message.role === "assistant") {
// Create assistant component for streaming
this.streamingComponent = new AssistantMessageComponent();
this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
this.chatContainer.addChild(this.streamingComponent);
this.streamingComponent.updateContent(event.message as AssistantMessage);
this.ui.requestRender();
@@ -831,7 +869,7 @@ export class TuiRenderer {
const assistantMsg = message;
// Add assistant message component
const assistantComponent = new AssistantMessageComponent(assistantMsg);
const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
}
// Note: tool calls and results are now handled via tool_execution_start/end events
@@ -877,7 +915,7 @@ export class TuiRenderer {
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
const assistantComponent = new AssistantMessageComponent(assistantMsg);
const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
// Create tool execution components for any tool calls
@@ -918,6 +956,22 @@ export class TuiRenderer {
}
// Clear pending tools after rendering initial messages
this.pendingTools.clear();
// Populate editor history with user messages from the session (oldest first so newest is at index 0)
for (const message of state.messages) {
if (message.role === "user") {
const textBlocks =
typeof message.content === "string"
? [{ type: "text", text: message.content }]
: message.content.filter((c) => c.type === "text");
const textContent = textBlocks.map((c) => c.text).join("");
// Skip compaction summary messages
if (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {
this.editor.addToHistory(textContent);
}
}
}
this.ui.requestRender();
}
@@ -961,7 +1015,7 @@ export class TuiRenderer {
}
} else if (message.role === "assistant") {
const assistantMsg = message;
const assistantComponent = new AssistantMessageComponent(assistantMsg);
const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
for (const content of assistantMsg.content) {
@@ -1023,7 +1077,12 @@ export class TuiRenderer {
return;
}
const levels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
// xhigh is only available for codex-max models
const modelId = this.agent.state.model?.id || "";
const supportsXhigh = modelId.includes("codex-max");
const levels: ThinkingLevel[] = supportsXhigh
? ["off", "minimal", "low", "medium", "high", "xhigh"]
: ["off", "minimal", "low", "medium", "high"];
const currentLevel = this.agent.state.thinkingLevel || "off";
const currentIndex = levels.indexOf(currentLevel);
const nextIndex = (currentIndex + 1) % levels.length;
@@ -1168,6 +1227,28 @@ export class TuiRenderer {
this.ui.requestRender();
}
private toggleThinkingBlockVisibility(): void {
this.hideThinkingBlock = !this.hideThinkingBlock;
this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
// Update all assistant message components and rebuild their content
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(this.hideThinkingBlock);
}
}
// Rebuild chat to apply visibility change
this.chatContainer.clear();
this.rebuildChatFromMessages();
// Show brief notification
const status = this.hideThinkingBlock ? "hidden" : "visible";
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking blocks: ${status}`), 1, 0));
this.ui.requestRender();
}
clearEditor(): void {
this.editor.setText("");
this.ui.requestRender();
@@ -1187,12 +1268,21 @@ export class TuiRenderer {
this.ui.requestRender();
}
private showSuccess(message: string, detail?: string): void {
showNewVersionNotification(newVersion: string): void {
// Show new version notification in the chat
this.chatContainer.addChild(new Spacer(1));
const text = detail
? `${theme.fg("success", message)}\n${theme.fg("muted", detail)}`
: theme.fg("success", message);
this.chatContainer.addChild(new Text(text, 1, 1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
theme.fg("muted", `New version ${newVersion} is available. Run: `) +
theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
1,
0,
),
);
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
@@ -1489,6 +1579,95 @@ export class TuiRenderer {
this.ui.setFocus(this.editor);
}
private showSessionSelector(): void {
// Create session selector
this.sessionSelector = new SessionSelectorComponent(
this.sessionManager,
async (sessionPath) => {
this.hideSessionSelector();
await this.handleResumeSession(sessionPath);
},
() => {
// Just hide the selector
this.hideSessionSelector();
this.ui.requestRender();
},
);
// Replace editor with selector
this.editorContainer.clear();
this.editorContainer.addChild(this.sessionSelector);
this.ui.setFocus(this.sessionSelector.getSessionList());
this.ui.requestRender();
}
private async handleResumeSession(sessionPath: string): Promise<void> {
// Unsubscribe first to prevent processing events during transition
this.unsubscribe?.();
// Abort and wait for completion
this.agent.abort();
await this.agent.waitForIdle();
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
}
this.statusContainer.clear();
// Clear UI state
this.queuedMessages = [];
this.pendingMessagesContainer.clear();
this.streamingComponent = null;
this.pendingTools.clear();
// Set the selected session as active
this.sessionManager.setSessionFile(sessionPath);
// Reload the session
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
this.agent.replaceMessages(loaded.messages);
// Restore model if saved in session
const savedModel = this.sessionManager.loadModel();
if (savedModel) {
const availableModels = (await getAvailableModels()).models;
const match = availableModels.find((m) => m.provider === savedModel.provider && m.id === savedModel.modelId);
if (match) {
this.agent.setModel(match);
}
}
// Restore thinking level if saved in session
const savedThinking = this.sessionManager.loadThinkingLevel();
if (savedThinking) {
this.agent.setThinkingLevel(savedThinking as ThinkingLevel);
}
// Resubscribe to agent
this.subscribeToAgent();
// Clear and re-render the chat
this.chatContainer.clear();
this.isFirstUserMessage = true;
this.renderInitialMessages(this.agent.state);
// Show confirmation message
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));
this.ui.requestRender();
}
private hideSessionSelector(): void {
// Replace selector with editor in the container
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.sessionSelector = null;
this.ui.setFocus(this.editor);
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
// For logout mode, filter to only show logged-in providers
let providersToShow: string[] = [];
@@ -2036,10 +2215,6 @@ export class TuiRenderer {
// Update footer with new state (fixes context % display)
this.footer.updateState(this.agent.state);
// Show success message
const successTitle = isAuto ? "✓ Context auto-compacted" : "✓ Context compacted";
this.showSuccess(successTitle, `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
@@ -2109,32 +2284,3 @@ export class TuiRenderer {
}
}
}
/**
* Kill a process and all its children (cross-platform)
*/
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui";
import { type Component, Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@@ -54,8 +54,8 @@ class UserMessageList implements Component {
// First line: cursor + message
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth);
const maxMsgWidth = width - 2; // Account for cursor (2 chars)
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
lines.push(messageLine);