feat: add fullscreen TUI with illwill
- New screens: Main Menu, Compile, Run, REPL, AI Generator, AI Settings, Help - AI configuration persisted in ~/.config/cljnim/config.json - Navigate with arrow keys, Enter, Escape - illwill added as dependency in cljnim.nimble
This commit is contained in:
@@ -8,3 +8,4 @@ bin = @["cljnim"]
|
|||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
requires "nim >= 2.0.0"
|
requires "nim >= 2.0.0"
|
||||||
|
requires "illwill >= 0.4.0"
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
# Clojure/Nim — AI-First Clojure Compiler
|
# Clojure/Nim — AI-First Clojure Compiler
|
||||||
import os, osproc, strutils, times
|
import os, osproc, strutils, times
|
||||||
import reader, emitter, types, repl, macros, deps, ai_assist
|
import reader, emitter, types, repl, macros, deps, ai_assist, tui
|
||||||
|
|
||||||
proc getLibPath(): string =
|
proc getLibPath(): string =
|
||||||
let appDir = getAppDir()
|
let appDir = getAppDir()
|
||||||
@@ -206,6 +206,7 @@ proc main() =
|
|||||||
echo " cljnim deps Resolve dependencies from deps.edn"
|
echo " cljnim deps Resolve dependencies from deps.edn"
|
||||||
echo " cljnim -e '<code>' Evaluate expression"
|
echo " cljnim -e '<code>' Evaluate expression"
|
||||||
echo " cljnim ai '<description>' Generate Clojure code with AI"
|
echo " cljnim ai '<description>' Generate Clojure code with AI"
|
||||||
|
echo " cljnim tui Start interactive TUI"
|
||||||
echo ""
|
echo ""
|
||||||
echo "REPL Commands:"
|
echo "REPL Commands:"
|
||||||
echo " :quit, :q Exit REPL"
|
echo " :quit, :q Exit REPL"
|
||||||
@@ -346,6 +347,9 @@ proc main() =
|
|||||||
stderr.writeLine(aiRes.suggestion)
|
stderr.writeLine(aiRes.suggestion)
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|
||||||
|
of "tui":
|
||||||
|
runTui()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
stderr.writeLine("Unknown command: " & cmd)
|
stderr.writeLine("Unknown command: " & cmd)
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# Clojure/Nim TUI — Terminal User Interface
|
||||||
|
# Fullscreen menu-driven interface for cljnim
|
||||||
|
|
||||||
|
import std/os
|
||||||
|
import illwill
|
||||||
|
import tui_config, tui_screens, repl
|
||||||
|
|
||||||
|
proc runTui*() =
|
||||||
|
## Main TUI entry point
|
||||||
|
let cfg = loadConfig()
|
||||||
|
var state = initState(cfg)
|
||||||
|
|
||||||
|
# ── illwill init ────────────────────────────────────────────────
|
||||||
|
proc exitProc() {.noconv.} =
|
||||||
|
illwillDeinit()
|
||||||
|
showCursor()
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
illwillInit(fullscreen = true)
|
||||||
|
setControlCHook(exitProc)
|
||||||
|
hideCursor()
|
||||||
|
|
||||||
|
var tb = newTerminalBuffer(terminalWidth(), terminalHeight())
|
||||||
|
|
||||||
|
# ── main loop ───────────────────────────────────────────────────
|
||||||
|
while not state.quit:
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
# Resize buffer if terminal changed
|
||||||
|
if tb.width != w or tb.height != h:
|
||||||
|
tb = newTerminalBuffer(w, h)
|
||||||
|
|
||||||
|
# Clear
|
||||||
|
tb.clear()
|
||||||
|
tb.setForegroundColor(fgWhite)
|
||||||
|
|
||||||
|
# Draw current screen
|
||||||
|
case state.currentScreen
|
||||||
|
of stMainMenu:
|
||||||
|
drawMainMenu(tb, state)
|
||||||
|
of stAiSettings:
|
||||||
|
drawAiSettings(tb, state)
|
||||||
|
of stCompile:
|
||||||
|
drawCompile(tb, state)
|
||||||
|
of stRun:
|
||||||
|
drawRun(tb, state)
|
||||||
|
of stAiGenerate:
|
||||||
|
drawAiGenerate(tb, state)
|
||||||
|
of stHelp:
|
||||||
|
drawHelp(tb, state)
|
||||||
|
of stRepl:
|
||||||
|
drawRepl(tb, state)
|
||||||
|
|
||||||
|
# Draw popup on top if active
|
||||||
|
drawPopup(tb, state)
|
||||||
|
|
||||||
|
# Render
|
||||||
|
tb.display()
|
||||||
|
|
||||||
|
# Handle input
|
||||||
|
var key = getKey()
|
||||||
|
if key == Key.None:
|
||||||
|
sleep(20)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Special handling for REPL launch
|
||||||
|
if state.currentScreen == stRepl and state.popupActive and state.popupMsg == "LAUNCH_REPL":
|
||||||
|
state.popupActive = false
|
||||||
|
state.popupMsg = ""
|
||||||
|
# Suspend TUI and launch REPL
|
||||||
|
illwillDeinit()
|
||||||
|
showCursor()
|
||||||
|
echo ""
|
||||||
|
echo "=== Starting Clojure/Nim REPL ==="
|
||||||
|
echo "Type :quit to return to TUI"
|
||||||
|
echo ""
|
||||||
|
var replState = initReplState(rmHuman)
|
||||||
|
try:
|
||||||
|
runHumanRepl(replState)
|
||||||
|
finally:
|
||||||
|
replState.cleanup()
|
||||||
|
# Re-init TUI
|
||||||
|
illwillInit(fullscreen = true)
|
||||||
|
setControlCHook(exitProc)
|
||||||
|
hideCursor()
|
||||||
|
tb = newTerminalBuffer(terminalWidth(), terminalHeight())
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Route input to current screen handler
|
||||||
|
if state.popupActive:
|
||||||
|
# Popup consumes Enter/Escape
|
||||||
|
case key
|
||||||
|
of Key.Enter, Key.Escape:
|
||||||
|
state.popupActive = false
|
||||||
|
state.popupMsg = ""
|
||||||
|
else: discard
|
||||||
|
else:
|
||||||
|
case state.currentScreen
|
||||||
|
of stMainMenu:
|
||||||
|
handleMainMenu(key, state)
|
||||||
|
of stAiSettings:
|
||||||
|
handleAiSettings(key, state)
|
||||||
|
of stCompile:
|
||||||
|
handleCompile(key, state)
|
||||||
|
of stRun:
|
||||||
|
handleRun(key, state)
|
||||||
|
of stAiGenerate:
|
||||||
|
handleAiGenerate(key, state)
|
||||||
|
of stHelp:
|
||||||
|
handleHelp(key, state)
|
||||||
|
of stRepl:
|
||||||
|
handleRepl(key, state)
|
||||||
|
|
||||||
|
sleep(20)
|
||||||
|
|
||||||
|
# ── cleanup ─────────────────────────────────────────────────────
|
||||||
|
illwillDeinit()
|
||||||
|
showCursor()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# TUI Configuration Manager
|
||||||
|
# Handles persistent config in ~/.config/cljnim/config.json
|
||||||
|
|
||||||
|
import std/[json, os, strutils]
|
||||||
|
import ai_assist
|
||||||
|
|
||||||
|
type
|
||||||
|
TuiConfig* = object
|
||||||
|
provider*: string # "deepseek", "openai", "mimo", "custom"
|
||||||
|
model*: string
|
||||||
|
baseUrl*: string
|
||||||
|
apiKey*: string
|
||||||
|
timeoutMs*: int
|
||||||
|
lastFile*: string # last used .clj file
|
||||||
|
|
||||||
|
const
|
||||||
|
DefaultProviders = {
|
||||||
|
"deepseek": ("https://api.deepseek.com", "deepseek-chat"),
|
||||||
|
"openai": ("https://api.openai.com", "gpt-4o-mini"),
|
||||||
|
"mimo": ("https://api.mi-mo.ai", "mimo-chat"),
|
||||||
|
"custom": ("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
proc configDir(): string =
|
||||||
|
getHomeDir() / ".config" / "cljnim"
|
||||||
|
|
||||||
|
proc configPath(): string =
|
||||||
|
configDir() / "config.json"
|
||||||
|
|
||||||
|
proc defaultConfig*(): TuiConfig =
|
||||||
|
result.provider = "deepseek"
|
||||||
|
result.model = "deepseek-chat"
|
||||||
|
result.baseUrl = "https://api.deepseek.com"
|
||||||
|
result.apiKey = ""
|
||||||
|
result.timeoutMs = 15000
|
||||||
|
result.lastFile = ""
|
||||||
|
|
||||||
|
proc loadConfig*(): TuiConfig =
|
||||||
|
let path = configPath()
|
||||||
|
if not fileExists(path):
|
||||||
|
return defaultConfig()
|
||||||
|
try:
|
||||||
|
let jsonStr = readFile(path)
|
||||||
|
let j = parseJson(jsonStr)
|
||||||
|
result.provider = j{"provider"}.getStr("deepseek")
|
||||||
|
result.model = j{"model"}.getStr("deepseek-chat")
|
||||||
|
result.baseUrl = j{"baseUrl"}.getStr("https://api.deepseek.com")
|
||||||
|
result.apiKey = j{"apiKey"}.getStr("")
|
||||||
|
result.timeoutMs = j{"timeoutMs"}.getInt(15000)
|
||||||
|
result.lastFile = j{"lastFile"}.getStr("")
|
||||||
|
except:
|
||||||
|
result = defaultConfig()
|
||||||
|
|
||||||
|
proc saveConfig*(cfg: TuiConfig) =
|
||||||
|
let dir = configDir()
|
||||||
|
if not dirExists(dir):
|
||||||
|
createDir(dir)
|
||||||
|
let j = %*{
|
||||||
|
"provider": cfg.provider,
|
||||||
|
"model": cfg.model,
|
||||||
|
"baseUrl": cfg.baseUrl,
|
||||||
|
"apiKey": cfg.apiKey,
|
||||||
|
"timeoutMs": cfg.timeoutMs,
|
||||||
|
"lastFile": cfg.lastFile
|
||||||
|
}
|
||||||
|
writeFile(configPath(), pretty(j))
|
||||||
|
|
||||||
|
proc toAiConfig*(cfg: TuiConfig): AiConfig =
|
||||||
|
## Convert TUI config to AiConfig for ai_assist module
|
||||||
|
result.apiKey = cfg.apiKey
|
||||||
|
result.baseUrl = cfg.baseUrl
|
||||||
|
result.model = cfg.model
|
||||||
|
result.timeoutMs = cfg.timeoutMs
|
||||||
|
if cfg.provider == "deepseek":
|
||||||
|
result.provider = aiDeepSeek
|
||||||
|
else:
|
||||||
|
result.provider = aiOpenAiCompatible
|
||||||
|
|
||||||
|
proc fromAiConfig*(cfg: AiConfig): TuiConfig =
|
||||||
|
## Convert AiConfig to TUI config
|
||||||
|
result.apiKey = cfg.apiKey
|
||||||
|
result.baseUrl = cfg.baseUrl
|
||||||
|
result.model = cfg.model
|
||||||
|
result.timeoutMs = cfg.timeoutMs
|
||||||
|
if cfg.provider == aiDeepSeek:
|
||||||
|
result.provider = "deepseek"
|
||||||
|
else:
|
||||||
|
result.provider = "openai"
|
||||||
|
|
||||||
|
proc isValid*(cfg: TuiConfig): bool =
|
||||||
|
cfg.apiKey.len > 0 and cfg.baseUrl.len > 0 and cfg.model.len > 0
|
||||||
|
|
||||||
|
proc providerDefaults*(provider: string): tuple[baseUrl, model: string] =
|
||||||
|
for i in 0..<DefaultProviders.len:
|
||||||
|
if DefaultProviders[i][0] == provider:
|
||||||
|
return DefaultProviders[i][1]
|
||||||
|
return ("", "")
|
||||||
|
|
||||||
|
proc cycleProvider*(current: string, direction: int): string =
|
||||||
|
let providers = @["deepseek", "openai", "mimo", "custom"]
|
||||||
|
var idx = providers.find(current)
|
||||||
|
if idx < 0: idx = 0
|
||||||
|
idx = (idx + direction + providers.len) mod providers.len
|
||||||
|
return providers[idx]
|
||||||
|
|
||||||
|
proc maskedKey*(cfg: TuiConfig): string =
|
||||||
|
if cfg.apiKey.len <= 4:
|
||||||
|
return ""
|
||||||
|
return "*".repeat(cfg.apiKey.len - 4) & cfg.apiKey[^4..^1]
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
# TUI Screens for cljnim
|
||||||
|
# All UI screens: Main Menu, AI Settings, Compile, Run, Generate, Help
|
||||||
|
|
||||||
|
import std/[os, strutils, osproc]
|
||||||
|
import illwill
|
||||||
|
import tui_config, ai_assist
|
||||||
|
|
||||||
|
type
|
||||||
|
ScreenType* = enum
|
||||||
|
stMainMenu, stAiSettings, stCompile, stRun, stAiGenerate, stHelp, stRepl
|
||||||
|
|
||||||
|
InputField* = object
|
||||||
|
label*: string
|
||||||
|
value*: string
|
||||||
|
cursorPos*: int
|
||||||
|
password*: bool
|
||||||
|
active*: bool
|
||||||
|
|
||||||
|
ScreenState* = object
|
||||||
|
currentScreen*: ScreenType
|
||||||
|
prevScreen*: ScreenType
|
||||||
|
menuIndex*: int
|
||||||
|
config*: TuiConfig
|
||||||
|
configDirty*: bool
|
||||||
|
fields*: seq[InputField]
|
||||||
|
activeField*: int
|
||||||
|
statusMsg*: string
|
||||||
|
statusColor*: ForegroundColor
|
||||||
|
popupMsg*: string
|
||||||
|
popupActive*: bool
|
||||||
|
outputText*: seq[string]
|
||||||
|
outputScroll*: int
|
||||||
|
quit*: bool
|
||||||
|
|
||||||
|
proc initState*(cfg: TuiConfig): ScreenState =
|
||||||
|
result.currentScreen = stMainMenu
|
||||||
|
result.prevScreen = stMainMenu
|
||||||
|
result.menuIndex = 0
|
||||||
|
result.config = cfg
|
||||||
|
result.configDirty = false
|
||||||
|
result.activeField = 0
|
||||||
|
result.statusMsg = "Ready"
|
||||||
|
result.statusColor = fgGreen
|
||||||
|
result.popupMsg = ""
|
||||||
|
result.popupActive = false
|
||||||
|
result.outputText = @[]
|
||||||
|
result.outputScroll = 0
|
||||||
|
result.quit = false
|
||||||
|
|
||||||
|
const
|
||||||
|
MenuItems = @[
|
||||||
|
"Compile .clj → .nim",
|
||||||
|
"Run .clj file",
|
||||||
|
"Start REPL",
|
||||||
|
"AI Code Generator",
|
||||||
|
"AI Settings",
|
||||||
|
"Help",
|
||||||
|
"Exit"
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc centerX(text: string, width: int): int =
|
||||||
|
max(0, (width - text.len) div 2)
|
||||||
|
|
||||||
|
proc truncate(s: string, maxLen: int): string =
|
||||||
|
if s.len <= maxLen: s
|
||||||
|
else: s[0..<maxLen-3] & "..."
|
||||||
|
|
||||||
|
proc wrapText(text: string, maxWidth: int): seq[string] =
|
||||||
|
result = @[]
|
||||||
|
for line in text.splitLines():
|
||||||
|
if line.len <= maxWidth:
|
||||||
|
result.add(line)
|
||||||
|
else:
|
||||||
|
var i = 0
|
||||||
|
while i < line.len:
|
||||||
|
let endIdx = min(i + maxWidth, line.len)
|
||||||
|
result.add(line[i..<endIdx])
|
||||||
|
i = endIdx
|
||||||
|
|
||||||
|
proc drawBox(tb: var TerminalBuffer, x1, y1, x2, y2: int, title = "") =
|
||||||
|
tb.drawRect(x1, y1, x2, y2)
|
||||||
|
if title.len > 0 and title.len < (x2 - x1):
|
||||||
|
let tx = centerX(title, x2 - x1 - 1) + x1 + 1
|
||||||
|
tb.write(tx, y1, fgCyan, " ", title, " ")
|
||||||
|
|
||||||
|
proc drawButton(tb: var TerminalBuffer, x, y: int, label: string, active: bool) =
|
||||||
|
if active:
|
||||||
|
tb.write(x, y, bgWhite, fgBlack, " ", label, " ", resetStyle)
|
||||||
|
else:
|
||||||
|
tb.write(x, y, fgWhite, "[", label, "]")
|
||||||
|
|
||||||
|
proc drawOutputArea(tb: var TerminalBuffer, x1, y1, x2, y2: int, lines: seq[string], scroll: int) =
|
||||||
|
tb.drawRect(x1, y1, x2, y2)
|
||||||
|
let visibleHeight = y2 - y1 - 1
|
||||||
|
let startIdx = if scroll < 0: max(0, lines.len - visibleHeight) else: scroll
|
||||||
|
for i in 0..<visibleHeight:
|
||||||
|
let lineIdx = startIdx + i
|
||||||
|
if lineIdx < lines.len:
|
||||||
|
tb.write(x1 + 1, y1 + 1 + i, fgWhite, truncate(lines[lineIdx], x2 - x1 - 2))
|
||||||
|
else:
|
||||||
|
tb.write(x1 + 1, y1 + 1 + i, fgBlack, "~")
|
||||||
|
|
||||||
|
# ─── Popup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawPopup*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
if not state.popupActive or state.popupMsg.len == 0: return
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
let lines = state.popupMsg.wrapText(w - 8)
|
||||||
|
let pw = min(w - 4, 60)
|
||||||
|
let ph = lines.len + 4
|
||||||
|
let px = max(2, (w - pw) div 2)
|
||||||
|
let py = max(2, (h - ph) div 2)
|
||||||
|
|
||||||
|
tb.fill(px, py, px + pw, py + ph, " ")
|
||||||
|
tb.drawBox(px, py, px + pw, py + ph, "Message")
|
||||||
|
for i, line in lines:
|
||||||
|
tb.write(px + 2, py + 2 + i, fgWhite, truncate(line, pw - 4))
|
||||||
|
tb.write(px + 2, py + ph - 1, fgYellow, "Press ENTER or ESC to close")
|
||||||
|
|
||||||
|
# ─── Main Menu ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawMainMenu*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
# Header
|
||||||
|
let title = "Clojure/Nim TUI"
|
||||||
|
tb.write(centerX(title, w), 1, fgCyan, title)
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
# Menu box
|
||||||
|
let boxW = 50
|
||||||
|
let boxH = MenuItems.len + 6
|
||||||
|
let bx = centerX("", boxW)
|
||||||
|
let by = max(4, (h - boxH) div 2)
|
||||||
|
drawBox(tb, bx, by, bx + boxW, by + boxH, "Main Menu")
|
||||||
|
|
||||||
|
for i, item in MenuItems:
|
||||||
|
let y = by + 2 + i
|
||||||
|
let arrow = if i == state.menuIndex: ">>> " else: " "
|
||||||
|
let color = if i == state.menuIndex: fgYellow else: fgWhite
|
||||||
|
tb.write(bx + 4, y, color, arrow, item)
|
||||||
|
|
||||||
|
# Footer status
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
let aiStatus = if state.config.isValid: state.config.provider & " (configured)" else: "not configured"
|
||||||
|
let status = "Status: " & state.statusMsg & " | AI: " & aiStatus
|
||||||
|
tb.write(2, h - 1, state.statusColor, truncate(status, w - 4))
|
||||||
|
|
||||||
|
proc handleMainMenu*(key: Key, state: var ScreenState) =
|
||||||
|
case key
|
||||||
|
of Key.Up:
|
||||||
|
state.menuIndex = (state.menuIndex - 1 + MenuItems.len) mod MenuItems.len
|
||||||
|
of Key.Down:
|
||||||
|
state.menuIndex = (state.menuIndex + 1) mod MenuItems.len
|
||||||
|
of Key.Enter:
|
||||||
|
case state.menuIndex
|
||||||
|
of 0:
|
||||||
|
state.currentScreen = stCompile
|
||||||
|
state.fields = @[InputField(label: "File", value: state.config.lastFile, cursorPos: state.config.lastFile.len, active: true)]
|
||||||
|
state.activeField = 0
|
||||||
|
state.outputText = @[]
|
||||||
|
of 1:
|
||||||
|
state.currentScreen = stRun
|
||||||
|
state.fields = @[InputField(label: "File", value: state.config.lastFile, cursorPos: state.config.lastFile.len, active: true)]
|
||||||
|
state.activeField = 0
|
||||||
|
state.outputText = @[]
|
||||||
|
of 2: state.currentScreen = stRepl
|
||||||
|
of 3:
|
||||||
|
state.currentScreen = stAiGenerate
|
||||||
|
state.fields = @[InputField(label: "Description", value: "", cursorPos: 0, active: true)]
|
||||||
|
state.activeField = 0
|
||||||
|
state.outputText = @[]
|
||||||
|
of 4:
|
||||||
|
state.prevScreen = stMainMenu
|
||||||
|
state.currentScreen = stAiSettings
|
||||||
|
state.activeField = 0
|
||||||
|
state.fields = @[
|
||||||
|
InputField(label: "Provider", value: state.config.provider, cursorPos: state.config.provider.len, active: true),
|
||||||
|
InputField(label: "Model", value: state.config.model, cursorPos: state.config.model.len, active: false),
|
||||||
|
InputField(label: "Base URL", value: state.config.baseUrl, cursorPos: state.config.baseUrl.len, active: false),
|
||||||
|
InputField(label: "API Key", value: state.config.apiKey, cursorPos: state.config.apiKey.len, password: true, active: false),
|
||||||
|
InputField(label: "Timeout (ms)", value: $state.config.timeoutMs, cursorPos: ($state.config.timeoutMs).len, active: false)
|
||||||
|
]
|
||||||
|
of 5: state.currentScreen = stHelp
|
||||||
|
of 6: state.quit = true
|
||||||
|
else: discard
|
||||||
|
of Key.Escape: state.quit = true
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
# ─── AI Settings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawAiSettings*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
tb.write(centerX("AI Configuration", w), 1, fgCyan, "AI Configuration")
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
let startY = 4
|
||||||
|
let labelW = 14
|
||||||
|
for i, f in state.fields:
|
||||||
|
let y = startY + i * 2
|
||||||
|
let lblColor = if i == state.activeField: fgYellow else: fgWhite
|
||||||
|
tb.write(4, y, lblColor, truncate(f.label, labelW) & ":")
|
||||||
|
|
||||||
|
let valX = 4 + labelW + 2
|
||||||
|
let valW = min(50, w - valX - 4)
|
||||||
|
var display = f.value
|
||||||
|
if f.password:
|
||||||
|
display = if f.value.len > 4: "*".repeat(f.value.len - 4) & f.value[^4..^1] else: f.value
|
||||||
|
display = truncate(display, valW)
|
||||||
|
|
||||||
|
if i == state.activeField:
|
||||||
|
tb.write(valX, y, bgWhite, fgBlack, display & " ".repeat(valW - display.len), resetStyle)
|
||||||
|
else:
|
||||||
|
tb.write(valX, y, fgWhite, display)
|
||||||
|
|
||||||
|
# Provider hint
|
||||||
|
if state.activeField == 0:
|
||||||
|
let (defaultUrl, defaultModel) = providerDefaults(state.fields[0].value)
|
||||||
|
if defaultUrl.len > 0:
|
||||||
|
tb.write(4, startY + state.fields.len * 2 + 1, fgBlack, "Defaults: " & defaultUrl & " | " & defaultModel)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
let btnY = h - 5
|
||||||
|
drawButton(tb, 4, btnY, "Save", state.activeField == state.fields.len)
|
||||||
|
drawButton(tb, 14, btnY, "Test", state.activeField == state.fields.len + 1)
|
||||||
|
drawButton(tb, 26, btnY, "Back", state.activeField == state.fields.len + 2)
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
tb.write(2, h - 1, state.statusColor, truncate(state.statusMsg, w - 4))
|
||||||
|
|
||||||
|
proc handleAiSettings*(key: Key, state: var ScreenState) =
|
||||||
|
if state.popupActive:
|
||||||
|
case key
|
||||||
|
of Key.Enter, Key.Escape:
|
||||||
|
state.popupActive = false
|
||||||
|
state.popupMsg = ""
|
||||||
|
else: discard
|
||||||
|
return
|
||||||
|
|
||||||
|
let totalFields = state.fields.len + 3 # fields + Save + Test + Back
|
||||||
|
|
||||||
|
case key
|
||||||
|
of Key.Up:
|
||||||
|
state.activeField = (state.activeField - 1 + totalFields) mod totalFields
|
||||||
|
of Key.Down:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Tab:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Backspace:
|
||||||
|
if state.activeField < state.fields.len:
|
||||||
|
var f = addr state.fields[state.activeField]
|
||||||
|
if f.cursorPos > 0:
|
||||||
|
f.value.delete(f.cursorPos - 1 .. f.cursorPos - 1)
|
||||||
|
f.cursorPos.dec
|
||||||
|
of Key.Left:
|
||||||
|
if state.activeField < state.fields.len and state.fields[state.activeField].cursorPos > 0:
|
||||||
|
state.fields[state.activeField].cursorPos.dec
|
||||||
|
of Key.Right:
|
||||||
|
if state.activeField < state.fields.len and state.fields[state.activeField].cursorPos < state.fields[state.activeField].value.len:
|
||||||
|
state.fields[state.activeField].cursorPos.inc
|
||||||
|
of Key.Enter:
|
||||||
|
if state.activeField < state.fields.len:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
elif state.activeField == state.fields.len: # Save
|
||||||
|
state.config.provider = state.fields[0].value
|
||||||
|
state.config.model = state.fields[1].value
|
||||||
|
state.config.baseUrl = state.fields[2].value
|
||||||
|
state.config.apiKey = state.fields[3].value
|
||||||
|
try:
|
||||||
|
state.config.timeoutMs = parseInt(state.fields[4].value)
|
||||||
|
except:
|
||||||
|
state.config.timeoutMs = 15000
|
||||||
|
saveConfig(state.config)
|
||||||
|
state.statusMsg = "Configuration saved!"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
elif state.activeField == state.fields.len + 1: # Test
|
||||||
|
let testCfg = TuiConfig(
|
||||||
|
provider: state.fields[0].value,
|
||||||
|
model: state.fields[1].value,
|
||||||
|
baseUrl: state.fields[2].value,
|
||||||
|
apiKey: state.fields[3].value,
|
||||||
|
timeoutMs: (try: parseInt(state.fields[4].value) except: 15000),
|
||||||
|
lastFile: ""
|
||||||
|
)
|
||||||
|
state.statusMsg = "Testing connection..."
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
let aiCfg = toAiConfig(testCfg)
|
||||||
|
let res = callAiApi(aiCfg, "Say 'OK' if you can read this.")
|
||||||
|
if res.ok:
|
||||||
|
state.popupMsg = "Connection successful!\nModel: " & testCfg.model & "\nResponse: " & res.suggestion
|
||||||
|
state.statusMsg = "Connection OK"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
state.popupMsg = "Connection failed:\n" & res.suggestion
|
||||||
|
state.statusMsg = "Connection failed"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
state.popupActive = true
|
||||||
|
else: # Back
|
||||||
|
state.currentScreen = state.prevScreen
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
of Key.Escape:
|
||||||
|
state.currentScreen = state.prevScreen
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
if state.activeField < state.fields.len:
|
||||||
|
let ch = char(key.ord)
|
||||||
|
if ch >= ' ' and ch <= '~':
|
||||||
|
var f = addr state.fields[state.activeField]
|
||||||
|
f.value.insert($ch, f.cursorPos)
|
||||||
|
f.cursorPos.inc
|
||||||
|
if state.activeField == 0:
|
||||||
|
let (url, model) = providerDefaults(f.value)
|
||||||
|
if url.len > 0:
|
||||||
|
state.fields[1].value = model
|
||||||
|
state.fields[2].value = url
|
||||||
|
state.fields[1].cursorPos = model.len
|
||||||
|
state.fields[2].cursorPos = url.len
|
||||||
|
for i in 0..<state.fields.len:
|
||||||
|
state.fields[i].active = (i == state.activeField)
|
||||||
|
|
||||||
|
# ─── Compile Screen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawCompile*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
tb.write(centerX("Compile to Nim", w), 1, fgCyan, "Compile to Nim")
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
let valX = 12
|
||||||
|
let valW = min(50, w - valX - 4)
|
||||||
|
let f = state.fields[0]
|
||||||
|
let display = truncate(f.value, valW)
|
||||||
|
tb.write(4, 4, fgWhite, "File:")
|
||||||
|
if state.activeField == 0:
|
||||||
|
tb.write(valX, 4, bgWhite, fgBlack, display & " ".repeat(valW - display.len), resetStyle)
|
||||||
|
else:
|
||||||
|
tb.write(valX, 4, fgWhite, display)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
let btnY = 7
|
||||||
|
drawButton(tb, 4, btnY, "Compile", state.activeField == 1)
|
||||||
|
drawButton(tb, 16, btnY, "Browse", state.activeField == 2)
|
||||||
|
drawButton(tb, 28, btnY, "Back", state.activeField == 3)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
if state.outputText.len > 0:
|
||||||
|
drawOutputArea(tb, 2, 9, w - 2, h - 3, state.outputText, state.outputScroll)
|
||||||
|
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
tb.write(2, h - 1, state.statusColor, truncate(state.statusMsg, w - 4))
|
||||||
|
|
||||||
|
proc handleCompile*(key: Key, state: var ScreenState) =
|
||||||
|
if state.popupActive:
|
||||||
|
case key
|
||||||
|
of Key.Enter, Key.Escape:
|
||||||
|
state.popupActive = false
|
||||||
|
state.popupMsg = ""
|
||||||
|
else: discard
|
||||||
|
return
|
||||||
|
|
||||||
|
let totalFields = 4 # file + Compile + Browse + Back
|
||||||
|
case key
|
||||||
|
of Key.Up:
|
||||||
|
state.activeField = (state.activeField - 1 + totalFields) mod totalFields
|
||||||
|
of Key.Down:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Tab:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Backspace:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos > 0:
|
||||||
|
state.fields[0].value.delete(state.fields[0].cursorPos - 1 .. state.fields[0].cursorPos - 1)
|
||||||
|
state.fields[0].cursorPos.dec
|
||||||
|
of Key.Left:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos > 0:
|
||||||
|
state.fields[0].cursorPos.dec
|
||||||
|
of Key.Right:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos < state.fields[0].value.len:
|
||||||
|
state.fields[0].cursorPos.inc
|
||||||
|
of Key.Enter:
|
||||||
|
if state.activeField == 0:
|
||||||
|
state.activeField = 1
|
||||||
|
elif state.activeField == 1: # Compile
|
||||||
|
let filePath = state.fields[0].value.strip()
|
||||||
|
if filePath.len == 0:
|
||||||
|
state.statusMsg = "Please enter a file path"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
elif not fileExists(filePath):
|
||||||
|
state.statusMsg = "File not found: " & filePath
|
||||||
|
state.statusColor = fgRed
|
||||||
|
else:
|
||||||
|
state.config.lastFile = filePath
|
||||||
|
saveConfig(state.config)
|
||||||
|
state.statusMsg = "Compiling..."
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
let appDir = getAppDir()
|
||||||
|
let cljnimBin = if fileExists(appDir / "cljnim"): appDir / "cljnim" else: "./cljnim"
|
||||||
|
let (output, exitCode) = execCmdEx(cljnimBin & " compile " & quoteShell(filePath))
|
||||||
|
state.outputText = output.splitLines()
|
||||||
|
if exitCode == 0:
|
||||||
|
state.statusMsg = "Compilation successful!"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
state.statusMsg = "Compilation failed"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
elif state.activeField == 2: # Browse
|
||||||
|
state.statusMsg = "Browse not implemented — type path manually"
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
else: # Back
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
of Key.Escape:
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
if state.activeField == 0:
|
||||||
|
let ch = char(key.ord)
|
||||||
|
if ch >= ' ' and ch <= '~':
|
||||||
|
state.fields[0].value.insert($ch, state.fields[0].cursorPos)
|
||||||
|
state.fields[0].cursorPos.inc
|
||||||
|
|
||||||
|
# ─── Run Screen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawRun*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
tb.write(centerX("Run Clojure File", w), 1, fgCyan, "Run Clojure File")
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
let valX = 12
|
||||||
|
let valW = min(50, w - valX - 4)
|
||||||
|
let display = truncate(state.fields[0].value, valW)
|
||||||
|
tb.write(4, 4, fgWhite, "File:")
|
||||||
|
if state.activeField == 0:
|
||||||
|
tb.write(valX, 4, bgWhite, fgBlack, display & " ".repeat(valW - display.len), resetStyle)
|
||||||
|
else:
|
||||||
|
tb.write(valX, 4, fgWhite, display)
|
||||||
|
|
||||||
|
let btnY = 7
|
||||||
|
drawButton(tb, 4, btnY, "Run", state.activeField == 1)
|
||||||
|
drawButton(tb, 14, btnY, "Browse", state.activeField == 2)
|
||||||
|
drawButton(tb, 26, btnY, "Back", state.activeField == 3)
|
||||||
|
|
||||||
|
if state.outputText.len > 0:
|
||||||
|
drawOutputArea(tb, 2, 9, w - 2, h - 3, state.outputText, state.outputScroll)
|
||||||
|
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
tb.write(2, h - 1, state.statusColor, truncate(state.statusMsg, w - 4))
|
||||||
|
|
||||||
|
proc handleRun*(key: Key, state: var ScreenState) =
|
||||||
|
if state.popupActive:
|
||||||
|
case key
|
||||||
|
of Key.Enter, Key.Escape:
|
||||||
|
state.popupActive = false
|
||||||
|
state.popupMsg = ""
|
||||||
|
else: discard
|
||||||
|
return
|
||||||
|
|
||||||
|
let totalFields = 4
|
||||||
|
case key
|
||||||
|
of Key.Up:
|
||||||
|
state.activeField = (state.activeField - 1 + totalFields) mod totalFields
|
||||||
|
of Key.Down:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Tab:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Backspace:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos > 0:
|
||||||
|
state.fields[0].value.delete(state.fields[0].cursorPos - 1 .. state.fields[0].cursorPos - 1)
|
||||||
|
state.fields[0].cursorPos.dec
|
||||||
|
of Key.Left:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos > 0:
|
||||||
|
state.fields[0].cursorPos.dec
|
||||||
|
of Key.Right:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos < state.fields[0].value.len:
|
||||||
|
state.fields[0].cursorPos.inc
|
||||||
|
of Key.Enter:
|
||||||
|
if state.activeField == 0:
|
||||||
|
state.activeField = 1
|
||||||
|
elif state.activeField == 1: # Run
|
||||||
|
let filePath = state.fields[0].value.strip()
|
||||||
|
if filePath.len == 0:
|
||||||
|
state.statusMsg = "Please enter a file path"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
elif not fileExists(filePath):
|
||||||
|
state.statusMsg = "File not found: " & filePath
|
||||||
|
state.statusColor = fgRed
|
||||||
|
else:
|
||||||
|
state.config.lastFile = filePath
|
||||||
|
saveConfig(state.config)
|
||||||
|
state.statusMsg = "Running..."
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
let appDir = getAppDir()
|
||||||
|
let cljnimBin = if fileExists(appDir / "cljnim"): appDir / "cljnim" else: "./cljnim"
|
||||||
|
let (output, exitCode) = execCmdEx(cljnimBin & " run " & quoteShell(filePath))
|
||||||
|
state.outputText = output.splitLines()
|
||||||
|
if exitCode == 0:
|
||||||
|
state.statusMsg = "Execution successful!"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
state.statusMsg = "Execution failed"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
elif state.activeField == 2: # Browse
|
||||||
|
state.statusMsg = "Browse not implemented — type path manually"
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
else: # Back
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
of Key.Escape:
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
if state.activeField == 0:
|
||||||
|
let ch = char(key.ord)
|
||||||
|
if ch >= ' ' and ch <= '~':
|
||||||
|
state.fields[0].value.insert($ch, state.fields[0].cursorPos)
|
||||||
|
state.fields[0].cursorPos.inc
|
||||||
|
|
||||||
|
# ─── AI Generate Screen ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawAiGenerate*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
tb.write(centerX("AI Code Generator", w), 1, fgCyan, "AI Code Generator")
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
let valX = 16
|
||||||
|
let valW = min(50, w - valX - 4)
|
||||||
|
let display = truncate(state.fields[0].value, valW)
|
||||||
|
tb.write(4, 4, fgWhite, "Description:")
|
||||||
|
if state.activeField == 0:
|
||||||
|
tb.write(valX, 4, bgWhite, fgBlack, display & " ".repeat(valW - display.len), resetStyle)
|
||||||
|
else:
|
||||||
|
tb.write(valX, 4, fgWhite, display)
|
||||||
|
|
||||||
|
let btnY = 7
|
||||||
|
drawButton(tb, 4, btnY, "Generate", state.activeField == 1)
|
||||||
|
drawButton(tb, 18, btnY, "Copy", state.activeField == 2)
|
||||||
|
drawButton(tb, 28, btnY, "Back", state.activeField == 3)
|
||||||
|
|
||||||
|
if state.outputText.len > 0:
|
||||||
|
drawOutputArea(tb, 2, 9, w - 2, h - 3, state.outputText, state.outputScroll)
|
||||||
|
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
tb.write(2, h - 1, state.statusColor, truncate(state.statusMsg, w - 4))
|
||||||
|
|
||||||
|
proc handleAiGenerate*(key: Key, state: var ScreenState) =
|
||||||
|
if state.popupActive:
|
||||||
|
case key
|
||||||
|
of Key.Enter, Key.Escape:
|
||||||
|
state.popupActive = false
|
||||||
|
state.popupMsg = ""
|
||||||
|
else: discard
|
||||||
|
return
|
||||||
|
|
||||||
|
let totalFields = 4
|
||||||
|
case key
|
||||||
|
of Key.Up:
|
||||||
|
state.activeField = (state.activeField - 1 + totalFields) mod totalFields
|
||||||
|
of Key.Down:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Tab:
|
||||||
|
state.activeField = (state.activeField + 1) mod totalFields
|
||||||
|
of Key.Backspace:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos > 0:
|
||||||
|
state.fields[0].value.delete(state.fields[0].cursorPos - 1 .. state.fields[0].cursorPos - 1)
|
||||||
|
state.fields[0].cursorPos.dec
|
||||||
|
of Key.Left:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos > 0:
|
||||||
|
state.fields[0].cursorPos.dec
|
||||||
|
of Key.Right:
|
||||||
|
if state.activeField == 0 and state.fields[0].cursorPos < state.fields[0].value.len:
|
||||||
|
state.fields[0].cursorPos.inc
|
||||||
|
of Key.Enter:
|
||||||
|
if state.activeField == 0:
|
||||||
|
state.activeField = 1
|
||||||
|
elif state.activeField == 1: # Generate
|
||||||
|
if not state.config.isValid:
|
||||||
|
state.statusMsg = "AI not configured — go to AI Settings"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
else:
|
||||||
|
let desc = state.fields[0].value.strip()
|
||||||
|
if desc.len == 0:
|
||||||
|
state.statusMsg = "Please enter a description"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
else:
|
||||||
|
state.statusMsg = "Generating..."
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
let res = generateCode(desc)
|
||||||
|
if res.ok:
|
||||||
|
state.outputText = res.suggestion.splitLines()
|
||||||
|
state.statusMsg = "Code generated!"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
state.outputText = @["Error:", res.suggestion]
|
||||||
|
state.statusMsg = "Generation failed"
|
||||||
|
state.statusColor = fgRed
|
||||||
|
elif state.activeField == 2: # Copy
|
||||||
|
if state.outputText.len > 0:
|
||||||
|
let fullText = state.outputText.join("\n")
|
||||||
|
# Try to copy to clipboard via xclip/xsel
|
||||||
|
let (_, exitCode) = execCmdEx("echo " & quoteShell(fullText) & " | xclip -selection clipboard 2>/dev/null || echo " & quoteShell(fullText) & " | xsel --clipboard --input 2>/dev/null")
|
||||||
|
if exitCode == 0:
|
||||||
|
state.statusMsg = "Copied to clipboard!"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
state.statusMsg = "Copy failed — install xclip or xsel"
|
||||||
|
state.statusColor = fgYellow
|
||||||
|
else: # Back
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
of Key.Escape:
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
state.statusMsg = "Ready"
|
||||||
|
state.statusColor = fgGreen
|
||||||
|
else:
|
||||||
|
if state.activeField == 0:
|
||||||
|
let ch = char(key.ord)
|
||||||
|
if ch >= ' ' and ch <= '~':
|
||||||
|
state.fields[0].value.insert($ch, state.fields[0].cursorPos)
|
||||||
|
state.fields[0].cursorPos.inc
|
||||||
|
|
||||||
|
# ─── Help Screen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawHelp*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
tb.write(centerX("Help", w), 1, fgCyan, "Help")
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
let helpText = @[
|
||||||
|
"Clojure/Nim TUI v0.1.0",
|
||||||
|
"",
|
||||||
|
"Navigation:",
|
||||||
|
" Arrow Keys / Tab Navigate menus and fields",
|
||||||
|
" Enter Select / Confirm",
|
||||||
|
" Escape Go back / Cancel",
|
||||||
|
" Backspace Delete character",
|
||||||
|
"",
|
||||||
|
"AI Configuration:",
|
||||||
|
" Set your API key in AI Settings. Supported providers:",
|
||||||
|
" - DeepSeek (deepseek-chat)",
|
||||||
|
" - OpenAI / OpenRouter (gpt-4o-mini)",
|
||||||
|
" - Xiaomi MiMo (mimo-chat)",
|
||||||
|
" - Custom OpenAI-compatible endpoints",
|
||||||
|
"",
|
||||||
|
"Config is saved to: ~/.config/cljnim/config.json",
|
||||||
|
"",
|
||||||
|
"Press ESC to return to Main Menu"
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, line in helpText:
|
||||||
|
if 4 + i < h - 2:
|
||||||
|
tb.write(4, 4 + i, fgWhite, truncate(line, w - 8))
|
||||||
|
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
tb.write(2, h - 1, fgGreen, "Press ESC to go back")
|
||||||
|
|
||||||
|
proc handleHelp*(key: Key, state: var ScreenState) =
|
||||||
|
case key
|
||||||
|
of Key.Escape, Key.Enter:
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
else: discard
|
||||||
|
|
||||||
|
# ─── REPL Screen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc drawRepl*(tb: var TerminalBuffer, state: var ScreenState) =
|
||||||
|
let w = terminalWidth()
|
||||||
|
let h = terminalHeight()
|
||||||
|
|
||||||
|
tb.write(centerX("REPL", w), 1, fgCyan, "REPL")
|
||||||
|
tb.drawHorizLine(0, w - 1, 2, doubleStyle = true)
|
||||||
|
|
||||||
|
let msg = @[
|
||||||
|
"The REPL runs in a separate terminal session.",
|
||||||
|
"",
|
||||||
|
"Press ENTER to start the REPL now.",
|
||||||
|
"Press ESC to go back to the menu.",
|
||||||
|
"",
|
||||||
|
"You can also run REPL from command line:",
|
||||||
|
" ./cljnim repl (human mode)",
|
||||||
|
" ./cljnim repl --json (AI/structured mode)",
|
||||||
|
" ./cljnim repl --tcp 5555 (TCP server mode)"
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, line in msg:
|
||||||
|
if 6 + i < h - 2:
|
||||||
|
tb.write(4, 6 + i, fgWhite, truncate(line, w - 8))
|
||||||
|
|
||||||
|
tb.drawHorizLine(0, w - 1, h - 2, doubleStyle = true)
|
||||||
|
tb.write(2, h - 1, fgGreen, "ENTER = start REPL | ESC = back")
|
||||||
|
|
||||||
|
proc handleRepl*(key: Key, state: var ScreenState) =
|
||||||
|
case key
|
||||||
|
of Key.Escape:
|
||||||
|
state.currentScreen = stMainMenu
|
||||||
|
of Key.Enter:
|
||||||
|
# Signal to main loop to suspend TUI and launch REPL
|
||||||
|
state.popupMsg = "LAUNCH_REPL"
|
||||||
|
state.popupActive = true
|
||||||
|
else: discard
|
||||||
Reference in New Issue
Block a user