diff --git a/crates/tb-ide/tests/execution.rs b/crates/tb-ide/tests/execution.rs
index 237ac71..e92a0b6 100644
--- a/crates/tb-ide/tests/execution.rs
+++ b/crates/tb-ide/tests/execution.rs
@@ -530,3 +530,35 @@ fn comment_only_source_change_also_requires_revision_choice() {
         DialogKind::ResumeRevision
     ));
 }
+
+#[test]
+fn platform_probe_records_each_basic_key_once_and_keeps_ide_keys_out() {
+    let t = Temp::new();
+    let log = t.0.join("platform-events.log");
+    let source = include_str!("../../../tests/platform/input.frm")
+        .replace("platform-events.log", &log.to_string_lossy());
+    let path = t.0.join("input.frm");
+    fs::write(&path, source).unwrap();
+    let mut a = t.app("");
+    a.load_initial_project(path).unwrap();
+    a.execute(Command::Start);
+    tick(&mut a);
+    key(&mut a, K::Char('a'), M::NONE);
+    tick(&mut a);
+    a.handle(Event::Key(KeyEvent::new_with_kind(
+        K::Char('a'),
+        M::NONE,
+        crossterm::event::KeyEventKind::Release,
+    )));
+    tick(&mut a);
+    let events = fs::read_to_string(&log).unwrap();
+    assert_eq!(
+        events.replace('\r', ""),
+        "Down: 97 : 0 \nPress: 97 : 0 \nUp: 97 : 0 \n"
+    );
+    edit_window(&mut a);
+    key(&mut a, K::F(1), M::NONE);
+    tick(&mut a);
+    assert_eq!(fs::read_to_string(&log).unwrap(), events);
+    assert!(matches!(a.active_window().unwrap().kind, WindowKind::Help));
+}
diff --git a/crates/tb-ui/examples/spike.rs b/crates/tb-ui/examples/spike.rs
index b6b5ff3..38196ed 100644
--- a/crates/tb-ui/examples/spike.rs
+++ b/crates/tb-ui/examples/spike.rs
@@ -1,15 +1,12 @@
 //! Phase-0-Spike: 80×25-Bildschirmpuffer, 16-Farben-Palette, Unicode,
 //! Tastatur- und Mausereignisse.
 //!
-//! Start: `cargo run -p tb-ui --example spike` — Beenden mit Esc.
+//! Start: `cargo run -p tb-ui --features terminal --example spike -- --log events.txt` — Beenden mit Esc.
 //! Getippte Zeichen erscheinen im Eingabebereich; Sondertasten und
 //! Mausereignisse werden in der Statuszeile angezeigt.
 
-use crossterm::event::{
-    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
-};
-use crossterm::execute;
-use std::io::stdout;
+use crossterm::event::{self, Event, KeyCode, KeyEventKind};
+use std::io::{stdout, Write};
 use std::time::Duration;
 use tb_ui::screen::{ScreenWidget, TextScreen};
 
@@ -57,8 +54,24 @@ fn status(s: &mut TextScreen, text: &str) {
 }
 
 fn main() -> anyhow::Result<()> {
-    let mut terminal = ratatui::init();
-    execute!(stdout(), EnableMouseCapture)?;
+    let args: Vec<_> = std::env::args_os().skip(1).collect();
+    anyhow::ensure!(
+        args.is_empty() || (args.len() == 2 && args[0] == "--log"),
+        "Aufruf: spike [--log neue-datei]"
+    );
+    let mut log = if args.len() == 2 {
+        Some(
+            std::fs::OpenOptions::new()
+                .write(true)
+                .create_new(true)
+                .open(&args[1])?,
+        )
+    } else {
+        None
+    };
+    let _guard = tb_ui::terminal::TerminalGuard::enter(stdout())?;
+    let mut terminal = ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(stdout()))?;
+    let mut sequence = 0u64;
 
     let (tw, th) = crossterm::terminal::size()?;
     let mut screen = TextScreen::with_size(tw as usize, th as usize);
