yoda: lecture seule par onglet, a la Ghostty (toggle_readonly)

Surveiller une session Claude Code ou Modjo sans risquer de lui taper
dedans, ni de lui coller quoi que ce soit.

- Terminal::read_only : input_no_scroll, input_scroll (donc paste) et
  la molette en mode souris n'ecrivent plus rien, et une frappe ne ramene
  plus la vue en bas. Couvre touches legacy et kitty, collage, clic
  milieu, Ctrl+C, gestionnaire de mots de passe, IME, glisser-deposer.
- Terminal::respond : ecriture non bloquee pour ce que le terminal repond
  lui-meme (PtyWrite, couleurs, taille, rapports de focus), sinon une
  application attendrait une reponse qui ne vient pas (CSI ? u de Claude
  Code, DA, DSR...).
- terminal_box : en lecture seule la souris revient au terminal
  (selection, copie et defilement locaux, meme dans une application qui
  la capture).
- Case « Lecture seule » dans Affichage et dans le menu contextuel ;
  action ToggleReadOnly dans les raccourcis (groupe Autre, sans
  raccourci par defaut, comme Ghostty et Konsole) ; cadenas
  changes-prevent-symbolic sur l'onglet, apres les alertes.

Test de bout en bout dans un vrai PTY (cat) : saisie et collage bloques,
reponse transmise, saisie retablie ; il echoue si le verrou est retire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lionel DARNIS 2026-10-01 14:19:02 +02:00
parent db9ee02d8d
commit a035b47e98
7 changed files with 142 additions and 8 deletions

View file

@ -140,6 +140,7 @@ save-output = Save output as text...
monitor-activity = Monitor for activity
monitor-silence = Monitor for silence
monitor-process-finished = Monitor for process finishing
read-only = Read-only
## Notifications
notification-show = Show tab

View file

@ -108,6 +108,7 @@ save-output = Enregistrer la sortie comme texte...
monitor-activity = Surveiller l'activité
monitor-silence = Surveiller le silence
monitor-process-finished = Surveiller la fin du processus
read-only = Lecture seule
## Notifications
notification-show = Afficher l'onglet

View file

@ -115,6 +115,7 @@ fn monitor_icon_name(terminal: &Terminal) -> Option<&'static str> {
Some(MonitorKind::Activity) => Some("dialog-information-symbolic"),
Some(MonitorKind::Silence) => Some("alarm-symbolic"),
Some(MonitorKind::ProcessFinished) => Some("emblem-ok-symbolic"),
None if terminal.read_only => Some("changes-prevent-symbolic"),
None if terminal.monitoring_enabled() => Some("view-reveal-symbolic"),
None => None,
}
@ -390,6 +391,7 @@ pub enum Action {
LayoutLoad,
LayoutSave,
Monitor(MonitorKind),
ToggleReadOnly,
PaneFocusDown,
PaneFocusLeft,
PaneFocusRight,
@ -449,6 +451,7 @@ impl Action {
Self::LayoutLoad => Message::LayoutLoad,
Self::LayoutSave => Message::LayoutSave,
Self::Monitor(monitor_kind) => Message::MonitorToggle(*monitor_kind, entity_opt),
Self::ToggleReadOnly => Message::ReadOnlyToggle(entity_opt),
Self::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Self::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
Self::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
@ -556,6 +559,7 @@ pub enum Message {
ShortcutSearch(String),
MonitorTick,
MonitorToggle(MonitorKind, Option<segmented_button::Entity>),
ReadOnlyToggle(Option<segmented_button::Entity>),
MouseEnter(pane_grid::Pane),
Opacity(u8),
PaneClicked(pane_grid::Pane),
@ -1809,6 +1813,22 @@ impl App {
}
}
fn terminal_read_only(&self, pane: pane_grid::Pane, entity: segmented_button::Entity) -> bool {
self.pane_model
.panes
.get(pane)
.and_then(|tab_model| tab_model.data::<Mutex<Terminal>>(entity))
.is_some_and(|terminal| terminal.lock().unwrap().read_only)
}
fn active_terminal_read_only(&self) -> bool {
let pane = self.pane_model.focused();
self.pane_model
.panes
.get(pane)
.is_some_and(|tab_model| self.terminal_read_only(pane, tab_model.active()))
}
fn active_terminal_monitors(&self) -> (bool, bool, bool) {
let pane = self.pane_model.focused();
if let Some(tab_model) = self.pane_model.panes.get(pane) {
@ -3034,6 +3054,20 @@ impl Application for App {
}
}
}
Message::ReadOnlyToggle(entity_opt) => {
if let Some(tab_model) = self.pane_model.active_mut() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
let mut icon_update = None;
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.read_only = !terminal.read_only;
icon_update = Some(monitor_icon_name(&terminal));
}
if let Some(icon_name) = icon_update {
monitor_update_tab_icon(tab_model, entity, icon_name);
}
}
}
Message::MonitorToggle(monitor_kind, entity_opt) => {
if let Some(tab_model) = self.pane_model.active_mut() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
@ -3718,7 +3752,7 @@ impl Application for App {
let terminal = terminal.lock().unwrap();
let rgb = terminal.effective_color(index);
let text = f(rgb);
terminal.input_no_scroll(text.into_bytes());
terminal.respond(text.into_bytes());
}
}
TermEvent::CursorBlinkingChange => {
@ -3732,7 +3766,7 @@ impl Application for App {
&& let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity)
{
let terminal = terminal.lock().unwrap();
terminal.input_no_scroll(text.into_bytes());
terminal.respond(text.into_bytes());
}
}
TermEvent::ResetTitle => {
@ -3757,7 +3791,7 @@ impl Application for App {
{
let terminal = terminal.lock().unwrap();
let text = f(terminal.size().into());
terminal.input_no_scroll(text.into_bytes());
terminal.respond(text.into_bytes());
}
}
TermEvent::Title(title) => {
@ -4160,6 +4194,7 @@ impl Application for App {
&self.config,
&self.key_binds,
self.active_terminal_monitors(),
self.active_terminal_read_only(),
&self.ssh_hosts,
)]
}
@ -4292,6 +4327,7 @@ impl Application for App {
entity,
link,
self.terminal_monitors(pane, entity),
self.terminal_read_only(pane, entity),
)
}),
)

