diff --git a/cljnim.nimble b/cljnim.nimble index ffef1bf..e7c5700 100644 --- a/cljnim.nimble +++ b/cljnim.nimble @@ -8,3 +8,4 @@ bin = @["cljnim"] # Dependencies requires "nim >= 2.0.0" +requires "illwill >= 0.4.0" diff --git a/src/cljnim.nim b/src/cljnim.nim index 3976ed2..ccee022 100644 --- a/src/cljnim.nim +++ b/src/cljnim.nim @@ -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 '' Evaluate expression" echo " cljnim ai '' 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) diff --git a/src/tui.nim b/src/tui.nim new file mode 100644 index 0000000..2774ae8 --- /dev/null +++ b/src/tui.nim @@ -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() diff --git a/src/tui_config.nim b/src/tui_config.nim new file mode 100644 index 0000000..ff316cb --- /dev/null +++ b/src/tui_config.nim @@ -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.. 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..>> " 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.. 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