Skip to content

TUI gets stuck on working... while /task reports No tasks found #738

@Oliver-ZPLiu

Description

@Oliver-ZPLiu

Description

The TUI can enter a stale busy state where the footer keeps showing working..., new user input is moved into Pending inputs, but /task reports No tasks found.

Based on testing, this looks like a TUI state synchronization bug rather than a DeepSeek API/model failure. The frontend/composer state can believe a turn is still running even when the engine/task state has no active work.

There appears to be a mismatch between:

  • frontend/composer state, especially app.is_loading
  • engine/task state, shown by /task

When app.is_loading remains true, later input is treated as queued/pending. But /task can still report No tasks found, which leaves the user in a dead-end state: the TUI looks busy, but there is nothing obvious to wait for or cancel.

Steps to reproduce

  1. On Windows cmd, start the npm-installed TUI with deepseek-tui.
  2. Send a normal prompt, for example 你是什么模型.
  3. Observe that the footer can remain stuck on working....
  4. Send another normal prompt.
  5. Observe that the new prompt is moved into Pending inputs.
  6. Run /task.
  7. Observe that /task can report No tasks found even though the footer still shows working....

Notes:

  • This does not reproduce every time.
  • Starting a clean session with deepseek-tui --fresh can avoid the issue, and the model can answer normally in a clean session.
  • This suggests the API key/model path is working, but the TUI can get stuck in an inconsistent frontend state.

Expected behavior

If the current turn is not actually active anymore, the TUI should not keep blocking future user input.

Expected behavior would be one of:

  • The message is successfully dispatched to the engine and a normal turn starts.
  • If dispatch fails before the turn starts, the TUI clears the busy state and shows a recoverable error.
  • Future user input should not be silently queued forever when /task says there are no active tasks.

Actual behavior

In the affected state:

  • The footer remains on working....
  • Submitting another normal prompt does not start a new model turn.
  • The prompt appears under Pending inputs.
  • /task reports No tasks found.
  • The UI appears busy, but there is no visible active task.

Environment

  • OS: Windows
  • DeepSeek CLI version: npm-installed deepseek / deepseek-tui
  • Model: deepseek-v4-pro
  • Shell: cmd

Logs or screenshots

Image

Likely cause

In crates/tui/src/tui/ui.rs, dispatch_user_message() sets the UI loading state before sending the message to the engine:

app.is_loading = true;
app.last_send_at = Some(Instant::now());

Then it sends Op::SendMessage to the engine.

If that send fails before the engine accepts the message or before a turn-started event is emitted, the function can return early without clearing the frontend loading state.

As a result:

dispatch_user_message()
  -> sets app.is_loading = true
  -> engine send fails before turn starts
  -> app.is_loading is not reset
  -> later Enter submits are classified as queued
  -> UI shows Pending inputs + working...
  -> /task can still say No tasks found

Suggested minimal fix

Clear the frontend busy state if sending the message to the engine fails before the turn starts.

Suggested change in dispatch_user_message():

if let Err(err) = engine_handle
    .send(Op::SendMessage {
        content,
        mode: app.mode,
        model: effective_model,
        goal_objective: app.goal.goal_objective.clone(),
        reasoning_effort: app.reasoning_effort.api_value().map(str::to_string),
        allow_shell: app.allow_shell,
        trust_mode: app.trust_mode,
        auto_approve: app.mode == AppMode::Yolo,
    })
    .await
{
    app.is_loading = false;
    app.last_send_at = None;
    return Err(err);
}

This keeps the normal success path unchanged, but prevents the composer from staying permanently busy when dispatch fails before the engine starts a turn.

Suggested regression test

A deterministic unit-level reproduction can simulate a closed engine channel:

#[tokio::test]
async fn dispatch_failure_before_turn_started_clears_loading_state() {
    let mut app = create_test_app();
    let engine = crate::core::engine::mock_engine_handle();
    let handle = engine.handle.clone();

    // Simulate the engine no longer receiving ops.
    drop(engine.rx_op);

    let result = dispatch_user_message(
        &mut app,
        &handle,
        crate::tui::app::QueuedMessage::new("hello".to_string(), None),
    )
    .await;

    assert!(result.is_err());
    assert!(
        !app.is_loading,
        "failed dispatch must not leave future composer submits queued as if a turn were live"
    );
    assert!(app.last_send_at.is_none());
}

Broader design suggestion

The minimal fix above addresses one concrete stale-loading path. Longer term, it may be worth making the TUI busy state more resilient by deriving or reconciling it from a single source of truth.

Possible improvements:

  • If is_loading == true but there is no active turn/task/stream, show a recoverable error or reset the composer state.
  • Make Pending inputs explain why the input is pending.
  • Add a timeout or consistency check for states where the footer says working... but the task manager reports no active work.
  • Ensure failed dispatch, engine shutdown, crash recovery, and resume/fresh-session paths all clean up frontend busy state consistently.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions