Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
799 changes: 799 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ version = "0.1.0"
edition = "2024"

[dependencies]
crossterm = "0.29.0"
ratatui = { version = "0.30.0", default-features = false, features = ["crossterm"] }
serde = { version = "1.0.228", features = ["derive"] }
toml = "1.1.2"
144 changes: 143 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,149 @@ A programmable terminal cockpit for getting oriented, finding the next thing tha

## Status

Pre-implementation scaffold. The accepted product PRD lives in `tnezdev/proposals` at `proposals/hud.md`.
Early V1 implementation. The accepted product PRD lives in `tnezdev/proposals` at `proposals/hud.md`.

V1 proves the cockpit loop:

```text
open -> orient -> move focus -> refresh -> act
```

## Install From Source

Prerequisites:

- Rust toolchain with Cargo.
- A terminal that supports alternate-screen TUIs.
- Optional local tools used by your panel commands, such as `tmux`, `gh`, or `task`.

Install the current GitHub source:

```sh
cargo install --git git@github.com:tnezdev/hud.git
```

Update to the latest source:

```sh
cargo install --git git@github.com:tnezdev/hud.git --force
```

Verify:

```sh
hud --version
```

## Configuration

By default, `hud` reads:

```text
$XDG_CONFIG_HOME/.hud/config.toml
```

If `XDG_CONFIG_HOME` is unset, the fallback is:

```text
$HOME/.config/.hud/config.toml
```

Use a specific file with:

```sh
hud --config ./examples/dogfood.toml
```

Validate config without opening the TUI:

```sh
hud --config ./examples/dogfood.toml --check-config
```

Minimal config:

```toml
title = "Work cockpit"
default_timeout_secs = 120

[[panels]]
id = "tasks"
title = "Tasks"
command = "task mine"
timeout_secs = 10

[[panels.actions]]
key = "t"
label = "Open tasks"
command = "taskwarrior-tui"
```

Config shape:

- `title`: dashboard title.
- `default_timeout_secs`: optional command timeout, default `120`.
- `[[panels]]`: one command-backed panel.
- `panels.id`: unique panel id.
- `panels.title`: panel title.
- `panels.command`: shell command used to refresh the panel.
- `panels.timeout_secs`: optional panel timeout override.
- `[[panels.actions]]`: optional fire-and-forget action for the focused panel.
- `panels.actions.key`: single-character keyboard shortcut.
- `panels.actions.label`: footer label.
- `panels.actions.command`: shell command launched without waiting for completion.

Plain text stdout is the V1 panel content protocol. Non-zero exits, timeouts, and launch failures render as panel error states instead of crashing the dashboard.

## Usage

Run with your default config:

```sh
hud
```

Try the built-in demo:

```sh
hud --demo
```

Try the repository dogfood config:

```sh
cargo run -- --config examples/dogfood.toml
```

Keybindings:

- `q`, `Esc`, or `Ctrl-C`: quit.
- `h`/`j`/`k`/`l`, arrow keys, or `Tab`: move focus.
- `r`: refresh focused panel.
- `R`: refresh all panels.
- Focused-panel action keys are shown in the footer.

## tmux Popup

One-off popup:

```sh
tmux display-popup -E -w 90% -h 80% 'hud'
```

With a local config during development:

```sh
tmux display-popup -E -w 90% -h 80% 'cd /path/to/hud && cargo run -- --config examples/dogfood.toml'
```

Example keybinding:

```tmux
bind-key H display-popup -E -w 90% -h 80% 'hud'
```

The dashboard is most comfortable at roughly 100 columns by 30 rows or larger. Quit returns control cleanly to tmux because `hud` restores the terminal alternate screen on exit.

## Development

Expand Down
54 changes: 48 additions & 6 deletions docs/engineering-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ src/ui.rs // ratatui rendering from typed state

These boundaries are provisional. They should appear only when code needs them, not as empty architecture scaffolding.

The current implementation has crossed the first useful threshold for these modules: config parsing, command execution, dashboard state, app coordination, action resolution, and ratatui rendering now exist because V1 needs them.

## Testing Strategy

- Unit tests cover config validation, output parsing, focus movement, and action resolution.
Expand All @@ -60,20 +62,60 @@ These boundaries are provisional. They should appear only when code needs them,
- Avoid an async runtime until interval refresh, command execution, or input handling proves it is needed.
- Treat long-running plugins, generated UI, approvals, and agent supervision as later design directions.

## Config Shape

V1 uses static TOML discovered at `$XDG_CONFIG_HOME/.hud/config.toml`, falling back to `$HOME/.config/.hud/config.toml` when `XDG_CONFIG_HOME` is unset.

```toml
title = "Work cockpit"
default_timeout_secs = 120

[[panels]]
id = "tasks"
title = "Tasks"
command = "task mine"
timeout_secs = 10

[[panels.actions]]
key = "t"
label = "Open tasks"
command = "taskwarrior-tui"
```

Config parsing happens once at the boundary into typed Rust values. Readers consume typed config, not TOML values.

## Command Boundary

Panel refreshes run through an injectable command runner. The real runner is a shell-command adapter at the app edge. Unit tests use fakes rather than real shell commands.

Command results distinguish:

- stdout
- stderr
- exit status
- timeout
- launch failure

