Skip to content

feat(tui): ghost-text follow-up prompt suggestion#2781

Open
punkcanyang wants to merge 4 commits into
Hmbown:mainfrom
punkcanyang:feat/prompt-suggestion
Open

feat(tui): ghost-text follow-up prompt suggestion#2781
punkcanyang wants to merge 4 commits into
Hmbown:mainfrom
punkcanyang:feat/prompt-suggestion

Conversation

@punkcanyang
Copy link
Copy Markdown
Contributor

@punkcanyang punkcanyang commented Jun 5, 2026

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

  1. On TurnComplete, summarizes last 8 messages and sends a one-shot API call to generate a follow-up question
  2. Result delivered via Arc<Mutex<Option<String>>> cross-thread cell (same pattern as balance_cell)
  3. Composer renders ghost text with TEXT_HINT color when input is empty and suggestion exists
  4. Tab inserts the suggestion; any keystroke dismisses it
  5. Cleared on TurnStarted

Cost

Each suggestion costs ~64 tokens (one-shot, non-streaming). /init won't trigger this since API key config is separate.

Test plan

  • cargo build -p codewhale-tui — compiles
  • cargo clippy -p codewhale-tui --all-targets --all-features — clean
  • 3984/3985 tests pass (1 pre-existing failure: empty_composer_cursor_accounts_for_placeholder_wrapping)
  • Manual: 2+ turns conversation, verify ghost text renders and Tab accepts

🤖 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 shared reqwest::Client (via OnceLock), OpenAI-compatible one-shot API call, and a message-summarization helper.
  • ui.rs: spawns the background task on TurnComplete, increments prompt_suggestion_gen on TurnStarted to 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 both visual_rows calculations 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_rows branches 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: both visual_rows calculations 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_rows calculations need the !is_history_search_active() guard added to match the render path.

Important Files Changed

Filename Overview
crates/tui/src/tui/widgets/mod.rs Ghost-text rendering added to the composer widget. Both visual_rows calculations fail to mirror the render guard (!is_history_search_active()), causing wrong vertical padding when history search is active with a live suggestion.
crates/tui/src/tui/ui.rs Event-loop wiring for background suggestion tasks, gen-token stale-suggestion protection, Tab-to-accept handler, and TurnStarted/cell-poll ordering. Gen-token mechanism correctly prevents stale suggestions from appearing after a new turn starts.
crates/tui/src/tui/prompt_suggestion.rs New module implementing the ghost-text suggestion backend: static shared reqwest::Client, OpenAI-compatible API call, and message summarization. Well-structured with proper error handling and OnceLock client reuse.
crates/tui/src/tui/app.rs Adds prompt_suggestion, prompt_suggestion_gen (AtomicU64), and prompt_suggestion_cell fields to App. Straightforward struct additions with correct initialization.
crates/tui/src/config.rs Adds opt-in 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 input
Loading

Comments Outside Diff (1)

  1. crates/tui/src/tui/ui.rs, line 4181-4183 (link)

    P1 Typing doesn't dismiss the suggestion — old ghost text re-appears on backspace

    The PR description says "any keystroke dismisses it," but app.prompt_suggestion is 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. Clearing prompt_suggestion on the first character typed matches the stated behaviour and avoids the surprising re-appearance.

    Fix in Codex Fix in Claude Code Fix in Cursor

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (3): Last reviewed commit: "test(tui): add prompt suggestion config ..." | Re-trigger Greptile

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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

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 .github/APPROVED_CONTRIBUTORS will be closed automatically.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant PR access by commenting /lgtm on a pull request.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/tui/src/tui/ui.rs Outdated
Comment on lines +1269 to +1273
if let Ok(mut guard) = app.prompt_suggestion_cell.lock() {
if let Some(suggestion) = guard.take() {
app.prompt_suggestion = Some(suggestion);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
            }
        }

Comment thread crates/tui/src/tui/prompt_suggestion.rs Outdated
model: &str,
recent_messages: &str,
) -> Option<String> {
let client = reqwest::Client::new();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;

Comment thread crates/tui/src/tui/ui.rs
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Comment thread crates/tui/src/tui/app.rs
Comment on lines +1185 to +1187
/// 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>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Comment thread crates/tui/src/tui/ui.rs
Comment thread crates/tui/src/tui/prompt_suggestion.rs Outdated
Comment thread crates/tui/src/tui/prompt_suggestion.rs
Comment thread crates/tui/src/tui/ui.rs
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 5, 2026

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 cargo fmt, add stale-result protection with a turn/generation token or cancellation, clear suggestions on user input/paste, use a nonblocking cell poll or better async delivery primitive, and add focused tests for Tab accept, dismissal, and stale-turn behavior? Once those are covered, I think this can be reconsidered as a direct community merge rather than needing a maintainer harvest.

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>
@punkcanyang
Copy link
Copy Markdown
Contributor Author

Thanks for the thoughtful review! Updated the branch with all the feedback addressed:

  • fmt: cargo fmt passes
  • Stale protection: AtomicU64 turn token — background tasks discard results if a newer turn has started
  • Input lifecycle: suggestion clears on any input change (tracked via prev_input_snapshot), not just TurnStarted
  • Model: uses deepseek-v4-flash (lightweight, 64 max_tokens) instead of the user's active model
  • try_lock: non-blocking try_lock() in the main loop instead of lock()
  • History search: suggestion hidden when history search is active
  • Static client: OnceLock<reqwest::Client> reused across calls

3984/3985 tests pass (1 pre-existing failure on main). Let me know if there's anything else to tighten up.

Comment thread crates/tui/src/tui/ui.rs
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 5, 2026

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 (deepseek-v4-flash) even when the user is working with another configured provider. Before this becomes a default composer behavior, I think we need a small product/config slice around it:

  • make the feature explicitly configurable, ideally default-off until the privacy/cost behavior is clear;
  • avoid cross-provider data egress by either using the active provider, a configured suggestion provider, or a clearly documented opt-in;
  • cover the disabled/no-key path and provider-selection behavior in focused tests;
  • keep the good lifecycle tests you already aimed at: stale turn discard, input/paste dismissal, history-search suppression, and Tab accept.

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.

punkcanyang and others added 2 commits June 6, 2026 17:57
- 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>
@punkcanyang
Copy link
Copy Markdown
Contributor Author

Added config opt-in and tests:

  • Config: prompt_suggestion = true (top-level, default false — opt-in only). Accessor: config.prompt_suggestion_enabled()
  • Provider-aware model: uses config.default_model() which respects the active provider — no more hardcoded deepseek-v4-flash cross-provider egress
  • Tests added: config default-off, config enabled, ghost text rendering, input dismissal, placeholder fallback

All checks green. Let me know if there's more to address.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants