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,146 @@
|
||||
//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed
|
||||
//! transcript screen in a phone-shaped window, for looking at.
|
||||
//!
|
||||
//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture
|
||||
//!
|
||||
//! `--phone` sets the headless sway output to the phone's own 1080x2424
|
||||
//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's
|
||||
//! phone reports (`ai_app::ui::fixture::PHONE_SCALE`) rather than the
|
||||
//! desktop's 1.0 -- same screen, same fixture and the same folding as
|
||||
//! the Android bench and the headless tests, so what differs between a
|
||||
//! screenshot here and one from the phone is the renderer, never the
|
||||
//! data.
|
||||
//!
|
||||
//! `--message TEXT` (through `RUN_HEADLESS_ARGS`) starts with that text
|
||||
//! already in the composer, `\n` for a newline -- the composer's grown
|
||||
//! and overflowing states are otherwise unreachable here, since this
|
||||
//! window has no keyboard to type into (UI_RULES.md's "check the states
|
||||
//! you can't see by default"). `--typed TEXT` *enters* the same text
|
||||
//! instead, one character per 100ms: laying the composer out from
|
||||
//! scratch and growing one already on screen are different cases, and
|
||||
//! only the second reproduced the caret landing in the bar's padding
|
||||
//! (IRIS.md, 2026-09-08).
|
||||
//!
|
||||
//! No server: `transcript-fixture` embeds the transcript. Colour,
|
||||
//! spacing, type and anything a person has to *see* is answered here;
|
||||
//! anything with an assertion behind it belongs in `tests/
|
||||
//! phone_screen.rs` one layer down.
|
||||
|
||||
use iris::prelude::*;
|
||||
use winit::{dpi::PhysicalSize, window::WindowAttributes};
|
||||
|
||||
/// The `--ime PX` argument: the bottom inset a keyboard would report,
|
||||
/// applied after the first frame the way Android's `on_insets_changed`
|
||||
/// does. The composer's keyboard-open layout is otherwise unreachable
|
||||
/// here, and it is where its mask went wrong before (see
|
||||
/// `ActiveData::own_mask`).
|
||||
fn ime_argv() -> Option<f32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--ime" {
|
||||
return args.next()?.parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `--message TEXT` argument, with `\n` taken as a newline so a
|
||||
/// multi-line message survives one shell word.
|
||||
fn message_argv() -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--message" {
|
||||
return Some(args.next()?.replace("\\n", "\n"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `--typed TEXT` argument: the same text as `--message`, but
|
||||
/// *entered* rather than preloaded -- one insertion per 100ms, into a
|
||||
/// focused field, the way a person types. The two are different cases
|
||||
/// for layout: `--message` is laid out from scratch on the first frame,
|
||||
/// while this grows an already-drawn composer, which is the path
|
||||
/// Iris's 2026-09-08 phone report is about.
|
||||
fn typed_argv() -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--typed" {
|
||||
return Some(args.next()?.replace("\\n", "\n"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
DefaultApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DefaultUiState)]
|
||||
pub struct Client {
|
||||
ui_state: DefaultUiState,
|
||||
#[allow(dead_code)]
|
||||
screen: Option<ai_app::ui::TranscriptScreen>,
|
||||
}
|
||||
|
||||
impl DefaultAppState for Client {
|
||||
fn window_attributes() -> WindowAttributes {
|
||||
WindowAttributes::default()
|
||||
.with_title("iris transcript (bench fixture)")
|
||||
.with_inner_size(PhysicalSize::new(
|
||||
ai_app::ui::fixture::PHONE_WIDTH,
|
||||
ai_app::ui::fixture::PHONE_HEIGHT,
|
||||
))
|
||||
}
|
||||
|
||||
fn new(
|
||||
mut ui_state: DefaultUiState,
|
||||
rsc: &mut DefaultRsc<Self>,
|
||||
_: Proxy<Self::Event>,
|
||||
) -> Self {
|
||||
let screen = match ai_app::ui::fixture::open(rsc, &mut ui_state) {
|
||||
Ok(opened) => {
|
||||
if let Some(message) = message_argv() {
|
||||
opened.screen.composer.field.edit(rsc).set(&message);
|
||||
}
|
||||
if let Some(text) = typed_argv() {
|
||||
let field = opened.screen.composer.field;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
for ch in text.chars() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
ctx.update(move |state: &mut Client, rsc| {
|
||||
state.set_focus(Some(field));
|
||||
let end = rsc[field].text().len();
|
||||
let mut edit = field.edit(rsc);
|
||||
if edit.text.caret().is_none() {
|
||||
edit.set_cursor_byte(end);
|
||||
}
|
||||
edit.insert(&ch.to_string());
|
||||
});
|
||||
redraw.request_redraw();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(inset) = ime_argv() {
|
||||
opened.screen.composer.set_bottom_inset(rsc, inset);
|
||||
}
|
||||
Some(opened.screen)
|
||||
}
|
||||
// On screen rather than a panic: this window exists to be
|
||||
// looked at, and "the fixture stopped folding" is something
|
||||
// to read, not a process that vanished (UI_RULES.md).
|
||||
Err(message) => {
|
||||
let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
|
||||
.color(Color::WHITE)
|
||||
.wrap(true)
|
||||
.pad(dp(16))
|
||||
.add_strong(rsc)
|
||||
.any();
|
||||
ui_state.set_root(text);
|
||||
None
|
||||
}
|
||||
};
|
||||
Self { ui_state, screen }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! I5's desktop proof: the transcript screen built from synthetic
|
||||
//! `ai_app::client::transcript_fold` rows (no network, no server -- see
|
||||
//! `lib.rs`'s doc for why `transcript-ui` itself never fetches anything),
|
||||
//! run via `iris/run-headless.sh transcript -- -p transcript-ui` for a
|
||||
//! screenshot on the winit backend, or `cargo run --example transcript -p
|
||||
//! transcript-ui` with a real compositor.
|
||||
//!
|
||||
//! The rows exercise every one of the seven "hard to get back" behaviours
|
||||
//! this box's markdown/selection work is meant to show: a heading, bold,
|
||||
//! italic, an inline code span, a link, a fenced code block (rich inline
|
||||
//! text), a multi-message conversation (bottom-anchored virtualised list),
|
||||
//! and a three-call tool run (collapsed by default -- tap it, or drive it
|
||||
//! with `ui-trace record --do "tap 'Tools'"` on Android, to prove
|
||||
//! hold-the-edge expand).
|
||||
|
||||
use ai_app::client::QuestionOption;
|
||||
use ai_app::client::transcript_fold::{QuestionCard, TranscriptItem, TranscriptRow as FoldedRow};
|
||||
use iris::prelude::*;
|
||||
|
||||
fn main() {
|
||||
DefaultApp::<Client>::run();
|
||||
}
|
||||
|
||||
#[derive(DefaultUiState)]
|
||||
pub struct Client {
|
||||
ui_state: DefaultUiState,
|
||||
#[allow(dead_code)]
|
||||
screen: ai_app::ui::TranscriptScreen,
|
||||
}
|
||||
|
||||
fn msg(seq: u64, from_user: bool, text: &str) -> FoldedRow {
|
||||
FoldedRow::Single(if from_user {
|
||||
TranscriptItem::UserMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
TranscriptItem::AssistantMsg {
|
||||
seq,
|
||||
text: text.to_string(),
|
||||
settled: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One tool call. `result` is `None` for a call with no result yet and
|
||||
/// `Some((output, failed))` for one that answered.
|
||||
fn tool_call(id: &str, tool: &str, input: &str, result: Option<(&str, bool)>) -> TranscriptItem {
|
||||
tool_call_in("run1", id, tool, input, result)
|
||||
}
|
||||
|
||||
/// The same, in a named run. Two runs in one transcript must not share a
|
||||
/// `run_id`: it is the row's identity in the list (`row::row_key`), and
|
||||
/// two rows under one key is the duplicate-key fault AGENTS.md's
|
||||
/// "Importing" section describes. Here it made two rows swap cached
|
||||
/// heights and draw at each other's boxes.
|
||||
fn tool_call_in(
|
||||
run: &str,
|
||||
id: &str,
|
||||
tool: &str,
|
||||
input: &str,
|
||||
result: Option<(&str, bool)>,
|
||||
) -> TranscriptItem {
|
||||
TranscriptItem::ToolRun {
|
||||
seq: 3,
|
||||
id: id.into(),
|
||||
run_id: run.into(),
|
||||
tool: tool.into(),
|
||||
input: input.into(),
|
||||
output: result.map(|(out, _)| out.to_string()).unwrap_or_default(),
|
||||
done: result.is_some(),
|
||||
failed: result.is_some_and(|(_, failed)| failed),
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A call stopped on the reader: one unanswered permission question.
|
||||
fn asking(id: &str, tool: &str, input: &str) -> TranscriptItem {
|
||||
let mut call = tool_call_in("run2", id, tool, input, None);
|
||||
if let TranscriptItem::ToolRun { asks, .. } = &mut call {
|
||||
asks.push(QuestionCard {
|
||||
seq: 9,
|
||||
id: format!("{id}-q"),
|
||||
prompt: "Allow this command?".into(),
|
||||
header: None,
|
||||
options: vec![
|
||||
QuestionOption {
|
||||
label: "Allow".into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Deny".into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
},
|
||||
],
|
||||
multi_select: false,
|
||||
answers: Vec::new(),
|
||||
});
|
||||
}
|
||||
call
|
||||
}
|
||||
|
||||
/// Longer than the card's own cap, so the "Show all N lines" control is on
|
||||
/// screen in the expanded shot.
|
||||
fn long_output() -> String {
|
||||
(0..200)
|
||||
.map(|i| format!("test ai_app::ui::case_{i} ... ok"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn synthetic_rows() -> Vec<FoldedRow> {
|
||||
vec and a fenced block:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```",
|
||||
),
|
||||
// Every state a tool card has to draw, in one run (P1b): a call
|
||||
// that worked, one the tool reported as failed, one whose result
|
||||
// never arrived, and one still running. The last two look the same
|
||||
// in the events -- an empty output and `done: false` -- and are
|
||||
// told apart only by whether the session is still working, which
|
||||
// is what `TranscriptScreen::set_session_working` says.
|
||||
FoldedRow::Tools(vec![
|
||||
tool_call(
|
||||
"t1",
|
||||
"Read",
|
||||
r#"{"file_path": "src/main.rs"}"#,
|
||||
Some(("fn main() {}\n", false)),
|
||||
),
|
||||
tool_call(
|
||||
"t2",
|
||||
"Bash",
|
||||
r#"{"command": "cargo build --release", "timeout": 480000, "description": "Build it"}"#,
|
||||
Some((
|
||||
"error: could not compile `iris`\nCaused by: linker not found",
|
||||
true,
|
||||
)),
|
||||
),
|
||||
tool_call("t3", "Grep", r#"{"pattern": "fn fold_event"}"#, None),
|
||||
]),
|
||||
// A lone call is a card too rather than a group of one -- and this
|
||||
// one carries the kilobyte output a collapsed card must not lay
|
||||
// out.
|
||||
FoldedRow::Single(tool_call(
|
||||
"t5",
|
||||
"Bash",
|
||||
r#"{"command": "cargo test -p transcript-ui -- --nocapture"}"#,
|
||||
Some((&long_output(), false)),
|
||||
)),
|
||||
msg(6, true, "Looks good, thanks!"),
|
||||
// Every block kind `ai_app::client::markdown_blocks` names, in one
|
||||
// row, so P1a's appearance can be looked at against the Compose
|
||||
// app's without a server (docs/RUST.md's P1a box). The heading,
|
||||
// paragraph, fence and table are the *same source* the bench
|
||||
// fixture carries (`app/bench-fixture/generate.py`), so the two
|
||||
// screenshots differ only in the renderer; the list and the quote
|
||||
// are extra, because the fixture has neither.
|
||||
msg(7, false, BLOCK_SAMPLER),
|
||||
]
|
||||
}
|
||||
|
||||
/// One of each markdown block, for the P1a screenshot pair. See
|
||||
/// [`synthetic_rows`].
|
||||
const BLOCK_SAMPLER: &str = "\
|
||||
## What changed
|
||||
|
||||
Iris **fold** render measure session window anchor context transcript \
|
||||
iris measure iris scroll call transcript layout *cursor* context, and a \
|
||||
[bench](https://example.com/bench) link.
|
||||
|
||||
```rust
|
||||
fn fold_event(items: Vec<Item>, seq: u64) -> Vec<Item> {
|
||||
// a comment worth keeping: this is the fold the app's own screen runs
|
||||
let mut out = items;
|
||||
out.push(Item::new(seq));
|
||||
out
|
||||
}
|
||||
```
|
||||
|
||||
| column | value |
|
||||
|---|---|
|
||||
| a | measure place draw tool call token context window anchor |
|
||||
|
||||
- one bullet
|
||||
- another, with `inline code`
|
||||
- nested one level
|
||||
1. first numbered
|
||||
2. second numbered
|
||||
|
||||
> A quoted line, to show the bar and the indent.
|
||||
";
|
||||
|
||||
impl DefaultAppState for Client {
|
||||
fn new(
|
||||
mut ui_state: DefaultUiState,
|
||||
rsc: &mut DefaultRsc<Self>,
|
||||
_: Proxy<Self::Event>,
|
||||
) -> Self {
|
||||
let screen = ai_app::ui::build(rsc, &mut ui_state, synthetic_rows());
|
||||
// Exercises `push_row`/`ItemKey` beyond construction time, matching
|
||||
// how a live SSE loop appends -- a row arriving after the screen
|
||||
// already exists must land at the bottom without disturbing what's
|
||||
// above it (I3's `push_back`/`snap_end`).
|
||||
screen.push_row(
|
||||
rsc,
|
||||
&FoldedRow::Single(TranscriptItem::CommandRow {
|
||||
seq: 8,
|
||||
text: "clear".into(),
|
||||
}),
|
||||
);
|
||||
// A second run at the live end, so the *running* state is on
|
||||
// screen too. It cannot share a row with "no result": the two are
|
||||
// the same events and are told apart only by whether the session
|
||||
// is working, which is a property of the row rather than of the
|
||||
// call (`TranscriptScreen::set_session_working`).
|
||||
screen.push_row(
|
||||
rsc,
|
||||
&FoldedRow::Tools(vec![
|
||||
tool_call_in(
|
||||
"run2",
|
||||
"t6",
|
||||
"Read",
|
||||
r#"{"file_path": "docs/RUST.md"}"#,
|
||||
Some(("# Moving the app to Rust\n", false)),
|
||||
),
|
||||
tool_call_in(
|
||||
"run2",
|
||||
"t7",
|
||||
"Bash",
|
||||
r#"{"command": "cargo clippy --workspace --all-targets"}"#,
|
||||
Some(("error: unused variable `x`", true)),
|
||||
),
|
||||
tool_call_in("run2", "t8", "Glob", r#"{"pattern": "**/*.rs"}"#, None),
|
||||
// Waiting on a permission, so this card is drawn *open*
|
||||
// whatever the reader last chose -- the command is the
|
||||
// thing being decided, and a row saying only "Bash"
|
||||
// cannot be decided on. It is also how the expanded card
|
||||
// (input block, output block, timeout) gets into the
|
||||
// screenshot without a finger.
|
||||
asking(
|
||||
"t9",
|
||||
"Bash",
|
||||
r#"{"command": "rm -rf target", "timeout": 120000, "description": "Clear the build"}"#,
|
||||
),
|
||||
]),
|
||||
);
|
||||
screen.set_session_working(rsc, true);
|
||||
// The expanded picture has no other way to be looked at on a
|
||||
// machine with no display and no finger -- see `run-headless.sh`
|
||||
// and docs/RUST.md's P1b box.
|
||||
if std::env::var_os("IRIS_TOOLS_EXPANDED").is_some() {
|
||||
assert!(
|
||||
screen.expand_tail_tools(rsc, true),
|
||||
"the newest row must be the tool run this flag is about"
|
||||
);
|
||||
}
|
||||
Self { ui_state, screen }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user