feat(tui): add multiline paste mode in minimal UI
This commit is contained in:
+17
-1
@@ -3,6 +3,21 @@
|
||||
"updated_at": "2026-02-23",
|
||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||
"plans": {
|
||||
"minimal-tui-multiline-paste-mode": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-23",
|
||||
"updated": "2026-02-23",
|
||||
"summary": "Added multiline compose support to minimal TUI via `/paste` (alias `/multiline`) so users can enter/paste multi-line prompts without truncation. The mode collects lines until a single `.` terminator and submits one combined message. Also added fullscreen-mode handling for the new command plus parser/help/tooltip coverage and multiline flow tests.",
|
||||
"files_modified": [
|
||||
"src/frontends/tui/commands.ts",
|
||||
"src/frontends/tui/minimal.ts",
|
||||
"src/frontends/tui/components/App.tsx",
|
||||
"src/frontends/tui/commands.test.ts",
|
||||
"src/frontends/tui/minimal.test.ts",
|
||||
"docs/plans/state.json"
|
||||
],
|
||||
"test_status": "pnpm test:run src/frontends/tui/commands.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing"
|
||||
},
|
||||
"toolloop-execution-claim-recovery": {
|
||||
"status": "completed",
|
||||
"date": "2026-02-23",
|
||||
@@ -6311,7 +6326,7 @@
|
||||
}
|
||||
},
|
||||
"overall_progress": {
|
||||
"total_test_count": 1962,
|
||||
"total_test_count": 1965,
|
||||
"all_tests_passing": true,
|
||||
"p0_completion": "3/3 (100%)",
|
||||
"p1_completion": "4/4 (100%)",
|
||||
@@ -6333,6 +6348,7 @@
|
||||
"gmail_filter_creation": "completed — gmail.filter.create tool added with criteria/action validation; gmail-auth now requests full Gmail scope (https://mail.google.com/) for complete filter permissions",
|
||||
"toolloop_action_intent_recovery": "completed — when a model claims it will execute a tool but emits no tool call, NativeAgent now issues one internal nudge and continues the same turn to execute tools or produce a concrete blocker",
|
||||
"toolloop_execution_claim_recovery": "completed — when a model claims a known tool already succeeded/failed without emitting a tool call, NativeAgent now nudges once and retries the same turn before returning text",
|
||||
"minimal_tui_multiline_paste_mode": "completed — minimal TUI now supports `/paste`/`/multiline` multiline compose mode ending with single '.' line, preventing newline truncation for pasted prompts",
|
||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback, plus 2026-02-23 arg hydration hardening, tool.args_rewritten audit metric, transient fetch retry/timeout hardening, localhost->127.0.0.1 fallback for transcription endpoint connectivity, and whisper docker-compose entrypoint arg fix for port 18801",
|
||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||
"next_up": "Track OpenClaw evolution regularly for inspiration and feature ideas"
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('parseCommand', () => {
|
||||
expect(parseCommand('/?')).toEqual({ type: 'help' });
|
||||
});
|
||||
|
||||
it('parses /paste and /multiline commands', () => {
|
||||
expect(parseCommand('/paste')).toEqual({ type: 'multiline' });
|
||||
expect(parseCommand('/multiline')).toEqual({ type: 'multiline' });
|
||||
});
|
||||
|
||||
it('parses /status command', () => {
|
||||
expect(parseCommand('/status')).toEqual({ type: 'status' });
|
||||
});
|
||||
@@ -139,6 +144,7 @@ describe('getHelpText', () => {
|
||||
it('returns help text with all commands', () => {
|
||||
const help = getHelpText();
|
||||
expect(help).toContain('/help');
|
||||
expect(help).toContain('/paste');
|
||||
expect(help).toContain('/model');
|
||||
expect(help).toContain('/tools');
|
||||
expect(help).toContain('/research');
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Command =
|
||||
| { type: 'quit' }
|
||||
| { type: 'reset' }
|
||||
| { type: 'help' }
|
||||
| { type: 'multiline' }
|
||||
| { type: 'status' }
|
||||
| { type: 'tools' }
|
||||
| { type: 'research'; task: string }
|
||||
@@ -63,6 +64,11 @@ export function parseCommand(input: string): Command | null {
|
||||
return { type: 'help' };
|
||||
}
|
||||
|
||||
// Multiline paste mode
|
||||
if (trimmed === '/paste' || trimmed === '/multiline') {
|
||||
return { type: 'multiline' };
|
||||
}
|
||||
|
||||
// Status
|
||||
if (trimmed === '/status') {
|
||||
return { type: 'status' };
|
||||
@@ -212,6 +218,7 @@ export function getHelpText(): string {
|
||||
return `
|
||||
Commands:
|
||||
/help, /? Show this help
|
||||
/paste, /multiline Enter multiline mode (finish with single '.' line)
|
||||
/tools Show available tools in this session
|
||||
/model [name] Show or switch model tier (local, default, fast, complex)
|
||||
/model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4)
|
||||
@@ -249,6 +256,8 @@ export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'so
|
||||
// List of all slash commands for autocompletion
|
||||
export const SLASH_COMMANDS = [
|
||||
'/help',
|
||||
'/paste',
|
||||
'/multiline',
|
||||
'/tools',
|
||||
'/model',
|
||||
'/backend',
|
||||
@@ -279,6 +288,8 @@ export const SLASH_COMMANDS = [
|
||||
// Command descriptions for tooltips
|
||||
export const COMMAND_TOOLTIPS: Record<string, string> = {
|
||||
'/help': 'Show available commands',
|
||||
'/paste': 'Compose a multiline message; finish with a single "." line',
|
||||
'/multiline': 'Compose a multiline message; finish with a single "." line',
|
||||
'/tools': 'Show authoritative runtime tool list for this session',
|
||||
'/model': 'Show or switch model (local, default, fast, complex)',
|
||||
'/backend': 'Show or switch local backend (ollama, llamacpp)',
|
||||
|
||||
@@ -713,6 +713,10 @@ export function App({
|
||||
return;
|
||||
}
|
||||
|
||||
case 'multiline':
|
||||
pushAssistantMessage('Multiline compose mode is currently available in minimal TUI only. In fullscreen mode, submit as a single message block.');
|
||||
return;
|
||||
|
||||
case 'message':
|
||||
break;
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
||||
handleVerboseCommand: () => void;
|
||||
handleToolEvent: (event: unknown) => void;
|
||||
handleCommand: (command: unknown) => Promise<void>;
|
||||
handleMessage: (content: string) => Promise<void>;
|
||||
handleEscapeAction: () => boolean;
|
||||
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||
clearSubmittedPromptLine: () => boolean;
|
||||
@@ -60,6 +61,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
||||
handleVerboseCommand: () => void;
|
||||
handleToolEvent: (event: unknown) => void;
|
||||
handleCommand: (command: unknown) => Promise<void>;
|
||||
handleMessage: (content: string) => Promise<void>;
|
||||
handleEscapeAction: () => boolean;
|
||||
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||
clearSubmittedPromptLine: () => boolean;
|
||||
@@ -401,6 +403,48 @@ describe('MinimalTui backend command', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('collects multiline input from /paste and sends as one message', async () => {
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asModelClient({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
|
||||
const promptSpy = vi.fn()
|
||||
.mockResolvedValueOnce('first line')
|
||||
.mockResolvedValueOnce('second line')
|
||||
.mockResolvedValueOnce('.');
|
||||
minimalTuiPrivates(tui).prompt = promptSpy;
|
||||
|
||||
const handleMessageSpy = vi.fn(async () => {});
|
||||
minimalTuiPrivates(tui).handleMessage = handleMessageSpy;
|
||||
minimalTuiPrivates(tui).running = true;
|
||||
minimalTuiPrivates(tui).rl = {
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
question: vi.fn(),
|
||||
write: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
await minimalTuiPrivates(tui).handleCommand({ type: 'multiline' });
|
||||
|
||||
expect(handleMessageSpy).toHaveBeenCalledWith('first line\nsecond line');
|
||||
expect(promptSpy).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('only renders tool activity when verbose mode is enabled', () => {
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
|
||||
@@ -476,6 +476,10 @@ export class MinimalTui {
|
||||
console.log(getHelpText() + '\n');
|
||||
break;
|
||||
|
||||
case 'multiline':
|
||||
await this.handleMultilineCommand();
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
this.printStatus();
|
||||
break;
|
||||
@@ -603,6 +607,27 @@ export class MinimalTui {
|
||||
console.log(`${output}\n`);
|
||||
}
|
||||
|
||||
private async handleMultilineCommand(): Promise<void> {
|
||||
console.log(`${colors.gray}Multiline mode: paste/type content. End with a single "." on its own line.${colors.reset}`);
|
||||
const lines: string[] = [];
|
||||
|
||||
while (this.running && this.rl) {
|
||||
const line = await this.prompt(`${colors.orange}...${colors.reset} `);
|
||||
if (line === '.') {
|
||||
break;
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
const content = lines.join('\n').trim();
|
||||
if (!content) {
|
||||
console.log(`${colors.gray}Multiline input cancelled.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleMessage(content);
|
||||
}
|
||||
|
||||
private handleContextCommand(): void {
|
||||
const history = this.config.session.getHistory();
|
||||
const estimated = estimateMessageTokens(history);
|
||||
|
||||
Reference in New Issue
Block a user