mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: add OpenClaw TUI prototype skill (#116583)
* feat: add OpenClaw TUI prototype skill * fix: harden TUI prototype tmux launcher * fix: size TUI grids after terminal attach * fix: preserve equal TUI pane dimensions * fix: make TUI grid refresh transactional
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: prototype-openclaw-tui
|
||||
description: Build throwaway, fixture-driven OpenClaw Clack or Pi TUI prototypes and compare multiple interactive variants side by side in tmux without running the full application or touching live state.
|
||||
user-invocable: true
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Prototype OpenClaw TUI
|
||||
|
||||
Use this skill to isolate one OpenClaw terminal surface, render it with the
|
||||
real OpenClaw TUI stack, and compare two to six design variants in tmux.
|
||||
|
||||
## Rules
|
||||
|
||||
- Read the root and scoped `AGENTS.md` files before editing.
|
||||
- Keep the prototype beside the target and name it `*.prototype.ts`.
|
||||
- When iterating on an existing surface, include its current implementation as
|
||||
the first `baseline` variant with the same fixture data. Only omit the
|
||||
baseline when the user explicitly asks for a greenfield comparison.
|
||||
- Use the real OpenClaw renderer, theme, copy, and component where practical.
|
||||
- Replace scanning, models, network calls, installs, config writes, SQLite, and
|
||||
other durable effects with deterministic in-memory fixtures.
|
||||
- Do not run the full OpenClaw application when the isolated surface is enough.
|
||||
- Treat prototype code as throwaway. Do not promote it directly to production.
|
||||
- Preserve unrelated and pre-existing worktree changes.
|
||||
|
||||
## Choose the renderer
|
||||
|
||||
- Wizard, onboarding, configuration, doctor, selection, or progress surface:
|
||||
use `createClackPrompter()` from `src/wizard/clack-prompter.ts`.
|
||||
- Agent shell, chat, overlay, editor, selector, or `src/tui/**` component:
|
||||
use `@earendil-works/pi-tui` and the existing OpenClaw component.
|
||||
|
||||
Do not invent a shared Clack/Pi abstraction. They are separate prototype
|
||||
recipes joined only by the tmux comparison loop.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the target module, its renderer adapter, callers, and adjacent tests.
|
||||
2. Create the smallest executable harness that reaches the target surface.
|
||||
3. If the surface already exists, make the no-argument invocation render its
|
||||
current implementation unchanged as `--variant=baseline`.
|
||||
4. Add structurally different alternatives behind `--variant=<id>`, for two
|
||||
to six total variants including the baseline.
|
||||
5. Give every variant the same fixture data and terminal dimensions.
|
||||
6. Run each variant directly in its own tmux pane. Do not pipe interactive TUI
|
||||
output; tmux must provide the PTY.
|
||||
7. Iterate on the harness with the launcher's `--refresh` mode. Respawn the
|
||||
panes in the existing session; do not kill the session or reopen the user's
|
||||
terminal.
|
||||
8. After the user chooses a direction, carry the decision into production code
|
||||
with normal tests and validation. Remove or separately capture the prototype.
|
||||
|
||||
## Launch the comparison grid
|
||||
|
||||
Run the bundled launcher with a session name, repository path, then title and
|
||||
command pairs:
|
||||
|
||||
```bash
|
||||
.agents/skills/prototype-openclaw-tui/scripts/launch-tmux-grid.sh \
|
||||
--open \
|
||||
app-recommendations-prototype "$PWD" \
|
||||
"A - Baseline" "node --import tsx src/wizard/setup.app-recommendations.prototype.ts --variant=baseline" \
|
||||
"B - Grouped" "node --import tsx src/wizard/setup.app-recommendations.prototype.ts --variant=grouped" \
|
||||
"C - Focused" "node --import tsx src/wizard/setup.app-recommendations.prototype.ts --variant=focused"
|
||||
```
|
||||
|
||||
After editing the prototype, run the same command with `--refresh` instead of
|
||||
`--open`. This preserves the tmux session, attached external terminal, window,
|
||||
and first pane while restarting all pane commands and reapplying the grid:
|
||||
|
||||
```bash
|
||||
.agents/skills/prototype-openclaw-tui/scripts/launch-tmux-grid.sh \
|
||||
--refresh \
|
||||
app-recommendations-prototype "$PWD" \
|
||||
"A - Baseline" "node --import tsx src/wizard/setup.app-recommendations.prototype.ts --variant=baseline" \
|
||||
"B - Grouped" "node --import tsx src/wizard/setup.app-recommendations.prototype.ts --variant=grouped" \
|
||||
"C - Focused" "node --import tsx src/wizard/setup.app-recommendations.prototype.ts --variant=focused"
|
||||
```
|
||||
|
||||
The launcher refuses to replace an existing session unless `--refresh` is
|
||||
explicitly provided. `--open` uses the user's
|
||||
`.command`-associated terminal app on macOS, Windows Terminal from WSL, or the
|
||||
first available Linux launcher from `xdg-terminal-exec`, `$TERMINAL`, and
|
||||
`x-terminal-emulator`. If no supported launcher is available, the tmux session
|
||||
remains ready at equal detached dimensions and the script exits successfully
|
||||
after printing the manual attach command. Attach, then run `--refresh` once to
|
||||
adopt the terminal's larger dimensions. The same guidance applies if an
|
||||
external terminal opens but does not attach within ten seconds. When invoked
|
||||
from a non-interactive `TERM=dumb` environment, the launcher removes its
|
||||
inherited `NO_COLOR` only from prototype pane processes so the external
|
||||
terminal can detect and render its normal colors. Prefer the external terminal
|
||||
over the Codex in-app terminal unless the user asks for the in-app surface.
|
||||
Leave the session running for user review unless asked to stop it.
|
||||
|
||||
## Handoff
|
||||
|
||||
Report:
|
||||
|
||||
- the prototype path and exact run command;
|
||||
- the tmux session name and attach command;
|
||||
- which effects were replaced with fixtures;
|
||||
- available variant ids;
|
||||
- whether the prototype changed production code (normally no).
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Prototype OpenClaw TUI"
|
||||
short_description: "Compare isolated OpenClaw TUI variants in tmux"
|
||||
default_prompt: "Use $prototype-openclaw-tui to isolate an OpenClaw Clack or Pi TUI surface with fixture data and compare multiple interactive variants in tmux."
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 [--open | --refresh] <session> <cwd> <title> <command> <title> <command> [<title> <command> ...] (2-6 variants)" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
configure_tmux_session() {
|
||||
local session_id=$1
|
||||
local window_id=$2
|
||||
|
||||
tmux set-option -t "$session_id" mouse on
|
||||
tmux set-option -t "$session_id" status-left " TUI PROTOTYPES "
|
||||
tmux set-option -t "$session_id" status-right "Ctrl-b z: zoom"
|
||||
tmux set-window-option -t "$window_id" pane-border-status top
|
||||
tmux set-window-option -t "$window_id" pane-border-format ' #[bold]#{pane_title} #[default]'
|
||||
tmux set-window-option -t "$window_id" allow-rename off
|
||||
tmux set-window-option -t "$window_id" automatic-rename off
|
||||
tmux set-window-option -t "$window_id" remain-on-exit on
|
||||
}
|
||||
|
||||
prepare_grid_geometry() {
|
||||
local window_id=$1
|
||||
local pane_count=$2
|
||||
local window_width window_height normalized_width normalized_height
|
||||
|
||||
case $pane_count in
|
||||
2)
|
||||
grid_columns=2
|
||||
grid_rows=1
|
||||
;;
|
||||
3)
|
||||
grid_columns=3
|
||||
grid_rows=1
|
||||
;;
|
||||
4)
|
||||
grid_columns=2
|
||||
grid_rows=2
|
||||
;;
|
||||
5)
|
||||
grid_columns=5
|
||||
grid_rows=1
|
||||
;;
|
||||
6)
|
||||
grid_columns=3
|
||||
grid_rows=2
|
||||
;;
|
||||
*)
|
||||
echo "expected 2-6 prototype panes, got: $pane_count" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Restore the attached session size before normalizing it so repeated
|
||||
# refreshes do not progressively shrink the prototype window.
|
||||
tmux resize-window -A -t "$window_id"
|
||||
read -r window_width window_height < <(
|
||||
tmux display-message -p -t "$window_id" '#{window_width} #{window_height}'
|
||||
)
|
||||
grid_pane_width=$(((window_width - grid_columns + 1) / grid_columns))
|
||||
# pane-border-status=top consumes the outer top line, while row separators
|
||||
# carry the remaining titles. Account for both so pane content heights match.
|
||||
grid_pane_height=$(((window_height - grid_rows) / grid_rows))
|
||||
if ((grid_pane_width < 1 || grid_pane_height < 1)); then
|
||||
echo "terminal is too small for $pane_count equal prototype panes" >&2
|
||||
return 1
|
||||
fi
|
||||
normalized_width=$((grid_pane_width * grid_columns + grid_columns - 1))
|
||||
normalized_height=$((grid_pane_height * grid_rows + grid_rows))
|
||||
|
||||
tmux resize-window -t "$window_id" -x "$normalized_width" -y "$normalized_height"
|
||||
}
|
||||
|
||||
build_comparison_grid() {
|
||||
local window_id=$1
|
||||
local first_pane=$2
|
||||
local pane_count=${#pane_commands[@]}
|
||||
local row column index remaining_columns right_width pane_id bottom_index
|
||||
local current_pane first_dimensions dimensions
|
||||
local row_anchors=()
|
||||
|
||||
prepare_grid_geometry "$window_id" "$pane_count"
|
||||
row_anchors[0]=$first_pane
|
||||
if ((grid_rows == 2)); then
|
||||
bottom_index=$grid_columns
|
||||
pane_id=$(tmux split-window -d -v -l "$grid_pane_height" -P -F '#{pane_id}' \
|
||||
-t "$first_pane" -c "$prototype_cwd" \
|
||||
"${pane_command_prefix[@]}" "${pane_commands[bottom_index]}")
|
||||
tmux select-pane -t "$pane_id" -T "${pane_titles[bottom_index]}"
|
||||
row_anchors[1]=$pane_id
|
||||
fi
|
||||
|
||||
for ((row = 0; row < grid_rows; row++)); do
|
||||
current_pane=${row_anchors[row]}
|
||||
for ((column = 1; column < grid_columns; column++)); do
|
||||
index=$((row * grid_columns + column))
|
||||
remaining_columns=$((grid_columns - column))
|
||||
right_width=$((grid_pane_width * remaining_columns + remaining_columns - 1))
|
||||
pane_id=$(tmux split-window -d -h -l "$right_width" -P -F '#{pane_id}' \
|
||||
-t "$current_pane" -c "$prototype_cwd" \
|
||||
"${pane_command_prefix[@]}" "${pane_commands[index]}")
|
||||
tmux select-pane -t "$pane_id" -T "${pane_titles[index]}"
|
||||
current_pane=$pane_id
|
||||
done
|
||||
done
|
||||
|
||||
first_dimensions=""
|
||||
while read -r dimensions; do
|
||||
if [[ -z $first_dimensions ]]; then
|
||||
first_dimensions=$dimensions
|
||||
elif [[ $dimensions != "$first_dimensions" ]]; then
|
||||
echo "tmux could not create equal prototype pane dimensions" >&2
|
||||
return 1
|
||||
fi
|
||||
done < <(tmux list-panes -t "$window_id" -F '#{pane_width}x#{pane_height}')
|
||||
}
|
||||
|
||||
open_external_terminal() {
|
||||
local session_name=$1
|
||||
local system_name
|
||||
system_name=$(uname -s)
|
||||
|
||||
if [[ $system_name == "Darwin" ]] && command -v open >/dev/null 2>&1; then
|
||||
local attach_dir attach_command terminal_app tmux_path
|
||||
attach_dir=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-tui-prototype.XXXXXX")
|
||||
attach_command="$attach_dir/attach.command"
|
||||
printf '#!/usr/bin/env bash\nattach_file=$0\nrm -f -- "$attach_file"\nrmdir -- "$(dirname "$attach_file")" 2>/dev/null || true\nexec tmux attach-session -t %q\n' \
|
||||
"=$session_name" >"$attach_command"
|
||||
chmod +x "$attach_command"
|
||||
|
||||
terminal_app=""
|
||||
if command -v osascript >/dev/null 2>&1; then
|
||||
terminal_app=$(osascript -l JavaScript \
|
||||
-e 'function run(argv) { ObjC.import("AppKit"); ObjC.import("Foundation"); const file = $.NSURL.fileURLWithPath(argv[0]); const app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL(file); return app ? ObjC.unwrap(app.path) : ""; }' \
|
||||
"$attach_command" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Ghostty 1.3 drops the leading slash when LaunchServices opens a .command
|
||||
# file. Use its documented command entry point while preserving the user's
|
||||
# .command association as the source of truth for their terminal choice.
|
||||
if [[ $terminal_app == */Ghostty.app ]]; then
|
||||
tmux_path=$(command -v tmux)
|
||||
if open -n -a "$terminal_app" --args -e \
|
||||
"$tmux_path" attach-session -t "=$session_name"; then
|
||||
rm -f -- "$attach_command"
|
||||
rmdir -- "$attach_dir" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if open "$attach_command"; then
|
||||
return 0
|
||||
fi
|
||||
rm -f -- "$attach_command"
|
||||
rmdir -- "$attach_dir" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $system_name == "Linux" && -n ${WSL_DISTRO_NAME:-} ]] &&
|
||||
command -v wt.exe >/dev/null 2>&1; then
|
||||
if wt.exe new-tab --title "OpenClaw TUI prototypes" \
|
||||
wsl.exe --distribution "$WSL_DISTRO_NAME" --exec \
|
||||
tmux attach-session -t "=$session_name"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $system_name == "Linux" ]]; then
|
||||
if command -v xdg-terminal-exec >/dev/null 2>&1; then
|
||||
if xdg-terminal-exec --title="OpenClaw TUI prototypes" \
|
||||
tmux attach-session -t "=$session_name"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n ${TERMINAL:-} ]] && command -v "$TERMINAL" >/dev/null 2>&1; then
|
||||
if "$TERMINAL" -e tmux attach-session -t "=$session_name"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v x-terminal-emulator >/dev/null 2>&1; then
|
||||
if x-terminal-emulator -e tmux attach-session -t "=$session_name"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
tmux_session_has_client() {
|
||||
local session_id=$1
|
||||
[[ -n $(tmux list-clients -t "$session_id" -F '#{client_name}' 2>/dev/null) ]]
|
||||
}
|
||||
|
||||
wait_for_tmux_client() {
|
||||
local session_id=$1
|
||||
local attempt=0
|
||||
|
||||
while ((attempt < 100)); do
|
||||
if tmux_session_has_client "$session_id"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
open_external=false
|
||||
refresh_session=false
|
||||
while [[ ${1:-} == --* ]]; do
|
||||
case $1 in
|
||||
--open)
|
||||
open_external=true
|
||||
;;
|
||||
--refresh)
|
||||
refresh_session=true
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ $open_external == true && $refresh_session == true ]]; then
|
||||
echo "--refresh reuses the attached terminal and cannot be combined with --open" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if (( $# < 6 || $# > 14 || ($# - 2) % 2 != 0 )); then
|
||||
usage
|
||||
fi
|
||||
|
||||
session_name=$1
|
||||
prototype_cwd=$2
|
||||
shift 2
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "tmux is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d $prototype_cwd ]]; then
|
||||
echo "working directory does not exist: $prototype_cwd" >&2
|
||||
exit 1
|
||||
fi
|
||||
session_exists=false
|
||||
if tmux has-session -t "=$session_name" 2>/dev/null; then
|
||||
session_exists=true
|
||||
fi
|
||||
if [[ $refresh_session == true && $session_exists == false ]]; then
|
||||
echo "tmux session does not exist: $session_name" >&2
|
||||
echo "create it first with --open" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $refresh_session == false && $session_exists == true ]]; then
|
||||
echo "tmux session already exists: $session_name" >&2
|
||||
echo "attach with: tmux attach-session -t '=$session_name'" >&2
|
||||
echo "refresh it with: $0 --refresh '$session_name' '$prototype_cwd' ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pane_titles=()
|
||||
pane_commands=()
|
||||
while (( $# > 0 )); do
|
||||
pane_titles[${#pane_titles[@]}]=$1
|
||||
pane_commands[${#pane_commands[@]}]=$2
|
||||
shift 2
|
||||
done
|
||||
|
||||
pane_command_prefix=(/bin/sh -c)
|
||||
if [[ ${TERM:-} == "dumb" && -n ${NO_COLOR:-} ]]; then
|
||||
pane_command_prefix=(env -u NO_COLOR /bin/sh -c)
|
||||
fi
|
||||
placeholder_command=(/bin/sh -c 'while :; do sleep 3600; done')
|
||||
|
||||
if [[ $refresh_session == true ]]; then
|
||||
session_id=$(tmux display-message -p -t "=$session_name" '#{session_id}')
|
||||
previous_window_id=""
|
||||
while read -r candidate_id candidate_name; do
|
||||
if [[ $candidate_name == prototypes ]]; then
|
||||
previous_window_id=$candidate_id
|
||||
break
|
||||
fi
|
||||
done < <(tmux list-windows -t "$session_id" -F '#{window_id} #{window_name}')
|
||||
if [[ -z $previous_window_id ]]; then
|
||||
echo "tmux session has no prototypes window: $session_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
refresh_window_created=false
|
||||
previous_window_renamed=false
|
||||
cleanup_failed_refresh() {
|
||||
if [[ $previous_window_renamed == true ]]; then
|
||||
tmux rename-window -t "$previous_window_id" prototypes 2>/dev/null || true
|
||||
fi
|
||||
if [[ $refresh_window_created == true ]]; then
|
||||
tmux kill-window -t "$window_id" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_failed_refresh ERR
|
||||
|
||||
first_pane=$(tmux new-window -d -P -F '#{pane_id}' \
|
||||
-t "$session_id:" -n "prototypes-refresh-$$" -c "$prototype_cwd" \
|
||||
"${placeholder_command[@]}")
|
||||
refresh_window_created=true
|
||||
window_id=$(tmux display-message -p -t "$first_pane" '#{window_id}')
|
||||
configure_tmux_session "$session_id" "$window_id"
|
||||
|
||||
tmux respawn-pane -k -t "$first_pane" -c "$prototype_cwd" \
|
||||
"${pane_command_prefix[@]}" "${pane_commands[0]}"
|
||||
tmux select-pane -t "$first_pane" -T "${pane_titles[0]}"
|
||||
build_comparison_grid "$window_id" "$first_pane"
|
||||
|
||||
tmux rename-window -t "$previous_window_id" "prototypes-previous-$$"
|
||||
previous_window_renamed=true
|
||||
tmux rename-window -t "$window_id" prototypes
|
||||
tmux select-window -t "$window_id"
|
||||
tmux kill-window -t "$previous_window_id"
|
||||
previous_window_renamed=false
|
||||
refresh_window_created=false
|
||||
trap - ERR
|
||||
else
|
||||
created_session=false
|
||||
cleanup_partial_session() {
|
||||
if [[ $created_session == true ]] && tmux has-session -t "=$session_name" 2>/dev/null; then
|
||||
tmux kill-session -t "=$session_name"
|
||||
fi
|
||||
}
|
||||
trap cleanup_partial_session ERR
|
||||
|
||||
first_pane=$(tmux new-session -d -P -F '#{pane_id}' \
|
||||
-s "$session_name" -n prototypes -c "$prototype_cwd" \
|
||||
"${placeholder_command[@]}")
|
||||
created_session=true
|
||||
session_id=$(tmux display-message -p -t "$first_pane" '#{session_id}')
|
||||
window_id=$(tmux display-message -p -t "$first_pane" '#{window_id}')
|
||||
configure_tmux_session "$session_id" "$window_id"
|
||||
tmux respawn-pane -k -t "$first_pane" -c "$prototype_cwd" \
|
||||
"${pane_command_prefix[@]}" "${pane_commands[0]}"
|
||||
tmux select-pane -t "$first_pane" -T "${pane_titles[0]}"
|
||||
if [[ $open_external == true ]]; then
|
||||
if open_external_terminal "$session_name"; then
|
||||
if ! wait_for_tmux_client "$session_id"; then
|
||||
echo "external terminal opened but did not attach in time; run --refresh after attaching" >&2
|
||||
fi
|
||||
else
|
||||
echo "could not open an external terminal; attach manually with:" >&2
|
||||
printf " tmux attach-session -t %q\n" "=$session_name" >&2
|
||||
fi
|
||||
fi
|
||||
build_comparison_grid "$window_id" "$first_pane"
|
||||
fi
|
||||
|
||||
tmux select-pane -t "$first_pane"
|
||||
|
||||
trap - ERR
|
||||
if [[ $refresh_session == true ]]; then
|
||||
echo "tmux session refreshed: $session_name"
|
||||
else
|
||||
echo "tmux session ready: $session_name"
|
||||
fi
|
||||
echo "attach with: tmux attach-session -t '=$session_name'"
|
||||
@@ -182,6 +182,8 @@ USER.md
|
||||
!.agents/skills/openclaw-parallels-smoke/**
|
||||
!.agents/skills/openclaw-pr-maintainer/
|
||||
!.agents/skills/openclaw-pr-maintainer/**
|
||||
!.agents/skills/prototype-openclaw-tui/
|
||||
!.agents/skills/prototype-openclaw-tui/**
|
||||
!.agents/skills/openclaw-qa-testing/
|
||||
!.agents/skills/openclaw-qa-testing/**
|
||||
!.agents/skills/openclaw-refactor-docs/
|
||||
|
||||
Reference in New Issue
Block a user