feat: add responsive ultrawide work lanes
CI / rust (push) Successful in 2m3s

This commit is contained in:
2026-08-31 17:46:18 -07:00
parent e7ca1d0a73
commit 3f15b13312
11 changed files with 304 additions and 74 deletions
+7 -6
View File
@@ -18,12 +18,13 @@ as installable binaries; building from source will remain supported.
This repository is in architecture and vertical-slice phase. The installable This repository is in architecture and vertical-slice phase. The installable
binary is still a scaffold, while the isolated native UI spikes now exercise an binary is still a scaffold, while the isolated native UI spikes now exercise an
interactive 3×1 workspace backed by six live comparison surfaces and the root workspace contains the first interactive 20/60/20 workspace backed by six live comparison surfaces, and the
bounded local PTY, runtime-actor, VT engine, and capability-gated workspace root workspace contains the first bounded local PTY, runtime actor, VT engine,
command boundaries. The GPUI slice renders one styled actor-owned VT session in and capability-gated workspace command boundaries. The GPUI slice shows a
the primary 60% work band while five deterministic surfaces keep updating in the responsive one-, three-, or five-lane work band—five on a 3440 px ultrawide—with
background. We are still validating selection/scrollback rendering, standalone one styled actor-owned VT session and five deterministic comparison surfaces.
runtime IPC/durability, ACP integration, Retained-history navigation is wired. We are still validating terminal text
selection and mouse input, standalone runtime IPC/durability, ACP integration,
packaging, and usage-data contracts before a large implementation. packaging, and usage-data contracts before a large implementation.
## Product shape ## Product shape
+56 -1
View File
@@ -7,7 +7,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use alacritty_terminal::event::{Event, EventListener, WindowSize}; use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::grid::Dimensions; use alacritty_terminal::grid::{Dimensions, Scroll};
use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::{Config, Osc52, Term, TermMode, point_to_viewport}; use alacritty_terminal::term::{Config, Osc52, Term, TermMode, point_to_viewport};
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor}; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Processor};
@@ -259,12 +259,23 @@ impl TerminalModes {
pub struct TerminalSnapshot { pub struct TerminalSnapshot {
pub revision: u64, pub revision: u64,
pub dimensions: TerminalDimensions, pub dimensions: TerminalDimensions,
pub display_offset: usize,
pub cells: Vec<TerminalCell>, pub cells: Vec<TerminalCell>,
pub cursor: TerminalCursor, pub cursor: TerminalCursor,
pub modes: TerminalModes, pub modes: TerminalModes,
pub title: Option<String>, pub title: Option<String>,
} }
/// Framework-neutral movement through the retained terminal history.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TerminalScroll {
Delta(i32),
PageUp,
PageDown,
Top,
Bottom,
}
impl TerminalSnapshot { impl TerminalSnapshot {
#[must_use] #[must_use]
pub fn cell(&self, row: u16, column: u16) -> Option<&TerminalCell> { pub fn cell(&self, row: u16, column: u16) -> Option<&TerminalCell> {
@@ -544,6 +555,31 @@ impl TerminalEngine {
} }
} }
/// Moves the visible viewport without writing bytes to the child PTY.
///
/// Returns `true` only when the retained-history offset changed.
pub fn scroll_display(&mut self, scroll: TerminalScroll) -> bool {
let previous_offset = self.terminal.grid().display_offset();
let scroll = match scroll {
TerminalScroll::Delta(lines) => Scroll::Delta(lines),
TerminalScroll::PageUp => Scroll::PageUp,
TerminalScroll::PageDown => Scroll::PageDown,
TerminalScroll::Top => Scroll::Top,
TerminalScroll::Bottom => Scroll::Bottom,
};
self.terminal.scroll_display(scroll);
if self.terminal.grid().display_offset() == previous_offset {
return false;
}
self.revision = self.revision.saturating_add(1);
true
}
#[must_use]
pub fn display_offset(&self) -> usize {
self.terminal.grid().display_offset()
}
#[must_use] #[must_use]
pub fn snapshot(&self) -> TerminalSnapshot { pub fn snapshot(&self) -> TerminalSnapshot {
let renderable = self.terminal.renderable_content(); let renderable = self.terminal.renderable_content();
@@ -571,6 +607,7 @@ impl TerminalEngine {
TerminalSnapshot { TerminalSnapshot {
revision: self.revision, revision: self.revision,
dimensions: self.dimensions, dimensions: self.dimensions,
display_offset: renderable.display_offset,
cells, cells,
cursor: TerminalCursor { cursor: TerminalCursor {
row: u16::try_from(cursor_point.line).unwrap_or(u16::MAX), row: u16::try_from(cursor_point.line).unwrap_or(u16::MAX),
@@ -874,6 +911,7 @@ mod tests {
use super::{ use super::{
KeyModifiers, TerminalCellStyle, TerminalDimensions, TerminalEngine, TerminalEngineOptions, KeyModifiers, TerminalCellStyle, TerminalDimensions, TerminalEngine, TerminalEngineOptions,
TerminalEvent, TerminalKey, TerminalKeyEvent, TerminalModes, TerminalNamedColor, TerminalEvent, TerminalKey, TerminalKeyEvent, TerminalModes, TerminalNamedColor,
TerminalScroll,
}; };
fn engine(rows: u16, columns: u16) -> TerminalEngine { fn engine(rows: u16, columns: u16) -> TerminalEngine {
@@ -917,6 +955,23 @@ mod tests {
assert_eq!(engine.snapshot().plain_rows()[0], "primary"); assert_eq!(engine.snapshot().plain_rows()[0], "primary");
} }
#[test]
fn scrolls_retained_history_without_touching_the_pty_input_path() {
let mut engine = engine(2, 8);
engine.process(b"one\r\ntwo\r\nthree\r\nfour");
assert_eq!(engine.display_offset(), 0);
let revision = engine.revision();
assert!(engine.scroll_display(TerminalScroll::PageUp));
assert!(engine.display_offset() > 0);
assert!(engine.revision() > revision);
assert_eq!(engine.snapshot().display_offset, engine.display_offset());
assert!(engine.scroll_display(TerminalScroll::Bottom));
assert_eq!(engine.display_offset(), 0);
assert!(!engine.scroll_display(TerminalScroll::Bottom));
}
#[test] #[test]
fn emits_protocol_replies_without_logging_input_content() { fn emits_protocol_replies_without_logging_input_content() {
let mut engine = engine(4, 10); let mut engine = engine(4, 10);
+14 -12
View File
@@ -71,21 +71,23 @@ shutdown against ordered raw-byte output. The GPUI slice feeds one actor session
through the terminal engine while five surfaces retain deterministic comparison through the terminal engine while five surfaces retain deterministic comparison
output. Its adapter groups adjacent cells into native GPUI paint runs and renders output. Its adapter groups adjacent cells into native GPUI paint runs and renders
ANSI/indexed/RGB colors, emphasis, hyperlinks, and cursor shapes. Window geometry ANSI/indexed/RGB colors, emphasis, hyperlinks, and cursor shapes. Window geometry
drives terminal rows and columns and resizes both the engine and PTY. Selection, drives terminal rows and columns and resizes both the engine and PTY. The engine
scrollback navigation, mouse reporting, and a lower-level terminal canvas remain. now exposes retained-history offsets and page/top/bottom viewport movement
This actor still runs in-process; moving the same framework-neutral without writing scroll keys to the PTY. Text selection, mouse reporting, and a
contract behind local authenticated IPC is the next durability step. See lower-level terminal canvas remain. This actor still runs in-process; moving the
decisions 0004 and 0005. same framework-neutral contract behind local authenticated IPC is the next
durability step. See decisions 0004 and 0005.
## Default workspace composition ## Default workspace composition
The default desktop workspace is a vertical 3×1 composition: the top 20% holds The default desktop workspace has three horizontal bands: the top 20% holds pane,
pane, agent, tool, context, and goal state; the middle 60% is the active terminal agent, tool, context, and goal state; the middle 60% holds responsive vertical
or work surface; the bottom 20% is the answer, choice, chat, and approval shelf. work lanes; and the bottom 20% is the answer, choice, chat, and approval shelf.
Six deterministic surfaces still update in the spike so performance comparisons The work region shows one lane in compact windows, three at normal desktop
remain meaningful, but only the selected surface owns the large work region. widths, and five on a 3440 px ultrawide. The selected lane alone owns keyboard
Users may deliberately split that region later; a dashboard grid is not the input. Six surfaces continue updating so performance comparisons remain
calm default. See decision 0008. meaningful, with a sliding visible window keeping the selected pane onscreen.
See decisions 0008 and 0009.
We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The
first spike must compare a reusable terminal crate with a small first-party layer. first spike must compare a reusable terminal crate with a small first-party layer.
+7 -5
View File
@@ -66,11 +66,13 @@ surface change never silently launches a process, moves the pane to another
host, or changes which agent owns the session. Unsupported surfaces are shown as host, or changes which agent owns the session. Unsupported surfaces are shown as
unavailable rather than simulated. unavailable rather than simulated.
The default workspace is a 3×1 vertical composition: the top 20% shows context, The default workspace has three horizontal bands: the top 20% shows context,
pane/agent state, tools, and goal; the middle 60% is the selected terminal or pane/agent state, tools, and goal; the middle 60% is a responsive set of vertical
work surface; the bottom 20% is the decision shelf. Multiple panes may continue work lanes; and the bottom 20% is the decision shelf. The work band shows one
working offscreen and remain one shortcut away. Explicit split and dashboard lane in compact windows, three at normal desktop widths, and up to five on an
layouts remain supported, but they do not displace the primary-terminal default. ultrawide display. One lane owns keyboard input at a time, and selecting a lane
never pauses the others. Explicit split trees and dashboard layouts remain
supported, but they do not displace this terminal-first default.
An optional decision shelf sits below the active work surface. It may show an An optional decision shelf sits below the active work surface. It may show an
answer draft, two or three concrete choices, why each was suggested, and the answer draft, two or three concrete choices, why each was suggested, and the
+8 -4
View File
@@ -118,10 +118,14 @@ who already have the final cargo-watch release installed.
blocked OSC 52 behavior, key encoding, application-cursor mode, and resize. blocked OSC 52 behavior, key encoding, application-cursor mode, and resize.
- `lumbridge-core` tests capability-gated split/select/rename/close commands, - `lumbridge-core` tests capability-gated split/select/rename/close commands,
idempotent request IDs, close-tree promotion, and atomic agentic setup plans. idempotent request IDs, close-tree promotion, and atomic agentic setup plans.
- The GPUI slice tests its key-event adapter, 20/60/20 geometry-to-PTY sizing, - The GPUI slice tests its key-event adapter, responsive one/three/five-lane
xterm 256-color conversion, styled-run coalescing, and cursor-run boundaries. geometry, selected-pane lane windowing, 20/60/20 geometry-to-PTY sizing, xterm
It renders one real actor-owned VT session while five surfaces continue their 256-color conversion, styled-run coalescing, cursor-run boundaries, and
deterministic background workload. scrollback shortcut routing. It renders one real actor-owned VT session while
five surfaces continue their deterministic background workload.
- `lumbridge-terminal` retains 10,000 history lines by default and tests
framework-neutral page/top/bottom viewport movement, revision changes, and
live-bottom no-ops without sending history-navigation bytes to the child PTY.
- The current-GPUI probe compile-checks real AccessKit element wiring and real - The current-GPUI probe compile-checks real AccessKit element wiring and real
platform input-handler installation. Unit tests cover its semantic tree and platform input-handler installation. Unit tests cover its semantic tree and
UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows, UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows,
+6 -6
View File
@@ -24,9 +24,9 @@ from macOS and Linux and the hard gates pass.
The current programs establish dependency, build, launch, and interaction The current programs establish dependency, build, launch, and interaction
baselines. GPUI now routes a real PTY through a VT engine and keyboard encoder, baselines. GPUI now routes a real PTY through a VT engine and keyboard encoder,
paints coalesced styled cell runs and cursor shapes, and derives PTY rows/columns paints coalesced styled cell runs and cursor shapes, and derives PTY rows/columns
from its 60% terminal band when the window changes. Selection, scrollback from its responsive one/three/five-lane 60% terminal band when the window
navigation, mouse modes, a native Markdown editor, and one isolated browser child changes. Retained-history page/top/bottom navigation is wired. Text selection,
remain. mouse modes, a native Markdown editor, and one isolated browser child remain.
## Ubuntu baseline — metal, 2026-08-31 ## Ubuntu baseline — metal, 2026-08-31
@@ -87,9 +87,9 @@ fallback.
## Measurement semantics ## Measurement semantics
Both renderers retain the same all-deterministic six-surface action stream for Both renderers retain the same all-deterministic six-surface action stream for
comparison. GPUI now presents the selected surface in a 20/60/20 vertical comparison. GPUI now presents a 20/60/20 workspace whose middle band shows one,
workspace while one terminal fixture is replaced with a real actor-owned VT three, or five vertical work lanes while one terminal fixture is replaced with a
session and five deterministic surfaces keep running offscreen. Counters real actor-owned VT session and five deterministic surfaces keep running. Counters
separate external PTY batches/lines from total model updates. The GPUI footer separate external PTY batches/lines from total model updates. The GPUI footer
reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
is deliberately not called key-to-present or frame-present latency: neither is deliberately not called key-to-present or frame-present latency: neither
+18 -12
View File
@@ -10,12 +10,14 @@ framework decision gate, not a decorative dashboard.
## Current-run audit ## Current-run audit
The first 2026-08-31 GPUI baseline proved six-surface density but gave every The first 2026-08-31 GPUI baseline proved six-surface density but gave every
pane equal visual priority. The current slice replaces that dashboard with a pane equal visual priority. The current slice replaces that dashboard with three
3×1 default: 20% context and pane selection, 60% active work surface, and 20% horizontal bands: 20% context and pane selection, a 60% responsive work region,
decision shelf. Keyboard focus, command-palette input, live PTY input, styled VT and a 20% decision shelf. The work region shows one, three, or five vertical
cells, cursor shapes, and geometry-driven resize are wired. Remaining visible lanes according to available width. Keyboard focus, command-palette input, live
gaps are terminal selection/scrollback, real decision-shelf actions, usage-detail PTY input, styled VT cells, cursor shapes, geometry-driven resize, and retained-
provenance, and end-to-end platform accessibility/IME. history navigation are wired. Remaining visible gaps are terminal text selection
and mouse modes, real decision-shelf actions, usage-detail provenance, and end-to-
end platform accessibility/IME.
Screenshots for the audit are stored outside Git under Screenshots for the audit are stored outside Git under
`~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot `~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot
@@ -40,8 +42,8 @@ research, not source assets for Lumbridge. We adapt these interaction patterns:
cards; cards;
- a quiet top tab strip for mixed terminal, Markdown, browser, and review - a quiet top tab strip for mixed terminal, Markdown, browser, and review
surfaces; surfaces;
- one primary work surface with optional splits, instead of forcing every - a primary work region with a small responsive number of equal vertical lanes,
surface into an equal dashboard tile; instead of either one ultrawide terminal or a dense equal-card dashboard;
- narrow contextual tools, such as files, review, or Buzz, that can collapse - narrow contextual tools, such as files, review, or Buzz, that can collapse
when the terminal needs the space; when the terminal needs the space;
- usage and agent state at the edge of the workspace rather than in modal - usage and agent state at the edge of the workspace rather than in modal
@@ -62,8 +64,9 @@ surface rather than a hosted Lumbridge control plane.
the selected pane receives the accent treatment. the selected pane receives the accent treatment.
4. A waiting pane uses an amber semantic label in both the context band and 4. A waiting pane uses an amber semantic label in both the context band and
attention sidebar. The label always includes words such as `NEEDS INPUT`. attention sidebar. The label always includes words such as `NEEDS INPUT`.
5. The middle 60% gives the selected terminal or work surface uninterrupted 5. The middle 60% gives one, three, or five terminal/work lanes uninterrupted
reading space and retains target, harness state, dimensions, and surface tabs. height and retains target, harness state, dimensions, and surface tabs. The
selected lane alone owns keyboard input.
6. The footer groups connection state, selected-harness identity, usage-window 6. The footer groups connection state, selected-harness identity, usage-window
provenance, and burn forecast into readable regions. provenance, and burn forecast into readable regions.
7. The selected pane has a quiet surface strip for Terminal, Browser, Tools, 7. The selected pane has a quiet surface strip for Terminal, Browser, Tools,
@@ -79,6 +82,8 @@ surface rather than a hosted Lumbridge control plane.
- `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits - `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits
available to the PTY. available to the PTY.
- `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette. - `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette.
- `Shift+PageUp/PageDown`: move the selected live terminal through retained
history; `Shift+Home/End` jumps to the history top/live bottom.
- Typing while the palette is open changes its query; arrows change the result; - Typing while the palette is open changes its query; arrows change the result;
`Enter` runs it; `Escape` closes it and restores pane focus. `Enter` runs it; `Escape` closes it and restores pane focus.
- `Enter` on a waiting agent opens its request detail. Approval remains a - `Enter` on a waiting agent opens its request detail. Approval remains a
@@ -92,8 +97,9 @@ receive the same state transitions and tests.
- In the first actor integration, one terminal pane consumes ordered output from - In the first actor integration, one terminal pane consumes ordered output from
a real local PTY while each deterministic tick updates the other five a real local PTY while each deterministic tick updates the other five
surfaces offscreen. The all-deterministic constructor remains available for surfaces. One, three, or five are visible according to available width; the
framework comparison and replay tests. all-deterministic constructor remains available for framework comparison and
replay tests.
- One pane enters and leaves `needs input` through a deterministic event. - One pane enters and leaves `needs input` through a deterministic event.
- Markdown, browser-boundary, and review panes update counters without using a - Markdown, browser-boundary, and review panes update counters without using a
web application shell. web application shell.
@@ -2,22 +2,21 @@
Status: accepted for the native vertical slice. Status: accepted for the native vertical slice.
Lumbridge defaults to three vertical bands in one column. The top 20% exposes Lumbridge defaults to three horizontal bands in one column. The top 20% exposes
pane, agent, tools, context, goal, target, and attention state. The middle 60% pane, agent, tools, context, goal, target, and attention state. The middle 60%
belongs to the active terminal or work surface. The bottom 20% holds answers, is the terminal-first work region. The bottom 20% holds answers, choices, chat,
choices, chat, approvals, and the local-analyst boundary. approvals, and the local-analyst boundary.
This composition favors the surface where engineers type and read for most of This composition favors the region where engineers type and read for most of the
the day. It keeps context and decisions visible without making six equally sized day. It keeps context and decisions visible without making every surface a small
cards compete for attention. Pane selectors and keyboard shortcuts switch the dashboard card. The middle region may contain a responsive number of vertical
large work surface. Users may later create deliberate splits or dashboard grids, work lanes under decision 0009; the selected lane alone owns keyboard input.
but those are workspace choices rather than the default visual hierarchy.
The framework spike retains six updating surfaces even when five are offscreen. The framework spike retains six updating surfaces even when five are offscreen.
That preserves the earlier streaming and reducer workload for performance That preserves the earlier streaming and reducer workload for performance
comparison. Hidden surfaces do not receive keyboard input merely because they comparison. Hidden surfaces do not receive keyboard input merely because they
continue to update. continue to update.
Window geometry determines the middle band's terminal rows and columns. The UI Window and lane geometry determine each terminal's rows and columns. The UI
resizes the terminal engine and PTY through the bounded runtime actor; it does resizes the terminal engine and PTY through the bounded runtime actor; it does
not resize only the text view or infer terminal wrapping after the fact. not resize only the text view or infer terminal wrapping after the fact.
@@ -0,0 +1,24 @@
# 0009: The primary work region uses responsive vertical lanes
Status: accepted for the native vertical slice.
The middle 60% work region shows one vertical lane in compact windows, three at
normal desktop widths, and five when at least 2800 px remains after the sidebar.
A 3440 px ultrawide therefore presents five lanes. A sliding window over the six
comparison panes always keeps the selected pane visible.
This is not a return to the original 2×3 dashboard. Every lane receives the full
work-region height, the surrounding context and decision bands remain stable,
and only the selected lane owns keyboard input. Clicking a lane selects it. Pane
processes and output continue independently of selection.
Terminal geometry is computed from the actual width of one lane, including gaps
and insets. The selected real PTY is resized through the runtime actor when the
responsive lane count or window bounds change. On the 3440×1440 reference
display the current fixed-cell spike derives approximately 71 columns by 40
rows per lane.
The first actor slice still owns one real PTY and five deterministic comparison
surfaces. Promoting the runtime boundary from one actor to a pane-indexed session
registry is required before all five visible terminal lanes can own independent
real processes.
+3 -2
View File
@@ -24,8 +24,9 @@ shared model retain the all-deterministic mode for like-for-like framework
comparison. GPUI feeds raw output through `lumbridge-terminal` and sends encoded comparison. GPUI feeds raw output through `lumbridge-terminal` and sends encoded
keyboard input and terminal protocol replies through the bounded runtime actor. keyboard input and terminal protocol replies through the bounded runtime actor.
Its visual adapter coalesces VT cells into native styled runs, paints cursor Its visual adapter coalesces VT cells into native styled runs, paints cursor
shapes, and resizes the engine and PTY from the middle 60% work band. Selection, shapes, and resizes the engine and PTY from a responsive one/three/five-lane
scrollback navigation, and mouse input remain intentionally unfinished. middle 60% work band. Retained-history navigation is wired; text selection and
mouse input remain intentionally unfinished.
Build independently: Build independently:
+153 -17
View File
@@ -16,7 +16,7 @@ use lumbridge_spike_model::{
use lumbridge_terminal::{ use lumbridge_terminal::{
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions, KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor,
TerminalSnapshot, TerminalScroll, TerminalSnapshot,
}; };
const BG: u32 = 0x090c12; const BG: u32 = 0x090c12;
@@ -43,6 +43,7 @@ const TERMINAL_CHROME_HEIGHT: f32 = 72.0;
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0; const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
const TERMINAL_CELL_WIDTH: f32 = 8.4; const TERMINAL_CELL_WIDTH: f32 = 8.4;
const TERMINAL_CELL_HEIGHT: f32 = 18.0; const TERMINAL_CELL_HEIGHT: f32 = 18.0;
const WORK_LANE_GAP: f32 = 4.0;
const TERMINAL_ROW_STEP: u16 = 2; const TERMINAL_ROW_STEP: u16 = 2;
const TERMINAL_COLUMN_STEP: u16 = 10; const TERMINAL_COLUMN_STEP: u16 = 10;
@@ -279,10 +280,14 @@ fn dim_color(color: u32) -> u32 {
fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions { fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions {
let width = f32::from(window_size.width); let width = f32::from(window_size.width);
let height = f32::from(window_size.height); let height = f32::from(window_size.height);
let lane_count = visible_lane_count(window_size);
let workspace_height = let workspace_height =
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0); (height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
let terminal_height = (workspace_height * 0.60 - TERMINAL_CHROME_HEIGHT).max(0.0); let terminal_height = (workspace_height * 0.60 - TERMINAL_CHROME_HEIGHT).max(0.0);
let terminal_width = (width - SIDEBAR_WIDTH - TERMINAL_HORIZONTAL_INSET).max(0.0); let workspace_width = (width - SIDEBAR_WIDTH).max(0.0);
let lane_gaps = WORK_LANE_GAP * lane_count.saturating_sub(1) as f32;
let lane_width = ((workspace_width - lane_gaps).max(0.0) / lane_count as f32).max(0.0);
let terminal_width = (lane_width - TERMINAL_HORIZONTAL_INSET).max(0.0);
let rows = (terminal_height / TERMINAL_CELL_HEIGHT) let rows = (terminal_height / TERMINAL_CELL_HEIGHT)
.floor() .floor()
.clamp(2.0, f32::from(u16::MAX)); .clamp(2.0, f32::from(u16::MAX));
@@ -298,6 +303,25 @@ fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensio
.expect("geometry clamps terminal dimensions above zero") .expect("geometry clamps terminal dimensions above zero")
} }
fn visible_lane_count(window_size: Size<Pixels>) -> usize {
let workspace_width = (f32::from(window_size.width) - SIDEBAR_WIDTH).max(0.0);
if workspace_width >= 2_800.0 {
5
} else if workspace_width >= 1_100.0 {
3
} else {
1
}
}
fn visible_lane_range(total: usize, selected: usize, lane_count: usize) -> std::ops::Range<usize> {
let count = lane_count.clamp(1, total.max(1)).min(total);
let start = selected
.saturating_sub(count / 2)
.min(total.saturating_sub(count));
start..start + count
}
impl RenderTiming { impl RenderTiming {
fn mark_dispatch(&mut self) { fn mark_dispatch(&mut self) {
self.pending_dispatch = Some(Instant::now()); self.pending_dispatch = Some(Instant::now());
@@ -527,6 +551,14 @@ impl LumbridgeShell {
cx.notify(); cx.notify();
} }
fn scroll_terminal(&mut self, scroll: TerminalScroll, cx: &mut Context<Self>) {
if !self.terminal.scroll_display(scroll) {
return;
}
self.publish_terminal_snapshot();
cx.notify();
}
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) { fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
self.dispatch(ShellAction::SelectPane(pane)); self.dispatch(ShellAction::SelectPane(pane));
window.focus(&self.root_focus); window.focus(&self.root_focus);
@@ -615,6 +647,13 @@ impl LumbridgeShell {
return; return;
} }
let modifiers = key_modifiers(event); let modifiers = key_modifiers(event);
if let Some(scroll) = terminal_scroll_from_parts(
event.keystroke.key.as_str(),
modifiers,
) {
self.scroll_terminal(scroll, cx);
return;
}
let Some(event) = terminal_key_from_parts( let Some(event) = terminal_key_from_parts(
event.keystroke.key.as_str(), event.keystroke.key.as_str(),
event.keystroke.key_char.as_deref(), event.keystroke.key_char.as_deref(),
@@ -629,6 +668,11 @@ impl LumbridgeShell {
if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) { if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
} }
if self.terminal.display_offset() > 0
&& self.terminal.scroll_display(TerminalScroll::Bottom)
{
self.publish_terminal_snapshot();
}
cx.notify(); cx.notify();
return; return;
} }
@@ -807,8 +851,13 @@ impl LumbridgeShell {
.into_any_element() .into_any_element()
} }
fn work_surface(&self) -> gpui::AnyElement { fn work_surface(
let pane = self.model.pane(self.model.selected_pane()); &self,
pane: &PaneState,
selected: bool,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let pane_id = pane.id();
let external = pane.output_source() == OutputSource::External; let external = pane.output_source() == OutputSource::External;
let status = if external { let status = if external {
self.runtime_status.badge() self.runtime_status.badge()
@@ -820,9 +869,17 @@ impl LumbridgeShell {
} else { } else {
pane.fixture().target.to_owned() pane.fixture().target.to_owned()
}; };
let surface_status = if external { let surface_status = if external && self.terminal_snapshot.display_offset > 0 {
format!( format!(
"{} · {}×{} · auto-fit", "{} · ↑{} · {}×{}",
status,
self.terminal_snapshot.display_offset,
self.terminal.dimensions().columns(),
self.terminal.dimensions().rows()
)
} else if external {
format!(
"{} · {}×{}",
status, status,
self.terminal.dimensions().columns(), self.terminal.dimensions().columns(),
self.terminal.dimensions().rows() self.terminal.dimensions().rows()
@@ -870,14 +927,19 @@ impl LumbridgeShell {
}; };
div() div()
.id(("work-lane", pane_id.index()))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(pane_id, window, cx);
}))
.flex() .flex()
.flex_col() .flex_col()
.size_full() .size_full()
.min_w_0()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.bg(rgb(PANEL)) .bg(rgb(PANEL))
.border_y_1() .border_1()
.border_color(rgb(BORDER)) .border_color(rgb(if selected { ACCENT } else { BORDER }))
.child( .child(
div() div()
.flex() .flex()
@@ -891,15 +953,23 @@ impl LumbridgeShell {
div() div()
.flex() .flex()
.items_center() .items_center()
.gap_3() .gap_2()
.min_w_0()
.text_sm() .text_sm()
.child(pane.fixture().title) .child(div().flex_none().child(pane.fixture().title))
.child(div().text_xs().text_color(rgb(MUTED)).child(detail)), .child(
div()
.truncate()
.text_xs()
.text_color(rgb(MUTED))
.child(detail),
),
) )
.child( .child(
div() div()
.flex_none()
.text_xs() .text_xs()
.text_color(rgb(SUCCESS)) .text_color(rgb(if selected { SUCCESS } else { MUTED }))
.child(surface_status), .child(surface_status),
), ),
) )
@@ -1068,7 +1138,7 @@ impl LumbridgeShell {
} }
impl Render for LumbridgeShell { impl Render for LumbridgeShell {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let attention = self.model.pane(PaneId::ClaudeUi); let attention = self.model.pane(PaneId::ClaudeUi);
let sidebar = div() let sidebar = div()
.flex() .flex()
@@ -1251,6 +1321,26 @@ impl Render for LumbridgeShell {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let selected = self.model.pane(self.model.selected_pane()); let selected = self.model.pane(self.model.selected_pane());
let lane_count = visible_lane_count(window.bounds().size);
let lane_range = visible_lane_range(
PaneId::ALL.len(),
self.model.selected_pane().index(),
lane_count,
);
let work_lanes = lane_range
.map(|index| {
let pane = self.model.pane(PaneId::ALL[index]);
div()
.flex_1()
.min_w_0()
.min_h_0()
.child(self.work_surface(
pane,
pane.id() == self.model.selected_pane(),
cx,
))
})
.collect::<Vec<_>>();
let workspace_stack = div() let workspace_stack = div()
.flex() .flex()
.flex_col() .flex_col()
@@ -1310,9 +1400,11 @@ impl Render for LumbridgeShell {
div() div()
.h(relative(0.60)) .h(relative(0.60))
.flex_none() .flex_none()
.flex()
.gap_1()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.child(self.work_surface()), .children(work_lanes),
) )
.child( .child(
div() div()
@@ -1498,6 +1590,19 @@ fn terminal_key_from_parts(
Some(TerminalKeyEvent { key, modifiers }) Some(TerminalKeyEvent { key, modifiers })
} }
fn terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option<TerminalScroll> {
if !modifiers.contains(KeyModifiers::SHIFT) {
return None;
}
match key {
"pageup" => Some(TerminalScroll::PageUp),
"pagedown" => Some(TerminalScroll::PageDown),
"home" => Some(TerminalScroll::Top),
"end" => Some(TerminalScroll::Bottom),
_ => None,
}
}
fn start_live_runtime(dimensions: TerminalDimensions) -> Result<RuntimeActor, RuntimeActorError> { fn start_live_runtime(dimensions: TerminalDimensions) -> Result<RuntimeActor, RuntimeActorError> {
let command = CommandConfig::new("/bin/sh") let command = CommandConfig::new("/bin/sh")
.map_err(RuntimeActorError::Start)? .map_err(RuntimeActorError::Start)?
@@ -1551,8 +1656,9 @@ fn main() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, indexed_terminal_color, KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalScroll,
terminal_dimensions_for_window, terminal_key_from_parts, terminal_paint_rows, indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts,
terminal_paint_rows, terminal_scroll_from_parts, visible_lane_count, visible_lane_range,
}; };
use gpui::{px, size}; use gpui::{px, size};
@@ -1582,7 +1688,37 @@ mod tests {
fn terminal_geometry_tracks_the_middle_sixty_percent() { fn terminal_geometry_tracks_the_middle_sixty_percent() {
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0))); let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)));
assert_eq!(dimensions.rows(), 24); assert_eq!(dimensions.rows(), 24);
assert_eq!(dimensions.columns(), 145); assert_eq!(dimensions.columns(), 45);
let ultrawide = size(px(3440.0), px(1440.0));
assert_eq!(visible_lane_count(ultrawide), 5);
let dimensions = terminal_dimensions_for_window(ultrawide);
assert_eq!(dimensions.rows(), 40);
assert_eq!(dimensions.columns(), 71);
}
#[test]
fn lane_window_keeps_the_selected_pane_visible() {
assert_eq!(visible_lane_range(6, 0, 5), 0..5);
assert_eq!(visible_lane_range(6, 5, 5), 1..6);
assert_eq!(visible_lane_range(6, 3, 3), 2..5);
assert_eq!(visible_lane_range(6, 4, 1), 4..5);
}
#[test]
fn shift_navigation_controls_scrollback_without_reaching_the_pty() {
assert_eq!(
terminal_scroll_from_parts("pageup", KeyModifiers::SHIFT),
Some(TerminalScroll::PageUp)
);
assert_eq!(
terminal_scroll_from_parts("end", KeyModifiers::SHIFT),
Some(TerminalScroll::Bottom)
);
assert_eq!(
terminal_scroll_from_parts("pageup", KeyModifiers::default()),
None
);
} }
#[test] #[test]