Make `oa coder` take keys and stream a reply

c8d2f76040b1 · Claude · · parent 468f1fa3257e

Make `oa coder` take keys and stream a reply

The interactive TUI was a mock. Its event loop had no input buffer, no
`Char` arm, no Backspace, no Enter and no call into the runtime: every
keystroke but Esc and Tab was discarded, and Tab appended the words
`[Toggled reasoning effort]` while toggling nothing. The welcome text
invited the reader to type into a pane with nothing to type into, and
the status bar advertised two keys, one of which did nothing and one of
which was not handled at all. The real turn loop, `execute_turn`, was
reachable only from `--headless`.

The composer's editing mechanics are ported from the ratatui textarea in
grok-build (Apache-2.0, commit 07b2f714); `src/composer/edit.rs` and
`keys.rs` record what was kept and what was trimmed, and the upstream
licence sits beside them. On top of that sit our own wrap rows, vertical
motion, and dispatch. The chrome is ours: the badge-in-rule panes the
`/coder` page draws, and the bullets, gutter and `›` composer of
`coder-ui.ts`.

Submitting sends the prompt to a runtime task over a channel. That task
owns the session and does the turn; the chunk callback — which is
`FnMut(&str) + Send + 'static` and so cannot borrow the transcript —
sends each chunk back down a channel, and the loop appends it on
arrival. The reply appears as it is written. The composer is held and
says so while a turn streams, a failed turn lands on the transcript
instead of ending the session, and raw mode and the alternate screen are
left on every path including a panic.

Three claims the screen made are gone. `Tab: effort` had nothing behind
it and could not have. `Shift+Tab: lane` could not be given anything
behind it either: `POST /api/v1/threads` publishes no model parameter and
the grant it returns pins the model, so the bar reports that model rather
than pretending to request one.

Driving the binary turned up the reason no turn had ever worked. The
thread request sent a model name in `lane`, which the server refuses —
`"ox-alpha" is not an admitted lane. Admitted: thread, local.` — and the
refusal was invisible because `create_thread` answered it with a
fabricated grant and `execute_turn` answered the proxy's rejection by
streaming `Completed autonomous reasoning turn (offline fallback).` and
returning success. The lane is now `thread`, and a refusal is now an
error. With that, a real prompt reaches a real model.

Two tests in cli_test.rs asserted the old lie and had to be rewritten:
both ran against production with no credentials and passed only because
a refusal read as success. They now run against a local proxy speaking
real server-sent events.

Verified by frame, not by reasoning: 20 tests in coder_tui_test.rs assert
on rendered `TestBackend` frames, four of them driving the real loop and
the real runtime over a real socket; and the release binary was driven
through a pty, where a typed prompt produced a streamed reply from
gemini-3.7-flash on openagents.com.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 181 · 2026-08-26T06:21:53.048854Z

Changed files

  • modified Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • added crates/openagents-cli/src/composer/LICENSE-APACHE-xai
  • added crates/openagents-cli/src/composer/edit.rs
  • added crates/openagents-cli/src/composer/keys.rs
  • added crates/openagents-cli/src/composer/mod.rs
  • modified crates/openagents-cli/src/interactive.rs
  • modified crates/openagents-cli/src/lib.rs
  • modified crates/openagents-cli/src/runtime.rs
  • modified crates/openagents-cli/src/tui.rs
  • modified crates/openagents-cli/tests/cli_test.rs
  • added crates/openagents-cli/tests/coder_tui_test.rs
  • added crates/openagents-cli/tests/support/mod.rs

Diff

13 files changed, +3371 -92

Cargo.lock modified +2

@@ -1386,6 +1386,8 @@ dependencies = [

1386 1386
 "tokio",
1387 1387
 "tracing",
1388 1388
 "tracing-subscriber",
1389
 "unicode-segmentation",
1390
 "unicode-width 0.2.0",
1389 1391
]
1390 1392
1391 1393
[[package]]
crates/openagents-cli/Cargo.toml modified +2

@@ -26,3 +26,5 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"]

26 26
futures = "0.3"
27 27
async-trait = "0.1"
28 28
eventsource-stream = "0.2.3"
29
unicode-width = "0.2"
30
unicode-segmentation = "1"
crates/openagents-cli/src/composer/LICENSE-APACHE-xai added +211

@@ -0,0 +1,211 @@

1
The files `edit.rs` and `keys.rs` in this directory are ported from
2
`crates/codegen/xai-ratatui-textarea/` in grok-build, read at commit
3
07b2f7144fd5c5c9d3dd1966937a87852d2dbdb8. Each file's module documentation
4
records what was kept and what was trimmed. The upstream licence follows.
5
6
----------------------------------------------------------------------
7
8
Copyright 2023-2026 SpaceXAI
9
10
11
                                 Apache License
12
                           Version 2.0, January 2004
13
                        http://www.apache.org/licenses/
14
15
   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
16
17
   1. Definitions.
18
19
      "License" shall mean the terms and conditions for use, reproduction,
20
      and distribution as defined by Sections 1 through 9 of this document.
21
22
      "Licensor" shall mean the copyright owner or entity authorized by
23
      the copyright owner that is granting the License.
24
25
      "Legal Entity" shall mean the union of the acting entity and all
26
      other entities that control, are controlled by, or are under common
27
      control with that entity. For the purposes of this definition,
28
      "control" means (i) the power, direct or indirect, to cause the
29
      direction or management of such entity, whether by contract or
30
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
31
      outstanding shares, or (iii) beneficial ownership of such entity.
32
33
      "You" (or "Your") shall mean an individual or Legal Entity
34
      exercising permissions granted by this License.
35
36
      "Source" form shall mean the preferred form for making modifications,
37
      including but not limited to software source code, documentation
38
      source, and configuration files.
39
40
      "Object" form shall mean any form resulting from mechanical
41
      transformation or translation of a Source form, including but
42
      not limited to compiled object code, generated documentation,
43
      and conversions to other media types.
44
45
      "Work" shall mean the work of authorship, whether in Source or
46
      Object form, made available under the License, as indicated by a
47
      copyright notice that is included in or attached to the work
48
      (an example is provided in the Appendix below).
49
50
      "Derivative Works" shall mean any work, whether in Source or Object
51
      form, that is based on (or derived from) the Work and for which the
52
      editorial revisions, annotations, elaborations, or other modifications
53
      represent, as a whole, an original work of authorship. For the purposes
54
      of this License, Derivative Works shall not include works that remain
55
      separable from, or merely link (or bind by name) to the interfaces of,
56
      the Work and Derivative Works thereof.
57
58
      "Contribution" shall mean any work of authorship, including
59
      the original version of the Work and any modifications or additions
60
      to that Work or Derivative Works thereof, that is intentionally
61
      submitted to Licensor for inclusion in the Work by the copyright owner
62
      or by an individual or Legal Entity authorized to submit on behalf of
63
      the copyright owner. For the purposes of this definition, "submitted"
64
      means any form of electronic, verbal, or written communication sent
65
      to the Licensor or its representatives, including but not limited to
66
      communication on electronic mailing lists, source code control systems,
67
      and issue tracking systems that are managed by, or on behalf of, the
68
      Licensor for the purpose of discussing and improving the Work, but
69
      excluding communication that is conspicuously marked or otherwise
70
      designated in writing by the copyright owner as "Not a Contribution."
71
72
      "Contributor" shall mean Licensor and any individual or Legal Entity
73
      on behalf of whom a Contribution has been received by Licensor and
74
      subsequently incorporated within the Work.
75
76
   2. Grant of Copyright License. Subject to the terms and conditions of
77
      this License, each Contributor hereby grants to You a perpetual,
78
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
79
      copyright license to reproduce, prepare Derivative Works of,
80
      publicly display, publicly perform, sublicense, and distribute the
81
      Work and such Derivative Works in Source or Object form.
82
83
   3. Grant of Patent License. Subject to the terms and conditions of
84
      this License, each Contributor hereby grants to You a perpetual,
85
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
86
      (except as stated in this section) patent license to make, have made,
87
      use, offer to sell, sell, import, and otherwise transfer the Work,
88
      where such license applies only to those patent claims licensable
89
      by such Contributor that are necessarily infringed by their
90
      Contribution(s) alone or by combination of their Contribution(s)
91
      with the Work to which such Contribution(s) was submitted. If You
92
      institute patent litigation against any entity (including a
93
      cross-claim or counterclaim in a lawsuit) alleging that the Work
94
      or a Contribution incorporated within the Work constitutes direct
95
      or contributory patent infringement, then any patent licenses
96
      granted to You under this License for that Work shall terminate
97
      as of the date such litigation is filed.
98
99
   4. Redistribution. You may reproduce and distribute copies of the
100
      Work or Derivative Works thereof in any medium, with or without
101
      modifications, and in Source or Object form, provided that You
102
      meet the following conditions:
103
104
      (a) You must give any other recipients of the Work or
105
          Derivative Works a copy of this License; and
106
107
      (b) You must cause any modified files to carry prominent notices
108
          stating that You changed the files; and
109
110
      (c) You must retain, in the Source form of any Derivative Works
111
          that You distribute, all copyright, patent, trademark, and
112
          attribution notices from the Source form of the Work,
113
          excluding those notices that do not pertain to any part of
114
          the Derivative Works; and
115
116
      (d) If the Work includes a "NOTICE" text file as part of its
117
          distribution, then any Derivative Works that You distribute must
118
          include a readable copy of the attribution notices contained
119
          within such NOTICE file, excluding those notices that do not
120
          pertain to any part of the Derivative Works, in at least one
121
          of the following places: within a NOTICE text file distributed
122
          as part of the Derivative Works; within the Source form or
123
          documentation, if provided along with the Derivative Works; or,
124
          within a display generated by the Derivative Works, if and
125
          wherever such third-party notices normally appear. The contents
126
          of the NOTICE file are for informational purposes only and
127
          do not modify the License. You may add Your own attribution
128
          notices within Derivative Works that You distribute, alongside
129
          or as an addendum to the NOTICE text from the Work, provided
130
          that such additional attribution notices cannot be construed
131
          as modifying the License.
132
133
      You may add Your own copyright statement to Your modifications and
134
      may provide additional or different license terms and conditions
135
      for use, reproduction, or distribution of Your modifications, or
136
      for any such Derivative Works as a whole, provided Your use,
137
      reproduction, and distribution of the Work otherwise complies with
138
      the conditions stated in this License.
139
140
   5. Submission of Contributions. Unless You explicitly state otherwise,
141
      any Contribution intentionally submitted for inclusion in the Work
142
      by You to the Licensor shall be under the terms and conditions of
143
      this License, without any additional terms or conditions.
144
      Notwithstanding the above, nothing herein shall supersede or modify
145
      the terms of any separate license agreement you may have executed
146
      with Licensor regarding such Contributions.
147
148
   6. Trademarks. This License does not grant permission to use the trade
149
      names, trademarks, service marks, or product names of the Licensor,
150
      except as required for reasonable and customary use in describing the
151
      origin of the Work and reproducing the content of the NOTICE file.
152
153
   7. Disclaimer of Warranty. Unless required by applicable law or
154
      agreed to in writing, Licensor provides the Work (and each
155
      Contributor provides its Contributions) on an "AS IS" BASIS,
156
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
157
      implied, including, without limitation, any warranties or conditions
158
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
159
      PARTICULAR PURPOSE. You are solely responsible for determining the
160
      appropriateness of using or redistributing the Work and assume any
161
      risks associated with Your exercise of permissions under this License.
162
163
   8. Limitation of Liability. In no event and under no legal theory,
164
      whether in tort (including negligence), contract, or otherwise,
165
      unless required by applicable law (such as deliberate and grossly
166
      negligent acts) or agreed to in writing, shall any Contributor be
167
      liable to You for damages, including any direct, indirect, special,
168
      incidental, or consequential damages of any character arising as a
169
      result of this License or out of the use or inability to use the
170
      Work (including but not limited to damages for loss of goodwill,
171
      work stoppage, computer failure or malfunction, or any and all
172
      other commercial damages or losses), even if such Contributor
173
      has been advised of the possibility of such damages.
174
175
   9. Accepting Warranty or Additional Liability. While redistributing
176
      the Work or Derivative Works thereof, You may choose to offer,
177
      and charge a fee for, acceptance of support, warranty, indemnity,
178
      or other liability obligations and/or rights consistent with this
179
      License. However, in accepting such obligations, You may act only
180
      on Your own behalf and on Your sole responsibility, not on behalf
181
      of any other Contributor, and only if You agree to indemnify,
182
      defend, and hold each Contributor harmless for any liability
183
      incurred by, or claims asserted against, such Contributor by reason
184
      of your accepting any such warranty or additional liability.
185
186
   END OF TERMS AND CONDITIONS
187
188
   APPENDIX: How to apply the Apache License to your work.
189
190
      To apply the Apache License to your work, attach the following
191
      boilerplate notice, with the fields enclosed by brackets "[]"
192
      replaced with your own identifying information. (Don't include
193
      the brackets!)  The text should be enclosed in the appropriate
194
      comment syntax for the file format. We also recommend that a
195
      file or class name and description of purpose be included on the
196
      same "printed page" as the copyright notice for easier
197
      identification within third-party archives.
198
199
   Copyright [yyyy] [name of copyright owner]
200
201
   Licensed under the Apache License, Version 2.0 (the "License");
202
   you may not use this file except in compliance with the License.
203
   You may obtain a copy of the License at
204
205
       http://www.apache.org/licenses/LICENSE-2.0
206
207
   Unless required by applicable law or agreed to in writing, software
208
   distributed under the License is distributed on an "AS IS" BASIS,
209
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
210
   See the License for the specific language governing permissions and
211
   limitations under the License.
crates/openagents-cli/src/composer/edit.rs added +650

@@ -0,0 +1,650 @@

