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}