diff --git a/AGENTS.md b/AGENTS.md index 81ca558..e9c8a95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,13 +207,22 @@ first if a remote spawn ever mangles an argument. and says which operation in a word** -- `BusyItem`, used by both the session list and the import list so the appearance is learned once. The word rather than a bare spinner because "deleting" and "importing" differ - in kind, and the inertness is the overlay consuming pointer events rather - than each caller remembering to disable its own click handler. -- **Importing and deleting run on the server, not in the request.** `DELETE - /setups/{id}/importable/{session}` and `POST - /setups/{id}/importable/{session}/import` both answer 202 and do the work - in a spawned task, because the phone that asked is free to leave and used - to cancel its own batch by doing so. What replaces the reply is + in kind. It dims and desaturates but does **not** make the row inert: the + caller disables its own click handler while it passes a label. An overlay + consuming pointer events was tried and swallowed the drag along with the + tap, so a list could not be scrolled while anything in it was busy. +- **Importing and deleting run on the server, not in the request, and a + batch is handed over in one call.** `POST + /setups/{id}/importable/delete` and `POST /setups/{id}/importable/import` + each take a list of session ids, answer 202, and do the work in spawned + tasks -- because the phone that asked is free to leave and used to cancel + its own batch by doing so. A list rather than a route per session because + one request per row made a handover 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. Every id is registered as in + flight before the 202 goes back. Only the *registering* is atomic; the + work itself settles per row, since six deletes that all roll back + together is not something a filesystem offers. What replaces the reply is `session::pending`: every row of the listing carries `pending` and `error`, and `GET /setups/{id}/importable/events` streams the changes. **Both, not either.** The stream is a broadcast with no memory, so an @@ -246,6 +255,20 @@ first if a remote spawn ever mangles an argument. be there to import again" is exactly the one the switch makes false. The server deletes the machine's copy *first*, so a machine it cannot reach leaves the session where it was instead of half-deleted. +- **A markdown table wraps its cells and never cuts one off.** The + renderer's own defaults draw every cell at one line with an ellipsis, + which on a phone loses most of a table -- and an elided cell looks + exactly like a short one, so nothing on screen says anything was cut. + `Markdown.kt` supplies its own header and row blocks with `maxLines = + Int.MAX_VALUE` and `TextOverflow.Clip`, cells aligned to the top of the + row so a two-line cell does not re-centre its neighbours. Width is the + other half: a column narrows to 136dp and no further, and past that the + whole table scrolls sideways rather than squeezing -- 136 because it is + the widest floor that still fits three columns across a phone, which is + the commonest table there is. Exercise it with the echo driver's + `/table N` (default six columns), which writes long cells on purpose: + a fixture of tidy one-word values renders fine whether or not the + truncation is fixed. - **Android Lint is not optional and is not run by a build.** It found a crash that had been shipping: `java.time` on a minSdk-24 app with desugaring off — and later a permission check that silently dropped every diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 65d5287..8d21262 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -629,47 +629,55 @@ fun startSession(settings: ServerSettings, sessionId: String) { } /** - * Removes a Claude Code session from the machine. + * Asks the machine to delete Claude Code sessions, and returns as soon as it has accepted the lot. * - * The transcript *is* the session, so this ends any chance of resuming that conversation. The + * The transcript *is* the session, so this ends any chance of resuming those conversations. The * caller confirms first; see ImportScreen. - */ -/** - * Asks the machine to delete a Claude Code session, and returns as soon as it has accepted. * * The work runs on the server, so this returning is not the same as it being done -- what says that - * is the row's own state, through [fetchImportable] and the change stream. That is the point: + * is each row's own state, through [fetchImportable] and the change stream. That is the point: * leaving the screen used to cancel the delete it had started. + * + * One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per + * row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were + * missed looked exactly like two that had not been picked. */ -fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) { - requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} +fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List) { + requestFromServer( + settings, + "/setups/$setup/importable/delete", + method = "POST", + jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(), + ) {} } /** - * Continues a Claude Code session in the background, returning once the server has accepted. + * Continues Claude Code sessions in the background, returning once the server has accepted them. * * Separate from [spawnSession] because the two are asked different questions. That one means "start * this and take me to it", so it waits and answers with the session. This is the import list's * batch: several at once, nobody waiting on any particular one, and the result arrives as a row - * changing rather than as a reply -- which is what lets the screen be left. + * changing rather than as a reply -- which is what lets the screen be left. One request for all of + * them, for the reason [deleteImportable] gives. */ fun startImport( settings: ServerSettings, setup: String, - sessionId: String, + sessionIds: List, provider: String, permissionMode: String? = null, model: String? = null, ) { val body = JSONObject().apply { + put("sessions", JSONArray(sessionIds)) put("provider", provider) permissionMode?.let { put("permissionMode", it) } model?.let { put("model", it) } } requestFromServer( settings, - "/setups/$setup/importable/$sessionId/import", + "/setups/$setup/importable/import", method = "POST", jsonBody = body.toString(), ) {} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index 7e50612..af9297a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -39,8 +39,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -167,12 +165,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } /** - * Hands [targets] to the server, marking each row as it goes. + * Hands [targets] to the server in one request, marking every row it covers. * - * The requests only *start* the work now -- the server runs it and says how it went on the + * The request only *starts* the work -- the server runs it and says how each row went on the * change stream, which is what lets this screen be left while a batch is still going. So there - * is nothing here to wait for and nothing to sequence: each row is marked, its request goes, - * and everything after that arrives as an event. + * is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and + * everything after that arrives as an event. * * Marked [WAITING] rather than with the operation's own word until the server confirms. Between * the request leaving and the `started` event coming back, "we have asked" is the truth and "it @@ -181,30 +179,29 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * The selection is dropped as the work is handed over, not when it finishes: the screen goes * back to how it started, and what says the work is happening is the rows it is happening to. */ - fun handOver(targets: List, send: suspend (Importable) -> Unit) { + fun handOver(targets: List, send: suspend (List) -> Unit) { selected = emptySet() running = running + targets.associate { it.id to WAITING } rowErrors = rowErrors - targets.map { it.id }.toSet() val setup = chosen + val ids = targets.map { it.id } scope.launch { - // All at once rather than a loop that awaits each: the handover is what has to - // survive leaving the screen, so it should take one round trip rather than one per - // row. What each request starts is already safe once the server has it. - targets - .map { target -> - async { - try { - withContext(Dispatchers.IO) { send(target) } - } catch (err: Exception) { - // The server never took it, so nothing is running and no event will - // arrive to say so. This is the one failure the screen must report - // itself. - running = running - target.id - rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't ask")) - } - } - } - .awaitAll() + // One request for the whole batch, not one per row. Sent row by row, a handover was + // only as atomic as the network: the fourth of six could fail, or the screen could be + // left with two still unsent, and what came back was some rows running and some + // untouched -- indistinguishable, on the list, from rows nobody had picked. Now + // either the server has the batch or it has none of it, and this is the one place + // that can be true. + try { + withContext(Dispatchers.IO) { send(ids) } + } catch (err: Exception) { + // The server never took it, so nothing is running and no event will arrive to say + // so. This is the one failure the screen must report itself -- and it is now the + // whole batch's failure, which is the point: no row was singled out. + running = running - ids.toSet() + rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" } + return@launch + } // Then ask what actually happened, if anything still looks outstanding. // @@ -240,11 +237,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio fun importAll(targets: List) { val setup = chosen ?: return val useProvider = provider ?: return - handOver(targets) { session -> + handOver(targets) { ids -> startImport( settings, setup = setup.id, - sessionId = session.id, + sessionIds = ids, provider = useProvider.name, permissionMode = permissionMode, ) @@ -464,9 +461,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio onClick = { val setup = chosen ?: return@TextButton confirming = null - handOver(targets) { session -> - deleteImportable(settings, setup.id, session.id) - } + handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) } } ) { // Coloured by consequence: this takes something away, wherever it appears. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 57a3b16..3efe3a4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -6,19 +6,31 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.compose.components.markdownComponents +import com.mikepenz.markdown.compose.elements.MarkdownTable +import com.mikepenz.markdown.compose.elements.MarkdownTableHeader +import com.mikepenz.markdown.compose.elements.MarkdownTableRow import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.elements.MarkdownCheckBox import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.markdownDimens import com.mikepenz.markdown.model.parseMarkdown import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.intellij.markdown.ast.ASTNode /** * An assistant's reply, rendered as the markdown it is written in. @@ -107,10 +119,100 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi .toSpanStyle() ), ), + dimens = + markdownDimens( + // Half the renderer's 16dp. Padding is charged on both sides of every cell, so at + // the default a fifth of the narrowest column went on space rather than on words + // -- and the narrowest column is where the wrapping below has the least room. + tableCellPadding = 8.dp, + // What a column narrows to before the table starts scrolling sideways instead. It + // is the floor, not the width: a table with room to spare spreads across it. + // + // Down from the renderer's 160dp, and the number is a measurement rather than a + // taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp + // makes even a three-column table -- the commonest shape there is -- scroll, while + // 136dp fits three across the phone this app is read on. Four and up still scroll, + // which is the right answer for genuinely too many columns: squeezing six columns + // into a phone would give every cell one word per line. + // + // Narrower would fit more, and stop being readable. This is the widest minimum + // that keeps three columns on screen, which is the trade the number is making. + tableCellWidth = 136.dp, + ), + components = + markdownComponents( + // The m3 renderer's own default, restored: supplying `components` at all replaces + // the whole set, and this is the only member of it the Material layer overrides. + checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, + table = { + MarkdownTable( + it.content, + it.node, + style = it.typography.table, + headerBlock = ::WrappingTableHeader, + rowBlock = ::WrappingTableRow, + ) + }, + ), modifier = modifier, ) } +/** + * A table header, and a table row, whose cells wrap rather than being cut off. + * + * The renderer draws every cell at `maxLines = 1` with an ellipsis, which on a phone means most of + * a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty + * characters ends in "..." with no way to see the rest. Nothing about the value says it was cut, + * either -- an elided cell looks like a short one, so a table of measurements reads as a table of + * plausible shorter measurements. + * + * So: as many lines as the cell needs, and [TextOverflow.Clip] rather than an ellipsis, which now + * never has anything to hide since the height grows to fit. Cells align to the top of the row, + * because a two-line cell beside a one-line one centred the short one against the middle of the + * tall one and lost the line the reader was reading across. + * + * What the wrapping does *not* do is make a wide table fit. The renderer already gives each column + * a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, which is + * the right answer for too many columns -- wrapping a six-column table into the width of a phone + * would give every cell one word per line. The two work together: the width is what the columns + * need, and the wrapping is what fills the space that width provides. + * + * Two functions rather than one because the renderer's header and row are separate composables -- + * the header is bold and sizes itself to its tallest cell -- and the parameters that matter here + * are the same three in both. + */ +@Composable +private fun WrappingTableHeader( + content: String, + header: ASTNode, + tableWidth: Dp, + style: TextStyle, +) { + MarkdownTableHeader( + content = content, + header = header, + tableWidth = tableWidth, + style = style, + verticalAlignment = Alignment.Top, + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Clip, + ) +} + +@Composable +private fun WrappingTableRow(content: String, row: ASTNode, tableWidth: Dp, style: TextStyle) { + MarkdownTableRow( + content = content, + header = row, + tableWidth = tableWidth, + style = style, + verticalAlignment = Alignment.Top, + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Clip, + ) +} + /** * [text] parsed: on the composing thread the first time this row is drawn, and off it every time * afterwards. diff --git a/server/src/routes.rs b/server/src/routes.rs index 9144ae4..5205e30 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -62,7 +62,7 @@ use axum::extract::{Path as UrlPath, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{delete, get, post}; +use axum::routing::{get, post}; use serde::{Deserialize, Serialize}; use tokio::sync::{broadcast, mpsc}; use tokio_stream::StreamExt; @@ -78,16 +78,11 @@ pub fn router(manager: Arc) -> Router { .route("/setups", get(list_setups).post(add_setup)) .route("/setups/probe", post(probe_setup)) .route("/setups/{id}/importable", get(list_importable)) - .route( - "/setups/{id}/importable/{session}", - delete(delete_importable), - ) - .route( - "/setups/{id}/importable/{session}/import", - post(start_import), - ) - // Static segment, so this wins over `{session}` above rather than - // being read as a session called "events". + // A batch at a time, never a session at a time -- see + // [`delete_importable`]. There is no `{session}` route to collide + // with, so all three of these are plain static segments. + .route("/setups/{id}/importable/delete", post(delete_importable)) + .route("/setups/{id}/importable/import", post(start_import)) .route("/setups/{id}/importable/events", get(importable_events)) .route( "/setups/{id}", @@ -527,26 +522,56 @@ struct ImportableRow { error: Option, } -/// Removes a Claude Code session from a machine. +/// Removes Claude Code sessions from a machine. /// /// The transcript *is* the session, so this ends any chance of resuming -/// that conversation -- including from an ai-app session already importing -/// it. The phone confirms before calling this; the server does not -/// second-guess a decision somebody was shown the cost of. +/// those conversations -- including from an ai-app session already +/// importing one. The phone confirms before calling this; the server does +/// not second-guess a decision somebody was shown the cost of. +/// +/// A batch and never a single session, which is the whole reason this is a +/// POST with a body rather than a `DELETE` on each id. The phone used to +/// send one request per row, and a handover was then only as atomic as the +/// network was reliable: leave the screen, lose signal, or have the fourth +/// of six requests fail, and some rows are being deleted while the rest are +/// untouched, with nothing anywhere that knows the difference. Here every +/// id is registered as in flight before the 202 goes back, so the answer to +/// "did my batch start" is one answer for the batch. +/// +/// Registering is what has to be atomic; the work itself does not. Each id +/// runs on its own task and settles on its own event, because six deletes +/// that must all succeed or all roll back is not something a filesystem +/// offers, and pretending otherwise would mean holding five sessions +/// hostage to the one that failed. async fn delete_importable( State(manager): State>, - UrlPath((id, session)): UrlPath<(String, String)>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, ) -> Result { let setup = setup_by_id(&manager, &id)?; - let transport = crate::session::transport::Transport::for_setup(&setup); - let target = session.clone(); - in_background(&manager, id, session, Operation::Deleting, async move { - crate::session::import::delete(&transport, &target).await - }); + for session in body.sessions { + let transport = crate::session::transport::Transport::for_setup(&setup); + let target = session.clone(); + in_background( + &manager, + id.clone(), + session, + Operation::Deleting, + async move { crate::session::import::delete(&transport, &target).await }, + ); + } Ok(StatusCode::ACCEPTED) } -/// Continues a Claude Code session, in the background. +/// Which sessions to delete. See [`delete_importable`] for why it is a list. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct DeleteBatch { + sessions: Vec, +} + +/// Continues Claude Code sessions, in the background. /// /// Separate from `POST /sessions` because the two are asked different /// questions. That one means "start this and take me to it", so it waits @@ -554,33 +579,44 @@ async fn delete_importable( /// several at once, nobody waiting on any particular one, and the answer /// arrives as a row changing rather than as a reply -- which is the whole /// point, since the screen it was started from may well be gone by then. +/// +/// A list for the same reason [`delete_importable`] takes one: the batch is +/// handed over in a single request, so it cannot half-arrive. async fn start_import( State(manager): State>, - UrlPath((id, session)): UrlPath<(String, String)>, + UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { // Checked before accepting, so an unknown machine is still an error the // caller sees rather than a failure it has to go and read off a row. setup_by_id(&manager, &id)?; - let request = SpawnRequest { - setup: id.clone(), - provider: body.provider, - // Nothing to say: `spawn` titles an import from the session it - // continues, and the cwd comes from the same place. - title: None, - model: body.model, - cwd: None, - permission_mode: body.permission_mode, - params: std::collections::BTreeMap::new(), - import: Some(session.clone()), - }; - let inner = Arc::clone(&manager); - in_background(&manager, id, session, Operation::Importing, async move { - spawn(&inner, request) - .await - .map(|_| ()) - .map_err(|err| anyhow::anyhow!("{err}")) - }); + for session in body.sessions { + let request = SpawnRequest { + setup: id.clone(), + provider: body.provider.clone(), + // Nothing to say: `spawn` titles an import from the session it + // continues, and the cwd comes from the same place. + title: None, + model: body.model.clone(), + cwd: None, + permission_mode: body.permission_mode.clone(), + params: std::collections::BTreeMap::new(), + import: Some(session.clone()), + }; + let inner = Arc::clone(&manager); + in_background( + &manager, + id.clone(), + session, + Operation::Importing, + async move { + spawn(&inner, request) + .await + .map(|_| ()) + .map_err(|err| anyhow::anyhow!("{err}")) + }, + ); + } Ok(StatusCode::ACCEPTED) } @@ -588,6 +624,9 @@ async fn start_import( #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] struct ImportRequest { + /// The sessions to continue, all with the settings below -- they were + /// picked together on one screen, so there is nothing to say per row. + sessions: Vec, provider: String, #[serde(default)] model: Option, diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 5d84371..2f5beeb 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -398,6 +398,14 @@ impl EchoDriver { let mixed = text .strip_prefix("/mixed") .map(|rest| rest.trim().parse::().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::().unwrap_or(6).clamp(1, 12)); let linger = text.strip_prefix("/slow").map(|rest| { Duration::from_secs(rest.trim().parse::().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); +/// 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| { + 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 diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 18ffb6c..bf85892 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -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==";