diff --git a/lua/composer/init.lua b/lua/composer/init.lua index 881a294..c53944e 100644 --- a/lua/composer/init.lua +++ b/lua/composer/init.lua @@ -770,13 +770,24 @@ end -- `key:Name` label; field-level modifier encodings stay parked with the -- input-pointer contract owner, so a flat label never matches a modified -- role (the explicit session commands stay the exact path). Returns the --- chord, the string "literal" for produced text (the caller inserts it), or --- nil for unmapped labels. +-- chord, the string "literal" for produced text (the caller inserts it, +-- except CR/LF below), or nil for unmapped labels. local function flat_key_shape(text) if type(text) ~= "string" or text == "" then return nil end if string.sub(text, 1, 4) ~= "key:" then + -- Headed Return arrives here, not as a label: both host capture entries + -- forward the produced `key.text` verbatim, and Return produces "\r" + -- (xkb/winit convention, the same byte the VT encoder emits for Enter). + -- A key-event CR/LF can only come from the Enter key itself (typing and + -- paste arrive as text/paste events, never key literals), so route it + -- through the bare Enter role for parity with the mock-chord and label + -- paths; otherwise custom enter-submits can never fire headed + -- (composer#21) and defaults silently bank a CR instead of a newline. + if text == "\r" or text == "\n" then + return { key = "enter", ctrl = false, alt = false, shift = false, super = false } + end return "literal" end local name = string.lower(string.sub(text, 5)) diff --git a/tests/scene-pump.test.ts b/tests/scene-pump.test.ts index 6cb8e5b..1b32f09 100644 --- a/tests/scene-pump.test.ts +++ b/tests/scene-pump.test.ts @@ -90,6 +90,41 @@ describe("production flat poll shape feeds the buffer", () => { expect(run.host.submittedFrames).toEqual([]); }); + test("flat CR/LF literals route through the Enter role (composer#21)", async () => { + const run = await scenePump({ environment: { VISUAL: "nvim" } }); + dispatch(run, OPEN_COMMAND); + injectFlat(run, { type: "key", text: "x" }); + await pump(run); + expect(await bufferText(run)).toBe("x"); + // Headed Return arrives as produced text "\r", not as a key:Enter + // label: under defaults it must newline exactly like the label path. + injectFlat(run, { type: "key", text: "\r" }); + await pump(run); + expect(await bufferText(run)).toBe("x\n"); + injectFlat(run, { type: "key", text: "\n" }); + await pump(run); + expect(await bufferText(run)).toBe("x\n\n"); + expect(await isOpen(run)).toBe(true); + expect(run.host.submittedFrames).toEqual([]); + }); + + test("flat CR literal submits under custom enter-submits (composer#21)", async () => { + const run = await scenePump({ + environment: { VISUAL: "nvim" }, + settings: { submit: "enter", newline: "shift+enter" }, + }); + dispatch(run, OPEN_COMMAND); + injectFlat(run, { type: "text", text: "CUSTOMSUB42" }); + await pump(run); + injectFlat(run, { type: "key", text: "\r" }); + await pump(run); + expect(await lastCode(run)).toBe("SUBMITTED"); + expect(await isOpen(run)).toBe(false); + expect(await bufferText(run)).toBe(""); + expect(run.host.submittedFrames).toHaveLength(1); + expect(run.host.submittedFrames[0]).toEqual(expectedFrame("CUSTOMSUB42")); + }); + test("flat key literals insert; key:Enter newlines; other labels swallow", async () => { const run = await scenePump({ environment: { VISUAL: "nvim" } }); dispatch(run, OPEN_COMMAND);