diff options
| author | soryu <soryu@soryu.co> | 2026-01-19 13:47:32 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-19 13:47:32 +0000 |
| commit | 0833fb1f30c0c3b920157deb882e0e902c3af02a (patch) | |
| tree | 45110fb8cb9277dfbaccfeb53ed9c1f76975022b | |
| parent | 786510379bed060db2b3742b7dfca671552d2c34 (diff) | |
| download | soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.tar.gz soryu-0833fb1f30c0c3b920157deb882e0e902c3af02a.zip | |
Add interactive TUI browser for tasks, contracts, and files (makima view) (#7)
* feat(tui): Implement fuzzy search with real-time filtering and highlighting
Adds comprehensive fuzzy search functionality to the TUI browser:
## Fuzzy Matching (fuzzy.rs)
- FuzzyMatcher wrapper using SkimMatcherV2 from fuzzy-matcher crate
- fuzzy_match() returns score and matched character indices
- fuzzy_match_all() supports multi-term search (space-separated)
- Recency-aware scoring to boost recent items in results
- Unit tests for all matching scenarios
## App State (app.rs)
- FilteredItem struct with index, score, and matched_indices
- apply_filter() uses fuzzy matching with score-based sorting
- match_count() and has_no_matches() helper methods
- Results sorted by match score (highest first)
## List View (list_view.rs)
- Highlighted matched characters in search results
- Yellow bold styling for matched chars
- Status icons with color coding
## Search Input (search_input.rs)
- Real-time match count display (X/Y matches)
- Visual feedback for no matches (red border)
- Placeholder text when search is empty
- Active search mode indication (yellow border)
## Event Handling (event.rs)
- Arrow key navigation while in search mode
- Ctrl+K/J for vim-style navigation during search
- Delete key support alongside backspace
- Ctrl+U to clear search query
- Tab toggles preview while searching
- Escape clears search and exits search mode
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:20:34 UTC
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:31:19 UTC
* Task completion checkpoint
* [WIP] Heartbeat checkpoint - 2026-01-19 11:39:07 UTC
* fix(tui): Fix module exports and main binary integration
- Update mod.rs to properly export app, event, fuzzy, and ui modules
- Add run() function for TUI entry point
- Fix run_view() to use ViewCommand enum instead of ViewArgs
- Fix event handling to use poll_event and handle_key_event
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | Cargo.lock | 256 | ||||
| -rw-r--r-- | makima/Cargo.toml | 7 | ||||
| -rw-r--r-- | makima/docs/view-command.md | 223 | ||||
| -rw-r--r-- | makima/src/bin/makima.rs | 67 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 13 | ||||
| -rw-r--r-- | makima/src/daemon/cli/view.rs | 128 | ||||
| -rw-r--r-- | makima/src/daemon/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 462 | ||||
| -rw-r--r-- | makima/src/daemon/tui/event.rs | 118 | ||||
| -rw-r--r-- | makima/src/daemon/tui/fuzzy.rs | 217 | ||||
| -rw-r--r-- | makima/src/daemon/tui/mod.rs | 96 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 209 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/contracts.rs | 24 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/files.rs | 90 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/mod.rs | 3 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/tasks.rs | 71 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/list_view.rs | 127 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/preview_pane.rs | 21 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/search_input.rs | 82 | ||||
| -rw-r--r-- | makima/src/daemon/tui/widgets/status_bar.rs | 19 |
21 files changed, 2227 insertions, 14 deletions
@@ -298,6 +298,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] name = "castaway" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -399,6 +405,20 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "compact_str" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" @@ -449,7 +469,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -572,6 +592,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -593,8 +638,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -612,12 +667,36 @@ dependencies = [ ] [[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -697,7 +776,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -1098,6 +1177,15 @@ dependencies = [ ] [[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1588,11 +1676,33 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.0", "web-time", ] [[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "instant" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1652,6 +1762,15 @@ dependencies = [ [[package]] name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" @@ -1815,6 +1934,12 @@ dependencies = [ [[package]] name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" @@ -1841,6 +1966,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] name = "macro_rules_attribute" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1870,9 +2004,11 @@ dependencies = [ "chrono", "clap", "config", + "crossterm", "dashmap", "dirs 5.0.1", "futures", + "fuzzy-matcher", "hex", "hf-hub", "hostname", @@ -1889,6 +2025,7 @@ dependencies = [ "parakeet-rs", "portable-pty", "rand 0.8.5", + "ratatui", "regex", "reqwest", "rusqlite", @@ -1999,6 +2136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2641,6 +2779,27 @@ dependencies = [ ] [[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str 0.8.1", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] name = "rawpointer" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2920,6 +3079,19 @@ dependencies = [ [[package]] name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" @@ -2927,7 +3099,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -3202,6 +3374,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] name = "signal-hook-registry" version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3543,6 +3736,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] name = "stt-client" version = "0.1.0" dependencies = [ @@ -3793,7 +4008,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -3960,7 +4175,7 @@ checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" dependencies = [ "ahash", "aho-corasick", - "compact_str", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", @@ -4360,10 +4575,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode_categories" @@ -5073,7 +5305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.2", ] [[package]] diff --git a/makima/Cargo.toml b/makima/Cargo.toml index a77d9ea..94e268c 100644 --- a/makima/Cargo.toml +++ b/makima/Cargo.toml @@ -41,6 +41,9 @@ dirs = "5.0" portable-pty = "0.8" async-trait = "0.1" +# TUI and fuzzy search +fuzzy-matcher = "0.3" + # OpenAPI utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } utoipa-swagger-ui = { version = "9", features = ["axum"] } @@ -84,3 +87,7 @@ jaq-core = "1.5" jaq-std = "1.6" indexmap = "2.0" ahash = "0.8" + +# TUI +ratatui = "0.29" +crossterm = "0.28" diff --git a/makima/docs/view-command.md b/makima/docs/view-command.md new file mode 100644 index 0000000..6ef790c --- /dev/null +++ b/makima/docs/view-command.md @@ -0,0 +1,223 @@ +# Makima View Command + +The `makima view` command provides an interactive Terminal User Interface (TUI) for browsing and managing tasks, contracts, and files in the makima system. + +## Overview + +The view command offers a fuzzy-searchable interface inspired by tools like [fzf](https://github.com/junegunn/fzf) and [try](https://github.com/tobi/try). It allows you to quickly navigate through your makima entities using keyboard shortcuts and real-time filtering. + +## Usage + +```bash +# Browse tasks interactively +makima view tasks + +# Browse contracts with an initial search query +makima view contracts "my project" + +# Browse files without preview pane +makima view files --no-preview + +# Browse tasks for a specific contract +makima view tasks --contract-id <uuid> + +# Change directory to selected task's worktree (shell integration) +cd $(makima view tasks) +``` + +## Subcommands + +| Subcommand | Description | +|------------|-------------| +| `tasks` | Browse tasks with status indicators and progress summaries | +| `contracts` | Browse contracts with phase and status information | +| `files` | Browse contract files with content preview | + +## Options + +| Option | Environment Variable | Description | +|--------|---------------------|-------------| +| `--api-url` | `MAKIMA_API_URL` | API URL for the makima server (default: `https://api.makima.jp`) | +| `--api-key` | `MAKIMA_API_KEY` | API key for authentication | +| `--contract-id` | `MAKIMA_CONTRACT_ID` | Filter results to a specific contract | +| `--no-preview` | - | Disable the preview pane | +| `--sort` | - | Sort order: `recent` (default), `name`, or `status` | + +## Keyboard Shortcuts + +### Navigation + +| Key | Action | +|-----|--------| +| `↑` / `k` | Move selection up | +| `↓` / `j` | Move selection down | +| `Page Up` | Move up one page | +| `Page Down` | Move down one page | +| `Home` | Go to first item | +| `End` | Go to last item | + +### Actions + +| Key | Action | +|-----|--------| +| `Enter` | View/select the highlighted item | +| `e` | Open in editor (uses `$EDITOR` environment variable) | +| `d` | Delete item (with confirmation prompt) | +| `c` | Navigate to task's worktree (outputs path for `cd`) | +| `Tab` | Toggle preview pane visibility | +| `Ctrl+r` | Refresh data from server | + +### Search + +| Key | Action | +|-----|--------| +| `/` | Focus search input | +| `Esc` | Clear search / cancel | +| (typing) | Filter items in real-time | + +### General + +| Key | Action | +|-----|--------| +| `q` | Quit the TUI | +| `?` | Show help overlay | +| `1` / `2` / `3` | Switch view mode (Tasks/Contracts/Files) | + +## Features + +### Fuzzy Search + +The search uses the SkimMatcherV2 algorithm, providing: + +- **Smart case matching**: Case-insensitive by default, case-sensitive if pattern contains uppercase +- **Word boundary bonuses**: Matches at word boundaries score higher +- **Consecutive character bonuses**: Consecutive matches score higher +- **Multi-term search**: Space-separated terms all must match (e.g., "fix bug" matches items containing both "fix" and "bug") + +### Recency Sorting + +When using the default `recent` sort order, items are sorted by last update time. The fuzzy search also applies a recency bonus, so recently modified items rank higher even with slightly lower match scores. + +### Preview Pane + +The preview pane (toggled with `Tab`) shows detailed information about the selected item: + +- **Tasks**: Name, status, plan, progress summary, worktree path +- **Contracts**: Name, phase, status, task counts, repository info +- **Files**: Name, type, content preview (markdown rendered) + +### Status Indicators + +Tasks display visual status indicators: + +| Icon | Status | +|------|--------| +| `▸` | Running | +| `✓` | Done | +| `✗` | Failed | +| `○` | Pending | +| `⏸` | Paused | +| `⊘` | Blocked | + +## Shell Integration + +### Change to Task Worktree + +The view command can output the selected item's path for shell integration: + +```bash +# Change to selected task's worktree +cd $(makima view tasks) + +# Or create a shell function +function mcd() { + local path=$(makima view tasks "$@") + if [ -n "$path" ]; then + cd "$path" + fi +} +``` + +### Open in Editor + +The `e` key opens the selected item in your configured editor: + +```bash +# Set your preferred editor +export EDITOR=code # VS Code +export EDITOR=vim # Vim +export EDITOR=nano # Nano +``` + +## Examples + +### Browse Running Tasks + +```bash +makima view tasks "running" +``` + +### Find a Specific Contract + +```bash +makima view contracts "authentication" +``` + +### Quick Task Navigation + +```bash +# Jump to a task directory with fuzzy search +cd $(makima view tasks "api endpoint") +``` + +## Architecture + +The view command is built using: + +- **[ratatui](https://github.com/ratatui/ratatui)**: TUI rendering framework +- **[crossterm](https://github.com/crossterm-rs/crossterm)**: Cross-platform terminal manipulation +- **[fuzzy-matcher](https://github.com/lotabout/fuzzy-matcher)**: SkimMatcherV2 fuzzy matching algorithm + +### Module Structure + +``` +makima/src/daemon/ +├── cli/ +│ └── view.rs # CLI argument definitions +└── tui/ + ├── mod.rs # Module exports + ├── fuzzy.rs # Fuzzy matching wrapper + ├── app.rs # Application state (TODO) + ├── event.rs # Event handling (TODO) + ├── ui.rs # UI rendering (TODO) + ├── widgets/ # Reusable components (TODO) + └── views/ # View-specific logic (TODO) +``` + +## Troubleshooting + +### Terminal Compatibility + +The TUI requires a terminal that supports: +- ANSI escape codes +- Alternate screen buffer +- Mouse events (optional) + +Most modern terminals (iTerm2, Terminal.app, Windows Terminal, Alacritty, kitty) are supported. + +### Small Terminal Windows + +If your terminal is too small, use `--no-preview` to disable the preview pane and maximize the list area. + +### API Connection Issues + +If you see connection errors, verify: +1. `MAKIMA_API_URL` is set correctly +2. `MAKIMA_API_KEY` is valid +3. Network connectivity to the server + +## See Also + +- [makima supervisor](./supervisor-commands.md) - Task orchestration commands +- [makima contract](./contract-commands.md) - Contract interaction commands +- [makima daemon](./daemon.md) - Daemon configuration diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 6ed1761..8fc8b60 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use makima::daemon::api::ApiClient; use makima::daemon::cli::{ - Cli, Commands, ContractCommand, SupervisorCommand, + Cli, Commands, ContractCommand, SupervisorCommand, ViewCommand, ViewArgs, }; +use makima::daemon::tui::{self, App, ListItem, ViewType}; use makima::daemon::config::{DaemonConfig, RepoEntry}; use makima::daemon::db::LocalDb; use makima::daemon::error::DaemonError; @@ -26,6 +27,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Daemon(args) => run_daemon(args).await, Commands::Supervisor(cmd) => run_supervisor(cmd).await, Commands::Contract(cmd) => run_contract(cmd).await, + Commands::View(args) => run_view(args).await, } } @@ -530,6 +532,69 @@ async fn run_contract( Ok(()) } +/// Run the TUI view command. +async fn run_view(cmd: ViewCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + // Extract view type and args from command + let (view_type, args) = match cmd { + ViewCommand::Tasks(args) => (ViewType::Tasks, args), + ViewCommand::Contracts(args) => (ViewType::Contracts, args), + ViewCommand::Files(args) => (ViewType::Files, args), + }; + + // Create API client + let client = ApiClient::new(args.api_url.clone(), args.api_key.clone())?; + + // Fetch initial data based on view type + let items = match view_type { + ViewType::Tasks => { + let contract_id = args.contract_id + .ok_or("Contract ID is required for tasks view (use --contract-id or MAKIMA_CONTRACT_ID)")?; + let result = client.supervisor_tasks(contract_id).await?; + // Parse tasks from JSON array + result.0.as_array() + .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) + .unwrap_or_default() + } + ViewType::Contracts => { + // For contracts, we would need a list contracts endpoint + // For now, return empty or fetch from a different endpoint + eprintln!("Contracts view not yet implemented - requires list contracts endpoint"); + Vec::new() + } + ViewType::Files => { + let contract_id = args.contract_id + .ok_or("Contract ID is required for files view (use --contract-id or MAKIMA_CONTRACT_ID)")?; + let result = client.contract_files(contract_id).await?; + // Parse files from JSON array + result.0.as_array() + .map(|arr| arr.iter().filter_map(ListItem::from_file).collect()) + .unwrap_or_default() + } + }; + + // Create TUI app + let mut app = App::new(view_type); + app.contract_id = args.contract_id; + app.set_items(items); + + // Run TUI + match tui::run(app) { + Ok(Some(path)) => { + // Output the path for shell integration (e.g., cd $(makima view tasks)) + tui::print_path(&path); + } + Ok(None) => { + // Normal exit, no output needed + } + Err(e) => { + eprintln!("TUI error: {}", e); + std::process::exit(1); + } + } + + Ok(()) +} + fn init_logging(level: &str, format: &str) { let filter = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new(level)) diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index cde6e16..842fa63 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -4,6 +4,7 @@ pub mod contract; pub mod daemon; pub mod server; pub mod supervisor; +pub mod view; use clap::{Parser, Subcommand}; @@ -11,6 +12,7 @@ pub use contract::ContractArgs; pub use daemon::DaemonArgs; pub use server::ServerArgs; pub use supervisor::SupervisorArgs; +pub use view::{ViewArgs, ViewCommand}; /// Makima - unified CLI for server, daemon, and task management. #[derive(Parser, Debug)] @@ -36,6 +38,17 @@ pub enum Commands { /// Contract commands for task-contract interaction #[command(subcommand)] Contract(ContractCommand), + + /// Interactive TUI browser for tasks, contracts, and files + /// + /// Provides a fuzzy-searchable interface with keyboard navigation. + /// + /// Keyboard shortcuts: + /// ↑/k: Move up ↓/j: Move down Enter: Select + /// /: Search Tab: Toggle preview q: Quit + /// e: Edit d: Delete c: cd to worktree + #[command(subcommand)] + View(ViewCommand), } /// Supervisor subcommands for contract orchestration. diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs new file mode 100644 index 0000000..f42c490 --- /dev/null +++ b/makima/src/daemon/cli/view.rs @@ -0,0 +1,128 @@ +//! View subcommand - interactive TUI browser for tasks, contracts, and files. +//! +//! The `makima view` command provides an interactive Terminal User Interface (TUI) +//! for browsing and managing makima entities. It features fuzzy search filtering, +//! keyboard navigation, and quick actions. +//! +//! # Usage +//! +//! ```bash +//! # Browse tasks interactively +//! makima view tasks +//! +//! # Browse contracts with an initial search query +//! makima view contracts "my project" +//! +//! # Browse files without preview pane +//! makima view files --no-preview +//! +//! # Browse tasks for a specific contract +//! makima view tasks --contract-id <uuid> +//! +//! # Change directory to selected task's worktree +//! cd $(makima view tasks) +//! ``` +//! +//! # Keyboard Shortcuts +//! +//! | Key | Action | +//! |-------------|---------------------------| +//! | `↑` / `k` | Move selection up | +//! | `↓` / `j` | Move selection down | +//! | `Enter` | View/select item | +//! | `e` | Open in editor ($EDITOR) | +//! | `d` | Delete item (with confirm)| +//! | `Tab` | Toggle preview pane | +//! | `/` | Focus search input | +//! | `Esc` | Clear search / cancel | +//! | `q` | Quit | +//! | `c` | Navigate to worktree (cd) | +//! | `Ctrl+r` | Refresh data | +//! | `?` | Show help | +//! +//! # Features +//! +//! - **Fuzzy Search**: Type to filter items in real-time +//! - **Multi-term Search**: Use space-separated terms (e.g., "fix bug") +//! - **Recency Sorting**: Recent items appear higher in results +//! - **Preview Pane**: See item details without leaving the list +//! - **Status Indicators**: Visual icons for task states + +use clap::{Args, Subcommand}; +use uuid::Uuid; + +/// Interactive TUI browser for tasks, contracts, and files. +/// +/// Provides a fuzzy-searchable interface for browsing and managing +/// makima entities with keyboard navigation and quick actions. +/// +/// # Examples +/// +/// Browse tasks: +/// ```bash +/// makima view tasks +/// ``` +/// +/// Browse with initial search: +/// ```bash +/// makima view contracts "auth" +/// ``` +#[derive(Subcommand, Debug)] +pub enum ViewCommand { + /// Browse tasks interactively + /// + /// Shows all tasks for the current contract with status indicators, + /// fuzzy search filtering, and quick actions. + Tasks(ViewArgs), + + /// Browse contracts interactively + /// + /// Lists all contracts with their phase, status, and task counts. + Contracts(ViewArgs), + + /// Browse files interactively + /// + /// Shows contract files with preview of their content. + Files(ViewArgs), +} + +/// Common arguments for view commands. +/// +/// These arguments are shared across all view subcommands (tasks, contracts, files). +#[derive(Args, Debug, Clone)] +pub struct ViewArgs { + /// API URL for the makima server + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY")] + pub api_key: String, + + /// Contract ID to filter results (optional) + /// + /// When specified, only shows items belonging to this contract. + #[arg(long, env = "MAKIMA_CONTRACT_ID")] + pub contract_id: Option<Uuid>, + + /// Initial search query + /// + /// Pre-populates the search field with this query when the TUI opens. + #[arg(index = 1)] + pub query: Option<String>, + + /// Disable the preview pane + /// + /// Shows only the item list without the side preview panel. + /// Useful for smaller terminal windows. + #[arg(long)] + pub no_preview: bool, + + /// Sort order for results + /// + /// - `recent`: Sort by last updated time (default) + /// - `name`: Sort alphabetically by name + /// - `status`: Group by status, then by name + #[arg(long, default_value = "recent")] + pub sort: String, +} diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index d7ec3f0..c348838 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -5,6 +5,7 @@ //! - `makima daemon` - Run the daemon (connect to server, manage tasks) //! - `makima supervisor` - Contract orchestration commands //! - `makima contract` - Task-contract interaction commands +//! - `makima view` - Interactive TUI browser for tasks, contracts, and files pub mod api; pub mod cli; @@ -14,9 +15,10 @@ pub mod error; pub mod process; pub mod task; pub mod temp; +pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand}; +pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewCommand}; pub use config::DaemonConfig; pub use error::{DaemonError, Result}; diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs new file mode 100644 index 0000000..a2c82a2 --- /dev/null +++ b/makima/src/daemon/tui/app.rs @@ -0,0 +1,462 @@ +//! TUI application state and logic. + +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use serde_json::Value; +use uuid::Uuid; + +/// Available views/resource types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewType { + Tasks, + Contracts, + Files, +} + +impl ViewType { + pub fn as_str(&self) -> &'static str { + match self { + ViewType::Tasks => "tasks", + ViewType::Contracts => "contracts", + ViewType::Files => "files", + } + } +} + +/// 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, +} + +/// 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) + Select, + /// Edit the selected item (open in editor) + 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, +} + +/// 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::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("progress").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()) { + lines.push(format!("│ Error: {}", error)); + } + 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)); + } + } + lines.push(format!("╰────────────────────────────────────────")); + } + } + + lines.join("\n") + } +} + +/// TUI Application state +pub struct App { + /// Current view type + pub view_type: ViewType, + /// 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>, + /// 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>, +} + +impl App { + pub fn new(view_type: ViewType) -> Self { + Self { + view_type, + 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, + status_message: None, + should_quit: false, + exit_action: None, + contract_id: None, + } + } + + /// 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::Edit => { + // Get worktree path and signal to launch editor + 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()); + } + } + 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(); + 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() { + // 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()); + } + 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::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()) + }) + } +} diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs new file mode 100644 index 0000000..12a6890 --- /dev/null +++ b/makima/src/daemon/tui/event.rs @@ -0,0 +1,118 @@ +//! TUI event handling. + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +use super::app::{Action, App, InputMode}; + +/// Poll for events with timeout +pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> { + if event::poll(timeout)? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } +} + +/// Handle a key event and return the resulting action +pub fn handle_key_event(app: &App, key: KeyEvent) -> Action { + match app.input_mode { + InputMode::Normal => handle_normal_mode(key), + InputMode::Search => handle_search_mode(key), + InputMode::Confirm => handle_confirm_mode(key), + } +} + +/// Handle key events in normal navigation mode +fn handle_normal_mode(key: KeyEvent) -> Action { + // Check for Ctrl+C first + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('c') => return Action::Quit, + _ => {} + } + } + + match key.code { + // Navigation + KeyCode::Up | KeyCode::Char('k') => Action::Up, + KeyCode::Down | KeyCode::Char('j') => Action::Down, + + // Actions + KeyCode::Enter => Action::Select, + KeyCode::Char('e') => Action::Edit, + KeyCode::Char('d') => Action::Delete, + KeyCode::Char('c') => Action::Navigate, // cd to worktree + + // Search + KeyCode::Char('/') => Action::EnterSearch, + + // Preview toggle (space to toggle preview visibility) + KeyCode::Char(' ') => Action::Select, + + // Refresh + KeyCode::Char('r') => Action::Refresh, + + // Quit + KeyCode::Char('q') | KeyCode::Esc => Action::Quit, + + _ => Action::None, + } +} + +/// Handle key events in search mode +fn handle_search_mode(key: KeyEvent) -> Action { + // Check for Ctrl+C first + if key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('c') => return Action::Quit, + KeyCode::Char('u') => return Action::ClearSearch, + _ => {} + } + } + + match key.code { + // Exit search mode + KeyCode::Esc => Action::ExitSearch, + KeyCode::Enter => Action::ExitSearch, + + // Text input + KeyCode::Char(c) => Action::SearchChar(c), + KeyCode::Backspace => Action::SearchBackspace, + + // Navigation while searching + KeyCode::Up => Action::Up, + KeyCode::Down => Action::Down, + + _ => Action::None, + } +} + +/// Handle key events in confirmation mode +fn handle_confirm_mode(key: KeyEvent) -> Action { + // Check for Ctrl+C first + if key.modifiers.contains(KeyModifiers::CONTROL) { + if let KeyCode::Char('c') = key.code { + return Action::Quit; + } + } + + match key.code { + // Confirm + KeyCode::Char('y') | KeyCode::Char('Y') => Action::ConfirmYes, + + // Cancel + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Action::ConfirmNo, + + _ => Action::None, + } +} + +/// Get help text for current mode +pub fn get_help_text(mode: InputMode) -> &'static str { + match mode { + InputMode::Normal => "j/k: navigate | Enter: details | e: edit | d: delete | c: cd | /: search | q: quit", + InputMode::Search => "Type to search | Enter/Esc: exit search | Up/Down: navigate", + InputMode::Confirm => "y: confirm | n/Esc: cancel", + } +} diff --git a/makima/src/daemon/tui/fuzzy.rs b/makima/src/daemon/tui/fuzzy.rs new file mode 100644 index 0000000..44c27ad --- /dev/null +++ b/makima/src/daemon/tui/fuzzy.rs @@ -0,0 +1,217 @@ +//! Fuzzy matching wrapper for search functionality. +//! +//! This module provides a wrapper around the `fuzzy-matcher` crate's +//! `SkimMatcherV2` algorithm, offering: +//! +//! - Single-term fuzzy matching with score and matched indices +//! - Multi-term search (space-separated patterns) +//! - Recency-adjusted scoring for time-aware results +//! - Case-insensitive matching by default +//! +//! # Examples +//! +//! ``` +//! use makima::daemon::tui::fuzzy::FuzzyMatcher; +//! +//! let matcher = FuzzyMatcher::new(); +//! +//! // Single pattern matching +//! if let Some((score, indices)) = matcher.fuzzy_match("hello world", "hlo") { +//! println!("Score: {}, Matched positions: {:?}", score, indices); +//! } +//! +//! // Multi-term search +//! if let Some(score) = matcher.fuzzy_match_all("fix authentication bug", "fix bug") { +//! println!("All terms matched with score: {}", score); +//! } +//! ``` + +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait; + +/// Fuzzy matcher wrapper providing search functionality. +/// +/// Wraps the `SkimMatcherV2` algorithm which provides: +/// - Smart case matching (case-insensitive unless pattern has uppercase) +/// - Word boundary bonuses +/// - Consecutive character bonuses +pub struct FuzzyMatcher { + matcher: SkimMatcherV2, +} + +impl FuzzyMatcher { + /// Create a new fuzzy matcher with default settings. + pub fn new() -> Self { + Self { + matcher: SkimMatcherV2::default(), + } + } + + /// Match a pattern against a string, returning score and matched indices. + /// + /// Returns `Some((score, indices))` if the pattern matches, where: + /// - `score` is a relevance score (higher is better) + /// - `indices` are the positions of matched characters in the text + /// + /// Returns `None` if the pattern doesn't match the text. + /// + /// # Arguments + /// + /// * `text` - The text to search in + /// * `pattern` - The pattern to search for + pub fn fuzzy_match(&self, text: &str, pattern: &str) -> Option<(i64, Vec<usize>)> { + self.matcher.fuzzy_indices(text, pattern) + } + + /// Match multiple patterns (space-separated) against a string. + /// + /// All patterns must match for the function to return a score. + /// The returned score is the sum of individual pattern scores. + /// + /// # Arguments + /// + /// * `text` - The text to search in + /// * `patterns` - Space-separated patterns (e.g., "fix bug" matches both "fix" and "bug") + /// + /// # Returns + /// + /// `Some(total_score)` if all patterns match, `None` otherwise. + pub fn fuzzy_match_all(&self, text: &str, patterns: &str) -> Option<i64> { + let patterns: Vec<&str> = patterns.split_whitespace().collect(); + + if patterns.is_empty() { + return Some(0); + } + + let mut total_score = 0i64; + + for pattern in patterns { + if let Some((score, _)) = self.matcher.fuzzy_indices(text, pattern) { + total_score += score; + } else { + return None; + } + } + + Some(total_score) + } + + /// Calculate a recency-adjusted score for time-aware sorting. + /// + /// Items with lower indices (more recent) receive a bonus to their score, + /// making them rank higher in search results. + /// + /// # Arguments + /// + /// * `base_score` - The original fuzzy match score + /// * `index` - The item's position in the list (0 = most recent) + /// * `total_items` - Total number of items in the list + /// + /// # Returns + /// + /// An adjusted score that factors in recency. + pub fn recency_adjusted_score(base_score: i64, index: usize, total_items: usize) -> i64 { + if total_items == 0 { + return base_score; + } + + // Recency bonus: items at the beginning get up to 20% bonus + // Formula: bonus = base_score * 0.2 * (1 - index/total_items) + let recency_factor = 1.0 - (index as f64 / total_items as f64); + let bonus = (base_score as f64 * 0.2 * recency_factor) as i64; + + base_score + bonus + } +} + +impl Default for FuzzyMatcher { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fuzzy_match_exact() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match("hello world", "hello"); + assert!(result.is_some()); + } + + #[test] + fn test_fuzzy_match_partial() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match("authentication", "auth"); + assert!(result.is_some()); + } + + #[test] + fn test_fuzzy_match_no_match() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match("hello", "xyz"); + assert!(result.is_none()); + } + + #[test] + fn test_multi_term_search() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match_all("fix authentication bug", "fix bug"); + assert!(result.is_some()); + } + + #[test] + fn test_case_insensitive() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match("Hello World", "hello"); + assert!(result.is_some()); + } + + #[test] + fn test_recency_bonus() { + // Earlier items (lower index) should get higher recency bonus + let score1 = FuzzyMatcher::recency_adjusted_score(100, 0, 50); + let score2 = FuzzyMatcher::recency_adjusted_score(100, 10, 50); + assert!(score1 > score2); + } + + #[test] + fn test_fuzzy_match_returns_indices() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match("hello world", "hlo"); + assert!(result.is_some()); + let (_, indices) = result.unwrap(); + // Should have matched 3 characters + assert_eq!(indices.len(), 3); + } + + #[test] + fn test_multi_term_empty_pattern() { + let matcher = FuzzyMatcher::new(); + let result = matcher.fuzzy_match_all("hello world", ""); + assert!(result.is_some()); + assert_eq!(result.unwrap(), 0); + } + + #[test] + fn test_multi_term_partial_match_fails() { + let matcher = FuzzyMatcher::new(); + // "xyz" doesn't match, so the whole search should fail + let result = matcher.fuzzy_match_all("fix authentication bug", "fix xyz"); + assert!(result.is_none()); + } + + #[test] + fn test_recency_bonus_edge_cases() { + // Zero total items should return base score + let score = FuzzyMatcher::recency_adjusted_score(100, 0, 0); + assert_eq!(score, 100); + + // Last item should get minimal bonus + let score_last = FuzzyMatcher::recency_adjusted_score(100, 49, 50); + let score_first = FuzzyMatcher::recency_adjusted_score(100, 0, 50); + assert!(score_first > score_last); + } +} diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs new file mode 100644 index 0000000..fd1d44d --- /dev/null +++ b/makima/src/daemon/tui/mod.rs @@ -0,0 +1,96 @@ +//! TUI module for interactive browsing. +//! +//! This module provides an interactive Terminal User Interface (TUI) for +//! browsing and managing tasks, contracts, and files in the makima system. +//! +//! # Features +//! +//! - **Fuzzy Search**: Real-time filtering with the SkimMatcherV2 algorithm +//! - **Keyboard Navigation**: Vim-style keybindings (j/k) and arrow keys +//! - **Preview Pane**: Side-by-side view of item details +//! - **Multiple Views**: Browse tasks, contracts, or files + +pub mod app; +pub mod event; +pub mod fuzzy; +pub mod ui; + +pub use app::{App, ListItem, ViewType, InputMode, Action}; +pub use fuzzy::FuzzyMatcher; + +use std::io; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::prelude::*; +use ratatui::backend::CrosstermBackend; + +pub type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>; + +/// Run the TUI application +pub fn run(mut app: App) -> Result<Option<String>, Box<dyn std::error::Error>> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = ratatui::Terminal::new(backend)?; + + // Run the main loop + let result = run_app(&mut terminal, &mut app); + + // Cleanup terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} + +fn run_app( + terminal: &mut Terminal, + app: &mut App, +) -> Result<Option<String>, Box<dyn std::error::Error>> { + use crossterm::event::Event; + use std::time::Duration; + + loop { + terminal.draw(|f| ui::render(f, app))?; + + // Poll for events with 100ms timeout + if let Some(evt) = event::poll_event(Duration::from_millis(100))? { + if let Event::Key(key) = evt { + let action = event::handle_key_event(app, key); + match action { + Action::Quit => break, + Action::OutputPath(path) => return Ok(Some(path)), + Action::None => {} + _ => { + let result = app.handle_action(action); + // Check if handle_action returned a special action + if let Action::OutputPath(path) = result { + return Ok(Some(path)); + } + } + } + } + } + + if app.should_quit { + break; + } + } + + Ok(None) +} + +/// Print a path to stdout (for cd integration) +pub fn print_path(path: &str) { + println!("{}", path); +} diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs new file mode 100644 index 0000000..4003344 --- /dev/null +++ b/makima/src/daemon/tui/ui.rs @@ -0,0 +1,209 @@ +//! TUI rendering. + +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use super::app::{App, InputMode, ViewType}; +use super::event::get_help_text; + +/// Main render function +pub fn render(frame: &mut Frame, app: &App) { + // Create main layout + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Main content + Constraint::Length(3), // Status/Help + ]) + .split(frame.area()); + + render_header(frame, app, chunks[0]); + render_main_content(frame, app, chunks[1]); + render_footer(frame, app, chunks[2]); + + // Render confirmation dialog if in confirm mode + if app.input_mode == InputMode::Confirm { + render_confirm_dialog(frame, app); + } +} + +/// Render header with title and search bar +fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let title = match app.view_type { + ViewType::Tasks => "Tasks", + ViewType::Contracts => "Contracts", + ViewType::Files => "Files", + }; + + let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { + format!("{} [Search: {}]", title, app.search_query) + } else { + format!("{} ({} items)", title, app.filtered_items.len()) + }; + + let header = Paragraph::new(header_text) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if app.input_mode == InputMode::Search { + Color::Yellow + } else { + Color::White + }))); + + frame.render_widget(header, area); +} + +/// Render main content (list + optional preview) +fn render_main_content(frame: &mut Frame, app: &App, area: Rect) { + if app.preview_visible && !app.preview_content.is_empty() { + // Split horizontally: list on left, preview on right + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(area); + + render_list(frame, app, chunks[0]); + render_preview(frame, app, chunks[1]); + } else { + render_list(frame, app, area); + } +} + +/// Render the item list +fn render_list(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec<ListItem> = app.filtered_items + .iter() + .enumerate() + .map(|(i, item)| { + let is_selected = i == app.selected_index; + + // Build the display line + let status_str = item.status + .as_ref() + .map(|s| format!(" [{}]", s)) + .unwrap_or_default(); + + let content = format!("{}{}", item.name, status_str); + + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + let status_color = item.status.as_ref().map(|s| { + match s.to_lowercase().as_str() { + "running" | "active" => Color::Green, + "pending" | "waiting" => Color::Yellow, + "completed" | "done" => Color::Blue, + "failed" | "error" => Color::Red, + _ => Color::White, + } + }).unwrap_or(Color::White); + + Style::default().fg(status_color) + }; + + ListItem::new(Line::from(vec![ + Span::styled(content, style), + ])) + }) + .collect(); + + let list = List::new(items) + .block(Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", app.view_type.as_str()))); + + frame.render_widget(list, area); +} + +/// Render the preview panel +fn render_preview(frame: &mut Frame, app: &App, area: Rect) { + let preview = Paragraph::new(Text::raw(&app.preview_content)) + .wrap(Wrap { trim: false }) + .block(Block::default() + .borders(Borders::ALL) + .title(" Preview ")); + + frame.render_widget(preview, area); +} + +/// Render footer with help text and status +fn render_footer(frame: &mut Frame, app: &App, area: Rect) { + let help_text = get_help_text(app.input_mode); + + let status_text = app.status_message + .as_ref() + .map(|s| format!(" | {}", s)) + .unwrap_or_default(); + + let footer_text = format!("{}{}", help_text, status_text); + + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::DarkGray)) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(footer, area); +} + +/// Render confirmation dialog as a centered popup +fn render_confirm_dialog(frame: &mut Frame, app: &App) { + let item_name = app.get_pending_delete_name() + .unwrap_or_else(|| "this item".to_string()); + + // Calculate popup size and position + let area = frame.area(); + let popup_width = 50.min(area.width.saturating_sub(4)); + let popup_height = 7; + + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + // Clear the area behind the popup + frame.render_widget(Clear, popup_area); + + // Build popup content + let text = vec![ + Line::from(""), + Line::from(Span::styled( + "Delete Confirmation", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(format!("Delete '{}'?", item_name)), + Line::from(""), + Line::from(vec![ + Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": confirm "), + Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw(": cancel"), + ]), + ]; + + let popup = Paragraph::new(text) + .alignment(Alignment::Center) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" Confirm ")); + + frame.render_widget(popup, popup_area); +} diff --git a/makima/src/daemon/tui/views/contracts.rs b/makima/src/daemon/tui/views/contracts.rs new file mode 100644 index 0000000..e2219b7 --- /dev/null +++ b/makima/src/daemon/tui/views/contracts.rs @@ -0,0 +1,24 @@ +//! Contracts view implementation. + +use uuid::Uuid; + +use crate::daemon::api::ApiClient; +use crate::daemon::tui::app::ListItem; + +/// Load contracts from API +pub async fn load_contracts( + _client: &ApiClient, +) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { + // TODO: Implement listing all contracts + // This would require a new API endpoint + Ok(Vec::new()) +} + +/// Get full contract details for preview +pub async fn get_contract_preview( + _client: &ApiClient, + _contract_id: Uuid, +) -> Result<String, Box<dyn std::error::Error>> { + // TODO: Implement contract preview + Ok("Contract preview not yet implemented".to_string()) +} diff --git a/makima/src/daemon/tui/views/files.rs b/makima/src/daemon/tui/views/files.rs new file mode 100644 index 0000000..e21a989 --- /dev/null +++ b/makima/src/daemon/tui/views/files.rs @@ -0,0 +1,90 @@ +//! Files view implementation. + +use uuid::Uuid; + +use crate::daemon::api::ApiClient; +use crate::daemon::tui::app::ListItem; + +/// Load files from API +pub async fn load_files( + client: &ApiClient, + contract_id: Uuid, +) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { + let result = client.contract_files(contract_id).await?; + + // Parse JSON response into ListItem + let files: Vec<serde_json::Value> = serde_json::from_value(result.0)?; + + let items = files + .into_iter() + .filter_map(|f| { + let id_str = f.get("id")?.as_str()?; + let id = Uuid::parse_str(id_str).ok()?; + + Some(ListItem { + id, + name: f + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed") + .to_string(), + status: None, + description: f + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), + updated_at: f + .get("updatedAt") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + extra: f, + }) + }) + .collect(); + + Ok(items) +} + +/// Get full file details for preview +pub async fn get_file_preview( + client: &ApiClient, + contract_id: Uuid, + file_id: Uuid, +) -> Result<String, Box<dyn std::error::Error>> { + let result = client.contract_file(contract_id, file_id).await?; + let file: serde_json::Value = result.0; + + let name = file + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let description = file + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("-"); + + // Try to get body content + let body_preview = if let Some(body) = file.get("body") { + if let Some(body_array) = body.as_array() { + body_array + .iter() + .filter_map(|item| { + let text = item.get("text").and_then(|v| v.as_str())?; + Some(text.to_string()) + }) + .take(5) + .collect::<Vec<_>>() + .join("\n") + } else { + "-".to_string() + } + } else { + "-".to_string() + }; + + Ok(format!( + "Name: {}\nDescription: {}\n\nContent:\n{}", + name, description, body_preview + )) +} diff --git a/makima/src/daemon/tui/views/mod.rs b/makima/src/daemon/tui/views/mod.rs new file mode 100644 index 0000000..699b6df --- /dev/null +++ b/makima/src/daemon/tui/views/mod.rs @@ -0,0 +1,3 @@ +pub mod contracts; +pub mod files; +pub mod tasks; diff --git a/makima/src/daemon/tui/views/tasks.rs b/makima/src/daemon/tui/views/tasks.rs new file mode 100644 index 0000000..fd52b11 --- /dev/null +++ b/makima/src/daemon/tui/views/tasks.rs @@ -0,0 +1,71 @@ +//! Tasks view implementation. + +use uuid::Uuid; + +use crate::daemon::api::ApiClient; +use crate::daemon::tui::app::ListItem; + +/// Load tasks from API +pub async fn load_tasks( + client: &ApiClient, + contract_id: Option<Uuid>, +) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { + let Some(contract_id) = contract_id else { + // TODO: Implement listing all tasks across contracts + return Ok(Vec::new()); + }; + + let result = client.supervisor_tasks(contract_id).await?; + + // Parse JSON response into ListItem + let tasks: Vec<serde_json::Value> = serde_json::from_value(result.0)?; + + let items = tasks + .into_iter() + .filter_map(|t| { + let id_str = t.get("id")?.as_str()?; + let id = Uuid::parse_str(id_str).ok()?; + + Some(ListItem { + id, + name: t + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed") + .to_string(), + status: t.get("status").and_then(|v| v.as_str()).map(String::from), + description: t + .get("progressSummary") + .and_then(|v| v.as_str()) + .map(String::from), + updated_at: t + .get("updatedAt") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + extra: t, + }) + }) + .collect(); + + Ok(items) +} + +/// Get full task details for preview +pub async fn get_task_preview( + client: &ApiClient, + task_id: Uuid, +) -> Result<String, Box<dyn std::error::Error>> { + let result = client.supervisor_get_task(task_id).await?; + let task: serde_json::Value = result.0; + + Ok(format!( + "Name: {}\nStatus: {}\nPlan: {}\n\nProgress:\n{}", + task.get("name").and_then(|v| v.as_str()).unwrap_or("-"), + task.get("status").and_then(|v| v.as_str()).unwrap_or("-"), + task.get("plan").and_then(|v| v.as_str()).unwrap_or("-"), + task.get("progressSummary") + .and_then(|v| v.as_str()) + .unwrap_or("-"), + )) +} diff --git a/makima/src/daemon/tui/widgets/list_view.rs b/makima/src/daemon/tui/widgets/list_view.rs new file mode 100644 index 0000000..ff8269a --- /dev/null +++ b/makima/src/daemon/tui/widgets/list_view.rs @@ -0,0 +1,127 @@ +//! List view widget with fuzzy match highlighting. + +use std::collections::HashSet; + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, List, ListItem, ListState}, +}; + +use crate::daemon::tui::app::{App, ViewMode}; + +/// Style for matched characters in search results +const MATCH_HIGHLIGHT_COLOR: Color = Color::Yellow; +const MATCH_HIGHLIGHT_MODIFIER: Modifier = Modifier::BOLD; + +/// Build a Line with highlighted characters based on matched indices +fn build_highlighted_name(name: &str, matched_indices: &[usize]) -> Vec<Span<'static>> { + if matched_indices.is_empty() { + return vec![Span::raw(name.to_string())]; + } + + let matched_set: HashSet<usize> = matched_indices.iter().cloned().collect(); + let mut spans = Vec::new(); + let mut current_run = String::new(); + let mut is_highlighted = false; + + for (byte_idx, ch) in name.char_indices() { + let should_highlight = matched_set.contains(&byte_idx); + + if should_highlight != is_highlighted { + // Flush current run + if !current_run.is_empty() { + if is_highlighted { + spans.push(Span::styled( + current_run.clone(), + Style::default() + .fg(MATCH_HIGHLIGHT_COLOR) + .add_modifier(MATCH_HIGHLIGHT_MODIFIER), + )); + } else { + spans.push(Span::raw(current_run.clone())); + } + current_run.clear(); + } + is_highlighted = should_highlight; + } + + current_run.push(ch); + } + + // Flush remaining + if !current_run.is_empty() { + if is_highlighted { + spans.push(Span::styled( + current_run, + Style::default() + .fg(MATCH_HIGHLIGHT_COLOR) + .add_modifier(MATCH_HIGHLIGHT_MODIFIER), + )); + } else { + spans.push(Span::raw(current_run)); + } + } + + spans +} + +/// Get status icon and color for an item +fn get_status_display(status: Option<&str>) -> (&'static str, Color) { + match status { + Some("running") => ("▸", Color::Green), + Some("done") => ("✓", Color::Blue), + Some("failed") => ("✗", Color::Red), + Some("pending") => ("○", Color::Yellow), + Some("paused") => ("⏸", Color::Cyan), + _ => (" ", Color::Gray), + } +} + +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let items: Vec<ListItem> = app + .filtered_items + .iter() + .map(|filtered_item| { + let item = &app.items[filtered_item.index]; + let (status_icon, status_color) = get_status_display(item.status.as_deref()); + + // Build spans with highlighted matched characters + let mut spans = vec![Span::styled( + format!("{} ", status_icon), + Style::default().fg(status_color), + )]; + + // Add name with match highlighting + spans.extend(build_highlighted_name(&item.name, &filtered_item.matched_indices)); + + ListItem::new(Line::from(spans)) + }) + .collect(); + + let view_label = match app.view_mode { + ViewMode::Tasks => "Tasks", + ViewMode::Contracts => "Contracts", + ViewMode::Files => "Files", + }; + + let title = format!( + " {} ({}{}) ", + view_label, + app.filtered_items.len(), + if app.filtered_items.len() != app.items.len() { + format!("/{}", app.items.len()) + } else { + String::new() + } + ); + + let list = List::new(items) + .block(Block::default().title(title).borders(Borders::ALL)) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("> "); + + let mut state = ListState::default(); + state.select(Some(app.selected_index)); + + f.render_stateful_widget(list, area, &mut state); +} diff --git a/makima/src/daemon/tui/widgets/mod.rs b/makima/src/daemon/tui/widgets/mod.rs new file mode 100644 index 0000000..ddea546 --- /dev/null +++ b/makima/src/daemon/tui/widgets/mod.rs @@ -0,0 +1,4 @@ +pub mod list_view; +pub mod preview_pane; +pub mod search_input; +pub mod status_bar; diff --git a/makima/src/daemon/tui/widgets/preview_pane.rs b/makima/src/daemon/tui/widgets/preview_pane.rs new file mode 100644 index 0000000..84095d0 --- /dev/null +++ b/makima/src/daemon/tui/widgets/preview_pane.rs @@ -0,0 +1,21 @@ +//! Preview pane widget. + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use crate::daemon::tui::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let content = app + .preview_content + .as_deref() + .unwrap_or("No preview available"); + + let preview = Paragraph::new(content) + .block(Block::default().title(" Preview ").borders(Borders::ALL)) + .wrap(Wrap { trim: true }); + + f.render_widget(preview, area); +} diff --git a/makima/src/daemon/tui/widgets/search_input.rs b/makima/src/daemon/tui/widgets/search_input.rs new file mode 100644 index 0000000..311b4f0 --- /dev/null +++ b/makima/src/daemon/tui/widgets/search_input.rs @@ -0,0 +1,82 @@ +//! Search input widget with match count and visual feedback. + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph}, +}; + +use crate::daemon::tui::app::{App, InputMode, ViewMode}; + +/// Color for the search bar when there are no matches +const NO_MATCH_COLOR: Color = Color::Red; +/// Color for the search bar when actively searching +const SEARCH_ACTIVE_COLOR: Color = Color::Yellow; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let view_label = match app.view_mode { + ViewMode::Tasks => "Tasks", + ViewMode::Contracts => "Contracts", + ViewMode::Files => "Files", + }; + + let (matched, total) = app.match_count(); + let has_no_matches = app.has_no_matches(); + let is_searching = matches!(app.input_mode, InputMode::Search); + let has_query = !app.search_query.is_empty(); + + // Determine border style based on state + let border_style = if has_no_matches { + Style::default().fg(NO_MATCH_COLOR) + } else if is_searching { + Style::default().fg(SEARCH_ACTIVE_COLOR) + } else { + Style::default() + }; + + // Build the search input content + let search_text = if app.search_query.is_empty() { + if is_searching { + " Type to search...".to_string() + } else { + " Press / to search".to_string() + } + } else { + format!(" {}", app.search_query) + }; + + // Build the title with match count + let title = if has_query { + if has_no_matches { + format!(" 🔍 Search [{}] - No matches ", view_label) + } else { + format!(" 🔍 Search [{}] - {}/{} matches ", view_label, matched, total) + } + } else { + format!(" 🔍 Search [{}] ", view_label) + }; + + // Create input text with appropriate style + let text_style = if app.search_query.is_empty() && !is_searching { + Style::default().fg(Color::DarkGray) + } else if has_no_matches { + Style::default().fg(NO_MATCH_COLOR) + } else { + Style::default() + }; + + let input = Paragraph::new(Span::styled(search_text, text_style)).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ); + + f.render_widget(input, area); + + // Show cursor in search mode + if is_searching { + // Calculate cursor position based on actual search query length + let cursor_x = area.x + app.search_query.len() as u16 + 2; + f.set_cursor_position(Position::new(cursor_x, area.y + 1)); + } +} diff --git a/makima/src/daemon/tui/widgets/status_bar.rs b/makima/src/daemon/tui/widgets/status_bar.rs new file mode 100644 index 0000000..3357c58 --- /dev/null +++ b/makima/src/daemon/tui/widgets/status_bar.rs @@ -0,0 +1,19 @@ +//! Status bar widget. + +use ratatui::{prelude::*, widgets::Paragraph}; + +use crate::daemon::tui::app::{App, InputMode}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let keybindings = match app.input_mode { + InputMode::Normal => { + "↑↓:Navigate Enter:View e:Edit d:Delete Tab:Preview /:Search q:Quit" + } + InputMode::Search => "Type to search Enter:Select Esc:Cancel", + InputMode::Confirm => "y:Confirm n:Cancel", + }; + + let status = Paragraph::new(keybindings).style(Style::default().bg(Color::DarkGray)); + + f.render_widget(status, area); +} |
