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
|
//! Top-level AtuinAi component that translates key events into AiTuiEvents.
//!
//! Global shortcuts (Ctrl+C, Esc) are handled in the capture phase so they
//! fire regardless of which child is focused. Contextual shortcuts (Enter,
//! Tab) are handled in the bubble phase so child components like the
//! permission Select can consume them first.
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use eye_declare::{Elements, EventResult, Hooks, component, props};
use crate::commands::inline::DriverEventSender;
use crate::tui::events::AiTuiEvent;
use crate::tui::state::AppMode;
/// Top-level wrapper component for the AI TUI.
///
/// Props carry the current mode so `handle_event` can translate keys
/// into the right `AiTuiEvent`. Children are rendered via slot children.
#[props]
pub(crate) struct AtuinAi {
pub mode: AppMode,
pub has_command: bool,
pub is_input_blank: bool,
pub pending_confirmation: bool,
pub has_executing_preview: bool,
}
#[derive(Default)]
pub(crate) struct AtuinAiState {
tx: Option<DriverEventSender>,
}
#[component(props = AtuinAi, state = AtuinAiState, children = Elements)]
fn atuin_ai(
_props: &AtuinAi,
_state: &AtuinAiState,
hooks: &mut Hooks<AtuinAi, AtuinAiState>,
children: Elements,
) -> Elements {
hooks.use_context::<DriverEventSender>(|tx, _, state| {
state.tx = tx.cloned();
});
// Capture phase: global shortcuts that must fire regardless of child focus.
hooks.use_event_capture(move |event, props, state| {
let Event::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
modifiers,
..
}) = event
else {
return EventResult::Ignored;
};
let Some(ref tx) = state.read().tx else {
return EventResult::Ignored;
};
// Ctrl+C — interrupt executing command or exit
if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') {
if props.has_executing_preview {
let _ = tx.send(AiTuiEvent::InterruptToolExecution);
} else {
let _ = tx.send(AiTuiEvent::Exit);
}
return EventResult::Consumed;
}
// Esc — always handled at the top level
if *code == KeyCode::Esc {
match props.mode {
AppMode::Input => {
if props.has_executing_preview {
let _ = tx.send(AiTuiEvent::InterruptToolExecution);
} else if props.pending_confirmation {
let _ = tx.send(AiTuiEvent::CancelConfirmation);
} else {
let _ = tx.send(AiTuiEvent::Exit);
}
}
AppMode::Generating | AppMode::Streaming => {
let _ = tx.send(AiTuiEvent::CancelGeneration);
}
AppMode::Error => {
let _ = tx.send(AiTuiEvent::Exit);
}
}
return EventResult::Consumed;
}
if *code == KeyCode::Tab
&& matches!(props.mode, AppMode::Input)
&& modifiers.contains(KeyModifiers::NONE)
&& props.has_command
&& props.is_input_blank
{
let _ = tx.send(AiTuiEvent::InsertCommand);
return EventResult::Consumed;
}
EventResult::Ignored
});
// Bubble phase: contextual shortcuts that children (e.g. Select) may handle first.
hooks.use_event(move |event, props, state| {
let Event::Key(KeyEvent {
code,
kind: KeyEventKind::Press,
..
}) = event
else {
return EventResult::Ignored;
};
let Some(ref tx) = state.read().tx else {
return EventResult::Ignored;
};
match props.mode {
AppMode::Input => match code {
KeyCode::Enter => {
if props.has_command && props.is_input_blank {
let _ = tx.send(AiTuiEvent::ExecuteCommand);
return EventResult::Consumed;
}
EventResult::Ignored
}
_ => EventResult::Ignored,
},
AppMode::Error => match code {
KeyCode::Enter | KeyCode::Char('r') => {
let _ = tx.send(AiTuiEvent::Retry);
EventResult::Consumed
}
_ => EventResult::Ignored,
},
_ => EventResult::Ignored,
}
});
children
}
|