Wrap table cells instead of cutting them off, and hand a batch over in one request
The renderer draws every table cell at one line with an ellipsis, so most
of a table was unreadable on a phone -- and an elided cell looks exactly
like a short one, so nothing said anything had been cut. Cells now take as
many lines as they need and align to the top of the row. Width is the other
half: a column narrows to 136dp and no further, and past that the table
scrolls sideways rather than squeezing. 136 is the widest floor that still
fits three columns across a phone, measured rather than picked; four and up
scroll, which is the right answer for genuinely too many columns.
The import screen used to send one request per selected row, so a handover
was only as atomic as the network: some rows started and the rest were
never asked for, and a row nobody asked for looks exactly like a row nobody
picked. `POST /setups/{id}/importable/delete` and `.../import` now take the
whole list, and every id is registered as in flight before the 202 goes
back. Only the registering is atomic -- the work settles per row, since six
deletes that all roll back together is not something a filesystem offers.
The echo driver grows `/table N`, with cells long enough to have been
truncated: a fixture of tidy one-word values renders fine whether or not
the bug is there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c159fa1e1
commit
778b2e3b04
7 files changed
+336
-93
No files matched your search
@@ -398,6 +398,14 @@ impl EchoDriver {
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||
// How many columns wide a fixture table should be, default six.
|
||||
// The count is the parameter because it is the thing the phone
|
||||
// has to react to: a narrow table lays itself out across the
|
||||
// screen and a wide one has to start scrolling sideways, and the
|
||||
// boundary between the two is where the layout is wrong.
|
||||
let table = text
|
||||
.strip_prefix("/table")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(6).clamp(1, 12));
|
||||
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
||||
});
|
||||
@@ -501,6 +509,14 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(columns) = table {
|
||||
send(Event::AssistantText {
|
||||
delta: markdown_table(columns),
|
||||
});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(beats) = mixed {
|
||||
for beat in 1..=beats {
|
||||
write_beat(&sink, &dir, beat).await;
|
||||
@@ -687,6 +703,68 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
/// mention it.
|
||||
type Held = (String, String, Vec<ImageRef>);
|
||||
|
||||
/// A markdown table [columns] wide, with cells too long for one line.
|
||||
///
|
||||
/// Both halves of that matter. Long cells are what the renderer used to cut
|
||||
/// off with an ellipsis, and a cut cell looks exactly like a short one, so
|
||||
/// a fixture of tidy one-word values would have rendered perfectly while
|
||||
/// the defect was still there. The column count is what decides whether
|
||||
/// the table fits the screen or has to scroll sideways.
|
||||
///
|
||||
/// Written out as markdown rather than assembled from a grid type because
|
||||
/// what is being tested is the renderer's parse of the syntax a model
|
||||
/// actually writes, pipes and alignment row included.
|
||||
fn markdown_table(columns: usize) -> String {
|
||||
let headings = [
|
||||
"What it is",
|
||||
"Where it lives",
|
||||
"What it costs",
|
||||
"Who asked for it",
|
||||
"When it changed",
|
||||
"Why it is here",
|
||||
"What replaces it",
|
||||
"What it breaks",
|
||||
"How it fails",
|
||||
"What to run",
|
||||
"Where to look",
|
||||
"What it assumes",
|
||||
];
|
||||
let values = [
|
||||
"a value long enough that a single line of a narrow column cannot hold all of it",
|
||||
"~/.config/ai-app/config.ron",
|
||||
"about four gigabytes resident, measured rather than estimated",
|
||||
"somebody who could not read the last one of these",
|
||||
"2026-08-31, in the same change as the wrapping",
|
||||
"because a cell that ends in an ellipsis says nothing about what it left out",
|
||||
"nothing yet",
|
||||
"the alignment of every row beside it",
|
||||
"silently, which is the expensive way",
|
||||
"cargo test -p ai-server",
|
||||
"server/src/session/echo.rs",
|
||||
"that the reader can scroll sideways",
|
||||
];
|
||||
let mut out = String::from("Here is what that produced:\n\n");
|
||||
let row = |cells: &mut dyn Iterator<Item = &str>| {
|
||||
let mut line = String::from("|");
|
||||
for cell in cells {
|
||||
line.push(' ');
|
||||
line.push_str(cell);
|
||||
line.push_str(" |");
|
||||
}
|
||||
line.push('\n');
|
||||
line
|
||||
};
|
||||
out.push_str(&row(&mut headings.iter().take(columns).copied()));
|
||||
out.push_str(&row(&mut std::iter::repeat_n("---", columns)));
|
||||
for offset in 0..4 {
|
||||
out.push_str(&row(
|
||||
&mut (0..columns).map(|column| values[(column + offset * 5) % values.len()])
|
||||
));
|
||||
}
|
||||
out.push_str("\nAnd that is the table.");
|
||||
out
|
||||
}
|
||||
|
||||
/// Ending a turn is also when anything held during it is taken up -- the
|
||||
/// moment a real CLI would have injected it. One place, because a turn has
|
||||
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
||||
|
||||
@@ -875,8 +875,6 @@ mod tests {
|
||||
assert!(!is_session_id(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 1x1 PNG, base64 -- the smallest thing with a real header.
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk\
|
||||
YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||
|
||||
Reference in new issue
Block a user