diff --git a/fish-rust/src/ffi.rs b/fish-rust/src/ffi.rs
index 0988de7ea..4601d260b 100644
--- a/fish-rust/src/ffi.rs
+++ b/fish-rust/src/ffi.rs
@@ -6,10 +6,12 @@ use ::std::fmt::{self, Debug, Formatter};
 use ::std::pin::Pin;
 #[rustfmt::skip]
 use ::std::slice;
+use crate::env::flags::EnvMode;
 pub use crate::wait_handle::{
     WaitHandleRef, WaitHandleRefFFI, WaitHandleStore, WaitHandleStoreFFI,
 };
-use crate::wchar::wstr;
+use crate::wchar::{wstr, WString};
+use crate::wchar_ffi::WCharFromFFI;
 use autocxx::prelude::*;
 use cxx::SharedPtr;
 use libc::pid_t;
@@ -47,7 +49,9 @@ include_cpp! {
     generate!("wperror")
 
     generate_pod!("pipes_ffi_t")
+    generate!("environment_t")
     generate!("env_stack_t")
+    generate!("env_var_t")
     generate!("make_pipes_ffi")
 
     generate!("valid_var_name_char")
@@ -148,6 +152,66 @@ impl parser_t {
         let job = self.ffi_job_get_from_pid(pid.into());
         unsafe { job.as_ref() }
     }
+
+    /// Helper to get a variable as a string, using the default flags.
+    pub fn var_as_string(&mut self, name: &wstr) -> Option<WString> {
+        self.pin().vars().unpin().get_as_string(name)
+    }
+
+    pub fn get_var_stack(&mut self) -> &mut env_stack_t {
+        self.pin().vars().unpin()
+    }
+
+    pub fn get_var_stack_env(&mut self) -> &environment_t {
+        self.vars_env_ffi()
+    }
+
+    pub fn set_var(&mut self, name: &wstr, value: &[&wstr], flags: EnvMode) -> libc::c_int {
+        self.get_var_stack().set_var(name, value, flags)
+    }
+}
+
+impl environment_t {
+    /// Helper to get a variable as a string, using the default flags.
+    pub fn get_as_string(&self, name: &wstr) -> Option<WString> {
+        self.get_as_string_flags(name, EnvMode::DEFAULT)
+    }
+
+    /// Helper to get a variable as a string, using the given flags.
+    pub fn get_as_string_flags(&self, name: &wstr, flags: EnvMode) -> Option<WString> {
+        self.get_or_null(&name.to_ffi(), flags.bits())
+            .as_ref()
+            .map(|s| s.as_string().from_ffi())
+    }
+}
+
+impl env_stack_t {
+    /// Helper to get a variable as a string, using the default flags.
+    pub fn get_as_string(&self, name: &wstr) -> Option<WString> {
+        self.get_as_string_flags(name, EnvMode::DEFAULT)
+    }
+
+    /// Helper to get a variable as a string, using the given flags.
+    pub fn get_as_string_flags(&self, name: &wstr, flags: EnvMode) -> Option<WString> {
+        self.get_or_null(&name.to_ffi(), flags.bits())
+            .as_ref()
+            .map(|s| s.as_string().from_ffi())
+    }
+
+    /// Helper to set a value.
+    pub fn set_var(&mut self, name: &wstr, value: &[&wstr], flags: EnvMode) -> libc::c_int {
+        use crate::wchar_ffi::{wstr_to_u32string, W0String};
+        let strings: Vec<W0String> = value.iter().map(wstr_to_u32string).collect();
+        let ptrs: Vec<*const u32> = strings.iter().map(|s| s.as_ptr()).collect();
+        self.pin()
+            .set_ffi(
+                &name.to_ffi(),
+                flags.bits(),
+                ptrs.as_ptr() as *const c_void,
+                ptrs.len(),
+            )
+            .into()
+    }
 }
 
 pub fn try_compile(anchored: &wstr, flags: &re::flags_t) -> Pin<Box<re::regex_result_ffi>> {
diff --git a/fish-rust/src/lib.rs b/fish-rust/src/lib.rs
index a90b33f92..59c8990c8 100644
--- a/fish-rust/src/lib.rs
+++ b/fish-rust/src/lib.rs
@@ -30,6 +30,7 @@ mod parse_constants;
 mod redirection;
 mod signal;
 mod smoke;
+mod termsize;
 mod threads;
 mod timer;
 mod tokenizer;
diff --git a/fish-rust/src/termsize.rs b/fish-rust/src/termsize.rs
new file mode 100644
index 000000000..45f1e69c6
--- /dev/null
+++ b/fish-rust/src/termsize.rs
@@ -0,0 +1,338 @@
+// Support for exposing the terminal size.
+use crate::common::assert_sync;
+use crate::env::flags::EnvMode;
+use crate::ffi::{environment_t, parser_t, Repin};
+use crate::flog::FLOG;
+use crate::wchar::{WString, L};
+use crate::wchar_ext::ToWString;
+use crate::wchar_ffi::WCharToFFI;
+use crate::wutil::fish_wcstoi;
+use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
+use std::sync::Mutex;
+
+// A counter which is incremented every SIGWINCH, or when the tty is otherwise invalidated.
+static TTY_TERMSIZE_GEN_COUNT: AtomicU32 = AtomicU32::new(0);
+
+/// Convert an environment variable to an int, or return a default value.
+/// The int must be >0 and <USHRT_MAX (from struct winsize).
+fn var_to_int_or(var: Option<WString>, default: isize) -> isize {
+    match var {
+        Some(s) => {
+            let proposed = fish_wcstoi(s.chars());
+            if let Ok(proposed) = proposed {
+                proposed
+            } else {
+                default
+            }
+        }
+        None => default,
+    }
+}
+
+/// \return a termsize from ioctl, or None on error or if not supported.
+fn read_termsize_from_tty() -> Option<Termsize> {
+    let mut ret: Option<Termsize> = None;
+    // Note: historically we've supported libc::winsize not existing.
+    let mut winsize: libc::winsize = unsafe { std::mem::zeroed() };
+    if unsafe { libc::ioctl(0, libc::TIOCGWINSZ, &mut winsize as *mut libc::winsize) } >= 0 {
+        // 0 values are unusable, fall back to the default instead.
+        if winsize.ws_col == 0 {
+            FLOG!(
+                term_support,
+                L!("Terminal has 0 columns, falling back to default width")
+            );
+            winsize.ws_col = Termsize::DEFAULT_WIDTH as u16;
+        }
+        if winsize.ws_row == 0 {
+            FLOG!(
+                term_support,
+                L!("Terminal has 0 rows, falling back to default height")
+            );
+            winsize.ws_row = Termsize::DEFAULT_HEIGHT as u16;
+        }
+        ret = Some(Termsize::new(
+            winsize.ws_col as isize,
+            winsize.ws_row as isize,
+        ));
+    }
+    ret
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct Termsize {
+    /// Width of the terminal, in columns.
+    pub width: isize,
+
+    /// Height of the terminal, in rows.
+    pub height: isize,
+}
+
+impl Termsize {
+    /// Default width and height.
+    pub const DEFAULT_WIDTH: isize = 80;
+    pub const DEFAULT_HEIGHT: isize = 24;
+
+    /// Construct from width and height.
+    pub fn new(width: isize, height: isize) -> Self {
+        Self { width, height }
+    }
+
+    /// Return a default-sized termsize.
+    pub fn defaults() -> Self {
+        Self::new(Self::DEFAULT_WIDTH, Self::DEFAULT_HEIGHT)
+    }
+}
+
+struct TermsizeData {
+    // The last termsize returned by TIOCGWINSZ, or none if none.
+    last_from_tty: Option<Termsize>,
+    // The last termsize seen from the environment (COLUMNS/LINES), or none if none.
+    last_from_env: Option<Termsize>,
+    // The last-seen tty-invalidation generation count.
+    // Set to a huge value so it's initially stale.
+    last_tty_gen_count: u32,
+}
+
+impl TermsizeData {
+    const fn defaults() -> Self {
+        Self {
+            last_from_tty: None,
+            last_from_env: None,
+            last_tty_gen_count: u32::max_value(),
+        }
+    }
+
+    /// \return the current termsize from this data.
+    fn current(&self) -> Termsize {
+        // This encapsulates our ordering logic. If we have a termsize from a tty, use it; otherwise use
+        // what we have seen from the environment.
+        if let Some(ts) = self.last_from_tty {
+            ts
+        } else if let Some(ts) = self.last_from_env {
+            ts
+        } else {
+            Termsize::defaults()
+        }
+    }
+
+    /// Mark that our termsize is (for the time being) from the environment, not the tty.
+    fn mark_override_from_env(&mut self, ts: Termsize) {
+        self.last_from_env = Some(ts);
+        self.last_from_tty = None;
+        self.last_tty_gen_count = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed);
+    }
+}
+
+/// Termsize monitoring is more complicated than one may think.
+/// The main source of complexity is the interaction between the environment variables COLUMNS/ROWS,
+/// the WINCH signal, and the TIOCGWINSZ ioctl.
+/// Our policy is "last seen wins": if COLUMNS or LINES is modified, we respect that until we get a
+/// SIGWINCH.
+pub struct TermsizeContainer {
+    // Our lock-protected data.
+    data: Mutex<TermsizeData>,
+
+    // An indication that we are currently in the process of setting COLUMNS and LINES, and so do
+    // not react to any changes.
+    setting_env_vars: AtomicBool,
+
+    /// A function used for accessing the termsize from the tty. This is only exposed for testing.
+    tty_size_reader: fn() -> Option<Termsize>,
+}
+
+impl TermsizeContainer {
+    /// \return the termsize without applying any updates.
+    /// Return the default termsize if none.
+    pub fn last(&self) -> Termsize {
+        self.data.lock().unwrap().current()
+    }
+
+    /// Initialize our termsize, using the given environment stack.
+    /// This will prefer to use COLUMNS and LINES, but will fall back to the tty size reader.
+    /// This does not change any variables in the environment.
+    pub fn initialize(&mut self, vars: &environment_t) -> Termsize {
+        let new_termsize = Termsize {
+            width: var_to_int_or(vars.get_as_string_flags(L!("COLUMNS"), EnvMode::GLOBAL), -1),
+            height: var_to_int_or(vars.get_as_string_flags(L!("LINES"), EnvMode::GLOBAL), -1),
+        };
+
+        let mut data = self.data.lock().unwrap();
+        if new_termsize.width > 0 && new_termsize.height > 0 {
+            data.mark_override_from_env(new_termsize);
+        } else {
+            data.last_tty_gen_count = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed);
+            data.last_from_tty = (self.tty_size_reader)();
+        }
+        data.current()
+    }
+
+    /// If our termsize is stale, update it, using \p parser firing any events that may be
+    /// registered for COLUMNS and LINES.
+    /// \return the updated termsize.
+    pub fn updating(&mut self, parser: &mut parser_t) -> Termsize {
+        let new_size;
+        let prev_size;
+
+        // Take the lock in a local region.
+        // Capture the size before and the new size.
+        {
+            let mut data = self.data.lock().unwrap();
+            prev_size = data.current();
+
+            // Critical read of signal-owned variable.
+            // This must happen before the TIOCGWINSZ ioctl.
+            let tty_gen_count: u32 = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed);
+            if data.last_tty_gen_count != tty_gen_count {
+                // Our idea of the size of the terminal may be stale.
+                // Apply any updates.
+                data.last_tty_gen_count = tty_gen_count;
+                data.last_from_tty = (self.tty_size_reader)();
+            }
+            new_size = data.current();
+        }
+
+        // Announce any updates.
+        if new_size != prev_size {
+            self.set_columns_lines_vars(new_size, parser);
+        }
+        new_size
+    }
+
+    fn set_columns_lines_vars(&mut self, val: Termsize, parser: &mut parser_t) {
+        let saved = self.setting_env_vars.swap(true, Ordering::Relaxed);
+        parser.pin().set_var_and_fire(
+            &L!("COLUMNS").to_ffi(),
+            EnvMode::GLOBAL.bits(),
+            val.width.to_wstring().to_ffi(),
+        );
+        parser.pin().set_var_and_fire(
+            &L!("LINES").to_ffi(),
+            EnvMode::GLOBAL.bits(),
+            val.height.to_wstring().to_ffi(),
+        );
+        self.setting_env_vars.store(saved, Ordering::Relaxed);
+    }
+
+    /// Note that COLUMNS and/or LINES global variables changed.
+    fn handle_columns_lines_var_change(&self, vars: &environment_t) {
+        // Do nothing if we are the ones setting it.
+        if self.setting_env_vars.load(Ordering::Relaxed) {
+            return;
+        }
+        // Construct a new termsize from COLUMNS and LINES, then set it in our data.
+        let new_termsize = Termsize {
+            width: var_to_int_or(
+                vars.get_as_string_flags(L!("COLUMNS"), EnvMode::GLOBAL),
+                Termsize::DEFAULT_WIDTH,
+            ),
+            height: var_to_int_or(
+                vars.get_as_string_flags(L!("LINES"), EnvMode::GLOBAL),
+                Termsize::DEFAULT_HEIGHT,
+            ),
+        };
+
+        // Store our termsize as an environment override.
+        self.data
+            .lock()
+            .unwrap()
+            .mark_override_from_env(new_termsize);
+    }
+
+    /// Note that a WINCH signal is received.
+    /// Naturally this may be called from within a signal handler.
+    pub fn handle_winch() {
+        TTY_TERMSIZE_GEN_COUNT.fetch_add(1, Ordering::Relaxed);
+    }
+
+    pub fn invalidate_tty() {
+        TTY_TERMSIZE_GEN_COUNT.fetch_add(1, Ordering::Relaxed);
+    }
+}
+
+static SHARED_CONTAINER: TermsizeContainer = TermsizeContainer {
+    data: Mutex::new(TermsizeData::defaults()),
+    setting_env_vars: AtomicBool::new(false),
+    tty_size_reader: read_termsize_from_tty,
+};
+
+const _: () = assert_sync::<TermsizeContainer>();
+
+/// Convenience helper to return the last known termsize.
+pub fn termsize_last() -> Termsize {
+    return SHARED_CONTAINER.last();
+}
+
+use crate::ffi_tests::add_test;
+add_test!("test_termsize", || {
+    let env_global = EnvMode::GLOBAL;
+    let parser: &mut parser_t = unsafe { &mut *parser_t::principal_parser_ffi() };
+
+    // Use a static variable so we can pretend we're the kernel exposing a terminal size.
+    static STUBBY_TERMSIZE: Mutex<Option<Termsize>> = Mutex::new(None);
+    fn stubby_termsize() -> Option<Termsize> {
+        *STUBBY_TERMSIZE.lock().unwrap()
+    }
+    let mut ts = TermsizeContainer {
+        data: Mutex::new(TermsizeData::defaults()),
+        setting_env_vars: AtomicBool::new(false),
+        tty_size_reader: stubby_termsize,
+    };
+
+    // Initially default value.
+    assert_eq!(ts.last(), Termsize::defaults());
+
+    // Haha we change the value, it doesn't even know.
+    *STUBBY_TERMSIZE.lock().unwrap() = Some(Termsize {
+        width: 42,
+        height: 84,
+    });
+    assert_eq!(ts.last(), Termsize::defaults());
+
+    // Ok let's tell it. But it still doesn't update right away.
+    TermsizeContainer::handle_winch();
+    assert_eq!(ts.last(), Termsize::defaults());
+
+    // Ok now we tell it to update.
+    ts.updating(parser);
+    assert_eq!(ts.last(), Termsize::new(42, 84));
+    assert_eq!(parser.var_as_string(L!("COLUMNS")).unwrap(), "42");
+    assert_eq!(parser.var_as_string(L!("LINES")).unwrap(), "84");
+
+    // Wow someone set COLUMNS and LINES to a weird value.
+    // Now the tty's termsize doesn't matter.
+    parser.set_var(L!("COLUMNS"), &[L!("75")], env_global);
+    parser.set_var(L!("LINES"), &[L!("150")], env_global);
+    ts.handle_columns_lines_var_change(parser.get_var_stack_env());
+    assert_eq!(ts.last(), Termsize::new(75, 150));
+    assert_eq!(parser.var_as_string(L!("COLUMNS")).unwrap(), "75");
+    assert_eq!(parser.var_as_string(L!("LINES")).unwrap(), "150");
+
+    parser.set_var(L!("COLUMNS"), &[L!("33")], env_global);
+    ts.handle_columns_lines_var_change(parser.get_var_stack_env());
+    assert_eq!(ts.last(), Termsize::new(33, 150));
+
+    // Oh it got SIGWINCH, now the tty matters again.
+    TermsizeContainer::handle_winch();
+    assert_eq!(ts.last(), Termsize::new(33, 150));
+    assert_eq!(ts.updating(parser), stubby_termsize().unwrap());
+    assert_eq!(parser.var_as_string(L!("COLUMNS")).unwrap(), "42");
+    assert_eq!(parser.var_as_string(L!("LINES")).unwrap(), "84");
+
+    // Test initialize().
+    parser.set_var(L!("COLUMNS"), &[L!("83")], env_global);
+    parser.set_var(L!("LINES"), &[L!("38")], env_global);
+    ts.initialize(parser.get_var_stack_env());
+    assert_eq!(ts.last(), Termsize::new(83, 38));
+
+    // initialize() even beats the tty reader until a sigwinch.
+    let mut ts2 = TermsizeContainer {
+        data: Mutex::new(TermsizeData::defaults()),
+        setting_env_vars: AtomicBool::new(false),
+        tty_size_reader: stubby_termsize,
+    };
+    ts.initialize(parser.get_var_stack_env());
+    ts2.updating(parser);
+    assert_eq!(ts.last(), Termsize::new(83, 38));
+    TermsizeContainer::handle_winch();
+    assert_eq!(ts2.updating(parser), stubby_termsize().unwrap());
+});
diff --git a/fish-rust/src/wchar_ext.rs b/fish-rust/src/wchar_ext.rs
index 707a3da81..ccf67d1fc 100644
--- a/fish-rust/src/wchar_ext.rs
+++ b/fish-rust/src/wchar_ext.rs
@@ -1,6 +1,89 @@
 use crate::wchar::{wstr, WString};
 use widestring::utfstr::CharsUtf32;
 
