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:
+5
-1
@@ -1,6 +1,6 @@
|
||||
# Clojure/Nim — AI-First Clojure Compiler
|
||||
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 =
|
||||
let appDir = getAppDir()
|
||||
@@ -206,6 +206,7 @@ proc main() =
|
||||
echo " cljnim deps Resolve dependencies from deps.edn"
|
||||
echo " cljnim -e '<code>' Evaluate expression"
|
||||
echo " cljnim ai '<description>' Generate Clojure code with AI"
|
||||
echo " cljnim tui Start interactive TUI"
|
||||
echo ""
|
||||
echo "REPL Commands:"
|
||||
echo " :quit, :q Exit REPL"
|
||||
@@ -346,6 +347,9 @@ proc main() =
|
||||
stderr.writeLine(aiRes.suggestion)
|
||||
quit(1)
|
||||
|
||||
of "tui":
|
||||
runTui()
|
||||
|
||||
else:
|
||||
stderr.writeLine("Unknown command: " & cmd)
|
||||
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