From a4485ae3022a827275bed4870128a6ebeda2ff2c Mon Sep 17 00:00:00 2001
From: satyaborg <satya.borg@gmail.com>
Date: Fri, 29 May 2026 22:26:56 +1000
Subject: [PATCH] feat: polish interactive tui

---
 README.md             |   8 +-
 devloop               | 280 +++++++++++++++++++++++++++++++++---------
 tests/devloop_test.sh |   9 ++
 3 files changed, 238 insertions(+), 59 deletions(-)

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] <spec.md> [max=5]
 | Option | Meaning |
 | --- | --- |
 | `--plain` | Force plain output, useful for automation |
-| `--tui` | Force simple terminal progress output |
+| `--tui` | Force terminal UI output |
 | `--coder <agent>` | Choose Codex or Claude Code for implementation (`codex`/`claude`) |
 | `--reviewer <agent>` | Choose Codex or Claude Code for review (`codex`/`claude`) |
 | `--report-format <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] <spec.md> [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] <spec.md> [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] <spec.md> [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"
