package com.example.aiapp import org.json.JSONObject // The common event model, mirrored from server/src/session/driver.rs -- // the app renders purely from this stream (replayed from the transcript by // cursor, then live), so there is no separate "load history" shape to keep // in sync with it. /** One transcript line: the event plus its resume cursor and time. */ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) /** * One choice offered in answer to a question. * * More than a label because the reader is deciding rather than confirming: what an option means, * and what picking it would produce, are the things that decide it. Both are absent on a * permission, whose Allow and Deny mean exactly what they say. */ data class QuestionOption(val label: String, val description: String?, val preview: String?) sealed class SessionEvent { data class UserMessage( val text: String, /** * The [MessageQueued] this resolves, or null when it never waited. * * Matched on rather than the text, because the same message sent twice is two waiting * bubbles and clearing whichever one matched first would leave the wrong one on screen. */ val id: String?, ) : SessionEvent() /** * A message the server has accepted and the session has not read yet. * * From the server, not from this app's memory of what it sent. The pending bubble used to be * screen state, so leaving the session or restarting the app drew nothing waiting while the * message was still queued -- and nothing waiting is what "there is nothing" looks like. * * Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by * [CommandSent]. */ data class MessageQueued(val id: String, val text: String) : SessionEvent() data class AssistantText(val delta: String) : SessionEvent() data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() data class ToolUpdate(val id: String, val output: String) : SessionEvent() data class ToolEnd(val id: String, val output: String) : SessionEvent() data class Image( val ref: String, /** The tool call whose result carried it, or null for a person's own attachment. */ val about: String?, ) : SessionEvent() data class Question( val id: String, val prompt: String, /** A few words naming what the question is about, when the asker offered one. */ val header: String?, val options: List, /** Whether several options may be chosen at once. */ val multiSelect: Boolean, /** The tool call this is permission for, or null when it is not about one. */ val about: String?, ) : SessionEvent() /** Everything chosen for one question, in the order it was offered. */ data class Answered(val id: String, val answers: List) : SessionEvent() /** * A message another agent sent this session. * * Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would * claim they had. It is also the explanation for a session that starts working on something * this device never asked for. */ data class PeerMessage(val from: String, val text: String) : SessionEvent() /** * A command the session was asked to run on itself and cannot run yet. * * Resolved by [CommandSent] with the same id. A command that ran straight away has only that * one, so nothing here ever draws a bubble that resolves in the same frame. */ data class CommandQueued(val id: String, val text: String) : SessionEvent() /** The same command, handed to the session. */ data class CommandSent(val id: String, val text: String) : SessionEvent() data class Status(val state: String) : SessionEvent() /** * What the session is set to, as the session itself reports it. * * Either field alone: the two are confirmed separately and by different things. Asking for a * change is not having one, so this -- not the request -- is what the pickers show. */ data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() /** * What a turn cost, and what the session has cost in total. * * [total] is the server's running figure, carried on the event so a reader never adds up its * own: a phone opens a session on the newest page of the transcript, so a sum it computed would * be that page's share of the conversation wearing the whole conversation's label. Zero on * entries recorded before the backend sent it. */ data class UsageDelta(val tokens: Long, val total: Long) : SessionEvent() /** * A compaction that finished, and how much context it recovered. * * The counts are nullable because the server sends them only when it was told them: a * compaction whose size nobody measured has to be able to say so, since a zero here would read * as "recovered nothing" and a made-up number would read as a measurement. */ data class Compacted( val preTokens: Long?, val postTokens: Long?, /** What asked for it, in the CLI's own word; `auto` is the one worth naming. */ val trigger: String?, ) : SessionEvent() /** * The conversation was cleared. Everything above this is still here to read and is no longer in * the session's context. * * An object rather than a class because it carries nothing: what it means is entirely its * position in the transcript. */ data object Cleared : SessionEvent() data class Error(val message: String) : SessionEvent() /** * An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new * event kind degrades to a placeholder row instead of killing the stream. */ data class Unknown(val type: String) : SessionEvent() } fun parseSeqEvent(json: String): SeqEvent { val body = JSONObject(json) val event = when (val type = body.getString("type")) { "userMessage" -> SessionEvent.UserMessage( body.getString("text"), body.optString("id").ifEmpty { null }, ) "messageQueued" -> SessionEvent.MessageQueued(body.getString("id"), body.getString("text")) "assistantText" -> SessionEvent.AssistantText(body.getString("delta")) "toolStart" -> SessionEvent.ToolStart( id = body.getString("id"), tool = body.getString("tool"), // Kept as raw JSON text: the input shape is the tool's own // business, and the UI only ever shows it verbatim. input = body.get("input").toString(), ) "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) "toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output")) "image" -> SessionEvent.Image( ref = body.getString("ref"), about = body.optString("about").ifEmpty { null }, ) "question" -> SessionEvent.Question( id = body.getString("id"), prompt = body.getString("prompt"), header = body.optString("header").ifEmpty { null }, options = body.getJSONArray("options").let { options -> (0 until options.length()).map { at -> val option = options.getJSONObject(at) QuestionOption( label = option.getString("label"), description = option.optString("description").ifEmpty { null }, preview = option.optString("preview").ifEmpty { null }, ) } }, multiSelect = body.optBoolean("multiSelect", false), about = body.optString("about").ifEmpty { null }, ) "answered" -> SessionEvent.Answered( body.getString("id"), body.getJSONArray("answers").let { answers -> (0 until answers.length()).map { answers.getString(it) } }, ) "peerMessage" -> SessionEvent.PeerMessage(body.getString("from"), body.getString("text")) "commandQueued" -> SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) "status" -> SessionEvent.Status(body.getString("state")) "settings" -> SessionEvent.Settings( model = body.optString("model").ifEmpty { null }, permissionMode = body.optString("permissionMode").ifEmpty { null }, ) "usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"), body.optLong("total", 0)) "compacted" -> SessionEvent.Compacted( preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null, postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null, trigger = body.optString("trigger").ifEmpty { null }, ) "cleared" -> SessionEvent.Cleared "error" -> SessionEvent.Error(body.getString("message")) else -> SessionEvent.Unknown(type) } return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) }