skill_web/components/run/
terminal_output.rs

1//! Terminal Output Panel - Slide-up terminal display for execution results
2//!
3//! Features:
4//! - Slides up from bottom (60vh height)
5//! - Dark terminal background with monospace font
6//! - Syntax highlighting for JSON/YAML output
7//! - Copy button, Re-run button, Close button
8//! - Minimize to thin bar at bottom
9//! - Auto-scroll to bottom for streaming output
10
11use yew::prelude::*;
12use crate::api::types::{ExecutionResponse, ExecutionStatus};
13
14#[derive(Properties, PartialEq)]
15pub struct TerminalOutputProps {
16    /// Whether the terminal is visible
17    pub visible: bool,
18    /// Execution result to display
19    pub execution: Option<ExecutionResponse>,
20    /// Callback to close the terminal
21    pub on_close: Callback<()>,
22    /// Callback to re-run the command
23    #[prop_or_default]
24    pub on_rerun: Option<Callback<()>>,
25    /// Callback to copy output
26    #[prop_or_default]
27    pub on_copy: Option<Callback<String>>,
28    /// Whether the terminal is minimized
29    #[prop_or(false)]
30    pub minimized: bool,
31    /// Callback to toggle minimize
32    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    // Auto-scroll to bottom when content changes
40    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    // Handle close
50    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    // Handle minimize toggle
59    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    // Handle re-run
68    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    // Handle copy
79    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    // Format output with syntax highlighting (simple for now)
93    let formatted_output = props.execution.as_ref().map(|exec| {
94        // Try to parse as JSON for pretty printing
95        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    // Get status color class
103    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            // Header bar
129            <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                    // Status indicator
132                    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                // Action buttons
149                <div class="flex items-center gap-2">
150                    // Copy button
151                    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                    // Re-run button
164                    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                    // Minimize/Maximize button
177                    <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                    // Close button
194                    <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            // Terminal content (hidden when minimized)
207            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                        // Command executed indicator
214                        <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                        // Output
220                        <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                        // Error message (if any)
233                        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                        // Success indicator
245                        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                        // Waiting state
252                        <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}