mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-25 00:54:56 +00:00
* chore(kimi-code): upgrade pi-tui to 0.78.1 and adapt native helpers
Bump @earendil-works/pi-tui from ^0.74.0 to ^0.78.1. pi-tui 0.75.5 replaced its koffi-based Windows VT input with a bundled native helper, and 0.76.0 added a darwin native helper for Terminal.app Shift+Enter.
- SEA build: teach native-deps to collect pi-tui's per-target .node files, drop the koffi registry, and add a native-file-only collect mode so only package.json + the target .node ship (28 -> 2 files).
- Redirect pi-tui's absolute-path native require() into the native-asset cache through the Module._load hook, and extend the native smoke test to actually load the helper.
- npm package: ship pi-tui's native/ directory so macOS Terminal.app Shift+Enter and Windows Shift+Tab keep working for npm installs.
* chore: add changeset for pi-tui upgrade
* chore: vendor @earendil-works/pi-tui 0.80.2
Fork the upstream pi-tui source into packages/pi-tui for local modification. Pristine snapshot of @earendil-works/pi-tui@0.80.2; the apps/kimi-code dependency on ^0.78.1 from npm is left unchanged.
* feat(kimi-code): integrate vendored @moonshot-ai/pi-tui
Replace the npm @earendil-works/pi-tui dependency with the vendored @moonshot-ai/pi-tui workspace package so the fork can be modified locally.
- Point apps/kimi-code imports and native-deps at @moonshot-ai/pi-tui.
- Make pi-tui source-first (exports -> src, publishConfig.exports -> dist, mirroring node-sdk) and strict-clean: bracket access for process.env / named capture groups, an override modifier, and non-null assertions for noUncheckedIndexedAccess.
- Bump the root tsconfig target to ES2024 and enable allowImportingTsExtensions (needed for pi-tui's /v regex and .ts imports, which node --test requires).
- Add packages/pi-tui to flake.nix workspaces and exclude the vendored source from oxlint.
* fix(pi-tui): export package.json for native asset resolution
The SEA native-asset collector resolves the package root via
require.resolve('@moonshot-ai/pi-tui/package.json'). The vendored
package's exports field only exposed ".", which blocked the
"./package.json" subpath and broke build:native:sea with
ERR_PACKAGE_PATH_NOT_EXPORTED.
* chore(kimi-code): sync pi-tui native prebuilds at build time
Copy packages/pi-tui/native prebuilds into apps/kimi-code/native
during build instead of tracking a manual copy in git. Only the
.node prebuilds are copied (not the C sources); the directory is
now a build artifact covered by .gitignore.
* fix(pi-tui): avoid destructive full redraw during streaming
When the first changed line is above the viewport, the differential
renderer fell back to fullRender(true), which clears scrollback and
yanks the user's viewport. On Windows Terminal this jumps to the
absolute top (microsoft/Terminal#20370).
Clamp the diff to the visible viewport when content length is
unchanged (spinner tick / markdown reflow above the viewport), so
streaming no longer triggers a full redraw in those cases. Length
changes still fall back to fullRender to reset the viewport.
* fix(kimi-code): update pi-tui imports in files merged from main
Two files added on main (effort-selector, plugin-command) still
imported the old @earendil-works/pi-tui package name; point them at
the vendored @moonshot-ai/pi-tui.
* chore(nix): update pnpmDeps hash after lockfile regen
* fix(pi-tui): clamp above-viewport diff even when content shrinks
Previously, when the first changed line was above the viewport and
content length changed (e.g. spinner removed at end of streaming), the
renderer fell back to fullRender(true), which clears scrollback and
yanks the viewport to the absolute top on Windows Terminal.
Always clamp the diff to the visible viewport instead, preserving the
user's scroll position. Stale bytes remain in scrollback but are not
visible.
* fix(kimi-code): keep activity placeholder to avoid streaming shrink
When streaming ends, removing the activity spinner shrank the content
by two rows, which (combined with transient→final code highlighting
above the viewport) triggered a destructive full redraw. Keep a one-row
placeholder in the activity pane when idle so the content does not
fully shrink.
* chore: refine streaming scroll changeset wording
* chore: remove obsolete pi-tui native helpers changeset
* chore: add pi-tui changesets and document the pi-tui changelog rule
Add changesets for the fork integration, package manifest export, and
viewport clamp fix so the vendored pi-tui keeps its own changelog.
Update the gen-changesets skill to treat @moonshot-ai/pi-tui as a
special internal package that lists itself instead of the CLI, with a
separate CLI changeset only when the change is user-visible there.
* chore(nix): update pnpmDeps hash after merging main
* fix(changeset): drop private kimi-code-docs from google-genai changeset
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
/**
|
|
* Tests for StdinBuffer
|
|
*
|
|
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
|
* MIT License - Copyright (c) 2025 opentui
|
|
*/
|
|
|
|
import assert from "node:assert";
|
|
import { beforeEach, describe, it } from "node:test";
|
|
import { StdinBuffer } from "../src/stdin-buffer.ts";
|
|
|
|
describe("StdinBuffer", () => {
|
|
let buffer: StdinBuffer;
|
|
let emittedSequences: string[];
|
|
|
|
beforeEach(() => {
|
|
buffer = new StdinBuffer({ timeout: 10 });
|
|
|
|
// Collect emitted sequences
|
|
emittedSequences = [];
|
|
buffer.on("data", (sequence) => {
|
|
emittedSequences.push(sequence);
|
|
});
|
|
});
|
|
|
|
// Helper to process data through the buffer
|
|
function processInput(data: string | Buffer): void {
|
|
buffer.process(data);
|
|
}
|
|
|
|
// Helper to wait for async operations
|
|
async function wait(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
describe("Regular Characters", () => {
|
|
it("should pass through regular characters immediately", () => {
|
|
processInput("a");
|
|
assert.deepStrictEqual(emittedSequences, ["a"]);
|
|
});
|
|
|
|
it("should pass through multiple regular characters", () => {
|
|
processInput("abc");
|
|
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
|
|
});
|
|
|
|
it("should handle unicode characters", () => {
|
|
processInput("hello 世界");
|
|
assert.deepStrictEqual(emittedSequences, ["h", "e", "l", "l", "o", " ", "世", "界"]);
|
|
});
|
|
});
|
|
|
|
describe("Complete Escape Sequences", () => {
|
|
it("should pass through complete mouse SGR sequences", () => {
|
|
const mouseSeq = "\x1b[<35;20;5m";
|
|
processInput(mouseSeq);
|
|
assert.deepStrictEqual(emittedSequences, [mouseSeq]);
|
|
});
|
|
|
|
it("should pass through complete arrow key sequences", () => {
|
|
const upArrow = "\x1b[A";
|
|
processInput(upArrow);
|
|
assert.deepStrictEqual(emittedSequences, [upArrow]);
|
|
});
|
|
|
|
it("should pass through complete function key sequences", () => {
|
|
const f1 = "\x1b[11~";
|
|
processInput(f1);
|
|
assert.deepStrictEqual(emittedSequences, [f1]);
|
|
});
|
|
|
|
it("should pass through meta key sequences", () => {
|
|
const metaA = "\x1ba";
|
|
processInput(metaA);
|
|
assert.deepStrictEqual(emittedSequences, [metaA]);
|
|
});
|
|
|
|
it("should pass through SS3 sequences", () => {
|
|
const ss3 = "\x1bOA";
|
|
processInput(ss3);
|
|
assert.deepStrictEqual(emittedSequences, [ss3]);
|
|
});
|
|
});
|
|
|
|
describe("Partial Escape Sequences", () => {
|
|
it("should buffer incomplete mouse SGR sequence", async () => {
|
|
processInput("\x1b");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b");
|
|
|
|
processInput("[<35");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
|
|
|
processInput(";20;5m");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
|
assert.strictEqual(buffer.getBuffer(), "");
|
|
});
|
|
|
|
it("should buffer incomplete CSI sequence", () => {
|
|
processInput("\x1b[");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
|
|
processInput("1;");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
|
|
processInput("5H");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]);
|
|
});
|
|
|
|
it("should buffer split across many chunks", () => {
|
|
processInput("\x1b");
|
|
processInput("[");
|
|
processInput("<");
|
|
processInput("3");
|
|
processInput("5");
|
|
processInput(";");
|
|
processInput("2");
|
|
processInput("0");
|
|
processInput(";");
|
|
processInput("5");
|
|
processInput("m");
|
|
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
|
});
|
|
|
|
it("should flush incomplete sequence after timeout", async () => {
|
|
processInput("\x1b[<35");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
|
|
// Wait for timeout
|
|
await wait(15);
|
|
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
|
|
});
|
|
});
|
|
|
|
describe("Mixed Content", () => {
|
|
it("should handle characters followed by escape sequence", () => {
|
|
processInput("abc\x1b[A");
|
|
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]);
|
|
});
|
|
|
|
it("should handle escape sequence followed by characters", () => {
|
|
processInput("\x1b[Aabc");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]);
|
|
});
|
|
|
|
it("should handle multiple complete sequences", () => {
|
|
processInput("\x1b[A\x1b[B\x1b[C");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]);
|
|
});
|
|
|
|
it("should handle partial sequence with preceding characters", () => {
|
|
processInput("abc\x1b[<35");
|
|
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
|
|
|
processInput(";20;5m");
|
|
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[<35;20;5m"]);
|
|
});
|
|
});
|
|
|
|
describe("Kitty Keyboard Protocol", () => {
|
|
it("should handle Kitty CSI u press events", () => {
|
|
// Press 'a' in Kitty protocol
|
|
processInput("\x1b[97u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]);
|
|
});
|
|
|
|
it("should handle Kitty CSI u release events", () => {
|
|
// Release 'a' in Kitty protocol
|
|
processInput("\x1b[97;1:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]);
|
|
});
|
|
|
|
it("should handle batched Kitty press and release", () => {
|
|
// Press 'a', release 'a' batched together (common over SSH)
|
|
processInput("\x1b[97u\x1b[97;1:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]);
|
|
});
|
|
|
|
it("should handle multiple batched Kitty events", () => {
|
|
// Press 'a', release 'a', press 'b', release 'b'
|
|
processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]);
|
|
});
|
|
|
|
it("should handle Kitty arrow keys with event type", () => {
|
|
// Up arrow press with event type
|
|
processInput("\x1b[1;1:1A");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]);
|
|
});
|
|
|
|
it("should handle Kitty functional keys with event type", () => {
|
|
// Delete key release
|
|
processInput("\x1b[3;1:3~");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]);
|
|
});
|
|
|
|
it("should split ESC+ESC+CSI into standalone ESC and the CSI sequence (WezTerm Escape key regression)", () => {
|
|
// WezTerm with enable_kitty_keyboard sends Escape key press as raw \x1b
|
|
// and the release as a full Kitty CSI-u sequence, concatenated.
|
|
// The buffer must not treat \x1b\x1b as a complete meta-key when the
|
|
// following byte starts a new escape sequence.
|
|
processInput("\x1b\x1b[27;129:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;129:3u"]);
|
|
});
|
|
|
|
it("should split ESC+ESC+CSI with no modifier (no num_lock)", () => {
|
|
processInput("\x1b\x1b[27;1:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;1:3u"]);
|
|
});
|
|
|
|
it("should still emit ESC+ESC as a single sequence when not followed by a new escape", () => {
|
|
// \x1b\x1b alone (no following CSI) stays as-is — e.g. ctrl+alt+[
|
|
processInput("\x1b\x1b");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b\x1b"]);
|
|
});
|
|
|
|
it("should handle plain characters mixed with Kitty sequences", () => {
|
|
// Plain 'a' followed by Kitty release
|
|
processInput("a\x1b[97;1:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
|
|
});
|
|
|
|
it("should drop raw duplicate character after matching Kitty printable sequence", () => {
|
|
processInput("\x1b[224uà");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[224u"]);
|
|
});
|
|
|
|
it("should drop raw duplicate character after matching Kitty printable sequence across chunks", () => {
|
|
processInput("\x1b[64u");
|
|
processInput("@");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[64u"]);
|
|
});
|
|
|
|
it("should keep non-matching plain character after Kitty printable sequence", () => {
|
|
processInput("\x1b[97ub");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "b"]);
|
|
});
|
|
|
|
it("should keep raw character after modified Kitty printable sequence", () => {
|
|
processInput("\x1b[64;3u@");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[64;3u", "@"]);
|
|
});
|
|
|
|
it("should handle rapid typing simulation with Kitty protocol", () => {
|
|
// Simulates typing "hi" quickly with releases interleaved
|
|
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[104u", "\x1b[104;1:3u", "\x1b[105u", "\x1b[105;1:3u"]);
|
|
});
|
|
});
|
|
|
|
describe("Mouse Events", () => {
|
|
it("should handle mouse press event", () => {
|
|
processInput("\x1b[<0;10;5M");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]);
|
|
});
|
|
|
|
it("should handle mouse release event", () => {
|
|
processInput("\x1b[<0;10;5m");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]);
|
|
});
|
|
|
|
it("should handle mouse move event", () => {
|
|
processInput("\x1b[<35;20;5m");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
|
});
|
|
|
|
it("should handle split mouse events", () => {
|
|
processInput("\x1b[<3");
|
|
processInput("5;1");
|
|
processInput("5;");
|
|
processInput("10m");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]);
|
|
});
|
|
|
|
it("should handle multiple mouse events", () => {
|
|
processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;1;1m", "\x1b[<35;2;2m", "\x1b[<35;3;3m"]);
|
|
});
|
|
|
|
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => {
|
|
processInput("\x1b[M abc");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]);
|
|
});
|
|
|
|
it("should buffer incomplete old-style mouse sequence", () => {
|
|
processInput("\x1b[M");
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b[M");
|
|
|
|
processInput(" a");
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
|
|
|
|
processInput("b");
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]);
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases", () => {
|
|
it("should handle empty input", () => {
|
|
processInput("");
|
|
// Empty string emits an empty data event
|
|
assert.deepStrictEqual(emittedSequences, [""]);
|
|
});
|
|
|
|
it("should handle lone escape character with timeout", async () => {
|
|
processInput("\x1b");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
|
|
// After timeout, should emit
|
|
await wait(15);
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b"]);
|
|
});
|
|
|
|
it("should handle lone escape character with explicit flush", () => {
|
|
processInput("\x1b");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
|
|
const flushed = buffer.flush();
|
|
assert.deepStrictEqual(flushed, ["\x1b"]);
|
|
});
|
|
|
|
it("should handle buffer input", () => {
|
|
processInput(Buffer.from("\x1b[A"));
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[A"]);
|
|
});
|
|
|
|
it("should handle very long sequences", () => {
|
|
const longSeq = `\x1b[${"1;".repeat(50)}H`;
|
|
processInput(longSeq);
|
|
assert.deepStrictEqual(emittedSequences, [longSeq]);
|
|
});
|
|
});
|
|
|
|
describe("Flush", () => {
|
|
it("should flush incomplete sequences", () => {
|
|
processInput("\x1b[<35");
|
|
const flushed = buffer.flush();
|
|
assert.deepStrictEqual(flushed, ["\x1b[<35"]);
|
|
assert.strictEqual(buffer.getBuffer(), "");
|
|
});
|
|
|
|
it("should return empty array if nothing to flush", () => {
|
|
const flushed = buffer.flush();
|
|
assert.deepStrictEqual(flushed, []);
|
|
});
|
|
|
|
it("should emit flushed data via timeout", async () => {
|
|
processInput("\x1b[<35");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
|
|
// Wait for timeout to flush
|
|
await wait(15);
|
|
|
|
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
|
|
});
|
|
});
|
|
|
|
describe("Clear", () => {
|
|
it("should clear buffered content without emitting", () => {
|
|
processInput("\x1b[<35");
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
|
|
|
buffer.clear();
|
|
assert.strictEqual(buffer.getBuffer(), "");
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
});
|
|
});
|
|
|
|
describe("Bracketed Paste", () => {
|
|
let emittedPaste: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
buffer = new StdinBuffer({ timeout: 10 });
|
|
|
|
// Collect emitted sequences
|
|
emittedSequences = [];
|
|
buffer.on("data", (sequence) => {
|
|
emittedSequences.push(sequence);
|
|
});
|
|
|
|
// Collect paste events
|
|
emittedPaste = [];
|
|
buffer.on("paste", (data) => {
|
|
emittedPaste.push(data);
|
|
});
|
|
});
|
|
|
|
it("should emit paste event for complete bracketed paste", () => {
|
|
const pasteStart = "\x1b[200~";
|
|
const pasteEnd = "\x1b[201~";
|
|
const content = "hello world";
|
|
|
|
processInput(pasteStart + content + pasteEnd);
|
|
|
|
assert.deepStrictEqual(emittedPaste, ["hello world"]);
|
|
assert.deepStrictEqual(emittedSequences, []); // No data events during paste
|
|
});
|
|
|
|
it("should handle paste arriving in chunks", () => {
|
|
processInput("\x1b[200~");
|
|
assert.deepStrictEqual(emittedPaste, []);
|
|
|
|
processInput("hello ");
|
|
assert.deepStrictEqual(emittedPaste, []);
|
|
|
|
processInput("world\x1b[201~");
|
|
assert.deepStrictEqual(emittedPaste, ["hello world"]);
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
});
|
|
|
|
it("should handle paste with input before and after", () => {
|
|
processInput("a");
|
|
processInput("\x1b[200~pasted\x1b[201~");
|
|
processInput("b");
|
|
|
|
assert.deepStrictEqual(emittedSequences, ["a", "b"]);
|
|
assert.deepStrictEqual(emittedPaste, ["pasted"]);
|
|
});
|
|
|
|
it("should handle paste with newlines", () => {
|
|
processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
|
|
|
|
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]);
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
});
|
|
|
|
it("should handle paste with unicode", () => {
|
|
processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
|
|
|
|
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]);
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
});
|
|
});
|
|
|
|
describe("Destroy", () => {
|
|
it("should clear buffer on destroy", () => {
|
|
processInput("\x1b[<35");
|
|
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
|
|
|
buffer.destroy();
|
|
assert.strictEqual(buffer.getBuffer(), "");
|
|
});
|
|
|
|
it("should clear pending timeouts on destroy", async () => {
|
|
processInput("\x1b[<35");
|
|
buffer.destroy();
|
|
|
|
// Wait longer than timeout
|
|
await wait(15);
|
|
|
|
// Should not have emitted anything
|
|
assert.deepStrictEqual(emittedSequences, []);
|
|
});
|
|
});
|
|
});
|