Release v0.7.28

- Add message queuing with configurable modes (one-at-a-time/all) (#15)
- Add /queue command to select queue mode
- Add TruncatedText component for proper viewport-aware text truncation
- Queue mode setting persists in ~/.pi/agent/settings.json
- Visual feedback for queued messages with proper ANSI handling
- Press Escape to abort and restore queued messages to editor
This commit is contained in:
Mario Zechner 2025-11-20 20:39:43 +01:00
parent e694d435fd
commit d44073b140
18 changed files with 477 additions and 108 deletions

View file

@ -760,6 +760,7 @@ export async function main(args: string[]) {
thinkingLevel: "off",
tools: codingTools,
},
queueMode: settingsManager.getQueueMode(),
transport: new ProviderTransport({
// Dynamic API key lookup based on current model's provider
getApiKey: async () => {

View file

@ -6,6 +6,7 @@ export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
queueMode?: "all" | "one-at-a-time";
}
export class SettingsManager {
@ -78,4 +79,13 @@ export class SettingsManager {
this.settings.defaultModel = modelId;
this.save();
}
getQueueMode(): "all" | "one-at-a-time" {
return this.settings.queueMode || "one-at-a-time";
}
setQueueMode(mode: "all" | "one-at-a-time"): void {
this.settings.queueMode = mode;
this.save();
}
}

View file

@ -0,0 +1,64 @@
import { type Component, Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import chalk from "chalk";
/**
* Dynamic border component that adjusts to viewport width
*/
class DynamicBorder implements Component {
render(width: number): string[] {
return [chalk.blue("─".repeat(Math.max(1, width)))];
}
}
/**
* Component that renders a queue mode selector with borders
*/
export class QueueModeSelectorComponent extends Container {
private selectList: SelectList;
constructor(
currentMode: "all" | "one-at-a-time",
onSelect: (mode: "all" | "one-at-a-time") => void,
onCancel: () => void,
) {
super();
const queueModes: SelectItem[] = [
{
value: "one-at-a-time",
label: "one-at-a-time",
description: "Process queued messages one by one (recommended)",
},
{ value: "all", label: "all", description: "Process all queued messages at once" },
];
// Add top border
this.addChild(new DynamicBorder());
// Create selector
this.selectList = new SelectList(queueModes, 2);
// Preselect current mode
const currentIndex = queueModes.findIndex((item) => item.value === currentMode);
if (currentIndex !== -1) {
this.selectList.setSelectedIndex(currentIndex);
}
this.selectList.onSelect = (item) => {
onSelect(item.value as "all" | "one-at-a-time");
};
this.selectList.onCancel = () => {
onCancel();
};
this.addChild(this.selectList);
// Add bottom border
this.addChild(new DynamicBorder());
}
getSelectList(): SelectList {
return this.selectList;
}
}

View file

@ -10,6 +10,7 @@ import {
ProcessTerminal,
Spacer,
Text,
TruncatedText,
TUI,
} from "@mariozechner/pi-tui";
import chalk from "chalk";
@ -26,6 +27,7 @@ import { DynamicBorder } from "./dynamic-border.js";
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 { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
import { UserMessageComponent } from "./user-message.js";
@ -37,6 +39,7 @@ import { UserMessageSelectorComponent } from "./user-message-selector.js";
export class TuiRenderer {
private ui: TUI;
private chatContainer: Container;
private pendingMessagesContainer: Container;
private statusContainer: Container;
private editor: CustomEditor;
private editorContainer: Container; // Container to swap between editor and selector
@ -53,6 +56,9 @@ export class TuiRenderer {
private changelogMarkdown: string | null = null;
private newVersion: string | null = null;
// Message queueing
private queuedMessages: string[] = [];
// Streaming message tracking
private streamingComponent: AssistantMessageComponent | null = null;
@ -62,6 +68,9 @@ export class TuiRenderer {
// Thinking level selector
private thinkingSelector: ThinkingSelectorComponent | null = null;
// Queue mode selector
private queueModeSelector: QueueModeSelectorComponent | null = null;
// Model selector
private modelSelector: ModelSelectorComponent | null = null;
@ -98,6 +107,7 @@ export class TuiRenderer {
this.scopedModels = scopedModels;
this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.editor = new CustomEditor();
this.editorContainer = new Container(); // Container to hold editor or selector
@ -145,6 +155,11 @@ export class TuiRenderer {
description: "Logout from OAuth provider",
};
const queueCommand: SlashCommand = {
name: "queue",
description: "Select message queue mode (opens selector UI)",
};
// Setup autocomplete for file paths and slash commands
const autocompleteProvider = new CombinedAutocompleteProvider(
[
@ -156,6 +171,7 @@ export class TuiRenderer {
branchCommand,
loginCommand,
logoutCommand,
queueCommand,
],
process.cwd(),
);
@ -228,6 +244,7 @@ export class TuiRenderer {
}
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.ui.addChild(new Spacer(1));
this.ui.addChild(this.editorContainer); // Use container that can hold editor or selector
@ -238,6 +255,26 @@ export class TuiRenderer {
this.editor.onEscape = () => {
// Intercept Escape key when processing
if (this.loadingAnimation && this.onInterruptCallback) {
// Get all queued messages
const queuedText = this.queuedMessages.join("\n\n");
// Get current editor text
const currentText = this.editor.getText();
// Combine: queued messages + current editor text
const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
// Put back in editor
this.editor.setText(combinedText);
// Clear queued messages
this.queuedMessages = [];
this.updatePendingMessagesDisplay();
// Clear agent's queue too
this.agent.clearMessageQueue();
// Abort
this.onInterruptCallback();
}
};
@ -321,6 +358,13 @@ export class TuiRenderer {
return;
}
// Check for /queue command
if (text === "/queue") {
this.showQueueModeSelector();
this.editor.setText("");
return;
}
// Normal message submission - validate model and API key first
const currentModel = this.agent.state.model;
if (!currentModel) {
@ -343,6 +387,27 @@ export class TuiRenderer {
return;
}
// Check if agent is currently streaming
if (this.agent.state.isStreaming) {
// Queue the message instead of submitting
this.queuedMessages.push(text);
// Queue in agent
await this.agent.queueMessage({
role: "user",
content: [{ type: "text", text }],
timestamp: Date.now(),
});
// Update pending messages display
this.updatePendingMessagesDisplay();
// Clear editor
this.editor.setText("");
this.ui.requestRender();
return;
}
// All good, proceed with submission
if (this.onInputCallback) {
this.onInputCallback(text);
@ -365,7 +430,7 @@ export class TuiRenderer {
switch (event.type) {
case "agent_start":
// Show loading animation
this.editor.disableSubmit = true;
// Note: Don't disable submit - we handle queuing in onSubmit callback
// Stop old loader before clearing
if (this.loadingAnimation) {
this.loadingAnimation.stop();
@ -378,6 +443,18 @@ export class TuiRenderer {
case "message_start":
if (event.message.role === "user") {
// Check if this is a queued message
const userMsg = event.message as any;
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
const messageText = textBlocks.map((c: any) => c.text).join("");
const queuedIndex = this.queuedMessages.indexOf(messageText);
if (queuedIndex !== -1) {
// Remove from queued messages
this.queuedMessages.splice(queuedIndex, 1);
this.updatePendingMessagesDisplay();
}
// Show user message immediately and clear editor
this.addMessageToChat(event.message);
this.editor.setText("");
@ -497,7 +574,7 @@ export class TuiRenderer {
this.streamingComponent = null;
}
this.pendingTools.clear();
this.editor.disableSubmit = false;
// Note: Don't need to re-enable submit - we never disable it
this.ui.requestRender();
break;
}
@ -810,6 +887,48 @@ export class TuiRenderer {
this.ui.setFocus(this.editor);
}
private showQueueModeSelector(): void {
// Create queue mode selector with current mode
this.queueModeSelector = new QueueModeSelectorComponent(
this.agent.getQueueMode(),
(mode) => {
// Apply the selected queue mode
this.agent.setQueueMode(mode);
// Save queue mode to settings
this.settingsManager.setQueueMode(mode);
// Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1));
const confirmText = new Text(chalk.dim(`Queue mode: ${mode}`), 1, 0);
this.chatContainer.addChild(confirmText);
// Hide selector and show editor again
this.hideQueueModeSelector();
this.ui.requestRender();
},
() => {
// Just hide the selector
this.hideQueueModeSelector();
this.ui.requestRender();
},
);
// Replace editor with selector
this.editorContainer.clear();
this.editorContainer.addChild(this.queueModeSelector);
this.ui.setFocus(this.queueModeSelector.getSelectList());
this.ui.requestRender();
}
private hideQueueModeSelector(): void {
// Replace selector with editor in the container
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.queueModeSelector = null;
this.ui.setFocus(this.editor);
}
private showModelSelector(): void {
// Create model selector with current model
this.modelSelector = new ModelSelectorComponent(
@ -1171,6 +1290,19 @@ export class TuiRenderer {
this.ui.requestRender();
}
private updatePendingMessagesDisplay(): void {
this.pendingMessagesContainer.clear();
if (this.queuedMessages.length > 0) {
this.pendingMessagesContainer.addChild(new Spacer(1));
for (const message of this.queuedMessages) {
const queuedText = chalk.dim("Queued: " + message);
this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
}
}
}
stop(): void {
if (this.loadingAnimation) {
this.loadingAnimation.stop();