Skip to main content

ctx_tui/
layout.rs

1use std::collections::{HashMap, HashSet};
2
3use toml::{Table, Value};
4
5use crate::builtins;
6
7#[derive(Debug, thiserror::Error, PartialEq, Eq)]
8#[error("{0}")]
9pub struct LayoutError(pub String);
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SplitDirection {
13    Row,    // panes side by side
14    Column, // panes stacked
15}
16
17impl SplitDirection {
18    const ALL: &[(&str, SplitDirection)] = &[
19        ("row", SplitDirection::Row),
20        ("column", SplitDirection::Column),
21    ];
22
23    fn parse(raw: &str) -> Option<SplitDirection> {
24        Self::ALL
25            .iter()
26            .find(|(name, _)| *name == raw)
27            .map(|(_, direction)| *direction)
28    }
29
30    fn names() -> String {
31        Self::ALL
32            .iter()
33            .map(|(name, _)| *name)
34            .collect::<Vec<_>>()
35            .join(", ")
36    }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct Pane {
41    pub command: Option<String>, // None means a plain shell (unless a builtin is set)
42    pub builtin: Option<String>, // a builtin standing in for a command
43    pub args: Option<String>,    // extra flags appended to a builtin's command
44    pub focus: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Split {
49    pub direction: SplitDirection,
50    pub panes: Vec<Node>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Node {
55    Pane(Pane),
56    Split(Split),
57}
58
59pub fn default_layout() -> Node {
60    Node::Pane(Pane::default())
61}
62
63pub fn parse_layout(data: &Table) -> Result<Node, LayoutError> {
64    let node = parse_node(data)?;
65    if count_focus(&node) > 1 {
66        return Err(LayoutError("at most one pane may set focus".to_string()));
67    }
68    Ok(node)
69}
70
71fn unknown_keys(data: &Table, known: &[&str]) -> Option<String> {
72    let mut unknown: Vec<&str> = data
73        .keys()
74        .map(String::as_str)
75        .filter(|key| !known.contains(key))
76        .collect();
77    if unknown.is_empty() {
78        return None;
79    }
80    unknown.sort_unstable();
81    Some(unknown.join(", "))
82}
83
84/// A TOML value as the string Python's `str()` coercion would make of it.
85pub(crate) fn coerce_string(value: &Value) -> String {
86    match value {
87        Value::String(s) => s.clone(),
88        other => other.to_string(),
89    }
90}
91
92/// Python-style truthiness for TOML values.
93fn truthy(value: &Value) -> bool {
94    match value {
95        Value::Boolean(b) => *b,
96        Value::Integer(i) => *i != 0,
97        Value::Float(f) => *f != 0.0,
98        Value::String(s) => !s.is_empty(),
99        Value::Array(a) => !a.is_empty(),
100        Value::Table(t) => !t.is_empty(),
101        Value::Datetime(_) => true,
102    }
103}
104
105fn parse_node(data: &Table) -> Result<Node, LayoutError> {
106    if data.contains_key("split") {
107        if let Some(unknown) = unknown_keys(data, &["split", "panes"]) {
108            return Err(LayoutError(format!("unknown split key(s): {unknown}")));
109        }
110        let raw = coerce_string(&data["split"]);
111        let Some(direction) = SplitDirection::parse(&raw) else {
112            return Err(LayoutError(format!(
113                "split must be one of {}, got '{raw}'",
114                SplitDirection::names()
115            )));
116        };
117        let panes = match data.get("panes") {
118            Some(Value::Array(panes)) if !panes.is_empty() => panes,
119            _ => {
120                return Err(LayoutError(
121                    "a split needs a non-empty 'panes' list".to_string(),
122                ));
123            }
124        };
125        let panes = panes
126            .iter()
127            .map(|pane| match pane {
128                Value::Table(table) => parse_node(table),
129                _ => Err(LayoutError(
130                    "each entry in 'panes' must be a table".to_string(),
131                )),
132            })
133            .collect::<Result<Vec<_>, _>>()?;
134        return Ok(Node::Split(Split { direction, panes }));
135    }
136    if let Some(unknown) = unknown_keys(data, &["command", "builtin", "args", "focus"]) {
137        return Err(LayoutError(format!("unknown pane key(s): {unknown}")));
138    }
139    if data.contains_key("command") && data.contains_key("builtin") {
140        return Err(LayoutError(
141            "a pane takes either a command or a builtin, not both".to_string(),
142        ));
143    }
144    let builtin = data.get("builtin").map(coerce_string);
145    if let Some(builtin) = &builtin
146        && !builtins::PANE_BUILTINS.contains(&builtin.as_str())
147    {
148        return Err(LayoutError(format!(
149            "unknown pane builtin '{builtin}' (supported: {})",
150            builtins::PANE_BUILTINS.join(", ")
151        )));
152    }
153    if data.contains_key("args") && !data.contains_key("builtin") {
154        return Err(LayoutError("pane args require a builtin".to_string()));
155    }
156    Ok(Node::Pane(Pane {
157        command: data.get("command").map(coerce_string),
158        builtin,
159        args: data.get("args").map(coerce_string),
160        focus: data.get("focus").is_some_and(truthy),
161    }))
162}
163
164fn count_focus(node: &Node) -> usize {
165    match node {
166        Node::Pane(pane) => pane.focus as usize,
167        Node::Split(split) => split.panes.iter().map(count_focus).sum(),
168    }
169}
170
171/// All creation-time value keys the layout's builtin panes consume.
172pub fn accepted_keys(node: &Node) -> HashSet<&'static str> {
173    match node {
174        Node::Pane(pane) => match &pane.builtin {
175            Some(builtin) => builtins::builtin_keys(builtin).iter().copied().collect(),
176            None => HashSet::new(),
177        },
178        Node::Split(split) => split.panes.iter().flat_map(accepted_keys).collect(),
179    }
180}
181
182/// Concretise builtin panes into command panes.
183///
184/// `values` carries creation-time key=value data for the builtins; None
185/// means the session is being recreated for an existing context.
186pub fn resolve_layout(node: &Node, values: Option<&HashMap<String, String>>) -> Node {
187    match node {
188        Node::Pane(pane) => match &pane.builtin {
189            None => node.clone(),
190            Some(builtin) => Node::Pane(Pane {
191                command: Some(builtins::builtin_command(
192                    builtin,
193                    pane.args.as_deref(),
194                    values,
195                )),
196                focus: pane.focus,
197                ..Pane::default()
198            }),
199        },
200        Node::Split(split) => Node::Split(Split {
201            direction: split.direction,
202            panes: split
203                .panes
204                .iter()
205                .map(|pane| resolve_layout(pane, values))
206                .collect(),
207        }),
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn parse(text: &str) -> Result<Node, LayoutError> {
216        let table: Table = toml::from_str(text).expect("valid TOML");
217        parse_layout(&table)
218    }
219
220    fn pane(command: &str) -> Node {
221        Node::Pane(Pane {
222            command: Some(command.to_string()),
223            ..Pane::default()
224        })
225    }
226
227    fn err(text: &str) -> String {
228        parse(text).expect_err("layout must be rejected").0
229    }
230
231    #[test]
232    fn empty_pane_gives_defaults() {
233        assert_eq!(parse("").unwrap(), Node::Pane(Pane::default()));
234    }
235
236    #[test]
237    fn pane_takes_command_and_focus() {
238        let node = parse("command = \"nvim\"\nfocus = true").unwrap();
239
240        assert_eq!(
241            node,
242            Node::Pane(Pane {
243                command: Some("nvim".to_string()),
244                focus: true,
245                ..Pane::default()
246            })
247        );
248    }
249
250    #[test]
251    fn split_holds_panes() {
252        let node = parse("split = \"row\"\npanes = [{}, { command = \"htop\" }]").unwrap();
253
254        assert_eq!(
255            node,
256            Node::Split(Split {
257                direction: SplitDirection::Row,
258                panes: vec![Node::Pane(Pane::default()), pane("htop")],
259            })
260        );
261    }
262
263    #[test]
264    fn splits_nest() {
265        let node = parse("split = \"column\"\npanes = [{ split = \"row\", panes = [{}, {}] }, {}]")
266            .unwrap();
267
268        assert_eq!(
269            node,
270            Node::Split(Split {
271                direction: SplitDirection::Column,
272                panes: vec![
273                    Node::Split(Split {
274                        direction: SplitDirection::Row,
275                        panes: vec![Node::Pane(Pane::default()), Node::Pane(Pane::default())],
276                    }),
277                    Node::Pane(Pane::default()),
278                ],
279            })
280        );
281    }
282
283    #[test]
284    fn pane_takes_a_builtin_with_args() {
285        let node = parse("builtin = \"claude\"\nargs = \"--model opus\"\nfocus = true").unwrap();
286
287        assert_eq!(
288            node,
289            Node::Pane(Pane {
290                builtin: Some("claude".to_string()),
291                args: Some("--model opus".to_string()),
292                focus: true,
293                ..Pane::default()
294            })
295        );
296    }
297
298    #[test]
299    fn pane_rejects_command_and_builtin_together() {
300        assert!(
301            err("command = \"claude\"\nbuiltin = \"claude\"")
302                .contains("either a command or a builtin")
303        );
304    }
305
306    #[test]
307    fn pane_rejects_an_unknown_builtin() {
308        assert!(err("builtin = \"clod\"").contains("unknown pane builtin 'clod'"));
309    }
310
311    #[test]
312    fn pane_rejects_args_without_a_builtin() {
313        assert!(err("command = \"nvim\"\nargs = \"-R\"").contains("args require a builtin"));
314    }
315
316    #[test]
317    fn unknown_pane_key_rejected() {
318        assert!(err("comand = \"nvim\"").contains("unknown pane key"));
319    }
320
321    #[test]
322    fn unknown_split_key_rejected() {
323        assert!(err("split = \"row\"\npanes = [{}]\nfocus = true").contains("unknown split key"));
324    }
325
326    #[test]
327    fn unknown_direction_rejected() {
328        assert!(err("split = \"diagonal\"\npanes = [{}]").contains("split must be one of"));
329    }
330
331    #[test]
332    fn empty_panes_rejected() {
333        assert!(err("split = \"row\"\npanes = []").contains("non-empty 'panes'"));
334    }
335
336    #[test]
337    fn missing_panes_rejected() {
338        assert!(err("split = \"row\"").contains("non-empty 'panes'"));
339    }
340
341    #[test]
342    fn multiple_focus_rejected() {
343        assert!(
344            err("split = \"row\"\npanes = [{ focus = true }, { focus = true }]")
345                .contains("at most one pane")
346        );
347    }
348
349    fn builtin_pane(builtin: &str, args: Option<&str>, focus: bool) -> Node {
350        Node::Pane(Pane {
351            builtin: Some(builtin.to_string()),
352            args: args.map(str::to_string),
353            focus,
354            ..Pane::default()
355        })
356    }
357
358    #[test]
359    fn accepted_keys_collects_builtin_keys_across_the_tree() {
360        let node = Node::Split(Split {
361            direction: SplitDirection::Row,
362            panes: vec![
363                builtin_pane("claude", None, false),
364                pane("nvim"),
365                Node::Pane(Pane::default()),
366            ],
367        });
368
369        assert_eq!(accepted_keys(&node), HashSet::from(["prompt"]));
370    }
371
372    #[test]
373    fn accepted_keys_is_empty_without_builtins() {
374        assert_eq!(accepted_keys(&pane("claude")), HashSet::new());
375    }
376
377    const TRUST: &str = "ctx builtin claude trust";
378
379    fn values(pairs: &[(&str, &str)]) -> HashMap<String, String> {
380        pairs
381            .iter()
382            .map(|(k, v)| (k.to_string(), v.to_string()))
383            .collect()
384    }
385
386    fn focused_pane(command: &str) -> Node {
387        Node::Pane(Pane {
388            command: Some(command.to_string()),
389            focus: true,
390            ..Pane::default()
391        })
392    }
393
394    #[test]
395    fn resolve_claude_passes_the_prompt_as_one_word() {
396        let node = resolve_layout(
397            &builtin_pane("claude", None, true),
398            Some(&values(&[("prompt", "explore the bug")])),
399        );
400
401        let quoted_prompt = r#"'"'"'explore the bug'"'"'"#;
402        assert_eq!(
403            node,
404            focused_pane(&format!("sh -c '{TRUST}; exec claude {quoted_prompt}'"))
405        );
406    }
407
408    #[test]
409    fn resolve_claude_without_a_prompt() {
410        let node = resolve_layout(&builtin_pane("claude", None, false), Some(&values(&[])));
411
412        assert_eq!(node, pane(&format!("sh -c '{TRUST}; exec claude'")));
413    }
414
415    #[test]
416    fn resolve_claude_keeps_extra_args() {
417        let node = resolve_layout(
418            &builtin_pane("claude", Some("--model opus"), false),
419            Some(&values(&[])),
420        );
421
422        assert_eq!(
423            node,
424            pane(&format!("sh -c '{TRUST}; exec claude --model opus'"))
425        );
426    }
427
428    #[test]
429    fn resolve_claude_on_a_recreated_session_resumes() {
430        let node = resolve_layout(&builtin_pane("claude", None, false), None);
431
432        assert_eq!(
433            node,
434            pane(&format!("sh -c '{TRUST}; exec claude --continue'"))
435        );
436    }
437
438    #[test]
439    fn resolve_claude_on_a_recreated_session_keeps_extra_args() {
440        let node = resolve_layout(&builtin_pane("claude", Some("--model opus"), false), None);
441
442        assert_eq!(
443            node,
444            pane(&format!(
445                "sh -c '{TRUST}; exec claude --model opus --continue'"
446            ))
447        );
448    }
449
450    #[test]
451    fn resolve_leaves_command_panes_alone() {
452        let node = Node::Split(Split {
453            direction: SplitDirection::Column,
454            panes: vec![pane("nvim"), builtin_pane("claude", None, false)],
455        });
456
457        let resolved = resolve_layout(&node, Some(&values(&[("prompt", "x")])));
458
459        assert_eq!(
460            resolved,
461            Node::Split(Split {
462                direction: SplitDirection::Column,
463                panes: vec![
464                    pane("nvim"),
465                    pane(&format!("sh -c '{TRUST}; exec claude x'"))
466                ],
467            })
468        );
469    }
470}