View file

@ -45,6 +45,7 @@ pub fn context_menu(
entity: segmented_button::Entity,
link: Option<String>,
monitors: (bool, bool, bool),
read_only: bool,
) -> Vec<widget::menu::Tree<Message>> {
let item =
|label: String, action: Action| MenuItem::Button(label, None, TabAction(entity, action));
@ -78,6 +79,7 @@ pub fn context_menu(
monitors.2,
Action::Monitor(MonitorKind::ProcessFinished),
),
check(fl!("read-only"), read_only, Action::ToggleReadOnly),
MenuItem::Divider,
item(fl!("split-horizontal"), Action::PaneSplitHorizontal),
item(fl!("split-vertical"), Action::PaneSplitVertical),
@ -163,6 +165,7 @@ pub fn menu_bar<'a>(
config: &Config,
key_binds: &HashMap<KeyBind, Action>,
monitors: (bool, bool, bool),
read_only: bool,
ssh_hosts: &[String],
) -> Element<'a, Message> {
let mut profile_items = Vec::with_capacity(config.profiles.len());
@ -264,6 +267,12 @@ pub fn menu_bar<'a>(
monitors.2,
Action::Monitor(MonitorKind::ProcessFinished),
),
MenuItem::CheckBox(
fl!("read-only"),
None,
read_only,
Action::ToggleReadOnly,
),
MenuItem::Divider,
MenuItem::Button(
fl!("menu-color-schemes"),

View file

@ -90,6 +90,7 @@ pub enum KeyBindAction {
TabPrev,
TabRename,
ToggleFullscreen,
ToggleReadOnly,
WindowClose,
WindowNew,
ZoomIn,
@ -132,6 +133,7 @@ impl KeyBindAction {
Self::TabPrev => Some(Action::TabPrev),
Self::TabRename => Some(Action::TabRename),
Self::ToggleFullscreen => Some(Action::ToggleFullscreen),
Self::ToggleReadOnly => Some(Action::ToggleReadOnly),
Self::WindowClose => Some(Action::WindowClose),
Self::WindowNew => Some(Action::WindowNew),
Self::ZoomIn => Some(Action::ZoomIn),
@ -295,6 +297,7 @@ pub fn action_label(action: KeyBindAction) -> String {
KeyBindAction::TabPrev => fl!("previous-tab"),
KeyBindAction::TabRename => fl!("rename-tab"),
KeyBindAction::ToggleFullscreen => fl!("toggle-fullscreen"),
KeyBindAction::ToggleReadOnly => fl!("read-only"),
KeyBindAction::WindowClose => fl!("close-window"),
KeyBindAction::WindowNew => fl!("new-window"),
KeyBindAction::ZoomIn => fl!("zoom-in"),
@ -369,7 +372,11 @@ pub fn shortcut_groups() -> Vec<ShortcutGroup> {
KeyBindAction::ZoomReset,
],
});
let mut other_actions = vec![KeyBindAction::ClearScrollback, KeyBindAction::SaveOutput];
let mut other_actions = vec![
KeyBindAction::ClearScrollback,
KeyBindAction::SaveOutput,
KeyBindAction::ToggleReadOnly,
];
#[cfg(feature = "password_manager")]
other_actions.push(KeyBindAction::PasswordManager);
groups.push(ShortcutGroup {

View file

@ -382,6 +382,9 @@ pub struct Terminal {
pub monitor_job_running: bool,
pub attention: Option<AttentionKind>,
notify_last: Option<(Instant, u64)>,
/// [yoda] Lecture seule : aucune saisie de l'utilisateur n'atteint le PTY
/// (touches, collage, souris) ; les réponses du terminal passent toujours.
pub read_only: bool,
pub needs_update: bool,
pub profile_id_opt: Option<ProfileId>,
pub tab_title_override: Option<String>,
@ -514,6 +517,7 @@ impl Terminal {
monitor_job_running: false,
attention: None,
notify_last: None,
read_only: false,
mouse_reporter: Default::default(),
needs_update: true,
notifier,
@ -607,7 +611,7 @@ impl Terminal {
const FOCUS_OUT: &[u8] = b"\x1b[O";
let input = if is_focused { FOCUS_IN } else { FOCUS_OUT };
self.input_no_scroll(input);
self.respond(input);
}
}
}
@ -620,7 +624,18 @@ impl Terminal {
self.with_buffer_mut(|buffer| buffer.set_redraw(redraw));
}
/// Saisie de l'utilisateur vers le PTY ; ignorée en lecture seule.
pub fn input_no_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
if self.read_only {
return;
}
self.notifier.notify(input);
}
/// [yoda] Réponse du terminal lui-même (rapports demandés par
/// l'application, focus) : jamais bloquée par la lecture seule, sinon
/// l'application attendrait une réponse qui ne vient pas.
pub fn respond<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
self.notifier.notify(input);
}
@ -638,6 +653,10 @@ impl Terminal {
}
pub fn input_scroll<I: Into<Cow<'static, [u8]>>>(&self, input: I) {
// En lecture seule, une frappe ne ramène pas non plus la vue en bas
if self.read_only {
return;
}
self.input_no_scroll(input);
self.scroll(TerminalScroll::Bottom);
}
@ -1299,8 +1318,11 @@ impl Terminal {
y,
);
for code in codes {
self.notifier.notify(code);
// Saisie de l'utilisateur : rien en lecture seule
if !self.read_only {
for code in codes {
self.notifier.notify(code);
}
}
} else {
self.scroll_as_arrows(delta);
@ -1513,6 +1535,61 @@ impl Drop for Terminal {
mod tests {
use super::*;
/// Lecture seule, de bout en bout : un vrai `cat` dans un vrai PTY. La
/// saisie bloquée n'arrive pas ; une réponse du terminal, si ; la saisie
/// repasse quand le verrou est levé.
#[cfg(unix)]
#[test]
fn read_only_blocks_input_but_not_replies() {
use std::time::Duration;
let (_panes, pane) = pane_grid::State::new(());
let mut model = segmented_button::Model::<segmented_button::SingleSelect>::default();
let entity = model.insert().id();
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let options = Options {
shell: Some(tty::Shell::new("cat".into(), Vec::new())),
..Options::default()
};
let mut terminal = Terminal::new(
pane,
entity,
event_tx,
Config::default(),
options,
&AppConfig::default(),
crate::terminal_theme::cosmic_dark(),
None,
None,
)
.unwrap();
let wait_for = |terminal: &Terminal, needle: &str| {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if terminal.scrollback_text().contains(needle) {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
false
};
terminal.read_only = true;
terminal.input_scroll(&b"bloque\n"[..]);
terminal.paste("colle\n".to_string());
terminal.respond(&b"repond\n"[..]);
assert!(wait_for(&terminal, "repond"));
// Écrites avant la réponse, la saisie et le collage seraient déjà là
let text = terminal.scrollback_text();
assert!(!text.contains("bloque"), "{text}");
assert!(!text.contains("colle"), "{text}");
terminal.read_only = false;
terminal.input_scroll(&b"libre\n"[..]);
assert!(wait_for(&terminal, "libre"));
}
#[test]
fn block_fill_couvre_toute_la_plage_unicode() {
// U+2580–U+259F : chaque élément de bloc a sa géométrie native.

View file

@ -974,7 +974,10 @@ where
let buffer_size = terminal.with_buffer(|buffer| buffer.size());
let is_app_cursor = terminal.term.lock().mode().contains(TermMode::APP_CURSOR);
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE);
// [yoda] En lecture seule, la souris revient au terminal : sélection et
// défilement locaux, même dans une application qui la capture.
let is_mouse_mode = terminal.term.lock().mode().intersects(TermMode::MOUSE_MODE)
&& !terminal.read_only;
let term_mode = *terminal.term.lock().mode();
match event {
Event::Window(event) => match event {