diff --git a/CHANGELOG.md b/CHANGELOG.md index 662091d..cbbc0f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project follows semantic versioning for tagged releases. initial; **Appearance…** in a profile's actions chooses one of eight colours and twelve symbols, applied at once. The palette leaves out red, orange and yellow, which mean low quota. +- Added `codex-profile appearance` so a profile's colour and symbol live with + the CLI and every tool shows the same ones: the menu-bar app reads and + writes them there, `shell-init --prompt` puts a dot in the profile's colour + before `[codex:]` (omitted with `NO_COLOR`), `env` exports + `CODEX_PROFILE_COLOR`, Raycast's Open Profile tints each profile's icon, + and `launcher create` defaults to the profile's colour. Launcher colours + gain `indigo` and `brown`. Choices are stored privately in `appearance.tsv` + and forgotten by `remove`. - Added **Quota in menu bar**, an opt-in gear-menu toggle that shows the lowest remaining Codex quota beside the menu-bar icon and keeps usage current with the same five-minute background read as alerts. diff --git a/README.md b/README.md index 31d2b6d..05fca7a 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,9 @@ profile crosses into low or critical. Both are off by default, and the app never switches profiles for you. Give each profile its own colour and symbol with **Appearance…** in its ⋯ -menu, so work and personal are easy to tell apart. +menu, or `codex-profile appearance work --color teal`, so work and personal +are easy to tell apart. The shell prompt, Raycast and new launchers use the +same colour. For an unavailable reading, use **Sign in to Codex CLI…** in that profile's actions menu, then refresh. Quota follows the profile's **CLI sign-in**. diff --git a/USAGE.md b/USAGE.md index afd1ec4..bb23564 100644 --- a/USAGE.md +++ b/USAGE.md @@ -376,6 +376,30 @@ initialized. Account identity still must be verified in the relevant UI. - Opening a named window never quits the stock window or another profile. +### Give each profile a colour and symbol + +Each profile has one colour and symbol that the menu-bar app, the shell +prompt, Raycast and new launchers all use, so `work` looks the same +everywhere: + +```sh +codex-profile appearance +codex-profile appearance work --color teal --symbol briefcase +codex-profile appearance work --reset +codex-profile appearance --json +``` + +Without a choice, `default` is `graphite` and every other profile takes a +stable colour from its name, so it never changes between launches or when +you add profiles; the symbol is the profile's initial (`monogram`). Colours +are `blue`, `indigo`, `purple`, `pink`, `teal`, `green`, `brown` and +`graphite`; red, orange and yellow are left out because the menu-bar app uses +them for low quota. Symbols are `monogram`, `person`, `house`, `briefcase`, +`building`, `book`, `flask`, `code`, `terminal`, `globe`, `leaf` and `bolt`. +Choices are stored privately in `appearance.tsv` beside the workspace +bindings, and `remove` forgets a removed profile's choice. The command never +reads authentication data. + ### Add named, color-coded macOS launchers Create small launcher apps for profiles you want to distinguish in Finder, @@ -388,8 +412,8 @@ codex-profile launcher list codex-profile launcher path personal ``` -Supported colors are `blue`, `green`, `teal`, `purple`, `pink`, `red`, -`orange`, and `graphite`. Launchers are stored in `~/Applications` by default; +A launcher takes the profile's colour by default. `--color` accepts the +profile colours plus `red` and `orange`. Launchers are stored in `~/Applications` by default; set `CODEX_PROFILE_LAUNCHER_ROOT` to use another directory. Creation derives a deterministically tinted icon from the installed ChatGPT artwork on the local Mac. The project does not redistribute that artwork. @@ -508,7 +532,7 @@ codex-profile use work ``` Activation exports functional `CODEX_HOME` and informational -`CODEX_PROFILE_NAME`. It affects subsequent Codex CLI commands in that shell, +`CODEX_PROFILE_NAME` and `CODEX_PROFILE_COLOR`. It affects subsequent Codex CLI commands in that shell, not an existing ChatGPT window. Open a new shell or unset both variables to deactivate. @@ -523,9 +547,11 @@ eval "$(codex-profile shell-init zsh --prompt)" codex-profile shell-init fish --prompt | source ``` -The dynamic prefix, for example `[codex:work]`, appears only when -`CODEX_PROFILE_NAME` matches the selected managed `CODEX_HOME`. It follows -profile changes and disappears when activation is unset or inconsistent. +The dynamic prefix, for example `● [codex:work]`, appears only when +`CODEX_PROFILE_NAME` matches the selected managed `CODEX_HOME`. The dot takes +the profile's colour from `CODEX_PROFILE_COLOR`, so the prompt never runs the +CLI; set `NO_COLOR=1` to leave it out. The prefix follows profile changes and +disappears when activation is unset or inconsistent. Your existing prompt is preserved; `shell-init` never edits startup files. Add `--completions` to load tab completion at the same time: @@ -683,6 +709,7 @@ codex-profile init [--share-with ] codex-profile setup codex-profile detach codex-profile remove [--yes] +codex-profile appearance [profile] [--color ] [--symbol ] [--reset] [--json] codex-profile launcher create [--name ] [--color ] [--force] codex-profile launcher list [--json] codex-profile launcher path @@ -736,7 +763,7 @@ launch or log mode. | `CODEX_PROFILE_USAGE_TIMEOUT` | Seconds `usage` waits for each profile's quota read, from 1 to 999; default `10`. | | `CODEX_CLI` | Use a specific Codex CLI. An invalid explicit override fails instead of silently selecting another binary. | | `CODEX_BUNDLED_CLI` | Optional fallback Codex CLI checked after `PATH` and before the selected app's bundled CLI. | -| `CODEX_PROFILE_CONFIG_HOME` | Override the private, versioned state directory containing workspace bindings, guard mode, and launcher metadata. | +| `CODEX_PROFILE_CONFIG_HOME` | Override the private, versioned state directory containing workspace bindings, guard mode, profile appearances, and launcher metadata. | | `CODEX_PROFILE_LAUNCHER_ROOT` | Override the macOS launcher install directory (default: `~/Applications`). | | `CODEX_PROFILE_UPGRADE_REPO` | Override the source-upgrade repository. | | `CODEX_PROFILE_UPGRADE_REF` | Override the upgrade git ref; defaults to the latest immutable release. | diff --git a/bin/codex-profile b/bin/codex-profile index bbfcc81..140ac32 100755 --- a/bin/codex-profile +++ b/bin/codex-profile @@ -19,6 +19,10 @@ CODEX_PROFILE_CONFIG_HOME="${CODEX_PROFILE_CONFIG_HOME:-${XDG_CONFIG_HOME:-$HOME CODEX_PROFILE_LAUNCHER_ROOT="${CODEX_PROFILE_LAUNCHER_ROOT:-$HOME/Applications}" WORKSPACE_REGISTRY="$CODEX_PROFILE_CONFIG_HOME/workspaces.tsv" WORKSPACE_GUARD_FILE="$CODEX_PROFILE_CONFIG_HOME/guard-mode" +APPEARANCE_REGISTRY="$CODEX_PROFILE_CONFIG_HOME/appearance.tsv" +# Profile colours leave out red, orange and yellow, which mean low quota. +PROFILE_COLORS="blue indigo purple pink teal green brown graphite" +PROFILE_SYMBOLS="monogram person house briefcase building book flask code terminal globe leaf bolt" LAUNCHER_STATE_DIR="$CODEX_PROFILE_CONFIG_HOME/launchers" STATE_SCHEMA_VERSION="1" STATE_VERSION_FILE="$CODEX_PROFILE_CONFIG_HOME/state-version" @@ -226,6 +230,17 @@ strict | Refuse mismatched profiles.' examples="$PROGRAM workspace guard $PROGRAM workspace guard strict" ;; + appearance) + syntax='appearance [profile] [--color ] [--symbol ] [--reset] [--json]' + description='Show or set the colour and symbol that mark a profile in the menu-bar app, Raycast, the shell prompt and new launchers. Without a choice, default is graphite and other profiles take a stable colour from their name. Stored with the private control state; never reads authentication data.' + options="--color | ${PROFILE_COLORS// /, }. +--symbol | ${PROFILE_SYMBOLS// /, }; monogram shows the profile's initial. +--reset | Return to the automatic colour and initial. +--json, -j | Emit machine-readable JSON." + examples="$PROGRAM appearance +$PROGRAM appearance work --color teal --symbol briefcase +$PROGRAM appearance work --reset" + ;; launcher) syntax='launcher ...' description='Manage named profile launchers for macOS. Launchers open the original signed ChatGPT app; they do not clone it.' @@ -241,7 +256,7 @@ $PROGRAM help launcher create" syntax='launcher create [options]' description='Create a macOS launcher for an initialized profile. The default name is ChatGPT , under ~/Applications unless CODEX_PROFILE_LAUNCHER_ROOT is set.' options='--name | Set the launcher name. ---color | blue (default), green, teal, purple, pink, red, orange, or graphite. +--color | Defaults to the profile colour; blue, indigo, purple, pink, teal, green, brown, graphite, red, or orange. --force, -f | Replace an existing managed launcher.' examples="$PROGRAM launcher create work --name \"ChatGPT Work\" --color teal" ;; @@ -405,6 +420,7 @@ clone-config [--force] remove [--yes] list [--details] | Names, or profile overview cards path | Print the Codex home path +appearance [profile] [--color ] [--symbol ] [--reset] [--json] Workspaces: workspace bind [--force] @@ -945,6 +961,8 @@ workspace_assert_state_paths_safe() { die "Refusing symlinked workspace guard file: $WORKSPACE_GUARD_FILE" [[ ! -L "$STATE_VERSION_FILE" ]] || \ die "Refusing symlinked state version file: $STATE_VERSION_FILE" + [[ ! -L "$APPEARANCE_REGISTRY" ]] || \ + die "Refusing symlinked appearance registry: $APPEARANCE_REGISTRY" if [[ -e "$WORKSPACE_REGISTRY" && ! -f "$WORKSPACE_REGISTRY" ]]; then die "Workspace registry is not a regular file: $WORKSPACE_REGISTRY" @@ -955,6 +973,9 @@ workspace_assert_state_paths_safe() { if [[ -e "$STATE_VERSION_FILE" && ! -f "$STATE_VERSION_FILE" ]]; then die "State version path is not a regular file: $STATE_VERSION_FILE" fi + if [[ -e "$APPEARANCE_REGISTRY" && ! -f "$APPEARANCE_REGISTRY" ]]; then + die "Appearance registry is not a regular file: $APPEARANCE_REGISTRY" + fi } ensure_workspace_config_home() { @@ -1745,6 +1766,7 @@ command_app_instance() { launcher_color_hex() { case "$1" in blue) printf '0A84FF\n' ;; + indigo) printf '5E5CE6\n' ;; green) printf '30D158\n' ;; teal) printf '64D2FF\n' ;; purple) printf 'BF5AF2\n' ;; @@ -1752,6 +1774,7 @@ launcher_color_hex() { red) printf 'FF453A\n' ;; orange) printf 'FF9F0A\n' ;; graphite) printf '8E8E93\n' ;; + brown) printf 'AC8E68\n' ;; *) return 1 ;; esac } @@ -1997,7 +2020,7 @@ EOF command_launcher_create() { local usage="Usage: $PROGRAM launcher create [--name ] [--color ] [--force]" - local profile="${1:-}" name="" color="blue" force=no path state_file old_path="" temp backup="" old_backup="" + local profile="${1:-}" name="" color="" force=no path state_file old_path="" temp backup="" old_backup="" [[ -n "$profile" && "$profile" != -* ]] || die "$usage" shift || true @@ -2028,8 +2051,15 @@ command_launcher_create() { done fi validate_launcher_name "$name" + # The profile's colour by default, so the Dock icon matches the menu, + # prompt and Raycast. + if [[ -z "$color" ]]; then + workspace_assert_state_paths_safe + profile_appearance "$profile" + color="$APPEARANCE_COLOR" + fi launcher_color_hex "$color" > /dev/null || - die "Unsupported launcher color '$color'. Use blue, green, teal, purple, pink, red, orange, or graphite." + die "Unsupported launcher color '$color'. Use blue, indigo, purple, pink, teal, green, brown, graphite, red, or orange." profile_is_initialized "$profile" || die "Profile '$profile' is not initialized; run '$PROGRAM init $profile' first." [[ "$(uname -s)" == "Darwin" ]] || die_macos_only launcher @@ -2798,6 +2828,7 @@ command_remove() { workspace_registry_validate profile_assert_removable "$profile" workspace_remove_profile_bindings "$profile" + appearance_forget_profile "$profile" note "Not initialized $profile ($codex_home)" return 0 fi @@ -2818,6 +2849,7 @@ command_remove() { rm -rf -- "$codex_home" || die "Cannot remove profile home: $codex_home" workspace_remove_profile_bindings "$profile" + appearance_forget_profile "$profile" note "Removed $profile ($codex_home)" } @@ -3607,6 +3639,164 @@ profile_overview_rows() { [[ "$found" == yes ]] || printf 'No initialized profiles. Run %s setup work.\n' "$PROGRAM" } +is_profile_color() { + [[ "$1" =~ ^[a-z]+$ && " $PROFILE_COLORS " == *" $1 "* ]] +} + +is_profile_symbol() { + [[ "$1" =~ ^[a-z]+$ && " $PROFILE_SYMBOLS " == *" $1 "* ]] +} + +# Graphite for default, the stock ChatGPT session. Otherwise FNV-1a over the +# name picks one of the other seven colours, matching the menu-bar app, so a +# profile keeps its colour on every launch and when profiles are added. +profile_automatic_color() { + local profile="$1" hash=2166136261 index byte + local choices=(blue indigo purple pink teal green brown) + + if [[ "$profile" == default ]]; then + printf 'graphite\n' + return 0 + fi + for ((index = 0; index < ${#profile}; index++)); do + printf -v byte '%d' "'${profile:index:1}" + hash=$(( ((hash ^ byte) * 16777619) & 0xFFFFFFFF )) + done + printf '%s\n' "${choices[hash % 7]}" +} + +# Sets APPEARANCE_COLOR, APPEARANCE_SYMBOL and APPEARANCE_AUTOMATIC. Lines +# that do not parse are ignored, so a damaged entry falls back to automatic. +profile_appearance() { + local profile="$1" name color symbol + + APPEARANCE_COLOR="$(profile_automatic_color "$profile")" + APPEARANCE_SYMBOL=monogram + APPEARANCE_AUTOMATIC=true + [[ -f "$APPEARANCE_REGISTRY" ]] || return 0 + while IFS=$'\t' read -r name color symbol || [[ -n "$name" ]]; do + if [[ "$name" != "$profile" ]] || ! is_profile_color "$color" || ! is_profile_symbol "$symbol"; then + continue + fi + APPEARANCE_COLOR="$color" + APPEARANCE_SYMBOL="$symbol" + APPEARANCE_AUTOMATIC=false + done < "$APPEARANCE_REGISTRY" +} + +# Rewrites the registry without the profile's entry, adding a new one when a +# colour and symbol are given. Callers hold the state mutation lock. +appearance_registry_rewrite() { + local profile="$1" color="${2:-}" symbol="${3:-}" name line_color line_symbol temp + + workspace_assert_state_paths_safe + state_schema_version > /dev/null + ensure_workspace_config_home + temp="$(mktemp "$CODEX_PROFILE_CONFIG_HOME/.appearance.tmp.XXXXXX")" || \ + die "Cannot create a temporary appearance registry." + chmod 600 "$temp" || { + rm -f "$temp" + die "Cannot set private permissions on temporary appearance registry." + } + if [[ -f "$APPEARANCE_REGISTRY" ]]; then + while IFS=$'\t' read -r name line_color line_symbol || [[ -n "$name" ]]; do + if [[ "$name" == "$profile" ]] || ! is_valid_profile_name "$name" || \ + ! is_profile_color "$line_color" || ! is_profile_symbol "$line_symbol"; then + continue + fi + printf '%s\t%s\t%s\n' "$name" "$line_color" "$line_symbol" >> "$temp" || { + rm -f "$temp" + die "Cannot write temporary appearance registry." + } + done < "$APPEARANCE_REGISTRY" + fi + if [[ -n "$color" ]]; then + printf '%s\t%s\t%s\n' "$profile" "$color" "$symbol" >> "$temp" || { + rm -f "$temp" + die "Cannot write temporary appearance registry." + } + fi + mv -f "$temp" "$APPEARANCE_REGISTRY" || { + rm -f "$temp" + die "Cannot replace appearance registry: $APPEARANCE_REGISTRY" + } +} + +appearance_forget_profile() { + [[ -f "$APPEARANCE_REGISTRY" ]] || return 0 + appearance_registry_rewrite "$1" +} + +command_appearance() { + local usage="Usage: $PROGRAM appearance [profile] [--color ] [--symbol ] [--reset] [--json]" + local profile="" color="" symbol="" reset=no json=no first=yes name + + while [[ "$#" -gt 0 ]]; do + case "$1" in + --color) + [[ -n "${2:-}" ]] || die "$usage" + color="$2" + shift + ;; + --symbol) + [[ -n "${2:-}" ]] || die "$usage" + symbol="$2" + shift + ;; + --reset) reset=yes ;; + --json | -j) json=yes ;; + -*) die "$usage" ;; + *) + [[ -z "$profile" ]] || die "$usage" + profile="$1" + ;; + esac + shift + done + [[ -z "$color" ]] || is_profile_color "$color" || \ + die "Unsupported color '$color'. Use ${PROFILE_COLORS// /, }." + [[ -z "$symbol" ]] || is_profile_symbol "$symbol" || \ + die "Unsupported symbol '$symbol'. Use ${PROFILE_SYMBOLS// /, }." + workspace_assert_state_paths_safe + + if [[ -n "$color" || -n "$symbol" || "$reset" == yes ]]; then + [[ -n "$profile" ]] || die "$usage" + [[ "$reset" == no || ( -z "$color" && -z "$symbol" ) ]] || \ + die "Use --reset on its own, or --color and --symbol." + validate_profile "$profile" + state_lock_acquire + require_initialized_profile "$profile" + if [[ "$reset" == yes ]]; then + appearance_registry_rewrite "$profile" + else + profile_appearance "$profile" + appearance_registry_rewrite "$profile" "${color:-$APPEARANCE_COLOR}" "${symbol:-$APPEARANCE_SYMBOL}" + fi + elif [[ -n "$profile" ]]; then + require_initialized_profile "$profile" + fi + + [[ "$json" == no ]] || printf '{"profiles":[' + while IFS= read -r name; do + if [[ -n "$profile" ]]; then + [[ "$name" == "$profile" ]] || continue + else + profile_is_initialized "$name" || continue + fi + profile_appearance "$name" + if [[ "$json" == yes ]]; then + if [[ "$first" == yes ]]; then first=no; else printf ','; fi + printf '{"profile":' + json_string "$name" + printf ',"color":"%s","symbol":"%s","automatic":%s}' "$APPEARANCE_COLOR" "$APPEARANCE_SYMBOL" "$APPEARANCE_AUTOMATIC" + else + printf '%s: %s %s%s\n' "$name" "$APPEARANCE_COLOR" "$APPEARANCE_SYMBOL" \ + "$([[ "$APPEARANCE_AUTOMATIC" == false ]] || printf ' (automatic)')" + fi + done < <(if [[ -n "$profile" ]]; then printf '%s\n' "$profile"; else discover_profiles yes; fi) + [[ "$json" == no ]] || printf ']}\n' +} + command_workspace_bind() { local usage="Usage: $PROGRAM workspace bind [--force]" local path="${1:-}" profile="${2:-}" force=no canonical existing @@ -3917,14 +4107,19 @@ command_env() { "$PROGRAM" "$profile" "$codex_home" "$PROGRAM" "$profile" >&2 fi + # The prompt colours its marker from this name without running the CLI. + workspace_assert_state_paths_safe + profile_appearance "$profile" case "$shell" in fish) printf 'set -gx CODEX_HOME %s\n' "$(fish_squote "$codex_home")" printf 'set -gx CODEX_PROFILE_NAME %s\n' "$(fish_squote "$profile")" + printf 'set -gx CODEX_PROFILE_COLOR %s\n' "$APPEARANCE_COLOR" ;; posix | sh | bash | zsh) printf 'export CODEX_HOME=%s\n' "$(shell_squote "$codex_home")" printf 'export CODEX_PROFILE_NAME=%s\n' "$(shell_squote "$profile")" + printf 'export CODEX_PROFILE_COLOR=%s\n' "$APPEARANCE_COLOR" ;; *) die "Unsupported shell '$shell'. Use bash, zsh, or fish." @@ -4508,7 +4703,7 @@ _codex_profile_candidates() { candidate_words='' complete_dirs=no if [[ "$#" -eq 0 ]]; then - candidate_words='app app-instance cli login init setup detach remove launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help' + candidate_words='app app-instance cli login init setup detach remove appearance launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help' return 0 fi shift @@ -4521,7 +4716,7 @@ _codex_profile_candidates() { fi case "$command:$word" in app:-- | app-instance:--) end_options=yes ;; - env:--shell) skip=yes ;; + env:--shell | appearance:--color | appearance:--symbol) skip=yes ;; *:-*) ;; *) positional=$((positional + 1)) ;; esac @@ -4573,6 +4768,16 @@ _codex_profile_candidates() { candidate_words='--force -f' [[ "$positional" -ge 2 || "$cur" == -* ]] || profiles=yes ;; + appearance) + case "$prev" in + --color) candidate_words='blue indigo purple pink teal green brown graphite' ;; + --symbol) candidate_words='monogram person house briefcase building book flask code terminal globe leaf bolt' ;; + *) + candidate_words='--color --symbol --reset --json -j' + [[ "$positional" -ne 0 || "$cur" == -* ]] || profiles=yes + ;; + esac + ;; launcher) if [[ "$#" -eq 0 ]]; then candidate_words='create list path remove' else @@ -4582,7 +4787,7 @@ _codex_profile_candidates() { elif [[ "$1" == remove && "$#" -eq 2 ]]; then candidate_words='--yes -y' elif [[ "$1" == create ]]; then case "$prev" in - --color) candidate_words='blue green teal purple pink red orange graphite' ;; + --color) candidate_words='blue indigo purple pink teal green brown graphite red orange' ;; --name) ;; *) candidate_words='--name --color --force -f' ;; esac @@ -4621,7 +4826,7 @@ _codex_profile_candidates() { list) [[ "$#" -ne 0 ]] || candidate_words='--details' ;; help) if [[ "$#" -eq 0 ]]; then - candidate_words='app app-instance cli login init setup detach remove launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help' + candidate_words='app app-instance cli login init setup detach remove appearance launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help' elif [[ "$#" -eq 1 ]]; then case "$1" in workspace) candidate_words='bind unbind list status guard' ;; @@ -4711,7 +4916,7 @@ function __codex_profile_complete set -l profiles no set -l directories no if test (count $args) -eq 0 - printf '%s\n' app app-instance cli login init setup detach remove launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help + printf '%s\n' app app-instance cli login init setup detach remove appearance launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help return end set -l cmd $args[1] @@ -4734,7 +4939,7 @@ function __codex_profile_complete switch "$cmd:$word" case app:-- app-instance:-- set end_options yes - case env:--shell + case env:--shell appearance:--color appearance:--symbol set skip yes case '*:-*' case '*' @@ -4793,6 +4998,18 @@ function __codex_profile_complete else set candidates --path --tail --instance end + case appearance + switch "$prev" + case --color + set candidates blue indigo purple pink teal green brown graphite + case --symbol + set candidates monogram person house briefcase building book flask code terminal globe leaf bolt + case '*' + set candidates --color --symbol --reset --json -j + if test $positional -eq 0; and not string match -q -- '-*' "$cur" + set profiles yes + end + end case launcher if test $count -eq 0 set candidates create list path remove @@ -4806,7 +5023,7 @@ function __codex_profile_complete else if test $args[1] = create switch "$prev" case --color - set candidates blue green teal purple pink red orange graphite + set candidates blue indigo purple pink teal green brown graphite red orange case --name case '*' set candidates --name --color --force -f @@ -4854,7 +5071,7 @@ function __codex_profile_complete string match -q -- '-*' "$cur"; or set profiles yes case help if test $count -eq 0 - set candidates app app-instance cli login init setup detach remove launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help + set candidates app app-instance cli login init setup detach remove appearance launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help else if test $count -eq 1 switch $args[1] case workspace @@ -4914,7 +5131,14 @@ emit_shell_prompt() { bash | zsh) cat <<'EOF' __codex_profile_update_prompt() { - local __cp_status=$? __cp_home="${HOME:-}/.codex" __cp_kept='' __cp_rest="${PS1:-}" __cp_name + local __cp_status=$? __cp_home="${HOME:-}/.codex" __cp_kept='' __cp_rest __cp_name __cp_code __cp_dot='' + # The coloured dot before the marker uses fixed escapes, so earlier ones + # are removed exactly before the markers themselves. + for __cp_code in 33 62 97 161 30 28 95 244; do + PS1="${PS1//"%F{$__cp_code}●%f "/}" + PS1="${PS1//"\\[\\e[38;5;${__cp_code}m\\]●\\[\\e[39m\\] "/}" + done + __cp_rest="${PS1:-}" # Tools such as Python venv save and restore PS1 with an earlier indicator # inside it, so remove every [codex:] indicator, not just a leading one. while [[ "$__cp_rest" == *'[codex:'* ]]; do @@ -4933,9 +5157,28 @@ __codex_profile_update_prompt() { [[ "$CODEX_PROFILE_NAME" == default ]] || __cp_home="$HOME/.codex-$CODEX_PROFILE_NAME" if [[ "${CODEX_HOME:-}" == "$__cp_home" ]]; then __codex_profile_prefix="[codex:$CODEX_PROFILE_NAME] " + # 256-colour approximations of the profile colours; NO_COLOR omits the dot. + case "${CODEX_PROFILE_COLOR:-}" in + blue) __cp_code=33 ;; + indigo) __cp_code=62 ;; + purple) __cp_code=97 ;; + pink) __cp_code=161 ;; + teal) __cp_code=30 ;; + green) __cp_code=28 ;; + brown) __cp_code=95 ;; + graphite) __cp_code=244 ;; + *) __cp_code='' ;; + esac + if [[ -n "$__cp_code" && -z "${NO_COLOR+x}" ]]; then + if [[ -n "${ZSH_VERSION:-}" ]]; then + __cp_dot="%F{$__cp_code}●%f " + else + __cp_dot="\\[\\e[38;5;${__cp_code}m\\]●\\[\\e[39m\\] " + fi + fi fi fi - PS1="$__codex_profile_prefix${PS1:-}" + PS1="$__cp_dot$__codex_profile_prefix${PS1:-}" return "$__cp_status" } EOF @@ -4971,6 +5214,32 @@ function fish_prompt set __cp_home "$HOME/.codex-$CODEX_PROFILE_NAME" end if test "$CODEX_HOME" = "$__cp_home" + # A dot in the profile colour; NO_COLOR omits it. + set -l __cp_hex + switch "$CODEX_PROFILE_COLOR" + case blue + set __cp_hex 0087ff + case indigo + set __cp_hex 5f5fd7 + case purple + set __cp_hex 875faf + case pink + set __cp_hex d7005f + case teal + set __cp_hex 008787 + case green + set __cp_hex 008700 + case brown + set __cp_hex 875f5f + case graphite + set __cp_hex 808080 + end + if test -n "$__cp_hex"; and not set -q NO_COLOR + set_color $__cp_hex + printf '●' + set_color normal + printf ' ' + end printf '[codex:%s] ' "$CODEX_PROFILE_NAME" end end @@ -5241,7 +5510,7 @@ main() { fi case "$command" in - status | usage | doctor | workspace | launcher) + status | usage | doctor | workspace | launcher | appearance) for arg in "$@"; do if [[ "$arg" == "--json" || "$arg" == "-j" ]]; then json_command=yes @@ -5283,6 +5552,9 @@ main() { launcher) command_launcher "$@" ;; + appearance) + command_appearance "$@" + ;; workspace) command_workspace "$@" ;; diff --git a/docs/llms.txt b/docs/llms.txt index 49f3fa4..719fa55 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -226,7 +226,9 @@ Profile rows show the tightest Codex CLI window's remaining percentage, reported duration and a ring; a window at 25% or less is highlighted and its reset countdown replaces the row description. The profile's actions menu lists every window. Hover for reset times. Each profile has a colour and symbol, -automatic until chosen with Appearance… in its actions. An opt-in, default-off +automatic until chosen with Appearance… in its actions or `codex-profile +appearance --color --symbol `; the shell prompt, +Raycast and new launchers use the same colour. An opt-in, default-off Quota in menu bar toggle shows the lowest remaining percentage beside the menu-bar icon and reads usage about every five minutes while on. Usage is read through the official app-server method account/rateLimits/read, cached in @@ -290,8 +292,9 @@ histories, memories, connectors, plans, limits, or cloud tasks. ## Named and color-coded macOS launchers `codex-profile launcher create [--name ] [--color ] -[--force]` creates a small unsigned app in `~/Applications` by default. Supported colors -are blue, green, teal, purple, pink, red, orange, and graphite. The command +[--force]` creates a small unsigned app in `~/Applications` by default. It takes +the profile's appearance colour unless `--color` names blue, indigo, purple, pink, +teal, green, brown, graphite, red, or orange. The command derives a deterministic tint from the installed ChatGPT icon locally and does not redistribute OpenAI artwork. @@ -312,9 +315,15 @@ refuses while its managed launcher exists. - `codex-profile path ` prints the profile's `CODEX_HOME` without creating it. +- `codex-profile appearance [profile] [--color ] [--symbol ] + [--reset] [--json]` shows or sets the colour and symbol the menu-bar app, + shell prompt, Raycast and new launchers use. Without a choice, default is + graphite and other profiles take a stable colour from their name. Colours + are blue, indigo, purple, pink, teal, green, brown and graphite; red, orange + and yellow are reserved for low quota. It never reads authentication data. - `codex-profile remove [--yes]` permanently deletes that profile's home after a typed confirmation (or `--yes`) and clears its workspace - bindings. It refuses while another profile links its configuration or while + bindings and appearance. It refuses while another profile links its configuration or while its managed launcher exists. - `codex-profile logs [--path|--tail [lines]]` prints the named ChatGPT window's desktop log, its path, or its last lines (default 50). diff --git a/macos/CodexProfilesMenu/DESIGN.md b/macos/CodexProfilesMenu/DESIGN.md index eb100bd..49825a1 100644 --- a/macos/CodexProfilesMenu/DESIGN.md +++ b/macos/CodexProfilesMenu/DESIGN.md @@ -54,10 +54,17 @@ Only explicitly adding a workspace asks for a folder. App-level setup, sign-in, the Return destination, Open at Login, Low-quota alerts, Quota in menu bar, About and Quit live in the gear menu. -Each profile has a colour and a symbol. Until the person chooses, `default` +Each profile has a colour and a symbol, stored by the CLI's `appearance` +command so the prompt, Raycast and new launchers show the same ones. The menu +reads them with every profile refresh and writes a choice through the CLI: +the choice shows at once, writes run in order so quick choices cannot land +out of order, a refresh never overwrites a write still in flight, and a +refused write shows the CLI's reason and reloads what it holds. Until the +person chooses, `default` is graphite and every other profile takes one of seven colours from an FNV-1a hash of its name, so the colour stays the same on every launch and does not -shift when profiles are added; the symbol is the profile's initial. The +shift when profiles are added; the symbol is the profile's initial. The app +and the CLI compute this identically, which the real-CLI test checks. The palette of blue, indigo, purple, pink, teal, green, brown and graphite leaves out red, orange and yellow so they keep meaning low quota, and white symbols keep 4.5:1 contrast on every colour. Appearance… in a profile's actions @@ -120,8 +127,8 @@ removal and relocation update affected rows before reloading, so a failed refresh cannot restore an old launch identity. Superseded or removed rows cannot launch. The CLI remains the only writer of profile and binding state; the GUI persists only its pins, -recents, destination preference, profile colours and symbols, and its -Low-quota alerts and menu-bar figure choices in UserDefaults. Profile-only launches start +recents, destination preference, and its Low-quota alerts and menu-bar figure +choices in UserDefaults. Profile colours and symbols belong to the CLI. Profile-only launches start in the home directory, avoiding accidental project-binding inheritance. Explicit workspace launches pass their folder to the CLI and preserve its guard rules. diff --git a/macos/CodexProfilesMenu/Previews/RenderPreview.swift b/macos/CodexProfilesMenu/Previews/RenderPreview.swift index ab37ef2..4268da0 100644 --- a/macos/CodexProfilesMenu/Previews/RenderPreview.swift +++ b/macos/CodexProfilesMenu/Previews/RenderPreview.swift @@ -36,14 +36,15 @@ struct RenderPreview { ]), checkedAt: now) } // One healthy, one low and one critical profile show every meter state. + // Chosen colours and symbols, as in the design; new profiles get automatic ones. store.loadPreview(bindings, profiles: profiles, usage: [ "default": sample(18, 36), "personal": sample(43, 78), "work": sample(93, 59), + ], appearances: [ + "default": ProfileAppearance(colour: .graphite, symbol: .person), + "personal": ProfileAppearance(colour: .green, symbol: .house), + "work": ProfileAppearance(colour: .blue, symbol: .briefcase), ]) if let first = bindings.first { store.togglePin(first) } - // Chosen colours and symbols, as in the design; new profiles get automatic ones. - store.setAppearance(ProfileAppearance(colour: .graphite, symbol: .person), for: "default") - store.setAppearance(ProfileAppearance(colour: .green, symbol: .house), for: "personal") - store.setAppearance(ProfileAppearance(colour: .blue, symbol: .briefcase), for: "work") if CommandLine.arguments.contains("--no-results") { store.query = "does-not-exist" } let controller = WorkspaceMenuViewController(store: store, defaults: defaults) diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift index 1217813..9569249 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/CLIClient.swift @@ -103,6 +103,56 @@ struct CLIClient: Sendable { }.value } + /// Every initialized profile's colour and symbol, chosen or automatic. + func loadAppearances() async throws -> [String: ProfileAppearance] { + let executableURL = try requiredExecutableURL() + return try await Task.detached(priority: .userInitiated) { + let result = try await ProcessRunner().run(executableURL: executableURL, arguments: ["appearance", "--json"]) + guard result.terminationStatus == 0 else { + throw CLIClientError.commandFailed(Self.errorMessage(from: result)) + } + return try Self.parseAppearances(result.standardOutput) + }.value + } + + /// Saves a profile's colour and symbol with the CLI, which the prompt, + /// Raycast and launchers also read; nil restores the automatic one. + func setAppearance(_ appearance: ProfileAppearance?, for profile: String) async throws { + guard Self.isValidProfileName(profile) else { throw CLIClientError.invalidProfileName } + let executableURL = try requiredExecutableURL() + let choice = appearance.map { ["--color", $0.colour.rawValue, "--symbol", $0.symbol.rawValue] } ?? ["--reset"] + try await Task.detached(priority: .userInitiated) { + let result = try await ProcessRunner().run(executableURL: executableURL, arguments: ["appearance", profile] + choice) + guard result.terminationStatus == 0 else { + throw CLIClientError.commandFailed(Self.errorMessage(from: result)) + } + }.value + } + + /// Entries naming a colour or symbol this app does not know are skipped, + /// so those profiles keep their automatic appearance. + static func parseAppearances(_ data: Data) throws -> [String: ProfileAppearance] { + struct Entry: Decodable { + let profile: String + let color: String + let symbol: String + } + struct Response: Decodable { let profiles: [Entry] } + let response: Response + do { + response = try JSONDecoder().decode(Response.self, from: data) + } catch { + throw CLIClientError.commandFailed("Could not read profile appearances: \(error.localizedDescription)") + } + var appearances: [String: ProfileAppearance] = [:] + for entry in response.profiles { + guard let colour = ProfileAppearance.Colour(rawValue: entry.color), + let symbol = ProfileAppearance.Symbol(rawValue: entry.symbol) else { continue } + appearances[entry.profile] = ProfileAppearance(colour: colour, symbol: symbol) + } + return appearances + } + func loadProfiles() async throws -> [String] { let executableURL = try requiredExecutableURL() return try await Task.detached(priority: .userInitiated) { diff --git a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift index 1366390..57800ad 100644 --- a/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift +++ b/macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceStore.swift @@ -20,6 +20,11 @@ final class WorkspaceStore { private(set) var phase: Phase = .idle private(set) var workspaces: [WorkspaceBinding] = [] + /// Profile colours and symbols from the CLI. Profiles it has not + /// reported use their automatic appearance. + private(set) var appearances: [String: ProfileAppearance] = [:] + private var appearanceWrite: Task? + private var pendingAppearanceWrites = 0 private(set) var profiles: [String] = [] private(set) var usage: [String: ProfileUsage] = [:] { didSet { if usage != oldValue { onUsageChange?() } } @@ -41,7 +46,6 @@ final class WorkspaceStore { private let defaults: UserDefaults private let recentsKey = "workspaceRecents" private let pinsKey = "workspacePins" - private let appearanceKey = "profileAppearance" private var refreshTask: Task? private var refreshRequested = false /// Whether the refresh in flight reports progress and its outcome in the @@ -83,22 +87,37 @@ final class WorkspaceStore { (defaults.stringArray(forKey: pinsKey) ?? []).contains(workspace.id) } - /// The profile's chosen colour and symbol, or its automatic appearance. + /// The profile's colour and symbol as the CLI last reported them, or its + /// automatic appearance. func appearance(for profile: String) -> ProfileAppearance { - let automatic = ProfileAppearance.automatic(for: profile) - guard let saved = defaults.dictionary(forKey: appearanceKey)?[profile] as? [String: String] else { return automatic } - return ProfileAppearance( - colour: saved["colour"].flatMap(ProfileAppearance.Colour.init) ?? automatic.colour, - symbol: saved["symbol"].flatMap(ProfileAppearance.Symbol.init) ?? automatic.symbol - ) - } - - /// Saves a profile's appearance; nil restores the automatic one. - func setAppearance(_ appearance: ProfileAppearance?, for profile: String) { - var saved = defaults.dictionary(forKey: appearanceKey) ?? [:] - saved[profile] = appearance.map { ["colour": $0.colour.rawValue, "symbol": $0.symbol.rawValue] } - defaults.set(saved, forKey: appearanceKey) + appearances[profile] ?? .automatic(for: profile) + } + + /// Saves through the CLI, which the prompt, Raycast and launchers read + /// too. The menu shows the choice at once; writes run in order, so quick + /// choices cannot land out of order. A refused write reloads what the CLI + /// holds rather than guessing. The returned task finishes with the write. + @discardableResult + func setAppearance(_ appearance: ProfileAppearance?, for profile: String) -> Task { + appearances[profile] = appearance ?? .automatic(for: profile) + pendingAppearanceWrites += 1 notify() + let earlier = appearanceWrite + let client = self.client + let write = Task { @MainActor in + await earlier?.value + do { + try await client.setAppearance(appearance, for: profile) + } catch { + statusMessage = error.localizedDescription + onError?(statusMessage) + if let loaded = try? await client.loadAppearances() { appearances = loaded } + } + pendingAppearanceWrites -= 1 + notify() + } + appearanceWrite = write + return write } func usageState(for profile: String) -> UsageState { @@ -259,7 +278,11 @@ final class WorkspaceStore { do { async let workspaceResponse = client.loadWorkspaces() async let profileResponse = client.loadProfiles() + async let appearanceResponse = client.loadAppearances() let (response, loadedProfiles) = try await (workspaceResponse, profileResponse) + // Appearance only decorates: a failed read keeps what is shown, + // and a write in flight is not overwritten by an older reading. + if let loaded = try? await appearanceResponse, pendingAppearanceWrites == 0 { appearances = loaded } workspaces = response.bindings profiles = loadedProfiles usage = usage.filter { profiles.contains($0.key) } @@ -483,8 +506,10 @@ final class WorkspaceStore { } func loadPreview(_ bindings: [WorkspaceBinding], profiles previewProfiles: [String]? = nil, usage previewUsage: [String: ProfileUsage] = [:], - readingUsage previewReading: Set = [], changingPaths previewChanging: Set = []) { + readingUsage previewReading: Set = [], changingPaths previewChanging: Set = [], + appearances previewAppearances: [String: ProfileAppearance] = [:]) { workspaces = bindings + appearances = previewAppearances profiles = Array(Set(previewProfiles ?? bindings.filter(\.profileExists).map(\.profile))).sorted() // Like refreshUsage, settle readings before usage observers run. readingUsage = previewReading diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift index 1d635ae..9ab527f 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/MenuInteractionTests.swift @@ -364,11 +364,15 @@ struct MenuInteractionTests { expect(swatches.filter { $0.state == .on }.map(\.colour) == [automatic.colour] && symbolChoices.filter { $0.state == .on }.map(\.symbol) == [.monogram], "the current colour and symbol must be marked as chosen") + try? FileManager.default.removeItem(at: marker) swatches.first { $0.colour == .teal }!.performClick(nil) symbolChoices.first { $0.symbol == .flask }!.performClick(nil) let chosen = ProfileAppearance(colour: .teal, symbol: .flask) - expect(store.appearance(for: "work") == chosen && WorkspaceStore(client: CLIClient(executableURL: nil), defaults: defaults).appearance(for: "work") == chosen, - "choices must apply at once and persist") + expect(store.appearance(for: "work") == chosen, "choices must show at once") + // The CLI holds appearances for every tool; the shim records the last write. + let savedChoice = "appearance\nwork\n--color\nteal\n--symbol\nflask\n" + try await waitUntil { (try? String(contentsOf: marker, encoding: .utf8)) == savedChoice } + expect((try? String(contentsOf: marker, encoding: .utf8)) == savedChoice, "choices must be saved through the CLI, in the order made") expect(descendants(controller.view).compactMap { $0 as? AppearanceEditorView }.first === editor && swatches.filter { $0.state == .on }.map(\.colour) == [.teal], "a choice must update the editor in place, so focus stays put") @@ -379,6 +383,8 @@ struct MenuInteractionTests { controller.editAppearance(of: "work") descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.title == "Reset" }!.performClick(nil) expect(store.appearance(for: "work") == automatic, "Reset must restore the automatic appearance") + try await waitUntil { (try? String(contentsOf: marker, encoding: .utf8)) == "appearance\nwork\n--reset\n" } + expect((try? String(contentsOf: marker, encoding: .utf8)) == "appearance\nwork\n--reset\n", "Reset must be saved through the CLI") descendants(controller.view).compactMap { $0 as? NSButton }.first { $0.title == "Done" }!.performClick(nil) expect(descendants(controller.view).contains { $0 is LaunchRowButton } && !descendants(controller.view).contains { $0 is AppearanceEditorView }, "Done must return to the list") diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift index cc4c1c2..95202e3 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/TestMain.swift @@ -169,6 +169,28 @@ struct CodexProfilesMenuTests { expect(created && store.profiles == ["work-main"], "profile creation did not refresh the GUI") expect(store.workspaces.isEmpty && store.filteredTargets == [.profile("work-main")], "creating a profile must immediately make it launchable without binding a folder") + // Appearances live with the CLI, which must agree with the app on the + // automatic choice and keep a chosen one for every tool. + for name in ["personal", "client-a", "z"] { _ = await store.createProfile(name) } + let automaticFromCLI = try await client.loadAppearances() + expect(["work-main", "personal", "client-a", "z"].allSatisfy { automaticFromCLI[$0] == .automatic(for: $0) }, + "the CLI and the app must pick the same automatic appearance") + let chosen = ProfileAppearance(colour: .teal, symbol: .flask) + await store.setAppearance(chosen, for: "work-main").value + let reopened = WorkspaceStore(client: client, defaults: defaults) + _ = await reopened.refresh() + expect(reopened.appearance(for: "work-main") == chosen, "a chosen appearance must persist through the CLI") + await store.setAppearance(nil, for: "work-main").value + _ = await reopened.refresh() + expect(reopened.appearance(for: "work-main") == .automatic(for: "work-main"), "a reset must reach the CLI") + await store.setAppearance(chosen, for: "missing-profile").value + expect(store.appearance(for: "missing-profile") == .automatic(for: "missing-profile") && store.statusMessage.contains("not initialized"), + "a refused write must show the CLI's reason and fall back to what the CLI holds") + for name in ["personal", "client-a", "z"] { + _ = try await ProcessRunner().run(executableURL: URL(fileURLWithPath: cliPath), arguments: ["remove", name, "--yes"]) + } + _ = await store.refresh() + let profileHome = URL(fileURLWithPath: home).appendingPathComponent(".codex-work-main") let permissions = try FileManager.default.attributesOfItem(atPath: profileHome.path)[.posixPermissions] as? NSNumber expect(permissions?.intValue == 0o700, "created profile is not private") diff --git a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift index 7ecede4..f64ec61 100644 --- a/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift +++ b/macos/CodexProfilesMenu/Tests/CodexProfilesMenuTests/UsageTests.swift @@ -266,6 +266,7 @@ enum UsageTests { # it is not a usage read, so it is not counted. if [ "$1" = list ]; then [ -f "$fixture/profiles" ] && cat "$fixture/profiles"; exit $?; fi if [ "$1" = workspace ]; then [ -f "$fixture/workspaces.json" ] && cat "$fixture/workspaces.json"; exit $?; fi + if [ "$1" = appearance ]; then [ -f "$fixture/appearance.json" ] && cat "$fixture/appearance.json"; exit $?; fi profile=${2:-work} if [ "$1" = app-server ]; then profile=work; fi printf '%s\n' "$@" > "$fixture/arguments-$profile" diff --git a/macos/README.md b/macos/README.md index 9ef9cfc..8eb9965 100644 --- a/macos/README.md +++ b/macos/README.md @@ -52,7 +52,9 @@ Each profile has a coloured tile. New profiles get a colour from their name, with `default` in graphite, and show their initial. **Appearance…** lets you choose one of eight colours and twelve symbols; changes apply at once and **Done** or Escape returns to the list. Red, orange and yellow are left out -because they mean low quota. +because they mean low quota. The choice is saved with `codex-profile +appearance`, so the shell prompt, Raycast and new launchers use the same +colour. Actions that cannot apply, such as revealing a missing folder, are disabled. Search receives focus when the menu opens. Typing selects the first diff --git a/raycast/README.md b/raycast/README.md index 73ae4e9..1984d0f 100644 --- a/raycast/README.md +++ b/raycast/README.md @@ -13,10 +13,13 @@ Community-maintained; not affiliated with OpenAI. Lists initialized profiles. Return opens the profile's ChatGPT window; ⌘Return opens Codex CLI for it in Terminal. It also copies the profile's `CODEX_HOME` path, shows it in Finder, or starts Codex CLI sign-in, and shows -each profile's lowest remaining quota when a reading is available. +each profile's lowest remaining quota when a reading is available. Each +profile's icon takes the colour set with `codex-profile appearance`, the same +one the menu bar app shows; an older codex-profile without that command keeps +plain icons. -Uses `list`, `path `, `app `, `cli `, -`login ` and `usage --json`. +Uses `list`, `path `, `appearance --json`, `app `, +`cli `, `login ` and `usage --json`. ### Open Workspace diff --git a/raycast/src/cli.ts b/raycast/src/cli.ts index a27542c..7ba83db 100644 --- a/raycast/src/cli.ts +++ b/raycast/src/cli.ts @@ -11,6 +11,7 @@ import { failureMessage, isUsableWorkspacePath, isValidProfileName, + parseAppearances, parseProfileList, parseWorkspaceList, type CommandOutput, @@ -208,6 +209,8 @@ export interface ProfileEntry { name: string; /** CODEX_HOME, as printed by `codex-profile path`. */ home?: string; + /** The profile's colour as a hex string, from `codex-profile appearance`. */ + color?: string; } /** Initialized profile names from `codex-profile list`. */ @@ -217,19 +220,31 @@ async function listProfiles(cli: string, signal?: AbortSignal): Promise> { + // Colours only decorate: a CLI without the command keeps plain icons. + try { + const result = await runCli(cli, ["appearance", "--json"], { signal, timeout: 10_000 }); + return result.code === 0 ? parseAppearances(result.stdout) : new Map(); + } catch (error) { + if (signal?.aborted) throw error; + return new Map(); + } +} + /** `codex-profile list`, then `codex-profile path ` for each initialized profile. */ export async function loadProfiles(cli: string, signal?: AbortSignal): Promise { - const names = await listProfiles(cli, signal); + const [names, colors] = await Promise.all([listProfiles(cli, signal), loadProfileColors(cli, signal)]); return Promise.all( names.map(async (name): Promise => { // The home is optional decoration: one failed lookup keeps the list. try { const path = await runCli(cli, ["path", name], { signal, timeout: 10_000 }); const home = path.code === 0 ? path.stdout.trim().split(/\r?\n/).pop() : undefined; - return { name, home: home && home.startsWith("/") ? home : undefined }; + return { name, home: home && home.startsWith("/") ? home : undefined, color: colors.get(name) }; } catch (error) { if (signal?.aborted) throw error; - return { name }; + return { name, color: colors.get(name) }; } }), ); diff --git a/raycast/src/lib/profile.ts b/raycast/src/lib/profile.ts index d4f98cc..7629371 100644 --- a/raycast/src/lib/profile.ts +++ b/raycast/src/lib/profile.ts @@ -78,6 +78,44 @@ export function parseWorkspaceList(stdout: string): WorkspaceList { return { guardMode: typeof value.guard_mode === "string" ? value.guard_mode : "warn", bindings }; } +/** + * Profile colours as the menu-bar app draws its tiles, keyed by the names + * `codex-profile appearance` uses. Red, orange and yellow mean low quota and + * are not profile colours. + */ +export const PROFILE_COLOR_HEX: Readonly> = { + blue: "#0A6CDB", + indigo: "#4E4CC4", + purple: "#8944AB", + pink: "#C7255B", + teal: "#0B7A84", + green: "#24853B", + brown: "#86664A", + graphite: "#737378", +}; + +/** Parses `codex-profile appearance --json` into profile colours. Unknown colours are skipped. */ +export function parseAppearances(stdout: string): Map { + let value: unknown; + try { + value = JSON.parse(stdout); + } catch { + throw new Error("codex-profile appearance returned output that is not JSON."); + } + if (!isRecord(value) || !Array.isArray(value.profiles)) { + throw new Error("codex-profile appearance returned JSON without a profiles list."); + } + const colors = new Map(); + for (const row of value.profiles) { + if (!isRecord(row)) continue; + const { profile, color } = row; + if (typeof profile !== "string" || typeof color !== "string" || !isValidProfileName(profile)) continue; + const hex = Object.hasOwn(PROFILE_COLOR_HEX, color) ? PROFILE_COLOR_HEX[color] : undefined; + if (hex) colors.set(profile, hex); + } + return colors; +} + /** Why a binding cannot open, or undefined when it can. */ export function bindingProblem(binding: WorkspaceBinding): string | undefined { if (!binding.pathExists && !binding.profileExists) return "Folder and profile are missing"; diff --git a/raycast/src/open-profile.tsx b/raycast/src/open-profile.tsx index 878b310..98e98ca 100644 --- a/raycast/src/open-profile.tsx +++ b/raycast/src/open-profile.tsx @@ -103,7 +103,7 @@ function ProfileItem(props: { return ( { }); }); +describe("appearances", () => { + test("maps each profile to its colour and skips anything unknown", () => { + const colors = parseAppearances( + JSON.stringify({ + profiles: [ + { profile: "work", color: "teal", symbol: "flask", automatic: false }, + { profile: "default", color: "graphite", symbol: "monogram", automatic: true }, + { profile: "client", color: "crimson", symbol: "monogram", automatic: false }, + { profile: "bad name", color: "teal", symbol: "monogram", automatic: false }, + { profile: "proto", color: "constructor", symbol: "monogram", automatic: false }, + "not an object", + ], + }), + ); + assert.deepEqual( + [...colors], + [ + ["work", "#0B7A84"], + ["default", "#737378"], + ], + ); + }); + + test("rejects output that is not an appearance list", () => { + assert.throws(() => parseAppearances("work: teal flask"), /not JSON/); + assert.throws(() => parseAppearances("{}"), /without a profiles list/); + }); + + test("offers exactly the CLI's colours", () => { + const source = readFileSync(CLI, "utf8"); + const match = source.match(/^PROFILE_COLORS="([a-z ]+)"$/m); + assert.ok(match, "bin/codex-profile defines PROFILE_COLORS"); + assert.deepEqual(Object.keys(PROFILE_COLOR_HEX).sort(), match[1].split(" ").sort()); + }); + + test("uses the menu-bar app's tile colours", () => { + const models = readFileSync( + new URL("../../macos/CodexProfilesMenu/Sources/CodexProfilesMenu/WorkspaceModels.swift", import.meta.url), + "utf8", + ); + const hex = (value: string) => + Math.round(Number(value) * 255) + .toString(16) + .toUpperCase() + .padStart(2, "0"); + let checked = 0; + for (const [, name, red, green, blue] of models.matchAll(/case \.(\w+): \(([\d.]+), ([\d.]+), ([\d.]+)\)/g)) { + assert.equal(PROFILE_COLOR_HEX[name], `#${hex(red)}${hex(green)}${hex(blue)}`, name); + checked += 1; + } + assert.equal(checked, Object.keys(PROFILE_COLOR_HEX).length); + }); +}); + describe("paths", () => { test("shortens paths inside the home directory", () => { assert.equal(displayPath("/Users/me/Dev/app", "/Users/me"), "~/Dev/app"); diff --git a/test/cli/appearance-test.sh b/test/cli/appearance-test.sh new file mode 100755 index 0000000..e58cf6d --- /dev/null +++ b/test/cli/appearance-test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/test/lib/assertions.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM +source "$ROOT_DIR/test/lib/cli-fixtures.sh" + +appearance_env() { + local tmp="$1" + shift + env HOME="$tmp/home" CODEX_PROFILE_CONFIG_HOME="$tmp/config" CODEX_PROFILE_NO_UPDATE_CHECK=1 "$@" +} + +test_automatic_appearance_is_stable_and_graphite_for_default() { + local tmp="$TMP_ROOT/automatic" + mkdir -p "$tmp/home/.codex" "$tmp/home/.codex-work" "$tmp/home/.codex-personal" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance + assert_status 0 + # These match ProfileAppearance.automatic in the menu-bar app. + assert_equals $'default: graphite monogram (automatic)\npersonal: pink monogram (automatic)\nwork: indigo monogram (automatic)' + [[ ! -e "$tmp/config/appearance.tsv" ]] || fail "reading an appearance must not create the registry" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --json + assert_status 0 + assert_equals '{"profiles":[{"profile":"work","color":"indigo","symbol":"monogram","automatic":true}]}' +} + +test_choices_persist_privately_and_reset() { + local tmp="$TMP_ROOT/choices" + mkdir -p "$tmp/home/.codex-work" "$tmp/home/.codex-personal" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --color teal --symbol flask + assert_status 0 + assert_equals 'work: teal flask' + [[ "$(mode_of "$tmp/config")" == 700 ]] || fail "config directory is not private" + [[ "$(mode_of "$tmp/config/appearance.tsv")" == 600 ]] || fail "appearance registry is not private" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --symbol house + assert_status 0 + assert_equals 'work: teal house' + run_cmd appearance_env "$tmp" "$SCRIPT" appearance personal --color green + assert_status 0 + run_cmd appearance_env "$tmp" "$SCRIPT" appearance --json + assert_status 0 + assert_equals '{"profiles":[{"profile":"personal","color":"green","symbol":"monogram","automatic":false},{"profile":"work","color":"teal","symbol":"house","automatic":false}]}' + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --reset + assert_status 0 + assert_equals 'work: indigo monogram (automatic)' + [[ "$(cat "$tmp/config/appearance.tsv")" == $'personal\tgreen\tmonogram' ]] || fail "reset touched another profile's entry" +} + +test_invalid_requests_are_refused() { + local tmp="$TMP_ROOT/invalid" + mkdir -p "$tmp/home/.codex-work" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --color red + assert_status 1 + assert_contains "Unsupported color 'red'" + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --symbol rocket + assert_status 1 + assert_contains "Unsupported symbol 'rocket'" + run_cmd appearance_env "$tmp" "$SCRIPT" appearance missing --color teal + assert_status 1 + assert_contains "Profile 'missing' is not initialized" + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --reset --color teal + assert_status 1 + assert_contains 'Use --reset on its own' + run_cmd appearance_env "$tmp" "$SCRIPT" appearance --color teal + assert_status 1 + assert_contains 'Usage:' + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --tint teal + assert_status 1 + assert_contains 'Usage:' + [[ ! -e "$tmp/config/appearance.tsv" ]] || fail "a refused request wrote the registry" +} + +test_damaged_entries_fall_back_and_symlinks_are_refused() { + local tmp="$TMP_ROOT/damaged" + mkdir -p "$tmp/home/.codex-work" "$tmp/config" + printf 'work\tcrimson\tflask\nbad name\tteal\tflask\n' > "$tmp/config/appearance.tsv" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work + assert_status 0 + assert_equals 'work: indigo monogram (automatic)' + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --color teal + assert_status 0 + [[ "$(cat "$tmp/config/appearance.tsv")" == $'work\tteal\tmonogram' ]] || fail "a rewrite kept damaged entries" + + rm "$tmp/config/appearance.tsv" + ln -s "$tmp/elsewhere" "$tmp/config/appearance.tsv" + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --color green + assert_status 1 + assert_contains 'Refusing symlinked appearance registry' + [[ ! -e "$tmp/elsewhere" ]] || fail "a symlinked registry was written through" +} + +test_env_exports_the_colour_and_remove_forgets_it() { + local tmp="$TMP_ROOT/env" + mkdir -p "$tmp/home/.codex-work" + + run_cmd appearance_env "$tmp" "$SCRIPT" appearance work --color brown + assert_status 0 + run_cmd appearance_env "$tmp" "$SCRIPT" env work + assert_status 0 + assert_contains 'export CODEX_PROFILE_COLOR=brown' + run_cmd appearance_env "$tmp" "$SCRIPT" env work --shell fish + assert_status 0 + assert_contains 'set -gx CODEX_PROFILE_COLOR brown' + + run_cmd appearance_env "$tmp" "$SCRIPT" remove work --yes + assert_status 0 + [[ ! -s "$tmp/config/appearance.tsv" ]] || fail "remove kept the profile's appearance" +} + +test_automatic_appearance_is_stable_and_graphite_for_default +test_choices_persist_privately_and_reset +test_invalid_requests_are_refused +test_damaged_entries_fall_back_and_symlinks_are_refused +test_env_exports_the_colour_and_remove_forgets_it + +printf 'appearance tests passed\n' diff --git a/test/cli/completions-test.sh b/test/cli/completions-test.sh index cec08c2..49d6bf2 100644 --- a/test/cli/completions-test.sh +++ b/test/cli/completions-test.sh @@ -138,6 +138,22 @@ for shell in bash zsh fish; do complete_words launcher create client --color '' assert_contains graphite assert_not_contains client + assert_contains indigo + complete_words appearance '' + assert_contains client + complete_words appearance client -- + assert_contains --color + assert_contains --symbol + assert_contains --reset + complete_words appearance client --color '' + assert_contains brown + assert_not_contains red + assert_not_contains client + complete_words appearance client --symbol '' + assert_contains briefcase + assert_not_contains client + complete_words appearance client --color teal '' + assert_not_contains client complete_words launcher create client --name '' assert_equals '' complete_words upgrade -- diff --git a/test/cli/launcher-test.sh b/test/cli/launcher-test.sh index f849899..633c832 100644 --- a/test/cli/launcher-test.sh +++ b/test/cli/launcher-test.sh @@ -343,7 +343,29 @@ test_launcher_recovers_after_the_bundle_is_deleted_outside_the_cli() { rm -rf "$tmp" } +test_launcher_defaults_to_the_profile_colour() { + local tmp app + tmp="$(mktemp -d)" + app="$tmp/apps/ChatGPT work.app" + prepare_launcher_test "$tmp" work + + run_cmd launcher_env "$tmp" "$SCRIPT" launcher create work + assert_status 0 + [[ "$(cat "$app/Contents/Resources/codex-profile/color")" == indigo ]] || fail "launcher did not use the automatic profile colour" + run_cmd launcher_env "$tmp" "$SCRIPT" appearance work --color brown + assert_status 0 + run_cmd launcher_env "$tmp" "$SCRIPT" launcher create work --force + assert_status 0 + [[ "$(cat "$app/Contents/Resources/codex-profile/color")" == brown ]] || fail "launcher did not follow the chosen profile colour" + run_cmd launcher_env "$tmp" "$SCRIPT" launcher create work --color orange --force + assert_status 0 + [[ "$(cat "$app/Contents/Resources/codex-profile/color")" == orange ]] || fail "an explicit launcher colour was not kept" + + rm -rf "$tmp" +} + test_launcher_create_list_and_path_are_deterministic +test_launcher_defaults_to_the_profile_colour test_launcher_create_is_idempotent_and_force_replaces_managed_bundle test_launcher_refuses_invalid_inputs_and_unmanaged_collisions test_launcher_cleans_up_a_failed_icon_build diff --git a/test/cli/prompt-test.sh b/test/cli/prompt-test.sh index a729466..88cfca5 100644 --- a/test/cli/prompt-test.sh +++ b/test/cli/prompt-test.sh @@ -10,6 +10,8 @@ mkdir -p "$TMP_ROOT/home/.codex-work" "$TMP_ROOT/home/.codex-other" "$TMP_ROOT/h ln -s "$SCRIPT" "$TMP_ROOT/bin/codex-profile" # Exercise the shell's registered hooks, as the shell does before displaying PS1. +# NO_COLOR keeps these exact transcripts to the marker itself; the coloured +# dot that precedes it has its own checks below. cat > "$TMP_ROOT/check.sh" <<'CHECK' set -eu PS1='original> ' @@ -52,7 +54,7 @@ for shell in bash zsh; do printf 'SKIP: %s prompt runtime unavailable\n' "$shell" continue fi - run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" TEST_SHELL="$shell" "$shell" -f "$TMP_ROOT/check.sh" + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" NO_COLOR=1 TEST_SHELL="$shell" "$shell" -f "$TMP_ROOT/check.sh" assert_status 0 assert_equals $'original> \n[codex:work] original> \n[codex:work] original> \n[codex:other] original> \n[codex:default] original> \noriginal> \noriginal> \noriginal> ' [[ ! -e "$TMP_ROOT/home/injected" ]] || fail 'prompt executed invalid profile text' @@ -86,14 +88,14 @@ CHECK for shell in bash zsh; do command -v "$shell" > /dev/null 2>&1 || continue - run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" TEST_SHELL="$shell" "$shell" -f "$TMP_ROOT/restore.sh" + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" NO_COLOR=1 TEST_SHELL="$shell" "$shell" -f "$TMP_ROOT/restore.sh" assert_status 0 assert_equals $'[codex:work] base> \n[codex:work] (venv) base> \n[codex:other] (venv) base> \n[codex:other] base> \nbase> \n[codex:not a profile] base> ' done # Bash users may already have multiple prompt hooks; preserve their array. # shellcheck disable=SC2016 # expanded by the child shell -run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" bash -eu -c ' +run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" NO_COLOR=1 bash -eu -c ' PS1="original> " PROMPT_COMMAND=("printf first" "PS1=\"original> \"; printf second") export PROMPT_COMMAND @@ -106,6 +108,37 @@ run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" bash -eu -c ' assert_status 0 assert_equals 'firstsecond[codex:work] original> ' +# A dot in the profile's colour precedes the marker. It follows the profile, +# never stacks, and leaves with the marker. +cat > "$TMP_ROOT/color.sh" <<'CHECK' +set -eu +PS1='base> ' +if [ "$TEST_SHELL" = bash ]; then + render() { eval "$PROMPT_COMMAND"; printf '%s\n' "$PS1"; } +else + render() { local hook; for hook in "${precmd_functions[@]}"; do "$hook"; done; printf '%s\n' "$PS1"; } +fi +eval "$(codex-profile shell-init "$TEST_SHELL" --prompt)" +codex-profile appearance work --color teal > /dev/null +codex-profile use work +render +render +codex-profile use other +render +unset CODEX_PROFILE_NAME CODEX_HOME +render +CHECK +for shell in bash zsh; do + command -v "$shell" > /dev/null 2>&1 || continue + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" TEST_SHELL="$shell" "$shell" -f "$TMP_ROOT/color.sh" + assert_status 0 + if [[ "$shell" == bash ]]; then + assert_equals $'\\[\\e[38;5;30m\\]●\\[\\e[39m\\] [codex:work] base> \n\\[\\e[38;5;30m\\]●\\[\\e[39m\\] [codex:work] base> \n\\[\\e[38;5;95m\\]●\\[\\e[39m\\] [codex:other] base> \nbase> ' + else + assert_equals $'%F{30}●%f [codex:work] base> \n%F{30}●%f [codex:work] base> \n%F{95}●%f [codex:other] base> \nbase> ' + fi +done + if command -v fish > /dev/null 2>&1; then cat > "$TMP_ROOT/check.fish" <<'CHECK' function fish_prompt @@ -134,7 +167,7 @@ fish_prompt false fish_prompt CHECK - run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" fish --no-config "$TMP_ROOT/check.fish" + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" NO_COLOR=1 fish --no-config "$TMP_ROOT/check.fish" assert_status 0 assert_equals $'original:0> \n[codex:work] original:0> \n[codex:work] original:0> \n[codex:other] original:0> \n[codex:default] original:0> \noriginal:0> \noriginal:0> \noriginal:0> \noriginal:1> ' [[ ! -e "$TMP_ROOT/home/injected" ]] || fail 'fish prompt executed invalid profile text' @@ -151,9 +184,21 @@ true | false fish_prompt printf 'called:%s' $prompt_called CHECK - run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" fish --no-config "$TMP_ROOT/pipeline.fish" + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" NO_COLOR=1 fish --no-config "$TMP_ROOT/pipeline.fish" assert_status 0 assert_equals $'[codex:work] status=0 pipeline=1,0>\n\n[codex:work] status=1 pipeline=0,1>\n\ncalled:yes' + # shellcheck disable=SC2016 # Evaluated by fish. + run_cmd env HOME="$TMP_ROOT/home" PATH="$TMP_ROOT/bin:$PATH" fish --no-config -c ' + function fish_prompt; printf "base> \n"; end + codex-profile shell-init fish --prompt | source + codex-profile use work + fish_prompt + set -gx NO_COLOR 1 + fish_prompt + ' + assert_status 0 + assert_contains '●' + assert_contains $'\n[codex:work] base> ' else printf 'SKIP: fish prompt runtime unavailable\n' fi diff --git a/test/cli/shell-test.sh b/test/cli/shell-test.sh index e958a5e..35596e1 100755 --- a/test/cli/shell-test.sh +++ b/test/cli/shell-test.sh @@ -167,7 +167,7 @@ test_completions_generate_shell_scripts() { for shell in bash zsh fish; do run_cmd "$SCRIPT" completions "$shell" assert_status 0 - assert_contains 'app app-instance cli login init setup detach remove launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help' + assert_contains 'app app-instance cli login init setup detach remove appearance launcher workspace run status usage path env use logs clone-config list doctor completions shell-init upgrade version help' assert_contains '--share-with' assert_contains '--prompt --completions' assert_contains '--instance --rebuild'