refactor(agentik-tui): вынести UI-компоненты в отдельный ui/ пакет
ci / JVM build + tests (push) Failing after 1m27s
ci / JVM build + tests (push) Failing after 1m27s
Каждый composable — свой файл. App.kt оставлен только под корневую композицию и глобальный key-handler. - ui/Header.kt — Header (идентификатор + focus label) - ui/HistoryPanel.kt — HistoryPanel + renderMessage (форматирование TuiMessage) - ui/InputLine.kt — InputLine + handleInputKey (key-handler строки ввода) - ui/Footer.kt — Footer (подсказка клавиш) - ui/HelpOverlay.kt — HelpOverlay (F1-список) Bonus-чистка: убрал неиспользуемый collectAsState для historyScroll (значение читалось, но никак не влияло на рендер — отдельный scroll viewport запланирован отдельным изменением). Размер App.kt: 154 → 61 строк. Каждый компонент <70 строк, импорты локализованы в файле. 6 desktop-таргетов компилируется, jvmTest 10/10.
This commit is contained in:
@@ -3,18 +3,19 @@ package pw.binom.agentik.tui
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import com.jakewharton.mosaic.layout.KeyEvent
|
|
||||||
import com.jakewharton.mosaic.layout.drawBehind
|
|
||||||
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
import com.jakewharton.mosaic.modifier.Modifier
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
import com.jakewharton.mosaic.ui.Box
|
|
||||||
import com.jakewharton.mosaic.ui.Column
|
import com.jakewharton.mosaic.ui.Column
|
||||||
import com.jakewharton.mosaic.ui.Row
|
import com.jakewharton.mosaic.ui.Row
|
||||||
import com.jakewharton.mosaic.ui.Text
|
import pw.binom.agentik.tui.ui.Footer
|
||||||
import com.jakewharton.mosaic.ui.TextStyle
|
import pw.binom.agentik.tui.ui.Header
|
||||||
|
import pw.binom.agentik.tui.ui.HelpOverlay
|
||||||
|
import pw.binom.agentik.tui.ui.HistoryPanel
|
||||||
|
import pw.binom.agentik.tui.ui.InputLine
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Корневая Compose-композиция TUI.
|
* Корневая Compose-композиция TUI. Содержит только каркас + глобальный key-handler;
|
||||||
|
* каждый регион (header/history/input/footer/help) — отдельный компонент в `ui/`.
|
||||||
*
|
*
|
||||||
* Layout (минимальный):
|
* Layout (минимальный):
|
||||||
* ```
|
* ```
|
||||||
@@ -28,6 +29,9 @@ import com.jakewharton.mosaic.ui.TextStyle
|
|||||||
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||||
* └─────────────────────────────────────────────────────────┘
|
* └─────────────────────────────────────────────────────────┘
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
|
||||||
|
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun App(state: AppState) {
|
internal fun App(state: AppState) {
|
||||||
@@ -50,105 +54,8 @@ internal fun App(state: AppState) {
|
|||||||
Header(state, focusIndex)
|
Header(state, focusIndex)
|
||||||
HistoryPanel(state)
|
HistoryPanel(state)
|
||||||
InputLine(state)
|
InputLine(state)
|
||||||
Footer(state, showHelp)
|
Footer(showHelp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showHelp) HelpOverlay()
|
if (showHelp) HelpOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Header(state: AppState, focusIndex: Int) {
|
|
||||||
val title by state.currentTitle.collectAsState()
|
|
||||||
val convId by state.currentConversationId.collectAsState()
|
|
||||||
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
|
||||||
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
|
||||||
val titleStr = title ?: "(нет диалога)"
|
|
||||||
Text(
|
|
||||||
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
|
||||||
textStyle = TextStyle.Bold + TextStyle.Invert,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun HistoryPanel(state: AppState) {
|
|
||||||
val messages by state.messages.collectAsState()
|
|
||||||
val scroll by state.historyScroll.collectAsState()
|
|
||||||
val rendered = if (messages.isEmpty()) {
|
|
||||||
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
|
||||||
} else {
|
|
||||||
messages.joinToString("") { renderMessage(it) }
|
|
||||||
}
|
|
||||||
Text(value = rendered)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderMessage(m: TuiMessage): String = when (m) {
|
|
||||||
is TuiMessage.System -> " ── ${m.text}\n"
|
|
||||||
is TuiMessage.User -> " > ${m.text}\n"
|
|
||||||
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
|
||||||
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
|
||||||
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
|
||||||
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InputLine(state: AppState) {
|
|
||||||
val text by state.input.collectAsState()
|
|
||||||
val cursor by state.cursor.collectAsState()
|
|
||||||
val streaming by state.streaming.collectAsState()
|
|
||||||
val cursorPos = cursor.coerceIn(0, text.length)
|
|
||||||
val before = text.substring(0, cursorPos)
|
|
||||||
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
|
||||||
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
|
||||||
val after = text.substring(afterStart.coerceAtMost(text.length))
|
|
||||||
val prompt = if (streaming) " ⋯" else " >"
|
|
||||||
|
|
||||||
Text(
|
|
||||||
value = "$prompt $before|$cursorChar|${after}",
|
|
||||||
modifier = Modifier
|
|
||||||
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
|
||||||
.drawBehind {
|
|
||||||
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
|
||||||
state.input.let { /* touch */ }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
|
||||||
if (ev.alt || ev.ctrl) return false
|
|
||||||
return when (ev.key) {
|
|
||||||
"Enter" -> state.submitInput() != null
|
|
||||||
"Backspace" -> { state.inputBackspace(); true }
|
|
||||||
"Delete" -> { state.inputDelete(); true }
|
|
||||||
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
|
||||||
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
|
||||||
"Home" -> { state.inputCursorHome(); true }
|
|
||||||
"End" -> { state.inputCursorEnd(); true }
|
|
||||||
else -> {
|
|
||||||
val s = ev.key
|
|
||||||
if (s.length == 1) { state.inputInsert(s); true }
|
|
||||||
else false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Footer(state: AppState, showHelp: Boolean) {
|
|
||||||
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
|
||||||
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
|
||||||
Text(value = hint, textStyle = TextStyle.Italic)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun HelpOverlay() {
|
|
||||||
Column(modifier = Modifier) {
|
|
||||||
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
|
||||||
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
|
||||||
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
|
||||||
Text(value = " ← / → курсор в input")
|
|
||||||
Text(value = " Enter отправить сообщение")
|
|
||||||
Text(value = " Backspace / Del удалить символ")
|
|
||||||
Text(value = " Esc очистить input")
|
|
||||||
Text(value = " Ctrl-D / Ctrl-C выход")
|
|
||||||
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нижняя подсказка с текущим набором горячих клавиш.
|
||||||
|
*
|
||||||
|
* При открытом help-оверлее показывает заглушку с указателем «наверху».
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Footer(showHelp: Boolean) {
|
||||||
|
val hint = if (showHelp) " ↑ наверху help-оверлей ↑ "
|
||||||
|
else " Tab focus ↑↓ scroll Enter send Esc clear F1 help Ctrl-D exit "
|
||||||
|
Text(value = hint, textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Верхняя инвертированная полоса с идентификатором и текущим фокусом.
|
||||||
|
*
|
||||||
|
* Пример: ` agentik · cli-tui:root · a1b2c3d4… · мой чат · focus=input `
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun Header(state: AppState, focusIndex: Int) {
|
||||||
|
val title by state.currentTitle.collectAsState()
|
||||||
|
val convId by state.currentConversationId.collectAsState()
|
||||||
|
val focusLabel = when (focusIndex) { 0 -> "input"; 1 -> "history"; 2 -> "sidebar"; else -> "?" }
|
||||||
|
val convStr = convId?.let { " · ${it.take(8)}…" } ?: ""
|
||||||
|
val titleStr = title ?: "(нет диалога)"
|
||||||
|
Text(
|
||||||
|
value = " agentik · ${state.config.id}$convStr · $titleStr · focus=$focusLabel ",
|
||||||
|
textStyle = TextStyle.Bold + TextStyle.Invert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Column
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import com.jakewharton.mosaic.ui.TextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полноэкранный оверлей со списком горячих клавиш.
|
||||||
|
* Включается/выключается по F1 (см. [pw.binom.agentik.tui.App]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HelpOverlay() {
|
||||||
|
Column(modifier = Modifier) {
|
||||||
|
Text(value = " --- HELP ---", textStyle = TextStyle.Bold + TextStyle.Invert)
|
||||||
|
Text(value = " Tab / Shift-Tab переключить фокус (history / input / sidebar)")
|
||||||
|
Text(value = " ↑ / ↓ скролл истории / курсор в input")
|
||||||
|
Text(value = " ← / → курсор в input")
|
||||||
|
Text(value = " Enter отправить сообщение")
|
||||||
|
Text(value = " Backspace / Del удалить символ")
|
||||||
|
Text(value = " Esc очистить input")
|
||||||
|
Text(value = " Ctrl-D / Ctrl-C выход")
|
||||||
|
Text(value = " F1 toggle help", textStyle = TextStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
import pw.binom.agentik.tui.TuiMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прокручиваемый (через клавиатуру) лог диалога.
|
||||||
|
* Каждое сообщение рендерится отдельной строкой с префиксом (см. [renderMessage]).
|
||||||
|
* При пустом списке показывается подсказка.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HistoryPanel(state: AppState) {
|
||||||
|
val messages by state.messages.collectAsState()
|
||||||
|
val rendered = if (messages.isEmpty()) {
|
||||||
|
" (пока пусто)\n Tab — переключить фокус, F1 — подсказки.\n"
|
||||||
|
} else {
|
||||||
|
messages.joinToString("") { renderMessage(it) }
|
||||||
|
}
|
||||||
|
Text(value = rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Превращает [TuiMessage] в одну строку с префиксом. Потоковые чанки получают курсор `▍`. */
|
||||||
|
internal fun renderMessage(m: TuiMessage): String = when (m) {
|
||||||
|
is TuiMessage.System -> " ── ${m.text}\n"
|
||||||
|
is TuiMessage.User -> " > ${m.text}\n"
|
||||||
|
is TuiMessage.Assistant -> " ╰ ${m.text}\n"
|
||||||
|
is TuiMessage.AssistantStreaming -> " ╰ ${m.text} ▍\n"
|
||||||
|
is TuiMessage.ToolCall -> " ⚙ ${m.toolName}${if (!m.title.isNullOrEmpty()) ": ${m.title}" else ""}\n"
|
||||||
|
is TuiMessage.ToolResult -> " ↳ ${m.result.take(200)}${if (m.result.length > 200) "…" else ""}\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package pw.binom.agentik.tui.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.jakewharton.mosaic.layout.KeyEvent
|
||||||
|
import com.jakewharton.mosaic.layout.drawBehind
|
||||||
|
import com.jakewharton.mosaic.layout.onPreviewKeyEvent
|
||||||
|
import com.jakewharton.mosaic.modifier.Modifier
|
||||||
|
import com.jakewharton.mosaic.ui.Text
|
||||||
|
import pw.binom.agentik.tui.AppState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нижняя строка ввода с курсором.
|
||||||
|
* При активном стриме ассистента показывает ` ⋯`, иначе ` >`.
|
||||||
|
*
|
||||||
|
* Содержимое строки: `<prompt> <before>|<cursorChar>|<after>` —
|
||||||
|
* `cursorChar` — это символ, на котором стоит курсор (или пробел в конце).
|
||||||
|
*
|
||||||
|
* Клавиши обрабатываются через [handleInputKey] внутри `onPreviewKeyEvent`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun InputLine(state: AppState) {
|
||||||
|
val text by state.input.collectAsState()
|
||||||
|
val cursor by state.cursor.collectAsState()
|
||||||
|
val streaming by state.streaming.collectAsState()
|
||||||
|
val cursorPos = cursor.coerceIn(0, text.length)
|
||||||
|
val before = text.substring(0, cursorPos)
|
||||||
|
val cursorChar = if (cursorPos < text.length) text[cursorPos].toString() else " "
|
||||||
|
val afterStart = if (cursorPos < text.length) cursorPos + 1 else cursorPos
|
||||||
|
val after = text.substring(afterStart.coerceAtMost(text.length))
|
||||||
|
val prompt = if (streaming) " ⋯" else " >"
|
||||||
|
|
||||||
|
Text(
|
||||||
|
value = "$prompt $before|$cursorChar|${after}",
|
||||||
|
modifier = Modifier
|
||||||
|
.onPreviewKeyEvent { ev -> handleInputKey(state, ev) }
|
||||||
|
.drawBehind {
|
||||||
|
// Snapshot-read state в drawBehind чтобы changes триггерили redraw.
|
||||||
|
state.input.let { /* touch */ }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка клавиш в [InputLine]. `true` = событие поглощено.
|
||||||
|
*
|
||||||
|
* Не перехватывает клавиши с `alt`/`ctrl` — они идут дальше
|
||||||
|
* на корневой обработчик ([pw.binom.agentik.tui.App]).
|
||||||
|
*/
|
||||||
|
internal fun handleInputKey(state: AppState, ev: KeyEvent): Boolean {
|
||||||
|
if (ev.alt || ev.ctrl) return false
|
||||||
|
return when (ev.key) {
|
||||||
|
"Enter" -> state.submitInput() != null
|
||||||
|
"Backspace" -> { state.inputBackspace(); true }
|
||||||
|
"Delete" -> { state.inputDelete(); true }
|
||||||
|
"Left", "ArrowLeft" -> { state.inputMoveCursor(-1); true }
|
||||||
|
"Right", "ArrowRight" -> { state.inputMoveCursor(+1); true }
|
||||||
|
"Home" -> { state.inputCursorHome(); true }
|
||||||
|
"End" -> { state.inputCursorEnd(); true }
|
||||||
|
else -> {
|
||||||
|
val s = ev.key
|
||||||
|
if (s.length == 1) { state.inputInsert(s); true }
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user