Built-in Terminal for Fresh
Architecture Overview
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:
- Terminal mode: Live interactive shell, input goes to PTY
- Scrollback mode: Read-only buffer view with editor navigation/selection
Incremental Scrollback Streaming
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.
File Structure
Each terminal maintains a single backing file containing rendered text:
~/.local/share/fresh/terminals/{encoded_workdir}/fresh-terminal-{id}.txtThe 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 │
└─────────────────────────────────────────┘Data Flow
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 TerminalStateQuit while in terminal mode:
1. Append visible screen to backing file (ensure complete state)
2. Save session as normalSession 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)Performance Characteristics
| 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) |
State Tracking
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
}Key Methods
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;
}Terminal Resize Handling
When terminal is resized:
- Old scrollback lines remain as-is (rendered at old width)
- New scrollback lines are rendered at new width
- The editor's line wrapping handles display of mixed-width lines
- No O(n) rewrite of history required
This is a feature: original output is preserved at original width rather than being re-wrapped or truncated.
Raw Log File (Optional)
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}.logThis file:
- Contains raw VTE escape sequences exactly as received from PTY
- Enables rebuilding full
TerminalStatevia replay - Only needed if user wants to resume live terminal after restore
- Can be disabled if terminals always start fresh on session restore
Mode Switching
Terminal Mode → Scrollback Mode (Ctrl+Space)
- Append visible screen to backing file
- Update
backing_file_history_endto current file position minus screen size - Load backing file as read-only buffer (lazy load)
- Set
editing_disabled = true - User can navigate, select, copy, search
Scrollback Mode → Terminal Mode (Ctrl+Space)
- Truncate backing file to
backing_file_history_end - Set
editing_disabled = false - Resume live rendering from
TerminalState - Scroll view to cursor position (bottom of terminal)
Session Persistence
Session Save
struct 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:
- If in terminal mode, append visible screen to backing file
- This ensures backing file always contains complete state
Session Restore
- Load backing file directly as read-only buffer
- User sees last session state immediately (lazy load)
- If user presses Ctrl+Space to enter terminal mode:
- Spawn new PTY with same shell/cwd
- Optionally replay raw log to restore
TerminalState - Or start fresh (simpler, recommended default)
Integration with Existing Buffer System
The backing file integrates with Fresh's existing file-backed buffer architecture:
- Files > 1MB use lazy loading (
BufferData::Unloaded) - Chunks loaded on-demand as user scrolls
- Full 200K line scrollback (~15MB) loads instantly
- Search, selection, copy all work via normal buffer mechanisms
This is why the incremental streaming approach works: we're not building a new system, we're leveraging the existing efficient buffer infrastructure.
Implementation Checklist
Core Changes
- [ ] Add
synced_history_linesandbacking_file_history_endtoTerminalState - [ ] Implement
flush_new_scrollback()method - [ ] Implement
append_visible_screen()method - [ ] Update PTY read loop to call
flush_new_scrollback()afterprocess_output() - [ ] Pass backing file writer to PTY read thread
Mode Switch Changes
- [ ]
sync_terminal_to_buffer(): append screen + lazy load (no replay) - [ ]
enter_terminal_mode(): truncate backing file - [ ] On quit: ensure visible screen is appended before session save
Session Restore Changes
- [ ] Load backing file directly (skip log replay)
- [ ] Defer log replay to
enter_terminal_mode()if needed - [ ] Consider removing log replay entirely (fresh terminal on restore)
Cleanup
- [ ] Remove
full_content_string()method (no longer needed) - [ ] Remove
replay_terminal_log_into_state()from restore path - [ ] Update tests for new architecture
Known Issues
Critical
Read-only mode accepts input: Text is inserted into buffer in scrollback mode. Fix: ensure
editing_disabledis respected.Keybindings don't work in scrollback mode: All keys typed as text. Fix: ensure
KeyContext::Normalis set on mode exit.
High Priority
- View doesn't scroll to cursor on resume: After scrolling in scrollback mode, resuming terminal mode leaves view at wrong position. Fix: scroll to bottom on mode entry.
Medium Priority
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.
Technical Details
Dependencies
alacritty_terminal = "0.25" # VT100/ANSI terminal emulation
portable-pty = "0.9" # Cross-platform PTY managementalacritty_terminal Capabilities Used
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 history
Scrollback Access
let 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
}