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
1 change: 1 addition & 0 deletions docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ This board tracks implementation direction for `moonbit-community/tty`. Use
| TTY-19 | done | OSC dynamic color queries | `docs/plans/2026-05-27-osc-dynamic-colors.md`, `color/`, `internal/vt/`, `internal/input/`, root package | root `Tty` can query default foreground, default background, and cursor colors while preserving interleaved input | `moon fmt`, `moon test color`, `moon test internal/vt`, `moon test internal/input`, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` |
| TTY-20 | done | DEC auto wrap mode commands | `docs/plans/2026-05-29-decawm-auto-wrap.md`, `internal/vt/`, root package | root `Tty` can enable and disable DECAWM without adding terminal state or a capability query | `moon fmt`, `moon test internal/vt`, `moon test .`, `moon check`, `moon info`, `git diff --check` |
| TTY-21 | done | Windows console input polling | `docs/plans/2026-06-08-windows-console-input-polling.md`, root package | Windows `Tty::read_event` reads console input records directly and reports resize events without public API changes | `moon fmt`, `moon check`, `moon test .`, `moon test internal/input`, `moon test`, `moon info`, `git diff --check`, Windows manual resize smoke pending |
| TTY-22 | done | Windows input record enum | `docs/plans/2026-06-09-windows-input-record-enum.md`, root package | Windows `INPUT_RECORD` bytes parse into private typed variants, including native mouse records | `moon fmt`, targeted Windows wbtests, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` |
| MVP-2 | done | queued input and shell commands in agent demo | `docs/plans/2026-05-21-agent-queued-shell.md`, `examples/agent` | `Tab` queues input for delayed submission and `!cmd` runs through `moonbitlang/async/process` without background tasks writing directly to tty | `moon fmt`, `moon check examples/agent`, `moon test examples/agent`, `moon check`, `moon test`, `moon info`, `git diff --check` |

## Current Rules
Expand Down
467 changes: 467 additions & 0 deletions docs/plans/2026-06-09-windows-input-record-enum.md

Large diffs are not rendered by default.

31 changes: 21 additions & 10 deletions examples/raw/main.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@ let number_to_xdigit : ReadOnlyArray[Byte] = [
]

///|
fn byte_to_hex_line(byte : Byte) -> Bytes {
let buf : FixedArray[Byte] = FixedArray::make(6, 0)
buf[0] = b'0'
buf[1] = b'x'
buf[2] = number_to_xdigit[byte.to_int() / 16]
buf[3] = number_to_xdigit[byte.to_int() % 16]
buf[4] = b'\r'
buf[5] = b'\n'
buf.unsafe_reinterpret_as_bytes()
fn byte_to_printable_char(byte : Byte) -> Char? {
if byte >= b' ' && byte <= b'~' {
Some(byte.to_char())
} else {
None
}
}

///|
fn byte_to_raw_line(byte : Byte) -> String {
let line = StringBuilder(size_hint=16)
line.write_string("0x")
line.write_char(number_to_xdigit[byte.to_int() / 16].to_char())
line.write_char(number_to_xdigit[byte.to_int() % 16].to_char())
if byte_to_printable_char(byte) is Some(char) {
line.write_string(" ")
line.write_string(char.escape(quote=true))
}
line.write_string("\r\n")
line.to_string()
}

///|
Expand All @@ -34,7 +45,7 @@ async fn run_raw_loop(tty : @tty.Tty) -> Unit {
break
}
tty.write("read ")
tty.write(byte_to_hex_line(byte))
tty.write(byte_to_raw_line(byte))
}
}

Expand Down
10 changes: 6 additions & 4 deletions examples/raw/main_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
///|
test "byte to hex line" {
assert_true(byte_to_hex_line(b'\x00') == b"0x00\r\n")
assert_true(byte_to_hex_line(b'\xab') == b"0xab\r\n")
assert_true(byte_to_hex_line(b'\xff') == b"0xff\r\n")
test "byte to raw line prints printable ascii char" {
assert_true(byte_to_raw_line(b'\x00') == "0x00\r\n")
assert_true(byte_to_raw_line(b'a') == "0x61 'a'\r\n")
assert_true(byte_to_raw_line(b' ') == "0x20 ' '\r\n")
assert_true(byte_to_raw_line(b'\\') == "0x5c '\\\\'\r\n")
assert_true(byte_to_raw_line(b'\xab') == "0xab\r\n")
}
48 changes: 48 additions & 0 deletions internal/input/decoder_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,54 @@ async test "flush escape key" {
})
}

///|
async test "decode delayed escape exposes csi mouse tail as text" {
with_delayed_input(b"\x1b", b"[3;71M", 50, events => {
@debug.assert_eq(
events.read_event(esc_timeout_ms=10),
input_key(@public_input.KeyEvent::new(Escape)),
)
for char in "[3;71M" {
@debug.assert_eq(
events.read_event(esc_timeout_ms=10),
input_key(@public_input.KeyEvent::new(Char(char), text=[char])),
)
}
})
}

