Skip to content

Commit 0e7c62b

Browse files
christsoclaude
andauthored
fix(tui): erase cancelled prompt instead of showing strikethrough (#60) (#61)
When pressing ESC in TUI selection menus, @clack/prompts renders the selected item with strikethrough styling, misleading users into thinking the item was removed. Add prompt wrappers that detect cancellation and erase the cancelled output using ANSI escape sequences, so ESC cleanly returns to the previous menu. ESC still functions as cancel (industry standard per fzf, bubbletea, clack), but the confusing strikethrough visual is replaced with a clean return to the parent menu. Closes #60 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6d2c58a commit 0e7c62b

6 files changed

Lines changed: 207 additions & 13 deletions

File tree

src/cli/tui/actions/init.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import * as p from '@clack/prompts';
22
import { initWorkspace } from '../../../core/workspace.js';
3+
import { text } from '../prompts.js';
34

45
/**
56
* Guided workspace initialization action.
67
* Prompts user for path and optional template source, then runs initWorkspace.
78
*/
89
export async function runInit(): Promise<void> {
910
try {
10-
const targetPath = await p.text({
11+
const targetPath = await text({
1112
message: 'Where should the workspace be created?',
1213
placeholder: '.',
1314
defaultValue: '.',
@@ -17,7 +18,7 @@ export async function runInit(): Promise<void> {
1718
return;
1819
}
1920

20-
const fromSource = await p.text({
21+
const fromSource = await text({
2122
message: 'Template source (leave empty for default)',
2223
placeholder: 'GitHub URL, path, or leave empty',
2324
defaultValue: '',

src/cli/tui/actions/plugins.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { getWorkspaceStatus } from '../../../core/status.js';
1515
import type { TuiContext } from '../context.js';
1616
import type { TuiCache } from '../cache.js';
17+
import { select, multiselect, text, confirm } from '../prompts.js';
1718

1819
/**
1920
* Get marketplace list, using cache when available.
@@ -56,7 +57,7 @@ async function installSelectedPlugin(
5657
// Determine scope
5758
let scope: 'project' | 'user' = 'user';
5859
if (context.hasWorkspace) {
59-
const scopeChoice = await p.select({
60+
const scopeChoice = await select({
6061
message: 'Install scope',
6162
options: [
6263
{ label: 'Project (this workspace)', value: 'project' as const },
@@ -144,7 +145,7 @@ export async function runInstallPlugin(context: TuiContext, cache?: TuiCache): P
144145
return;
145146
}
146147

147-
const selected = await p.select({
148+
const selected = await select({
148149
message: 'Select a plugin to install',
149150
options: allPlugins,
150151
});
@@ -178,7 +179,7 @@ export async function runManagePlugins(context: TuiContext, cache?: TuiCache): P
178179
value: plugin.source,
179180
}));
180181

181-
const selected = await p.multiselect({
182+
const selected = await multiselect({
182183
message: 'Select plugins to remove',
183184
options,
184185
required: false,
@@ -196,7 +197,7 @@ export async function runManagePlugins(context: TuiContext, cache?: TuiCache): P
196197
// Determine scope
197198
let scope: 'project' | 'user' = context.hasWorkspace ? 'project' : 'user';
198199
if (context.hasWorkspace) {
199-
const scopeChoice = await p.select({
200+
const scopeChoice = await select({
200201
message: 'Remove from which scope?',
201202
options: [
202203
{ label: 'Project (this workspace)', value: 'project' as const },
@@ -266,7 +267,7 @@ export async function runBrowseMarketplaces(
266267
{ label: 'Back', value: '__back__' },
267268
];
268269

269-
const selected = await p.select({
270+
const selected = await select({
270271
message: 'Marketplaces',
271272
options,
272273
});
@@ -276,7 +277,7 @@ export async function runBrowseMarketplaces(
276277
}
277278

278279
if (selected === '__add__') {
279-
const source = await p.text({
280+
const source = await text({
280281
message: 'Marketplace source (GitHub URL, owner/repo, or name)',
281282
placeholder: 'e.g., anthropics/claude-plugins-official',
282283
});
@@ -320,7 +321,7 @@ async function runMarketplaceDetail(
320321
cache?: TuiCache,
321322
): Promise<void> {
322323
while (true) {
323-
const action = await p.select({
324+
const action = await select({
324325
message: `Marketplace: ${marketplaceName}`,
325326
options: [
326327
{ label: 'Browse plugins', value: 'browse' as const },
@@ -352,7 +353,7 @@ async function runMarketplaceDetail(
352353
});
353354
pluginOptions.push({ label: 'Back', value: '__back__' });
354355

355-
const selectedPlugin = await p.select({
356+
const selectedPlugin = await select({
356357
message: 'Select a plugin to install',
357358
options: pluginOptions,
358359
});
@@ -394,7 +395,7 @@ async function runMarketplaceDetail(
394395
}
395396

396397
if (action === 'remove') {
397-
const confirmed = await p.confirm({
398+
const confirmed = await confirm({
398399
message: `Remove marketplace "${marketplaceName}"?`,
399400
});
400401

src/cli/tui/actions/update.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import * as p from '@clack/prompts';
22
import { execa } from 'execa';
3+
import { confirm } from '../prompts.js';
34

45
/**
56
* Self-update action.
67
* Detects the package manager and runs a global update for allagents.
78
*/
89
export async function runUpdate(): Promise<void> {
910
try {
10-
const confirmed = await p.confirm({
11+
const confirmed = await confirm({
1112
message: 'Check for and install updates?',
1213
});
1314

src/cli/tui/prompts.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as p from '@clack/prompts';
2+
import { cursor, erase } from 'sisteransi';
3+
4+
/**
5+
* Erase the cancelled prompt output from the terminal.
6+
*
7+
* When @clack/prompts renders a cancel state, it shows the prompt message
8+
* with the selected value crossed out (strikethrough). This is confusing
9+
* because pressing ESC should cleanly return to the previous menu.
10+
*
11+
* This function moves the cursor up past the cancelled prompt's rendered
12+
* lines and erases them, so the user sees a clean return.
13+
*/
14+
function eraseCancelledPrompt(lines = 2): void {
15+
process.stdout.write(cursor.move(0, -lines) + erase.down());
16+
}
17+
18+
type SelectOptions<T> = Parameters<typeof p.select<T>>[0];
19+
type MultiselectOptions<T> = Parameters<typeof p.multiselect<T>>[0];
20+
type TextOptions = Parameters<typeof p.text>[0];
21+
type ConfirmOptions = Parameters<typeof p.confirm>[0];
22+
23+
/**
24+
* Wrapper around p.select that erases the strikethrough on cancel.
25+
*/
26+
export async function select<T>(opts: SelectOptions<T>): Promise<T | symbol> {
27+
const result = await p.select<T>(opts);
28+
if (p.isCancel(result)) {
29+
eraseCancelledPrompt();
30+
}
31+
return result;
32+
}
33+
34+
/**
35+
* Wrapper around p.multiselect that erases the strikethrough on cancel.
36+
*/
37+
export async function multiselect<T>(
38+
opts: MultiselectOptions<T>,
39+
): Promise<T[] | symbol> {
40+
const result = await p.multiselect<T>(opts);
41+
if (p.isCancel(result)) {
42+
eraseCancelledPrompt();
43+
}
44+
return result;
45+
}
46+
47+
/**
48+
* Wrapper around p.text that erases the strikethrough on cancel.
49+
*/
50+
export async function text(opts: TextOptions): Promise<string | symbol> {
51+
const result = await p.text(opts);
52+
if (p.isCancel(result)) {
53+
eraseCancelledPrompt();
54+
}
55+
return result;
56+
}
57+
58+
/**
59+
* Wrapper around p.confirm that erases the strikethrough on cancel.
60+
*/
61+
export async function confirm(opts: ConfirmOptions): Promise<boolean | symbol> {
62+
const result = await p.confirm(opts);
63+
if (p.isCancel(result)) {
64+
eraseCancelledPrompt();
65+
}
66+
return result;
67+
}

src/cli/tui/wizard.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { relative } from 'node:path';
44
import packageJson from '../../../package.json';
55
import { TuiCache } from './cache.js';
66
import { getTuiContext, type TuiContext } from './context.js';
7+
import { select } from './prompts.js';
78
import { runInit } from './actions/init.js';
89
import { runSync } from './actions/sync.js';
910
import { runStatus } from './actions/status.js';
@@ -99,7 +100,7 @@ export async function runWizard(): Promise<void> {
99100
while (true) {
100101
p.note(buildSummary(context), 'Workspace');
101102

102-
const action = await p.select<MenuAction>({
103+
const action = await select<MenuAction>({
103104
message: 'What would you like to do?',
104105
options: buildMenuOptions(context),
105106
});

tests/unit/cli/tui-prompts.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
2+
3+
// Mock @clack/prompts and sisteransi before importing the wrapper.
4+
// These mocks are scoped to this file — placed under tests/unit/cli/
5+
// to avoid leaking into src/cli/tui/__tests__/ where context.test.ts
6+
// imports real modules.
7+
const cancelSymbol = Symbol('clack:cancel');
8+
const mockSelect = mock(() => Promise.resolve('value' as unknown));
9+
const mockMultiselect = mock(() => Promise.resolve(['value'] as unknown));
10+
const mockText = mock(() => Promise.resolve('text' as unknown));
11+
const mockConfirm = mock(() => Promise.resolve(true as unknown));
12+
const mockIsCancel = mock((v: unknown) => v === cancelSymbol);
13+
14+
mock.module('@clack/prompts', () => ({
15+
select: mockSelect,
16+
multiselect: mockMultiselect,
17+
text: mockText,
18+
confirm: mockConfirm,
19+
isCancel: mockIsCancel,
20+
}));
21+
22+
const mockCursorMove = mock(() => '\x1b[MOVE]');
23+
const mockEraseDown = mock(() => '\x1b[ERASE]');
24+
mock.module('sisteransi', () => ({
25+
cursor: { move: mockCursorMove },
26+
erase: { down: mockEraseDown },
27+
}));
28+
29+
let stdoutWrites: string[] = [];
30+
const originalWrite = process.stdout.write;
31+
32+
const { select, multiselect, text, confirm } = await import(
33+
'../../../src/cli/tui/prompts.js'
34+
);
35+
36+
describe('TUI prompt wrappers', () => {
37+
beforeEach(() => {
38+
stdoutWrites = [];
39+
process.stdout.write = ((data: string) => {
40+
stdoutWrites.push(data);
41+
return true;
42+
}) as typeof process.stdout.write;
43+
});
44+
45+
afterEach(() => {
46+
process.stdout.write = originalWrite;
47+
mockSelect.mockReset();
48+
mockMultiselect.mockReset();
49+
mockText.mockReset();
50+
mockConfirm.mockReset();
51+
mockCursorMove.mockReset();
52+
mockEraseDown.mockReset();
53+
});
54+
55+
describe('select', () => {
56+
it('returns value without erasing when not cancelled', async () => {
57+
mockSelect.mockResolvedValueOnce('chosen');
58+
const result = await select({ message: 'Pick', options: [] });
59+
expect(result).toBe('chosen');
60+
expect(stdoutWrites).toHaveLength(0);
61+
});
62+
63+
it('erases prompt output when cancelled', async () => {
64+
mockSelect.mockResolvedValueOnce(cancelSymbol);
65+
const result = await select({ message: 'Pick', options: [] });
66+
expect(result).toBe(cancelSymbol);
67+
expect(mockCursorMove).toHaveBeenCalledWith(0, -2);
68+
expect(mockEraseDown).toHaveBeenCalled();
69+
expect(stdoutWrites.length).toBeGreaterThan(0);
70+
});
71+
});
72+
73+
describe('multiselect', () => {
74+
it('returns value without erasing when not cancelled', async () => {
75+
mockMultiselect.mockResolvedValueOnce(['a', 'b']);
76+
const result = await multiselect({ message: 'Pick', options: [] });
77+
expect(result).toEqual(['a', 'b']);
78+
expect(stdoutWrites).toHaveLength(0);
79+
});
80+
81+
it('erases prompt output when cancelled', async () => {
82+
mockMultiselect.mockResolvedValueOnce(cancelSymbol);
83+
const result = await multiselect({ message: 'Pick', options: [] });
84+
expect(result).toBe(cancelSymbol);
85+
expect(mockCursorMove).toHaveBeenCalledWith(0, -2);
86+
expect(mockEraseDown).toHaveBeenCalled();
87+
});
88+
});
89+
90+
describe('text', () => {
91+
it('returns value without erasing when not cancelled', async () => {
92+
mockText.mockResolvedValueOnce('hello');
93+
const result = await text({ message: 'Type' });
94+
expect(result).toBe('hello');
95+
expect(stdoutWrites).toHaveLength(0);
96+
});
97+
98+
it('erases prompt output when cancelled', async () => {
99+
mockText.mockResolvedValueOnce(cancelSymbol);
100+
const result = await text({ message: 'Type' });
101+
expect(result).toBe(cancelSymbol);
102+
expect(mockCursorMove).toHaveBeenCalledWith(0, -2);
103+
expect(mockEraseDown).toHaveBeenCalled();
104+
});
105+
});
106+
107+
describe('confirm', () => {
108+
it('returns value without erasing when not cancelled', async () => {
109+
mockConfirm.mockResolvedValueOnce(true);
110+
const result = await confirm({ message: 'Sure?' });
111+
expect(result).toBe(true);
112+
expect(stdoutWrites).toHaveLength(0);
113+
});
114+
115+
it('erases prompt output when cancelled', async () => {
116+
mockConfirm.mockResolvedValueOnce(cancelSymbol);
117+
const result = await confirm({ message: 'Sure?' });
118+
expect(result).toBe(cancelSymbol);
119+
expect(mockCursorMove).toHaveBeenCalledWith(0, -2);
120+
expect(mockEraseDown).toHaveBeenCalled();
121+
});
122+
});
123+
});

0 commit comments

Comments
 (0)