Skip to content

Commit f14744e

Browse files
committed
Fix multi-digit CLI shortcuts
1 parent 66ca97b commit f14744e

2 files changed

Lines changed: 81 additions & 7 deletions

File tree

‎scripts/orbit-cli-tests.mjs‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
suggestedProject,
1818
suggestedTopics,
1919
} from './orbit-cli-core.mjs';
20-
import { buildPublicationPreview, chooseTopics } from './orbit-cli.mjs';
20+
import { buildPublicationPreview, chooseTopics, numericShortcutDecision } from './orbit-cli.mjs';
2121
import { OrbitApiClient, OrbitApiError, STAGING_ORIGIN } from './orbit-live-client.mjs';
2222

2323
let assertions = 0;
@@ -30,6 +30,25 @@ check(normalizeAgentArgument('selene') === 'selene', 'Düz ajan argümanı çöz
3030
check(normalizeAgentArgument('@Selene') === 'selene', '@ ve büyük harfli ajan argümanı çözülemedi.');
3131
check(normalizeAgentArgument('unknown') === null, 'Bilinmeyen ajan kabul edildi.');
3232

33+
check(
34+
numericShortcutDecision('1', 11).state === 'ambiguous',
35+
'Çift haneli menüde ilk rakam erken seçime dönüştü.',
36+
);
37+
check(
38+
numericShortcutDecision('11', 11).state === 'complete'
39+
&& numericShortcutDecision('11', 11).index === 10,
40+
'Çift haneli hızlı seçim son seçeneği çözemedi.',
41+
);
42+
check(
43+
numericShortcutDecision('2', 11).state === 'complete'
44+
&& numericShortcutDecision('2', 11).index === 1,
45+
'Tek anlamlı tek haneli hızlı seçim bozuldu.',
46+
);
47+
check(
48+
numericShortcutDecision('1', 9).state === 'complete',
49+
'Tek haneli menü hızlı seçimi gereksiz yere bekliyor.',
50+
);
51+
3352
const summary = deriveSummary('Orbit için yerel ve güvenli bir terminal istemcisi kuruyoruz.\n\nİkinci paragraf.');
3453
check(summary === 'Orbit için yerel ve güvenli bir terminal istemcisi kuruyoruz.', 'Summary ilk anlamlı paragraftan türetilmedi.');
3554
check(deriveSummary('x '.repeat(200)).length <= 221, 'Summary üst sınırı aştı.');

‎scripts/orbit-cli.mjs‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ const color = {
4646

4747
class ExitOrbit extends Error {}
4848

49+
const NUMERIC_SHORTCUT_DELAY_MS = 700;
50+
51+
export function numericShortcutDecision(buffer, optionCount) {
52+
if (!/^[1-9]\d*$/.test(buffer) || !Number.isInteger(optionCount) || optionCount < 1) {
53+
return { state: 'invalid', index: null };
54+
}
55+
56+
const matches = Array.from({ length: optionCount }, (_, index) => String(index + 1))
57+
.filter((value) => value.startsWith(buffer));
58+
if (!matches.length) return { state: 'invalid', index: null };
59+
60+
const exactIndex = matches.includes(buffer) ? Number(buffer) - 1 : null;
61+
const hasLongerMatch = matches.some((value) => value.length > buffer.length);
62+
if (exactIndex !== null && !hasLongerMatch) return { state: 'complete', index: exactIndex };
63+
return { state: exactIndex === null ? 'partial' : 'ambiguous', index: exactIndex };
64+
}
65+
4966
export class TerminalUI {
5067
constructor() {
5168
this.interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -97,34 +114,72 @@ export class TerminalUI {
97114

98115
readline.emitKeypressEvents(process.stdin);
99116
let selected = 0;
117+
let numericBuffer = '';
100118
const render = () => {
101119
this.clear();
102120
this.header(title);
103121
options.forEach((option, index) => {
104122
const active = index === selected;
105123
process.stdout.write(`${active ? `${color.violet}›${color.reset}` : ' '} ${index + 1} ${active ? color.bold : ''}${option.label}${color.reset}\n`);
106124
});
107-
process.stdout.write(`\n${color.dim}↑↓ gezin · Enter seç · numara hızlı seçim · Ctrl+C çıkış${color.reset}\n`);
125+
const numericStatus = numericBuffer ? ` · seçim: ${numericBuffer}` : '';
126+
process.stdout.write(`\n${color.dim}↑↓ gezin · Enter seç · numara hızlı seçim${numericStatus} · Ctrl+C çıkış${color.reset}\n`);
108127
};
109128

110129
return new Promise((resolve, reject) => {
111130
const previousRaw = process.stdin.isRaw;
131+
let numericTimer = null;
132+
let finished = false;
112133
process.stdin.setRawMode(true);
113134
process.stdin.resume();
135+
const clearNumericTimer = () => {
136+
if (numericTimer !== null) clearTimeout(numericTimer);
137+
numericTimer = null;
138+
};
114139
const finish = (callback) => {
140+
if (finished) return;
141+
finished = true;
142+
clearNumericTimer();
115143
process.stdin.off('keypress', onKeypress);
116144
process.stdin.setRawMode(previousRaw ?? false);
117145
process.stdin.pause();
118146
process.stdout.write('\x1b[?25h');
119147
callback();
120148
};
149+
const clearNumericInput = () => {
150+
clearNumericTimer();
151+
numericBuffer = '';
152+
};
153+
const resolveNumericInput = (decision) => finish(() => resolve(options[decision.index].value));
154+
const armNumericFallback = (decision) => {
155+
clearNumericTimer();
156+
if (decision.index === null) return;
157+
numericTimer = setTimeout(() => resolveNumericInput(decision), NUMERIC_SHORTCUT_DELAY_MS);
158+
};
121159
const onKeypress = (text, key = {}) => {
122160
if (key.ctrl && key.name === 'c') return finish(() => reject(new ExitOrbit()));
123-
if (key.name === 'up') selected = (selected - 1 + options.length) % options.length;
124-
else if (key.name === 'down') selected = (selected + 1) % options.length;
125-
else if (key.name === 'return') return finish(() => resolve(options[selected].value));
126-
else if (/^[1-9]$/.test(text) && options[Number(text) - 1]) {
127-
return finish(() => resolve(options[Number(text) - 1].value));
161+
if (key.name === 'up') {
162+
clearNumericInput();
163+
selected = (selected - 1 + options.length) % options.length;
164+
} else if (key.name === 'down') {
165+
clearNumericInput();
166+
selected = (selected + 1) % options.length;
167+
} else if (key.name === 'return') {
168+
const decision = numericShortcutDecision(numericBuffer, options.length);
169+
if (decision.index !== null) return resolveNumericInput(decision);
170+
return finish(() => resolve(options[selected].value));
171+
} else if (/^\d$/.test(text)) {
172+
clearNumericTimer();
173+
const decision = numericShortcutDecision(`${numericBuffer}${text}`, options.length);
174+
if (decision.state === 'invalid') {
175+
numericBuffer = '';
176+
process.stdout.write('\x07');
177+
} else {
178+
numericBuffer += text;
179+
if (decision.index !== null) selected = decision.index;
180+
if (decision.state === 'complete') return resolveNumericInput(decision);
181+
armNumericFallback(decision);
182+
}
128183
} else return;
129184
render();
130185
};

0 commit comments

Comments
 (0)