mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
chore: commit all code
This commit is contained in:
@@ -1,86 +1,23 @@
|
||||
# Rust/codex-rs
|
||||
## Strict Ordering In The TUI History
|
||||
|
||||
In the codex-rs folder where the rust code lives:
|
||||
The TUI enforces strict, per‑turn ordering for all streamed content. Every
|
||||
stream insert (Answer or Reasoning) must be associated with a stable
|
||||
`(request_ordinal, output_index, sequence_number)` key provided by the model.
|
||||
|
||||
- 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.
|
||||
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`.
|
||||
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
|
||||
- A stream insert MUST carry a non‑empty stream id. The UI seeds an order key
|
||||
for `(kind, id)` from the event's `OrderMeta` before any insert.
|
||||
- The TUI WILL NOT insert streaming content without a stream id. Any attempt to
|
||||
insert without an id is dropped with an error log to make the issue visible
|
||||
during development.
|
||||
|
||||
Completion/build step
|
||||
Developer guidance:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
When making individual changes prefer running tests on individual files or projects first if asked, but otherwise rely on `./build-fast.sh` at the end.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- 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).
|
||||
|
||||
## Ephemeral Context Injection
|
||||
|
||||
Codex automatically injects fresh context at the end of each model request:
|
||||
|
||||
- **Location**: Ephemeral items are appended as the last items in the request via `get_formatted_input()`
|
||||
- **Content**: System status (cwd, git branch, reasoning level), browser info (URL, type), screenshots
|
||||
- **Generation**: `build_turn_ephemeral_items()` creates fresh context for each retry attempt
|
||||
- **Storage**: Stored in `Prompt.ephemeral_items` field, ensuring they're regenerated per-request
|
||||
|
||||
This design ensures the model always receives current environment context without complex refresh logic or state management.
|
||||
|
||||
@@ -69,21 +69,14 @@ agent_run {
|
||||
agent_wait {"batch_id":"<batch_id>","return_all":true,"timeout_seconds": 600 } // Long timeout or you can do separate work and check back later.
|
||||
|
||||
|
||||
# Warnings
|
||||
# WARNING (using git)
|
||||
- Do not create new branches or make changes to git unless requested.
|
||||
- Before pushing, always run `git pull` to sync with remote. Prefer merge over rebase by default; avoid rebases as a first resort.
|
||||
- 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.
|
||||
|
||||
|
||||
# Final output
|
||||
You can include FULL markdown in any responses you make. These will be converted to beautiful output in the terminal.
|
||||
|
||||
**In particular** you can use fenced code blocks which will be shown with full syntax highlighting. Try to include the language of the block e.g.
|
||||
|
||||
```bash
|
||||
# Shell example
|
||||
set -euo pipefail
|
||||
./build-fast.sh
|
||||
```
|
||||
|
||||
Markdown tables, quotes, callouts, task lists, strikethrough and inline code are also all supported.
|
||||
Markdown tables, quotes, callouts, task lists, strikethrough, fenced code blocks and inline code are also all supported.
|
||||
|
||||
+32
-14
@@ -1034,6 +1034,7 @@ impl Session {
|
||||
exec_command_context: ExecCommandContext,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) {
|
||||
let ExecCommandContext {
|
||||
sub_id,
|
||||
@@ -1062,7 +1063,7 @@ impl Session {
|
||||
parsed_cmd: parse_command(&command_for_display),
|
||||
}),
|
||||
};
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: self.current_request_ordinal(), output_index, sequence_number: seq_hint };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||||
let event = self.make_event_with_order(&sub_id, msg, order, seq_hint);
|
||||
let _ = self.tx_event.send(event).await;
|
||||
}
|
||||
@@ -1076,6 +1077,7 @@ impl Session {
|
||||
is_apply_patch: bool,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) {
|
||||
let ExecToolCallOutput {
|
||||
stdout,
|
||||
@@ -1107,7 +1109,7 @@ impl Session {
|
||||
duration: *duration,
|
||||
})
|
||||
};
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: self.current_request_ordinal(), output_index, sequence_number: seq_hint };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||||
let event = self.make_event_with_order(sub_id, msg, order, seq_hint);
|
||||
let _ = self.tx_event.send(event).await;
|
||||
|
||||
@@ -1133,12 +1135,13 @@ impl Session {
|
||||
exec_args: ExecInvokeArgs<'a>,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) -> crate::error::Result<ExecToolCallOutput> {
|
||||
let is_apply_patch = begin_ctx.apply_patch.is_some();
|
||||
let sub_id = begin_ctx.sub_id.clone();
|
||||
let call_id = begin_ctx.call_id.clone();
|
||||
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index)
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req)
|
||||
.await;
|
||||
|
||||
let result = process_exec_tool_call(
|
||||
@@ -1172,6 +1175,7 @@ impl Session {
|
||||
is_apply_patch,
|
||||
seq_hint.map(|h| h.saturating_add(1)),
|
||||
output_index,
|
||||
attempt_req,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2144,9 +2148,11 @@ async fn run_turn(
|
||||
let mut attempt_input: Vec<ResponseItem> = input.clone();
|
||||
loop {
|
||||
// Each loop iteration corresponds to a single provider HTTP request.
|
||||
// Increment the attempt ordinal first so all OrderMeta emitted during
|
||||
// this attempt share the same `req`.
|
||||
// Increment the attempt ordinal first and capture its value so all
|
||||
// OrderMeta emitted during this attempt share the same `req`, even if
|
||||
// later attempts start before all events have been delivered.
|
||||
sess.begin_http_attempt();
|
||||
let attempt_req = sess.current_request_ordinal();
|
||||
// Build status items (screenshots, system status) fresh for each attempt
|
||||
let status_items = build_turn_status_items(sess).await;
|
||||
|
||||
@@ -2168,7 +2174,7 @@ async fn run_turn(
|
||||
// Start a new scratchpad for this HTTP attempt
|
||||
sess.begin_attempt_scratchpad();
|
||||
|
||||
match try_run_turn(sess, turn_diff_tracker, &sub_id, &prompt).await {
|
||||
match try_run_turn(sess, turn_diff_tracker, &sub_id, &prompt, attempt_req).await {
|
||||
Ok(output) => {
|
||||
// Record status items to conversation history after successful turn
|
||||
// This ensures they persist for future requests in the right chronological order
|
||||
@@ -2373,6 +2379,7 @@ async fn try_run_turn(
|
||||
turn_diff_tracker: &mut TurnDiffTracker,
|
||||
sub_id: &str,
|
||||
prompt: &Prompt,
|
||||
attempt_req: u64,
|
||||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||||
// call_ids that are part of this response.
|
||||
let completed_call_ids = prompt
|
||||
@@ -2459,7 +2466,7 @@ async fn try_run_turn(
|
||||
ResponseEvent::Created => {}
|
||||
ResponseEvent::OutputItemDone { item, sequence_number, output_index } => {
|
||||
let response =
|
||||
handle_response_item(sess, turn_diff_tracker, sub_id, item.clone(), sequence_number, output_index).await?;
|
||||
handle_response_item(sess, turn_diff_tracker, sub_id, item.clone(), sequence_number, output_index, attempt_req).await?;
|
||||
|
||||
// Save into scratchpad so we can seed a retry if the stream drops later.
|
||||
sess.scratchpad_push(&item, &response);
|
||||
@@ -2510,7 +2517,7 @@ async fn try_run_turn(
|
||||
// Use the item_id if present, otherwise fall back to sub_id
|
||||
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
|
||||
let order = crate::protocol::OrderMeta {
|
||||
request_ordinal: sess.current_request_ordinal(),
|
||||
request_ordinal: attempt_req,
|
||||
output_index,
|
||||
sequence_number,
|
||||
};
|
||||
@@ -2526,7 +2533,7 @@ async fn try_run_turn(
|
||||
// Use the item_id if present, otherwise fall back to sub_id
|
||||
let mut event_id = item_id.unwrap_or_else(|| sub_id.to_string());
|
||||
if let Some(si) = summary_index { event_id = format!("{}#s{}", event_id, si); }
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number };
|
||||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: delta.clone() }), order, sequence_number);
|
||||
sess.tx_event.send(stamped).await.ok();
|
||||
|
||||
@@ -2542,7 +2549,7 @@ async fn try_run_turn(
|
||||
// Use the item_id if present, otherwise fall back to sub_id
|
||||
let mut event_id = item_id.unwrap_or_else(|| sub_id.to_string());
|
||||
if let Some(ci) = content_index { event_id = format!("{}#c{}", event_id, ci); }
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number };
|
||||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta }), order, sequence_number);
|
||||
sess.tx_event.send(stamped).await.ok();
|
||||
}
|
||||
@@ -2643,6 +2650,7 @@ async fn handle_response_item(
|
||||
item: ResponseItem,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) -> CodexResult<Option<ResponseInputItem>> {
|
||||
debug!(?item, "Output item");
|
||||
let output = match item {
|
||||
@@ -2651,7 +2659,7 @@ async fn handle_response_item(
|
||||
let event_id = id.unwrap_or_else(|| sub_id.to_string());
|
||||
for item in content {
|
||||
if let ContentItem::OutputText { text } = item {
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number: seq_hint };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessage(AgentMessageEvent { message: text }), order, seq_hint);
|
||||
sess.tx_event.send(stamped).await.ok();
|
||||
}
|
||||
@@ -2675,7 +2683,7 @@ async fn handle_response_item(
|
||||
ReasoningItemReasoningSummary::SummaryText { text } => text,
|
||||
};
|
||||
let eid = format!("{}#s{}", event_id, i);
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number: seq_hint };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||||
let stamped = sess.make_event_with_order(&eid, EventMsg::AgentReasoning(AgentReasoningEvent { text }), order, seq_hint);
|
||||
sess.tx_event.send(stamped).await.ok();
|
||||
}
|
||||
@@ -2686,7 +2694,7 @@ async fn handle_response_item(
|
||||
ReasoningItemContent::ReasoningText { text } => text,
|
||||
ReasoningItemContent::Text { text } => text,
|
||||
};
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: sess.current_request_ordinal(), output_index, sequence_number: seq_hint };
|
||||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }), order, seq_hint);
|
||||
sess.tx_event.send(stamped).await.ok();
|
||||
}
|
||||
@@ -2710,6 +2718,7 @@ async fn handle_response_item(
|
||||
call_id,
|
||||
seq_hint,
|
||||
output_index,
|
||||
attempt_req,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -2754,6 +2763,7 @@ async fn handle_response_item(
|
||||
effective_call_id,
|
||||
seq_hint,
|
||||
output_index,
|
||||
attempt_req,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -2826,6 +2836,7 @@ async fn handle_function_call(
|
||||
call_id: String,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) -> ResponseInputItem {
|
||||
let ctx = ToolCallCtx::new(sub_id.clone(), call_id.clone(), seq_hint, output_index);
|
||||
match name.as_str() {
|
||||
@@ -2836,7 +2847,7 @@ async fn handle_function_call(
|
||||
return *output;
|
||||
}
|
||||
};
|
||||
handle_container_exec_with_params(params, sess, turn_diff_tracker, sub_id, call_id, seq_hint, output_index)
|
||||
handle_container_exec_with_params(params, sess, turn_diff_tracker, sub_id, call_id, seq_hint, output_index, attempt_req)
|
||||
.await
|
||||
}
|
||||
"update_plan" => handle_update_plan(sess, &ctx, arguments).await,
|
||||
@@ -4654,6 +4665,7 @@ async fn handle_container_exec_with_params(
|
||||
call_id: String,
|
||||
seq_hint: Option<u64>,
|
||||
output_index: Option<u32>,
|
||||
attempt_req: u64,
|
||||
) -> ResponseInputItem {
|
||||
// Intercept risky git branch-changing commands and require an explicit confirm prefix.
|
||||
// We support a simple convention: prefix the script with `confirm:` to proceed.
|
||||
@@ -4996,6 +5008,7 @@ async fn handle_container_exec_with_params(
|
||||
},
|
||||
seq_hint,
|
||||
output_index,
|
||||
attempt_req,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -5021,6 +5034,7 @@ async fn handle_container_exec_with_params(
|
||||
error,
|
||||
sandbox_type,
|
||||
sess,
|
||||
attempt_req,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -5041,6 +5055,7 @@ async fn handle_sandbox_error(
|
||||
error: SandboxErr,
|
||||
sandbox_type: SandboxType,
|
||||
sess: &Session,
|
||||
attempt_req: u64,
|
||||
) -> ResponseInputItem {
|
||||
let call_id = exec_command_context.call_id.clone();
|
||||
let sub_id = exec_command_context.sub_id.clone();
|
||||
@@ -5112,6 +5127,8 @@ async fn handle_sandbox_error(
|
||||
|
||||
// This is an escalated retry; the policy will not be
|
||||
// examined and the sandbox has been set to `None`.
|
||||
// Use the same attempt_req as the tool call that failed; this retry
|
||||
// is still part of the current provider attempt.
|
||||
let retry_output_result = sess
|
||||
.run_exec_with_events(
|
||||
turn_diff_tracker,
|
||||
@@ -5133,6 +5150,7 @@ async fn handle_sandbox_error(
|
||||
},
|
||||
None,
|
||||
None,
|
||||
attempt_req,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -624,6 +624,18 @@ 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);
|
||||
@@ -1255,3 +1267,4 @@ impl TimingStats {
|
||||
)
|
||||
}
|
||||
}
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
@@ -29,6 +29,9 @@ 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,
|
||||
|
||||
@@ -1226,10 +1226,10 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
let potential_ph_start = p - ph.len();
|
||||
if text[potential_ph_start..p] == *ph {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
// Use `get` to avoid panicking on non-char-boundary indices.
|
||||
match text.get(potential_ph_start..p) {
|
||||
Some(slice) if slice == ph => Some(ph.clone()),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -649,7 +649,8 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
/// Handle streaming delta for both answer and reasoning
|
||||
fn handle_streaming_delta(&mut self, kind: StreamKind, id: String, delta: String) { streaming::handle_streaming_delta(self, kind, id, delta); }
|
||||
// Legacy helper removed: streaming now requires explicit sequence numbers.
|
||||
// Call sites should invoke `streaming::delta_text(self, kind, id, delta, seq)` directly.
|
||||
|
||||
/// Defer or handle an interrupt based on whether we're streaming
|
||||
fn defer_or_handle<F1, F2>(&mut self, defer_fn: F1, handle_fn: F2)
|
||||
@@ -2194,7 +2195,7 @@ 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);
|
||||
streaming::delta_text(self, StreamKind::Answer, id.clone(), delta, Some(event.event_seq));
|
||||
// Show responding state while assistant streams
|
||||
self.bottom_pane.update_status_text("responding".to_string());
|
||||
}
|
||||
@@ -2246,7 +2247,7 @@ 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);
|
||||
streaming::delta_text(self, StreamKind::Reasoning, id.clone(), delta, Some(event.event_seq));
|
||||
// Show thinking state while reasoning streams
|
||||
self.bottom_pane.update_status_text("thinking".to_string());
|
||||
}
|
||||
@@ -2390,7 +2391,7 @@ impl ChatWidget<'_> {
|
||||
let ok = Self::order_key_from_order_meta(event.order.as_ref());
|
||||
self.seed_stream_order_key(StreamKind::Reasoning, &id, ok);
|
||||
|
||||
self.handle_streaming_delta(StreamKind::Reasoning, id.clone(), delta);
|
||||
streaming::delta_text(self, StreamKind::Reasoning, id.clone(), delta, Some(event.event_seq));
|
||||
}
|
||||
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
if self.stream_state.drop_streaming {
|
||||
@@ -6640,7 +6641,7 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
acc = acc.saturating_add(h);
|
||||
// Spacing rule must mirror the render path: no spacer between
|
||||
// adjacent collapsed reasoning cells.
|
||||
let mut should_add_spacing = idx < all_content.len() - 1 && !item.is_title_only();
|
||||
let mut should_add_spacing = idx < all_content.len() - 1 && h > 0;
|
||||
if should_add_spacing {
|
||||
let this_is_collapsed_reasoning = item
|
||||
.as_any()
|
||||
@@ -7056,7 +7057,7 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
// Add spacing only if something was actually rendered for this item.
|
||||
// Prevent a stray blank when zero-height, and suppress spacing between
|
||||
// consecutive collapsed reasoning titles so they appear as a tight list.
|
||||
let mut should_add_spacing = idx < all_content.len() - 1 && !item.is_title_only();
|
||||
let mut should_add_spacing = idx < all_content.len() - 1 && visible_height > 0;
|
||||
if should_add_spacing {
|
||||
// Special-case: two adjacent collapsed reasoning cells → no spacer.
|
||||
let this_is_collapsed_reasoning = item
|
||||
|
||||
@@ -14,23 +14,7 @@ pub(super) fn is_write_cycle_active(chat: &ChatWidget<'_>) -> bool {
|
||||
chat.stream.is_write_cycle_active()
|
||||
}
|
||||
|
||||
pub(super) fn handle_streaming_delta(
|
||||
chat: &mut ChatWidget<'_>,
|
||||
kind: StreamKind,
|
||||
id: String,
|
||||
delta: String,
|
||||
) {
|
||||
if chat.stream_state.drop_streaming {
|
||||
tracing::debug!(
|
||||
"dropping streaming delta after cancel (kind={:?}, id={})",
|
||||
kind, id
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::debug!("handle_streaming_delta kind={:?}, delta={:?}", kind, delta);
|
||||
delta_text(chat, kind, id, delta);
|
||||
chat.mark_needs_redraw();
|
||||
}
|
||||
// Note: direct streaming is triggered from ChatWidget with explicit sequence numbers
|
||||
|
||||
// New facade: begin a stream for a kind, with optional id
|
||||
pub(super) fn begin(chat: &mut ChatWidget<'_>, kind: StreamKind, id: Option<String>) {
|
||||
@@ -40,10 +24,11 @@ pub(super) fn begin(chat: &mut ChatWidget<'_>, kind: StreamKind, id: Option<Stri
|
||||
}
|
||||
|
||||
// New facade: apply a delta (ensures begin is called for this id/kind)
|
||||
pub(super) fn delta_text(chat: &mut ChatWidget<'_>, kind: StreamKind, id: String, delta: String) {
|
||||
pub(super) fn delta_text(chat: &mut ChatWidget<'_>, kind: StreamKind, id: String, delta: String, seq: Option<u64>) {
|
||||
chat.stream_state.current_kind = Some(kind);
|
||||
let sink = AppEventHistorySink(chat.app_event_tx.clone());
|
||||
chat.stream.begin_with_id(kind, Some(id), &sink);
|
||||
chat.stream.set_last_sequence_number(kind, seq);
|
||||
|
||||
chat.stream.push_and_maybe_commit(&delta, &sink);
|
||||
}
|
||||
|
||||
@@ -77,18 +77,8 @@ fn final_answer_without_newline_is_flushed_immediately() {
|
||||
});
|
||||
|
||||
// Drain history insertions and verify the final line is present.
|
||||
// We no longer emit a visible "codex" header during streaming.
|
||||
let cells = drain_insert_history(&rx);
|
||||
assert!(
|
||||
cells.iter().any(|lines| {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.map(|sp| sp.content.clone())
|
||||
.collect::<String>();
|
||||
s.contains("codex")
|
||||
}),
|
||||
"expected 'codex' header to be emitted",
|
||||
);
|
||||
let found_final = cells.iter().any(|lines| {
|
||||
let s = lines
|
||||
.iter()
|
||||
@@ -1024,7 +1014,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
"answer header should not be emitted before first newline commit"
|
||||
);
|
||||
|
||||
// Newline arrives, then header is emitted
|
||||
// Newline arrives; no visible header should be emitted for Answer
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-a".into(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
@@ -1048,8 +1038,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_codex_post,
|
||||
"expected 'codex' header to be emitted after first newline commit"
|
||||
!saw_codex_post,
|
||||
"did not expect a visible 'codex' header to be emitted after first newline commit"
|
||||
);
|
||||
|
||||
// Reasoning: header immediately
|
||||
@@ -1118,26 +1108,16 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&rx);
|
||||
let mut header_count = 0usize;
|
||||
let mut combined = String::new();
|
||||
for lines in &cells {
|
||||
for l in lines {
|
||||
for sp in &l.spans {
|
||||
let s = &sp.content;
|
||||
if s == "codex" {
|
||||
header_count += 1;
|
||||
}
|
||||
combined.push_str(s);
|
||||
}
|
||||
combined.push('\n');
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
header_count,
|
||||
2,
|
||||
"expected two 'codex' headers for two AgentMessage events in one turn; cells={:?}",
|
||||
cells.len()
|
||||
);
|
||||
assert!(
|
||||
combined.contains("First message"),
|
||||
"missing first message: {combined}"
|
||||
|
||||
@@ -236,14 +236,6 @@ pub(crate) trait HistoryCell {
|
||||
false // Default: most cells should not be removed
|
||||
}
|
||||
|
||||
/// Check if this cell is ONLY a title line (no content after it)
|
||||
/// Used to avoid spacing between standalone titles and their content
|
||||
fn is_title_only(&self) -> bool {
|
||||
let lines = self.display_lines_trimmed();
|
||||
// Cell is title-only if it has exactly 1 line and that line is a title
|
||||
lines.len() == 1 && lines.first().map_or(false, is_title_line)
|
||||
}
|
||||
|
||||
/// Returns the gutter symbol for this cell type
|
||||
/// Returns None if no symbol should be displayed
|
||||
fn gutter_symbol(&self) -> Option<&'static str> {
|
||||
@@ -331,10 +323,6 @@ impl HistoryCell for Box<dyn HistoryCell> {
|
||||
self.as_ref().should_remove()
|
||||
}
|
||||
|
||||
fn is_title_only(&self) -> bool {
|
||||
self.as_ref().is_title_only()
|
||||
}
|
||||
|
||||
fn gutter_symbol(&self) -> Option<&'static str> {
|
||||
self.as_ref().gutter_symbol()
|
||||
}
|
||||
@@ -2263,7 +2251,7 @@ fn exec_render_parts_parsed(
|
||||
));
|
||||
}
|
||||
}
|
||||
"List" => {
|
||||
"List Files" => {
|
||||
spans.push(Span::styled(
|
||||
line_text.to_string(),
|
||||
Style::default().fg(crate::colors::text()),
|
||||
@@ -2292,6 +2280,21 @@ fn exec_render_parts_parsed(
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a List Files cell and nothing emitted (e.g., suppressed due to matching Search path),
|
||||
// still show a single contextual line so users can see where we listed.
|
||||
if matches!(action, ExecAction::List) && !any_content_emitted {
|
||||
let display_p = match &ctx_path {
|
||||
Some(p) if !p.is_empty() => {
|
||||
if p.ends_with('/') { p.to_string() } else { format!("{p}/") }
|
||||
}
|
||||
_ => "./".to_string(),
|
||||
};
|
||||
pre.push(Line::from(vec![
|
||||
Span::styled("└ ", Style::default().add_modifier(Modifier::DIM)),
|
||||
Span::styled(format!("in {display_p}"), Style::default().fg(crate::colors::text())),
|
||||
]));
|
||||
}
|
||||
|
||||
// Collapse adjacent Read ranges for the same file inside a single exec's preamble
|
||||
coalesce_read_ranges_in_lines_local(&mut pre);
|
||||
|
||||
@@ -2301,78 +2304,116 @@ fn exec_render_parts_parsed(
|
||||
|
||||
// Local helper: coalesce "<file> (lines A to B)" entries when contiguous.
|
||||
fn coalesce_read_ranges_in_lines_local(lines: &mut Vec<Line<'static>>) {
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
if lines.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
fn parse_read_line(line: &Line<'_>) -> Option<(String, u32, u32, String)> {
|
||||
if line.spans.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Nothing to do for empty/single line vectors
|
||||
if lines.len() <= 1 { return; }
|
||||
|
||||
// Parse a content line of the form
|
||||
// "└ <file> (lines A to B)" or " <file> (lines A to B)"
|
||||
// into (filename, start, end, prefix, original_index).
|
||||
fn parse_read_line_with_index(idx: usize, line: &Line<'_>) -> Option<(String, u32, u32, String, usize)> {
|
||||
if line.spans.is_empty() { return None; }
|
||||
let prefix = line.spans[0].content.to_string();
|
||||
if !(prefix == "└ " || prefix == " ") {
|
||||
return None;
|
||||
}
|
||||
let rest: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect();
|
||||
if let Some(idx) = rest.rfind(" (lines ") {
|
||||
let fname = rest[..idx].to_string();
|
||||
let tail = &rest[idx + 1..];
|
||||
if !(prefix == "└ " || prefix == " ") { return None; }
|
||||
let rest: String = line.spans.iter().skip(1).map(|s| s.content.as_ref()).collect();
|
||||
if let Some(i) = rest.rfind(" (lines ") {
|
||||
let fname = rest[..i].to_string();
|
||||
let tail = &rest[i + 1..];
|
||||
if tail.starts_with("(lines ") && tail.ends_with(")") {
|
||||
let inner = &tail[7..tail.len() - 1];
|
||||
if let Some((s1, s2)) = inner.split_once(" to ") {
|
||||
if let (Ok(a), Ok(b)) = (s1.trim().parse::<u32>(), s2.trim().parse::<u32>()) {
|
||||
return Some((fname, a, b, prefix));
|
||||
return Some((fname, a, b, prefix, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
// Merge overlapping or touching ranges for the same file, regardless of adjacency.
|
||||
let mut i: usize = 0;
|
||||
while i < lines.len() {
|
||||
let Some((fname_a, mut a1, mut a2, prefix_a)) = parse_read_line(&lines[i]) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let mut k = i + 1;
|
||||
while k < lines.len() {
|
||||
if let Some((fname_b, b1, b2, _prefix_b)) = parse_read_line(&lines[k]) {
|
||||
if fname_b == fname_a {
|
||||
// Merge if overlapping or contiguous
|
||||
let touch_or_overlap = b1 <= a2.saturating_add(1) && b2.saturating_add(1) >= a1;
|
||||
if touch_or_overlap {
|
||||
a1 = a1.min(b1);
|
||||
a2 = a2.max(b2);
|
||||
let new_spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(
|
||||
prefix_a.clone(),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled(
|
||||
fname_a.clone(),
|
||||
Style::default().fg(crate::colors::text()),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" (lines {} to {})", a1, a2),
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
),
|
||||
];
|
||||
lines[i] = Line::from(new_spans);
|
||||
lines.remove(k);
|
||||
continue; // keep checking more occurrences of the same file
|
||||
}
|
||||
}
|
||||
|
||||
// Collect read ranges grouped by filename, preserving first-seen order.
|
||||
// Also track the earliest prefix to reuse when emitting a single line per file.
|
||||
#[derive(Default)]
|
||||
struct FileRanges { prefix: String, first_index: usize, ranges: Vec<(u32, u32)> }
|
||||
|
||||
let mut files: Vec<(String, FileRanges)> = Vec::new();
|
||||
let mut non_read_lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
if let Some((fname, a, b, prefix, orig_idx)) = parse_read_line_with_index(idx, line) {
|
||||
// Insert or update entry for this file, preserving encounter order
|
||||
if let Some((_name, fr)) = files.iter_mut().find(|(n, _)| n == &fname) {
|
||||
fr.ranges.push((a.min(b), a.max(b)));
|
||||
// Keep earliest index as stable ordering anchor
|
||||
if orig_idx < fr.first_index { fr.first_index = orig_idx; }
|
||||
} else {
|
||||
files.push((fname, FileRanges { prefix, first_index: orig_idx, ranges: vec![(a.min(b), a.max(b))] }));
|
||||
}
|
||||
k += 1;
|
||||
} else {
|
||||
non_read_lines.push(line.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if files.is_empty() { return; }
|
||||
|
||||
// For each file: merge overlapping/touching ranges; then sort ascending and emit one line.
|
||||
fn merge_and_sort(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
|
||||
if v.len() <= 1 { return v; }
|
||||
v.sort_by_key(|(s, _)| *s);
|
||||
let mut out: Vec<(u32, u32)> = Vec::with_capacity(v.len());
|
||||
let mut cur = v[0];
|
||||
for &(s, e) in v.iter().skip(1) {
|
||||
if s <= cur.1.saturating_add(1) { // touching or overlap
|
||||
cur.1 = cur.1.max(e);
|
||||
} else {
|
||||
out.push(cur);
|
||||
cur = (s, e);
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
// Rebuild the lines vector: keep header (if present) and any non-read lines,
|
||||
// then append one consolidated line per file in first-seen order by index.
|
||||
let mut rebuilt: Vec<Line<'static>> = Vec::with_capacity(lines.len());
|
||||
|
||||
// Heuristic: preserve an initial header line that does not start with a connector.
|
||||
if !lines.is_empty() {
|
||||
if lines[0].spans.first().map(|s| s.content.as_ref() != "└ " && s.content.as_ref() != " ").unwrap_or(false) {
|
||||
rebuilt.push(lines[0].clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Sort files by their first appearance index to keep stable ordering with other files.
|
||||
files.sort_by_key(|(_n, fr)| fr.first_index);
|
||||
|
||||
for (name, mut fr) in files.into_iter() {
|
||||
fr.ranges = merge_and_sort(fr.ranges);
|
||||
// Build range annotation: " (lines S1 to E1, S2 to E2, ...)"
|
||||
let mut ann = String::new();
|
||||
ann.push_str(" (");
|
||||
ann.push_str("lines ");
|
||||
for (i, (s, e)) in fr.ranges.iter().enumerate() {
|
||||
if i > 0 { ann.push_str(", "); }
|
||||
ann.push_str(&format!("{} to {}", s, e));
|
||||
}
|
||||
ann.push(')');
|
||||
|
||||
let spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(fr.prefix, Style::default().add_modifier(Modifier::DIM)),
|
||||
Span::styled(name, Style::default().fg(crate::colors::text())),
|
||||
Span::styled(ann, Style::default().fg(crate::colors::text_dim())),
|
||||
];
|
||||
rebuilt.push(Line::from(spans));
|
||||
}
|
||||
|
||||
// Append any other non-read lines (rare for Read sections, but safe)
|
||||
// Note: keep their original order after consolidated entries
|
||||
rebuilt.extend(non_read_lines.into_iter());
|
||||
|
||||
*lines = rebuilt;
|
||||
}
|
||||
|
||||
impl WidgetRef for &ExecCell {
|
||||
@@ -2774,6 +2815,74 @@ impl CollapsibleReasoningCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append lines with conservative de-duplication:
|
||||
/// - Drops exact adjacent duplicates (same plain text as last line).
|
||||
/// - Drops any leading prefix of `new_lines` that exactly matches the
|
||||
/// existing tail (overlap), ignoring ephemeral debug marker lines like
|
||||
/// "[sN ...]" on both sides so markers don't break matching.
|
||||
pub fn append_lines_dedup(&mut self, mut new_lines: Vec<Line<'static>>) {
|
||||
if new_lines.is_empty() { return; }
|
||||
|
||||
let to_plain = |l: &Line<'_>| -> String {
|
||||
l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()
|
||||
};
|
||||
let is_marker = |l: &Line<'_>| -> bool {
|
||||
let t = to_plain(l).trim().to_string();
|
||||
t.starts_with('[') && t.ends_with(']')
|
||||
};
|
||||
|
||||
// Compute overlap (suffix/prefix match) while skipping marker lines.
|
||||
let mut existing_plain: Vec<String> = self
|
||||
.lines
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| !is_marker(l))
|
||||
.take(64)
|
||||
.map(|l| to_plain(l))
|
||||
.collect();
|
||||
existing_plain.reverse();
|
||||
|
||||
let incoming_plain: Vec<String> = new_lines
|
||||
.iter()
|
||||
.filter(|l| !is_marker(l))
|
||||
.map(|l| to_plain(l))
|
||||
.collect();
|
||||
|
||||
let max_overlap = existing_plain.len().min(incoming_plain.len());
|
||||
let mut overlap = 0usize;
|
||||
for k in (1..=max_overlap).rev() {
|
||||
if existing_plain.len() >= k && incoming_plain.len() >= k {
|
||||
if existing_plain[existing_plain.len()-k..] == incoming_plain[..k] {
|
||||
overlap = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlap > 0 {
|
||||
// Drop the first `overlap` non-marker lines from `new_lines`, preserving markers.
|
||||
let mut to_drop = overlap;
|
||||
let mut trimmed: Vec<Line<'static>> = Vec::with_capacity(new_lines.len());
|
||||
for l in new_lines.into_iter() {
|
||||
if to_drop > 0 && !is_marker(&l) {
|
||||
to_drop -= 1;
|
||||
continue;
|
||||
}
|
||||
trimmed.push(l);
|
||||
}
|
||||
new_lines = trimmed;
|
||||
}
|
||||
|
||||
// Append, dropping immediate exact duplicates (plain text) to reduce noise.
|
||||
for nl in new_lines.drain(..) {
|
||||
let dup = self
|
||||
.lines
|
||||
.last()
|
||||
.map(|last| to_plain(last) == to_plain(&nl))
|
||||
.unwrap_or(false);
|
||||
if !dup { self.lines.push(nl); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a compact debug summary of detected section titles and line metrics
|
||||
/// for on-screen overlays. Only used when the TUI `--order` overlay is active.
|
||||
pub(crate) fn debug_title_overlay(&self) -> String {
|
||||
@@ -2952,13 +3061,6 @@ impl HistoryCell for CollapsibleReasoningCell {
|
||||
HistoryCellType::Reasoning
|
||||
}
|
||||
|
||||
// Ensure collapsed reasoning always gets standard spacing after it.
|
||||
// Treating it as a title-only cell suppresses the inter-cell spacer,
|
||||
// which causes the missing blank line effect users observed.
|
||||
fn is_title_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
// Touch id for sanity/read to avoid dead-code warning; used for routing in ChatWidget
|
||||
let _ = &self.id;
|
||||
@@ -4822,6 +4924,23 @@ fn new_parsed_command(
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a List Files cell and the loop above produced no content (e.g.,
|
||||
// the list path was suppressed because a Search referenced the same path),
|
||||
// emit a single contextual line so the location is always visible.
|
||||
if matches!(action, ExecAction::List) && !any_content_emitted {
|
||||
let display_p = match &ctx_path {
|
||||
Some(p) if !p.is_empty() => {
|
||||
if p.ends_with('/') { p.to_string() } else { format!("{p}/") }
|
||||
}
|
||||
_ => "./".to_string(),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("└ ", Style::default().add_modifier(Modifier::DIM)),
|
||||
Span::styled(format!("in {display_p}"), Style::default().fg(crate::colors::text())),
|
||||
]));
|
||||
// no-op: avoid unused assignment warning; the variable's value is not consumed later
|
||||
}
|
||||
|
||||
// Show stdout for real run commands; keep read/search/list concise unless error
|
||||
let show_stdout = matches!(action, ExecAction::Run);
|
||||
let use_angle_pipe = show_stdout; // add "> " prefix for run output
|
||||
|
||||
@@ -311,6 +311,11 @@ impl MarkdownStreamCollector {
|
||||
source
|
||||
}
|
||||
|
||||
/// Returns true if the internal buffer currently ends with a newline.
|
||||
pub fn ends_with_newline(&self) -> bool {
|
||||
self.buffer.ends_with('\n')
|
||||
}
|
||||
|
||||
/// Render a preview of the current buffer into lines without mutating
|
||||
/// internal counters. Unlike `finalize_and_drain`, this does not append a
|
||||
/// synthetic trailing newline, so the preview reflects what a soft-commit
|
||||
|
||||
@@ -97,9 +97,14 @@ impl StreamController {
|
||||
fn state(&self, kind: StreamKind) -> &StreamState {
|
||||
&self.states[Self::idx(kind)]
|
||||
}
|
||||
fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState {
|
||||
&mut self.states[Self::idx(kind)]
|
||||
}
|
||||
fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState {
|
||||
&mut self.states[Self::idx(kind)]
|
||||
}
|
||||
|
||||
/// Record the latest provider sequence_number for this stream kind.
|
||||
pub(crate) fn set_last_sequence_number(&mut self, kind: StreamKind, seq: Option<u64>) {
|
||||
self.state_mut(kind).last_sequence_number = seq;
|
||||
}
|
||||
|
||||
fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool {
|
||||
let emitted = self.header.maybe_emit(kind, out_lines);
|
||||
@@ -125,7 +130,8 @@ impl StreamController {
|
||||
// Parse trailing #s<idx>
|
||||
let idx = id.split('#').last().and_then(|frag| frag.strip_prefix('s'));
|
||||
if let Some(sidx) = idx {
|
||||
let marker = format!("[s{}]", sidx);
|
||||
let seq_part = self.state(kind).last_sequence_number.map(|n| format!(" seq{}", n)).unwrap_or_default();
|
||||
let marker = format!("[s{}{}]", sidx, seq_part);
|
||||
let dim = crate::colors::text_dim();
|
||||
lines.push(Line::from(ratatui::text::Span::styled(marker, ratatui::style::Style::default().fg(dim))));
|
||||
}
|
||||
@@ -233,7 +239,9 @@ impl StreamController {
|
||||
{
|
||||
let mut header_lines = Vec::new();
|
||||
if self.emit_header_if_needed(kind, &mut header_lines) {
|
||||
sink.insert_history(header_lines);
|
||||
// Always associate header lines with the active stream id so
|
||||
// the TUI can enforce strict per-stream ordering.
|
||||
sink.insert_history_with_kind(self.current_stream_id.clone(), kind, header_lines);
|
||||
self.thinking_placeholder_shown = true;
|
||||
// For answers, optionally insert an empty streaming cell with a hidden header so
|
||||
// the UI can show a body placeholder (ellipsis) before the first text arrives.
|
||||
@@ -382,7 +390,10 @@ impl StreamController {
|
||||
}
|
||||
(committed, saw_heading)
|
||||
};
|
||||
if saw_heading {
|
||||
// Only early-commit a heading when we are at a line boundary to
|
||||
// avoid truncating partially streamed titles (e.g., "Summar").
|
||||
let at_boundary = { self.state(kind).collector.ends_with_newline() };
|
||||
if saw_heading && at_boundary {
|
||||
let relax_list = self.config.tui.stream.relax_list_holdback;
|
||||
let relax_code = self.config.tui.stream.relax_code_holdback;
|
||||
let mut newly_completed = {
|
||||
@@ -418,8 +429,12 @@ impl StreamController {
|
||||
|
||||
/// Insert a reasoning section break and commit any newly completed lines.
|
||||
pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) {
|
||||
if self.current_stream != Some(StreamKind::Reasoning) {
|
||||
self.begin(StreamKind::Reasoning, sink);
|
||||
// Only insert a section break when we are actively streaming Reasoning
|
||||
// and have a seeded stream id. Without an id, the TUI will drop the
|
||||
// insert per strict ordering rules.
|
||||
if self.current_stream != Some(StreamKind::Reasoning) || self.current_stream_id.is_none() {
|
||||
tracing::debug!("skip section break: no active reasoning stream or missing id");
|
||||
return;
|
||||
}
|
||||
let cfg = self.config.clone();
|
||||
// Scope the mutable borrow of state to collector ops only
|
||||
@@ -759,11 +774,13 @@ impl StreamController {
|
||||
// Fall through to inject the message
|
||||
}
|
||||
|
||||
// If no stream is active for this kind, begin a new section now.
|
||||
// Otherwise, preserve the existing stream context (including id)
|
||||
// so finalization can correctly target the matching cell.
|
||||
// Strict ordering policy: We must already have begun this stream with an id.
|
||||
// Do NOT auto-begin without an id; the caller (ChatWidget) is responsible for
|
||||
// seeding the stream with `begin_with_id(kind, Some(id), ...)` prior to applying
|
||||
// a full final. If this is violated, drop and log.
|
||||
if self.current_stream != Some(kind) {
|
||||
self.begin(kind, sink);
|
||||
tracing::error!("strict ordering: apply_full_final called without active {:?} stream; missing begin_with_id(id)", kind);
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) struct StreamState {
|
||||
pub(crate) has_seen_delta: bool,
|
||||
pub(crate) last_commit_instant: Option<std::time::Instant>,
|
||||
pub(crate) tail_chars_since_commit: usize,
|
||||
pub(crate) last_sequence_number: Option<u64>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
@@ -26,6 +27,7 @@ impl StreamState {
|
||||
has_seen_delta: false,
|
||||
last_commit_instant: None,
|
||||
tail_chars_since_commit: 0,
|
||||
last_sequence_number: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +43,7 @@ impl StreamState {
|
||||
has_seen_delta: false,
|
||||
last_commit_instant: None,
|
||||
tail_chars_since_commit: 0,
|
||||
last_sequence_number: None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn clear(&mut self) {
|
||||
@@ -50,6 +53,7 @@ impl StreamState {
|
||||
self.has_seen_delta = false;
|
||||
self.last_commit_instant = None;
|
||||
self.tail_chars_since_commit = 0;
|
||||
self.last_sequence_number = None;
|
||||
}
|
||||
pub(crate) fn step(&mut self) -> crate::markdown_stream::StepResult {
|
||||
self.streamer.step()
|
||||
@@ -122,7 +126,7 @@ impl HeaderEmitter {
|
||||
pub(crate) fn maybe_emit(
|
||||
&mut self,
|
||||
kind: StreamKind,
|
||||
out_lines: &mut Vec<ratatui::text::Line<'static>>,
|
||||
_out_lines: &mut Vec<ratatui::text::Line<'static>>,
|
||||
) -> bool {
|
||||
let already_emitted_this_turn = match kind {
|
||||
StreamKind::Reasoning => self.reasoning_emitted_this_turn,
|
||||
@@ -130,10 +134,10 @@ impl HeaderEmitter {
|
||||
};
|
||||
let already_emitted_in_stream = self.has_emitted_for_stream(kind);
|
||||
if !already_emitted_in_stream && !already_emitted_this_turn {
|
||||
// Do not render a visible header for Reasoning; only mark emission.
|
||||
if matches!(kind, StreamKind::Answer) {
|
||||
out_lines.push(render_header_line(kind));
|
||||
}
|
||||
// Do not render a visible header line for either stream kind.
|
||||
// We still mark the header as emitted to preserve per-turn gating
|
||||
// and stream state, but the UI should not show the "codex" prefix
|
||||
// on streaming assistant messages.
|
||||
match kind {
|
||||
StreamKind::Reasoning => {
|
||||
self.reasoning_emitted_in_stream = true;
|
||||
|
||||
Reference in New Issue
Block a user