+/// Helpers to convert things to widestring.
+/// This is like std::string::ToString.
+pub trait ToWString {
+    fn to_wstring(&self) -> WString;
+}
+
+#[inline]
+fn to_wstring_impl(mut val: u64, neg: bool) -> WString {
+    // 20 digits max in u64: 18446744073709551616.
+    let mut digits = [0; 24];
+    let mut ndigits = 0;
+    while val > 0 {
+        digits[ndigits] = (val % 10) as u8;
+        val /= 10;
+        ndigits += 1;
+    }
+    if ndigits == 0 {
+        digits[0] = 0;
+        ndigits = 1;
+    }
+    let mut result = WString::with_capacity(ndigits + neg as usize);
+    if neg {
+        result.push('-');
+    }
+    for i in (0..ndigits).rev() {
+        result.push((digits[i] + b'0') as char);
+    }
+    result
+}
+
+/// Implement to_wstring() for signed types.
+macro_rules! impl_to_wstring_signed {
+    ($t:ty) => {
+        impl ToWString for $t {
+            fn to_wstring(&self) -> WString {
+                let val = *self as i64;
+                to_wstring_impl(val.unsigned_abs(), val < 0)
+            }
+        }
+    };
+}
+impl_to_wstring_signed!(i8);
+impl_to_wstring_signed!(i16);
+impl_to_wstring_signed!(i32);
+impl_to_wstring_signed!(i64);
+impl_to_wstring_signed!(isize);
+
+/// Implement to_wstring() for unsigned types.
+macro_rules! impl_to_wstring_unsigned {
+    ($t:ty) => {
+        impl ToWString for $t {
+            fn to_wstring(&self) -> WString {
+                to_wstring_impl(*self as u64, false)
+            }
+        }
+    };
+}
+
+impl_to_wstring_unsigned!(u8);
+impl_to_wstring_unsigned!(u16);
+impl_to_wstring_unsigned!(u32);
+impl_to_wstring_unsigned!(u64);
+impl_to_wstring_unsigned!(usize);
+
+#[test]
+fn test_to_wstring() {
+    assert_eq!(0_u64.to_wstring(), "0");
+    assert_eq!(1_u64.to_wstring(), "1");
+    assert_eq!(0_i64.to_wstring(), "0");
+    assert_eq!(1_i64.to_wstring(), "1");
+    assert_eq!((-1_i64).to_wstring(), "-1");
+    assert_eq!((-5_i64).to_wstring(), "-5");
+    let mut val: i64 = 1;
+    loop {
+        assert_eq!(val.to_wstring(), val.to_string());
+        let Some(next) = val.checked_mul(-3) else { break; };
+        val = next;
+    }
+    assert_eq!(u64::MAX.to_wstring(), "18446744073709551615");
+    assert_eq!(i64::MIN.to_wstring(), "-9223372036854775808");
+    assert_eq!(i64::MAX.to_wstring(), "9223372036854775807");
+}
+
 /// A thing that a wide string can start with or end with.
 /// It must have a chars() method which returns a double-ended char iterator.
 pub trait CharPrefixSuffix {
diff --git a/src/env.cpp b/src/env.cpp
index 6d05e653c..f8536d4d0 100644
--- a/src/env.cpp
+++ b/src/env.cpp
@@ -1414,6 +1414,12 @@ int env_stack_t::set(const wcstring &key, env_mode_flags_t mode, wcstring_list_t
     return ret.status;
 }
 
+int env_stack_t::set_ffi(const wcstring &key, env_mode_flags_t mode, const void *vals,
+                         size_t count) {
+    const wchar_t *const *ptr = static_cast<const wchar_t *const *>(vals);
+    return this->set(key, mode, wcstring_list_t(ptr, ptr + count));
+}
+
 int env_stack_t::set_one(const wcstring &key, env_mode_flags_t mode, wcstring val) {
     wcstring_list_t vals;
     vals.push_back(std::move(val));
diff --git a/src/env.h b/src/env.h
index b3901a4e4..9cf6fba69 100644
--- a/src/env.h
+++ b/src/env.h
@@ -242,6 +242,10 @@ class env_stack_t final : public environment_t {
     /// Sets the variable with the specified name to the given values.
     int set(const wcstring &key, env_mode_flags_t mode, wcstring_list_t vals);
 
+    /// Sets the variable with the specified name to the given values.
+    /// The values should have type const wchar_t *const * (but autocxx doesn't support that).
+    int set_ffi(const wcstring &key, env_mode_flags_t mode, const void *vals, size_t count);
+
     /// Sets the variable with the specified name to a single value.
     int set_one(const wcstring &key, env_mode_flags_t mode, wcstring val);
 
diff --git a/src/parser.cpp b/src/parser.cpp
index c5f2bebd3..89a18fdf2 100644
--- a/src/parser.cpp
+++ b/src/parser.cpp
@@ -62,6 +62,8 @@ parser_t &parser_t::principal_parser() {
     return *principal;
 }
 
+parser_t *parser_t::principal_parser_ffi() { return &principal_parser(); }
+
 void parser_t::assert_can_execute() const { ASSERT_IS_MAIN_THREAD(); }
 
 rust::Box<WaitHandleStoreFFI> &parser_t::get_wait_handles_ffi() { return wait_handles; }
diff --git a/src/parser.h b/src/parser.h
index 07f36820c..661e3c54f 100644
--- a/src/parser.h
+++ b/src/parser.h
@@ -315,6 +315,9 @@ class parser_t : public std::enable_shared_from_this<parser_t> {
     /// Get the "principal" parser, whatever that is.
     static parser_t &principal_parser();
 
+    /// ffi helper. Obviously this is totally bogus.
+    static parser_t *principal_parser_ffi();
+
     /// Assert that this parser is allowed to execute on the current thread.
     void assert_can_execute() const;
 
@@ -388,6 +391,9 @@ class parser_t : public std::enable_shared_from_this<parser_t> {
     env_stack_t &vars() { return *variables; }
     const env_stack_t &vars() const { return *variables; }
 
+    /// Rust helper - variables as an environment_t.
+    const environment_t &vars_env_ffi() const { return *variables; }
+
     int remove_var_ffi(const wcstring &key, int mode) { return vars().remove(key, mode); }
 
     /// Get the library data.