blob: dca95cd5be86b4c02d1ec0047791159b3f40b25b [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
mod key;
mod mouse;
mod wire;
pub use key::{Key, Modifier, Modifiers};
pub use mouse::{MouseAction, MouseButton, MouseEvent, ScrollDirection};
use super::{TerminalInputModes, TerminalSize};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("terminal input rejected: {0}")]
pub struct InputError(pub &'static str);
#[derive(Clone, Debug, Eq, PartialEq, JsonSchema)]
#[schemars(with = "RawAction", rename = "TerminalInputAction")]
pub enum InputAction {
Text(String),
Paste(String),
Key { key: Key, modifiers: Modifiers },
Mouse(MouseAction),
}
impl InputAction {
/// Strict client/tool boundary. Provider-default normalization, when needed,
/// belongs to the provider adapter, never to this decoder.
pub fn parse(value: Value) -> Result<Self, InputError> {
let raw: RawAction = serde_json::from_value(value)
.map_err(|_| InputError("invalid action fields or values"))?;
let action = match raw {
RawAction::Text { text } => {
if text.is_empty()
|| text
.chars()
.any(|c| c <= '\u{1f}' || ('\u{7f}'..='\u{9f}').contains(&c))
{
return Err(InputError(
"text must be nonempty and contain no terminal controls",
));
}
Self::Text(text)
}
RawAction::Paste { text } => {
validate_paste(&text)?;
Self::Paste(text)
}
RawAction::Key { key, modifiers } => {
let key = Key::parse(&key)?;
// Reject unsupported combinations independently of live mode.
key.encode(modifiers, false)?;
Self::Key { key, modifiers }
}
RawAction::Mouse {
event,
x,
y,
button,
direction,
modifiers,
} => Self::Mouse(MouseAction::new(event, x, y, button, direction, modifiers)?),
};
Ok(action)
}
}
/// Encodes the entire batch before the caller can perform any native effect.
/// The caller supplies modes and size from one serialized screen cut.
pub fn encode_actions(
actions: &[InputAction],
modes: TerminalInputModes,
size: TerminalSize,
) -> Result<String, InputError> {
encode_actions_with_paste(actions, modes, size, false)
}
/// Paste framing is read from the same native parser cut as keyboard modes.
pub fn encode_actions_with_paste(
actions: &[InputAction],
modes: TerminalInputModes,
size: TerminalSize,
bracketed_paste: bool,
) -> Result<String, InputError> {
if actions.is_empty() {
return Err(InputError("expected at least one input action"));
}
let mut output = String::new();
for action in actions {
let encoded = match action {
InputAction::Text(text) => {
if text.is_empty()
|| text
.chars()
.any(|c| c <= '\u{1f}' || ('\u{7f}'..='\u{9f}').contains(&c))
{
return Err(InputError(
"text must be nonempty and contain no terminal controls",
));
}
output.push_str(text);
continue;
}
InputAction::Paste(text) => {
validate_paste(text)?;
let text = text.replace("\r\n", "\n").replace('\r', "\n");
if bracketed_paste {
format!("\x1b[200~{text}\x1b[201~")
} else {
text.replace('\n', "\r")
}
}
InputAction::Key { key, modifiers } => {
key.encode(*modifiers, modes.application_cursor_keys_mode)?
}
InputAction::Mouse(mouse) => mouse.encode(modes, size)?,
};
output.push_str(&encoded);
}
Ok(output)
}
/// Canonical requested byte count, independent of live modes or terminal size.
/// Admission still encodes again against the actual worker cut before effects.
pub fn encoded_actions_byte_len(actions: &[InputAction]) -> Result<usize, InputError> {
if actions.is_empty() {
return Err(InputError("expected at least one input action"));
}
let mut length = 0usize;
for action in actions {
let bytes = match action {
InputAction::Text(text) => {
if text.is_empty()
|| text
.chars()
.any(|c| c <= '\u{1f}' || ('\u{7f}'..='\u{9f}').contains(&c))
{
return Err(InputError(
"text must be nonempty and contain no terminal controls",
));
}
text.len()
}
InputAction::Paste(text) => {
validate_paste(text)?;
text.len()
.checked_add(12)
.ok_or(InputError("input length overflow"))?
}
InputAction::Key { key, modifiers } => key.encode(*modifiers, false)?.len(),
InputAction::Mouse(mouse) => mouse.encode_sgr().len(),
};
length = length
.checked_add(bytes)
.ok_or(InputError("input length overflow"))?;
}
Ok(length)
}
#[derive(Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum RawAction {
Text {
#[schemars(length(min = 1), regex(pattern = "^[^\\x00-\\x1f\\x7f-\\x9f]+$"))]
text: String,
},
Paste {
#[schemars(
length(min = 1),
regex(pattern = "^[^\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]+$")
)]
text: String,
},
Key {
#[schemars(regex(
pattern = "^(?:[ -~]|enter|escape|tab|backspace|arrow_(?:up|down|left|right)|home|end|insert|delete|page_(?:up|down)|f(?:[1-9]|1[0-2]))$"
))]
key: String,
#[serde(default)]
modifiers: Modifiers,
},
Mouse {
event: mouse::RawEvent,
#[serde(deserialize_with = "coordinate")]
#[schemars(range(max = 9007199254740991u64))]
x: u64,
#[serde(deserialize_with = "coordinate")]
#[schemars(range(max = 9007199254740991u64))]
y: u64,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "MouseButton")]
button: Option<MouseButton>,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "ScrollDirection")]
direction: Option<ScrollDirection>,
#[serde(default)]
modifiers: Modifiers,
},
}
// Missing is allowed; explicit null is not part of the strict wire contract.
fn present<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
T::deserialize(de).map(Some)
}
fn coordinate<'de, D: serde::Deserializer<'de>>(de: D) -> Result<u64, D::Error> {
let value = f64::deserialize(de)?;
if !(0.0..=9_007_199_254_740_991.0).contains(&value) || value.fract() != 0.0 {
return Err(serde::de::Error::custom(
"expected a non-negative safe integer",
));
}
Ok(value as u64)
}
fn validate_paste(text: &str) -> Result<(), InputError> {
if text.is_empty()
|| text
.chars()
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
{
return Err(InputError(
"paste must be nonempty and contain no terminal controls except whitespace",
));
}
Ok(())
}
/// Pointer events from an attached human terminal may race native mode/size changes.
/// Only those explicit unsupported pointer events are ignored; other input stays strict.
pub fn encode_controller_actions(
actions: &[InputAction],
modes: TerminalInputModes,
size: TerminalSize,
bracketed_paste: bool,
) -> Result<String, InputError> {
encoded_actions_byte_len(actions)?;
let applicable: Vec<_> = actions
.iter()
.filter(
|action| !matches!(action, InputAction::Mouse(mouse) if !mouse.applicable(modes, size)),
)
.cloned()
.collect();
if applicable.is_empty() {
return Ok(String::new());
}
encode_actions_with_paste(&applicable, modes, size, bracketed_paste)
}