1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
use std::sync::Arc;
use crossterm::event::{KeyEvent, MouseEvent};
use crate::tmux::session::SessionView;
/// Data the new-session modal gathers and hands off to the tmux actor.
/// The `name` is the unprefixed user-entered name; the actor prepends
/// `Config::session_prefix` (e.g. `bosun-`) before calling tmux.
#[derive(Debug, Clone)]
pub struct SessionSpec {
pub name: String,
pub path: String,
pub agent: String,
pub args: String,
pub options: SpecOptions,
/// When `Some`, the actor stamps this container ID onto the
/// new tmux session's `@bosun_container_id` user option so the
/// freshly-created session appears as a tab inside the named
/// sidebar container. `None` (the default) creates a session
/// that gets its own fresh single-tab container on reconcile,
/// matching the pre-tabs behavior.
pub container_id: Option<String>,
}
/// Agent-specific flags the user toggled in the new-session modal.
/// The actor's `build_agent_command` reads these and produces the
/// right CLI flags when spawning the agent.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SpecOptions {
pub claude: ClaudeOptions,
pub codex: CodexOptions,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClaudeOptions {
pub session_mode: ClaudeSessionMode,
pub skip_permissions: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ClaudeSessionMode {
#[default]
New,
Continue,
Resume,
}
impl ClaudeSessionMode {
pub fn label(self) -> &'static str {
match self {
Self::New => "New",
Self::Continue => "Continue",
Self::Resume => "Resume",
}
}
pub fn next(self) -> Self {
match self {
Self::New => Self::Continue,
Self::Continue => Self::Resume,
Self::Resume => Self::New,
}
}
pub fn prev(self) -> Self {
match self {
Self::New => Self::Resume,
Self::Continue => Self::New,
Self::Resume => Self::Continue,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodexOptions {
/// Codex `--yolo` — bypass approvals and sandbox. Dangerous.
pub yolo: bool,
}
/// Commands flow from the UI/app task into the tmux actor.
#[derive(Debug)]
pub enum Command {
/// Refresh the session list immediately (out of schedule).
ListNow,
/// Attach to the selected session. The actor takes care of
/// installing the Ctrl-Q binding before attach and removing it after.
#[allow(dead_code)]
Attach { name: String },
/// The user selected a different session; the actor should capture
/// its pane with priority so the preview updates quickly. The name
/// is owned so the command can cross the mpsc boundary.
FocusPreview { name: String },
/// Create a new tmux session from the new-session modal's form data.
CreateSession(SessionSpec),
/// Kill a session by its internal tmux name. `tmux kill-session -t`.
KillSession(String),
/// Kill every tmux session named in `tabs` in one batch — used
/// by `Shift+D` to tear down all tabs in a container at once.
/// The actor iterates `KillSession` for each name; sidebar
/// reconcile drops the now-empty container on the next
/// refresh.
KillContainer { tabs: Vec<String> },
/// Rename the pretty display name of a session. The internal tmux
/// name never changes (we only update the `@bosun_display` user
/// option); the UI picks up the new label on the next refresh.
RenameSession {
internal: String,
new_display: String,
},
/// Kill and recreate a session using the metadata we persisted
/// as `@bosun_*` tmux user options when it was first created.
/// The new session gets a fresh internal name (new hex suffix)
/// but keeps the same display name, path, agent and options.
RestartSession(String),
/// Read the current `@bosun_*` metadata off a live session so
/// the modify-session modal can pre-fill its fields. The actor
/// replies with an `AppMsg::ModifySpecReady`.
OpenModifySession { internal: String },
/// Persist a new spec to the session's `@bosun_*` user options
/// (and update the display label if it changed). Does NOT
/// restart the running agent — the user picks that up next
/// time they hit `R`. Also upserts the recents row so the
/// recents picker reflects the latest spec.
ModifySession { internal: String, spec: SessionSpec },
/// Delete a single recent entry from the SQLite store by its
/// primary key. The RecentsModal emits this when the user hits
/// `d` on a highlighted row.
DeleteRecent(i64),
/// Persist the divider position to config.toml. Intercepted by
/// the app loop — never forwarded to the tmux actor.
SaveDivider(Option<u16>),
/// Persist the user-defined sidebar (sections + session order) to
/// config.toml. Intercepted by the app loop — never forwarded to
/// the tmux actor.
SaveSidebar(crate::sidebar::SidebarModel),
/// Persist the display_name → section_name history to config.toml.
/// Intercepted by the app loop — never forwarded to the tmux actor.
SaveSessionHistory(std::collections::HashMap<String, String>),
/// Persist the global TDF banner font to config.toml. Intercepted
/// by the app loop — never forwarded to the tmux actor.
SaveBannerFont(String),
/// Insert a new section header above the cursor with the given name.
/// Intercepted by the app loop — never forwarded to the tmux actor.
InsertSection { name: String },
/// Rename a section header by its id.
/// Intercepted by the app loop — never forwarded to the tmux actor.
RenameSection { id: String, new_name: String },
/// Set the active theme. Intercepted by the app loop — this
/// command is NEVER forwarded to the tmux actor (it's a pure UI
/// state change). `persist=false` is a transient live preview
/// (sent by the theme picker on every arrow key); `persist=true`
/// also writes `theme = "<name>"` to `config.toml`. On cancel,
/// the picker emits `SetTheme { original, persist: false }` to
/// revert the UI without touching disk.
SetTheme { name: String, persist: bool },
/// Spawn the configured external editor (`bosun editor <cmd>` /
/// `editor = "..."` in config.toml) against the highlighted
/// session's path. Intercepted by the app loop — runs
/// `<editor> <path>` detached so the TUI keeps the foreground.
/// The reducer pre-resolves both fields so the loop just calls
/// `Command::new(...).spawn()`.
OpenEditor { editor: String, path: String },
/// Graceful shutdown signal.
#[allow(dead_code)]
Shutdown,
}
/// Messages flow from actors (input, tmux) back to the app task.
/// The app task is the single writer of `AppState`.
///
/// There's no periodic `Tick` variant any more. As of the tmux -C
/// rewrite, session-list refreshes are push-driven by control-mode
/// notifications from the `tmux_actor`'s monitor subprocess, not by
/// a 1Hz poller. Main no longer generates `ListNow` commands from a
/// timer — it just waits for `SessionsRefreshed` to arrive.
#[derive(Debug)]
pub enum AppMsg {
/// A key from the terminal.
Key(KeyEvent),
/// A mouse event from the terminal. Used by the draggable divider
/// between the session list and preview. Dropped by non-mouse
/// consumers.
Mouse(MouseEvent),
/// A paste event from the terminal (bracketed paste). Crossterm
/// decodes `\e[200~ ... \e[201~` sequences from the outer
/// terminal and hands us the inner text as a `String`. Outer
/// terminals also use bracketed paste to deliver drag-drop
/// content (file paths, image markers), so this is the path
/// for "I dropped a file onto bosun". When the embed is
/// focused we re-wrap and forward to the embed PTY; otherwise
/// bosun ignores it (no modal currently accepts pasted text
/// directly — they all go through `Key(c)` events for
/// individual characters).
Paste(String),
/// Terminal was resized.
Resize(u16, u16),
/// Fresh session list from tmux, with smoothed status and optional
/// preview buffer per entry. `select_after` carries an internal
/// session name that the app should jump the selection to — set
/// when this refresh is the result of a create (so the new session
/// auto-highlights). `None` for notification-driven refreshes where
/// the app preserves its existing selection.
SessionsRefreshed {
sessions: Vec<SessionView>,
select_after: Option<String>,
},
/// Lightweight preview refresh for a single (focused) session. The
/// tmux actor's fast-preview tick (`Config::preview_tick_ms`,
/// default 200ms) captures just the focused pane and emits this
/// message — much cheaper than a full `SessionsRefreshed`. The app
/// handler updates the preview bytes on the matching SessionView
/// in place and does no other work (no detector run, no sidebar
/// reconcile, no statusbar sync). A no-op if the named session is
/// no longer in the list (it was killed between capture and
/// delivery).
PreviewRefreshed { name: String, bytes: Arc<[u8]> },
/// Response to `Command::OpenModifySession`: the actor has
/// read the live `@bosun_*` metadata off the named session and
/// the app should open the modify-session modal pre-filled
/// from `spec`. `internal` lets the modal remember which
/// session it's editing so the submit emits
/// `Command::ModifySession` against the right name.
ModifySpecReady { internal: String, spec: SessionSpec },
/// Lightweight status push for a single session. Sibling of
/// `PreviewRefreshed` — emitted by the tmux actor's fast tick
/// once it has captured + classified a pane, so the sidebar
/// glyph updates at the fast-tick cadence instead of waiting
/// for the 1Hz `SessionsRefreshed` reconcile. The app handler
/// updates the matching `SessionView.session.status` in place
/// and does nothing else — no list reconcile, no statusbar
/// diff, no detector re-run. A no-op if the named session is
/// no longer in the list.
StatusRefreshed {
name: String,
status: crate::tmux::detector::Status,
},
/// A chunk of bytes read from an embedded terminal's PTY (2.0+).
/// `session` is the internal tmux session name the embed was
/// spawned for. The app handler discards the chunk if the
/// currently-active embed isn't for the same session (stale
/// chunks from a previous embed instance after focus switch);
/// otherwise it feeds the bytes into the embed's vt100 parser.
/// Each chunk arrives from a dedicated reader thread inside
/// `EmbedTerminal` and triggers a normal redraw on the next
/// iteration of the app event loop.
EmbedBytes { session: String, bytes: Vec<u8> },
/// An attach just started — the UI should render a placeholder
/// while we block in `tmux attach`.
AttachStarted { name: String },
/// The attach returned (user detached).
AttachEnded { name: String },
/// A non-fatal error to surface in the status bar.
Warn(String),
/// A fatal error — bail out of the event loop.
Fatal(String),
/// Explicit shutdown request (Ctrl-C, SIGTERM).
Shutdown,
/// SIGCONT — we came back from Ctrl-Z suspend, re-enter raw mode.
Resume,
/// Terminal regained focus (e.g. switching back to the iTerm
/// window). Triggers a full repaint to recover from things like
/// iTerm's Cmd+R "reset" that clears the screen and exits alt
/// screen out from under us without notifying ratatui.
FocusGained,
/// Terminal lost focus. We only track it so the *next*
/// `FocusGained` is recognized as a genuine refocus (and not the
/// echo a terminal emits when focus reporting is re-enabled), so
/// recovery runs once instead of looping. See `App::has_focus`.
FocusLost,
}