feat(tui): add multiline paste mode in minimal UI
This commit is contained in:
@@ -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