feat: make terminal the primary workspace surface
CI / rust (push) Successful in 2m7s

This commit is contained in:
2026-08-31 17:18:55 -07:00
parent b838d000db
commit 1204689de0
11 changed files with 643 additions and 157 deletions
+5 -4
View File
@@ -18,11 +18,12 @@ 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 six-surface workspace and the root workspace contains the first interactive 3×1 workspace backed by six live comparison surfaces and the root workspace contains the first
bounded local PTY, runtime-actor, VT engine, and capability-gated workspace bounded local PTY, runtime-actor, VT engine, and capability-gated workspace
command boundaries. The GPUI slice renders one real actor-owned VT session beside command boundaries. The GPUI slice renders one styled actor-owned VT session in
five deterministic surfaces. We are still validating the dedicated styled the primary 60% work band while five deterministic surfaces keep updating in the
terminal renderer, standalone runtime IPC/durability, ACP integration, background. We are still validating selection/scrollback rendering, 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
+15 -3
View File
@@ -69,12 +69,24 @@ The first `lumbridge-runtime` actor now owns one `PtySession` on a dedicated
thread. Bounded command and event queues serialize input, resize, close, and thread. Bounded command and event queues serialize input, resize, close, and
shutdown against ordered raw-byte output. The GPUI slice feeds one actor session 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 current text adapter intentionally renders plain rows and does not output. Its adapter groups adjacent cells into native GPUI paint runs and renders
yet paint cell styles, selection, or the cursor; the next rendering slice owns ANSI/indexed/RGB colors, emphasis, hyperlinks, and cursor shapes. Window geometry
that work. This actor still runs in-process; moving the same framework-neutral drives terminal rows and columns and resizes both the engine and PTY. Selection,
scrollback navigation, mouse reporting, and a lower-level terminal canvas remain.
This actor still runs in-process; moving the same framework-neutral
contract behind local authenticated IPC is the next durability step. See contract behind local authenticated IPC is the next durability step. See
decisions 0004 and 0005. decisions 0004 and 0005.
## Default workspace composition
The default desktop workspace is a vertical 3×1 composition: the top 20% holds
pane, agent, tool, context, and goal state; the middle 60% is the active terminal
or work surface; the bottom 20% is the answer, choice, chat, and approval shelf.
Six deterministic surfaces still update in the spike so performance comparisons
remain meaningful, but only the selected surface owns the large work region.
Users may deliberately split that region later; a dashboard grid is not the
calm default. See decision 0008.
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.
Remaining correctness cases include OSC 8 links, Kitty Remaining correctness cases include OSC 8 links, Kitty
+6
View File
@@ -66,6 +66,12 @@ 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,
pane/agent state, tools, and goal; the middle 60% is the selected terminal or
work surface; the bottom 20% is the decision shelf. Multiple panes may continue
working offscreen and remain one shortcut away. Explicit split and dashboard
layouts remain supported, but they do not displace the primary-terminal 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
capability each choice would need. Suggestions are inert data. Choosing one may capability each choice would need. Suggestions are inert data. Choosing one may
+7
View File
@@ -32,6 +32,13 @@ They are shallow snapshots for study, not dependencies or vendored source.
- `block/buzz`: Rust human/agent collaboration plus production ACP harness design. - `block/buzz`: Rust human/agent collaboration plus production ACP harness design.
- `bbarit/terminal`: Rust agent terminal/IDE ideas. No top-level license file was - `bbarit/terminal`: Rust agent terminal/IDE ideas. No top-level license file was
present in the captured snapshot, so do not reuse implementation code. present in the captured snapshot, so do not reuse implementation code.
- `OnlyTerp/opengrok`: provider-wire maps, behavioral verification of reasoning
controls, local-key posture, drift detection, and fail-closed provider UX;
MIT, but its Grok Bot patching is product-specific reference work.
- `elie222/rakazo`: persistent AI teammates, delegation, routines, computer
providers, browser/terminal/desktop tools, and deterministic sandbox testing;
Apache-2.0. Its Electron/React/Postgres architecture is research-only because
Lumbridge's shell and local runtime remain native Rust and SQLite.
## Protocols ## Protocols
+2
View File
@@ -18,6 +18,8 @@ checkouts are reference material and are not part of Lumbridge's source tree.
| Agent IDEs | Orca | `main` | `02a7742406a5` | 322 MB | `stablyai/orca` | | Agent IDEs | Orca | `main` | `02a7742406a5` | 322 MB | `stablyai/orca` |
| Agent IDEs | Xum | `main` | `a4be59aade55` | 76 MB | `coder/xum` | | Agent IDEs | Xum | `main` | `a4be59aade55` | 76 MB | `coder/xum` |
| Agent IDEs | Zed | `main` | `ce48461eaadd` | 118 MB | `zed-industries/zed` | | Agent IDEs | Zed | `main` | `ce48461eaadd` | 118 MB | `zed-industries/zed` |
| Agent IDEs | OpenGrok | `main` | `2b356649cfe5` | 5.4 MB | `OnlyTerp/opengrok` |
| Agent IDEs | Rakazo | `main` | `7e1ef9ea5d5d` | 30 MB | `elie222/rakazo` |
| Protocols | ACP specification | `main` | `f76fe3f3d132` | 67 MB | `agentclientprotocol/agent-client-protocol` | | Protocols | ACP specification | `main` | `f76fe3f3d132` | 67 MB | `agentclientprotocol/agent-client-protocol` |
| Protocols | ACP Rust SDK | `main` | `754d5aa1ce2c` | 4.5 MB | `agentclientprotocol/rust-sdk` | | Protocols | ACP Rust SDK | `main` | `754d5aa1ce2c` | 4.5 MB | `agentclientprotocol/rust-sdk` |
| Harnesses | Codex | `main` | `17e8101699c5` | 103 MB | `openai/codex` | | Harnesses | Codex | `main` | `17e8101699c5` | 103 MB | `openai/codex` |
+4 -3
View File
@@ -118,9 +118,10 @@ 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. It renders one real actor-owned VT - The GPUI slice tests its key-event adapter, 20/60/20 geometry-to-PTY sizing,
session as plain snapshot rows and keeps five surfaces deterministic; styled xterm 256-color conversion, styled-run coalescing, and cursor-run boundaries.
cell/cursor rendering remains a separate gate. It renders one real actor-owned VT session while five surfaces continue their
deterministic background workload.
- 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,
+8 -6
View File
@@ -22,10 +22,11 @@ from macOS and Linux and the hard gates pass.
| API clarity and maintenance burden | review | pending | pending | | API clarity and maintenance burden | review | pending | pending |
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,
its renderer still flattens the immutable screen snapshot to plain rows. The paints coalesced styled cell runs and cursor shapes, and derives PTY rows/columns
next iteration adds styled cells/cursor/selection, measured pane-driven resize, from its 60% terminal band when the window changes. Selection, scrollback
a native Markdown editor, and one isolated browser child. navigation, mouse modes, a native Markdown editor, and one isolated browser child
remain.
## Ubuntu baseline — metal, 2026-08-31 ## Ubuntu baseline — metal, 2026-08-31
@@ -86,8 +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. The GPUI integration mode replaces one terminal fixture with a real comparison. GPUI now presents the selected surface in a 20/60/20 vertical
actor-owned VT session and leaves five deterministic surfaces running. Counters workspace while one terminal fixture is replaced with a real actor-owned VT
session and five deterministic surfaces keep running offscreen. 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 -18
View File
@@ -9,15 +9,13 @@ framework decision gate, not a decorative dashboard.
## Current-run audit ## Current-run audit
The 2026-08-31 GPUI baseline succeeds at calm density: the six surfaces are easy The first 2026-08-31 GPUI baseline proved six-surface density but gave every
to scan, typography is consistent, and local/remote targets remain visible. It pane equal visual priority. The current slice replaces that dashboard with a
fails the task flow in four important ways: 3×1 default: 20% context and pane selection, 60% active work surface, and 20%
decision shelf. Keyboard focus, command-palette input, live PTY input, styled VT
1. all panes have equal visual priority, so `NEEDS INPUT` is too easy to miss; cells, cursor shapes, and geometry-driven resize are wired. Remaining visible
2. no pane exposes visible keyboard focus or selection; gaps are terminal selection/scrollback, real decision-shelf actions, usage-detail
3. the command palette affordance has no interaction behind it; provenance, and end-to-end platform accessibility/IME.
4. status and usage text in the footer is too quiet to explain provenance or
confidence at a glance.
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
@@ -60,11 +58,12 @@ surface rather than a hosted Lumbridge control plane.
palette shortcut. palette shortcut.
2. The sidebar starts with a compact `ATTENTION` group, followed by workspaces 2. The sidebar starts with a compact `ATTENTION` group, followed by workspaces
and remote hosts. It does not become a notification feed. and remote hosts. It does not become a notification feed.
3. The active pane receives a two-pixel accent edge plus a quiet tinted header. 3. A compact top 20% context band keeps all pane identities and states visible;
4. A waiting pane uses an amber semantic label and remains visible when another the selected pane receives the accent treatment.
pane is selected. The label always includes words such as `NEEDS INPUT`. 4. A waiting pane uses an amber semantic label in both the context band and
5. Pane headers expose title, execution target, harness state, and unread count attention sidebar. The label always includes words such as `NEEDS INPUT`.
in a stable order. 5. The middle 60% gives the selected terminal or work surface uninterrupted
reading space and retains target, harness state, dimensions, and surface tabs.
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,
@@ -75,7 +74,7 @@ surface rather than a hosted Lumbridge control plane.
## Keyboard task flow ## Keyboard task flow
- `Alt+Arrow` or `Alt+h/j/k/l`: move focus through the 2×3 pane grid while - `Alt+Arrow` or `Alt+h/j/k/l`: move selection through the pane topology while
leaving ordinary terminal arrows and text available to the PTY. leaving ordinary terminal arrows and text available to the PTY.
- `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.
@@ -93,8 +92,8 @@ 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. The all-deterministic constructor remains available for framework surfaces offscreen. The all-deterministic constructor remains available for
comparison and replay tests. 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.
@@ -103,7 +102,8 @@ receive the same state transitions and tests.
element-build timing is never presented as display-present timing. element-build timing is never presented as display-present timing.
- The PTY actor replaces exactly one synthetic stream without changing focus or - The PTY actor replaces exactly one synthetic stream without changing focus or
layout contracts. Synthetic streams stay available for repeatable performance layout contracts. Synthetic streams stay available for repeatable performance
tests. Raw lines do not claim terminal-emulation fidelity. tests. Live bytes are parsed into VT state and coalesced native paint runs;
deterministic fixture text makes no terminal-emulation claim.
## Hard gates ## Hard gates
@@ -0,0 +1,23 @@
# 0008: The default workspace is a primary-terminal 3×1 composition
Status: accepted for the native vertical slice.
Lumbridge defaults to three vertical bands in one column. The top 20% exposes
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,
choices, chat, approvals, and the local-analyst boundary.
This composition favors the surface where engineers type and read for most of
the day. It keeps context and decisions visible without making six equally sized
cards compete for attention. Pane selectors and keyboard shortcuts switch the
large work surface. Users may later create deliberate splits or dashboard grids,
but those are workspace choices rather than the default visual hierarchy.
The framework spike retains six updating surfaces even when five are offscreen.
That preserves the earlier streaming and reducer workload for performance
comparison. Hidden surfaces do not receive keyboard input merely because they
continue to update.
Window geometry determines the middle band's terminal rows and columns. The UI
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.
+3 -2
View File
@@ -23,8 +23,9 @@ GPUI also has an integration mode with one real local PTY owned by
shared model retain the all-deterministic mode for like-for-like framework 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 currently flattens the VT snapshot to plain rows, so styled Its visual adapter coalesces VT cells into native styled runs, paints cursor
cells, selection, and cursor painting remain intentionally unfinished. shapes, and resizes the engine and PTY from the middle 60% work band. Selection,
scrollback navigation, and mouse input remain intentionally unfinished.
Build independently: Build independently:
+552 -121
View File
@@ -2,8 +2,8 @@ use std::collections::VecDeque;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use gpui::{ use gpui::{
App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds, App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
WindowOptions, actions, div, prelude::*, px, rgb, size, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
}; };
use lumbridge_runtime::{ use lumbridge_runtime::{
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions,
@@ -14,8 +14,9 @@ use lumbridge_spike_model::{
ShellModel, SurfaceKind, WORKSPACES, ShellModel, SurfaceKind, WORKSPACES,
}; };
use lumbridge_terminal::{ use lumbridge_terminal::{
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey, KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
TerminalKeyEvent, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor,
TerminalSnapshot,
}; };
const BG: u32 = 0x090c12; const BG: u32 = 0x090c12;
@@ -34,6 +35,14 @@ const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
const RUNTIME_DRAIN_LIMIT: usize = 64; const RUNTIME_DRAIN_LIMIT: usize = 64;
const LIVE_PANE: PaneId = PaneId::CodexRuntime; const LIVE_PANE: PaneId = PaneId::CodexRuntime;
const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i"; const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i";
const SIDEBAR_WIDTH: f32 = 248.0;
const APP_HEADER_HEIGHT: f32 = 48.0;
const TAB_BAR_HEIGHT: f32 = 38.0;
const APP_FOOTER_HEIGHT: f32 = 32.0;
const TERMINAL_CHROME_HEIGHT: f32 = 72.0;
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
const TERMINAL_CELL_WIDTH: f32 = 8.4;
const TERMINAL_CELL_HEIGHT: f32 = 18.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;
@@ -65,6 +74,7 @@ struct LumbridgeShell {
runtime: Option<RuntimeActor>, runtime: Option<RuntimeActor>,
runtime_status: LiveRuntimeStatus, runtime_status: LiveRuntimeStatus,
terminal: TerminalEngine, terminal: TerminalEngine,
terminal_snapshot: TerminalSnapshot,
last_runtime_sequence: u64, last_runtime_sequence: u64,
root_focus: FocusHandle, root_focus: FocusHandle,
pane_focus: [FocusHandle; 6], pane_focus: [FocusHandle; 6],
@@ -123,6 +133,171 @@ struct RenderTiming {
dispatch_to_element_micros: VecDeque<u128>, dispatch_to_element_micros: VecDeque<u128>,
} }
#[derive(Clone, Eq, PartialEq)]
struct TerminalPaintRun {
text: String,
columns: u16,
foreground: u32,
background: u32,
style: TerminalCellStyle,
cursor: Option<TerminalCursorShape>,
hyperlink: bool,
}
impl TerminalPaintRun {
fn can_merge(&self, other: &Self) -> bool {
self.foreground == other.foreground
&& self.background == other.background
&& self.style == other.style
&& self.cursor == other.cursor
&& self.hyperlink == other.hyperlink
}
}
fn terminal_paint_rows(snapshot: &TerminalSnapshot) -> Vec<Vec<TerminalPaintRun>> {
(0..snapshot.dimensions.rows())
.map(|row| {
let mut runs: Vec<TerminalPaintRun> = Vec::new();
for column in 0..snapshot.dimensions.columns() {
let Some(cell) = snapshot.cell(row, column) else {
continue;
};
if cell.wide_spacer {
continue;
}
let mut foreground = terminal_color(cell.foreground);
let mut background = terminal_color(cell.background);
if cell.style.contains(TerminalCellStyle::INVERSE) {
std::mem::swap(&mut foreground, &mut background);
}
if cell.style.contains(TerminalCellStyle::DIM) {
foreground = dim_color(foreground);
}
if cell.style.contains(TerminalCellStyle::HIDDEN) {
foreground = background;
}
let cursor = (snapshot.cursor.row == row
&& snapshot.cursor.column == column
&& snapshot.cursor.shape != TerminalCursorShape::Hidden)
.then_some(snapshot.cursor.shape);
let text = if cell.text.is_empty() {
" ".to_owned()
} else {
cell.text.clone()
};
let next = TerminalPaintRun {
text,
columns: if cell.wide { 2 } else { 1 },
foreground,
background,
style: cell.style,
cursor,
hyperlink: cell.hyperlink.is_some(),
};
if let Some(current) = runs.last_mut()
&& current.can_merge(&next)
{
current.text.push_str(&next.text);
current.columns = current.columns.saturating_add(next.columns);
continue;
}
runs.push(next);
}
runs
})
.collect()
}
fn terminal_color(color: TerminalColor) -> u32 {
match color {
TerminalColor::Rgb { red, green, blue } => {
(u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)
}
TerminalColor::Indexed(index) => indexed_terminal_color(index),
TerminalColor::Named(named) => match named {
TerminalNamedColor::Black => 0x1d2430,
TerminalNamedColor::Red => 0xff6b6b,
TerminalNamedColor::Green => 0x70d6a8,
TerminalNamedColor::Yellow => 0xf1c76a,
TerminalNamedColor::Blue => 0x68b5f8,
TerminalNamedColor::Magenta => 0xc79bf2,
TerminalNamedColor::Cyan => 0x63d5da,
TerminalNamedColor::White => 0xdbe5f4,
TerminalNamedColor::BrightBlack => 0x6d7a91,
TerminalNamedColor::BrightRed => 0xff8b8b,
TerminalNamedColor::BrightGreen => 0x93e6be,
TerminalNamedColor::BrightYellow => 0xf8d98c,
TerminalNamedColor::BrightBlue => 0x8bc8ff,
TerminalNamedColor::BrightMagenta => 0xd9b4fb,
TerminalNamedColor::BrightCyan => 0x86e7eb,
TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground => 0xf4f8ff,
TerminalNamedColor::Foreground => TEXT,
TerminalNamedColor::Background => BG,
TerminalNamedColor::Cursor => 0xf4f8ff,
TerminalNamedColor::DimBlack => 0x121820,
TerminalNamedColor::DimRed => 0x9f4a4a,
TerminalNamedColor::DimGreen => 0x4e9878,
TerminalNamedColor::DimYellow => 0xa88a49,
TerminalNamedColor::DimBlue => 0x477fac,
TerminalNamedColor::DimMagenta => 0x896ca4,
TerminalNamedColor::DimCyan => 0x459397,
TerminalNamedColor::DimWhite | TerminalNamedColor::DimForeground => MUTED,
},
}
}
fn indexed_terminal_color(index: u8) -> u32 {
const ANSI: [u32; 16] = [
0x1d2430, 0xff6b6b, 0x70d6a8, 0xf1c76a, 0x68b5f8, 0xc79bf2, 0x63d5da, 0xdbe5f4, 0x6d7a91,
0xff8b8b, 0x93e6be, 0xf8d98c, 0x8bc8ff, 0xd9b4fb, 0x86e7eb, 0xf4f8ff,
];
match index {
0..=15 => ANSI[usize::from(index)],
16..=231 => {
const LEVELS: [u32; 6] = [0, 95, 135, 175, 215, 255];
let offset = u32::from(index - 16);
let red = LEVELS[(offset / 36) as usize];
let green = LEVELS[((offset % 36) / 6) as usize];
let blue = LEVELS[(offset % 6) as usize];
(red << 16) | (green << 8) | blue
}
232..=255 => {
let level = 8 + 10 * u32::from(index - 232);
(level << 16) | (level << 8) | level
}
}
}
fn dim_color(color: u32) -> u32 {
let dim = |channel: u32| channel * 13 / 20;
(dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff)
}
fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions {
let width = f32::from(window_size.width);
let height = f32::from(window_size.height);
let workspace_height =
(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_width = (width - SIDEBAR_WIDTH - TERMINAL_HORIZONTAL_INSET).max(0.0);
let rows = (terminal_height / TERMINAL_CELL_HEIGHT)
.floor()
.clamp(2.0, f32::from(u16::MAX));
let columns = (terminal_width / TERMINAL_CELL_WIDTH)
.floor()
.clamp(20.0, f32::from(u16::MAX));
TerminalDimensions::with_cell_size(
rows as u16,
columns as u16,
TERMINAL_CELL_WIDTH.round() as u16,
TERMINAL_CELL_HEIGHT.round() as u16,
)
.expect("geometry clamps terminal dimensions above zero")
}
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());
@@ -207,12 +382,23 @@ impl LumbridgeShell {
}) })
.detach(); .detach();
let terminal = TerminalEngine::new(TerminalEngineOptions::default()); let terminal_dimensions = terminal_dimensions_for_window(window.bounds().size);
let terminal = TerminalEngine::new(TerminalEngineOptions {
dimensions: terminal_dimensions,
..TerminalEngineOptions::default()
});
let terminal_snapshot = terminal.snapshot();
let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) { let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) {
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting), Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())), Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
}; };
cx.observe_window_bounds(window, |shell, window, cx| {
let dimensions = terminal_dimensions_for_window(window.bounds().size);
shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
})
.detach();
Self { Self {
model: ShellModel::with_external_output(LIVE_PANE) model: ShellModel::with_external_output(LIVE_PANE)
.expect("the live comparison pane is a terminal"), .expect("the live comparison pane is a terminal"),
@@ -220,6 +406,7 @@ impl LumbridgeShell {
runtime, runtime,
runtime_status, runtime_status,
terminal, terminal,
terminal_snapshot,
last_runtime_sequence: 0, last_runtime_sequence: 0,
root_focus, root_focus,
pane_focus, pane_focus,
@@ -316,7 +503,9 @@ impl LumbridgeShell {
} }
fn publish_terminal_snapshot(&mut self) { fn publish_terminal_snapshot(&mut self) {
let lines = self.terminal.snapshot().plain_rows(); let snapshot = self.terminal.snapshot();
let lines = snapshot.plain_rows();
self.terminal_snapshot = snapshot;
self.dispatch(ShellAction::ReplaceExternalOutput { self.dispatch(ShellAction::ReplaceExternalOutput {
pane: LIVE_PANE, pane: LIVE_PANE,
lines, lines,
@@ -474,11 +663,10 @@ impl LumbridgeShell {
cx.notify(); cx.notify();
} }
fn pane_card( fn pane_selector(
pane: &PaneState, pane: &PaneState,
selected: bool, selected: bool,
runtime_status: LiveRuntimeStatus, runtime_status: LiveRuntimeStatus,
terminal_dimensions: TerminalDimensions,
focus: FocusHandle, focus: FocusHandle,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> gpui::AnyElement { ) -> gpui::AnyElement {
@@ -500,147 +688,296 @@ impl LumbridgeShell {
} else { } else {
ACCENT ACCENT
}; };
let line_start = pane.lines().len().saturating_sub(12);
div() div()
.id(("pane", id.index())) .id(("pane", id.index()))
.track_focus(&focus) .track_focus(&focus)
.tab_index(id.index() as isize + 1) .tab_index(id.index() as isize + 1)
.flex() .flex()
.flex_col() .items_center()
.min_w_0() .justify_between()
.min_h_0() .gap_3()
.overflow_hidden() .h_full()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL })) .min_w(px(172.0))
.px_3()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
.border_1() .border_1()
.border_color(rgb(if selected || needs_input { .border_color(rgb(if selected || needs_input {
state_color state_color
} else { } else {
BORDER BORDER_QUIET
})) }))
.rounded(px(5.0)) .rounded(px(5.0))
.when(selected, |view| view.border_2())
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(id, window, cx); shell.select_pane(id, window, cx);
})) }))
.child( .child(
div() div()
.flex() .flex()
.items_center() .flex_col()
.justify_between() .min_w_0()
.px_3()
.h(px(36.0))
.flex_none()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
.border_b_1()
.border_color(rgb(BORDER_QUIET))
.text_sm()
.text_color(rgb(TEXT))
.child( .child(
div() div()
.flex() .truncate()
.items_center() .text_sm()
.gap_2() .text_color(rgb(TEXT))
.child(
div()
.text_xs()
.text_color(rgb(MUTED))
.child(format!("{}", id.index() + 1)),
)
.child(if external { .child(if external {
"Shell · VT session" "Terminal · local shell"
} else { } else {
pane.fixture().title pane.fixture().title
}), }),
) )
.child(div().text_xs().text_color(rgb(state_color)).child(label)), .child(
div()
.mt_1()
.truncate()
.text_xs()
.text_color(rgb(MUTED))
.child(pane.fixture().target.to_owned()),
),
) )
.child(div().text_xs().text_color(rgb(state_color)).child(label))
.into_any_element()
}
fn terminal_run(run: TerminalPaintRun) -> gpui::AnyElement {
let cursor = run.cursor;
div()
.flex_none()
.h(px(TERMINAL_CELL_HEIGHT))
.w(px(f32::from(run.columns) * TERMINAL_CELL_WIDTH))
.overflow_hidden()
.whitespace_nowrap()
.text_color(rgb(run.foreground))
.bg(rgb(run.background))
.when(run.style.contains(TerminalCellStyle::BOLD), |view| {
view.font_weight(FontWeight::BOLD)
})
.when(run.style.contains(TerminalCellStyle::ITALIC), |view| {
view.italic()
})
.when(
run.style.contains(TerminalCellStyle::UNDERLINE)
|| run.style.contains(TerminalCellStyle::DOUBLE_UNDERLINE)
|| run.style.contains(TerminalCellStyle::UNDERCURL)
|| run.style.contains(TerminalCellStyle::DOTTED_UNDERLINE)
|| run.style.contains(TerminalCellStyle::DASHED_UNDERLINE)
|| run.hyperlink,
|view| view.underline(),
)
.when(run.style.contains(TerminalCellStyle::STRIKEOUT), |view| {
view.line_through()
})
.when(cursor == Some(TerminalCursorShape::Block), |view| {
view.bg(rgb(TEXT)).text_color(rgb(BG))
})
.when(cursor == Some(TerminalCursorShape::HollowBlock), |view| {
view.border_1().border_color(rgb(TEXT))
})
.when(cursor == Some(TerminalCursorShape::Underline), |view| {
view.border_b_2().border_color(rgb(TEXT))
})
.when(cursor == Some(TerminalCursorShape::Beam), |view| {
view.border_l_2().border_color(rgb(TEXT))
})
.child(run.text)
.into_any_element()
}
fn terminal_view(&self) -> gpui::AnyElement {
let rows = terminal_paint_rows(&self.terminal_snapshot)
.into_iter()
.map(|runs| {
div()
.flex()
.h(px(TERMINAL_CELL_HEIGHT))
.flex_none()
.children(runs.into_iter().map(Self::terminal_run))
})
.collect::<Vec<_>>();
div()
.flex()
.flex_col()
.size_full()
.overflow_hidden()
.bg(rgb(BG))
.font_family("monospace")
.text_size(px(13.0))
.children(rows)
.into_any_element()
}
fn work_surface(&self) -> gpui::AnyElement {
let pane = self.model.pane(self.model.selected_pane());
let external = pane.output_source() == OutputSource::External;
let status = if external {
self.runtime_status.badge()
} else {
pane.fixture().badge
};
let detail = if external {
self.runtime_status.detail()
} else {
pane.fixture().target.to_owned()
};
let surface_status = if external {
format!(
"{} · {}×{} · auto-fit",
status,
self.terminal.dimensions().columns(),
self.terminal.dimensions().rows()
)
} else {
status.to_owned()
};
let surface = match pane.kind() {
SurfaceKind::Terminal => "TERMINAL",
SurfaceKind::Markdown => "CONTEXT",
SurfaceKind::Browser => "BROWSER",
SurfaceKind::Review => "REVIEW",
};
let tabs = ["TERMINAL", "BROWSER", "TOOLS", "CONTEXT", "GOAL", "REVIEW"]
.into_iter()
.map(|label| {
div()
.h_full()
.flex()
.items_center()
.px_3()
.text_xs()
.text_color(rgb(if label == surface { ACCENT } else { MUTED }))
.when(label == surface, |view| {
view.border_b_2().border_color(rgb(ACCENT))
})
.child(label)
})
.collect::<Vec<_>>();
let content = if external {
self.terminal_view()
} else {
let start = pane.lines().len().saturating_sub(18);
div()
.flex()
.flex_col()
.gap_1()
.size_full()
.overflow_hidden()
.font_family("monospace")
.text_sm()
.text_color(rgb(TEXT))
.children(pane.lines()[start..].iter().cloned())
.into_any_element()
};
div()
.flex()
.flex_col()
.size_full()
.min_h_0()
.overflow_hidden()
.bg(rgb(PANEL))
.border_y_1()
.border_color(rgb(BORDER))
.child( .child(
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_between() .justify_between()
.h(px(36.0))
.flex_none()
.px_3() .px_3()
.py_2() .bg(rgb(PANEL_ALT))
.text_xs() .child(
.text_color(rgb(MUTED)) div()
.child(if external { .flex()
format!( .items_center()
"{} · {}×{} · Alt+Shift+arrows resize", .gap_3()
runtime_status.detail(), .text_sm()
terminal_dimensions.columns(), .child(pane.fixture().title)
terminal_dimensions.rows() .child(div().text_xs().text_color(rgb(MUTED)).child(detail)),
) )
} else { .child(
pane.fixture().target.to_owned() div()
}) .text_xs()
.when(selected, |view| view.child("FOCUSED")), .text_color(rgb(SUCCESS))
.child(surface_status),
),
) )
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.gap_3()
.h(px(26.0))
.flex_none()
.px_3()
.border_y_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(div().text_color(rgb(ACCENT)).child("TERMINAL"))
.child(div().text_color(rgb(MUTED)).child("BROWSER"))
.child(div().text_color(rgb(MUTED)).child("TOOLS"))
.child(div().text_color(rgb(MUTED)).child("CONTEXT"))
.child(div().text_color(rgb(MUTED)).child("GOAL"))
.child(div().text_color(rgb(MUTED)).child("REVIEW")),
)
})
.child( .child(
div() div()
.flex() .flex()
.flex_col() .h(px(28.0))
.flex_none()
.px_1()
.bg(rgb(PANEL_ALT))
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.children(tabs),
)
.child(
div()
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.gap_1() .p_3()
.px_3() .child(content),
.pb_3() )
.text_sm() .into_any_element()
.text_color(rgb(TEXT)) }
.children(pane.lines()[line_start..].iter().cloned()),
fn decision_region(&self) -> gpui::AnyElement {
let choice = |label: &'static str, detail: &'static str| {
div()
.flex()
.flex_col()
.min_w(px(200.0))
.px_3()
.py_2()
.rounded(px(5.0))
.border_1()
.border_color(rgb(BORDER))
.bg(rgb(PANEL_ALT))
.text_sm()
.text_color(rgb(TEXT))
.child(label)
.child(div().mt_1().text_xs().text_color(rgb(MUTED)).child(detail))
};
div()
.flex()
.flex_col()
.size_full()
.min_h_0()
.overflow_hidden()
.px_3()
.py_2()
.bg(rgb(PANEL))
.child(
div()
.flex()
.items_center()
.justify_between()
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("DECISION SHELF · answer, choices, approvals"),
)
.child(
div()
.text_color(rgb(ATTENTION))
.child("LOCAL ANALYST OFF · suggestions inert"),
),
)
.child(
div()
.flex()
.items_center()
.gap_2()
.mt_2()
.child(choice("Continue", "Keep the selected agent moving"))
.child(choice("Review plan", "Inspect capability-scoped commands"))
.child(choice("Ask…", "Refine before any action")),
) )
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.h(px(38.0))
.flex_none()
.px_3()
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("LOCAL ANALYST · OFF · suggestions only"),
)
.child(
div()
.flex()
.gap_2()
.text_color(rgb(TEXT))
.child("Continue")
.child("Review plan")
.child("Ask…"),
),
)
})
.into_any_element() .into_any_element()
} }
@@ -899,29 +1236,91 @@ impl Render for LumbridgeShell {
.child("1 interactive VT · 5 deterministic · 1 waiting"), .child("1 interactive VT · 5 deterministic · 1 waiting"),
); );
let pane_cards = self let pane_selectors = self
.model .model
.panes() .panes()
.iter() .iter()
.map(|pane| { .map(|pane| {
Self::pane_card( Self::pane_selector(
pane, pane,
self.model.selected_pane() == pane.id(), self.model.selected_pane() == pane.id(),
self.runtime_status.clone(), self.runtime_status.clone(),
self.terminal.dimensions(),
self.pane_focus[pane.id().index()].clone(), self.pane_focus[pane.id().index()].clone(),
cx, cx,
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let grid = div() let selected = self.model.pane(self.model.selected_pane());
.grid() let workspace_stack = div()
.grid_cols(3) .flex()
.grid_rows(2) .flex_col()
.gap_2()
.p_2()
.size_full() .size_full()
.children(pane_cards); .min_h_0()
.overflow_hidden()
.child(
div()
.h(relative(0.20))
.flex_none()
.flex()
.flex_col()
.min_h_0()
.overflow_hidden()
.px_3()
.py_2()
.bg(rgb(PANEL))
.child(
div()
.flex()
.items_center()
.justify_between()
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("CONTEXT · panes, agents, tools, goal"),
)
.child(
div()
.text_color(rgb(if selected.needs_input() {
ATTENTION
} else {
SUCCESS
}))
.child(if selected.needs_input() {
"NEEDS INPUT · decision below"
} else {
"WORKSPACE HEALTHY"
}),
),
)
.child(
div()
.flex()
.items_center()
.gap_2()
.mt_2()
.flex_1()
.min_h_0()
.overflow_hidden()
.children(pane_selectors),
),
)
.child(
div()
.h(relative(0.60))
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.work_surface()),
)
.child(
div()
.h(relative(0.20))
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.decision_region()),
);
let counters = self.model.counters(); let counters = self.model.counters();
let footer_left = format!( let footer_left = format!(
@@ -1014,7 +1413,7 @@ impl Render for LumbridgeShell {
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.child(tabs) .child(tabs)
.child(grid), .child(workspace_stack),
), ),
) )
.child( .child(
@@ -1141,7 +1540,11 @@ fn main() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{KeyModifiers, TerminalKey, terminal_key_from_parts}; use super::{
KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, indexed_terminal_color,
terminal_dimensions_for_window, terminal_key_from_parts, terminal_paint_rows,
};
use gpui::{px, size};
#[test] #[test]
fn maps_named_and_composed_gpui_keys_to_terminal_input() { fn maps_named_and_composed_gpui_keys_to_terminal_input() {
@@ -1158,4 +1561,32 @@ mod tests {
let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap(); let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap();
assert_eq!(control.key, TerminalKey::Text("c".into())); assert_eq!(control.key, TerminalKey::Text("c".into()));
} }
#[test]
fn terminal_geometry_tracks_the_middle_sixty_percent() {
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)));
assert_eq!(dimensions.rows(), 24);
assert_eq!(dimensions.columns(), 145);
}
#[test]
fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() {
let mut terminal = TerminalEngine::new(TerminalEngineOptions::default());
terminal.process(b"\x1b[31mAB\x1b[0m");
let rows = terminal_paint_rows(&terminal.snapshot());
assert!(
rows[0]
.iter()
.any(|run| run.text == "AB" && run.columns == 2)
);
assert!(rows[0].iter().any(|run| run.cursor.is_some()));
}
#[test]
fn xterm_color_cube_and_grayscale_are_deterministic() {
assert_eq!(indexed_terminal_color(16), 0x000000);
assert_eq!(indexed_terminal_color(231), 0xffffff);
assert_eq!(indexed_terminal_color(232), 0x080808);
assert_eq!(indexed_terminal_color(255), 0xeeeeee);
}
} }