From 6f48c36f672333c33aefc8b1da608710ed625649 Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Sun, 3 May 2026 18:45:03 +0800 Subject: [PATCH] feat(partnerctl): add interactive single select --- .../java/work/slhaf/partner/ctl/ui/Prompt.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) 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 9783cc72..399f9de9 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 @@ -96,6 +96,63 @@ class Prompt private constructor( require(choices.isNotEmpty()) { "choices must not be empty" } require(defaultIndex in choices.indices) { "defaultIndex must be within choices indices" } + return if (terminal.type == "dumb") { + fallbackSelect(label, choices, defaultIndex) + } else { + try { + interactiveSelect(label, choices, defaultIndex) + } catch (e: PromptCancelledException) { + throw e + } catch (_: Throwable) { + fallbackSelect(label, choices, defaultIndex) + } + } + } + + private fun interactiveSelect( + label: String, + choices: List>, + defaultIndex: Int, + ): T { + var cursor = when { + choices[defaultIndex].enabled -> defaultIndex + else -> choices.indexOfFirst { it.enabled }.takeIf { it >= 0 } ?: defaultIndex + } + var renderedLines = 0 + val oldAttributes = terminal.enterRawMode() + + try { + while (true) { + renderedLines = renderSelect(label, choices, cursor, renderedLines) + + when (readKey()) { + PromptKey.UP -> cursor = moveCursor(choices, cursor, -1) + PromptKey.DOWN -> cursor = moveCursor(choices, cursor, 1) + PromptKey.ENTER -> { + val choice = choices[cursor] + if (choice.enabled) { + terminal.attributes = oldAttributes + println() + return choice.value + } + beep() + } + + PromptKey.CANCEL -> throw PromptCancelledException() + PromptKey.SPACE, + PromptKey.OTHER -> Unit + } + } + } finally { + terminal.attributes = oldAttributes + } + } + + private fun fallbackSelect( + label: String, + choices: List>, + defaultIndex: Int, + ): T { println(label) choices.forEachIndexed { index, choice -> val disabled = if (choice.enabled) "" else " (unavailable)" @@ -118,6 +175,33 @@ class Prompt private constructor( } } + private fun renderSelect( + label: String, + choices: List>, + 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, Enter confirm") + choices.forEachIndexed { index, choice -> + val pointer = if (index == cursor) "❯" else " " + val disabled = if (choice.enabled) "" else " (unavailable)" + val description = choice.description?.let { " - $it" } ?: "" + add("$pointer ${choice.label}$disabled$description") + } + } + + lines.forEach { terminal.writer().println(it) } + terminal.writer().flush() + return lines.size + } + fun multiSelect( label: String, choices: List>,