refactor(partnerctl): extract TerminalText styling utility and reuse it in prompt/chat rendering

This commit is contained in:
2026-05-08 23:44:08 +08:00
parent a8e3a84db8
commit fbd1d17fc4
4 changed files with 137 additions and 47 deletions

View File

@@ -4,25 +4,72 @@ import work.slhaf.partner.api.InteractionEvent
import work.slhaf.partner.api.ModuleEvent import work.slhaf.partner.api.ModuleEvent
import work.slhaf.partner.api.ReplyEvent import work.slhaf.partner.api.ReplyEvent
import work.slhaf.partner.api.SystemEvent import work.slhaf.partner.api.SystemEvent
import work.slhaf.partner.ctl.support.PromptPart
import work.slhaf.partner.ctl.support.PromptStyle
import work.slhaf.partner.ctl.support.TerminalText
internal class ChatEventRenderer { internal class ChatEventRenderer {
fun renderCommittedUserInput(content: String): String = "you: $content" fun renderCommittedUserInput(content: String): String {
return TerminalText.render(
listOf(
PromptPart("you", PromptStyle.CYAN),
PromptPart(": "),
PromptPart(content),
)
)
}
fun renderActiveReply(content: String): String { fun renderActiveReply(content: String): String {
return if (content.isBlank()) { return if (content.isBlank()) {
"assistant:" TerminalText.render(
listOf(
PromptPart("assistant", PromptStyle.GREEN),
PromptPart(":"),
)
)
} else { } else {
"assistant: $content" TerminalText.render(
listOf(
PromptPart("assistant", PromptStyle.GREEN),
PromptPart(": "),
PromptPart(content),
)
)
} }
} }
fun renderEventMessage(event: InteractionEvent): String? { fun renderEventMessage(event: InteractionEvent): String? {
return when (event) { return when (event) {
is ReplyEvent -> null is ReplyEvent -> null
is SystemEvent -> "system: ${event.title}: ${event.content}" is SystemEvent -> TerminalText.render(
is ModuleEvent -> "module:${event.data.module}: ${event.data.content}" listOf(
PromptPart("system", PromptStyle.YELLOW),
PromptPart(": "),
PromptPart(event.title),
PromptPart(": "),
PromptPart(event.content),
)
)
is ModuleEvent -> TerminalText.render(
listOf(
PromptPart("module", PromptStyle.BLUE),
PromptPart(":"),
PromptPart(event.data.module),
PromptPart(": "),
PromptPart(event.data.content),
)
)
} }
} }
fun renderSendFailure(message: String): String = "send failed: $message" fun renderSendFailure(message: String): String {
return TerminalText.render(
listOf(
PromptPart("send failed", PromptStyle.RED),
PromptPart(": "),
PromptPart(message),
)
)
}
} }

View File

