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)")
|
||||
Reference in New Issue
Block a user