diff --git a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts new file mode 100644 index 00000000..ea69ff25 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts @@ -0,0 +1,45 @@ +import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; +import { getSelectListTheme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** + * Component that renders a show images selector with borders + */ +export class ShowImagesSelectorComponent extends Container { + private selectList: SelectList; + + constructor(currentValue: boolean, onSelect: (show: boolean) => void, onCancel: () => void) { + super(); + + const items: SelectItem[] = [ + { value: "yes", label: "Yes", description: "Show images inline in terminal" }, + { value: "no", label: "No", description: "Show text placeholder instead" }, + ]; + + // Add top border + this.addChild(new DynamicBorder()); + + // Create selector + this.selectList = new SelectList(items, 5, getSelectListTheme()); + + // Preselect current value + this.selectList.setSelectedIndex(currentValue ? 0 : 1); + + this.selectList.onSelect = (item) => { + onSelect(item.value === "yes"); + }; + + this.selectList.onCancel = () => { + onCancel(); + }; + + this.addChild(this.selectList); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + getSelectList(): SelectList { + return this.selectList; + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 27c6f567..1e7771b6 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -107,6 +107,7 @@ export class ToolExecutionComponent extends Container { for (const img of imageBlocks) { // Show inline image only if terminal supports it AND user setting allows it if (caps.images && this.showImages && img.data && img.mimeType) { + this.addChild(new Spacer(1)); const imageComponent = new Image( img.data, img.mimeType, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2603e8bf..ca55f4e6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -17,7 +17,6 @@ import { Loader, Markdown, ProcessTerminal, - SelectList, Spacer, Text, TruncatedText, @@ -49,12 +48,13 @@ import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; +import { ShowImagesSelectorComponent } from "./components/show-images-selector.js"; import { ThemeSelectorComponent } from "./components/theme-selector.js"; import { ThinkingSelectorComponent } from "./components/thinking-selector.js"; import { ToolExecutionComponent } from "./components/tool-execution.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; -import { getEditorTheme, getMarkdownTheme, getSelectListTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; export class InteractiveMode { private session: AgentSession; @@ -593,7 +593,7 @@ export class InteractiveMode { return; } if (text === "/show-images") { - this.handleShowImagesCommand(); + this.showShowImagesSelector(); this.editor.setText(""); return; } @@ -1652,7 +1652,7 @@ export class InteractiveMode { this.showStatus(`Auto-compaction: ${newState ? "on" : "off"}`); } - private handleShowImagesCommand(): void { + private showShowImagesSelector(): void { // Only available if terminal supports images const caps = getCapabilities(); if (!caps.images) { @@ -1660,41 +1660,29 @@ export class InteractiveMode { return; } - const currentValue = this.settingsManager.getShowImages(); - const items = [ - { value: "yes", label: "Yes", description: "Show images inline in terminal" }, - { value: "no", label: "No", description: "Show text placeholder instead" }, - ]; + this.showSelector((done) => { + const selector = new ShowImagesSelectorComponent( + this.settingsManager.getShowImages(), + (newValue) => { + this.settingsManager.setShowImages(newValue); - const selector = new SelectList(items, 5, getSelectListTheme()); - selector.setSelectedIndex(currentValue ? 0 : 1); + // Update all existing tool execution components with new setting + for (const child of this.chatContainer.children) { + if (child instanceof ToolExecutionComponent) { + child.setShowImages(newValue); + } + } - selector.onSelect = (item) => { - const newValue = item.value === "yes"; - this.settingsManager.setShowImages(newValue); - this.showStatus(`Inline images: ${newValue ? "on" : "off"}`); - this.chatContainer.removeChild(selector); - - // Update all existing tool execution components with new setting - for (const child of this.chatContainer.children) { - if (child instanceof ToolExecutionComponent) { - child.setShowImages(newValue); - } - } - - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; - - selector.onCancel = () => { - this.chatContainer.removeChild(selector); - this.ui.setFocus(this.editor); - this.ui.requestRender(); - }; - - this.chatContainer.addChild(selector); - this.ui.setFocus(selector); - this.ui.requestRender(); + done(); + this.showStatus(`Inline images: ${newValue ? "on" : "off"}`); + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector.getSelectList() }; + }); } private async executeCompaction(customInstructions?: string, isAuto = false): Promise { diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 315038c6..45e283ef 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -60,7 +60,16 @@ export class Image implements Component { const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth }); if (result) { - lines = [result.sequence]; + // Return `rows` lines so TUI accounts for image height + // First (rows-1) lines are empty (TUI clears them) + // Last line: move cursor back up, then output image sequence + lines = []; + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + // Move cursor up to first row, then output image + const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : ""; + lines.push(moveUp + result.sequence); } else { const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); lines = [this.theme.fallbackColor(fallback)]; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 72ea30cf..0bf2e70d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -255,38 +255,6 @@ export class TUI extends Container { return; } - // Check if image lines changed - they require special handling to avoid duplication - // Only force full re-render if image content actually changed, not just because images exist - const imageLineChanged = (() => { - for (let i = firstChanged; i < Math.max(newLines.length, this.previousLines.length); i++) { - const prevLine = this.previousLines[i] || ""; - const newLine = newLines[i] || ""; - if (this.containsImage(prevLine) || this.containsImage(newLine)) { - if (prevLine !== newLine) return true; - } - } - return false; - })(); - - if (imageLineChanged) { - let buffer = "\x1b[?2026h"; // Begin synchronized output - // For Kitty protocol, delete all images before re-render - if (getCapabilities().images === "kitty") { - buffer += "\x1b_Ga=d\x1b\\"; - } - buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - buffer += newLines[i]; - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - this.cursorRow = newLines.length - 1; - this.previousLines = newLines; - this.previousWidth = width; - return; - } - // Check if firstChanged is outside the viewport // cursorRow is the line where cursor is (0-indexed) // Viewport shows lines from (cursorRow - height + 1) to cursorRow