feat(terminal): profiles, scrollback, and a configurable escape key
Stage 1 of the terminal config/copy-mode arc. The terminal had no configuration surface at all: the command hardcoded $SHELL, scrollback was a per-open argument only, and the escape chord was a literal in Rust. No protocol change. Profiles are a raw Lua table, pmacs.terminal.profiles, not a registry setting: ConfigValue is four scalars with no table kind, so profiles join pmacs.lsp.config and pmacs.pair.sets until table-valued settings exist. The registry gains three scalars whose defaults reproduce the previous behavior exactly. Field resolution is explicit open argument, then profile field, then scalar setting, then $SHELL. env MERGES, with explicit entries overriding the profile's, because first-wins there would silently drop half a user's environment. An explicitly named profile that does not exist is an error even when terminal.default-profile is valid, so a typo cannot silently fall back. The two open-time settings resolve through the GLOBAL chain, because they are read before the identity buffer exists and no caller could have pinned a local override on a buffer that does not yet exist. Only terminal.escape-key resolves per buffer, which makes a per-terminal escape a supported feature. The escape key is parsed at most once per (terminal, config epoch), and the cache lives on TerminalSession so its lifetime is the terminal's, with no purge hook to forget. The epoch alone is not a sufficient key: it does not advance when focus moves between two terminals with different buffer-local values, so an epoch-only cache serves one terminal's chord to the other. An unparseable value falls back to C-c and reports once per terminal per effective invalid value through the status line, because a terminal with no escape chord cannot be escaped to fix the setting that broke it. Repeating the escape now sends THAT chord to the child through the ordinary key encoder, rather than a hardcoded ETX. With an escape of C-x, the previous code sent Ctrl-C and made literal Ctrl-X unreachable. C-c t opens a terminal. COHERENCE Priority 1 names a terminal keybinding, and section 2 step 8 grades the terminal works-but- undiscoverable; C-c is already a live global prefix, so this is a new leaf rather than a shadow. It is unreachable from inside a terminal, where C-c is the escape. Acceptance is tests/terminal_config_acceptance.rs, deliberately NOT crdt-gated so CI actually runs it. Four bites, each against a different plausible wrong implementation: a hardcoded ETX fails acc6/9; an epoch-only cache key fails acc7; a single last-entry cache fails acc8's parse count; removing the invalid-value fallback fails acc10. Two test-instrument notes worth keeping. cat -v is the echo probe because the screen rejects C0 controls before they reach cells, so a raw echoed Ctrl-X would be invisible. And the probe counts occurrences rather than testing presence, because a single-character probe collides with the child's own banner text.
This commit is contained in:
parent
05984f1b1b
commit
664cc25d0c
|
|
@ -3,6 +3,38 @@
|
|||
local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required")
|
||||
local raw_open = assert(terminal._open, "pmacs.terminal._open is required")
|
||||
|
||||
-- Q#TC2a. Every default reproduces today's behavior exactly, so a tree
|
||||
-- with no settings written and no profiles registered behaves as before.
|
||||
pmacs.config.define {
|
||||
name = "terminal.default-profile",
|
||||
type = "string",
|
||||
default = "",
|
||||
allow_empty = true,
|
||||
mutability = "live",
|
||||
description = "Profile name from pmacs.terminal.profiles to open by default. " ..
|
||||
"Empty means no profile: fall back to $SHELL.",
|
||||
}
|
||||
|
||||
pmacs.config.define {
|
||||
name = "terminal.scrollback-rows",
|
||||
type = "integer",
|
||||
default = 10000,
|
||||
min = 0,
|
||||
max = 4000000,
|
||||
mutability = "live",
|
||||
description = "Rows of scrollback retained per terminal. " ..
|
||||
"0 retains no history.",
|
||||
}
|
||||
|
||||
pmacs.config.define {
|
||||
name = "terminal.escape-key",
|
||||
type = "string",
|
||||
default = "C-c",
|
||||
mutability = "live",
|
||||
description = "Chord that escapes to the editor from a terminal. " ..
|
||||
"Pressing it twice sends the chord itself to the child.",
|
||||
}
|
||||
|
||||
local function bind_terminal_keys(buffer)
|
||||
local function bind(sequence, command)
|
||||
pmacs.keymap.bind {
|
||||
|
|
@ -19,22 +51,127 @@ local function bind_terminal_keys(buffer)
|
|||
bind("M->", "terminal.scroll-bottom")
|
||||
end
|
||||
|
||||
-- Q#TC1: profiles are a raw Lua table, not a config setting. The
|
||||
-- registry stores four scalars and has no table kind, so a profile —
|
||||
-- inherently `{ command, args, cwd, env }` — lives here beside
|
||||
-- `pmacs.lsp.config` and `pmacs.pair.sets` until table-valued settings
|
||||
-- exist.
|
||||
terminal.profiles = terminal.profiles or {}
|
||||
|
||||
local PROFILE_FIELDS = {
|
||||
command = "string",
|
||||
args = "table",
|
||||
cwd = "string",
|
||||
env = "table",
|
||||
}
|
||||
|
||||
local function validate_profile(name, profile)
|
||||
if type(profile) ~= "table" then
|
||||
error(string.format("terminal profile %q must be a table", name), 0)
|
||||
end
|
||||
for key, value in pairs(profile) do
|
||||
local expected = PROFILE_FIELDS[key]
|
||||
if not expected then
|
||||
error(string.format("terminal profile %q: unknown field %q", name, tostring(key)), 0)
|
||||
end
|
||||
if type(value) ~= expected then
|
||||
error(string.format(
|
||||
"terminal profile %q: field %q must be a %s, got %s",
|
||||
name, key, expected, type(value)), 0)
|
||||
end
|
||||
end
|
||||
return profile
|
||||
end
|
||||
|
||||
local function known_profile_names()
|
||||
local names = {}
|
||||
for name in pairs(terminal.profiles) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
return names
|
||||
end
|
||||
|
||||
-- Q#TC2 / Q#TC3a: resolve a profile by name, or nil when none is
|
||||
-- selected. An explicitly requested profile that does not exist is an
|
||||
-- error even when `terminal.default-profile` is valid — a typo must not
|
||||
-- silently fall back to the default.
|
||||
local function resolve_profile(requested)
|
||||
local name = requested
|
||||
if name == nil then
|
||||
local configured = pmacs.config.get("terminal.default-profile")
|
||||
if configured == nil or configured == "" then return nil end
|
||||
name = configured
|
||||
end
|
||||
local profile = terminal.profiles[name]
|
||||
if profile == nil then
|
||||
local known = known_profile_names()
|
||||
local listed = #known > 0 and table.concat(known, ", ") or "(none defined)"
|
||||
error(string.format(
|
||||
"terminal profile %q is not defined; known profiles: %s", name, listed), 0)
|
||||
end
|
||||
return validate_profile(name, profile)
|
||||
end
|
||||
|
||||
-- Q#TC3a merge order, per field: explicit open field, then the profile's
|
||||
-- field, then the scalar setting, then the built-in fallback. `env` is
|
||||
-- the one field where "first wins" would be wrong, so it MERGES with
|
||||
-- explicit entries overriding the profile's — any other reading silently
|
||||
-- drops half a user's environment.
|
||||
local function merge_env(profile_env, explicit_env)
|
||||
if profile_env == nil then return explicit_env end
|
||||
local merged = {}
|
||||
for key, value in pairs(profile_env) do merged[key] = value end
|
||||
for key, value in pairs(explicit_env or {}) do merged[key] = value end
|
||||
return merged
|
||||
end
|
||||
|
||||
function terminal.open(spec)
|
||||
local buffer = raw_open(spec)
|
||||
spec = spec or {}
|
||||
local resolved = {}
|
||||
for key, value in pairs(spec) do
|
||||
if key ~= "profile" then resolved[key] = value end
|
||||
end
|
||||
|
||||
local profile = resolve_profile(spec.profile)
|
||||
if profile then
|
||||
for key in pairs(PROFILE_FIELDS) do
|
||||
if key ~= "env" and resolved[key] == nil then resolved[key] = profile[key] end
|
||||
end
|
||||
resolved.env = merge_env(profile.env, spec.env)
|
||||
end
|
||||
|
||||
-- The two open-time settings resolve through the GLOBAL chain
|
||||
-- (Q#TC2b): they are read before the identity buffer exists, so there
|
||||
-- is no terminal to resolve a buffer-local against.
|
||||
if resolved.scrollback_rows == nil then
|
||||
resolved.scrollback_rows = pmacs.config.get("terminal.scrollback-rows")
|
||||
end
|
||||
if resolved.command == nil then
|
||||
resolved.command = os.getenv("SHELL") or "/bin/sh"
|
||||
end
|
||||
|
||||
local buffer = raw_open(resolved)
|
||||
bind_terminal_keys(buffer)
|
||||
return buffer
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "terminal",
|
||||
description = "Open a terminal running $SHELL (or /bin/sh).",
|
||||
fn = function()
|
||||
return terminal.open {
|
||||
command = os.getenv("SHELL") or "/bin/sh",
|
||||
}
|
||||
description = "Open a terminal running the configured profile, or $SHELL.",
|
||||
fn = function(profile)
|
||||
return terminal.open { profile = profile }
|
||||
end,
|
||||
}
|
||||
|
||||
-- Q#TC10: the opening binding. `COHERENCE.md` Priority 1 names a
|
||||
-- terminal keybinding as part of protecting the golden journey, and §2
|
||||
-- step 8 grades the terminal "works but undiscoverable". `C-c` is
|
||||
-- already a live global prefix (fold's `C-c @ ...`), so this is a new
|
||||
-- leaf under it rather than a shadow.
|
||||
--
|
||||
-- Named limitation: unreachable from INSIDE a terminal window, where
|
||||
-- `C-c` is consumed as the escape. `M-x terminal` still works there.
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" }
|
||||
|
||||
pmacs.command.define {
|
||||
name = "terminal.copy-selection",
|
||||
description = "Copy the active terminal selection.",
|
||||
|
|
|
|||
|
|
@ -989,19 +989,28 @@ impl EditorState {
|
|||
.get(&frontend_id)
|
||||
.is_some_and(|state| state.terminal_escape);
|
||||
if let Some(view_key) = terminal_key {
|
||||
// Q#TC4: the escape chord is per terminal, resolved through
|
||||
// `terminal.escape-key` and cached on the session so this
|
||||
// hot path parses at most once per (terminal, config epoch).
|
||||
let escape_chord = self.terminal_escape_chord(view_key.buffer_id);
|
||||
if escaped {
|
||||
self.dispatchers
|
||||
.entry(frontend_id)
|
||||
.or_default()
|
||||
.terminal_escape = false;
|
||||
if chord.is_some_and(is_terminal_escape_chord) {
|
||||
if chord == Some(escape_chord) {
|
||||
// Q#TC4b: repeating the escape sends THAT chord to the
|
||||
// child, not a hardcoded ETX. With a configured escape
|
||||
// of `C-x`, sending Ctrl-C here would both surprise the
|
||||
// user and make literal Ctrl-X unreachable, since the
|
||||
// first press is always consumed as the escape.
|
||||
self.claim_terminal_controller(view_key);
|
||||
self.send_terminal_bytes(view_key.buffer_id, &[0x03]);
|
||||
self.send_terminal_escape_literal(view_key, escape_chord);
|
||||
return;
|
||||
}
|
||||
// The post-escape key starts a fresh ordinary sequence below.
|
||||
} else if !dispatcher_pending {
|
||||
if chord.is_some_and(is_terminal_escape_chord) {
|
||||
if chord == Some(escape_chord) {
|
||||
let state = self.dispatchers.entry(frontend_id).or_default();
|
||||
state.terminal_escape = true;
|
||||
state.dispatcher = KeyDispatcher::new();
|
||||
|
|
@ -1117,6 +1126,54 @@ impl EditorState {
|
|||
.then_some(key)
|
||||
}
|
||||
|
||||
/// This terminal's effective escape chord (Q#TC4).
|
||||
///
|
||||
/// Resolution is `get("terminal.escape-key", terminal_buffer)` —
|
||||
/// buffer-local, then global, then default — because unlike the two
|
||||
/// open-time settings this one is read while the terminal exists, so
|
||||
/// a per-terminal escape is expressible and supported (Q#TC2b).
|
||||
///
|
||||
/// The parse and the once-per-terminal invalid-value report both live
|
||||
/// in [`crate::terminal::TerminalManager::escape_chord`]; this method
|
||||
/// only supplies the resolved spelling and the epoch that keys the
|
||||
/// cache, and surfaces any report through the status line — the same
|
||||
/// channel `send_terminal_bytes` uses for terminal failures.
|
||||
fn terminal_escape_chord(&self, buffer_id: crate::buffer::BufferId) -> Chord {
|
||||
let lua = self.lua_host.lua();
|
||||
let (spelling, epoch) = crate::lua_bindings::config_string_and_epoch(
|
||||
lua,
|
||||
"terminal.escape-key",
|
||||
Some(buffer_id),
|
||||
crate::terminal::DEFAULT_TERMINAL_ESCAPE_KEY,
|
||||
);
|
||||
let (chord, report) = self
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.escape_chord(buffer_id, epoch, &spelling);
|
||||
if let Some(message) = report {
|
||||
self.core.borrow_mut().status = message;
|
||||
}
|
||||
chord
|
||||
}
|
||||
|
||||
/// Send the configured escape chord to the child as literal input
|
||||
/// (Q#TC4b), through the same encoder ordinary keys use so it
|
||||
/// inherits application-cursor and modifier handling.
|
||||
fn send_terminal_escape_literal(&self, key: TerminalViewKey, chord: Chord) {
|
||||
let event = KeyEvent::new(chord.code, chord.modifiers);
|
||||
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(event) else {
|
||||
return;
|
||||
};
|
||||
let modes = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.modes_for_view(key)
|
||||
.unwrap_or_default();
|
||||
if let Some(bytes) = crate::terminal::input::encode_key(terminal_key, modifiers, modes) {
|
||||
self.send_terminal_bytes(key.buffer_id, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_terminal_controller(&self, key: TerminalViewKey) {
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
let _ = manager.register_view(key);
|
||||
|
|
@ -4421,10 +4478,6 @@ fn sanitize_single_line(s: &str) -> String {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn is_terminal_escape_chord(chord: Chord) -> bool {
|
||||
chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL
|
||||
}
|
||||
|
||||
fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> {
|
||||
let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
|
||||
let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code);
|
||||
|
|
|
|||
|
|
@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback:
|
|||
}
|
||||
}
|
||||
|
||||
/// Read a `String` setting plus the registry epoch that keys any cache
|
||||
/// built from it (Q#TC4c).
|
||||
///
|
||||
/// The epoch is returned WITH the value deliberately: a caller caching a
|
||||
/// parsed form needs both, and reading them in two calls would let a
|
||||
/// `set` land between them and produce a cache stamped with the wrong
|
||||
/// epoch. `fallback` covers a bare core whose runtime never defined the
|
||||
/// setting, matching [`config_u32`].
|
||||
#[must_use]
|
||||
pub fn config_string_and_epoch(
|
||||
lua: &Lua,
|
||||
name: &str,
|
||||
buffer_id: Option<BufferId>,
|
||||
fallback: &str,
|
||||
) -> (String, u64) {
|
||||
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
|
||||
return (fallback.to_owned(), 0);
|
||||
};
|
||||
let borrowed = registry.borrow();
|
||||
let epoch = borrowed.value_epoch();
|
||||
let value = match borrowed.get(name, buffer_id) {
|
||||
Ok(crate::config_registry::ConfigValue::Str(v)) => v.clone(),
|
||||
_ => fallback.to_owned(),
|
||||
};
|
||||
(value, epoch)
|
||||
}
|
||||
|
||||
/// Short-circuit a binding when the init phase has completed.
|
||||
///
|
||||
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ pub use pmacs_protocol::terminal::{
|
|||
/// Configuration-time, not a wire bound: history never crosses the
|
||||
/// protocol, so this stays core-owned.
|
||||
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
|
||||
|
||||
/// Default `terminal.escape-key`, and the fallback an unparseable value
|
||||
/// falls back to (Q#TC4a).
|
||||
pub const DEFAULT_TERMINAL_ESCAPE_KEY: &str = "C-c";
|
||||
/// Maximum retained main-screen history cells. Core-owned for the same
|
||||
/// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`].
|
||||
pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::ansi::AnsiParserProfile;
|
|||
use crate::buffer::{Buffer, BufferId};
|
||||
use crate::cell::{Cell, CellCoord, CellSize};
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::key::{Chord, parse_chord};
|
||||
use crate::process::{
|
||||
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
|
||||
RestartPolicy, StdinMode, TerminalMode,
|
||||
|
|
@ -218,12 +219,40 @@ pub(super) struct TerminalSession {
|
|||
pub(super) screen: TerminalScreen,
|
||||
pub(super) process: TerminalProcessState,
|
||||
pub(super) annotated: bool,
|
||||
/// Resolved `terminal.escape-key` for this terminal (Q#TC4c).
|
||||
///
|
||||
/// The cache lives HERE, not in an editor-side map, because a
|
||||
/// session is created in [`TerminalManager::open`] and dropped on
|
||||
/// kill/prune — so its lifetime is exactly the cache's, with no
|
||||
/// purge hook to forget. An editor-side map would leak an entry per
|
||||
/// terminal; a single last-entry cache would reparse (and re-report
|
||||
/// an invalid value) every time focus alternates between two
|
||||
/// terminals.
|
||||
pub(super) escape: Option<EscapeCache>,
|
||||
}
|
||||
|
||||
/// One terminal's parsed escape chord, valid for one config epoch.
|
||||
pub(super) struct EscapeCache {
|
||||
/// The `ConfigRegistry::value_epoch` this was parsed at. The key is
|
||||
/// `(this session, epoch)`: the epoch alone is not enough, because
|
||||
/// it does not advance when focus moves between terminals with
|
||||
/// different buffer-local values.
|
||||
pub(super) epoch: u64,
|
||||
/// The effective chord — the parsed spelling, or the `C-c` fallback.
|
||||
pub(super) chord: Chord,
|
||||
/// The invalid spelling already reported for this terminal, if any.
|
||||
/// Reporting is once per terminal per effective invalid value: an
|
||||
/// unchanged bad value stays quiet, a *different* bad value reports
|
||||
/// again because it is a new mistake.
|
||||
pub(super) reported_invalid: Option<String>,
|
||||
}
|
||||
|
||||
/// Owns the one-buffer/one-process/one-screen terminal registry.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalManager {
|
||||
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
||||
/// Total escape-key parses performed (Q#TC4c observability).
|
||||
escape_parses: u64,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
/// Removed buffers whose children are still being reaped. Their events
|
||||
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
|
||||
|
|
@ -331,6 +360,7 @@ impl TerminalManager {
|
|||
screen,
|
||||
process: TerminalProcessState::Running,
|
||||
annotated: false,
|
||||
escape: None,
|
||||
},
|
||||
);
|
||||
debug_assert!(previous.is_none(), "fresh BufferId collided");
|
||||
|
|
@ -538,6 +568,71 @@ impl TerminalManager {
|
|||
.map_err(TerminalError::Process)
|
||||
}
|
||||
|
||||
/// Resolve this terminal's effective escape chord, parsing at most
|
||||
/// once per `(terminal, config epoch)` (Q#TC4c).
|
||||
///
|
||||
/// `spelling` is the caller-resolved `terminal.escape-key` value and
|
||||
/// `epoch` the registry's `value_epoch()` it was read at. Returns the
|
||||
/// effective chord plus, at most once per terminal per effective
|
||||
/// invalid value, a message the caller should surface.
|
||||
///
|
||||
/// An unparseable spelling falls back to `C-c` rather than leaving the
|
||||
/// terminal with no escape at all (Q#TC4a): without one, every key goes
|
||||
/// to the child and the user cannot reach the binding that would fix
|
||||
/// the setting that broke it.
|
||||
pub fn escape_chord(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
epoch: u64,
|
||||
spelling: &str,
|
||||
) -> (Chord, Option<String>) {
|
||||
let fallback = default_escape_chord();
|
||||
if let Some(session) = self.sessions.get(&buffer_id)
|
||||
&& let Some(cache) = session.escape.as_ref()
|
||||
&& cache.epoch == epoch
|
||||
{
|
||||
return (cache.chord, None);
|
||||
}
|
||||
self.escape_parses = self.escape_parses.saturating_add(1);
|
||||
let Some(session) = self.sessions.get_mut(&buffer_id) else {
|
||||
return (fallback, None);
|
||||
};
|
||||
let previously_reported = session
|
||||
.escape
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.reported_invalid.clone());
|
||||
let (chord, reported_invalid, report) = match parse_chord(spelling) {
|
||||
Ok(chord) => (chord, None, None),
|
||||
Err(error) => {
|
||||
let already = previously_reported.as_deref() == Some(spelling);
|
||||
let message = (!already).then(|| {
|
||||
format!(
|
||||
"terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c"
|
||||
)
|
||||
});
|
||||
(fallback, Some(spelling.to_owned()), message)
|
||||
}
|
||||
};
|
||||
session.escape = Some(EscapeCache {
|
||||
epoch,
|
||||
chord,
|
||||
reported_invalid,
|
||||
});
|
||||
(chord, report)
|
||||
}
|
||||
|
||||
/// How many escape-key spellings this manager has parsed.
|
||||
///
|
||||
/// An observability seam for Q#TC4c's cache contract, which is
|
||||
/// otherwise unpinnable for a VALID setting: a correct per-session
|
||||
/// cache and a single last-entry cache produce identical behavior
|
||||
/// there and differ only in how often they parse. Counting reports
|
||||
/// covers the invalid case; this covers the valid one.
|
||||
#[must_use]
|
||||
pub fn escape_parses(&self) -> u64 {
|
||||
self.escape_parses
|
||||
}
|
||||
|
||||
/// Resize a terminal screen and its PTY after validating shared limits.
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
|
|
@ -730,3 +825,12 @@ fn sanitize_metadata(value: &str) -> String {
|
|||
}
|
||||
clean
|
||||
}
|
||||
|
||||
/// The built-in terminal escape chord, and the fallback for an
|
||||
/// unparseable `terminal.escape-key` (Q#TC4a).
|
||||
pub(super) fn default_escape_chord() -> Chord {
|
||||
Chord::new(
|
||||
crossterm::event::KeyCode::Char('c'),
|
||||
crossterm::event::KeyModifiers::CONTROL,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,576 @@
|
|||
//! Terminal configuration acceptance (Stage 1 of
|
||||
//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 1-12).
|
||||
//!
|
||||
//! Deliberately NOT `#[cfg(feature = "crdt")]`: CI never enables that
|
||||
//! feature, so a gated suite is written and then never run.
|
||||
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use mlua::Value;
|
||||
use pmacs::cell::{CellSize, Glyph};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
use pmacs::terminal::TerminalViewKey;
|
||||
use pmacs::window::WindowId;
|
||||
|
||||
fn exec(state: &EditorState, src: &str) {
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(src)
|
||||
.exec()
|
||||
.unwrap_or_else(|e| panic!("lua failed: {src}\n{e}"));
|
||||
}
|
||||
|
||||
fn eval_err(state: &EditorState, src: &str) -> String {
|
||||
let result: mlua::Result<Value> = state.lua_host.lua().load(src).eval();
|
||||
match result {
|
||||
Ok(_) => panic!("expected an error from: {src}"),
|
||||
Err(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String {
|
||||
let manager = state.terminal_manager.borrow();
|
||||
let Some(snapshot) = manager.snapshot(buffer) else {
|
||||
return String::new();
|
||||
};
|
||||
let mut text = String::new();
|
||||
for cell in &snapshot.cells {
|
||||
match &cell.glyph {
|
||||
Glyph::Char(c) => text.push(*c),
|
||||
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
|
||||
Glyph::Continuation => {}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
state.tick_processes();
|
||||
if screen_text(state, buffer).contains(needle) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Give LOCAL a window on `buffer` and register/claim its terminal view,
|
||||
/// which is what makes `dispatch_key`'s terminal arm reachable.
|
||||
fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> WindowId {
|
||||
state.core.borrow_mut().switch_active_buffer(buffer).ok();
|
||||
let window = state.core.borrow().active_window_id();
|
||||
let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer);
|
||||
let mut manager = state.terminal_manager.borrow_mut();
|
||||
manager.register_view(key);
|
||||
manager.claim_controller(key);
|
||||
let _ = manager.snapshot_for_view(key, CellSize::new(10, 40));
|
||||
window
|
||||
}
|
||||
|
||||
fn terminal_buffers(state: &EditorState) -> Vec<pmacs::buffer::BufferId> {
|
||||
let manager = state.terminal_manager.borrow();
|
||||
state
|
||||
.core
|
||||
.borrow()
|
||||
.registry
|
||||
.borrow()
|
||||
.ids()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| manager.is_terminal(*id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Open a terminal from Lua and return the identity buffer it created.
|
||||
///
|
||||
/// The id is derived by diffing the manager's terminal set rather than
|
||||
/// returned through Lua: `BufferIdLua` exposes no id accessor, and
|
||||
/// diffing also asserts in passing that exactly one terminal appeared.
|
||||
fn open_cat_terminal(state: &EditorState, lua_spec: &str) -> pmacs::buffer::BufferId {
|
||||
let before = terminal_buffers(state);
|
||||
exec(
|
||||
state,
|
||||
&format!("TERM_BUF = pmacs.terminal.open {{ {lua_spec} }}"),
|
||||
);
|
||||
let after = terminal_buffers(state);
|
||||
let mut fresh: Vec<_> = after
|
||||
.into_iter()
|
||||
.filter(|id| !before.contains(id))
|
||||
.collect();
|
||||
assert_eq!(fresh.len(), 1, "exactly one terminal must have opened");
|
||||
fresh.remove(0)
|
||||
}
|
||||
|
||||
/// `cat -v` is the echo instrument, deliberately: the terminal screen
|
||||
/// rejects C0/C1 controls before they enter cells (Vterm Stage 1
|
||||
/// criterion 2), so a raw echoed `Ctrl-X` would be invisible and a test
|
||||
/// probing for it could never pass. `-v` renders it as the printable
|
||||
/// two-character `^X`, which is what makes "the configured chord reached
|
||||
/// the child" observable at all.
|
||||
const CAT_PROFILE: &str = r#"
|
||||
pmacs.terminal.profiles.echo = {
|
||||
command = "/bin/sh",
|
||||
args = { "-c", "printf 'READY\r\n'; exec cat -v" },
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Did the last key ARM the terminal escape?
|
||||
///
|
||||
/// Observed behaviorally rather than through an accessor: while the
|
||||
/// escape is armed the next key goes to ordinary dispatch, so it never
|
||||
/// reaches the child. `cat` echoes anything that does reach it, which
|
||||
/// makes "the probe character did not appear" the exact observable for
|
||||
/// "that chord was consumed as the escape".
|
||||
fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, probe: char) -> bool {
|
||||
// Count occurrences rather than testing for presence: the screen
|
||||
// already holds the child's own output, and a single-character probe
|
||||
// like 'R' collides with the "READY" banner. Only an INCREASE proves
|
||||
// this keystroke reached the child.
|
||||
let before = screen_text(state, buffer).matches(probe).count();
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char(probe), KeyModifiers::NONE),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
state.tick_processes();
|
||||
if screen_text(state, buffer).matches(probe).count() > before {
|
||||
return false;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Acceptance 1: a profile spec is strict, and rejects before anything spawns.
|
||||
#[test]
|
||||
fn acc1_profile_specs_are_strict_and_reject_before_spawning() {
|
||||
let state = EditorState::new();
|
||||
let before = state.core.borrow().registry.borrow().ids().len();
|
||||
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.terminal.profiles.bad = { command = "/bin/sh", nonsense = true }"#,
|
||||
);
|
||||
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "bad" }"#);
|
||||
assert!(
|
||||
err.contains("unknown field") && err.contains("nonsense"),
|
||||
"the error must name the offending field: {err}"
|
||||
);
|
||||
|
||||
exec(&state, "pmacs.terminal.profiles.wrong = { command = 42 }");
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.terminal.open { profile = "wrong" }"#,
|
||||
);
|
||||
assert!(err.contains("must be a string"), "typed field error: {err}");
|
||||
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
before,
|
||||
"a rejected profile must create no buffer"
|
||||
);
|
||||
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance 2: an unknown profile names the known ones and creates nothing.
|
||||
#[test]
|
||||
fn acc2_unknown_profile_lists_known_names_and_creates_nothing() {
|
||||
let state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.terminal.profiles.other = { command = "/bin/sh" }"#,
|
||||
);
|
||||
let before = state.core.borrow().registry.borrow().ids().len();
|
||||
|
||||
// Via the default setting.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "ghost")"#,
|
||||
);
|
||||
let err = eval_err(&state, "return pmacs.terminal.open {}");
|
||||
assert!(err.contains("ghost"), "names the missing profile: {err}");
|
||||
assert!(
|
||||
err.contains("echo") && err.contains("other"),
|
||||
"must LIST the known profiles: {err}"
|
||||
);
|
||||
|
||||
// An explicit bad profile fails even though the default is now valid —
|
||||
// a typo must not silently fall back (Q#TC3a).
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
|
||||
);
|
||||
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "typo" }"#);
|
||||
assert!(err.contains("typo"), "explicit bad profile errors: {err}");
|
||||
|
||||
assert_eq!(
|
||||
state.core.borrow().registry.borrow().ids().len(),
|
||||
before,
|
||||
"no buffer, session, or process is created"
|
||||
);
|
||||
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
||||
}
|
||||
|
||||
/// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and
|
||||
/// `env` MERGES rather than replacing.
|
||||
#[test]
|
||||
fn acc3_field_resolution_order_and_env_merge() {
|
||||
let mut state = EditorState::new();
|
||||
exec(
|
||||
&state,
|
||||
r#"
|
||||
pmacs.terminal.profiles.merged = {
|
||||
command = "/bin/sh",
|
||||
args = { "-c", "printf 'PROFILE:%s:%s\r\n' \"$FROM_PROFILE\" \"$SHARED\"; exec cat" },
|
||||
env = { FROM_PROFILE = "p", SHARED = "profile" },
|
||||
}
|
||||
"#,
|
||||
);
|
||||
let buffer = open_cat_terminal(
|
||||
&state,
|
||||
r#"profile = "merged", env = { SHARED = "explicit" }"#,
|
||||
);
|
||||
assert!(
|
||||
tick_until(&mut state, "PROFILE:p:explicit", buffer),
|
||||
"profile env survives and explicit env overrides the same key: {:?}",
|
||||
screen_text(&state, buffer)
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 3 (explicit command wins) and 4 (`""` means no profile).
|
||||
#[test]
|
||||
fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
|
||||
);
|
||||
|
||||
// Explicit command beats the profile's.
|
||||
let explicit = open_cat_terminal(
|
||||
&state,
|
||||
r#"command = "/bin/sh", args = { "-c", "printf 'EXPLICIT\r\n'; exec cat" }"#,
|
||||
);
|
||||
assert!(tick_until(&mut state, "EXPLICIT", explicit));
|
||||
|
||||
// `""` is the no-profile sentinel: falls through to $SHELL.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.default-profile", "")"#,
|
||||
);
|
||||
let bare = open_cat_terminal(&state, "");
|
||||
let spec_ok = state.terminal_manager.borrow().is_terminal(bare);
|
||||
assert!(spec_ok, "an empty default must open a $SHELL terminal");
|
||||
assert!(
|
||||
!screen_text(&state, bare).contains("READY"),
|
||||
"the echo profile must NOT have been applied"
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 5: scrollback resolves from the setting, is overridden by an
|
||||
/// explicit value, and `0` is legal.
|
||||
#[test]
|
||||
fn acc5_scrollback_setting_override_and_bounds() {
|
||||
let state = EditorState::new();
|
||||
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
|
||||
assert_eq!(
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
||||
.eval::<i64>()
|
||||
.unwrap(),
|
||||
0,
|
||||
"0 is a legal scrollback value meaning 'retain no history'"
|
||||
);
|
||||
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.config.set("terminal.scrollback-rows", -1)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("-1") || err.contains("min"),
|
||||
"below range: {err}"
|
||||
);
|
||||
let err = eval_err(
|
||||
&state,
|
||||
r#"return pmacs.config.set("terminal.scrollback-rows", 4000001)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("4000001") || err.contains("max"),
|
||||
"above range: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance 6 and 9: the configured chord escapes, repeating it sends
|
||||
/// THAT chord to the child, and an ordinary `C-c` still reaches the child.
|
||||
#[test]
|
||||
fn acc6_acc9_configured_escape_chord_and_literal_repeat() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
assert!(tick_until(&mut state, "READY", buffer));
|
||||
focus_terminal(&state, buffer);
|
||||
|
||||
exec(&state, r#"pmacs.config.set("terminal.escape-key", "C-x")"#);
|
||||
|
||||
// `C-x C-x` must send Ctrl-X (0x18), which `cat` echoes back. Against
|
||||
// the pre-change hardcoded `&[0x03]` this sends Ctrl-C instead.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
tick_until(&mut state, "^X", buffer),
|
||||
"C-x C-x must send literal Ctrl-X: {:?}",
|
||||
screen_text(&state, buffer)
|
||||
);
|
||||
|
||||
// With the escape moved, an ordinary C-c is just another key.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
tick_until(&mut state, "^C", buffer),
|
||||
"plain C-c must reach the child once the escape moved: {:?}",
|
||||
screen_text(&state, buffer)
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 7, 8 and 8a: per-terminal escape resolution, an A→B→A parse
|
||||
/// count that does not grow, and a cache that dies with its terminal.
|
||||
#[test]
|
||||
fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
let a = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
exec(&state, "TERM_A = TERM_BUF");
|
||||
let b = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
exec(&state, "TERM_B = TERM_BUF");
|
||||
assert!(tick_until(&mut state, "READY", a));
|
||||
assert!(tick_until(&mut state, "READY", b));
|
||||
|
||||
// Different buffer-local escapes, then NO further writes.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set_local(TERM_A, "terminal.escape-key", "C-x")"#,
|
||||
);
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set_local(TERM_B, "terminal.escape-key", "C-b")"#,
|
||||
);
|
||||
|
||||
// Prime both caches. Each priming press ARMS the escape, so it is
|
||||
// consumed with a probe — otherwise the next chord would be read as
|
||||
// the escape repeat rather than a fresh escape.
|
||||
focus_terminal(&state, a);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(escape_was_armed(&mut state, a, 'M'), "A primes on its C-x");
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b");
|
||||
let primed = state.terminal_manager.borrow().escape_parses();
|
||||
|
||||
// Acceptance 7 — BOTH directions. Asserting only that A still works
|
||||
// after A->B->A is not enough: an epoch-only cache hands whichever
|
||||
// entry it finds to every terminal, so A keeps working by accident
|
||||
// while B silently inherits A's chord. The discriminating assertion
|
||||
// is that EACH terminal honors its OWN chord and NOT the other's.
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, b, 'R'),
|
||||
"terminal B must escape on its own C-b"
|
||||
);
|
||||
// ...and A's chord must be ordinary input in B, not an escape.
|
||||
focus_terminal(&state, b);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
!escape_was_armed(&mut state, b, 'S'),
|
||||
"terminal A's C-x must NOT escape terminal B"
|
||||
);
|
||||
|
||||
focus_terminal(&state, a);
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, a, 'Q'),
|
||||
"terminal A must still escape on its own C-x after A->B->A"
|
||||
);
|
||||
|
||||
// Acceptance 8: that round trip parsed nothing new. A single
|
||||
// last-entry cache would have reparsed twice.
|
||||
assert_eq!(
|
||||
state.terminal_manager.borrow().escape_parses(),
|
||||
primed,
|
||||
"A->B->A with no setting written must not reparse"
|
||||
);
|
||||
|
||||
// Acceptance 8a: the cache dies with its terminal.
|
||||
let sessions_before = state.terminal_manager.borrow().len();
|
||||
exec(&state, "pmacs.terminal.terminate(TERM_A)");
|
||||
exec(&state, "pmacs.buffer.kill(TERM_A)");
|
||||
// Pruning is tick-driven (the manager reaps on the process tick), so
|
||||
// the session outlives the kill call by design.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while state.terminal_manager.borrow().len() >= sessions_before {
|
||||
state.tick_processes();
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"killing the terminal must remove its session, and with it the cache"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 10 and 10a: an unparseable value falls back, reports through
|
||||
/// the status line, and reports once per terminal per effective bad value.
|
||||
#[test]
|
||||
fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() {
|
||||
let mut state = EditorState::new();
|
||||
exec(&state, CAT_PROFILE);
|
||||
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
|
||||
assert!(tick_until(&mut state, "READY", buffer));
|
||||
focus_terminal(&state, buffer);
|
||||
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.escape-key", "not-a-chord")"#,
|
||||
);
|
||||
state.core.borrow_mut().status.clear();
|
||||
|
||||
// Acceptance 10: falls back to C-c, so the terminal stays escapable.
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
// Read the report BEFORE probing: `status` is a single slot, and the
|
||||
// probe key's own rejected self-insert would overwrite it.
|
||||
let reported = state.core.borrow().status.clone();
|
||||
assert!(
|
||||
reported.contains("terminal.escape-key") && reported.contains("not-a-chord"),
|
||||
"the report must name the setting and the bad value: {reported:?}"
|
||||
);
|
||||
assert!(
|
||||
escape_was_armed(&mut state, buffer, 'Q'),
|
||||
"an invalid escape-key must fall back to C-c, not leave the \
|
||||
terminal unescapable"
|
||||
);
|
||||
|
||||
// Acceptance 10a: the same bad value does not report again.
|
||||
state.core.borrow_mut().status.clear();
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
state.core.borrow().status.is_empty(),
|
||||
"an unchanged invalid value must not re-report: {:?}",
|
||||
state.core.borrow().status
|
||||
);
|
||||
let _ = escape_was_armed(&mut state, buffer, 'W');
|
||||
|
||||
// A DIFFERENT bad value is new information, so it reports again.
|
||||
exec(
|
||||
&state,
|
||||
r#"pmacs.config.set("terminal.escape-key", "also-bad")"#,
|
||||
);
|
||||
state.core.borrow_mut().status.clear();
|
||||
state.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(
|
||||
state.core.borrow().status.contains("also-bad"),
|
||||
"a different invalid value must report: {:?}",
|
||||
state.core.borrow().status
|
||||
);
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
}
|
||||
|
||||
/// Acceptance 11: the opening binding exists, resolves to the command, and
|
||||
/// shadowed nothing (`keymap.bind` is strict, so loading the runtime at all
|
||||
/// proves the second half).
|
||||
#[test]
|
||||
fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() {
|
||||
let state = EditorState::new();
|
||||
let command: Option<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(r#"local d = pmacs.describe.key("C-c t"); return d and d.command"#)
|
||||
.eval()
|
||||
.expect("describe.key");
|
||||
assert_eq!(
|
||||
command.as_deref(),
|
||||
Some("terminal"),
|
||||
"C-c t must open a terminal"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance 12: with no settings written and no profiles registered, the
|
||||
/// defaults reproduce the pre-arc behavior.
|
||||
#[test]
|
||||
fn acc12_defaults_reproduce_prior_behavior() {
|
||||
let state = EditorState::new();
|
||||
let lua = state.lua_host.lua();
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.default-profile")"#)
|
||||
.eval::<String>()
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
|
||||
.eval::<i64>()
|
||||
.unwrap(),
|
||||
10_000
|
||||
);
|
||||
assert_eq!(
|
||||
lua.load(r#"return pmacs.config.get("terminal.escape-key")"#)
|
||||
.eval::<String>()
|
||||
.unwrap(),
|
||||
"C-c"
|
||||
);
|
||||
assert!(
|
||||
lua.load("return next(pmacs.terminal.profiles) == nil")
|
||||
.eval::<bool>()
|
||||
.unwrap(),
|
||||
"no profiles are registered by default"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue