Skip to main content

imp_tui/views/
personality.rs

1use std::path::{Path, PathBuf};
2
3use imp_core::personality::{
4    default_soul_markdown, generated_tunable_line, replace_tunable_line, soul_identity_text,
5    tunable_state_for_label, SoulTunableState,
6};
7use imp_core::resources::{discover_project_soul, suggested_project_soul_path};
8use ratatui::buffer::Buffer;
9use ratatui::layout::{Constraint, Direction, Layout, Rect};
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
13
14use crate::theme::Theme;
15use crate::views::editor::EditorState;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum PersonalityScope {
19    Global,
20    Project,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum PersonalityTab {
25    Builder,
26    Source,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PersonalityField {
31    Scope,
32    Autonomy,
33    Brevity,
34    Caution,
35    Warmth,
36    Planning,
37    Save,
38}
39
40const FIELDS: &[PersonalityField] = &[
41    PersonalityField::Scope,
42    PersonalityField::Autonomy,
43    PersonalityField::Brevity,
44    PersonalityField::Caution,
45    PersonalityField::Warmth,
46    PersonalityField::Planning,
47    PersonalityField::Save,
48];
49
50fn resolve_project_soul_path(cwd: &Path) -> PathBuf {
51    discover_project_soul(cwd)
52        .map(|soul| soul.path)
53        .unwrap_or_else(|| suggested_project_soul_path(cwd))
54}
55
56#[derive(Debug, Clone)]
57pub struct PendingOverwrite {
58    pub label: &'static str,
59    pub replacement_line: String,
60    pub diff_preview: String,
61}
62
63#[derive(Debug, Clone)]
64pub struct PersonalityState {
65    pub selected: usize,
66    pub scope: PersonalityScope,
67    pub tab: PersonalityTab,
68    pub editor: EditorState,
69    pub dirty_global: bool,
70    pub dirty_project: bool,
71    pub pending_overwrite: Option<PendingOverwrite>,
72    global_path: PathBuf,
73    project_path: PathBuf,
74    global_source: String,
75    project_source: String,
76}
77
78impl PersonalityState {
79    fn normalized_selected(&self) -> usize {
80        self.selected.min(FIELDS.len().saturating_sub(1))
81    }
82
83    pub fn new(cwd: PathBuf, scope: PersonalityScope) -> Self {
84        Self::from_paths(
85            imp_core::config::Config::user_config_dir().join("soul.md"),
86            resolve_project_soul_path(&cwd),
87            scope,
88        )
89    }
90
91    pub fn from_paths(
92        global_path: PathBuf,
93        project_path: PathBuf,
94        scope: PersonalityScope,
95    ) -> Self {
96        let global_source =
97            std::fs::read_to_string(&global_path).unwrap_or_else(|_| default_soul_markdown());
98        let project_source =
99            std::fs::read_to_string(&project_path).unwrap_or_else(|_| default_soul_markdown());
100        let mut editor = EditorState::new();
101        editor.set_content(match scope {
102            PersonalityScope::Global => &global_source,
103            PersonalityScope::Project => &project_source,
104        });
105        Self {
106            selected: 0,
107            scope,
108            tab: PersonalityTab::Builder,
109            editor,
110            dirty_global: false,
111            dirty_project: false,
112            pending_overwrite: None,
113            global_path,
114            project_path,
115            global_source,
116            project_source,
117        }
118    }
119
120    pub fn current_field(&self) -> PersonalityField {
121        FIELDS[self.normalized_selected()]
122    }
123
124    pub fn current_path(&self) -> &PathBuf {
125        match self.scope {
126            PersonalityScope::Global => &self.global_path,
127            PersonalityScope::Project => &self.project_path,
128        }
129    }
130
131    pub fn is_dirty(&self) -> bool {
132        match self.scope {
133            PersonalityScope::Global => self.dirty_global,
134            PersonalityScope::Project => self.dirty_project,
135        }
136    }
137
138    fn set_dirty(&mut self, dirty: bool) {
139        match self.scope {
140            PersonalityScope::Global => self.dirty_global = dirty,
141            PersonalityScope::Project => self.dirty_project = dirty,
142        }
143    }
144
145    fn sync_editor_to_scope_store(&mut self) {
146        match self.scope {
147            PersonalityScope::Global => self.global_source = self.editor.content().to_string(),
148            PersonalityScope::Project => self.project_source = self.editor.content().to_string(),
149        }
150    }
151
152    fn load_scope_into_editor(&mut self) {
153        let content = match self.scope {
154            PersonalityScope::Global => self.global_source.as_str(),
155            PersonalityScope::Project => self.project_source.as_str(),
156        };
157        self.editor.set_content(content);
158    }
159
160    pub fn sentence(&self) -> String {
161        soul_identity_text(self.editor.content())
162    }
163
164    pub fn move_up(&mut self) {
165        if self.tab == PersonalityTab::Builder && self.selected > 0 {
166            self.selected -= 1;
167        }
168    }
169
170    pub fn move_down(&mut self) {
171        if self.tab == PersonalityTab::Builder && self.selected + 1 < FIELDS.len() {
172            self.selected += 1;
173        }
174    }
175
176    pub fn switch_tab(&mut self) {
177        self.pending_overwrite = None;
178        self.tab = match self.tab {
179            PersonalityTab::Builder => PersonalityTab::Source,
180            PersonalityTab::Source => PersonalityTab::Builder,
181        };
182    }
183
184    pub fn toggle_scope(&mut self) {
185        self.sync_editor_to_scope_store();
186        self.scope = match self.scope {
187            PersonalityScope::Global => PersonalityScope::Project,
188            PersonalityScope::Project => PersonalityScope::Global,
189        };
190        self.load_scope_into_editor();
191        self.pending_overwrite = None;
192    }
193
194    pub fn tunable_display(&self, label: &'static str) -> &'static str {
195        match tunable_state_for_label(self.editor.content(), label) {
196            SoulTunableState::Preset(0) => "very low",
197            SoulTunableState::Preset(1) => "low",
198            SoulTunableState::Preset(2) => "balanced",
199            SoulTunableState::Preset(3) => "high",
200            SoulTunableState::Preset(4) => "very high",
201            SoulTunableState::Preset(_) => "preset",
202            SoulTunableState::Edited => "edited",
203            SoulTunableState::Missing => "missing",
204        }
205    }
206
207    fn cycle_tunable(&mut self, label: &'static str, forward: bool) {
208        let state = tunable_state_for_label(self.editor.content(), label);
209        let next_idx = match state {
210            SoulTunableState::Preset(idx) => {
211                if forward {
212                    (idx + 1) % 5
213                } else {
214                    (idx + 4) % 5
215                }
216            }
217            SoulTunableState::Missing => {
218                if forward {
219                    0
220                } else {
221                    4
222                }
223            }
224            SoulTunableState::Edited => {
225                if forward {
226                    0
227                } else {
228                    4
229                }
230            }
231        };
232        let Some(new_line) = generated_tunable_line(label, next_idx) else {
233            return;
234        };
235
236        if matches!(state, SoulTunableState::Edited) {
237            let current = imp_core::personality::parse_tunables_section(self.editor.content())
238                .get(label)
239                .cloned()
240                .unwrap_or_default();
241            self.pending_overwrite = Some(PendingOverwrite {
242                label,
243                replacement_line: new_line.clone(),
244                diff_preview: format!("- {label}: {current}\n+ {new_line}"),
245            });
246            return;
247        }
248
249        let updated = replace_tunable_line(self.editor.content(), label, &new_line);
250        self.editor.set_content(&updated);
251        self.sync_editor_to_scope_store();
252        self.set_dirty(true);
253    }
254
255    pub fn cycle_forward(&mut self) {
256        if self.tab != PersonalityTab::Builder {
257            return;
258        }
259        match self.current_field() {
260            PersonalityField::Scope => self.toggle_scope(),
261            PersonalityField::Autonomy => self.cycle_tunable("Autonomy", true),
262            PersonalityField::Brevity => self.cycle_tunable("Brevity", true),
263            PersonalityField::Caution => self.cycle_tunable("Caution", true),
264            PersonalityField::Warmth => self.cycle_tunable("Warmth", true),
265            PersonalityField::Planning => self.cycle_tunable("Planning", true),
266            PersonalityField::Save => {}
267        }
268    }
269
270    pub fn cycle_backward(&mut self) {
271        if self.tab != PersonalityTab::Builder {
272            return;
273        }
274        match self.current_field() {
275            PersonalityField::Scope => self.toggle_scope(),
276            PersonalityField::Autonomy => self.cycle_tunable("Autonomy", false),
277            PersonalityField::Brevity => self.cycle_tunable("Brevity", false),
278            PersonalityField::Caution => self.cycle_tunable("Caution", false),
279            PersonalityField::Warmth => self.cycle_tunable("Warmth", false),
280            PersonalityField::Planning => self.cycle_tunable("Planning", false),
281            PersonalityField::Save => {}
282        }
283    }
284
285    pub fn confirm_overwrite(&mut self) {
286        let Some(pending) = self.pending_overwrite.take() else {
287            return;
288        };
289        let updated = replace_tunable_line(
290            self.editor.content(),
291            pending.label,
292            &pending.replacement_line,
293        );
294        self.editor.set_content(&updated);
295        self.sync_editor_to_scope_store();
296        self.set_dirty(true);
297    }
298
299    pub fn cancel_overwrite(&mut self) {
300        self.pending_overwrite = None;
301    }
302
303    pub fn save_success(&mut self) {
304        self.sync_editor_to_scope_store();
305        self.set_dirty(false);
306    }
307
308    pub fn insert_char(&mut self, c: char) {
309        self.editor.insert_char(c);
310        self.sync_editor_to_scope_store();
311        self.set_dirty(true);
312    }
313
314    pub fn insert_newline(&mut self) {
315        self.editor.insert_newline();
316        self.sync_editor_to_scope_store();
317        self.set_dirty(true);
318    }
319
320    pub fn pop_char(&mut self) {
321        self.editor.delete_back();
322        self.sync_editor_to_scope_store();
323        self.set_dirty(true);
324    }
325
326    pub fn move_left(&mut self) {
327        self.editor.move_left();
328    }
329
330    pub fn move_right(&mut self) {
331        self.editor.move_right();
332    }
333}
334
335pub struct PersonalityView<'a> {
336    state: &'a PersonalityState,
337    theme: &'a Theme,
338}
339
340impl<'a> PersonalityView<'a> {
341    pub fn new(state: &'a PersonalityState, theme: &'a Theme) -> Self {
342        Self { state, theme }
343    }
344}
345
346impl Widget for PersonalityView<'_> {
347    fn render(self, area: Rect, buf: &mut Buffer) {
348        if area.height < 12 || area.width < 50 {
349            return;
350        }
351
352        Clear.render(area, buf);
353        let block = Block::default()
354            .title(" Personality ")
355            .borders(Borders::ALL)
356            .border_style(self.theme.accent_style());
357        let inner = block.inner(area);
358        block.render(area, buf);
359
360        let rows = Layout::default()
361            .direction(Direction::Vertical)
362            .constraints([
363                Constraint::Length(3),
364                Constraint::Length(2),
365                Constraint::Min(8),
366                Constraint::Length(2),
367            ])
368            .split(inner);
369
370        Paragraph::new(self.state.sentence())
371            .style(self.theme.style())
372            .block(Block::default().title(" Identity ").borders(Borders::ALL))
373            .wrap(Wrap { trim: false })
374            .render(rows[0], buf);
375
376        let scope = match self.state.scope {
377            PersonalityScope::Global => "global",
378            PersonalityScope::Project => "project",
379        };
380        let tab = match self.state.tab {
381            PersonalityTab::Builder => "builder",
382            PersonalityTab::Source => "source",
383        };
384        Paragraph::new(format!(
385            "Scope: {scope}  •  Tab: {tab}  •  Path: {}{}",
386            self.state.current_path().display(),
387            if self.state.is_dirty() {
388                "  • unsaved"
389            } else {
390                ""
391            }
392        ))
393        .style(self.theme.muted_style())
394        .render(rows[1], buf);
395
396        match self.state.tab {
397            PersonalityTab::Builder => render_builder(rows[2], buf, self.state),
398            PersonalityTab::Source => render_source(rows[2], buf, self.state),
399        }
400
401        let hints = if self.state.pending_overwrite.is_some() {
402            "Enter/Y: confirm overwrite  Esc/N: cancel"
403        } else {
404            match self.state.tab {
405                PersonalityTab::Builder => {
406                    "Tab: source  ↑/↓ move  ←/→ change  Enter on save to write file  Ctrl-S save  Esc close"
407                }
408                PersonalityTab::Source => {
409                    "Tab: builder  type to edit  arrows move  Enter newline  Backspace delete  Ctrl-S save  Esc close"
410                }
411            }
412        };
413        Paragraph::new(hints)
414            .style(self.theme.muted_style())
415            .render(rows[3], buf);
416
417        if let Some(pending) = &self.state.pending_overwrite {
418            let modal = centered_rect(70, 40, area);
419            Clear.render(modal, buf);
420            Paragraph::new(pending.diff_preview.clone())
421                .block(
422                    Block::default()
423                        .title(" Confirm overwrite ")
424                        .borders(Borders::ALL),
425                )
426                .wrap(Wrap { trim: false })
427                .render(modal, buf);
428        }
429    }
430}
431
432fn render_builder(area: Rect, buf: &mut Buffer, state: &PersonalityState) {
433    let mut lines = Vec::new();
434    push_field_line(
435        lines.as_mut(),
436        state,
437        PersonalityField::Scope,
438        "scope",
439        match state.scope {
440            PersonalityScope::Global => "global",
441            PersonalityScope::Project => "project",
442        },
443    );
444    push_field_line(
445        lines.as_mut(),
446        state,
447        PersonalityField::Autonomy,
448        "autonomy",
449        state.tunable_display("Autonomy"),
450    );
451    push_field_line(
452        lines.as_mut(),
453        state,
454        PersonalityField::Brevity,
455        "brevity",
456        state.tunable_display("Brevity"),
457    );
458    push_field_line(
459        lines.as_mut(),
460        state,
461        PersonalityField::Caution,
462        "caution",
463        state.tunable_display("Caution"),
464    );
465    push_field_line(
466        lines.as_mut(),
467        state,
468        PersonalityField::Warmth,
469        "warmth",
470        state.tunable_display("Warmth"),
471    );
472    push_field_line(
473        lines.as_mut(),
474        state,
475        PersonalityField::Planning,
476        "planning",
477        state.tunable_display("Planning"),
478    );
479    push_field_line(
480        lines.as_mut(),
481        state,
482        PersonalityField::Save,
483        "save",
484        "write soul.md",
485    );
486
487    Paragraph::new(lines)
488        .block(Block::default().title(" Builder ").borders(Borders::ALL))
489        .render(area, buf);
490}
491
492fn render_source(area: Rect, buf: &mut Buffer, state: &PersonalityState) {
493    Paragraph::new(state.editor.content().to_string())
494        .block(Block::default().title(" Source ").borders(Borders::ALL))
495        .wrap(Wrap { trim: false })
496        .render(area, buf);
497}
498
499fn push_field_line(
500    lines: &mut Vec<Line<'static>>,
501    state: &PersonalityState,
502    field: PersonalityField,
503    label: &str,
504    value: &str,
505) {
506    let selected = state.tab == PersonalityTab::Builder && state.current_field() == field;
507    let indicator = if selected { "▸" } else { " " };
508    let style = if selected {
509        Style::default().add_modifier(Modifier::REVERSED)
510    } else {
511        Style::default()
512    };
513    lines.push(Line::from(vec![
514        Span::styled(format!("{} ", indicator), style),
515        Span::styled(format!("{label:<12}"), style.add_modifier(Modifier::BOLD)),
516        Span::styled(value.to_string(), style),
517    ]));
518}
519
520fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
521    let popup_layout = Layout::default()
522        .direction(Direction::Vertical)
523        .constraints([
524            Constraint::Percentage((100 - percent_y) / 2),
525            Constraint::Percentage(percent_y),
526            Constraint::Percentage((100 - percent_y) / 2),
527        ])
528        .split(r);
529    Layout::default()
530        .direction(Direction::Horizontal)
531        .constraints([
532            Constraint::Percentage((100 - percent_x) / 2),
533            Constraint::Percentage(percent_x),
534            Constraint::Percentage((100 - percent_x) / 2),
535        ])
536        .split(popup_layout[1])[1]
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn current_field_clamps_stale_selection() {
545        let tmp = tempfile::tempdir().unwrap();
546        let mut state = PersonalityState::new(tmp.path().to_path_buf(), PersonalityScope::Global);
547        state.selected = usize::MAX;
548
549        assert_eq!(state.current_field(), PersonalityField::Save);
550    }
551
552    #[test]
553    fn personality_state_defaults_to_generated_soul() {
554        let tmp = tempfile::tempdir().unwrap();
555        let state = PersonalityState::from_paths(
556            tmp.path().join("global-soul.md"),
557            tmp.path().join("project-soul.md"),
558            PersonalityScope::Global,
559        );
560        assert!(state.sentence().contains("You are imp"));
561        assert_eq!(state.tunable_display("Autonomy"), "high");
562    }
563
564    #[test]
565    fn personality_state_marks_custom_lines_as_edited() {
566        let tmp = tempfile::tempdir().unwrap();
567        let mut state = PersonalityState::from_paths(
568            tmp.path().join("global-soul.md"),
569            tmp.path().join("project-soul.md"),
570            PersonalityScope::Global,
571        );
572        state.editor.set_content(
573            "# Soul\n\nYou are imp.\n\n## Tunables\n\n- Autonomy: custom autonomy line\n",
574        );
575        assert_eq!(state.tunable_display("Autonomy"), "edited");
576    }
577
578    #[test]
579    fn personality_state_prefers_ancestor_project_soul_path() {
580        let tmp = tempfile::tempdir().unwrap();
581        let project = tmp.path().join("project");
582        let nested = project.join("src").join("deep");
583        std::fs::create_dir_all(project.join(".imp")).unwrap();
584        std::fs::create_dir_all(&nested).unwrap();
585        let project_soul = project.join(".imp").join("soul.md");
586        std::fs::write(&project_soul, "# Soul\n\nproject soul\n").unwrap();
587
588        let state = PersonalityState::new(nested, PersonalityScope::Project);
589        assert_eq!(state.current_path(), &project_soul);
590    }
591}