feat(tui): let Esc cancel active minimal-mode response generation
This commit is contained in:
@@ -29,6 +29,7 @@ function asAgent(value: unknown): NativeAgent {
|
|||||||
function minimalTuiPrivates(value: MinimalTui): {
|
function minimalTuiPrivates(value: MinimalTui): {
|
||||||
handleBackendCommand: (provider: string) => Promise<void>;
|
handleBackendCommand: (provider: string) => Promise<void>;
|
||||||
handleModelCommand: (tier: string, providerModel?: string) => void;
|
handleModelCommand: (tier: string, providerModel?: string) => void;
|
||||||
|
handleEscapeAction: () => boolean;
|
||||||
prompt: (text: string) => Promise<string>;
|
prompt: (text: string) => Promise<string>;
|
||||||
rl: {
|
rl: {
|
||||||
once: (event: string, cb: () => void) => void;
|
once: (event: string, cb: () => void) => void;
|
||||||
@@ -37,10 +38,12 @@ function minimalTuiPrivates(value: MinimalTui): {
|
|||||||
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
||||||
};
|
};
|
||||||
activePromptCancel: (() => void) | null;
|
activePromptCancel: (() => void) | null;
|
||||||
|
activeOperationCancel: (() => void) | null;
|
||||||
} {
|
} {
|
||||||
return value as unknown as {
|
return value as unknown as {
|
||||||
handleBackendCommand: (provider: string) => Promise<void>;
|
handleBackendCommand: (provider: string) => Promise<void>;
|
||||||
handleModelCommand: (tier: string, providerModel?: string) => void;
|
handleModelCommand: (tier: string, providerModel?: string) => void;
|
||||||
|
handleEscapeAction: () => boolean;
|
||||||
prompt: (text: string) => Promise<string>;
|
prompt: (text: string) => Promise<string>;
|
||||||
rl: {
|
rl: {
|
||||||
once: (event: string, cb: () => void) => void;
|
once: (event: string, cb: () => void) => void;
|
||||||
@@ -49,6 +52,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
|||||||
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
||||||
};
|
};
|
||||||
activePromptCancel: (() => void) | null;
|
activePromptCancel: (() => void) | null;
|
||||||
|
activeOperationCancel: (() => void) | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,4 +333,26 @@ describe('MinimalTui prompt cancellation', () => {
|
|||||||
expect(write).toHaveBeenCalledWith(null, { name: 'return' });
|
expect(write).toHaveBeenCalledWith(null, { name: 'return' });
|
||||||
expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull();
|
expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses Esc to cancel active running operation', () => {
|
||||||
|
const mockSession = {
|
||||||
|
id: 'test',
|
||||||
|
getHistory: () => [],
|
||||||
|
addMessage: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
replaceHistory: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tui = new MinimalTui({
|
||||||
|
session: asSession(mockSession),
|
||||||
|
modelClient: asRouter({}),
|
||||||
|
systemPrompt: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelRunningOperation = vi.fn();
|
||||||
|
minimalTuiPrivates(tui).activeOperationCancel = cancelRunningOperation;
|
||||||
|
|
||||||
|
expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true);
|
||||||
|
expect(cancelRunningOperation).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export class MinimalTui {
|
|||||||
private currentHint = '';
|
private currentHint = '';
|
||||||
private lastLine = '';
|
private lastLine = '';
|
||||||
private activePromptCancel: (() => void) | null = null;
|
private activePromptCancel: (() => void) | null = null;
|
||||||
|
private activeOperationCancel: (() => void) | null = null;
|
||||||
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
||||||
|
|
||||||
constructor(private config: MinimalTuiConfig) {}
|
constructor(private config: MinimalTuiConfig) {}
|
||||||
@@ -84,6 +85,29 @@ export class MinimalTui {
|
|||||||
return key?.name === 'escape' || key?.sequence === '\x1b' || char === '\x1b';
|
return key?.name === 'escape' || key?.sequence === '\x1b' || char === '\x1b';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleEscapeAction(): boolean {
|
||||||
|
if (this.activePromptCancel) {
|
||||||
|
this.activePromptCancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeOperationCancel) {
|
||||||
|
this.activeOperationCancel();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.rl) {
|
||||||
|
try {
|
||||||
|
this.rl.write(null, { ctrl: true, name: 'u' });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private showHint(line: string): void {
|
private showHint(line: string): void {
|
||||||
if (!line.startsWith('/')) {
|
if (!line.startsWith('/')) {
|
||||||
this.clearHint();
|
this.clearHint();
|
||||||
@@ -164,17 +188,7 @@ export class MinimalTui {
|
|||||||
// Listen for line changes to show hints
|
// Listen for line changes to show hints
|
||||||
this.keypressHandler = (char: string, key: readline.Key) => {
|
this.keypressHandler = (char: string, key: readline.Key) => {
|
||||||
if (this.isEscapeKey(char, key)) {
|
if (this.isEscapeKey(char, key)) {
|
||||||
if (this.activePromptCancel) {
|
this.handleEscapeAction();
|
||||||
this.activePromptCancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.rl) {
|
|
||||||
try {
|
|
||||||
this.rl.write(null, { ctrl: true, name: 'u' });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +212,7 @@ export class MinimalTui {
|
|||||||
|
|
||||||
console.log(getColoredBanner());
|
console.log(getColoredBanner());
|
||||||
console.log(`\n${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`);
|
console.log(`\n${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`);
|
||||||
console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts${colors.reset}\n`);
|
console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts or running responses${colors.reset}\n`);
|
||||||
|
|
||||||
await this.promptLoop();
|
await this.promptLoop();
|
||||||
}
|
}
|
||||||
@@ -917,7 +931,17 @@ export class MinimalTui {
|
|||||||
try {
|
try {
|
||||||
// Use agent if available (supports tool loop)
|
// Use agent if available (supports tool loop)
|
||||||
if (this.config.agent) {
|
if (this.config.agent) {
|
||||||
|
let cancelRequested = false;
|
||||||
|
this.activeOperationCancel = () => {
|
||||||
|
if (cancelRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelRequested = true;
|
||||||
|
this.config.agent?.cancel();
|
||||||
|
console.log(`\n${colors.gray}Cancelling...${colors.reset}`);
|
||||||
|
};
|
||||||
const response = await this.config.agent.process(content);
|
const response = await this.config.agent.process(content);
|
||||||
|
this.activeOperationCancel = null;
|
||||||
const rendered = renderMarkdown(response);
|
const rendered = renderMarkdown(response);
|
||||||
console.log(rendered);
|
console.log(rendered);
|
||||||
console.log();
|
console.log();
|
||||||
@@ -930,11 +954,23 @@ export class MinimalTui {
|
|||||||
// Try streaming if available
|
// Try streaming if available
|
||||||
if (this.config.modelClient.chatStream) {
|
if (this.config.modelClient.chatStream) {
|
||||||
let fullContent = '';
|
let fullContent = '';
|
||||||
|
let cancelRequested = false;
|
||||||
|
this.activeOperationCancel = () => {
|
||||||
|
if (cancelRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelRequested = true;
|
||||||
|
console.log(`\n${colors.gray}Cancelling...${colors.reset}`);
|
||||||
|
};
|
||||||
|
|
||||||
for await (const event of this.config.modelClient.chatStream({
|
for await (const event of this.config.modelClient.chatStream({
|
||||||
messages: this.config.session.getHistory(),
|
messages: this.config.session.getHistory(),
|
||||||
system: this.config.systemPrompt,
|
system: this.config.systemPrompt,
|
||||||
})) {
|
})) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
fullContent += '\n\n[interrupted]';
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (event.type === 'content' && event.content) {
|
if (event.type === 'content' && event.content) {
|
||||||
process.stdout.write(event.content);
|
process.stdout.write(event.content);
|
||||||
fullContent += event.content;
|
fullContent += event.content;
|
||||||
@@ -950,16 +986,21 @@ export class MinimalTui {
|
|||||||
throw event.error ?? new Error('Stream error');
|
throw event.error ?? new Error('Stream error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.activeOperationCancel = null;
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
|
|
||||||
this.config.session.addMessage({ role: 'assistant', content: fullContent });
|
this.config.session.addMessage({ role: 'assistant', content: fullContent });
|
||||||
} else {
|
} else {
|
||||||
|
this.activeOperationCancel = () => {
|
||||||
|
console.log(`\n${colors.gray}Cancellation is not available for non-streaming responses.${colors.reset}`);
|
||||||
|
};
|
||||||
// Fallback to non-streaming
|
// Fallback to non-streaming
|
||||||
const response = await this.config.modelClient.chat({
|
const response = await this.config.modelClient.chat({
|
||||||
messages: this.config.session.getHistory(),
|
messages: this.config.session.getHistory(),
|
||||||
system: this.config.systemPrompt,
|
system: this.config.systemPrompt,
|
||||||
});
|
});
|
||||||
|
this.activeOperationCancel = null;
|
||||||
|
|
||||||
const rendered = renderMarkdown(response.content);
|
const rendered = renderMarkdown(response.content);
|
||||||
console.log(rendered);
|
console.log(rendered);
|
||||||
@@ -973,12 +1014,15 @@ export class MinimalTui {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error instanceof Error ? error.message : error);
|
console.error('Error:', error instanceof Error ? error.message : error);
|
||||||
console.log();
|
console.log();
|
||||||
|
} finally {
|
||||||
|
this.activeOperationCancel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(preserveStdin = false): void {
|
stop(preserveStdin = false): void {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.activePromptCancel = null;
|
this.activePromptCancel = null;
|
||||||
|
this.activeOperationCancel = null;
|
||||||
if (this.rl) {
|
if (this.rl) {
|
||||||
if (preserveStdin) {
|
if (preserveStdin) {
|
||||||
// Remove readline listeners but don't close stdin
|
// Remove readline listeners but don't close stdin
|
||||||
|
|||||||
Reference in New Issue
Block a user