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 rust/Cargo.lock

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

27 changes: 27 additions & 0 deletions rust/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,32 @@ itself says it is a path (separator, `~`, `file://`, provider URI or line suffix
file extensions or touches the file system: the host checks existence, owns hit regions, and opens the
target. At most 256 texts of 4096 bytes. See `rust/fixtures/terminal/link-target-v1.json`.

## Output styles

`outputStyle.decode` accepts `{text, state?}` and returns `{lines: Line[], state}`. A host sends each chunk of one
console's output as it arrives, passing back the `state` the previous answer returned (none for the first chunk), and
keeps only plain text plus styled ranges, so copying, search, output links and the IDE API never see an escape. A
`Line` is `{text, spans?}`: the text without its line ending and `{start, length, style}` ranges in UTF-16 code units,
ascending and non-overlapping; text outside every range is plain. A `style` has optional `foreground` and `background`
(`{index: 0-255}` or `{rgb: "#rrggbb"}`) and the flags `bold`, `dim`, `italic`, `underline`, `inverse`, `hidden` and
`strikethrough`, each omitted when unset. `lines` are the lines the chunk ended, oldest first, the first continuing the
previous open line; `state.line` is the line still open, which the host shows after them. The rest of the state (the
style in effect, an unfinished escape sequence and a pending carriage return) is opaque to the host, and a state Core
did not return is `invalid_request`.

SGR (`ESC [ … m`) sets the style across chunks and lines: 0 resets; 1, 2, 3, 4 (`4:0` off), 7, 8, 9 and 21 set
attributes and 22–29 clear them; 30–37, 90–97, 40–47 and 100–107 pick palette colours 0–15, and 39 and 49 restore the
defaults; 38 and 48 take `5;n` or `2;r;g;b`, also in the colon forms `38:5:n`, `38:2:r:g:b` and `38:2::r:g:b`; 58 is
consumed and ignored, and an invalid colour keeps the current one. `\n` ends a line. `\r` makes the next printed
character replace the line, so `\r\n` is a plain line ending and progress rewrites keep their last state; `ESC [ 2 K`,
and `ESC [ K` right after a carriage return, clear the line; `ESC [ G` (column 1) acts as `\r`. Private (`<=>?`) and
intermediate-byte sequences, every other control sequence, OSC/DCS/SOS/PM/APC strings (up to BEL or `ESC \`, keeping
the text between OSC 8 hyperlink markers), other escapes, and control characters other than tab are dropped. The host
maps palette entries 0–15 to its theme and 16–255 to the xterm cube and grey ramp, swaps colours for `inverse` and
draws `hidden` text in the background colour. A chunk is at most 1 MiB; lines longer than 16 KiB continue on the next
line, and a line keeps at most 512 ranges. Lives in `rust/crates/terminal`; see
`rust/fixtures/terminal/output-style-v1.json`.

The Rust core is the shared application runtime for macOS SwiftUI and Windows
React/Tauri. macOS calls the stable C ABI while the Tauri host links the Rust
crate directly. The C ABI remains:
Expand Down Expand Up @@ -567,6 +593,7 @@ or removing a command fails until the table changes in the same commit.
| `workspace.visibilityDefaults` | Return the built-in hidden-path lists (from `lithe-core-rules`) |
| `workbench.chromeLayout.reduce` | Apply one transition to the rearrangeable workbench chrome: stripe, title bar and status bar button placement, hidden stripe buttons and open tool panels (from `lithe-core-rules`) |
| `terminal.parseLinkTarget` | Resolve terminal link candidates into file or external targets |
| `outputStyle.decode` | Decode one chunk of console output with ANSI escapes into plain lines and styled ranges, carrying the console's state to the next chunk |
| `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame |
| `debug.launch` | Queue a launch or attach request, including during initialization |
| `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters |
Expand Down
5 changes: 5 additions & 0 deletions rust/crates/core/src/protocol/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ pub enum CoreCommand {
WorkbenchChromeLayoutReduce,
/// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`).
TerminalParseLinkTarget,
/// Decodes one chunk of console output with ANSI escapes into plain lines and styled ranges
/// (`outputStyle.decode`), from `lithe-terminal`.
OutputStyleDecode,
/// Language server sessions, documents, edits, requests and events (`lsp.*`).
Lsp(LspCommand),
/// Normalizes the stored proxy settings into the policy a network stack
Expand Down Expand Up @@ -1220,6 +1223,7 @@ impl CoreCommand {
}
"workbench.chromeLayout.reduce" => Some(Self::WorkbenchChromeLayoutReduce),
"terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget),
"outputStyle.decode" => Some(Self::OutputStyleDecode),
"debug.createSession" => Some(Self::Debug(DebugCommand::CreateSession)),
"debug.launch" => Some(Self::Debug(DebugCommand::Launch)),
"debug.steppingFilters" => Some(Self::Debug(DebugCommand::SteppingFilters)),
Expand Down Expand Up @@ -1677,6 +1681,7 @@ mod tests {
"debug.decodeBackendConfiguration",
"outputLinks.match",
"outputLinks.decodeResolution",
"outputStyle.decode",
] {
assert!(CoreCommand::parse(command).is_some(), "missing {command}");
}
Expand Down
16 changes: 16 additions & 0 deletions rust/crates/core/src/runtime/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,22 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon
Err(error) => CoreResponse::failure(id, error),
}
}
CoreCommand::OutputStyleDecode => {
let result =
serde_json::from_value::<lithe_terminal::DecodeOutputStyleRequest>(parsed.payload)
.map_err(|error| {
CoreError::new(ErrorCode::InvalidRequest, "Invalid output style request")
.with_details(error.to_string())
})
.and_then(lithe_terminal::decode_output_style);
match result {
Ok(data) => CoreResponse::success(
id,
serde_json::to_value(data).expect("output style result should encode"),
),
Err(error) => CoreResponse::failure(id, error),
}
}
CoreCommand::MarkdownRender => {
match serde_json::from_value::<MarkdownRenderRequest>(parsed.payload).map_err(|error| {
CoreError::new(ErrorCode::InvalidRequest, "Invalid Markdown render request")
Expand Down
35 changes: 35 additions & 0 deletions rust/crates/core/src/tests/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,3 +540,38 @@ fn terminal_link_fixtures_cross_the_json_boundary() {
);
}
}

#[test]
fn output_style_fixtures_cross_the_json_boundary() {
// Each case replays its chunks with the state the previous answer returned, as a host does.
let fixture: Value = serde_json::from_str(include_str!(
"../../../../fixtures/terminal/output-style-v1.json"
))
.unwrap();
for case in fixture["cases"].as_array().unwrap() {
let mut lines = Vec::new();
let mut state = Value::Null;
for chunk in case["chunks"].as_array().unwrap() {
let mut request = serde_json::json!({ "text": chunk });
if !state.is_null() {
request["state"] = state;
}
let result = call_fixture_command("outputStyle.decode", request);
assert_eq!(result["ok"], true, "{}: {result}", case["name"]);
lines.extend(result["data"]["lines"].as_array().unwrap().iter().cloned());
state = result["data"]["state"].clone();
}
assert_eq!(Value::Array(lines), case["lines"], "{}", case["name"]);
assert_eq!(state["line"], case["open"], "{}", case["name"]);
}
}

#[test]
fn output_style_rejects_a_state_it_did_not_return() {
let result = call_fixture_command(
"outputStyle.decode",
serde_json::json!({ "text": "x", "state": { "line": { "text": "a" }, "pending": "not an escape" } }),
);
assert_eq!(result["ok"], false);
assert_eq!(result["error"]["code"], "invalid_request");
}
5 changes: 4 additions & 1 deletion rust/crates/terminal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ name = "lithe-terminal"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Terminal output link detection shared by every Lithe host"
description = "Terminal output link detection and ANSI style decoding shared by every Lithe host"

[dependencies]
lithe-protocol = { path = "../protocol" }
serde = { version = "1.0", features = ["derive"] }

[dev-dependencies]
serde_json = { version = "1.0", features = ["preserve_order"] }
5 changes: 5 additions & 0 deletions rust/crates/terminal/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
//! Terminal output interpretation shared by the native hosts.

mod link;
mod style;

pub use link::{parse_link_targets, ParseLinkTargetsRequest};
pub use style::{
decode_output_style, DecodeOutputStyleRequest, DecodeOutputStyleResponse, OutputColor,
OutputLine, OutputSpan, OutputStyle, OutputStyleState, MAX_LINE_BYTES, MAX_SPANS_PER_LINE,
};
Loading
Loading