diff --git a/README.md b/README.md index 4620b29..7b86d0a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Devloop -**Spec in. Reviewed code out.** +**Spec-driven code and review loop.** `devloop` is a single Bash executable that runs a local implementation and review loop for agent-written code. @@ -95,7 +95,7 @@ devloop [options] [max=5] | Option | Meaning | | --- | --- | | `--plain` | Force plain output, useful for automation | -| `--tui` | Force simple terminal progress output | +| `--tui` | Force terminal UI output | | `--coder ` | Choose Codex or Claude Code for implementation (`codex`/`claude`) | | `--reviewer ` | Choose Codex or Claude Code for review (`codex`/`claude`) | | `--report-format ` | Choose `html` or `markdown` | @@ -116,7 +116,9 @@ When stdout is a terminal, running `devloop` without arguments opens a menu: - `Settings`: view spec search paths, and add or remove one custom spec path. - `Doctor`: verify required commands, optional UI tools, and installed skills. -`gum` powers prompts, confirmations, status output, paging, and setup screens. `fzf` powers searchable pickers for specs, tracks, and reports. +Nested menu screens keep `Back` as the final option so you can return to the previous menu without exiting Devloop. + +`gum` powers the branded help screen, prompts, confirmations, status output, paging, and setup screens. `fzf` powers searchable pickers for specs, tracks, and reports. ## What Devloop Does diff --git a/devloop b/devloop index 7c5fabf..67914a6 100755 --- a/devloop +++ b/devloop @@ -24,6 +24,12 @@ ENTER_WORKTREE=false if [ -t 0 ] && [ -t 1 ]; then ENTER_WORKTREE=true; fi UI_STEP_TITLE="" +UI_ACCENT_COLOR="141" +UI_REC_COLOR="135" +UI_OK_COLOR="141" +UI_DIM_COLOR="244" +UI_BORDER_COLOR="141" +UI_BACK=false EVENT_IDS=() EVENT_TITLES=() @@ -58,6 +64,9 @@ COMMIT_MESSAGES=() COMMIT_PATHS=() main() { + if has_arg "--plain" "$@"; then USE_TUI=false; fi + if has_arg "--tui" "$@"; then USE_TUI=true; fi + if [ "${1:-}" = "spec" ]; then shift spec_command "$@" @@ -105,15 +114,26 @@ main() { } welcome() { + if [ "$USE_TUI" = true ] && [ -t 1 ] && ui_has_gum; then + welcome_tui + return + fi + welcome_plain +} + +devloop_logo() { cat <<'EOF' - __ __ - ____/ /__ _ __/ /___ ____ ____ - / __ / _ \ | / / / __ \/ __ \/ __ \ -/ /_/ / __/ |/ / / /_/ / /_/ / /_/ / -\__,_/\___/|___/_/\____/\____/ .___/ - /_/ +░█▀▄░█▀▀░█░█░█░░░█▀█░█▀█░█▀█ +░█░█░█▀▀░▀▄▀░█░░░█░█░█░█░█▀▀ +░▀▀░░▀▀▀░░▀░░▀▀▀░▀▀▀░▀▀▀░▀░░ +EOF +} -Spec-driven code and review loop. Codex implements and Claude Code reviews by default. +welcome_plain() { + devloop_logo + cat <<'EOF' + +Spec-driven code and review loop. Usage: devloop [options] [max=5] @@ -132,7 +152,7 @@ Common commands: devloop --create-pr .specs/change.md Options: - --tui force simple terminal progress output + --tui force terminal UI output --plain force plain output --coder codex|claude choose Codex or Claude Code for implementation --reviewer codex|claude choose Codex or Claude Code for review @@ -146,6 +166,35 @@ Options: EOF } +welcome_tui() { + ui_logo stdout + gum style --foreground "$UI_DIM_COLOR" "Spec-driven code and review loop." + printf '\n\n' + gum style --foreground "$UI_ACCENT_COLOR" --bold "Usage" + printf ' devloop [options] [max=5]\n\n' + gum style --foreground "$UI_ACCENT_COLOR" --bold "Common commands" + printf ' %-42s %s\n' "devloop doctor" "check required and optional tools" + printf ' %-42s %s\n' "devloop reports" "open previous run reports" + printf ' %-42s %s\n' "devloop continue" "resume a tracked run" + printf ' %-42s %s\n' "devloop menu" "open the guided UI" + printf ' %-42s %s\n' 'devloop spec "add retry behavior"' "generate a spec" + printf ' %-42s %s\n' "devloop .specs/change.md" "run a spec" + printf ' %-42s %s\n' "devloop --create-pr .specs/change.md" "open a PR after acceptance" + printf '\n' + gum style --foreground "$UI_ACCENT_COLOR" --bold "Options" + printf ' %-30s %s\n' "--tui" "force terminal UI output" + printf ' %-30s %s\n' "--plain" "force plain output" + printf ' %-30s %s\n' "--coder codex|claude" "choose implementation agent" + printf ' %-30s %s\n' "--reviewer codex|claude" "choose review agent" + printf ' %-30s %s\n' "--report-format html|markdown" "choose report format" + printf ' %-30s %s\n' "--no-strict" "weaken strict review gates" + printf ' %-30s %s\n' "--in-place" "run in the current worktree" + printf ' %-30s %s\n' "--create-pr, --pr" "push accepted branch and open a PR" + printf ' %-30s %s\n' "--no-shell, --stay" "do not open a shell after completion" + printf ' %-30s %s\n' "--shell, --enter-worktree" "open a shell after completion" + printf ' %-30s %s\n' "-h, --help" "show this screen" +} + usage() { printf '%s\n' "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--no-shell|--stay|--shell|--enter-worktree] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [max=5]" printf '%s\n' "agents: Codex or Claude Code" @@ -234,6 +283,40 @@ devloop_config_value() { return 1 } +write_config_value() { + local scope="$1" + local key="$2" + local value="$3" + local file tmp + case "$key" in + spec_dir) ;; + *) printf 'unknown config key: %s\n' "$key" >&2; return 2 ;; + esac + file="$(devloop_config_file_for_scope "$scope")" || return $? + mkdir -p "$(dirname "$file")" || return 1 + tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" + if [ -f "$file" ]; then + sed -E "/^[[:space:]]*$key[[:space:]]*=/d" "$file" > "$tmp" + fi + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$file" +} + +remove_config_value() { + local scope="$1" + local key="$2" + local file tmp + case "$key" in + spec_dir) ;; + *) printf 'unknown config key: %s\n' "$key" >&2; return 2 ;; + esac + file="$(devloop_config_file_for_scope "$scope")" || return $? + [ -f "$file" ] || return 0 + tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" + sed -E "/^[[:space:]]*$key[[:space:]]*=/d" "$file" > "$tmp" + mv "$tmp" "$file" +} + normalize_spec_dir() { local dir="$1" dir="$(trim_string "$dir")" @@ -357,7 +440,7 @@ spec_dir_status() { write_config_spec_dir() { local scope="local" local dir - local expanded normalized file tmp + local expanded normalized file if [ "$#" -eq 2 ]; then scope="$1" dir="$2" @@ -374,24 +457,14 @@ write_config_spec_dir() { } file="$(devloop_config_file_for_scope "$scope")" || return $? mkdir -p "$(dirname "$file")" "$normalized" || return 1 - tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" - if [ -f "$file" ]; then - sed -E '/^[[:space:]]*spec_dir[[:space:]]*=/d' "$file" > "$tmp" - fi - printf 'spec_dir=%s\n' "$normalized" >> "$tmp" - mv "$tmp" "$file" + write_config_value "$scope" spec_dir "$normalized" || return $? printf '%s\n' "$normalized" } remove_config_spec_dir() { local scope="local" - local file tmp if [ "$#" -eq 1 ]; then scope="$1"; fi - file="$(devloop_config_file_for_scope "$scope")" || return $? - [ -f "$file" ] || return 0 - tmp="$(mktemp "${TMPDIR:-/tmp}/devloop-config.XXXXXX")" - sed -E '/^[[:space:]]*spec_dir[[:space:]]*=/d' "$file" > "$tmp" - mv "$tmp" "$file" + remove_config_value "$scope" spec_dir } ui_has_gum() { @@ -402,18 +475,24 @@ ui_has_fzf() { [ "$USE_TUI" = true ] && command -v fzf >/dev/null 2>&1 } +ui_color_code() { + local color="$1" + case "$color" in + accent|border|cyan) printf '38;5;%s\n' "$UI_ACCENT_COLOR" ;; + rec|run|active|pink) printf '38;5;%s\n' "$UI_REC_COLOR" ;; + ok|green) printf '38;5;%s\n' "$UI_OK_COLOR" ;; + dim|gray|grey) printf '38;5;%s\n' "$UI_DIM_COLOR" ;; + warn|yellow) printf '33\n' ;; + fail|red) printf '31\n' ;; + bold) printf '1\n' ;; + esac +} + ui_color() { local color="$1" local text="$2" local code="" - case "$color" in - green) code="32" ;; - red) code="31" ;; - yellow) code="33" ;; - cyan) code="36" ;; - dim) code="2" ;; - bold) code="1" ;; - esac + code="$(ui_color_code "$color")" if [ "$USE_TUI" = true ] && [ -n "$code" ] && [ -t 2 ]; then printf '\033[%sm%s\033[0m' "$code" "$text" else @@ -421,13 +500,43 @@ ui_color() { fi } +ui_logo() { + local target="${1:-stderr}" + local logo + logo="$(devloop_logo)" + if ui_has_gum; then + if [ "$target" = "stdout" ]; then + gum style --border normal --border-foreground "$UI_BORDER_COLOR" --foreground "$UI_ACCENT_COLOR" --padding "0 2" --margin "1 0 1 0" "$logo" + else + gum style --border normal --border-foreground "$UI_BORDER_COLOR" --foreground "$UI_ACCENT_COLOR" --padding "0 2" --margin "1 0 1 0" "$logo" >&2 + fi + return + fi + if [ "$target" = "stdout" ]; then + printf '\n%s\n' "$logo" + else + printf '\n%s\n' "$logo" >&2 + fi +} + ui_header() { local title="$1" local subtitle="${2:-}" + if [ "$title" = "devloop" ]; then + ui_logo stderr + if [ -n "$subtitle" ]; then + if ui_has_gum; then + gum style --foreground "$UI_DIM_COLOR" "$subtitle" >&2 + else + printf '%s\n' "$subtitle" >&2 + fi + fi + return + fi if ui_has_gum; then - gum style --border normal --padding "0 2" --margin "1 0 1 0" --foreground 212 "$title" >&2 + gum style --border normal --border-foreground "$UI_BORDER_COLOR" --padding "0 2" --margin "1 0 1 0" --foreground "$UI_ACCENT_COLOR" --bold "$title" >&2 if [ -n "$subtitle" ]; then - gum style --foreground 244 "$subtitle" >&2 + gum style --foreground "$UI_DIM_COLOR" "$subtitle" >&2 fi else printf '\n%s\n' "$title" >&2 @@ -436,7 +545,7 @@ ui_header() { } ui_print_key_values() { - local label value max width pairs=("$@") + local label value max width pairs=("$@") label_text max=0 while [ "$#" -gt 0 ]; do label="$1" @@ -449,7 +558,8 @@ ui_print_key_values() { label="$1" value="$2" shift 2 - printf " %-*s%s\n" "$width" "$label" "$value" >&2 + label_text="$(printf "%-*s" "$width" "$label")" + printf " %s%s\n" "$(ui_color accent "$label_text")" "$value" >&2 done } @@ -459,7 +569,12 @@ ui_choose() { local choices=("$@") local index answer if ui_has_gum; then - gum choose --header "$header" "${choices[@]}" + gum choose \ + --header "$header" \ + --header.foreground "$UI_ACCENT_COLOR" \ + --cursor.foreground "$UI_REC_COLOR" \ + --selected.foreground "$UI_OK_COLOR" \ + "${choices[@]}" return $? fi if [ "$USE_TUI" = true ] && [ -t 0 ]; then @@ -485,7 +600,13 @@ ui_confirm() { local prompt="$1" local answer if ui_has_gum; then - gum confirm "$prompt" + gum confirm \ + --prompt.foreground "$UI_ACCENT_COLOR" \ + --selected.foreground "$UI_REC_COLOR" \ + --selected.background "" \ + --unselected.foreground "$UI_DIM_COLOR" \ + --unselected.background "" \ + "$prompt" return $? fi if [ "$USE_TUI" = true ] && [ -t 0 ]; then @@ -503,7 +624,13 @@ ui_input() { local default="${2:-}" local answer if ui_has_gum; then - gum input --placeholder "$prompt" --value "$default" + gum input \ + --placeholder "$prompt" \ + --value "$default" \ + --placeholder.foreground "$UI_DIM_COLOR" \ + --prompt.foreground "$UI_DIM_COLOR" \ + --cursor.foreground "$UI_REC_COLOR" \ + --header.foreground "$UI_ACCENT_COLOR" return $? fi if [ "$USE_TUI" = true ] && [ -t 0 ]; then @@ -545,11 +672,25 @@ ui_pick_from_file() { local header="$2" if [ ! -s "$file" ]; then return 1; fi if ui_has_fzf; then - fzf --height 70% --layout reverse --border --prompt "$header > " --preview 'sed -n "1,80p" {} 2>/dev/null' < "$file" + fzf \ + --height 70% \ + --layout reverse \ + --border \ + --color "fg:-1,bg:-1,fg+:${UI_OK_COLOR},bg+:-1,hl:${UI_REC_COLOR},hl+:${UI_REC_COLOR},prompt:${UI_DIM_COLOR},pointer:${UI_REC_COLOR},marker:${UI_OK_COLOR},spinner:${UI_REC_COLOR},info:${UI_DIM_COLOR},border:${UI_BORDER_COLOR},header:${UI_ACCENT_COLOR},gutter:-1" \ + --prompt "$header > " \ + --preview 'sed -n "1,80p" {} 2>/dev/null' < "$file" return $? fi if ui_has_gum; then - gum filter --placeholder "$header" < "$file" + gum filter \ + --placeholder "$header" \ + --indicator.foreground "$UI_REC_COLOR" \ + --selected-indicator.foreground "$UI_OK_COLOR" \ + --unselected-prefix.foreground "$UI_DIM_COLOR" \ + --header.foreground "$UI_ACCENT_COLOR" \ + --match.foreground "$UI_REC_COLOR" \ + --prompt.foreground "$UI_DIM_COLOR" \ + --placeholder.foreground "$UI_DIM_COLOR" < "$file" return $? fi if [ "$USE_TUI" = true ] && [ -t 0 ]; then @@ -559,21 +700,25 @@ ui_pick_from_file() { sed -n '1p' "$file" } +ui_go_back() { + UI_BACK=true +} + ui_status_line() { local status="$1" local label="$2" local detail="$3" local badge color case "$status" in - ok) badge="[ok]"; color="green" ;; - run) badge="[run]"; color="cyan" ;; - warn) badge="[warn]"; color="yellow" ;; - fail) badge="[fail]"; color="red" ;; + ok) badge="[ok]"; color="ok" ;; + run) badge="[run]"; color="rec" ;; + warn) badge="[warn]"; color="warn" ;; + fail) badge="[fail]"; color="fail" ;; skip) badge="[skip]"; color="dim" ;; *) badge="[$status]"; color="dim" ;; esac if [ "$USE_TUI" = true ]; then - printf '%s %-28s %s\n' "$(ui_color "$color" "$badge")" "$label" "$detail" >&2 + printf '%s %-28s %s\n' "$(ui_color "$color" "$badge")" "$label" "$(ui_color dim "$detail")" >&2 else printf '[devloop] %s: %s\n' "$label" "$detail" >&2 fi @@ -596,7 +741,7 @@ ui_spinner_wait() { if [ "${UI_STEP_STARTED:-0}" -gt 0 ]; then elapsed=$((SECONDS - UI_STEP_STARTED)); fi minutes=$((elapsed / 60)) seconds=$((elapsed % 60)) - printf '\r\033[K%s %s %02d:%02d' "$(ui_color cyan "$frame")" "$title" "$minutes" "$seconds" >&2 + printf '\r\033[K%s %s %02d:%02d' "$(ui_color rec "$frame")" "$title" "$minutes" "$seconds" >&2 sleep 0.2 index=$((index + 1)) done @@ -679,9 +824,10 @@ interactive_menu() { return 2 fi - local choice + local choice code while true; do - ui_header "devloop" "Spec-driven code and review loop" + UI_BACK=false + ui_header "devloop" "Spec-driven code and review loop." choice="$(ui_choose "What do you want to do?" \ "Run a spec" \ "Create a spec" \ @@ -691,11 +837,26 @@ interactive_menu() { "Doctor" \ "Quit")" || return 130 case "$choice" in - "Run a spec") interactive_run_spec; return $? ;; - "Create a spec") interactive_create_spec; return $? ;; + "Run a spec") + interactive_run_spec + code=$? + if [ "$UI_BACK" = true ]; then continue; fi + return "$code" + ;; + "Create a spec") + interactive_create_spec + code=$? + if [ "$UI_BACK" = true ]; then continue; fi + return "$code" + ;; "Continue a run") continue_command; return $? ;; "Open reports") reports_command; return $? ;; - "Settings") interactive_settings || return $? ;; + "Settings") + interactive_settings + code=$? + if [ "$UI_BACK" = true ]; then continue; fi + return "$code" + ;; "Doctor") devloop_doctor "$ROOT_DIR"; return $? ;; "Quit") return 0 ;; esac @@ -717,7 +878,11 @@ interactive_run_spec() { interactive_create_spec() { local agent context - agent="$(ui_choose "Spec agent" "Codex" "Claude Code")" || return 130 + agent="$(ui_choose "Spec agent" "Codex" "Claude Code" "Back")" || return 130 + if [ "$agent" = "Back" ]; then + ui_go_back + return 0 + fi agent="$(agent_choice_value "$agent")" context="$(ui_input "Describe the change, or leave blank for interview mode" "")" || return 130 if [ -n "$context" ]; then @@ -762,7 +927,7 @@ interactive_settings() { printf '%s\n' "spec path removed" >&2 fi ;; - "Back") return 0 ;; + "Back") ui_go_back; return 0 ;; esac done } @@ -797,7 +962,7 @@ interactive_run_setup() { "Toggle worktree mode" \ "Toggle PR creation" \ "Change report format" \ - "Cancel")" || return 130 + "Back")" || return 130 case "$choice" in "Start run") if [ "$create_pr" = true ] && ! ui_confirm "Push the accepted branch and open a PR after acceptance?"; then @@ -810,11 +975,13 @@ interactive_run_setup() { return "$code" ;; "Change coder") - value="$(ui_choose "Implementation agent" "Codex" "Claude Code")" || return 130 + value="$(ui_choose "Implementation agent" "Codex" "Claude Code" "Back")" || return 130 + [ "$value" = "Back" ] && continue coder="$(agent_choice_value "$value")" ;; "Change reviewer") - value="$(ui_choose "Review agent" "Claude Code" "Codex")" || return 130 + value="$(ui_choose "Review agent" "Claude Code" "Codex" "Back")" || return 130 + [ "$value" = "Back" ] && continue reviewer="$(agent_choice_value "$value")" ;; "Change pass limit") @@ -835,10 +1002,11 @@ interactive_run_setup() { if [ "$create_pr" = true ]; then create_pr=false; else create_pr=true; fi ;; "Change report format") - value="$(ui_choose "Report format" "html" "markdown")" || return 130 + value="$(ui_choose "Report format" "html" "markdown" "Back")" || return 130 + [ "$value" = "Back" ] && continue report_format="$value" ;; - "Cancel") return 130 ;; + "Back") ui_go_back; return 0 ;; esac done } diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 0fd4e1c..4da0257 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -261,6 +261,15 @@ equals "$(cd "$raw_tilde_repo" && devloop_spec_dir)" ".specs" "raw tilde config session_output=$'unrelated 11111111-1111-4111-8111-111111111111\nTo continue this session, run codex exec resume 22222222-2222-4222-8222-222222222222' equals "$(extract_session_id "$session_output")" "22222222-2222-4222-8222-222222222222" "extract_session_id uses session marker" +contains "$(devloop_logo)" "░█▀▄░█▀▀" "devloop logo" +equals "$(ui_color_code accent)" "38;5;141" "accent color" +equals "$(ui_color_code rec)" "38;5;135" "run color" +equals "$(ui_color_code ok)" "38;5;141" "ok color" +equals "$(ui_color_code dim)" "38;5;244" "dim color" +if ! ( ui_choose() { printf '%s\n' "Back"; }; UI_BACK=false; interactive_create_spec >/dev/null 2>&1; [ "$UI_BACK" = true ] ); then fail "create spec back navigation"; fi +if ! ( ui_choose() { printf '%s\n' "Back"; }; UI_BACK=false; interactive_settings >/dev/null 2>&1; [ "$UI_BACK" = true ] ); then fail "settings back navigation"; fi +if ! ( ui_choose() { printf '%s\n' "Back"; }; UI_BACK=false; interactive_run_setup "spec.md" >/dev/null 2>&1; [ "$UI_BACK" = true ] ); then fail "run setup back navigation"; fi + picker_file="$work/picker.txt" printf '%s\n' "alpha" "beta" > "$picker_file" old_use_tui="$USE_TUI"