summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui/app.rs')
-rw-r--r--makima/src/daemon/tui/app.rs536
1 files changed, 483 insertions, 53 deletions
diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs
index a2c82a2..3eff998 100644
--- a/makima/src/daemon/tui/app.rs
+++ b/makima/src/daemon/tui/app.rs
@@ -1,28 +1,55 @@
//! TUI application state and logic.
+use std::collections::VecDeque;
+
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use serde_json::Value;
use uuid::Uuid;
+/// Maximum number of output lines to keep in buffer
+const MAX_OUTPUT_LINES: usize = 10000;
+
/// Available views/resource types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewType {
- Tasks,
+ /// List of contracts
Contracts,
- Files,
+ /// Tasks for a specific contract
+ Tasks,
+ /// Task output streaming view
+ TaskOutput,
}
impl ViewType {
pub fn as_str(&self) -> &'static str {
match self {
- ViewType::Tasks => "tasks",
ViewType::Contracts => "contracts",
- ViewType::Files => "files",
+ ViewType::Tasks => "tasks",
+ ViewType::TaskOutput => "output",
}
}
}
+/// A saved view state for navigation stack
+#[derive(Debug, Clone)]
+pub struct ViewState {
+ /// The type of view
+ pub view_type: ViewType,
+ /// Contract ID (for Tasks view)
+ pub contract_id: Option<Uuid>,
+ /// Contract name (for breadcrumb display)
+ pub contract_name: Option<String>,
+ /// Task ID (for TaskOutput view)
+ pub task_id: Option<Uuid>,
+ /// Task name (for breadcrumb display)
+ pub task_name: Option<String>,
+ /// Selected index at time of navigation
+ pub selected_index: usize,
+ /// Scroll offset at time of navigation
+ pub scroll_offset: usize,
+}
+
/// Input mode for the TUI
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
@@ -32,6 +59,142 @@ pub enum InputMode {
Search,
/// Confirmation dialog (e.g., for delete)
Confirm,
+ /// Edit mode - editing name
+ EditName,
+ /// Edit mode - editing description/plan
+ EditDescription,
+}
+
+/// Edit state for inline editing
+#[derive(Debug, Clone, Default)]
+pub struct EditState {
+ /// ID of the item being edited
+ pub item_id: Option<Uuid>,
+ /// Original name
+ pub original_name: String,
+ /// Original description
+ pub original_description: String,
+ /// Current name value
+ pub name: String,
+ /// Current description value
+ pub description: String,
+ /// Cursor position in current field
+ pub cursor: usize,
+}
+
+/// Output line type for rendering
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum OutputMessageType {
+ /// Assistant text response
+ Assistant,
+ /// Tool being called
+ ToolUse,
+ /// Result from tool
+ ToolResult,
+ /// Final result/summary
+ Result,
+ /// System message
+ System,
+ /// Error message
+ Error,
+ /// Raw/unformatted output
+ Raw,
+}
+
+impl OutputMessageType {
+ pub fn from_str(s: &str) -> Self {
+ match s.to_lowercase().as_str() {
+ "assistant" => Self::Assistant,
+ "tool_use" => Self::ToolUse,
+ "tool_result" => Self::ToolResult,
+ "result" => Self::Result,
+ "system" => Self::System,
+ "error" => Self::Error,
+ _ => Self::Raw,
+ }
+ }
+}
+
+/// A single line of task output
+#[derive(Debug, Clone)]
+pub struct OutputLine {
+ /// The type of message
+ pub message_type: OutputMessageType,
+ /// The content text
+ pub content: String,
+ /// Tool name (for tool_use messages)
+ pub tool_name: Option<String>,
+ /// Whether this is an error (for tool_result)
+ pub is_error: bool,
+ /// Cost in USD (for result messages)
+ pub cost_usd: Option<f64>,
+ /// Duration in ms (for result messages)
+ pub duration_ms: Option<u64>,
+}
+
+/// Output buffer for task output view
+#[derive(Debug, Clone, Default)]
+pub struct OutputBuffer {
+ /// Lines of output
+ pub lines: VecDeque<OutputLine>,
+ /// Current scroll offset (0 = bottom, auto-scroll)
+ pub scroll_offset: usize,
+ /// Auto-scroll enabled
+ pub auto_scroll: bool,
+}
+
+impl OutputBuffer {
+ pub fn new() -> Self {
+ Self {
+ lines: VecDeque::new(),
+ scroll_offset: 0,
+ auto_scroll: true,
+ }
+ }
+
+ pub fn add_line(&mut self, line: OutputLine) {
+ self.lines.push_back(line);
+ // Trim to max size
+ while self.lines.len() > MAX_OUTPUT_LINES {
+ self.lines.pop_front();
+ }
+ // Auto-scroll to bottom
+ if self.auto_scroll {
+ self.scroll_offset = 0;
+ }
+ }
+
+ pub fn clear(&mut self) {
+ self.lines.clear();
+ self.scroll_offset = 0;
+ self.auto_scroll = true;
+ }
+
+ pub fn scroll_up(&mut self, amount: usize) {
+ self.scroll_offset = self.scroll_offset.saturating_add(amount);
+ self.auto_scroll = false;
+ }
+
+ pub fn scroll_down(&mut self, amount: usize) {
+ self.scroll_offset = self.scroll_offset.saturating_sub(amount);
+ if self.scroll_offset == 0 {
+ self.auto_scroll = true;
+ }
+ }
+
+ pub fn scroll_to_bottom(&mut self) {
+ self.scroll_offset = 0;
+ self.auto_scroll = true;
+ }
+}
+
+/// WebSocket connection state
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WsConnectionState {
+ Disconnected,
+ Connecting,
+ Connected,
+ Reconnecting,
}
/// Actions that can be performed
@@ -43,9 +206,13 @@ pub enum Action {
Up,
/// Move selection down
Down,
- /// Select current item (show details)
+ /// Select current item (show details in preview)
Select,
- /// Edit the selected item (open in editor)
+ /// Drill down into selected item (contracts -> tasks, tasks -> output)
+ DrillDown,
+ /// Go back to previous view
+ GoBack,
+ /// Edit the selected item (inline editing)
Edit,
/// Delete the selected item
Delete,
@@ -73,6 +240,30 @@ pub enum Action {
LaunchEditor(String),
/// Refresh data
Refresh,
+ /// Request to load tasks for a contract (internal)
+ LoadTasks { contract_id: Uuid, contract_name: String },
+ /// Request to load task output (internal)
+ LoadTaskOutput { task_id: Uuid, task_name: String },
+ /// Request to delete an item (internal)
+ PerformDelete { id: Uuid, item_type: ViewType },
+ /// Add character in edit mode
+ EditChar(char),
+ /// Backspace in edit mode
+ EditBackspace,
+ /// Switch to next edit field (Tab)
+ EditNextField,
+ /// Save edit changes
+ EditSave,
+ /// Cancel edit
+ EditCancel,
+ /// Request to perform update (internal)
+ PerformUpdate { id: Uuid, item_type: ViewType, name: String, description: String },
+ /// Scroll output up
+ ScrollUp,
+ /// Scroll output down
+ ScrollDown,
+ /// Scroll to bottom of output
+ ScrollToBottom,
}
/// A displayable item in the TUI
@@ -179,6 +370,27 @@ impl ListItem {
let mut lines = Vec::new();
match view_type {
+ ViewType::Contracts => {
+ lines.push(format!("╭─ Contract Details ─────────────────────"));
+ lines.push(format!("│ Name: {}", self.name));
+ lines.push(format!("│ ID: {}", self.id));
+ if let Some(ref status) = self.status {
+ lines.push(format!("│ Phase: {}", status));
+ }
+ if let Some(ref desc) = self.description {
+ lines.push(format!("│ Description: {}", desc));
+ }
+ // Show task count if available
+ if let Some(count) = self.extra.get("taskCount").and_then(|v| v.as_i64()) {
+ lines.push(format!("│ Tasks: {}", count));
+ }
+ if let Some(count) = self.extra.get("fileCount").and_then(|v| v.as_i64()) {
+ lines.push(format!("│ Files: {}", count));
+ }
+ lines.push(format!("│"));
+ lines.push(format!("│ Press Enter to view tasks"));
+ lines.push(format!("╰────────────────────────────────────────"));
+ }
ViewType::Tasks => {
lines.push(format!("╭─ Task Details ─────────────────────────"));
lines.push(format!("│ Name: {}", self.name));
@@ -193,44 +405,20 @@ impl ListItem {
lines.push(format!("│ Worktree: {}", path));
}
// Add progress if available
- if let Some(progress) = self.extra.get("progress").and_then(|v| v.as_str()) {
+ if let Some(progress) = self.extra.get("progressSummary").and_then(|v| v.as_str()) {
lines.push(format!("│ Progress: {}", progress));
}
- if let Some(error) = self.extra.get("error").and_then(|v| v.as_str()) {
+ if let Some(error) = self.extra.get("errorMessage").and_then(|v| v.as_str()) {
lines.push(format!("│ Error: {}", error));
}
+ lines.push(format!("│"));
+ lines.push(format!("│ Press Enter to view output"));
lines.push(format!("╰────────────────────────────────────────"));
}
- ViewType::Contracts => {
- lines.push(format!("╭─ Contract Details ─────────────────────"));
- lines.push(format!("│ Name: {}", self.name));
- lines.push(format!("│ ID: {}", self.id));
- if let Some(ref status) = self.status {
- lines.push(format!("│ Phase: {}", status));
- }
- if let Some(ref desc) = self.description {
- lines.push(format!("│ Description: {}", desc));
- }
- lines.push(format!("╰────────────────────────────────────────"));
- }
- ViewType::Files => {
- lines.push(format!("╭─ File Details ─────────────────────────"));
- lines.push(format!("│ Name: {}", self.name));
- lines.push(format!("│ ID: {}", self.id));
- if let Some(ref desc) = self.description {
- lines.push(format!("│ {}", desc));
- }
- // Add content preview if available
- if let Some(content) = self.extra.get("content").and_then(|v| v.as_str()) {
- lines.push(format!("│"));
- lines.push(format!("│ Content:"));
- for line in content.lines().take(10) {
- lines.push(format!("│ {}", line));
- }
- if content.lines().count() > 10 {
- lines.push(format!("│ ... ({} more lines)", content.lines().count() - 10));
- }
- }
+ ViewType::TaskOutput => {
+ // Output view doesn't use this preview pane
+ lines.push(format!("╭─ Task Output ──────────────────────────"));
+ lines.push(format!("│ Streaming task output..."));
lines.push(format!("╰────────────────────────────────────────"));
}
}
@@ -243,6 +431,16 @@ impl ListItem {
pub struct App {
/// Current view type
pub view_type: ViewType,
+ /// Navigation stack for drill-down views
+ pub view_stack: Vec<ViewState>,
+ /// Current contract ID (when viewing tasks)
+ pub contract_id: Option<Uuid>,
+ /// Current contract name (for breadcrumb)
+ pub contract_name: Option<String>,
+ /// Current task ID (when viewing output)
+ pub task_id: Option<Uuid>,
+ /// Current task name (for breadcrumb)
+ pub task_name: Option<String>,
/// All items (unfiltered)
pub items: Vec<ListItem>,
/// Filtered items (based on search)
@@ -261,20 +459,29 @@ pub struct App {
pub preview_visible: bool,
/// Pending delete item (for confirmation)
pub pending_delete: Option<Uuid>,
+ /// Edit state for inline editing
+ pub edit_state: EditState,
/// Status message
pub status_message: Option<String>,
/// Whether the app should quit
pub should_quit: bool,
/// Action to return when exiting (for OutputPath, LaunchEditor)
pub exit_action: Option<Action>,
- /// Contract ID (for API calls)
- pub contract_id: Option<Uuid>,
+ /// Output buffer for task output view
+ pub output_buffer: OutputBuffer,
+ /// WebSocket connection state
+ pub ws_state: WsConnectionState,
}
impl App {
pub fn new(view_type: ViewType) -> Self {
Self {
view_type,
+ view_stack: Vec::new(),
+ contract_id: None,
+ contract_name: None,
+ task_id: None,
+ task_name: None,
items: Vec::new(),
filtered_items: Vec::new(),
selected_index: 0,
@@ -284,13 +491,85 @@ impl App {
preview_content: String::new(),
preview_visible: false,
pending_delete: None,
+ edit_state: EditState::default(),
status_message: None,
should_quit: false,
exit_action: None,
- contract_id: None,
+ output_buffer: OutputBuffer::new(),
+ ws_state: WsConnectionState::Disconnected,
}
}
+ /// Push current state to navigation stack and prepare for new view
+ pub fn push_view(&mut self, new_view: ViewType) {
+ // Save current state
+ let state = ViewState {
+ view_type: self.view_type,
+ contract_id: self.contract_id,
+ contract_name: self.contract_name.clone(),
+ task_id: self.task_id,
+ task_name: self.task_name.clone(),
+ selected_index: self.selected_index,
+ scroll_offset: 0, // TODO: track scroll offset if needed
+ };
+ self.view_stack.push(state);
+
+ // Switch to new view
+ self.view_type = new_view;
+ self.items.clear();
+ self.filtered_items.clear();
+ self.selected_index = 0;
+ self.search_query.clear();
+ self.preview_content.clear();
+ self.preview_visible = false;
+ }
+
+ /// Pop from navigation stack and restore previous view state
+ pub fn pop_view(&mut self) -> bool {
+ if let Some(state) = self.view_stack.pop() {
+ self.view_type = state.view_type;
+ self.contract_id = state.contract_id;
+ self.contract_name = state.contract_name;
+ self.task_id = state.task_id;
+ self.task_name = state.task_name;
+ self.selected_index = state.selected_index;
+ self.items.clear();
+ self.filtered_items.clear();
+ self.search_query.clear();
+ self.preview_content.clear();
+ self.preview_visible = false;
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Check if we can go back
+ pub fn can_go_back(&self) -> bool {
+ !self.view_stack.is_empty()
+ }
+
+ /// Get breadcrumb path for current view
+ pub fn get_breadcrumb(&self) -> String {
+ let mut parts = vec!["Contracts".to_string()];
+
+ if self.view_type == ViewType::Tasks || self.view_type == ViewType::TaskOutput {
+ if let Some(ref name) = self.contract_name {
+ parts.push(name.clone());
+ }
+ parts.push("Tasks".to_string());
+ }
+
+ if self.view_type == ViewType::TaskOutput {
+ if let Some(ref name) = self.task_name {
+ parts.push(name.clone());
+ }
+ parts.push("Output".to_string());
+ }
+
+ parts.join(" > ")
+ }
+
/// Set items and update filtered list
pub fn set_items(&mut self, items: Vec<ListItem>) {
self.items = items;
@@ -349,22 +628,130 @@ impl App {
}
Action::None
}
+ Action::DrillDown => {
+ // Drill down into selected item
+ match self.view_type {
+ ViewType::Contracts => {
+ // From contracts, drill into tasks
+ if let Some(item) = self.selected_item() {
+ let contract_id = item.id;
+ let contract_name = item.name.clone();
+ return Action::LoadTasks { contract_id, contract_name };
+ }
+ }
+ ViewType::Tasks => {
+ // From tasks, drill into task output
+ if let Some(item) = self.selected_item() {
+ let task_id = item.id;
+ let task_name = item.name.clone();
+ return Action::LoadTaskOutput { task_id, task_name };
+ }
+ }
+ ViewType::TaskOutput => {
+ // No further drill-down from output view
+ }
+ }
+ Action::None
+ }
+ Action::GoBack => {
+ if self.can_go_back() {
+ self.pop_view();
+ // Signal to caller to refresh data for the restored view
+ Action::Refresh
+ } else {
+ // At root level, quit
+ self.should_quit = true;
+ Action::Quit
+ }
+ }
Action::Edit => {
- // Get worktree path and signal to launch editor
+ // Enter edit mode for selected item
if let Some(item) = self.selected_item() {
- if let Some(path) = item.get_worktree_path() {
- self.should_quit = true;
- self.exit_action = Some(Action::LaunchEditor(path.clone()));
- return Action::LaunchEditor(path);
- } else {
- self.status_message = Some("No worktree path for this item".to_string());
+ let name = item.name.clone();
+ let description = item.description.clone().unwrap_or_default();
+ self.edit_state = EditState {
+ item_id: Some(item.id),
+ original_name: name.clone(),
+ original_description: description.clone(),
+ name,
+ description,
+ cursor: 0,
+ };
+ self.edit_state.cursor = self.edit_state.name.len();
+ self.input_mode = InputMode::EditName;
+ }
+ Action::None
+ }
+ Action::EditChar(c) => {
+ match self.input_mode {
+ InputMode::EditName => {
+ self.edit_state.name.insert(self.edit_state.cursor, c);
+ self.edit_state.cursor += 1;
}
+ InputMode::EditDescription => {
+ self.edit_state.description.insert(self.edit_state.cursor, c);
+ self.edit_state.cursor += 1;
+ }
+ _ => {}
}
Action::None
}
+ Action::EditBackspace => {
+ match self.input_mode {
+ InputMode::EditName => {
+ if self.edit_state.cursor > 0 {
+ self.edit_state.cursor -= 1;
+ self.edit_state.name.remove(self.edit_state.cursor);
+ }
+ }
+ InputMode::EditDescription => {
+ if self.edit_state.cursor > 0 {
+ self.edit_state.cursor -= 1;
+ self.edit_state.description.remove(self.edit_state.cursor);
+ }
+ }
+ _ => {}
+ }
+ Action::None
+ }
+ Action::EditNextField => {
+ match self.input_mode {
+ InputMode::EditName => {
+ self.input_mode = InputMode::EditDescription;
+ self.edit_state.cursor = self.edit_state.description.len();
+ }
+ InputMode::EditDescription => {
+ self.input_mode = InputMode::EditName;
+ self.edit_state.cursor = self.edit_state.name.len();
+ }
+ _ => {}
+ }
+ Action::None
+ }
+ Action::EditSave => {
+ if let Some(id) = self.edit_state.item_id {
+ let name = self.edit_state.name.clone();
+ let description = self.edit_state.description.clone();
+ self.input_mode = InputMode::Normal;
+ // Return action to perform the update
+ return Action::PerformUpdate {
+ id,
+ item_type: self.view_type,
+ name,
+ description,
+ };
+ }
+ self.input_mode = InputMode::Normal;
+ Action::None
+ }
+ Action::EditCancel => {
+ self.edit_state = EditState::default();
+ self.input_mode = InputMode::Normal;
+ self.status_message = Some("Edit cancelled".to_string());
+ Action::None
+ }
Action::Delete => {
// First press: enter confirm mode
- // Clone the values we need to avoid borrow issues
if let Some(item) = self.selected_item() {
let id = item.id;
let name = item.name.clone();
@@ -389,10 +776,13 @@ impl App {
}
Action::ConfirmYes => {
if self.input_mode == InputMode::Confirm {
- if let Some(_delete_id) = self.pending_delete.take() {
- // TODO: Make API call to delete the item
- // For now, just show status
- self.status_message = Some("Delete confirmed (API call not implemented)".to_string());
+ if let Some(delete_id) = self.pending_delete.take() {
+ self.input_mode = InputMode::Normal;
+ // Return action to perform the delete
+ return Action::PerformDelete {
+ id: delete_id,
+ item_type: self.view_type,
+ };
}
self.input_mode = InputMode::Normal;
}
@@ -447,6 +837,46 @@ impl App {
self.exit_action = Some(Action::LaunchEditor(path.clone()));
Action::LaunchEditor(path)
}
+ Action::LoadTasks { contract_id, contract_name } => {
+ // Prepare for tasks view
+ self.push_view(ViewType::Tasks);
+ self.contract_id = Some(contract_id);
+ self.contract_name = Some(contract_name.clone());
+ Action::LoadTasks { contract_id, contract_name }
+ }
+ Action::LoadTaskOutput { task_id, task_name } => {
+ // Prepare for output view
+ self.push_view(ViewType::TaskOutput);
+ self.task_id = Some(task_id);
+ self.task_name = Some(task_name.clone());
+ Action::LoadTaskOutput { task_id, task_name }
+ }
+ Action::PerformDelete { id, item_type } => {
+ // Pass through to caller for API call
+ Action::PerformDelete { id, item_type }
+ }
+ Action::PerformUpdate { id, item_type, name, description } => {
+ // Pass through to caller for API call
+ Action::PerformUpdate { id, item_type, name, description }
+ }
+ Action::ScrollUp => {
+ if self.view_type == ViewType::TaskOutput {
+ self.output_buffer.scroll_up(5);
+ }
+ Action::None
+ }
+ Action::ScrollDown => {
+ if self.view_type == ViewType::TaskOutput {
+ self.output_buffer.scroll_down(5);
+ }
+ Action::None
+ }
+ Action::ScrollToBottom => {
+ if self.view_type == ViewType::TaskOutput {
+ self.output_buffer.scroll_to_bottom();
+ }
+ Action::None
+ }
Action::None => Action::None,
}
}