feat: add Tab key to cycle through thinking levels

- 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
This commit is contained in:
Tino Ehrich 2025-11-19 10:18:24 +01:00
parent 1f68d6eb40
commit 9e8373b86a
3 changed files with 68 additions and 15 deletions

View file

@ -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()) {

View file

@ -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;
}
}

View file

@ -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();