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.ReplyEvent
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 {
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 {
return if (content.isBlank()) {
"assistant:"
TerminalText.render(
listOf(
PromptPart("assistant", PromptStyle.GREEN),
PromptPart(":"),
)
)
} else {
"assistant: $content"
TerminalText.render(
listOf(
PromptPart("assistant", PromptStyle.GREEN),
PromptPart(": "),
PromptPart(content),
)
)
}
}
fun renderEventMessage(event: InteractionEvent): String? {
return when (event) {
is ReplyEvent -> null
is SystemEvent -> "system: ${event.title}: ${event.content}"
is ModuleEvent -> "module:${event.data.module}: ${event.data.content}"
is SystemEvent -> TerminalText.render(
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.EventStatus
import work.slhaf.partner.api.ReplyEvent
import work.slhaf.partner.ctl.support.TerminalText
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import kotlin.math.ceil
@@ -196,7 +197,7 @@ internal class ChatScreen(
private fun measureDisplayRows(text: String): Int {
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())
.toInt()
.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.terminal.Terminal
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.Path
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(
private val terminal: Terminal,
private val reader: LineReader,
@@ -46,28 +33,25 @@ class Prompt private constructor(
}
}
private val colorEnabled: Boolean =
System.getenv("NO_COLOR") == null &&
!(System.getenv("TERM") ?: "").equals("dumb", ignoreCase = true)
private val colorEnabled: Boolean = TerminalText.colorEnabled
private fun ansi(code: String, text: String): String {
val escape = 27.toChar()
return if (colorEnabled) "$escape[${code}m$text$escape[0m" else text
return TerminalText.ansi(code, text, colorEnabled)
}
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("?")
@@ -85,18 +69,7 @@ class Prompt private constructor(
}
private fun renderPrompt(parts: List<PromptPart>): String {
return parts.joinToString(separator = "") { part ->
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)
}
}
return TerminalText.render(parts, colorEnabled)
}
fun print(message: String) {