The V1 default command timeout is 120 seconds. Panels can override it with `timeout_secs`.

Action commands use the same injectable boundary but are fire-and-forget: `hud` verifies that the action launches, then returns to the dashboard without waiting for completion.

## Output Protocol

Plain text stdout is valid V1 panel content. Structured output is intentionally reserved for a later semantic component pass; it should be parsed once at the panel output boundary when introduced.

## Refresh Model

V1 starts with manual refresh only.

- Manual refresh reruns either the focused panel or all panels, depending on the keybinding.
- There is no background polling in the first implementation slice.
- A slow panel should not block input handling or crash the dashboard.
- Manually triggered panel commands run outside the terminal input loop so a slow panel does not block navigation or quitting.
- Long-running panel processes are out of scope for v1; each refresh is a bounded command invocation.
- Per-panel interval refresh remains a likely later extension. When added, time must enter through an injectable clock or tick source so tests can advance time deterministically.

## Open Decisions

1. What is the smallest useful config shape?
2. Should structured panel output be newline-delimited JSON in v1, or a single JSON document per refresh?
3. When should per-panel interval refresh be introduced?
4. What is the minimum semantic component set for the first useful dashboard?
5. What does a deploy mean initially: local binary, GitHub release artifact, or package manager path?
1. When should per-panel interval refresh be introduced?
2. What is the minimum semantic component set after plain text?
3. Should structured panel output be newline-delimited JSON, a single JSON document per refresh, or both behind explicit protocol markers?
9 changes: 4 additions & 5 deletions docs/v1-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,10 @@ V1 should not require all of these to be polished before the cockpit loop works.
- V1 panel sources are command-backed.
- V1 actions are fire-and-forget local commands.
- V1 deploy means a source-install path from GitHub.
- The default panel command timeout is 120 seconds, with per-panel `timeout_secs` overrides.
- Plain text stdout is the V1 output protocol; structured output remains a reserved boundary for later semantic components.

## Open Scope Questions

1. What is the exact TOML config shape?
2. Does V1 implement structured output, or only reserve and document the boundary?
3. Is the default command timeout 2 minutes, 5 minutes, or something panel-type-specific?
4. Which semantic component comes immediately after plain text?
5. Should initial panel commands run automatically on app open, or should the first screen wait for manual refresh?
1. Which semantic component comes immediately after plain text?
2. Should structured panel output be newline-delimited JSON, a single JSON document per refresh, or both behind explicit protocol markers?
47 changes: 47 additions & 0 deletions examples/dogfood.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
title = "Work cockpit"
default_timeout_secs = 15

[[panels]]
id = "tmux"
title = "tmux sessions"
command = "tmux list-sessions 2>/dev/null || printf 'no tmux server\n'"
timeout_secs = 5

[[panels.actions]]
key = "t"
label = "switch to work"
command = "tmux has-session -t work 2>/dev/null || tmux new-session -d -s work; tmux switch-client -t work"

[[panels]]
id = "repos"
title = "dirty repos"
command = "for dir in \"$HOME\"/Code/*; do [ -d \"$dir/.git\" ] || continue; status=$(git -C \"$dir\" status --short 2>/dev/null); [ -n \"$status\" ] && printf '%s\n%s\n\n' \"${dir##*/}\" \"$status\"; done"
timeout_secs = 20

[[panels.actions]]
key = "r"
label = "code window"
command = "tmux new-window -n code -c \"$HOME/Code\""

[[panels]]
id = "github"
title = "hud issues"
command = "gh issue list --repo tnezdev/hud --state open --limit 8 2>/dev/null || printf 'gh unavailable or not authenticated\n'"
timeout_secs = 10

[[panels.actions]]
key = "g"
label = "open GitHub"
command = "gh repo view tnezdev/hud --web"

[[panels]]
id = "tasks"
title = "tasks"
command = "task mine limit:8 2>/dev/null || printf 'taskwarrior unavailable\n'"
timeout_secs = 10

[[panels]]
id = "agents"
title = "agents"
command = "ps -axo pid,comm,args | grep -E '[c]odex|[a]gent' | head -12"
timeout_secs = 5
59 changes: 59 additions & 0 deletions src/action.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use crate::panel::{Action, DashboardState};

pub fn resolve_action(state: &DashboardState, key: char) -> Option<Action> {
state
.focused_panel()
.and_then(|panel| panel.actions.iter().find(|action| action.key == key))
.cloned()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::HudConfig;

#[test]
fn resolves_action_for_focused_panel_only() {
let config = HudConfig::from_toml(
r#"
title = "Test"

[[panels]]
id = "one"
title = "One"
command = "one"

[[panels.actions]]
key = "o"
label = "Open one"
command = "open-one"

[[panels]]
id = "two"
title = "Two"
command = "two"

[[panels.actions]]
key = "t"
label = "Open two"
command = "open-two"
"#,
)
.expect("valid config");
let mut state = DashboardState::from_config(&config);

assert_eq!(
resolve_action(&state, 'o').map(|action| action.command),
Some("open-one".into())
);
assert_eq!(resolve_action(&state, 't'), None);

state.focus_next();

assert_eq!(resolve_action(&state, 'o'), None);
assert_eq!(
resolve_action(&state, 't').map(|action| action.command),
Some("open-two".into())
);
}
}
Loading
Loading