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.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.Box
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Row
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.jakewharton.mosaic.ui.TextStyle
|
||||
import pw.binom.agentik.tui.ui.Footer
|
||||
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 (минимальный):
|
||||
* ```
|
||||
@@ -28,6 +29,9 @@ import com.jakewharton.mosaic.ui.TextStyle
|
||||
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||
* └─────────────────────────────────────────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* Глобальные клавиши (Tab/Shift-Tab/F1/Esc) обрабатываются здесь.
|
||||
* Клавиши внутри строки ввода — в [InputLine] (через свой `onPreviewKeyEvent`).
|
||||
*/
|
||||
@Composable
|
||||
internal fun App(state: AppState) {
|
||||
@@ -50,105 +54,8 @@ internal fun App(state: AppState) {
|
||||
Header(state, focusIndex)
|
||||
HistoryPanel(state)
|
||||
InputLine(state)
|
||||
Footer(state, showHelp)
|
||||
Footer(showHelp)
|
||||
}
|
||||
}
|
||||
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