1
//! Grapheme-correct text editing over a flat buffer.
2
//!
3
//! # Provenance
4
//!
5
//! Ported from `crates/codegen/xai-ratatui-textarea/src/editor.rs` in
6
//! grok-build, © 2023-2026 SpaceXAI, Apache-2.0. Reference clone read at
7
//! commit `07b2f7144fd5c5c9d3dd1966937a87852d2dbdb8`. The Apache-2.0 text and
8
//! the copyright line are reproduced in `LICENSE-APACHE-xai` beside this file.
9
//!
10
//! Trimmed against the original, and only by removing things this crate has no
11
//! use for:
12
//!
13
//! - The `atomic_byte_ranges` parameter threaded through every function. It
14
//!   exists upstream so a rich element — an image chip — deletes as one unit.
15
//!   Every call site here passed an empty slice, under which
16
//!   `previous_atomic_boundary` is exactly `previous_grapheme_boundary`,
17
//!   `atomic_word_class` is exactly `word_class`, and the rest are identities,
18
//!   so the parameter is gone and the callees are collapsed into their
19
//!   grapheme forms.
20
//! - `single_line_viewport`, which scrolls a one-line field horizontally. The
21
//!   composer soft-wraps instead, and this was the file's only use of
22
//!   `unicode-width`.
23
//! - `EditPlan`'s buffer identity and generation counter, along with
24
//!   `apply_plan`, `validate_plan` and `ApplyEditPlanError`. Those guard a
25
//!   plan computed against one buffer from being applied to another; nothing
26
//!   here holds a plan across a mutation.
27
//! - The `Movement` vocabulary and `resolve_movement`, which need wrap
28
//!   geometry the upstream widget owns. `super::Composer` does its own
29
//!   vertical motion over its own wrap rows.
30
//!
31
//! What is kept is kept as written: the plan-then-apply split, the word-class
32
//! run scanning, the line-edge chaining that Ctrl+A and Ctrl+E rely on, and
33
//! all of the grapheme boundary math. The upstream file's one let-chain lived
34
//! in the atomic-range normalizer that this port drops, so nothing here needs
35
//! rewriting for this workspace's edition 2021.
36
37
use std::ops::{Deref, Range};
38
39
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation as _};
40
41
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42
pub enum WordStyle {
43
    /// Words break at punctuation as well as whitespace.
44
    Small,
45
    /// Words break only at whitespace, the way readline's Ctrl+W does.
46
    WhitespaceDelimited,
47
}
48
49
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50
pub enum EditCommand {
51
    Insert(char),
52
    MoveGraphemeLeft,
53
    MoveGraphemeRight,
54
    MoveWordLeft(WordStyle),
55
    MoveWordRight(WordStyle),
56
    MoveLogicalLineStart,
57
    MoveLogicalLineEnd,
58
    DeleteGraphemeBackward,
59
    DeleteGraphemeForward,
60
    DeleteWordBackward(WordStyle),
61
    DeleteWordForward(WordStyle),
62
    DeleteToLineStart,
63
    DeleteToLineEnd,
64
}
65
66
impl EditCommand {
67
    /// True for the commands that only move the caret.
68
    pub fn is_navigation(self) -> bool {
69
        matches!(
70
            self,
71
            Self::MoveGraphemeLeft
72
                | Self::MoveGraphemeRight
73
                | Self::MoveWordLeft(_)
74
                | Self::MoveWordRight(_)
75
                | Self::MoveLogicalLineStart
76
                | Self::MoveLogicalLineEnd
77
        )
78
    }
79
}
80
81
#[derive(Debug, Clone, PartialEq, Eq)]
82
pub struct EditDelta {
83
    pub replaced_byte_range: Range<usize>,
84
    pub inserted_byte_range: Range<usize>,
85
}
86
87
#[derive(Debug, Clone, PartialEq, Eq)]
88
pub enum EditOutcome {
89
    Unchanged,
90
    CursorOnly,
91
    TextOnly(EditDelta),
92
    TextAndCursor(EditDelta),
93
}
94
95
impl EditOutcome {
96
    fn from_changes(delta: Option<EditDelta>, cursor_changed: bool) -> Self {
97
        match (delta, cursor_changed) {
98
            (None, false) => Self::Unchanged,
99
            (None, true) => Self::CursorOnly,
100
            (Some(delta), false) => Self::TextOnly(delta),
101
            (Some(delta), true) => Self::TextAndCursor(delta),
102
        }
103
    }
104
105
    /// Whether the frame needs redrawing.
106
    pub fn changed(&self) -> bool {
107
        !matches!(self, Self::Unchanged)
108
    }
109
110
    /// Whether the text itself moved, as opposed to only the caret.
111
    pub fn text_changed(&self) -> bool {
112
        matches!(self, Self::TextOnly(_) | Self::TextAndCursor(_))
113
    }
114
}
115
116
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117
pub enum PostEditCursorAffinity {
118
    /// The caret lands exactly where the plan says.
119
    Exact,
120
    /// The caret is pushed right to the next grapheme boundary if the edit
121
    /// left it inside a cluster.
122
    Right,
123
}
124
125
/// What an edit would do, computed without doing it.
126
#[derive(Debug, Clone)]
127
pub struct EditPlan {
128
    replaced_byte_range: Range<usize>,
129
    replacement: String,
130
    removed_text: String,
131
    cursor_byte: usize,
132
    cursor_affinity: PostEditCursorAffinity,
133
}
134
135
impl EditPlan {
136
    pub fn removed_text(&self) -> &str {
137
        &self.removed_text
138
    }
139
}
140
141
/// A string and a caret byte offset, where the offset is always on a grapheme
142
/// boundary.
143
#[derive(Debug, Clone, Default, PartialEq, Eq)]
144
pub struct EditBuffer {
145
    text: String,
146
    cursor_byte: usize,
147
}
148
149
impl Deref for EditBuffer {
150
    type Target = str;
151
152
    fn deref(&self) -> &Self::Target {
153
        self.text()
154
    }
155
}
156
157
impl EditBuffer {
158
    pub fn new() -> Self {
159
        Self::default()
160
    }
161
162
    pub fn from_text(text: impl Into<String>) -> Self {
163
        let text = text.into();
164
        let cursor_byte = text.len();
165
        Self { text, cursor_byte }
166
    }
167
168
    pub fn text(&self) -> &str {
169
        &self.text
170
    }
171
172
    pub fn into_text(self) -> String {
173
        self.text
174
    }
175
176
    pub fn cursor_byte(&self) -> usize {
177
        self.cursor_byte
178
    }
179
180
    pub fn is_empty(&self) -> bool {
181
        self.text.is_empty()
182
    }
183
184
    /// Empty the buffer and hand back what was in it.
185
    pub fn take(&mut self) -> String {
186
        self.cursor_byte = 0;
187
        std::mem::take(&mut self.text)
188
    }
189
190
    /// External cursor requests use nearest grapheme boundaries; ties go left
191
    /// for determinism.
192
    pub fn set_cursor_byte(&mut self, cursor_byte: usize) -> EditOutcome {
193
        let old_cursor = self.cursor_byte;
194
        self.cursor_byte = normalize_external_cursor(&self.text, cursor_byte);
195
        EditOutcome::from_changes(None, self.cursor_byte != old_cursor)
196
    }
197
198
    pub fn insert_str(&mut self, text: &str) -> EditOutcome {
199
        let plan = self.plan_replace_byte_range(self.cursor_byte..self.cursor_byte, text);
200
        self.apply_plan(&plan)
201
    }
202
203
    pub fn plan_replace_byte_range(&self, range: Range<usize>, replacement: &str) -> EditPlan {
204
        let range = normalize_replacement_range(&self.text, range);
205
        let cursor_byte = self.cursor_byte;
206
        let next_cursor = if cursor_byte < range.start {
207
            cursor_byte
208
        } else if cursor_byte <= range.end {
209
            range.start + replacement.len()
210
        } else {
211
            cursor_byte - (range.end - range.start) + replacement.len()
212
        };
213
        self.make_plan(
214
            range,
215
            replacement.to_owned(),
216
            next_cursor,
217
            PostEditCursorAffinity::Right,
218
        )
219
    }
220
221
    pub fn plan_command(&self, command: EditCommand) -> EditPlan {
222
        let cursor_byte = self.cursor_byte;
223
        match command {
224
            EditCommand::Insert(character) => self.make_plan(
225
                cursor_byte..cursor_byte,
226
                character.to_string(),
227
                cursor_byte + character.len_utf8(),
228
                PostEditCursorAffinity::Right,
229
            ),
230
            EditCommand::MoveGraphemeLeft => self.make_plan(
231
                cursor_byte..cursor_byte,
232
                String::new(),
233
                previous_grapheme_boundary(&self.text, cursor_byte),
234
                PostEditCursorAffinity::Exact,
235
            ),
236
            EditCommand::MoveGraphemeRight => self.make_plan(
237
                cursor_byte..cursor_byte,
238
                String::new(),
239
                next_grapheme_boundary(&self.text, cursor_byte),
240
                PostEditCursorAffinity::Exact,
241
            ),
242
            EditCommand::MoveWordLeft(style) => self.make_plan(
243
                cursor_byte..cursor_byte,
244
                String::new(),
245
                self.previous_word_boundary(style, cursor_byte),
246
                PostEditCursorAffinity::Exact,
247
            ),
248
            EditCommand::MoveWordRight(style) => self.make_plan(
249
                cursor_byte..cursor_byte,
250
                String::new(),
251
                self.next_word_boundary(style, cursor_byte),
252
                PostEditCursorAffinity::Exact,
253
            ),
254
            EditCommand::MoveLogicalLineStart => self.make_plan(
255
                cursor_byte..cursor_byte,
256
                String::new(),
257
                self.logical_line_start_target(cursor_byte),
258
                PostEditCursorAffinity::Exact,
259
            ),
260
            EditCommand::MoveLogicalLineEnd => self.make_plan(
261
                cursor_byte..cursor_byte,
262
                String::new(),
263
                self.logical_line_end_target(cursor_byte),
264
                PostEditCursorAffinity::Exact,
265
            ),
266
            EditCommand::DeleteGraphemeBackward => {
267
                let start = previous_grapheme_boundary(&self.text, cursor_byte);
268
                self.make_plan(
269
                    start..cursor_byte,
270
                    String::new(),
271
                    start,
272
                    PostEditCursorAffinity::Right,
273
                )
274
            }
275
            EditCommand::DeleteGraphemeForward => {
276
                let end = next_grapheme_boundary(&self.text, cursor_byte);
277
                self.make_plan(
278
                    cursor_byte..end,
279
                    String::new(),
280
                    cursor_byte,
281
                    PostEditCursorAffinity::Right,
282
                )
283
            }
284
            EditCommand::DeleteWordBackward(style) => {
285
                let start = self.previous_word_boundary(style, cursor_byte);
286
                self.make_plan(
287
                    start..cursor_byte,
288
                    String::new(),
289
                    start,
290
                    PostEditCursorAffinity::Right,
291
                )
292
            }
293
            EditCommand::DeleteWordForward(style) => {
294
                let end = self.next_word_boundary(style, cursor_byte);
295
                self.make_plan(
296
                    cursor_byte..end,
297
                    String::new(),
298
                    cursor_byte,
299
                    PostEditCursorAffinity::Right,
300
                )
301
            }
302
            EditCommand::DeleteToLineStart => {
303
                let line_start = self.line_start_at(cursor_byte);
304
                let start = if cursor_byte == line_start {
305
                    previous_grapheme_boundary(&self.text, line_start)
306
                } else {
307
                    line_start
308
                };
309
                self.make_plan(
310
                    start..cursor_byte,
311
                    String::new(),
312
                    start,
313
                    PostEditCursorAffinity::Right,
314
                )
315
            }
316
            EditCommand::DeleteToLineEnd => {
317
                let line_end = self.line_end_from(cursor_byte);
318
                let start = cursor_byte.min(line_end);
319
                let end = if cursor_byte >= line_end {
320
                    self.line_ending_at(line_end)
321
                        .map_or(line_end, |range| range.end)
322
                } else {
323
                    line_end
324
                };
325
                self.make_plan(
326
                    start..end,
327
                    String::new(),
328
                    start,
329
                    PostEditCursorAffinity::Right,
330
                )
331
            }
332
        }
333
    }
334
335
    pub fn apply(&mut self, command: EditCommand) -> EditOutcome {
336
        let plan = self.plan_command(command);
337
        self.apply_plan(&plan)
338
    }
339
340
    fn make_plan(
341
        &self,
342
        replaced_byte_range: Range<usize>,
343
        replacement: String,
344
        cursor_byte: usize,
345
        cursor_affinity: PostEditCursorAffinity,
346
    ) -> EditPlan {
347
        let removed_text = self.text[replaced_byte_range.clone()].to_owned();
348
        EditPlan {
349
            replaced_byte_range,
350
            replacement,
351
            removed_text,
352
            cursor_byte,
353
            cursor_affinity,
354
        }
355
    }
356
357
    pub fn apply_plan(&mut self, plan: &EditPlan) -> EditOutcome {
358
        let old_cursor = self.cursor_byte;
359
        let text_changed = plan.removed_text != plan.replacement;
360
        let inserted_len = plan.replacement.len();
361
        if text_changed {
362
            self.text
363
                .replace_range(plan.replaced_byte_range.clone(), &plan.replacement);
364
        }
365
        self.cursor_byte = match plan.cursor_affinity {
366
            PostEditCursorAffinity::Exact => plan.cursor_byte,
367
            PostEditCursorAffinity::Right => ceil_grapheme_boundary(&self.text, plan.cursor_byte),
368
        };
369
        let cursor_changed = self.cursor_byte != old_cursor;
370
        let delta = if text_changed {
371
            Some(EditDelta {
372
                inserted_byte_range: plan.replaced_byte_range.start
373
                    ..(plan.replaced_byte_range.start + inserted_len),
374
                replaced_byte_range: plan.replaced_byte_range.clone(),
375
            })
376
        } else {
377
            None
378
        };
379
        EditOutcome::from_changes(delta, cursor_changed)
380
    }
381
382
    fn previous_word_boundary(&self, style: WordStyle, cursor_byte: usize) -> usize {
383
        let mut position = cursor_byte;
384
        while position > 0 {
385
            let previous = previous_grapheme_boundary(&self.text, position);
386
            if word_class(&self.text[previous..position], style) == Some(WordClass::Whitespace) {
387
                position = previous;
388
            } else {
389
                break;
390
            }
391
        }
392
393
        if position == 0 {
394
            return 0;
395
        }
396
397
        let previous = previous_grapheme_boundary(&self.text, position);
398
        let target_class = word_class(&self.text[previous..position], style);
399
        while position > 0 {
400
            let previous = previous_grapheme_boundary(&self.text, position);
401
            if word_class(&self.text[previous..position], style) != target_class {
402
                break;
403
            }
404
            position = previous;
405
        }
406
        position
407
    }
408
409
    fn next_word_boundary(&self, style: WordStyle, cursor_byte: usize) -> usize {
410
        let mut position = cursor_byte;
411
        while position < self.text.len() {
412
            let next = next_grapheme_boundary(&self.text, position);
413
            if word_class(&self.text[position..next], style) == Some(WordClass::Whitespace) {
414
                position = next;
415
            } else {
416
                break;
417
            }
418
        }
419
420
        if position == self.text.len() {
421
            return position;
422
        }
423
424
        let next = next_grapheme_boundary(&self.text, position);
425
        let target_class = word_class(&self.text[position..next], style);
426
        while position < self.text.len() {
427
            let next = next_grapheme_boundary(&self.text, position);
428
            if word_class(&self.text[position..next], style) != target_class {
429
                break;
430
            }
431
            position = next;
432
        }
433
        position
434
    }
435
436
    /// Ctrl+A at the start of a line chains to the start of the line above.
437
    fn logical_line_start_target(&self, cursor_byte: usize) -> usize {
438
        let line_start = self.line_start_at(cursor_byte);
439
        if cursor_byte == line_start && line_start > 0 {
440
            let previous_line_end = previous_grapheme_boundary(&self.text, line_start);
441
            self.line_start_at(previous_line_end)
442
        } else {
443
            line_start
444
        }
445
    }
446
447
    /// Ctrl+E at the end of a line chains to the end of the line below.
448
    fn logical_line_end_target(&self, cursor_byte: usize) -> usize {
449
        let line_end = self.line_end_from(cursor_byte);
450
        if cursor_byte == line_end {
451
            self.line_ending_at(line_end)
452
                .map_or(line_end, |range| self.line_end_from(range.end))
453
        } else {
454
            line_end
455
        }
456
    }
457
458
    pub fn line_start_at(&self, cursor_byte: usize) -> usize {
459
        let cursor_byte = cursor_byte.min(self.text.len());
460
        (0..cursor_byte)
461
            .rev()
462
            .find(|position| self.text.as_bytes()[*position] == b'\n')
463
            .map_or(0, |position| position + 1)
464
    }
465
466
    pub fn line_end_from(&self, cursor_byte: usize) -> usize {
467
        let cursor_byte = cursor_byte.min(self.text.len());
468
        (cursor_byte..self.text.len())
469
            .find(|position| self.text.as_bytes()[*position] == b'\n')
470
            .map_or(self.text.len(), |line_feed| {
471
                if line_feed > 0 && self.text.as_bytes()[line_feed - 1] == b'\r' {
472
                    line_feed - 1
473
                } else {
474
                    line_feed
475
                }
476
            })
477
    }
478
479
    fn line_ending_at(&self, line_end: usize) -> Option<Range<usize>> {
480
        let remaining = self.text.get(line_end..)?;
481
        if remaining.starts_with("\r\n") {
482
            Some(line_end..line_end + 2)
483
        } else if remaining.starts_with('\n') {
484
            Some(line_end..line_end + 1)
485
        } else {
486
            None
487
        }
488
    }
489
}
490
491
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492
enum WordClass {
493
    Whitespace,
494
    Word,
495
    Punctuation,
496
}
497
498
fn word_class(grapheme: &str, style: WordStyle) -> Option<WordClass> {
499
    let character = grapheme.chars().next()?;
500
    if character.is_whitespace() {
501
        Some(WordClass::Whitespace)
502
    } else if style == WordStyle::WhitespaceDelimited
503
        || character.is_alphanumeric()
504
        || character == '_'
505
    {
506
        Some(WordClass::Word)
507
    } else {
508
        Some(WordClass::Punctuation)
509
    }
510
}
511
512
fn normalize_replacement_range(text: &str, range: Range<usize>) -> Range<usize> {
513
    let raw_start = range.start.min(range.end).min(text.len());
514
    let raw_end = range.start.max(range.end).min(text.len());
515
    if raw_start == raw_end {
516
        let cursor = normalize_external_cursor(text, raw_start);
517
        return cursor..cursor;
518
    }
519
    floor_grapheme_boundary(text, raw_start)..ceil_grapheme_boundary(text, raw_end)
520
}
521
522
pub fn floor_grapheme_boundary(text: &str, byte: usize) -> usize {
523
    let byte = byte.min(text.len());
524
    if byte == text.len() {
525
        return byte;
526
    }
527
    text.grapheme_indices(true)
528
        .map(|(index, _)| index)
529
        .take_while(|index| *index <= byte)
530
        .last()
531
        .unwrap_or(0)
532
}
533
534
pub fn ceil_grapheme_boundary(text: &str, byte: usize) -> usize {
535
    let byte = byte.min(text.len());
536
    if byte == text.len() {
537
        return byte;
538
    }
539
    text.grapheme_indices(true)
540
        .map(|(index, _)| index)
541
        .find(|index| *index >= byte)
542
        .unwrap_or(text.len())
543
}
544
545
fn normalize_external_cursor(text: &str, byte: usize) -> usize {
546
    let byte = byte.min(text.len());
547
    let before = floor_grapheme_boundary(text, byte);
548
    let after = ceil_grapheme_boundary(text, byte);
549
    if byte - before <= after - byte {
550
        before
551
    } else {
552
        after
553
    }
554
}
555
556
pub fn previous_grapheme_boundary(text: &str, byte: usize) -> usize {
557
    let byte = byte.min(text.len());
558
    if byte == 0 {
559
        return 0;
560
    }
561
    let mut cursor = GraphemeCursor::new(byte, text.len(), true);
562
    match cursor.prev_boundary(text, 0) {
563
        Ok(Some(boundary)) => boundary,
564
        Ok(None) => 0,
565
        Err(_) => floor_grapheme_boundary(text, byte.saturating_sub(1)),
566
    }
567
}
568
569
pub fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
570
    let byte = byte.min(text.len());
571
    if byte == text.len() {
572
        return byte;
573
    }
574
    let mut cursor = GraphemeCursor::new(byte, text.len(), true);
575
    match cursor.next_boundary(text, 0) {
576
        Ok(Some(boundary)) => boundary,
577
        Ok(None) => text.len(),
578
        Err(_) => ceil_grapheme_boundary(text, byte.saturating_add(1)),
579
    }
