diff --git a/agentik-tui/build.gradle.kts b/agentik-tui/build.gradle.kts new file mode 100644 index 0000000..addc474 --- /dev/null +++ b/agentik-tui/build.gradle.kts @@ -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") { + 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 +} 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 new file mode 100644 index 0000000..0671c58 --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/App.kt @@ -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) + } +} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/AppState.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/AppState.kt new file mode 100644 index 0000000..3415151 --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/AppState.kt @@ -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 = _focusIndex.asStateFlow() + + /** Видимость help-оверлея. */ + private val _showHelp = MutableStateFlow(false) + val showHelp: StateFlow = _showHelp.asStateFlow() + + /** Сообщения диалога. */ + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + /** Заголовок текущего диалога. */ + private val _currentTitle = MutableStateFlow(null) + val currentTitle: StateFlow = _currentTitle.asStateFlow() + + /** ID текущего диалога. */ + private val _currentConversationId = MutableStateFlow(null) + val currentConversationId: StateFlow = _currentConversationId.asStateFlow() + + /** Список диалогов (sidebar). */ + private val _conversations = MutableStateFlow>(emptyList()) + val conversations: StateFlow> = _conversations.asStateFlow() + + /** Курсор в списке диалогов. */ + private val _conversationsCursor = MutableStateFlow(0) + val conversationsCursor: StateFlow = _conversationsCursor.asStateFlow() + + /** Поле ввода. */ + private val _input = MutableStateFlow("") + val input: StateFlow = _input.asStateFlow() + + /** Курсор в input (offset в chars). */ + private val _cursor = MutableStateFlow(0) + val cursor: StateFlow = _cursor.asStateFlow() + + /** Идёт ли стрим. */ + private val _streaming = MutableStateFlow(false) + val streaming: StateFlow = _streaming.asStateFlow() + + /** Scrollback index: 0 = прижат к низу. */ + private val _historyScroll = MutableStateFlow(0) + val historyScroll: StateFlow = _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() diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/Main.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/Main.kt new file mode 100644 index 0000000..da66599 --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/Main.kt @@ -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) = 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): 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()) +} diff --git a/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/TuiApp.kt b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/TuiApp.kt new file mode 100644 index 0000000..83f07ce --- /dev/null +++ b/agentik-tui/src/commonMain/kotlin/pw/binom/agentik/tui/TuiApp.kt @@ -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) + } + } +} diff --git a/agentik-tui/src/jvmMain/kotlin/pw/binom/agentik/tui/Platform.jvm.kt b/agentik-tui/src/jvmMain/kotlin/pw/binom/agentik/tui/Platform.jvm.kt new file mode 100644 index 0000000..6e9fa39 --- /dev/null +++ b/agentik-tui/src/jvmMain/kotlin/pw/binom/agentik/tui/Platform.jvm.kt @@ -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) diff --git a/agentik-tui/src/nativeMain/kotlin/pw/binom/agentik/tui/Platform.native.kt b/agentik-tui/src/nativeMain/kotlin/pw/binom/agentik/tui/Platform.native.kt new file mode 100644 index 0000000..aa9b2cf --- /dev/null +++ b/agentik-tui/src/nativeMain/kotlin/pw/binom/agentik/tui/Platform.native.kt @@ -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)") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a644ded..f528444 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,11 +14,15 @@ text-embedding-kmp = "3.0.0-SNAPSHOT" kotlin-logging = "3.0.5" logback = "1.5.18" jline = "3.30.0" +mosaic = "0.18.0" [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", 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" } 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-таргета) --- 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" } # --- commons --- diff --git a/settings.gradle.kts b/settings.gradle.kts index 440e3f2..00eba2e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,6 +39,9 @@ include(":client") // CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов. // KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar. include(":agentik-cli") +// TUI-клиент поверх :client — Compose-style UI (Mosaic от Jake Wharton), +// рендерится в ANSI-терминал. KMP со всеми desktop-целями (без ios). +include(":agentik-tui") // Встраиваемая долговременная память агента. `:memory-api` — интерфейсы, // `:memory-md` — реализация на базе §-файлов (Hermes-style). include(":memory-api")