Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -492,7 +497,7 @@ class AgentLayerRenderer(
append(color)
append(glyph)
append(suffix)
append(AgentColors.RESET)
append(AnsiColorAdapter.RESET)
append(" ")
append(agent.name)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {
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<NeutralColor> = 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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<Int>): ColorRamp = ColorRamp(stops = stops.map(::fromAnsi))

val phaseRamps: Map<CognitivePhase, ColorRamp> =
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<CognitivePhase, NeutralColor> =
phaseRamps.mapValues { (_, ramp) -> ramp.sample(0.5f) }

val agentStateColors: Map<AgentColorState, NeutralColor> =
mapOf(
AgentColorState.IDLE to fromAnsi(240),
AgentColorState.ACTIVE to fromAnsi(226),
AgentColorState.ESCALATING to fromAnsi(208),
AgentColorState.ERROR to fromAnsi(196),
)

val agentActivityColors: Map<AgentActivityState, NeutralColor> =
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<FlowColorState, NeutralColor> =
mapOf(
FlowColorState.DORMANT to fromAnsi(240),
FlowColorState.ACTIVATING to fromAnsi(226),
FlowColorState.TRANSMITTING to fromAnsi(45),
FlowColorState.RECEIVED to fromAnsi(46),
)

val particleColors: Map<ParticleColorKind, NeutralColor> =
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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<NeutralColor>,
) {
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)
}
}
}
Loading