///|
async test "decode delayed escape exposes sgr mouse tail as text" {
with_delayed_input(b"\x1b", b"[<3;71;12M", 50, events => {
@debug.assert_eq(
events.read_event(esc_timeout_ms=10),
input_key(@public_input.KeyEvent::new(Escape)),
)
for char in "[<3;71;12M" {
@debug.assert_eq(
events.read_event(esc_timeout_ms=10),
input_key(@public_input.KeyEvent::new(Char(char), text=[char])),
)
}
})
}

///|
async test "decode delayed sgr mouse prefix consumes csi prefix as unknown" {
with_delayed_input(b"\x1b[<", b"3;71;12M", 50, events => {
@debug.assert_eq(
events.read_event(esc_timeout_ms=10),
input_unknown(b"\x1b[<"),
)
for char in "3;71;12M" {
@debug.assert_eq(
events.read_event(esc_timeout_ms=10),
input_key(@public_input.KeyEvent::new(Char(char), text=[char])),
)
}
})
}

///|
async test "decode escape followed by non printable byte" {
with_input(b"\x1b\x80", events => {
Expand Down
69 changes: 69 additions & 0 deletions internal/io/byte_queue.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
///|
#warnings("-14")
struct ByteQueue {
queue : @async.Queue[Byte]
read_buf : @async/io.ReaderBuffer
}

///|
#warnings("-14")
pub fn ByteQueue::new() -> ByteQueue {
{ queue: Queue(kind=Unbounded), read_buf: @async/io.ReaderBuffer::new() }
}

///|
pub fn ByteQueue::close(self : ByteQueue) -> Unit {
self.queue.close(error=@async/io.ReaderClosed, clear=true)
}

///|
pub fn ByteQueue::put_byte(self : ByteQueue, byte : Byte) -> Unit {
guard (try! self.queue.try_put(byte))
}

///|
pub fn ByteQueue::put_data(self : ByteQueue, data : &@async/io.Data) -> Unit {
for byte in data.binary() {
self.put_byte(byte)
}
}

///|
pub impl @async/io.Reader for ByteQueue with fn _get_internal_buffer(self) {
self.read_buf
}

///|
pub impl @async/io.Reader for ByteQueue with fn _direct_read(
self,
dst,
offset~,
max_len~,
) {
if max_len <= 0 {
return 0
}
let first = try self.queue.get() catch {
@async/io.ReaderClosed => return 0
error => raise error
} noraise {
byte => byte
}
dst[offset] = first
let mut len = 1
while len < max_len {
let next = try self.queue.try_get() catch {
@async/io.ReaderClosed => None
error => raise error
} noraise {
byte => byte
}
if next is Some(byte) {
dst[offset + len] = byte
len += 1
} else {
break
}
}
len
}
45 changes: 45 additions & 0 deletions internal/io/byte_queue_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
///|
async test "byte queue drains queued bytes" {
let queue = ByteQueue::new()
queue.put_byte(b'a')
queue.put_byte(b'b')
queue.put_byte(b'c')
let dst = FixedArray::make(4, b'\x00')
let n = queue.read(dst, max_len=4)
@debug.assert_eq(n, 3)
@debug.assert_eq(dst.unsafe_reinterpret_as_bytes()[:n].to_owned(), b"abc")
}

///|
async test "byte queue waits for first byte" {
let queue = ByteQueue::new()
let dst = FixedArray::make(2, b'\x00')
@async.with_task_group() <| group => {
group.spawn_bg() <| () => {
@async.sleep(10)
queue.put_byte(b'x')
}
let n = queue.read(dst, max_len=2)
@debug.assert_eq(n, 1)
@debug.assert_eq(dst.unsafe_reinterpret_as_bytes()[:n].to_owned(), b"x")
}
}

///|
async test "byte queue reports eof when closed" {
let queue = ByteQueue::new()
queue.close()
let dst = FixedArray::make(1, b'\x00')
@debug.assert_eq(queue.read(dst, max_len=1), 0)
}

///|
async test "byte queue writes data" {
let queue = ByteQueue::new()
queue.put_data("ab")
queue.put_data(b"cd")
let dst = FixedArray::make(8, b'\x00')
let n = queue.read(dst, max_len=8)
@debug.assert_eq(n, 4)
@debug.assert_eq(dst.unsafe_reinterpret_as_bytes()[:n].to_owned(), b"abcd")
}
9 changes: 9 additions & 0 deletions internal/io/moon.pkg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {
"moonbitlang/async",
"moonbitlang/async/io" @async/io,
}

import {
"moonbitlang/async",
"moonbitlang/core/debug",
} for "test"
19 changes: 19 additions & 0 deletions internal/io/pkg.generated.mbti
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Generated using `moon info`, DON'T EDIT IT
package "moonbit-community/tty/internal/io"

// Values

// Errors

// Types and methods
type ByteQueue
pub fn ByteQueue::close(Self) -> Unit
pub fn ByteQueue::new() -> Self
pub fn ByteQueue::put_byte(Self, Byte) -> Unit
pub fn ByteQueue::put_data(Self, &@moonbitlang/async/io.Data) -> Unit
pub impl @moonbitlang/async/io.Reader for ByteQueue

// Type aliases

// Traits

Loading
Loading