Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pty-early-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Keep output and exit status of short-lived terminal commands that finish before the terminal session attaches its listeners
6 changes: 4 additions & 2 deletions .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ jobs:
needs: check-paths
if: needs.check-paths.outputs.matched == 'true'
name: Visual Regression (kilo-ui) # kilocode_change
runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change
# kilocode_change: temporary GitHub-hosted runner while Blacksmith apt mirror connectivity is broken, see Blacksmith report for run 34574732611
runs-on: ubuntu-24.04 # kilocode_change
timeout-minutes: 15

steps:
Expand Down Expand Up @@ -221,7 +222,8 @@ jobs:
needs: check-paths
if: needs.check-paths.outputs.matched == 'true'
name: Visual Regression (kilo-vscode webview) # kilocode_change
runs-on: ${{ github.repository == 'Kilo-Org/kilocode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} # kilocode_change
# kilocode_change: temporary GitHub-hosted runner while Blacksmith apt mirror connectivity is broken, see Blacksmith report for run 34574732611
runs-on: ubuntu-24.04 # kilocode_change
timeout-minutes: 15
env:
NODE_OPTIONS: --max-old-space-size=4096
Expand Down
19 changes: 10 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"solid-js@1.9.12": "patches/solid-js@1.9.12.patch"
"solid-js@1.9.12": "patches/solid-js@1.9.12.patch",
"bun-pty@0.4.8": "patches/bun-pty@0.4.8.patch"
},
"version": "7.6.2",
"peerDependencies": {}
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/kilocode/pty/latch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Disp, Exit, Proc } from "../../pty/pty"

// bun-pty emits data and exit from its read loop and drops events that fire before a listener
// is attached. A short-lived child can exit in the gap between spawn and the Pty service
// registering its listeners, so buffer early events and replay them once a listener attaches.
// Replay runs in a microtask so the caller finishes wiring the session before it observes them.
function attach<T>(
early: Disp,
buffer: T[],
subscribe: (listener: (event: T) => void) => Disp,
listener: (event: T) => void,
): Disp {
early.dispose()
const disp = subscribe(listener)
const state = { live: true }
queueMicrotask(() => {
if (!state.live) return
for (const event of buffer.splice(0)) listener(event)
})
return {
dispose() {
state.live = false
disp.dispose()
},
}
}

export function latch(proc: Proc): Proc {
const data: string[] = []
const exit: Exit[] = []
const early = {
data: proc.onData((chunk) => data.push(chunk)),
exit: proc.onExit((event) => exit.push(event)),
}
return {
...proc,
onData: (listener) => attach(early.data, data, (fn) => proc.onData(fn), listener),
onExit: (listener) => attach(early.exit, exit, (fn) => proc.onExit(fn), listener),
}
}
7 changes: 5 additions & 2 deletions packages/core/src/pty/pty.bun.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { spawn as create } from "bun-pty"
import { latch } from "../kilocode/pty/latch" // kilocode_change
import type { Opts, Proc } from "./pty"

export type { Disp, Exit, Opts, Proc } from "./pty"

export function spawn(file: string, args: string[], opts: Opts): Proc {
const pty = create(file, args, opts)
return {
// kilocode_change start - bun-pty drops events emitted before listeners attach
return latch({
// kilocode_change end
pid: pty.pid,
onData(listener) {
return pty.onData(listener)
Expand All @@ -22,5 +25,5 @@ export function spawn(file: string, args: string[], opts: Opts): Proc {
kill(signal) {
pty.kill(signal)
},
}
}) // kilocode_change
}
36 changes: 36 additions & 0 deletions packages/core/test/kilocode/pty-latch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { spawn } from "../../src/pty/pty.bun"

const run = process.platform === "win32" ? test.skip : test

// bun-pty fires each event once from its read loop. Without the latch, a child that exits
// before the caller attaches listeners loses both its output and its exit (0/20 delivered).
run("replays output and exit to listeners attached after the child exited", async () => {
const proc = spawn("sh", ["-c", 'printf "early"; exit 7'], {
name: "xterm",
cwd: "/tmp",
env: { PATH: process.env.PATH ?? "" },
})
await Bun.sleep(300)

const chunks: string[] = []
const exit = Promise.withResolvers<{ exitCode: number }>()
proc.onData((chunk) => chunks.push(chunk))
proc.onExit((event) => exit.resolve(event))

const timeout = Bun.sleep(3000).then(() => {
throw new Error("timed out waiting for replayed exit")
})
expect(await Promise.race([exit.promise, timeout])).toEqual({ exitCode: 7 })
expect(chunks.join("")).toContain("early")
})

run("does not replay to a listener disposed before the microtask runs", async () => {
const proc = spawn("sh", ["-c", "exit 0"], { name: "xterm", cwd: "/tmp", env: { PATH: process.env.PATH ?? "" } })
await Bun.sleep(300)

const seen: unknown[] = []
proc.onExit((event) => seen.push(event)).dispose()
await Bun.sleep(50)
expect(seen).toEqual([])
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions packages/tui/test/cli/tui/question-custom-answer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// kilocode_change - new file
/** @jsxImportSource @opentui/solid */
import { TextareaRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
Expand All @@ -12,9 +13,9 @@ import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createEventSource } from "../../fixture/tui-sdk"

async function wait(fn: () => boolean, timeout = 2000) {
async function wait(fn: () => boolean | Promise<boolean>, timeout = 5000) {
const start = Date.now()
while (!fn()) {
while (!(await fn())) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
Expand Down Expand Up @@ -104,8 +105,11 @@ async function mount(input: { root: string; requests: { path: string; body: unkn
}

async function openCustomEditor(prompt: Awaited<ReturnType<typeof mount>>) {
await prompt.app.renderOnce()
await Bun.sleep(50)
// The provider tree mounts the prompt asynchronously, so render until the options are on screen.
await wait(async () => {
await prompt.app.renderOnce()
return prompt.app.captureCharFrame().includes("Type your own answer")
})
await prompt.app.flush()
prompt.app.mockInput.pressArrow("down")
await prompt.app.flush()
Expand Down
15 changes: 15 additions & 0 deletions patches/bun-pty@0.4.8.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diff --git a/src/terminal.ts b/src/terminal.ts
index ec248d46a939f8a09cd669e853cefb126922c80a..3d23a1e4e63d439274588bf4dfb16f4c5c8539d3 100644
--- a/src/terminal.ts
+++ b/src/terminal.ts
@@ -172,7 +172,9 @@ export class Terminal implements IPty {
if (this.handle < 0) throw new Error("PTY spawn failed");

this._pid = lib.symbols.bun_pty_get_pid(this.handle);
- this._startReadLoop();
+ // Defer the first read so listeners attached right after spawn() see output
+ // and exit that the reader thread already queued while the child started.
+ queueMicrotask(() => this._startReadLoop());
}

/* ------------- accessors ------------- */
Loading