580
}
581
582
#[cfg(test)]
583
mod tests {
584
    use super::*;
585
586
    fn buffer(text: &str, cursor: usize) -> EditBuffer {
587
        let mut b = EditBuffer::from_text(text);
588
        let _ = b.set_cursor_byte(cursor);
589
        b
590
    }
591
592
    #[test]
593
    fn insert_lands_at_the_caret() {
594
        let mut b = buffer("ac", 1);
595
        let _ = b.apply(EditCommand::Insert('b'));
596
        assert_eq!(b.text(), "abc");
597
        assert_eq!(b.cursor_byte(), 2);
598
    }
599
600
    #[test]
601
    fn backspace_removes_a_whole_grapheme_cluster() {
602
        // A ZWJ emoji sequence is many bytes and one grapheme. Deleting by
603
        // char or by byte would leave a broken cluster on screen.
604
        let mut b = EditBuffer::from_text("hi 👩‍💻");
605
        let before = b.text().len();
606
        let _ = b.apply(EditCommand::DeleteGraphemeBackward);
607
        assert_eq!(b.text(), "hi ");
608
        assert!(before - b.text().len() > 4);
609
    }
610
611
    #[test]
612
    fn ctrl_w_rubs_out_to_whitespace_not_to_punctuation() {
613
        let mut b = EditBuffer::from_text("git commit -m hello-world");
614
        let _ = b.apply(EditCommand::DeleteWordBackward(
615
            WordStyle::WhitespaceDelimited,
616
        ));
617
        assert_eq!(b.text(), "git commit -m ");
618
    }
619
620
    #[test]
621
    fn alt_backspace_stops_at_punctuation() {
622
        let mut b = EditBuffer::from_text("hello-world");
623
        let _ = b.apply(EditCommand::DeleteWordBackward(WordStyle::Small));
624
        assert_eq!(b.text(), "hello-");
625
    }
626
627
    #[test]
628
    fn ctrl_u_kills_to_the_start_of_the_line() {
629
        let mut b = buffer("hello world", 5);
630
        let _ = b.apply(EditCommand::DeleteToLineStart);
631
        assert_eq!(b.text(), " world");
632
        assert_eq!(b.cursor_byte(), 0);
633
    }
634
635
    #[test]
636
    fn line_edges_respect_embedded_newlines() {
637
        let b = buffer("one\ntwo", 5);
638
        assert_eq!(b.line_start_at(5), 4);
639
        assert_eq!(b.line_end_from(5), 7);
640
    }
641
642
    #[test]
643
    fn grapheme_moves_step_over_a_cluster_in_one_go() {
644
        let mut b = buffer("👩‍💻x", 0);
645
        let _ = b.apply(EditCommand::MoveGraphemeRight);
646
        assert!(b.cursor_byte() > 4);
647
        let _ = b.apply(EditCommand::MoveGraphemeLeft);
648
        assert_eq!(b.cursor_byte(), 0);
649
    }
650
}
crates/openagents-cli/src/composer/keys.rs added +272

@@ -0,0 +1,272 @@

1
//! Key chords to edit commands.
2
//!
3
//! # Provenance
4
//!
5
//! Ported from `crates/codegen/xai-ratatui-textarea/src/editor_keys.rs` in
6
//! grok-build, © 2023-2026 SpaceXAI, Apache-2.0, read at commit
7
//! `07b2f7144fd5c5c9d3dd1966937a87852d2dbdb8`. See `LICENSE-APACHE-xai`.
8
//!
9
//! `classify_key_event` is the upstream function, arm for arm and in the same
10
//! order — the order carries meaning, since several arms deliberately shadow
11
//! later ones. `is_altgr` is inlined from the upstream crate root, and
12
//! `resolve_movement` is not ported: it resolves the vertical keys against
13
//! wrap geometry that the upstream widget owns, and `super::Composer` answers
14
//! those keys against its own wrap rows instead.
15
//!
16
//! The comments on the odd-looking arms are upstream's and are worth keeping:
17
//! they record terminal behaviour that is not guessable.
18
19
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
20
21
use super::edit::{EditCommand, WordStyle};
22
23
// On Windows, AltGr arrives as Ctrl+Alt; on other platforms it's composed
24
// before reaching us.
25
#[cfg(target_os = "windows")]
26
#[inline]
27
fn is_altgr(modifiers: KeyModifiers) -> bool {
28
    let without_shift = modifiers & !KeyModifiers::SHIFT;
29
    without_shift == (KeyModifiers::CONTROL | KeyModifiers::ALT)
30
}
31
32
#[cfg(not(target_os = "windows"))]
33
#[inline]
34
fn is_altgr(_modifiers: KeyModifiers) -> bool {
35
    false
36
}
37
38
pub fn classify_key_event(event: &KeyEvent) -> Option<EditCommand> {
39
    match event {
40
        // Some terminals encode Ctrl-B/Ctrl-F as bare C0 characters.
41
        KeyEvent {
42
            code: KeyCode::Char('\u{0002}'),
43
            modifiers: KeyModifiers::NONE,
44
            ..
45
        } => Some(EditCommand::MoveGraphemeLeft),
46
        KeyEvent {
47
            code: KeyCode::Char('\u{0006}'),
48
            modifiers: KeyModifiers::NONE,
49
            ..
50
        } => Some(EditCommand::MoveGraphemeRight),
51
        KeyEvent {
52
            code: KeyCode::Char('h'),
53
            modifiers,
54
            ..
55
        } if *modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
56
            Some(EditCommand::DeleteWordBackward(WordStyle::Small))
57
        }
58
        // Kitty protocol loss can surface Backspace as raw BS or DEL;
59
        // modifiers are unreliable.
60
        KeyEvent {
61
            code: KeyCode::Char('\u{0008}') | KeyCode::Char('\u{007f}'),
62
            ..
63
        } => Some(EditCommand::DeleteGraphemeBackward),
64
        KeyEvent {
65
            code: KeyCode::Backspace,
66
            modifiers,
67
            ..
68
        } => Some(backspace_command(*modifiers)),
69
        KeyEvent {
70
            code: KeyCode::Delete,
71
            modifiers,
72
            ..
73
        } => Some(delete_command(*modifiers)),
74
        KeyEvent {
75
            code: KeyCode::Char('w'),
76
            modifiers: KeyModifiers::CONTROL,
77
            ..
78
        } => Some(EditCommand::DeleteWordBackward(
79
            WordStyle::WhitespaceDelimited,
80
        )),
81
        KeyEvent {
82
            code: KeyCode::Left,
83
            modifiers,
84
            ..
85
        } if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => {
86
            Some(EditCommand::MoveWordLeft(WordStyle::Small))
87
        }
88
        KeyEvent {
89
            code: KeyCode::Right,
90
            modifiers,
91
            ..
92
        } if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => {
93
            Some(EditCommand::MoveWordRight(WordStyle::Small))
94
        }
95
        KeyEvent {
96
            code: KeyCode::Char('a'),
97
            modifiers: KeyModifiers::CONTROL,
98
            ..
99
        } => Some(EditCommand::MoveLogicalLineStart),
100
        KeyEvent {
101
            code: KeyCode::Char('e'),
102
            modifiers: KeyModifiers::CONTROL,
103
            ..
104
        } => Some(EditCommand::MoveLogicalLineEnd),
105
        KeyEvent {
106
            code: KeyCode::Left,
107
            modifiers: KeyModifiers::NONE,
108
            ..
109
        }
110
        | KeyEvent {
111
            code: KeyCode::Char('b'),
112
            modifiers: KeyModifiers::CONTROL,
113
            ..
114
        } => Some(EditCommand::MoveGraphemeLeft),
115
        KeyEvent {
116
            code: KeyCode::Right,
117
            modifiers: KeyModifiers::NONE,
118
            ..
119
        }
120
        | KeyEvent {
121
            code: KeyCode::Char('f'),
122
            modifiers: KeyModifiers::CONTROL,
123
            ..
124
        } => Some(EditCommand::MoveGraphemeRight),
125
        KeyEvent {
126
            code: KeyCode::Char('b'),
127
            modifiers: KeyModifiers::ALT,
128
            ..
129
        } => Some(EditCommand::MoveWordLeft(WordStyle::Small)),
130
        KeyEvent {
131
            code: KeyCode::Char('f'),
132
            modifiers: KeyModifiers::ALT,
133
            ..
134
        } => Some(EditCommand::MoveWordRight(WordStyle::Small)),
135
        KeyEvent {
136
            code: KeyCode::Char('u'),
137
            modifiers: KeyModifiers::CONTROL,
138
            ..
139
        } => Some(EditCommand::DeleteToLineStart),
140
        KeyEvent {
141
            code: KeyCode::Char('k'),
142
            modifiers: KeyModifiers::CONTROL,
143
            ..
144
        } => Some(EditCommand::DeleteToLineEnd),
145
        KeyEvent {
146
            code: KeyCode::Char('h'),
147
            modifiers: KeyModifiers::CONTROL,
148
            ..
149
        } => Some(EditCommand::DeleteGraphemeBackward),
150
        KeyEvent {
151
            code: KeyCode::Char('d'),
152
            modifiers: KeyModifiers::CONTROL,
153
            ..
154
        } => Some(EditCommand::DeleteGraphemeForward),
155
        KeyEvent {
156
            code: KeyCode::Char('d'),
157
            modifiers,
158
            ..
159
        } if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => {
160
            Some(EditCommand::DeleteWordForward(WordStyle::Small))
161
        }
162
        KeyEvent {
163
            code: KeyCode::Char(character),
164
            modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
165
            ..
166
        } if !character.is_control() => {
167
            let character = if event.modifiers.contains(KeyModifiers::SHIFT) {
168
                shifted_char(*character)
169
            } else {
170
                *character
171
            };
172
            Some(EditCommand::Insert(character))
173
        }
174
        KeyEvent {
175
            code: KeyCode::Char(character),
176
            modifiers,
177
            ..
178
        } if is_altgr(*modifiers) && !character.is_control() => {
179
            Some(EditCommand::Insert(*character))
180
        }
181
        _ => None,
182
    }
183
}
184
185
fn shifted_char(character: char) -> char {
186
    if character.is_ascii_lowercase() {
187
        character.to_ascii_uppercase()
188
    } else {
189
        character
190
    }
191
}
192
193
fn backspace_command(modifiers: KeyModifiers) -> EditCommand {
194
    // Backspace preserves exact historical chords; extra modifiers fall back
195
    // to grapheme delete.
196
    match modifiers {
197
        KeyModifiers::ALT | KeyModifiers::CONTROL => {
198
            EditCommand::DeleteWordBackward(WordStyle::Small)
199
        }
200
        KeyModifiers::SUPER => EditCommand::DeleteToLineStart,
201
        _ => EditCommand::DeleteGraphemeBackward,
202
    }
203
}
204
205
fn delete_command(modifiers: KeyModifiers) -> EditCommand {
206
    // Delete accepts Shift in addition to a word modifier because enhanced
207
    // protocols retain it.
208
    if modifiers.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL | KeyModifiers::SUPER) {
209
        EditCommand::DeleteWordForward(WordStyle::Small)
210
    } else {
211
        EditCommand::DeleteGraphemeForward
212
    }
213
}
214
215
#[cfg(test)]
216
mod tests {
217
    use super::*;
218
219
    fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
220
        KeyEvent::new(code, modifiers)
221
    }
222
223
    #[test]
224
    fn a_plain_letter_inserts_itself() {
225
        assert_eq!(
226
            classify_key_event(&key(KeyCode::Char('a'), KeyModifiers::NONE)),
227
            Some(EditCommand::Insert('a'))
228
        );
229
    }
230
231
    #[test]
232
    fn shift_uppercases_an_ascii_letter() {
233
        assert_eq!(
234
            classify_key_event(&key(KeyCode::Char('a'), KeyModifiers::SHIFT)),
235
            Some(EditCommand::Insert('A'))
236
        );
237
    }
238
239
    #[test]
240
    fn a_raw_del_byte_is_still_a_backspace() {
241
        assert_eq!(
242
            classify_key_event(&key(KeyCode::Char('\u{007f}'), KeyModifiers::NONE)),
243
            Some(EditCommand::DeleteGraphemeBackward)
244
        );
245
    }
246
247
    #[test]
248
    fn ctrl_w_and_alt_backspace_use_different_word_styles() {
249
        assert_eq!(
250
            classify_key_event(&key(KeyCode::Char('w'), KeyModifiers::CONTROL)),
251
            Some(EditCommand::DeleteWordBackward(
252
                WordStyle::WhitespaceDelimited
253
            ))
254
        );
255
        assert_eq!(
256
            classify_key_event(&key(KeyCode::Backspace, KeyModifiers::ALT)),
257
            Some(EditCommand::DeleteWordBackward(WordStyle::Small))
258
        );
259
    }
260
261
    #[test]
262
    fn enter_and_escape_are_not_edit_commands() {
263
        assert_eq!(
264
            classify_key_event(&key(KeyCode::Enter, KeyModifiers::NONE)),
265
            None
266
        );
267
        assert_eq!(
268
            classify_key_event(&key(KeyCode::Esc, KeyModifiers::NONE)),
269
            None
270
        );
271
    }
272
}
crates/openagents-cli/src/composer/mod.rs added +413

@@ -0,0 +1,413 @@

1
//! The composer: the line `oa coder` types into.
2
//!
3
//! The editing mechanics are ported from the ratatui textarea in grok-build
4
//! (Apache-2.0) — see `edit.rs` and `keys.rs` for the provenance and for what
5
//! was trimmed. This module is the part that is ours: the soft-wrap geometry
6
//! the composer draws itself with, vertical motion over those rows, and the
7
//! small dispatch that turns a key into an edit, a newline, or a submission.
8
9
pub mod edit;
10
pub mod keys;
11
12
use std::ops::Range;
13
14
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15
use unicode_segmentation::UnicodeSegmentation as _;
16
use unicode_width::UnicodeWidthStr as _;
17
18
use edit::EditBuffer;
19
20
/// What a key did to the composer.
21
#[derive(Debug, Clone, PartialEq, Eq)]
22
pub enum ComposerAction {
23
    /// The key meant nothing here; the caller may still want it.
24
    Ignored,
25
    /// The composer changed and the frame is stale.
26
    Redraw,
27
    /// Enter, with the text that was in the composer. The composer is now empty.
28
    Submit(String),
29
}
30
31
/// A multi-line input with a caret.
32
#[derive(Debug, Default)]
33
pub struct Composer {
34
    buffer: EditBuffer,
35
    /// The display column a vertical move is aiming for. Set on the first of a
36
    /// run of Up/Down presses and held across the run, so walking down through
37
    /// a short line and out the other side returns to the column you started
38
    /// in rather than to the short line's end.
39
    preferred_col: Option<usize>,
40
}
41
42
impl Composer {
43
    pub fn new() -> Self {
44
        Self::default()
45
    }
46
47
    pub fn text(&self) -> &str {
48
        self.buffer.text()
49
    }
50
51
    pub fn is_empty(&self) -> bool {
52
        self.buffer.is_empty()
53
    }
54
55
    pub fn cursor_byte(&self) -> usize {
56
        self.buffer.cursor_byte()
57
    }
58
59
    /// Empty the composer and return what was typed.
60
    pub fn take(&mut self) -> String {
61
        self.preferred_col = None;
62
        self.buffer.take()
63
    }
64
65
    pub fn insert_str(&mut self, text: &str) {
66
        self.preferred_col = None;
67
        let _ = self.buffer.insert_str(text);
68
    }
69
70
    /// The rows the composer draws, soft-wrapped to `width` columns.
71
    pub fn rows(&self, width: usize) -> Vec<&str> {
72
        wrap_rows(self.text(), width)
73
            .into_iter()
74
            .map(|range| &self.text()[range])
75
            .collect()
76
    }
77
78
    /// Where the caret sits, as (row index, display column).
79
    pub fn cursor_rowcol(&self, width: usize) -> (usize, usize) {
80
        let rows = wrap_rows(self.text(), width);
81
        let cursor = self.buffer.cursor_byte();
82
        let index = row_of(&rows, cursor);
83
        let row = &rows[index];
84
        let column = self.text()[row.start..cursor.max(row.start).min(row.end)].width();
85
        (index, column)
86
    }
87
88
    /// Apply a key.
89
    ///
90
    /// Enter submits and Alt+Enter (or Ctrl+J, or Shift+Enter where the
91
    /// terminal reports it) inserts a newline. Everything else goes to the
92
    /// ported classifier, so the readline chords it knows — Ctrl+A, Ctrl+E,
93
    /// Ctrl+W, Ctrl+K, Ctrl+U, Alt+B, Alt+F — work here too.
94
    pub fn handle_key(&mut self, key: &KeyEvent, width: usize) -> ComposerAction {
95
        match key.code {
96
            KeyCode::Enter
97
                if key
98
                    .modifiers
99
                    .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT) =>
100
            {
101
                self.insert_str("\n");
102
                return ComposerAction::Redraw;
103
            }
104
            KeyCode::Char('j') if key.modifiers == KeyModifiers::CONTROL => {
105
                self.insert_str("\n");
106
                return ComposerAction::Redraw;
107
            }
108
            KeyCode::Enter => {
109
                let text = self.take();
110
                return ComposerAction::Submit(text);
111
            }
112
            KeyCode::Up => {
113
                return self.move_vertically(-1, width);
114
            }
115
            KeyCode::Down => {
116
                return self.move_vertically(1, width);
117
            }
118
            _ => {}
119
        }
120
121
        let Some(command) = keys::classify_key_event(key) else {
122
            return ComposerAction::Ignored;
123
        };
124
        // Any horizontal or editing command abandons the column a vertical run
125
        // was aiming for.
126
        self.preferred_col = None;
127
        // The key was the composer's even when the caret was already at the
128
        // edge it was asked to move to, so the caller does not also get it.
129
        let _ = self.buffer.apply(command);
130
        ComposerAction::Redraw
131
    }
132
133
    /// Move the caret one wrapped row up or down, holding the preferred column.
