summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-19 13:47:32 +0000
committerGitHub <noreply@github.com>2026-01-19 13:47:32 +0000
commit0833fb1f30c0c3b920157deb882e0e902c3af02a (patch)
tree45110fb8cb9277dfbaccfeb53ed9c1f76975022b
parent786510379bed060db2b3742b7dfca671552d2c34 (diff)
downloadsoryu-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.lock256
-rw-r--r--makima/Cargo.toml7
-rw-r--r--makima/docs/view-command.md223
-rw-r--r--makima/src/bin/makima.rs67
-rw-r--r--makima/src/daemon/cli/mod.rs13
-rw-r--r--makima/src/daemon/cli/view.rs128
-rw-r--r--makima/src/daemon/mod.rs4
-rw-r--r--makima/src/daemon/tui/app.rs462
-rw-r--r--makima/src/daemon/tui/event.rs118
-rw-r--r--makima/src/daemon/tui/fuzzy.rs217
-rw-r--r--makima/src/daemon/tui/mod.rs96
-rw-r--r--makima/src/daemon/tui/ui.rs209
-rw-r--r--makima/src/daemon/tui/views/contracts.rs24
-rw-r--r--makima/src/daemon/tui/views/files.rs90
-rw-r--r--makima/src/daemon/tui/views/mod.rs3
-rw-r--r--makima/src/daemon/tui/views/tasks.rs71
-rw-r--r--makima/src/daemon/tui/widgets/list_view.rs127
-rw-r--r--makima/src/daemon/tui/widgets/mod.rs4
-rw-r--r--makima/src/daemon/tui/widgets/preview_pane.rs21
-rw-r--r--makima/src/daemon/tui/widgets/search_input.rs82
-rw-r--r--makima/src/daemon/tui/widgets/status_bar.rs19
21 files changed, 2227 insertions, 14 deletions
diff --git a/Cargo.lock b/Cargo.lock
index f501dcb..2383bf5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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);
+}