mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
* fix(terminal): send CSI-u Shift+Enter to kitty TUIs (droid) on Windows (#7620) On Windows, Shift+Enter was always sent as the Alt+Enter byte ESC+CR (added in #2418 for Codex, which reads win32-input-mode and ignores CSI-u). droid speaks the kitty keyboard protocol, parses CSI-u directly, and treats ESC+CR as a plain Enter — so Shift+Enter SUBMITTED the message instead of inserting a newline. droid works in other terminals (Windows Terminal, Warp) because those honor win32-input-mode / kitty; Orca (xterm.js) withholds kitty from local Windows ConPTY panes and emits neither. Make the Windows Shift+Enter byte pane-aware: latch whether a pane's program advertised the kitty keyboard protocol (query CSI ? u, push CSI > .. u, or set CSI = .. u) and send CSI-u (\x1b[13;2u) to those panes, keeping the Codex-compatible ESC+CR for win32-input-mode-only TUIs. Non-Windows is unchanged (always CSI-u). Verified end-to-end against the real droid and Codex CLIs through the actual production functions: droid now newlines, Codex still newlines. * fix(terminal): route Windows Shift+Enter safely for Droid --------- Co-authored-by: Neil <neil@stably.ai> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { createProcessTableSnapshotReader, parseProcessTableRows } from './process-table-snapshot'
|
|
|
|
function deferred<T>(): {
|
|
promise: Promise<T>
|
|
resolve: (v: T) => void
|
|
reject: (e: unknown) => void
|
|
} {
|
|
let resolve!: (v: T) => void
|
|
let reject!: (e: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
describe('process-table-snapshot reader', () => {
|
|
it('collapses concurrent calls into a single ps scan', async () => {
|
|
let scans = 0
|
|
const gate = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return gate.promise
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
const a = reader.getSnapshot()
|
|
const b = reader.getSnapshot()
|
|
const c = reader.getSnapshot()
|
|
gate.resolve('ps-output')
|
|
|
|
expect(await a).toBe('ps-output')
|
|
expect(await b).toBe('ps-output')
|
|
expect(await c).toBe('ps-output')
|
|
// Why: the in-flight promise is shared, so a burst of panes inspecting at
|
|
// once forks `ps` exactly once.
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('reuses the cached snapshot within the TTL window', async () => {
|
|
let scans = 0
|
|
let clock = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return Promise.resolve(`scan-${scans}`)
|
|
},
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
clock = 499
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('rescans once the TTL expires', async () => {
|
|
let scans = 0
|
|
let clock = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return Promise.resolve(`scan-${scans}`)
|
|
},
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
clock = 500
|
|
expect(await reader.getSnapshot()).toBe('scan-2')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('stamps capture time after the scan resolves so a slow ps cannot serve a stale snapshot', async () => {
|
|
let scans = 0
|
|
let clock = 0
|
|
const gate = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return scans === 1 ? gate.promise : Promise.resolve(`scan-${scans}`)
|
|
},
|
|
now: () => clock,
|
|
ttlMs: 500
|
|
})
|
|
|
|
const first = reader.getSnapshot()
|
|
// The scan takes 600ms of wall clock to return — longer than the TTL.
|
|
clock = 600
|
|
gate.resolve('scan-1')
|
|
expect(await first).toBe('scan-1')
|
|
|
|
// capturedAt is stamped at now()=600, so a call at 900 is still within TTL.
|
|
clock = 900
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('does not cache failures and retries on the next call', async () => {
|
|
let scans = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
if (scans === 1) {
|
|
return Promise.reject(new Error('ps timed out'))
|
|
}
|
|
return Promise.resolve('recovered')
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
await expect(reader.getSnapshot()).rejects.toThrow('ps timed out')
|
|
// Why: a transient ps failure must not poison the cache — the next
|
|
// inspection re-scans rather than returning a cached error.
|
|
expect(await reader.getSnapshot()).toBe('recovered')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('forces a post-request scan even when a same-tick cache exists', async () => {
|
|
let scans = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: async () => `scan-${++scans}`,
|
|
now: () => 0
|
|
})
|
|
|
|
expect(await reader.getSnapshot()).toBe('scan-1')
|
|
expect(await reader.getFreshSnapshot()).toBe('scan-2')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('shares same-turn fresh requests but queues one scan after a pre-existing scan', async () => {
|
|
let scans = 0
|
|
const first = deferred<string>()
|
|
const second = deferred<string>()
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: () => {
|
|
scans += 1
|
|
return scans === 1 ? first.promise : second.promise
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
const stale = reader.getSnapshot()
|
|
const freshA = reader.getFreshSnapshot()
|
|
const freshB = reader.getFreshSnapshot()
|
|
expect(scans).toBe(1)
|
|
first.resolve('stale')
|
|
expect(await stale).toBe('stale')
|
|
await Promise.resolve()
|
|
expect(scans).toBe(2)
|
|
second.resolve('fresh')
|
|
expect(await freshA).toBe('fresh')
|
|
expect(await freshB).toBe('fresh')
|
|
expect(scans).toBe(2)
|
|
})
|
|
|
|
it('does not let an ordinary same-turn miss race a queued fresh scan', async () => {
|
|
let scans = 0
|
|
const reader = createProcessTableSnapshotReader({
|
|
runPs: async () => `scan-${++scans}`,
|
|
now: () => 0
|
|
})
|
|
|
|
const fresh = reader.getFreshSnapshot()
|
|
const ordinary = reader.getSnapshot()
|
|
|
|
await expect(Promise.all([fresh, ordinary])).resolves.toEqual(['scan-1', 'scan-1'])
|
|
expect(scans).toBe(1)
|
|
})
|
|
|
|
it('shares one parsed-rows array across a burst so panes do not each re-parse', async () => {
|
|
// Mirrors the POSIX default reader: runPs parses inside the deduped scan, so
|
|
// every caller in the TTL window gets the SAME ProcessTableRow[] instance
|
|
// instead of re-tokenizing identical stdout per pane.
|
|
let parses = 0
|
|
const gate = deferred<ReturnType<typeof parseProcessTableRows>>()
|
|
const reader = createProcessTableSnapshotReader<ReturnType<typeof parseProcessTableRows>>({
|
|
runPs: () => {
|
|
parses += 1
|
|
return gate.promise
|
|
},
|
|
now: () => 0
|
|
})
|
|
|
|
const a = reader.getSnapshot()
|
|
const b = reader.getSnapshot()
|
|
gate.resolve(parseProcessTableRows('100 1 Ss+ /bin/zsh'))
|
|
|
|
const rowsA = await a
|
|
const rowsB = await b
|
|
expect(parses).toBe(1)
|
|
// Reference identity: the burst reuses one parse, not one-per-caller.
|
|
expect(rowsA).toBe(rowsB)
|
|
})
|
|
})
|
|
|
|
describe('parseProcessTableRows', () => {
|
|
it('parses pid/ppid/stat and keeps the full command (including spaces)', () => {
|
|
const rows = parseProcessTableRows(
|
|
['501 1 S /bin/zsh', '600 501 S+ node /path/bin/codex --flag'].join('\n')
|
|
)
|
|
expect(rows).toEqual([
|
|
{ pid: 501, ppid: 1, stat: 'S', command: '/bin/zsh' },
|
|
{ pid: 600, ppid: 501, stat: 'S+', command: 'node /path/bin/codex --flag' }
|
|
])
|
|
})
|
|
|
|
it('tolerates CRLF and skips header/blank/non-matching lines', () => {
|
|
const rows = parseProcessTableRows(' PID PPID STAT COMMAND\r\n42 1 Ss /sbin/launchd\r\n\r\n')
|
|
expect(rows).toEqual([{ pid: 42, ppid: 1, stat: 'Ss', command: '/sbin/launchd' }])
|
|
})
|
|
})
|