feat(tui): ghost-text follow-up prompt suggestion#2781
Conversation
After each completed turn, a lightweight API call generates a short follow-up question rendered as dimmed ghost text in the composer. Tab accepts the suggestion; typing dismisses it. - prompt_suggestion.rs: async suggestion generation via API - app.rs: prompt_suggestion display field + suggestion_cell for cross-thread delivery (Arc<Mutex<Option<String>>> pattern) - widgets/mod.rs: ghost text rendered with TEXT_HINT when input is empty and suggestion exists - ui.rs: suggestion generation on TurnComplete, cleanup on TurnStarted, Tab acceptance in event loop Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks @punkcanyang for taking the time to contribute. This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in Please read |
There was a problem hiding this comment.
Code Review
This pull request introduces asynchronous ghost-text follow-up prompt suggestions in the composer when the input is empty. Feedback highlights several areas for improvement: using try_lock() instead of lock() in the main UI event loop to prevent blocking, reusing a static reqwest::Client to avoid creating a new connection on every suggestion request, defaulting to a cheaper/faster model instead of the user's primary model to save costs, and explicitly clearing the suggestion on new input so it does not reappear when typed text is deleted.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if let Ok(mut guard) = app.prompt_suggestion_cell.lock() { | ||
| if let Some(suggestion) = guard.take() { | ||
| app.prompt_suggestion = Some(suggestion); | ||
| } | ||
| } |
There was a problem hiding this comment.
Using lock() on a std::sync::Mutex inside the main UI event loop can block the UI thread and cause frame drops or stuttering if the background thread is holding the lock. Since this is a polling operation on every tick, it is highly recommended to use try_lock() instead to ensure it remains completely non-blocking.
if let Ok(mut guard) = app.prompt_suggestion_cell.try_lock() {
if let Some(suggestion) = guard.take() {
app.prompt_suggestion = Some(suggestion);
}
}| model: &str, | ||
| recent_messages: &str, | ||
| ) -> Option<String> { | ||
| let client = reqwest::Client::new(); |
There was a problem hiding this comment.
Creating a new reqwest::Client on every function call prevents connection reuse (keep-alive) and forces a new TCP/TLS handshake for every suggestion request. Since this is called after every completed turn, reusing a static client via std::sync::LazyLock is much more efficient.
static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(reqwest::Client::new);
let client = &*CLIENT;| let suggestion_cell = app.prompt_suggestion_cell.clone(); | ||
| let api_key = config.deepseek_api_key().unwrap_or_default(); | ||
| let base_url = config.deepseek_base_url(); | ||
| let model = config.default_model(); |
There was a problem hiding this comment.
The PR description states that a lightweight API call (e.g., v4-flash) is used to generate the follow-up suggestion. However, config.default_model() typically resolves to the user's primary/pro model (e.g., deepseek-v4-pro), which is significantly more expensive and slower. Consider explicitly resolving or defaulting to a cheaper/faster model (like deepseek-v4-flash) for this background task to avoid unexpected API costs.
| /// Ghost-text follow-up suggestion shown in the composer when empty. | ||
| /// Generated asynchronously after each completed turn; cleared on new input. | ||
| pub prompt_suggestion: Option<String>, |
There was a problem hiding this comment.
The comment and PR description state that the suggestion is "cleared on new input" (typing dismisses it). However, the current implementation only hides the ghost text when input_text is non-empty, but does not actually clear prompt_suggestion (set it to None). If a user types a character and then deletes it, the same suggestion will reappear. To match the described behavior, app.prompt_suggestion should be explicitly set to None when new input is inserted (e.g., inside insert_char and insert_str).
|
Thanks @punkcanyang. This is a thoughtful composer UX idea, and I like the direction. I do not want to merge it as-is yet because CI is currently red on formatting, and a few normal-use TUI paths need tightening before this is release-safe: stale suggestions can still land after a newer turn starts, typing/backspace can bring an old suggestion back, the background call currently uses the active model instead of a deliberately lightweight provider-aware suggestion model, and the widget can show the suggestion in states like history search. Could you update the branch to run Thanks again for the contribution. |
…ycle - Use static OnceLock<reqwest::Client> to reuse connections - Use lightweight model (deepseek-v4-flash) for suggestions - Add AtomicU64 turn token for stale-suggestion protection - Use try_lock() instead of lock() in main loop - Clear suggestion on any input change (tracked via prev_input_snapshot) - Hide suggestion during history search in composer widget Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for the thoughtful review! Updated the branch with all the feedback addressed:
3984/3985 tests pass (1 pre-existing failure on main). Let me know if there's anything else to tighten up. |
|
Thanks for turning that around quickly, @punkcanyang. The lifecycle fixes are a real improvement, and the current CI run being green is helpful evidence. I am still going to leave this open rather than merge or harvest it for v0.9.0 today. The remaining release question is broader than the original race/formatting issues: this now makes an automatic network call after every completed turn, and it always routes that follow-up context through the DeepSeek config/model (
So: no takeover from me right now, because you are actively iterating and the branch is in a much better place. I would be happy to reconsider it as a direct community merge once the opt-in/provider-routing surface is nailed down. |
- Add prompt_suggestion: Option<bool> config field with prompt_suggestion_enabled() accessor (defaults to false) - Guard suggestion generation behind the config check - Use config.default_model() (provider-aware) instead of hardcoded deepseek-v4-flash to avoid cross-provider data egress - Document in config.example.toml Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Config tests: defaults to false, enabled when set true - Widget tests: ghost text renders when set, hidden when input non-empty, hidden when no suggestion Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Added config opt-in and tests:
All checks green. Let me know if there's more to address. |
Summary
After each completed turn, a lightweight API call (v4-flash, max_tokens=64) generates a short follow-up question rendered as dimmed ghost text in the composer. Tab accepts the suggestion, typing dismisses it.
Mirrors Claude Code's prompt suggestion behavior.
How it works
TurnComplete, summarizes last 8 messages and sends a one-shot API call to generate a follow-up questionArc<Mutex<Option<String>>>cross-thread cell (same pattern asbalance_cell)TEXT_HINTcolor when input is empty and suggestion existsTurnStartedCost
Each suggestion costs ~64 tokens (one-shot, non-streaming).
/initwon't trigger this since API key config is separate.Test plan
cargo build -p codewhale-tui— compilescargo clippy -p codewhale-tui --all-targets --all-features— cleanempty_composer_cursor_accounts_for_placeholder_wrapping)🤖 Generated with Claude Code
Greptile Summary
This PR adds an opt-in ghost-text follow-up prompt suggestion feature to the TUI composer. After each completed turn, a background task calls the configured API with a lightweight prompt and delivers the result via an
Arc<Mutex>cell guarded by a monotonic generation token; the suggestion renders as dimmed text when the composer is empty and Tab accepts it.prompt_suggestion.rs: new module with a static sharedreqwest::Client(viaOnceLock), OpenAI-compatible one-shot API call, and a message-summarization helper.ui.rs: spawns the background task onTurnComplete, incrementsprompt_suggestion_genonTurnStartedto discard stale results, and polls the shared cell each event-loop tick.widgets/mod.rs: renders ghost text in the composer's empty-input branch, but bothvisual_rowscalculations omit the!is_history_search_active()guard that the render path uses, causing wrong vertical padding when history search is active while a suggestion exists.Confidence Score: 4/5
Safe to merge with a small fix: the two
visual_rowsbranches in the composer widget don't mirror the render guard for history-search mode, producing incorrect vertical padding in that specific state.The core async machinery (gen-token staleness protection, static shared HTTP client, opt-in config flag) is well-designed and correct. The one concrete defect is in
widgets/mod.rs: bothvisual_rowscalculations pick the suggestion text as the placeholder size even when history search is active, while the render path correctly suppresses the suggestion in that state. The padding mismatch is visible and reproducible whenever a suggestion is live and the user opens history search.crates/tui/src/tui/widgets/mod.rs — both
visual_rowscalculations need the!is_history_search_active()guard added to match the render path.Important Files Changed
visual_rowscalculations fail to mirror the render guard (!is_history_search_active()), causing wrong vertical padding when history search is active with a live suggestion.reqwest::Client, OpenAI-compatible API call, and message summarization. Well-structured with proper error handling and OnceLock client reuse.prompt_suggestion,prompt_suggestion_gen(AtomicU64), andprompt_suggestion_cellfields toApp. Straightforward struct additions with correct initialization.prompt_suggestion: Option<bool>config field with correct default-off semantics, accessor, merge logic, and unit tests.Sequence Diagram
sequenceDiagram participant Engine participant EventLoop participant BgTask as Background Task participant Cell as prompt_suggestion_cell participant Composer Engine->>EventLoop: TurnComplete (Completed) EventLoop->>EventLoop: "capture gen_token = prompt_suggestion_gen" EventLoop->>BgTask: tokio::spawn(generate_suggestion) BgTask->>BgTask: summarize_recent_messages BgTask->>BgTask: HTTP POST /chat/completions BgTask->>Cell: lock → write (gen_token, suggestion) loop Every event-loop tick EventLoop->>Cell: try_lock → take (gen_token, suggestion) EventLoop->>EventLoop: "if gen_token == current → set prompt_suggestion" end Engine->>EventLoop: TurnStarted EventLoop->>EventLoop: "prompt_suggestion = None" EventLoop->>EventLoop: "prompt_suggestion_gen += 1 (invalidates in-flight task)" EventLoop->>Composer: render with app.prompt_suggestion Composer->>Composer: "if empty input && suggestion → show ghost text" Composer-->>EventLoop: Tab pressed → insert suggestion into inputComments Outside Diff (1)
crates/tui/src/tui/ui.rs, line 4181-4183 (link)The PR description says "any keystroke dismisses it," but
app.prompt_suggestionis never cleared in the character-insertion handler. When the input is non-empty the ghost text is just visually hidden; if the user types a character and then deletes it, the suggestion reappears with the same stale text. Clearingprompt_suggestionon the first character typed matches the stated behaviour and avoids the surprising re-appearance.Reviews (3): Last reviewed commit: "test(tui): add prompt suggestion config ..." | Re-trigger Greptile