134
    fn move_vertically(&mut self, delta: isize, width: usize) -> ComposerAction {
135
        let rows = wrap_rows(self.text(), width);
136
        let cursor = self.buffer.cursor_byte();
137
        let index = row_of(&rows, cursor);
138
        let column = self
139
            .preferred_col
140
            .unwrap_or_else(|| self.text()[rows[index].start..cursor].width());
141
142
        let target = index as isize + delta;
143
        if target < 0 || target as usize >= rows.len() {
144
            // Off the end of the composer. The caller takes the key, which is
145
            // how Up on a one-line composer reaches the transcript.
146
            self.preferred_col = None;
147
            return ComposerAction::Ignored;
148
        }
149
150
        self.preferred_col = Some(column);
151
        let row = &rows[target as usize];
152
        let byte = byte_at_column(self.text(), row.clone(), column);
153
        let _ = self.buffer.set_cursor_byte(byte);
154
        ComposerAction::Redraw
155
    }
156
}
157
158
/// The index of the row holding `cursor`.
159
///
160
/// Rows do not abut: the newline between two logical lines, and the spaces a
161
/// soft break consumes, are in no row at all. A caret in one of those gaps
162
/// belongs to the row that ends there, which is what puts it at the end of the
163
/// line you just walked up to rather than at the start of the next one.
164
///
165
/// The exception is a break with no gap — a word split mid-word because it was
166
/// longer than the row. There the caret belongs to the row that starts at that
167
/// byte, because that is where the next character will appear.
168
fn row_of(rows: &[Range<usize>], cursor: usize) -> usize {
169
    for (index, row) in rows.iter().enumerate() {
170
        if cursor < row.start {
171
            return index.saturating_sub(1);
172
        }
173
        if cursor < row.end {
174
            return index;
175
        }
176
        if cursor == row.end {
177
            let split_here = rows.get(index + 1).is_some_and(|next| next.start == cursor);
178
            if !split_here {
179
                return index;
180
            }
181
        }
182
    }
183
    rows.len().saturating_sub(1)
184
}
185
186
/// The byte offset within `row` closest to display column `column`.
187
fn byte_at_column(text: &str, row: Range<usize>, column: usize) -> usize {
188
    let mut byte = row.start;
189
    let mut seen = 0usize;
190
    for (offset, grapheme) in text[row.clone()].grapheme_indices(true) {
191
        let w = grapheme.width().max(1);
192
        if seen + w > column {
193
            return row.start + offset;
194
        }
195
        seen += w;
196
        byte = row.start + offset + grapheme.len();
197
    }
198
    byte
199
}
200
201
/// Soft-wrap `text` into byte ranges, one per display row.
202
///
203
/// Hard newlines always start a row. Within a logical line, a row breaks at
204
/// the last space that fits, and a word longer than the row breaks mid-word
205
/// rather than running off the edge. The spaces a break consumes belong to
206
/// neither row, so a wrapped paragraph has no leading space on its
207
/// continuations.
208
///
209
/// Every logical line contributes at least one range, and the ranges cover the
210
/// text in order, so a caret anywhere in the text — including at its very end
211
/// — falls in exactly one row.
212
pub fn wrap_rows(text: &str, width: usize) -> Vec<Range<usize>> {
213
    let width = width.max(1);
214
    let mut rows = Vec::new();
215
    let mut line_start = 0usize;
216
217
    loop {
218
        let line_end = text[line_start..]
219
            .find('\n')
220
            .map_or(text.len(), |offset| line_start + offset);
221
        wrap_one_line(text, line_start..line_end, width, &mut rows);
222
        if line_end == text.len() {
223
            break;
224
        }
225
        line_start = line_end + 1;
226
    }
227
228
    if rows.is_empty() {
229
        rows.push(0..0);
230
    }
231
    rows
232
}
233
234
fn wrap_one_line(text: &str, line: Range<usize>, width: usize, rows: &mut Vec<Range<usize>>) {
235
    let mut cursor = line.start;
236
    loop {
237
        let mut consumed = 0usize;
238
        let mut end = cursor;
239
        // The last point at which a break would land between words.
240
        let mut break_at: Option<usize> = None;
241
        let mut overflowed = false;
242
243
        for (offset, grapheme) in text[cursor..line.end].grapheme_indices(true) {
244
            let at = cursor + offset;
245
            let w = grapheme.width().max(1);
246
            if consumed + w > width {
247
                overflowed = true;
248
                break;
249
            }
250
            if grapheme == " " && at > cursor {
251
                break_at = Some(at);
252
            }
253
            consumed += w;
254
            end = at + grapheme.len();
255
        }
256
257
        if !overflowed {
258
            rows.push(cursor..line.end);
259
            return;
260
        }
261
262
        // Break at the space if there was one, otherwise mid-word at the last
263
        // grapheme that fit. A row that fits nothing at all still advances by
264
        // one grapheme, so this cannot spin.
265
        let (row_end, mut next) = match break_at {
266
            Some(space) => (space, space + 1),
267
            None if end > cursor => (end, end),
268
            None => {
269
                let one = text[cursor..line.end]
270
                    .grapheme_indices(true)
271
                    .next()
272
                    .map_or(line.end, |(_, g)| cursor + g.len());
273
                (one, one)
274
            }
275
        };
276
        rows.push(cursor..row_end);
277
        // A run of spaces at a break belongs to the break, not to the next row.
278
        while next < line.end && text[next..].starts_with(' ') {
279
            next += 1;
280
        }
281
        cursor = next;
282
        if cursor >= line.end {
283
            return;
284
        }
285
    }
286
}
287
288
#[cfg(test)]
289
mod tests {
290
    use super::*;
291
292
    fn key(code: KeyCode) -> KeyEvent {
293
        KeyEvent::new(code, KeyModifiers::NONE)
294
    }
295
296
    fn typed(composer: &mut Composer, text: &str) {
297
        for ch in text.chars() {
298
            composer.handle_key(&key(KeyCode::Char(ch)), 40);
299
        }
300
    }
301
302
    #[test]
303
    fn typing_lands_in_the_buffer() {
304
        let mut c = Composer::new();
305
        typed(&mut c, "hello");
306
        assert_eq!(c.text(), "hello");
307
        assert_eq!(c.cursor_rowcol(40), (0, 5));
308
    }
309
310
    #[test]
311
    fn backspace_removes_the_last_character() {
312
        let mut c = Composer::new();
313
        typed(&mut c, "hello");
314
        c.handle_key(&key(KeyCode::Backspace), 40);
315
        assert_eq!(c.text(), "hell");
316
    }
317
318
    #[test]
319
    fn left_and_right_move_the_caret_without_changing_the_text() {
320
        let mut c = Composer::new();
321
        typed(&mut c, "abc");
322
        c.handle_key(&key(KeyCode::Left), 40);
323
        c.handle_key(&key(KeyCode::Left), 40);
324
        assert_eq!(c.cursor_rowcol(40), (0, 1));
325
        typed(&mut c, "X");
326
        assert_eq!(c.text(), "aXbc");
327
    }
328
329
    #[test]
330
    fn enter_submits_and_empties_the_composer() {
331
        let mut c = Composer::new();
332
        typed(&mut c, "ship it");
333
        let action = c.handle_key(&key(KeyCode::Enter), 40);
334
        assert_eq!(action, ComposerAction::Submit("ship it".to_string()));
335
        assert!(c.is_empty());
336
    }
337
338
    #[test]
339
    fn alt_enter_inserts_a_newline_instead_of_submitting() {
340
        let mut c = Composer::new();
341
        typed(&mut c, "one");
342
        let action = c.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT), 40);
343
        assert_eq!(action, ComposerAction::Redraw);
344
        typed(&mut c, "two");
345
        assert_eq!(c.text(), "one\ntwo");
346
        assert_eq!(c.cursor_rowcol(40), (1, 3));
347
    }
348
349
    #[test]
350
    fn esc_is_not_the_composers_key() {
351
        let mut c = Composer::new();
352
        assert_eq!(
353
            c.handle_key(&key(KeyCode::Esc), 40),
354
            ComposerAction::Ignored
355
        );
356
    }
357
358
    #[test]
359
    fn up_off_the_top_hands_the_key_back() {
360
        let mut c = Composer::new();
361
        typed(&mut c, "one line");
362
        assert_eq!(c.handle_key(&key(KeyCode::Up), 40), ComposerAction::Ignored);
363
    }
364
365
    #[test]
366
    fn up_and_down_hold_the_column_across_a_short_line() {
367
        let mut c = Composer::new();
368
        typed(&mut c, "abcdefgh");
369
        c.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT), 40);
370
        typed(&mut c, "ab");
371
        c.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT), 40);
372
        typed(&mut c, "abcdefgh");
373
        // Caret is at column 8 on row 2. Up lands on the short row's end, and
374
        // Up again returns to column 8.
375
        c.handle_key(&key(KeyCode::Up), 40);
376
        assert_eq!(c.cursor_rowcol(40), (1, 2));
377
        c.handle_key(&key(KeyCode::Up), 40);
378
        assert_eq!(c.cursor_rowcol(40), (0, 8));
379
    }
380
381
    #[test]
382
    fn wrap_breaks_at_a_space() {
383
        let text = "the quick brown fox";
384
        let rows: Vec<&str> = wrap_rows(text, 10).into_iter().map(|r| &text[r]).collect();
385
        assert_eq!(rows, vec!["the quick", "brown fox"]);
386
    }
387
388
    #[test]
389
    fn wrap_splits_a_word_that_cannot_fit() {
390
        let text = "abcdefghij";
391
        let rows: Vec<&str> = wrap_rows(text, 4).into_iter().map(|r| &text[r]).collect();
392
        assert_eq!(rows, vec!["abcd", "efgh", "ij"]);
393
    }
394
395
    #[test]
396
    fn wrap_gives_an_empty_line_its_own_row() {
397
        let text = "a\n\nb";
398
        let rows: Vec<&str> = wrap_rows(text, 10).into_iter().map(|r| &text[r]).collect();
399
        assert_eq!(rows, vec!["a", "", "b"]);
400
    }
401
402
    #[test]
403
    fn wrap_covers_an_empty_buffer() {
404
        assert_eq!(wrap_rows("", 10), vec![0..0]);
405
    }
406
407
    #[test]
408
    fn a_wide_grapheme_counts_for_two_columns() {
409
        let mut c = Composer::new();
410
        c.insert_str("漢字");
411
        assert_eq!(c.cursor_rowcol(40), (0, 4));
412
    }
413
}
crates/openagents-cli/src/interactive.rs modified +461 -48

@@ -1,77 +1,490 @@

