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",
|
"updated_at": "2026-02-23",
|
||||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||||
"plans": {
|
"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": {
|
"toolloop-execution-claim-recovery": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-23",
|
"date": "2026-02-23",
|
||||||
@@ -6311,7 +6326,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1962,
|
"total_test_count": 1965,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (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",
|
"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_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",
|
"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",
|
"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",
|
"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"
|
"next_up": "Track OpenClaw evolution regularly for inspiration and feature ideas"
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ describe('parseCommand', () => {
|
|||||||
expect(parseCommand('/?')).toEqual({ type: 'help' });
|
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', () => {
|
it('parses /status command', () => {
|
||||||
expect(parseCommand('/status')).toEqual({ type: 'status' });
|
expect(parseCommand('/status')).toEqual({ type: 'status' });
|
||||||
});
|
});
|
||||||
@@ -139,6 +144,7 @@ describe('getHelpText', () => {
|
|||||||
it('returns help text with all commands', () => {
|
it('returns help text with all commands', () => {
|
||||||
const help = getHelpText();
|
const help = getHelpText();
|
||||||
expect(help).toContain('/help');
|
expect(help).toContain('/help');
|
||||||
|
expect(help).toContain('/paste');
|
||||||
expect(help).toContain('/model');
|
expect(help).toContain('/model');
|
||||||
expect(help).toContain('/tools');
|
expect(help).toContain('/tools');
|
||||||
expect(help).toContain('/research');
|
expect(help).toContain('/research');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type Command =
|
|||||||
| { type: 'quit' }
|
| { type: 'quit' }
|
||||||
| { type: 'reset' }
|
| { type: 'reset' }
|
||||||
| { type: 'help' }
|
| { type: 'help' }
|
||||||
|
| { type: 'multiline' }
|
||||||
| { type: 'status' }
|
| { type: 'status' }
|
||||||
| { type: 'tools' }
|
| { type: 'tools' }
|
||||||
| { type: 'research'; task: string }
|
| { type: 'research'; task: string }
|
||||||
@@ -63,6 +64,11 @@ export function parseCommand(input: string): Command | null {
|
|||||||
return { type: 'help' };
|
return { type: 'help' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multiline paste mode
|
||||||
|
if (trimmed === '/paste' || trimmed === '/multiline') {
|
||||||
|
return { type: 'multiline' };
|
||||||
|
}
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
if (trimmed === '/status') {
|
if (trimmed === '/status') {
|
||||||
return { type: 'status' };
|
return { type: 'status' };
|
||||||
@@ -212,6 +218,7 @@ export function getHelpText(): string {
|
|||||||
return `
|
return `
|
||||||
Commands:
|
Commands:
|
||||||
/help, /? Show this help
|
/help, /? Show this help
|
||||||
|
/paste, /multiline Enter multiline mode (finish with single '.' line)
|
||||||
/tools Show available tools in this session
|
/tools Show available tools in this session
|
||||||
/model [name] Show or switch model tier (local, default, fast, complex)
|
/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)
|
/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
|
// List of all slash commands for autocompletion
|
||||||
export const SLASH_COMMANDS = [
|
export const SLASH_COMMANDS = [
|
||||||
'/help',
|
'/help',
|
||||||
|
'/paste',
|
||||||
|
'/multiline',
|
||||||
'/tools',
|
'/tools',
|
||||||
'/model',
|
'/model',
|
||||||
'/backend',
|
'/backend',
|
||||||
@@ -279,6 +288,8 @@ export const SLASH_COMMANDS = [
|
|||||||
// Command descriptions for tooltips
|
// Command descriptions for tooltips
|
||||||
export const COMMAND_TOOLTIPS: Record<string, string> = {
|
export const COMMAND_TOOLTIPS: Record<string, string> = {
|
||||||
'/help': 'Show available commands',
|
'/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',
|
'/tools': 'Show authoritative runtime tool list for this session',
|
||||||
'/model': 'Show or switch model (local, default, fast, complex)',
|
'/model': 'Show or switch model (local, default, fast, complex)',
|
||||||
'/backend': 'Show or switch local backend (ollama, llamacpp)',
|
'/backend': 'Show or switch local backend (ollama, llamacpp)',
|
||||||
|
|||||||
@@ -713,6 +713,10 @@ export function App({
|
|||||||
return;
|
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':
|
case 'message':
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
|||||||
handleVerboseCommand: () => void;
|
handleVerboseCommand: () => void;
|
||||||
handleToolEvent: (event: unknown) => void;
|
handleToolEvent: (event: unknown) => void;
|
||||||
handleCommand: (command: unknown) => Promise<void>;
|
handleCommand: (command: unknown) => Promise<void>;
|
||||||
|
handleMessage: (content: string) => Promise<void>;
|
||||||
handleEscapeAction: () => boolean;
|
handleEscapeAction: () => boolean;
|
||||||
handleCtrlCPress: (nowMs?: number) => boolean;
|
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||||
clearSubmittedPromptLine: () => boolean;
|
clearSubmittedPromptLine: () => boolean;
|
||||||
@@ -60,6 +61,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
|||||||
handleVerboseCommand: () => void;
|
handleVerboseCommand: () => void;
|
||||||
handleToolEvent: (event: unknown) => void;
|
handleToolEvent: (event: unknown) => void;
|
||||||
handleCommand: (command: unknown) => Promise<void>;
|
handleCommand: (command: unknown) => Promise<void>;
|
||||||
|
handleMessage: (content: string) => Promise<void>;
|
||||||
handleEscapeAction: () => boolean;
|
handleEscapeAction: () => boolean;
|
||||||
handleCtrlCPress: (nowMs?: number) => boolean;
|
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||||
clearSubmittedPromptLine: () => 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', () => {
|
it('only renders tool activity when verbose mode is enabled', () => {
|
||||||
const mockSession = {
|
const mockSession = {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
|
|||||||
@@ -476,6 +476,10 @@ export class MinimalTui {
|
|||||||
console.log(getHelpText() + '\n');
|
console.log(getHelpText() + '\n');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'multiline':
|
||||||
|
await this.handleMultilineCommand();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'status':
|
case 'status':
|
||||||
this.printStatus();
|
this.printStatus();
|
||||||
break;
|
break;
|
||||||
@@ -603,6 +607,27 @@ export class MinimalTui {
|
|||||||
console.log(`${output}\n`);
|
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 {
|
private handleContextCommand(): void {
|
||||||
const history = this.config.session.getHistory();
|
const history = this.config.session.getHistory();
|
||||||
const estimated = estimateMessageTokens(history);
|
const estimated = estimateMessageTokens(history);
|
||||||
|
|||||||
Reference in New Issue
Block a user