feat(partnerctl): add Prompt UI helper with ask/confirm/select and multi-select support

This commit is contained in:
2026-05-03 17:38:56 +08:00
parent cbdb33fefe
commit 40af7a4de6
2 changed files with 449 additions and 0 deletions

View File

@@ -0,0 +1,329 @@
package work.slhaf.partner.ctl.ui
import org.jline.reader.EndOfFileException
import org.jline.reader.LineReader
import org.jline.reader.LineReaderBuilder
import org.jline.reader.UserInterruptException
import org.jline.terminal.Terminal
import org.jline.terminal.TerminalBuilder
class Prompt private constructor(
private val terminal: Terminal,
private val reader: LineReader,
) {
companion object {
fun create(): Prompt {
val terminal = TerminalBuilder.builder()
.system(true)
.dumb(true)
.build()
val reader = LineReaderBuilder.builder()
.terminal(terminal)
.build()
return Prompt(terminal, reader)
}
}
fun print(message: String) {
terminal.writer().print(message)
terminal.writer().flush()
}
fun println(message: String = "") {
terminal.writer().println(message)
terminal.writer().flush()
}
fun blank() = println()
fun section(title: String) {
blank()
println(" $title")
}
fun info(message: String) = println("[info] $message")
fun success(message: String) = println("[ok] $message")
fun warn(message: String) = println("[warn] $message")
fun error(message: String) = println("[error] $message")
fun ask(
label: String,
defaultValue: String? = null,
required: Boolean = true,
validator: ((String) -> String?)? = null,
): String {
val suffix = if (defaultValue != null) " [$defaultValue]" else ""
while (true) {
val input = readLine("$label$suffix: ").trim()
val value = when {
input.isNotEmpty() -> input
defaultValue != null -> defaultValue
!required -> ""
else -> {
error("This value is required.")
continue
}
}
val validationError = validator?.invoke(value) ?: return value
error(validationError)
}
}
fun confirm(label: String, defaultValue: Boolean = true): Boolean {
val suffix = if (defaultValue) " [Y/n]" else " [y/N]"
while (true) {
when (readLine("$label$suffix ").trim().lowercase()) {
"" -> return defaultValue
"y", "yes" -> return true
"n", "no" -> return false
else -> error("Please answer yes or no.")
}
}
}
fun <T> select(
label: String,
choices: List<Choice<T>>,
defaultIndex: Int = 0,
): T {
require(choices.isNotEmpty()) { "choices must not be empty" }
require(defaultIndex in choices.indices) { "defaultIndex must be within choices indices" }
println(label)
choices.forEachIndexed { index, choice ->
val disabled = if (choice.enabled) "" else " (unavailable)"
val description = choice.description?.let { " - $it" } ?: ""
println(" ${index + 1}. ${choice.label}$disabled$description")
}
while (true) {
val input = readLine("Choose [${defaultIndex + 1}]: ").trim()
val selectedIndex = if (input.isEmpty()) defaultIndex else input.toIntOrNull()?.minus(1)
if (selectedIndex != null && selectedIndex in choices.indices) {
val choice = choices[selectedIndex]
if (choice.enabled) return choice.value
error("${choice.label} is not available.")
continue
}
error("Please enter a number between 1 and ${choices.size}.")
}
}
fun <T> multiSelect(
label: String,
choices: List<Choice<T>>,
defaultSelected: Set<Int> = emptySet(),
): List<T> {
require(choices.isNotEmpty()) { "choices must not be empty" }
require(defaultSelected.all { it in choices.indices }) { "defaultSelected must only contain valid choice indices" }
return if (terminal.type == "dumb") {
fallbackMultiSelect(label, choices, defaultSelected)
} else {
try {
interactiveMultiSelect(label, choices, defaultSelected)
} catch (e: PromptCancelledException) {
throw e
} catch (_: Throwable) {
fallbackMultiSelect(label, choices, defaultSelected)
}
}
}
private fun <T> interactiveMultiSelect(
label: String,
choices: List<Choice<T>>,
defaultSelected: Set<Int>,
): List<T> {
val selected = defaultSelected
.filter { choices[it].enabled }
.toMutableSet()
var cursor = choices.indexOfFirst { it.enabled }.takeIf { it >= 0 } ?: 0
var renderedLines = 0
val oldAttributes = terminal.enterRawMode()
try {
while (true) {
renderedLines = renderMultiSelect(label, choices, selected, cursor, renderedLines)
when (readKey()) {
PromptKey.UP -> cursor = moveCursor(choices, cursor, -1)
PromptKey.DOWN -> cursor = moveCursor(choices, cursor, 1)
PromptKey.SPACE -> {
if (choices[cursor].enabled) {
if (!selected.add(cursor)) selected.remove(cursor)
} else {
beep()
}
}
PromptKey.ENTER -> {
terminal.attributes = oldAttributes
println()
return selected.sorted().map { choices[it].value }
}
PromptKey.CANCEL -> throw PromptCancelledException()
PromptKey.OTHER -> Unit
}
}
} finally {
terminal.attributes = oldAttributes
}
}
private fun <T> fallbackMultiSelect(
label: String,
choices: List<Choice<T>>,
defaultSelected: Set<Int>,
): List<T> {
println(label)
choices.forEachIndexed { index, choice ->
val selected = if (index in defaultSelected) "x" else " "
val disabled = if (choice.enabled) "" else " (unavailable)"
val description = choice.description?.let { " - $it" } ?: ""
println(" ${index + 1}. [$selected] ${choice.label}$disabled$description")
}
println("Enter numbers separated by comma. Leave empty to use defaults.")
while (true) {
val input = readLine("Select: ").trim()
val selectedIndices = if (input.isEmpty()) {
defaultSelected
} else {
parseIndexList(input, choices.size)
}
if (selectedIndices == null) {
error("Please enter numbers between 1 and ${choices.size}, separated by comma.")
continue
}
val disabled = selectedIndices.map { choices[it] }.filterNot { it.enabled }
if (disabled.isNotEmpty()) {
error("Unavailable choice selected: ${disabled.joinToString { it.label }}")
continue
}
return selectedIndices.sorted().map { choices[it].value }
}
}
private fun <T> renderMultiSelect(
label: String,
choices: List<Choice<T>>,
selected: Set<Int>,
cursor: Int,
previousLines: Int,
): Int {
if (previousLines > 0) {
terminal.writer().print("\u001B[${previousLines}A")
terminal.writer().print("\u001B[J")
}
val lines = buildList {
add(label)
add(" ↑/↓ move, Space toggle, Enter confirm")
choices.forEachIndexed { index, choice ->
val pointer = if (index == cursor) "" else " "
val checked = if (index in selected) "x" else " "
val disabled = if (choice.enabled) "" else " (unavailable)"
val description = choice.description?.let { " - $it" } ?: ""
add("$pointer [$checked] ${choice.label}$disabled$description")
}
}
lines.forEach { terminal.writer().println(it) }
terminal.writer().flush()
return lines.size
}
private fun moveCursor(choices: List<Choice<*>>, current: Int, delta: Int): Int {
var next = current
repeat(choices.size) {
next = (next + delta + choices.size) % choices.size
if (choices[next].enabled) return next
}
return current
}
private fun readKey(): PromptKey {
return when (val first = terminal.reader().read()) {
-1, 3, 4 -> PromptKey.CANCEL
10, 13 -> PromptKey.ENTER
32 -> PromptKey.SPACE
27 -> readEscapeKey()
else -> when (first.toChar()) {
'k', 'K' -> PromptKey.UP
'j', 'J' -> PromptKey.DOWN
'q', 'Q' -> PromptKey.CANCEL
else -> PromptKey.OTHER
}
}
}
private fun readEscapeKey(): PromptKey {
val second = terminal.reader().read(50L)
if (second == -1) return PromptKey.CANCEL
if (second != '['.code) return PromptKey.OTHER
return when (terminal.reader().read(50L)) {
'A'.code -> PromptKey.UP
'B'.code -> PromptKey.DOWN
else -> PromptKey.OTHER
}
}
private fun beep() {
terminal.writer().print('\u0007')
terminal.writer().flush()
}
private fun readLine(prompt: String): String {
return try {
reader.readLine(prompt)
} catch (_: UserInterruptException) {
throw PromptCancelledException()
} catch (_: EndOfFileException) {
throw PromptCancelledException()
}
}
private fun parseIndexList(input: String, size: Int): Set<Int>? {
if (input.isBlank()) return emptySet()
return input
.split(',', ' ')
.filter { it.isNotBlank() }
.map { it.toIntOrNull()?.minus(1) ?: return null }
.takeIf { indices -> indices.all { it in 0 until size } }
?.toSet()
}
}
data class Choice<T>(
val label: String,
val value: T,
val description: String? = null,
val enabled: Boolean = true,
)
private enum class PromptKey {
UP,
DOWN,
SPACE,
ENTER,
CANCEL,
OTHER,
}
class PromptCancelledException : RuntimeException()

