From 32fb823aac229243131c3ddf0522d8607e1610f4 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 4 Mar 2026 19:47:53 -0600 Subject: [PATCH 1/2] Add platform-neutral color model and adapters --- .../phosphor/choreography/AgentLayer.kt | 19 ++- .../socket/phosphor/color/AnsiColorAdapter.kt | 141 ++++++++++++++++ .../phosphor/color/CognitiveColorModel.kt | 136 +++++++++++++++ .../link/socket/phosphor/color/ColorRamp.kt | 52 ++++++ .../socket/phosphor/color/NeutralColor.kt | 159 ++++++++++++++++++ .../phosphor/color/PlatformColorAdapter.kt | 8 + .../socket/phosphor/field/FlowConnection.kt | 45 +++++ .../phosphor/field/FlowLayerRenderer.kt | 23 ++- .../socket/phosphor/field/ParticleSystem.kt | 19 ++- .../phosphor/field/SubstrateRenderer.kt | 47 +++--- .../phosphor/palette/CognitiveColorRamp.kt | 51 +++--- .../phosphor/renderer/ComposeColorAdapter.kt | 21 +++ .../phosphor/renderer/ComposeRenderer.kt | 47 +----- .../phosphor/signal/AgentVisualState.kt | 85 ++++++++-- .../phosphor/color/AnsiColorAdapterTest.kt | 97 +++++++++++ .../phosphor/color/CognitiveColorModelTest.kt | 58 +++++++ .../socket/phosphor/color/ColorRampTest.kt | 88 ++++++++++ .../socket/phosphor/color/NeutralColorTest.kt | 89 ++++++++++ .../renderer/ComposeColorAdapterTest.kt | 38 +++++ .../phosphor/renderer/TerminalRenderer.kt | 12 +- 20 files changed, 1104 insertions(+), 131 deletions(-) create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/AnsiColorAdapter.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/CognitiveColorModel.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/PlatformColorAdapter.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeColorAdapter.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/AnsiColorAdapterTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/CognitiveColorModelTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/ColorRampTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/NeutralColorTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeColorAdapterTest.kt diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt index 26fa987..ac7a596 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/choreography/AgentLayer.kt @@ -5,6 +5,9 @@ import kotlin.math.acos import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt +import link.socket.phosphor.color.AgentColorState +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel import link.socket.phosphor.coordinate.CoordinateSpace import link.socket.phosphor.coordinate.CoordinateTransform import link.socket.phosphor.math.Vector2 @@ -455,6 +458,8 @@ class AgentLayerRenderer( private val useUnicode: Boolean = true, private val showStatusText: Boolean = true, ) { + private val ansi = AnsiColorAdapter.DEFAULT + /** * Render all agents to a list of positioned render items. */ @@ -492,7 +497,7 @@ class AgentLayerRenderer( append(color) append(glyph) append(suffix) - append(AgentColors.RESET) + append(AnsiColorAdapter.RESET) append(" ") append(agent.name) } @@ -501,10 +506,10 @@ class AgentLayerRenderer( val statusDisplay = if (showStatusText && agent.statusText.isNotEmpty()) { buildString { - append("\u001B[38;5;240m") // Gray + append(ansi.foreground(CognitiveColorModel.agentStateColors.getValue(AgentColorState.IDLE))) append("\u2514\u2500 ") // └─ append(agent.statusText) - append(AgentColors.RESET) + append(AnsiColorAdapter.RESET) } } else { null @@ -538,10 +543,10 @@ class AgentLayerRenderer( private fun getShimmerColor(agent: AgentVisualState): String { val phase = agent.pulsePhase return when { - phase < 0.25f -> "\u001B[38;5;226m" // Bright gold - phase < 0.5f -> "\u001B[38;5;228m" // Light gold - phase < 0.75f -> "\u001B[38;5;226m" // Bright gold - else -> "\u001B[38;5;220m" // Gold + phase < 0.25f -> AnsiColorAdapter.foregroundEscapeForCode(226) // Bright gold + phase < 0.5f -> AnsiColorAdapter.foregroundEscapeForCode(228) // Light gold + phase < 0.75f -> AnsiColorAdapter.foregroundEscapeForCode(226) // Bright gold + else -> AnsiColorAdapter.foregroundEscapeForCode(220) // Gold } } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/AnsiColorAdapter.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/AnsiColorAdapter.kt new file mode 100644 index 0000000..5593e46 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/AnsiColorAdapter.kt @@ -0,0 +1,141 @@ +package link.socket.phosphor.color + +/** + * ANSI output mode. + */ +enum class AnsiColorMode { + ANSI_256, + TRUE_COLOR, +} + +/** + * Adapts neutral colors to ANSI terminal escape sequences. + * + * In ANSI_256 mode, colors are quantized to the nearest ANSI palette index. + * In TRUE_COLOR mode, exact RGB values are emitted using 24-bit escapes. + */ +class AnsiColorAdapter( + private val mode: AnsiColorMode = AnsiColorMode.ANSI_256, +) : PlatformColorAdapter { + override fun adapt(color: NeutralColor): String = foreground(color) + + fun foreground(color: NeutralColor): String { + return when (mode) { + AnsiColorMode.ANSI_256 -> foregroundEscapeForCode(ansi256Code(color)) + AnsiColorMode.TRUE_COLOR -> { + "${ESC}38;2;${color.redInt};${color.greenInt};${color.blueInt}m" + } + } + } + + fun background(color: NeutralColor): String { + return when (mode) { + AnsiColorMode.ANSI_256 -> backgroundEscapeForCode(ansi256Code(color)) + AnsiColorMode.TRUE_COLOR -> { + "${ESC}48;2;${color.redInt};${color.greenInt};${color.blueInt}m" + } + } + } + + fun ansi256Code(color: NeutralColor): Int = Ansi256Palette.nearestIndex(color) + + fun neutralFromAnsi256(index: Int): NeutralColor = ansi256ToNeutral(index) + + companion object { + private const val ESC = "\u001B[" + const val RESET = "${ESC}0m" + + val DEFAULT: AnsiColorAdapter = AnsiColorAdapter() + + fun ansi256ToNeutral(index: Int): NeutralColor = Ansi256Palette.colorAt(index) + + fun foregroundEscapeForCode(code: Int): String = "${ESC}38;5;${code.coerceIn(0, 255)}m" + + fun backgroundEscapeForCode(code: Int): String = "${ESC}48;5;${code.coerceIn(0, 255)}m" + } +} + +private object Ansi256Palette { + private val baseColors = + arrayOf( + intArrayOf(0, 0, 0), + intArrayOf(128, 0, 0), + intArrayOf(0, 128, 0), + intArrayOf(128, 128, 0), + intArrayOf(0, 0, 128), + intArrayOf(128, 0, 128), + intArrayOf(0, 128, 128), + intArrayOf(192, 192, 192), + intArrayOf(128, 128, 128), + intArrayOf(255, 0, 0), + intArrayOf(0, 255, 0), + intArrayOf(255, 255, 0), + intArrayOf(0, 0, 255), + intArrayOf(255, 0, 255), + intArrayOf(0, 255, 255), + intArrayOf(255, 255, 255), + ) + + private val colorCubeLevels = intArrayOf(0, 95, 135, 175, 215, 255) + + private val colors: List = List(256) { index -> colorAtIndex(index) } + + fun colorAt(index: Int): NeutralColor = colors[index.coerceIn(0, 255)] + + fun nearestIndex(color: NeutralColor): Int { + val targetR = color.redInt + val targetG = color.greenInt + val targetB = color.blueInt + + var bestIndex = 0 + var bestDistance = Long.MAX_VALUE + + for (index in colors.indices) { + val candidate = colors[index] + val dR = targetR - candidate.redInt + val dG = targetG - candidate.greenInt + val dB = targetB - candidate.blueInt + val distance = ((dR * dR) + (dG * dG) + (dB * dB)).toLong() + + if (distance < bestDistance || (distance == bestDistance && index > bestIndex)) { + bestDistance = distance + bestIndex = index + } + } + + return bestIndex + } + + private fun colorAtIndex(index: Int): NeutralColor { + if (index < baseColors.size) { + val base = baseColors[index] + return NeutralColor.fromRgba( + red = base[0] / NeutralColor.CHANNEL_MAX_FLOAT, + green = base[1] / NeutralColor.CHANNEL_MAX_FLOAT, + blue = base[2] / NeutralColor.CHANNEL_MAX_FLOAT, + alpha = 1f, + ) + } + + if (index in 16..231) { + val cubeIndex = index - 16 + val red = colorCubeLevels[cubeIndex / 36] + val green = colorCubeLevels[(cubeIndex % 36) / 6] + val blue = colorCubeLevels[cubeIndex % 6] + return NeutralColor.fromRgba( + red = red / NeutralColor.CHANNEL_MAX_FLOAT, + green = green / NeutralColor.CHANNEL_MAX_FLOAT, + blue = blue / NeutralColor.CHANNEL_MAX_FLOAT, + alpha = 1f, + ) + } + + val gray = 8 + ((index - 232) * 10) + return NeutralColor.fromRgba( + red = gray / NeutralColor.CHANNEL_MAX_FLOAT, + green = gray / NeutralColor.CHANNEL_MAX_FLOAT, + blue = gray / NeutralColor.CHANNEL_MAX_FLOAT, + alpha = 1f, + ) + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/CognitiveColorModel.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/CognitiveColorModel.kt new file mode 100644 index 0000000..ba22df8 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/CognitiveColorModel.kt @@ -0,0 +1,136 @@ +package link.socket.phosphor.color + +import link.socket.phosphor.signal.AgentActivityState +import link.socket.phosphor.signal.CognitivePhase + +/** + * Semantic states for agent-level color signaling. + */ +enum class AgentColorState { + IDLE, + ACTIVE, + ESCALATING, + ERROR, +} + +/** + * Semantic states for flow-connection coloring. + */ +enum class FlowColorState { + DORMANT, + ACTIVATING, + TRANSMITTING, + RECEIVED, +} + +/** + * Semantic particle classes for terminal renderers. + */ +enum class ParticleColorKind { + MOTE, + SPARK, + TRAIL, + RIPPLE, +} + +/** + * Platform-neutral semantic color definitions for cognitive visualization. + */ +object CognitiveColorModel { + private fun fromAnsi(index: Int): NeutralColor = AnsiColorAdapter.ansi256ToNeutral(index) + + private fun phaseRamp(stops: List): ColorRamp = ColorRamp(stops = stops.map(::fromAnsi)) + + val phaseRamps: Map = + mapOf( + CognitivePhase.PERCEIVE to phaseRamp(listOf(17, 18, 24, 31, 38, 74, 110, 117, 153, 189, 231)), + CognitivePhase.RECALL to phaseRamp(listOf(52, 94, 130, 136, 172, 178, 214, 220, 221)), + CognitivePhase.PLAN to phaseRamp(listOf(23, 29, 30, 36, 37, 43, 79, 115, 159)), + CognitivePhase.EXECUTE to phaseRamp(listOf(52, 88, 124, 160, 196, 202, 208, 214, 220, 226, 231)), + CognitivePhase.EVALUATE to phaseRamp(listOf(53, 54, 91, 97, 134, 140, 141, 183, 189)), + CognitivePhase.LOOP to phaseRamp(listOf(232, 236, 240, 244, 248, 252, 255)), + CognitivePhase.NONE to phaseRamp(listOf(232, 236, 240, 244, 248, 252, 255)), + ) + + /** + * Representative phase hues sampled from each phase ramp midpoint. + */ + val phaseColors: Map = + phaseRamps.mapValues { (_, ramp) -> ramp.sample(0.5f) } + + val agentStateColors: Map = + mapOf( + AgentColorState.IDLE to fromAnsi(240), + AgentColorState.ACTIVE to fromAnsi(226), + AgentColorState.ESCALATING to fromAnsi(208), + AgentColorState.ERROR to fromAnsi(196), + ) + + val agentActivityColors: Map = + mapOf( + AgentActivityState.SPAWNING to agentStateColors.getValue(AgentColorState.IDLE), + AgentActivityState.IDLE to agentStateColors.getValue(AgentColorState.IDLE), + AgentActivityState.ACTIVE to agentStateColors.getValue(AgentColorState.ACTIVE), + AgentActivityState.PROCESSING to agentStateColors.getValue(AgentColorState.ACTIVE), + AgentActivityState.COMPLETE to fromAnsi(82), + ) + + val flowStateColors: Map = + mapOf( + FlowColorState.DORMANT to fromAnsi(240), + FlowColorState.ACTIVATING to fromAnsi(226), + FlowColorState.TRANSMITTING to fromAnsi(45), + FlowColorState.RECEIVED to fromAnsi(46), + ) + + val particleColors: Map = + mapOf( + ParticleColorKind.MOTE to fromAnsi(240), + ParticleColorKind.SPARK to fromAnsi(226), + ParticleColorKind.TRAIL to fromAnsi(45), + ParticleColorKind.RIPPLE to fromAnsi(51), + ) + + val confidenceRamp: ColorRamp = + ColorRamp( + stops = + listOf( + fromAnsi(239), + fromAnsi(242), + fromAnsi(245), + fromAnsi(81), + fromAnsi(51), + fromAnsi(45), + fromAnsi(82), + ), + ) + + val flowIntensityRamp: ColorRamp = + ColorRamp( + stops = + listOf( + fromAnsi(236), + fromAnsi(240), + fromAnsi(31), + fromAnsi(45), + fromAnsi(51), + ), + ) + + val reasoningRoleColor: NeutralColor = fromAnsi(213) + val codegenRoleColor: NeutralColor = fromAnsi(45) + val coordinatorRoleColor: NeutralColor = fromAnsi(226) + + fun phaseRampFor(phase: CognitivePhase): ColorRamp = phaseRamps.getValue(phase) + + fun phaseColorFor(phase: CognitivePhase): NeutralColor = phaseColors.getValue(phase) + + fun roleColorFor(role: String): NeutralColor { + return when (role.lowercase()) { + "reasoning", "spark" -> reasoningRoleColor + "codegen", "code", "jazz" -> codegenRoleColor + "coordinator" -> coordinatorRoleColor + else -> agentStateColors.getValue(AgentColorState.IDLE) + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt new file mode 100644 index 0000000..9f6eeaa --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/ColorRamp.kt @@ -0,0 +1,52 @@ +package link.socket.phosphor.color + +/** + * Ordered color stops from dark to bright (or low to high semantic intensity). + */ +data class ColorRamp( + val stops: List, +) { + init { + require(stops.size >= 2) { "Color ramp must have at least 2 stops" } + } + + private val lastIndex = stops.lastIndex + + /** + * Sample the ramp at t in [0, 1], linearly interpolating between stops. + */ + fun sample(t: Float): NeutralColor { + val clamped = t.coerceIn(0f, 1f) + if (clamped <= 0f) return stops.first() + if (clamped >= 1f) return stops.last() + + val scaled = clamped * lastIndex + val lower = scaled.toInt().coerceIn(0, lastIndex) + val upper = (lower + 1).coerceIn(0, lastIndex) + + if (lower == upper) return stops[lower] + + val localT = scaled - lower + return NeutralColor.lerp(stops[lower], stops[upper], localT) + } + + companion object { + /** + * Build a linear ramp from start -> end with [steps] inclusive stops. + */ + fun gradient( + start: NeutralColor, + end: NeutralColor, + steps: Int, + ): ColorRamp { + require(steps >= 2) { "steps must be >= 2, got $steps" } + + val generated = + List(steps) { index -> + val t = index.toFloat() / (steps - 1).toFloat() + NeutralColor.lerp(start, end, t) + } + return ColorRamp(generated) + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt new file mode 100644 index 0000000..d51448e --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/NeutralColor.kt @@ -0,0 +1,159 @@ +package link.socket.phosphor.color + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Platform-neutral color representation with normalized RGBA channels. + * + * Internally this is stored as packed 8-bit RGBA for stable equality and + * deterministic round-trips through hex serialization. + */ +@JvmInline +value class NeutralColor private constructor( + private val packedRgba: UInt, +) { + val red: Float get() = redInt / CHANNEL_MAX_FLOAT + val green: Float get() = greenInt / CHANNEL_MAX_FLOAT + val blue: Float get() = blueInt / CHANNEL_MAX_FLOAT + val alpha: Float get() = alphaInt / CHANNEL_MAX_FLOAT + + val r: Float get() = red + val g: Float get() = green + val b: Float get() = blue + val a: Float get() = alpha + + val redInt: Int get() = ((packedRgba shr 24) and CHANNEL_MASK).toInt() + val greenInt: Int get() = ((packedRgba shr 16) and CHANNEL_MASK).toInt() + val blueInt: Int get() = ((packedRgba shr 8) and CHANNEL_MASK).toInt() + val alphaInt: Int get() = (packedRgba and CHANNEL_MASK).toInt() + + fun toHex(includeAlpha: Boolean = true): String { + val rgb = "%02X%02X%02X".format(redInt, greenInt, blueInt) + return if (includeAlpha) { + "#$rgb%02X".format(alphaInt) + } else { + "#$rgb" + } + } + + fun withAlpha(alpha: Float): NeutralColor = fromRgba(red, green, blue, alpha) + + companion object { + internal const val CHANNEL_MAX_INT: Int = 255 + internal const val CHANNEL_MAX_FLOAT: Float = 255f + private const val HUE_DEGREES: Float = 360f + private val CHANNEL_MASK: UInt = 0xFFu + + val BLACK: NeutralColor = fromRgba(0f, 0f, 0f, 1f) + val WHITE: NeutralColor = fromRgba(1f, 1f, 1f, 1f) + val TRANSPARENT: NeutralColor = fromRgba(0f, 0f, 0f, 0f) + + fun fromRgba( + red: Float, + green: Float, + blue: Float, + alpha: Float = 1f, + ): NeutralColor { + val r = toChannel(red) + val g = toChannel(green) + val b = toChannel(blue) + val a = toChannel(alpha) + + val packed = + ((r.toUInt() and CHANNEL_MASK) shl 24) or + ((g.toUInt() and CHANNEL_MASK) shl 16) or + ((b.toUInt() and CHANNEL_MASK) shl 8) or + (a.toUInt() and CHANNEL_MASK) + + return NeutralColor(packed) + } + + /** + * Parse #RRGGBB or #RRGGBBAA (with or without leading #). + */ + fun fromHex(hex: String): NeutralColor { + val trimmed = hex.trim().removePrefix("#") + require(trimmed.length == 6 || trimmed.length == 8) { + "hex must be RRGGBB or RRGGBBAA, got '$hex'" + } + require(trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) { + "hex contains invalid characters: '$hex'" + } + + val r = trimmed.substring(0, 2).toInt(16) + val g = trimmed.substring(2, 4).toInt(16) + val b = trimmed.substring(4, 6).toInt(16) + val a = if (trimmed.length == 8) trimmed.substring(6, 8).toInt(16) else CHANNEL_MAX_INT + + return fromRgba( + red = r / CHANNEL_MAX_FLOAT, + green = g / CHANNEL_MAX_FLOAT, + blue = b / CHANNEL_MAX_FLOAT, + alpha = a / CHANNEL_MAX_FLOAT, + ) + } + + /** + * Convert HSL values (h in degrees, s/l in 0..1) to normalized RGBA. + */ + fun fromHsl( + h: Float, + s: Float, + l: Float, + a: Float = 1f, + ): NeutralColor { + val hue = ((h % HUE_DEGREES) + HUE_DEGREES) % HUE_DEGREES + val saturation = s.coerceIn(0f, 1f) + val lightness = l.coerceIn(0f, 1f) + + if (saturation == 0f) { + return fromRgba(lightness, lightness, lightness, a) + } + + val chroma = (1f - abs((2f * lightness) - 1f)) * saturation + val huePrime = hue / 60f + val x = chroma * (1f - abs((huePrime % 2f) - 1f)) + + val (rPrime, gPrime, bPrime) = + when { + huePrime < 1f -> Triple(chroma, x, 0f) + huePrime < 2f -> Triple(x, chroma, 0f) + huePrime < 3f -> Triple(0f, chroma, x) + huePrime < 4f -> Triple(0f, x, chroma) + huePrime < 5f -> Triple(x, 0f, chroma) + else -> Triple(chroma, 0f, x) + } + + val m = lightness - (chroma / 2f) + return fromRgba( + red = (rPrime + m).coerceIn(0f, 1f), + green = (gPrime + m).coerceIn(0f, 1f), + blue = (bPrime + m).coerceIn(0f, 1f), + alpha = a.coerceIn(0f, 1f), + ) + } + + fun lerp( + start: NeutralColor, + end: NeutralColor, + t: Float, + ): NeutralColor { + val clamped = t.coerceIn(0f, 1f) + return fromRgba( + red = interpolate(start.red, end.red, clamped), + green = interpolate(start.green, end.green, clamped), + blue = interpolate(start.blue, end.blue, clamped), + alpha = interpolate(start.alpha, end.alpha, clamped), + ) + } + + private fun interpolate( + start: Float, + end: Float, + t: Float, + ): Float = start + ((end - start) * t) + + private fun toChannel(value: Float): Int = (value.coerceIn(0f, 1f) * CHANNEL_MAX_FLOAT).roundToInt() + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/PlatformColorAdapter.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/PlatformColorAdapter.kt new file mode 100644 index 0000000..1a4bb2a --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/color/PlatformColorAdapter.kt @@ -0,0 +1,8 @@ +package link.socket.phosphor.color + +/** + * Generic adapter contract from neutral Phosphor colors to platform color types. + */ +interface PlatformColorAdapter { + fun adapt(color: NeutralColor): T +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowConnection.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowConnection.kt index 592b726..0108d50 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowConnection.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowConnection.kt @@ -1,5 +1,8 @@ package link.socket.phosphor.field +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.FlowColorState import link.socket.phosphor.math.Vector2 /** @@ -55,10 +58,52 @@ data class TaskToken( /** Colors for task tokens */ object Colors { + private val ansi = AnsiColorAdapter.DEFAULT + + @Deprecated( + message = "Use active() with CognitiveColorModel + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "TaskToken.Colors.active()", + ), + ) const val ACTIVE = "\u001B[38;5;226m" // Yellow + + @Deprecated( + message = "Use trail() with CognitiveColorModel + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "TaskToken.Colors.trail()", + ), + ) const val TRAIL = "\u001B[38;5;240m" // Gray + + @Deprecated( + message = "Use received() with CognitiveColorModel + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "TaskToken.Colors.received()", + ), + ) const val RECEIVED = "\u001B[38;5;46m" // Green + + @Deprecated( + message = "Use AnsiColorAdapter.RESET instead.", + replaceWith = ReplaceWith("AnsiColorAdapter.RESET"), + ) const val RESET = "\u001B[0m" + + fun active(): String = + ansi.foreground( + CognitiveColorModel.flowStateColors.getValue(FlowColorState.ACTIVATING), + ) + + fun trail(): String = ansi.foreground(CognitiveColorModel.flowStateColors.getValue(FlowColorState.DORMANT)) + + fun received(): String = + ansi.foreground( + CognitiveColorModel.flowStateColors.getValue(FlowColorState.RECEIVED), + ) } } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayerRenderer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayerRenderer.kt index f5ff6a0..dc2143c 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayerRenderer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/FlowLayerRenderer.kt @@ -2,6 +2,9 @@ package link.socket.phosphor.field import kotlin.math.abs import kotlin.math.roundToInt +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.FlowColorState import link.socket.phosphor.math.Vector2 /** @@ -14,6 +17,8 @@ class FlowLayerRenderer( private val useUnicode: Boolean = true, private val showDormantConnections: Boolean = false, ) { + private val ansi = AnsiColorAdapter.DEFAULT + /** * Rendered item for a flow connection. */ @@ -238,10 +243,14 @@ class FlowLayerRenderer( // Determine color based on state val color = when (connection.state) { - FlowState.DORMANT -> "\u001B[38;5;240m" // Gray - FlowState.ACTIVATING -> "\u001B[38;5;226m" // Yellow - FlowState.TRANSMITTING -> "\u001B[38;5;45m" // Cyan - FlowState.RECEIVED -> "\u001B[38;5;46m" // Green + FlowState.DORMANT -> + ansi.foreground(CognitiveColorModel.flowStateColors.getValue(FlowColorState.DORMANT)) + FlowState.ACTIVATING -> + ansi.foreground(CognitiveColorModel.flowStateColors.getValue(FlowColorState.ACTIVATING)) + FlowState.TRANSMITTING -> + ansi.foreground(CognitiveColorModel.flowStateColors.getValue(FlowColorState.TRANSMITTING)) + FlowState.RECEIVED -> + ansi.foreground(CognitiveColorModel.flowStateColors.getValue(FlowColorState.RECEIVED)) } return FlowRenderItem( @@ -350,7 +359,7 @@ class FlowLayerRenderer( if (x in 0 until width && y in 0 until height) { val existing = grid[y][x] if (existing.first == background) { - grid[y][x] = char to TaskToken.Companion.Colors.TRAIL + grid[y][x] = char to TaskToken.Companion.Colors.trail() } } } @@ -362,7 +371,7 @@ class FlowLayerRenderer( val x = item.tokenPosition.x.roundToInt() val y = item.tokenPosition.y.roundToInt() if (x in 0 until width && y in 0 until height) { - grid[y][x] = item.tokenChar to TaskToken.Companion.Colors.ACTIVE + grid[y][x] = item.tokenChar to TaskToken.Companion.Colors.active() } } } @@ -373,7 +382,7 @@ class FlowLayerRenderer( if (color != null) { append(color) append(char) - append("\u001B[0m") + append(AnsiColorAdapter.RESET) } else { append(char) } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/ParticleSystem.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/ParticleSystem.kt index 6249e20..dfbbdf5 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/ParticleSystem.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/ParticleSystem.kt @@ -1,6 +1,9 @@ package link.socket.phosphor.field import kotlin.math.roundToInt +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.ParticleColorKind import link.socket.phosphor.math.Vector2 /** @@ -310,6 +313,8 @@ class ParticleRenderer( private val height: Int, private val useUnicode: Boolean = true, ) { + private val ansi = AnsiColorAdapter.DEFAULT + /** * Render particles to a 2D character grid. * @@ -354,10 +359,14 @@ class ParticleRenderer( if (x in 0 until width && y in 0 until height) { val color = when (p.type) { - ParticleType.MOTE -> "\u001B[38;5;240m" // Gray - ParticleType.SPARK -> "\u001B[38;5;226m" // Yellow - ParticleType.TRAIL -> "\u001B[38;5;45m" // Cyan - ParticleType.RIPPLE -> "\u001B[38;5;51m" // Light cyan + ParticleType.MOTE -> + ansi.foreground(CognitiveColorModel.particleColors.getValue(ParticleColorKind.MOTE)) + ParticleType.SPARK -> + ansi.foreground(CognitiveColorModel.particleColors.getValue(ParticleColorKind.SPARK)) + ParticleType.TRAIL -> + ansi.foreground(CognitiveColorModel.particleColors.getValue(ParticleColorKind.TRAIL)) + ParticleType.RIPPLE -> + ansi.foreground(CognitiveColorModel.particleColors.getValue(ParticleColorKind.RIPPLE)) } grid[y][x] = p.glyph to color } @@ -369,7 +378,7 @@ class ParticleRenderer( if (color != null) { append(color) append(char) - append("\u001B[0m") + append(AnsiColorAdapter.RESET) } else { append(char) } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/SubstrateRenderer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/SubstrateRenderer.kt index eeaeb91..e4e1425 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/SubstrateRenderer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/field/SubstrateRenderer.kt @@ -1,5 +1,6 @@ package link.socket.phosphor.field +import link.socket.phosphor.color.AnsiColorAdapter import link.socket.phosphor.math.Vector2 /** @@ -118,16 +119,12 @@ class ColoredSubstrateRenderer( append(color) append(glyph) } - append(ANSI_RESET) + append(AnsiColorAdapter.RESET) } add(row) } } } - - companion object { - private const val ANSI_RESET = "\u001B[0m" - } } /** @@ -158,15 +155,15 @@ class SubstrateColorScheme( SubstrateColorScheme( listOf( // Dark gray - "\u001B[38;5;236m", + AnsiColorAdapter.foregroundEscapeForCode(236), // Gray - "\u001B[38;5;240m", + AnsiColorAdapter.foregroundEscapeForCode(240), // Light gray - "\u001B[38;5;250m", + AnsiColorAdapter.foregroundEscapeForCode(250), // Cyan - "\u001B[38;5;45m", + AnsiColorAdapter.foregroundEscapeForCode(45), // Bright cyan - "\u001B[38;5;51m", + AnsiColorAdapter.foregroundEscapeForCode(51), ), ) @@ -175,15 +172,15 @@ class SubstrateColorScheme( SubstrateColorScheme( listOf( // Dark green - "\u001B[38;5;22m", + AnsiColorAdapter.foregroundEscapeForCode(22), // Green - "\u001B[38;5;28m", + AnsiColorAdapter.foregroundEscapeForCode(28), // Bright green - "\u001B[38;5;34m", + AnsiColorAdapter.foregroundEscapeForCode(34), // Lime - "\u001B[38;5;46m", + AnsiColorAdapter.foregroundEscapeForCode(46), // Bright lime - "\u001B[38;5;118m", + AnsiColorAdapter.foregroundEscapeForCode(118), ), ) @@ -192,15 +189,15 @@ class SubstrateColorScheme( SubstrateColorScheme( listOf( // Dark purple - "\u001B[38;5;53m", + AnsiColorAdapter.foregroundEscapeForCode(53), // Purple - "\u001B[38;5;127m", + AnsiColorAdapter.foregroundEscapeForCode(127), // Magenta - "\u001B[38;5;165m", + AnsiColorAdapter.foregroundEscapeForCode(165), // Pink - "\u001B[38;5;207m", + AnsiColorAdapter.foregroundEscapeForCode(207), // Light pink - "\u001B[38;5;213m", + AnsiColorAdapter.foregroundEscapeForCode(213), ), ) @@ -209,15 +206,15 @@ class SubstrateColorScheme( SubstrateColorScheme( listOf( // Dark brown - "\u001B[38;5;94m", + AnsiColorAdapter.foregroundEscapeForCode(94), // Brown - "\u001B[38;5;130m", + AnsiColorAdapter.foregroundEscapeForCode(130), // Orange - "\u001B[38;5;172m", + AnsiColorAdapter.foregroundEscapeForCode(172), // Gold - "\u001B[38;5;214m", + AnsiColorAdapter.foregroundEscapeForCode(214), // Yellow - "\u001B[38;5;226m", + AnsiColorAdapter.foregroundEscapeForCode(226), ), ) } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/CognitiveColorRamp.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/CognitiveColorRamp.kt index 92d2078..bf124f5 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/CognitiveColorRamp.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/palette/CognitiveColorRamp.kt @@ -1,5 +1,7 @@ package link.socket.phosphor.palette +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel import link.socket.phosphor.signal.CognitivePhase /** @@ -58,47 +60,36 @@ data class CognitiveColorRamp( } companion object { + private val ansiAdapter = AnsiColorAdapter.DEFAULT + + private fun fromModel( + phase: CognitivePhase, + modelPhase: CognitivePhase = phase, + ): CognitiveColorRamp { + val stops = + CognitiveColorModel.phaseRampFor(modelPhase).stops.map { color -> + ansiAdapter.ansi256Code(color) + } + return CognitiveColorRamp(phase = phase, colorStops = stops) + } + /** Cool blues -> white (sensory, exploratory) */ - val PERCEIVE = - CognitiveColorRamp( - phase = CognitivePhase.PERCEIVE, - colorStops = listOf(17, 18, 24, 31, 38, 74, 110, 117, 153, 189, 231), - ) + val PERCEIVE = fromModel(CognitivePhase.PERCEIVE) /** Dark amber -> warm gold (memory, warmth) */ - val RECALL = - CognitiveColorRamp( - phase = CognitivePhase.RECALL, - colorStops = listOf(52, 94, 130, 136, 172, 178, 214, 220, 221), - ) + val RECALL = fromModel(CognitivePhase.RECALL) /** Teal -> cyan (structured, deliberate) */ - val PLAN = - CognitiveColorRamp( - phase = CognitivePhase.PLAN, - colorStops = listOf(23, 29, 30, 36, 37, 43, 79, 115, 159), - ) + val PLAN = fromModel(CognitivePhase.PLAN) /** Red -> yellow -> white (discharge, energy) */ - val EXECUTE = - CognitiveColorRamp( - phase = CognitivePhase.EXECUTE, - colorStops = listOf(52, 88, 124, 160, 196, 202, 208, 214, 220, 226, 231), - ) + val EXECUTE = fromModel(CognitivePhase.EXECUTE) /** Purple -> dim lavender (reflection, settling) */ - val EVALUATE = - CognitiveColorRamp( - phase = CognitivePhase.EVALUATE, - colorStops = listOf(53, 54, 91, 97, 134, 140, 141, 183, 189), - ) + val EVALUATE = fromModel(CognitivePhase.EVALUATE) /** Neutral gray ramp for LOOP/NONE phases */ - val NEUTRAL = - CognitiveColorRamp( - phase = CognitivePhase.NONE, - colorStops = listOf(232, 236, 240, 244, 248, 252, 255), - ) + val NEUTRAL = fromModel(phase = CognitivePhase.NONE, modelPhase = CognitivePhase.NONE) /** * Get the color ramp for a cognitive phase. diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeColorAdapter.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeColorAdapter.kt new file mode 100644 index 0000000..e7a633b --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeColorAdapter.kt @@ -0,0 +1,21 @@ +package link.socket.phosphor.renderer + +import link.socket.phosphor.color.ColorRamp +import link.socket.phosphor.color.NeutralColor +import link.socket.phosphor.color.PlatformColorAdapter + +/** + * Default adapter from NeutralColor to ComposeRenderer's platform color model. + */ +class ComposeColorAdapter : PlatformColorAdapter { + override fun adapt(color: NeutralColor): ComposeColor { + return ComposeColor( + red = color.redInt, + green = color.greenInt, + blue = color.blueInt, + alpha = color.alpha, + ) + } + + fun adapt(ramp: ColorRamp): List = ramp.stops.map(::adapt) +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt index 8516732..6c266ca 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt @@ -1,6 +1,8 @@ package link.socket.phosphor.renderer import kotlin.math.roundToInt +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.PlatformColorAdapter /** * Generic color model for Compose-like draw surfaces. @@ -55,6 +57,8 @@ data class ComposeRenderFrame( class ComposeRenderer( override val preferredFps: Int = DEFAULT_TARGET_FPS, private val skipWhitespaceCells: Boolean = true, + private val colorAdapter: PlatformColorAdapter = ComposeColorAdapter(), + private val ansiColorAdapter: AnsiColorAdapter = AnsiColorAdapter.DEFAULT, ) : PhosphorRenderer { override val target: RenderTarget = RenderTarget.COMPOSE @@ -70,7 +74,7 @@ class ComposeRenderer( val cell = frame.cellAt(row, col) if (skipWhitespaceCells && cell.char == ' ') continue - val start = ansi256ToColor(cell.fgColor) + val start = colorAdapter.adapt(ansiColorAdapter.neutralFromAnsi256(cell.fgColor)) val end = start.lighten(if (cell.bold) 0.25f else 0.12f) val alpha = (cell.luminance ?: if (cell.char == ' ') 0f else 1f).coerceIn(0f, 1f) @@ -96,45 +100,12 @@ class ComposeRenderer( companion object { const val DEFAULT_TARGET_FPS: Int = 60 - private val ANSI_BASE_COLORS = - arrayOf( - ComposeColor(0, 0, 0), - ComposeColor(128, 0, 0), - ComposeColor(0, 128, 0), - ComposeColor(128, 128, 0), - ComposeColor(0, 0, 128), - ComposeColor(128, 0, 128), - ComposeColor(0, 128, 128), - ComposeColor(192, 192, 192), - ComposeColor(128, 128, 128), - ComposeColor(255, 0, 0), - ComposeColor(0, 255, 0), - ComposeColor(255, 255, 0), - ComposeColor(0, 0, 255), - ComposeColor(255, 0, 255), - ComposeColor(0, 255, 255), - ComposeColor(255, 255, 255), - ) - - private val ANSI_COLOR_CUBE_LEVELS = intArrayOf(0, 95, 135, 175, 215, 255) + private val defaultAnsiAdapter = AnsiColorAdapter.DEFAULT + private val defaultComposeAdapter = ComposeColorAdapter() internal fun ansi256ToColor(index: Int): ComposeColor { - val normalized = index.coerceIn(0, 255) - - if (normalized < ANSI_BASE_COLORS.size) { - return ANSI_BASE_COLORS[normalized] - } - - if (normalized in 16..231) { - val cubeIndex = normalized - 16 - val r = ANSI_COLOR_CUBE_LEVELS[cubeIndex / 36] - val g = ANSI_COLOR_CUBE_LEVELS[(cubeIndex % 36) / 6] - val b = ANSI_COLOR_CUBE_LEVELS[cubeIndex % 6] - return ComposeColor(r, g, b) - } - - val gray = 8 + ((normalized - 232) * 10) - return ComposeColor(gray, gray, gray) + val neutral = defaultAnsiAdapter.neutralFromAnsi256(index) + return defaultComposeAdapter.adapt(neutral) } } } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AgentVisualState.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AgentVisualState.kt index bc453fd..9b69083 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AgentVisualState.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/signal/AgentVisualState.kt @@ -1,5 +1,8 @@ package link.socket.phosphor.signal +import link.socket.phosphor.color.AgentColorState +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel import link.socket.phosphor.math.Vector2 import link.socket.phosphor.math.Vector3 @@ -156,30 +159,90 @@ object AgentGlyphs { * Color scheme for agent rendering. */ object AgentColors { + private val ansi = AnsiColorAdapter.DEFAULT + // ANSI 256-color codes + @Deprecated( + message = "Use CognitiveColorModel.agentStateColors + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "AnsiColorAdapter.DEFAULT.foreground(" + + "CognitiveColorModel.agentStateColors.getValue(AgentColorState.IDLE))", + ), + ) const val IDLE = "\u001B[38;5;240m" // Gray + + @Deprecated( + message = "Use CognitiveColorModel.agentStateColors + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "AnsiColorAdapter.DEFAULT.foreground(" + + "CognitiveColorModel.agentStateColors.getValue(AgentColorState.ACTIVE))", + ), + ) const val ACTIVE = "\u001B[38;5;226m" // Gold/Yellow + + @Deprecated( + message = "Use CognitiveColorModel.agentActivityColors + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "AnsiColorAdapter.DEFAULT.foreground(" + + "CognitiveColorModel.agentActivityColors.getValue(AgentActivityState.PROCESSING))", + ), + ) const val PROCESSING = "\u001B[38;5;226m" // Gold with shimmer + + @Deprecated( + message = "Use CognitiveColorModel.agentActivityColors + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "AnsiColorAdapter.DEFAULT.foreground(" + + "CognitiveColorModel.agentActivityColors.getValue(AgentActivityState.SPAWNING))", + ), + ) const val SPAWNING = "\u001B[38;5;240m" // Gray + + @Deprecated( + message = "Use CognitiveColorModel.agentActivityColors + AnsiColorAdapter instead.", + replaceWith = + ReplaceWith( + "AnsiColorAdapter.DEFAULT.foreground(" + + "CognitiveColorModel.agentActivityColors.getValue(AgentActivityState.COMPLETE))", + ), + ) const val COMPLETE = "\u001B[38;5;82m" // Green + + @Deprecated( + message = "Use AnsiColorAdapter.RESET instead.", + replaceWith = ReplaceWith("AnsiColorAdapter.RESET"), + ) const val RESET = "\u001B[0m" // Role-based colors + @Deprecated( + message = "Use CognitiveColorModel.roleColorFor + AnsiColorAdapter instead.", + replaceWith = ReplaceWith("AnsiColorAdapter.DEFAULT.foreground(CognitiveColorModel.reasoningRoleColor)"), + ) const val REASONING = "\u001B[38;5;213m" // Pink/Magenta (Spark) + + @Deprecated( + message = "Use CognitiveColorModel.roleColorFor + AnsiColorAdapter instead.", + replaceWith = ReplaceWith("AnsiColorAdapter.DEFAULT.foreground(CognitiveColorModel.codegenRoleColor)"), + ) const val CODEGEN = "\u001B[38;5;45m" // Cyan (Jazz) + + @Deprecated( + message = "Use CognitiveColorModel.roleColorFor + AnsiColorAdapter instead.", + replaceWith = ReplaceWith("AnsiColorAdapter.DEFAULT.foreground(CognitiveColorModel.coordinatorRoleColor)"), + ) const val COORDINATOR = "\u001B[38;5;226m" // Gold /** * Get color for agent state. */ fun forState(state: AgentActivityState): String { - return when (state) { - AgentActivityState.SPAWNING -> SPAWNING - AgentActivityState.IDLE -> IDLE - AgentActivityState.ACTIVE -> ACTIVE - AgentActivityState.PROCESSING -> PROCESSING - AgentActivityState.COMPLETE -> COMPLETE - } + val neutral = CognitiveColorModel.agentActivityColors.getValue(state) + return ansi.foreground(neutral) } /** @@ -187,10 +250,10 @@ object AgentColors { */ fun forRole(role: String): String { return when (role.lowercase()) { - "reasoning", "spark" -> REASONING - "codegen", "code", "jazz" -> CODEGEN - "coordinator" -> COORDINATOR - else -> IDLE + "reasoning", "spark" -> ansi.foreground(CognitiveColorModel.reasoningRoleColor) + "codegen", "code", "jazz" -> ansi.foreground(CognitiveColorModel.codegenRoleColor) + "coordinator" -> ansi.foreground(CognitiveColorModel.coordinatorRoleColor) + else -> ansi.foreground(CognitiveColorModel.agentStateColors.getValue(AgentColorState.IDLE)) } } } diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/AnsiColorAdapterTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/AnsiColorAdapterTest.kt new file mode 100644 index 0000000..79452c6 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/AnsiColorAdapterTest.kt @@ -0,0 +1,97 @@ +package link.socket.phosphor.color + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AnsiColorAdapterTest { + @Test + fun `ansi256 palette round-trips legacy phase stops`() { + val adapter = AnsiColorAdapter.DEFAULT + val legacyStops = + listOf( + 17, + 18, + 24, + 31, + 38, + 74, + 110, + 117, + 153, + 189, + 231, + 52, + 94, + 130, + 136, + 172, + 178, + 214, + 220, + 221, + 23, + 29, + 30, + 36, + 37, + 43, + 79, + 115, + 159, + 88, + 124, + 160, + 196, + 202, + 208, + 226, + 53, + 54, + 91, + 97, + 134, + 140, + 141, + 183, + 232, + 236, + 240, + 244, + 248, + 252, + 255, + ) + + legacyStops.forEach { code -> + val neutral = adapter.neutralFromAnsi256(code) + assertEquals(code, adapter.ansi256Code(neutral), "Expected ANSI code $code to round-trip") + } + } + + @Test + fun `foreground and background escape use ansi256 format`() { + val color = AnsiColorAdapter.ansi256ToNeutral(196) + + val foreground = AnsiColorAdapter.DEFAULT.foreground(color) + val background = AnsiColorAdapter.DEFAULT.background(color) + + assertEquals("\u001B[38;5;196m", foreground) + assertEquals("\u001B[48;5;196m", background) + } + + @Test + fun `true color mode emits 24 bit escapes`() { + val adapter = AnsiColorAdapter(mode = AnsiColorMode.TRUE_COLOR) + val color = NeutralColor.fromHex("#3366CC") + + assertEquals("\u001B[38;2;51;102;204m", adapter.foreground(color)) + assertEquals("\u001B[48;2;51;102;204m", adapter.background(color)) + } + + @Test + fun `foreground escape for code clamps out of range values`() { + val escaped = AnsiColorAdapter.foregroundEscapeForCode(999) + assertTrue(escaped.contains("38;5;255")) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/CognitiveColorModelTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/CognitiveColorModelTest.kt new file mode 100644 index 0000000..f6434ed --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/CognitiveColorModelTest.kt @@ -0,0 +1,58 @@ +package link.socket.phosphor.color + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import link.socket.phosphor.signal.AgentActivityState +import link.socket.phosphor.signal.CognitivePhase + +class CognitiveColorModelTest { + private val ansi = AnsiColorAdapter.DEFAULT + + @Test + fun `all cognitive phases have semantic colors and ramps`() { + CognitivePhase.entries.forEach { phase -> + assertTrue(CognitiveColorModel.phaseColors.containsKey(phase), "Missing phase color for $phase") + assertTrue(CognitiveColorModel.phaseRamps.containsKey(phase), "Missing phase ramp for $phase") + } + } + + @Test + fun `all agent states and activity states have assigned colors`() { + AgentColorState.entries.forEach { state -> + assertTrue(CognitiveColorModel.agentStateColors.containsKey(state), "Missing agent state color for $state") + } + + AgentActivityState.entries.forEach { state -> + assertTrue( + CognitiveColorModel.agentActivityColors.containsKey(state), + "Missing agent activity color for $state", + ) + } + } + + @Test + fun `confidence and flow ramps have at least five stops`() { + assertTrue(CognitiveColorModel.confidenceRamp.stops.size >= 5) + assertTrue(CognitiveColorModel.flowIntensityRamp.stops.size >= 5) + } + + @Test + fun `phase ramps preserve legacy ansi identity`() { + val expected = + mapOf( + CognitivePhase.PERCEIVE to listOf(17, 18, 24, 31, 38, 74, 110, 117, 153, 189, 231), + CognitivePhase.RECALL to listOf(52, 94, 130, 136, 172, 178, 214, 220, 221), + CognitivePhase.PLAN to listOf(23, 29, 30, 36, 37, 43, 79, 115, 159), + CognitivePhase.EXECUTE to listOf(52, 88, 124, 160, 196, 202, 208, 214, 220, 226, 231), + CognitivePhase.EVALUATE to listOf(53, 54, 91, 97, 134, 140, 141, 183, 189), + CognitivePhase.LOOP to listOf(232, 236, 240, 244, 248, 252, 255), + CognitivePhase.NONE to listOf(232, 236, 240, 244, 248, 252, 255), + ) + + expected.forEach { (phase, legacyStops) -> + val adapted = CognitiveColorModel.phaseRampFor(phase).stops.map(ansi::ansi256Code) + assertEquals(legacyStops, adapted, "ANSI stop mismatch for $phase") + } + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/ColorRampTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/ColorRampTest.kt new file mode 100644 index 0000000..68f11b8 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/ColorRampTest.kt @@ -0,0 +1,88 @@ +package link.socket.phosphor.color + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ColorRampTest { + @Test + fun `sample returns first stop at zero`() { + val ramp = + ColorRamp( + listOf( + NeutralColor.fromHex("#000000"), + NeutralColor.fromHex("#FFFFFF"), + ), + ) + + assertEquals(ramp.stops.first(), ramp.sample(0f)) + } + + @Test + fun `sample returns last stop at one`() { + val ramp = + ColorRamp( + listOf( + NeutralColor.fromHex("#000000"), + NeutralColor.fromHex("#FFFFFF"), + ), + ) + + assertEquals(ramp.stops.last(), ramp.sample(1f)) + } + + @Test + fun `sample interpolates midpoint`() { + val ramp = + ColorRamp( + listOf( + NeutralColor.fromHex("#000000"), + NeutralColor.fromHex("#FFFFFF"), + ), + ) + + val midpoint = ramp.sample(0.5f) + assertEquals(128, midpoint.redInt) + assertEquals(128, midpoint.greenInt) + assertEquals(128, midpoint.blueInt) + } + + @Test + fun `sample clamps input bounds`() { + val ramp = + ColorRamp( + listOf( + NeutralColor.fromHex("#111111"), + NeutralColor.fromHex("#EEEEEE"), + ), + ) + + assertEquals(ramp.stops.first(), ramp.sample(-10f)) + assertEquals(ramp.stops.last(), ramp.sample(10f)) + } + + @Test + fun `gradient builds inclusive stops`() { + val start = NeutralColor.fromHex("#000000") + val end = NeutralColor.fromHex("#FFFFFF") + val ramp = ColorRamp.gradient(start, end, steps = 5) + + assertEquals(5, ramp.stops.size) + assertEquals(start, ramp.stops.first()) + assertEquals(end, ramp.stops.last()) + } + + @Test + fun `gradient with fewer than 2 steps throws`() { + assertFailsWith { + ColorRamp.gradient(NeutralColor.BLACK, NeutralColor.WHITE, steps = 1) + } + } + + @Test + fun `ramp with fewer than 2 stops throws`() { + assertFailsWith { + ColorRamp(listOf(NeutralColor.BLACK)) + } + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/NeutralColorTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/NeutralColorTest.kt new file mode 100644 index 0000000..9789d7d --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/color/NeutralColorTest.kt @@ -0,0 +1,89 @@ +package link.socket.phosphor.color + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class NeutralColorTest { + @Test + fun `fromHex parses rgb input`() { + val color = NeutralColor.fromHex("#3366CC") + + assertEquals(0x33, color.redInt) + assertEquals(0x66, color.greenInt) + assertEquals(0xCC, color.blueInt) + assertEquals(0xFF, color.alphaInt) + } + + @Test + fun `fromHex parses rgba input`() { + val color = NeutralColor.fromHex("11223380") + + assertEquals(0x11, color.redInt) + assertEquals(0x22, color.greenInt) + assertEquals(0x33, color.blueInt) + assertEquals(0x80, color.alphaInt) + } + + @Test + fun `fromHex rejects invalid lengths`() { + assertFailsWith { + NeutralColor.fromHex("#12345") + } + } + + @Test + fun `fromHex rejects invalid characters`() { + assertFailsWith { + NeutralColor.fromHex("#12ZZAA") + } + } + + @Test + fun `fromHsl converts primaries`() { + val red = NeutralColor.fromHsl(0f, 1f, 0.5f) + val green = NeutralColor.fromHsl(120f, 1f, 0.5f) + val blue = NeutralColor.fromHsl(240f, 1f, 0.5f) + + assertEquals(255, red.redInt) + assertEquals(255, green.greenInt) + assertEquals(255, blue.blueInt) + } + + @Test + fun `toHex round trips through fromHex`() { + val original = NeutralColor.fromRgba(0.2f, 0.45f, 0.9f, 0.7f) + val roundTripped = NeutralColor.fromHex(original.toHex()) + + assertEquals(original, roundTripped) + } + + @Test + fun `lerp interpolates midpoint`() { + val start = NeutralColor.fromHex("#000000") + val end = NeutralColor.fromHex("#FFFFFF") + val midpoint = NeutralColor.lerp(start, end, 0.5f) + + assertEquals(128, midpoint.redInt) + assertEquals(128, midpoint.greenInt) + assertEquals(128, midpoint.blueInt) + } + + @Test + fun `toHex can omit alpha`() { + val color = NeutralColor.fromHex("#AABBCCDD") + assertEquals("#AABBCC", color.toHex(includeAlpha = false)) + } + + @Test + fun `fromRgba clamps out of range channels`() { + val color = NeutralColor.fromRgba(-1f, 0.5f, 2f, 9f) + + assertEquals(0, color.redInt) + assertEquals(128, color.greenInt) + assertEquals(255, color.blueInt) + assertEquals(255, color.alphaInt) + assertTrue(color.red in 0f..1f) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeColorAdapterTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeColorAdapterTest.kt new file mode 100644 index 0000000..0594704 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeColorAdapterTest.kt @@ -0,0 +1,38 @@ +package link.socket.phosphor.renderer + +import kotlin.test.Test +import kotlin.test.assertEquals +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.NeutralColor +import link.socket.phosphor.signal.CognitivePhase + +class ComposeColorAdapterTest { + @Test + fun `adapter maps neutral channels directly`() { + val adapter = ComposeColorAdapter() + val neutral = NeutralColor.fromHex("#3366CC80") + + val compose = adapter.adapt(neutral) + + assertEquals(51, compose.red) + assertEquals(102, compose.green) + assertEquals(204, compose.blue) + assertEquals(128f / 255f, compose.alpha) + } + + @Test + fun `same phase ramp maps consistently to ansi and compose`() { + val ansi = AnsiColorAdapter.DEFAULT + val compose = ComposeColorAdapter() + val ramp = CognitiveColorModel.phaseRampFor(CognitivePhase.PLAN) + + val ansiStops = ramp.stops.map(ansi::ansi256Code) + val composeStops = compose.adapt(ramp) + + ansiStops.indices.forEach { index -> + val fromAnsiPath = ComposeRenderer.ansi256ToColor(ansiStops[index]) + assertEquals(fromAnsiPath, composeStops[index]) + } + } +} diff --git a/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt b/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt index e52d208..5b0cc25 100644 --- a/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt +++ b/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt @@ -1,5 +1,6 @@ package link.socket.phosphor.renderer +import link.socket.phosphor.color.AnsiColorAdapter import link.socket.phosphor.palette.AsciiLuminancePalette /** @@ -81,7 +82,7 @@ class TerminalRenderer( if (includeAnsi) { appendStyle(line, cell) line.append(char) - line.append(ANSI_RESET) + line.append(AnsiColorAdapter.RESET) } else { line.append(char) } @@ -115,8 +116,8 @@ class TerminalRenderer( cell: FrameCell, ) { builder.append(if (cell.bold) ANSI_BOLD else ANSI_NORMAL_WEIGHT) - builder.append(ansiForeground(cell.fgColor)) - cell.bgColor?.let { bg -> builder.append(ansiBackground(bg)) } + builder.append(AnsiColorAdapter.foregroundEscapeForCode(cell.fgColor)) + cell.bgColor?.let { bg -> builder.append(AnsiColorAdapter.backgroundEscapeForCode(bg)) } } companion object { @@ -125,10 +126,5 @@ class TerminalRenderer( private const val ESC = "\u001B[" private const val ANSI_BOLD = "${ESC}1m" private const val ANSI_NORMAL_WEIGHT = "${ESC}22m" - private const val ANSI_RESET = "${ESC}0m" - - private fun ansiForeground(code: Int): String = "${ESC}38;5;${code.coerceIn(0, 255)}m" - - private fun ansiBackground(code: Int): String = "${ESC}48;5;${code.coerceIn(0, 255)}m" } } From 39a5d44bd49fb8fcaa1f80e14dd0dba51ad9c842 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 4 Mar 2026 20:32:33 -0600 Subject: [PATCH 2/2] Bump phosphor version to 0.4.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8f3f425..d586db3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,4 @@ kotlin.version=2.3.10 org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers #Phosphor -phosphorVersion=0.3.0 +phosphorVersion=0.4.0