diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt index 83c724cb..0a1e1c48 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatEventRenderer.kt @@ -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), + ) + ) + } } diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt index a9a6cbf1..908b1093 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/chat/ChatScreen.kt @@ -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) diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/TerminalText.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/TerminalText.kt new file mode 100644 index 00000000..3a8a1a15 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/TerminalText.kt @@ -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, 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() + } +} diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt index 269f1b95..03864c9f 100644 --- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt @@ -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): 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) {