View File

@@ -0,0 +1,120 @@
package experimental
private enum class DemoAction {
HOME,
RUNTIME,
CONFIGURATION,
FINISH,
}
private enum class DemoModule {
WEBSOCKET_GATEWAY,
ONEBOT_ADAPTER,
TELEGRAM_ADAPTER,
}
fun main() {
val prompt = _root_ide_package_.work.slhaf.partner.ctl.ui.Prompt.Companion.create()
try {
prompt.section("Output")
prompt.println("Plain println")
prompt.print("Plain print")
prompt.println(" + println")
prompt.info("This is an info message.")
prompt.success("This is a success message.")
prompt.warn("This is a warning message.")
prompt.error("This is an error message.")
prompt.blank()
prompt.section("Ask")
val required = prompt.ask(
label = "Required value",
required = true,
)
prompt.info("Required value = $required")
val withDefault = prompt.ask(
label = "Value with default",
defaultValue = "default-value",
)
prompt.info("Value with default = $withDefault")
val optional = prompt.ask(
label = "Optional value",
required = false,
)
prompt.info("Optional value = ${optional.ifBlank { "<blank>" }}")
val port = prompt.ask(
label = "Port",
defaultValue = "8765",
) { value ->
val number = value.toIntOrNull()
when (number) {
null -> "Port must be a number."
!in 1..65535 -> "Port must be between 1 and 65535."
else -> null
}
}
prompt.info("Port = $port")
prompt.section("Confirm")
val confirmed = prompt.confirm("Continue?", defaultValue = true)
prompt.info("Continue = $confirmed")
prompt.section("Select")
val action = prompt.select(
label = "Choose next action:",
choices = listOf(
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice("Home", DemoAction.HOME, "Configure Partner home"),
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice(
"Runtime",
DemoAction.RUNTIME,
"Install Partner runtime"
),
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice(
"Configuration",
DemoAction.CONFIGURATION,
"Configure model and gateway"
),
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice(
"Download release",
DemoAction.FINISH,
"Not available yet",
enabled = false
),
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice("Finish", DemoAction.FINISH),
),
defaultIndex = 0,
)
prompt.info("Selected action = $action")
prompt.section("Multi select")
val modules = prompt.multiSelect(
label = "Select modules:",
choices = listOf(
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice(
"WebSocket gateway",
DemoModule.WEBSOCKET_GATEWAY,
"Local WebSocket gateway"
),
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice("OneBot adapter", DemoModule.ONEBOT_ADAPTER),
_root_ide_package_.work.slhaf.partner.ctl.ui.Choice(
"Telegram adapter",
DemoModule.TELEGRAM_ADAPTER,
"Not available yet",
enabled = false
),
),
defaultSelected = setOf(0),
)
prompt.info("Selected modules = ${modules.joinToString()}")
prompt.section("Done")
prompt.success("Prompt demo completed.")
} catch (_: work.slhaf.partner.ctl.ui.PromptCancelledException) {
prompt.blank()
prompt.warn("Prompt demo cancelled.")
}
}