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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cheetahclaws.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def __getattr__(self, name):
render_diff, _has_diff,
stream_text, stream_thinking, flush_response,
_start_tool_spinner, _stop_tool_spinner, _change_spinner_phrase,
set_spinner_phrase, set_rich_live,
set_spinner_phrase, set_rich_live, set_spinner_tips,
print_tool_start, print_tool_end,
_RICH, console,
)
Expand Down Expand Up @@ -1034,6 +1034,12 @@ def _row(colored: str, plain: str) -> str:
_rich_live_default = not _in_ssh and not _is_dumb and not _is_macos_terminal
set_rich_live(config.get("rich_live", _rich_live_default))

# Apply spinner_tips config: rotating Claude-Code-style tips beneath the
# spinner. Disabled automatically where multi-line cursor moves misbehave
# (dumb terminals, macOS Terminal.app) so the tip line never garbles output.
_spinner_tips_default = not _is_dumb and not _is_macos_terminal
set_spinner_tips(config.get("spinner_tips", _spinner_tips_default))

# Initialize proactive polling state via RuntimeContext (defaults already set)
session_ctx.last_interaction_time = time.time()
if session_ctx.proactive_thread is None:
Expand Down
1 change: 1 addition & 0 deletions docs/guides/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ and indexed in the [README Documentation section](../../README.md#documentation)
| Proactive monitoring | `/proactive [duration]` starts a background sentinel daemon; agent wakes automatically after inactivity, enabling continuous monitoring loops without user prompts |
| Force quit | 3× Ctrl+C within 2 seconds triggers `os._exit(1)` — kills the process immediately regardless of blocking I/O |
| Rich Live streaming | When `rich` is installed, responses render as live-updating Markdown in place. Auto-disabled in SSH sessions to prevent repeated output; override with `/config rich_live=false`. |
| Spinner tips | While the model works, the spinner shows an elapsed timer plus a rotating Claude-Code-style "Tip:" line surfacing handy commands (`/compact`, `/checkpoint`, `/research`, …). Auto-disabled on dumb / macOS Terminal where multi-line cursor moves misbehave; toggle with `/config spinner_tips=false`. |
| Context injection | Auto-loads `CLAUDE.md`, git status, cwd, persistent memory |
| Session persistence | Autosave on exit to `daily/YYYY-MM-DD/` (per-day limit) + `history.json` (master, all sessions) + `session_latest.json` (/resume); sessions include `session_id` and `saved_at` metadata; `/load` grouped by date |
| Cloud sync | `/cloudsave` syncs sessions to private GitHub Gists; auto-sync on exit; load from cloud by Gist ID. No new dependencies (stdlib `urllib`). |
Expand Down
2 changes: 1 addition & 1 deletion ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
_TOOL_SPINNER_PHRASES, _DEBATE_SPINNER_PHRASES,
_start_tool_spinner, _stop_tool_spinner, _change_spinner_phrase,
print_tool_start, print_tool_end, _tool_desc,
set_rich_live,
set_rich_live, set_spinner_tips,
)
83 changes: 77 additions & 6 deletions ui/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import sys
import json
import time
import threading

# ── Optional rich for markdown rendering ──────────────────────────────────
Expand Down Expand Up @@ -258,33 +259,98 @@ def flush_response() -> None:
"🎯 Finding common ground...",
]

# Rotating "did you know" tips shown beneath the spinner while the model works,
# Claude-Code style. Each references a real CheetahClaws feature/command.
_SPINNER_TIPS = [
"Use /compact to shrink a long conversation without losing the thread",
"Run /checkpoint to snapshot the session, then /rewind to jump back",
"Type /plan to enter plan mode — Claude designs before it edits",
"Use /ssj for SSJ Developer Mode — a power menu of expert tools",
"Try /research <topic> to fan out web searches into a cited report",
"Spawn background helpers with /agent — see them with /agents",
"Persistent memories live in /memory — search, list, or consolidate",
"Toggle extended reasoning anytime with /thinking",
"Check token usage with /context and spend with /cost",
"Switch models on the fly with /model — no restart needed",
"Recolor the whole UI with /theme — pick from a dozen palettes",
"Run /web to open the browser terminal / chat UI in the background",
"Sync sessions to a GitHub Gist with /cloudsave",
"Bridge chats with /telegram, /slack, /wechat, or /qq",
"Summarize any-size PDF or code file with /summarize",
"Set permission mode with /permissions — auto, accept-all, or manual",
"Stuck on health? /doctor diagnoses your installation",
"Paste an image from the clipboard straight to the model with /image",
"Manage MCP servers live with /mcp reload / add / remove",
"Drop a CLAUDE.md with /init so Claude learns your project conventions",
]

