Runtime footer: app-controlled model/context/cwd/latency/cost under replies

Hermes can append a text "runtime footer" (model, context %, workdir,
latency, cost) to final replies, but only when display.runtime_footer is
enabled in the hermes config. We want the same info but controlled by the
APP, not the gateway config. So the gateway now ALWAYS sends the data as a
structured `runtime` object on final assistant messages, and the app decides
whether/what to show.

Gateway (gateway-plugin/):
- protocol.py: new runtime_footer() helper + `runtime` field on the
  message / message.stop frames. Keys (all optional, absent when the data
  is unavailable — e.g. no cost for local models): model (vendor prefix
  dropped), context_pct (0-100), cwd (home-relative), latency (seconds),
  cost (USD).
- adapter.py: a post_api_request plugin hook captures the turn's model +
  prompt tokens + start time (platform-filtered to android so other
  platforms don't pollute the buffer). _build_runtime_footer() resolves the
  model's context window (cached, best-effort, off the event loop via
  asyncio.to_thread with a timeout) and computes context_pct. The runtime
  object is attached on every final send (streaming message.stop and
  non-streaming message, plus the fallback paths).
- outbox.py: `runtime` preserved in history reconstruction so the footer
  survives a restart / first open.

App (app/shared/):
- Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload /
  MessageStopPayload / HistoryMessage.
- ChatStore.kt: `runtime` on MessageItem, wired through live + history
  reconciliation.
- SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled +
  runtimeFooterFields (persisted per device).
- IrisController.kt: StateFlows + toggleRuntimeFooter() /
  toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser.
- SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip
  menu (Model · Context % · Workdir · Latency · Cost) to pick fields.
- ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer
  left, time right, Telegram-style), only for final non-streaming assistant
  answers; Inspector pane now shows the runtime fields too.

Docs: 04-wire-protocol.md + frames.schema.json document the `runtime`
object.

