iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9a6562dc6
commit
6d5a231f5c
100 files changed
+924
-3295
No files matched your search
@@ -0,0 +1,134 @@
|
||||
//! How much of a long thing a transcript draws before offering the rest
|
||||
//! behind a tap.
|
||||
//!
|
||||
//! One rule, four surfaces: a tool call's input, its output, and a user or
|
||||
//! assistant message. It lives here rather than at any one of them because
|
||||
//! four copies would eventually disagree about what "too long" is, and
|
||||
//! because the Compose app has to answer the same question the same way --
|
||||
//! `TextCap.kt` is the Kotlin half, and the two are checked against the
|
||||
//! same numbers so a benchmark comparing the apps is comparing renderers
|
||||
//! rather than policies.
|
||||
//!
|
||||
//! **Lines and bytes both, whichever runs out first**, because they run
|
||||
//! out on different things: a diff is thousands of short lines, a minified
|
||||
//! file or a base64 blob is one enormous one, and a cap that only counted
|
||||
//! one of them draws the whole of the other.
|
||||
//!
|
||||
//! **Cut at the head, keeping the beginning.** A tool's output is read
|
||||
//! from the top and the line saying what went wrong is nearly always the
|
||||
//! first; a message is read from the top for the obvious reason. (A path
|
||||
//! is identified by its other end -- none of these is a path.)
|
||||
|
||||
/// The default bound on a verbatim block -- a tool call's input or its
|
||||
/// output. Short, because this text is a machine's and the reader is
|
||||
/// looking for one line of it.
|
||||
pub const VERBATIM_LINES: usize = 80;
|
||||
pub const VERBATIM_BYTES: usize = 4096;
|
||||
|
||||
/// The bound on a message, a person's or the model's. Larger than a
|
||||
/// verbatim block's in bytes and smaller in lines: prose is read whole and
|
||||
/// wraps, so a screenful of it is far fewer lines than a screenful of a
|
||||
/// log, and cutting a reply at 80 lines would cut most long answers that
|
||||
/// nobody would call long.
|
||||
pub const MESSAGE_LINES: usize = 200;
|
||||
pub const MESSAGE_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// A cap of nothing would draw an empty panel and a "Show all" for
|
||||
/// everything there is, which reads as a rendering fault rather than as a
|
||||
/// cap. Checked at compile time, since all four are constants.
|
||||
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
|
||||
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
|
||||
|
||||
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
|
||||
/// count it was cut *from*; `None` when the whole of it fits.
|
||||
///
|
||||
/// The count is the whole text's, not the shown part's -- it is what the
|
||||
/// "Show all N lines" offer says, and a reader deciding whether to ask for
|
||||
/// the rest wants to know how much the rest is.
|
||||
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
|
||||
debug_assert!(
|
||||
max_lines > 0 && max_bytes > 0,
|
||||
"a cap of nothing shows an empty block and a 'Show all' for every value there is",
|
||||
);
|
||||
let by_lines = text
|
||||
.char_indices()
|
||||
.filter(|(_, c)| *c == '\n')
|
||||
.nth(max_lines - 1)
|
||||
.map(|(i, _)| i);
|
||||
let by_bytes = (text.len() > max_bytes).then(|| {
|
||||
let mut end = max_bytes;
|
||||
// Back up to a character boundary: a cut inside a multi-byte
|
||||
// character panics on the slice below, and a transcript is full of
|
||||
// them.
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
end
|
||||
});
|
||||
let cut = match (by_lines, by_bytes) {
|
||||
(Some(a), Some(b)) => a.min(b),
|
||||
(a, b) => a.or(b)?,
|
||||
};
|
||||
Some((&text[..cut], text.lines().count()))
|
||||
}
|
||||
|
||||
/// What a "Show all" offer says, so the wording is one string rather than
|
||||
/// one per surface.
|
||||
pub fn show_all_label(lines: usize) -> String {
|
||||
format!("Show all {lines} lines")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_under_both_bounds_is_not_cut() {
|
||||
assert_eq!(cut("one\ntwo\nthree", 80, 4096), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_bound_cuts_at_a_line_boundary() {
|
||||
let text = "a\nb\nc\nd\n";
|
||||
let (shown, lines) = cut(text, 2, 4096).expect("four lines is over a bound of two");
|
||||
assert_eq!(shown, "a\nb");
|
||||
assert_eq!(
|
||||
lines, 4,
|
||||
"the count is the whole text's, not the shown part's"
|
||||
);
|
||||
}
|
||||
|
||||
/// The half the line bound cannot catch: one enormous line, which is
|
||||
/// what a minified file or an embedded image arrives as.
|
||||
#[test]
|
||||
fn the_byte_bound_cuts_one_long_line() {
|
||||
let text = "x".repeat(5000);
|
||||
let (shown, lines) = cut(&text, 80, 4096).expect("5000 bytes is over a bound of 4096");
|
||||
assert_eq!(shown.len(), 4096);
|
||||
assert_eq!(lines, 1);
|
||||
}
|
||||
|
||||
/// Whichever bites first, rather than whichever was checked first.
|
||||
#[test]
|
||||
fn the_tighter_of_the_two_bounds_wins() {
|
||||
let text = "aaaa\n".repeat(100);
|
||||
let (shown, _) = cut(&text, 80, 100).expect("over both");
|
||||
assert_eq!(shown.len(), 100, "the byte bound is the tighter one here");
|
||||
let (shown, _) = cut(&text, 4, 4096).expect("over the line bound");
|
||||
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
|
||||
}
|
||||
|
||||
/// A cut that lands inside a multi-byte character has to back up to
|
||||
/// the boundary; slicing there would panic, and a transcript carries
|
||||
/// em dashes and box drawing in every other line.
|
||||
#[test]
|
||||
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
|
||||
let text = "é".repeat(100);
|
||||
let (shown, _) = cut(&text, 80, 11).expect("200 bytes is over a bound of 11");
|
||||
assert_eq!(
|
||||
shown,
|
||||
"é".repeat(5),
|
||||
"11 bytes lands mid-character; 10 is the cut"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user