Skip to content

feat: add config editor to TUI for creating and editing daemons#171

Merged
jdx merged 10 commits intomainfrom
feat/tui-config-editor
Jan 19, 2026
Merged

feat: add config editor to TUI for creating and editing daemons#171
jdx merged 10 commits intomainfrom
feat/tui-config-editor

Conversation

@jdx
Copy link
Owner

@jdx jdx commented Jan 19, 2026

Summary

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 (shift+e) to edit an existing daemon's configuration
  • Press D while editing to delete a daemon from config

Form fields

  • Run command (required) - Command to execute
  • Auto behavior - Start/Stop toggles for directory hooks
  • Retry count - Number of retry attempts on failure
  • Ready checks - Delay, output pattern, HTTP URL, or port
  • Boot start - Automatically start on system boot
  • Dependencies - Comma-separated daemon names
  • Cron schedule - Cron expression and retrigger behavior

Keybindings in config editor

Key Action
Tab/j/k Navigate between fields
Enter Start editing a text field
Space Toggle checkboxes or cycle through options
Ctrl+S Save the configuration
Esc/q Cancel (with confirmation if unsaved changes)
D Delete daemon (edit mode only)

Test plan

  • Run pitchfork tui and press n to create a new daemon
  • Fill in form fields and press Ctrl+S to save
  • Verify daemon appears in pitchfork.toml
  • Select a daemon and press E to edit
  • Modify fields and save, verify changes in config
  • Press D while editing to delete, confirm deletion
  • Test validation (empty required fields, invalid port)
  • Test cancel with unsaved changes confirmation

🤖 Generated with Claude Code


Note

Introduces a full CRUD config editor in the TUI, including file selection, validation, and integrated save/delete flows.

  • New views: ConfigEditor and ConfigFileSelect with keybindings (n, E, Ctrl+S, D, q/Esc); help/footer updated
  • Form-based editor (EditorState, FormField*) mapping to PitchforkTomlDaemon (supports auto, retry, ready checks, cron schedule/retrigger, etc.) with validation and UTF-8-safe text editing
  • Save writes to selected pitchfork.toml (duplicate/rename handling), delete removes daemon from config; confirmation via PendingAction
  • Event loop, actions, and UI overlays wired for open/save/delete/cancel; dashboard remains primary view while overlays reuse prev_view
  • Docs: TUI guide updated with Config Editor features and keybindings; adds file selector overlay and editor UI rendering

Written by Cursor Bugbot for commit 91e66a5. This will update automatically on new commits. Configure here.

Copilot AI review requested due to automatic review settings January 19, 2026 16:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'recieve' to 'receive'.

Copilot uses AI. Check for mistakes.
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()
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"Name: (press 'i' to edit)".to_string()
"Name: (press 'i' to edit daemon name)".to_string()

Copilot uses AI. Check for mistakes.
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();
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
editor.daemon_id_cursor = editor.daemon_id.len();
editor.daemon_id_cursor = editor.daemon_id.chars().count();

Copilot uses AI. Check for mistakes.
src/tui/app.rs Outdated
*opt = if text.is_empty() { None } else { Some(text) };
}
FormFieldValue::Number(n) => {
*n = text.parse().unwrap_or(0);
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
*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());
}
}
}

Copilot uses AI. Check for mistakes.
.daemon_id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
{
{
// 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 (_).");

Copilot uses AI. Check for mistakes.
src/tui/app.rs Outdated
valid = false;
}
("ready_http", FormFieldValue::OptionalText(Some(url)))
if !url.starts_with("http") =>
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if !url.starts_with("http") =>
if !(url.starts_with("http://") || url.starts_with("https://")) =>

Copilot uses AI. Check for mistakes.
@jdx jdx force-pushed the feat/tui-config-editor branch 2 times, most recently from 9229b1b to 2bdf7bd Compare January 19, 2026 16:29
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

jdx and others added 6 commits January 19, 2026 11:49
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>
@jdx jdx force-pushed the feat/tui-config-editor branch from cb5e4d4 to 3e94c61 Compare January 19, 2026 17:50
cursor[bot]

This comment was marked as outdated.

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>
cursor[bot]

This comment was marked as outdated.

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>
cursor[bot]

This comment was marked as outdated.

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>
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI review requested due to automatic review settings January 19, 2026 19:57
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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",

Copilot uses AI. Check for mistakes.
}

// Delete daemon (edit mode only)
KeyCode::Char('D') if matches!(editor.mode, EditMode::Edit { .. }) => {
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
KeyCode::Char('D') if matches!(editor.mode, EditMode::Edit { .. }) => {
KeyCode::Char('D') | KeyCode::Char('d') if matches!(editor.mode, EditMode::Edit { .. }) => {

Copilot uses AI. Check for mistakes.
Comment on lines +621 to +633
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;
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.

pub fn confirm_action(&mut self, action: PendingAction) {
self.pending_action = Some(action);
self.prev_view = self.view;
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
self.prev_view = self.view;
if !matches!(self.view, View::Confirm) {
self.prev_view = self.view;
}

Copilot uses AI. Check for mistakes.
@jdx jdx merged commit fd17a86 into main Jan 19, 2026
4 checks passed
@jdx jdx deleted the feat/tui-config-editor branch January 19, 2026 22:20
@jdx jdx mentioned this pull request Jan 19, 2026
jdx added a commit that referenced this pull request Jan 20, 2026
## 🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants