| 1 | // Run: tsx src/__tests__/composer-keyboard.test.ts |
| 2 | // |
| 3 | // Tests for composer prompt-history keyboard gating. |
| 4 | |
| 5 | import { |
| 6 | canUsePromptHistory, |
| 7 | composerEnterAction, |
| 8 | insertComposerNewline, |
| 9 | isFnKeyEvent, |
| 10 | promptHistoryDirectionFromEvent, |
| 11 | type PromptHistoryDirection, |
| 12 | } from "../lib/composerKeyboard"; |
| 13 | import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts"; |
| 14 | import type { ComposerInvocation } from "../lib/invocationDisplay"; |
| 15 | |
| 16 | let passed = 0; |
| 17 | let failed = 0; |
| 18 | |
| 19 | function eq(a: unknown, b: unknown, label: string) { |
| 20 | if (a === b) { |
| 21 | process.stdout.write(` PASS ${label}\n`); |
| 22 | passed += 1; |
| 23 | } else { |
| 24 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 25 | failed += 1; |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | function eligible( |
| 30 | direction: PromptHistoryDirection | null, |
| 31 | overrides: Partial<Parameters<typeof canUsePromptHistory>[0]> = {}, |
| 32 | ) { |
| 33 | return canUsePromptHistory({ |
| 34 | direction, |
| 35 | menuOpen: false, |
| 36 | composing: false, |
| 37 | altKey: false, |
| 38 | ctrlKey: false, |
| 39 | metaKey: false, |
| 40 | shiftKey: false, |
| 41 | fnKey: false, |
| 42 | value: "", |
| 43 | selectionStart: 0, |
| 44 | selectionEnd: 0, |
| 45 | historyIndex: -1, |
| 46 | ...overrides, |
| 47 | }); |
| 48 | } |
| 49 | |
| 50 | console.log("\ncomposerKeyboard"); |
| 51 | |
| 52 | eq(promptHistoryDirectionFromEvent({ key: "ArrowUp" }), "up", "ArrowUp maps to history up"); |
| 53 | eq(promptHistoryDirectionFromEvent({ key: "ArrowDown" }), "down", "ArrowDown maps to history down"); |
| 54 | eq(promptHistoryDirectionFromEvent({ key: "PageUp", code: "PageUp", keyCode: 33 }), null, "PageUp does not map to history"); |
| 55 | eq(promptHistoryDirectionFromEvent({ key: "Home", code: "Home", keyCode: 36 }), null, "Home does not map to history"); |
| 56 | eq(promptHistoryDirectionFromEvent({ key: "Unidentified", keyCode: 38 }), "up", "legacy unidentified keyCode 38 maps up"); |
| 57 | |
| 58 | eq(isFnKeyEvent({ key: "Fn" }), true, "Fn key is detected"); |
| 59 | eq(isFnKeyEvent({ key: "ArrowUp", getModifierState: (key) => key === "Fn" }), true, "Fn modifier is detected"); |
| 60 | |
| 61 | eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0 }), true, "empty input ArrowUp can recall history"); |
| 62 | eq(eligible("down", { value: "", selectionStart: 0, selectionEnd: 0 }), false, "empty input ArrowDown does not start history"); |
| 63 | eq(eligible("up", { value: "draft", selectionStart: 5, selectionEnd: 5 }), false, "ArrowUp at draft end keeps native textarea movement"); |
| 64 | eq(eligible("up", { value: "draft", selectionStart: 0, selectionEnd: 0 }), true, "ArrowUp at first position recalls history"); |
| 65 | eq(eligible("down", { value: "history", selectionStart: 7, selectionEnd: 7, historyIndex: 0 }), true, "ArrowDown at recalled history end moves newer"); |
| 66 | eq(eligible("down", { value: "history", selectionStart: 3, selectionEnd: 3, historyIndex: 0 }), false, "ArrowDown inside text keeps native movement"); |
| 67 | eq(eligible("up", { value: "line1\nline2", selectionStart: 7, selectionEnd: 7 }), false, "ArrowUp inside multiline text keeps native movement"); |
| 68 | eq(eligible("up", { value: "history", selectionStart: 7, selectionEnd: 7, historyIndex: 0 }), true, "history mode allows repeated ArrowUp"); |
| 69 | eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0, fnKey: true }), false, "Fn-modified arrows are not history shortcuts"); |
| 70 | eq(eligible("up", { value: "", selectionStart: 0, selectionEnd: 0, shiftKey: true }), false, "Shift+Arrow preserves selection behavior"); |
| 71 | |
| 72 | console.log("\ncomposerEnterAction"); |
| 73 | |
| 74 | resetCustomShortcuts(); |
| 75 | eq(composerEnterAction({ key: "Enter" }, "darwin"), "send", "plain Enter sends by default"); |
| 76 | eq(composerEnterAction({ key: "Enter", shiftKey: true }, "darwin"), "newline-native", "Shift+Enter keeps the native line break by default"); |
| 77 | eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "windows"), "send", "Ctrl+Enter keeps the legacy default send behavior"); |
| 78 | eq(composerEnterAction({ key: "Enter", metaKey: true }, "darwin"), "send", "Cmd+Enter keeps the legacy default send behavior"); |
| 79 | eq(composerEnterAction({ key: "Enter", altKey: true }, "linux"), "send", "Alt+Enter keeps the legacy default send behavior"); |
| 80 | eq(composerEnterAction({ key: "a" }, "darwin"), null, "non-Enter keys are ignored"); |
| 81 | |
| 82 | saveCustomShortcut("composer.newline", { key: "Enter", ctrl: true }); |
| 83 | eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "windows"), "newline-insert", "custom Ctrl+Enter inserts a newline manually"); |
| 84 | eq(composerEnterAction({ key: "Enter", shiftKey: true }, "windows"), "none", "Shift+Enter does nothing once the newline chord moved to Ctrl+Enter"); |
| 85 | eq(composerEnterAction({ key: "Enter" }, "windows"), "send", "plain Enter still sends with a custom newline chord"); |
| 86 | resetCustomShortcuts(); |
| 87 | |
| 88 | saveCustomShortcut("composer.send", { key: "Enter", ctrl: true }); |
| 89 | eq(composerEnterAction({ key: "Enter", ctrlKey: true }, "linux"), "send", "custom Ctrl+Enter sends (WeChat-style layout)"); |
| 90 | eq(composerEnterAction({ key: "Enter" }, "linux"), "newline-insert", "plain Enter breaks the line when the send chord moved to Ctrl+Enter"); |
| 91 | eq(composerEnterAction({ key: "Enter", shiftKey: true }, "linux"), "newline-native", "Shift+Enter still breaks the line in the WeChat-style layout"); |
| 92 | eq(composerEnterAction({ key: "Enter", altKey: true }, "linux"), "none", "a custom send chord disables legacy modified-Enter aliases"); |
| 93 | |
| 94 | resetCustomShortcuts(); |
| 95 | eq(composerEnterAction({ key: "Enter" }, "darwin"), "send", "reset restores plain-Enter send"); |
| 96 | eq(composerEnterAction({ key: "Enter", shiftKey: true }, "darwin"), "newline-native", "reset restores the Shift+Enter default"); |
| 97 | |
| 98 | const boundaryInvocations: ComposerInvocation[] = [ |
| 99 | { id: "first", offset: 0, command: { name: "first", description: "First skill", kind: "skill" } }, |
| 100 | { id: "second", offset: 0, command: { name: "second", description: "Second subagent", kind: "subagent" } }, |
| 101 | ]; |
| 102 | const newlineAfterFirst = insertComposerNewline("task", boundaryInvocations, { |
| 103 | start: 0, |
| 104 | end: 0, |
| 105 | afterInvocationId: "first", |
| 106 | }); |
| 107 | eq(newlineAfterFirst.text, "\ntask", "custom newline updates the composer text"); |
| 108 | eq( |
| 109 | JSON.stringify(newlineAfterFirst.invocations.map((invocation) => [invocation.id, invocation.offset])), |
| 110 | JSON.stringify([["first", 0], ["second", 1]]), |
| 111 | "custom newline stays after the invocation at the caret boundary", |
| 112 | ); |
| 113 | |
| 114 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 115 | if (failed > 0) process.exit(1); |
| 116 |