Compare commits

..
Author SHA1 Message Date
Miguel Palhas ca585447ff fix(maestro): dir autocomplete dismissal and key handling
Escape closes the popup, refocuses the input and stops propagation so it
can't also discard a surrounding draft pane. Tab commits the highlighted
entry and lets focus move to the next field. Blur / clicking outside
dismisses, and a dismissed popup stays closed until the operator types
again.
2026-08-23 20:38:55 +01:00
Miguel Palhas 71301861c0 chore(maestro): add happy-dom for DOM-level web tests 2026-08-23 20:38:55 +01:00
Miguel Palhas 2ce1dc43fe merge: tui inline draft pane + paste forwarding fix (#63) 2026-08-23 20:31:18 +01:00
Miguel Palhas e99e21260b Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/63-tui-draft-pane 2026-08-23 20:26:26 +01:00
Miguel Palhas cea06f9204 fix(maestro): forward bracketed pastes into the attached session
forward_to_session dropped Paste(_) on the floor, so pastes into a
session vanished once terminal.rs enabled bracketed paste. Split the
arm: Paste forwards the payload verbatim (the remote readline
reassembles it), only Mouse events have no wire encoding.
2026-08-23 20:25:57 +01:00
Miguel Palhas 1199596528 feat(maestro): inline draft pane replaces the SpawnModal overlay
Action::OpenSpawnModal becomes NewDraftPane: pressing s turns the empty
pane into an inline draft form (or opens a new tab when the pane shows a
session). The form is a pane-level widget with its own field focus; a
successful submit replaces the draft with the new session in place,
matching the web client's draft panes. SpawnModal and OverlayKind::Spawn
are removed; kill-confirm and help overlays stay.
2026-08-23 20:25:46 +01:00
Miguel Palhas 6d21061799 merge: worktree placement, existing branches, guarded cleanup (#56) 2026-08-23 20:21:08 +01:00
Miguel Palhas 431f9def6b Merge branch 'blitz/spawn-v2' of https://git.naps.pt/naps62/maestro into spawn-v2/56-worktree-place 2026-08-23 20:17:21 +01:00
Miguel Palhas d8acc3adde feat(maestro): always clean up worktrees on kill behind a dirty-work guard 2026-08-23 20:17:08 +01:00
Miguel Palhas 95f0e42df5 feat(maestro): add draft-pane widget with its own field focus 2026-08-23 20:16:43 +01:00
Miguel Palhas 6f8de8de7c fix(maestro): draft-pane regressions from the integration review
Six findings from reviewing the accumulated blitz/spawn-v2 diff:

- loadRecents restored the seeded cwd but never emitted it, so the leaf's
  draft kept cwd:"" and the next render reseeded the form blank.
- stripDrafts did not recurse, so every half-filled draft in a split tab
  was written to localStorage; the old test passed only because hydrate
  discards draft on the way back in.
- showRawCwd and selectPreset grabbed focus from async init paths, so a
  draft pane stole the caret seconds after mounting.
- .mv-draft inherited no layout once the form left .modal-body.
- onSuccess mounted the new session onto a leaf that may have been closed
  while the POST was in flight, stranding it in no pane.
2026-08-23 20:12:28 +01:00
Miguel Palhas 018f44727c feat(maestro): place worktrees at <repo>/worktrees/<branch> 2026-08-23 20:09:38 +01:00
Miguel Palhas 85e7749e5a feat(maestro): worktree specs can check out an existing branch 2026-08-23 20:05:26 +01:00
Miguel Palhas 35bc5bfb40 merge: tui text-input widget (#58) 2026-08-23 20:00:47 +01:00
32 changed files with 1545 additions and 503 deletions
+25 -2
View File
@@ -67,8 +67,10 @@ pub struct SpawnArgs {
/// Branch name for the worktree (required with --worktree-from).
#[arg(long)]
pub branch: Option<String>,
/// Remove the worktree when the session is killed.
#[arg(long)]
/// Remove the worktree when the session is killed. Kept for wire
/// compatibility (#56): the daemon now always cleans up on kill, guarded
/// against unsaved work.
#[arg(long, default_value_t = true)]
pub cleanup_on_kill: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
@@ -147,6 +149,27 @@ mod tests {
}
}
#[test]
fn cleanup_on_kill_defaults_true_for_the_wire() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repos/foo",
"--branch",
"feat/x",
])
.unwrap();
match cli.command {
Command::Spawn(a) => assert!(a.cleanup_on_kill),
_ => panic!("wrong subcommand"),
}
}
#[test]
fn spawn_worktree_requires_branch() {
let err = Cli::try_parse_from([
+1
View File
@@ -44,6 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
base_repo: repo.clone(),
branch: branch.clone(),
cleanup_on_kill: args.cleanup_on_kill,
create_branch: true,
}),
_ => None,
};
+3 -2
View File
@@ -87,8 +87,9 @@ pub struct DaemonSection {
/// Root for everything daemon-local: sidecars, FIFOs, etc.
#[serde(default)]
pub state_dir: Option<PathBuf>,
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
/// with a `worktree` spec.
/// Override root for ephemeral worktrees the daemon creates on
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
#[serde(default)]
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
+2 -1
View File
@@ -47,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_dir())?;
std::fs::create_dir_all(config.fifo_dir())?;
std::fs::create_dir_all(&config.worktree_root)?;
// No worktree dir to pre-create: without an override the worktrees live
// under their base repos, created on demand (#56).
let sidecar = SidecarStore::new(config.sidecar_dir());
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
+6 -6
View File
@@ -53,7 +53,10 @@ pub struct DaemonConfig {
pub home_dir: String,
pub bind_addr: SocketAddr,
pub state_dir: PathBuf,
pub worktree_root: PathBuf,
/// `[daemon].worktree_root` override (#56). `None` — the default — places
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
/// operators who want them elsewhere set this.
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
@@ -131,9 +134,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
Some(p) => p,
None => default_state_dir()?,
};
let worktree_root = daemon
.worktree_root
.unwrap_or_else(|| state_dir.join("worktrees"));
let pty_replay_bytes = daemon
.pty_replay_bytes
@@ -152,7 +152,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
home_dir,
bind_addr,
state_dir,
worktree_root,
worktree_root: daemon.worktree_root,
pty_replay_bytes,
tmux_tmpdir,
spawn_history_days,
@@ -188,7 +188,7 @@ mod tests {
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert!(cfg.worktree_root.is_none());
assert!(!cfg.hostname.is_empty());
}
+27 -24
View File
@@ -38,13 +38,12 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
pub struct SessionRecord {
pub session: Session,
pub fifo_path: PathBuf,
/// Worktree we created at spawn time and that should be cleaned up on
/// kill (if `cleanup_worktree_on_kill`).
/// Worktree we created at spawn time; removed on kill unless the
/// dirty-work guard refuses (#56).
pub worktree_path: Option<PathBuf>,
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
pub cleanup_worktree_on_kill: bool,
pub bridge: PtyBridge,
}
@@ -213,24 +212,20 @@ impl SessionManager {
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
Some(WorktreeSpec {
base_repo, branch, ..
}) => {
let path = self
.inner
.worktree_mgr
.add(base_repo, branch)
.map_err(SessionError::Worktree)?;
Some(spec) => {
let WorktreeSpec {
base_repo, branch, ..
} = spec;
let path = if spec.create_branch {
self.inner.worktree_mgr.add_new(base_repo, branch)
} else {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
}
None => (req.cwd.clone(), None, None),
};
let cleanup_worktree_on_kill = req
.worktree
.as_ref()
.map(|w| w.cleanup_on_kill)
.unwrap_or(false);
let id = Uuid::new_v4();
// Build the FIFO + reader task *before* asking tmux to spawn the
// command — otherwise the child's first bytes race the separate
@@ -315,7 +310,6 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
cleanup_worktree_on_kill,
bridge,
},
);
@@ -357,8 +351,9 @@ impl SessionManager {
}
}
/// Kill a session and remove its on-disk artifacts. The worktree is only
/// pruned if the create request set `cleanup_on_kill: true`.
/// Kill a session and remove its on-disk artifacts. The worktree is
/// always pruned -- unless it holds uncommitted changes or commits not on
/// any other ref, in which case the guard leaves it on disk (#56).
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
@@ -454,7 +449,6 @@ impl SessionManager {
fifo_path,
worktree_path: None,
worktree_base_repo: None,
cleanup_worktree_on_kill: false,
bridge,
},
);
@@ -703,9 +697,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if record.cleanup_worktree_on_kill {
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
let _ = inner.worktree_mgr.remove(base, path);
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
// user-facing choice. The manager's guard is what protects unsaved work --
// when it refuses, leave the tree on disk and say where it is so the
// operator can find it.
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
%id,
path = %path.display(),
error = %e,
"left worktree on disk"
);
}
}
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
+143 -29
View File
@@ -4,64 +4,113 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum WorktreeError {
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
/// A worktree already occupies the target path. The caller decides what
/// to do; the daemon never relocates to a different directory (#56).
#[error("worktree path already exists: {}", .path.display())]
PathExists { path: PathBuf },
/// The worktree holds unsaved work; removal was refused (#56).
#[error("left {} on disk: {}", .path.display(), .reason)]
Guarded { path: PathBuf, reason: String },
}
/// Generates a unique path under `root` and runs `git worktree add` rooted at
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
/// `add()`s never collide.
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
/// are findable from the repo; an operator-set root overrides that.
#[derive(Debug, Clone)]
pub struct WorktreeManager {
root: PathBuf,
/// `[daemon].worktree_root` override. `None` uses the default
/// `<repo>/worktrees/<branch>` placement.
root_override: Option<PathBuf>,
}
impl WorktreeManager {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
pub fn new(root_override: Option<PathBuf>) -> Self {
Self { root_override }
}
pub fn root(&self) -> &Path {
&self.root
pub fn root(&self) -> Option<&Path> {
self.root_override.as_deref()
}
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
/// Returns the path of the new worktree on success.
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
std::fs::create_dir_all(&self.root)?;
let path = self.generate_path(branch);
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "add", "-b", branch])
.arg(&path)
.output()?;
/// Where a worktree for `branch` will be created: the override root, or
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
/// predictable (no UUID suffix), so an existing directory at the target
/// is an error rather than a silently different location.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
match &self.root_override {
Some(root) => root.join(slug),
None => base_repo.join("worktrees").join(slug),
}
}
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
/// branch. Returns the path of the new worktree on success.
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, true)
}
/// `git -C <base_repo> worktree add <path> <branch>` — checks out a branch
/// that must already exist (#56).
pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, false)
}
fn run_add(
&self,
base_repo: &Path,
branch: &str,
create_branch: bool,
) -> Result<PathBuf, WorktreeError> {
let path = self.target_path(base_repo, branch);
if path.symlink_metadata().is_ok() {
return Err(WorktreeError::PathExists { path });
}
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
}
let out = cmd.output()?;
check(out)?;
Ok(path)
}
/// `git -C <base_repo> worktree remove --force <path>`.
/// `git -C <base_repo> worktree remove <path>` — but only when the
/// worktree holds nothing unique (#56). A worktree with uncommitted
/// changes, or with commits reachable from no other ref, is refused and
/// left on disk so the operator can rescue it; cleanup is unconditional,
/// which makes this guard the only thing standing between a stray kill
/// and destroyed work.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
if let Some(reason) = unsafe_to_remove(path)? {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason,
});
}
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "remove", "--force"])
.args(["worktree", "remove"])
.arg(path)
.output()?;
check(out)
}
fn generate_path(&self, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
let unique = Uuid::new_v4().simple();
self.root.join(format!("{slug}-{unique}"))
}
}
fn sanitize_branch_for_path(branch: &str) -> String {
@@ -79,6 +128,71 @@ fn sanitize_branch_for_path(branch: &str) -> String {
out
}
/// `Some(reason)` when deleting `path` would destroy work: uncommitted
/// changes, or commits reachable from no ref other than the worktree's own
/// branch (`git worktree remove` keeps the branch, but nothing checks it out
/// anymore — treat its commits as at risk anyway, per #56).
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
let status = run_git(path, ["status", "--porcelain"])?;
if !status.stdout.is_empty() {
return Ok(Some("has uncommitted changes".into()));
}
let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout)
.trim()
.to_string();
let branch =
String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout)
.trim()
.to_string();
// Every ref except this worktree's own branch counts as a safe home for
// its commits. Detached HEAD excludes everything.
let refs =
String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout)
.lines()
.filter(|r| match branch.as_str() {
"HEAD" => true,
b => *r != format!("refs/heads/{b}"),
})
.map(str::to_string)
.collect::<Vec<_>>();
let mut cmd = Command::new("git");
cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]);
if !refs.is_empty() {
cmd.arg("--not").args(&refs);
}
let out = cmd.output()?;
check(out.clone())?;
let count: usize = String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.unwrap_or(0);
if count > 0 {
return Ok(Some(format!(
"has {count} commit(s) not present on any other ref"
)));
}
Ok(None)
}
fn run_git(
dir: &Path,
args: impl IntoIterator<Item = &'static str>,
) -> Result<Output, WorktreeError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(WorktreeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
fn check(out: Output) -> Result<(), WorktreeError> {
if out.status.success() {
Ok(())
+2 -4
View File
@@ -115,7 +115,6 @@ pub struct DaemonFixture {
pub tmux: TmuxFixture,
pub manager: SessionManager,
pub state_dir: TempDir,
pub worktree_root: TempDir,
}
impl DaemonFixture {
@@ -132,9 +131,9 @@ impl DaemonFixture {
fn build(history_days: Option<u32>) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let worktree_root = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
// Default placement: worktrees land under their base repo (#56).
let worktree_mgr = WorktreeManager::new(None);
let mut manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
sidecar,
@@ -149,7 +148,6 @@ impl DaemonFixture {
tmux,
manager,
state_dir,
worktree_root,
}
}
@@ -38,7 +38,7 @@ impl Fx {
let manager = SessionManager::new(
fake.clone(),
SidecarStore::new(pick(tmp.path())),
WorktreeManager::new(tmp.path().join("worktrees")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
fifo_dir,
);
Self { manager, fake, tmp }
@@ -387,7 +387,7 @@ fn attaching_spawn_history_after_cloning_panics() {
let manager = SessionManager::new(
fake,
SidecarStore::new(tmp.path().join("sidecars")),
WorktreeManager::new(tmp.path().join("worktrees")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
tmp.path().join("fifos"),
);
let _clone = manager.clone();
+70 -6
View File
@@ -7,9 +7,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use common::{init_repo, wait_for_async, DaemonFixture};
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus};
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
use tempfile::TempDir;
#[tokio::test]
@@ -123,8 +123,7 @@ async fn recover_adopts_existing_tmux_sessions() {
// build a parallel manager pointing at the same dirs + same tmux server
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
let revived = maestro_daemon::SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
sidecar,
@@ -214,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let sidecar_dir = fx.sidecar_dir();
let fifo_dir = fx.fifo_dir();
let tmux = fx.tmux.tmux.clone();
let worktree_root = fx.worktree_root.path().to_path_buf();
// Drop the original manager. Its PtyBridge closes the FIFO's only
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
@@ -223,7 +221,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
drop(fx.manager);
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
let revived =
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
assert_eq!(revived.recover().await.expect("recover"), 1);
@@ -365,6 +363,72 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
/// loses its worktree on kill — the guard, not the flag, protects work.
#[tokio::test]
async fn kill_removes_worktree_even_when_spec_says_keep() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-keep");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/keep".into(),
cleanup_on_kill: false,
create_branch: true,
}),
})
.await
.expect("create");
assert!(wt_path.exists());
fx.manager.kill(s.id).await.expect("kill");
assert!(!wt_path.exists(), "worktree must be removed on kill");
}
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
/// left on disk where the operator can find it.
#[tokio::test]
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat-guarded");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
fx.manager.kill(s.id).await.expect("kill");
assert!(
wt_path.exists(),
"dirty worktree must be left on disk, not destroyed"
);
assert!(fx.manager.snapshot().await.is_empty());
}
#[tokio::test]
async fn liveness_watcher_leaves_running_sessions_alone() {
let fx = DaemonFixture::new();
+164 -9
View File
@@ -31,9 +31,9 @@ fn add_creates_worktree_on_new_branch() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/example").expect("add worktree");
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert!(path.exists());
assert!(path.join("README").exists());
assert!(path.starts_with(wt_root.path()));
@@ -42,6 +42,63 @@ fn add_creates_worktree_on_new_branch() {
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn add_places_worktree_inside_the_base_repo_by_default() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// No override: the worktree must land where the repository keeps its
// own worktrees (#56), not under some daemon state dir.
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, repo.join("worktrees").join("feat-example"));
assert!(path.exists());
assert!(path.join("README").exists());
let branches = worktree_branches(&repo);
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn worktree_root_override_redirects_placement() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, wt_root.path().join("feat-example"));
assert!(path.exists());
}
#[test]
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// A predictable path means collisions are possible; they must be
// reported, never silently relocated.
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
let mgr = WorktreeManager::new(None);
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains(
repo.join("worktrees")
.join("feat-collide")
.to_str()
.unwrap()
),
"{msg}"
);
}
#[test]
fn add_twice_generates_distinct_paths() {
let repo_dir = TempDir::new().unwrap();
@@ -49,10 +106,10 @@ fn add_twice_generates_distinct_paths() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let p1 = mgr.add(&repo, "feat/a").expect("add a");
let p2 = mgr.add(&repo, "feat/b").expect("add b");
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
assert_ne!(p1, p2);
assert!(p1.exists());
assert!(p2.exists());
@@ -65,9 +122,9 @@ fn remove_prunes_worktree() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/gone").expect("add");
let path = mgr.add_new(&repo, "feat/gone").expect("add");
assert!(path.exists());
mgr.remove(&repo, &path).expect("remove");
@@ -79,6 +136,104 @@ fn remove_prunes_worktree() {
assert_eq!(branches, vec!["main".to_string()]);
}
#[test]
fn add_existing_checks_out_branch_without_creating_it() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// Pre-create the branch with its own commit so we can tell a checkout
// from an accidental `-b`.
let created = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "-b", "feat/dup"])
.status()
.unwrap();
assert!(created.success());
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
let staged = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["add", "."])
.status()
.unwrap();
assert!(staged.success());
let committed = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["commit", "-q", "-am", "wip"])
.status()
.unwrap();
assert!(committed.success());
let back = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "main"])
.status()
.unwrap();
assert!(back.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
assert!(path.exists());
assert!(path.join("README").exists());
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
let branches = worktree_branches(&repo);
let mut sorted = branches.clone();
sorted.sort();
assert_eq!(
sorted,
vec!["feat/dup".to_string(), "main".to_string()],
"{branches:?}"
);
}
#[test]
fn remove_refuses_worktree_with_uncommitted_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
mgr.remove(&repo, &path)
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
// Committing the change doesn't help while it is the only copy…
run_git(&path, &["add", "."]);
run_git(&path, &["commit", "-q", "-m", "unsaved"]);
mgr.remove(&repo, &path)
.expect_err("unique commit must be refused");
assert!(path.exists());
// …but once the commit is reachable from main, removal is safe.
run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]);
mgr.remove(&repo, &path).expect("merged worktree can go");
assert!(!path.exists());
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
@@ -95,9 +250,9 @@ fn add_fails_when_branch_already_exists() {
assert!(status.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let err = mgr.add(&repo, "feat/dup").unwrap_err();
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
+87 -7
View File
@@ -114,7 +114,7 @@ async fn multi_attach_sees_same_output() {
}
#[tokio::test(flavor = "multi_thread")]
async fn cleanup_on_kill_removes_worktree() {
async fn kill_removes_clean_worktree() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
@@ -129,18 +129,19 @@ async fn cleanup_on_kill_removes_worktree() {
base_repo: base.path().to_path_buf(),
branch: "feature/e2e".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
// The daemon's WorktreeManager puts new worktrees under
// `<state_dir>/worktrees/<branch>`. We can't peek the daemon's state dir
// directly from here, but we can verify by asking git: a worktree must
// exist that lives outside the original repo.
// Worktrees live inside the base repo at `<repo>/worktrees/<branch>`
// (#56), so `git worktree list` must show the predictable in-repo path.
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/e2e")),
"expected a worktree for feature/e2e branch, got: {worktrees:?}"
worktrees
.iter()
.any(|w| w.contains("worktrees/feature-e2e")),
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
@@ -160,6 +161,85 @@ async fn cleanup_on_kill_removes_worktree() {
);
}
/// The guard (#56): killing a session whose worktree holds uncommitted
/// changes must leave the tree on disk — cleanup is unconditional, so this
/// guard is what keeps a stray kill from destroying work.
#[tokio::test(flavor = "multi_thread")]
async fn kill_spares_worktree_with_uncommitted_changes() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let wt_path = base.path().join("worktrees").join("feature-unsaved");
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/unsaved".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
let s = client.spawn_session(&req).await.expect("spawn worktree");
assert!(wt_path.exists());
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
client.kill_session(s.id).await.expect("kill");
// The session is gone but the dirty worktree stays findable on disk.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let sessions = client.list_sessions().await.unwrap_or_default();
if sessions.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(
wt_path.exists(),
"guarded worktree must be left on disk after kill"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn spawn_on_existing_branch_checks_it_out() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: base.path().to_path_buf(),
branch: "feature/existing".into(),
cleanup_on_kill: true,
create_branch: false,
}),
};
let s = client
.spawn_session(&req)
.await
.expect("spawn on existing branch");
let worktrees = git_worktree_list(base.path());
assert!(
worktrees.iter().any(|w| w.contains("feature/existing")),
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (mut daemon, client) = bring_up().await;
+39 -1
View File
@@ -111,8 +111,20 @@ pub enum ActivityState {
pub struct WorktreeSpec {
pub base_repo: PathBuf,
pub branch: String,
#[serde(default)]
/// Kept on the wire for CLI compatibility (#56) but no longer a
/// user-facing choice: the daemon always cleans up on kill, guarded
/// against unsaved work. Defaults to `true`.
#[serde(default = "default_true")]
pub cleanup_on_kill: bool,
/// When false, `branch` must already exist and is checked out as-is
/// (#56); when true (the default, and what every payload meant before
/// the field existed) the daemon creates it with `worktree add -b`.
#[serde(default = "default_true")]
pub create_branch: bool,
}
fn default_true() -> bool {
true
}
/// HTTP `POST /api/sessions` body.
@@ -393,6 +405,7 @@ mod tests {
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: true,
}),
};
assert_eq!(req, round_trip(&req));
@@ -410,6 +423,31 @@ mod tests {
assert!(req.metadata.is_empty());
}
/// A client that predates the new/existing discriminator sends no
/// `create_branch`; the daemon must read that as "create a new branch" —
/// exactly what every payload meant before the field existed (#56).
#[test]
fn worktree_spec_defaults_on_legacy_payload() {
let json = serde_json::json!({
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/foo",
});
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
assert!(spec.create_branch);
assert!(spec.cleanup_on_kill);
}
#[test]
fn worktree_spec_round_trips_existing_branch_mode() {
let spec = WorktreeSpec {
base_repo: PathBuf::from("/work"),
branch: "feat/foo".into(),
cleanup_on_kill: true,
create_branch: false,
};
assert_eq!(spec, round_trip(&spec));
}
#[test]
fn catalog_event_round_trips_and_tags() {
let session = sample_session();
+13 -2
View File
@@ -38,7 +38,7 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
NewDraftPane,
KillHighlighted,
// Tabs
@@ -78,7 +78,7 @@ impl Action {
"SidebarPrev" => return Ok(Action::SidebarPrev),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
@@ -202,6 +202,7 @@ mod tests {
for a in [
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
@@ -209,4 +210,14 @@ mod tests {
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
}
}
/// The spawn modal is gone (issue #63); its old action string must not
/// resolve to anything.
#[test]
fn the_removed_spawn_modal_action_no_longer_parses() {
assert_eq!(
Action::parse("OpenSpawnModal").unwrap_err(),
ActionParseError::Unknown("OpenSpawnModal".into())
);
}
}
+170 -84
View File
@@ -6,9 +6,9 @@
//! changes, and a 30Hz heartbeat.
//!
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / `handle_overlay_input`.
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / the Insert-mode pane routing.
use std::collections::{HashMap, HashSet};
use std::io::Stdout;
@@ -24,15 +24,16 @@ use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::draft::DraftInputResult;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::TuiError;
@@ -49,7 +50,6 @@ pub struct App {
catalog: CatalogStore,
emulators: SessionEmulators,
state: AppState,
spawn: SpawnModal,
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
@@ -90,7 +90,6 @@ impl App {
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
spawn: SpawnModal::default(),
help,
chord: ChordState::new(chord_timeout),
daemon,
@@ -152,7 +151,15 @@ impl App {
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
if let Some(effect) = self.forward_to_session(&key) {
// Insert mode over a focused pane: a draft eats the key
// into its form, a session gets the raw bytes.
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
{
self.handle_draft_input(&key)
} else {
self.forward_to_session(&key)
};
if let Some(effect) = effect {
self.perform(vec![effect]).await;
}
}
@@ -179,27 +186,7 @@ impl App {
}
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
use crate::overlays::SpawnInputResult;
match self.state.overlay().cloned() {
Some(OverlayKind::Spawn) => {
let mut effects = Vec::new();
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.dismiss_overlay();
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
command: self.spawn.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}));
}
}
(OverlayDecision::Consumed, effects)
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
@@ -265,14 +252,7 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenSpawnModal => {
self.spawn = SpawnModal {
cwd: Default::default(),
command: Default::default(),
focused_field: Default::default(),
};
self.state.show_overlay(OverlayKind::Spawn);
}
Action::NewDraftPane => self.state.open_draft_pane(),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
@@ -322,44 +302,38 @@ impl App {
/// Encode a keystroke for the focused pane. `None` when there is nothing
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_tab().pane?;
let sid = self.state.active_session()?;
self.emulators.get(sid)?;
let bytes = match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
out
}
InputKey::Enter => b"\r".to_vec(),
InputKey::Tab => b"\t".to_vec(),
InputKey::BackTab => b"\x1b[Z".to_vec(),
InputKey::Backspace => vec![0x7f],
InputKey::Esc => vec![0x1b],
InputKey::Up => b"\x1b[A".to_vec(),
InputKey::Down => b"\x1b[B".to_vec(),
InputKey::Right => b"\x1b[C".to_vec(),
InputKey::Left => b"\x1b[D".to_vec(),
InputKey::Home => b"\x1b[H".to_vec(),
InputKey::End => b"\x1b[F".to_vec(),
InputKey::Delete => b"\x1b[3~".to_vec(),
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
};
let bytes = encode_key(key)?;
Some(Effect::Forward { id: sid, bytes })
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted the form.
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
return None;
};
if draft.on_input(key) != DraftInputResult::Submit {
return None;
}
Some(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
command: draft.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}))
}
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
match self.daemon.create_session(&req).await {
Ok(_) => {
self.state.dismiss_overlay();
Ok(session) => {
// Fill the pane in place with the new session (web parity,
// #57) and attach to it right away.
let sid = session.id;
self.state.complete_spawn(sid);
self.attach(sid);
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
}
@@ -377,7 +351,12 @@ impl App {
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
let attached: HashSet<Uuid> = self
.state
.tabs
.iter()
.filter_map(crate::state::Tab::session)
.collect();
let sidebar = Sidebar {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
@@ -387,7 +366,7 @@ impl App {
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
active_idx: self.state.active_tab_idx,
};
let active_sid = self.state.active_tab().pane;
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
@@ -395,13 +374,17 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane),
})
});
let draft = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Draft(d)) => Some(d),
_ => None,
};
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: pane.as_ref(),
state: &self.state,
spawn: &self.spawn,
draft,
help: &self.help,
};
@@ -449,6 +432,43 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
}
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
/// with no byte representation (mouse events). A bracketed paste is forwarded
/// verbatim — the remote readline reassembles it, since the pane's PTY also
/// has bracketed paste enabled.
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
Some(out)
}
InputKey::Enter => Some(b"\r".to_vec()),
InputKey::Tab => Some(b"\t".to_vec()),
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
InputKey::Backspace => Some(vec![0x7f]),
InputKey::Esc => Some(vec![0x1b]),
InputKey::Up => Some(b"\x1b[A".to_vec()),
InputKey::Down => Some(b"\x1b[B".to_vec()),
InputKey::Right => Some(b"\x1b[C".to_vec()),
InputKey::Left => Some(b"\x1b[D".to_vec()),
InputKey::Home => Some(b"\x1b[H".to_vec()),
InputKey::End => Some(b"\x1b[F".to_vec()),
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
InputKey::Paste(text) => Some(text.as_bytes().to_vec()),
InputKey::Mouse { .. } => None,
}
}
/// Build the lines shown by the help overlay from the live keymap, so the
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
@@ -516,10 +536,33 @@ mod tests {
}
#[test]
fn open_spawn_modal_sets_overlay() {
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
app.dispatch(Action::NewDraftPane);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Insert);
assert!(app.state.overlay().is_none(), "no overlay involved");
}
/// A tab already showing a session gets a new tab for the draft.
#[test]
fn new_draft_pane_over_a_session_opens_a_new_tab() {
let sid = Uuid::from_u128(10);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane);
assert_eq!(app.state.tabs.len(), 2);
assert_eq!(app.state.active_tab_idx, 1);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.tabs[0].session(), Some(sid));
}
/// Seed the catalog with one session and highlight it.
@@ -554,7 +597,7 @@ mod tests {
app.dispatch(Action::AttachHighlighted),
vec![Effect::Attach(sid)]
);
assert_eq!(app.state.active_tab().pane, Some(sid));
assert_eq!(app.state.active_tab().session(), Some(sid));
}
#[test]
@@ -587,23 +630,35 @@ mod tests {
}
#[test]
fn submitting_the_spawn_modal_carries_the_typed_fields() {
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
app.spawn.command = crate::text_input::TextInput::with_value("claude");
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
app.dispatch(Action::NewDraftPane);
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
match effects.as_slice() {
[Effect::Spawn(req)] => {
for c in "/srv/work".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // cwd → command
for c in "claude".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // command → submit
match app.handle_draft_input(&InputKey::Enter) {
Some(Effect::Spawn(req)) => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
}
other => panic!("expected one Spawn effect, got {other:?}"),
other => panic!("expected a Spawn effect, got {other:?}"),
}
}
/// Keys fed while no draft exists must be ignored, not panic.
#[test]
fn draft_input_is_a_no_op_without_a_draft() {
let mut app = make_app();
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
/// failure message stayed on screen until another overlay replaced it.
#[test]
@@ -632,6 +687,37 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// A draft pane is not a session: forwarding must not try to reach it.
#[test]
fn forwarding_is_a_no_op_with_a_draft_pane() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
/// paste into an attached session vanished once bracketed paste was
/// enabled — the payload must be forwarded verbatim.
#[test]
fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() {
assert_eq!(
encode_key(&InputKey::Paste("cd /tmp/foo".into())),
Some(b"cd /tmp/foo".to_vec())
);
}
#[test]
fn mouse_events_have_no_wire_encoding() {
use crate::input::{MouseKind, Region};
assert!(encode_key(&InputKey::Mouse {
kind: MouseKind::Click,
region: Region::Pane,
col: 3,
row: 4,
})
.is_none());
}
#[test]
fn kill_highlighted_opens_confirm() {
use chrono::Utc;
+5 -1
View File
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("up", "SidebarPrev"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "OpenSpawnModal"),
("s", "NewDraftPane"),
("x", "KillHighlighted"),
// Tabs
("ctrl-t", "NewTab"),
@@ -155,6 +155,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
);
}
#[test]
+272
View File
@@ -0,0 +1,272 @@
//! Draft pane — the spawn form living in an ordinary pane (issue #63).
//!
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
//! the caller a built request and the pane is replaced with the new session,
//! matching the web client's inline draft panes (#57). Field editing goes
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
//!
//! Scope note: presets, the project list, generated names, and the worktree
//! section land with issues #64/#65 — this is cwd + command only.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::input::InputKey;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum DraftField {
#[default]
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DraftPane {
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: DraftField,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftInputResult {
Consumed,
Submit,
}
impl DraftPane {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
DraftField::Cwd => DraftField::Command,
DraftField::Command => DraftField::Submit,
DraftField::Submit => DraftField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
DraftField::Cwd => DraftField::Submit,
DraftField::Command => DraftField::Cwd,
DraftField::Submit => DraftField::Command,
};
}
/// Handle one keystroke while this draft owns input (Insert mode, pane
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
/// the caller fires the POST and replaces the draft with the session.
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
match key {
InputKey::Tab => {
self.next_field();
DraftInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
DraftInputResult::Consumed
}
InputKey::Enter => match self.focused_field {
DraftField::Submit => DraftInputResult::Submit,
_ => {
self.next_field();
DraftInputResult::Consumed
}
},
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
DraftField::Cwd => self.cwd.handle_key(other),
DraftField::Command => self.command.handle_key(other),
DraftField::Submit => {}
}
DraftInputResult::Consumed
}
}
}
/// Draw into the pane area. `pane_focused` gates the field highlight and
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let block = Block::default().title(" draft ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let style_for = |field: DraftField| {
if pane_focused && self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field =
|f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 {
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd));
let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command));
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
"[ Spawn ]",
style_for(DraftField::Submit),
)])),
rows[2],
);
if pane_focused {
match self.focused_field {
DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
DraftField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn draft_pane_renders_field_labels_and_values() {
let draft = DraftPane {
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: DraftField::Cwd,
};
let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true));
for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn field_rotation_wraps_in_both_directions() {
let mut draft = DraftPane::default();
assert_eq!(draft.focused_field, DraftField::Cwd);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Command);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Submit);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Cwd);
draft.prev_field();
assert_eq!(draft.focused_field, DraftField::Submit);
draft.on_input(&InputKey::BackTab);
assert_eq!(draft.focused_field, DraftField::Command);
draft.on_input(&InputKey::Tab);
draft.on_input(&InputKey::Tab);
assert_eq!(draft.focused_field, DraftField::Cwd);
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut draft = DraftPane::default();
assert_eq!(draft.focused_field, DraftField::Cwd);
for k in [
InputKey::char('/'),
InputKey::char('t'),
InputKey::char('m'),
InputKey::char('p'),
] {
draft.on_input(&k);
}
draft.on_input(&InputKey::Left);
draft.on_input(&InputKey::Backspace);
assert_eq!(draft.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
draft.on_input(&InputKey::Tab);
assert_eq!(draft.focused_field, DraftField::Command);
draft.on_input(&InputKey::Paste("/tmp/foo".into()));
assert_eq!(draft.command.value(), "/tmp/foo");
draft.on_input(&InputKey::ctrl_char('w'));
assert_eq!(draft.command.value(), "");
assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// Enter walks forward until the submit field, where it submits.
#[test]
fn enter_walks_fields_then_submits_from_submit() {
let mut draft = DraftPane::default();
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
assert_eq!(draft.focused_field, DraftField::Command);
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
assert_eq!(draft.focused_field, DraftField::Submit);
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, block border takes column 0).
#[test]
fn render_places_the_cursor_after_the_typed_text_when_focused() {
let draft = DraftPane {
cwd: crate::text_input::TextInput::with_value("abc"),
..DraftPane::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1));
// Unfocused pane: no field highlight either.
let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false));
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), false)).unwrap();
let buf = terminal.backend().buffer();
assert!(
!buf.content()
.iter()
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)),
"unfocused draft must not highlight fields:\n{rendered_unfocused}"
);
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod app;
pub mod catalog;
pub mod cli;
pub mod config;
pub mod draft;
pub mod emulators;
pub mod input;
pub mod keymap;
+7 -249
View File
@@ -1,161 +1,16 @@
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
//! transient toast.
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
//!
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: SpawnField,
}
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Submit,
SpawnField::Submit => SpawnField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Cwd => SpawnField::Submit,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Submit => SpawnField::Command,
};
}
/// Handle one keystroke while this modal owns input. Returns
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
/// the POST and dismisses the modal.
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
use crate::input::InputKey;
match key {
InputKey::Tab => {
self.next_field();
SpawnInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
SpawnInputResult::Consumed
}
InputKey::Esc => SpawnInputResult::Cancel,
InputKey::Enter => match self.focused_field {
SpawnField::Submit => SpawnInputResult::Submit,
_ => {
self.next_field();
SpawnInputResult::Consumed
}
},
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
SpawnField::Cwd => self.cwd.handle_key(other),
SpawnField::Command => self.command.handle_key(other),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
}
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 10);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let highlight = |field: SpawnField| {
if self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label =
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field = |f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
focused: bool|
-> u16 {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
let cmd_col = draw_field(
f,
&self.command,
rows[1],
self.focused_field == SpawnField::Command,
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[2],
);
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult {
Consumed,
Cancel,
Submit,
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
@@ -262,33 +117,6 @@ mod tests {
out
}
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Command);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
#[test]
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
@@ -306,81 +134,11 @@ mod tests {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s OpenSpawnModal".into(),
"s NewDraftPane".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("OpenSpawnModal"));
}
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
/// spawn modal could only be cycled forwards.
#[test]
fn shift_tab_walks_the_spawn_modal_backwards() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Command);
}
#[test]
fn tab_and_shift_tab_are_inverses() {
let mut modal = SpawnModal::default();
for _ in 0..3 {
modal.on_input(&crate::input::InputKey::Tab);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Cwd);
}
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
for k in [
crate::input::InputKey::char('/'),
crate::input::InputKey::char('t'),
crate::input::InputKey::char('m'),
crate::input::InputKey::char('p'),
] {
modal.on_input(&k);
}
modal.on_input(&crate::input::InputKey::Left);
modal.on_input(&crate::input::InputKey::Backspace);
assert_eq!(modal.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
modal.on_input(&crate::input::InputKey::Tab);
assert_eq!(modal.focused_field, SpawnField::Command);
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
assert_eq!(modal.command.value(), "/tmp/foo");
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
assert_eq!(modal.command.value(), "");
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, modal is centered on screen).
#[test]
fn render_places_the_cursor_after_the_typed_text() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("abc"),
..SpawnModal::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
assert!(rendered.contains("NewDraftPane"));
}
}
+60 -7
View File
@@ -10,7 +10,7 @@ use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the active tab's pane is a draft (issue #63).
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub spawn: &'a SpawnModal,
pub help: &'a HelpOverlay,
}
@@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
@@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_status_bar(f, status_area, inputs.state);
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::KillConfirm(id)) => {
let kc = KillConfirm {
label: id.to_string(),
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
Focus::Pane => "pane",
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
};
let mode_style = match state.mode {
@@ -266,4 +266,57 @@ mod tests {
assert_eq!(map.region_at(15, 10), Region::Modal);
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
#[test]
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
use crate::overlays::HelpOverlay;
use crate::state::PaneRef;
use std::collections::HashSet;
let mut state = AppState::new();
state.open_draft_pane();
let draft = match &state.tabs[0].pane {
Some(PaneRef::Draft(d)) => d,
other => panic!("expected a draft pane, got {other:?}"),
};
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: None,
draft: Some(draft),
state: &state,
help: &help,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
}
+109 -15
View File
@@ -1,14 +1,16 @@
//! `AppState` — the top-level mutable state of the running TUI.
//!
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
//! session), an active tab index, focus split between sidebar and pane, the
//! current input mode, and an optional overlay (spawn modal, kill confirm,
//! help, transient toast).
//! session or a draft form), an active tab index, focus split between sidebar
//! and pane, the current input mode, and an optional overlay (kill confirm,
//! help, transient toast). The spawn form is no longer an overlay — an empty
//! pane is a draft pane (issue #63).
//!
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
use uuid::Uuid;
use crate::draft::DraftPane;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,15 +19,31 @@ pub enum Focus {
Pane,
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
#[derive(Debug, Clone, PartialEq)]
pub enum PaneRef {
Session(Uuid),
Draft(DraftPane),
}
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
pub pane: Option<Uuid>,
pub pane: Option<PaneRef>,
}
impl Tab {
/// The session this tab shows, if it shows one. Draft panes have none.
pub fn session(&self) -> Option<Uuid> {
match &self.pane {
Some(PaneRef::Session(sid)) => Some(*sid),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
Spawn,
KillConfirm(Uuid),
Help,
Toast(String),
@@ -108,7 +126,8 @@ impl AppState {
///
/// Returns the session that tab was showing, if any, so the caller can
/// release its emulator. Without that the tab's WebSocket task and vt100
/// parser outlive the tab forever.
/// parser outlive the tab forever. A draft pane holds no session and
/// releases nothing.
pub fn close_tab(&mut self) -> Option<Uuid> {
if self.tabs.len() <= 1 {
return None;
@@ -117,9 +136,10 @@ impl AppState {
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
let sid = closed.session()?;
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
closed.pane.filter(|_| !still_shown)
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
(!still_shown).then_some(sid)
}
pub fn next_tab(&mut self) {
@@ -147,11 +167,37 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
self.active_tab_mut().pane = Some(session_id);
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
/// starts immediately, same flow as [`Self::attach_to_focused`].
pub fn open_draft_pane(&mut self) {
if self.active_tab().pane.is_some() {
self.new_tab();
}
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// A draft submit succeeded: replace whatever the active tab showed with
/// the new session in place, matching the web client's behaviour (#57).
pub fn complete_spawn(&mut self, sid: Uuid) {
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// The session shown by the active tab's pane, if any.
pub fn active_session(&self) -> Option<Uuid> {
self.active_tab().session()
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
@@ -288,7 +334,7 @@ mod tests {
let mut state = AppState::new();
let sid = Uuid::from_u128(7);
state.attach_to_focused(sid);
assert_eq!(state.tabs[0].pane, Some(sid));
assert_eq!(state.tabs[0].session(), Some(sid));
assert_eq!(state.focus, Focus::Pane);
// The user expects to type into the session right away; the leader
// (default ctrl-a) is the way back.
@@ -314,7 +360,7 @@ mod tests {
let mut state = AppState::new();
let sid = Uuid::from_u128(3);
state.new_tab();
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), Some(sid));
}
@@ -324,9 +370,9 @@ mod tests {
fn close_tab_keeps_a_session_another_tab_still_shows() {
let mut state = AppState::new();
let sid = Uuid::from_u128(4);
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
state.new_tab();
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), None);
}
@@ -334,7 +380,7 @@ mod tests {
#[test]
fn close_tab_is_a_no_op_on_the_last_tab() {
let mut state = AppState::new();
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
@@ -347,4 +393,52 @@ mod tests {
assert!(state.dismiss_overlay());
assert!(state.overlay().is_none());
}
/// Issue #63: `s` over an empty pane turns that pane into a draft —
/// focused, Insert mode on, typing starts immediately.
#[test]
fn open_draft_reuses_the_empty_active_pane() {
let mut state = AppState::new();
state.open_draft_pane();
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
assert_eq!(state.tabs.len(), 1);
}
/// A tab already showing a session gets a fresh tab for the draft; v1
/// has no split tree, so a new tab is the "split".
#[test]
fn open_draft_over_a_session_opens_a_new_tab() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
// The session tab is untouched.
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
}
/// Web parity (#57): submitting fills the pane in place.
#[test]
fn complete_spawn_replaces_the_draft_with_the_session() {
let mut state = AppState::new();
state.open_draft_pane();
let sid = Uuid::from_u128(21);
state.complete_spawn(sid);
assert_eq!(state.active_tab().session(), Some(sid));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
}
/// A closed draft tab holds no session, so nothing is released.
#[test]
fn close_tab_reports_nothing_for_a_draft_pane() {
let mut state = AppState::new();
state.open_draft_pane();
state.new_tab();
state.active_tab_idx = 0;
assert_eq!(state.close_tab(), None);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use ratatui::style::Style;
use crate::input::InputKey;
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
+3 -2
View File
@@ -280,8 +280,9 @@ maestro daemon # foreground; or wire your own service manager
```
Every setting has a default, so `~/.config/maestro/config.toml` is optional —
create it only to change the bind address, worktree root, spawn presets, or TUI
keymap.
create it only to change the bind address, spawn presets, or TUI
keymap. (Worktrees live inside their base repos at `<repo>/worktrees/<branch>`;
set `[daemon].worktree_root` only to override that.)
## Non-Linux hosts
+1
View File
@@ -17,6 +17,7 @@
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9"
+93 -11
View File
@@ -15,15 +15,18 @@ importers:
specifier: ^5.5.0
version: 5.5.0
devDependencies:
happy-dom:
specifier: ^20.11.6
version: 20.11.6
typescript:
specifier: ^5.6.3
version: 5.9.3
vite:
specifier: ^5.4.10
version: 5.4.21
version: 5.4.21(@types/node@26.2.0)
vitest:
specifier: ^2.1.9
version: 2.1.9
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
packages:
@@ -296,6 +299,15 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -337,6 +349,10 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -362,6 +378,10 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -382,6 +402,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
happy-dom@20.11.6:
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
engines: {node: '>=20.0.0'}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -451,6 +475,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -512,11 +539,27 @@ packages:
jsdom:
optional: true
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.21.5':
@@ -667,6 +710,16 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 26.2.0
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
@@ -674,13 +727,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.9(vite@5.4.21)':
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21
vite: 5.4.21(@types/node@26.2.0)
'@vitest/pretty-format@2.1.9':
dependencies:
@@ -715,6 +768,10 @@ snapshots:
assertion-error@2.0.1: {}
buffer-image-size@0.6.4:
dependencies:
'@types/node': 26.2.0
cac@6.7.14: {}
chai@5.3.3:
@@ -733,6 +790,8 @@ snapshots:
deep-eql@5.0.2: {}
entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.21.5:
@@ -770,6 +829,19 @@ snapshots:
fsevents@2.3.3:
optional: true
happy-dom@20.11.6:
dependencies:
'@types/node': 26.2.0
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
loupe@3.2.1: {}
magic-string@0.30.21:
@@ -843,13 +915,15 @@ snapshots:
typescript@5.9.3: {}
vite-node@2.1.9:
undici-types@8.3.0: {}
vite-node@2.1.9(@types/node@26.2.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21
vite: 5.4.21(@types/node@26.2.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -861,18 +935,19 @@ snapshots:
- supports-color
- terser
vite@5.4.21:
vite@5.4.21(@types/node@26.2.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.60.4
optionalDependencies:
'@types/node': 26.2.0
fsevents: 2.3.3
vitest@2.1.9:
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21)
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
@@ -888,9 +963,12 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.21
vite-node: 2.1.9
vite: 5.4.21(@types/node@26.2.0)
vite-node: 2.1.9(@types/node@26.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0
happy-dom: 20.11.6
transitivePeerDependencies:
- less
- lightningcss
@@ -902,7 +980,11 @@ snapshots:
- supports-color
- terser
whatwg-mimetype@3.0.0: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.21.3: {}
+143
View File
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { listDir } from "./api.js";
const listDirMock = vi.mocked(listDir);
let input: HTMLInputElement;
let nextField: HTMLInputElement;
let docKeydown: ReturnType<typeof vi.fn>;
let ac: DirAutocompleteHandle | undefined;
function popup(): HTMLUListElement {
return document.querySelector(".dir-ac") as HTMLUListElement;
}
function items(): HTMLElement[] {
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
}
async function type(value: string): Promise<void> {
input.focus();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
* default browser behaviour (focus move, form submit) was suppressed. */
function press(key: string): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
input.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
beforeEach(() => {
vi.useFakeTimers();
document.body.replaceChildren();
input = document.createElement("input");
nextField = document.createElement("input");
document.body.append(input, nextField);
docKeydown = vi.fn();
document.addEventListener("keydown", docKeydown);
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
});
afterEach(() => {
ac?.destroy();
document.removeEventListener("keydown", docKeydown);
vi.useRealTimers();
});
describe("dirAutocomplete dismissal", () => {
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup().hidden).toBe(true);
expect(input.value).toBe("/tmp/pro");
expect(document.activeElement).toBe(input);
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
});
it("blur dismisses the popup", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
});
it("does not reopen on refocus until the operator types again", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
input.blur();
await vi.advanceTimersByTimeAsync(150);
input.focus();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
await type("/tmp/ot");
expect(popup().hidden).toBe(false);
});
});
describe("dirAutocomplete keyboard model", () => {
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
nextField.focus();
const { defaultPrevented } = press("Tab");
expect(input.value).toBe("/tmp/proj-a/");
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
expect(document.activeElement).toBe(nextField); // focus was not yanked back
});
it("Tab with nothing highlighted just moves on", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/nope-matches-nothing");
expect(popup().hidden).toBe(true);
const { defaultPrevented } = press("Tab");
expect(defaultPrevented).toBe(false);
expect(input.value).toBe("/tmp/nope-matches-nothing");
});
it("arrows move the highlight and Enter commits it without submitting", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/");
press("ArrowDown");
expect(items()[1].classList.contains("is-active")).toBe(true);
const enter = press("Enter");
expect(enter.defaultPrevented).toBe(true); // no form submit
expect(input.value).toBe("/tmp/proj-b/");
});
});
describe("dirAutocomplete opening", () => {
it("still opens on focus when not previously dismissed", async () => {
ac = attachDirAutocomplete(input);
input.focus();
await vi.advanceTimersByTimeAsync(50);
expect(popup().hidden).toBe(false);
expect(listDirMock).toHaveBeenCalled();
});
});
+36 -27
View File
@@ -4,15 +4,19 @@
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
//
// Tab complete the longest common prefix; if one match, accept it (and
// append `/` so the next level loads); if nothing to extend, cycle.
// Tab commit the highlighted entry and let focus move on; with nothing
// highlighted, plain tab navigation
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit)
// Esc close the dropdown
// Esc close the dropdown, refocus the input, and swallow the key so it
// can't also discard a surrounding draft pane
//
// Once dismissed (Esc or blur) the popup stays closed until the operator types
// again — refocusing alone doesn't reopen it.
//
// Failures (offline daemon, permission denied) degrade to no suggestions.
import { longestCommonPrefix, splitPath } from "./paths.js";
import { splitPath } from "./paths.js";
import { listDir } from "./api.js";
export interface DirAutocompleteHandle {
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
let fetchSeq = 0;
let debounce: number | undefined;
let open = false;
// Set when the operator dismissed the popup (Esc, blur, outside click); a
// refocus must not reopen it — only new typing does.
let dismissed = false;
function close(): void {
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
window.removeEventListener("resize", reposition);
}
function dismiss(): void {
dismissed = true;
close();
}
function reposition(): void {
const r = input.getBoundingClientRect();
dropdown.style.left = `${r.left}px`;
@@ -132,21 +144,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
}
// Accept `name` as the next path segment: complete it and append `/` so the
// child directory's listing loads immediately (the drill-in gesture).
function accept(name: string): void {
// child directory's listing loads on the next interaction (the drill-in
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
// and move to the next field in one gesture.
function commit(name: string): void {
const { dir } = splitPath(input.value);
input.value = `${dir}${name}/`;
input.focus();
// Notify other listeners (spawn.ts marks the field user-edited) and load
// the child level.
// Notify other listeners (spawnForm marks the field user-edited).
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function accept(name: string): void {
commit(name);
input.focus();
}
function onInput(): void {
dismissed = false;
scheduleRefresh();
}
function onFocus(): void {
if (dismissed) return;
refresh();
}
@@ -154,27 +173,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
if (e.key === "Escape") {
if (open) {
e.preventDefault();
close();
// Swallow the key: an Escape that bubbles would also discard the
// draft pane hosting this form.
e.stopPropagation();
dismiss();
input.focus();
}
return;
}
if (e.key === "Tab") {
if (!open || filtered.length === 0) return; // let focus move normally
e.preventDefault();
if (filtered.length === 1) {
accept(filtered[0]);
return;
}
const { dir, prefix } = splitPath(input.value);
const lcp = longestCommonPrefix(filtered);
if (lcp.length > prefix.length) {
input.value = `${dir}${lcp}`;
input.dispatchEvent(new Event("input", { bubbles: true }));
} else {
// Nothing to extend — cycle the highlight like a second shell <tab>.
highlight = (highlight + 1) % filtered.length;
render();
}
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
return;
}
if (!open) return;
@@ -196,7 +205,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
function onBlur(): void {
// Delay so a dropdown mousedown can complete first.
window.setTimeout(close, 120);
window.setTimeout(dismiss, 120);
}
input.addEventListener("input", onInput);
+9 -3
View File
@@ -152,10 +152,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
leaf.draft = values;
},
onSuccess: (session) => {
leaf.sessionId = session.id;
leaf.draft = null;
// The pane can be closed while the POST is in flight. Mounting the
// session onto a leaf no longer in the tree would strand it in no
// pane at all, so only claim the leaf while it is still there.
const t = activeTab(layout);
if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id;
const attached = leaves(t.root).includes(leaf);
if (attached) {
leaf.sessionId = session.id;
leaf.draft = null;
t.focusedLeafId = leaf.id;
}
render();
opts.onOpenSessionsChange?.();
},
+26
View File
@@ -99,4 +99,30 @@ describe("serializeLayout", () => {
}
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
});
it("keeps the draft out of the stored blob itself, not just out of hydrate", () => {
// A draft pane only exists once a tab has split, so the strip has to walk
// into split branches; asserting on the restored layout alone passes even
// when the draft was written to localStorage, because hydrate drops it.
const layout = hydrate({
tabs: [
{
id: "t",
root: {
kind: "split",
dir: "row",
a: { kind: "leaf", id: "l1", sessionId: "s1" },
b: { kind: "leaf", id: "l2", sessionId: null },
},
},
],
activeTabId: "t",
}) as Layout;
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" };
const raw = serializeLayout(layout);
expect(raw).not.toContain("secret-project");
expect(raw).not.toContain("amber-fox");
});
});
+6 -1
View File
@@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string {
}
function stripDrafts(node: Node | null): Node | null {
if (!node || node.kind !== "leaf") return node;
if (!node) return node;
// Recurse: a draft pane only exists once a tab has split, so a shallow strip
// would persist every half-filled form it was meant to drop.
if (node.kind === "split") {
return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! };
}
return { ...node, draft: null };
}
+13 -7
View File
@@ -209,14 +209,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
syncBranchToName(); // seed the branch from the initial name
// ---- command tiles ---------------------------------------------------
function selectPreset(id: string): void {
// `focus` is false for the two initial applications (construction, and again
// when `/api/info` resolves): a draft pane must not grab the caret out from
// under the operator seconds after it mounts.
function selectPreset(id: string, focus = true): void {
selectedPresetId = id;
const preset = presets.find((p) => p.id === id);
const isCustom = !preset || preset.command === "";
cmdRaw.hidden = !isCustom;
if (isCustom) {
command = cmdRaw.value.trim();
cmdRaw.focus();
if (focus) cmdRaw.focus();
} else {
command = preset!.command;
}
@@ -270,17 +273,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (wanted) {
const match = presets.find((p) => p.command === wanted && p.command !== "");
if (match) {
selectPreset(match.id);
selectPreset(match.id, false);
} else {
const custom = presets.find((p) => p.command === "");
cmdRaw.value = wanted;
selectPreset(custom ? custom.id : "custom");
selectPreset(custom ? custom.id : "custom", false);
command = wanted;
}
return;
}
const first = presets.find((p) => p.command !== "");
if (first) selectPreset(first.id);
if (first) selectPreset(first.id, false);
}
// ---- working-directory picker ---------------------------------------
@@ -345,17 +348,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
cwdRaw.value = seedCwd;
markActiveRecent(cwd);
} else {
showRawCwd(seedCwd);
showRawCwd(seedCwd, false);
}
seedCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
showRawCwd(homeDir, false);
} else {
cwdRawWrap.hidden = true;
}
// The seeded cwd is in place — a restored worktree can probe now.
if (wtEnabled.checked) void probeGitRoot();
// Publish it: construction ran `emit()` with an empty cwd, so without this
// the leaf's draft keeps `cwd: ""` and the next render reseeds it blank.
emit();
}
function recentRow(dir: RecentDir): HTMLElement {
+6
View File
@@ -2079,6 +2079,12 @@ textarea {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
/* The modal form got its stacking and padding from `.modal-body`; in a pane
the form is the host, so it carries them itself. */
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-3);
}
/* pane tools cluster (disconnect / split / close), top-right corner. The attach