+added console spport for lyngio

+added console support to lyng/jlyng CLI
+added unicode escapes
+created tetris console sample
This commit is contained in:
sergeych committed 2026-03-19 01:09:32 +03:00
1 parent 1a080bc53e
commit d9d7cafec8
44 files changed
+3842 -28

No files matched your search

@@ -0,0 +1,22 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on Android
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
actual fun getSystemConsole(): LyngConsole = MordantLyngConsole
@@ -0,0 +1,600 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.console
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjIterationFinishedException
import net.sergeych.lyng.obj.ObjIterator
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.obj.toObj
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.console.ConsoleEvent
import net.sergeych.lyngio.console.ConsoleEventSource
import net.sergeych.lyngio.console.LyngConsole
import net.sergeych.lyngio.console.getSystemConsole
import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.LyngConsoleSecured
/**
* Install Lyng module `lyng.io.console` into the given scope's ImportManager.
*/
fun createConsoleModule(policy: ConsoleAccessPolicy, scope: Scope): Boolean =
createConsoleModule(policy, scope.importManager)
fun createConsole(policy: ConsoleAccessPolicy, scope: Scope): Boolean = createConsoleModule(policy, scope)
/** Same as [createConsoleModule] but with explicit [ImportManager]. */
fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean {
val name = "lyng.io.console"
if (manager.packageNames.contains(name)) return false
manager.addPackage(name) { module ->
buildConsoleModule(module, policy)
}
return true
}
fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager)
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) {
val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy)
val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {}
consoleType.apply {
addClassFnDoc(
name = "isSupported",
doc = "Whether console control API is supported on this platform.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
ObjBool(console.isSupported)
}
addClassFnDoc(
name = "isTty",
doc = "Whether current stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard {
ObjBool(console.isTty())
}
}
addClassFnDoc(
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
moduleName = module.packageName
) {
consoleGuard {
ObjString(console.ansiLevel().name)
}
}
addClassFnDoc(
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
moduleName = module.packageName
) {
consoleGuard {
console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull
}
}
addClassFnDoc(
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
moduleName = module.packageName
) {
consoleGuard {
val tty = console.isTty()
val ansi = console.ansiLevel()
val geometry = console.geometry()
ObjConsoleDetails(
supported = console.isSupported,
isTty = tty,
ansiLevel = ansi.name,
geometry = geometry?.let { ObjConsoleGeometry(it.columns, it.rows) },
)
}
}
addClassFnDoc(
name = "write",
doc = "Write text directly to console output.",
params = listOf(ParamDoc("text", type("lyng.String"))),
moduleName = module.packageName
) {
consoleGuard {
val text = requiredArg<ObjString>(0).value
console.write(text)
ObjVoid
}
}
addClassFnDoc(
name = "flush",
doc = "Flush console output buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.flush()
ObjVoid
}
}
addClassFnDoc(
name = "home",
doc = "Move cursor to home position (1,1).",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[H")
ObjVoid
}
}
addClassFnDoc(
name = "clear",
doc = "Clear the visible screen buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[2J")
ObjVoid
}
}
addClassFnDoc(
name = "moveTo",
doc = "Move cursor to 1-based row and column.",
params = listOf(
ParamDoc("row", type("lyng.Int")),
ParamDoc("column", type("lyng.Int")),
),
moduleName = module.packageName
) {
consoleGuard {
val row = requiredArg<net.sergeych.lyng.obj.ObjInt>(0).value
val col = requiredArg<net.sergeych.lyng.obj.ObjInt>(1).value
console.write("\u001B[${row};${col}H")
ObjVoid
}
}
addClassFnDoc(
name = "clearLine",
doc = "Clear the current line.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[2K")
ObjVoid
}
}
addClassFnDoc(
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[?1049h")
ObjVoid
}
}
addClassFnDoc(
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[?1049l")
ObjVoid
}
}
addClassFnDoc(
name = "setCursorVisible",
doc = "Show or hide the terminal cursor.",
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
moduleName = module.packageName
) {
consoleGuard {
val visible = requiredArg<ObjBool>(0).value
console.write(if (visible) "\u001B[?25h" else "\u001B[?25l")
ObjVoid
}
}
addClassFnDoc(
name = "events",
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
returns = type("ConsoleEventStream"),
moduleName = module.packageName
) {
consoleGuard {
console.events().toConsoleEventStream()
}
}
addClassFnDoc(
name = "setRawMode",
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard {
val enabled = requiredArg<ObjBool>(0).value
ObjBool(console.setRawMode(enabled))
}
}
}
module.addConstDoc(
name = "Console",
value = consoleType,
doc = "Console runtime API.",
type = type("Console"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleGeometry",
value = ObjConsoleGeometry.type,
doc = "Terminal geometry.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleDetails",
value = ObjConsoleDetails.type,
doc = "Consolidated console capability details.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleEvent",
value = ObjConsoleEvent.type,
doc = "Base class for console events.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleResizeEvent",
value = ObjConsoleResizeEvent.type,
doc = "Terminal resize event.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleKeyEvent",
value = ObjConsoleKeyEvent.typeObj,
doc = "Keyboard event.",
type = type("lyng.Class"),
moduleName = module.packageName
)
module.addConstDoc(
name = "ConsoleEventStream",
value = ObjConsoleEventStream.type,
doc = "Endless iterable stream of console events.",
type = type("lyng.Class"),
moduleName = module.packageName
)
}
private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: ConsoleAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "console access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "console error")
}
}
private fun ConsoleEventSource.toConsoleEventStream(): ObjConsoleEventStream {
return ObjConsoleEventStream(this)
}
private class ObjConsoleEventStream(
private val source: ConsoleEventSource,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply {
addFnDoc(
name = "iterator",
doc = "Create an iterator over incoming console events.",
returns = type("lyng.Iterator"),
moduleName = "lyng.io.console",
) {
val stream = thisAs<ObjConsoleEventStream>()
ObjConsoleEventIterator(stream.source)
}
}
}
}
private class ObjConsoleEventIterator(
private val source: ConsoleEventSource,
) : Obj() {
private var cached: Obj? = null
private var closed = false
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
private suspend fun ensureCached(): Boolean {
if (closed) return false
if (cached != null) return true
val event = source.nextEvent()
if (event == null) {
closeSource()
return false
}
cached = event.toObjEvent()
return true
}
private suspend fun closeSource() {
if (closed) return
closed = true
source.close()
}
suspend fun hasNext(): Boolean = ensureCached()
suspend fun next(scope: Scope): Obj {
if (!ensureCached()) {
scope.raiseError(ObjIterationFinishedException(scope))
}
val out = cached ?: scope.raiseError("console iterator internal error: missing cached event")
cached = null
return out
}
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventIterator", ObjIterator).apply {
addFnDoc(
name = "hasNext",
doc = "Whether another console event is available.",
returns = type("lyng.Bool"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().hasNext().toObj()
}
addFnDoc(
name = "next",
doc = "Return the next console event.",
returns = type("ConsoleEvent"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().next(requireScope())
}
addFnDoc(
name = "cancelIteration",
doc = "Stop reading console events and release resources.",
returns = type("lyng.Void"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().closeSource()
ObjVoid
}
}
}
}
private fun ConsoleEvent.toObjEvent(): Obj = when (this) {
is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows)
is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = "keydown", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = "keyup", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta)
}
private abstract class ObjConsoleEventBase(
private val eventType: String,
final override val objClass: net.sergeych.lyng.obj.ObjClass,
) : Obj() {
fun eventTypeName(): String = eventType
}
private class ObjConsoleEvent : ObjConsoleEventBase("event", type) {
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEvent").apply {
addPropertyDoc(
name = "type",
doc = "Event type string: resize, keydown, keyup.",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleEventBase).eventTypeName()) }
)
}
}
}
private class ObjConsoleResizeEvent(
val columns: Int,
val rows: Int,
) : ObjConsoleEventBase("resize", type) {
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleResizeEvent", ObjConsoleEvent.type).apply {
addPropertyDoc(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() }
)
addPropertyDoc(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() }
)
}
}
}
private class ObjConsoleKeyEvent(
type: String,
val key: String,
val code: String?,
val ctrl: Boolean,
val alt: Boolean,
val shift: Boolean,
val meta: Boolean,
) : ObjConsoleEventBase(type, typeObj) {
companion object {
val typeObj = net.sergeych.lyng.obj.ObjClass("ConsoleKeyEvent", ObjConsoleEvent.type).apply {
addPropertyDoc(
name = "key",
doc = "Logical key name (e.g. ArrowLeft, a, Escape).",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) }
)
addPropertyDoc(
name = "code",
doc = "Optional hardware/code identifier.",
type = type("lyng.String", nullable = true),
moduleName = "lyng.io.console",
getter = {
val code = (this.thisObj as ObjConsoleKeyEvent).code
code?.let(::ObjString) ?: ObjNull
}
)
addPropertyDoc(
name = "ctrl",
doc = "Whether Ctrl modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() }
)
addPropertyDoc(
name = "alt",
doc = "Whether Alt modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() }
)
addPropertyDoc(
name = "shift",
doc = "Whether Shift modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() }
)
addPropertyDoc(
name = "meta",
doc = "Whether Meta/Super modifier is pressed.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() }
)
}
}
}
private class ObjConsoleGeometry(
val columns: Int,
val rows: Int,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleGeometry").apply {
addPropertyDoc(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() }
)
addPropertyDoc(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() }
)
}
}
}
private class ObjConsoleDetails(
val supported: Boolean,
val isTty: Boolean,
val ansiLevel: String,
val geometry: ObjConsoleGeometry?,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
companion object {
val type = net.sergeych.lyng.obj.ObjClass("ConsoleDetails").apply {
addPropertyDoc(
name = "supported",
doc = "Whether console API is supported.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() }
)
addPropertyDoc(
name = "isTty",
doc = "Whether output is connected to a TTY.",
type = type("lyng.Bool"),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() }
)
addPropertyDoc(
name = "ansiLevel",
doc = "Detected ANSI color capability level.",
type = type("lyng.String"),
moduleName = "lyng.io.console",
getter = { ObjString((this.thisObj as ObjConsoleDetails).ansiLevel) }
)
addPropertyDoc(
name = "geometry",
doc = "Current terminal geometry or null.",
type = type("ConsoleGeometry", nullable = true),
moduleName = "lyng.io.console",
getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull }
)
}
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal expect fun consoleFlowDebug(message: String, error: Throwable? = null)
@@ -0,0 +1,132 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
/**
* ANSI color support level detected for the active console.
*/
enum class ConsoleAnsiLevel {
NONE,
BASIC16,
ANSI256,
TRUECOLOR,
}
/**
* Console geometry in character cells.
*/
data class ConsoleGeometry(
val columns: Int,
val rows: Int,
)
/**
* Input/terminal events emitted by the console runtime.
*/
sealed interface ConsoleEvent {
data class Resize(
val columns: Int,
val rows: Int,
) : ConsoleEvent
data class KeyDown(
val key: String,
val code: String? = null,
val ctrl: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false,
val meta: Boolean = false,
) : ConsoleEvent
data class KeyUp(
val key: String,
val code: String? = null,
val ctrl: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false,
val meta: Boolean = false,
) : ConsoleEvent
}
/**
* Pull-based console event source.
*
* `nextEvent(timeoutMs)` returns:
* - next event when available,
* - `null` on timeout,
* - `null` after close.
*/
interface ConsoleEventSource {
suspend fun nextEvent(timeoutMs: Long = 0L): ConsoleEvent?
suspend fun close()
}
private object EmptyConsoleEventSource : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {
// no-op
}
}
/**
* Platform-independent console runtime surface.
*/
interface LyngConsole {
val isSupported: Boolean
suspend fun isTty(): Boolean
suspend fun geometry(): ConsoleGeometry?
suspend fun ansiLevel(): ConsoleAnsiLevel
suspend fun write(text: String)
suspend fun flush()
fun events(): ConsoleEventSource
/**
* Set terminal raw input mode. Returns true when mode was changed.
*/
suspend fun setRawMode(enabled: Boolean): Boolean
}
object UnsupportedLyngConsole : LyngConsole {
override val isSupported: Boolean = false
override suspend fun isTty(): Boolean = false
override suspend fun geometry(): ConsoleGeometry? = null
override suspend fun ansiLevel(): ConsoleAnsiLevel = ConsoleAnsiLevel.NONE
override suspend fun write(text: String) {
// no-op
}
override suspend fun flush() {
// no-op
}
override fun events(): ConsoleEventSource = EmptyConsoleEventSource
override suspend fun setRawMode(enabled: Boolean): Boolean = false
}
@@ -0,0 +1,286 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.input.RawModeScope
import com.github.ajalt.mordant.input.enterRawModeOrNull
import com.github.ajalt.mordant.rendering.AnsiLevel
import com.github.ajalt.mordant.terminal.Terminal
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
import net.sergeych.mp_tools.globalLaunch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
/**
* Mordant-backed console runtime implementation.
*/
object MordantLyngConsole : LyngConsole {
private val terminal: Terminal? by lazy {
runCatching { Terminal() }.getOrNull()
}
private val stateMutex = Mutex()
private var rawModeRequested: Boolean = false
private var rawModeScope: RawModeScope? = null
private suspend fun forceRawModeReset(t: Terminal): Boolean = stateMutex.withLock {
if (!rawModeRequested) return@withLock false
runCatching { rawModeScope?.close() }
.onFailure { consoleFlowDebug("forceRawModeReset: close failed", it) }
rawModeScope = null
rawModeRequested = false
if (!t.terminalInfo.inputInteractive) return@withLock false
val reopened = t.enterRawModeOrNull()
rawModeScope = reopened
rawModeRequested = reopened != null
reopened != null
}
override val isSupported: Boolean
get() = terminal != null
override suspend fun isTty(): Boolean {
val t = terminal ?: return false
return t.terminalInfo.outputInteractive
}
override suspend fun geometry(): ConsoleGeometry? {
val t = terminal ?: return null
val size = t.updateSize()
return ConsoleGeometry(size.width, size.height)
}
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val t = terminal ?: return ConsoleAnsiLevel.NONE
return when (t.terminalInfo.ansiLevel) {
AnsiLevel.NONE -> ConsoleAnsiLevel.NONE
AnsiLevel.ANSI16 -> ConsoleAnsiLevel.BASIC16
AnsiLevel.ANSI256 -> ConsoleAnsiLevel.ANSI256
AnsiLevel.TRUECOLOR -> ConsoleAnsiLevel.TRUECOLOR
}
}
override suspend fun write(text: String) {
terminal?.rawPrint(text)
}
override suspend fun flush() {
// Mordant prints via platform streams immediately.
}
override fun events(): ConsoleEventSource {
val t = terminal ?: return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
val sourceState = Mutex()
var running = true
globalLaunch {
var lastWidth = t.updateSize().width
var lastHeight = t.updateSize().height
val startMark = TimeSource.Monotonic.markNow()
var lastHeartbeatMark = startMark
var loops = 0L
var readAttempts = 0L
var readFailures = 0L
var keyEvents = 0L
var resizeEvents = 0L
var rawNullLoops = 0L
var lastKeyMark = startMark
var lastRawRecoveryMark = startMark
consoleFlowDebug("events: collector started")
try {
while (currentCoroutineContext().isActive && sourceState.withLock { running }) {
loops += 1
val currentSize = runCatching { t.updateSize() }.getOrNull()
if (currentSize == null) {
delay(150)
continue
}
if (currentSize.width != lastWidth || currentSize.height != lastHeight) {
out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height))
lastWidth = currentSize.width
lastHeight = currentSize.height
resizeEvents += 1
}
val raw = stateMutex.withLock {
if (!rawModeRequested) {
null
} else {
// Recover raw scope lazily if it was dropped due to an earlier read failure.
if (rawModeScope == null) {
rawModeScope = t.enterRawModeOrNull()
if (rawModeScope == null) {
consoleFlowDebug("events: failed to reopen raw mode scope")
} else {
consoleFlowDebug("events: raw mode scope reopened")
}
}
rawModeScope
}
}
if (raw == null || !t.terminalInfo.inputInteractive) {
rawNullLoops += 1
delay(150)
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
consoleFlowDebug(
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
)
lastHeartbeatMark = TimeSource.Monotonic.markNow()
}
continue
}
readAttempts += 1
val readResult = runCatching { raw.readEventOrNull(150.milliseconds) }
if (readResult.isFailure) {
readFailures += 1
consoleFlowDebug("events: readEventOrNull failed; resetting raw scope", readResult.exceptionOrNull())
// Raw scope became invalid; close and force reopen on next iteration.
stateMutex.withLock {
runCatching { rawModeScope?.close() }
rawModeScope = null
}
delay(50)
continue
}
val ev = readResult.getOrNull()
val resized = runCatching { t.updateSize() }.getOrNull()
if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) {
out.trySend(ConsoleEvent.Resize(resized.width, resized.height))
lastWidth = resized.width
lastHeight = resized.height
}
when (ev) {
is KeyboardEvent -> {
keyEvents += 1
lastKeyMark = TimeSource.Monotonic.markNow()
out.trySend(
ConsoleEvent.KeyDown(
key = ev.key,
code = null,
ctrl = ev.ctrl,
alt = ev.alt,
shift = ev.shift,
meta = false,
)
)
}
else -> {
// Mouse/other events are ignored in Lyng console v1.
}
}
// Some terminals silently stop delivering keyboard events while raw reads keep succeeding.
// If we had keys before and then prolonged key inactivity, proactively recycle raw scope.
if (keyEvents > 0L &&
lastKeyMark.elapsedNow() >= 4.seconds &&
lastRawRecoveryMark.elapsedNow() >= 4.seconds
) {
if (rawModeRequested) {
consoleFlowDebug("events: key inactivity detected; forcing raw reset")
val resetOk = forceRawModeReset(t)
if (resetOk) {
consoleFlowDebug("events: raw reset succeeded during inactivity recovery")
lastKeyMark = TimeSource.Monotonic.markNow()
} else {
consoleFlowDebug("events: raw reset failed during inactivity recovery")
}
lastRawRecoveryMark = TimeSource.Monotonic.markNow()
}
}
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
consoleFlowDebug(
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
)
lastHeartbeatMark = TimeSource.Monotonic.markNow()
}
}
} catch (e: CancellationException) {
consoleFlowDebug("events: collector cancelled (normal)")
// normal
} catch (e: ScriptFlowIsNoMoreCollected) {
consoleFlowDebug("events: collector stopped by flow consumer (normal)")
// normal
} catch (e: Exception) {
consoleFlowDebug("events: collector loop failed", e)
// terminate event source loop
} finally {
consoleFlowDebug(
"events: collector ended uptime=${startMark.elapsedNow().inWholeMilliseconds}ms loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested"
)
out.close()
}
}
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (timeoutMs <= 0L) {
return out.receiveCatching().getOrNull()
}
return withTimeoutOrNull(timeoutMs.milliseconds) {
out.receiveCatching().getOrNull()
}
}
override suspend fun close() {
sourceState.withLock { running = false }
out.close()
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
val t = terminal ?: return false
return stateMutex.withLock {
if (enabled) {
if (!t.terminalInfo.inputInteractive) return@withLock false
if (rawModeRequested) return@withLock false
val scope = t.enterRawModeOrNull() ?: return@withLock false
rawModeScope = scope
rawModeRequested = true
consoleFlowDebug("setRawMode(true): enabled")
true
} else {
val hadRaw = rawModeRequested || rawModeScope != null
rawModeRequested = false
val scope = rawModeScope
rawModeScope = null
runCatching { scope?.close() }
.onFailure { consoleFlowDebug("setRawMode(false): close failed", it) }
consoleFlowDebug("setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw
}
}
}
}
@@ -0,0 +1,23 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
/**
* Get the system default console implementation.
*/
expect fun getSystemConsole(): LyngConsole
@@ -0,0 +1,55 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console.security
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
/**
* Primitive console operations for access control decisions.
*/
sealed interface ConsoleAccessOp {
data class WriteText(val length: Int) : ConsoleAccessOp
data object ReadEvents : ConsoleAccessOp
data class SetRawMode(val enabled: Boolean) : ConsoleAccessOp
}
class ConsoleAccessDeniedException(
val op: ConsoleAccessOp,
val reasonDetail: String? = null,
) : IllegalStateException("Console access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
/**
* Policy interface that decides whether a specific console operation is allowed.
*/
interface ConsoleAccessPolicy {
suspend fun check(op: ConsoleAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
suspend fun require(op: ConsoleAccessOp, ctx: AccessContext = AccessContext()) {
val res = check(op, ctx)
if (!res.isAllowed()) throw ConsoleAccessDeniedException(op, res.reason)
}
}
object PermitAllConsoleAccessPolicy : ConsoleAccessPolicy {
override suspend fun check(op: ConsoleAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Allow)
}
@@ -0,0 +1,68 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console.security
import net.sergeych.lyngio.console.*
import net.sergeych.lyngio.fs.security.AccessContext
/**
* Decorator that applies a [ConsoleAccessPolicy] to a delegate [LyngConsole].
*/
class LyngConsoleSecured(
private val delegate: LyngConsole,
private val policy: ConsoleAccessPolicy,
private val ctx: AccessContext = AccessContext(),
) : LyngConsole {
override val isSupported: Boolean
get() = delegate.isSupported
override suspend fun isTty(): Boolean = delegate.isTty()
override suspend fun geometry(): ConsoleGeometry? = delegate.geometry()
override suspend fun ansiLevel(): ConsoleAnsiLevel = delegate.ansiLevel()
override suspend fun write(text: String) {
policy.require(ConsoleAccessOp.WriteText(text.length), ctx)
delegate.write(text)
}
override suspend fun flush() {
delegate.flush()
}
override fun events(): ConsoleEventSource {
val source = delegate.events()
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
policy.require(ConsoleAccessOp.ReadEvents, ctx)
return source.nextEvent(timeoutMs)
}
override suspend fun close() {
source.close()
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
policy.require(ConsoleAccessOp.SetRawMode(enabled), ctx)
return delegate.setRawMode(enabled)
}
}
@@ -0,0 +1,264 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.docs
import net.sergeych.lyng.miniast.BuiltinDocRegistry
import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.type
object ConsoleBuiltinDocs {
private var registered = false
fun ensure() {
if (registered) return
BuiltinDocRegistry.module("lyng.io.console") {
classDoc(
name = "Console",
doc = "Console runtime API."
) {
method(
name = "isSupported",
doc = "Whether console control API is supported on this platform.",
returns = type("lyng.Bool"),
isStatic = true
)
method(
name = "isTty",
doc = "Whether stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
isStatic = true
)
method(
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
isStatic = true
)
method(
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
isStatic = true
)
method(
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
isStatic = true
)
method(
name = "write",
doc = "Write text directly to console output.",
params = listOf(ParamDoc("text", type("lyng.String"))),
isStatic = true
)
method(
name = "flush",
doc = "Flush console output buffer.",
isStatic = true
)
method(
name = "home",
doc = "Move cursor to home position (1,1).",
isStatic = true
)
method(
name = "clear",
doc = "Clear the visible screen buffer.",
isStatic = true
)
method(
name = "moveTo",
doc = "Move cursor to 1-based row and column.",
params = listOf(
ParamDoc("row", type("lyng.Int")),
ParamDoc("column", type("lyng.Int")),
),
isStatic = true
)
method(
name = "clearLine",
doc = "Clear the current line.",
isStatic = true
)
method(
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
isStatic = true
)
method(
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
isStatic = true
)
method(
name = "setCursorVisible",
doc = "Show or hide the terminal cursor.",
params = listOf(ParamDoc("visible", type("lyng.Bool"))),
isStatic = true
)
method(
name = "events",
doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.",
returns = type("ConsoleEventStream"),
isStatic = true
)
method(
name = "setRawMode",
doc = "Enable or disable raw keyboard mode. Returns true if mode changed.",
params = listOf(ParamDoc("enabled", type("lyng.Bool"))),
returns = type("lyng.Bool"),
isStatic = true
)
}
classDoc(
name = "ConsoleEventStream",
doc = "Endless iterable stream of console events."
) {
method(
name = "iterator",
doc = "Create an iterator over incoming console events.",
returns = type("lyng.Iterator")
)
}
classDoc(
name = "ConsoleGeometry",
doc = "Terminal geometry."
) {
field(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int")
)
field(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int")
)
}
classDoc(
name = "ConsoleDetails",
doc = "Consolidated console capability details."
) {
field(
name = "supported",
doc = "Whether console API is supported.",
type = type("lyng.Bool")
)
field(
name = "isTty",
doc = "Whether output is attached to a TTY.",
type = type("lyng.Bool")
)
field(
name = "ansiLevel",
doc = "Detected ANSI color capability.",
type = type("lyng.String")
)
field(
name = "geometry",
doc = "Current geometry or null.",
type = type("ConsoleGeometry", nullable = true)
)
}
classDoc(
name = "ConsoleEvent",
doc = "Base class for console events."
) {
field(
name = "type",
doc = "Event kind string.",
type = type("lyng.String")
)
}
classDoc(
name = "ConsoleResizeEvent",
doc = "Resize event."
) {
field(
name = "type",
doc = "Event kind string: resize.",
type = type("lyng.String")
)
field(
name = "columns",
doc = "Terminal width in character cells.",
type = type("lyng.Int")
)
field(
name = "rows",
doc = "Terminal height in character cells.",
type = type("lyng.Int")
)
}
classDoc(
name = "ConsoleKeyEvent",
doc = "Keyboard event."
) {
field(
name = "type",
doc = "Event kind string: keydown or keyup.",
type = type("lyng.String")
)
field(
name = "key",
doc = "Logical key name.",
type = type("lyng.String")
)
field(
name = "code",
doc = "Optional hardware code.",
type = type("lyng.String", nullable = true)
)
field(
name = "ctrl",
doc = "Ctrl modifier state.",
type = type("lyng.Bool")
)
field(
name = "alt",
doc = "Alt modifier state.",
type = type("lyng.Bool")
)
field(
name = "shift",
doc = "Shift modifier state.",
type = type("lyng.Bool")
)
field(
name = "meta",
doc = "Meta modifier state.",
type = type("lyng.Bool")
)
}
valDoc(
name = "Console",
doc = "Console runtime API.",
type = type("Console")
)
valDoc(name = "ConsoleGeometry", doc = "Terminal geometry class.", type = type("lyng.Class"))
valDoc(name = "ConsoleDetails", doc = "Console details class.", type = type("lyng.Class"))
valDoc(name = "ConsoleEvent", doc = "Base console event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleResizeEvent", doc = "Resize event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleKeyEvent", doc = "Keyboard event class.", type = type("lyng.Class"))
valDoc(name = "ConsoleEventStream", doc = "Iterable console event stream class.", type = type("lyng.Class"))
}
registered = true
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun getNativeSystemConsole(): LyngConsole = MordantLyngConsole
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on JS
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
actual fun getSystemConsole(): LyngConsole = MordantLyngConsole
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import java.io.File
import java.time.Instant
private val flowDebugLogFilePath: String =
System.getenv("LYNG_CONSOLE_DEBUG_LOG")
?.takeIf { it.isNotBlank() }
?: "/tmp/lyng_console_flow_debug.log"
private val flowDebugLogLock = Any()
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
runCatching {
val line = buildString {
append(Instant.now().toString())
append(" [console-flow] ")
append(message)
append('\n')
if (error != null) {
append(error.stackTraceToString())
append('\n')
}
}
synchronized(flowDebugLogLock) {
File(flowDebugLogFilePath).appendText(line)
}
}
}
@@ -0,0 +1,496 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.jline.terminal.Attributes
import org.jline.terminal.Terminal
import org.jline.terminal.TerminalBuilder
import org.jline.utils.NonBlockingReader
import java.io.EOFException
import java.util.*
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
/**
* JVM console implementation:
* - output/capabilities/input use a single JLine terminal instance
* to avoid dual-terminal contention.
*/
object JvmLyngConsole : LyngConsole {
private const val DEBUG_REVISION = "jline-r26-force-rebuild-on-noop-recovery-2026-03-19"
private val codeSourceLocation: String by lazy {
runCatching {
JvmLyngConsole::class.java.protectionDomain?.codeSource?.location?.toString()
}.getOrNull() ?: "<unknown>"
}
private val terminalRef = AtomicReference<Terminal?>(null)
private val terminalInitLock = Any()
private fun currentTerminal(): Terminal? {
val existing = terminalRef.get()
if (existing != null) return existing
synchronized(terminalInitLock) {
val already = terminalRef.get()
if (already != null) return already
val created = buildTerminal()
if (created != null) terminalRef.set(created)
return created
}
}
private fun buildTerminal(): Terminal? {
System.setProperty(TerminalBuilder.PROP_DISABLE_DEPRECATED_PROVIDER_WARNING, "true")
val providerOrders = listOf(
"exec",
"exec,ffm",
null,
)
for (providers in providerOrders) {
val terminal = runCatching {
val builder = TerminalBuilder.builder().system(true)
if (providers != null) builder.providers(providers)
builder.build()
}.onFailure {
if (providers != null) {
consoleFlowDebug("jline-events: terminal build failed providers=$providers", it)
} else {
consoleFlowDebug("jline-events: terminal build failed default providers", it)
}
}.getOrNull()
if (terminal != null) {
val termType = terminal.type.lowercase(Locale.getDefault())
if (termType.contains("dumb")) {
consoleFlowDebug("jline-events: terminal rejected providers=${providers ?: "<default>"} type=${terminal.type}")
runCatching { terminal.close() }
continue
}
consoleFlowDebug("jline-events: terminal built providers=${providers ?: "<default>"} type=${terminal.type}")
consoleFlowDebug("jline-events: runtime-marker rev=$DEBUG_REVISION codeSource=$codeSourceLocation")
return terminal
}
}
return null
}
private val stateMutex = Mutex()
private var rawModeRequested: Boolean = false
private var rawSavedAttributes: Attributes? = null
private fun enforceRawReadAttrs(term: Terminal) {
runCatching {
val attrs = term.attributes
attrs.setLocalFlag(Attributes.LocalFlag.ICANON, false)
attrs.setLocalFlag(Attributes.LocalFlag.ECHO, false)
attrs.setControlChar(Attributes.ControlChar.VMIN, 0)
attrs.setControlChar(Attributes.ControlChar.VTIME, 1)
term.setAttributes(attrs)
}.onFailure {
consoleFlowDebug("jline-events: enforceRawReadAttrs failed", it)
}
}
override val isSupported: Boolean
get() = currentTerminal() != null
override suspend fun isTty(): Boolean {
val term = currentTerminal() ?: return false
return !term.type.lowercase(Locale.getDefault()).contains("dumb")
}
override suspend fun geometry(): ConsoleGeometry? {
val term = currentTerminal() ?: return null
val size = runCatching { term.size }.getOrNull() ?: return null
if (size.columns <= 0 || size.rows <= 0) return null
return ConsoleGeometry(size.columns, size.rows)
}
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val colorTerm = (System.getenv("COLORTERM") ?: "").lowercase(Locale.getDefault())
val term = (System.getenv("TERM") ?: "").lowercase(Locale.getDefault())
return when {
colorTerm.contains("truecolor") || colorTerm.contains("24bit") -> ConsoleAnsiLevel.TRUECOLOR
term.contains("256color") -> ConsoleAnsiLevel.ANSI256
term.isNotBlank() && term != "dumb" -> ConsoleAnsiLevel.BASIC16
else -> ConsoleAnsiLevel.NONE
}
}
override suspend fun write(text: String) {
val term = currentTerminal() ?: return
term.writer().print(text)
}
override suspend fun flush() {
val term = currentTerminal() ?: return
term.writer().flush()
}
override fun events(): ConsoleEventSource {
var activeTerm = currentTerminal() ?: return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
val keyEvents = AtomicLong(0L)
val keyCodesRead = AtomicLong(0L)
val keySendFailures = AtomicLong(0L)
val readFailures = AtomicLong(0L)
val readerRecoveries = AtomicLong(0L)
var lastHeartbeat = TimeSource.Monotonic.markNow()
val keyLoopRunning = AtomicBoolean(true)
val keyLoopCount = AtomicLong(0L)
val keyReadStartNs = AtomicLong(0L)
val keyReadEndNs = AtomicLong(0L)
val lastKeyReadNs = AtomicLong(System.nanoTime())
val lastRecoveryNs = AtomicLong(0L)
val recoveryRequested = AtomicBoolean(false)
val running = AtomicBoolean(true)
var winchHandler: Terminal.SignalHandler? = null
var reader = activeTerm.reader()
var keyThread: Thread? = null
var heartbeatThread: Thread? = null
fun emitResize() {
val size = runCatching { activeTerm.size }.getOrNull() ?: return
out.trySend(ConsoleEvent.Resize(size.columns, size.rows))
}
fun cleanup() {
running.set(false)
keyLoopRunning.set(false)
runCatching { reader.shutdown() }
runCatching {
if (winchHandler != null) {
activeTerm.handle(Terminal.Signal.WINCH, winchHandler)
}
}.onFailure {
consoleFlowDebug("jline-events: WINCH handler restore failed", it)
}
runCatching { keyThread?.interrupt() }
runCatching { heartbeatThread?.interrupt() }
out.close()
}
fun installWinchHandler() {
winchHandler = runCatching {
activeTerm.handle(Terminal.Signal.WINCH) {
emitResize()
}
}.onFailure {
consoleFlowDebug("jline-events: WINCH handler install failed", it)
}.getOrNull()
}
fun tryRebuildTerminal(): Boolean {
val oldTerm = activeTerm
val rebuilt = runCatching {
synchronized(terminalInitLock) {
if (terminalRef.get() === oldTerm) {
terminalRef.set(null)
}
}
runCatching { oldTerm.close() }
.onFailure { consoleFlowDebug("jline-events: old terminal close failed during rebuild", it) }
currentTerminal()
}.onFailure {
consoleFlowDebug("jline-events: terminal rebuild failed", it)
}.getOrNull() ?: return false
if (rebuilt === oldTerm) {
consoleFlowDebug("jline-events: terminal rebuild returned same terminal instance")
return false
}
activeTerm = rebuilt
reader = activeTerm.reader()
val rawRequestedNow = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
rawModeRequested
} finally {
stateMutex.unlock()
}
if (rawRequestedNow) {
val saved = runCatching { activeTerm.enterRawMode() }.getOrNull()
if (saved != null) {
enforceRawReadAttrs(activeTerm)
if (runCatching { stateMutex.tryLock() }.getOrNull() == true) {
try {
rawSavedAttributes = saved
} finally {
stateMutex.unlock()
}
}
} else {
consoleFlowDebug("jline-events: terminal rebuild succeeded but enterRawMode failed")
}
}
installWinchHandler()
emitResize()
consoleFlowDebug("jline-events: terminal rebuilt and rebound")
return true
}
consoleFlowDebug("jline-events: collector started rev=$DEBUG_REVISION")
emitResize()
installWinchHandler()
keyThread = thread(start = true, isDaemon = true, name = "lyng-jline-key-reader") {
consoleFlowDebug("jline-events: key-reader thread started")
consoleFlowDebug("jline-events: using NonBlockingReader key path")
while (running.get() && keyLoopRunning.get()) {
keyLoopCount.incrementAndGet()
try {
if (recoveryRequested.compareAndSet(true, false)) {
val prevReader = reader
runCatching { prevReader.shutdown() }
.onFailure { consoleFlowDebug("jline-events: reader shutdown failed during recovery", it) }
reader = activeTerm.reader()
if (reader.hashCode() == prevReader.hashCode()) {
consoleFlowDebug("jline-events: reader recovery no-op oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()} -> forcing terminal rebuild")
if (!tryRebuildTerminal()) {
consoleFlowDebug("jline-events: forced terminal rebuild did not produce a new reader")
}
} else {
consoleFlowDebug("jline-events: reader recovered oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()}")
}
readerRecoveries.incrementAndGet()
lastRecoveryNs.set(System.nanoTime())
}
val isRaw = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
rawModeRequested
} finally {
stateMutex.unlock()
}
if (!isRaw) {
Thread.sleep(20)
continue
}
keyReadStartNs.set(System.nanoTime())
val event = readKeyEvent(reader)
keyReadEndNs.set(System.nanoTime())
if (event == null) {
continue
}
keyCodesRead.incrementAndGet()
lastKeyReadNs.set(System.nanoTime())
if (out.trySend(event).isSuccess) {
keyEvents.incrementAndGet()
} else {
keySendFailures.incrementAndGet()
}
} catch (_: InterruptedException) {
break
} catch (e: Throwable) {
readFailures.incrementAndGet()
consoleFlowDebug("jline-events: blocking read failed", e)
try {
Thread.sleep(50)
} catch (_: InterruptedException) {
break
}
}
}
}
heartbeatThread = thread(start = true, isDaemon = true, name = "lyng-jline-heartbeat") {
while (running.get()) {
if (lastHeartbeat.elapsedNow() >= 2.seconds) {
val requested = runCatching { stateMutex.tryLock() }.getOrNull() == true && try {
rawModeRequested
} finally {
stateMutex.unlock()
}
val readStartNs = keyReadStartNs.get()
val readEndNs = keyReadEndNs.get()
val lastKeyNs = lastKeyReadNs.get()
val idleMs = if (lastKeyNs > 0L) (System.nanoTime() - lastKeyNs) / 1_000_000L else 0L
val readBlockedMs = if (readStartNs > 0L && readEndNs < readStartNs) {
(System.nanoTime() - readStartNs) / 1_000_000L
} else 0L
if (requested && keyCodesRead.get() > 0L && idleMs >= 1400L) {
val sinceRecoveryMs = (System.nanoTime() - lastRecoveryNs.get()) / 1_000_000L
if (sinceRecoveryMs >= 1200L) {
recoveryRequested.set(true)
consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery")
}
}
consoleFlowDebug(
"jline-events: heartbeat keyCodes=${keyCodesRead.get()} keysSent=${keyEvents.get()} sendFailures=${keySendFailures.get()} readFailures=${readFailures.get()} recoveries=${readerRecoveries.get()} rawRequested=$requested keyLoop=${keyLoopCount.get()} readBlockedMs=$readBlockedMs keyIdleMs=$idleMs keyPath=reader"
)
lastHeartbeat = TimeSource.Monotonic.markNow()
}
try {
Thread.sleep(200)
} catch (_: InterruptedException) {
break
}
}
}
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (!running.get()) return null
if (timeoutMs <= 0L) {
return out.receiveCatching().getOrNull()
}
return withTimeoutOrNull(timeoutMs.milliseconds) {
out.receiveCatching().getOrNull()
}
}
override suspend fun close() {
cleanup()
consoleFlowDebug(
"jline-events: collector ended keys=${keyEvents.get()} readFailures=${readFailures.get()}"
)
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
val term = currentTerminal() ?: return false
return stateMutex.withLock {
if (enabled) {
if (rawModeRequested) return@withLock false
val saved = runCatching { term.enterRawMode() }.getOrNull() ?: return@withLock false
enforceRawReadAttrs(term)
rawSavedAttributes = saved
rawModeRequested = true
consoleFlowDebug("jline-events: setRawMode(true): enabled")
true
} else {
val hadRaw = rawModeRequested
rawModeRequested = false
val saved = rawSavedAttributes
rawSavedAttributes = null
runCatching {
if (saved != null) term.setAttributes(saved)
}.onFailure {
consoleFlowDebug("jline-events: setRawMode(false): restore failed", it)
}
consoleFlowDebug("jline-events: setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw
}
}
}
private fun readKeyEvent(reader: NonBlockingReader): ConsoleEvent.KeyDown? {
val code = reader.read(120L)
if (code == NonBlockingReader.READ_EXPIRED) return null
if (code < 0) throw EOFException("non-blocking reader returned EOF")
return decodeKey(code) { timeout -> readNextCode(reader, timeout) }
}
private fun decodeKey(code: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (code == 27) {
val next = nextCode(25L)
if (next == null || next < 0) {
return key("Escape")
}
if (next == '['.code || next == 'O'.code) {
val sb = StringBuilder()
sb.append(next.toChar())
var i = 0
while (i < 6) {
val c = nextCode(25L) ?: break
if (c < 0) break
sb.append(c.toChar())
if (c.toChar().isLetter() || c == '~'.code) break
i += 1
}
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
}
// Alt+key
val base = decodePlainKey(next)
return ConsoleEvent.KeyDown(
key = base.key,
code = base.code,
ctrl = base.ctrl,
alt = true,
shift = base.shift,
meta = false
)
}
return decodePlainKey(code)
}
private fun readNextCode(reader: NonBlockingReader, timeoutMs: Long): Int? {
val c = reader.read(timeoutMs)
if (c == NonBlockingReader.READ_EXPIRED) return null
if (c < 0) throw EOFException("non-blocking reader returned EOF while decoding key sequence")
return c
}
private fun decodePlainKey(code: Int): ConsoleEvent.KeyDown = when (code) {
3 -> key("c", ctrl = true)
9 -> key("Tab")
10, 13 -> key("Enter")
127, 8 -> key("Backspace")
32 -> key(" ")
else -> {
if (code in 1..26) {
val ch = ('a'.code + code - 1).toChar().toString()
key(ch, ctrl = true)
} else {
val ch = code.toChar().toString()
key(ch, shift = ch.length == 1 && ch[0].isLetter() && ch[0].isUpperCase())
}
}
}
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? = when (seq) {
"[A", "OA" -> key("ArrowUp")
"[B", "OB" -> key("ArrowDown")
"[C", "OC" -> key("ArrowRight")
"[D", "OD" -> key("ArrowLeft")
"[H", "OH" -> key("Home")
"[F", "OF" -> key("End")
"[2~" -> key("Insert")
"[3~" -> key("Delete")
"[5~" -> key("PageUp")
"[6~" -> key("PageDown")
else -> null
}
private fun key(
value: String,
ctrl: Boolean = false,
alt: Boolean = false,
shift: Boolean = false,
): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown(
key = value,
code = null,
ctrl = ctrl,
alt = alt,
shift = shift,
meta = false
)
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
actual fun getSystemConsole(): LyngConsole = JvmLyngConsole
@@ -0,0 +1,115 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng.io.console
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Scope
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjIllegalOperationException
import net.sergeych.lyngio.console.security.ConsoleAccessOp
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
class LyngConsoleModuleTest {
private fun newScope(): Scope = Scope.new()
@Test
fun installIsIdempotent() = runBlocking {
val scope = newScope()
assertTrue(createConsoleModule(PermitAllConsoleAccessPolicy, scope))
assertFalse(createConsoleModule(PermitAllConsoleAccessPolicy, scope))
}
@Test
fun moduleSmokeScript() = runBlocking {
val scope = newScope()
createConsoleModule(PermitAllConsoleAccessPolicy, scope)
val code = """
import lyng.io.console
import lyng.stdlib
val d = Console.details()
assert(d.supported is Bool)
assert(d.isTty is Bool)
assert(d.ansiLevel is String)
val g = Console.geometry()
if (g != null) {
assert(g.columns is Int)
assert(g.rows is Int)
assert(g.columns > 0)
assert(g.rows > 0)
}
assert(Console.events() is Iterable)
Console.write("")
Console.flush()
Console.home()
Console.clear()
Console.moveTo(1, 1)
Console.clearLine()
Console.enterAltScreen()
Console.leaveAltScreen()
Console.setCursorVisible(true)
val changed = Console.setRawMode(false)
assert(changed is Bool)
true
""".trimIndent()
val result = scope.eval(code)
assertIs<ObjBool>(result)
assertTrue(result.value)
}
@Test
fun denyWritePolicyMapsToIllegalOperation() {
runBlocking {
val denyWritePolicy = object : ConsoleAccessPolicy {
override suspend fun check(op: ConsoleAccessOp, ctx: AccessContext): AccessDecision = when (op) {
is ConsoleAccessOp.WriteText -> AccessDecision(Decision.Deny, "denied by test policy")
else -> AccessDecision(Decision.Allow)
}
}
val scope = newScope()
createConsoleModule(denyWritePolicy, scope)
val error = kotlin.test.assertFailsWith<ExecutionError> {
scope.eval(
"""
import lyng.io.console
Console.write("x")
""".trimIndent()
)
}
assertIs<ObjIllegalOperationException>(error.errorObject)
}
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class MordantLyngConsoleJvmTest {
@Test
fun basicCapabilitiesSmoke() = runBlocking {
val console = getSystemConsole()
assertNotNull(console)
// Must be callable in any environment (interactive or redirected output).
val tty = console.isTty()
val ansi = console.ansiLevel()
val geometry = console.geometry()
if (geometry != null) {
assertTrue(geometry.columns > 0, "columns must be positive when geometry is present")
assertTrue(geometry.rows > 0, "rows must be positive when geometry is present")
}
// no-op smoke checks
console.write("")
console.flush()
// Keep values live so compiler doesn't optimize away calls in future changes
assertNotNull(ansi)
assertTrue(tty || !tty)
}
@Test
fun setRawModeContract() = runBlocking {
val console = getSystemConsole()
val enabledChanged = console.setRawMode(true)
val disabledChanged = console.setRawMode(false)
// If enabling changed state, disabling should also change it back.
if (enabledChanged) {
assertTrue(disabledChanged, "raw mode disable should report changed after enable")
}
}
@Test
fun eventsSourceDoesNotCrash() = runBlocking {
val console = getSystemConsole()
val source = console.events()
val event = source.nextEvent(350)
source.close()
// Any event kind is acceptable in this smoke test; null is also valid when idle.
if (event != null) {
assertTrue(
event is ConsoleEvent.Resize || event is ConsoleEvent.KeyDown || event is ConsoleEvent.KeyUp,
"unexpected event type: ${event::class.simpleName}"
)
}
}
}
@@ -0,0 +1,243 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import kotlinx.cinterop.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import platform.posix.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
internal actual fun getNativeSystemConsole(): LyngConsole = LinuxPosixLyngConsole
internal object LinuxConsoleKeyDecoder {
fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (firstCode == 27) {
val next = nextCode(25L)
if (next == null || next < 0) return key("Escape")
if (next == '['.code || next == 'O'.code) {
val sb = StringBuilder()
sb.append(next.toChar())
var i = 0
while (i < 8) {
val c = nextCode(25L) ?: break
if (c < 0) break
sb.append(c.toChar())
if (c.toChar().isLetter() || c == '~'.code) break
i += 1
}
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
}
val base = decodePlain(next)
return ConsoleEvent.KeyDown(
key = base.key,
code = base.code,
ctrl = base.ctrl,
alt = true,
shift = base.shift,
meta = false,
)
}
return decodePlain(firstCode)
}
private fun decodePlain(code: Int): ConsoleEvent.KeyDown {
if (code == 3) return ConsoleEvent.KeyDown(key = "c", ctrl = true)
if (code == 9) return key("Tab")
if (code == 10 || code == 13) return key("Enter")
if (code == 32) return key(" ")
if (code == 127 || code == 8) return key("Backspace")
val c = code.toChar()
return if (c in 'A'..'Z') {
ConsoleEvent.KeyDown(key = c.toString(), shift = true)
} else {
key(c.toString())
}
}
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? {
val letter = seq.lastOrNull() ?: return null
val shift = seq.contains(";2")
val alt = seq.contains(";3")
val ctrl = seq.contains(";5")
val key = when (letter) {
'A' -> "ArrowUp"
'B' -> "ArrowDown"
'C' -> "ArrowRight"
'D' -> "ArrowLeft"
'H' -> "Home"
'F' -> "End"
else -> return null
}
return ConsoleEvent.KeyDown(key = key, ctrl = ctrl, alt = alt, shift = shift)
}
private fun key(name: String): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown(key = name)
}
@OptIn(ExperimentalForeignApi::class)
object LinuxPosixLyngConsole : LyngConsole {
private val stateMutex = Mutex()
private var rawModeRequested = false
private var savedAttrsBlob: ByteArray? = null
override val isSupported: Boolean
get() = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
override suspend fun isTty(): Boolean = isSupported
override suspend fun geometry(): ConsoleGeometry? = readGeometry()
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val colorTerm = (getenv("COLORTERM")?.toKString() ?: "").lowercase()
val term = (getenv("TERM")?.toKString() ?: "").lowercase()
return when {
colorTerm.contains("truecolor") || colorTerm.contains("24bit") -> ConsoleAnsiLevel.TRUECOLOR
term.contains("256color") -> ConsoleAnsiLevel.ANSI256
term.isNotBlank() && term != "dumb" -> ConsoleAnsiLevel.BASIC16
else -> ConsoleAnsiLevel.NONE
}
}
override suspend fun write(text: String) {
kotlin.io.print(text)
}
override suspend fun flush() {
fflush(null)
}
override fun events(): ConsoleEventSource {
if (!isSupported) {
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
}
return object : ConsoleEventSource {
var closed = false
var lastGeometry: ConsoleGeometry? = null
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (closed) return null
val started = TimeSource.Monotonic.markNow()
while (!closed) {
val g = readGeometry()
if (g != null && (lastGeometry == null || g.columns != lastGeometry?.columns || g.rows != lastGeometry?.rows)) {
lastGeometry = g
return ConsoleEvent.Resize(g.columns, g.rows)
}
val rawRequested = stateMutex.withLock { rawModeRequested }
val pollSliceMs = if (timeoutMs <= 0L) 250L else minOf(250L, timeoutMs)
if (rawRequested) {
val ev = readKeyEvent(pollSliceMs)
if (ev != null) return ev
} else {
delay(25)
}
if (timeoutMs > 0L && started.elapsedNow() >= timeoutMs.milliseconds) {
return null
}
}
return null
}
override suspend fun close() {
closed = true
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
if (!isSupported) return false
return stateMutex.withLock {
if (enabled) {
if (rawModeRequested) return@withLock false
memScoped {
val attrs = alloc<termios>()
if (tcgetattr(STDIN_FILENO, attrs.ptr) != 0) return@withLock false
val saved = ByteArray(sizeOf<termios>().toInt())
saved.usePinned { pinned ->
memcpy(pinned.addressOf(0), attrs.ptr, sizeOf<termios>().convert())
}
savedAttrsBlob = saved
attrs.c_lflag = attrs.c_lflag and ICANON.convert<UInt>().inv() and ECHO.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and IXON.convert<UInt>().inv() and ISTRIP.convert<UInt>().inv()
attrs.c_oflag = attrs.c_oflag and OPOST.convert<UInt>().inv()
if (tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) != 0) return@withLock false
}
rawModeRequested = true
true
} else {
val hadRaw = rawModeRequested
rawModeRequested = false
val saved = savedAttrsBlob
if (saved != null) {
memScoped {
val attrs = alloc<termios>()
saved.usePinned { pinned ->
memcpy(attrs.ptr, pinned.addressOf(0), sizeOf<termios>().convert())
}
tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr)
}
}
hadRaw
}
}
}
private fun readGeometry(): ConsoleGeometry? = memScoped {
val ws = alloc<winsize>()
if (ioctl(STDOUT_FILENO, TIOCGWINSZ.convert(), ws.ptr) != 0) return null
val cols = ws.ws_col.toInt()
val rows = ws.ws_row.toInt()
if (cols <= 0 || rows <= 0) return null
ConsoleGeometry(columns = cols, rows = rows)
}
private fun readByte(timeoutMs: Long): Int? = memScoped {
val pfd = alloc<pollfd>()
pfd.fd = STDIN_FILENO
pfd.events = POLLIN.convert()
pfd.revents = 0
val ready = poll(pfd.ptr, 1.convert(), timeoutMs.toInt())
if (ready <= 0) return null
val buf = ByteArray(1)
val count = buf.usePinned { pinned ->
read(STDIN_FILENO, pinned.addressOf(0), 1.convert())
}
if (count <= 0) return null
val b = buf[0].toInt()
if (b < 0) b + 256 else b
}
private fun readKeyEvent(timeoutMs: Long): ConsoleEvent.KeyDown? {
val first = readByte(timeoutMs) ?: return null
return LinuxConsoleKeyDecoder.decode(first) { timeout ->
readByte(timeout)
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LinuxPosixLyngConsoleTest {
private fun decode(vararg bytes: Int): ConsoleEvent.KeyDown {
var i = 1
return LinuxConsoleKeyDecoder.decode(bytes[0]) { _ ->
if (i >= bytes.size) null else bytes[i++]
}
}
@Test
fun decodesArrowLeft() {
val ev = decode(27, '['.code, 'D'.code)
assertEquals("ArrowLeft", ev.key)
assertFalse(ev.ctrl)
}
@Test
fun decodesArrowRightCtrlModifier() {
val ev = decode(27, '['.code, '1'.code, ';'.code, '5'.code, 'C'.code)
assertEquals("ArrowRight", ev.key)
assertTrue(ev.ctrl)
}
@Test
fun decodesEscape() {
val ev = decode(27)
assertEquals("Escape", ev.key)
}
@Test
fun decodesCtrlC() {
val ev = decode(3)
assertEquals("c", ev.key)
assertTrue(ev.ctrl)
}
@Test
fun decodesUppercaseShift() {
val ev = decode('A'.code)
assertEquals("A", ev.key)
assertTrue(ev.shift)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun getNativeSystemConsole(): LyngConsole = MordantLyngConsole
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun getNativeSystemConsole(): LyngConsole = MordantLyngConsole
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on Native
}
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
actual fun getSystemConsole(): LyngConsole = getNativeSystemConsole()
internal expect fun getNativeSystemConsole(): LyngConsole
@@ -0,0 +1,22 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on wasmJs
}