Verified end-to-end on device: final replies carry
`qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on
the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.
This commit is contained in:
ARIA committed 2026-08-21 19:32:05 +02:00
1 parent 678c0344c8
commit 9286937e2d
13 files changed
+979 -290

No files matched your search

@@ -10,6 +10,7 @@ import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.RuntimeMeta
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE
@@ -45,10 +46,10 @@ sealed interface ChatItem {
/** Delivery status of a user message (M5: read.receipt; M7: failed sends). */
enum class MsgStatus {
Pending, // optimistic, not yet acknowledged by the gateway
Sent, // gateway accepted it (echo received)
Read, // agent received and started processing it (read.receipt)
Failed, // send failed (error frame); tap the bubble to retry
Pending, // optimistic, not yet acknowledged by the gateway
Sent, // gateway accepted it (echo received)
Read, // agent received and started processing it (read.receipt)
Failed, // send failed (error frame); tap the bubble to retry
}
/** A chat message (user / assistant / commentary / streaming bubble).
@@ -66,6 +67,7 @@ data class MessageItem(
val streaming: Boolean = false,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val media: List<MediaItem> = emptyList(),
val isSystem: Boolean = false,
) : ChatItem
@@ -119,16 +121,17 @@ class ChatStore {
companion object {
const val DEFAULT_LANE = "android:default"
fun randomId(prefix: String): String =
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
}
// ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
fun laneKey(chatId: String, threadId: String?): String =
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
fun laneKey(
chatId: String,
threadId: String?,
): String = if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
/** Split a lane key back into (chatId, threadId). */
fun parseLane(key: String): Pair<String, String?> {
@@ -145,7 +148,10 @@ class ChatStore {
return laneKey(chatId, frame.threadId)
}
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) {
private fun updateLane(
lane: String,
transform: (List<ChatItem>) -> List<ChatItem>,
) {
val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty())
_lanes.value = map
@@ -154,7 +160,11 @@ class ChatStore {
// ── Optimistic send ───────────────────────────────────────────────────
/** Optimistic add: show the user's message immediately (pending) in [lane]. */
fun addPending(text: String, lane: String, media: List<MediaItem> = emptyList()): String {
fun addPending(
text: String,
lane: String,
media: List<MediaItem> = emptyList(),
): String {
localSeq++
val id = "local_$localSeq"
updateLane(lane) {
@@ -166,7 +176,10 @@ class ChatStore {
/** Append a locally generated, centered system notice (gateway restart /
* online) to [lane]. Not a user or agent turn: it is not selectable,
* deletable, or echoed to the server. */
fun addSystemMessage(lane: String, text: String) {
fun addSystemMessage(
lane: String,
text: String,
) {
localSeq++
val id = "sys_$localSeq"
updateLane(lane) {
@@ -195,7 +208,10 @@ class ChatStore {
// ── message (final / standalone, incl. non-streaming + user echo) ─────
private fun onMessage(lane: String, frame: Frame) {
private fun onMessage(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessagePayload>() ?: return
// Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane.
@@ -205,99 +221,143 @@ class ChatStore {
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
val cur = list[byId] as? MessageItem ?: return@updateLane list
val updated = cur.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
streaming = false,
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
model = p.model,
tokens = p.tokens,
ts = p.ts ?: cur.ts,
media = mergeMedia(cur.media, p.media),
)
val updated =
cur.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
streaming = false,
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
model = p.model,
tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
ts = p.ts ?: cur.ts,
media = mergeMedia(cur.media, p.media),
)
list.toMutableList().also { it[byId] = updated }
} else if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
val pendingIdx =
list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (pendingIdx >= 0) {
list.toMutableList().also {
it[pendingIdx] = MessageItem(
id = p.messageId, role = p.role, text = p.text,
ts = p.ts ?: 0, reasoning = p.reasoning,
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
)
it[pendingIdx] =
MessageItem(
id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
)
}
} else {
list + MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
media = p.media.map { it.toMediaItem() },
)
list +
MessageItem(
id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
media = p.media.map { it.toMediaItem() },
)
}
} else {
list + MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
media = p.media.map { it.toMediaItem() },
)
list +
MessageItem(
id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
media = p.media.map { it.toMediaItem() },
)
}
}
}
/**
* Remove the matching optimistic user bubble from the parent flat lane of
* [lane] (an auto-created thread). The gateway moved the message into the
* fresh thread, so the pending bubble must follow it; the echo then replaces
* it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane.
*/
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
* Remove the matching optimistic user bubble from the parent flat lane of
* [lane] (an auto-created thread). The gateway moved the message into the
* fresh thread, so the pending bubble must follow it; the echo then replaces
* it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane.
*/
private fun relocatePendingToThreadLane(
lane: String,
p: MessagePayload,
) {
val (chatId, threadId) = parseLane(lane)
if (threadId == null) return
val flatLane = chatId
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx = flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
val idx =
flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
/** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
private fun mergeMedia(
existing: List<MediaItem>,
incoming: List<MediaRef>,
): List<MediaItem> {
if (incoming.isEmpty()) return existing
val byId = existing.associateBy { it.mediaId }
return incoming.map { ref ->
byId[ref.mediaId]?.copy(
kind = ref.kind, mime = ref.mime, size = ref.size, filename = ref.filename,
kind = ref.kind,
mime = ref.mime,
size = ref.size,
filename = ref.filename,
) ?: ref.toMediaItem()
}
}
private fun MediaRef.toMediaItem() =
MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
private fun MediaRef.toMediaItem() = MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(lane: String, frame: Frame) {
private fun onMessageStart(
lane: String,
frame: Frame,
) {
if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
else list + MessageItem(
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true,
)
if (list.any { it.id == p.messageId }) {
list
} else {
list +
MessageItem(
id = p.messageId,
role = p.role,
text = "",
ts = 0,
streaming = true,
)
}
}
}
// ── message.update (replace live bubble text; full snapshot) ──────────
private fun onMessageUpdate(lane: String, frame: Frame) {
private fun onMessageUpdate(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId }
@@ -309,28 +369,39 @@ class ChatStore {
// ── message.stop (finalize the live bubble) ───────────────────────────
private fun onMessageStop(lane: String, frame: Frame) {
private fun onMessageStop(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessageStopPayload>() ?: return
updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one.
list + MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
ts = p.ts ?: 0, reasoning = p.reasoning,
model = p.model, tokens = p.tokens,
)
} else {
val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also {
it[idx] = cur.copy(
list +
MessageItem(
id = p.messageId,
role = ROLE_ASSISTANT,
text = p.finalText,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
streaming = false,
ts = p.ts ?: cur.ts,
runtime = p.runtime,
)
} else {
val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also {
it[idx] =
cur.copy(
text = p.finalText,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
streaming = false,
ts = p.ts ?: cur.ts,
)
}
}
}
@@ -338,21 +409,31 @@ class ChatStore {
// ── tool.start (new tool card) ────────────────────────────────────────
private fun onToolStart(lane: String, frame: Frame) {
private fun onToolStart(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolStartPayload>() ?: return
toolSeq++
val id = "tool_$toolSeq"
updateLane(lane) { list ->
list + ToolItem(
id = id, index = p.index, name = p.name,
preview = p.preview, args = p.args,
)
list +
ToolItem(
id = id,
index = p.index,
name = p.name,
preview = p.preview,
args = p.args,
)
}
}
// ── tool.progress (in-progress note) ──────────────────────────────────
private fun onToolProgress(lane: String, frame: Frame) {
private fun onToolProgress(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolProgressPayload>() ?: return
updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
@@ -364,24 +445,33 @@ class ChatStore {
// ── tool.end (mark the tool card complete) ────────────────────────────
private fun onToolEnd(lane: String, frame: Frame) {
private fun onToolEnd(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolEndPayload>() ?: return
updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return@updateLane list
val cur = list[idx] as ToolItem
list.toMutableList().also {
it[idx] = cur.copy(
done = true, ok = p.ok, duration = p.duration,
outputPreview = p.outputPreview,
)
it[idx] =
cur.copy(
done = true,
ok = p.ok,
duration = p.duration,
outputPreview = p.outputPreview,
)
}
}
}
// ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary(lane: String, frame: Frame) {
private fun onCommentary(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are rendered
// as a centered system notice by the controller, on the down/up state
@@ -392,11 +482,18 @@ class ChatStore {
// and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
else list + MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
ts = 0, isCommentary = true,
)
if (list.any { it.id == p.messageId }) {
list
} else {
list +
MessageItem(
id = p.messageId,
role = ROLE_ASSISTANT,
text = p.text,
ts = 0,
isCommentary = true,
)
}
}
}
@@ -415,20 +512,28 @@ class ChatStore {
* falling back to the lane's last assistant message). The item has no local
* path yet — the controller pulls it and calls [setMediaLocalPath].
*/
private fun onMediaOffer(lane: String, frame: Frame) {
private fun onMediaOffer(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MediaOfferPayload>() ?: return
updateLane(lane) { list ->
// Already attached (offer replayed)? Skip.
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
val item = MediaItem(
mediaId = p.mediaId, kind = p.kind, mime = p.mime,
size = p.size, filename = p.filename,
)
val targetIdx = if (p.messageId != null) {
list.indexOfFirst { it.id == p.messageId }
} else {
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
}
val item =
MediaItem(
mediaId = p.mediaId,
kind = p.kind,
mime = p.mime,
size = p.size,
filename = p.filename,
)
val targetIdx =
if (p.messageId != null) {
list.indexOfFirst { it.id == p.messageId }
} else {
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
}
if (targetIdx < 0) return@updateLane list
val cur = list[targetIdx] as? MessageItem ?: return@updateLane list
list.toMutableList().also { it[targetIdx] = cur.copy(media = cur.media + item) }
@@ -436,17 +541,26 @@ class ChatStore {
}
/** Record a pulled file's local path for [mediaId] (all lanes). */
fun setMediaLocalPath(mediaId: String, localPath: String) {
fun setMediaLocalPath(
mediaId: String,
localPath: String,
) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem) {
item.copy(media = item.media.map { m ->
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
})
} else item
}
val updated =
list.map { item ->
if (item is MessageItem) {
item.copy(
media =
item.media.map { m ->
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
},
)
} else {
item
}
}
if (updated != list) {
map[lane] = updated
changed = true
@@ -460,13 +574,16 @@ class ChatStore {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read
) {
item.copy(pending = false, status = MsgStatus.Read)
} else item
}
val updated =
list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read
) {
item.copy(pending = false, status = MsgStatus.Read)
} else {
item
}
}
if (updated != list) {
map[lane] = updated
changed = true
@@ -476,11 +593,11 @@ class ChatStore {
}
/**
* Remove messages by id from every lane (a `message.deleted` frame). The
* server is authoritative: the frame carries no lane, and a message id is
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
* id is absent, e.g. a local-only id another device never had).
*/
* Remove messages by id from every lane (a `message.deleted` frame). The
* server is authoritative: the frame carries no lane, and a message id is
* unique, so scanning all lanes is both safe and idempotent (a no-op when the
* id is absent, e.g. a local-only id another device never had).
*/
fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap()
@@ -506,12 +623,13 @@ class ChatStore {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
val updated =
list.map { item ->
when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
}
}
}
if (updated != list) {
map[lane] = updated
changed = true
@@ -525,11 +643,14 @@ class ChatStore {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed)
} else item
}
val updated =
list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed)
} else {
item
}
}
if (updated != list) {
map[lane] = updated
changed = true
@@ -539,12 +660,17 @@ class ChatStore {
}
/** M7: re-arm a failed user message for a retry send. */
fun rearmForRetry(lane: String, messageId: String) {
fun rearmForRetry(
lane: String,
messageId: String,
) {
updateLane(lane) { list ->
list.map { item ->
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
item.copy(pending = true, status = MsgStatus.Pending)
} else item
} else {
item
}
}
}
}
@@ -558,12 +684,16 @@ class ChatStore {
* the in-memory store is empty and the `sync` delta does not cover older
* messages.
*/
fun loadHistory(lane: String, messages: List<MessageItem>) {
fun loadHistory(
lane: String,
messages: List<MessageItem>,
) {
updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet()
val preserved = list.filter { item ->
item !is MessageItem || item.id !in historyIds
}
val preserved =
list.filter { item ->
item !is MessageItem || item.id !in historyIds
}
(messages + preserved).sortedBy { item ->
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
}
@@ -573,4 +703,4 @@ class ChatStore {
fun clear() {
_lanes.value = emptyMap()
}
}
}
@@ -66,6 +66,15 @@ interface SecureStore {
* system font scale). */
var fontSizeScale: Float
/** UI setting: show the runtime-metadata footer under final assistant
* messages (model / context % / cwd / latency / cost). */
var runtimeFooterEnabled: Boolean
/** UI setting: which runtime-footer fields to show, as a comma-separated
* list in display order (e.g. "model,context_pct,cwd"). Empty = the
* default set. Valid keys: model, context_pct, cwd, latency, cost. */
var runtimeFooterFields: String
fun savePairing(
url: String,
token: String,
@@ -20,11 +20,12 @@ import kotlinx.serialization.json.put
const val PROTOCOL_VERSION = 1
object IrisJson {
val instance: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
}
val instance: Json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
}
}
// ── Frame type constants ────────────────────────────────────────────────
@@ -198,6 +199,22 @@ data class HelloAckPayload(
// ── message (server -> app) ─────────────────────────────────────────────
/**
* Structured runtime metadata for the app's footer (mirror of the gateway's
* `runtime` object). The gateway ALWAYS sends this on final assistant
* messages; whether/what is shown is a per-app setting (Settings → Runtime
* footer), NOT a hermes config. Fields are absent when the data is
* unavailable (local models have no cost, etc.).
*/
@Serializable
data class RuntimeMeta(
val model: String? = null,
@SerialName("context_pct") val contextPct: Int? = null,
val cwd: String? = null,
val latency: Double? = null,
val cost: Double? = null,
)
@Serializable
data class MessagePayload(
@SerialName("message_id") val messageId: String,
@@ -207,6 +224,7 @@ data class MessagePayload(
@SerialName("reply_to") val replyTo: String? = null,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null,
val media: List<MediaRef> = emptyList(),
)
@@ -260,7 +278,9 @@ data class MediaPullPayload(
)
@Serializable
data class MediaPullEndPayload(val ok: Boolean)
data class MediaPullEndPayload(
val ok: Boolean,
)
// ── M2: streaming frames (server -> app) ────────────────────────────────
@@ -283,6 +303,7 @@ data class MessageStopPayload(
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null,
)
@@ -335,13 +356,20 @@ data class MessageSendPayload(
// ── typing / error / ping ───────────────────────────────────────────────
@Serializable
data class TypingPayload(val on: Boolean)
data class TypingPayload(
val on: Boolean,
)
@Serializable
data class ErrorPayload(val code: String, val message: String)
data class ErrorPayload(
val code: String,
val message: String,
)
@Serializable
data class PingPayload(val ts: Long? = null)
data class PingPayload(
val ts: Long? = null,
)
// ── M3: channel directory (app -> server requests) ──────────────────────
@@ -353,13 +381,19 @@ data class ChannelCreatePayload(
)
@Serializable
data class ChannelRenamePayload(val name: String)
data class ChannelRenamePayload(
val name: String,
)
@Serializable
data class ChannelFavoritePayload(val on: Boolean)
data class ChannelFavoritePayload(
val on: Boolean,
)
@Serializable
data class ChannelSetAutomationPayload(val on: Boolean)
data class ChannelSetAutomationPayload(
val on: Boolean,
)
@Serializable
data class ChannelIconPayload(
@@ -370,10 +404,14 @@ data class ChannelIconPayload(
)
@Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
data class ChannelListPayload(
val channels: List<ChannelInfo> = emptyList(),
)
@Serializable
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
data class ChannelDeletedPayload(
@SerialName("chat_id") val chatId: String,
)
// ── M3: search ──────────────────────────────────────────────────────────
@@ -424,10 +462,14 @@ data class CommandsCatalogPayload(
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
@Serializable
data class SyncPayload(val cursor: Long)
data class SyncPayload(
val cursor: Long,
)
@Serializable
data class SyncDonePayload(val cursor: Long)
data class SyncDonePayload(
val cursor: Long,
)
// ── history (full message history for a chat/thread) ────────────────────
@@ -440,6 +482,7 @@ data class HistoryMessage(
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null,
val media: List<MediaRef> = emptyList(),
)
@@ -496,7 +539,9 @@ data class ReadReceiptPayload(
)
@Serializable
data class StatusPayload(val state: String)
data class StatusPayload(
val state: String,
)
// ── Frame builders ──────────────────────────────────────────────────────
@@ -509,16 +554,17 @@ fun helloFrame(
): Frame =
Frame(
type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
payload =
IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
),
),
),
)
fun messageSendFrame(
@@ -534,79 +580,109 @@ fun messageSendFrame(
type = TYPE_MESSAGE_SEND,
chatId = chatId,
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
),
payload =
IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
),
)
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame =
fun channelCreateFrame(
id: Int,
name: String,
kind: String = "channel",
parentChatId: String? = null,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_CREATE,
payload = IrisJson.instance.encodeToJsonElement(
ChannelCreatePayload.serializer(),
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
),
payload =
IrisJson.instance.encodeToJsonElement(
ChannelCreatePayload.serializer(),
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
),
)
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
fun channelRenameFrame(
id: Int,
chatId: String,
name: String,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_RENAME,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelRenamePayload.serializer(),
ChannelRenamePayload(name = name),
),
payload =
IrisJson.instance.encodeToJsonElement(
ChannelRenamePayload.serializer(),
ChannelRenamePayload(name = name),
),
)
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelSetDefaultFrame(
id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
fun channelFavoriteFrame(
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_FAVORITE,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on),
),
payload =
IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on),
),
)
fun channelSetAutomationFrame(id: Int, chatId: String, on: Boolean): Frame =
fun channelSetAutomationFrame(
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_SET_AUTOMATION,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on),
),
payload =
IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on),
),
)
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
fun channelIconFrame(
id: Int,
chatId: String,
icon: String?,
color: String?,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_ICON,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color),
),
payload =
IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color),
),
)
fun channelDeleteFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelDeleteFrame(
id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelListFrame(id: Int): Frame =
Frame(id = id, type = TYPE_CHANNEL_LIST)
fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
fun searchFrame(
id: Int,
@@ -620,25 +696,29 @@ fun searchFrame(
type = TYPE_SEARCH,
chatId = chatId,
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
SearchPayload.serializer(),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
),
payload =
IrisJson.instance.encodeToJsonElement(
SearchPayload.serializer(),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
),
)
/** Request the gateway's slash-command catalog (the composer's "/" drawer).
* Answered by a `commands.catalog` frame carrying the same id. */
fun commandsCatalogFrame(id: Int): Frame =
Frame(id = id, type = TYPE_COMMANDS_CATALOG)
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
fun syncFrame(id: Int, cursor: Long): Frame =
fun syncFrame(
id: Int,
cursor: Long,
): Frame =
Frame(
id = id,
type = TYPE_SYNC,
payload = IrisJson.instance.encodeToJsonElement(
SyncPayload.serializer(),
SyncPayload(cursor = cursor),
),
payload =
IrisJson.instance.encodeToJsonElement(
SyncPayload.serializer(),
SyncPayload(cursor = cursor),
),
)
/** Request a page of full message history for a chat/thread (initial open /
@@ -656,10 +736,11 @@ fun historyFrame(
type = TYPE_HISTORY,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit)
},
payload =
buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit)
},
)
/** Request deletion of the given message(s) in a chat/thread. The server
@@ -675,9 +756,10 @@ fun messageDeleteFrame(
type = TYPE_MESSAGE_DELETE,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
},
payload =
buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
},
)
// ── M4 frame builders ────────────────────────────────────────────────────
@@ -693,39 +775,53 @@ fun mediaUploadStartFrame(
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_START,
payload = IrisJson.instance.encodeToJsonElement(
MediaUploadStartPayload.serializer(),
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
),
payload =
IrisJson.instance.encodeToJsonElement(
MediaUploadStartPayload.serializer(),
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
),
)
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame =
fun mediaUploadEndFrame(
id: Int,
mediaRef: String,
sha256: String,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_END,
payload = IrisJson.instance.encodeToJsonElement(
MediaUploadEndPayload.serializer(),
MediaUploadEndPayload(mediaRef, sha256),
),
payload =
IrisJson.instance.encodeToJsonElement(
MediaUploadEndPayload.serializer(),
MediaUploadEndPayload(mediaRef, sha256),
),
)
fun mediaPullFrame(id: Int, mediaId: String): Frame =
fun mediaPullFrame(
id: Int,
mediaId: String,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_PULL,
payload = IrisJson.instance.encodeToJsonElement(
MediaPullPayload.serializer(),
MediaPullPayload(mediaId),
),
payload =
IrisJson.instance.encodeToJsonElement(
MediaPullPayload.serializer(),
MediaPullPayload(mediaId),
),
)
// ── M5 frame builders ───────────────────────────────────────────────────
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
fun fcmRegisterFrame(
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame(
type = TYPE_FCM_REGISTER,
payload = IrisJson.instance.encodeToJsonElement(
FcmRegisterPayload.serializer(),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
),
)
payload =
IrisJson.instance.encodeToJsonElement(
FcmRegisterPayload.serializer(),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
),
)
@@ -146,8 +146,7 @@ class IrisController(
/** True while [frame] is a sync replay that already reached the device
* via push (live frames carry no cursor and are never suppressed). */
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean =
frame.cursor?.let { it <= lastPushedCursor } ?: false
private fun isPushedReplay(frame: iris.protocol.Frame): Boolean = frame.cursor?.let { it <= lastPushedCursor } ?: false
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
// Persisted (Settings → "Threads").
@@ -183,6 +182,48 @@ class IrisController(
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
}
// ── Runtime-metadata footer (Settings → "Runtime footer") ───────────────────────────────────────────────
// The gateway ALWAYS sends the structured runtime metadata on final
// assistant messages; these settings control whether/what the app shows
// (per-device display preference, like tool verbosity). [runtimeFooterFields]
// is a comma-separated list of field keys in display order.
private val _runtimeFooterEnabled = MutableStateFlow(store.runtimeFooterEnabled)
val runtimeFooterEnabled: StateFlow<Boolean> = _runtimeFooterEnabled.asStateFlow()
private val _runtimeFooterFields = MutableStateFlow(parseRuntimeFields(store.runtimeFooterFields))
val runtimeFooterFields: StateFlow<List<String>> = _runtimeFooterFields.asStateFlow()
fun toggleRuntimeFooter() {
_runtimeFooterEnabled.value = !_runtimeFooterEnabled.value
store.runtimeFooterEnabled = _runtimeFooterEnabled.value
}
/** Toggle a single runtime-footer field on/off (preserving order). */
fun toggleRuntimeField(field: String) {
val current = _runtimeFooterFields.value
val updated = if (field in current) current - field else current + field
_runtimeFooterFields.value = updated
store.runtimeFooterFields = updated.joinToString(",")
}
companion object {
const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f
/** Valid runtime-footer field keys (mirror of the gateway's RUNTIME_FIELDS). */
val RUNTIME_FIELD_KEYS = listOf("model", "context_pct", "cwd", "latency", "cost")
/** Default footer fields (in display order) when the user has none set. */
val RUNTIME_FIELDS_DEFAULT = listOf("model", "context_pct", "cwd")
/** Parse a persisted comma-separated field list; unknown keys dropped,
* order preserved, empty falls back to the default set. */
fun parseRuntimeFields(raw: String): List<String> {
val parsed = raw.split(",").map { it.trim() }.filter { it in RUNTIME_FIELD_KEYS }
return if (parsed.isEmpty()) RUNTIME_FIELDS_DEFAULT else parsed
}
}
// ── Appearance (Settings → Appearance) ────────────────────────────────
// User-customizable bubble colors + background (color or image).
// Persisted as ARGB ints + an image path (see UserTheme).
@@ -252,11 +293,6 @@ class IrisController(
store.fontSizeScale = clamped
}
companion object {
const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f
}
// ── M3: search state ──────────────────────────────────────────────────
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
@@ -915,6 +951,7 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
reasoning = reasoning,
model = model,
tokens = tokens,
runtime = runtime,
media =
media.map {
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
@@ -141,6 +141,7 @@ import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* Chat screen (M3): channel drawer + topic switcher + search + settings,
@@ -157,6 +158,8 @@ fun ChatScreen(controller: IrisController) {
val toolDetail by controller.toolDetail.collectAsState()
val threadsEnabled by controller.threadsEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
@@ -506,6 +509,8 @@ fun ChatScreen(controller: IrisController) {
}
}
},
runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields,
)
}
}
@@ -1776,8 +1781,11 @@ private fun InspectorPane(
InfoRow("Threads", threads.size.toString())
Spacer(modifier = Modifier.height(8.dp))
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
InfoRow("Model", lastAssistant?.model ?: "—")
InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—")
InfoRow("Model", lastAssistant?.runtime?.model ?: "—")
InfoRow("Context", lastAssistant?.runtime?.contextPct?.let { "$it%" } ?: "—")
InfoRow("Workdir", lastAssistant?.runtime?.cwd ?: "—")
InfoRow("Latency", lastAssistant?.runtime?.latency?.let { formatLatency(it) } ?: "—")
InfoRow("Cost", lastAssistant?.runtime?.cost?.let { formatCost(it) } ?: "—")
}
}
@@ -2211,6 +2219,8 @@ private fun MessageBubble(
selected: Boolean = false,
onToggleSelect: () -> Unit = {},
onLongPress: () -> Unit = {},
runtimeFooterEnabled: Boolean = false,
runtimeFooterFields: List<String> = emptyList(),
) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
@@ -2345,28 +2355,34 @@ private fun MessageBubble(
}
}
if (!isUser) {
// Model / token footer (final assistant answers only).
if (!isCommentary && !msg.streaming &&
(msg.model != null || msg.tokens != null)
) {
// Runtime-metadata footer + timestamp on ONE line (footer left,
// time right) — Telegram-style. The app controls whether/what
// the footer shows via Settings → Runtime footer.
val footerText =
if (!isCommentary && !msg.streaming && runtimeFooterEnabled) {
buildRuntimeFooterText(msg, runtimeFooterFields)
} else {
""
}
if (footerText.isNotEmpty() || time.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
buildString {
msg.model?.let { append(it) }
if (msg.model != null && msg.tokens != null) append(" · ")
msg.tokens?.let { append("$it tok") }
},
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
)
}
// M7: timestamp below the footer (reference shows "12:41").
if (time.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
if (footerText.isNotEmpty()) {
Text(
footerText,
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
modifier = Modifier.weight(1f),
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (time.isNotEmpty()) {
Text(time, fontSize = 10.sp, color = textColor.copy(alpha = 0.5f))
}
}
}
}
@@ -2378,6 +2394,39 @@ private fun MessageBubble(
}
}
/** Build the runtime-footer text from the message's runtime metadata, showing
* only the enabled [fields] (in order) that have data. Empty when none. */
private fun buildRuntimeFooterText(
msg: MessageItem,
fields: List<String>,
): String {
val runtime = msg.runtime ?: return ""
val parts = mutableListOf<String>()
for (field in fields) {
when (field) {
"model" -> runtime.model?.let { parts.add(it) }
"context_pct" -> runtime.contextPct?.let { parts.add("$it%") }
"cwd" -> runtime.cwd?.let { parts.add(it) }
"latency" -> runtime.latency?.let { parts.add(formatLatency(it)) }
"cost" -> runtime.cost?.let { parts.add(formatCost(it)) }
}
}
return parts.joinToString(" · ")
}
/** Humanize a turn duration: `<1s`, `22s`, `1m05s` (matches hermes' footer). */
private fun formatLatency(seconds: Double): String {
if (seconds < 1) return "<1s"
val total = seconds.roundToInt()
if (total < 60) return "${total}s"
val m = total / 60
val s = total % 60
return "${m}m${s.toString().padStart(2, '0')}s"
}
/** Format a cost in USD: sub-cent at 4 decimals, else 2. */
private fun formatCost(cost: Double): String = if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost)
/** Circular selection indicator shown beside a bubble in selection mode. */
@Composable
private fun SelectionCheck(selected: Boolean) {
@@ -72,6 +72,8 @@ fun SettingsScreen(
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState()
val theme = LocalUserTheme.current
@@ -159,6 +161,39 @@ fun SettingsScreen(
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("📊 Runtime footer", fontSize = 14.sp)
Text(
"Show model, context, workdir, latency and cost under replies",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = runtimeFooterEnabled,
onCheckedChange = { controller.toggleRuntimeFooter() },
)
}
if (runtimeFooterEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Fields to show", fontSize = 12.sp, color = IrisColors.textDim)
Spacer(modifier = Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
IrisController.RUNTIME_FIELD_KEYS.forEach { field ->
TopicChip(
label = runtimeFieldLabel(field),
selected = field in runtimeFooterFields,
onClick = { controller.toggleRuntimeField(field) },
)
}
}
}
}
Text(
"Appearance",
@@ -620,3 +655,14 @@ private fun argbToHsv(argb: Int): FloatArray {
/** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
/** Human label for a runtime-footer field key (Settings → Runtime footer). */
private fun runtimeFieldLabel(field: String): String =
when (field) {
"model" -> "Model"
"context_pct" -> "Context %"
"cwd" -> "Workdir"
"latency" -> "Latency"
"cost" -> "Cost"
else -> field
}