From 73e0366121ae7e28261e658d31c49ab3a8483b65 Mon Sep 17 00:00:00 2001 From: re2zero Date: Wed, 19 Aug 2026 02:33:40 +0800 Subject: [PATCH] fix(tui): treat bare LF as Shift+Enter in the /tree selector The composer accepts three encodings for Shift+Enter (kitty CSI-u, the legacy \x1b[13;2~ form, and a bare LF from the iTerm2 mapping e.g. Claude Code's /terminal-setup). The /tree selector only handled the kitty form and silently routed a bare LF into the plain-Enter branch, so summarize-and- switch never fired for those terminals. Mirror the composer: fall through a bare LF to summarize-and-switch while plain CR (or the decoded Enter key) still does a plain switch. Fixes #8821 --- .../src/modes/components/tree-selector.ts | 8 +- .../tree-selector-shift-enter-8821.test.ts | 89 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/modes/components/tree-selector-shift-enter-8821.test.ts diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index d83150eef..c3f88d959 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -849,13 +849,17 @@ class TreeList implements Component { this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines); } else if (matchesSelectPageDown(keyData) || matchesKey(keyData, "right")) { this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines); - } else if (matchesKey(keyData, "shift+enter") || matchesKey(keyData, "shift+return")) { + } else if ( + matchesKey(keyData, "shift+enter") || + matchesKey(keyData, "shift+return") || + keyData === "\n" // Shift+Enter delivered as bare LF (iTerm2 legacy mapping) — matches the composer (issue #8821) + ) { // Summarize-and-switch: fork with a branch summary without the extra prompt. const selected = this.#filteredNodes[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id, { summarize: true }); } - } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { + } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) { const selected = this.#filteredNodes[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id, { summarize: false }); diff --git a/packages/coding-agent/test/modes/components/tree-selector-shift-enter-8821.test.ts b/packages/coding-agent/test/modes/components/tree-selector-shift-enter-8821.test.ts new file mode 100644 index 000000000..70bd6a12a --- /dev/null +++ b/packages/coding-agent/test/modes/components/tree-selector-shift-enter-8821.test.ts @@ -0,0 +1,89 @@ +import { beforeAll, describe, expect, it } from "bun:test"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector"; +import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { SessionEntry, SessionTreeNode } from "@oh-my-pi/pi-coding-agent/session/session-entries"; + +// Issue #8821 — a terminal that delivers Shift+Enter as a bare LF (iTerm2 +// legacy mapping, e.g. Claude Code's /terminal-setup) must not have the key +// reinterpreted as plain Enter inside the /tree selector. The composer already +// treats `\n` as Shift+Enter; the selector must match so summarize-and-switch +// still fires. + +function node(entry: SessionEntry): SessionTreeNode { + return { entry, children: [] }; +} + +function chain(entries: SessionEntry[]): SessionTreeNode { + const [head, ...rest] = entries.map(node); + let tail = head as SessionTreeNode; + for (const next of rest) { + tail.children.push(next); + tail = next; + } + return head as SessionTreeNode; +} + +const base = (id: string, parentId: string | null) => ({ id, parentId, timestamp: "2026-01-01T00:00:00.000Z" }); + +const userEntry: SessionEntry = { + ...base("u1", null), + type: "message", + message: { role: "user", content: "start", timestamp: 0 } as AgentMessage, +} as SessionEntry; + +const responseEntry: SessionEntry = { + ...base("r1", "u1"), + type: "message", + message: { role: "assistant", content: "response", timestamp: 1 } as unknown as AgentMessage, +} as SessionEntry; + +interface SelectRecord { + entryId: string; + options: { summarize: boolean }; +} + +function selectorWithOnSelect( + entries: SessionEntry[], + records: SelectRecord[], +): { selector: TreeSelectorComponent; onSelect: (id: string, o: { summarize: boolean }) => void } { + const onSelect = (entryId: string, options: { summarize: boolean }) => { + records.push({ entryId, options }); + }; + const selector = new TreeSelectorComponent([chain(entries)], entries.at(-1)?.id ?? null, 40, onSelect, () => {}); + return { selector, onSelect }; +} + +describe("tree selector Shift+Enter fallback (issue #8821)", () => { + beforeAll(async () => { + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + }); + + it("treats a bare LF as Shift+Enter (summarize-and-switch)", () => { + const records: SelectRecord[] = []; + const { selector } = selectorWithOnSelect([userEntry, responseEntry], records); + selector.handleInput("\n"); + expect(records).toEqual([{ entryId: responseEntry.id, options: { summarize: true } }]); + }); + + it("keeps plain CR as plain Enter (plain switch, no summary)", () => { + const records: SelectRecord[] = []; + const { selector } = selectorWithOnSelect([userEntry, responseEntry], records); + selector.handleInput("\r"); + expect(records).toEqual([{ entryId: responseEntry.id, options: { summarize: false } }]); + }); + + it("still recognizes the kitty CSI-u Shift+Enter encoding", () => { + const records: SelectRecord[] = []; + const { selector } = selectorWithOnSelect([userEntry, responseEntry], records); + selector.handleInput("\u001b[13;2u"); + expect(records).toEqual([{ entryId: responseEntry.id, options: { summarize: true } }]); + }); + + it("leaves the legacy CSI ~ form alone (parsed as shift+f3, not Enter)", () => { + const records: SelectRecord[] = []; + const { selector } = selectorWithOnSelect([userEntry, responseEntry], records); + selector.handleInput("\u001b[13;2~"); + expect(records).toEqual([]); + }); +});