From e8a2fa783e51f422ae68f2ecbac1582bb1ff8b9f Mon Sep 17 00:00:00 2001 From: DarkPhilosophy <19309990+DarkPhilosophy@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:27:13 +0300 Subject: [PATCH] feat(tui): add cursor-adjacent autocomplete popups Add the passive popup renderer plus opt-in slash-command, file mention, prompt action/reference, and emoji popup placement in one self-contained change based directly on upstream/main. Model mentions remain in the dedicated inline model picker PR. --- docs/settings.md | 7 + packages/coding-agent/CHANGELOG.md | 11 + .../src/config/settings-schema.ts | 34 ++ .../src/modes/controllers/input-controller.ts | 2 +- .../modes/controllers/selector-controller.ts | 12 + .../src/modes/interactive-mode.ts | 8 + .../test/command-suggestions-popup.test.ts | 277 +++++++++ .../test/welcome-history-resize.test.ts | 46 +- packages/tui/CHANGELOG.md | 5 + packages/tui/src/autocomplete.ts | 28 +- .../tui/src/chrome/transcript-container.ts | 7 +- packages/tui/src/components/editor.ts | 85 ++- packages/tui/src/components/select-list.ts | 11 +- packages/tui/src/prompt/composer.ts | 1 + .../src/prompt/prompt-action-autocomplete.ts | 12 +- packages/tui/src/terminal-capabilities.ts | 6 +- packages/tui/src/tui.ts | 312 ++++++++-- packages/tui/test/autocomplete.test.ts | 18 + packages/tui/test/cursor-overlay.test.ts | 565 ++++++++++++++++++ packages/tui/test/editor.test.ts | 96 ++- .../tui/test/github-ref-autocomplete.test.ts | 1 + packages/tui/test/image-budget.test.ts | 4 +- packages/tui/test/image-clip.test.ts | 6 +- .../test/prompt-action-autocomplete.test.ts | 3 + .../test/resize-multiplexer-anchor.test.ts | 17 + .../tui/test/transcript-container.test.ts | 15 +- 26 files changed, 1492 insertions(+), 97 deletions(-) create mode 100644 packages/coding-agent/test/command-suggestions-popup.test.ts create mode 100644 packages/tui/test/cursor-overlay.test.ts diff --git a/docs/settings.md b/docs/settings.md index 698660d50f5..fc8d95c2f53 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -750,9 +750,16 @@ tui: | `images.blockImages` | boolean | `false` | Never send images to providers. | | `tui.hyperlinks` | enum | `auto` | `off`, `auto`, `always`. | | `tui.mouse` | boolean | `false` | Capture mouse clicks in the main session so live subagent cards and HUD rows focus on click, with a hover highlight on the target. Native text selection becomes Shift+drag and wheel scroll becomes Shift+wheel while on. | +| `display.commandSuggestionsPopup` | boolean | `false` | Show slash-command and argument suggestions in a bordered popup without adding conversation rows. Under Appearance → Display. Native selection and scrolling remain unchanged. | +| `display.autocompleteSuggestionsPopup` | boolean | `false` | Show `@` file mentions, `#` prompt actions/references, and `:` emoji suggestions in the bordered popup without moving conversation rows. Model mentions remain controlled by `display.inlineModelPicker`. | +| `display.popupFill` | boolean | `false` | Opt in to the message-colored background fill for bordered command and argument suggestion popups. | | `display.pinnedAgents` | enum | `collapsed` | Pinned live-agent jump list above the editor: `off` hides it, `collapsed` shows a few rows with an expander, `full` lists all. | | `tui.resizeScrollback` | enum | `rebuild` | How a settled width resize refreshes transcript rows kept in terminal scrollback: `append` replays the transcript at the new width below retained history, `rebuild` erases pane scrollback then replays one current-width copy, `preserve` repaints only the viewport. | +Command popup interactions do not rebuild scrollback. After a resize, popup backing that remains addressable is restored in place. If covered rows have left the viewport or reflow has changed their physical extent, recovery rebuilds the application's complete history. This exceptional recovery also applies in `preserve`/`append` mode and can remove pre-existing shell or pane scrollback that the application does not own. + +Popup fill is intentionally opt-in. With fill disabled, Kitty images can remain visible through unfilled popup cells; enable `display.popupFill` when opaque image occlusion is needed. Popup interaction never deletes image placements, including through tmux: deleting a partially archived placement would also remove its scrollback cells. A terminal-default background reset alone does not hide Kitty images. + For a custom status line, set `statusLine.preset: custom` and configure `statusLine.leftSegments`, `statusLine.rightSegments`, and `statusLine.segmentOptions`. Include `status` in either segment list to render extension statuses registered through `ctx.ui.setStatus()`, ordered by key and joined inline. Set `statusLine.showHookStatus: false` to suppress the same statuses in the footer. The `cost` segment shows recorded session costs. For an active provider/model with scheduled pricing, it appends `↑` during peak hours or `↓` off-peak, refreshing at boundaries even while idle. The arrow reflects the current tariff, not past spending; flat-price models and explicit cost overrides have no arrow. See [usage costs and time-based pricing](models.md#usage-costs-and-time-based-pricing) for the UTC schedule and estimation semantics. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e397862873d..8a36bb4918a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an optional Autocomplete Suggestions Popup for `@` file mentions, `#` actions/references, and `:` emoji suggestions without moving the chat ([#12671](https://github.com/can1357/oh-my-pi/pull/12671) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)). + ## [18.2.7] - 2026-09-21 ### Breaking Changes @@ -610,6 +614,13 @@ - Browser startup reuses a successful system-Chrome fallback instead of retrying an unavailable download during the same open. - Browser clicks and other interactions no longer stall when OMP-owned tabs are in the background, including after worker timeout recovery. +### Added + +- Added an opt-in Popup Background Fill setting; command popups keep their unfilled appearance by default ([#11946](https://github.com/can1357/oh-my-pi/pull/11946) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)). + +- Added an optional Command Suggestions Popup in Appearance → Display that keeps the chat stationary while suggestions open, filter, and close ([#11946](https://github.com/can1357/oh-my-pi/pull/11946) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)). + +### Changed ## [18.1.20] - 2026-09-13 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 4a582ea090c..8689dc56029 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1154,6 +1154,40 @@ export const SETTINGS_SCHEMA = { }, }, + "display.popupFill": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Popup Background Fill", + description: "Fill bordered command and argument suggestion popups with the message surface color", + }, + }, + + "display.commandSuggestionsPopup": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Command Suggestions Popup", + description: + "Show slash-command and argument suggestions in a bordered popup without moving the chat or changing native scrolling", + }, + }, + + "display.autocompleteSuggestionsPopup": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Autocomplete Suggestions Popup", + description: "Show @, #, and : autocomplete suggestions in the bordered popup", + }, + }, + "display.shimmer": { type: "enum", values: ["classic", "kitt", "disabled"] as const, diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 596fe64c2c2..3a8a7898a65 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -754,7 +754,7 @@ export class InputController { // empty (resize transactions) or the row falls outside it: routing stale // spans would highlight or focus an unrelated agent from old rows. #viewportCandidates(screenRow: number): string[] { - const viewport = this.ctx.ui.getMutableViewport(); + const viewport = this.ctx.ui.getMutableViewport(screenRow); const local = screenRow - viewport.top; if (viewport.length === 0 || local < 0 || local >= viewport.length) return []; return this.ctx.resolveViewportClickCandidates(local); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 6c79091e2f0..b17a81ab814 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -661,6 +661,18 @@ export class SelectorController { this.ctx.eventController.refreshIdleCompactionTimer(); break; + case "display.popupFill": + this.ctx.editor.popupFill = value as boolean; + this.ctx.ui.requestRender(); + break; + case "display.commandSuggestionsPopup": + this.ctx.editor.commandSuggestionsPopup = value as boolean; + this.ctx.ui.requestRender(); + break; + case "display.autocompleteSuggestionsPopup": + this.ctx.editor.autocompleteSuggestionsPopup = value as boolean; + this.ctx.ui.requestRender(); + break; case "autocompleteMaxVisible": this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value)); break; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 7c24a8c5ef8..7f2828c916a 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1255,6 +1255,10 @@ export class InteractiveMode implements InteractiveModeContext { this.editor.setImeSafeCursorLayout(settings.get("tui.imeSafeCursor")); this.#applyVimMode(this.editor); this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible")); + this.editor.commandSuggestionsPopup = settings.get("display.commandSuggestionsPopup"); + this.editor.autocompleteSuggestionsPopup = settings.get("display.autocompleteSuggestionsPopup"); + this.editor.popupFill = settings.get("display.popupFill"); + this.editor.onAutocompleteRender = (render, offset, rows) => this.ui.setCursorOverlay(render, offset, rows); this.syncEditorSpelling(); this.editor.viewportRowsProvider = () => this.ui.terminal.rows; this.editor.onAutocompleteCancel = () => { @@ -5749,6 +5753,10 @@ export class InteractiveMode implements InteractiveModeContext { nextEditor.setImeSafeCursorLayout(this.settings.get("tui.imeSafeCursor")); this.#applyVimMode(nextEditor); nextEditor.setAutocompleteMaxVisible(this.settings.get("autocompleteMaxVisible")); + nextEditor.commandSuggestionsPopup = this.settings.get("display.commandSuggestionsPopup"); + nextEditor.autocompleteSuggestionsPopup = this.settings.get("display.autocompleteSuggestionsPopup"); + nextEditor.popupFill = this.settings.get("display.popupFill"); + nextEditor.onAutocompleteRender = (render, offset, rows) => this.ui.setCursorOverlay(render, offset, rows); nextEditor.setSpellingFeatures({ typoDetection: this.settings.get("spelling.typoDetection"), autocomplete: this.settings.get("spelling.autocomplete"), diff --git a/packages/coding-agent/test/command-suggestions-popup.test.ts b/packages/coding-agent/test/command-suggestions-popup.test.ts new file mode 100644 index 00000000000..2b1851c3237 --- /dev/null +++ b/packages/coding-agent/test/command-suggestions-popup.test.ts @@ -0,0 +1,277 @@ +import { afterEach, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { KeybindingsManager as AppKeybindingsManager } from "@oh-my-pi/pi-tui/app-keybindings"; +import { createPromptActionAutocompleteProvider } from "@oh-my-pi/pi-tui/prompt/prompt-action-autocomplete"; +import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui"; +import { TranscriptContainer } from "@oh-my-pi/pi-tui/chrome/transcript-container"; +import { Composer } from "@oh-my-pi/pi-tui/prompt/composer"; +import { encodeKittyPlacement } from "@oh-my-pi/pi-tui/terminal-capabilities"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal"; + +let composer: Composer | undefined; +afterEach(() => composer?.stop()); + +it.each(["", " "])("restores chat and history through popup filtering with prefix %j", async prefix => { + const terminal = new VirtualTerminal(60, 12); + composer = new Composer({ preferences: { quiet: true }, terminal }); + const transcript = new TranscriptContainer(); + const block = { + render: () => Array.from({ length: 40 }, (_, i) => `CHAT_${i + 1}`), + isTranscriptBlockFinalized: () => true, + }; + transcript.addChild(block); + composer.setRuntimeChildren([transcript, composer.editor]); + composer.editor.commandSuggestionsPopup = true; + composer.editor.onAutocompleteRender = (render, offset, rows) => composer!.ui.setCursorOverlay(render, offset, rows); + composer.editor.setAutocompleteProvider( + new CombinedAutocompleteProvider(Array.from({ length: 12 }, (_, i) => ({ name: `command${i}` }))), + ); + composer.editor.onAutocompleteUpdate = () => composer!.ui.requestRender(); + composer.editor.onAutocompleteCancel = () => composer!.ui.requestRender(); + composer.start(); + composer.ui.setFocus(composer.editor); + const paint = async () => { + await Bun.sleep(40); + composer!.ui.requestRender(); + await terminal.waitForRender(); + }; + await paint(); + await paint(); + const history = () => terminal.getScrollBuffer().slice(0, -terminal.rows); + const beforeHistory = history(); + const before = terminal.getViewport().map(Bun.stripANSI); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = data => { + writes.push(data); + write(data); + }; + composer.editor.handleInput(prefix); + for (const input of ["/", "command1", "\x7f", "\x1b"]) { + composer.editor.handleInput(input); + await paint(); + expect(history()).toEqual(beforeHistory); + if (input === "/") expect(terminal.getViewport().join("\n")).toContain("command0"); + } + composer.editor.setText(""); + await paint(); + expect(terminal.getViewport().map(Bun.stripANSI)).toEqual(before); + expect(writes.join("")).not.toMatch(/\x1b\[(?:2|3)J|\x1b\[\?1049h|\x1b\[\?1003h/); +}); + +it.each([false, true])("applies popup background only when fill is enabled (%s), retaining image data", async fill => { + const terminal = new VirtualTerminal(40, 12); + composer = new Composer({ preferences: { quiet: true }, terminal }); + const placement = encodeKittyPlacement({ imageId: 713, placementId: 713, columns: 40, rows: 8 }); + const image = { render: () => [...Array(7).fill(""), "\x1b7\x1b[7A" + placement + "\x1b8"] }; + composer.setRuntimeChildren([image, composer.editor]); + composer.editor.commandSuggestionsPopup = true; + composer.editor.popupFill = fill; + composer.editor.onAutocompleteRender = (render, offset, rows) => composer!.ui.setCursorOverlay(render, offset, rows); + composer.editor.setAutocompleteProvider( + new CombinedAutocompleteProvider(Array.from({ length: 12 }, (_, i) => ({ name: `command${i}` }))), + ); + composer.editor.onAutocompleteUpdate = () => composer!.ui.requestRender(); + composer.editor.onAutocompleteCancel = () => composer!.ui.requestRender(); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = data => { + writes.push(data); + write(data); + }; + composer.start(); + composer.ui.setFocus(composer.editor); + await terminal.waitForRender(); + expect(writes.join("")).toMatch(/\x1b_Ga=p,[^\x1b]*i=713,[^\x1b]*z=-2147483648\x1b\\/); + writes.length = 0; + composer.editor.handleInput("/"); + await Bun.sleep(40); + composer.ui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getViewport().join("\n")).toContain("command0"); + // Background styling is opt-in and must never delete transcript graphics. + for (let row = 0; row < 8; row++) { + if (fill) + expect(terminal.getViewportRowBackgroundColumns(row)).toEqual(Array.from({ length: 40 }, (_, col) => col)); + else if (row === 0) expect(terminal.getViewportRowBackgroundColumns(row)).toEqual([]); + } + expect(writes.join("")).not.toMatch(/\x1b_Ga=d,/); + writes.length = 0; + composer.editor.handleInput("\x1b"); + composer.ui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getViewport().join("\n")).not.toContain("command0"); + expect(writes.join("")).toMatch(/\x1b_Ga=p,[^\x1b]*i=713,/); + expect(writes.join("")).not.toMatch(/\x1b_Ga=d,/); +}); + +it.each([ + { + command: "advisor", + values: ["on", "off", "status", "dump", "configure"], + filter: "o", + option: "off", + selected: "on", + }, + { command: "move", values: ["/tmp/one/", "/tmp/two/"], filter: "/tmp/", option: "/tmp/two/", selected: "/tmp/one/" }, +])( + "keeps $command arguments above the editor while filtering and accepting them", + async ({ command, values, filter, option, selected }) => { + const terminal = new VirtualTerminal(44, 18); + composer = new Composer({ preferences: { quiet: true }, terminal }); + const transcript = new TranscriptContainer(); + const block = { + render: () => Array.from({ length: 30 }, (_, i) => `CHAT_${i}`), + isTranscriptBlockFinalized: () => true, + }; + transcript.addChild(block); + composer.setRuntimeChildren([transcript, composer.editor, { render: () => ["BELOW_EDITOR"] }]); + composer.editor.commandSuggestionsPopup = true; + composer.editor.onAutocompleteRender = (render, offset, rows) => + composer!.ui.setCursorOverlay(render, offset, rows); + composer.editor.setAutocompleteProvider( + new CombinedAutocompleteProvider([ + { + name: command, + getArgumentCompletions: prefix => + values.filter(value => value.startsWith(prefix)).map(value => ({ value, label: value })), + }, + ]), + ); + composer.editor.onAutocompleteUpdate = () => composer!.ui.requestRender(); + composer.editor.onAutocompleteCancel = () => composer!.ui.requestRender(); + composer.start(); + composer.ui.setFocus(composer.editor); + const paint = async () => { + await Bun.sleep(150); + composer!.ui.requestRender(); + await terminal.waitForRender(); + }; + await paint(); + const history = terminal.getScrollBuffer().slice(0, -terminal.rows); + for (const input of [`/${command} `, filter]) { + composer.editor.handleInput(input); + await paint(); + const rows = terminal.getViewport().map(Bun.stripANSI); + const editorRow = rows.findIndex(row => row.includes(`/${command}`)); + const optionRow = rows.findIndex(row => row.includes(option)); + expect(optionRow).toBeGreaterThanOrEqual(0); + expect(optionRow).toBeLessThan(editorRow); + expect(rows.findIndex(row => row.includes("BELOW_EDITOR"))).toBeGreaterThan(editorRow); + expect(terminal.getScrollBuffer().slice(0, -terminal.rows)).toEqual(history); + } + composer.editor.handleInput("\t"); + await paint(); + expect(composer.editor.getText().trimEnd()).toBe(`/${command} ${selected}`); + composer.editor.handleInput("\x1b"); + await paint(); + expect(terminal.getViewport().join("\n")).not.toContain(option); + }, +); + +it.each([ + { emptyProvider: false, force: false }, + { emptyProvider: true, force: false }, + { emptyProvider: false, force: true }, +])("keeps fallback file arguments above the editor (%j)", async ({ emptyProvider, force }) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "popup-path-")); + try { + await fs.writeFile(path.join(directory, "candidate.txt"), ""); + if (force) await fs.writeFile(path.join(directory, "candidate2.txt"), ""); + const terminal = new VirtualTerminal(80, 18); + composer = new Composer({ preferences: { quiet: true }, terminal }); + const transcript = new TranscriptContainer(); + const block = { + render: () => Array.from({ length: 30 }, (_, i) => `CHAT_${i}`), + isTranscriptBlockFinalized: () => true, + }; + transcript.addChild(block); + composer.setRuntimeChildren([transcript, composer.editor]); + composer.editor.commandSuggestionsPopup = true; + composer.editor.onAutocompleteRender = (render, offset, rows) => + composer!.ui.setCursorOverlay(render, offset, rows); + composer.editor.setAutocompleteProvider( + new CombinedAutocompleteProvider([ + { + name: "review", + ...(emptyProvider ? { getArgumentCompletions: () => [] } : {}), + }, + ]), + ); + composer.editor.onAutocompleteUpdate = () => composer!.ui.requestRender(); + composer.start(); + composer.ui.setFocus(composer.editor); + composer.editor.handleInput(`/review ${directory}/cand`); + if (force) composer.editor.handleInput("\t"); + await Bun.sleep(200); + composer.ui.requestRender(); + await terminal.waitForRender(); + const rows = terminal.getViewport().map(Bun.stripANSI); + const optionRow = rows.findIndex(row => row.includes("candidate.txt")); + expect(optionRow).toBeGreaterThanOrEqual(0); + expect(optionRow).toBeLessThan(rows.findIndex(row => row.includes("/review"))); + composer.editor.handleInput("\t"); + expect(composer.editor.getText().trimEnd()).toBe(`/review ${directory}/candidate.txt`); + } finally { + composer?.stop(); + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +it.each([ + { input: "@cand", kind: "file mention" }, + { input: "#cop", kind: "prompt action" }, + { input: ":smi", kind: "emoji" }, +])("renders $kind completions above the editor without moving history", async ({ input }) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "popup-trigger-")); + try { + await fs.writeFile(path.join(directory, "candidate.txt"), ""); + const terminal = new VirtualTerminal(52, 16); + composer = new Composer({ preferences: { quiet: true }, terminal }); + const transcript = new TranscriptContainer(); + transcript.addChild({ render: () => Array.from({ length: 24 }, (_, index) => `CHAT_${index}`) }); + composer.setRuntimeChildren([transcript, composer.editor, { render: () => ["BELOW_EDITOR"] }]); + composer.editor.autocompleteSuggestionsPopup = true; + composer.editor.onAutocompleteRender = (render, offset, rows) => + composer!.ui.setCursorOverlay(render, offset, rows); + const provider = createPromptActionAutocompleteProvider({ + commands: [], + basePath: directory, + keybindings: AppKeybindingsManager.inMemory({}), + copyCurrentLine: () => {}, + copyPrompt: () => {}, + undo: () => {}, + moveCursorToMessageEnd: () => {}, + moveCursorToMessageStart: () => {}, + moveCursorToLineStart: () => {}, + moveCursorToLineEnd: () => {}, + }); + const expected = await provider.getSuggestions([input], 0, input.length); + expect(expected).not.toBeNull(); + const expectedLabel = expected!.items[0]!.label; + composer.editor.setAutocompleteProvider(provider); + composer.editor.onAutocompleteUpdate = () => composer!.ui.requestRender(); + composer.editor.onAutocompleteCancel = () => composer!.ui.requestRender(); + composer.start(); + composer.ui.setFocus(composer.editor); + await terminal.waitForRender(); + const beforeHistory = terminal.getScrollBuffer().slice(0, -terminal.rows); + composer.editor.handleInput(input); + await Bun.sleep(40); + composer.ui.requestRender(); + await terminal.waitForRender(); + const rows = terminal.getViewport().map(Bun.stripANSI); + const editorRow = rows.findLastIndex(row => row.includes(input)); + const normalizedLabel = expectedLabel.replace(/\s+/g, " "); + const optionRow = rows.findIndex(row => row.replace(/\s+/g, " ").includes(normalizedLabel)); + expect(optionRow).toBeGreaterThanOrEqual(0); + expect(optionRow).toBeLessThan(editorRow); + expect(rows.findIndex(row => row.includes("BELOW_EDITOR"))).toBeGreaterThan(editorRow); + expect(terminal.getScrollBuffer().slice(0, -terminal.rows)).toEqual(beforeHistory); + } finally { + composer?.stop(); + await fs.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/coding-agent/test/welcome-history-resize.test.ts b/packages/coding-agent/test/welcome-history-resize.test.ts index 980c8064690..2059f5f574b 100644 --- a/packages/coding-agent/test/welcome-history-resize.test.ts +++ b/packages/coding-agent/test/welcome-history-resize.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { TranscriptContainer } from "@oh-my-pi/pi-tui/chrome/transcript-container"; import { COMPOSER_DEFAULTS, Composer } from "@oh-my-pi/pi-tui/prompt/composer"; import { initTheme } from "@oh-my-pi/pi-tui/theme"; -import { type Component, Container, type RenderScheduler, visibleWidth } from "@oh-my-pi/pi-tui"; +import { CURSOR_MARKER, type Component, Container, type RenderScheduler, visibleWidth } from "@oh-my-pi/pi-tui"; import { Image } from "@oh-my-pi/pi-tui/components/image"; import { getKittyGraphics, setKittyGraphics } from "@oh-my-pi/pi-tui/kitty-graphics"; import { getCellDimensions, ImageProtocol, setCellDimensions, TERMINAL } from "@oh-my-pi/pi-tui/terminal-capabilities"; @@ -399,6 +399,50 @@ describe("composer welcome native-history resize", () => { expect(plainBuffer(terminal)).toContain("block-1@40"); }); + it("completes an empty replay and resumes the mutable viewport after popup damage", async () => { + const terminal = new VirtualTerminal(40, 8); + const scheduler = new VirtualRenderScheduler(); + const composer = new Composer({ + terminal, + tuiOptions: { renderScheduler: scheduler }, + preferences: { ...COMPOSER_DEFAULTS, quiet: true }, + }); + const transcript = new TranscriptContainer(); + const tail = { + status: "initial", + popup: true, + render() { + composer.ui.setCursorOverlay( + this.popup ? () => ["STALE POPUP", "STALE POPUP", "STALE POPUP", "STALE POPUP"] : undefined, + 0, + 1, + ); + return ["row1", "row2", "row3", "row4", "row5", `${CURSOR_MARKER}${this.status}`]; + }, + }; + composer.setRuntimeChildren([transcript, tail]); + composer.start({ playWelcomeIntro: false }); + await scheduler.settle(terminal); + composer.ui.requestRender(); + await scheduler.settle(terminal); + expect(plainBuffer(terminal).join("\n")).toContain("STALE POPUP"); + + // A resize while the overlay is painted forces the destructive replay + // path that must wait for even an empty replay acknowledgement. + terminal.resize(40, 4); + await scheduler.advance(terminal, 160); + + tail.status = "resumed"; + tail.popup = false; + composer.ui.requestRender(); + await scheduler.settle(terminal); + + const output = plainBuffer(terminal).join("\n"); + expect(output).not.toContain("STALE POPUP"); + expect(output).toContain("resumed"); + expect(composer.ui.getMutableViewport().length).toBeGreaterThan(0); + composer.ui.stop(); + }); describe("resumed session scrollback retirement and tool allocation", () => { it("flushes overflowing transcript blocks across frames and restores tool allocation after clearScrollback", async () => { const terminal = new TrackingTerminal(80, 10); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index baf309390ce..fb55c2fd53b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added passive cursor-adjacent popup rendering for command suggestions without allocating transcript rows ([#11946](https://github.com/can1357/oh-my-pi/pull/11946) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)). +- Added opt-in cursor-adjacent popup placement for non-command autocomplete triggers without allocating transcript rows ([#12671](https://github.com/can1357/oh-my-pi/pull/12671) by [@DarkPhilosophy](https://github.com/DarkPhilosophy)). + ## [18.2.7] - 2026-09-21 ### Breaking Changes diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 3878ec0eeb0..675fea0fcbf 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -214,6 +214,8 @@ export interface AutocompleteProvider { ): Promise<{ items: AutocompleteItem[]; prefix: string; // What we're matching against (e.g., "/" or "src/") + /** Suggestions supplied by a matched slash command's argument provider. */ + commandArgument?: boolean; } | null>; /** Apply the selected item and return new text + cursor position */ @@ -256,7 +258,7 @@ export interface AutocompleteProvider { cursorLine: number, cursorCol: number, signal?: AbortSignal, - ): Promise<{ items: AutocompleteItem[]; prefix: string } | null>; + ): Promise<{ items: AutocompleteItem[]; prefix: string; commandArgument?: boolean } | null>; /** Whether a Tab press should attempt file completion at the cursor. */ shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean; @@ -558,10 +560,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { cursorLine: number, cursorCol: number, signal?: AbortSignal, - ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { + ): Promise<{ items: AutocompleteItem[]; prefix: string; commandArgument?: boolean } | null> { if (signal?.aborted) return null; const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); + let commandArgument = false; const leadingSlashStart = findLeadingSlashCommandStart(textBeforeCursor); const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor); @@ -618,6 +621,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const argumentText = commandText.slice(spaceIndex + 1); // Text after space const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName)); + commandArgument = command !== undefined && (!("allowArgs" in command) || command.allowArgs !== false); if (command && "allowArgs" in command && command.allowArgs === false && !/\S/.test(argumentText)) { return null; } @@ -632,6 +636,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return { items: argumentSuggestions, prefix: argumentText, + commandArgument: true, }; } } @@ -651,7 +656,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (rawPrefix.length > 0 && this.#isOutsideCwd(rawPrefix)) { const items = await this.#getFileSuggestions(atPrefix); if (items.length === 0) return null; - return { items, prefix: atPrefix }; + return { items, prefix: atPrefix, ...(commandArgument ? { commandArgument: true } : {}) }; } const suggestions = rawPrefix.length > 0 @@ -660,13 +665,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (suggestions.length === 0 && rawPrefix.length > 0) { const fallback = await this.#getFileSuggestions(atPrefix); if (fallback.length === 0) return null; - return { items: fallback, prefix: atPrefix }; + return { items: fallback, prefix: atPrefix, ...(commandArgument ? { commandArgument: true } : {}) }; } if (suggestions.length === 0) return null; return { items: suggestions, prefix: atPrefix, + ...(commandArgument ? { commandArgument: true } : {}), }; } @@ -686,12 +692,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return { items: suggestions, prefix: pathMatch, + ...(commandArgument ? { commandArgument: true } : {}), }; } return { items: suggestions, prefix: pathMatch, + ...(commandArgument ? { commandArgument: true } : {}), }; } @@ -1157,10 +1165,19 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { cursorLine: number, cursorCol: number, signal?: AbortSignal, - ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { + ): Promise<{ items: AutocompleteItem[]; prefix: string; commandArgument?: boolean } | null> { if (signal?.aborted) return null; const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); + const commandName = /^\s*\/(\S+) /.exec(textBeforeCursor)?.[1]; + const commandArgument = + !lines.slice(0, cursorLine).some(line => line.trim() !== "") && + commandName !== undefined && + this.#commands.some( + command => + (!("allowArgs" in command) || command.allowArgs !== false) && + commandMatchesNameOrAlias(command, commandName), + ); // Don't trigger if we're typing a slash command at the start of the line if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) { @@ -1176,6 +1193,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return { items: suggestions, prefix: pathMatch, + ...(commandArgument ? { commandArgument: true } : {}), }; } diff --git a/packages/tui/src/chrome/transcript-container.ts b/packages/tui/src/chrome/transcript-container.ts index 029696c38f4..0df80252dbd 100644 --- a/packages/tui/src/chrome/transcript-container.ts +++ b/packages/tui/src/chrome/transcript-container.ts @@ -432,7 +432,9 @@ export class TranscriptContainer extends Container { if (!this.#replayPending) return undefined; const rows = this.#renderReplay(width); this.#replayPending = false; - if (rows.length === 0) return undefined; + // Even an empty ledger needs an acknowledged replay transaction: TUI + // uses its completion to release a destructive reset and restore the + // mutable viewport after a damaged popup. const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" }; this.#offered = { batch, kind: "replay" }; return batch; @@ -780,8 +782,7 @@ export class TranscriptContainer extends Container { } #startReplay(): void { - const head = this.#entries[this.#frontier]; - this.#replayPending = this.#frontier > 0 || (head?.mode === "appendOnly" && head.emitted > 0); + this.#replayPending = true; this.#replayRequested = false; } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index fb10df078c3..92bf3c88089 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -14,8 +14,10 @@ import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keyb import { extractPrintableText, matchesKey, parseKey } from "../keys"; import { KillRing } from "../kill-ring"; import type { SymbolTheme } from "../symbols"; -import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; +import { type Component, CURSOR_MARKER, type CursorOverlayRenderer, type Focusable } from "../tui"; +import { Box } from "./box"; import { + applyBackgroundToLine, getSegmenter, getWidthConfigEpoch, getWordNavKind, @@ -585,6 +587,7 @@ export class Editor implements Component, Focusable { | { line: number; startCol: number; endCol: number; original: string; cursorOffset: number } | undefined; #autocompletePrefix: string = ""; + #autocompleteCommandArgument = false; #autocompleteRequestId: number = 0; #autocompletePendingRequest: AutocompleteRequest | undefined; #autocompleteRequestRunning = false; @@ -592,6 +595,13 @@ export class Editor implements Component, Focusable { #autocompleteWaiters: Array<() => void> = []; #autocompleteMaxVisible: number = 10; onAutocompleteUpdate?: () => void; + /** Opt in to passive slash suggestions when a host supplies a popup renderer. */ + commandSuggestionsPopup = false; + /** Opt in to file mentions, prompt actions/references, and emoji in the same popup. */ + autocompleteSuggestionsPopup = false; + popupFill = false; + /** A frame host may paint suggestions over existing cells instead of allocating layout rows. */ + onAutocompleteRender?: (render: CursorOverlayRenderer | undefined, cursorOffset: number, editorRows: number) => void; /** Called after an async text-assist result mutates the document outside an input event, so hosts can schedule a repaint. */ onTextAssistApplied?: () => void; /** Terminal height source for clamping the autocomplete dropdown. Hosts wire this to their Terminal's rows. */ @@ -754,6 +764,33 @@ export class Editor implements Component, Focusable { return this.#autocompleteState !== null; } + #autocompleteBox = new Box(0, 0).setIgnoreTight(true); + + #renderAutocompleteOverlay: CursorOverlayRenderer = (width, maxRows) => { + if (!this.#autocompleteList || maxRows < 1) return []; + const framed = maxRows >= 3 && width >= 3; + this.#autocompleteList.setMaxVisible(Math.min(this.#autocompleteMaxVisible, maxRows - (framed ? 2 : 0)), true); + + if (!framed) { + return this.popupFill + ? this.#autocompleteList + .render(width) + .map(line => applyBackgroundToLine(line, width, this.#theme.surfaceColor ?? PASSTHROUGH_COLOR)) + : this.#autocompleteList.render(width); + } + this.#autocompleteBox.setBorder({ + chars: this.#theme.symbols.boxRound, + color: this.#theme.accentColor ?? this.borderColor, + }); + this.#autocompleteBox.clear(); + this.#autocompleteBox.addChild(this.#autocompleteList); + return this.popupFill + ? this.#autocompleteBox + .render(width) + .map(line => applyBackgroundToLine(line, width, this.#theme.surfaceColor ?? PASSTHROUGH_COLOR)) + : this.#autocompleteBox.render(width); + }; + /** * Get the available width for top border content given a total terminal width. * Accounts for the border characters and horizontal padding when visible. @@ -1469,15 +1506,35 @@ export class Editor implements Component, Focusable { // Add autocomplete list if active if (this.#autocompleteState && this.#autocompleteList) { - // Clamp the dropdown to the terminal viewport: the editor rows already - // rendered above plus a small reserve must stay visible. - const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24; - this.#autocompleteList.setMaxVisible( - Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)), - ); - const autocompleteResult = this.#autocompleteList.render(width); - result.push(...autocompleteResult); + const commandPopup = + this.commandSuggestionsPopup && + (this.#autocompleteCommandArgument || + (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null && !this.#selectedCompletionIsPath())); + const genericTrigger = this.#autocompletePrefix.trimStart()[0]; + const genericPopup = + this.autocompleteSuggestionsPopup && + !this.#autocompleteCommandArgument && + genericTrigger !== undefined && + "@#:".includes(genericTrigger); + if ((commandPopup || genericPopup) && this.onAutocompleteRender) { + this.onAutocompleteRender( + this.focused ? this.#renderAutocompleteOverlay : undefined, + Math.max( + 0, + result.findIndex(row => row.includes(CURSOR_MARKER)), + ), + result.length, + ); + } else { + this.onAutocompleteRender?.(undefined, 0, result.length); + const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24; + this.#autocompleteList.setMaxVisible( + Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)), + ); + result.push(...this.#autocompleteList.render(width)); + } } + if (!this.#autocompleteState || !this.#autocompleteList) this.onAutocompleteRender?.(undefined, 0, result.length); return result; } @@ -4007,7 +4064,10 @@ export class Editor implements Component, Focusable { prefix: string, items: Array<{ value: string; label: string; description?: string }>, ): SelectList { - const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : AUTOCOMPLETE_SELECT_LIST_LAYOUT; + const layout = + findLeadingSlashCommandStart(prefix) !== null + ? SLASH_COMMAND_SELECT_LIST_LAYOUT + : AUTOCOMPLETE_SELECT_LIST_LAYOUT; return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList, layout); } @@ -4062,6 +4122,7 @@ export class Editor implements Component, Focusable { if (replacements.endCol > line.length) return; const original = line.slice(replacements.startCol, replacements.endCol); this.#autocompletePrefix = original; + this.#autocompleteCommandArgument = false; this.#autocompleteList = this.#createAutocompleteList( original, replacements.items.map(value => ({ value, label: value })), @@ -4121,6 +4182,7 @@ export class Editor implements Component, Focusable { this.#autocompleteList = undefined; this.#textAssistReplacement = undefined; this.#autocompletePrefix = ""; + this.#autocompleteCommandArgument = false; if (notifyCancel && wasAutocompleting) { this.onAutocompleteCancel?.(); } @@ -4177,7 +4239,7 @@ export class Editor implements Component, Focusable { const lines = [...this.#state.lines]; const cursorLine = this.#state.cursorLine; const cursorCol = this.#state.cursorCol; - let suggestions: { items: AutocompleteItem[]; prefix: string } | null; + let suggestions: { items: AutocompleteItem[]; prefix: string; commandArgument?: boolean } | null; try { if (request.kind === "force") { const getForceFileSuggestions = provider.getForceFileSuggestions; @@ -4207,6 +4269,7 @@ export class Editor implements Component, Focusable { if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) { this.#autocompletePrefix = suggestions.prefix; + this.#autocompleteCommandArgument = suggestions.commandArgument === true; this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items); this.#autocompleteState = request.kind === "force" ? "force" : "regular"; this.onAutocompleteUpdate?.(); diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 04cf46fa2a1..31cfec2c914 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -149,6 +149,7 @@ export class SelectList implements Component, MouseRoutable { { sourceLabel: string; sourceDescription: string | undefined; label: string; description: string | undefined } >(); #maxVisible: number; + #maxVisibleIncludesStatus = false; #selection: MenuSelection; #hoveredIndex: number | null = null; /** Per-render map of 0-based output line → filtered-item index. */ @@ -188,8 +189,9 @@ export class SelectList implements Component, MouseRoutable { }; } - /** Refit the visible row budget (hosts clamp the list to available height). */ - setMaxVisible(rows: number): void { + /** Refit the row budget; bounded hosts may include the search status in it. */ + setMaxVisible(rows: number, includeSearchStatus = false): void { + this.#maxVisibleIncludesStatus = includeSearchStatus; this.#maxVisible = Math.max(1, Math.trunc(rows)); } @@ -249,7 +251,8 @@ export class SelectList implements Component, MouseRoutable { render(width: number): readonly string[] { const lines: string[] = []; this.#hitRows = []; - let showSearchStatus = this.#shouldRenderSearchStatus(); + let showSearchStatus = + this.#shouldRenderSearchStatus() && (!this.#maxVisibleIncludesStatus || this.#maxVisible > 1); // If no items match filter, distinguish an empty data set from no search matches. if (this.#selection.visibleItems.length === 0) { @@ -269,7 +272,7 @@ export class SelectList implements Component, MouseRoutable { const wrapEnabled = this.layout.wrapDescription === true; // `maxVisible` is the picker's visual row budget. For non-wrap layouts // every item is one row, so the budget matches the original item count. - const visualBudget = this.#maxVisible; + const visualBudget = this.#maxVisible - (this.#maxVisibleIncludesStatus && showSearchStatus ? 1 : 0); // Compute per-item visual row counts at the conservative width (i.e. // assume the scrollbar column might be reserved). For non-wrap layouts diff --git a/packages/tui/src/prompt/composer.ts b/packages/tui/src/prompt/composer.ts index dc9196245ba..cef517017ac 100644 --- a/packages/tui/src/prompt/composer.ts +++ b/packages/tui/src/prompt/composer.ts @@ -326,6 +326,7 @@ export class Composer implements TerminalFrameProvider { /** Compose the bounded mutable viewport and the next ordered history append. */ renderFrame(viewport: ViewportSize): TerminalFramePlan { if (!this.#started || this.#stopped) return { viewport: [] }; + this.ui.setCursorOverlay(undefined, 0, 0); const width = Math.max(1, viewport.columns); const rows = Math.max(0, viewport.rows); if (this.#resizeRetiredHeaderStart !== undefined) { diff --git a/packages/tui/src/prompt/prompt-action-autocomplete.ts b/packages/tui/src/prompt/prompt-action-autocomplete.ts index 2bb3bd7aa0b..c8422f0a889 100644 --- a/packages/tui/src/prompt/prompt-action-autocomplete.ts +++ b/packages/tui/src/prompt/prompt-action-autocomplete.ts @@ -135,7 +135,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { cursorLine: number, cursorCol: number, signal?: AbortSignal, - ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { + ): Promise<{ items: AutocompleteItem[]; prefix: string; commandArgument?: boolean } | null> { if (signal?.aborted) return null; const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); @@ -158,8 +158,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { // GitHub references and internal URLs while keeping prompt-action // tokens such as `#copy` literal. const githubRefSuggestions = getGithubRefSuggestions(textBeforeCursor); - if (githubRefSuggestions) return githubRefSuggestions; - return getInternalUrlSuggestions(textBeforeCursor, undefined, signal, this.#internalUrlCaller); + if (githubRefSuggestions) return { ...githubRefSuggestions, commandArgument: true }; + const internalSuggestions = await getInternalUrlSuggestions( + textBeforeCursor, + undefined, + signal, + this.#internalUrlCaller, + ); + return internalSuggestions ? { ...internalSuggestions, commandArgument: true } : null; } } diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index c42b55822da..6593503b657 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -945,6 +945,9 @@ export function encodeKittyPlacement(options: { if (options.placementId) params.push(`p=${options.placementId}`); if (options.columns) params.push(`c=${options.columns}`); if (options.rows) params.push(`r=${options.rows}`); + // Keep inline graphics below opaque UI cells without removing their data + // or placements from native scrollback (Kitty graphics stacking contract). + params.push("z=-2147483648"); return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`); } @@ -955,7 +958,7 @@ export function encodeKittyPlacement(options: { * not match (passthrough placements stay untouched). */ const KITTY_DIRECT_PLACEMENT_LINE = - /^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?\x1b\\(?:\x1b8)?$/; + /^(?:\x1b7(?:\x1b\[(\d+)A)?)?\x1b_Ga=p,q=2,C=1,i=(\d+)(?:,p=(\d+))?(?:,c=(\d+))?(?:,r=(\d+))?(?:,z=-2147483648)?\x1b\\(?:\x1b8)?$/; export interface ParsedKittyPlacementLine { imageId: number; @@ -1015,6 +1018,7 @@ export function encodeKittyPlacementLine(options: { const srcY = Math.floor((options.imageHeightPx * hiddenRows) / options.rows); params.push(`y=${srcY}`, `h=${Math.max(1, options.imageHeightPx - srcY)}`); } + params.push("z=-2147483648"); // No tmux passthrough: inside tmux the component's own line arrives // wrapped, never parses, and never reaches this rewrite. const apc = `\x1b_G${params.join(",")}\x1b\\`; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5b54b1d1ee5..e369ccb9dbc 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -141,6 +141,9 @@ export interface TUIOptions { renderScheduler?: RenderScheduler; onPaint?: (paint: TuiPaint) => void; } +/** Passive layer painted beside the editor, without allocating transcript rows. */ +export type CursorOverlayRenderer = (width: number, maxRows: number) => readonly string[]; + /** Physical terminal dimensions supplied to a frame provider. */ export interface ViewportSize { readonly columns: number; @@ -710,6 +713,17 @@ export class TUI extends Container { // Screen row where the provider's mutable viewport begins (0-based); rows // above it hold history still visible on the physical screen. #providerViewportTop = 0; + #cursorOverlayRender: CursorOverlayRenderer | undefined; + #cursorOverlayOffset = 0; + #cursorOverlayEditorRows = 0; + #cursorOverlayPlacement: "auto" | "above" = "auto"; + #cursorOverlayBacking: + | { top: number; rows: string[]; painted: readonly string[]; width?: number; height?: number } + | undefined; + /** Only the current physical screen, never native scrollback. */ + #providerScreen: readonly string[] = []; + /** Rows above this boundary are external history, not restorable blank cells. */ + #providerScreenKnownTop = 0; // Net composer-space offset of the published hit-test origin behind the // painted top, from the last paint: replay-replaced rows minus viewport // rows the paint prepended for a short viewport. Negative while prepended @@ -862,6 +876,7 @@ export class TUI extends Container { #ghosttyInitialImageDelayTimer: RenderTimer | undefined; #ghosttyImageReadyAtMs = 0; #clearScrollbackOnNextRender = false; + #clearScrollbackWaitsForReplay = false; // Consumed by the next frame: a user-driven redraw gesture (resetDisplay, // requestRender(true)) that must rewrite the viewport even when the diff // believes nothing changed. @@ -906,6 +921,8 @@ export class TUI extends Container { #resizeInPlaceActive = false; #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode(); #resizeReplaySize: string | undefined; + #cursorOverlayResizePending = false; + #cursorOverlayHistoryDamaged = false; // Holds an alternate-screen exit until its replacement full paint can emit it // atomically. It must survive a deferred Ghostty image frame. #pendingAltExit = ""; @@ -931,6 +948,25 @@ export class TUI extends Container { return mode === "append" || mode === "rebuild" || mode === "preserve" ? mode : "preserve"; } + /** + * Set a passive, non-focus-stealing layer for the current composed frame. + * The frame provider must support complete history replay: a resize can + * archive painted overlay cells before SIGWINCH reaches the application. + */ + setCursorOverlay( + render: CursorOverlayRenderer | undefined, + cursorOffset: number, + editorRows: number, + placement: "auto" | "above" = "auto", + ): void { + if (render && !this.#frameProvider?.beginHistoryReplay) { + throw new Error("Cursor overlays require a frame provider with beginHistoryReplay()"); + } + this.#cursorOverlayRender = render; + this.#cursorOverlayOffset = cursorOffset; + this.#cursorOverlayEditorRows = editorRows; + this.#cursorOverlayPlacement = placement; + } /** Install a listener for completed terminal paints. */ setPaintListener(listener: ((paint: TuiPaint) => void) | null): void { this.#paintListener = listener; @@ -940,6 +976,12 @@ export class TUI extends Container { setFrameProvider(provider: TerminalFrameProvider | undefined): void { this.#frameProvider = provider; this.#providerWindow = []; + this.#providerScreen = []; + this.#providerScreenKnownTop = 0; + this.#cursorOverlayRender = undefined; + this.#cursorOverlayBacking = undefined; + this.#cursorOverlayResizePending = false; + this.#cursorOverlayHistoryDamaged = false; this.#providerPreparedRows = []; this.#resizeReplaySize = undefined; this.requestRender(true); @@ -1163,13 +1205,21 @@ export class TUI extends Container { * The origin is in composer rows: a replay paint replaces leading composer * blanks with history rows and prepends blanks for a short viewport, so * the painted top is backed out by that net pad. + * With a hit-test row, returns an empty window only when that row is covered + * by the passive popup; visible targets elsewhere remain interactive. */ - getMutableViewport(): { top: number; length: number } { + getMutableViewport(screenRow?: number): { top: number; length: number } { if ( this.#altActive || this.#resizeAltActive || this.#resizeProbe !== undefined || this.#resizeInPlaceActive || + this.#cursorOverlayHistoryDamaged || + this.#clearScrollbackWaitsForReplay || + (screenRow !== undefined && + this.#cursorOverlayBacking !== undefined && + screenRow >= this.#cursorOverlayBacking.top && + screenRow < this.#cursorOverlayBacking.top + this.#cursorOverlayBacking.rows.length) || this.#ghosttyInitialImageDelayTimer !== undefined ) { return { top: 0, length: 0 }; @@ -1286,6 +1336,7 @@ export class TUI extends Container { return; } if (this.#altActive) { + this.#trackResizeBurst(); // A fullscreen overlay owns the alt buffer: repaint the modal at // the new size. Never snapshot the normal window or probe its // anchor against the alternate grid — not even for a toggle echo. @@ -1392,6 +1443,7 @@ export class TUI extends Container { * in-flight CPR tag so a rewrap-invalidated reply cannot anchor a new geometry. */ #trackResizeBurst(): void { + this.#cursorOverlayResizePending = this.#providerScreen.length > 0 || this.#cursorOverlayBacking !== undefined; const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight; if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true; this.#resizeBurstLastHeight = this.terminal.rows; @@ -1658,62 +1710,20 @@ export class TUI extends Container { reportedRow === undefined ? this.#providerViewportTop : reportedRow - this.#reflowedRowCount(probe.window, 0, probe.offset, width); - let top: number; - if (isInsideTerminalMultiplexer()) { - if (reportedRow !== undefined) { - // The parked cursor's reply is exact under multiplexer clipping: - // discards leave the cursor in place, pushes only occur after - // everything below it is discarded (the bottom row IS the - // attached position), and grow pull-down rides it down. It - // therefore also reflects intermediate geometries that SIGWINCH - // coalescing hid from the burst tracker, and always outranks the - // clip model. The `height - staleRows` bound must NOT apply here: - // it encodes bottom-preserving rewrap, but a multiplexer shrink - // may have discarded stale rows below the cursor instead of - // pushing the top ones. Frame-size clamping happens when the - // settled plan frame is emitted. - top = Math.max(0, reportedTop); - } else if (height < this.#previousHeight && !this.#resizeBurstGrew) { - // Last resort after the retry: model the clip deterministically - // from the saved parked cursor. Rows strictly below the cursor - // are discarded first (even non-blank ones — measured against - // real tmux), and only the remainder of the shrink pushes top - // rows into scrollback; across an observed burst the totals - // telescope from pre-burst state. SIGWINCH coalescing can hide a - // grow from this model, which is why a reply always wins above. - const parkedRow = this.#providerViewportTop + this.#reflowedRowCount(probe.window, 0, probe.offset, width); - const shrink = this.#previousHeight - height; - const discardedBelow = Math.min(shrink, Math.max(0, this.#previousHeight - 1 - parkedRow)); - const pushed = Math.max(0, shrink - discardedBelow); - top = Math.max(0, this.#providerViewportTop - pushed); - } else { - // CPR-less grow or reversed burst: the pre-resize top is - // stale-low, every grow step already pulled scrollback down. - // Anchor at the conservative upper bound — pull never exceeds - // the burst's accumulated growth, and pushes/discards only lower - // the top. Exact when scrollback covers the pull; when it does - // not, the repaint lands below the real viewport and leaves - // stale rows above rather than overwriting committed ones. - top = Math.max(0, this.#providerViewportTop + this.#resizeBurstPull); - } - } else { - // Direct terminals rewrap bottom-preserving: with `staleRows` stale - // rows on screen the viewport top cannot exceed `height - staleRows` - // whenever a push happened, so the bound reconstructs height-shrink - // pushes that leave the cursor behind (kitty clamps the cursor - // instead of scrolling it). A CPR-less grow is stale-low like the - // multiplexer case — grow pull-down moved the real viewport — so it - // anchors at the accumulated pull bound, still under the clamp. - const fallbackTop = - reportedRow === undefined && this.#resizeBurstGrew - ? this.#providerViewportTop + this.#resizeBurstPull - : reportedTop; - top = Math.max(0, Math.min(fallbackTop, height - staleRows)); - } + // CPR is exact for multiplexer clipping; only direct terminals apply + // the bottom-preserving bound. Shutdown shares the CPR-less fallback. + const top = + reportedRow === undefined + ? this.#fallbackResizeAnchor(probe.window, probe.offset, width, height) + : isInsideTerminalMultiplexer() + ? Math.max(0, reportedTop) + : Math.max(0, Math.min(reportedTop, height - staleRows)); if ($flag("PI_DEBUG_REDRAW")) { const msg = `[${new Date().toISOString()}] resize anchor: size=${width}x${height} cpr=${reportedRow ?? "timeout"} park=${probe.offset} stale=${staleRows} old=${this.#providerViewportTop} top=${top}\n`; fs.appendFileSync(getDebugLogPath(), msg); } + if (this.#cursorOverlayResizePending) + this.#remapCursorOverlayBacking(width, height, top, reportedRow !== undefined); this.#providerViewportTop = Math.min(top, Math.max(0, height - 1)); // Resolved geometry invalidates the replay offset with the old anchor; // the forced repaint recomputes it (usually zero). @@ -1722,6 +1732,23 @@ export class TUI extends Container { this.requestRender(true); } + #fallbackResizeAnchor(window: readonly string[], offset: number, width: number, height: number): number { + if (isInsideTerminalMultiplexer()) { + if (height < this.#previousHeight && !this.#resizeBurstGrew) { + // tmux discards rows below the parked cursor before pushing the top. + const parkedRow = this.#providerViewportTop + this.#reflowedRowCount(window, 0, offset, width); + const shrink = this.#previousHeight - height; + const discardedBelow = Math.min(shrink, Math.max(0, this.#previousHeight - 1 - parkedRow)); + return Math.max(0, this.#providerViewportTop - Math.max(0, shrink - discardedBelow)); + } + // A grow/reversed burst can pull history down; never anchor above it. + return Math.max(0, this.#providerViewportTop + this.#resizeBurstPull); + } + const staleRows = this.#reflowedRowCount(window, 0, window.length, width); + const top = this.#providerViewportTop + (this.#resizeBurstGrew ? this.#resizeBurstPull : 0); + return Math.max(0, Math.min(top, height - staleRows)); + } + /** * Rows `[start, end)` of a previously painted window re-measured at * `width`. Every terminal rewraps content on a width change — including @@ -1741,6 +1768,59 @@ export class TUI extends Container { return rows; } + /** Reconcile saved cells against the measured post-resize viewport anchor. */ + #remapCursorOverlayBacking(width: number, height: number, viewportTop: number, anchorKnown = true): void { + this.#cursorOverlayResizePending = false; + const backing = this.#cursorOverlayBacking; + if (!anchorKnown && (this.#resizeBurstGrew || width !== this.#previousWidth)) { + if (backing) { + this.#cursorOverlayHistoryDamaged = true; + } else { + // Unknown scrollback pull cannot be reconstructed without a cursor + // report. Only newly painted provider rows may back a later popup. + this.#providerScreen = []; + this.#providerScreenKnownTop = height; + } + return; + } + const paintedScreen = Array.from(this.#providerScreen); + if (backing) { + for (let index = 0; index < backing.painted.length; index++) { + paintedScreen[backing.top + index] = backing.painted[index]!; + } + } + const screenTop = viewportTop - this.#reflowedRowCount(paintedScreen, 0, this.#providerViewportTop, width); + const top = backing ? screenTop + this.#reflowedRowCount(paintedScreen, 0, backing.top, width) : 0; + const end = backing + ? top + this.#reflowedRowCount(paintedScreen, backing.top, backing.top + backing.rows.length, width) + : 0; + if (backing && (top < 0 || end > height)) { + this.#cursorOverlayHistoryDamaged = true; + return; + } + const screen = Array.from({ length: height }, () => ""); + let targetRow = screenTop; + for (let index = 0; index < paintedScreen.length; index++) { + const source = this.#providerScreen[index] ?? ""; + const count = this.#reflowedRowCount(paintedScreen, index, index + 1, width); + const covered = backing !== undefined && index >= backing.top && index < backing.top + backing.rows.length; + if (covered && this.#reflowedRowCount(this.#providerScreen, index, index + 1, width) !== count) { + this.#cursorOverlayHistoryDamaged = true; + return; + } + for (let part = 0; part < count; part++, targetRow++) { + if (targetRow < 0 || targetRow >= height) continue; + screen[targetRow] = count === 1 ? source : sliceByColumn(source, part * width, width); + } + } + this.#providerScreenKnownTop = Math.max( + 0, + Math.min(height, screenTop + this.#reflowedRowCount(paintedScreen, 0, this.#providerScreenKnownTop, width)), + ); + this.#providerScreen = screen; + if (backing) this.#cursorOverlayBacking = { top, rows: screen.slice(top, end), painted: [], width, height }; + } + /** Paint the full semantic tail on the borrowed resize buffer. */ #renderResizeAltFrame(width: number, height: number): void { const provider = this.#frameProvider; @@ -1931,11 +2011,16 @@ export class TUI extends Container { */ #flushHistoryBeforeStop(): void { const provider = this.#frameProvider; - if (provider?.beginHistoryFlush === undefined) return; + if (!provider || (!provider.beginHistoryFlush && !this.#cursorOverlayBacking)) return; const width = this.terminal.columns; const height = this.terminal.rows; if (width <= 0 || height <= 0) return; - provider.beginHistoryFlush(); + provider.beginHistoryFlush?.(); + // Flush normally cancels replay. Recover resize-damaged popup backing + // afterward, before any new-geometry output can discard the saved rows. + if (this.#cursorOverlayResizePending || this.#cursorOverlayHistoryDamaged) { + this.#prepareResizeReplay(width, height); + } while (true) { let plan: TerminalFramePlan; let viewport: string[]; @@ -1945,12 +2030,18 @@ export class TUI extends Container { viewport = Array.from(plan.viewport); if (viewport.length > height) viewport = viewport.slice(0, height); } while (this.#imageBudget.endPass()); - if (plan.history === undefined) return; + if (plan.history === undefined) { + if (this.#cursorOverlayBacking) { + this.#emitPlanFrame(width, height, viewport, undefined, provider, true); + } + return; + } const acceptedBefore = this.#acceptedHistoryBatchId; - this.#emitPlanFrame(width, height, viewport, plan.history, provider); + this.#emitPlanFrame(width, height, viewport, plan.history, provider, true); if (plan.history.id > acceptedBefore && this.#acceptedHistoryBatchId === acceptedBefore) { throw new Error("History flush did not accept the offered batch"); } + if (!provider.beginHistoryFlush && !this.#clearScrollbackOnNextRender) return; } } @@ -2005,6 +2096,10 @@ export class TUI extends Container { // ED3 with a complete-ledger replay. Running that pair during stop would // erase native history and re-stream the whole transcript at quit; drop // the latch so the flush below writes only un-retired rows. + // Popup recovery must survive a deferred frame and flush cancellation. + // Re-arm it after beginHistoryFlush, even when resize already consumed + // the original damage flag or an offered append has been acknowledged. + this.#cursorOverlayHistoryDamaged ||= this.#clearScrollbackWaitsForReplay; this.#clearScrollbackOnNextRender = false; this.#flushHistoryBeforeStop(); // Deliberately leave transmitted images in the terminal's graphics store: @@ -2139,6 +2234,7 @@ export class TUI extends Container { return true; } #prepareForcedRender(clearScrollback: boolean): void { + if (clearScrollback) this.#clearScrollbackWaitsForReplay = false; if (clearScrollback && !this.#clearScrollbackOnNextRender) { this.#frameProvider?.beginHistoryReplay?.(); } @@ -2680,6 +2776,20 @@ export class TUI extends Container { * the `widthChanged`-gated commit-ledger logic in {@link #doRender}. */ #prepareResizeReplay(width: number, height: number): void { + if (this.#cursorOverlayResizePending) { + // Shutdown may precede CPR; use the same terminal-specific fallback. + const window = this.#providerWindow.length > 0 ? this.#providerWindow : this.#resizeProbeWindow; + const offset = this.#resizeProbe?.offset ?? this.#parkedViewportOffset; + const top = this.#fallbackResizeAnchor(window, offset, width, height); + this.#remapCursorOverlayBacking(width, height, top, false); + this.#providerViewportTop = top; + } + if (this.#cursorOverlayHistoryDamaged) { + this.#cursorOverlayHistoryDamaged = false; + this.#prepareForcedRender(true); + this.#clearScrollbackWaitsForReplay = true; + return; + } const size = `${width}x${height}`; if ( !this.#hasEverRendered || @@ -2761,6 +2871,7 @@ export class TUI extends Container { viewportRows: string[], offered: HistoryBatch | undefined, provider: TerminalFrameProvider | undefined, + flushing = false, ): void { // Callers composite their overlays inside the budget pass, so `viewportRows` // is already the complete frame. Bound the store here rather than at @@ -2797,7 +2908,10 @@ export class TUI extends Container { // Destructive reset (session replace, /tree, explicit clear, or a settled // resize in rebuild mode): erase native history and the viewport, // then repaint from row zero. - const destructiveReset = this.#clearScrollbackOnNextRender; + // A provider may queue the complete replay behind an already offered + // append. Acknowledge that batch without consuming the paired clear. + const destructiveReset = + this.#clearScrollbackOnNextRender && (!this.#clearScrollbackWaitsForReplay || history?.kind === "replay"); if (destructiveReset) { this.#providerViewportTop = 0; this.#providerWindow = []; @@ -2810,6 +2924,10 @@ export class TUI extends Container { const geometryStable = this.#hasEverRendered && this.#previousWidth === width && this.#previousHeight === height; const startTop = destructiveReset ? 0 : Math.min(this.#providerViewportTop, Math.max(0, height - 1)); const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows)); + const knownTop = destructiveReset + ? 0 + : Math.min(startTop, this.#providerScreen.length > 0 ? this.#providerScreenKnownTop : startTop); + const nextKnownTop = Math.max(0, knownTop - Math.max(0, startTop + historyRows.length + rows - height)); const pendingAltExit = this.#pendingAltExit; let buffer = this.#paintBeginSequence + pendingAltExit; if (destructiveReset && TERMINAL.imageProtocol === ImageProtocol.Kitty) { @@ -2854,8 +2972,56 @@ export class TUI extends Container { !this.#forceViewportRepaintOnNextRender && !destructiveReset && this.#providerWindow.length > 0; + const marker = markers[0]; + const logicalEditorTop = newTop + (marker?.row ?? 0) - this.#cursorOverlayOffset; + const editorTop = Math.max(0, logicalEditorTop); + const editorBottom = Math.max(0, Math.min(height, logicalEditorTop + this.#cursorOverlayEditorRows)); + const safeAbove = Math.max(0, editorTop - nextKnownTop); + const below = height - editorBottom; + const above = this.#cursorOverlayPlacement === "above" || safeAbove >= below; + const available = above ? safeAbove : below; + const overlayPrepared = + marker && !flushing && !this.hasOverlay() && available > 0 + ? this.#prepareLinesArray(this.#cursorOverlayRender?.(width, available) ?? [], width) + : { lines: [], rows: [] }; + const overlayRows = overlayPrepared.lines; + const overlayCount = Math.min(overlayRows.length, available); + const overlayTop = above ? editorTop - overlayCount : editorBottom; + const previousOverlay = this.#cursorOverlayBacking; + const remappedBacking = previousOverlay?.width === width && previousOverlay?.height === height; + // A partial multicell overwrite destroys the whole glyph, not only the + // covered row. Restore its anchor and all reserved rows as one unit. + let restoredScaledBacking = false; + if (previousOverlay && (geometryStable || remappedBacking) && !destructiveReset) { + let restoreTop = previousOverlay.top; + let restoreEnd = restoreTop + previousOverlay.rows.length; + while (this.#osc66SpacerGlyphWidth(this.#providerScreen, restoreTop) >= 0) restoreTop--; + while (this.#osc66SpacerGlyphWidth(this.#providerScreen, restoreEnd) >= 0) restoreEnd++; + restoredScaledBacking = + restoreTop < previousOverlay.top || restoreEnd > previousOverlay.top + previousOverlay.rows.length; + // Restore physical cells before an append can scroll them into history. + for (let row = restoreTop; row < restoreEnd; row++) { + if (!restoredScaledBacking && diffable && row >= overlayTop && row < overlayTop + overlayCount) continue; + const restored = this.#prepareLine( + this.#providerScreen[row] ?? "", + width, + getWidthConfigEpoch(), + TERMINAL.imageProtocol, + ); + buffer += `\x1b[${row + 1};1H${this.#lineRewriteSequence( + restored, + width, + row, + -1, + -1, + this.#osc66SpacerGlyphWidth(this.#providerScreen, row), + )}`; + } + } + this.#cursorOverlayBacking = undefined; if (diffable) { for (let index = 0; index < rows; index++) { + if (newTop + index >= overlayTop && newTop + index < overlayTop + overlayCount) continue; const previous = this.#providerPreparedRows[index]; const current = prepared.rows[index]!; if ( @@ -2919,11 +3085,32 @@ export class TUI extends Container { const mutableTop = newTop + replayViewportRows; const mutablePreparedLines = replayViewportRows > 0 ? prepared.lines.slice(replayViewportRows) : prepared.lines; const mutablePreparedRows = replayViewportRows > 0 ? prepared.rows.slice(replayViewportRows) : prepared.rows; - const marker = markers[0]; const target = marker !== undefined && rows > 0 ? this.#targetHardwareCursorState({ row: newTop + Math.min(marker.row, rows - 1), col: marker.col }, height) : null; + const screenPrefix = destructiveReset ? [] : this.#providerScreen.slice(0, startTop); + while (screenPrefix.length < startTop) screenPrefix.push(""); + this.#providerScreen = [...screenPrefix, ...preparedHistory.lines.slice(-height), ...prepared.lines].slice( + -height, + ); + this.#providerScreenKnownTop = nextKnownTop; + if (overlayCount > 0) { + const covered: string[] = []; + for (let index = 0; index < overlayCount; index++) { + const row = overlayTop + index; + covered.push(this.#providerScreen[row] ?? ""); + if ( + diffable && + !restoredScaledBacking && + this.#providerWindow.length === rows && + previousOverlay?.painted[row - previousOverlay.top] === overlayRows[index] + ) + continue; + buffer += `\x1b[${row + 1};1H${this.#lineRewriteSequence(overlayPrepared.rows[index]!, width, row)}`; + } + this.#cursorOverlayBacking = { top: overlayTop, rows: covered, painted: overlayRows.slice(0, overlayCount) }; + } if (target) { buffer += `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`; this.#parkedViewportOffset = Math.max(0, target.row - mutableTop); @@ -2959,7 +3146,10 @@ export class TUI extends Container { this.#resizeBurstLastHeight = undefined; this.#resizeBurstPull = 0; this.#previousFrameLength = mutablePreparedLines.length; - this.#clearScrollbackOnNextRender = false; + if (destructiveReset) { + this.#clearScrollbackOnNextRender = false; + this.#clearScrollbackWaitsForReplay = false; + } this.#forceViewportRepaintOnNextRender = false; this.#hasEverRendered = true; this.#resizeReplaySize = undefined; @@ -3069,6 +3259,8 @@ export class TUI extends Container { // provider repaint can overwrite history at the stale row. if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) { if (this.#frameProvider !== undefined) { + this.#resizeProbeWindow = this.#providerWindow; + this.#resizeProbeOffset = this.#parkedViewportOffset; this.#beginResizeAnchorProbe(); return; } diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index a18b0dbcd24..40615093493 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -423,6 +423,23 @@ describe("CombinedAutocompleteProvider", () => { } }); + it.each([false, true])("preserves forced file provenance for allowArgs=%s", async allowArgs => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-force-args-")); + try { + fs.writeFileSync(path.join(baseDir, "candidate.txt"), "content"); + const provider = new CombinedAutocompleteProvider( + [{ name: "command", description: "Command", allowArgs }], + baseDir, + ); + const line = "/command ./cand"; + const result = await provider.getForceFileSuggestions([line], 0, line.length); + expect(result?.items.map(item => item.label)).toContain("candidate.txt"); + expect(result?.commandArgument === true).toBe(allowArgs); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + it("returns slash command argument completions instead of @ file references when the command defines them", async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-rename-args-")); try { @@ -446,6 +463,7 @@ describe("CombinedAutocompleteProvider", () => { expect(result).toEqual({ prefix: "repro @", + commandArgument: true, items: [{ value: "repro @literal", label: "Keep @ in the title" }], }); } finally { diff --git a/packages/tui/test/cursor-overlay.test.ts b/packages/tui/test/cursor-overlay.test.ts new file mode 100644 index 00000000000..17088049267 --- /dev/null +++ b/packages/tui/test/cursor-overlay.test.ts @@ -0,0 +1,565 @@ +import { expect, it } from "bun:test"; +import { wrapTmuxPassthrough } from "../src/tmux"; +import { CURSOR_MARKER, TUI, type TerminalFramePlan, type TerminalFrameProvider } from "../src/tui"; +import { withoutTerminalMultiplexer } from "./helpers/terminal-multiplexer"; +import { VirtualRenderScheduler } from "./virtual-render-scheduler"; +import { VirtualTerminal } from "./virtual-terminal"; + +withoutTerminalMultiplexer(); + +class Provider implements TerminalFrameProvider { + #history: readonly string[] = []; + #nextHistoryId = 2; + frame: TerminalFramePlan = { + history: { id: 1, kind: "append", rows: Array.from({ length: 30 }, (_, i) => `HISTORY_${i}`) }, + viewport: ["live", `${CURSOR_MARKER}input`], + }; + renderFrame(): TerminalFramePlan { + return this.frame; + } + acknowledgeHistory(): void { + const batch = this.frame.history; + if (batch) { + this.#history = batch.kind === "replay" ? batch.rows : [...this.#history, ...batch.rows]; + this.#nextHistoryId = Math.max(this.#nextHistoryId, batch.id + 1); + } + this.frame = { viewport: this.frame.viewport }; + } + beginHistoryReplay(): void { + this.frame = { + history: { id: this.#nextHistoryId++, kind: "replay", rows: this.#history }, + viewport: this.frame.viewport, + }; + } + renderResizeFrame(): readonly string[] { + return this.frame.viewport; + } +} + +function expectCleanAuthoritativeHistory(terminal: VirtualTerminal, count: number): void { + const rows = terminal.getScrollBuffer(); + expect(rows.filter(row => row.startsWith("HISTORY_"))).toEqual( + Array.from({ length: count }, (_, index) => `HISTORY_${index}`), + ); + expect(rows.join("\n")).not.toContain("MENU_"); +} + +it("restores covered screen cells before new history is appended", async () => { + const reference = new VirtualTerminal(40, 12); + const terminal = new VirtualTerminal(40, 12); + const ui = new TUI(terminal); + const referenceUi = new TUI(reference); + const provider = new Provider(); + const referenceProvider = new Provider(); + ui.setFrameProvider(provider); + referenceUi.setFrameProvider(referenceProvider); + ui.start(); + referenceUi.start(); + const paint = async () => { + ui.requestRender(true); + referenceUi.requestRender(true); + await terminal.waitForRender(); + await reference.waitForRender(); + }; + try { + await paint(); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + await paint(); + expect(terminal.getViewport().join("\n")).toContain("MENU_1"); + const next: TerminalFramePlan = { + history: { id: 2, kind: "append", rows: Array.from({ length: 8 }, (_, i) => `NEW_${i}`) }, + viewport: ["updated", `${CURSOR_MARKER}input`], + }; + provider.frame = next; + referenceProvider.frame = next; + await paint(); + expect(terminal.getScrollBuffer().slice(0, -terminal.rows).join("\n")).not.toContain("MENU_"); + ui.setCursorOverlay(undefined, 0, 0); + await paint(); + expect(terminal.getScrollBuffer()).toEqual(reference.getScrollBuffer()); + } finally { + ui.stop(); + referenceUi.stop(); + } +}); + +it("clips over-width cursor popup rows at the terminal boundary", async () => { + const terminal = new VirtualTerminal(20, 6); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = data => { + writes.push(data); + write(data); + }; + const ui = new TUI(terminal); + const provider = new Provider(); + provider.frame = { viewport: ["live", `${CURSOR_MARKER}input`] }; + ui.setFrameProvider(provider); + ui.setCursorOverlay(() => ["X".repeat(25)], 0, 1); + ui.start(); + try { + ui.requestRender(true); + await terminal.waitForRender(); + expect(writes.join("")).not.toContain("X".repeat(25)); + expect(terminal.getViewport()[2]).toBe("X".repeat(20)); + expect(terminal.getViewport()[3]).toBe(""); + expect(terminal.getViewport()[1]).toBe("input"); + } finally { + ui.stop(); + } +}); + +it("restores the complete scaled glyph when a popup covers only its lower row", async () => { + const terminal = new VirtualTerminal(40, 6); + const ui = new TUI(terminal); + const provider = new Provider(); + const heading = "\x1b]66;s=2;Heading\x1b\\"; + provider.frame = { viewport: ["", "", heading, "", `${CURSOR_MARKER}input`, ""] }; + ui.setFrameProvider(provider); + ui.start(); + try { + await terminal.waitForRender(); + ui.setCursorOverlay(() => ["MENU"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = data => { + writes.push(data); + write(data); + }; + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + await terminal.waitForRender(); + expect(writes.join("")).toContain(heading); + expect(writes.join("")).not.toContain("\x1b[4;1H\x1b[0m\x1b[K"); + } finally { + ui.stop(); + } +}); + +it("replays authoritative history after a popup is resized into native scrollback", async () => { + const terminal = new VirtualTerminal(40, 12); + const ui = new TUI(terminal); + const provider = new Provider(); + ui.setFrameProvider(provider); + ui.start(); + try { + await terminal.waitForRender(); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + terminal.resize(40, 4); + await terminal.waitForRender(() => terminal.getViewport().some(row => row.includes("input"))); + await Bun.sleep(300); + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + await terminal.waitForRender(); + const rows = terminal.getScrollBuffer(); + expect(rows.filter(row => row.startsWith("HISTORY_"))).toEqual( + Array.from({ length: 30 }, (_, i) => `HISTORY_${i}`), + ); + expect(rows.join("\n")).not.toContain("MENU_"); + } finally { + ui.stop(); + } +}); + +it("restores popup backing on stop without an optional history flush hook", async () => { + const terminal = new VirtualTerminal(40, 12); + const reference = new VirtualTerminal(40, 12); + const ui = new TUI(terminal); + const referenceUi = new TUI(reference); + ui.setFrameProvider(new Provider()); + referenceUi.setFrameProvider(new Provider()); + ui.start(); + referenceUi.start(); + try { + await terminal.waitForRender(); + await reference.waitForRender(); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getViewport().join("\n")).toContain("MENU_1"); + } finally { + ui.stop(); + referenceUi.stop(); + } + expect(terminal.getScrollBuffer()).toEqual(reference.getScrollBuffer()); +}); + +it.each([ + { width: 40, height: 4, historyCount: 30, stop: false, keepOpen: false }, + { width: 40, height: 16, historyCount: 30, stop: false, keepOpen: false }, + { width: 40, height: 20, historyCount: 12, stop: false, keepOpen: false }, + { width: 40, height: 20, historyCount: 12, stop: true, keepOpen: false }, + { width: 60, height: 20, historyCount: 12, stop: false, keepOpen: true }, + { width: 40, height: 20, historyCount: 12, stop: false, keepOpen: true }, + { width: 30, height: 12, historyCount: 30, stop: false, keepOpen: true }, + { width: 30, height: 12, historyCount: 30, stop: true, keepOpen: false }, +])("restores popup history without cursor reports: %j", async ({ width, height, historyCount, stop, keepOpen }) => { + const terminal = new VirtualTerminal(40, 12); + const start = terminal.start.bind(terminal); + terminal.start = (_input, resize) => start(() => {}, resize); + const write = terminal.write.bind(terminal); + terminal.write = data => { + write(data); + // VirtualTerminal's Kitty core does not pull the inactive normal + // buffer on growth. Emulate the xterm-style pull on its restoration. + if (height > 12 && data.includes("\x1b[?1049l")) { + const pull = Math.min(height - 12, Math.max(0, historyCount + 2 - 12)); + if (pull > 0) write(`\x1b[${pull}+T\x1b[${pull}B`); + } + }; + const ui = new TUI(terminal); + const provider = new Provider(); + provider.frame = { + history: { id: 1, kind: "append", rows: Array.from({ length: historyCount }, (_, i) => `HISTORY_${i}`) }, + viewport: provider.frame.viewport, + }; + ui.setFrameProvider(provider); + ui.start(); + try { + await terminal.waitForRender(); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + const selector = ui.showOverlay({ render: () => ["SELECTOR"] }, { fullscreen: true, mouseTracking: false }); + await terminal.waitForRender(); + terminal.resize(width, height); + await terminal.waitForRender(); + if (stop) ui.stop(); + else { + selector.hide(); + if (keepOpen) { + ui.requestRender(); + await Bun.sleep(600); + await terminal.waitForRender(); + expect(terminal.getViewport().join("\n")).toContain("MENU_1"); + } + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + await terminal.waitForRender(); + await Bun.sleep(600); + await terminal.waitForRender(); + } + const rows = terminal.getScrollBuffer(); + expect(rows.filter(row => row.startsWith("HISTORY_"))).toEqual( + Array.from({ length: historyCount }, (_, i) => `HISTORY_${i}`), + ); + expect(rows.join("\n")).not.toContain("MENU_"); + } finally { + ui.stop(); + } +}); + +it.each(["clear", "shrink"] as const)("restores popup backing changed during fullscreen: %s", async action => { + const terminal = new VirtualTerminal(40, 12); + const reference = new VirtualTerminal(40, 12); + const ui = new TUI(terminal); + const referenceUi = new TUI(reference); + ui.setFrameProvider(new Provider()); + referenceUi.setFrameProvider(new Provider()); + ui.start(); + referenceUi.start(); + try { + await terminal.waitForRender(); + await reference.waitForRender(); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + const selector = ui.showOverlay({ render: () => ["SELECTOR"] }, { fullscreen: true }); + await terminal.waitForRender(); + const popup = action === "clear" ? undefined : () => ["SMALL_MENU"]; + ui.setCursorOverlay(popup, 0, 1); + referenceUi.setCursorOverlay(popup, 0, 1); + referenceUi.requestRender(); + selector.hide(); + await terminal.waitForRender(); + await reference.waitForRender(); + expect(terminal.getScrollBuffer()).toEqual(reference.getScrollBuffer()); + } finally { + ui.stop(); + referenceUi.stop(); + } +}); + +it("recovers popup rows when stopping before resize settles", async () => { + const terminal = new VirtualTerminal(40, 12); + const scheduler = new VirtualRenderScheduler(); + const ui = new TUI(terminal, undefined, { renderScheduler: scheduler }); + ui.setFrameProvider(new Provider()); + ui.start(); + try { + await scheduler.settle(terminal); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await scheduler.settle(terminal); + terminal.resize(40, 8); + } finally { + ui.stop(); + } + await terminal.flush(); + expectCleanAuthoritativeHistory(terminal, 30); + expect(terminal.getViewport().join("\n")).not.toContain("MENU_"); +}); + +it.each(["append", undefined] as const)( + "recovers after acknowledging queued %s history before shutdown cancels replay", + async kind => { + let stoppedAfterAppend = false; + class DelayedReplayProvider extends Provider { + #replayQueued = false; + beginHistoryFlush(): void { + this.#replayQueued = false; + if (this.frame.history?.kind === "replay") this.frame = { viewport: this.frame.viewport }; + } + override beginHistoryReplay(): void { + if (this.frame.history) this.#replayQueued = true; + else super.beginHistoryReplay(); + } + override acknowledgeHistory(): void { + super.acknowledgeHistory(); + if (this.#replayQueued) { + this.#replayQueued = false; + super.beginHistoryReplay(); + queueMicrotask(() => { + stoppedAfterAppend = true; + ui.stop(); + }); + } + } + } + const terminal = new VirtualTerminal(40, 12); + const scheduler = new VirtualRenderScheduler(); + const ui = new TUI(terminal, undefined, { renderScheduler: scheduler }); + const provider = new DelayedReplayProvider(); + ui.setFrameProvider(provider); + ui.start(); + try { + await scheduler.settle(terminal); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await scheduler.settle(terminal); + provider.frame = { + history: { id: 2, kind, rows: ["HISTORY_30"] }, + viewport: provider.frame.viewport, + }; + terminal.resize(40, 4); + await scheduler.advance(terminal, 120); + expect(stoppedAfterAppend).toBe(true); + } finally { + ui.stop(); + } + await terminal.flush(); + expectCleanAuthoritativeHistory(terminal, 31); + expect(terminal.getViewport().join("\n")).not.toContain("MENU_"); + }, +); + +it.each([false, true])("preserves pulled external history when popup predates growth=%s", async openBeforeResize => { + const terminal = new VirtualTerminal(40, 12); + terminal.write(Array.from({ length: 50 }, (_, index) => `SHELL_${index}\r\n`).join("")); + const ui = new TUI(terminal); + const provider = new Provider(); + provider.frame = { + history: { id: 1, rows: Array.from({ length: 12 }, (_, index) => `HISTORY_${index}`) }, + viewport: provider.frame.viewport, + }; + ui.setFrameProvider(provider); + ui.start(); + const open = () => + ui.setCursorOverlay((_width, rows) => Array.from({ length: rows }, (_, index) => `MENU_${index}`), 0, 1, "above"); + try { + await terminal.waitForRender(); + const original = terminal.getScrollBuffer().filter(row => /^(SHELL|HISTORY)_/.test(row)); + if (openBeforeResize) { + open(); + ui.requestRender(); + await terminal.waitForRender(); + } + terminal.resize(40, 20); + await Bun.sleep(600); + await terminal.waitForRender(); + if (!openBeforeResize) open(); + ui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getViewport().join("\n")).toContain("MENU_"); + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getScrollBuffer().filter(row => /^(SHELL|HISTORY)_/.test(row))).toEqual(original); + } finally { + ui.stop(); + } +}); + +it("chooses available safe space below an editor after external history is pulled down", async () => { + const terminal = new VirtualTerminal(40, 12); + terminal.write(Array.from({ length: 40 }, (_, index) => `SHELL_${index}\r\n`).join("")); + const ui = new TUI(terminal); + const provider = new Provider(); + provider.frame = { viewport: [`${CURSOR_MARKER}EDITOR`, "STATUS", "EXTENSION"] }; + ui.setFrameProvider(provider); + ui.start(); + try { + await terminal.waitForRender(); + terminal.resize(40, 20); + await Bun.sleep(600); + await terminal.waitForRender(); + ui.setCursorOverlay(() => ["SAFE_MENU"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + const rows = terminal.getViewport(); + const menu = rows.findIndex(row => row.includes("SAFE_MENU")); + expect(menu).toBeGreaterThan(rows.findIndex(row => row.includes("EDITOR"))); + } finally { + ui.stop(); + } +}); + +it.each([ + [40, 14], + [40, 10], + [60, 12], +])("preserves native history when popup backing remains addressable at %ix%i", async (columns, rows) => { + const terminal = new VirtualTerminal(40, 12); + const reference = new VirtualTerminal(40, 12); + const ui = new TUI(terminal); + const referenceUi = new TUI(reference); + for (const target of [terminal, reference]) { + target.write(Array.from({ length: 20 }, (_, i) => `EXTERNAL_${i}\r\n`).join("")); + } + ui.setFrameProvider(new Provider()); + referenceUi.setFrameProvider(new Provider()); + ui.start(); + referenceUi.start(); + try { + await terminal.waitForRender(); + await reference.waitForRender(); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + terminal.resize(columns!, rows!); + reference.resize(columns!, rows!); + await Bun.sleep(400); + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + referenceUi.requestRender(); + await terminal.waitForRender(); + await reference.waitForRender(); + expect(terminal.getScrollBuffer()).toEqual(reference.getScrollBuffer()); + expect(terminal.getScrollBuffer().join("\n")).not.toContain("MENU_"); + } finally { + ui.stop(); + referenceUi.stop(); + } +}); + +it("keeps external scrollback on stop during a non-damaging resize", async () => { + const terminal = new VirtualTerminal(40, 12); + terminal.write(Array.from({ length: 20 }, (_, i) => `EXTERNAL_${i}\r\n`).join("")); + const ui = new TUI(terminal); + ui.setFrameProvider(new Provider()); + ui.start(); + await terminal.waitForRender(); + const external = terminal.getScrollBuffer().filter(row => row.startsWith("EXTERNAL_")); + try { + ui.setCursorOverlay(() => ["MENU_1", "MENU_2", "MENU_3", "MENU_4"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + terminal.resize(40, 10); + } finally { + ui.stop(); + } + await terminal.flush(); + const rows = terminal.getScrollBuffer(); + expect(rows.filter(row => row.startsWith("EXTERNAL_"))).toEqual(external); + expect(rows.filter(row => row.startsWith("HISTORY_"))).toEqual(Array.from({ length: 30 }, (_, i) => `HISTORY_${i}`)); + expect(rows.join("\n")).not.toContain("MENU_"); +}); + +it("keeps uncovered click targets available while blocking popup-covered rows", async () => { + const terminal = new VirtualTerminal(40, 8); + const ui = new TUI(terminal); + const provider = new Provider(); + provider.frame = { + viewport: ["CARD", "row1", "row2", "row3", "row4", "COVERED", `${CURSOR_MARKER}input`, "footer"], + }; + ui.setFrameProvider(provider); + ui.start(); + try { + await terminal.waitForRender(); + ui.setCursorOverlay(() => ["MENU"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getViewport()[5]).toBe("MENU"); + expect(ui.getMutableViewport(0)).toEqual({ top: 0, length: 8 }); + expect(ui.getMutableViewport(5)).toEqual({ top: 0, length: 0 }); + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + await terminal.waitForRender(); + expect(ui.getMutableViewport(5)).toEqual({ top: 0, length: 8 }); + } finally { + ui.stop(); + } +}); + +it.each([false, true])("preserves image placement IDs when history scrolls under a popup (tmux=%s)", async tmux => { + if (tmux) Bun.env.TMUX = "/tmp/omp-test-tmux,1,0"; + const terminal = new VirtualTerminal(40, 12); + const ui = new TUI(terminal); + const provider = new Provider(); + const apc = "\x1b_Ga=p,q=2,C=1,i=713,p=713,c=40,r=8,z=-2147483648\x1b\\"; + const placement = "\x1b7\x1b[7A" + (tmux ? wrapTmuxPassthrough(apc) : apc) + "\x1b8"; + provider.frame = { viewport: [...Array(7).fill(""), placement, `${CURSOR_MARKER}input`] }; + ui.setFrameProvider(provider); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = data => { + writes.push(data); + write(data); + }; + ui.start(); + try { + await terminal.waitForRender(); + writes.length = 0; + ui.setCursorOverlay(() => ["MODEL_RESULT"], 0, 1); + ui.requestRender(); + await terminal.waitForRender(); + expect(writes.join("")).not.toMatch(/\x1b_Ga=d,/); + expect(terminal.getViewport().join("\n")).toContain("MODEL_RESULT"); + expect(terminal.getViewportRowBackgroundColumns(7)).toEqual([]); + provider.frame = { + history: { id: 2, kind: "append", rows: Array(6).fill("APPENDED") }, + viewport: provider.frame.viewport, + }; + ui.requestRender(); + await terminal.waitForRender(); + ui.setCursorOverlay(undefined, 0, 0); + ui.requestRender(); + await terminal.waitForRender(); + expect(writes.join("")).toContain(placement); + expect(writes.join("")).not.toMatch(/\x1b_Ga=d,/); + } finally { + ui.stop(); + } +}); + +it("places suggestions immediately after the visible tail of a clipped editor", async () => { + const terminal = new VirtualTerminal(40, 8); + const ui = new TUI(terminal); + const provider = new Provider(); + provider.frame = { viewport: [`${CURSOR_MARKER}input`, "editor bottom", ...Array(6).fill("footer")] }; + ui.setFrameProvider(provider); + ui.setCursorOverlay(() => ["MENU_1", "MENU_2"], 2, 4); + ui.start(); + try { + await terminal.waitForRender(); + expect(terminal.getViewport()[1]).toBe("editor bottom"); + expect(terminal.getViewport()[2]).toBe("MENU_1"); + expect(terminal.getViewport()[3]).toBe("MENU_2"); + } finally { + ui.stop(); + } +}); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index e86cfbd7ae1..40d6859fdb2 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -6,6 +6,7 @@ import { stripVTControlCharacters } from "node:util"; import { type ComposerStyle, CURSOR_MARKER, + type CursorOverlayRenderer, Editor, type EditorTheme, registerComposerStyle, @@ -447,6 +448,97 @@ describe("Editor component", () => { }); describe("autocomplete triggers", () => { + it.each([1, 3, 4, 6])("keeps an overflowing popup inside its %i-row budget", async maxRows => { + const editor = new Editor(defaultEditorTheme); + editor.focused = true; + editor.commandSuggestionsPopup = true; + let overlay: CursorOverlayRenderer | undefined; + editor.onAutocompleteRender = render => { + overlay = render; + }; + editor.setAutocompleteProvider( + new CombinedAutocompleteProvider(Array.from({ length: 30 }, (_, index) => ({ name: `command${index}` }))), + ); + const updated = Promise.withResolvers(); + editor.onAutocompleteUpdate = updated.resolve; + editor.handleInput("/"); + await updated.promise; + editor.render(40); + if (!overlay) throw new Error("Expected a command popup renderer"); + const rows = overlay(40, maxRows).map(stripVTControlCharacters); + expect(rows.length).toBeLessThanOrEqual(maxRows); + expect(rows.join("\n")).toContain("command0"); + if (maxRows >= 3) { + const border = defaultEditorTheme.symbols.boxRound; + expect(rows[0]).toBe(border.topLeft + border.horizontal.repeat(38) + border.topRight); + expect(rows.at(-1)).toBe(border.bottomLeft + border.horizontal.repeat(38) + border.bottomRight); + } + }); + + it("removes the previous passive popup when completion switches to an absolute path", async () => { + const editor = new Editor(defaultEditorTheme); + editor.focused = true; + editor.commandSuggestionsPopup = true; + let popupVisible = false; + editor.onAutocompleteRender = render => { + popupVisible = render !== undefined; + }; + editor.setAutocompleteProvider({ + async getSuggestions(lines, cursorLine, cursorCol) { + const prefix = lines[cursorLine]!.slice(0, cursorCol); + return { prefix, items: [{ label: "candidate", value: prefix === "/" ? "help" : "/tmp/file" }] }; + }, + applyCompletion(lines, cursorLine, cursorCol) { + return { lines, cursorLine, cursorCol }; + }, + }); + for (const input of ["/", "tmp/f"]) { + const updated = Promise.withResolvers(); + editor.onAutocompleteUpdate = updated.resolve; + editor.handleInput(input); + await updated.promise; + const rows = editor.render(80).join("\n"); + if (input === "/") { + expect(popupVisible).toBe(true); + expect(rows).not.toContain("candidate"); + } else { + expect(rows).toContain("candidate"); + expect(popupVisible).toBe(false); + } + } + }); + + it("keeps relative file completions inline when they are not command arguments", async () => { + const editor = new Editor(defaultEditorTheme); + editor.focused = true; + editor.commandSuggestionsPopup = true; + let popupVisible = false; + editor.onAutocompleteRender = render => { + popupVisible = render !== undefined; + }; + editor.setAutocompleteProvider({ + async getSuggestions() { + return { + prefix: "./cand", + commandArgument: false, + items: [{ label: "candidate.txt", value: "./candidate.txt" }], + }; + }, + applyCompletion(lines, cursorLine, cursorCol) { + return { lines, cursorLine, cursorCol }; + }, + }); + const updated = Promise.withResolvers(); + editor.onAutocompleteUpdate = updated.resolve; + editor.handleInput("/quit ./cand"); + await updated.promise; + const rows = editor.render(80); + expect(rows.findIndex(row => row.includes("candidate.txt"))).toBeGreaterThan( + rows.findIndex(row => row.includes("/quit")), + ); + expect(popupVisible).toBe(false); + }); + it("triggers slash-command autocomplete without losing the hardware cursor anchor", async () => { const editor = new Editor(defaultEditorTheme); editor.focused = true; @@ -474,7 +566,7 @@ describe("Editor component", () => { expect(editor.render(80).some(line => line.includes(CURSOR_MARKER))).toBe(true); }); - it("caps wrapped slash-command descriptions at two rows with an ellipsis", async () => { + it.each(["/", " /"])("caps wrapped command descriptions for prefix %j at two rows", async prefix => { const editor = new Editor(defaultEditorTheme); const longDescription = "Plan and execute non-trivial architectural improvements to the codebase. Use this skill when you need to refactor existing systems and it keeps rambling on far past what two popup rows can hold."; @@ -488,7 +580,7 @@ describe("Editor component", () => { const { promise: autocompleteUpdated, resolve: resolveAutocompleteUpdated } = Promise.withResolvers(); editor.onAutocompleteUpdate = resolveAutocompleteUpdated; - editor.handleInput("/"); + editor.handleInput(prefix); await autocompleteUpdated; const rendered = editor.render(80).map(line => stripVTControlCharacters(line)); diff --git a/packages/tui/test/github-ref-autocomplete.test.ts b/packages/tui/test/github-ref-autocomplete.test.ts index 20880fe09ab..00e64b22648 100644 --- a/packages/tui/test/github-ref-autocomplete.test.ts +++ b/packages/tui/test/github-ref-autocomplete.test.ts @@ -128,6 +128,7 @@ describe("github-ref autocomplete — provider integration", () => { expect(suggestions).toEqual({ prefix: "#123", + commandArgument: true, items: [ { value: "pr://123", label: "PR #123", description: "GitHub pull request" }, { value: "issue://123", label: "Issue #123", description: "GitHub issue" }, diff --git a/packages/tui/test/image-budget.test.ts b/packages/tui/test/image-budget.test.ts index e3bb868eec0..f9287c26b64 100644 --- a/packages/tui/test/image-budget.test.ts +++ b/packages/tui/test/image-budget.test.ts @@ -314,7 +314,7 @@ describe("tmux Kitty graphics passthrough", () => { expect(encodeKitty("AA==", { columns: 1, rows: 1 })).toBe(expected("\x1b_Ga=T,f=100,q=2,C=1,c=1,r=1;AA==\x1b\\")); expect(encodeKittyTransmit("AA==", 9)).toBe(expected("\x1b_Ga=t,f=100,q=2,i=9;AA==\x1b\\")); expect(encodeKittyPlacement({ imageId: 9, placementId: 9, columns: 3, rows: 2 })).toBe( - expected("\x1b_Ga=p,q=2,C=1,i=9,p=9,c=3,r=2\x1b\\"), + expected("\x1b_Ga=p,q=2,C=1,i=9,p=9,c=3,r=2,z=-2147483648\x1b\\"), ); expect(encodeKittyVirtualPlacement({ imageId: 9, placementId: 9, columns: 3, rows: 2 })).toBe( expected("\x1b_Ga=p,U=1,q=2,i=9,p=9,c=3,r=2\x1b\\"), @@ -1777,7 +1777,7 @@ describe("kitty transmit / placement encoding", () => { it("encodeKittyPlacement displays a transmitted image by id with a stable placement id", () => { const seq = encodeKittyPlacement({ imageId: 9, placementId: 9, columns: 3, rows: 2 }); - expect(seq).toBe("\x1b_Ga=p,q=2,C=1,i=9,p=9,c=3,r=2\x1b\\"); + expect(seq).toBe("\x1b_Ga=p,q=2,C=1,i=9,p=9,c=3,r=2,z=-2147483648\x1b\\"); expect(seq).not.toContain(BASE64_ONE_PIXEL_PNG); }); }); diff --git a/packages/tui/test/image-clip.test.ts b/packages/tui/test/image-clip.test.ts index 632954fb97f..1688a26512a 100644 --- a/packages/tui/test/image-clip.test.ts +++ b/packages/tui/test/image-clip.test.ts @@ -80,16 +80,16 @@ describe("kitty direct-placement wire format", () => { const base = { imageId: 7, columns: 4, rows: 6, imageHeightPx: 60 }; // Whole block visible (last line at viewport row 9): full anchored form. expect(encodeKittyPlacementLine({ ...base, placementId: 1, screenRow: 9 })).toBe( - "\x1b7\x1b[5A\x1b_Ga=p,q=2,C=1,i=7,p=1,c=4,r=6\x1b\\\x1b8", + "\x1b7\x1b[5A\x1b_Ga=p,q=2,C=1,i=7,p=1,c=4,r=6,z=-2147483648\x1b\\\x1b8", ); // Straddling (last line at row 3): two rows hidden above, four visible — // the source slice starts at 60*2/6 = 20px. expect(encodeKittyPlacementLine({ ...base, placementId: 2, screenRow: 3 })).toBe( - "\x1b7\x1b[3A\x1b_Ga=p,q=2,C=1,i=7,p=2,c=4,r=4,y=20,h=40\x1b\\\x1b8", + "\x1b7\x1b[3A\x1b_Ga=p,q=2,C=1,i=7,p=2,c=4,r=4,y=20,h=40,z=-2147483648\x1b\\\x1b8", ); // Only the last row visible: no cursor movement, bottom slice only. expect(encodeKittyPlacementLine({ ...base, placementId: 3, screenRow: 0 })).toBe( - "\x1b_Ga=p,q=2,C=1,i=7,p=3,c=4,r=1,y=50,h=10\x1b\\", + "\x1b_Ga=p,q=2,C=1,i=7,p=3,c=4,r=1,y=50,h=10,z=-2147483648\x1b\\", ); }); }); diff --git a/packages/tui/test/prompt-action-autocomplete.test.ts b/packages/tui/test/prompt-action-autocomplete.test.ts index 41e142ceab0..aff66e67136 100644 --- a/packages/tui/test/prompt-action-autocomplete.test.ts +++ b/packages/tui/test/prompt-action-autocomplete.test.ts @@ -219,6 +219,7 @@ describe("prompt action autocomplete", () => { expect(suggestions).toEqual({ prefix: "repro #copy", + commandArgument: true, items: [{ value: "repro #copy-title", label: "Keep #copy in the title" }], }); }); @@ -242,6 +243,7 @@ describe("prompt action autocomplete", () => { expect(suggestions).not.toBeNull(); expect(suggestions?.prefix).toBe("omp://"); + expect(suggestions?.commandArgument).toBe(true); expect(suggestions?.items.length).toBeGreaterThan(0); }); @@ -271,6 +273,7 @@ describe("prompt action autocomplete", () => { expect(suggestions).not.toBeNull(); expect(suggestions?.prefix).toBe("omp://"); + expect(suggestions?.commandArgument).toBe(true); expect(suggestions?.items.length).toBeGreaterThan(0); }); diff --git a/packages/tui/test/resize-multiplexer-anchor.test.ts b/packages/tui/test/resize-multiplexer-anchor.test.ts index dc44a1010b0..ea5ee31e90a 100644 --- a/packages/tui/test/resize-multiplexer-anchor.test.ts +++ b/packages/tui/test/resize-multiplexer-anchor.test.ts @@ -133,6 +133,23 @@ describe("resize anchoring inside a terminal multiplexer", () => { else Bun.env.TMUX = previousTmux; }); + it("restores popup backing at the clipping anchor when stopped before CPR", () => { + const { terminal, tui, provider, writes } = startRig(0); + Object.assign(provider, { + beginHistoryReplay: () => { + throw new Error("Unexpected destructive replay"); + }, + }); + tui.setCursorOverlay(() => ["MENU"], 0, 1); + tui.renderNow(); + terminal.resize(40, 10); + writes.length = 0; + tui.stop(); + const output = writes.join(""); + expect(output).toMatch(/\x1b\[5;1H[^\n]*live-1/); + expect(output).not.toMatch(/\x1b\[(?:2|3)J/); + }); + it("skips the SIGWINCH-side erase so a racing re-layout cannot blank popped scrollback", () => { const { terminal, tui, renderScheduler, writes } = startRig(); writes.length = 0; diff --git a/packages/tui/test/transcript-container.test.ts b/packages/tui/test/transcript-container.test.ts index 82b1f64a62b..14acc3df957 100644 --- a/packages/tui/test/transcript-container.test.ts +++ b/packages/tui/test/transcript-container.test.ts @@ -318,10 +318,23 @@ describe("TranscriptContainer", () => { expect(transcript.emittedStableRows()).toEqual([0]); transcript.beginReplay(); - expect(transcript.peekReplayBatch(80)).toBeUndefined(); + const replay = transcript.peekReplayBatch(80); + expect(replay?.rows).toEqual([]); + transcript.acknowledgeFinalizedBatch(replay!.id); expect(transcript.renderViewport(80, 5, frame)).toEqual(["answer"]); }); + it("offers an acknowledged empty replay when the committed ledger is empty", () => { + const transcript = new TranscriptContainer(); + + transcript.beginReplay(); + const replay = transcript.peekReplayBatch(80); + + expect(replay).toEqual({ id: 1, rows: [], kind: "replay" }); + transcript.acknowledgeFinalizedBatch(replay!.id); + expect(transcript.peekReplayBatch(80)).toBeUndefined(); + }); + beforeAll(async () => { await initTheme(false); });