From db3c49099c505675feeb710b1734b2de932125bf Mon Sep 17 00:00:00 2001 From: subochev Date: Thu, 17 Sep 2026 13:53:33 +0300 Subject: [PATCH] =?UTF-8?q?refactor(agentik-tui):=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20UI-=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B2=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20ui/=20=D0=BF=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждый 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. --- .../kotlin/pw/binom/agentik/tui/App.kt | 115 ++---------------- .../kotlin/pw/binom/agentik/tui/ui/Footer.kt | 17 +++ .../kotlin/pw/binom/agentik/tui/ui/Header.kt | 26 ++++ .../pw/binom/agentik/tui/ui/HelpOverlay.kt | 26 ++++ .../pw/binom/agentik/tui/ui/HistoryPanel.kt | 34 ++++++ .../pw/binom/agentik/tui/ui/InputLine.kt | 67 ++++++++++ 6 files changed, 181 insertions(+), 104 deletions(-) create mode 100644 agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Footer.kt create mode 100644 agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Header.kt create mode 100644 agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HelpOverlay.kt create mode 100644 agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HistoryPanel.kt create mode 100644 agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/InputLine.kt diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/App.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/App.kt index 0671c58..c4a25da 100644 --- a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/App.kt +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/App.kt @@ -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) - } -} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Footer.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Footer.kt new file mode 100644 index 0000000..f72e5e6 --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Footer.kt @@ -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) +} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Header.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Header.kt new file mode 100644 index 0000000..3a8e518 --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/Header.kt @@ -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, + ) +} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HelpOverlay.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HelpOverlay.kt new file mode 100644 index 0000000..780c56e --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HelpOverlay.kt @@ -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) + } +} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HistoryPanel.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HistoryPanel.kt new file mode 100644 index 0000000..d1743dd --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/HistoryPanel.kt @@ -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" +} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/InputLine.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/InputLine.kt new file mode 100644 index 0000000..2c9f95c --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/ui/InputLine.kt @@ -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 + +/** + * Нижняя строка ввода с курсором. + * При активном стриме ассистента показывает ` ⋯`, иначе ` >`. + * + * Содержимое строки: ` ||` — + * `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 + } + } +}