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:
Agent
2026-09-16 14:41:05 +03:00
parent b0bbc57880
commit e68db11aaa
9 changed files with 571 additions and 0 deletions
+87
View File
@@ -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)")
+15
View File
@@ -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 ---
+3
View File
@@ -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")