From 9e8373b86ad17095dd168677186451f755dc7d98 Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Wed, 19 Nov 2025 10:18:24 +0100 Subject: [PATCH 1/5] feat: add Tab key to cycle through thinking levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add onTab callback to CustomEditor - Implement cycleThinkingLevel() in TuiRenderer - Only works when model supports reasoning - Cycles through: off → minimal → low → medium → high → off - Update footer to show current thinking level with '(tab to cycle)' hint - Update header instructions to mention tab for thinking - Show notification when thinking level changes --- .../coding-agent/src/tui/custom-editor.ts | 9 +++++ packages/coding-agent/src/tui/footer.ts | 39 ++++++++++++------- packages/coding-agent/src/tui/tui-renderer.ts | 35 ++++++++++++++++- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/tui/custom-editor.ts b/packages/coding-agent/src/tui/custom-editor.ts index f01075a0..57930fab 100644 --- a/packages/coding-agent/src/tui/custom-editor.ts +++ b/packages/coding-agent/src/tui/custom-editor.ts @@ -6,8 +6,17 @@ import { Editor } from "@mariozechner/pi-tui"; export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlC?: () => void; + public onTab?: () => boolean; handleInput(data: string): void { + // Intercept Tab key when autocomplete is not showing + if (data === "\t" && !this.isShowingAutocomplete() && this.onTab) { + const handled = this.onTab(); + if (handled) { + return; + } + } + // Intercept Escape key - but only if autocomplete is NOT active // (let parent handle escape for autocomplete cancellation) if (data === "\x1b" && this.onEscape && !this.isShowingAutocomplete()) { diff --git a/packages/coding-agent/src/tui/footer.ts b/packages/coding-agent/src/tui/footer.ts index 0ececaff..2cf391f8 100644 --- a/packages/coding-agent/src/tui/footer.ts +++ b/packages/coding-agent/src/tui/footer.ts @@ -85,30 +85,41 @@ export class FooterComponent { const statsLeft = statsParts.join(" "); - // Add model name on the right side - let modelName = this.state.model?.id || "no-model"; + // Add model name on the right side, plus thinking level if model supports it + const modelName = this.state.model?.id || "no-model"; + + // Add thinking level hint if model supports reasoning + let rightSide = modelName; + if (this.state.model?.reasoning) { + const thinkingLevel = this.state.thinkingLevel || "off"; + const thinkingHint = chalk.dim(" (tab to cycle)"); + rightSide = `${modelName} • ${thinkingLevel}${thinkingHint}`; + } + const statsLeftWidth = visibleWidth(statsLeft); - const modelWidth = visibleWidth(modelName); + const rightSideWidth = visibleWidth(rightSide); // Calculate available space for padding (minimum 2 spaces between stats and model) const minPadding = 2; - const totalNeeded = statsLeftWidth + minPadding + modelWidth; + const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; let statsLine: string; if (totalNeeded <= width) { // Both fit - add padding to right-align model - const padding = " ".repeat(width - statsLeftWidth - modelWidth); - statsLine = statsLeft + padding + modelName; + const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); + statsLine = statsLeft + padding + rightSide; } else { - // Need to truncate model name - const availableForModel = width - statsLeftWidth - minPadding; - if (availableForModel > 3) { - // Truncate model name to fit - modelName = modelName.substring(0, availableForModel); - const padding = " ".repeat(width - statsLeftWidth - visibleWidth(modelName)); - statsLine = statsLeft + padding + modelName; + // Need to truncate right side + const availableForRight = width - statsLeftWidth - minPadding; + if (availableForRight > 3) { + // Truncate to fit (strip ANSI codes for length calculation, then truncate raw string) + const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, ""); + const truncatedPlain = plainRightSide.substring(0, availableForRight); + // For simplicity, just use plain truncated version (loses color, but fits) + const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length); + statsLine = statsLeft + padding + truncatedPlain; } else { - // Not enough space for model name at all + // Not enough space for right side at all statsLine = statsLeft; } } diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 12c826a9..2b64cdd7 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -1,4 +1,4 @@ -import type { Agent, AgentEvent, AgentState } from "@mariozechner/pi-agent"; +import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent"; import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -169,6 +169,9 @@ export class TuiRenderer { chalk.dim("ctrl+k") + chalk.gray(" to delete line") + "\n" + + chalk.dim("tab") + + chalk.gray(" to cycle thinking") + + "\n" + chalk.dim("/") + chalk.gray(" for commands") + "\n" + @@ -210,6 +213,10 @@ export class TuiRenderer { this.handleCtrlC(); }; + this.editor.onTab = () => { + return this.cycleThinkingLevel(); + }; + // Handle editor submission this.editor.onSubmit = async (text: string) => { text = text.trim(); @@ -572,6 +579,32 @@ export class TuiRenderer { } } + private cycleThinkingLevel(): boolean { + // Only cycle if model supports thinking + if (!this.agent.state.model?.reasoning) { + return false; // Not handled, let default Tab behavior continue + } + + const levels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; + const currentLevel = this.agent.state.thinkingLevel || "off"; + const currentIndex = levels.indexOf(currentLevel); + const nextIndex = (currentIndex + 1) % levels.length; + const nextLevel = levels[nextIndex]; + + // Apply the new thinking level + this.agent.setThinkingLevel(nextLevel); + + // Save thinking level change to session + this.sessionManager.saveThinkingLevelChange(nextLevel); + + // Show brief notification + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(chalk.dim(`Thinking level: ${nextLevel}`), 1, 0)); + this.ui.requestRender(); + + return true; // Handled + } + clearEditor(): void { this.editor.setText(""); this.ui.requestRender(); From 0df48f6b337512c7e50e8d1c2d86339cb671b75c Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Wed, 19 Nov 2025 10:38:10 +0100 Subject: [PATCH 2/5] feat: add visual thinking level indicator via border colors The horizontal borders around the input editor now change color based on the current thinking level, providing immediate visual feedback: - off: gray (default) - minimal: dim blue - low: blue - medium: cyan - high: magenta The more thinking, the brighter/more vibrant the color. Changes: - Add public borderColor property to Editor component (packages/tui) - Use borderColor instead of hardcoded chalk.gray for borders - Add getThinkingBorderColor() helper in TuiRenderer - Add updateEditorBorderColor() to apply color changes - Update border color when: - Cycling with Tab key - Selecting via /thinking command - Restoring session with thinking level set --- packages/coding-agent/src/tui/tui-renderer.ts | 34 +++++++++++++++++++ packages/tui/src/components/editor.ts | 5 ++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 2b64cdd7..8dbdbfb1 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -491,6 +491,9 @@ export class TuiRenderer { // Update footer with loaded state this.footer.updateState(state); + // Update editor border color based on current thinking level + this.updateEditorBorderColor(); + // Render messages for (let i = 0; i < state.messages.length; i++) { const message = state.messages[i]; @@ -579,6 +582,31 @@ export class TuiRenderer { } } + private getThinkingBorderColor(level: ThinkingLevel): (str: string) => string { + // More thinking = more color (gray → dim colors → bright colors) + switch (level) { + case "off": + return chalk.gray; + case "minimal": + return chalk.dim.blue; + case "low": + return chalk.blue; + case "medium": + return chalk.cyan; + case "high": + return chalk.magenta; + default: + return chalk.gray; + } + } + + private updateEditorBorderColor(): void { + const level = this.agent.state.thinkingLevel || "off"; + const color = this.getThinkingBorderColor(level); + this.editor.borderColor = color; + this.ui.requestRender(); + } + private cycleThinkingLevel(): boolean { // Only cycle if model supports thinking if (!this.agent.state.model?.reasoning) { @@ -597,6 +625,9 @@ export class TuiRenderer { // Save thinking level change to session this.sessionManager.saveThinkingLevelChange(nextLevel); + // Update border color + this.updateEditorBorderColor(); + // Show brief notification this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(chalk.dim(`Thinking level: ${nextLevel}`), 1, 0)); @@ -635,6 +666,9 @@ export class TuiRenderer { // Save thinking level change to session this.sessionManager.saveThinkingLevelChange(level); + // Update border color + this.updateEditorBorderColor(); + // Show confirmation message with proper spacing this.chatContainer.addChild(new Spacer(1)); const confirmText = new Text(chalk.dim(`Thinking level: ${level}`), 1, 0); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 4374775d..6195e404 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -28,6 +28,9 @@ export class Editor implements Component { private config: TextEditorConfig = {}; + // Border color (can be changed dynamically) + public borderColor: (str: string) => string = chalk.gray; + // Autocomplete support private autocompleteProvider?: AutocompleteProvider; private autocompleteList?: SelectList; @@ -61,7 +64,7 @@ export class Editor implements Component { } render(width: number): string[] { - const horizontal = chalk.gray("─"); + const horizontal = this.borderColor("─"); // Layout the text - use full width const layoutLines = this.layoutText(width); From b367f5bec649f9b713a27669970bb9099da3d3e4 Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Wed, 19 Nov 2025 10:39:21 +0100 Subject: [PATCH 3/5] refactor: improve thinking level display in footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change from 'off (tab to cycle)' to 'Thinking: off' for better clarity. Format: 'model-id • Thinking: [level]' --- packages/coding-agent/src/tui/footer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/coding-agent/src/tui/footer.ts b/packages/coding-agent/src/tui/footer.ts index 2cf391f8..88f2e34c 100644 --- a/packages/coding-agent/src/tui/footer.ts +++ b/packages/coding-agent/src/tui/footer.ts @@ -92,8 +92,7 @@ export class FooterComponent { let rightSide = modelName; if (this.state.model?.reasoning) { const thinkingLevel = this.state.thinkingLevel || "off"; - const thinkingHint = chalk.dim(" (tab to cycle)"); - rightSide = `${modelName} • ${thinkingLevel}${thinkingHint}`; + rightSide = `${modelName} • Thinking: ${thinkingLevel}`; } const statsLeftWidth = visibleWidth(statsLeft); From 5336843de878c6e34df92982608f82930588d581 Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Wed, 19 Nov 2025 10:41:58 +0100 Subject: [PATCH 4/5] fix: change thinking level cycling from Tab to Ctrl+T Tab key was preventing file path autocomplete from working. Changed to Ctrl+T to avoid conflict with Tab completion. - Tab now works normally for file/path autocomplete - Ctrl+T cycles through thinking levels - Shows message if model doesn't support thinking --- packages/coding-agent/src/tui/custom-editor.ts | 12 +++++------- packages/coding-agent/src/tui/tui-renderer.ts | 15 ++++++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/tui/custom-editor.ts b/packages/coding-agent/src/tui/custom-editor.ts index 57930fab..b89a64d5 100644 --- a/packages/coding-agent/src/tui/custom-editor.ts +++ b/packages/coding-agent/src/tui/custom-editor.ts @@ -6,15 +6,13 @@ import { Editor } from "@mariozechner/pi-tui"; export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlC?: () => void; - public onTab?: () => boolean; + public onCtrlT?: () => void; handleInput(data: string): void { - // Intercept Tab key when autocomplete is not showing - if (data === "\t" && !this.isShowingAutocomplete() && this.onTab) { - const handled = this.onTab(); - if (handled) { - return; - } + // Intercept Ctrl+T for thinking level cycling + if (data === "\x14" && this.onCtrlT) { + this.onCtrlT(); + return; } // Intercept Escape key - but only if autocomplete is NOT active diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 8dbdbfb1..01713d24 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -169,7 +169,7 @@ export class TuiRenderer { chalk.dim("ctrl+k") + chalk.gray(" to delete line") + "\n" + - chalk.dim("tab") + + chalk.dim("ctrl+t") + chalk.gray(" to cycle thinking") + "\n" + chalk.dim("/") + @@ -213,8 +213,8 @@ export class TuiRenderer { this.handleCtrlC(); }; - this.editor.onTab = () => { - return this.cycleThinkingLevel(); + this.editor.onCtrlT = () => { + this.cycleThinkingLevel(); }; // Handle editor submission @@ -607,10 +607,13 @@ export class TuiRenderer { this.ui.requestRender(); } - private cycleThinkingLevel(): boolean { + private cycleThinkingLevel(): void { // Only cycle if model supports thinking if (!this.agent.state.model?.reasoning) { - return false; // Not handled, let default Tab behavior continue + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(chalk.dim("Current model does not support thinking"), 1, 0)); + this.ui.requestRender(); + return; } const levels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; @@ -632,8 +635,6 @@ export class TuiRenderer { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(chalk.dim(`Thinking level: ${nextLevel}`), 1, 0)); this.ui.requestRender(); - - return true; // Handled } clearEditor(): void { From ac34a810dd6ac17f1e634257a2ba444973f487af Mon Sep 17 00:00:00 2001 From: Tino Ehrich Date: Wed, 19 Nov 2025 18:07:53 +0100 Subject: [PATCH 5/5] fix: change thinking level cycling from Ctrl+T to Shift+Tab --- packages/coding-agent/src/tui/custom-editor.ts | 8 ++++---- packages/coding-agent/src/tui/tui-renderer.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/tui/custom-editor.ts b/packages/coding-agent/src/tui/custom-editor.ts index b89a64d5..08527dff 100644 --- a/packages/coding-agent/src/tui/custom-editor.ts +++ b/packages/coding-agent/src/tui/custom-editor.ts @@ -6,12 +6,12 @@ import { Editor } from "@mariozechner/pi-tui"; export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlC?: () => void; - public onCtrlT?: () => void; + public onShiftTab?: () => void; handleInput(data: string): void { - // Intercept Ctrl+T for thinking level cycling - if (data === "\x14" && this.onCtrlT) { - this.onCtrlT(); + // Intercept Shift+Tab for thinking level cycling + if (data === "\x1b[Z" && this.onShiftTab) { + this.onShiftTab(); return; } diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 01713d24..e9fcf847 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -169,7 +169,7 @@ export class TuiRenderer { chalk.dim("ctrl+k") + chalk.gray(" to delete line") + "\n" + - chalk.dim("ctrl+t") + + chalk.dim("shift+tab") + chalk.gray(" to cycle thinking") + "\n" + chalk.dim("/") + @@ -213,7 +213,7 @@ export class TuiRenderer { this.handleCtrlC(); }; - this.editor.onCtrlT = () => { + this.editor.onShiftTab = () => { this.cycleThinkingLevel(); };