skill_web/components/run/
terminal_output.rs1use yew::prelude::*;
12use crate::api::types::{ExecutionResponse, ExecutionStatus};
13
14#[derive(Properties, PartialEq)]
15pub struct TerminalOutputProps {
16 pub visible: bool,
18 pub execution: Option<ExecutionResponse>,
20 pub on_close: Callback<()>,
22 #[prop_or_default]
24 pub on_rerun: Option<Callback<()>>,
25 #[prop_or_default]
27 pub on_copy: Option<Callback<String>>,
28 #[prop_or(false)]
30 pub minimized: bool,
31 pub on_toggle_minimize: Callback<()>,
33}
34
35#[function_component(TerminalOutput)]
36pub fn terminal_output(props: &TerminalOutputProps) -> Html {
37 let terminal_ref = use_node_ref();
38
39 use_effect_with((props.execution.clone(), terminal_ref.clone()), |(execution, terminal_ref)| {
41 if execution.is_some() {
42 if let Some(terminal) = terminal_ref.cast::<web_sys::HtmlElement>() {
43 terminal.set_scroll_top(terminal.scroll_height());
44 }
45 }
46 || ()
47 });
48
49 let on_close_click = {
51 let on_close = props.on_close.clone();
52 Callback::from(move |e: MouseEvent| {
53 e.prevent_default();
54 on_close.emit(());
55 })
56 };
57
58 let on_minimize_click = {
60 let on_toggle = props.on_toggle_minimize.clone();
61 Callback::from(move |e: MouseEvent| {
62 e.prevent_default();
63 on_toggle.emit(());
64 })
65 };
66
67 let on_rerun_click = {
69 let on_rerun = props.on_rerun.clone();
70 Callback::from(move |e: MouseEvent| {
71 e.prevent_default();
72 if let Some(callback) = &on_rerun {
73 callback.emit(());
74 }
75 })
76 };
77
78 let on_copy_click = {
80 let on_copy = props.on_copy.clone();
81 let output = props.execution.as_ref().map(|e| e.output.clone());
82 Callback::from(move |e: MouseEvent| {
83 e.prevent_default();
84 if let Some(callback) = &on_copy {
85 if let Some(output) = &output {
86 callback.emit(output.clone());
87 }
88 }
89 })
90 };
91
92 let formatted_output = props.execution.as_ref().map(|exec| {
94 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&exec.output) {
96 serde_json::to_string_pretty(&json).unwrap_or_else(|_| exec.output.clone())
97 } else {
98 exec.output.clone()
99 }
100 });
101
102 let status_class = props.execution.as_ref().map(|exec| {
104 match exec.status {
105 ExecutionStatus::Success => "text-success-500",
106 ExecutionStatus::Failed | ExecutionStatus::Timeout => "text-error-500",
107 ExecutionStatus::Running => "text-primary-500",
108 ExecutionStatus::Pending => "text-gray-500 dark:text-gray-400",
109 ExecutionStatus::Cancelled => "text-warning-500",
110 }
111 });
112
113 if !props.visible {
114 return html! {};
115 }
116
117 html! {
118 <div class={classes!(
119 "fixed", "bottom-0", "left-0", "right-0",
120 "bg-white", "dark:bg-gray-900",
121 "border-t", "border-gray-200", "dark:border-gray-700",
122 "shadow-lg",
123 "z-50",
124 "transition-all", "duration-200",
125 props.visible.then(|| "translate-y-0").unwrap_or("translate-y-full"),
126 props.minimized.then(|| "h-14").unwrap_or("h-[60vh]")
127 )}>
128 <div class="flex items-center justify-between px-6 py-3 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
130 <div class="flex items-center gap-4">
131 if let Some(exec) = &props.execution {
133 <div class="flex items-center gap-2">
134 <span class={classes!("text-sm", "font-semibold", status_class.clone())}>
135 { format!("{:?}", exec.status) }
136 </span>
137 <span class="text-xs text-gray-500 dark:text-gray-400">
138 { format!("({}ms)", exec.duration_ms) }
139 </span>
140 </div>
141 } else {
142 <span class="text-sm text-gray-500 dark:text-gray-400">
143 { "Waiting for execution..." }
144 </span>
145 }
146 </div>
147
148 <div class="flex items-center gap-2">
150 if props.on_copy.is_some() && props.execution.is_some() {
152 <button
153 onclick={on_copy_click}
154 class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
155 title="Copy output"
156 >
157 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
158 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
159 </svg>
160 </button>
161 }
162
163 if props.on_rerun.is_some() {
165 <button
166 onclick={on_rerun_click}
167 class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
168 title="Re-run command"
169 >
170 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
171 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
172 </svg>
173 </button>
174 }
175
176 <button
178 onclick={on_minimize_click}
179 class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
180 title={if props.minimized { "Maximize" } else { "Minimize" }}
181 >
182 if props.minimized {
183 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
184 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
185 </svg>
186 } else {
187 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
188 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
189 </svg>
190 }
191 </button>
192
193 <button
195 onclick={on_close_click}
196 class="p-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-error-500 transition-colors"
197 title="Close"
198 >
199 <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
200 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
201 </svg>
202 </button>
203 </div>
204 </div>
205
206 if !props.minimized {
208 <div
209 ref={terminal_ref}
210 class="p-6 overflow-y-auto h-[calc(100%-56px)] bg-gray-50 dark:bg-gray-900"
211 >
212 if let Some(exec) = &props.execution {
213 <div class="text-gray-600 dark:text-gray-400 mb-4 font-mono text-sm">
215 <span>{ "$ " }</span>
216 <span class="text-primary-500">{ &exec.id }</span>
217 </div>
218
219 <pre class={classes!(
221 "whitespace-pre-wrap",
222 "break-words",
223 "font-mono",
224 "text-sm",
225 "text-gray-900",
226 "dark:text-gray-100",
227 status_class
228 )}>
229 { formatted_output.as_ref().unwrap_or(&exec.output) }
230 </pre>
231
232 if let Some(error) = &exec.error {
234 <div class="mt-4 p-4 bg-error-50 dark:bg-error-900/20 border-l-4 border-error-500 rounded">
235 <div class="text-error-500 font-semibold mb-2">
236 { "Error:" }
237 </div>
238 <pre class="text-error-500 whitespace-pre-wrap font-mono text-sm">
239 { error }
240 </pre>
241 </div>
242 }
243
244 if exec.status == ExecutionStatus::Success {
246 <div class="mt-4 text-success-500 font-mono text-sm">
247 { format!("✓ Success ({}ms)", exec.duration_ms) }
248 </div>
249 }
250 } else {
251 <div class="flex items-center gap-2 text-gray-600 dark:text-gray-400 font-mono text-sm">
253 <span>{ "Executing" }</span>
254 <span class="animate-pulse">{ "..." }</span>
255 </div>
256 }
257 </div>
258 }
259 </div>
260 }
261}