feat(coding-agent): added temp model controls and auto-chdir

This commit is contained in:
can1357
2026-01-08 02:51:29 +01:00
parent 91a49e7178
commit d11ccd2e93
10 changed files with 210 additions and 86 deletions
@@ -38,30 +38,40 @@ export class ExtensionDashboard extends Container {
private inspector: InspectorPanel;
private settingsManager: SettingsManager | null;
private cwd: string;
private terminalHeight: number;
public onClose?: () => void;
constructor(cwd: string, settingsManager: SettingsManager | null = null) {
constructor(cwd: string, settingsManager: SettingsManager | null = null, terminalHeight?: number) {
super();
this.cwd = cwd;
this.settingsManager = settingsManager;
this.terminalHeight = terminalHeight ?? process.stdout.rows ?? 24;
const disabledIds = settingsManager?.getDisabledExtensions() ?? [];
this.state = createInitialState(cwd, disabledIds);
// Calculate max visible items based on terminal height
// Reserve ~10 lines for header, tabs, help text, borders
const maxVisible = Math.max(5, Math.floor((this.terminalHeight - 10) / 2));
// Create main list - always focused
this.mainList = new ExtensionList(this.state.searchFiltered, {
onSelectionChange: (ext) => {
this.state.selected = ext;
this.inspector.setExtension(ext);
this.mainList = new ExtensionList(
this.state.searchFiltered,
{
onSelectionChange: (ext) => {
this.state.selected = ext;
this.inspector.setExtension(ext);
},
onToggle: (extensionId, enabled) => {
this.handleExtensionToggle(extensionId, enabled);
},
onMasterToggle: (providerId) => {
this.handleProviderToggle(providerId);
},
masterSwitchProvider: this.getActiveProviderId(),
},
onToggle: (extensionId, enabled) => {
this.handleExtensionToggle(extensionId, enabled);
},
onMasterToggle: (providerId) => {
this.handleProviderToggle(providerId);
},
masterSwitchProvider: this.getActiveProviderId(),
});
maxVisible,
);
this.mainList.setFocused(true);
// Create inspector
@@ -91,9 +101,10 @@ export class ExtensionDashboard extends Container {
this.addChild(new Text(this.renderTabBar(), 0, 0));
this.addChild(new Spacer(1));
// Help text
// 2-column body
this.addChild(new TwoColumnBody(this.mainList, this.inspector));
// 2-column body with height limit
// Reserve ~8 lines for header, tabs, help text, borders
const bodyMaxHeight = Math.max(5, this.terminalHeight - 8);
this.addChild(new TwoColumnBody(this.mainList, this.inspector, bodyMaxHeight));
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("dim", " ↑/↓: navigate Space: toggle Tab: next provider Esc: close"), 0, 0));
@@ -262,10 +273,12 @@ export class ExtensionDashboard extends Container {
class TwoColumnBody implements Component {
private leftPane: ExtensionList;
private rightPane: InspectorPanel;
private maxHeight: number;
constructor(left: ExtensionList, right: InspectorPanel) {
constructor(left: ExtensionList, right: InspectorPanel, maxHeight: number) {
this.leftPane = left;
this.rightPane = right;
this.maxHeight = maxHeight;
}
render(width: number): string[] {
@@ -275,11 +288,12 @@ class TwoColumnBody implements Component {
const leftLines = this.leftPane.render(leftWidth);
const rightLines = this.rightPane.render(rightWidth);
const maxLines = Math.max(leftLines.length, rightLines.length);
// Limit to maxHeight lines
const numLines = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
const combined: string[] = [];
const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
for (let i = 0; i < maxLines; i++) {
for (let i = 0; i < numLines; i++) {
const left = truncateToWidth(leftLines[i] ?? "", leftWidth);
const leftPadded = left + " ".repeat(Math.max(0, leftWidth - visibleWidth(left)));
const right = truncateToWidth(rightLines[i] ?? "", rightWidth);
@@ -31,7 +31,7 @@ export interface ExtensionListCallbacks {
masterSwitchProvider?: string | null;
}
const MAX_VISIBLE = 25;
const DEFAULT_MAX_VISIBLE = 15;
/** Flattened list item for rendering */
type ListItem =
@@ -48,14 +48,21 @@ export class ExtensionList implements Component {
private focused = false;
private callbacks: ExtensionListCallbacks;
private masterSwitchProvider: string | null = null;
private maxVisible: number;
constructor(extensions: Extension[], callbacks: ExtensionListCallbacks = {}) {
constructor(extensions: Extension[], callbacks: ExtensionListCallbacks = {}, maxVisible?: number) {
this.extensions = extensions;
this.callbacks = callbacks;
this.masterSwitchProvider = callbacks.masterSwitchProvider ?? null;
this.maxVisible = maxVisible ?? DEFAULT_MAX_VISIBLE;
this.rebuildList();
}
setMaxVisible(maxVisible: number): void {
this.maxVisible = maxVisible;
this.clampSelection();
}
setExtensions(extensions: Extension[]): void {
this.extensions = extensions;
this.rebuildList();
@@ -126,7 +133,7 @@ export class ExtensionList implements Component {
// Calculate visible range
const startIdx = this.scrollOffset;
const endIdx = Math.min(startIdx + MAX_VISIBLE, this.listItems.length);
const endIdx = Math.min(startIdx + this.maxVisible, this.listItems.length);
// Render visible items
for (let i = startIdx; i < endIdx; i++) {
@@ -143,7 +150,7 @@ export class ExtensionList implements Component {
}
// Scroll indicator
if (this.listItems.length > MAX_VISIBLE) {
if (this.listItems.length > this.maxVisible) {
const indicator = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.listItems.length})`);
lines.push(indicator);
}
@@ -388,8 +395,8 @@ export class ExtensionList implements Component {
// Adjust scroll offset
if (this.selectedIndex < this.scrollOffset) {
this.scrollOffset = this.selectedIndex;
} else if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
} else if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
}
}
@@ -470,8 +477,8 @@ export class ExtensionList implements Component {
private moveSelectionDown(): void {
if (this.selectedIndex < this.listItems.length - 1) {
this.selectedIndex++;
if (this.selectedIndex >= this.scrollOffset + MAX_VISIBLE) {
this.scrollOffset = this.selectedIndex - MAX_VISIBLE + 1;
if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
}
this.notifySelectionChange();
}
@@ -34,7 +34,7 @@ interface ScopedModelItem {
thinkingLevel: string;
}
type ModelRole = "default" | "smol" | "slow";
type ModelRole = "default" | "smol" | "slow" | "temporary";
interface MenuAction {
label: string;
@@ -75,6 +75,7 @@ export class ModelSelectorComponent extends Container {
private errorMessage?: string;
private tui: TUI;
private scopedModels: ReadonlyArray<ScopedModelItem>;
private temporaryOnly: boolean;
// Tab state
private providers: string[] = [ALL_TAB];
@@ -92,6 +93,7 @@ export class ModelSelectorComponent extends Container {
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>, role: string) => void,
onCancel: () => void,
options?: { temporaryOnly?: boolean },
) {
super();
@@ -102,6 +104,7 @@ export class ModelSelectorComponent extends Container {
this.scopedModels = scopedModels;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
this.temporaryOnly = options?.temporaryOnly ?? false;
// Load current role assignments from settings
this._loadRoleModels();
@@ -483,10 +486,16 @@ export class ModelSelectorComponent extends Container {
return;
}
// Enter - open context menu
// Enter - open context menu or select directly in temporary mode
if (isEnter(keyData)) {
if (this.filteredModels[this.selectedIndex]) {
this.openMenu();
const selectedModel = this.filteredModels[this.selectedIndex];
if (selectedModel) {
if (this.temporaryOnly) {
// In temporary mode, skip menu and select directly
this.handleSelect(selectedModel.model, "temporary");
} else {
this.openMenu();
}
}
return;
}
@@ -536,6 +545,12 @@ export class ModelSelectorComponent extends Container {
}
private handleSelect(model: Model<any>, role: ModelRole): void {
// For temporary role, don't save to settings - just notify caller
if (role === "temporary") {
this.onSelectCallback(model, role);
return;
}
// Save to settings
this.settingsManager.setModelRole(role, `${model.provider}/${model.id}`);
@@ -942,9 +942,9 @@ export class InteractiveMode {
this.editor.onCtrlD = () => this.handleCtrlD();
this.editor.onCtrlZ = () => this.handleCtrlZ();
this.editor.onShiftTab = () => this.cycleThinkingLevel();
this.editor.onCtrlP = () => this.cycleModel("forward");
this.editor.onShiftCtrlP = () => this.cycleModel("backward");
this.editor.onCtrlY = () => this.cycleRoleModel();
this.editor.onCtrlP = () => this.cycleRoleModel();
this.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
this.editor.onCtrlY = () => this.showModelSelector({ temporaryOnly: true });
// Global debug handler on TUI (works regardless of focus)
this.ui.onDebug = () => this.handleDebugCommand();
@@ -952,9 +952,6 @@ export class InteractiveMode {
this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility();
this.editor.onCtrlG = () => this.openExternalEditor();
this.editor.onCtrlY = () => {
void this.toggleVoiceListening();
};
this.editor.onQuestionMark = () => this.handleHotkeysCommand();
this.editor.onCtrlV = () => this.handleImagePaste();
@@ -992,6 +989,14 @@ export class InteractiveMode {
private setupEditorSubmitHandler(): void {
this.editor.onSubmit = async (text: string) => {
text = text.trim();
// Empty submit while streaming with queued messages: flush queues immediately
if (!text && this.session.isStreaming && this.session.queuedMessageCount > 0) {
// Abort current stream and let queued messages be processed
await this.session.abort();
return;
}
if (!text) return;
// Handle slash commands
@@ -1995,27 +2000,9 @@ export class InteractiveMode {
}
}
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
private async cycleRoleModel(options?: { temporary?: boolean }): Promise<void> {
try {
const result = await this.session.cycleModel(direction);
if (result === undefined) {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
this.statusLine.invalidate();
this.updateEditorBorderColor();
const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);
}
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
}
private async cycleRoleModel(): Promise<void> {
try {
const result = await this.session.cycleRoleModels(["slow", "default", "smol"]);
const result = await this.session.cycleRoleModels(["slow", "default", "smol"], options);
if (!result) {
this.showStatus("Only one role model available");
return;
@@ -2026,7 +2013,8 @@ export class InteractiveMode {
const roleLabel = result.role === "default" ? "default" : result.role;
const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}`);
const tempLabel = options?.temporary ? " (temporary)" : "";
this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}`);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
@@ -2256,7 +2244,7 @@ export class InteractiveMode {
*/
private showExtensionsDashboard(): void {
this.showSelector((done) => {
const dashboard = new ExtensionDashboard(process.cwd(), this.settingsManager);
const dashboard = new ExtensionDashboard(process.cwd(), this.settingsManager, this.ui.terminal.rows);
dashboard.onClose = () => {
done();
this.ui.requestRender();
@@ -2380,7 +2368,7 @@ export class InteractiveMode {
}
}
private showModelSelector(): void {
private showModelSelector(options?: { temporaryOnly?: boolean }): void {
this.showSelector((done) => {
const selector = new ModelSelectorComponent(
this.ui,
@@ -2390,24 +2378,36 @@ export class InteractiveMode {
this.session.scopedModels,
async (model, role) => {
try {
// Only update agent state for default role
if (role === "default") {
if (role === "temporary") {
// Temporary: update agent state but don't persist to settings
await this.session.setModelTemporary(model);
this.statusLine.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Temporary model: ${model.id}`);
done();
this.ui.requestRender();
} else if (role === "default") {
// Default: update agent state and persist
await this.session.setModel(model, role);
this.statusLine.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Default model: ${model.id}`);
// Don't call done() - selector stays open for role assignment
} else {
// Other roles (smol, slow): just update settings, not current model
const roleLabel = role === "smol" ? "Smol" : role;
this.showStatus(`${roleLabel} model: ${model.id}`);
// Don't call done() - selector stays open
}
// For other roles (small), just show status - settings already updated by selector
const roleLabel = role === "default" ? "Default" : role === "smol" ? "Smol" : role;
this.showStatus(`${roleLabel} model: ${model.id}`);
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
// Don't call done() - selector stays open
},
() => {
done();
this.ui.requestRender();
},
options,
);
return { component: selector, focus: selector };
});
@@ -2995,8 +2995,10 @@ export class InteractiveMode {
| \`Ctrl+D\` | Exit (when editor is empty) |
| \`Ctrl+Z\` | Suspend to background |
| \`Shift+Tab\` | Cycle thinking level |
| \`Ctrl+P\` | Cycle models |
| \`Ctrl+Y\` | Cycle role models (slow/default/smol) |
| \`Ctrl+P\` | Cycle role models (slow/default/smol) |
| \`Shift+Ctrl+P\` | Cycle role models (temporary) |
| \`Ctrl+Y\` | Select model (temporary) |
| \`Ctrl+L\` | Select model (set roles) |
| \`Ctrl+O\` | Toggle tool output expansion |
| \`Ctrl+T\` | Toggle thinking block visibility |
| \`Ctrl+G\` | Edit message in external editor |