//! 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 {
/// List of contracts
Contracts,
/// Tasks for a specific contract
Tasks,
/// Task output streaming view
TaskOutput,
}
impl ViewType {
pub fn as_str(&self) -> &'static str {
match self {
ViewType::Contracts => "contracts",
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 {
/// Normal navigation mode
Normal,
/// Fuzzy search mode
Search,
/// Confirmation dialog (e.g., for delete)
Confirm,
/// Edit mode - editing name
EditName,
/// Edit mode - editing description/plan
EditDescription,
/// Create contract - editing name
CreateName,
/// Create contract - editing description
CreateDescription,
}
/// Create contract form field
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreateFormField {
Name,
Description,
ContractType,
Repository,
}
/// Repository suggestion from history
#[derive(Debug, Clone)]
pub struct RepositorySuggestion {
pub name: String,
pub repository_url: Option<String>,
pub local_path: Option<String>,
pub source_type: String,
pub use_count: i32,
}
/// State for create contract form
#[derive(Debug, Clone, Default)]
pub struct CreateContractState {
/// Contract name
pub name: String,
/// Contract description
pub description: String,
/// Contract type: "simple" or "specification"
pub contract_type: String,
/// Repository URL (optional)
pub repository_url: String,
/// Currently focused field
pub focused_field: usize,
/// Cursor position in current text field
pub cursor: usize,
/// Available repository suggestions
pub repo_suggestions: Vec<RepositorySuggestion>,
/// Selected suggestion index (for repository field)
pub selected_suggestion: usize,
/// Whether suggestions popup is visible
pub show_suggestions: bool,
/// Whether suggestions have been loaded
pub suggestions_loaded: bool,
}
impl CreateContractState {
pub fn new() -> Self {
Self {
name: String::new(),
description: String::new(),
contract_type: "simple".to_string(),
repository_url: String::new(),
focused_field: 0,
cursor: 0,
repo_suggestions: Vec::new(),
selected_suggestion: 0,
show_suggestions: false,
suggestions_loaded: false,
}
}
/// Set repository suggestions
pub fn set_suggestions(&mut self, suggestions: Vec<RepositorySuggestion>) {
self.repo_suggestions = suggestions;
self.selected_suggestion = 0;
self.show_suggestions = !self.repo_suggestions.is_empty();
self.suggestions_loaded = true;
}
/// Apply the selected suggestion to the form
pub fn apply_selected_suggestion(&mut self) {
if let Some(suggestion) = self.repo_suggestions.get(self.selected_suggestion) {
// Apply the suggestion
if let Some(ref url) = suggestion.repository_url {
self.repository_url = url.clone();
} else if let Some(ref path) = suggestion.local_path {
self.repository_url = path.clone();
}
self.cursor = self.repository_url.len();
self.show_suggestions = false;
}
}
/// Navigate to next suggestion
pub fn next_suggestion(&mut self) {
if !self.repo_suggestions.is_empty() {
self.selected_suggestion = (self.selected_suggestion + 1) % self.repo_suggestions.len();
}
}
/// Navigate to previous suggestion
pub fn prev_suggestion(&mut self) {
if !self.repo_suggestions.is_empty() {
self.selected_suggestion = if self.selected_suggestion == 0 {
self.repo_suggestions.len() - 1
} else {
self.selected_suggestion - 1
};
}
}
/// Get the field at the given index
pub fn field_at(&self, index: usize) -> CreateFormField {
match index {
0 => CreateFormField::Name,
1 => CreateFormField::Description,
2 => CreateFormField::ContractType,
3 => CreateFormField::Repository,
_ => CreateFormField::Name,
}
}
/// Get the current field
pub fn current_field(&self) -> CreateFormField {
self.field_at(self.focused_field)
}
/// Get mutable reference to the current text field value
pub fn current_value_mut(&mut self) -> Option<&mut String> {
match self.current_field() {
CreateFormField::Name => Some(&mut self.name),
CreateFormField::Description => Some(&mut self.description),
CreateFormField::Repository => Some(&mut self.repository_url),
CreateFormField::ContractType => None, // Not a text field
}
}
/// Get the current text field value
pub fn current_value(&self) -> Option<&str> {
match self.current_field() {
CreateFormField::Name => Some(&self.name),
CreateFormField::Description => Some(&self.description),
CreateFormField::Repository => Some(&self.repository_url),
CreateFormField::ContractType => None,
}
}
/// Move to next field
pub fn next_field(&mut self) {
self.focused_field = (self.focused_field + 1) % 4;
self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0);
// Don't hide suggestions - they stay visible
}
/// Move to previous field
pub fn prev_field(&mut self) {
self.focused_field = if self.focused_field == 0 { 3 } else { self.focused_field - 1 };
self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0);
// Don't hide suggestions - they stay visible
}
/// Toggle contract type
pub fn toggle_contract_type(&mut self) {
self.contract_type = if self.contract_type == "simple" {
"specification".to_string()
} else {
"simple".to_string()
};
}
/// Insert character at cursor
pub fn insert_char(&mut self, c: char) {
let cursor = self.cursor;
match self.current_field() {
CreateFormField::Name => {
self.name.insert(cursor, c);
self.cursor += 1;
}
CreateFormField::Description => {
self.description.insert(cursor, c);
self.cursor += 1;
}
CreateFormField::Repository => {
self.repository_url.insert(cursor, c);
self.cursor += 1;
}
CreateFormField::ContractType => {}
}
}
/// Delete character before cursor
pub fn backspace(&mut self) {
if self.cursor > 0 {
let cursor = self.cursor - 1;
match self.current_field() {
CreateFormField::Name => {
self.name.remove(cursor);
self.cursor = cursor;
}
CreateFormField::Description => {
self.description.remove(cursor);
self.cursor = cursor;
}
CreateFormField::Repository => {
self.repository_url.remove(cursor);
self.cursor = cursor;
}
CreateFormField::ContractType => {}
}
}
}
/// Check if form is valid (name is required)
pub fn is_valid(&self) -> bool {
!self.name.trim().is_empty()
}
}
/// 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
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// Do nothing
None,
/// Move selection up
Up,
/// Move selection down
Down,
/// Select current item (show details in preview)
Select,
/// 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,
/// Navigate to worktree (output path and exit)
Navigate,
/// Confirm pending action
ConfirmYes,
/// Cancel pending action
ConfirmNo,
/// Enter search mode
EnterSearch,
/// Exit search mode
ExitSearch,
/// Add character to search
SearchChar(char),
/// Backspace in search
SearchBackspace,
/// Clear search
ClearSearch,
/// Quit the application
Quit,
/// Output a path to stdout and exit (for cd integration)
OutputPath(String),
/// Launch editor with path
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,
/// Open create contract form
NewContract,
/// Add character in create form
CreateChar(char),
/// Backspace in create form
CreateBackspace,
/// Move to next field in create form
CreateNextField,
/// Move to previous field in create form
CreatePrevField,
/// Toggle value (for contract type)
CreateToggle,
/// Submit create form
CreateSubmit,
/// Cancel create form
CreateCancel,
/// Request to create contract (internal)
PerformCreateContract {
name: String,
description: String,
contract_type: String,
repository_url: Option<String>,
},
/// Request to load repository suggestions (internal)
LoadRepoSuggestions,
/// Navigate to next suggestion in create form
CreateNextSuggestion,
/// Navigate to previous suggestion in create form
CreatePrevSuggestion,
/// Apply selected suggestion in create form
CreateApplySuggestion,
}
/// A displayable item in the TUI
#[derive(Debug, Clone)]
pub struct ListItem {
pub id: Uuid,
pub name: String,
pub status: Option<String>,
pub description: Option<String>,
/// Extra data for actions (e.g., worktree path)
pub extra: Value,
}
impl ListItem {
pub fn from_task(value: &Value) -> Option<Self> {
let id = value.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())?;
let name = value.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unnamed")
.to_string();
let status = value.get("status")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = value.get("plan")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
id,
name,
status,
description,
extra: value.clone(),
})
}
pub fn from_contract(value: &Value) -> Option<Self> {
let id = value.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())?;
let name = value.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unnamed")
.to_string();
let status = value.get("phase")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let description = value.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
id,
name,
status,
description,
extra: value.clone(),
})
}
pub fn from_file(value: &Value) -> Option<Self> {
let id = value.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())?;
let name = value.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unnamed")
.to_string();
let description = value.get("template_name")
.and_then(|v| v.as_str())
.map(|s| format!("Template: {}", s));
Some(Self {
id,
name,
status: None,
description,
extra: value.clone(),
})
}
/// Get the worktree path from task extra data
pub fn get_worktree_path(&self) -> Option<String> {
// Try various field names that might contain the worktree path
self.extra.get("worktreePath")
.or_else(|| self.extra.get("worktree_path"))
.or_else(|| self.extra.get("workdir"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Build a detailed view string for display
pub fn build_detail_view(&self, view_type: ViewType) -> String {
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));
lines.push(format!("│ ID: {}", self.id));
if let Some(ref status) = self.status {
lines.push(format!("│ Status: {}", status));
}
if let Some(ref desc) = self.description {
lines.push(format!("│ Plan: {}", desc));
}
if let Some(path) = self.get_worktree_path() {
lines.push(format!("│ Worktree: {}", path));
}
// Add progress if available
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("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::TaskOutput => {
// Output view doesn't use this preview pane
lines.push(format!("╭─ Task Output ──────────────────────────"));
lines.push(format!("│ Streaming task output..."));
lines.push(format!("╰────────────────────────────────────────"));
}
}
lines.join("\n")
}
}
/// TUI Application state
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)
pub filtered_items: Vec<ListItem>,
/// Currently selected index in filtered list
pub selected_index: usize,
/// Current input mode
pub input_mode: InputMode,
/// Search query
pub search_query: String,
/// Fuzzy matcher
matcher: SkimMatcherV2,
/// Preview content (for selected item)
pub preview_content: String,
/// Whether preview is visible
pub preview_visible: bool,
/// Pending delete item (for confirmation)
pub pending_delete: Option<Uuid>,
/// Edit state for inline editing
pub edit_state: EditState,
/// Create contract form state
pub create_state: CreateContractState,
/// 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>,
/// 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,
input_mode: InputMode::Normal,
search_query: String::new(),
matcher: SkimMatcherV2::default(),
preview_content: String::new(),
preview_visible: false,
pending_delete: None,
edit_state: EditState::default(),
create_state: CreateContractState::new(),
status_message: None,
should_quit: false,
exit_action: 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;
self.update_filtered_items();
}
/// Update filtered items based on search query
pub fn update_filtered_items(&mut self) {
if self.search_query.is_empty() {
self.filtered_items = self.items.clone();
} else {
let mut scored: Vec<_> = self.items
.iter()
.filter_map(|item| {
let score = self.matcher.fuzzy_match(&item.name, &self.search_query)?;
Some((score, item.clone()))
})
.collect();
// Sort by score (highest first)
scored.sort_by(|a, b| b.0.cmp(&a.0));
self.filtered_items = scored.into_iter().map(|(_, item)| item).collect();
}
// Reset selection if out of bounds
if self.selected_index >= self.filtered_items.len() {
self.selected_index = self.filtered_items.len().saturating_sub(1);
}
}
/// Get currently selected item
pub fn selected_item(&self) -> Option<&ListItem> {
self.filtered_items.get(self.selected_index)
}
/// Handle an action and return the resulting action
pub fn handle_action(&mut self, action: Action) -> Action {
match action {
Action::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
Action::None
}
Action::Down => {
if self.selected_index < self.filtered_items.len().saturating_sub(1) {
self.selected_index += 1;
}
Action::None
}
Action::Select => {
// Build detailed view for selected item
if let Some(item) = self.selected_item() {
self.preview_content = item.build_detail_view(self.view_type);
self.preview_visible = true;
}
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();
// Push view and set state before returning
self.push_view(ViewType::Tasks);
self.contract_id = Some(contract_id);
self.contract_name = Some(contract_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();
// Push view and set state before returning
self.push_view(ViewType::TaskOutput);
self.task_id = Some(task_id);
self.task_name = Some(task_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 => {
// Enter edit mode for selected item
if let Some(item) = self.selected_item() {
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
if let Some(item) = self.selected_item() {
let id = item.id;
let name = item.name.clone();
self.pending_delete = Some(id);
self.input_mode = InputMode::Confirm;
self.status_message = Some(format!("Delete '{}'? (y/n)", name));
}
Action::None
}
Action::Navigate => {
// Get worktree path and output it
if let Some(item) = self.selected_item() {
if let Some(path) = item.get_worktree_path() {
self.should_quit = true;
self.exit_action = Some(Action::OutputPath(path.clone()));
return Action::OutputPath(path);
} else {
self.status_message = Some("No worktree path for this item".to_string());
}
}
Action::None
}
Action::ConfirmYes => {
if self.input_mode == InputMode::Confirm {
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;
}
Action::None
}
Action::ConfirmNo => {
if self.input_mode == InputMode::Confirm {
self.pending_delete = None;
self.input_mode = InputMode::Normal;
self.status_message = Some("Delete cancelled".to_string());
}
Action::None
}
Action::EnterSearch => {
self.input_mode = InputMode::Search;
Action::None
}
Action::ExitSearch => {
self.input_mode = InputMode::Normal;
Action::None
}
Action::SearchChar(c) => {
self.search_query.push(c);
self.update_filtered_items();
Action::None
}
Action::SearchBackspace => {
self.search_query.pop();
self.update_filtered_items();
Action::None
}
Action::ClearSearch => {
self.search_query.clear();
self.update_filtered_items();
Action::None
}
Action::Quit => {
self.should_quit = true;
Action::Quit
}
Action::Refresh => {
// Signal to caller to refresh data
Action::Refresh
}
Action::OutputPath(path) => {
self.should_quit = true;
self.exit_action = Some(Action::OutputPath(path.clone()));
Action::OutputPath(path)
}
Action::LaunchEditor(path) => {
self.should_quit = true;
self.exit_action = Some(Action::LaunchEditor(path.clone()));
Action::LaunchEditor(path)
}
Action::LoadTasks { contract_id, contract_name } => {
// Pass through to caller for data loading (view already pushed by DrillDown)
Action::LoadTasks { contract_id, contract_name }
}
Action::LoadTaskOutput { task_id, task_name } => {
// Pass through to caller for data loading (view already pushed by DrillDown)
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::NewContract => {
// Only allow creating contracts from contracts view
if self.view_type == ViewType::Contracts {
self.create_state = CreateContractState::new();
self.input_mode = InputMode::CreateName;
// Request to load repository suggestions
return Action::LoadRepoSuggestions;
}
Action::None
}
Action::CreateChar(c) => {
self.create_state.insert_char(c);
Action::None
}
Action::CreateBackspace => {
self.create_state.backspace();
Action::None
}
Action::CreateNextField => {
self.create_state.next_field();
Action::None
}
Action::CreatePrevField => {
self.create_state.prev_field();
Action::None
}
Action::CreateToggle => {
if self.create_state.current_field() == CreateFormField::ContractType {
self.create_state.toggle_contract_type();
}
Action::None
}
Action::CreateSubmit => {
if self.create_state.is_valid() {
let name = self.create_state.name.clone();
let description = self.create_state.description.clone();
let contract_type = self.create_state.contract_type.clone();
let repository_url = if self.create_state.repository_url.is_empty() {
None
} else {
Some(self.create_state.repository_url.clone())
};
self.input_mode = InputMode::Normal;
return Action::PerformCreateContract {
name,
description,
contract_type,
repository_url,
};
} else {
self.status_message = Some("Name is required".to_string());
}
Action::None
}
Action::CreateCancel => {
self.create_state = CreateContractState::new();
self.input_mode = InputMode::Normal;
self.status_message = Some("Create cancelled".to_string());
Action::None
}
Action::PerformCreateContract { name, description, contract_type, repository_url } => {
// Pass through to caller for API call
Action::PerformCreateContract { name, description, contract_type, repository_url }
}
Action::LoadRepoSuggestions => {
// Pass through to caller for API call
Action::LoadRepoSuggestions
}
Action::CreateNextSuggestion => {
self.create_state.next_suggestion();
Action::None
}
Action::CreatePrevSuggestion => {
self.create_state.prev_suggestion();
Action::None
}
Action::CreateApplySuggestion => {
self.create_state.apply_selected_suggestion();
Action::None
}
Action::None => Action::None,
}
}
/// Get the name of the item being deleted (for confirmation dialog)
pub fn get_pending_delete_name(&self) -> Option<String> {
self.pending_delete.and_then(|id| {
self.filtered_items.iter()
.find(|item| item.id == id)
.map(|item| item.name.clone())
})
}
}