_tool_spinner_thread = None
_tool_spinner_stop = threading.Event()
_spinner_phrase = ""
_spinner_lock = threading.Lock()
_spinner_start = 0.0 # monotonic timestamp when current spinner began
_spinner_tips_enabled = True # toggled via set_spinner_tips() (config spinner_tips)
_spinner_tip = "" # tip currently displayed (rotates while spinning)


def set_spinner_tips(enabled: bool) -> None:
"""Called from repl.py to apply the spinner_tips config setting."""
global _spinner_tips_enabled
_spinner_tips_enabled = bool(enabled)


def _fmt_elapsed(seconds: float) -> str:
s = int(seconds)
if s < 60:
return f"{s}s"
return f"{s // 60}m {s % 60:02d}s"


def _pick_tip() -> str:
import random
return random.choice(_SPINNER_TIPS)


def _run_tool_spinner():
"""Background spinner on a single line using carriage return."""
"""Background spinner. Single carriage-return line, plus a Claude-Code-style
rotating tip line beneath it when attached to a TTY and tips are enabled."""
chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
i = 0
# Tips need cursor up/down moves, which only behave on a real terminal.
two_line = _spinner_tips_enabled and bool(getattr(sys.stdout, "isatty", lambda: False)())
while not _tool_spinner_stop.is_set():
with _spinner_lock:
phrase = _spinner_phrase
tip = _spinner_tip
frame = chars[i % len(chars)]
sys.stdout.write(f"\r {frame} {clr(phrase, 'dim')} ")
elapsed = _fmt_elapsed(time.monotonic() - _spinner_start)
if two_line:
# Rotate the tip roughly every 12s.
if i and i % 120 == 0:
with _spinner_lock:
globals()["_spinner_tip"] = _pick_tip()
tip = _spinner_tip
line1 = f" {frame} {clr(phrase, 'dim')} {clr('(' + elapsed + ')', 'dim')}"
line2 = f" {clr('⎿ Tip: ' + tip, 'dim')}"
# Write line1, drop to line2, then climb back up to line1's column 0
# so the next frame overwrites in place. \033[2K clears each line.
sys.stdout.write("\r\033[2K" + line1 + "\n\033[2K" + line2 + "\033[1A\r")
else:
sys.stdout.write(f"\r\033[2K {frame} {clr(phrase, 'dim')} {clr('(' + elapsed + ')', 'dim')} ")
sys.stdout.flush()
i += 1
_tool_spinner_stop.wait(0.1)

def _start_tool_spinner():
global _tool_spinner_thread
global _tool_spinner_thread, _spinner_start
if _tool_spinner_thread and _tool_spinner_thread.is_alive():
return
import random
with _spinner_lock:
global _spinner_phrase
global _spinner_phrase, _spinner_tip
import random
_spinner_phrase = random.choice(_TOOL_SPINNER_PHRASES)
_spinner_tip = _pick_tip()
_spinner_start = time.monotonic()
_tool_spinner_stop.clear()
_tool_spinner_thread = threading.Thread(target=_run_tool_spinner, daemon=True)
_tool_spinner_thread.start()
Expand All @@ -309,7 +375,12 @@ def _stop_tool_spinner():
_tool_spinner_stop.set()
_tool_spinner_thread.join(timeout=1)
_tool_spinner_thread = None
sys.stdout.write(f"\r{' ' * 50}\r")
# Clear the spinner line and, if we drew one, the tip line below it, then
# leave the cursor at column 0 of the (now blank) spinner line.
if _spinner_tips_enabled and bool(getattr(sys.stdout, "isatty", lambda: False)()):
sys.stdout.write("\r\033[2K\n\033[2K\033[1A\r")
else:
sys.stdout.write(f"\r{' ' * 50}\r")
sys.stdout.flush()


Expand Down
Loading