1
//! Interactive coder TUI session, diff rendering, keybindings & live transcript
1
//! The interactive `oa coder` session.
2
//!
3
//! Three pieces, kept apart on purpose:
4
//!
5
//! - [`CoderApp`] is the state machine. It takes keys and turn events and
6
//!   produces a view. It touches no terminal and no network, so a test can
7
//!   drive it directly and assert on the frame it renders.
8
//! - [`run_loop`] joins that state machine to a stream of terminal events and
9
//!   a channel of turn events. It is generic over both, so the loop a test
10
//!   runs is the loop production runs.
11
//! - [`runtime_actor`] owns the [`CoderRuntimeSession`] and does the turns.
12
//!   It is a task rather than a call inside the loop because
13
//!   `execute_turn` borrows the session for the length of a turn, and the
14
//!   frame has to keep drawing while that turn streams.
15
//!
16
//! The chunk callback `execute_turn` takes is `FnMut(&str) + Send + 'static`,
17
//! which cannot borrow the transcript. It sends each chunk down a channel
18
//! instead, and the loop appends it on arrival — so the reply appears as it is
19
//! written rather than in one block at the end.
2 20
3 21
use crate::cli::CoderArgs;
4
use crate::tui::BoxFrame;
22
use crate::composer::{Composer, ComposerAction};
23
use crate::runtime::{CoderRuntimeSession, Lane};
24
use crate::tools::HarnessToolRegistry;
25
use crate::tui::{composer_text_width, BoxFrame, ChromeView, Entry, Role};
26
5 27
use crossterm::{
6
    event::{self, Event, KeyCode, KeyModifiers},
28
    event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
7 29
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
8 30
    ExecutableCommand,
9 31
};
10
use ratatui::backend::CrosstermBackend;
32
use futures::{Stream, StreamExt};
33
use ratatui::backend::{Backend, CrosstermBackend};
11 34
use ratatui::Terminal;
12
use std::io::stdout;
35
use std::io::{stdout, IsTerminal};
13 36
use std::time::Duration;
37
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
38
39
/// A message for the runtime task.
40
#[derive(Debug, Clone)]
41
pub enum Control {
42
    /// Run a turn on this prompt.
43
    Prompt(String),
44
}
45
46
/// A message from the runtime task.
47
#[derive(Debug, Clone)]
48
pub enum TurnEvent {
49
    /// A piece of the reply, as it was written.
50
    Chunk(String),
51
    /// The turn finished. Carries the final answer, which matters only when
52
    /// nothing streamed.
53
    Done(String),
54
    /// The turn failed. The session stays open.
55
    Failed(String),
56
    /// The model the server's grant named for that turn.
57
    ///
58
    /// It is reported rather than assumed because the CLI cannot choose it:
59
    /// `POST /api/v1/threads` publishes no model parameter, and the grant it
60
    /// returns pins the model that answers.
61
    Model(String),
62
}
63
64
/// How often the streaming bullet flips.
65
const PULSE: Duration = Duration::from_millis(400);
14 66
15
pub async fn run_tui(args: CoderArgs, _token: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
16
    println!("Starting interactive Coder session...");
17
    if !atty_is_terminal() {
18
        println!("Non-interactive terminal detected. Running basic prompt mode.");
19
        if let Some(prompt) = args.prompt {
20
            println!("User prompt: {}", prompt);
21
            println!("Coder response: Interactive session initialized in non-TTY mode.");
67
pub struct CoderApp {
68
    title: String,
69
    entries: Vec<Entry>,
70
    composer: Composer,
71
    /// The model the last grant named. Unknown until a turn has opened one.
72
    model: Option<String>,
73
    busy: bool,
74
    pulse: bool,
75
    scrollback: usize,
76
    should_exit: bool,
77
}
78
79
impl CoderApp {
80
    pub fn new(title: &str) -> Self {
81
        let entries = vec![Entry {
82
            role: Role::Notice,
83
            // Every claim here is one this screen keeps. The old welcome text
84
            // invited the reader to type into a session that discarded keys.
85
            text: "Type a prompt and press Enter. The reply streams in below \
86
                   as the model writes it."
87
                .to_string(),
88
            settled: true,
89
        }];
90
        Self {
91
            title: title.to_string(),
92
            entries,
93
            composer: Composer::new(),
94
            model: None,
95
            busy: false,
96
            pulse: true,
97
            scrollback: 0,
98
            should_exit: false,
22 99
        }
23
        return Ok(());
24 100
    }
25 101
26
    enable_raw_mode()?;
27
    let mut stdout = stdout();
28
    stdout.execute(EnterAlternateScreen)?;
29
    let backend = CrosstermBackend::new(stdout);
30
    let mut terminal = Terminal::new(backend)?;
102
    /// The model the last turn's grant named, if a turn has opened one.
103
    pub fn model(&self) -> Option<&str> {
104
        self.model.as_deref()
105
    }
31 106
32
    let frame = BoxFrame::new("openagents coder");
33
    let mut transcript = String::from("Welcome to OpenAgents Coder (Rust CLI v0.1.0)
107
    pub fn busy(&self) -> bool {
108
        self.busy
109
    }
34 110
35
Type your instructions or press Esc to exit.");
36
    if let Some(p) = args.prompt {
37
        transcript.push_str(&format!("
111
    pub fn should_exit(&self) -> bool {
112
        self.should_exit
113
    }
38 114
39
User: {}", p));
40
        transcript.push_str("
41
Agent: Ready to execute.");
115
    pub fn entries(&self) -> &[Entry] {
116
        &self.entries
42 117
    }
43 118
44
    loop {
119
    /// The transcript as text, for `--export`.
120
    pub fn transcript(&self) -> String {
121
        self.entries
122
            .iter()
123
            .filter(|e| !e.text.is_empty())
124
            .map(|e| {
125
                let who = match e.role {
126
                    Role::You => "you",
127
                    Role::Assistant => "coder",
128
                    Role::Tool => "tool",
129
                    Role::Notice => "note",
130
                    Role::Error => "error",
131
                };
132
                format!("[{who}] {}", e.text)
133
            })
134
            .collect::<Vec<_>>()
135
            .join("\n\n")
136
    }
137
138
    fn push(&mut self, role: Role, text: impl Into<String>) {
139
        self.entries.push(Entry::new(role, text));
140
        // New material at the bottom pulls the view back to the bottom.
141
        self.scrollback = 0;
142
    }
143
144
    /// Send a prompt, whatever its source: the composer, or `--prompt`.
145
    pub fn submit(&mut self, prompt: String, control: &UnboundedSender<Control>) {
146
        let prompt = prompt.trim_end().to_string();
147
        if prompt.is_empty() {
148
            return;
149
        }
150
        self.push(Role::You, prompt.clone());
151
        self.entries.push(Entry::streaming(Role::Assistant));
152
        self.busy = true;
153
        self.scrollback = 0;
154
        if control.send(Control::Prompt(prompt)).is_err() {
155
            self.finish_turn();
156
            self.push(
157
                Role::Error,
158
                "The runtime task is gone, so this prompt was not sent. Restart the session.",
159
            );
160
        }
161
    }
162
163
    /// Settle whatever was streaming and take the composer off hold.
164
    fn finish_turn(&mut self) {
165
        if let Some(last) = self.entries.last_mut() {
166
            if !last.settled {
167
                last.settled = true;
168
            }
169
        }
170
        self.busy = false;
171
    }
172
173
    pub fn on_turn_event(&mut self, event: TurnEvent) {
174
        match event {
175
            TurnEvent::Chunk(chunk) => {
176
                match self.entries.last_mut() {
177
                    Some(last) if !last.settled => last.text.push_str(&chunk),
178
                    // A chunk with no open turn to attach to still belongs on
179
                    // the transcript rather than in the bin.
180
                    _ => self.entries.push(Entry {
181
                        role: Role::Assistant,
182
                        text: chunk,
183
                        settled: false,
184
                    }),
185
                }
186
                self.scrollback = 0;
187
            }
188
            TurnEvent::Done(answer) => {
189
                // `execute_turn` returns the last step's text, which has
190
                // already streamed. It is the fallback for the paths that
191
                // return without streaming anything.
192
                if let Some(last) = self.entries.last_mut() {
193
                    if !last.settled && last.text.is_empty() {
194
                        last.text = answer;
195
                    }
196
                }
197
                self.finish_turn();
198
            }
199
            TurnEvent::Failed(message) => {
200
                self.finish_turn();
201
                self.push(Role::Error, format!("Turn failed: {message}"));
202
            }
203
            TurnEvent::Model(model) => self.model = Some(model),
204
        }
205
    }
206
207
    pub fn tick(&mut self) {
208
        if self.busy {
209
            self.pulse = !self.pulse;
210
        } else {
211
            self.pulse = true;
212
        }
213
    }
214
215
    pub fn on_key(&mut self, key: &KeyEvent, width: u16, control: &UnboundedSender<Control>) {
216
        // A key release reported by an enhanced protocol is not a keystroke.
217
        if key.kind == KeyEventKind::Release {
218
            return;
219
        }
220
221
        match key.code {
222
            KeyCode::Esc => {
223
                self.should_exit = true;
224
                return;
225
            }
226
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
227
                self.should_exit = true;
228
                return;
229
            }
230
            KeyCode::PageUp => {
231
                self.scrollback = self.scrollback.saturating_add(5);
232
                return;
233
            }
234
            KeyCode::PageDown => {
235
                self.scrollback = self.scrollback.saturating_sub(5);
236
                return;
237
            }
238
            _ => {}
239
        }
240
241
        if self.busy {
242
            // The composer is on hold. Saying so is the point of the pane's
243
            // title; swallowing the key here is what makes that true.
244
            return;
245
        }
246
247
        match self.composer.handle_key(key, composer_text_width(width)) {
248
            ComposerAction::Submit(text) => self.submit(text, control),
249
            ComposerAction::Redraw => self.scrollback = 0,
250
            ComposerAction::Ignored => match key.code {
251
                // Up and Down reach the transcript once the caret has run out
252
                // of composer to move through.
253
                KeyCode::Up => self.scrollback = self.scrollback.saturating_add(1),
254
                KeyCode::Down => self.scrollback = self.scrollback.saturating_sub(1),
255
                _ => {}
256
            },
257
        }
258
    }
259
260
    /// Draw one frame.
261
    pub fn draw<B: Backend>(&self, terminal: &mut Terminal<B>) -> std::io::Result<()> {
262
        let frame = BoxFrame::new(&self.title);
45 263
        terminal.draw(|f| {
46
            let size = f.area();
47
            frame.render(f, size, &transcript);
264
            let area = f.area();
265
            let rows = self.composer.rows(composer_text_width(area.width));
266
            let cursor = self.composer.cursor_rowcol(composer_text_width(area.width));
267
            let view = ChromeView {
268
                title: &self.title,
269
                entries: &self.entries,
270
                composer_rows: &rows,
271
                composer_cursor: cursor,
272
                model: self.model.as_deref(),
273
                busy: self.busy,
274
                pulse: self.pulse,
275
                scrollback: self.scrollback,
276
            };
277
            frame.render(f, area, &view);
48 278
        })?;
279
        Ok(())
280
    }
281
}
49 282
50
        if event::poll(Duration::from_millis(100))? {
51
            if let Event::Key(key) = event::read()? {
52
                match key.code {
53
                    KeyCode::Esc | KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
54
                        break;
55
                    }
56
                    KeyCode::Esc => {
57
                        break;
58
                    }
59
                    KeyCode::Tab => {
60
                        transcript.push_str("
61
[Toggled reasoning effort]");
62
                    }
63
                    _ => {}
283
/// Drive the session until the reader exits or the terminal event stream ends.
284
///
285
/// `keepalive` is a sender for the turn channel that this function holds for
286
/// its own lifetime, so `turns.recv()` cannot resolve to `None` and spin the
287
/// loop when the runtime task ends.
288
pub async fn run_loop<B, S>(
289
    terminal: &mut Terminal<B>,
290
    app: &mut CoderApp,
291
    events: &mut S,
292
    control: UnboundedSender<Control>,
293
    turns: &mut UnboundedReceiver<TurnEvent>,
294
    keepalive: UnboundedSender<TurnEvent>,
295
) -> std::io::Result<()>
296
where
297
    B: Backend,
298
    S: Stream<Item = std::io::Result<Event>> + Unpin,
299
{
300
    let _keepalive = keepalive;
301
    let mut ticker = tokio::time::interval(PULSE);
302
    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
303
304
    loop {
305
        app.draw(terminal)?;
306
        if app.should_exit() {
307
            return Ok(());
308
        }
309
310
        let width = terminal.size()?.width;
311
312
        tokio::select! {
313
            event = events.next() => match event {
314
                Some(Ok(Event::Key(key))) => app.on_key(&key, width, &control),
315
                Some(Ok(_)) => {}
316
                // A terminal that has gone away is an exit, not an error to
317
                // report into a screen nobody can see.
318
                Some(Err(_)) | None => return Ok(()),
319
            },
320
            turn = turns.recv() => {
321
                if let Some(event) = turn {
322
                    app.on_turn_event(event);
323
                }
324
            },
325
            _ = ticker.tick() => app.tick(),
326
        }
327
    }
328
}
329
330
/// Own the session and run the turns it is asked for.
331
pub async fn runtime_actor(
332
    mut session: CoderRuntimeSession,
333
    mut control: UnboundedReceiver<Control>,
334
    events: UnboundedSender<TurnEvent>,
335
) {
336
    while let Some(message) = control.recv().await {
337
        match message {
338
            Control::Prompt(prompt) => {
339
                let sink = events.clone();
340
                let result = session
341
                    .execute_turn(&prompt, move |chunk| {
342
                        let _ = sink.send(TurnEvent::Chunk(chunk.to_string()));
343
                    })
344
                    .await;
345
                // Report the grant's model before the turn settles, so the
346
                // status bar names what answered rather than what was asked.
347
                if let Some(grant) = &session.last_grant {
348
                    let _ = events.send(TurnEvent::Model(grant.model.clone()));
349
                }
350
                let event = match result {
351
                    Ok(answer) => TurnEvent::Done(answer),
352
                    Err(error) => TurnEvent::Failed(error.to_string()),
353
                };
354
                if events.send(event).is_err() {
355
                    return;
64 356
                }
65 357
            }
66 358
        }
67 359
    }
360
}
361
362
/// Put the terminal back however this function is left, including by a panic
363
/// or by an error on the way out of a turn.
364
struct TerminalGuard;
365
366
impl TerminalGuard {
367
    fn enter() -> std::io::Result<Self> {
368
        enable_raw_mode()?;
369
        stdout().execute(EnterAlternateScreen)?;
370
        Ok(Self)
371
    }
372
}
373
374
impl Drop for TerminalGuard {
375
    fn drop(&mut self) {
376
        let _ = disable_raw_mode();
377
        let _ = stdout().execute(LeaveAlternateScreen);
378
        let _ = stdout().execute(crossterm::cursor::Show);
379
    }
380
}
381
382
/// A panic inside the alternate screen would otherwise leave the terminal in
383
/// raw mode with the backtrace painted somewhere the reader cannot scroll to.
384
fn install_panic_hook() {
385
    use std::sync::Once;
386
    static ONCE: Once = Once::new();
387
    ONCE.call_once(|| {
388
        let previous = std::panic::take_hook();
389
        std::panic::set_hook(Box::new(move |info| {
390
            let _ = disable_raw_mode();
391
            let _ = stdout().execute(LeaveAlternateScreen);
392
            let _ = stdout().execute(crossterm::cursor::Show);
393
            previous(info);
394
        }));
395
    });
396
}
397
398
pub async fn run_tui(
399
    args: CoderArgs,
400
    token: Option<String>,
401
) -> Result<(), Box<dyn std::error::Error>> {
402
    let lane = Lane::from_str(&args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string()));
403
404
    if !is_terminal() {
405
        return run_without_a_terminal(args, token, lane).await;
406
    }
407
408
    let tools = HarnessToolRegistry::new(None);
409
    let session = CoderRuntimeSession::new(lane.clone(), None, token, tools);
410
411
    let (control_tx, control_rx) = unbounded_channel::<Control>();
412
    let (event_tx, mut event_rx) = unbounded_channel::<TurnEvent>();
413
    let runtime = tokio::spawn(runtime_actor(session, control_rx, event_tx.clone()));
68 414
69
    disable_raw_mode()?;
70
    std::io::stdout().execute(LeaveAlternateScreen)?;
415
    let mut app = CoderApp::new("openagents coder");
416
    if let Some(prompt) = args.prompt.clone() {
417
        app.submit(prompt, &control_tx);
418
    }
419
420
    install_panic_hook();
421
    let result = {
422
        let _guard = TerminalGuard::enter()?;
423
        let backend = CrosstermBackend::new(stdout());
424
        let mut terminal = Terminal::new(backend)?;
425
        let mut events = EventStream::new();
426
        run_loop(
427
            &mut terminal,
428
            &mut app,
429
            &mut events,
430
            control_tx,
431
            &mut event_rx,
432
            event_tx,
433
        )
434
        .await
435
    };
436
437
    runtime.abort();
438
    result?;
439
440
    if let Some(path) = args.export {
441
        std::fs::write(&path, app.transcript())?;
442
        println!("Transcript written to {path}");
443
    }
444
    Ok(())
445
}
446
447
/// Without a terminal there is no session to run, so run the prompt straight
448
/// through and stream the reply to stdout.
449
///
450
/// The version this replaces printed `Coder response: Interactive session
451
/// initialized in non-TTY mode.` and never called the runtime at all.
452
async fn run_without_a_terminal(
453
    args: CoderArgs,
454
    token: Option<String>,
455
    lane: Lane,
456
) -> Result<(), Box<dyn std::error::Error>> {
457
    let Some(prompt) = args.prompt else {
458
        eprintln!(
459
            "`oa coder` needs a terminal for an interactive session. \
460
             Give it a prompt, or use `--headless`, to run one turn here."
461
        );
462
        return Ok(());
463
    };
464
465
    let tools = HarnessToolRegistry::new(None);
466
    let mut session = CoderRuntimeSession::new(lane, None, token, tools);
467
    // The reply is printed as it streams. `execute_turn` also returns the last
468
    // step's text, which is the same text — so it is printed only when nothing
469
    // streamed, which is how the offline paths still say something.
470
    let streamed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
471
    let saw = std::sync::Arc::clone(&streamed);
472
    let answer = session
473
        .execute_turn(&prompt, move |chunk| {
474
            use std::io::Write;
475
            saw.store(true, std::sync::atomic::Ordering::Relaxed);
476
            print!("{chunk}");
477
            let _ = std::io::stdout().flush();
478
        })
479
        .await
480
        .map_err(|e| e.to_string())?;
481
    if !streamed.load(std::sync::atomic::Ordering::Relaxed) {
482
        print!("{answer}");
483
    }
484
    println!();
71 485
    Ok(())
72 486
}
73 487
74
fn atty_is_terminal() -> bool {
75
    // Check if stdin / stdout is terminal or running in harness
76
    std::io::IsTerminal::is_terminal(&std::io::stdin()) && std::io::IsTerminal::is_terminal(&std::io::stdout())
488
fn is_terminal() -> bool {
489
    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
77 490
}
crates/openagents-cli/src/lib.rs modified +1

@@ -16,6 +16,7 @@ pub mod api_passthrough;

16 16
pub mod auth;
17 17
pub mod box_client;
18 18
pub mod cli;
19
pub mod composer;
19 20
pub mod computer;
20 21
pub mod delegate;
21 22
pub mod forum;
crates/openagents-cli/src/runtime.rs modified +56 -11

@@ -56,6 +56,16 @@ impl Lane {

56 56
    }
57 57
}
58 58
59
/// As much of an error body as belongs in a one-line message.
60
fn snippet(body: &str) -> String {
61
    let body = body.trim();
62
    if body.chars().count() <= 200 {
63
        return body.to_string();
64
    }
65
    let head: String = body.chars().take(200).collect();
66
    format!("{head}…")
67
}
68
59 69
#[derive(Debug, Clone, Serialize, Deserialize)]
60 70
pub struct InferenceGrant {
61 71
    pub thread_id: String,

@@ -77,6 +87,9 @@ pub struct ChatMessage {

77 87
78 88
pub struct CoderRuntimeSession {
79 89
    pub lane: Lane,
90
    /// The grant the last turn opened, so a caller can report the model that
91
    /// actually answered rather than the one it would have asked for.
92
    pub last_grant: Option<InferenceGrant>,
80 93
    pub api_base: String,
81 94
    pub user_token: Option<String>,
82 95
    pub http: reqwest::Client,

@@ -88,7 +101,14 @@ impl CoderRuntimeSession {

88 101
    pub fn new(lane: Lane, api_base: Option<String>, user_token: Option<String>, tools: HarnessToolRegistry) -> Self {
89 102
        Self {
90 103
            lane,
91
            api_base: api_base.unwrap_or_else(|| "https://openagents.com/api/v1".to_string()),
104
            last_grant: None,
105
            // `OPENAGENTS_API_BASE` points the session at another host. A test
106
            // that has to prove the streaming path end to end needs somewhere
107
            // to point it that is not production, and an operator on staging
108
            // needs the same switch.
109
            api_base: api_base
110
                .or_else(|| std::env::var("OPENAGENTS_API_BASE").ok().filter(|v| !v.trim().is_empty()))
111
                .unwrap_or_else(|| "https://openagents.com/api/v1".to_string()),
92 112
            user_token,
93 113
            http: reqwest::Client::builder()
94 114
                .timeout(Duration::from_secs(300))

@@ -140,9 +160,17 @@ impl CoderRuntimeSession {

140 160
141 161
        let resp = self.http.post(&url)
142 162
            .headers(headers)
163
            // `lane` is the thread's execution shape, and the server admits
164
            // only `thread` and `local` — this path is the proxy, which
165
            // is `thread`. It used to send a model name here, and every
166
            // request was refused with `"ox-alpha" is not an admitted lane`;
167
            // the refusal was invisible because the caller answered it with a
168
            // fabricated grant. A model cannot be named at thread open at all:
169
            // the endpoint publishes no model parameter and the grant the
170
            // server returns pins the model that answers.
143 171
            .json(&serde_json::json!({
144 172
                "objective": "Coding assistant session",
145
                "lane": self.lane.model_name(),
173
                "lane": "thread",
146 174
            }))
147 175
            .send()
148 176
            .await?;

@@ -164,12 +192,12 @@ impl CoderRuntimeSession {

164 192
                model,
165 193
            })
166 194
        } else {
167
            Ok(InferenceGrant {
168
                thread_id: "th_local_fallback".to_string(),
169
                token: self.user_token.clone().unwrap_or_else(|| "oat_anon".to_string()),
170
                proxy_url: "https://openagents.com/api/inference/proxy".to_string(),
171
                model: self.lane.model_name().to_string(),
172
            })
195
            // This used to invent a grant with a placeholder token and carry
196
            // on, so a refused request reached the reader as a completed turn.
197
            // Say what happened instead; the caller puts it on the transcript.
198
            let status = resp.status();
199
            let body = resp.text().await.unwrap_or_default();
200
            Err(format!("{} refused the thread request: {} {}", url, status, snippet(&body)).into())
173 201
        }
174 202
    }
175 203

@@ -196,6 +224,7 @@ impl CoderRuntimeSession {

196 224
        });
197 225
198 226
        let grant = self.create_thread().await?;
227
        self.last_grant = Some(grant.clone());
199 228
200 229
        let mut max_steps = 30;
201 230
        let mut final_answer = String::new();

@@ -227,11 +256,27 @@ impl CoderRuntimeSession {

227 256
                .send()
228 257
                .await;
229 258
259
            // A refused or unreachable proxy is a failed turn. The version
260
            // this replaces streamed the words `Completed autonomous reasoning
261
            // turn (offline fallback).` and returned success, so a rejected
262
            // request and a finished one looked the same on screen.
230 263
            let resp = match resp {
231 264
                Ok(r) if r.status().is_success() => r,
232
                _ => {
233
                    chunk_callback("Completed autonomous reasoning turn (offline fallback).");
234
                    return Ok("Completed autonomous reasoning turn (offline fallback).".to_string());
265
                Ok(r) => {
266
                    let status = r.status();
267
                    let body = r.text().await.unwrap_or_default();
268
                    return Err(format!(
269
                        "{} refused the turn: {} {}",
270
                        grant.proxy_url,
271
                        status,
272
                        snippet(&body)
273
                    )
274
                    .into());
275
                }
276
                Err(error) => {
277
                    return Err(
278
                        format!("{} could not be reached: {}", grant.proxy_url, error).into()
279
                    )
235 280
                }
236 281
            };
237 282
crates/openagents-cli/src/tui.rs modified +434 -28

@@ -1,59 +1,465 @@

1
//! Terminal user interface and frame rendering adapted from grok-build/ratatui
1
//! Terminal chrome for `oa coder`.
2
//!
3
//! The identity comes from two places that already exist. The `/coder` page in
4
//! the website (`lib/openagents_web/live/coder_live.ex`) draws a box whose
5
//! title sits inside the top rule as a badge — `┌───── OpenAgents ─────┐`, the
6
//! rule dim and the badge bright — which is the WebTUI `box-`/`shear-` shape
7
//! written out in characters. The TypeScript coder UI
8
//! (`packages/openagents-cli/src/coder-ui.ts`) supplies the transcript's
9
//! grammar: one bullet per turn in a four-column gutter, a colour per role, and
10
//! a `›` composer under a rule.
11
//!
12
//! This module owns only the drawing. It holds no session state, so every
13
//! frame it produces is a function of the view it is handed, which is what
14
//! makes the frames assertable in a test.
2 15
3 16
use ratatui::{
4
    layout::{Constraint, Direction, Layout, Rect},
17
    layout::{Constraint, Direction, Layout, Position, Rect},
5 18
    style::{Color, Modifier, Style},
6 19
    text::{Line, Span},
7
    widgets::{Block, Borders, Paragraph, Wrap},
20
    widgets::{Block, Borders, Paragraph},
8 21
    Frame,
9 22
};
23
use unicode_width::UnicodeWidthStr;
24
25
/// Columns reserved for the bullet before a turn's first line.
26
pub const GUTTER: usize = 4;
27
28
/// The bullet a settled turn carries, and the one a streaming turn pulses to.
29
pub const BULLET_SETTLED: &str = "⏺";
30
pub const BULLET_PULSE: &str = "○";
31
32
/// The composer's prompt.
33
pub const PROMPT: &str = "› ";
34
35
/// Who wrote a transcript entry.
36
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37
pub enum Role {
38
    You,
39
    Assistant,
40
    Tool,
41
    Notice,
42
    Error,
43
}
44
45
impl Role {
46
    /// The colours are the ones `coder-ui.ts` uses, so the two surfaces read
47
    /// the same: cyan for the reader, green for the model, magenta for a tool,
48
    /// yellow for a notice, red for a failure.
49
    pub fn color(self) -> Color {
50
        match self {
51
            Role::You => Color::Cyan,
52
            Role::Assistant => Color::Green,
53
            Role::Tool => Color::Magenta,
54
            Role::Notice => Color::Yellow,
55
            Role::Error => Color::Red,
56
        }
57
    }
58
}
59
60
/// One turn on the transcript.
61
///
62
/// `settled` is false while a reply is still arriving; the bullet pulses and
63
/// an entry with no text yet draws an ellipsis rather than nothing, so a turn
64
/// that has been sent is visible before its first chunk lands.
65
#[derive(Clone, Debug)]
66
pub struct Entry {
67
    pub role: Role,
68
    pub text: String,
69
    pub settled: bool,
70
}
71
72
impl Entry {
73
    pub fn new(role: Role, text: impl Into<String>) -> Self {
74
        Self {
75
            role,
76
            text: text.into(),
77
            settled: true,
78
        }
79
    }
80
81
    pub fn streaming(role: Role) -> Self {
82
        Self {
83
            role,
84
            text: String::new(),
85
            settled: false,
86
        }
87
    }
88
}
89
90
/// Everything a frame needs. Borrowed, never owned.
91
pub struct ChromeView<'a> {
92
    pub title: &'a str,
93
    pub entries: &'a [Entry],
94
    /// The composer's text, already soft-wrapped to [`composer_text_width`].
95
    pub composer_rows: &'a [&'a str],
96
    /// Caret position in the composer, as (row index, display column).
97
    pub composer_cursor: (usize, usize),
98
    /// The model the last grant named, or `None` before a turn has opened one.
99
    pub model: Option<&'a str>,
100
    /// True while a turn is streaming: the composer stops taking keys and says so.
101
    pub busy: bool,
102
    /// Flips on a timer to animate the streaming bullet.
103
    pub pulse: bool,
104
    /// Rows the reader has scrolled back from the bottom of the transcript.
105
    pub scrollback: usize,
106
}
10 107
11 108
pub struct BoxFrame {
12 109
    pub title: String,
13
    pub status: String,
14 110
}
15 111
16 112
impl BoxFrame {
17 113
    pub fn new(title: &str) -> Self {
18 114
        Self {
19 115
            title: title.to_string(),
20
            status: "ready".to_string(),
21 116
        }
22 117
    }
23 118
24
    pub fn render(&self, f: &mut Frame, area: Rect, content: &str) {
119
    /// The rule colour. Dim, like `foreground2` on the `/coder` page, so the
120
    /// badge and the content sit in front of the frame rather than inside it.
121
    fn rule() -> Style {
122
        Style::default().fg(Color::DarkGray)
123
    }
124
125
    /// A title badge for the top rule: one space of air either side, bright,
126
    /// so it reads as set into the line rather than printed over it.
127
    fn badge(text: &str, color: Color) -> Line<'static> {
128
        Line::from(vec![Span::styled(
129
            format!(" {text} "),
130
            Style::default().fg(color).add_modifier(Modifier::BOLD),
131
        )])
132
    }
133
134
    fn pane(title: &str, color: Color) -> Block<'static> {
135
        Block::default()
136
            .borders(Borders::ALL)
137
            .border_style(Self::rule())
138
            .title(Self::badge(title, color))
139
    }
140
141
    pub fn render(&self, f: &mut Frame, area: Rect, view: &ChromeView) {
142
        // The composer grows with what is typed, up to a third of the screen,
143
        // and the transcript pays for it. Two rows of frame plus at least one
144
        // row of text.
145
        let composer_rows = view.composer_rows.len().clamp(1, composer_cap(area.height));
146
        let composer_height = composer_rows as u16 + 2;
147
25 148
        let chunks = Layout::default()
26 149
            .direction(Direction::Vertical)
27 150
            .constraints([
28 151
                Constraint::Length(3),
29
                Constraint::Min(5),
152
                Constraint::Min(3),
153
                Constraint::Length(composer_height),
30 154
                Constraint::Length(3),
31 155
            ])
32 156
            .split(area);
33 157
34
        // Header
158
        self.render_header(f, chunks[0]);
159
        render_transcript(f, chunks[1], view);
160
        render_composer(f, chunks[2], view);
161
        render_status(f, chunks[3], view);
162
    }
163
164
    fn render_header(&self, f: &mut Frame, area: Rect) {
35 165
        let header = Paragraph::new(Line::from(vec![
36
            Span::styled(" OpenAgents ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
37
            Span::raw("│ "),
166
            Span::styled(
167
                " OpenAgents ",
168
                Style::default()
169
                    .fg(Color::Cyan)
170
                    .add_modifier(Modifier::BOLD),
171
            ),
172
            Span::styled("│ ", Self::rule()),
38 173
            Span::styled(&self.title, Style::default().fg(Color::White)),
39 174
        ]))
40
        .block(Block::default().borders(Borders::ALL).title("Agent Context"));
41
        f.render_widget(header, chunks[0]);
42
43
        // Main Body
44
        let body = Paragraph::new(content)
45
            .block(Block::default().borders(Borders::ALL).title("Transcript"))
46
            .wrap(Wrap { trim: true });
47
        f.render_widget(body, chunks[1]);
48
49
        // Footer / Status Bar
50
        let footer = Paragraph::new(Line::from(vec![
51
            Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
52
            Span::styled(&self.status, Style::default().fg(Color::Green)),
53
            Span::raw(" │ "),
54
            Span::styled("Tab: effort │ Shift+Tab: lane │ Esc: exit", Style::default().fg(Color::DarkGray)),
55
        ]))
56
        .block(Block::default().borders(Borders::ALL));
57
        f.render_widget(footer, chunks[2]);
175
        .block(Self::pane("Agent Context", Color::Cyan));
176
        f.render_widget(header, area);
177
    }
178
}
179
180
/// How many rows of composer text the screen can spare.
181
fn composer_cap(height: u16) -> usize {
182
    // The header, the status bar, the composer's own frame, and three rows of
183
    // transcript are not negotiable; whatever is left can go to the composer.
184
    let reserved = 3 + 3 + 2 + 3;
185
    usize::from(height.saturating_sub(reserved)).clamp(1, 8)
186
}
187
188
/// Break `text` into rows no wider than `width` columns, preferring to break
189
/// at a space. Hard newlines in the text are kept.
190
pub fn wrap(text: &str, width: usize) -> Vec<String> {
191
    if width == 0 {
192
        return vec![String::new()];
193
    }
194
    let mut rows = Vec::new();
195
    for hard in text.split('\n') {
196
        if hard.is_empty() {
197
            rows.push(String::new());
198
            continue;
199
        }
200
        let mut row = String::new();
201
        let mut row_width = 0usize;
202
        // The last column at which a break would land between words, and how
203
        // wide the row was there, so a break can be rewound to it.
204
        let mut break_at: Option<(usize, usize)> = None;
205
        for ch in hard.chars() {
206
            let w = ch.to_string().width().max(1);
207
            if row_width + w > width {
208
                match break_at {
209
                    Some((byte, _)) if byte < row.len() => {
210
                        let tail = row.split_off(byte);
211
                        rows.push(row.trim_end().to_string());
212
                        row = tail.trim_start().to_string();
213
                        row_width = row.width();
214
                    }
215
                    _ => {
216
                        rows.push(std::mem::take(&mut row));
217
                        row_width = 0;
218
                    }
219
                }
220
                break_at = None;
221
            }
222
            if ch == ' ' {
223
                break_at = Some((row.len(), row_width));
224
            }
225
            row.push(ch);
226
            row_width += w;
227
        }
228
        rows.push(row);
229
    }
230
    if rows.is_empty() {
231
        rows.push(String::new());
232
    }
233
    rows
234
}
235
236
/// The transcript, wrapped and windowed so the newest rows stay in view.
237
fn render_transcript(f: &mut Frame, area: Rect, view: &ChromeView) {
238
    let block = BoxFrame::pane("Transcript", Color::Cyan);
239
    let inner = block.inner(area);
240
    f.render_widget(block, area);
241
242
    let body = usize::from(inner.width).saturating_sub(GUTTER).max(8);
243
    let mut rows: Vec<Line> = Vec::new();
244
245
    for entry in view.entries {
246
        // A settled entry with nothing in it is a turn that produced no text.
247
        // Drawing its bullet would say something happened when nothing did.
248
        if entry.settled && entry.text.is_empty() {
249
            continue;
250
        }
251
        if !rows.is_empty() {
252
            rows.push(Line::from(""));
253
        }
254
        let glyph = if entry.settled || view.pulse {
255
            BULLET_SETTLED
256
        } else {
257
            BULLET_PULSE
258
        };
259
        let head = Span::styled(
260
            format!("  {glyph} "),
261
            Style::default().fg(entry.role.color()),
262
        );
263
        let text_style = match entry.role {
264
            Role::Notice => Style::default().fg(Color::DarkGray),
265
            Role::Error => Style::default().fg(Color::Red),
266
            _ => Style::default(),
267
        };
268
269
        let wrapped = if entry.text.is_empty() {
270
            vec!["…".to_string()]
271
        } else {
272
            wrap(&entry.text, body)
273
        };
274
        for (index, row) in wrapped.into_iter().enumerate() {
275
            let lead = if index == 0 {
276
                head.clone()
277
            } else {
278
                Span::raw(" ".repeat(GUTTER))
279
            };
280
            rows.push(Line::from(vec![lead, Span::styled(row, text_style)]));
281
        }
282
    }
283
284
    let height = usize::from(inner.height);
285
    let top = rows
286
        .len()
287
        .saturating_sub(height)
288
        .saturating_sub(view.scrollback);
289
    let window: Vec<Line> = rows.into_iter().skip(top).take(height).collect();
290
    f.render_widget(Paragraph::new(window), inner);
291
}
292
293
/// The composer: a `›` prompt, the text, and a caret the reader can see.
294
fn render_composer(f: &mut Frame, area: Rect, view: &ChromeView) {
295
    let (title, color) = if view.busy {
296
        ("Message · waiting for the reply", Color::DarkGray)
297
    } else {
298
        ("Message", Color::Cyan)
299
    };
300
    let block = BoxFrame::pane(title, color);
301
    let inner = block.inner(area);
302
    f.render_widget(block, area);
303
304
    let text_style = if view.busy {
305
        Style::default().fg(Color::DarkGray)
306
    } else {
307
        Style::default().fg(Color::White)
308
    };
309
    let prompt_style = Style::default().fg(if view.busy {
310
        Color::DarkGray
311
    } else {
312
        Color::Cyan
313
    });
314
315
    // The window follows the caret, so a composer taller than its pane still
316
    // shows the row being typed.
317
    let height = usize::from(inner.height).max(1);
318
    let first = view.composer_cursor.0.saturating_sub(height - 1);
319
    let lines: Vec<Line> = view
320
        .composer_rows
321
        .iter()
322
        .enumerate()
323
        .skip(first)
324
        .take(height)
325
        .map(|(index, row)| {
326
            // The prompt marks the composer, not each row, so a wrapped
327
            // continuation is indented under it rather than repeating it.
328
            let lead = if index == 0 {
329
                Span::styled(PROMPT, prompt_style)
330
            } else {
331
                Span::raw(" ".repeat(PROMPT.width()))
332
            };
333
            Line::from(vec![lead, Span::styled(*row, text_style)])
334
        })
335
        .collect();
336
    f.render_widget(Paragraph::new(lines), inner);
337
338
    if !view.busy {
339
        let (row, column) = view.composer_cursor;
340
        let x = inner.x + (PROMPT.width() + column) as u16;
341
        let y = inner.y + (row.saturating_sub(first)) as u16;
342
        if x < inner.right() && y < inner.bottom() {
343
            f.set_cursor_position(Position::new(x, y));
344
        }
345
    }
346
}
347
348
/// How wide the composer's text is, given the whole frame's width.
349
///
350
/// The composer wraps to this before the view is built, so the caller and the
351
/// renderer agree on where the rows break.
352
pub fn composer_text_width(frame_width: u16) -> usize {
353
    usize::from(frame_width)
354
        .saturating_sub(2 + PROMPT.width())
355
        .max(4)
356
}
357
358
/// The keys the status bar offers, most useful first.
359
///
360
/// Both of the ones the old bar advertised are gone, and neither could have
361
/// been made to work.
362
///
363
/// `Tab: effort` toggled nothing: it appended the words `[Toggled reasoning
364
/// effort]` to the transcript, and `execute_turn` has no effort field to send
365
/// even if it had meant it. `Shift+Tab: lane` was never handled at all, and a
366
/// lane control here cannot do what its name says: `POST /api/v1/threads`
367
/// publishes no model parameter, and the grant it returns pins the model that
368
/// answers. The bar reports that model instead, which is a fact rather than a
369
/// request.
370
const HINTS: [&str; 4] = [
371
    "Enter: send",
372
    "Esc: exit",
373
    "Alt+Enter: newline",
374
    "PgUp/PgDn: scroll",
375
];
376
377
/// Fit as many hints as the row holds, dropping them from the end.
378
///
379
/// A row written past the last column is truncated by the renderer, and half a
380
/// hint is worse than none: `PgU` names no key. A window too narrow for even
381
/// the first hint gets no hint row at all, and keeps its status and lane.
382
fn hint_row(hints: &[&str], budget: usize) -> String {
383
    let mut shown = hints.len();
384
    while shown > 0 {
385
        let row = hints[..shown].join(" · ");
386
        if row.width() <= budget {
387
            return row;
388
        }
389
        shown -= 1;
390
    }
391
    String::new()
392
}
393
394
/// The status bar. Every key it names is a key that works.
395
fn render_status(f: &mut Frame, area: Rect, view: &ChromeView) {
396
    let block = Block::default()
397
        .borders(Borders::ALL)
398
        .border_style(BoxFrame::rule());
399
    let inner = block.inner(area);
400
    f.render_widget(block, area);
401
402
    let (state, state_color) = if view.busy {
403
        ("streaming", Color::Yellow)
404
    } else {
405
        ("ready", Color::Green)
406
    };
407
    // While a turn streams the composer is on hold, so the keys that reach it
408
    // are not offered.
409
    let hints: &[&str] = if view.busy {
410
        &["Esc: exit", "PgUp/PgDn: scroll"]
411
    } else {
412
        &HINTS
413
    };
414
415
    const SEPARATOR: &str = " │ ";
416
    let mut spans = vec![
417
        Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
418
        Span::styled(state, Style::default().fg(state_color)),
419
        Span::styled(SEPARATOR, BoxFrame::rule()),
420
        Span::styled("Model: ", Style::default().fg(Color::DarkGray)),
421
        Span::styled(
422
            view.model.unwrap_or("not yet granted"),
423
            Style::default().fg(if view.model.is_some() {
424
                Color::White
425
            } else {
426
                Color::DarkGray
427
            }),
428
        ),
429
    ];
430
    let used: usize = spans.iter().map(|span| span.content.width()).sum();
431
    let budget = usize::from(inner.width).saturating_sub(used + SEPARATOR.width());
432
433
    let row = hint_row(hints, budget);
434
    if !row.is_empty() {
435
        spans.push(Span::styled(SEPARATOR, BoxFrame::rule()));
436
        spans.push(Span::styled(row, Style::default().fg(Color::DarkGray)));
437
    }
438
    f.render_widget(Paragraph::new(Line::from(spans)), inner);
439
}
440
441
#[cfg(test)]
442
mod tests {
443
    use super::*;
444
445
    #[test]
446
    fn wrap_breaks_at_spaces() {
447
        assert_eq!(
448
            wrap("the quick brown fox", 10),
449
            vec!["the quick".to_string(), "brown fox".to_string()]
450
        );
451
    }
452
453
    #[test]
454
    fn wrap_splits_a_word_too_long_for_the_row() {
455
        assert_eq!(
456
            wrap("abcdefghij", 4),
457
            vec!["abcd".to_string(), "efgh".to_string(), "ij".to_string()]
458
        );
459
    }
460
461
    #[test]
462
    fn wrap_keeps_hard_newlines() {
463
        assert_eq!(wrap("a\nb", 10), vec!["a".to_string(), "b".to_string()]);
58 464
    }
59 465
}
crates/openagents-cli/tests/cli_test.rs modified +54 -5

@@ -1,3 +1,5 @@

1
mod support;
2
1 3
#[cfg(test)]
2 4
mod tests {
3 5
    use openagents_cli::runtime::{CoderRuntimeSession, Lane};

@@ -76,12 +78,46 @@ mod tests {

76 78
        assert!(redacted.contains("[REDACTED_PAT]"));
77 79
    }
78 80
81
    /// A turn streams its reply and returns it.
82
    ///
83
    /// This used to run against production with no credentials and assert
84
    /// success. It passed because a refusal was answered with a fabricated
85
    /// grant and the words `Completed autonomous reasoning turn (offline
86
    /// fallback).` — so the test asserted the fallback, not a turn. It now
87
    /// runs against a local proxy that streams real server-sent events.
79 88
    #[tokio::test]
80 89
    async fn test_live_inference_loop_issue_83() {
81
        let tools = HarnessToolRegistry::new(None);
82
        let mut session = CoderRuntimeSession::new(Lane::OxAlpha, None, None, tools);
83
        let res = session.execute_turn("hello", |_| {}).await;
84
        assert!(res.is_ok());
90
        let stub = crate::support::start(vec!["four ", "chunks ", "in ", "order"], None).await;
91
        let tools = HarnessToolRegistry::new(Some(std::env::temp_dir()));
92
        let mut session = CoderRuntimeSession::new(Lane::OxAlpha, Some(stub.base), None, tools);
93
94
        let seen = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
95
        let sink = std::sync::Arc::clone(&seen);
96
        let answer = session
97
            .execute_turn("hello", move |chunk| {
98
                sink.lock().unwrap().push_str(chunk);
99
            })
100
            .await
101
            .expect("the turn failed");
102
103
        assert_eq!(*seen.lock().unwrap(), "four chunks in order");
104
        assert_eq!(answer, "four chunks in order");
105
    }
106
107
    /// And a refused turn is reported as one.
108
    #[tokio::test]
109
    async fn a_refused_turn_is_an_error_not_a_finished_turn() {
110
        let stub = crate::support::start_refusing().await;
111
        let tools = HarnessToolRegistry::new(Some(std::env::temp_dir()));
112
        let mut session = CoderRuntimeSession::new(Lane::OxAlpha, Some(stub.base), None, tools);
113
114
        let error = session
115
            .execute_turn("hello", |_| {})
116
            .await
117
            .expect_err("a 401 was reported as a finished turn");
118
        let message = error.to_string();
119
        assert!(message.contains("401"), "{message}");
120
        assert!(!message.contains("offline fallback"), "{message}");
85 121
    }
86 122
87 123
    #[tokio::test]

@@ -97,12 +133,25 @@ mod tests {

97 133
        assert!(out.output.contains("test_output_123"));
98 134
    }
99 135
136
    /// Delegation runs a child turn and reports what it produced.
137
    ///
138
    /// Like the test above, this passed against production with no
139
    /// credentials only because a refusal read as success.
140
    /// `DelegationSupervisor` builds its own session, so the local proxy is
141
    /// named through `OPENAGENTS_API_BASE`; no other test in this binary reads
142
    /// that variable without also passing an explicit base, which wins over it.
100 143
    #[tokio::test]
101 144
    async fn test_real_multi_lane_delegation_issue_85() {
145
        let stub = crate::support::start(vec!["child ", "did the work"], None).await;
146
        std::env::set_var("OPENAGENTS_API_BASE", &stub.base);
147
102 148
        let supervisor = DelegationSupervisor::new(1, "ox-alpha", None);
103 149
        let results = supervisor.dispatch("test task").await;
150
151
        std::env::remove_var("OPENAGENTS_API_BASE");
104 152
        assert_eq!(results.len(), 1);
105
        assert!(results[0].success);
153
        assert!(results[0].success, "{}", results[0].output);
154
        assert_eq!(results[0].output, "child did the work");
106 155
    }
107 156
108 157
    #[test]
crates/openagents-cli/tests/coder_tui_test.rs added +678

@@ -0,0 +1,678 @@

1
//! Frame-level proof that the interactive coder takes keys and shows replies.
2
//!
3
//! Every assertion here is against what the terminal would actually show. The
4
//! frames come from ratatui's `TestBackend`, so a test failing means the
5
//! reader would not have seen the thing, not that some intermediate value was
6
//! wrong.
7
//!
8
//! The end-to-end tests at the bottom run the real `run_loop` and the real
9
//! `runtime_actor` over a real HTTP server speaking real server-sent events.
10
//! Only the model behind that server is a stand-in.
11
12
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
13
use futures::Stream;
14
use openagents_cli::interactive::{run_loop, runtime_actor, CoderApp, Control, TurnEvent};
15
use openagents_cli::runtime::{CoderRuntimeSession, Lane};
16
use openagents_cli::tools::HarnessToolRegistry;
17
18
mod support;
19
use ratatui::backend::TestBackend;
20
use ratatui::Terminal;
21
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
22
23
const WIDTH: u16 = 74;
24
const HEIGHT: u16 = 22;
25
26
fn terminal() -> Terminal<TestBackend> {
27
    terminal_of(WIDTH, HEIGHT)
28
}
29
30
fn terminal_of(width: u16, height: u16) -> Terminal<TestBackend> {
31
    Terminal::new(TestBackend::new(width, height)).expect("test terminal")
32
}
33
34
/// The frame as the reader would see it, one row per line.
35
fn screen(terminal: &Terminal<TestBackend>) -> String {
36
    let buffer = terminal.backend().buffer();
37
    (0..buffer.area.height)
38
        .map(|y| {
39
            (0..buffer.area.width)
40
                .map(|x| buffer[(x, y)].symbol())
41
                .collect::<String>()
42
        })
43
        .collect::<Vec<_>>()
44
        .join("\n")
45
}
46
47
fn key(code: KeyCode) -> KeyEvent {
48
    KeyEvent::new(code, KeyModifiers::NONE)
49
}
50
51
fn app() -> (
52
    CoderApp,
53
    UnboundedSender<Control>,
54
    UnboundedReceiver<Control>,
55
) {
56
    let (tx, rx) = unbounded_channel();
57
    (CoderApp::new("openagents coder"), tx, rx)
58
}
59
60
fn type_str(app: &mut CoderApp, control: &UnboundedSender<Control>, text: &str) {
61
    for ch in text.chars() {
62
        app.on_key(&key(KeyCode::Char(ch)), WIDTH, control);
63
    }
64
}
65
66
// ---------------------------------------------------------------- the input
67
68
#[test]
69
fn what_you_type_appears_in_the_composer() {
70
    let (mut app, control, _rx) = app();
71
    let mut term = terminal();
72
    type_str(&mut app, &control, "list the open issues");
73
    app.draw(&mut term).unwrap();
74
75
    let frame = screen(&term);
76
    assert!(
77
        frame.contains("› list the open issues"),
78
        "the composer did not show what was typed:\n{frame}"
79
    );
80
}
81
82
#[test]
83
fn backspace_takes_a_character_back_off_the_screen() {
84
    let (mut app, control, _rx) = app();
85
    let mut term = terminal();
86
    type_str(&mut app, &control, "hello");
87
    app.on_key(&key(KeyCode::Backspace), WIDTH, &control);
88
    app.draw(&mut term).unwrap();
89
90
    let frame = screen(&term);
91
    assert!(frame.contains("› hell"), "{frame}");
92
    assert!(!frame.contains("› hello"), "{frame}");
93
}
94
95
#[test]
96
fn the_caret_sits_where_the_next_character_will_go() {
97
    let (mut app, control, _rx) = app();
98
    let mut term = terminal();
99
    type_str(&mut app, &control, "abc");
100
    app.on_key(&key(KeyCode::Left), WIDTH, &control);
101
    app.draw(&mut term).unwrap();
102
103
    // The composer pane's left border is column 0, its inner text starts at
104
    // column 1, and the prompt `› ` takes two more.
105
    let (x, _y) = term.get_cursor_position().unwrap().into();
106
    assert_eq!(x, 1 + 2 + 2, "caret was not left of the last character");
107
108
    type_str(&mut app, &control, "X");
109
    app.draw(&mut term).unwrap();
110
    assert!(screen(&term).contains("› abXc"), "{}", screen(&term));
111
}
112
113
#[test]
114
fn alt_enter_opens_a_second_composer_row_and_enter_still_sends() {
115
    let (mut app, control, mut rx) = app();
116
    let mut term = terminal();
117
    type_str(&mut app, &control, "one");
118
    app.on_key(
119
        &KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
120
        WIDTH,
121
        &control,
122
    );
123
    type_str(&mut app, &control, "two");
124
    app.draw(&mut term).unwrap();
125
126
    let frame = screen(&term);
127
    assert!(frame.contains("› one"), "{frame}");
128
    assert!(frame.contains("  two"), "{frame}");
129
130
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
131
    match rx.try_recv() {
132
        Ok(Control::Prompt(prompt)) => assert_eq!(prompt, "one\ntwo"),
133
        other => panic!("Enter did not send both rows as one prompt: {other:?}"),
134
    }
135
}
136
137
// ------------------------------------------------------------ the turn cycle
138
139
#[test]
140
fn submitting_puts_the_prompt_on_the_transcript_and_asks_the_runtime() {
141
    let (mut app, control, mut rx) = app();
142
    let mut term = terminal();
143
    type_str(&mut app, &control, "what changed today");
144
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
145
    app.draw(&mut term).unwrap();
146
147
    match rx.try_recv() {
148
        Ok(Control::Prompt(prompt)) => assert_eq!(prompt, "what changed today"),
149
        other => panic!("the runtime was not asked for a turn: {other:?}"),
150
    }
151
152
    let frame = screen(&term);
153
    assert!(
154
        frame.contains("what changed today"),
155
        "the prompt is not on the transcript:\n{frame}"
156
    );
157
    assert!(
158
        frame.contains("waiting for the reply"),
159
        "the composer does not say it is on hold:\n{frame}"
160
    );
161
    assert!(frame.contains("streaming"), "{frame}");
162
}
163
164
#[test]
165
fn a_reply_shows_up_while_it_is_still_arriving() {
166
    let (mut app, control, _rx) = app();
167
    let mut term = terminal();
168
    type_str(&mut app, &control, "hi");
169
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
170
171
    app.on_turn_event(TurnEvent::Chunk("The first ".to_string()));
172
    app.draw(&mut term).unwrap();
173
    let partial = screen(&term);
174
    assert!(
175
        partial.contains("The first"),
176
        "the first chunk was not drawn:\n{partial}"
177
    );
178
    assert!(
179
        app.busy(),
180
        "the turn was treated as finished by its first chunk"
181
    );
182
183
    app.on_turn_event(TurnEvent::Chunk("half arrived.".to_string()));
184
    app.draw(&mut term).unwrap();
185
    assert!(screen(&term).contains("The first half arrived."));
186
187
    app.on_turn_event(TurnEvent::Done(String::new()));
188
    app.draw(&mut term).unwrap();
189
    let done = screen(&term);
190
    assert!(!app.busy(), "the composer stayed on hold after Done");
191
    assert!(done.contains("ready"), "{done}");
192
    assert!(!done.contains("waiting for the reply"), "{done}");
193
}
194
195
#[test]
196
fn keys_typed_during_a_turn_do_not_reach_the_composer() {
197
    let (mut app, control, _rx) = app();
198
    let mut term = terminal();
199
    type_str(&mut app, &control, "go");
200
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
201
    type_str(&mut app, &control, "ignored");
202
    app.draw(&mut term).unwrap();
203
204
    assert!(
205
        !screen(&term).contains("ignored"),
206
        "a key typed mid-turn landed in a composer that says it is on hold"
207
    );
208
}
209
210
#[test]
211
fn a_reply_with_no_chunks_falls_back_to_the_returned_answer() {
212
    let (mut app, control, _rx) = app();
213
    let mut term = terminal();
214
    type_str(&mut app, &control, "go");
215
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
216
    app.on_turn_event(TurnEvent::Done("the whole answer at once".to_string()));
217
    app.draw(&mut term).unwrap();
218
    assert!(
219
        screen(&term).contains("the whole answer at once"),
220
        "{}",
221
        screen(&term)
222
    );
223
}
224
225
#[test]
226
fn a_failed_turn_lands_on_the_transcript_and_the_session_stays_open() {
227
    let (mut app, control, mut rx) = app();
228
    let mut term = terminal();
229
    type_str(&mut app, &control, "go");
230
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
231
    let _ = rx.try_recv();
232
233
    app.on_turn_event(TurnEvent::Failed("connection reset".to_string()));
234
    app.draw(&mut term).unwrap();
235
236
    let frame = screen(&term);
237
    assert!(frame.contains("Turn failed"), "{frame}");
238
    assert!(frame.contains("connection reset"), "{frame}");
239
    assert!(!app.should_exit(), "a failed turn ended the session");
240
    assert!(!app.busy(), "a failed turn left the composer on hold");
241
242
    // And the next prompt still goes out.
243
    type_str(&mut app, &control, "again");
244
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
245
    assert!(matches!(rx.try_recv(), Ok(Control::Prompt(p)) if p == "again"));
246
}
247
248
// ------------------------------------------------------------- the keybinds
249
250
#[test]
251
fn every_key_the_status_bar_names_does_something() {
252
    const WIDE: u16 = 120;
253
    let (mut app, control, mut rx) = app();
254
    let mut term = terminal_of(WIDE, HEIGHT);
255
    app.draw(&mut term).unwrap();
256
    let frame = screen(&term);
257
258
    // Whatever the bar claims, claim it here too, so a new label without a
259
    // key behind it fails this test.
260
    assert!(frame.contains("Enter: send"), "{frame}");
261
    assert!(frame.contains("Alt+Enter: newline"), "{frame}");
262
    assert!(frame.contains("PgUp/PgDn: scroll"), "{frame}");
263
    assert!(frame.contains("Esc: exit"), "{frame}");
264
265
    // Neither of the keys the old bar advertised is here. `Tab: effort` had
266
    // nothing behind it — `execute_turn` sends no effort field. `Shift+Tab:
267
    // lane` could not be given anything behind it: the thread endpoint
268
    // publishes no model parameter and the grant pins the model.
269
    assert!(!frame.contains("Tab: effort"), "{frame}");
270
    assert!(!frame.contains("Shift+Tab"), "{frame}");
271
272
    // Enter sends.
273
    type_str(&mut app, &control, "x");
274
    app.on_key(&key(KeyCode::Enter), WIDE, &control);
275
    assert!(matches!(rx.try_recv(), Ok(Control::Prompt(_))));
276
    app.on_turn_event(TurnEvent::Done("ok".to_string()));
277
278
    // Esc exits.
279
    app.on_key(&key(KeyCode::Esc), WIDE, &control);
280
    assert!(app.should_exit());
281
}
282
283
/// Shift+Tab is not bound, so it does nothing rather than pretending to.
284
#[test]
285
fn shift_tab_is_not_bound() {
286
    let (mut app, control, mut rx) = app();
287
    app.on_key(&key(KeyCode::BackTab), WIDTH, &control);
288
    assert!(!app.should_exit());
289
    assert!(app.model().is_none());
290
    assert!(
291
        rx.try_recv().is_err(),
292
        "Shift+Tab sent something to the runtime"
293
    );
294
}
295
296
/// The bar names the model the grant chose, and says so honestly before one.
297
#[test]
298
fn the_model_shown_is_the_one_the_grant_named() {
299
    let (mut app, control, _rx) = app();
300
    let mut term = terminal_of(120, HEIGHT);
301
    app.draw(&mut term).unwrap();
302
    assert!(
303
        screen(&term).contains("Model: not yet granted"),
304
        "{}",
305
        screen(&term)
306
    );
307
308
    type_str(&mut app, &control, "go");
309
    app.on_key(&key(KeyCode::Enter), 120, &control);
310
    app.on_turn_event(TurnEvent::Model("ox-alpha-2".to_string()));
311
    app.on_turn_event(TurnEvent::Done("done".to_string()));
312
    app.draw(&mut term).unwrap();
313
    assert_eq!(app.model(), Some("ox-alpha-2"));
314
    assert!(
315
        screen(&term).contains("Model: ox-alpha-2"),
316
        "{}",
317
        screen(&term)
318
    );
319
}
320
321
/// PgUp reaches material the transcript has scrolled past, and PgDn returns.
322
#[test]
323
fn paging_up_shows_what_scrolled_off_the_top() {
324
    let (mut app, control, _rx) = app();
325
    let mut term = terminal();
326
327
    type_str(&mut app, &control, "the first question");
328
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
329
    app.on_turn_event(TurnEvent::Chunk(
330
        (1..=30)
331
            .map(|n| format!("line {n}"))
332
            .collect::<Vec<_>>()
333
            .join("\n"),
334
    ));
335
    app.on_turn_event(TurnEvent::Done(String::new()));
336
    app.draw(&mut term).unwrap();
337
338
    // The newest rows are what the reader sees, so the prompt is off the top.
339
    let bottom = screen(&term);
340
    assert!(bottom.contains("line 30"), "{bottom}");
341
    assert!(!bottom.contains("the first question"), "{bottom}");
342
343
    for _ in 0..8 {
344
        app.on_key(&key(KeyCode::PageUp), WIDTH, &control);
345
    }
346
    app.draw(&mut term).unwrap();
347
    let scrolled = screen(&term);
348
    assert!(
349
        scrolled.contains("the first question"),
350
        "PgUp did not reach the prompt:\n{scrolled}"
351
    );
352
    assert!(!scrolled.contains("line 30"), "{scrolled}");
353
354
    for _ in 0..8 {
355
        app.on_key(&key(KeyCode::PageDown), WIDTH, &control);
356
    }
357
    app.draw(&mut term).unwrap();
358
    assert!(
359
        screen(&term).contains("line 30"),
360
        "PgDn did not come back to the bottom:\n{}",
361
        screen(&term)
362
    );
363
}
364
365
/// The status bar's own row, which is the second from the bottom.
366
fn status_row(terminal: &Terminal<TestBackend>) -> String {
367
    let frame = screen(terminal);
368
    let rows: Vec<&str> = frame.lines().collect();
369
    rows[rows.len() - 2].to_string()
370
}
371
372
#[test]
373
fn a_narrow_window_drops_hints_rather_than_showing_half_of_one() {
374
    let (app, _control, _rx) = app();
375
376
    // Wide enough for two whole hints and no more.
377
    let mut term = terminal_of(74, HEIGHT);
378
    app.draw(&mut term).unwrap();
379
    let row = status_row(&term);
380
    assert!(row.contains("Enter: send \u{b7} Esc: exit"), "{row}");
381
    assert!(!row.contains("PgU"), "a hint was cut in half: {row}");
382
383
    // Too narrow for even the first: the status and the lane stay, the hints go.
384
    let mut term = terminal_of(46, HEIGHT);
385
    app.draw(&mut term).unwrap();
386
    let row = status_row(&term);
387
    assert!(row.contains("Status: ready"), "{row}");
388
    assert!(row.contains("Model: not yet granted"), "{row}");
389
    assert!(!row.contains("Ent"), "a hint was cut in half: {row}");
390
    for line in screen(&term).lines() {
391
        assert_eq!(
392
            line.chars().count(),
393
            46,
394
            "a row is not exactly the window's width:\n{}",
395
            screen(&term)
396
        );
397
    }
398
}
399
400
#[test]
401
fn ctrl_c_exits() {
402
    let (mut app, control, _rx) = app();
403
    app.on_key(
404
        &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
405
        WIDTH,
406
        &control,
407
    );
408
    assert!(app.should_exit());
409
}
410
411
#[test]
412
fn the_welcome_text_promises_only_what_the_screen_does() {
413
    let mut term = terminal();
414
    let (app, _control, _rx) = app();
415
    app.draw(&mut term).unwrap();
416
    let frame = screen(&term);
417
    assert!(frame.contains("Type a prompt"), "{frame}");
418
    // The frame keeps the boxed panes and their titles set into the rule.
419
    assert!(frame.contains("Agent Context"), "{frame}");
420
    assert!(frame.contains("Transcript"), "{frame}");
421
    assert!(frame.contains("Message"), "{frame}");
422
}
423
424
// ------------------------------------------------------------- end-to-end
425
426
/// A stream of terminal events the test writes by hand.
427
fn scripted(rx: UnboundedReceiver<Event>) -> impl Stream<Item = std::io::Result<Event>> + Unpin {
428
    Box::pin(futures::stream::unfold(rx, |mut rx| async move {
429
        rx.recv().await.map(|event| (Ok(event), rx))
430
    }))
431
}
432
433
async fn drive(
434
    app: &mut CoderApp,
435
    term: &mut Terminal<TestBackend>,
436
    keys: UnboundedReceiver<Event>,
437
    control: UnboundedSender<Control>,
438
    turns: &mut UnboundedReceiver<TurnEvent>,
439
    keepalive: UnboundedSender<TurnEvent>,
440
) {
441
    let mut events = scripted(keys);
442
    run_loop(term, app, &mut events, control, turns, keepalive)
443
        .await
444
        .expect("the loop returned an error");
445
}
446
447
fn send_keys(tx: &UnboundedSender<Event>, text: &str) {
448
    for ch in text.chars() {
449
        let _ = tx.send(Event::Key(key(KeyCode::Char(ch))));
450
    }
451
}
452
453
/// The whole loop, with a stub runtime on the other end of the real channels.
454
#[tokio::test]
455
async fn end_to_end_over_the_loop_with_a_stub_runtime() {
456
    let mut term = terminal();
457
    let mut app = CoderApp::new("openagents coder");
458
    let (keys_tx, keys_rx) = unbounded_channel();
459
    let (control_tx, mut control_rx) = unbounded_channel::<Control>();
460
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
461
462
    // The stand-in for `runtime_actor`: same channels, same message types.
463
    let stub_sink = turn_tx.clone();
464
    let keys_for_stub = keys_tx.clone();
465
    tokio::spawn(async move {
466
        while let Some(Control::Prompt(prompt)) = control_rx.recv().await {
467
            assert_eq!(prompt, "who are you");
468
            for chunk in ["I am ", "openagents ", "coder."] {
469
                let _ = stub_sink.send(TurnEvent::Chunk(chunk.to_string()));
470
                tokio::time::sleep(std::time::Duration::from_millis(20)).await;
471
            }
472
            let _ = stub_sink.send(TurnEvent::Done(String::new()));
473
            tokio::time::sleep(std::time::Duration::from_millis(120)).await;
474
            let _ = keys_for_stub.send(Event::Key(key(KeyCode::Esc)));
475
        }
476
    });
477
478
    send_keys(&keys_tx, "who are you");
479
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));
480
481
    drive(
482
        &mut app,
483
        &mut term,
484
        keys_rx,
485
        control_tx,
486
        &mut turn_rx,
487
        turn_tx,
488
    )
489
    .await;
490
491
    let frame = screen(&term);
492
    assert!(
493
        frame.contains("who are you"),
494
        "the typed prompt is not on the final frame:\n{frame}"
495
    );
496
    assert!(
497
        frame.contains("I am openagents coder."),
498
        "the streamed reply is not on the final frame:\n{frame}"
499
    );
500
    assert!(app.should_exit(), "the loop did not exit on Esc");
501
}
502
503
// -------------------------------------------- end-to-end over real HTTP/SSE
504
505
/// The real loop, the real `runtime_actor`, and the real `CoderRuntimeSession`
506
/// against a real socket speaking real server-sent events.
507
///
508
/// The reader types a prompt, presses Enter, and the reply appears. The turn is
509
/// interrupted deliberately after its first chunk to prove the transcript is
510
/// showing text while the turn is still open — not assembling it at the end.
511
#[tokio::test]
512
async fn end_to_end_over_real_http_shows_a_chunk_before_the_turn_finishes() {
513
    let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
514
    let stub = support::start(vec!["Reading the ", "repository now."], Some(gate_rx)).await;
515
516
    let session = CoderRuntimeSession::new(
517
        Lane::OxAlpha,
518
        Some(stub.base),
519
        Some("oat_test".to_string()),
520
        HarnessToolRegistry::new(Some(std::env::temp_dir())),
521
    );
522
523
    let (control_tx, control_rx) = unbounded_channel::<Control>();
524
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
525
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
526
527
    let mut term = terminal();
528
    let mut app = CoderApp::new("openagents coder");
529
    let (keys_tx, keys_rx) = unbounded_channel();
530
531
    send_keys(&keys_tx, "read the repo");
532
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));
533
534
    // Once the first chunk is on the transcript, exit — with the second chunk
535
    // still held behind the gate on the server.
536
    let keys_for_exit = keys_tx.clone();
537
    tokio::spawn(async move {
538
        tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
539
        let _ = keys_for_exit.send(Event::Key(key(KeyCode::Esc)));
540
    });
541
542
    drive(
543
        &mut app,
544
        &mut term,
545
        keys_rx,
546
        control_tx,
547
        &mut turn_rx,
548
        turn_tx,
549
    )
550
    .await;
551
    let _ = gate_tx.send(());
552
553
    let frame = screen(&term);
554
    assert!(
555
        frame.contains("read the repo"),
556
        "the typed prompt is not on the frame:\n{frame}"
557
    );
558
    assert!(
559
        frame.contains("Reading the"),
560
        "the first streamed chunk never reached the transcript:\n{frame}"
561
    );
562
    assert!(
563
        !frame.contains("repository now."),
564
        "the held-back chunk arrived, so this run proves nothing about streaming:\n{frame}"
565
    );
566
    assert!(
567
        frame.contains("waiting for the reply"),
568
        "the turn was not still open when the frame was taken:\n{frame}"
569
    );
570
}
571
572
/// The same stack, allowed to finish, so the whole reply lands.
573
#[tokio::test]
574
async fn end_to_end_over_real_http_streams_a_whole_reply_onto_the_transcript() {
575
    let stub = support::start(vec!["Two files ", "changed today."], None).await;
576
577
    let session = CoderRuntimeSession::new(
578
        Lane::OxAlpha,
579
        Some(stub.base),
580
        Some("oat_test".to_string()),
581
        HarnessToolRegistry::new(Some(std::env::temp_dir())),
582
    );
583
584
    let (control_tx, control_rx) = unbounded_channel::<Control>();
585
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
586
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
587
588
    let mut term = terminal();
589
    let mut app = CoderApp::new("openagents coder");
590
    let (keys_tx, keys_rx) = unbounded_channel();
591
592
    send_keys(&keys_tx, "what changed");
593
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));
594
595
    let keys_for_exit = keys_tx.clone();
596
    tokio::spawn(async move {
597
        tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
598
        let _ = keys_for_exit.send(Event::Key(key(KeyCode::Esc)));
599
    });
600
601
    drive(
602
        &mut app,
603
        &mut term,
604
        keys_rx,
605
        control_tx,
606
        &mut turn_rx,
607
        turn_tx,
608
    )
609
    .await;
610
611
    let frame = screen(&term);
612
    assert!(frame.contains("what changed"), "{frame}");
613
    assert!(
614
        frame.contains("Two files changed today."),
615
        "the reply did not arrive whole:\n{frame}"
616
    );
617
    assert!(
618
        frame.contains("ready"),
619
        "the composer never came off hold:\n{frame}"
620
    );
621
}
622
623
/// A refused request reaches the reader as a failure, not as a finished turn.
624
///
625
/// Before this change `create_thread` answered a 401 by inventing a grant with
626
/// a placeholder token, and `execute_turn` answered the proxy's rejection by
627
/// streaming `Completed autonomous reasoning turn (offline fallback).` and
628
/// returning success. A session with no token therefore looked exactly like a
629
/// session that had worked.
630
#[tokio::test]
631
async fn a_refused_turn_says_so_on_the_transcript() {
632
    let stub = support::start_refusing().await;
633
634
    let session = CoderRuntimeSession::new(
635
        Lane::OxAlpha,
636
        Some(stub.base),
637
        None,
638
        HarnessToolRegistry::new(Some(std::env::temp_dir())),
639
    );
640
641
    let (control_tx, control_rx) = unbounded_channel::<Control>();
642
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
643
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
644
645
    let mut term = terminal_of(100, HEIGHT);
646
    let mut app = CoderApp::new("openagents coder");
647
    let (keys_tx, keys_rx) = unbounded_channel();
648
    send_keys(&keys_tx, "hello");
649
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));
650
651
    let keys_for_exit = keys_tx.clone();
652
    tokio::spawn(async move {
653
        tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
654
        let _ = keys_for_exit.send(Event::Key(key(KeyCode::Esc)));
655
    });
656
657
    drive(
658
        &mut app,
659
        &mut term,
660
        keys_rx,
661
        control_tx,
662
        &mut turn_rx,
663
        turn_tx,
664
    )
665
    .await;
666
667
    let frame = screen(&term);
668
    assert!(frame.contains("Turn failed"), "{frame}");
669
    assert!(frame.contains("401"), "{frame}");
670
    assert!(
671
        !frame.contains("offline fallback"),
672
        "a refused turn still reads as a completed one:\n{frame}"
673
    );
674
    assert!(
675
        frame.contains("ready"),
676
        "the composer stayed on hold:\n{frame}"
677
    );
678
}
crates/openagents-cli/tests/support/mod.rs added +137

@@ -0,0 +1,137 @@

1
//! A stand-in for the OpenAgents inference proxy, for tests that need a turn
2
//! to actually stream.
3
//!
4
//! It speaks the two routes `CoderRuntimeSession` calls — `POST /threads` for
5
//! the grant, and the proxy URL that grant points at for the completion — over
6
//! a real socket, with real server-sent events. Everything between the session
7
//! and the model is therefore the production code path; only the model is a
8
//! stand-in.
9
10
use std::sync::Arc;
11
use tokio::io::{AsyncReadExt, AsyncWriteExt};
12
13
/// A running stub. Its `base` goes to `CoderRuntimeSession::new`.
14
pub struct StubProxy {
15
    pub base: String,
16
}
17
18
/// Start a stub that streams `chunks` as one assistant message.
19
///
20
/// If `gate` is given, the stream pauses after its first chunk until that
21
/// receiver resolves, which is how a test observes a half-finished turn.
22
pub async fn start(
23
    chunks: Vec<&'static str>,
24
    gate: Option<tokio::sync::oneshot::Receiver<()>>,
25
) -> StubProxy {
26
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
27
    let port = listener.local_addr().unwrap().port();
28
    let base = format!("http://127.0.0.1:{port}/api/v1");
29
    let grant_url = Arc::new(format!("http://127.0.0.1:{port}/proxy"));
30
31
    tokio::spawn(async move {
32
        let mut gate = gate;
33
        loop {
34
            let Ok((mut socket, _)) = listener.accept().await else {
35
                return;
36
            };
37
            let request = match read_request(&mut socket).await {
38
                Some(request) => request,
39
                None => continue,
40
            };
41
42
            if request.starts_with("POST /api/v1/threads") {
43
                let body = format!(
44
                    r#"{{"thread":{{"id":"th_test"}},"grant":{{"token":"tok_test","url":"{grant_url}","model":"ox-alpha"}}}}"#
45
                );
46
                let response = format!(
47
                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
48
                    body.len()
49
                );
50
                let _ = socket.write_all(response.as_bytes()).await;
51
                let _ = socket.flush().await;
52
                continue;
53
            }
54
55
            if request.starts_with("POST /proxy") {
56
                let _ = socket
57
                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n")
58
                    .await;
59
                let _ = socket.flush().await;
60
                for (index, chunk) in chunks.iter().enumerate() {
61
                    let frame = format!(
62
                        "data: {}\n\n",
63
                        serde_json::json!({ "choices": [{ "delta": { "content": chunk } }] })
64
                    );
65
                    let _ = socket.write_all(frame.as_bytes()).await;
66
                    let _ = socket.flush().await;
67
                    if index == 0 {
68
                        if let Some(gate) = gate.take() {
69
                            let _ = gate.await;
70
                        }
71
                    }
72
                }
73
                let _ = socket.write_all(b"data: [DONE]\n\n").await;
74
                let _ = socket.flush().await;
75
                continue;
76
            }
77
78
            let _ = socket
79
                .write_all(
80
                    b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
81
                )
82
                .await;
83
        }
84
    });
85
86
    StubProxy { base }
87
}
88
89
/// Start a stub that refuses everything, the way the real host does without a
90
/// token.
91
pub async fn start_refusing() -> StubProxy {
92
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
93
    let port = listener.local_addr().unwrap().port();
94
    let base = format!("http://127.0.0.1:{port}/api/v1");
95
96
    tokio::spawn(async move {
97
        while let Ok((mut socket, _)) = listener.accept().await {
98
            let _ = read_request(&mut socket).await;
99
            let _ = socket
100
                .write_all(
101
                    b"HTTP/1.1 401 Unauthorized\r\ncontent-type: application/json\r\ncontent-length: 26\r\nconnection: close\r\n\r\n{\"error\":\"token required\"}",
102
                )
103
                .await;
104
            let _ = socket.flush().await;
105
        }
106
    });
107
108
    StubProxy { base }
109
}
110
111
/// Read one request, headers and declared body, and return it as text.
112
async fn read_request(socket: &mut tokio::net::TcpStream) -> Option<String> {
113
    let mut request = Vec::new();
114
    let mut buffer = [0u8; 4096];
115
    loop {
116
        let read = socket.read(&mut buffer).await.ok()?;
117
        if read == 0 {
118
            break;
119
        }
120
        request.extend_from_slice(&buffer[..read]);
121
        let text = String::from_utf8_lossy(&request);
122
        if let Some(headers_end) = text.find("\r\n\r\n") {
123
            let length = text
124
                .lines()
125
                .find_map(|line| {
126
                    line.strip_prefix("content-length: ")
127
                        .or_else(|| line.strip_prefix("Content-Length: "))
128
                })
129
                .and_then(|value| value.trim().parse::<usize>().ok())
130
                .unwrap_or(0);
131
            if request.len() >= headers_end + 4 + length {
132
                break;
133
            }
134
        }
135
    }
136
    Some(String::from_utf8_lossy(&request).to_string())
137
}

This page updates live while a promote is in flight · changelog