Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 20 additions & 23 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,9 @@ use crate::{
/// Maximum attached paste size, leaving headroom below the frame limit.
const MAX_PASTE: usize = 8 * 1024 * 1024;

// A maximum-size paste expands to this base64 bound in `encode_command`.
// Reserve 64 KiB for the command envelope and keep the result within one frame.
// A maximum-size paste leaves the client base64-encoded in one `Command::Paste` frame.
const _: () = assert!(
MAX_PASTE.div_ceil(3) * 4 + 64 * 1024 <= crate::frame::MAX_FRAME as usize,
crate::frame::fits_base64(MAX_PASTE),
"MAX_PASTE must base64-encode to under frame::MAX_FRAME"
);

Expand Down Expand Up @@ -80,7 +79,8 @@ enum NoticeLevel {
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Dashboard,
/// Typing a command to spawn in `spawn_cwd` (bottom command line focused).
/// The spawn prompt for `spawn_cwd`: a typed command or a picked agent,
/// selected by `spawn_page`.
Spawn,
/// Live directory picker (the `@` flow) that sets `spawn_cwd`.
PickDir,
Expand Down Expand Up @@ -150,9 +150,10 @@ pub enum SpawnPage {
/// What Enter does with a picker row.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DirKind {
/// The resolved path (row 0): Enter runs the command there.
/// The resolved path (row 0): Enter opens the spawn prompt there.
Use,
/// A current task's directory: Enter runs there; Tab descends into it.
/// A current task's directory: Enter opens the spawn prompt there; Tab
/// descends into it.
Jump,
/// A subdirectory: Enter and Tab descend into it.
Into,
Expand Down Expand Up @@ -225,8 +226,9 @@ pub struct App {
pub mode: Mode,
pub group_mode: GroupMode,
pub input: EditBuffer,
/// Directory a spawned command runs in. Set to `invocation_dir` for the `n`
/// flow, or to the picked directory for the `@` flow.
/// Directory a typed command or a picked agent launches in. Set to
/// `invocation_dir` for the `n` flow, or to the picked directory for the
/// `@` flow.
pub spawn_cwd: PathBuf,
/// Group assigned to the next spawn. Custom mode snapshots the selected
/// task's group; State and Dir modes leave the spawn unassigned.
Expand Down Expand Up @@ -266,7 +268,6 @@ pub struct App {
/// Directory `fleetcom` was launched from: base for relative `@` paths and
/// the "default" section that sorts first in "by dir" mode.
pub invocation_dir: PathBuf,
pub invocation_label: String,
// `@` directory-picker state (only meaningful in `Mode::PickDir`).
pub dir_input: EditBuffer,
pub dir_candidates: Vec<DirCand>,
Expand Down Expand Up @@ -457,7 +458,6 @@ impl App {
make: impl FnOnce(u16, u16, Sender<()>) -> Box<dyn Transport>,
) -> Self {
let invocation_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let invocation_label = path::abbreviate(&invocation_dir);
// The core runs every PTY at the *content* size: full height minus the
// one row attached mode reserves for its status bar.
let pane_rows = rows.saturating_sub(1).max(1);
Expand Down Expand Up @@ -497,7 +497,6 @@ impl App {
last_paint: None,
force_paint: false,
invocation_dir,
invocation_label,
dir_input: EditBuffer::default(),
dir_candidates: Vec::new(),
dir_sel: 0,
Expand Down Expand Up @@ -588,6 +587,7 @@ impl App {

/// Task sections in render order. Navigation uses their flattened order.
pub fn sections(&self) -> Vec<(String, Vec<usize>)> {
let home = path::abbreviate(&self.invocation_dir);
let mut labeled: Vec<(u8, String, u8, String, u64, usize)> = self
.views
.iter()
Expand All @@ -601,7 +601,7 @@ impl App {
GroupMode::Dir => {
let label = path::abbreviate(&v.cwd);
// Keep the invocation directory first.
let rank = if label == self.invocation_label { 0 } else { 1 };
let rank = if label == home { 0 } else { 1 };
(rank, label)
}
GroupMode::Custom => match &v.group {
Expand Down Expand Up @@ -999,14 +999,6 @@ impl App {
});
}

fn spawn_task(&mut self, command: &str) {
self.transport.send(Command::Spawn {
command: command.to_string(),
cwd: self.spawn_cwd.clone(),
group: self.spawn_group.clone(),
});
}

// --- `@` directory picker -------------------------------------------------

/// Rebuild directory-picker rows with the resolved path first. When the
Expand Down Expand Up @@ -1044,7 +1036,7 @@ impl App {
for name in list_dirs(&base, partial) {
let path = base.join(&name);
// A current-task directory that is also a subdirectory already has
// a row: Enter runs there, and Tab descends.
// a row: Enter opens the spawn prompt there, and Tab descends.
if cands
.iter()
.any(|c| c.kind == DirKind::Jump && c.path == path)
Expand Down Expand Up @@ -1237,7 +1229,8 @@ impl App {
}
}

/// Clear the text-prompt state and return to the dashboard.
/// Clear the command buffer and return to the dashboard. The Agent page's
/// own state is reset when the prompt opens, not here.
fn close_prompt(&mut self) {
self.input.clear();
self.mode = Mode::Dashboard;
Expand Down Expand Up @@ -1552,7 +1545,11 @@ impl App {
_ => match self.spawn_page {
SpawnPage::Command => self.on_key_textinput(k, |app, cmd| {
if !cmd.is_empty() {
app.spawn_task(cmd);
app.transport.send(Command::Spawn {
command: cmd.to_string(),
cwd: app.spawn_cwd.clone(),
group: app.spawn_group.clone(),
});
}
}),
SpawnPage::Agent => self.on_key_agent(k),
Expand Down
1 change: 0 additions & 1 deletion src/app_readme_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ impl Dirs {
fn fixture_app(dirs: &Dirs, group_mode: GroupMode, views: Vec<TaskView>) -> App {
let mut app = App::assemble(FIXTURE_ROWS, FIXTURE_COLS, |_, _, _| Box::new(NoTransport));
app.daemon_backed = true;
app.invocation_label = path::abbreviate(&dirs.fleetcom);
app.invocation_dir = dirs.fleetcom.clone();
app.spawn_cwd = dirs.fleetcom.clone();
app.group_mode = group_mode;
Expand Down
88 changes: 17 additions & 71 deletions src/app_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,11 @@ fn dir_mode_groups_by_cwd() {
app.group_mode = GroupMode::Dir;
let s = app.sections();
assert_eq!(s.len(), 2, "one section per distinct cwd");
assert_eq!(s[0].0, app.invocation_label, "invocation dir sorts first");
assert_eq!(
s[0].0,
path::abbreviate(&app.invocation_dir),
"invocation dir sorts first"
);
assert_eq!(s[1].0, "/tmp");
}

Expand Down Expand Up @@ -617,7 +621,7 @@ fn dir_mode_idle_task_holds_its_row() {
app.spawn_in("sleep 5", inv); // id 2
app.pump();
app.group_mode = GroupMode::Dir;
let label = app.invocation_label.clone();
let label = path::abbreviate(&app.invocation_dir);
assert_eq!(app.section_ids(), vec![(label.clone(), vec![1, 2])]);

let i = app.views.iter().position(|v| v.id == 1).unwrap();
Expand Down Expand Up @@ -687,7 +691,7 @@ fn dir_mode_finished_sinks_within_section() {
app.group_mode = GroupMode::Dir;
assert_eq!(
app.section_ids(),
vec![(app.invocation_label.clone(), vec![2, 1])],
vec![(path::abbreviate(&app.invocation_dir), vec![2, 1])],
"finished id 1 sinks below live id 2 within its directory"
);
}
Expand Down Expand Up @@ -1400,7 +1404,7 @@ fn rows_interleave_headers_and_tasks() {
app.group_mode = GroupMode::Dir;
let rows = app.list_rows();
assert_eq!(rows.len(), 4, "two sections, one task each");
assert_eq!(rows[0], Row::Section(app.invocation_label.clone()));
assert_eq!(rows[0], Row::Section(path::abbreviate(&app.invocation_dir)));
assert!(matches!(rows[1], Row::Task(i) if app.views[i].id == 1));
assert_eq!(rows[2], Row::Section("/tmp".to_string()));
assert!(matches!(rows[3], Row::Task(i) if app.views[i].id == 2));
Expand Down Expand Up @@ -2513,17 +2517,12 @@ fn find_panel_rows_name_the_task_and_its_section() {
app.pump();
app.on_key_dashboard(key(KeyCode::Char('/')));

let mut out = Vec::new();
crate::ui::render(&mut out, &mut app).unwrap();
let frame = String::from_utf8_lossy(&out).into_owned();
let frame = painted(&mut app);
assert!(frame.contains("✻ api tests · Running"), "{frame:?}");
assert!(frame.contains("enter jump · ↑↓ pick · esc"), "{frame:?}");

find_type(&mut app, "zzz");
app.last_frame.clear();
let mut out = Vec::new();
crate::ui::render(&mut out, &mut app).unwrap();
let frame = String::from_utf8_lossy(&out).into_owned();
let frame = painted(&mut app);
assert!(frame.contains("(no matching tasks)"), "{frame:?}");
}

Expand Down Expand Up @@ -2977,9 +2976,7 @@ fn pickgroup_caret_edits_refresh_the_filter() {
fn paste_lands_at_the_caret() {
let mut app = App::new_local(30, 100);
app.mode = Mode::Spawn;
for c in "cargo t".chars() {
app.on_key_spawn(key(KeyCode::Char(c)));
}
app.type_spawn("cargo t");
app.on_key_spawn(key(KeyCode::Left)); // caret before 't'
app.on_paste("x\ny"); // control chars still stripped
assert_eq!(app.input.as_str(), "cargo xyt");
Expand Down Expand Up @@ -3023,9 +3020,7 @@ fn custom_mode_spawn_inherits_the_selected_group() {
assert_eq!(app.mode, Mode::Spawn);
assert_eq!(app.spawn_group.as_deref(), Some("alpha"));

for c in "sleep 5".chars() {
app.on_key_spawn(key(KeyCode::Char(c)));
}
app.type_spawn("sleep 5");
app.on_key_spawn(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 2).unwrap();
Expand Down Expand Up @@ -3072,9 +3067,7 @@ fn state_and_dir_mode_spawns_stay_unassigned() {
}

app.on_key_dashboard(key(KeyCode::Char('n')));
for c in "sleep 5".chars() {
app.on_key_spawn(key(KeyCode::Char(c)));
}
app.type_spawn("sleep 5");
app.on_key_spawn(key(KeyCode::Enter));
app.pump();
let v = app.views.iter().find(|v| v.id == 2).unwrap();
Expand Down Expand Up @@ -3127,34 +3120,6 @@ impl App {
}
}

/// Create an app with only a stub `claude` on the core's `PATH`. Discover and launch that
/// stub without running a real agent. Keep capture assets and Claude registry reads inside
/// the scratch tree.
fn stub_claude_fixture(tag: &str) -> (App, Scratch) {
let dir = temp(&format!("agent_{tag}"));
let bin = dir.join("bin");
std::fs::create_dir_all(&bin).unwrap();
write_executable(&bin.join("claude"), "exit 0");
let ctx = crate::protocol::LaunchContext {
env: vec![
("PATH".into(), bin.as_os_str().to_os_string()),
(
path::FLEETCOM_RUNTIME_DIR.into(),
dir.join("run").into_os_string(),
),
(
"CLAUDE_CONFIG_DIR".into(),
dir.join("claude-home").into_os_string(),
),
],
cwd: dir.to_path_buf(),
};
let mut app = App::new_local_with_ctx(30, 100, ctx);
app.pump();
assert_eq!(app.agents, ["claude"], "discovery must see only the stub");
(app, dir)
}

/// Switch between Command and Agent with Tab only when agents are installed. With no
/// agents, ignore Tab and BackTab and keep the current page.
#[test]
Expand Down Expand Up @@ -3258,14 +3223,7 @@ fn session_load_status_keeps_capture_notices_and_failures() {
],
cwd: dir.to_path_buf(),
};
let managed = |agent: &str| crate::session::SessionEntry {
kind: crate::session::EntryKind::Managed {
agent: agent.into(),
resume: None,
},
group: None,
name: None,
};
let managed = |agent| crate::session::SessionEntry::managed(agent, None);
let cfg = crate::session::SessionConfig::from([(
dir.to_string_lossy().into_owned(),
vec![managed("codex"), managed("grok"), managed("codex")],
Expand Down Expand Up @@ -3295,12 +3253,10 @@ fn session_load_status_keeps_capture_notices_and_failures() {
}

/// Press `n`, Tab, then Enter to launch the first installed agent in the invocation
/// directory. Close the prompt, admit a managed task, and label its row with the
/// program word.
/// directory and close the prompt.
#[test]
fn agent_enter_launches_the_highlighted_agent_managed() {
let (mut app, _dir) = stub_claude_fixture("enter");
let sent = app.record_sends();
let (mut app, sent) = App::with_agents(&["claude"]);
app.open_agent_page();
app.on_key_spawn(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::Dashboard, "Enter closes the prompt");
Expand All @@ -3312,13 +3268,6 @@ fn agent_enter_launches_the_highlighted_agent_managed() {
group: None,
}]
);
app.pump();
assert_eq!(app.views.len(), 1);
assert!(app.views[0].managed, "the core must admit a managed task");
assert_eq!(
app.views[0].command, "claude",
"the row shows the program word"
);
}

/// Launch the selected row on Enter, using the directory selected through `@` and the group
Expand Down Expand Up @@ -4368,10 +4317,7 @@ fn peek_shows_short_output_instead_of_the_blank_grid_tail() {
"the peek reads the selected task's screen"
);
app.mode = Mode::Peek;
app.last_frame.clear();
let mut out = Vec::new();
crate::ui::render(&mut out, &mut app).unwrap();
let frame = String::from_utf8_lossy(&out);
let frame = painted(&mut app);
for word in ["alpha", "beta", "gamma"] {
assert!(frame.contains(word), "peek frame must show {word:?}");
}
Expand Down
5 changes: 3 additions & 2 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
//!
//! Every task's PTY master is held by the daemon. On daemon termination, the PTYs
//! are closed and SIGHUP is sent by the kernel to each terminal's foreground
//! process group: the whole task, with job control off under `$SHELL -c`. On
//! normal shutdown, send SIGTERM to each task group, then SIGKILL after a grace
//! process group: the whole task, because a literal task runs with job control off
//! under `$SHELL -c` and a managed task execs the agent binary with no shell at all.
//! On normal shutdown, send SIGTERM to each task group, then SIGKILL after a grace
//! period, and remove the socket and lock. After a crash or SIGKILL, only the PTYs
//! are closed; HUP-immune tasks may be left running without a supervisor.

Expand Down
8 changes: 7 additions & 1 deletion src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::{

/// jzon-encoded control data (a `Command`, or any event but `Screen`).
pub const KIND_CONTROL: u8 = 1;
/// A `Screen` event: a jzon header (id, cursor, lines) followed by the raw
/// A `Screen` event: a jzon metadata header followed by the raw
/// `contents_formatted` bytes, spliced by [`crate::protocol`].
pub const KIND_SCREEN: u8 = 2;
/// Connection-opening handshake containing the protocol version and launch
Expand All @@ -23,6 +23,12 @@ pub const KIND_HELLO: u8 = 3;
/// against this limit.
pub const MAX_FRAME: u32 = 64 * 1024 * 1024;

/// Whether `n` raw bytes, base64-encoded, fit in one frame beside a 64 KiB reserve
/// for the jzon envelope that carries them (`Command::Paste`, `Event::ClipboardCopy`).
pub const fn fits_base64(n: usize) -> bool {
n.div_ceil(3) * 4 + 64 * 1024 <= MAX_FRAME as usize
}

/// Maximum time one frame write to a socket peer may block. A peer that
/// stops draining its socket (crashed, SIGSTOPped, hostile) must not wedge
/// the writer: the daemon's event writes and the client's command writes
Expand Down
Loading
Loading