Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9410f52
feat(display-links): paint constructed http(s) URL cards via MCP
heavygee Aug 13, 2026
4d76ebb
fix(display-links): register MCP tool on Cursor sessions only
heavygee Aug 13, 2026
37a3d47
fix(cli): follow relocated ~/.cursor symlink for HAPI MCP overlay
heavygee Aug 8, 2026
fc71895
fix(display-links): refuse cross-runner loopback MCP targeting
heavygee Aug 13, 2026
7731a0e
feat(display-links): paint exact-copy strings via texts cards
heavygee Aug 16, 2026
150a91f
fix(display-links): unique row keys and localize card labels
heavygee Aug 16, 2026
2988f06
fix(display-links): send configured extra hub headers on CLI fallback
heavygee Aug 16, 2026
bbb5487
fix(display-links): honor extra hub headers in the mjs fallback
heavygee Aug 16, 2026
a6eeea5
fix(display-links): bind sessionId and exclusive-enable Cursor MCP
heavygee Aug 16, 2026
7a505ee
fix(display-links): keep sibling MCP enabled and hide exact-copy from…
heavygee Aug 16, 2026
483b6b2
fix(display-links): redact exact-copy from tool-call export and argv
heavygee Aug 16, 2026
c99e6b4
fix(display-links): project display-links blocks in golden fixtures
heavygee Aug 20, 2026
6966261
fix(display-links): port normative block to iOS/Android fixtures
heavygee Aug 20, 2026
c8f9cf4
fix(android): local-bind display-links titles across modules
heavygee Aug 20, 2026
22592dd
fix(android): reuse openUrl for display-links taps
heavygee Aug 20, 2026
ad78548
fix(display-links): unique per-session MCP tool name
heavygee Aug 20, 2026
45c4ec5
fix(display-links): align retention + doctor with parser/tool name
heavygee Aug 20, 2026
5364d9d
fix(display-links): fail-closed redact + mark Android clips sensitive
heavygee Aug 20, 2026
09e4074
fix(display-links): whitelist redacted tool input fields only
heavygee Aug 20, 2026
6473faf
fix(display-links): redact non-objects; local-only iOS pasteboard
heavygee Aug 20, 2026
9449a23
fix(display-links): add roundSummary to DisplayLinksBlock after rebase
heavygee Sep 9, 2026
08fc59f
fix(fixtures): bump upstream codex-blank-stream-ids to fixtureVersion 2
heavygee Sep 9, 2026
f57226e
fix(display-links): redact ACP permission agentState inputs
heavygee Sep 9, 2026
5958591
fix(fixtures): regen after rebase onto upstream/main
heavygee Sep 12, 2026
d9f926a
fix(display-links): accept unescaped spaces in native href validation
heavygee Sep 12, 2026
e8cbc5a
fix(fixtures): regen after rebase onto upstream/main
heavygee Sep 16, 2026
ad7ebce
feat(cli): land upstream #1564 display_links on the fork
heavygee Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Start with the task's files; read only relevant sections of these references, no
| Native API contract, chat conformance | [client contract](docs/api/client-contract/index.md), [iOS](ios/README.md), [Android](android/README.md) |
| Encrypted native push relay | [relay/README.md](relay/README.md) |
| User docs / marketing site | `docs/` (VitePress) / `website/` |
| Paint tappable URLs / exact-copy strings (Cursor only) | MCP `display_links` (Cursor sessions) or `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — pass `sessionId` = this chat; construct hrefs and secrets by concatenation (`"tia"+"nn"`, `"VK"+"K"`), never type landmine hosts or tokens in prose; never echo secrets after painting the card |

## Repository conventions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import app.hapi.companion.feature.chat.blocks.AgentReasoningBlockView
import app.hapi.companion.feature.chat.blocks.AgentTextBlockView
import app.hapi.companion.feature.chat.blocks.CliOutputBlockView
import app.hapi.companion.feature.chat.blocks.CodexReviewBlockView
import app.hapi.companion.feature.chat.blocks.DisplayLinksBlockView
import app.hapi.companion.feature.chat.blocks.GeneratedImageBlockView
import app.hapi.companion.feature.chat.blocks.ToolCallBlockView
import app.hapi.companion.feature.chat.blocks.ToolGroupBlockView
Expand All @@ -18,6 +19,7 @@ import app.hapi.protocol.chat.AgentTextBlock
import app.hapi.protocol.chat.ChatBlock
import app.hapi.protocol.chat.CliOutputBlock
import app.hapi.protocol.chat.CodexReviewBlock
import app.hapi.protocol.chat.DisplayLinksBlock
import app.hapi.protocol.chat.GeneratedImageBlock
import app.hapi.protocol.chat.ToolCallBlock
import app.hapi.protocol.chat.ToolGroupBlock
Expand Down Expand Up @@ -71,6 +73,7 @@ fun ChatBlockCard(
is AgentEventBlock -> AgentEventBlockView(block, modifier)
is CliOutputBlock -> CliOutputBlockView(block, modifier)
is GeneratedImageBlock -> GeneratedImageBlockView(block, modifier)
is DisplayLinksBlock -> DisplayLinksBlockView(block, modifier)
is CodexReviewBlock -> CodexReviewBlockView(block, modifier)
is ToolCallBlock -> ToolCallBlockView(block, basePath, modifier, processSteps)
is ToolGroupBlock -> ToolGroupBlockView(block, basePath, modifier)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ fun rememberChatLinkHandler(
}
}

private fun Context.openUrl(url: String) {
internal fun Context.openUrl(url: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
} catch (_: ActivityNotFoundException) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package app.hapi.companion.feature.chat.blocks

import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.os.PersistableBundle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import app.hapi.companion.feature.chat.openUrl
import app.hapi.companion.ui.theme.hapi
import app.hapi.protocol.chat.DisplayLinksBlock

/**
* Port of web `DisplayLinksCard`: tappable http(s) rows plus exact-copy strings.
*/
@Composable
fun DisplayLinksBlockView(block: DisplayLinksBlock, modifier: Modifier = Modifier) {
val context = LocalContext.current
val hasUrls = block.urls.isNotEmpty()
val hasTexts = block.texts.isNotEmpty()
val heading = when {
hasUrls && hasTexts -> "Links & copy"
hasTexts -> "Copy"
else -> "Links"
}

Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
tonalElevation = 1.dp,
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = heading,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.hapi.hint,
)
for (url in block.urls) {
val urlTitle = url.title
val navigable = runCatching {
// Android Uri.parse is lenient; still mirror http(s) gate used by protocol.
val forScheme = url.href.replace(" ", "%20").toUri()
forScheme.scheme.equals("http", ignoreCase = true) ||
forScheme.scheme.equals("https", ignoreCase = true)
}.getOrDefault(false)
if (navigable) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
// Prefer space-escaped href for ACTION_VIEW; keep card text original.
val openHref = if (url.href.contains(' ')) {
url.href.replace(" ", "%20")
} else {
url.href
}
context.openUrl(openHref)
}
.padding(10.dp),
) {
Text(
text = urlTitle?.takeIf { it.isNotBlank() } ?: url.href,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!urlTitle.isNullOrBlank()) {
Text(
text = url.href,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.hapi.hint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
} else {
Text(
text = urlTitle?.takeIf { it.isNotBlank() } ?: url.href,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.hapi.hint,
)
}
}
for (text in block.texts) {
val textTitle = text.title
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(textTitle ?: "copy", text.value).apply {
description.extras = PersistableBundle().apply {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
}
}
clipboard.setPrimaryClip(clip)
}
.padding(10.dp),
) {
if (!textTitle.isNullOrBlank()) {
Text(
text = textTitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.hapi.hint,
)
}
Text(
text = text.value,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Tap to copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.hapi.hint,
)
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,23 @@ sealed class NormalizedAgentContent {
val source: InlineMediaSource? = null,
) : NormalizedAgentContent()

data class DisplayLinkItem(
val href: String,
val title: String? = null,
)

data class DisplayTextItem(
val value: String,
val title: String? = null,
)

data class DisplayLinks(
val urls: List<DisplayLinkItem>,
val texts: List<DisplayTextItem> = emptyList(),
val uuid: String,
val parentUUID: String? = null,
) : NormalizedAgentContent()

data class CodexReviewContent(
val review: CodexReview,
val uuid: String,
Expand All @@ -221,6 +238,7 @@ internal fun NormalizedAgentContent.uuidOrNull(): String? = when (this) {
is NormalizedAgentContent.ToolUse -> uuid
is NormalizedAgentContent.ToolResult -> uuid
is NormalizedAgentContent.GeneratedImage -> uuid
is NormalizedAgentContent.DisplayLinks -> uuid
is NormalizedAgentContent.CodexReviewContent -> uuid
is NormalizedAgentContent.Sidechain -> uuid
is NormalizedAgentContent.Summary -> null
Expand All @@ -232,6 +250,7 @@ internal fun NormalizedAgentContent.parentUuidOrNull(): String? = when (this) {
is NormalizedAgentContent.ToolUse -> parentUUID
is NormalizedAgentContent.ToolResult -> parentUUID
is NormalizedAgentContent.GeneratedImage -> parentUUID
is NormalizedAgentContent.DisplayLinks -> parentUUID
is NormalizedAgentContent.CodexReviewContent -> parentUUID
is NormalizedAgentContent.Sidechain -> parentUUID
is NormalizedAgentContent.Summary -> null
Expand Down Expand Up @@ -541,6 +560,18 @@ class GeneratedImageBlock(
override val kind: String get() = "generated-image"
}

class DisplayLinksBlock(
override val id: String,
val localId: String?,
override var createdAt: Long,
override var invokedAt: Long?,
val urls: List<NormalizedAgentContent.DisplayLinkItem>,
val texts: List<NormalizedAgentContent.DisplayTextItem>,
override var meta: JsonElement?,
) : ChatBlock() {
override val kind: String get() = "display-links"
}

class AgentEventBlock(
override val id: String,
override var createdAt: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
* Port of `web/scripts/fixtures/projection.ts` — the NORMATIVE projection of
Expand Down Expand Up @@ -119,6 +121,23 @@ fun projectChatBlock(block: ChatBlock): JsonObject {
projected["fileName"] = JsonPrimitive(block.fileName)
projected["mimeType"] = nullableString(block.mimeType)
}
is DisplayLinksBlock -> {
projected["localId"] = nullableString(block.localId)
projected["urls"] = JsonArray(block.urls.map { url ->
buildJsonObject {
put("href", url.href)
if (url.title != null) put("title", url.title)
}
})
if (block.texts.isNotEmpty()) {
projected["texts"] = JsonArray(block.texts.map { text ->
buildJsonObject {
put("value", text.value)
if (text.title != null) put("title", text.title)
}
})
}
}
is AgentEventBlock -> {
projected["event"] = block.event.raw
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,70 @@ private fun normalizeToolResultPermissions(value: JsonElement?): ToolResultPermi
)
}

/** Port of shared `isDisplayableHttpHref` (http/https only). */
internal fun isDisplayableHttpHref(href: String): Boolean {
val trimmed = href.trim()
if (trimmed.isEmpty() || trimmed.length > 2048) return false
return try {
// Validation copy only — keep original href bytes on DisplayLinkItem.
// java.net.URI rejects unescaped spaces that `new URL()` (web) accepts.
val uri = java.net.URI(trimmed.replace(" ", "%20"))
val scheme = uri.scheme?.lowercase()
scheme == "http" || scheme == "https"
} catch (_: Exception) {
false
}
}

/** Port of shared `safeParseDisplayLinksInput`. */
internal fun parseDisplayLinksInput(value: JsonElement?): List<NormalizedAgentContent.DisplayLinkItem> {
val items = value as? JsonArray ?: return emptyList()
val urls = mutableListOf<NormalizedAgentContent.DisplayLinkItem>()
for (item in items.take(20)) {
when (item) {
is JsonPrimitive -> {
if (!item.isString) continue
val href = item.content.trim()
if (!isDisplayableHttpHref(href)) continue
urls.add(NormalizedAgentContent.DisplayLinkItem(href = href))
}
is JsonObject -> {
val rawHref = asString(item["href"].orNull() ?: item["url"]) ?: continue
val href = rawHref.trim()
if (!isDisplayableHttpHref(href)) continue
val title = asString(item["title"])?.trim()?.takeIf { it.isNotEmpty() }
urls.add(NormalizedAgentContent.DisplayLinkItem(href = href, title = title))
}
else -> Unit
}
}
return urls
}

/** Port of shared `safeParseDisplayTextsInput`. */
internal fun parseDisplayTextsInput(value: JsonElement?): List<NormalizedAgentContent.DisplayTextItem> {
val items = value as? JsonArray ?: return emptyList()
val texts = mutableListOf<NormalizedAgentContent.DisplayTextItem>()
for (item in items.take(20)) {
when (item) {
is JsonPrimitive -> {
if (!item.isString) continue
val raw = item.content
if (raw.isEmpty() || raw.length > 8192 || raw.trim().isEmpty()) continue
texts.add(NormalizedAgentContent.DisplayTextItem(value = raw))
}
is JsonObject -> {
val rawValue = asString(item["value"].orNull() ?: item["text"]) ?: continue
if (rawValue.isEmpty() || rawValue.length > 8192 || rawValue.trim().isEmpty()) continue
val title = asString(item["title"])?.trim()?.takeIf { it.isNotEmpty() }
texts.add(NormalizedAgentContent.DisplayTextItem(value = rawValue, title = title))
}
else -> Unit
}
}
return texts
}

private fun normalizeAgentEvent(value: JsonElement?): AgentEvent? {
val record = asObject(value) ?: return null
if (asString(record["type"]) == null) return null
Expand Down Expand Up @@ -808,6 +872,28 @@ fun normalizeAgentRecord(
return eventMessage(messageId, localId, createdAt, data, meta)
}

if (dataType == "display-links") {
val urls = parseDisplayLinksInput(data["urls"])
val texts = parseDisplayTextsInput(data["texts"])
if (urls.isEmpty() && texts.isEmpty()) return null
val uuid = asString(data["id"]) ?: messageId
return NormalizedMessage.Agent(
id = messageId,
localId = localId,
createdAt = createdAt,
isSidechain = false,
content = listOf(
NormalizedAgentContent.DisplayLinks(
urls = urls,
texts = texts,
uuid = uuid,
parentUUID = null,
)
),
meta = meta,
)
}

if (dataType == "generated-image") {
val imageId = asString(data["imageId"].orNull() ?: data["image_id"]) ?: return null
val uuid = asString(data["id"]) ?: messageId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,20 @@ fun reduceTimeline(
)
}

is NormalizedAgentContent.DisplayLinks -> {
blocks.add(
DisplayLinksBlock(
id = "${msg.id}:$idx",
localId = msg.localId,
createdAt = msg.createdAt,
invokedAt = msg.invokedAt,
urls = c.urls,
texts = c.texts,
meta = msg.meta,
)
)
}

is NormalizedAgentContent.Reasoning -> {
val streamId = nonBlankStreamId(c.streamId)
if (streamId != null) {
Expand Down
Loading
Loading