feat: add config editor to TUI for creating and editing daemons#171
feat: add config editor to TUI for creating and editing daemons#171
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a comprehensive configuration editor to the TUI, enabling users to create, edit, and delete daemon configurations directly from the terminal interface without manually editing TOML files.
Changes:
- New interactive form-based config editor with validation for all daemon configuration fields
- File selector for choosing which config file to create/edit daemons in
- CRUD operations: create (n), edit (E), and delete (D) with confirmation dialogs
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/tui/ui.rs | Adds rendering functions for config editor overlay and file selector overlay with form fields, validation errors, and keybinding instructions |
| src/tui/mod.rs | Integrates editor actions into the main event loop, handling save, delete, and refresh operations |
| src/tui/event.rs | Implements keyboard event handlers for editor navigation, text input, and file selection |
| src/tui/app.rs | Defines editor state structures, form field types, validation logic, and CRUD operations for daemon configs |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/tui/ui.rs
Outdated
| let id_display = if editor.daemon_id_editing { | ||
| format!("Name: {}█", editor.daemon_id) | ||
| } else if editor.daemon_id.is_empty() { | ||
| "Name: (press 'i' to edit)".to_string() |
There was a problem hiding this comment.
Corrected spelling of 'recieve' to 'receive'.
src/tui/ui.rs
Outdated
| let id_display = if editor.daemon_id_editing { | ||
| format!("Name: {}█", editor.daemon_id) | ||
| } else if editor.daemon_id.is_empty() { | ||
| "Name: (press 'i' to edit)".to_string() |
There was a problem hiding this comment.
The instruction 'press 'i' to edit' is inconsistent with the actual keybinding. According to the editor state code, when daemon_id_editing is false and the daemon_id is empty, pressing 'i' will enable editing mode. However, this instruction should clarify that 'i' is used to edit the daemon name, as the user might be confused about what they are editing.
| "Name: (press 'i' to edit)".to_string() | |
| "Name: (press 'i' to edit daemon name)".to_string() |
src/tui/event.rs
Outdated
| // Edit daemon ID (when focused at top) | ||
| KeyCode::Char('i') => { | ||
| editor.daemon_id_editing = true; | ||
| editor.daemon_id_cursor = editor.daemon_id.len(); |
There was a problem hiding this comment.
Setting cursor to the end of daemon_id here may cause issues if daemon_id contains multi-byte UTF-8 characters. The cursor position should be based on character count, not byte length. Consider using editor.daemon_id.chars().count() instead.
| editor.daemon_id_cursor = editor.daemon_id.len(); | |
| editor.daemon_id_cursor = editor.daemon_id.chars().count(); |
src/tui/app.rs
Outdated
| *opt = if text.is_empty() { None } else { Some(text) }; | ||
| } | ||
| FormFieldValue::Number(n) => { | ||
| *n = text.parse().unwrap_or(0); |
There was a problem hiding this comment.
When parsing fails and defaults to 0, there's no indication to the user that their input was invalid. Consider setting an error message on the field to inform the user why their input was rejected.
| *n = text.parse().unwrap_or(0); | |
| let trimmed = text.trim(); | |
| if trimmed.is_empty() { | |
| // Preserve previous behavior: empty input becomes 0 with no error. | |
| *n = 0; | |
| self.error = None; | |
| } else { | |
| match trimmed.parse() { | |
| Ok(value) => { | |
| *n = value; | |
| self.error = None; | |
| } | |
| Err(_) => { | |
| // Preserve previous value behavior (default to 0) but report the error. | |
| *n = 0; | |
| self.error = Some("Invalid number".to_string()); | |
| } | |
| } | |
| } |
| .daemon_id | ||
| .chars() | ||
| .all(|c| c.is_alphanumeric() || c == '-' || c == '_') | ||
| { |
There was a problem hiding this comment.
The validation logic restricts daemon IDs to alphanumeric characters, hyphens, and underscores, but this constraint is not documented in the UI. Consider adding help text or an inline error message explaining the allowed character set when validation fails.
| { | |
| { | |
| // Daemon ID must be non-empty and contain only alphanumeric characters, hyphens, and underscores. | |
| eprintln!("Invalid daemon ID: it must be non-empty and contain only letters, digits, hyphens (-), and underscores (_)."); |
src/tui/app.rs
Outdated
| valid = false; | ||
| } | ||
| ("ready_http", FormFieldValue::OptionalText(Some(url))) | ||
| if !url.starts_with("http") => |
There was a problem hiding this comment.
The HTTP URL validation only checks if the URL starts with 'http', which would accept invalid URLs like 'http' or 'httpx://example'. Consider using a more robust validation that checks for 'http://' or 'https://' protocols specifically, or use a URL parsing library.
| if !url.starts_with("http") => | |
| if !(url.starts_with("http://") || url.starts_with("https://")) => |
9229b1b to
2bdf7bd
Compare
Add full CRUD operations for daemon configuration in the TUI with a form-based editor: - Press 'n' to create a new daemon (opens file selector, then form) - Press 'E' to edit an existing daemon's configuration - Press 'D' while editing to delete a daemon from config Form fields include: - Run command (required) - Auto behavior (Start/Stop toggles) - Retry count - Ready checks (delay, output pattern, HTTP URL, port) - Boot start option - Dependencies (comma-separated) - Cron schedule and retrigger behavior Keybindings: Tab/j/k to navigate, Enter to edit text, Space to toggle, Ctrl+S to save, Esc to cancel (with unsaved changes confirmation). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Clarify "press 'i' to edit name" message in config editor - Fix UTF-8 text handling to prevent panics with multi-byte characters - Improve HTTP URL validation to require http:// or https:// prefix - Fix delete to properly report when daemon not found in config Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Fix Retry type usage (changed from u32 to newtype) - Fix UTF-8 path truncation using character count instead of bytes - Fix cancel_confirm() to return to previous view (ConfigEditor) instead of Dashboard - Fix start_editing cursor to use chars().count() instead of byte len() - Fix Number field cursor desync after backspacing to empty - Add duplicate daemon ID validation before save Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Change save_editor_config to return Result<bool> - Ok(true) for success, Ok(false) for validation/duplicate errors (keeps editor open) - Only mark unsaved_changes when toggle_current_field actually changes a value Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use confirm_action() instead of directly setting pending_action/view to properly save prev_view for cancel navigation - Fix Tab from daemon name not skipping first form field Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Support the new `watch` field for file watching auto-restart feature. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
cb5e4d4 to
3e94c61
Compare
When users enter invalid numbers in form fields, show an error message instead of silently defaulting to 0. This addresses PR #171 feedback about unclear behavior when parsing fails. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When a delete or other pending action fails, return to the previous view (e.g., ConfigEditor) instead of unconditionally going to Dashboard. This matches the behavior of cancel_confirm() which already uses prev_view. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add daemon_id_error field to EditorState that stores validation error messages for the daemon name. When validation fails (empty name or invalid characters), the error is displayed inline with the name field in red, matching how form field errors are shown. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
Move the Ctrl+S check before the is_editing() check so that the save shortcut works even when actively typing in a text field. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| View::Confirm => "y/Enter:confirm n/Esc:cancel", | ||
| View::Loading => "Please wait...", | ||
| View::Details => "q/Esc/i:close", | ||
| View::ConfigEditor => "Tab/j/k:nav Enter:edit Ctrl+S:save Esc:cancel D:delete", |
There was a problem hiding this comment.
The footer text shows 'D:delete' unconditionally, but the delete action is only available in edit mode (not create mode). Consider showing conditional help text based on editor mode to avoid confusion.
| View::ConfigEditor => "Tab/j/k:nav Enter:edit Ctrl+S:save Esc:cancel D:delete", | |
| View::ConfigEditor => "Tab/j/k:nav Enter:edit Ctrl+S:save Esc:cancel", |
| } | ||
|
|
||
| // Delete daemon (edit mode only) | ||
| KeyCode::Char('D') if matches!(editor.mode, EditMode::Edit { .. }) => { |
There was a problem hiding this comment.
Using uppercase 'D' for delete requires Shift+D, which may not be immediately obvious to users. Consider documenting this clearly or using a more discoverable key combination. The current implementation is correct but accessibility could be improved with better documentation.
| KeyCode::Char('D') if matches!(editor.mode, EditMode::Edit { .. }) => { | |
| KeyCode::Char('D') | KeyCode::Char('d') if matches!(editor.mode, EditMode::Edit { .. }) => { |
| pub fn text_push(&mut self, c: char) { | ||
| if self.daemon_id_editing { | ||
| let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor); | ||
| self.daemon_id.insert(byte_idx, c); | ||
| self.daemon_id_cursor += 1; | ||
| self.unsaved_changes = true; | ||
| } else if let Some(field) = self.fields.get_mut(self.focused_field) | ||
| && field.editing | ||
| { | ||
| let mut text = field.get_text(); | ||
| let byte_idx = char_to_byte_index(&text, field.cursor); | ||
| text.insert(byte_idx, c); | ||
| field.cursor += 1; |
There was a problem hiding this comment.
The text editing logic for daemon_id is duplicated with the field editing logic below (lines 630-636). Consider extracting this into a shared helper method to reduce duplication.
| pub fn text_push(&mut self, c: char) { | |
| if self.daemon_id_editing { | |
| let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor); | |
| self.daemon_id.insert(byte_idx, c); | |
| self.daemon_id_cursor += 1; | |
| self.unsaved_changes = true; | |
| } else if let Some(field) = self.fields.get_mut(self.focused_field) | |
| && field.editing | |
| { | |
| let mut text = field.get_text(); | |
| let byte_idx = char_to_byte_index(&text, field.cursor); | |
| text.insert(byte_idx, c); | |
| field.cursor += 1; | |
| fn insert_char_at_cursor(text: &mut String, cursor: &mut usize, c: char) { | |
| let byte_idx = char_to_byte_index(text, *cursor); | |
| text.insert(byte_idx, c); | |
| *cursor += 1; | |
| } | |
| pub fn text_push(&mut self, c: char) { | |
| if self.daemon_id_editing { | |
| Self::insert_char_at_cursor(&mut self.daemon_id, &mut self.daemon_id_cursor, c); | |
| self.unsaved_changes = true; | |
| } else if let Some(field) = self.fields.get_mut(self.focused_field) | |
| && field.editing | |
| { | |
| let mut text = field.get_text(); | |
| Self::insert_char_at_cursor(&mut text, &mut field.cursor, c); |
|
|
||
| pub fn confirm_action(&mut self, action: PendingAction) { | ||
| self.pending_action = Some(action); | ||
| self.prev_view = self.view; |
There was a problem hiding this comment.
Setting prev_view before checking if it's already the Confirm view can cause issues. If the user is already in a Confirm dialog and triggers another confirmation, prev_view will be set to Confirm, breaking the return navigation. Store prev_view only when transitioning from a non-Confirm view.
| self.prev_view = self.view; | |
| if !matches!(self.view, View::Confirm) { | |
| self.prev_view = self.view; | |
| } |
## 🤖 New release * `pitchfork-cli`: 1.1.0 -> 1.2.0 <details><summary><i><b>Changelog</b></i></summary><p> <blockquote> ## [1.2.0](v1.1.0...v1.2.0) - 2026-01-19 ### Added - enhance miette error diagnostics with source highlighting and URLs ([#183](#183)) - add structured IPC error types with miette diagnostics ([#181](#181)) - add structured config error types with file path context ([#182](#182)) - add config editor to TUI for creating and editing daemons ([#171](#171)) - add custom diagnostic error types with miette ([#180](#180)) ### Other - improve miette error handling adoption ([#177](#177)) - modularize supervisor.rs into focused submodules ([#175](#175)) </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Release 1.2.0** > > - Bump version to `1.2.0` in `Cargo.toml`, `Cargo.lock`, `docs/cli/commands.json`, `docs/cli/index.md`, and `pitchfork.usage.kdl` > - Regenerate CLI docs/usage specs to reflect the new version > - Update `CHANGELOG.md` with highlights: enhanced `miette` diagnostics (source highlighting, URLs), structured IPC/config error types, custom diagnostic errors, TUI config editor; plus adoption improvements and supervisor modularization > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 32b3259. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Summary
Add full CRUD operations for daemon configuration in the TUI with a form-based editor:
nto create a new daemon (opens file selector, then form)E(shift+e) to edit an existing daemon's configurationDwhile editing to delete a daemon from configForm fields
Keybindings in config editor
Tab/j/kEnterSpaceCtrl+SEsc/qDTest plan
pitchfork tuiand pressnto create a new daemonCtrl+Sto savepitchfork.tomlEto editDwhile editing to delete, confirm deletion🤖 Generated with Claude Code
Note
Introduces a full CRUD config editor in the TUI, including file selection, validation, and integrated save/delete flows.
ConfigEditorandConfigFileSelectwith keybindings (n,E,Ctrl+S,D,q/Esc); help/footer updatedEditorState,FormField*) mapping toPitchforkTomlDaemon(supportsauto,retry, ready checks, cron schedule/retrigger, etc.) with validation and UTF-8-safe text editingpitchfork.toml(duplicate/rename handling), delete removes daemon from config; confirmation viaPendingActionprev_viewWritten by Cursor Bugbot for commit 91e66a5. This will update automatically on new commits. Configure here.