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
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
//! Pty-pane methods on `App` — clipboard copy/paste out of a live
//! terminal grid, restart, rename, external-tool launcher, install
//! confirm prompt, PTY tab bufferline management.
//!
//! Extracted from `app/mod.rs` (file-split refactor — Task #963).
//! Pure non-destructive move; no API change.
use super::*;
impl App {
/// Extract text from the Pty pane's render grid between two cell
/// coords (col, row) and copy to the system clipboard. Uses
/// row-major linear cell order — for multi-row selections, joins
/// with `\n` at row boundaries. mouse-round-9 SEV-2 2026-07-11.
pub fn copy_pty_selection_to_clipboard(
&mut self,
pane_id: PaneId,
origin: (u16, u16),
cur: (u16, u16),
) {
let Some(Pane::Pty(session)) = self.panes.get_mut(pane_id) else {
return;
};
let grid = session.render_grid(false);
let (cols, rows) = (grid.cols, grid.rows);
// Order origin/cur in row-major reading order.
let (start, end) = {
let a_idx = origin.1 as usize * cols as usize + origin.0 as usize;
let b_idx = cur.1 as usize * cols as usize + cur.0 as usize;
if a_idx <= b_idx {
(origin, cur)
} else {
(cur, origin)
}
};
let mut out = String::new();
for r in start.1..=end.1 {
if r >= rows {
break;
}
let col_lo = if r == start.1 { start.0 } else { 0 };
let col_hi = if r == end.1 { end.0 } else { cols - 1 };
let mut row_text = String::new();
for c in col_lo..=col_hi {
if let Some(cell) = grid.cell(r, c) {
if cell.text.is_empty() {
row_text.push(' ');
} else {
row_text.push_str(&cell.text);
}
}
}
// Trim trailing spaces per row so selections that cross
// whitespace-padded rows don't paste with a huge tail.
let trimmed = row_text.trim_end();
out.push_str(trimmed);
if r < end.1 {
out.push('\n');
}
}
if out.is_empty() {
return;
}
self.clipboard.set(out, false);
self.toast("pty: selection copied");
}
/// `term.paste` — paste the system clipboard into the active Pty
/// pane's child process. No-op when the active pane isn't a Pty
/// or the clipboard is empty. mouse-round-9 SEV-2 2026-07-11.
pub fn pty_paste_clipboard(&mut self) {
let Some(cur) = self.active else { return };
if !matches!(self.panes.get(cur), Some(Pane::Pty(_))) {
self.toast("term.paste — active pane is not a terminal");
return;
}
let text = self.clipboard.text();
if text.is_empty() {
self.toast("term.paste — clipboard empty");
return;
}
if let Some(Pane::Pty(s)) = self.panes.get_mut(cur) {
s.write_bytes(text.as_bytes());
}
}
/// `term.clear` — send Ctrl+L to the child (clears the screen in
/// most shells / terminals). Same effect the user gets by typing
/// Ctrl+L while the Pty is focused, but reachable from the
/// right-click menu. mouse-round-9 SEV-2 2026-07-11.
pub fn pty_send_ctrl_l(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Pty(s)) = self.panes.get_mut(cur) {
s.write_bytes(&[0x0c]);
} else {
self.toast("term.clear — active pane is not a terminal");
}
}
/// `term.restart` — restart the child process in the active Pty
/// pane. Sends `SIGTERM` then re-spawns from the same profile.
/// mouse-round-9 SEV-2 2026-07-11. TODO: proper respawn — for now
/// send Ctrl+C and let the user re-run manually (safer than
/// destroying the pty state).
pub fn pty_restart(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Pty(s)) = self.panes.get_mut(cur) {
// Ctrl+C to interrupt, then Ctrl+D to signal EOF — most
// shells re-launch under the launcher. Users who want a
// full teardown can close the pane and re-open.
s.write_bytes(&[0x03]);
self.toast("term.restart — sent Ctrl+C (re-run manually)");
} else {
self.toast("term.restart — active pane is not a terminal");
}
}
/// Accept handler for `PromptKind::PtySessionName`. Empty input
/// clears the name (reverts to the binary profile's label).
pub fn rename_active_pty(&mut self, name: &str) {
let Some(cur) = self.active else { return };
let name = name.trim();
// Snapshot prefixes upfront so we don't hold a config borrow
// while the pane is mutated below.
let prefixes: Vec<String> = self.config.ui.ticket_prefixes.clone();
if let Some(Pane::Pty(s)) = self.panes.get_mut(cur) {
s.display_name = (!name.is_empty()).then(|| name.to_string());
let label = s.tab_label_with_prefixes(&prefixes);
self.toast(format!("session: {label}"));
}
}
pub fn open_shell(&mut self) {
// Spawn in the *active* workspace — so in a multi-workspace
// setup, term.shell opens in the focused workspace's directory,
// not the launch primary.
//
// 2026-07-22 — user asked for side-by-side (Horizontal) as
// the default terminal placement to match Claude/Codex. Was
// Vertical (stacked below). Explicit placement variants
// (`term.shell_left/right/top/bottom`) let the right-click
// menu override per gesture.
let cwd = self.active_workspace_path().to_path_buf();
self.open_pty_dir(
crate::pty_pane::BinaryProfile::shell(Some(cwd)),
crate::layout::SplitDir::Horizontal,
);
}
/// Placement-aware variant of `open_shell` — used by the split-
/// strip terminal chip's right-click menu so users can pick
/// where a new shell lands (left / right / top / bottom half).
pub fn open_shell_at(&mut self, placement: crate::app::ai::PanePlacement) {
let cwd = self.active_workspace_path().to_path_buf();
self.open_pty_at_placement(crate::pty_pane::BinaryProfile::shell(Some(cwd)), placement);
}
/// External-tool launcher — htop / iftop / btop / etc. If the
/// binary is on PATH, opens it in a Pty pane; otherwise toasts
/// a `brew install <pkg>` hint. Wired to `:tools.<id>` palette
/// commands and to the integration_icon chips.
pub fn run_external_tool(&mut self, id: &str) {
let Some(tool) = crate::tools::EXTERNAL_TOOLS.iter().find(|t| t.id == id) else {
self.toast(format!("tools: unknown tool `{id}`"));
return;
};
if crate::tools::is_on_path(tool.binary) {
let ws = self.active_workspace_path().to_path_buf();
// qa-feature 2026-07-01 — tools that require root
// (e.g. iftop needs /dev/bpf*) are launched under
// `sudo` so the user gets a password prompt instead
// of a permission-denied dump.
// 2026-07-04 — `--preserve-env=TERM,TERMINFO_DIRS` so
// the terminfo lookup path we set on the pty child
// survives across sudo's env-scrub (default sudoers
// whitelist doesn't include TERMINFO_DIRS, so iftop
// otherwise dies with "Error opening terminal:
// xterm-ghostty").
let bin_with_args = match tool.id {
// iftop's auto-picked interface is often `anpi2` on
// macOS (Apple's secondary radio) which sees zero
// traffic. Detect the default-route interface and
// pass it explicitly.
"iftop" => match crate::tools::default_route_iface() {
Some(iface) => format!("{} -i {}", tool.binary, iface),
None => tool.binary.to_string(),
},
_ => tool.binary.to_string(),
};
let cmdline = if tool.needs_sudo {
format!("sudo --preserve-env=TERM,TERMINFO_DIRS {}", bin_with_args)
} else {
bin_with_args
};
// First-launch hint for sudo-needing tools — one-time
// toast pointing at the docs page with the sudoers.d
// one-liner so power users can skip the password prompt.
// Marker at `~/.config/mnml/.tools-sudo-hint-shown` so it
// only fires once across sessions. See docs/tools.md.
if tool.needs_sudo {
maybe_show_sudo_tools_hint(self);
}
// 2026-07-19 — was hardcoded "tools" as the pane tab
// label. Use the tool's `id` for the label AND stamp
// the integration_id so the tab-icon resolver reads
// the chip glyph via a deterministic id lookup rather
// than a fuzzy substring match on args/label.
self.open_pty(
crate::pty_pane::BinaryProfile::task(tool.id, &cmdline, ws)
.with_integration(tool.id),
);
return;
}
// Not installed. On macOS + Linux we offer to install via
// brew / apt; elsewhere (Windows / unknown OS) we just
// toast a hint since there's no single canonical package
// manager + the `$SHELL -c` Pty spawn assumes POSIX.
let install_cmd = crate::tools::install_hint(tool.brew_pkg, tool.apt_pkg);
if !crate::tools::install_is_spawnable() {
self.toast(format!(
"{label}: not installed. Try `{install_cmd}`",
label = tool.label
));
return;
}
self.pending_tool_install = Some((tool.id.to_string(), install_cmd.clone()));
let mut prompt = crate::prompt::Prompt::new(
crate::prompt::PromptKind::ToolInstallConfirm,
format!("Install {} via `{install_cmd}`?", tool.label),
);
// User invoked the tool that isn't installed — the affirmative
// answer is the intent. Focus Install.
prompt.cursor = 0;
self.prompt = Some(prompt);
}
/// Accept handler for `PromptKind::ToolInstallConfirm` — fired
/// from the picker accept path. If the user accepted with `y`,
/// spawn the install command in a Pty pane.
pub fn accept_tool_install(&mut self, input: String) {
let Some((_id, install_cmd)) = self.pending_tool_install.take() else {
return;
};
let accepted = input
.trim()
.chars()
.next()
.map(|c| c.eq_ignore_ascii_case(&'y'))
.unwrap_or(false);
if !accepted {
return;
}
let ws = self.active_workspace_path().to_path_buf();
self.open_pty(crate::pty_pane::BinaryProfile::task(
"install",
&install_cmd,
ws,
));
}
/// Spawn a new pty session as a *tab* of the pty pane `strip_owner`
/// — no split. The new session takes over `strip_owner`'s leaf;
/// `strip_owner` becomes a background pane reachable via the tab
/// strip. Backs the strip's `+` button.
pub fn add_pty_tab(&mut self, strip_owner: PaneId, profile: crate::pty_pane::BinaryProfile) {
match crate::pty_pane::PtySession::spawn(profile, 24, 80) {
Ok(mut s) => {
self.apply_saved_pty_name(&mut s);
self.panes.push(Pane::Pty(s));
let new_id = self.panes.len() - 1;
// Re-point every leaf that shows `strip_owner` to the new
// session — keeps it a single leaf with a tab strip.
self.layout_mut().set_leaf_pane(strip_owner, new_id);
self.active = Some(new_id);
self.focus = crate::focus::Focus::Pane;
}
Err(e) => self.toast(format!("can't open session: {e}")),
}
}
/// True if any pane is a pty (the event loop polls faster while one's open so
/// streaming output stays smooth).
pub fn has_pty_pane(&self) -> bool {
self.panes.iter().any(|p| matches!(p, Pane::Pty(_)))
}
// ─── AI: `claude -p` one-shots ──────────────────────────────────
/// Relay the user's `AiToolConfirm` answer to the blocked agent
/// worker through its confirm channel.
pub(crate) fn resolve_tool_confirm(&mut self, approved: bool) {
if let Some(job_id) = self.pending_tool_confirm.take()
&& let Some(tx) = self.ai_confirm_senders.get(&job_id)
{
let _ = tx.send(approved);
}
}
}