mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/history): enforce strict global ordering and stream IDs
- Replace legacy turn-window logic with stable per insert - Require non-empty stream IDs; seed order keys per (kind,id) - Pair out-of-order ExecEnd/Begin and drop canceled execs - Surface init/submit errors as BackgroundEvent diagnostics - Improve status panes, borders, and preview truncation by display width - Update release workflow; docs: AGENTS.md, prompt_coder.md; add MERGE.md and postmortem Build: ./build-fast.sh passes locally with no warnings.
This commit is contained in:
+115
-16
@@ -273,6 +273,7 @@ jobs:
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -425,6 +426,119 @@ jobs:
|
||||
fi
|
||||
echo "tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Generate CHANGELOG and release notes by running our own `code` CLI in headless mode.
|
||||
# It will:
|
||||
# - Review changes between the previous tag and the new version
|
||||
# - Update CHANGELOG.md with a new section for vNEW_VERSION
|
||||
# - Write rich release notes to release-notes/RELEASE_NOTES.md
|
||||
- name: Generate CHANGELOG + release notes (Code)
|
||||
if: env.OPENAI_API_KEY != ''
|
||||
shell: bash
|
||||
env:
|
||||
NEW_VERSION: ${{ needs.determine-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --local user.email "[email protected]"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
# Determine previous tag (the most recent tag before the new one).
|
||||
# If none exists, use the initial commit as the base.
|
||||
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p' || true)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
BASE=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||
RANGE="$BASE..HEAD"
|
||||
else
|
||||
RANGE="$PREV_TAG..HEAD"
|
||||
fi
|
||||
echo "Previous tag: ${PREV_TAG:-<none>}"
|
||||
echo "Range: $RANGE"
|
||||
|
||||
# Extract a Linux x86_64 Code binary from artifacts and make it runnable.
|
||||
mkdir -p .code-bin release-notes
|
||||
LNX_ZST=$(ls -1 release-assets/code-x86_64-unknown-linux-musl.* 2>/dev/null | head -n1 || true)
|
||||
if [ -z "$LNX_ZST" ]; then
|
||||
echo "Could not find linux x86_64 Code artifact in release-assets/" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$LNX_ZST" in
|
||||
*.zst)
|
||||
zstd -d -q --force "$LNX_ZST" -o .code-bin/code ;;
|
||||
*.tar.gz)
|
||||
tar -xzf "$LNX_ZST" -C .code-bin && mv .code-bin/code-x86_64-unknown-linux-musl .code-bin/code ;;
|
||||
*.zip)
|
||||
unzip -q -o "$LNX_ZST" -d .code-bin && mv .code-bin/code-x86_64-unknown-linux-musl .code-bin/code ;;
|
||||
*)
|
||||
cp "$LNX_ZST" .code-bin/code ;;
|
||||
esac
|
||||
chmod +x .code-bin/code
|
||||
|
||||
# Prepare context for the model.
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
echo "# Commit log ($RANGE)" > release-notes/context.md
|
||||
git log --no-color --format='* %h %s (%an)' --abbrev=8 --no-merges $RANGE >> release-notes/context.md || true
|
||||
|
||||
# Build the task prompt (with variables expanded by bash).
|
||||
cat > release-notes/prompt.expanded.txt <<PROMPT
|
||||
You are Code, running headless during CI for a release.
|
||||
|
||||
Goal:
|
||||
- Review the provided git changes for this release.
|
||||
- Update the top-level CHANGELOG.md by inserting a new section for the release.
|
||||
- Write rich GitHub release notes to release-notes/RELEASE_NOTES.md.
|
||||
|
||||
Requirements:
|
||||
- Version: v${NEW_VERSION}
|
||||
- Date: ${DATE}
|
||||
- Previous tag: ${PREV_TAG:-none}
|
||||
- Range: ${RANGE}
|
||||
- Keep the existing changelog style and headings.
|
||||
- Categorize entries by feat, fix, perf, refactor, docs, chore, ci, test, build.
|
||||
- Link PRs and issues like #123 when present; include contributor handles when visible in commit messages.
|
||||
- Keep lines wrapped reasonably; use markdown lists.
|
||||
- Ensure idempotence: if a section for this version already exists, replace its contents.
|
||||
- After updating files, stage them and commit with: docs(changelog): update for v${NEW_VERSION}
|
||||
- Also create release-notes/RELEASE_NOTES.md mirroring the new CHANGELOG section with a brief intro and installation snippet.
|
||||
|
||||
Context (git log excerpt follows):
|
||||
PROMPT
|
||||
cat release-notes/context.md >> release-notes/prompt.expanded.txt
|
||||
|
||||
# Run Code in fully automated exec mode against the repo root.
|
||||
./.code-bin/code exec --full-auto --skip-git-repo-check --cwd "$GITHUB_WORKSPACE" < release-notes/prompt.expanded.txt || {
|
||||
echo "Code exec returned non-zero; continuing to check for outputs..." >&2
|
||||
}
|
||||
|
||||
# If the agent forgot to write release notes, synthesize a minimal one from CHANGELOG.
|
||||
if [ ! -s release-notes/RELEASE_NOTES.md ]; then
|
||||
awk -v v="${NEW_VERSION}" '/^## /{p=($2==("v"v)||$2==v)} p{print}' CHANGELOG.md > release-notes/RELEASE_NOTES.md || true
|
||||
fi
|
||||
|
||||
# Commit CHANGELOG changes if any.
|
||||
if ! git diff --quiet -- CHANGELOG.md; then
|
||||
git add CHANGELOG.md release-notes/RELEASE_NOTES.md || true
|
||||
git commit -m "docs(changelog): update for v${NEW_VERSION} [skip ci]" || true
|
||||
fi
|
||||
|
||||
- name: Fallback release notes from CHANGELOG (no OPENAI_API_KEY)
|
||||
if: env.OPENAI_API_KEY == ''
|
||||
shell: bash
|
||||
env:
|
||||
NEW_VERSION: ${{ needs.determine-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release-notes
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
awk -v v="${NEW_VERSION}" '/^## /{p=($2==("v"v)||$2==v)} p{print}' CHANGELOG.md > release-notes/RELEASE_NOTES.md || true
|
||||
fi
|
||||
if [ ! -s release-notes/RELEASE_NOTES.md ]; then
|
||||
printf "## @just-every/code v%s\n\nSee CHANGELOG.md for details." "${NEW_VERSION}" > release-notes/RELEASE_NOTES.md
|
||||
fi
|
||||
|
||||
- name: Assert release notes exist
|
||||
shell: bash
|
||||
run: |
|
||||
test -s release-notes/RELEASE_NOTES.md || { echo "release-notes/RELEASE_NOTES.md missing" >&2; exit 1; }
|
||||
|
||||
- name: Check if we should publish
|
||||
id: should_publish
|
||||
shell: bash
|
||||
@@ -458,22 +572,7 @@ jobs:
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: Release v${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
## @just-every/code v${{ steps.version.outputs.version }}
|
||||
|
||||
Fork of OpenAI Codex with additional features:
|
||||
- Enhanced image handling (drag-and-drop, paste, file path detection)
|
||||
- /reasoning slash command for dynamic reasoning effort
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
npm install -g @just-every/code
|
||||
```
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
code
|
||||
```
|
||||
body_path: release-notes/RELEASE_NOTES.md
|
||||
files: |
|
||||
release-assets/*
|
||||
env:
|
||||
|
||||
+1
-4
@@ -92,7 +92,4 @@ branches/
|
||||
|
||||
# coder directories
|
||||
.coder/
|
||||
.code/
|
||||
|
||||
# Merge instructions
|
||||
MERGE.md
|
||||
.code/
|
||||
@@ -1,3 +1,16 @@
|
||||
# Rust/codex-rs
|
||||
|
||||
In the codex-rs folder where the rust code lives:
|
||||
|
||||
- Crate names are prefixed with `codex-`. For example, the `core` folder's crate is named `codex-core`
|
||||
- When using format! and you can inline variables into {}, always do that.
|
||||
|
||||
Completion/build step
|
||||
|
||||
- Always validate using `./build-fast.sh` from the repo root. This is the single required check and must pass cleanly.
|
||||
- Policy: All errors AND all warnings must be fixed before you’re done. Treat any compiler warning as a failure and address it (rename unused vars with `_`, remove `mut`, delete dead code, etc.).
|
||||
- Do not run additional format/lint/test commands on completion (e.g., `just fmt`, `just fix`, `cargo test`) unless explicitly requested for a specific task.
|
||||
|
||||
## Strict Ordering In The TUI History
|
||||
|
||||
The TUI enforces strict, per‑turn ordering for all streamed content. Every
|
||||
@@ -10,14 +23,60 @@ stream insert (Answer or Reasoning) must be associated with a stable
|
||||
insert without an id is dropped with an error log to make the issue visible
|
||||
during development.
|
||||
|
||||
Developer guidance:
|
||||
## Commit Messages
|
||||
|
||||
- Always provide a stream id on `AgentReasoning*` and `AgentMessage*` events
|
||||
(both deltas and finals) and ensure `OrderMeta` is present.
|
||||
- If you introduce a new streaming path or a controller flush, propagate the
|
||||
current stream id through to the `InsertHistoryWithKind` sink. Do not call the
|
||||
no‑id begin path in the controller for Answer/Reasoning inserts.
|
||||
- If an insert path triggers the log
|
||||
"strict ordering: missing stream id …; dropping …", treat it as a bug in the
|
||||
emitter and fix the source to supply the id before merging.
|
||||
- Review staged changes before every commit: `git --no-pager diff --staged --stat` (and skim `git --no-pager diff --staged` if needed).
|
||||
- Write a descriptive subject that explains what changed and why. Avoid placeholders like "chore: commit local work".
|
||||
- Prefer Conventional Commits with an optional scope: `feat(tui/history): …`, `fix(core/exec): …`, `docs(agents): …`.
|
||||
- Keep the subject ≤ 72 chars; add a short body if rationale or context helps future readers.
|
||||
- Use imperative, present tense: "add", "fix", "update" (not "added", "fixes").
|
||||
- For merge commits, skip custom prefixes like `merge(main<-origin/main):`. Use a clear subject such as `Merge origin/main: <what changed and how conflicts were resolved>`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `feat(tui/history): show exit code and duration for Exec cells`
|
||||
- `fix(core/codex): handle SIGINT in on_exec_command_begin to avoid orphaned child`
|
||||
- `docs(agents): clarify commit-message expectations`
|
||||
|
||||
## Git Push Policy (Do Not Rebase On Push Requests)
|
||||
|
||||
When the user asks you to "push" local work:
|
||||
|
||||
- Never rebase in this flow. Do not use `git pull --rebase` or attempt to replay local commits.
|
||||
- Prefer a simple merge of `origin/main` into the current branch, keeping our local history intact.
|
||||
- If the remote only has trivial release metadata changes (e.g., `codex-cli/package.json` version bumps), adopt the remote version for those files and keep ours for everything else unless the user specifies otherwise.
|
||||
- If in doubt or if conflicts touch non-trivial areas, pause and ask before resolving.
|
||||
|
||||
Quick procedure (merge-only):
|
||||
|
||||
- Commit your local work first:
|
||||
- Review: `git --no-pager diff --stat` and `git --no-pager diff`
|
||||
- Stage + commit: `git add -A && git commit -m "<descriptive message of local changes>"`
|
||||
- Fetch remote: `git fetch origin`
|
||||
- Merge without auto-commit: `git merge --no-ff --no-commit origin/main` (stops before committing so you can choose sides)
|
||||
- Resolve policy:
|
||||
- Default to ours: `git checkout --ours .`
|
||||
- Take remote for trivial package/version files as needed, e.g.: `git checkout --theirs codex-cli/package.json`
|
||||
- Stage and commit the merge with a descriptive message, e.g.:
|
||||
- `git add -A && git commit -m "Merge origin/main: adopt remote version bumps; keep ours elsewhere (<areas>)"`
|
||||
- Run `./build-fast.sh` and then `git push`
|
||||
|
||||
## Command Execution Architecture
|
||||
|
||||
The command execution flow in Codex follows an event-driven pattern:
|
||||
|
||||
1. **Core Layer** (`codex-core/src/codex.rs`):
|
||||
- `on_exec_command_begin()` initiates command execution
|
||||
- Creates `EventMsg::ExecCommandBegin` events with command details
|
||||
|
||||
2. **TUI Layer** (`codex-tui/src/chatwidget.rs`):
|
||||
- `handle_codex_event()` processes execution events
|
||||
- Manages `RunningCommand` state for active commands
|
||||
- Creates `HistoryCell::Exec` for UI rendering
|
||||
|
||||
3. **History Cell** (`codex-tui/src/history_cell.rs`):
|
||||
- `new_active_exec_command()` - Creates cell for running command
|
||||
- `new_completed_exec_command()` - Updates with final output
|
||||
- Handles syntax highlighting via `ParsedCommand`
|
||||
|
||||
This architecture separates concerns between execution logic (core), UI state management (chatwidget), and rendering (history_cell).
|
||||
@@ -0,0 +1,32 @@
|
||||
Goal
|
||||
◦ Merge latest upstream/main into our repo via a merge branch.
|
||||
◦ Prefer our changes, but adopt upstream TUI improvements where compatible.
|
||||
◦ Verify only with ./build-fast.sh.
|
||||
|
||||
Constraints
|
||||
◦ Keep our TUI architecture (themes/colors, browser, multi-agent) intact.
|
||||
|
||||
Essential Steps
|
||||
◦ Prep:
|
||||
· Stash if dirty. Create branch from main.
|
||||
· Pull from origin/main
|
||||
· Fetch upstream openai/codex:main
|
||||
◦ Merge:
|
||||
· Merge upstream with -X ours.
|
||||
· Resolve conflicts; don’t blanket keep ours for TUI — review diffs and integrate upstream fixes that don’t break our themes/browser/agents. Most of the time you WILL keep our TUI changes, but still review to make sure there's nothing we can merge which is missed.
|
||||
· For TUI files (chatwidget, history_cell, bottom_pane/*), pull in upstream improvements selectively; keep our theme hooks (mod colors;), browser HUD, and agent UI.
|
||||
· Keep our versions of AGENTS.md, CHANGELOG.md and README.md
|
||||
◦ Build/fix:
|
||||
· Run ./build-fast.sh.
|
||||
· Fix all errors
|
||||
· Fix all warnings
|
||||
◦ Finalize:
|
||||
· Commit changes
|
||||
· Merge branch into main (do not push)
|
||||
· If you stashed files, unstash them
|
||||
|
||||
Report:
|
||||
◦ Finally please produce a report on;
|
||||
· What upstream changes were incorporated
|
||||
· What upstream changes were dropped
|
||||
· Any other code which was cleaned up or changed
|
||||
@@ -75,6 +75,9 @@ agent_wait {"batch_id":"<batch_id>","return_all":true,"timeout_seconds": 600 } /
|
||||
- If a rebase is explicitly required by maintainers, confirm first and proceed carefully; otherwise stick to pull/merge to prevent history churn and conflicts.
|
||||
- NEVER use `git revert` or `git checkout` unless you are sure it will not overwrite any unrelated changes. Multiple changes may have been made to the code and you can not be sure that you will revert only your changes.
|
||||
|
||||
# WARNING (editing files)
|
||||
- Never replace the content of a file before checking what exists in it. For example, when writing to AGENTS.md always append, don't just replace existing content.
|
||||
- If you do need to delete or clear content, unless the deletion was requested by the user, always create a backup or save to git first.
|
||||
|
||||
# Final output
|
||||
You can include FULL markdown in any responses you make. These will be converted to beautiful output in the terminal.
|
||||
|
||||
+32
-14
@@ -451,7 +451,9 @@ impl Codex {
|
||||
// experimental resume path (undocumented)
|
||||
let resume_path = config.experimental_resume.clone();
|
||||
info!("resume_path: {resume_path:?}");
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(64);
|
||||
// Use an unbounded submission queue to avoid any possibility of back‑pressure
|
||||
// between the TUI submit worker and the core loop during interrupts/cancels.
|
||||
let (tx_sub, rx_sub) = async_channel::unbounded();
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let user_instructions = get_user_instructions(&config).await;
|
||||
@@ -1356,18 +1358,32 @@ impl Session {
|
||||
|
||||
fn abort(&self) {
|
||||
info!("Aborting existing session");
|
||||
// (debug removed)
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
// (debug removed)
|
||||
state.pending_approvals.clear();
|
||||
state.pending_input.clear();
|
||||
// Do not clear `pending_input` here. When a user submits a new message
|
||||
// immediately after an interrupt, it may have been routed to
|
||||
// `pending_input` by an earlier code path. Clearing it would drop the
|
||||
// user's message and prevent the next turn from ever starting.
|
||||
state.turn_scratchpad = None;
|
||||
if let Some(agent) = state.current_agent.take() {
|
||||
// Take current agent while holding the lock, then drop the lock BEFORE calling abort
|
||||
let current = state.current_agent.take();
|
||||
drop(state);
|
||||
if let Some(agent) = current {
|
||||
agent.abort(TurnAbortReason::Interrupted);
|
||||
// (debug removed)
|
||||
} else {
|
||||
// (debug removed)
|
||||
}
|
||||
// Also terminate any running exec sessions (PTY-based) so child processes do not linger.
|
||||
// Best-effort cleanup for PTY-based exec sessions would go here. The
|
||||
// PTY implementation already kills processes on session drop; in the
|
||||
// common LocalShellCall path we also kill processes immediately via
|
||||
// KillOnDrop in exec.rs.
|
||||
|
||||
// (debug removed)
|
||||
}
|
||||
|
||||
/// Spawn the configured notifier (if any) with the given JSON payload as
|
||||
@@ -1441,8 +1457,7 @@ pub(crate) struct AgentAgent {
|
||||
|
||||
impl AgentAgent {
|
||||
fn spawn(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) -> Self {
|
||||
let handle =
|
||||
tokio::spawn(run_agent(Arc::clone(&sess), sub_id.clone(), input)).abort_handle();
|
||||
let handle = tokio::spawn(run_agent(Arc::clone(&sess), sub_id.clone(), input)).abort_handle();
|
||||
Self {
|
||||
sess,
|
||||
sub_id,
|
||||
@@ -1508,16 +1523,17 @@ async fn submission_loop(
|
||||
// To break out of this loop, send Op::Shutdown.
|
||||
while let Ok(sub) = rx_sub.recv().await {
|
||||
debug!(?sub, "Submission");
|
||||
// (submission diagnostics removed)
|
||||
match sub.op {
|
||||
Op::Interrupt => {
|
||||
let sess = match sess.as_ref() {
|
||||
Some(sess) => sess,
|
||||
Some(sess) => sess.clone(),
|
||||
None => {
|
||||
send_no_session_event(sub.id).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
sess.abort();
|
||||
tokio::spawn(async move { sess.abort() });
|
||||
}
|
||||
Op::ConfigureSession {
|
||||
provider,
|
||||
@@ -1753,12 +1769,13 @@ async fn submission_loop(
|
||||
// This prevents token buildup from old screenshots/status messages
|
||||
sess.cleanup_old_status_items().await;
|
||||
|
||||
// attempt to inject input into current agent
|
||||
if let Err(items) = sess.inject_input(items) {
|
||||
// no current agent, spawn a new one
|
||||
let agent = AgentAgent::spawn(Arc::clone(sess), sub.id, items);
|
||||
sess.set_agent(agent);
|
||||
}
|
||||
// Abort synchronously here to avoid a race that can kill the
|
||||
// newly spawned agent if the async abort runs after set_agent.
|
||||
sess.abort();
|
||||
|
||||
// Spawn a new agent for this user input.
|
||||
let agent = AgentAgent::spawn(Arc::clone(sess), sub.id, items);
|
||||
sess.set_agent(agent);
|
||||
}
|
||||
Op::ExecApproval { id, decision } => {
|
||||
let sess = match sess.as_ref() {
|
||||
@@ -1865,7 +1882,8 @@ async fn submission_loop(
|
||||
|
||||
// Ensure any running agent is aborted so streaming stops promptly.
|
||||
if let Some(sess_arc) = sess.as_ref() {
|
||||
sess_arc.abort();
|
||||
let s2 = sess_arc.clone();
|
||||
tokio::spawn(async move { s2.abort(); });
|
||||
}
|
||||
|
||||
// Gracefully flush and shutdown rollout recorder on session end so tests
|
||||
|
||||
+10
-23
@@ -431,8 +431,8 @@ impl App<'_> {
|
||||
// Unified Esc policy with modal-first handling:
|
||||
// - If any modal is active, forward Esc to the widget so the modal can close itself.
|
||||
// - Otherwise apply global Esc ordering:
|
||||
// 1) If there's text, clear it.
|
||||
// 2) Else if agent is running, stop it.
|
||||
// 1) If agent is running, stop it (even if the composer has text).
|
||||
// 2) Else if there's text, clear it.
|
||||
// 3) Else double‑Esc engages backtrack/edit‑previous.
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
// Modal-first: give active modal views priority to handle Esc.
|
||||
@@ -448,18 +448,18 @@ impl App<'_> {
|
||||
let now = Instant::now();
|
||||
const THRESHOLD: Duration = Duration::from_millis(600);
|
||||
|
||||
// Step 1: clear composer text if present.
|
||||
if !widget.composer_is_empty() {
|
||||
widget.clear_composer();
|
||||
// Arm double‑Esc so a quick second Esc proceeds to steps 2/3.
|
||||
// Step 1: stop agent if running, regardless of composer content.
|
||||
if widget.is_task_running() {
|
||||
let _ = widget.on_ctrl_c();
|
||||
// Arm double‑Esc so next Esc can trigger backtrack.
|
||||
self.last_esc_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 2: stop agent if running.
|
||||
if widget.is_task_running() {
|
||||
let _ = widget.on_ctrl_c();
|
||||
// Arm double‑Esc so next Esc can trigger backtrack.
|
||||
// Step 2: clear composer text if present.
|
||||
if !widget.composer_is_empty() {
|
||||
widget.clear_composer();
|
||||
// Arm double‑Esc so a quick second Esc proceeds to step 3.
|
||||
self.last_esc_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
@@ -624,18 +624,6 @@ impl App<'_> {
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.dispatch_codex_event(event);
|
||||
}
|
||||
AppEvent::BackendFailure(msg) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
// Surface a small notice in history, then reset UI state.
|
||||
let note = crate::history_cell::new_background_event(
|
||||
format!("backend error: {} — restarting session…", msg),
|
||||
);
|
||||
widget.insert_history_lines(note.display_lines());
|
||||
// ChatWidget no longer exposes `restart_backend`; start a fresh
|
||||
// conversation UI so the next input re-initializes cleanly.
|
||||
widget.new_conversation(self.enhanced_keys_supported);
|
||||
}
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
// Stop background threads and break the UI loop.
|
||||
self.commit_anim_running.store(false, Ordering::Release);
|
||||
@@ -1267,4 +1255,3 @@ impl TimingStats {
|
||||
)
|
||||
}
|
||||
}
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
@@ -29,9 +29,6 @@ impl<T> fmt::Debug for Redacted<T> {
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AppEvent {
|
||||
CodexEvent(Event),
|
||||
/// The backend conversation task failed (submit or event stream ended).
|
||||
/// Carries a short diagnostic message for history.
|
||||
BackendFailure(String),
|
||||
|
||||
/// Request a redraw which will be debounced by the [`App`].
|
||||
RequestRedraw,
|
||||
|
||||
+155
-30
@@ -189,6 +189,9 @@ pub(crate) struct ChatWidget<'a> {
|
||||
|
||||
// Guard for out-of-order exec events: track call_ids that already ended
|
||||
ended_call_ids: HashSet<ExecCallId>,
|
||||
/// Exec call_ids that were explicitly cancelled by user interrupt. Used to
|
||||
/// drop any late ExecEnd events so we don't render duplicate cells.
|
||||
canceled_exec_call_ids: HashSet<ExecCallId>,
|
||||
|
||||
// Accumulated diff/session state
|
||||
diffs: DiffsState,
|
||||
@@ -401,6 +404,15 @@ impl ChatWidget<'_> {
|
||||
self.internal_seq = self.internal_seq.saturating_add(1);
|
||||
OrderKey { req, out: i32::MIN + 1, seq: self.internal_seq }
|
||||
}
|
||||
|
||||
// Synthetic key for internal notices tied to the upcoming turn that
|
||||
// should appear immediately after the user prompt but still before any
|
||||
// model output for that turn.
|
||||
fn next_req_key_after_prompt(&mut self) -> OrderKey {
|
||||
let req = self.last_seen_request_index.saturating_add(1);
|
||||
self.internal_seq = self.internal_seq.saturating_add(1);
|
||||
OrderKey { req, out: i32::MIN + 2, seq: self.internal_seq }
|
||||
}
|
||||
/// Hide the bottom spinner/status if the UI is idle (no streams, tools, agents, or tasks).
|
||||
fn maybe_hide_spinner(&mut self) {
|
||||
let any_tools_running = !self.exec.running_commands.is_empty()
|
||||
@@ -1057,6 +1069,20 @@ impl ChatWidget<'_> {
|
||||
// Defensive: clear transient flags so UI can quiesce
|
||||
self.agents_ready_to_start = false;
|
||||
self.active_task_ids.clear();
|
||||
// Restore any queued messages back into the composer so the user can
|
||||
// immediately press Enter to resume the conversation where they left off.
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
let mut prefill = String::new();
|
||||
for (i, qm) in self.queued_user_messages.iter().enumerate() {
|
||||
if i > 0 { prefill.push('\n'); }
|
||||
prefill.push_str(qm.display_text.trim_end());
|
||||
}
|
||||
self.clear_composer();
|
||||
if !prefill.is_empty() { self.insert_str(&prefill); }
|
||||
self.queued_user_messages.clear();
|
||||
// Clear any sticky status like "queued for next turn" now that we returned text
|
||||
self.bottom_pane.update_status_text(String::new());
|
||||
}
|
||||
self.maybe_hide_spinner();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -1098,8 +1124,20 @@ impl ChatWidget<'_> {
|
||||
{
|
||||
Ok(conv) => conv,
|
||||
Err(e) => {
|
||||
// TODO: surface this error to the user.
|
||||
tracing::error!("failed to initialize conversation: {e}");
|
||||
// Surface a visible background event so users see why nothing starts.
|
||||
let ev = Event {
|
||||
id: "diagnostic".to_string(),
|
||||
event_seq: 0,
|
||||
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
|
||||
message: format!(
|
||||
"❌ Failed to initialize model session: {}.\n• Ensure an OpenAI API key is set (CODE_OPENAI_API_KEY / OPENAI_API_KEY) or run `code login`.\n• Also verify config.cwd is an absolute path.",
|
||||
e
|
||||
),
|
||||
}),
|
||||
order: None,
|
||||
};
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1110,11 +1148,13 @@ impl ChatWidget<'_> {
|
||||
|
||||
let conversation = new_conversation.conversation;
|
||||
let conversation_clone = conversation.clone();
|
||||
let app_event_tx_submit = app_event_tx_clone.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(op) = codex_op_rx.recv().await {
|
||||
let id = conversation_clone.submit(op).await;
|
||||
if let Err(e) = id {
|
||||
if let Err(e) = conversation_clone.submit(op).await {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
let ev = Event { id: "diagnostic".to_string(), event_seq: 0, msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: format!("⚠️ Failed to submit Op to core: {}", e) }), order: None };
|
||||
app_event_tx_submit.send(AppEvent::CodexEvent(ev));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1122,6 +1162,7 @@ impl ChatWidget<'_> {
|
||||
while let Ok(event) = conversation.next_event().await {
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(event));
|
||||
}
|
||||
// (debug end notice removed)
|
||||
});
|
||||
|
||||
// Browser manager is now handled through the global state
|
||||
@@ -1155,7 +1196,8 @@ impl ChatWidget<'_> {
|
||||
last_token_usage: TokenUsage::default(),
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState { running_commands: HashMap::new(), running_read_agg_index: None, pending_exec_ends: HashMap::new() },
|
||||
exec: ExecState { running_commands: HashMap::new(), running_read_agg_index: None, pending_exec_ends: HashMap::new() },
|
||||
canceled_exec_call_ids: HashSet::new(),
|
||||
tools_state: ToolState { running_custom_tools: HashMap::new(), running_web_search: HashMap::new() },
|
||||
// Use max width to disable wrapping during streaming
|
||||
// Text will be properly wrapped when displayed based on terminal width
|
||||
@@ -1280,7 +1322,8 @@ impl ChatWidget<'_> {
|
||||
last_token_usage: TokenUsage::default(),
|
||||
content_buffer: String::new(),
|
||||
last_assistant_message: None,
|
||||
exec: ExecState { running_commands: HashMap::new(), running_read_agg_index: None, pending_exec_ends: HashMap::new() },
|
||||
exec: ExecState { running_commands: HashMap::new(), running_read_agg_index: None, pending_exec_ends: HashMap::new() },
|
||||
canceled_exec_call_ids: HashSet::new(),
|
||||
tools_state: ToolState { running_custom_tools: HashMap::new(), running_web_search: HashMap::new() },
|
||||
live_builder: RowBuilder::new(usize::MAX),
|
||||
pending_images: HashMap::new(),
|
||||
@@ -1714,9 +1757,17 @@ impl ChatWidget<'_> {
|
||||
.collect();
|
||||
// Truncate preview to avoid overflow in narrow panes
|
||||
let mut preview = text.clone();
|
||||
if preview.len() > 120 {
|
||||
preview.truncate(120);
|
||||
preview.push_str("…");
|
||||
// Truncate preview by display width, not bytes, to avoid splitting
|
||||
// a multi-byte character at an invalid boundary.
|
||||
{
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
let maxw = 120usize;
|
||||
if preview.width() > maxw {
|
||||
preview = format!(
|
||||
"{}…",
|
||||
crate::live_wrap::take_prefix_by_width(&preview, maxw.saturating_sub(1)).0
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(format!(
|
||||
"title='{}' bytes={} chars={} width={} spans={} span_bytes={:?}",
|
||||
@@ -1850,6 +1901,9 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
// Surface a local diagnostic note and anchor it to the NEXT turn,
|
||||
// placing it directly after the user prompt so ordering is stable.
|
||||
// (debug message removed)
|
||||
// Fade the welcome cell only when a user actually posts a message.
|
||||
for cell in &self.history_cells { cell.trigger_fade(); }
|
||||
let UserMessage { display_text, mut ordered_items } = user_message;
|
||||
@@ -2051,16 +2105,12 @@ impl ChatWidget<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
// If a task is running, queue this message to be sent when the
|
||||
// current turn completes. Keep it visible at the bottom as
|
||||
// "user (queued)" until then.
|
||||
let task_is_running = self.bottom_pane.is_task_running() || !self.active_task_ids.is_empty();
|
||||
if task_is_running {
|
||||
self.queued_user_messages.push_back(UserMessage { display_text: original_text.clone(), ordered_items: items });
|
||||
// Show a subtle status so users know it's queued
|
||||
self.bottom_pane.update_status_text("queued for next turn".to_string());
|
||||
self.request_redraw();
|
||||
return;
|
||||
// New policy: always send immediately even if a task is running.
|
||||
// The core will abort any current task on UserInput and start a new turn.
|
||||
// This prevents messages from being stranded in `queued_user_messages`
|
||||
// after an interrupt (there is no TaskComplete after Op::Interrupt).
|
||||
if self.bottom_pane.is_task_running() || !self.active_task_ids.is_empty() {
|
||||
self.bottom_pane.update_status_text("interrupting & starting new turn".to_string());
|
||||
}
|
||||
|
||||
// Idle path: append the user cell first so the upcoming TaskStarted
|
||||
@@ -2071,14 +2121,14 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
// Now send to the agent
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.unwrap_or_else(|e| tracing::error!("failed to send message: {e}"));
|
||||
let _ = self.codex_op_tx.send(Op::UserInput { items });
|
||||
if !original_text.is_empty() {
|
||||
self.codex_op_tx
|
||||
.send(Op::AddToHistory { text: original_text.clone() })
|
||||
.unwrap_or_else(|e| tracing::error!("failed to send AddHistory op: {e}"));
|
||||
}
|
||||
|
||||
// (debug watchdog removed)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -2186,6 +2236,11 @@ impl ChatWidget<'_> {
|
||||
EventMsg::WebSearchComplete(ev) => tools::web_search_complete(self, ev.call_id, ev.query),
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
tracing::debug!("AgentMessageDelta: {:?}", delta);
|
||||
// If the user requested an interrupt, ignore late deltas.
|
||||
if self.stream_state.drop_streaming {
|
||||
tracing::debug!("Ignoring Answer delta after interrupt");
|
||||
return;
|
||||
}
|
||||
// Ignore late deltas for ids that have already finalized in this turn
|
||||
if self.stream_state.closed_answer_ids.contains(&StreamId(id.clone())) {
|
||||
tracing::debug!("Ignoring Answer delta for closed id={}", id);
|
||||
@@ -2195,7 +2250,13 @@ impl ChatWidget<'_> {
|
||||
let ok = Self::order_key_from_order_meta(event.order.as_ref());
|
||||
self.seed_stream_order_key(StreamKind::Answer, &id, ok);
|
||||
// Stream answer delta through StreamController
|
||||
streaming::delta_text(self, StreamKind::Answer, id.clone(), delta, Some(event.event_seq));
|
||||
streaming::delta_text(
|
||||
self,
|
||||
StreamKind::Answer,
|
||||
id.clone(),
|
||||
delta,
|
||||
event.order.as_ref().and_then(|o| o.sequence_number),
|
||||
);
|
||||
// Show responding state while assistant streams
|
||||
self.bottom_pane.update_status_text("responding".to_string());
|
||||
}
|
||||
@@ -2237,6 +2298,10 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||
tracing::debug!("AgentReasoningDelta: {:?}", delta);
|
||||
if self.stream_state.drop_streaming {
|
||||
tracing::debug!("Ignoring Reasoning delta after interrupt");
|
||||
return;
|
||||
}
|
||||
// Ignore late deltas for ids that have already finalized in this turn
|
||||
if self.stream_state.closed_reasoning_ids.contains(&StreamId(id.clone())) {
|
||||
tracing::debug!("Ignoring Reasoning delta for closed id={}", id);
|
||||
@@ -2247,7 +2312,13 @@ impl ChatWidget<'_> {
|
||||
tracing::info!("[order] EventMsg::AgentReasoningDelta id={} key={:?}", id, ok);
|
||||
self.seed_stream_order_key(StreamKind::Reasoning, &id, ok);
|
||||
// Stream reasoning delta through StreamController
|
||||
streaming::delta_text(self, StreamKind::Reasoning, id.clone(), delta, Some(event.event_seq));
|
||||
streaming::delta_text(
|
||||
self,
|
||||
StreamKind::Reasoning,
|
||||
id.clone(),
|
||||
delta,
|
||||
event.order.as_ref().and_then(|o| o.sequence_number),
|
||||
);
|
||||
// Show thinking state while reasoning streams
|
||||
self.bottom_pane.update_status_text("thinking".to_string());
|
||||
}
|
||||
@@ -2257,6 +2328,9 @@ impl ChatWidget<'_> {
|
||||
self.stream.insert_reasoning_section_break(&sink);
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
// This begins the new turn; clear the pending prompt anchor count
|
||||
// so subsequent background events use standard placement.
|
||||
self.pending_user_prompts_for_next_turn = 0;
|
||||
// Reset stream headers for new turn
|
||||
self.stream.reset_headers_for_new_turn();
|
||||
self.stream_state.current_kind = None;
|
||||
@@ -2382,6 +2456,10 @@ impl ChatWidget<'_> {
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta }) => {
|
||||
if self.stream_state.drop_streaming {
|
||||
tracing::debug!("Ignoring RawContent delta after interrupt");
|
||||
return;
|
||||
}
|
||||
// Treat raw reasoning content the same as summarized reasoning
|
||||
if self.stream_state.closed_reasoning_ids.contains(&StreamId(id.clone())) {
|
||||
tracing::debug!("Ignoring RawContent delta for closed id={}", id);
|
||||
@@ -2391,7 +2469,13 @@ impl ChatWidget<'_> {
|
||||
let ok = Self::order_key_from_order_meta(event.order.as_ref());
|
||||
self.seed_stream_order_key(StreamKind::Reasoning, &id, ok);
|
||||
|
||||
streaming::delta_text(self, StreamKind::Reasoning, id.clone(), delta, Some(event.event_seq));
|
||||
streaming::delta_text(
|
||||
self,
|
||||
StreamKind::Reasoning,
|
||||
id.clone(),
|
||||
delta,
|
||||
event.order.as_ref().and_then(|o| o.sequence_number),
|
||||
);
|
||||
}
|
||||
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
if self.stream_state.drop_streaming {
|
||||
@@ -2711,6 +2795,7 @@ impl ChatWidget<'_> {
|
||||
.on_history_entry_response(log_id, offset, entry.map(|e| e.text));
|
||||
}
|
||||
EventMsg::ShutdownComplete => {
|
||||
self.history_push(history_cell::new_background_event("🟡 ShutdownComplete".to_string()));
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
|
||||
@@ -2720,7 +2805,19 @@ impl ChatWidget<'_> {
|
||||
info!("BackgroundEvent: {message}");
|
||||
// Surface lightweight background events in the history feed
|
||||
// so users see confirmations (e.g., CDP connect success).
|
||||
self.history_push(history_cell::new_background_event(message.clone()));
|
||||
// If a user prompt was just inserted for the upcoming turn,
|
||||
// anchor these background events to that next turn so they
|
||||
// render below the prompt and above model output.
|
||||
if self.pending_user_prompts_for_next_turn > 0 {
|
||||
let key = self.next_req_key_after_prompt();
|
||||
let _ = self.history_insert_with_key_global_tagged(
|
||||
Box::new(history_cell::new_background_event(message.clone())),
|
||||
key,
|
||||
"bg-next-turn",
|
||||
);
|
||||
} else {
|
||||
self.history_push(history_cell::new_background_event(message.clone()));
|
||||
}
|
||||
|
||||
// Also reflect CDP connect success in the status line.
|
||||
if message.starts_with("✅ Connected to Chrome via CDP") {
|
||||
@@ -3651,7 +3748,7 @@ impl ChatWidget<'_> {
|
||||
.downcast_mut::<history_cell::CollapsibleReasoningCell>()
|
||||
{
|
||||
tracing::debug!("Appending {} lines to Reasoning(id={})", lines.len(), rid);
|
||||
reasoning_cell.lines.extend(lines);
|
||||
reasoning_cell.append_lines_dedup(lines);
|
||||
reasoning_cell.set_in_progress(true);
|
||||
self.invalidate_height_cache();
|
||||
self.autoscroll_if_near_bottom();
|
||||
@@ -3674,7 +3771,7 @@ impl ChatWidget<'_> {
|
||||
// Refresh the cache with the corrected index
|
||||
self.reasoning_index.insert(rid.clone(), found_idx);
|
||||
tracing::debug!("Recovered stale reasoning index; appending at {} for id={}", found_idx, rid);
|
||||
reasoning_cell.lines.extend(lines);
|
||||
reasoning_cell.append_lines_dedup(lines);
|
||||
reasoning_cell.set_in_progress(true);
|
||||
self.invalidate_height_cache();
|
||||
self.autoscroll_if_near_bottom();
|
||||
@@ -6851,6 +6948,24 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
};
|
||||
|
||||
let content_y = ps[idx];
|
||||
|
||||
// Targeted bottom-row spacer compensation:
|
||||
// If the viewport is at the very bottom (viewport_bottom == total_height)
|
||||
// and this is the last item being considered (idx == end_idx - 1), we
|
||||
// may land inside the 1-row spacer just above this last item. In that
|
||||
// case content_y > scroll_pos and the renderer would not account for
|
||||
// the spacer gap before drawing the item, leaving a 1-row blank at the
|
||||
// bottom. Nudge screen_y down by that exact gap so the last cell sits
|
||||
// flush with the bottom without altering general scroll math.
|
||||
if viewport_bottom == total_height && idx == end_idx.saturating_sub(1) {
|
||||
let gap = content_y.saturating_sub(scroll_pos);
|
||||
if gap > 0 {
|
||||
let remaining = (content_area.y + content_area.height).saturating_sub(screen_y);
|
||||
let shift = gap.min(remaining);
|
||||
screen_y = screen_y.saturating_add(shift);
|
||||
}
|
||||
}
|
||||
|
||||
let skip_top = if content_y < scroll_pos { scroll_pos - content_y } else { 0 };
|
||||
|
||||
// Stop if we've gone past the bottom of the screen
|
||||
@@ -7039,14 +7154,24 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
let below_y = item_area.y.saturating_add(visible_height);
|
||||
let bottom_y = content_area.y.saturating_add(content_area.height);
|
||||
let maxw = item_area.width as usize;
|
||||
if text.len() > maxw { text.truncate(maxw); }
|
||||
// Truncate safely by display width, not by bytes, to avoid
|
||||
// panics on non-UTF-8 boundaries (e.g., emoji/CJK). Use the
|
||||
// same width logic as our live wrap utilities.
|
||||
let draw_text = {
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
if text.width() > maxw {
|
||||
crate::live_wrap::take_prefix_by_width(&text, maxw).0
|
||||
} else {
|
||||
text.clone()
|
||||
}
|
||||
};
|
||||
if item_area.width > 0 {
|
||||
if below_y < bottom_y {
|
||||
buf.set_string(item_area.x, below_y, text.clone(), style);
|
||||
buf.set_string(item_area.x, below_y, draw_text.clone(), style);
|
||||
} else if item_area.y > content_area.y {
|
||||
// Fall back to above the item if no space below
|
||||
let above_y = item_area.y.saturating_sub(1);
|
||||
buf.set_string(item_area.x, above_y, text.clone(), style);
|
||||
buf.set_string(item_area.x, above_y, draw_text.clone(), style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ pub(super) fn finalize_all_running_as_interrupted(chat: &mut ChatWidget<'_>) {
|
||||
finalize_exec_cell_at(chat, idx, 130, stdout_empty.clone(), interrupted_msg.clone());
|
||||
}
|
||||
}
|
||||
// Track cancelled exec call_ids so late ExecEnd events are dropped.
|
||||
for (call_id, _) in chat.exec.running_commands.iter() {
|
||||
chat.canceled_exec_call_ids.insert(call_id.clone());
|
||||
}
|
||||
chat.exec.running_commands.clear();
|
||||
|
||||
if !chat.tools_state.running_custom_tools.is_empty() {
|
||||
@@ -409,6 +413,15 @@ pub(super) fn handle_exec_begin_now(chat: &mut ChatWidget<'_>, ev: ExecCommandBe
|
||||
|
||||
pub(super) fn handle_exec_end_now(chat: &mut ChatWidget<'_>, ev: ExecCommandEndEvent, order: &OrderMeta) {
|
||||
chat.ended_call_ids.insert(super::ExecCallId(ev.call_id.clone()));
|
||||
// If this call was already marked as cancelled, drop the End to avoid
|
||||
// inserting a duplicate completed cell after the user interrupt.
|
||||
if chat
|
||||
.canceled_exec_call_ids
|
||||
.remove(&super::ExecCallId(ev.call_id.clone()))
|
||||
{
|
||||
chat.maybe_hide_spinner();
|
||||
return;
|
||||
}
|
||||
let ExecCommandEndEvent { call_id, exit_code, duration: _, stdout, stderr } = ev;
|
||||
let cmd = chat.exec.running_commands.remove(&super::ExecCallId(call_id.clone()));
|
||||
chat.height_manager.borrow_mut().record_event(HeightEvent::RunEnd);
|
||||
|
||||
@@ -2901,7 +2901,13 @@ impl CollapsibleReasoningCell {
|
||||
if is_title {
|
||||
title_idxs.push(i);
|
||||
let mut text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
if text.len() > 60 { text.truncate(60); text.push_str("…"); }
|
||||
// Truncate preview by display width to avoid slicing inside a UTF-8 codepoint.
|
||||
// Reserve 1 col for the ellipsis when truncation occurs.
|
||||
let maxw = 60usize;
|
||||
if text.width() > maxw {
|
||||
let (prefix, _suffix, _w) = crate::live_wrap::take_prefix_by_width(&text, maxw.saturating_sub(1));
|
||||
text = format!("{}…", prefix);
|
||||
}
|
||||
title_previews.push(text);
|
||||
}
|
||||
}
|
||||
@@ -3426,15 +3432,19 @@ impl HistoryCell for StreamingContentCell {
|
||||
let _ = (cur_y, remaining_skip);
|
||||
}
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
// Hide the header line (e.g., "codex") when using a gutter symbol
|
||||
if self.gutter_symbol().is_some() {
|
||||
if self.lines.len() == 1 {
|
||||
// Single-line cell with gutter symbol = just a header, hide it completely
|
||||
Vec::new()
|
||||
} else {
|
||||
// Multi-line cell with gutter symbol = skip the title line
|
||||
self.lines[1..].to_vec()
|
||||
}
|
||||
// Hide a leading title header line (e.g., "codex") if present.
|
||||
// This mirrors AssistantMarkdownCell behavior so streaming and final
|
||||
// cells render identically with the header suppressed.
|
||||
let has_leading_header = self
|
||||
.lines
|
||||
.first()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_leading_header {
|
||||
if self.lines.len() == 1 { Vec::new() } else { self.lines[1..].to_vec() }
|
||||
} else {
|
||||
self.lines.clone()
|
||||
}
|
||||
@@ -3460,11 +3470,22 @@ impl StreamingContentCell {
|
||||
return cache.clone();
|
||||
}
|
||||
}
|
||||
// Reuse the same segmentation logic as Assistant, operating on current lines
|
||||
// Reuse the same segmentation logic as Assistant, operating on current
|
||||
// lines but explicitly dropping a leading "codex" header if present so
|
||||
// it never renders during streaming.
|
||||
let mut body_lines = self.lines.clone();
|
||||
if let Some(first) = body_lines.first() {
|
||||
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
|
||||
if flat.trim().eq_ignore_ascii_case("codex") {
|
||||
let _ = body_lines.get(0).is_some();
|
||||
body_lines.remove(0);
|
||||
}
|
||||
}
|
||||
let tmp = AssistantMarkdownCell {
|
||||
raw: String::new(),
|
||||
id: None,
|
||||
lines: std::iter::once(Line::from("codex")).chain(self.lines.clone()).collect(),
|
||||
// We do not prepend a header; segmentation should be based on body only.
|
||||
lines: body_lines,
|
||||
cached_layout: std::cell::RefCell::new(None),
|
||||
};
|
||||
let cache = tmp.ensure_layout(width);
|
||||
|
||||
@@ -738,24 +738,17 @@ pub(crate) fn set_last_sequence_number(&mut self, kind: StreamKind, seq: Option<
|
||||
let has_delta = state.has_seen_delta;
|
||||
|
||||
if has_delta {
|
||||
// Many providers send a final reasoning/message that may include
|
||||
// text not present in prior deltas. For Reasoning, prefer to
|
||||
// merge the final message into the collector to avoid losing
|
||||
// content, preserving the number of already committed lines so
|
||||
// we don't duplicate what the user already saw.
|
||||
if matches!(kind, StreamKind::Reasoning) && !message.is_empty() {
|
||||
// Key-based deduplication for Reasoning:
|
||||
// If we have already streamed deltas for this reasoning section
|
||||
// (same stream id within this request), do NOT inject the final
|
||||
// full text again. Finalize the stream using the accumulated
|
||||
// deltas only. This avoids duplicate headings/paragraphs without
|
||||
// relying on string comparisons.
|
||||
if matches!(kind, StreamKind::Reasoning) {
|
||||
tracing::debug!(
|
||||
"Merging final {:?} content into collector before finalize (len={})",
|
||||
kind,
|
||||
message.len()
|
||||
"Dedup: ignoring final {:?} after deltas; finalizing existing content",
|
||||
kind
|
||||
);
|
||||
let committed = state.collector.committed_count();
|
||||
let mut msg = message.to_owned();
|
||||
if !msg.ends_with('\n') { msg.push('\n'); }
|
||||
let state_mut = self.state_mut(kind);
|
||||
state_mut
|
||||
.collector
|
||||
.replace_with_and_mark_committed(&msg, committed);
|
||||
return self.finalize(kind, immediate, sink);
|
||||
}
|
||||
// For Answer (or empty message), finalize existing streamed content.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Interrupt → Resume Postmortem
|
||||
|
||||
This document summarizes the root cause, symptoms, and fixes for the
|
||||
"Esc then send never resumes" issue in the Code (Codex CLI, Rust) TUI/core
|
||||
stack.
|
||||
|
||||
## Summary
|
||||
|
||||
Pressing Esc (or Ctrl+C) to interrupt a running turn, then immediately entering
|
||||
a new message, frequently resulted in no new model turn starting. The UI showed
|
||||
the user message, but there was no `TaskStarted`, and sometimes the system
|
||||
appeared to hang until forcibly terminated.
|
||||
|
||||
Two independent problems contributed to this behavior:
|
||||
|
||||
1) A deadlock in `Session::abort()` in the core.
|
||||
2) The TUI queuing policy after interrupts, which could strand messages.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- After Esc, the next user message shows in history but no assistant output or
|
||||
`TaskStarted` appears.
|
||||
- A second Ctrl+C exits the TUI, but the background task may linger.
|
||||
- In earlier runs, messages sent while a task was running were queued waiting
|
||||
for `TaskComplete` (which never arrives after an `Interrupt`).
|
||||
|
||||
## Root Cause
|
||||
|
||||
### 1) Deadlock in `Session::abort()`
|
||||
|
||||
`Session::abort()` held the session mutex (`self.state.lock()`) and then called
|
||||
`agent.abort()`. The `AgentAgent::abort()` emitted a protocol event using
|
||||
`Session::make_event()`, which also attempted to lock `self.state`. This
|
||||
re-entrant lock acquisition resulted in a deadlock:
|
||||
|
||||
- `Session::abort()` acquires the lock.
|
||||
- Calls `agent.abort()` while still holding the lock.
|
||||
- `agent.abort()` → `make_event()` tries to acquire the same lock → blocks
|
||||
forever.
|
||||
|
||||
Result: the submission loop remained stuck in the interrupt path and never got
|
||||
to process the next `UserInput` turn.
|
||||
|
||||
### 2) Stranded messages after interrupts
|
||||
|
||||
Historically the TUI queued new user messages while a task was running, and
|
||||
dispatched them on `TaskComplete`. After an `Interrupt`, there is no
|
||||
`TaskComplete`, so the queued message could be left stranded indefinitely.
|
||||
|
||||
## Fixes
|
||||
|
||||
1) Remove the deadlock in `Session::abort()`:
|
||||
- Take (`take()`) the `current_agent` while holding the mutex, then drop the
|
||||
lock before calling `agent.abort()`. This ensures `make_event()` can safely
|
||||
acquire the lock again and avoids the self-deadlock.
|
||||
|
||||
2) Stronger atomicity around new input after interrupts:
|
||||
- For `Op::UserInput`, abort synchronously first (to ensure the prior agent
|
||||
is gone), then spawn and set the new agent. This prevents races where an
|
||||
async abort could kill the newly spawned agent.
|
||||
|
||||
3) TUI sending policy:
|
||||
- Always send user messages immediately, even if a task appears to be
|
||||
running. The core’s `UserInput` path aborts the prior agent and starts a
|
||||
fresh turn, so messages are never stranded waiting for a `TaskComplete`.
|
||||
|
||||
4) UX guards (non-functional):
|
||||
- Ignore late deltas after an interrupt so partial output does not trickle in
|
||||
post-cancel.
|
||||
- Drop late `ExecEnd` events for commands that were already marked as
|
||||
cancelled to avoid duplicate cells.
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
- Never call back into code that may re-acquire a mutex you currently hold.
|
||||
Move side-effects (like emitting events) outside the lock’s critical section.
|
||||
- Prefer a simple, predictable policy after interrupts: abort first, then spawn
|
||||
the next turn. Avoid queuing by frontends unless they can prove delivery.
|
||||
- When diagnosing event-ordered systems, add short-lived diagnostics at the
|
||||
critical edges (submit, loop recv, abort begin/end, agent spawn, run begin)
|
||||
and then remove them once the root cause is fixed.
|
||||
|
||||
## Files/Areas Changed
|
||||
|
||||
- `core/src/codex.rs`
|
||||
- `Session::abort()` releases the lock before calling `agent.abort()`.
|
||||
- `Op::UserInput` aborts synchronously before spawning the next agent.
|
||||
|
||||
- `tui/src/chatwidget.rs`
|
||||
- Always send user input immediately (no queuing after interrupts).
|
||||
- Ignore late deltas after cancel; drop late ExecEnd for cancelled calls.
|
||||
|
||||
These changes resolved the “Esc then send never resumes” issue and restored a
|
||||
reliable cancel→resume experience.
|
||||
|
||||
Reference in New Issue
Block a user