docs/internal/terminal.md
Fresh's terminal is implemented as a special buffer type backed by alacritty_terminal for VT100/ANSI emulation and portable-pty for cross-platform PTY management. Terminals can be displayed in any split and support two modes:
The terminal uses an incremental streaming architecture that avoids O(n) work on mode switches and session restore. The key insight is that scrollback history is append-only.
Each terminal maintains a single backing file containing rendered text:
~/.local/share/fresh/terminals/{encoded_workdir}/fresh-terminal-{id}.txt
The backing file structure:
┌─────────────────────────────────────────┐
│ Scrollback history (append-only) │ ← grows incrementally as lines
│ Line 1 │ scroll off the top of screen
│ Line 2 │
│ ... │
│ Line N │
├─────────────────────────────────────────┤
│ Visible screen (rewritable tail) │ ← present only in scrollback mode
│ Screen line 0 │ (~50 lines, rewritten each switch)
│ ... │
│ Screen line 49 │
└─────────────────────────────────────────┘
During terminal operation (PTY read loop):
PTY output bytes
│
▼
state.process_output() ──► TerminalState (in-memory grid)
│
▼
check: history_size increased?
│
YES ──► append new scrollback lines to backing file
(one line at a time, as they scroll off screen)
Exit terminal mode (enter scrollback mode):
1. Append visible screen (~50 lines) to backing file
2. Load backing file as read-only buffer (lazy load, instant)
Re-enter terminal mode:
1. Truncate backing file to scrollback-only (remove visible screen tail)
2. Resume live terminal rendering from TerminalState
Quit while in terminal mode:
1. Append visible screen to backing file (ensure complete state)
2. Save session as normal
Session restore:
1. Load backing file directly (lazy load, instant)
2. User starts in scrollback mode viewing last session state
3. Raw log replay only if user re-enters terminal mode (deferred)
| Operation | Before | After |
|---|---|---|
| Mode switch | ~500ms (replay + full_content_string) | ~5ms (append 50 lines) |
| Session restore | ~1000ms (replay 2x) | ~10ms (lazy load) |
| PTY read overhead | ~0 | ~0.1ms per scroll (append one line) |
pub struct TerminalState {
term: Term<NullListener>,
parser: Processor,
cols: u16,
rows: u16,
dirty: bool,
terminal_title: String,
// Incremental streaming state
synced_history_lines: usize, // lines already written to backing file
backing_file_history_end: u64, // byte offset where scrollback ends
}
impl TerminalState {
/// Append any new scrollback lines to the backing file.
/// Called after process_output() in the PTY read loop.
pub fn flush_new_scrollback<W: Write>(&mut self, writer: &mut W) -> io::Result<usize>;
/// Append visible screen content to the backing file.
/// Called when exiting terminal mode.
pub fn append_visible_screen<W: Write>(&self, writer: &mut W) -> io::Result<()>;
/// Get byte offset where scrollback ends (for truncation on mode re-entry).
pub fn backing_file_history_end(&self) -> u64;
}
When terminal is resized:
This is a feature: original output is preserved at original width rather than being re-wrapped or truncated.
For re-entering terminal mode after session restore, a raw log of PTY bytes is maintained:
~/.local/share/fresh/terminals/{encoded_workdir}/fresh-terminal-{id}.log
This file:
TerminalState via replaybacking_file_history_end to current file position minus screen sizeediting_disabled = truebacking_file_history_endediting_disabled = falseTerminalStatestruct TerminalSession {
pub id: u64,
pub shell: String,
pub cwd: PathBuf,
pub cols: u16,
pub rows: u16,
pub backing_path: PathBuf,
pub log_path: PathBuf, // for optional live terminal resume
}
Before saving session:
TerminalStateThe backing file integrates with Fresh's existing file-backed buffer architecture:
BufferData::Unloaded)This is why the incremental streaming approach works: we're not building a new system, we're leveraging the existing efficient buffer infrastructure.
synced_history_lines and backing_file_history_end to TerminalStateflush_new_scrollback() methodappend_visible_screen() methodflush_new_scrollback() after process_output()sync_terminal_to_buffer(): append screen + lazy load (no replay)enter_terminal_mode(): truncate backing fileenter_terminal_mode() if neededfull_content_string() method (no longer needed)replay_terminal_log_into_state() from restore pathRead-only mode accepts input: Text is inserted into buffer in scrollback mode. Fix: ensure editing_disabled is respected.
Keybindings don't work in scrollback mode: All keys typed as text. Fix: ensure KeyContext::Normal is set on mode exit.
Inconsistent display between modes: Line numbers and layout differ. Consider unifying visual presentation.
Status message truncated on narrow terminals: "Terminal mode disabled..." too long for 80 columns.
alacritty_terminal = "0.25" # VT100/ANSI terminal emulation
portable-pty = "0.9" # Cross-platform PTY management
Term::grid() - access to scrollback via negative line indicesgrid.history_size() - track scrollback growthgrid[Line(-n)] - read scrollback linesTerm::selection - native selection supportTerm::selection_to_string() - copy selected textTerm::scroll_display() - scroll through historylet grid = term.grid();
let history_size = grid.history_size();
// Scrollback lines: Line(-history_size) to Line(-1)
// Visible screen: Line(0) to Line(rows-1)
for i in (1..=history_size).rev() {
let line = Line(-(i as i32));
let row_data = &grid[line];
// ... write line to backing file
}