From 41477c2510030628dfafa76f72259392756d2e03 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:45:40 +0800 Subject: [PATCH] fix(tui): preserve streamed code on completion --- tui/bun.lock | 20 +++++++++--------- tui/package.json | 2 +- tui/src/app.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++ tui/src/app.ts | 20 +++++++++++++++--- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/tui/bun.lock b/tui/bun.lock index 9f036a36d..ba827a7e9 100644 --- a/tui/bun.lock +++ b/tui/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@nanobot/tui", "dependencies": { - "@opentui/core": "0.5.3", + "@opentui/core": "0.5.10", }, "devDependencies": { "@types/bun": "^1.3.13", @@ -14,23 +14,23 @@ }, }, "packages": { - "@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="], + "@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], diff --git a/tui/package.json b/tui/package.json index 0fd7bab74..488bd5f98 100644 --- a/tui/package.json +++ b/tui/package.json @@ -10,7 +10,7 @@ "test": "bun test" }, "dependencies": { - "@opentui/core": "0.5.3" + "@opentui/core": "0.5.10" }, "devDependencies": { "@types/bun": "^1.3.13", diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index cc3c76f8c..92c908c5b 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -9,6 +9,7 @@ import { } from "@opentui/core" import { MockTreeSitterClient, + TestRecorder, createTestRenderer, type TestRendererSetup, } from "@opentui/core/testing" @@ -491,6 +492,17 @@ describe("NanobotTui layout", () => { expect(ui.composer.plainText).toContain("replacement") expect(ui.composer.plainText).not.toContain("Image #1") + ui.composer.setText("") + setup.mockInput.pressKey("v", { ctrl: true }) + await waitUntil(() => ui.composer.plainText === "[Image #1] ") + ui.composer.cursorOffset = 10 + setup.mockInput.pressArrow("left", { shift: true }) + await waitUntil(() => ui.composer.cursorOffset === 0) + await setup.mockInput.typeText("replacement") + await waitUntil(() => ui.draft.imageCount === 0) + expect(ui.composer.plainText).toContain("replacement") + expect(ui.composer.plainText).not.toContain("Image #1") + ui.composer.setText("") setup.mockInput.pressKey("v", { ctrl: true }) await waitUntil(() => ui.composer.plainText === "[Image #1] ") @@ -2052,6 +2064,44 @@ describe("NanobotTui layout", () => { } }) + test("keeps streamed fenced code visible while completing the response", async () => { + setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" }) + const app = mount(setup) + const response = [ + "Commit types:", + "", + "```text", + "feat:", + "fix:", + "perf:", + "docs:", + "test:", + "refactor:", + "chore:", + "```", + "", + "Include the reason in the body.", + ].join("\n") + + app.accept({ event: "attached", chat_id: "chat" }) + app.accept({ event: "delta", chat_id: "chat", text: response }) + await setup.flush() + expect(setup.captureCharFrame()).toContain("feat:") + + const recorder = new TestRecorder(setup.renderer) + recorder.rec() + app.accept({ event: "stream_end", chat_id: "chat" }) + app.accept({ event: "turn_end", chat_id: "chat" }) + await setup.flush() + recorder.stop() + + expect(recorder.recordedFrames.length).toBeGreaterThan(0) + expect(recorder.recordedFrames.every(({ frame }) => frame.includes("feat:"))).toBeTrue() + expect(recorder.recordedFrames.every(({ frame }) => ( + frame.includes("Include the reason in the body.") + ))).toBeTrue() + }) + test("renders assistant LaTeX as Unicode text without changing code", async () => { setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) diff --git a/tui/src/app.ts b/tui/src/app.ts index 15427c464..facf5d07a 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -1738,16 +1738,30 @@ export class NanobotTui { key.preventDefault() return } - if (!key.ctrl && !key.meta && !key.shift && (key.name === "left" || key.name === "right")) { + if (!key.ctrl && !key.meta && (key.name === "left" || key.name === "right")) { const direction = key.name === "left" ? -1 : 1 + const cursor = this.composerStringCursor() const target = this.draft.moveImageCursor( this.composer.plainText, - this.composerStringCursor(), + cursor, direction, ) if (target !== null) { this.composerCursor = target - this.setComposerStringCursor(this.composer.plainText, target) + if (key.shift) { + const cursorOffset = this.composerOffsetForStringIndex(this.composer.plainText, cursor) + const targetOffset = this.composerOffsetForStringIndex(this.composer.plainText, target) + this.composer.setSelection( + Math.min(cursorOffset, targetOffset), + Math.max(cursorOffset, targetOffset), + ) + // OpenTUI 0.5.10 clears the selection through the public cursor + // setter. Move the native edit cursor directly so the placeholder + // remains one selected, replaceable unit. + this.composer.editBuffer.setCursorByOffset(targetOffset) + } else { + this.setComposerStringCursor(this.composer.plainText, target) + } key.preventDefault() return }