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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f
# program = "C:\\Program Files\\Git\\bin\\bash.exe" # optional; platform default when omitted
# command_args = ["-lc"] # optional; platform default when omitted

[terminal]
# Open one empty shell automatically when a project has no explicit startup
# commands. Keep this false to create the first shell only with <leader> t.
auto_open = false

# Reserve startup commands: each [[startup_command]] opens its own terminal
# pane and runs `command` immediately (via the configured shell). Panes are
# per project, so these run again for every project tab you open, not once
Expand All @@ -99,8 +104,8 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f
# project tabs). This caps only the startup
# batch — open more anytime with <leader> t (panes past the eighth are reached
# via focus cycling, Shift+←/→). `name` labels the tab; when omitted the
# command text is used. With no [[startup_command]] entries, nightcrow opens a
# single empty shell.
# command text is used. With no [[startup_command]] entries, no pane is opened
# unless terminal.auto_open is true.
#
# Editing this table does not need a restart: <leader> u in the TUI, or the
# reload button in the browser header, re-reads this file. Startup commands take
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ replay는 1 MiB chunk로 분할하고 daemon frame payload는 4 MiB 이하로

## Config reload

`POST /api/reload`와 attach의 reload request는 transport와 무관한 같은 operation을 호출한다. `config.toml` 전체를 parse/validate한 뒤에만 적용하며, 파일이 사라졌거나 잘못되면 session을 변경하지 않는다. `[[plugin]]` 변경은 열린 repository hub에 즉시 요청하고, `command`/`args`/`env` 변경 때만 child를 교체한다. `allowed_resume_flags`와 `watch_on_signal`은 다음 판정부터 읽는다. `[[startup_command]]` 변경은 이후 생성되는 hub에만 적용한다. web/listener·log·layout/input/tree/mouse 설정은 재시작 대상이다.
`POST /api/reload`와 attach의 reload request는 transport와 무관한 같은 operation을 호출한다. `config.toml` 전체를 parse/validate한 뒤에만 적용하며, 파일이 사라졌거나 잘못되면 session을 변경하지 않는다. `[[plugin]]` 변경은 열린 repository hub에 즉시 요청하고, `command`/`args`/`env` 변경 때만 child를 교체한다. `allowed_resume_flags`와 `watch_on_signal`은 다음 판정부터 읽는다. `[[startup_command]]`와 `[terminal] auto_open` 변경은 이후 생성되는 hub에만 적용한다. web/listener·log·layout/input/tree/mouse 설정은 재시작 대상이다.