@@ -70,7 +83,12 @@ fn main() -> anyhow::Result<()> {
         if !event::poll(Duration::from_millis(100))? {
             continue;
         }
-        match event::read()? {
+        let received = event::read()?;
+        sequence += 1;
+        if let Some(log) = &mut log {
+            writeln!(log, "{sequence}: {received:?}")?;
+        }
+        match received {
             Event::Key(k) if k.kind != KeyEventKind::Release => match k.code {
                 KeyCode::Esc => break,
                 KeyCode::Char(c) => {
@@ -105,7 +123,5 @@ fn main() -> anyhow::Result<()> {
         }
     }
 
-    execute!(stdout(), DisableMouseCapture)?;
-    ratatui::restore();
     Ok(())
 }
diff --git a/crates/tb-ui/src/terminal.rs b/crates/tb-ui/src/terminal.rs
index 77493b2..a8204d4 100644
--- a/crates/tb-ui/src/terminal.rs
+++ b/crates/tb-ui/src/terminal.rs
@@ -11,23 +11,44 @@ use std::io::{self, Write};
 pub struct TerminalGuard<W: Write> {
     writer: W,
     raw: fn(bool) -> io::Result<()>,
+    #[cfg(windows)]
+    modes: Option<ConsoleModes>,
 }
 impl<W: Write> TerminalGuard<W> {
     pub fn enter(writer: W) -> io::Result<Self> {
-        Self::with_raw(writer, |on| {
+        #[cfg(windows)]
+        let modes = ConsoleModes::capture()?;
+        #[cfg(windows)]
+        modes.enable_output()?;
+        #[allow(unused_mut)]
+        let mut guard = Self::with_raw(writer, |on| {
             if on {
                 enable_raw_mode()
             } else {
                 disable_raw_mode()
             }
-        })
+        })?;
+        #[cfg(windows)]
+        {
+            guard.modes = Some(modes);
+        }
+        Ok(guard)
     }
     fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> {
-        let mut guard = Self { writer, raw };
+        let mut guard = Self {
+            writer,
+            raw,
+            #[cfg(windows)]
+            modes: None,
+        };
         guard.resume()?;
         Ok(guard)
     }
     fn resume(&mut self) -> io::Result<()> {
+        #[cfg(windows)]
+        if let Some(modes) = &self.modes {
+            modes.enable_output()?;
+        }
         (self.raw)(true)?;
         execute!(
             self.writer,
@@ -38,15 +59,18 @@ impl<W: Write> TerminalGuard<W> {
         )
     }
     fn suspend(&mut self) -> io::Result<()> {
-        (self.raw)(false)?;
-        execute!(
+        let raw = (self.raw)(false);
+        let output = execute!(
             self.writer,
             ResetColor,
             Show,
             DisableMouseCapture,
             DisableBracketedPaste,
             LeaveAlternateScreen
-        )
+        );
+        #[cfg(windows)]
+        let output = output.and(self.modes.as_ref().map_or(Ok(()), ConsoleModes::restore));
+        raw.and(output)
     }
     /// Foreground child owns cooked terminal input. Always restore the IDE,
     /// including spawn/wait errors and a child interrupted by Ctrl+C.
@@ -91,6 +115,10 @@ impl<W: Write> TerminalGuard<W> {
 }
 impl<W: Write> Drop for TerminalGuard<W> {
     fn drop(&mut self) {
+        #[cfg(windows)]
+        if let Some(modes) = &self.modes {
+            let _ = modes.enable_output();
+        }
         let _ = (self.raw)(false);
         let _ = execute!(
             self.writer,
@@ -103,6 +131,58 @@ impl<W: Write> Drop for TerminalGuard<W> {
     }
 }
 
+// Crossterm stellt unter Windows Raw-/Mausflags pauschal zurück. Der Besitzer
+// muss zusätzlich die tatsächlichen Eingabe- und Ausgabemodi wiederherstellen.
+#[cfg(windows)]
+#[derive(Debug)]
+struct ConsoleModes([(std::os::windows::io::RawHandle, u32); 2]);
+#[cfg(windows)]
+#[link(name = "kernel32")]
+extern "system" {
+    fn GetStdHandle(kind: u32) -> std::os::windows::io::RawHandle;
+    fn GetConsoleMode(handle: std::os::windows::io::RawHandle, mode: *mut u32) -> i32;
+    fn SetConsoleMode(handle: std::os::windows::io::RawHandle, mode: u32) -> i32;
+}
+#[cfg(windows)]
+impl ConsoleModes {
+    fn capture() -> io::Result<Self> {
+        let mut modes = [(std::ptr::null_mut(), 0); 2];
+        for (slot, kind) in modes.iter_mut().zip([(-10i32) as u32, (-11i32) as u32]) {
+            // Borrowed standard handles: valid until the terminal owner exits.
+            slot.0 = unsafe { GetStdHandle(kind) };
+            if unsafe { GetConsoleMode(slot.0, &mut slot.1) } == 0 {
+                return Err(io::Error::last_os_error());
+            }
+        }
+        Ok(Self(modes))
+    }
+    fn enable_output(&self) -> io::Result<()> {
+        // Crossterm caches ANSI support, but a Shell handoff restores the old
+        // output flags. Re-enable VT for each ownership cycle, including re-entry.
+        if crossterm::ansi_support::supports_ansi()
+            && unsafe { SetConsoleMode(self.0[1].0, self.0[1].1 | 0x0004) } == 0
+        {
+            return Err(io::Error::last_os_error());
+        }
+        Ok(())
+    }
+    fn restore(&self) -> io::Result<()> {
+        let mut result = Ok(());
+        for (handle, mode) in self.0 {
+            if unsafe { SetConsoleMode(handle, mode) } == 0 && result.is_ok() {
+                result = Err(io::Error::last_os_error());
+            }
+        }
+        result
+    }
+}
+#[cfg(windows)]
+impl Drop for ConsoleModes {
+    fn drop(&mut self) {
+        let _ = self.restore();
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -149,7 +229,7 @@ mod tests {
             ("exit 7", false, 7),
             (
                 if cfg!(windows) {
-                    "ping -n 30 127.0.0.1 >nul"
+                    "for /L %i in (1,1,1000000000) do @rem"
                 } else {
                     "exec sleep 30"
                 },
@@ -160,10 +240,13 @@ mod tests {
             let out = Output(Rc::default());
             let copy = out.clone();
             let mut guard = TerminalGuard::with_raw(out, raw).unwrap();
-            assert_eq!(
-                guard.shell_with_interrupt(command, || abort).unwrap(),
-                expected
-            );
+            let started = std::time::Instant::now();
+            let code = guard.shell_with_interrupt(command, || abort).unwrap();
+            if !abort {
+                assert_eq!(code, expected);
+            }
+            // Windows TerminateProcess liefert einen Code; Unix ein Signal.
+            assert!(started.elapsed() < std::time::Duration::from_secs(5));
             assert_eq!(RAW.load(Ordering::SeqCst), 1);
             let text = String::from_utf8(copy.0.borrow().clone()).unwrap();
             assert_eq!(text.matches("?1049h").count(), 2);
@@ -187,4 +270,111 @@ mod tests {
             .unwrap()
             .contains("?25h"));
     }
+    /// Run explicitly on Windows; never counts as a Windows Terminal UI test.
+    #[cfg(windows)]
+    #[test]
+    #[ignore = "benötigt Windows mit echter Konsole; isolierter Kindprozess"]
+    fn windows_console_restores_modes() {
+        use std::os::windows::{io::AsRawHandle, process::CommandExt};
+        use std::process::{Command, Stdio};
+        use std::time::{Duration, Instant};
+        const CHILD: &str = "TB_CONSOLE_TEST_CHILD";
+        if std::env::var_os(CHILD).is_none() {
+            let mut child = Command::new(std::env::current_exe().unwrap())
+                .args([
+                    "--exact",
+                    "terminal::tests::windows_console_restores_modes",
+                    "--ignored",
+                    "--nocapture",
+                ])
+                .env(CHILD, "1")
+                .creation_flags(0x00000010) // CREATE_NEW_CONSOLE: never touches the caller's console.
+                .stdin(Stdio::null())
+                .stdout(Stdio::null())
+                .stderr(Stdio::null())
+                .spawn()
+                .unwrap();
+            let deadline = Instant::now() + Duration::from_secs(30);
+            loop {
+                if let Some(status) = child.try_wait().unwrap() {
+                    assert!(
+                        status.success(),
+                        "Isolierte Windows-Konsolenprüfung: {status}"
+                    );
+                    return;
+                }
+                if Instant::now() >= deadline {
+                    let _ = child.kill();
+                    let _ = child.wait();
+                    panic!("Windows-Konsolenprüfung hat die Frist überschritten");
+                }
+                std::thread::sleep(Duration::from_millis(20));
+            }
+        }
+        #[link(name = "kernel32")]
+        extern "system" {
+            fn SetStdHandle(kind: u32, handle: std::os::windows::io::RawHandle) -> i32;
+        }
+        let open = |name| {
+            std::fs::OpenOptions::new()
+                .read(true)
+                .write(true)
+                .open(name)
+                .unwrap()
+        };
+        let input = open("CONIN$");
+        let output = open("CONOUT$");
+        // Standard streams started as NUL; bind to this child's own new console.
+        assert_ne!(
+            unsafe { SetStdHandle((-10i32) as u32, input.as_raw_handle()) },
+            0
+        );
+        assert_ne!(
+            unsafe { SetStdHandle((-11i32) as u32, output.as_raw_handle()) },
+            0
+        );
+        let original = ConsoleModes::capture().unwrap();
+        // Non-default echo flags catch a reset-to-default masquerading as restore.
+        let mode = original.0[0].1 & !0x0004; // ENABLE_ECHO_INPUT
+        assert_ne!(unsafe { SetConsoleMode(input.as_raw_handle(), mode) }, 0);
+        let expected = ConsoleModes::capture().unwrap();
+        let assert_restored = || assert_eq!(ConsoleModes::capture().unwrap().0, expected.0);
+        for (command, abort, code) in [
+            ("exit 0", false, 0),
+            ("exit 7", false, 7),
+            ("for /L %i in (1,1,1000000000) do @rem", true, 0),
+        ] {
+            let mut guard = TerminalGuard::enter(io::stdout()).unwrap();
+            guard.suspend().unwrap();
+            assert_restored();
+            guard.resume().unwrap();
+            if crossterm::ansi_support::supports_ansi() {
+                assert_ne!(ConsoleModes::capture().unwrap().0[1].1 & 0x0004, 0);
+            }
+            let result = guard.shell_with_interrupt(command, || abort).unwrap();
+            if !abort {
+                assert_eq!(result, code);
+            }
+            assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
+            drop(guard);
+            assert_restored();
+        }
+        let error = (|| -> io::Result<()> {
+            let _guard = TerminalGuard::enter(io::stdout())?;
+            Err(io::Error::other("Erzwungener Renderfehler"))
+        })();
+        assert!(error.is_err());
+        assert_restored();
+        struct Broken;
+        impl Write for Broken {
+            fn write(&mut self, _: &[u8]) -> io::Result<usize> {
+                Err(io::Error::other("Initialisierung"))
+            }
+            fn flush(&mut self) -> io::Result<()> {
+                Ok(())
+            }
+        }
+        assert!(TerminalGuard::enter(Broken).is_err());
+        assert_restored();
+    }
 }
diff --git a/tests/support/ide-execution-pty.py b/tests/support/ide-execution-pty.py
index b422070..d43c472 100644
--- a/tests/support/ide-execution-pty.py
+++ b/tests/support/ide-execution-pty.py
@@ -2,6 +2,7 @@
 """Headless Unix PTY smoke: real terminal handoff, child Ctrl+C and IDE resume.
 Run after cargo build -p tb-ide: python3 tests/support/ide-execution-pty.py
 """
+import argparse
 import fcntl
 import os
 from pathlib import Path
@@ -13,10 +14,10 @@ import tempfile
 import termios
 import time
 
-binary = Path(__file__).resolve().parents[2] / 'target/debug/tb'
 
 
-def exercise(abort=False, file_shell=False):
+
+def exercise(binary, transcript_dir=None, abort=False, file_shell=False):
     with tempfile.TemporaryDirectory(prefix='tb-pty-') as directory:
         source = Path(directory) / 'main.bas'
         command = "printf '\\nWAITING_CHILD\\n'; exec sleep 30" if abort else "printf '\\nSHELL_PROOF\\n'; exit 7"
@@ -84,6 +85,9 @@ def exercise(abort=False, file_shell=False):
             assert transcript.count(b'?1049h') >= 2
             assert transcript.count(b'?1049l') >= 2
         finally:
+            if transcript_dir:
+                name = 'file-shell' if file_shell else 'shell-abort' if abort else 'shell-exit'
+                (transcript_dir / (name + '.ansi')).write_bytes(transcript)
             if not reaped:
                 os.killpg(pid, signal.SIGKILL)
                 os.waitpid(pid, 0)
@@ -91,6 +95,18 @@ def exercise(abort=False, file_shell=False):
             os.close(slave)
 
 
-for kwargs in ({}, {'abort': True}, {'file_shell': True}):
-    exercise(**kwargs)
-    print('PASS', kwargs or {'shell_exit': 7})
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--binary', type=Path, default=Path(__file__).resolve().parents[2] / 'target/debug/tb')
+    parser.add_argument('--transcript-dir', type=Path)
+    args = parser.parse_args()
+    binary = args.binary.resolve(strict=True)
+    if args.transcript_dir:
+        args.transcript_dir.mkdir(parents=True, exist_ok=True)
+    for kwargs in ({}, {'abort': True}, {'file_shell': True}):
+        exercise(binary, args.transcript_dir, **kwargs)
+        print('PASS', kwargs or {'shell_exit': 7})
+
+
+if __name__ == '__main__':
+    main()
