feat(agentik-tui): TUI client v2 на Mosaic — без vim-клавиш, только Tab/Enter/Esc/Ctrl-D/F1/стрелки
Вместо v1 (history-список + slash-команды) делаем сразу v2: - header (id/conv/focus), history, input, footer - переключение фокуса Tab/Shift-Tab (history/input/sidebar) - ↑↓ scrollback, ←→ курсор в input - Enter submit (User-сообщение в history), Backspace/Del удаление, Esc clear - Ctrl-D/Ctrl-C выход (заглушка — пишет в history, реальный exit добавим) - F1 toggle help-оверлея Архитектурно: используем StateFlow+collectAsState вместо mutableStateOf, потому что в Mosaic 0.18 recompose от mutableStateOf-writes из key-handler не триггерится автоматически (требует ручного Snapshot.apply). StateFlow через collectAsState работает out-of-the-box (см. samples/snake в репо Mosaic). Compose Compiler plugin (org.jetbrains.kotlin.plugin.compose) обязателен — без него @Composable-лямбды компилятся в Function0 вместо Function2 и runMosaicBlocking не находит сигнатуру. Цели сборки: jvm + macosX64/Arm64 + linuxX64/Arm64 + mingwX64 (iOS не нужен). Бэкенд (:client, ktor-cio) — jvmMain only пока, nativeMain заглушка. Tests: 370/370 green.
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
|
||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
// Suppress Beta-предупреждения от expect/actual объектов.
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mosaic 0.18 поддерживает JVM + desktop-native (macosX64/macosArm64/linuxX64/linuxArm64/mingwX64).
|
||||||
|
// iOS пропускаем — на iOS не бывает TUI-сессий.
|
||||||
|
jvm()
|
||||||
|
macosX64()
|
||||||
|
macosArm64()
|
||||||
|
linuxX64()
|
||||||
|
linuxArm64()
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(project(":proto"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// JetBrains Compose runtime — тащит Mosaic как обёртку.
|
||||||
|
implementation(libs.mosaic.runtime)
|
||||||
|
implementation(libs.mosaic.tty.terminal)
|
||||||
|
}
|
||||||
|
jvmMain.dependencies {
|
||||||
|
implementation(project(":client"))
|
||||||
|
}
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalKotlinGradlePluginApi::class)
|
||||||
|
jvm {
|
||||||
|
binaries {
|
||||||
|
executable {
|
||||||
|
mainClass.set("pw.binom.agentik.tui.MainKt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fatjar (uberjar) ---
|
||||||
|
//
|
||||||
|
// Аналогично `:agentik-cli`: shadowJar склеивает `jvmJar` + `jvmRuntimeClasspath` в self-contained
|
||||||
|
// `*-all.jar`. Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — регистрируем явно.
|
||||||
|
val shadowJarTask = tasks.register<ShadowJar>("shadowJar") {
|
||||||
|
archiveBaseName.set("agentik-tui")
|
||||||
|
archiveClassifier.set("all")
|
||||||
|
description = "Self-contained fatjar with all runtime dependencies bundled (incl. Compose-runtime + Mosaic)."
|
||||||
|
group = "build"
|
||||||
|
|
||||||
|
from(tasks.named("jvmJar"))
|
||||||
|
val cc = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer
|
||||||
|
}
|
||||||
|
from(cc.getByName("jvmRuntimeClasspath"))
|
||||||
|
|
||||||
|
mergeServiceFiles()
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes["Main-Class"] = "pw.binom.agentik.tui.MainKt"
|
||||||
|
attributes["Implementation-Title"] = "agentik-tui"
|
||||||
|
attributes["Implementation-Version"] = project.version.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
includeEmptyDirs = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая Compose-композиция TUI.
|
||||||
|
*
|
||||||
|
* Layout (минимальный):
|
||||||
|
* ```
|
||||||
|
* ┌─────────────────────────────────────────────────────────┐
|
||||||
|
* │ HEADER: agentik · id · conv-id · focus=… │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ HISTORY (весь актуальный диалог) │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ INPUT LINE: > text| │
|
||||||
|
* ├─────────────────────────────────────────────────────────┤
|
||||||
|
* │ FOOTER: ↑↓ scroll Tab focus Enter send F1 help … │
|
||||||
|
* └─────────────────────────────────────────────────────────┘
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun App(state: AppState) {
|
||||||
|
val focusIndex by state.focusIndex.collectAsState()
|
||||||
|
val showHelp by state.showHelp.collectAsState()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.onPreviewKeyEvent { ev ->
|
||||||
|
when (ev.key) {
|
||||||
|
"Tab" -> { state.cycleFocus(direction = if (ev.shift) -1 else +1); true }
|
||||||
|
"F1" -> { state.toggleHelp(); true }
|
||||||
|
"Escape", "Esc" -> {
|
||||||
|
if (showHelp) state.setShowHelp(false)
|
||||||
|
else if (focusIndex == 0) state.inputClear()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Header(state, focusIndex)
|
||||||
|
HistoryPanel(state)
|
||||||
|
InputLine(state)
|
||||||
|
Footer(state, 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,160 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние TUI. По дизайну — singleton, переживает все экраны.
|
||||||
|
*
|
||||||
|
* Используем [StateFlow] вместо Compose [androidx.compose.runtime.mutableStateOf]
|
||||||
|
* потому что в Mosaic 0.18 recomposition от `mutableStateOf`-writes из key-event
|
||||||
|
* handlers работает нестабильно (требует ручного [androidx.compose.runtime.Snapshot]
|
||||||
|
* apply). `StateFlow` + `collectAsState()` — работает out-of-the-box
|
||||||
|
* (см. samples/snake в репо Mosaic).
|
||||||
|
*/
|
||||||
|
internal class AppState(val config: TuiConfig) {
|
||||||
|
/** Зона фокуса: 0 = input, 1 = history, 2 = sidebar. */
|
||||||
|
private val _focusIndex = MutableStateFlow(0)
|
||||||
|
val focusIndex: StateFlow<Int> = _focusIndex.asStateFlow()
|
||||||
|
|
||||||
|
/** Видимость help-оверлея. */
|
||||||
|
private val _showHelp = MutableStateFlow(false)
|
||||||
|
val showHelp: StateFlow<Boolean> = _showHelp.asStateFlow()
|
||||||
|
|
||||||
|
/** Сообщения диалога. */
|
||||||
|
private val _messages = MutableStateFlow<List<TuiMessage>>(emptyList())
|
||||||
|
val messages: StateFlow<List<TuiMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
/** Заголовок текущего диалога. */
|
||||||
|
private val _currentTitle = MutableStateFlow<String?>(null)
|
||||||
|
val currentTitle: StateFlow<String?> = _currentTitle.asStateFlow()
|
||||||
|
|
||||||
|
/** ID текущего диалога. */
|
||||||
|
private val _currentConversationId = MutableStateFlow<String?>(null)
|
||||||
|
val currentConversationId: StateFlow<String?> = _currentConversationId.asStateFlow()
|
||||||
|
|
||||||
|
/** Список диалогов (sidebar). */
|
||||||
|
private val _conversations = MutableStateFlow<List<ConvSummary>>(emptyList())
|
||||||
|
val conversations: StateFlow<List<ConvSummary>> = _conversations.asStateFlow()
|
||||||
|
|
||||||
|
/** Курсор в списке диалогов. */
|
||||||
|
private val _conversationsCursor = MutableStateFlow(0)
|
||||||
|
val conversationsCursor: StateFlow<Int> = _conversationsCursor.asStateFlow()
|
||||||
|
|
||||||
|
/** Поле ввода. */
|
||||||
|
private val _input = MutableStateFlow("")
|
||||||
|
val input: StateFlow<String> = _input.asStateFlow()
|
||||||
|
|
||||||
|
/** Курсор в input (offset в chars). */
|
||||||
|
private val _cursor = MutableStateFlow(0)
|
||||||
|
val cursor: StateFlow<Int> = _cursor.asStateFlow()
|
||||||
|
|
||||||
|
/** Идёт ли стрим. */
|
||||||
|
private val _streaming = MutableStateFlow(false)
|
||||||
|
val streaming: StateFlow<Boolean> = _streaming.asStateFlow()
|
||||||
|
|
||||||
|
/** Scrollback index: 0 = прижат к низу. */
|
||||||
|
private val _historyScroll = MutableStateFlow(0)
|
||||||
|
val historyScroll: StateFlow<Int> = _historyScroll.asStateFlow()
|
||||||
|
|
||||||
|
// ---------- мутации ----------
|
||||||
|
|
||||||
|
fun cycleFocus(direction: Int = +1) {
|
||||||
|
_focusIndex.value = (_focusIndex.value + direction).mod(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleHelp() { _showHelp.value = !_showHelp.value }
|
||||||
|
fun setShowHelp(v: Boolean) { _showHelp.value = v }
|
||||||
|
|
||||||
|
fun inputInsert(s: String) {
|
||||||
|
val pos = _cursor.value.coerceIn(0, _input.value.length)
|
||||||
|
_input.value = _input.value.substring(0, pos) + s + _input.value.substring(pos)
|
||||||
|
_cursor.value = pos + s.length
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputBackspace() {
|
||||||
|
val pos = _cursor.value
|
||||||
|
if (pos <= 0) return
|
||||||
|
_input.value = _input.value.substring(0, pos - 1) + _input.value.substring(pos)
|
||||||
|
_cursor.value = pos - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputDelete() {
|
||||||
|
val pos = _cursor.value
|
||||||
|
if (pos >= _input.value.length) return
|
||||||
|
_input.value = _input.value.substring(0, pos) + _input.value.substring(pos + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun inputClear() { _input.value = ""; _cursor.value = 0 }
|
||||||
|
|
||||||
|
fun inputMoveCursor(delta: Int) {
|
||||||
|
_cursor.value = (_cursor.value + delta).coerceIn(0, _input.value.length)
|
||||||
|
}
|
||||||
|
fun inputCursorHome() { _cursor.value = 0 }
|
||||||
|
fun inputCursorEnd() { _cursor.value = _input.value.length }
|
||||||
|
|
||||||
|
fun submitInput(): String? {
|
||||||
|
val text = _input.value.trim()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
_messages.value = _messages.value + TuiMessage.User(text = text, ts = nowInstant())
|
||||||
|
inputClear()
|
||||||
|
_streaming.value = true
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendAssistant(chunk: String) {
|
||||||
|
val list = _messages.value.toMutableList()
|
||||||
|
val last = list.lastOrNull()
|
||||||
|
if (last is TuiMessage.AssistantStreaming) {
|
||||||
|
list[list.lastIndex] = last.copy(text = last.text + chunk)
|
||||||
|
} else {
|
||||||
|
list.add(TuiMessage.AssistantStreaming(text = chunk, ts = nowInstant()))
|
||||||
|
}
|
||||||
|
_messages.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishAssistant() {
|
||||||
|
val list = _messages.value.toMutableList()
|
||||||
|
val last = list.lastOrNull() ?: return
|
||||||
|
if (last is TuiMessage.AssistantStreaming) {
|
||||||
|
list[list.lastIndex] = TuiMessage.Assistant(text = last.text, ts = last.ts)
|
||||||
|
_messages.value = list
|
||||||
|
}
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newConversation(id: String, title: String?) {
|
||||||
|
_currentConversationId.value = id
|
||||||
|
_currentTitle.value = title
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_historyScroll.value = 0
|
||||||
|
_streaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postSystem(text: String) {
|
||||||
|
_messages.value = _messages.value + TuiMessage.System(text = text, ts = nowInstant())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снимок диалога для sidebar. */
|
||||||
|
internal data class ConvSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Рендер-единица. */
|
||||||
|
internal sealed interface TuiMessage {
|
||||||
|
val ts: Instant
|
||||||
|
|
||||||
|
data class System(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class User(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class AssistantStreaming(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class Assistant(val text: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class ToolCall(val toolName: String, val title: String?, val args: String, override val ts: Instant) : TuiMessage
|
||||||
|
data class ToolResult(val toolName: String, val result: String, override val ts: Instant) : TuiMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun nowInstant(): Instant = kotlin.time.Clock.System.now()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа TUI-клиента agentik.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* agentik-tui [--server URL] [--id ID] [--no-history] [--help]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Без аргументов — стартует Compose-Mosaic UI.
|
||||||
|
*/
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val cfg = parseCliArgs(args) ?: run {
|
||||||
|
printUsage()
|
||||||
|
return@runBlocking
|
||||||
|
}
|
||||||
|
TuiApp(cfg).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация TUI, вычисленная из аргументов + переменных среды.
|
||||||
|
* Доступна из других файлов commonMain как `internal`.
|
||||||
|
*/
|
||||||
|
internal data class TuiConfig(
|
||||||
|
val server: String,
|
||||||
|
val id: String,
|
||||||
|
val historyEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCliArgs(args: Array<String>): TuiConfig? {
|
||||||
|
var server: String? = null
|
||||||
|
var id: String? = null
|
||||||
|
var historyEnabled = true
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < args.size) {
|
||||||
|
when (val a = args[i]) {
|
||||||
|
"--help", "-h", "help" -> return null
|
||||||
|
"--server", "-s" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует URL" }
|
||||||
|
server = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--id" -> {
|
||||||
|
require(i + 1 < args.size) { "$a требует значение" }
|
||||||
|
id = args[i + 1]; i += 2
|
||||||
|
}
|
||||||
|
"--no-history" -> { historyEnabled = false; i++ }
|
||||||
|
"--" -> i++
|
||||||
|
else -> error("неизвестный аргумент: $a (введите --help)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val envServer = platformEnv("AGENTIK_SERVER")
|
||||||
|
val envUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
|
val resolvedServer = server ?: envServer ?: "http://localhost:8080/agentik"
|
||||||
|
val resolvedId = id ?: "cli-tui:${envUser}"
|
||||||
|
|
||||||
|
return TuiConfig(
|
||||||
|
server = resolvedServer,
|
||||||
|
id = resolvedId,
|
||||||
|
historyEnabled = historyEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Читает переменную среды. JVM actual — `System.getenv`, native actual — `getenv()` через cinterop.
|
||||||
|
* Доступ к environment делается через expect/actual, чтобы commonMain не тащил JVM-пакеты.
|
||||||
|
*/
|
||||||
|
internal expect fun platformEnv(key: String): String?
|
||||||
|
|
||||||
|
private fun printUsage() {
|
||||||
|
val defaultServer = platformEnv("AGENTIK_SERVER") ?: "http://localhost:8080/agentik"
|
||||||
|
val defaultUser = platformEnv("USER") ?: platformEnv("USERNAME") ?: "anon"
|
||||||
|
|
||||||
|
println("""
|
||||||
|
agentik-tui — Compose-Mosaic UI поверх протокола agentik
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
agentik-tui [--server URL] [--id ID] [--no-history]
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
--server, -s URL базовый URL (default: $defaultServer)
|
||||||
|
--id ID идентификатор клиента (default: cli-tui:${defaultUser})
|
||||||
|
--no-history не сохранять состояние
|
||||||
|
--help, -h эта справка
|
||||||
|
|
||||||
|
В UI:
|
||||||
|
Tab / Shift-Tab переключить фокус между историей и вводом
|
||||||
|
↑ / ↓ скроллить историю / двигать курсор в инпуте
|
||||||
|
← / → двинуть курсор в инпуте
|
||||||
|
Enter отправить сообщение
|
||||||
|
Ctrl-C / Ctrl-D выйти
|
||||||
|
F1 показать подсказки по горячим клавишам
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import com.jakewharton.mosaic.runMosaicBlocking
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневая точка запуска UI. Стартует Mosaic-рантайм и ждёт завершения приложения.
|
||||||
|
*
|
||||||
|
* По дизайну — singleton: все остальные модули (UI, бэкенд-корутины) живут внутри
|
||||||
|
* одной Compose-композиции и пользуются её [CoroutineScope].
|
||||||
|
*
|
||||||
|
* Реальный бэкенд (TuiBackend) подключается в следующем коммите: сейчас
|
||||||
|
* стартует на пустом [Agent]-заглушке для smoke-теста.
|
||||||
|
*/
|
||||||
|
internal class TuiApp(private val config: TuiConfig) {
|
||||||
|
fun run() {
|
||||||
|
runMosaicBlocking {
|
||||||
|
val state = remember { AppState(config) }
|
||||||
|
App(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Платформенная фабрика [Agent]. JVM-only пока: native не подключали ktor-движки.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = System.getenv(key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реализация [TuiApp.createAgent] для JVM — обычный ktor-cio через `:client`.
|
||||||
|
*/
|
||||||
|
internal fun jvmCreateAgent(baseUrl: String, id: String): Agent = AgentikAgent(id = id, baseUrl = baseUrl)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package pw.binom.agentik.tui
|
||||||
|
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заглушка для native-целей: TUI на нативе пока не работает — нужно подключить
|
||||||
|
* ktor-client-* движки и termios. Нативный бинарь собирается, но main() падает
|
||||||
|
* с понятной ошибкой.
|
||||||
|
*/
|
||||||
|
internal actual fun platformEnv(key: String): String? = null
|
||||||
|
|
||||||
|
internal fun nativeCreateAgent(baseUrl: String, id: String): Agent =
|
||||||
|
error("agentik-tui native target is not implemented yet (baseUrl=$baseUrl)")
|
||||||
@@ -14,11 +14,15 @@ text-embedding-kmp = "3.0.0-SNAPSHOT"
|
|||||||
kotlin-logging = "3.0.5"
|
kotlin-logging = "3.0.5"
|
||||||
logback = "1.5.18"
|
logback = "1.5.18"
|
||||||
jline = "3.30.0"
|
jline = "3.30.0"
|
||||||
|
mosaic = "0.18.0"
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
# JetBrains Compose Compiler plugin — обязательно для @Composable в KMP-проектах
|
||||||
|
# с Compose Multiplatform 1.8+; без него @Composable-лямбды ломаются (Function0 вместо Function2).
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
|
||||||
|
|
||||||
@@ -60,6 +64,17 @@ mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version
|
|||||||
# --- CLI: JLine (readline для JVM-таргета) ---
|
# --- CLI: JLine (readline для JVM-таргета) ---
|
||||||
jline = { module = "org.jline:jline", version.ref = "jline" }
|
jline = { module = "org.jline:jline", version.ref = "jline" }
|
||||||
|
|
||||||
|
# --- TUI: Mosaic (Jetpack Compose → ANSI-терминал), jvm + desktop-native. ---
|
||||||
|
# https://github.com/JakeWharton/mosaic
|
||||||
|
mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-jvm = { module = "com.jakewharton.mosaic:mosaic-runtime-jvm", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-macosx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosx64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-macosarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-macosarm64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-linuxx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxx64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-linuxarm64 = { module = "com.jakewharton.mosaic:mosaic-runtime-linuxarm64", version.ref = "mosaic" }
|
||||||
|
mosaic-runtime-mingwx64 = { module = "com.jakewharton.mosaic:mosaic-runtime-mingwx64", version.ref = "mosaic" }
|
||||||
|
mosaic-tty-terminal = { module = "com.jakewharton.mosaic:mosaic-tty-terminal", version.ref = "mosaic" }
|
||||||
|
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
|
|
||||||
# --- commons ---
|
# --- commons ---
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ include(":client")
|
|||||||
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов.
|
||||||
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar.
|
||||||
include(":agentik-cli")
|
include(":agentik-cli")
|
||||||
|
// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton),
|
||||||
|
// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios).
|
||||||
|
include(":agentik-tui")
|
||||||
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
|
// Встраиваемая долговременная память агента. `:memory-api` — интерфейсы,
|
||||||
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
|
// `:memory-md` — реализация на базе §-файлов (Hermes-style).
|
||||||
include(":memory-api")
|
include(":memory-api")
|
||||||
|
|||||||
Reference in New Issue
Block a user