reload lock은 concurrent reload를 직렬화하고, catalog transaction은 reload와 project open이 서로 다른 config table을 보는 틈을 막는다. hub queue가 가득 차 전달하지 못한 repository는 보고서의 `unreachable`로 표시하며, reload 결과는 요청한 client에만 반환한다. plugin reload가 기존 pane의 opt-in을 조용히 취소하거나 relaunch budget을 재생성하지 않는다.

Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ nightcrow reads `~/.nightcrow/config.toml`. Every field is optional and omitted
| `[theme]` | `name = "yellow"` | `yellow`, `cyan`, `green`, `magenta`, or `blue`. Seeds the session accent when no saved accent exists. |
| `[input]` | `leader = "ctrl+f"` | One `ctrl+<ascii-letter>` chord. `ctrl+i` and `ctrl+m` are rejected because terminals report them as Tab and Enter. |
| `[mouse]` | `enabled = true` | Captures clicks and wheel events for the TUI; `false` gives selection and mouse handling back to the outer terminal. |
| `[terminal]` | `auto_open = false` | With no startup commands, `true` opens one shell per project automatically; `false` waits for `<prefix> t`. |
| `[agent_indicator]` | `enabled = true`, `hot_window_secs = 15`, `auto_follow = false` | Hot window is `3..=3600` seconds. `auto_follow` selects the freshest recently changed file after 2 seconds of inactivity. |
| `[tree]` | `respect_gitignore = true`, `max_depth = 64`, `live_watch = true` | `max_depth` is `1..=1024`; `live_watch = false` refreshes the tree on entry instead of watching expanded directories. |
| `[shell]` | `program` omitted; `command_args` platform default | Unix uses `$SHELL` or `/bin/sh` with `[-lc]`; Windows uses `%ComSpec%` or `cmd.exe` with `[/C]`. The command is always the final single argument; interpolation such as `"{}"` is not supported. |
Expand Down Expand Up @@ -64,7 +65,7 @@ plugin = "recovery"
command = "cargo test --watch"
```

Configured entries and repeated CLI `--exec COMMAND` values share an 8-pane startup limit, in config-first order. `command` cannot be empty. A project with no startup entries starts with one shell; each project may hold up to 8 panes total.
Configured entries and repeated CLI `--exec COMMAND` values share an 8-pane startup limit, in config-first order. `command` cannot be empty. A project with no startup entries starts with no panes by default; set `[terminal] auto_open = true` to restore one automatic shell. Each project may hold up to 8 panes total.

## `[[plugin]]`

Expand Down Expand Up @@ -92,7 +93,7 @@ See [Plugins](plugins.md) for installation and the bundled recovery plugin.
Use `<prefix> u` in the TUI or the reload control in the browser. nightcrow parses and validates the whole file before applying anything; a missing, malformed, or invalid file leaves the running session unchanged.

- `[[plugin]]` is re-applied immediately to open projects. Changing a plugin's executable, arguments, or environment restarts that plugin and can abandon a pending recovery.
- `[[startup_command]]` applies to projects opened after the reload. Existing project panes keep running; CLI `--exec` panes remain part of the merged startup list.
- `[[startup_command]]` and `[terminal] auto_open` apply to projects opened after the reload. Existing project panes keep running; CLI `--exec` panes remain part of the merged startup list.
- All other settings require a daemon restart. A TUI reads its client settings when it attaches, while the running daemon keeps its listener and server settings until restart.

Restarting a session stops its terminal programs. Use [Getting started](getting-started.md#detach-and-stop) for the shutdown procedure.
2 changes: 1 addition & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ status는 최신 값 하나가 완전한 그림이라 중간 값을 conflate할

### Reload는 전체 검증 후 제한적으로 적용한다

살아 있는 pane을 보존하려고 `config.toml`을 부분 적용하지 않는다. 파일 전체를 parse/validate한 뒤 `[[plugin]]`은 열린 hub에, `[[startup_command]]` 새 hub에만 적용한다. plugin 권한 flag와 watch switch는 다음 판정부터 읽고, child 교체가 필요한 command/args/env만 재시작한다. concurrent reload는 lock으로 직렬화하고 전달하지 못한 hub는 성공으로 가장하지 않는다.
살아 있는 pane을 보존하려고 `config.toml`을 부분 적용하지 않는다. 파일 전체를 parse/validate한 뒤 `[[plugin]]`은 열린 hub에, `[[startup_command]]`와 `[terminal] auto_open`은 새 hub에만 적용한다. plugin 권한 flag와 watch switch는 다음 판정부터 읽고, child 교체가 필요한 command/args/env만 재시작한다. concurrent reload는 lock으로 직렬화하고 전달하지 못한 hub는 성공으로 가장하지 않는다.

## TUI 입력과 git 표시

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ nightcrow -d

There is one session per running daemon. `nightcrow attach` reuses an existing session and starts one in the background when none is available. The daemon owns the open repositories and terminal programs; clients only attach to it. Startup prints the browser URL and the attach-socket path. A background daemon writes its output to `~/.nightcrow/daemon.out`.

Use `--exec COMMAND` once per startup pane when starting a daemon. Configured `[[startup_command]]` entries run first, followed by these CLI commands. The combined startup list is limited to 8 panes per project; each project gets its own list. With no startup commands, a project starts with one shell. All terminal panes in a project share an 8-pane limit; later panes are opened with `<prefix> t` until that limit is reached.
Use `--exec COMMAND` once per startup pane when starting a daemon. Configured `[[startup_command]]` entries run first, followed by these CLI commands. The combined startup list is limited to 8 panes per project; each project gets its own list. With no startup commands, a project starts with no terminal process; press `<prefix> t` to open the first shell, or set `[terminal] auto_open = true` to open one automatically. All terminal panes in a project share an 8-pane limit; later panes are opened with `<prefix> t` until that limit is reached.

The browser and TUI share repositories, terminals, project order, active project, and accent. The TUI's leader is `Ctrl+F` by default; see [Keyboard and mouse](keybindings.md) for all controls.

Expand Down
2 changes: 2 additions & 0 deletions docs/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

The session can serve up to 10 repositories. Each repository is a project tab with its own status, commit-log, tree, and terminal views. A project can hold up to 8 terminal panes; its panes keep running while another project is active.

Projects start without a terminal process unless a startup command is configured. Open the first shell with `<prefix> t`, or set `[terminal] auto_open = true` to create one automatically for projects without startup commands.

Open and close projects with `<prefix> o` and `<prefix> x`; switch among tabs with `F1`–`F10`. Opening a repository that is already open focuses the existing tab instead of creating a duplicate worktree view. The browser and every attached TUI share the project set, order, and active project.

If tabs do not fit, the tab row folds inactive tabs behind an overflow marker. A background project shows an attention marker when its terminal reports unread activity; selecting that project acknowledges the marker, and later activity can raise it again.
Expand Down
28 changes: 20 additions & 8 deletions src/application/session_terminals_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
//!
//! One test with a real session behind it, because everything this step added
//! only exists between the parts: the tab is built with the repository's end of
//! the connection, the daemon's offer to size the startup terminals is answered
//! from there, and the pane that comes back has to reach that tab's emulator
//! with its output. A fake at any of those seams would assert the seam rather
//! than the crossing.
//! the connection, a pane request crosses to the daemon, and the pane that
//! comes back has to reach that tab's emulator with its output. A fake at any
//! of those seams would assert the seam rather than the crossing.

use crate::application::input::dispatch::ProjectContext;
use crate::application::session_link::SessionLink;
Expand Down Expand Up @@ -93,8 +92,21 @@ fn a_tab_shows_the_pane_the_session_is_running_and_the_output_it_produces() {
);
assert_eq!(ws.projects().len(), 1);

// And so does its pane: the session opened its startup shell once the client
// answered the offer to size it.
// A project does not spend a process on a shell until somebody asks for it.
let quiet_until = Instant::now() + Duration::from_millis(100);
while Instant::now() < quiet_until {
link.sync(&mut ws, &ctx);
for project in ws.projects_mut() {
project.poll_terminal();
}
std::thread::sleep(Duration::from_millis(5));
}
assert!(
ws.active().is_some_and(|app| app.terminal.panes.is_empty()),
"a default project must not auto-open a terminal"
);

ws.active_mut().expect("a tab").open_new_pane();
assert!(
tick_until(&mut link, &mut ws, &ctx, |ws| {
ws.active()
Expand All @@ -114,8 +126,8 @@ fn a_tab_shows_the_pane_the_session_is_running_and_the_output_it_produces() {
"the pane produced no output the client could render"
);

// The fresh-launch rule, end to end: keystrokes go to the terminal that just
// appeared rather than to the file list the view was built on.
// The explicit-open rule, end to end: keystrokes go to the terminal that
// just appeared rather than to the file list the view was built on.
assert_eq!(
ws.active().expect("a tab").focus,
crate::app::Focus::Terminal
Expand Down
1 change: 1 addition & 0 deletions src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ pub(crate) fn run_daemon(
agent_indicator: &cfg.agent_indicator,
theme: &cfg.theme,
shell: &cfg.shell,
terminal: &cfg.terminal,
paths: &paths,
persist: true,
startup_commands: startup,
Expand Down
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod log;
mod panels;
mod plugin;
mod shell;
mod terminal;
mod web;

pub use layout::{Accent, InputConfig, LayoutConfig, StartupCommand, ThemeConfig, parse_leader};
Expand All @@ -16,6 +17,7 @@ pub use log::{LogConfig, LogRotation};
pub use panels::{AgentIndicatorConfig, MouseConfig, TreeConfig};
pub use plugin::PluginConfig;
pub use shell::ShellConfig;
pub use terminal::TerminalConfig;
#[cfg(test)]
pub use web::generate_password;
pub use web::{WebViewerConfig, ensure_web_viewer_password};
Expand All @@ -38,6 +40,7 @@ pub struct Config {
pub input: InputConfig,
pub tree: TreeConfig,
pub mouse: MouseConfig,
pub terminal: TerminalConfig,
pub web_viewer: WebViewerConfig,
/// The shell every terminal pane is spawned with. When absent, the platform
/// default is used.
Expand Down
9 changes: 9 additions & 0 deletions src/config/terminal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};

/// Startup behavior shared by every terminal surface in the session.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TerminalConfig {
/// Open one bare shell when a project has no explicit startup commands.
pub auto_open: bool,
}
1 change: 1 addition & 0 deletions src/config/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod panels;
mod plugin;
mod shell;
mod startup;
mod terminal;
mod theme;
mod tree;
mod web;
14 changes: 14 additions & 0 deletions src/config/tests/terminal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
use crate::config::Config;

#[test]
fn terminal_does_not_auto_open_by_default() {
assert!(!Config::default().terminal.auto_open);
let parsed: Config = toml::from_str("").unwrap();
assert!(!parsed.terminal.auto_open);
}

#[test]
fn terminal_auto_open_can_be_enabled() {
let parsed: Config = toml::from_str("[terminal]\nauto_open = true\n").unwrap();
assert!(parsed.terminal.auto_open);
}
1 change: 1 addition & 0 deletions src/daemon/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn daemon(dir: &tempfile::TempDir, repos: &[String]) -> TestDaemon {
persist: false,
startup_commands: Vec::new(),
cli_startup: Vec::new(),
terminal: crate::config::TerminalConfig::default(),
shell: crate::config::ShellConfig::default(),
prefs: crate::session::prefs::PrefsStore::at(dir.path().join("viewer.json")),
status_encoder: crate::session::test_status_encoder,
Expand Down
18 changes: 5 additions & 13 deletions src/daemon/serve_tests/terminals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,12 @@ fn attaching_subscribes_to_the_terminals_of_every_open_repository() {
let mut client = Client::attach_raw(daemon.path());
client.hello();

let (id, _) = client.next_terminal_event();
let (id, event) = client.next_terminal_event();
assert!(!id.is_empty(), "the event says which repository it is for");
// And the subscription is live from the start: a fresh hub offers its
// startup terminals to be sized before creating them, so that offer reaches
// a client that has asked for nothing.
let mut offered = false;
for _ in 0..8 {
let (_, event) = client.next_terminal_event();
if matches!(event, HubServerMessage::Pending { .. }) {
offered = true;
break;
}
}
assert!(offered, "the startup terminals were never offered");
// The subscription is live from the start even though the default policy
// does not create or offer a shell: the hub handshake reaches a client that
// has asked for no terminal operation and reports an empty replay.
assert!(matches!(event, HubServerMessage::Hello { panes: 0, .. }));
drop(repo);
}

Expand Down
11 changes: 11 additions & 0 deletions src/session/catalog/catalog_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub(super) struct CatalogRuntime {
startup_commands: Vec<crate::config::StartupCommand>,
cli_startup: Vec<String>,
plugins: Vec<crate::config::PluginConfig>,
terminal: crate::config::TerminalConfig,
shell: crate::config::ShellConfig,
ownership: Arc<crate::session::size_owner::SizeOwnership>,
status_encoder: StatusEncoder,
Expand All @@ -24,6 +25,7 @@ impl Default for CatalogRuntime {
startup_commands: Vec::new(),
cli_startup: Vec::new(),
plugins: Vec::new(),
terminal: crate::config::TerminalConfig::default(),
shell: crate::config::ShellConfig::default(),
ownership: Arc::new(crate::session::size_owner::SizeOwnership::default()),
status_encoder: empty_status_payload,
Expand All @@ -36,13 +38,15 @@ impl CatalogRuntime {
startup_commands: Vec<crate::config::StartupCommand>,
plugins: Vec<crate::config::PluginConfig>,
cli_startup: Vec<String>,
terminal: crate::config::TerminalConfig,
shell: crate::config::ShellConfig,
status_encoder: StatusEncoder,
) -> Self {
Self {
startup_commands,
plugins,
cli_startup,
terminal,
shell,
status_encoder,
..Self::default()
Expand All @@ -65,6 +69,7 @@ impl CatalogRuntime {
&member.path,
self.startup_commands.clone(),
self.plugins.clone(),
self.terminal.auto_open,
self.shell.clone(),
Arc::clone(&self.ownership),
),
Expand All @@ -83,10 +88,12 @@ impl CatalogRuntime {
pub(super) fn replace_config(
&mut self,
file_startup: &[crate::config::StartupCommand],
terminal: crate::config::TerminalConfig,
plugins: Vec<crate::config::PluginConfig>,
) -> anyhow::Result<Vec<Arc<RepoEntry>>> {
let merged = crate::config::merge_startup_commands(file_startup, &self.cli_startup)?;
self.startup_commands = merged;
self.terminal = terminal;
self.plugins = plugins;
Ok(self.entries.clone())
}
Expand All @@ -104,6 +111,10 @@ impl CatalogRuntime {
self.startup_commands.clone()
}

pub(super) fn startup_command_count(&self) -> usize {
self.startup_commands.len()
}

#[cfg(test)]
pub(super) fn plugins(&self) -> Vec<crate::config::PluginConfig> {
self.plugins.clone()
Expand Down
Loading