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. 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?, /** * What was attached to it, by the ref the files route serves: images, and any file, told * apart by [isImageRef]. * * On the message rather than beside it: these arrived as separate image events until * 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent * it, and left this app deciding from adjacency which message an image went with. */ val attachments: List, ) : 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 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. */ data class MessageQueued(val id: String, val text: String, val attachments: List) : SessionEvent() /** * A queued message taken back before the session read it. * * Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects * replays both, and without this one it would put back a bubble for a message that is never * coming. */ data class MessageDropped(val id: 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, /** * Where the turn this started begins, when the server could say. * * The live Claude Code path only learns a turn was somebody else's when the turn ends, so * the event arrives below everything it caused; this is what puts it back above it. Null * for a message read out of a session file, and for one that started no turn. */ val turnStart: Long? = null, ) : 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. */ 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. 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 how much the model was holding when it ended. * * [context] is prompt plus both cache figures. Carried on the event rather than summed by the * reader, because it is not a sum: a conversation's context drops at a compaction and a clear, * so adding turns up would report a figure the session stopped being true of. Null where the * dialect did not say, which leaves the context unmeasured rather than unchanged. */ data class UsageDelta(val tokens: Long, val context: 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 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 what it means is entirely its * position in the transcript. */ data object Cleared : SessionEvent() /** * The session stopped because its account's usage limit was reached. * * Its own event rather than an [Error] carrying the CLI's sentence, because it is a state * rather than something that went wrong -- and because the raw sentence is `Claude AI usage * limit reached|1788546972`, which is not readable by the person it is shown to. * * [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts * on it; what this draws it as is a time, not a countdown, because nothing here re-measures it. */ data class LimitReached(val resetsAt: Double?) : SessionEvent() data class Error(val message: String) : SessionEvent() /** * An event type this app build doesn't know -- a newer server. Kept rather than thrown so one * new event kind degrades to a placeholder row instead of killing the stream. */ data class Unknown(val type: String) : SessionEvent() } /** * A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since * the server omits the field rather than sending an empty list. */ private fun JSONObject.stringList(name: String): List { val array = optJSONArray(name) ?: return emptyList() return (0 until array.length()).map { array.getString(it) } } 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 }, body.stringList("attachments"), ) "messageQueued" -> SessionEvent.MessageQueued( body.getString("id"), body.getString("text"), body.stringList("attachments"), ) "messageDropped" -> SessionEvent.MessageDropped(body.getString("id")) "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"), if (body.has("turnStart")) body.getLong("turnStart") else null, ) "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"), if (body.has("context")) body.getLong("context") else null, ) "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 "limitReached" -> SessionEvent.LimitReached( if (body.has("resetsAt")) body.getDouble("resetsAt") else null ) "error" -> SessionEvent.Error(body.getString("message")) else -> SessionEvent.Unknown(type) } return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) } /** * Whether [state] is one the session is doing work in -- the states a turn is still open under. * * One predicate because two readers have to agree on the list: the session screen's working * indicator, and the fold's decision that the newest reply is finished. Two copies would drift the * first time the server grows a state, and the drift would be a reply that never splits or one * split mid-stream. */ fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" /** * The context after [event], given what it was before. * * The same rule the server folds with, because the screen has to keep up between page loads: the * summary it opened with is a measurement from before this stream started. * * The two that lower it are the point. A clear takes the conversation away and a compaction * replaces it with a summary, so a figure measured before either stopped being true at that moment * -- and carrying it forward is how a session that had just been cleared went on reporting the * context it no longer had. * * Null is "we don't know", which each of them can reach. */ fun contextAfter(current: Long?, event: SessionEvent): Long? = when (event) { // Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn // -- which every context figure is -- rather than unknown. is SessionEvent.UsageDelta -> event.context ?: current is SessionEvent.Compacted -> event.postTokens is SessionEvent.Cleared -> null else -> current }