Merge main into bash-mode
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user