@@ -5,6 +5,7 @@ import org.jline.terminal.TerminalBuilder
import work.slhaf.partner.api.InteractionEvent import work.slhaf.partner.api.InteractionEvent
import work.slhaf.partner.api.InteractionEvent.EventStatus import work.slhaf.partner.api.InteractionEvent.EventStatus
import work.slhaf.partner.api.ReplyEvent import work.slhaf.partner.api.ReplyEvent
import work.slhaf.partner.ctl.support.TerminalText
import java.util.concurrent.BlockingQueue import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import kotlin.math.ceil import kotlin.math.ceil
@@ -196,7 +197,7 @@ internal class ChatScreen(
private fun measureDisplayRows(text: String): Int { private fun measureDisplayRows(text: String): Int {
val width = terminal.width.takeIf { it > 0 } ?: DEFAULT_TERMINAL_WIDTH val width = terminal.width.takeIf { it > 0 } ?: DEFAULT_TERMINAL_WIDTH
return text.split('\n').sumOf { line -> return TerminalText.stripAnsi(text).split('\n').sumOf { line ->
ceil(displayWidth(line).coerceAtLeast(1).toDouble() / width.toDouble()) ceil(displayWidth(line).coerceAtLeast(1).toDouble() / width.toDouble())
.toInt() .toInt()
.coerceAtLeast(1) .coerceAtLeast(1)

View File

@@ -0,0 +1,69 @@
package work.slhaf.partner.ctl.support
data class PromptPart(
val text: String,
val style: PromptStyle = PromptStyle.PLAIN,
)
enum class PromptStyle {
PLAIN,
DIM,
BOLD,
CYAN,
GREEN,
YELLOW,
RED,
BLUE,
}
object TerminalText {
val colorEnabled: Boolean
get() = System.getenv("NO_COLOR") == null &&
!(System.getenv("TERM") ?: "").equals("dumb", ignoreCase = true)
fun render(parts: List<PromptPart>, colorEnabled: Boolean = this.colorEnabled): String {
return parts.joinToString(separator = "") { render(it, colorEnabled) }
}
fun render(part: PromptPart, colorEnabled: Boolean = this.colorEnabled): String {
return when (part.style) {
PromptStyle.PLAIN -> part.text
PromptStyle.DIM -> ansi("2", part.text, colorEnabled)
PromptStyle.BOLD -> ansi("1", part.text, colorEnabled)
PromptStyle.CYAN -> ansi("36", part.text, colorEnabled)
PromptStyle.GREEN -> ansi("32", part.text, colorEnabled)
PromptStyle.YELLOW -> ansi("33", part.text, colorEnabled)
PromptStyle.RED -> ansi("31", part.text, colorEnabled)
PromptStyle.BLUE -> ansi("34", part.text, colorEnabled)
}
}
fun ansi(code: String, text: String, colorEnabled: Boolean = this.colorEnabled): String {
if (!colorEnabled) return text
val escape = 27.toChar()
return "$escape[${code}m$text$escape[0m"
}
fun stripAnsi(text: String): String {
val escape = 27.toChar()
val result = StringBuilder(text.length)
var index = 0
while (index < text.length) {
if (text[index] == escape && index + 1 < text.length && text[index + 1] == '[') {
index += 2
while (index < text.length && text[index] !in '@'..'~') {
index++
}
if (index < text.length) {
index++
}
} else {
result.append(text[index])
index++
}
}
return result.toString()
}
}

View File

@@ -8,26 +8,13 @@ import org.jline.reader.LineReaderBuilder
import org.jline.reader.UserInterruptException import org.jline.reader.UserInterruptException
import org.jline.terminal.Terminal import org.jline.terminal.Terminal
import org.jline.terminal.TerminalBuilder import org.jline.terminal.TerminalBuilder
import work.slhaf.partner.ctl.support.PromptPart
import work.slhaf.partner.ctl.support.PromptStyle
import work.slhaf.partner.ctl.support.TerminalText
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
data class PromptPart(
val text: String,
val style: PromptStyle = PromptStyle.PLAIN,
)
enum class PromptStyle {
PLAIN,
DIM,
BOLD,
CYAN,
GREEN,
YELLOW,
RED,
BLUE,
}
class Prompt private constructor( class Prompt private constructor(
private val terminal: Terminal, private val terminal: Terminal,
private val reader: LineReader, private val reader: LineReader,
@@ -46,28 +33,25 @@ class Prompt private constructor(
} }
} }
private val colorEnabled: Boolean = private val colorEnabled: Boolean = TerminalText.colorEnabled
System.getenv("NO_COLOR") == null &&
!(System.getenv("TERM") ?: "").equals("dumb", ignoreCase = true)
private fun ansi(code: String, text: String): String { private fun ansi(code: String, text: String): String {
val escape = 27.toChar() return TerminalText.ansi(code, text, colorEnabled)
return if (colorEnabled) "$escape[${code}m$text$escape[0m" else text
} }
private fun bold(text: String) = ansi("1", text) private fun bold(text: String) = TerminalText.render(PromptPart(text, PromptStyle.BOLD), colorEnabled)
private fun dim(text: String) = ansi("2", text) private fun dim(text: String) = TerminalText.render(PromptPart(text, PromptStyle.DIM), colorEnabled)
private fun cyan(text: String) = ansi("36", text) private fun cyan(text: String) = TerminalText.render(PromptPart(text, PromptStyle.CYAN), colorEnabled)
private fun green(text: String) = ansi("32", text) private fun green(text: String) = TerminalText.render(PromptPart(text, PromptStyle.GREEN), colorEnabled)
private fun yellow(text: String) = ansi("33", text) private fun yellow(text: String) = TerminalText.render(PromptPart(text, PromptStyle.YELLOW), colorEnabled)
private fun red(text: String) = ansi("31", text) private fun red(text: String) = TerminalText.render(PromptPart(text, PromptStyle.RED), colorEnabled)
private fun blue(text: String) = ansi("34", text) private fun blue(text: String) = TerminalText.render(PromptPart(text, PromptStyle.BLUE), colorEnabled)
private fun questionPrefix() = cyan("?") private fun questionPrefix() = cyan("?")
@@ -85,18 +69,7 @@ class Prompt private constructor(
} }
private fun renderPrompt(parts: List<PromptPart>): String { private fun renderPrompt(parts: List<PromptPart>): String {
return parts.joinToString(separator = "") { part -> return TerminalText.render(parts, colorEnabled)
when (part.style) {
PromptStyle.PLAIN -> part.text
PromptStyle.DIM -> dim(part.text)
PromptStyle.BOLD -> bold(part.text)
PromptStyle.CYAN -> cyan(part.text)
PromptStyle.GREEN -> green(part.text)
PromptStyle.YELLOW -> yellow(part.text)
PromptStyle.RED -> red(part.text)
PromptStyle.BLUE -> blue(part.text)
}
}
} }
fun print(message: String) { fun print(message: String) {