Skip to content

Commit a3bdf83

Browse files
committed
feat: add possibility to input in terminal windows
1 parent d0ef299 commit a3bdf83

File tree

7 files changed

+118
-30
lines changed

7 files changed

+118
-30
lines changed

src/agent/event.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ pub enum AgentOutputEvent {
6767
#[derive(Clone, Debug)]
6868
pub enum AgentControlEvent {
6969
SendMessage(String),
70+
/// Sends data to stdin of running terminal by idx
71+
TerminalData(usize, Vec<u8>),
7072
CancelTask,
7173
NewTask,
7274
}

src/agent/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,9 @@ impl Agent {
655655
self.sender.send(AgentOutputEvent::NewTask).ok();
656656
persist_history(&self.messages);
657657
}
658+
AgentControlEvent::TerminalData(idx, data) => {
659+
self.process_registry.read().await.send_data(idx, data);
660+
}
658661
}
659662
}
660663
if let Err(e) = self.process_messages(system_prompt_token_count).await {

src/agent/utils.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,7 @@ pub async fn add_env_message<'a>(
106106
"| {} | {} | `{}` |",
107107
id,
108108
if let Some(exit_status) = status {
109-
if let Some(code) = exit_status.code() {
110-
format!("Exited({})", code)
111-
} else {
112-
"Exited(-1)".to_string()
113-
}
109+
format!("Exited({})", exit_status)
114110
} else {
115111
"Running".to_string()
116112
},

src/tools/execute_command/mod.rs

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use std::process::ExitStatus;
55

66
use anyhow::Result;
77
use process_wrap::tokio::{TokioChildWrapper, TokioCommandWrap};
8-
use tokio::io::{AsyncBufReadExt, BufReader};
9-
use tokio::process::{ChildStderr, ChildStdout};
8+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
9+
use tokio::process::{ChildStderr, ChildStdin, ChildStdout};
1010
use tokio::sync::{mpsc, oneshot};
1111

1212
use crate::agent::event::AgentCommandStatus;
@@ -27,22 +27,25 @@ pub struct ProcessRegistry {
2727
struct ProcessData {
2828
command: String,
2929
output: String,
30-
exit_status: Option<ExitStatus>,
30+
exit_status: Option<i32>,
3131
receiver: mpsc::UnboundedReceiver<ProcessOutput>,
3232
terminate_sender: Option<oneshot::Sender<()>>,
33+
input_sender: Option<mpsc::UnboundedSender<Vec<u8>>>,
3334
}
3435

3536
enum ProcessOutput {
36-
Exited(ExitStatus),
37+
Exited(Option<ExitStatus>),
3738
Output(String),
3839
Error(String),
3940
}
4041

4142
struct ProcessRuntime {
4243
_process: Box<dyn TokioChildWrapper>,
4344
stdout: ChildStdout,
45+
stdin: ChildStdin,
4446
stderr: ChildStderr,
4547
sender: mpsc::UnboundedSender<ProcessOutput>,
48+
input_signal: mpsc::UnboundedReceiver<Vec<u8>>,
4649
terminate_signal: oneshot::Receiver<()>,
4750
}
4851

@@ -52,29 +55,37 @@ impl ProcessRuntime {
5255

5356
let stdout = Self::handle_stdout(self.stdout, self.sender.clone());
5457
let stderr = Self::handle_stderr(self.stderr, self.sender.clone());
58+
let stdin = Self::handle_stdin(self.stdin, self.input_signal);
59+
5560
let status = Box::into_pin(self._process.wait());
5661
pin!(stdout);
5762
pin!(stderr);
58-
let mut exit_status = ExitStatus::default();
63+
pin!(stdin);
64+
65+
let mut exit_status = None;
5966
tokio::select! {
6067
result = &mut stdout => {
6168
tracing::trace!("Stdout handler completed: {:?}", result);
69+
exit_status = Some(ExitStatus::default());
6270
}
6371
result = &mut stderr => {
6472
tracing::trace!("Stderr handler completed: {:?}", result);
6573
}
6674
// capture the status so we don't need to wait for a timeout
6775
result = status => {
6876
if let Ok(result) = result {
69-
exit_status = result;
77+
exit_status = Some(result);
7078
}
7179
tracing::trace!("Process exited with status: {:?}", result);
7280
}
81+
result = &mut stdin => {
82+
tracing::trace!("Stdin handler completed: {:?}", result);
83+
}
7384
_ = self.terminate_signal => {
7485
tracing::debug!("Receive terminal_signal");
7586
if self._process.start_kill().is_ok() {
7687
if let Ok(status) = Box::into_pin(self._process.wait()).await {
77-
exit_status = status;
88+
exit_status = Some(status);
7889
}
7990
}
8091
}
@@ -123,17 +134,36 @@ impl ProcessRuntime {
123134
}
124135
}
125136
}
137+
138+
async fn handle_stdin(mut stdin: ChildStdin, mut receiver: mpsc::UnboundedReceiver<Vec<u8>>) {
139+
while let Some(data) = receiver.recv().await {
140+
tracing::trace!("Writing data to stdin: {:?}", data);
141+
if let Err(e) = stdin.write_all(data.as_slice()).await {
142+
tracing::error!(error = ?e, "Error writing data to child process");
143+
break;
144+
}
145+
if let Err(e) = stdin.flush().await {
146+
tracing::error!(error = ?e, "Error flushing data to child process");
147+
break;
148+
}
149+
}
150+
}
126151
}
127152

128153
impl ProcessRegistry {
129154
async fn spawn_process(
130155
&self,
131156
command: &str,
132157
cwd: &str,
133-
) -> Result<(Box<dyn TokioChildWrapper>, ChildStdout, ChildStderr)> {
158+
) -> Result<(
159+
Box<dyn TokioChildWrapper>,
160+
ChildStdout,
161+
ChildStderr,
162+
ChildStdin,
163+
)> {
134164
let mut child = TokioCommandWrap::with_new(SHELL, |cmd| {
135165
cmd.current_dir(cwd)
136-
.stdin(std::process::Stdio::null())
166+
.stdin(std::process::Stdio::piped())
137167
.stdout(std::process::Stdio::piped())
138168
.stderr(std::process::Stdio::piped());
139169

@@ -164,20 +194,28 @@ impl ProcessRegistry {
164194
.take()
165195
.ok_or_else(|| anyhow::anyhow!("Failed to get stdout"))?;
166196

167-
Ok((process, stdout, stderr))
197+
let stdin = process
198+
.stdin()
199+
.take()
200+
.ok_or_else(|| anyhow::anyhow!("Failed to get stdin"))?;
201+
202+
Ok((process, stdout, stderr, stdin))
168203
}
169204

170205
pub async fn execute_command(&mut self, command: &str, cwd: &str) -> Result<usize> {
171206
self.counter = self.counter.saturating_add(1);
172-
let (process, stdout, stderr) = self.spawn_process(command, cwd).await?;
207+
let (process, stdout, stderr, stdin) = self.spawn_process(command, cwd).await?;
173208
let (tx, rx) = mpsc::unbounded_channel();
174209
let (t_tx, t_rx) = tokio::sync::oneshot::channel();
210+
let (in_tx, in_rx) = mpsc::unbounded_channel();
175211

176212
let runtime = ProcessRuntime {
177213
_process: process,
178214
stdout,
179215
stderr,
216+
stdin,
180217
sender: tx,
218+
input_signal: in_rx,
181219
terminate_signal: t_rx,
182220
};
183221

@@ -191,6 +229,7 @@ impl ProcessRegistry {
191229
exit_status: None,
192230
receiver: rx,
193231
terminate_sender: Some(t_tx),
232+
input_sender: Some(in_tx),
194233
},
195234
);
196235
Ok(self.counter)
@@ -214,7 +253,11 @@ impl ProcessRegistry {
214253
while let Ok(output) = process.receiver.try_recv() {
215254
match output {
216255
ProcessOutput::Exited(exit_status) => {
217-
process.exit_status = Some(exit_status)
256+
process.exit_status = Some(
257+
exit_status
258+
.map(|s| s.code().unwrap_or_default())
259+
.unwrap_or(1),
260+
)
218261
}
219262
ProcessOutput::Output(str) => process.output += &str,
220263
ProcessOutput::Error(str) => process.output += &str,
@@ -231,12 +274,12 @@ impl ProcessRegistry {
231274
modified_terminal_states
232275
}
233276

234-
pub fn get_process(&self, id: usize) -> Option<(Option<ExitStatus>, &String)> {
277+
pub fn get_process(&self, id: usize) -> Option<(Option<i32>, &String)> {
235278
let process = self.processes.get(&id)?;
236279
Some((process.exit_status, &process.output))
237280
}
238281

239-
pub fn processes(&self) -> impl Iterator<Item = (usize, Option<ExitStatus>, &String)> {
282+
pub fn processes(&self) -> impl Iterator<Item = (usize, Option<i32>, &String)> {
240283
self.processes
241284
.iter()
242285
.map(|(key, value)| (*key, value.exit_status, &value.command))
@@ -253,4 +296,12 @@ impl ProcessRegistry {
253296
}
254297
Ok(())
255298
}
299+
300+
pub fn send_data(&self, idx: usize, data: Vec<u8>) {
301+
if let Some(process) = self.processes.get(&idx) {
302+
if let Some(sender) = process.input_sender.as_ref() {
303+
sender.send(data).ok();
304+
}
305+
}
306+
}
256307
}

src/tools/execute_command/tools.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,7 @@ impl Tool for ExecuteCommandTool {
133133
if let Some(exit_status) = exit_status {
134134
return Ok(format!(
135135
"Command ID: {}\nExit Status: Exited({})\nOutput:\n{}",
136-
command_id,
137-
exit_status.code().unwrap_or_default(),
138-
output
136+
command_id, exit_status, output
139137
));
140138
}
141139
command_output = output.to_string();
@@ -194,9 +192,7 @@ impl Tool for GetCommandResultTool {
194192
if let Some(exit_status) = exit_status {
195193
Ok(format!(
196194
"Command ID: {}\nExit Status: Exited({})\nOutput:\n{}",
197-
args.command_id,
198-
exit_status.code().unwrap_or_default(),
199-
output
195+
args.command_id, exit_status, output
200196
))
201197
} else {
202198
Ok(format!(

src/tui/app.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright © 2025 Huly Labs. Use of this source code is governed by the MIT license.
22
use std::collections::{HashMap, HashSet};
33
use std::path::PathBuf;
4+
use std::vec;
45

56
use crate::agent::event::{AgentCommandStatus, AgentState};
67
use crate::config::Config;
@@ -83,7 +84,6 @@ pub struct UiState<'a> {
8384
pub textarea: TextArea<'a>,
8485
pub focus: FocusedComponent,
8586
pub tree_state: FileTreeState,
86-
pub history_scroll_state: ScrollbarState,
8787
pub history_state: ListState,
8888
pub history_opened_state: HashSet<usize>,
8989
pub throbber_state: throbber_widgets_tui::ThrobberState,
@@ -107,7 +107,6 @@ impl UiState<'_> {
107107
Self {
108108
textarea: TextArea::default(),
109109
focus: FocusedComponent::Input,
110-
history_scroll_state: ScrollbarState::default(),
111110
tree_state: FileTreeState::new(workspace),
112111
history_state: ListState::default(),
113112
history_opened_state: HashSet::default(),
@@ -194,6 +193,48 @@ impl App<'_> {
194193
&mut self.ui.terminal_state,
195194
&event,
196195
);
196+
if key_event.kind == KeyEventKind::Press {
197+
let input_data = match key_event.code {
198+
KeyCode::Char(ch) => {
199+
if ch == 'c'
200+
&& key_event.modifiers == KeyModifiers::CONTROL
201+
{
202+
vec![3]
203+
} else {
204+
vec![ch as u8]
205+
}
206+
}
207+
KeyCode::Enter => {
208+
vec![b'\n']
209+
}
210+
KeyCode::Down
211+
if key_event.modifiers == KeyModifiers::ALT =>
212+
{
213+
vec![b'\x1b', b'[', b'B']
214+
}
215+
KeyCode::Up
216+
if key_event.modifiers == KeyModifiers::ALT =>
217+
{
218+
vec![b'\x1b', b'[', b'A']
219+
}
220+
_ => {
221+
vec![]
222+
}
223+
};
224+
if !input_data.is_empty() {
225+
tracing::trace!(
226+
"Sending data to terminal: {} {:?}",
227+
self.ui.terminal_state.selected_idx,
228+
input_data
229+
);
230+
self.agent_sender
231+
.send(AgentControlEvent::TerminalData(
232+
self.ui.terminal_state.selected_idx + 1,
233+
input_data,
234+
))
235+
.unwrap()
236+
}
237+
}
197238
}
198239
}
199240
}
@@ -383,11 +424,11 @@ impl App<'_> {
383424
terminal_state.selected_idx = idx - 1;
384425
terminal_state.scroll_position = 0;
385426
}
386-
KeyCode::Down => {
427+
KeyCode::Down if key_event.modifiers != KeyModifiers::ALT => {
387428
terminal_state.scroll_position =
388429
terminal_state.scroll_position.saturating_add(1);
389430
}
390-
KeyCode::Up => {
431+
KeyCode::Up if key_event.modifiers != KeyModifiers::ALT => {
391432
terminal_state.scroll_position =
392433
terminal_state.scroll_position.saturating_sub(1);
393434
}

src/tui/widgets.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,6 @@ impl Widget for &mut App<'_> {
160160
.border_style(theme.border_style(matches!(self.ui.focus, FocusedComponent::History)));
161161

162162
let chat_len = self.model.messages.len();
163-
self.ui.history_scroll_state = self.ui.history_scroll_state.content_length(chat_len);
164163

165164
let builder = ListBuilder::new(|context| {
166165
let item = MessageWidget::new(

0 commit comments

Comments
 (0)