diff --git a/backend/paket.dependencies b/backend/paket.dependencies index 40fc7ae310..e28df45c11 100644 --- a/backend/paket.dependencies +++ b/backend/paket.dependencies @@ -10,6 +10,7 @@ nuget FSharp.Core = 10.0.100 nuget System.Text.Json = 10.0.0 nuget NodaTime = 3.2.2 nuget System.IO.Hashing 10.0.0 +nuget Wcwidth = 4.0.1 // Sqlite and binary serialization nuget Microsoft.Data.Sqlite = 10.0.0 diff --git a/backend/paket.lock b/backend/paket.lock index 7e33a3d649..01d1d201d9 100644 --- a/backend/paket.lock +++ b/backend/paket.lock @@ -76,3 +76,4 @@ NUGET System.Memory (4.6.3) System.Text.Json (10.0) System.Threading.Tasks.Extensions (4.6.3) + Wcwidth (4.0.1) diff --git a/backend/src/Builtins/Builtins.Cli/Builtins.Cli.fsproj b/backend/src/Builtins/Builtins.Cli/Builtins.Cli.fsproj index bcb234d70e..03a1d4ba1f 100644 --- a/backend/src/Builtins/Builtins.Cli/Builtins.Cli.fsproj +++ b/backend/src/Builtins/Builtins.Cli/Builtins.Cli.fsproj @@ -13,8 +13,8 @@ - + diff --git a/backend/src/Builtins/Builtins.Cli/Libs/Posix.fs b/backend/src/Builtins/Builtins.Cli/Libs/Posix.fs index c89a6cb843..1d073273b2 100644 --- a/backend/src/Builtins/Builtins.Cli/Libs/Posix.fs +++ b/backend/src/Builtins/Builtins.Cli/Libs/Posix.fs @@ -119,6 +119,12 @@ module Libc = [] extern int private read_raw(int fd, byte[] buf, int count) + [] + extern int64 private lseek_raw(int fd, int64 offset, int whence) + + [] + extern int private ioctl_raw(int fd, uint64 request, IntPtr argument) + [] extern int private write_raw(int fd, byte[] buf, int count) @@ -151,6 +157,10 @@ module Libc = let O_APPEND = if isMac then 0x8 else 0x400 // Linux + let SEEK_SET = 0 + let SEEK_CUR = 1 + let SEEK_END = 2 + // -- Wrappers ----------------------------------------------------- @@ -362,6 +372,39 @@ module Libc = let n = read_raw (fd, buf, count) if n < 0 then Error(lastError ()) else Ok(buf[0 .. n - 1]) + let fdSeek + (fd : int) + (offset : int64) + (whence : int) + : Result = + let position = lseek_raw (fd, offset, whence) + if position < 0L then Error(lastError ()) else Ok position + + /// Read one terminal file descriptor's window size as (columns, rows). + let tryTerminalWindowSize (fd : int) : Option = + if OperatingSystem.IsWindows() then + None + else + let request = + if isMac then + 0x40087468UL // TIOCGWINSZ on Darwin + else + 0x5413UL // TIOCGWINSZ on Linux + + let buffer = Marshal.AllocHGlobal 8 + try + if ioctl_raw (fd, request, buffer) = 0 then + let rows = uint16 (Marshal.ReadInt16(buffer, 0)) + let columns = uint16 (Marshal.ReadInt16(buffer, 2)) + if rows > 0us && columns > 0us then + Some(int64 columns, int64 rows) + else + None + else + None + finally + Marshal.FreeHGlobal buffer + let fdWrite (fd : int) (data : byte[]) : Result = let mutable offset = 0 let mutable error = None @@ -972,6 +1015,38 @@ let fns () : List = deprecated = NotDeprecated } + { name = fn "posixFdSeek" 0 + typeParams = [] + parameters = + [ Param.make "fd" TInt "File descriptor to reposition" + Param.make "offset" TInt "Byte offset" + Param.make + "whence" + TInt + "Seek origin: 0 for start, 1 for current position, 2 for end" ] + returnType = TypeReference.result TInt (posixErrorTypeRef ()) + description = + "Seeks to a new position in an open file and returns the resulting byte offset." + fn = + (function + | _, vm, _, [ DInt fd; DInt offset; DInt whence ] -> + let resultOk = Dval.resultOk KTInt (posixErrorKT ()) + let resultError = Dval.resultError KTInt (posixErrorKT ()) + match + Libc.fdSeek + (intToInt32 vm fd) + (intToInt64 vm offset) + (intToInt32 vm whence) + with + | Ok position -> resultOk (Dval.int (bigint position)) |> Ply + | Error e -> resultError (dPosixError e) |> Ply + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Impure + capabilities = LibExecution.Capabilities.Needs.fileReadWrite + deprecated = NotDeprecated } + + { name = fn "posixFdWrite" 0 typeParams = [] parameters = diff --git a/backend/src/Builtins/Builtins.Cli/Libs/Terminal.fs b/backend/src/Builtins/Builtins.Cli/Libs/Terminal.fs index 3d745c0a43..4b7d4bf7ca 100644 --- a/backend/src/Builtins/Builtins.Cli/Libs/Terminal.fs +++ b/backend/src/Builtins/Builtins.Cli/Libs/Terminal.fs @@ -8,98 +8,165 @@ open LibExecution.RuntimeTypes open LibExecution.Builtin.Shortcuts -// Cache terminal dimensions β€” refreshed at most every 500ms -let mutable private cachedWidth : int64 = 0L -let mutable private cachedHeight : int64 = 0L -let mutable private lastDimensionCheck : int64 = 0L -let private dimensionCacheMs = 500L - -let private shouldRefreshDimensions () = - let now = - System.Diagnostics.Stopwatch.GetTimestamp() * 1000L - / System.Diagnostics.Stopwatch.Frequency - if now - lastDimensionCheck > dimensionCacheMs then - lastDimensionCheck <- now - true - else - false - -let private isUnix () = - System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( - System.Runtime.InteropServices.OSPlatform.Linux - ) - || System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform( - System.Runtime.InteropServices.OSPlatform.OSX - ) - -/// Try to get a terminal dimension via tput, falling back to the given default. -let private tputDimension (tputArg : string) (fallback : int) : int = - try - let p = new System.Diagnostics.Process() - p.StartInfo.FileName <- "/bin/sh" - p.StartInfo.Arguments <- $"-c \"tput {tputArg} 2>/dev/null || echo {fallback}\"" - p.StartInfo.UseShellExecute <- false - p.StartInfo.RedirectStandardOutput <- true - p.StartInfo.CreateNoWindow <- true - if p.Start() then - let output = p.StandardOutput.ReadToEnd().Trim() - p.WaitForExit() - match System.Int32.TryParse(output) with - | true, v when v > 0 -> v - | _ -> fallback +/// Restores terminal state if the process exits before Dark can clean up. +/// +/// Dark supplies the restoration sequence; the host stores it and invokes it +/// from process lifecycle callbacks. +module TerminalRestoreGuard = + let private gate = obj () + let mutable private restoreSequence : string option = None + + let private snapshot () : string option = lock gate (fun () -> restoreSequence) + + /// Store the fallback restoration sequence after pending output completes. + /// + /// Waiting prevents a new fallback from overtaking an earlier terminal + /// restoration. + let arm (sequence : string) : unit = + NonBlockingConsole.wait () + lock gate (fun () -> restoreSequence <- Some sequence) + + /// Disarm fallback restoration after pending output completes. + let disarm () : unit = + NonBlockingConsole.wait () + lock gate (fun () -> restoreSequence <- None) + + /// Write the active restoration sequence, if any. + /// + /// The injected writer allows this behavior to be tested without exiting. + let restoreWith (write : string -> unit) : unit = + match snapshot () with + | Some sequence -> write sequence + | None -> () + + let private restoreToTerminal () : unit = + try + restoreWith (fun sequence -> + System.Console.Out.Write sequence + System.Console.Out.Flush()) + with _ -> + // Cleanup must never replace the failure that caused it. + () + + do + System.AppDomain.CurrentDomain.ProcessExit.Add(fun _ -> restoreToTerminal ()) + System.AppDomain.CurrentDomain.UnhandledException.Add(fun _ -> + restoreToTerminal ()) + System.Console.CancelKeyPress.Add(fun _ -> restoreToTerminal ()) + + +/// Measure plain text in terminal columns using Unicode width data and +/// extended grapheme clusters. +module DisplayWidth = + let private isRegionalIndicator (value : int) : bool = + value >= 0x1F1E6 && value <= 0x1F1FF + + let private clusterWidth (cluster : string) : int = + let mutable runes = cluster.EnumerateRunes() + let mutable widestScalar = 0 + let mutable regionalIndicators = 0 + let mutable hasEmojiVariationSelector = false + + while runes.MoveNext() do + let rune = runes.Current + let value = rune.Value + + if isRegionalIndicator value then regionalIndicators <- regionalIndicators + 1 + + if value = 0xFE0F then hasEmojiVariationSelector <- true + + let scalarWidth = + Wcwidth.UnicodeCalculator.GetWidth( + rune, + System.Nullable Wcwidth.Unicode.Version_17_0_0 + ) + |> max 0 + + widestScalar <- max widestScalar scalarWidth + + if regionalIndicators >= 2 then + // Regional indicators are individually narrow but flag pairs occupy one + // wide terminal glyph. + 2 + elif hasEmojiVariationSelector && widestScalar = 1 then + // VS16 requests emoji presentation for otherwise narrow characters such + // as U+2764 HEAVY BLACK HEART. + 2 else - fallback - with _ -> - fallback + widestScalar + + /// Return the number of terminal columns occupied by plain, single-line text. + /// + /// ANSI control sequences must be removed before calling this function. + /// Control characters have width zero. + let ofString (text : string) : int = + text |> String.toEgcSeq |> Seq.sumBy clusterWidth -/// Get a terminal dimension with caching: check env var, then Console, then tput. + /// Return whether text contains an ASCII/Unicode control character. + let containsControl (text : string) : bool = + text |> Seq.exists System.Char.IsControl + + +/// Report raw terminal facts used by Dark's TUI availability policy. +module TerminalCapabilities = + let isInputTerminal () : bool = not System.Console.IsInputRedirected + + let isOutputTerminal () : bool = not System.Console.IsOutputRedirected + + let terminalName () : string = + match System.Environment.GetEnvironmentVariable "TERM" with + | null -> "" + | value -> value + + +/// Read one terminal dimension, preferring an explicit environment override. let private getDimension (envVar : string) (consoleFn : unit -> int) - (tputArg : string) - (defaultVal : int) - (cached : byref) + (fallback : int) : int64 = - if not (shouldRefreshDimensions ()) && cached > 0L then - cached - else - let result = - try - match System.Environment.GetEnvironmentVariable(envVar) with - | null -> - let consoleVal = consoleFn () - if consoleVal = defaultVal && isUnix () then - tputDimension tputArg defaultVal |> int64 - else - int64 consoleVal - | envVal -> - match System.Int32.TryParse(envVal) with - | true, v when v > 0 -> int64 v - | _ -> int64 defaultVal - with _ -> - int64 defaultVal - cached <- result - result + try + match System.Environment.GetEnvironmentVariable envVar with + | null -> + let value = consoleFn () + if value > 0 then int64 value else int64 fallback + | envValue -> + match System.Int32.TryParse envValue with + | true, value when value > 0 -> int64 value + | _ -> int64 fallback + with _ -> + int64 fallback + + +/// Read the kernel's current terminal window size without a process spawn. +let private tryUnixTerminalSize () : (int64 * int64) option = + [ 1; 0; 2 ] |> List.tryPick Posix.Libc.tryTerminalWindowSize + + +/// Return the current terminal size. +/// +/// Prefer the Unix terminal API, then fall back to environment values, +/// `System.Console`, or 80Γ—24. +let terminalSize () : int64 * int64 = + match tryUnixTerminalSize () with + | Some size -> size + | None -> + let width = getDimension "COLUMNS" (fun () -> System.Console.WindowWidth) 80 + let height = getDimension "LINES" (fun () -> System.Console.WindowHeight) 24 + (width, height) let fns () : List = - [ { name = fn "cliGetTerminalHeight" 0 + [ { name = fn "cliTerminalSize" 0 typeParams = [] parameters = [ Param.make "unit" TUnit "" ] - returnType = TInt - description = "Get the current terminal viewport height in number of lines" + returnType = TTuple(TInt, TInt, []) + description = "Sample terminal width and height together as (columns, rows)" fn = (function - | _, _, [], [ DUnit ] -> - getDimension - "LINES" - (fun () -> System.Console.WindowHeight) - "lines" - 24 - &cachedHeight - |> bigint - |> Dval.int - |> Ply + | _, _, _, [ DUnit ] -> + let (width, height) = terminalSize () + DTuple(Dval.int (bigint width), Dval.int (bigint height), []) |> Ply | _ -> incorrectArgs ()) sqlSpec = NotQueryable previewable = Impure @@ -107,23 +174,14 @@ let fns () : List = deprecated = NotDeprecated } - { name = fn "cliGetTerminalWidth" 0 + { name = fn "cliGetLogDir" 0 typeParams = [] parameters = [ Param.make "unit" TUnit "" ] - returnType = TInt - description = "Get the current terminal viewport width in number of columns" + returnType = TString + description = "Returns the absolute path to the CLI log directory" fn = (function - | _, _, [], [ DUnit ] -> - getDimension - "COLUMNS" - (fun () -> System.Console.WindowWidth) - "cols" - 80 - &cachedWidth - |> bigint - |> Dval.int - |> Ply + | _, _, [], [ DUnit ] -> DString(LibConfig.Config.logDir) |> Ply | _ -> incorrectArgs ()) sqlSpec = NotQueryable previewable = Impure @@ -131,14 +189,81 @@ let fns () : List = deprecated = NotDeprecated } - { name = fn "cliGetLogDir" 0 + { name = fn "cliTerminalRestoreArm" 0 typeParams = [] - parameters = [ Param.make "unit" TUnit "" ] - returnType = TString - description = "Returns the absolute path to the CLI log directory" + parameters = + [ Param.make + "restoreSequence" + TString + "The terminal restoration sequence supplied by the Dark TUI runtime" ] + returnType = TUnit + description = + "Arm emergency terminal restoration for host failure or process termination" fn = (function - | _, _, [], [ DUnit ] -> DString(LibConfig.Config.logDir) |> Ply + | _, _, _, [ DString sequence ] -> + TerminalRestoreGuard.arm sequence + Ply DUnit + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Impure + capabilities = LibExecution.Capabilities.Needs.stdout + deprecated = NotDeprecated } + + + { name = fn "cliTerminalRestoreDisarm" 0 + typeParams = [] + parameters = [ Param.make "unit" TUnit "A unit" ] + returnType = TUnit + description = "Flush normal terminal cleanup and disarm fallback restoration" + fn = + (function + | _, _, _, [ DUnit ] -> + TerminalRestoreGuard.disarm () + Ply DUnit + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Impure + capabilities = LibExecution.Capabilities.Needs.stdout + deprecated = NotDeprecated } + + + { name = fn "cliTerminalInspectText" 0 + typeParams = [] + parameters = [ Param.make "text" TString "One candidate logical terminal row" ] + returnType = TTuple(TInt, TBool, []) + description = + "Return (display width when control-free, contains control characters)" + fn = + (function + | _, _, _, [ DString text ] -> + DTuple( + text |> DisplayWidth.ofString |> bigint |> Dval.int, + text |> DisplayWidth.containsControl |> DBool, + [] + ) + |> Ply + | _ -> incorrectArgs ()) + sqlSpec = NotQueryable + previewable = Pure + capabilities = LibExecution.Capabilities.noCaps + deprecated = NotDeprecated } + + + { name = fn "cliTerminalSessionInfo" 0 + typeParams = [] + parameters = [ Param.make "unit" TUnit "A unit" ] + returnType = TTuple(TBool, TBool, [ TString ]) + description = "Return (input is terminal, output is terminal, TERM value)" + fn = + (function + | _, _, _, [ DUnit ] -> + DTuple( + TerminalCapabilities.isInputTerminal () |> DBool, + TerminalCapabilities.isOutputTerminal () |> DBool, + [ TerminalCapabilities.terminalName () |> DString ] + ) + |> Ply | _ -> incorrectArgs ()) sqlSpec = NotQueryable previewable = Impure diff --git a/backend/src/Builtins/Builtins.Cli/paket.references b/backend/src/Builtins/Builtins.Cli/paket.references index 668f52d153..065ac6de36 100644 --- a/backend/src/Builtins/Builtins.Cli/paket.references +++ b/backend/src/Builtins/Builtins.Cli/paket.references @@ -1,2 +1,3 @@ Ply FSharp.Core +Wcwidth diff --git a/backend/tests/Tests/Terminal.Tests.fs b/backend/tests/Tests/Terminal.Tests.fs new file mode 100644 index 0000000000..0748e3d629 --- /dev/null +++ b/backend/tests/Tests/Terminal.Tests.fs @@ -0,0 +1,102 @@ +/// Tests the native terminal mechanisms that Dark cannot provide itself. +module Tests.Terminal + +open Expecto + +module TerminalRestoreGuard = Builtins.Cli.Libs.Terminal.TerminalRestoreGuard +module DisplayWidth = Builtins.Cli.Libs.Terminal.DisplayWidth +module PosixLibc = Builtins.Cli.Libs.Posix.Libc + + +let displayWidthTests = + [ "", 0 + "hello", 5 + "Γ©", 1 + "e\u0301", 1 + "η•Œ", 2 + "πŸ™‚", 2 + "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", 2 + "πŸ‡ΊπŸ‡Έ", 2 + "β™₯️", 2 + "Β·", 1 + "Aη•ŒπŸ™‚e\u0301", 6 ] + |> List.map (fun (text, expected) -> + let display = sprintf "%A" text + test $"width of {display}" { + Expect.equal + (DisplayWidth.ofString text) + expected + "text should occupy the expected number of terminal columns" + }) + |> testList "Terminal display width" + + +let tests = + testList + "Terminal" + [ test "terminal size sample is always positive" { + let (width, height) = Builtins.Cli.Libs.Terminal.terminalSize () + Expect.isGreaterThan width 0L "terminal width should be positive" + Expect.isGreaterThan height 0L "terminal height should be positive" + } + test "fallback restoration can be armed, replaced, and disarmed" { + TerminalRestoreGuard.disarm () + let writes = ResizeArray() + + try + TerminalRestoreGuard.restoreWith writes.Add + + TerminalRestoreGuard.arm "restore-fullscreen" + TerminalRestoreGuard.restoreWith writes.Add + + TerminalRestoreGuard.arm "restore-inline" + TerminalRestoreGuard.restoreWith writes.Add + + TerminalRestoreGuard.disarm () + TerminalRestoreGuard.restoreWith writes.Add + + Expect.sequenceEqual + writes + [ "restore-fullscreen"; "restore-inline" ] + "only the currently armed restoration sequence should be invoked" + finally + TerminalRestoreGuard.disarm () + } + test "terminal text metrics identify dynamic controls" { + Expect.isFalse + (DisplayWidth.containsControl "plainη•Œ") + "plain Unicode text should be safe" + Expect.isTrue + (DisplayWidth.containsControl "before\u001b[2Jafter") + "terminal escape content should require sanitization" + } + test "file descriptors can seek before a bounded read" { + let path = System.IO.Path.GetTempFileName() + + try + System.IO.File.WriteAllText(path, "one\ntwo\nthree\n") + + match PosixLibc.openFile path PosixLibc.O_RDONLY 0 with + | Error(errno, message) -> + failtestf "open failed with errno %d: %s" errno message + | Ok fd -> + try + Expect.equal + (PosixLibc.fdSeek fd 8L PosixLibc.SEEK_SET) + (Ok 8L) + "seek should return the new absolute byte offset" + + match PosixLibc.fdRead fd 6 with + | Error(errno, message) -> + failtestf "read failed with errno %d: %s" errno message + | Ok bytes -> + Expect.equal + (System.Text.Encoding.UTF8.GetString bytes) + "three\n" + "the bounded read should start at the seeked offset" + finally + PosixLibc.fdClose fd |> ignore + finally + System.IO.File.Delete path + } + displayWidthTests ] diff --git a/backend/tests/Tests/Tests.fs b/backend/tests/Tests/Tests.fs index f28d14b01d..68819283c1 100644 --- a/backend/tests/Tests/Tests.fs +++ b/backend/tests/Tests/Tests.fs @@ -35,6 +35,7 @@ let main (args : string array) : int = Tests.LibParser.tests Tests.WrittenTypesLoweringParity.tests Tests.HttpClient.tests + Tests.Terminal.tests // package manager Tests.Propagation.tests diff --git a/backend/tests/Tests/Tests.fsproj b/backend/tests/Tests/Tests.fsproj index 597bdd27cf..636a351097 100644 --- a/backend/tests/Tests/Tests.fsproj +++ b/backend/tests/Tests/Tests.fsproj @@ -35,6 +35,7 @@ + diff --git a/packages/darklang/cli/ai/agent.dark b/packages/darklang/cli/ai/agent.dark index 450a9803e9..fdcd653272 100644 --- a/packages/darklang/cli/ai/agent.dark +++ b/packages/darklang/cli/ai/agent.dark @@ -312,18 +312,16 @@ let runInteractiveLoop else if input == "exit" || input == "quit" || input == ":q" then () else - Stdlib.print (Colors.dimText "[agent] thinking...") + Stdlib.printLine (Colors.dimText "[agent] thinking...") match Darklang.LLM.Agent.runWithHistory agent history input with | Ok response -> - Stdlib.print (Colors.carriageReturn ++ Colors.clearLine) - Stdlib.printLine "" Stdlib.printLine (Colors.info "agent> ") Stdlib.printLine response.response // Use full message history including tool calls/results for context continuity let _ = runInteractiveLoop agent response.messages () | Error e -> - Stdlib.printLine (Colors.carriageReturn ++ Colors.clearLine ++ Colors.error e) + Stdlib.printLine (Colors.error e) runInteractiveLoop agent history let runInteractive (state: Cli.AppState) (provider: String) (requestedProvider: Stdlib.Option.Option) (requestedModel: Stdlib.Option.Option) (writeMode: WriteMode) : Unit = diff --git a/packages/darklang/cli/apps/editor.dark b/packages/darklang/cli/apps/editor.dark index f6b6f2ee90..bdd439b760 100644 --- a/packages/darklang/cli/apps/editor.dark +++ b/packages/darklang/cli/apps/editor.dark @@ -4,9 +4,16 @@ module Darklang.Cli.Apps.Editor // run foreground apps, and start/stop daemons, all on one screen. Enter is the primary action: it runs a // foreground app (handing the screen over to it) or toggles a daemon. A SubApp the CLI main loop drives. +type AppPresentation = + { installed: Bool + daemonState: String + recentLogs: List } + type State = { /// the full catalog, ordered daemons-first (so selection indices line up with the grouped display) apps: List + /// Prepared external facts used by the pure view, aligned with `apps`. + presentation: List selected: Int /// last action feedback, shown near the footer message: String @@ -23,31 +30,70 @@ type Action = let selectedApp (state: State) : Stdlib.Option.Option = Stdlib.List.getAt state.apps state.selected +let presentationAt (state: State) (index: Int) : AppPresentation = + Stdlib.List.getAt state.presentation index + |> Stdlib.Option.withDefault + (AppPresentation { + installed = false + daemonState = "" + recentLogs = [] + }) + +/// Refresh filesystem/process facts during a state transition, never in view. +let refreshPresentation (state: State) : State = + let installed = Registry.installedSlugs () + let presentation = + state.apps + |> Stdlib.List.indexedMap (fun idx app -> + let daemonState = + match app.target with + | Daemon _ -> Command.daemonState app.slug + | Foreground _ -> "" + let recentLogs = + if state.showDetail && idx == state.selected then + match app.target with + | Daemon _ -> Stdlib.Cli.Daemon.tailLog app.slug 5 + | Foreground _ -> [] + else + [] + AppPresentation { + installed = Stdlib.List.member installed app.slug + daemonState = daemonState + recentLogs = recentLogs + }) + + { state with presentation = presentation } + // ── rendering ── // `s` cut to `maxLen` display columns, with a trailing ellipsis when it doesn't fit. let truncate (s: String) (maxLen: Int) : String = - if (Stdlib.String.length s) <= maxLen then + if maxLen <= 0 then + "" + else if Darklang.Cli.Tui.Text.displayWidth s <= maxLen then s + else if maxLen == 1 then + "…" else - (Stdlib.String.slice s 0 (maxLen - 1)) ++ "…" + Darklang.Cli.Tui.Text.clipToWidth s (maxLen - 1) ++ "…" -let appRow (state: State) (idx: Int) (app: Model.App) : String = +let appRow (state: State) (width: Int) (idx: Int) (app: Model.App) : String = + let presentation = presentationAt state idx let pointer = if idx == state.selected then Colors.colorize (Colors.bold ++ Colors.cyan) "β–Έ " else " " - let installGlyph = if Registry.isInstalled app.slug then Colors.success "βœ“" else Colors.dimText "Β·" + let installGlyph = + if presentation.installed then Colors.success "βœ“" + else Colors.dimText "Β·" // Daemon liveness, shown twice so it reads at a glance: a colored dot right up front (scan the column) // and the colored state word at the end. ● green = running, ● yellow = stale, β—‹ dim = stopped. let rawState = - match app.target with - | Daemon _ -> Command.daemonState app.slug - | Foreground _ -> "" + presentation.daemonState let isRunning = Stdlib.String.startsWith rawState "running" let isStale = Stdlib.String.startsWith rawState "stale" @@ -67,8 +113,6 @@ let appRow (state: State) (idx: Int) (app: Model.App) : String = let stateWidth = if rawState == "" then 0 else (Stdlib.String.length rawState) + 3 let name = Stdlib.Result.withDefault (Stdlib.String.padEnd app.name " " 26) app.name - let w = Darklang.Cli.Terminal.getWidth () - let width = if w <= 0 then 80 else w // prefix (β–Έ + install glyph + run glyph + space) = 5 cols, name column = 26, gap before description = 2 let descBudget = Stdlib.Int.max (width - 5 - 26 - 2 - stateWidth) 8 let desc = truncate app.description descBudget @@ -77,31 +121,43 @@ let appRow (state: State) (idx: Int) (app: Model.App) : String = pointer ++ installGlyph ++ runGlyph ++ " " ++ name ++ " " ++ (Colors.dimText desc) ++ trailing -// A titled group of rows (nothing if the group is empty). `rows` carries each app's catalog index so the -// selection highlight stays correct across the split. -let printSection (state: State) (title: String) (rows: List<(Int * Model.App)>) : Unit = +type LogicalRow = + { appIndex: Stdlib.Option.Option + content: String } + +// A titled group of logical rows. Catalog indices keep selection aligned +// across the daemon/foreground split. +let sectionRows + (state: State) + (width: Int) + (title: String) + (rows: List<(Int * Model.App)>) + : List = if Stdlib.List.isEmpty rows then - () + [] else - Stdlib.printLine (Colors.dimText (" " ++ title)) - - rows - |> Stdlib.List.map (fun pair -> - let (i, a) = pair - appRow state i a) - |> Stdlib.printLines - - -let render (state: State) : Unit = - Stdlib.print "" - Stdlib.printLine (Colors.boldText " Apps ") - Stdlib.printLine "" - + let heading = + LogicalRow { + appIndex = Stdlib.Option.Option.None + content = Colors.dimText (" " ++ title) + } + let appRows = + rows + |> Stdlib.List.map (fun (index, app) -> + LogicalRow { + appIndex = Stdlib.Option.Option.Some index + content = appRow state width index app + }) + Stdlib.List.append [ heading ] appRows + +let catalogRows (state: State) (width: Int) : List = if Stdlib.List.isEmpty state.apps then - Stdlib.printLine (Colors.dimText " No apps in the catalog.") + [ LogicalRow { + appIndex = Stdlib.Option.Option.None + content = Colors.dimText " No apps in the catalog." + } ] else let indexed = state.apps |> Stdlib.List.indexedMap (fun i a -> (i, a)) - let daemons = indexed |> Stdlib.List.filter (fun pair -> @@ -114,55 +170,124 @@ let render (state: State) : Unit = let (_i, a) = pair Stdlib.Bool.not (Model.isDaemon a.target)) - printSection state "Daemons" daemons - Stdlib.printLine "" - printSection state "Foreground apps" foreground - - // optional detail pane for the selected app (`i`) - let _detail = - if state.showDetail then - match selectedApp state with - | Some app -> - Stdlib.printLine "" - Stdlib.printLine - (Colors.dimText (" slug: " ++ app.slug ++ " target: " ++ Model.reference app.target)) - - match app.target with - | Daemon _ -> - let logs = Stdlib.Cli.Daemon.tailLog app.slug 5 - if Stdlib.List.isEmpty logs then - () - else - Stdlib.printLine (Colors.dimText " recent logs:") - logs - |> Stdlib.List.map (fun l -> " " ++ Colors.dimText l) - |> Stdlib.printLines - // TODO: for foreground apps, surface running copies here (pids of processes running this command), - // so `i` is useful for them too β€” today it only shows slug + target. - | Foreground _ -> () - | None -> () - else - () + let daemonRows = sectionRows state width "Daemons" daemons + let foregroundRows = + sectionRows state width "Foreground apps" foreground + let separator = + if Stdlib.List.isEmpty daemonRows + || Stdlib.List.isEmpty foregroundRows + then + [] + else + [ LogicalRow { + appIndex = Stdlib.Option.Option.None + content = "" + } ] + Stdlib.List.flatten [ daemonRows; separator; foregroundRows ] + +let detailRows (state: State) : List = + if state.showDetail then + match selectedApp state with + | Some app -> + let presentation = presentationAt state state.selected + let summary = + Colors.dimText + (" slug: " + ++ app.slug + ++ " target: " + ++ Model.reference app.target) + let logs = + if Stdlib.List.isEmpty presentation.recentLogs then + [] + else + Stdlib.List.append + [ Colors.dimText " recent logs:" ] + (presentation.recentLogs + |> Stdlib.List.map (fun line -> + " " ++ Colors.dimText line)) + Stdlib.List.flatten [ [ ""; summary ]; logs ] + | None -> [] + else + [] - Stdlib.printLine "" - if state.message != "" then - Stdlib.printLine (" " ++ state.message) - Stdlib.printLine "" +let messageRows (state: State) : List = + if state.message == "" then + [] else - () + [ ""; " " ++ state.message ] - // Explain the app status markers shown in each row. +let legendRow () : String = let legend = " " ++ (Colors.success "βœ“") ++ (Colors.dimText " installed ") ++ (Colors.colorize Colors.green "●") ++ (Colors.dimText " running ") ++ (Colors.colorize Colors.yellow "●") ++ (Colors.dimText " stale ") ++ (Colors.dimText "β—‹ stopped") - Stdlib.printLine legend + legend - Stdlib.printLine - (Colors.dimText - " ↑↓ move Β· enter run/start Β· a add Β· x remove Β· i detail Β· esc exit") +/// Build the complete apps-browser view without terminal I/O. +let viewAtSize + (state: State) + (size: Darklang.Cli.Tui.Size) + : Darklang.Cli.Tui.View = + let width = Stdlib.Int.max 1 size.width + let contentHeight = Stdlib.Int.max 1 size.height + let titleHeight = if contentHeight >= 3 then 2 else 1 + let footerHeight = + if contentHeight >= 4 then 2 + else if contentHeight >= 2 then 1 + else 0 + let bodyHeight = + Stdlib.Int.max 0 (contentHeight - titleHeight - footerHeight) + + let grouped = catalogRows state width + let selectedPhysicalRow = + grouped + |> Stdlib.List.findFirstIndex (fun row -> + row.appIndex == Stdlib.Option.Option.Some state.selected) + |> Stdlib.Option.withDefault 0 + + let supplement = + Stdlib.List.append (detailRows state) (messageRows state) + let supplementHeight = + Stdlib.Int.min + (Stdlib.List.length supplement) + (Stdlib.Int.max 0 (bodyHeight - 1)) + let listHeight = Stdlib.Int.max 0 (bodyHeight - supplementHeight) + let listRows = + grouped + |> Stdlib.List.map (fun row -> row.content) + |> Darklang.Cli.Tui.Layout.viewport + selectedPhysicalRow + listHeight + let bodyRows = + Darklang.Cli.Tui.Layout.fitRows + bodyHeight + (Stdlib.List.append + listRows + (Stdlib.List.take supplement supplementHeight)) + + let titleRows = + Darklang.Cli.Tui.Layout.fitRows + titleHeight + [ Colors.boldText " Apps " ] + let footerRows = + let desired = + [ legendRow () + Colors.dimText + " ↑↓ move Β· enter run/start Β· a add Β· x remove Β· i detail Β· esc exit" ] + Darklang.Cli.Tui.Layout.fitRows footerHeight desired + let rows = + Stdlib.List.flatten [ titleRows; bodyRows; footerRows ] + |> Stdlib.List.take contentHeight + |> Stdlib.List.map (fun row -> + Darklang.Cli.Tui.Text.clipToWidth row width) + + Darklang.Cli.Tui.View { + rows = rows + cursor = Darklang.Cli.Tui.Cursor.Hidden + mode = Darklang.Cli.Tui.ViewMode.Fullscreen + } // ── actions ── @@ -212,11 +337,13 @@ let toggleDaemon (state: State) (app: Model.App) (entrypoint: String) : State = /// enter β€” the primary action: run a foreground app (hand it the screen) / toggle a daemon. let activate (state: State) : Action = match selectedApp state with - | None -> Action.Continue state + | None -> Action.Continue (refreshPresentation state) | Some app -> match app.target with | Foreground command -> Action.LaunchFg command - | Daemon entrypoint -> Action.Continue (toggleDaemon state app entrypoint) + | Daemon entrypoint -> + Action.Continue + (refreshPresentation (toggleDaemon state app entrypoint)) let handleKey @@ -230,40 +357,68 @@ let handleKey | Escape -> Action.Exit state | UpArrow -> let s = if state.selected > 0 then state.selected - 1 else 0 - Action.Continue ({ state with selected = s }) + Action.Continue { state with selected = s } | DownArrow -> - let maxIdx = count - 1 + let maxIdx = Stdlib.Int.max 0 (count - 1) let s = if state.selected < maxIdx then state.selected + 1 else maxIdx - Action.Continue ({ state with selected = s }) + Action.Continue { state with selected = s } | Enter -> activate state | _ -> match keyChar with - | Some "a" -> Action.Continue (install state) - | Some "x" -> Action.Continue (uninstall state) - | Some "i" -> Action.Continue ({ state with showDetail = Stdlib.Bool.not state.showDetail }) + | Some "a" -> + Action.Continue (refreshPresentation (install state)) + | Some "x" -> + Action.Continue (refreshPresentation (uninstall state)) + | Some "i" -> + Action.Continue + (refreshPresentation + { state with + showDetail = Stdlib.Bool.not state.showDetail }) | Some "q" -> Action.Exit state | _ -> Action.Continue state // ── SubApp adapter + launch ── -let makeSubApp (state: State) : Cli.SubApp = +type Session = + { state: State + terminal: Darklang.Cli.Tui.TerminalSession.State } + +let presentState (session: Session) (state: State) : Session = + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + let terminal = + Darklang.Cli.Tui.TerminalSession.presentAtSize + session.terminal + size + (viewAtSize state size) + Session { state = state; terminal = terminal } + +let makeSubApp (session: Session) : Cli.SubApp = Cli.SubApp { onKey = fun key modifiers keyChar -> - match handleKey state key modifiers keyChar with - | Continue s -> (Cli.SubAppAction.Continue, makeSubApp s) - | Exit s -> (Cli.SubAppAction.Exit, makeSubApp s) - | LaunchFg cmd -> (Cli.SubAppAction.LaunchCommand cmd, makeSubApp state) - onDisplay = fun () -> render state - onSave = fun () -> () } + match handleKey session.state key modifiers keyChar with + | Continue state -> + let next = presentState session state + (Cli.SubAppAction.Continue, makeSubApp next) + | Exit state -> + let next = Session { state = state; terminal = session.terminal } + (Cli.SubAppAction.Exit, makeSubApp next) + | LaunchFg cmd -> + (Cli.SubAppAction.LaunchCommand cmd, makeSubApp session) + // The session presents on launch and state transitions. + onDisplay = fun () -> () + onSave = fun () -> () + onTerminate = + fun () -> + let _stopped = + Darklang.Cli.Tui.TerminalSession.stop session.terminal + () } /// Launch the interactive browser over the whole catalog (daemons first). Returns a cliState whose current /// page is this SubApp (the CLI main loop then drives it until Esc). let launch (cliState: Cli.AppState) : Cli.AppState = - Stdlib.print "[?1049h" - let all = Registry.catalog cliState.currentBranchId let daemons = all |> Stdlib.List.filter (fun a -> Model.isDaemon a.target) let foreground = all |> Stdlib.List.filter (fun a -> Stdlib.Bool.not (Model.isDaemon a.target)) @@ -272,12 +427,21 @@ let launch (cliState: Cli.AppState) : Cli.AppState = let state = Editor.State { apps = ordered + presentation = [] selected = 0 message = "" showDetail = false } - - let app = makeSubApp state - - { cliState with - currentPage = Cli.Page.SubApp app - needsFullRedraw = true } + |> refreshPresentation + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + match + Darklang.Cli.Tui.TerminalSession.startAtSize + size + (viewAtSize state size) + with + | Ok terminal -> + let session = Session { state = state; terminal = terminal } + { cliState with + currentPage = Cli.Page.SubApp (makeSubApp session) } + | Error message -> + Stdlib.printLine message + cliState diff --git a/packages/darklang/cli/apps/views/ai-chats.dark b/packages/darklang/cli/apps/views/ai-chats.dark index 6756ef6e88..bf04e92d85 100644 --- a/packages/darklang/cli/apps/views/ai-chats.dark +++ b/packages/darklang/cli/apps/views/ai-chats.dark @@ -78,13 +78,13 @@ let statusBadge (status: String) : String = | "error" -> Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bgRed ++ Darklang.Cli.Colors.white ++ Darklang.Cli.Colors.bold) " ERROR " | _ -> Darklang.Cli.Colors.dimText status -let hr () : String = - let cols = Darklang.Cli.Terminal.getWidth () - Darklang.Cli.Colors.dimText (Stdlib.String.repeat "─" cols) +let hr (cols: Int) : String = + Darklang.Cli.Colors.dimText + (Stdlib.String.repeat "─" (Stdlib.Int.max 1 cols)) -let thinHr () : String = - let cols = Darklang.Cli.Terminal.getWidth () - Darklang.Cli.Colors.dimText (Stdlib.String.repeat "β•Œ" cols) +let thinHr (cols: Int) : String = + Darklang.Cli.Colors.dimText + (Stdlib.String.repeat "β•Œ" (Stdlib.Int.max 1 cols)) let padRight (s: String) (width: Int) : String = let len = Stdlib.String.length s @@ -93,7 +93,7 @@ let padRight (s: String) (width: Int) : String = else s ++ (Stdlib.String.repeat " " (width - len)) -let renderThread (thread: Thread) : Unit = +let threadRows (cols: Int) (thread: Thread) : List = let idStr = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan) ("#" ++ Stdlib.Int.toString thread.id) let badge = statusBadge thread.status let topicStr = @@ -111,17 +111,23 @@ let renderThread (thread: Thread) : Unit = let tokensStr = Darklang.Cli.Colors.dimText (padRight thread.tokens 10) let timeStr = Darklang.Cli.Colors.dimText (padRight thread.time 8) - Stdlib.printLine (" " ++ idStr ++ " " ++ badge ++ " " ++ topicStr ++ " " ++ branchStr ++ " " ++ costStr ++ " " ++ tokensStr ++ " " ++ timeStr) - let detailColor = match thread.status with | "review" -> Darklang.Cli.Colors.yellow | _ -> Darklang.Cli.Colors.dim - Stdlib.printLine (Darklang.Cli.Colors.colorize detailColor (" ╰─ " ++ thread.detail)) + [ " " ++ idStr ++ " " ++ badge ++ " " ++ topicStr ++ " " ++ branchStr ++ " " ++ costStr ++ " " ++ tokensStr ++ " " ++ timeStr + Darklang.Cli.Colors.colorize detailColor + (" ╰─ " ++ thread.detail) + thinHr cols ] -let render () : Unit = +/// Build the complete AI-chats dashboard without reading or writing the +/// terminal. Rows are clipped by the gallery after it composes its footer. +let rowsAtSize + (size: Darklang.Cli.Tui.Size) + : List = let threads = demoThreads () + let cols = Stdlib.Int.max 1 size.width let runningCount = threads @@ -135,16 +141,6 @@ let render () : Unit = let threadCount = Stdlib.List.length threads - // Header - Stdlib.printLine "" - Stdlib.printLine - (" " - ++ Darklang.Cli.Colors.boldText "dark ai chats" - ++ " " - ++ Darklang.Cli.Colors.dimText ("β€” " ++ Stdlib.Int.toString threadCount ++ " threads β€’ " ++ Stdlib.Int.toString runningCount ++ " running β€’ " ++ Stdlib.Int.toString reviewCount ++ " awaiting review")) - Stdlib.printLine "" - Stdlib.printLine (hr ()) - // Column headers let headerLine = " " @@ -161,20 +157,12 @@ let render () : Unit = ++ Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.dim) (padRight "TOKENS" 10) ++ " " ++ Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.dim) (padRight "TIME" 8) - Stdlib.printLine headerLine - Stdlib.printLine (hr ()) - - // Threads - threads - |> Stdlib.List.iter (fun thread -> - renderThread thread - Stdlib.printLine (thinHr ())) - - Stdlib.printLine (hr ()) + let threadLines = + threads + |> Stdlib.List.map (threadRows cols) + |> Stdlib.List.flatten - // Summary - Stdlib.printLine "" - Stdlib.printLine + let summary = (" " ++ Darklang.Cli.Colors.boldText "Session totals" ++ " " @@ -186,14 +174,32 @@ let render () : Unit = ++ " Uptime: " ++ Darklang.Cli.Colors.boldText "51m" ++ " " ++ Darklang.Cli.Colors.dimText "β”‚" ++ " Files changed: " ++ Darklang.Cli.Colors.boldText "31") - Stdlib.printLine "" - // Keyboard hints - Stdlib.printLine + let hints = (" " ++ Darklang.Cli.Colors.dimText "[enter]" ++ " open thread " ++ Darklang.Cli.Colors.dimText "[r]" ++ " review flagged " ++ Darklang.Cli.Colors.dimText "[n]" ++ " new thread " ++ Darklang.Cli.Colors.dimText "[k]" ++ " kill thread " ++ Darklang.Cli.Colors.dimText "[q]" ++ " quit") - Stdlib.printLine "" + + Stdlib.List.flatten [ + [ "" + " " + ++ Darklang.Cli.Colors.boldText "dark ai chats" + ++ " " + ++ Darklang.Cli.Colors.dimText + ("β€” " + ++ Stdlib.Int.toString threadCount + ++ " threads β€’ " + ++ Stdlib.Int.toString runningCount + ++ " running β€’ " + ++ Stdlib.Int.toString reviewCount + ++ " awaiting review") + "" + hr cols + headerLine + hr cols ] + threadLines + [ hr cols; ""; summary; ""; hints; "" ] + ] diff --git a/packages/darklang/cli/apps/views/app.dark b/packages/darklang/cli/apps/views/app.dark index 684dfb5d88..004f892c14 100644 --- a/packages/darklang/cli/apps/views/app.dark +++ b/packages/darklang/cli/apps/views/app.dark @@ -2,87 +2,158 @@ module Darklang.Cli.Apps.Views.App type Screen = | ViewList of selected: Int - | ViewDisplay of viewIndex: Int + /// The focused row is used as the dashboard's scroll position. + | ViewDisplay of viewIndex: Int * focusedRow: Int type State = { screen: Screen views: List } -// ── Rendering ── - -let renderViewList (state: State) (selected: Int) : Unit = - let termWidth = Darklang.Cli.Terminal.getWidth () - - // Build entire output as a single string to avoid flicker - let header = - "\n" - ++ " " - ++ Darklang.Cli.Colors.boldText "Views" - ++ " " - ++ Darklang.Cli.Colors.dimText ("β€” " ++ Stdlib.Int.toString (Stdlib.List.length state.views) ++ " available") - ++ "\n\n" - ++ Darklang.Cli.Colors.dimText (Stdlib.String.repeat "─" termWidth) - ++ "\n\n" - - let viewLines = +// ── Pure rendering ── + +let clipRows (width: Int) (rows: List) : List = + rows + |> Stdlib.List.map (fun row -> + Darklang.Cli.Tui.Text.clipToWidth row width) + +let listItemRows + (selected: Int) + (index: Int) + (entry: ViewEntry) + : List = + let isSelected = index == selected + let pointer = + if isSelected then + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan) + "β–Έ " + else + " " + let name = + if isSelected then + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan) + entry.name + else + Darklang.Cli.Colors.boldText entry.name + [ " " ++ pointer ++ name + " " ++ Darklang.Cli.Colors.dimText entry.description + "" ] + +let viewListRows + (state: State) + (selected: Int) + (width: Int) + (height: Int) + : List = + let titleHeight = if height >= 3 then 3 else 1 + let footerHeight = + if height >= 4 then 2 + else if height >= 2 then 1 + else 0 + let bodyHeight = Stdlib.Int.max 0 (height - titleHeight - footerHeight) + + let titleRows = + Darklang.Cli.Tui.Layout.fitRows + titleHeight + [ " " + ++ Darklang.Cli.Colors.boldText "Views" + ++ " " + ++ Darklang.Cli.Colors.dimText + ("β€” " + ++ Stdlib.Int.toString + (Stdlib.List.length state.views) + ++ " available") + Darklang.Cli.Colors.dimText + (Stdlib.String.repeat "─" width) ] + + let itemRows = state.views - |> Stdlib.List.indexedMap (fun idx view -> (idx, view)) - |> Stdlib.List.map (fun pair -> - let (idx, view) = pair - let isSelected = idx == selected - let pointer = - if isSelected then - Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan) "β–Έ " - else - " " - let nameStr = - if isSelected then - Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan) view.name - else - Darklang.Cli.Colors.boldText view.name - let descStr = Darklang.Cli.Colors.dimText (" " ++ view.description) - - " " ++ pointer ++ nameStr ++ "\n" ++ " " ++ descStr ++ "\n") - |> Stdlib.String.join "\n" - - let footer = - "\n" - ++ Darklang.Cli.Colors.dimText (Stdlib.String.repeat "─" termWidth) - ++ "\n\n" - ++ " " - ++ Darklang.Cli.Colors.dimText "↑↓" - ++ " navigate " - ++ Darklang.Cli.Colors.dimText "enter" - ++ " open view " - ++ Darklang.Cli.Colors.dimText "esc" - ++ " exit" - ++ "\n" - - // Single atomic write: hide cursor, clear, content, show cursor - Stdlib.print ("\u001b[?25l" ++ Darklang.Cli.Terminal.Display.clearScreen ++ header ++ viewLines ++ footer ++ "\u001b[?25h") - - -let renderViewDisplay (state: State) (viewIndex: Int) : Unit = - // Hide cursor + clear in one shot, then render view - Stdlib.print ("\u001b[?25l" ++ Darklang.Cli.Terminal.Display.clearScreen) - - match Stdlib.List.getAt state.views viewIndex with - | Some view -> view.render () - | None -> - Stdlib.printLine (Darklang.Cli.Colors.error "View not found") - - // Back hint at bottom - Stdlib.printLine "" - Stdlib.printLine (" " ++ Darklang.Cli.Colors.dimText "[esc] back to views list") - Stdlib.printLine "" - Stdlib.print "\u001b[?25h" - - -let renderState (state: State) : Unit = - match state.screen with - | ViewList selected -> renderViewList state selected - | ViewDisplay viewIndex -> renderViewDisplay state viewIndex + |> Stdlib.List.indexedMap (fun index entry -> + listItemRows selected index entry) + |> Stdlib.List.flatten + let selectedRow = Stdlib.Int.max 0 (selected * 3) + let bodyRows = + Darklang.Cli.Tui.Layout.viewport + itemRows + selectedRow + bodyHeight + let footerRows = + Darklang.Cli.Tui.Layout.fitRows + footerHeight + [ Darklang.Cli.Colors.dimText + (Stdlib.String.repeat "─" width) + " " + ++ Darklang.Cli.Colors.dimText "↑↓" + ++ " navigate " + ++ Darklang.Cli.Colors.dimText "enter" + ++ " open view " + ++ Darklang.Cli.Colors.dimText "esc" + ++ " exit" ] + + Stdlib.List.flatten [ titleRows; bodyRows; footerRows ] + +let viewDisplayRows + (state: State) + (viewIndex: Int) + (focusedRow: Int) + (size: Darklang.Cli.Tui.Size) + (height: Int) + : List = + let footerHeight = if height >= 2 then 2 else 1 + let bodyHeight = Stdlib.Int.max 0 (height - footerHeight) + let dashboardRows = + match Stdlib.List.getAt state.views viewIndex with + | Some entry -> rowsAtSize entry.kind size + | None -> [ Darklang.Cli.Colors.error "View not found" ] + let bodyRows = + Darklang.Cli.Tui.Layout.viewport + dashboardRows + focusedRow + bodyHeight + let footerRows = + Darklang.Cli.Tui.Layout.fitRows + footerHeight + [ Darklang.Cli.Colors.dimText + (Stdlib.String.repeat "─" (Stdlib.Int.max 1 size.width)) + " " + ++ Darklang.Cli.Colors.dimText "↑↓" + ++ " scroll " + ++ Darklang.Cli.Colors.dimText "esc" + ++ " back to views list" ] + Stdlib.List.append bodyRows footerRows + +/// Build the complete gallery view without terminal reads or writes. +let viewAtSize + (state: State) + (size: Darklang.Cli.Tui.Size) + : Darklang.Cli.Tui.View = + let width = Stdlib.Int.max 1 size.width + let contentHeight = Stdlib.Int.max 1 size.height + let logicalRows = + match state.screen with + | ViewList selected -> + viewListRows state selected width contentHeight + | ViewDisplay(viewIndex, focusedRow) -> + viewDisplayRows + state + viewIndex + focusedRow + size + contentHeight + let rows = + clipRows + width + (Darklang.Cli.Tui.Layout.fitRows + contentHeight + logicalRows) + + Darklang.Cli.Tui.View { + rows = rows + cursor = Darklang.Cli.Tui.Cursor.Hidden + mode = Darklang.Cli.Tui.ViewMode.Fullscreen + } // ── Key handling ── @@ -103,85 +174,161 @@ let handleKey match key with | Escape -> Action.Exit state | UpArrow -> - let newIdx = if selected > 0 then selected - 1 else selected - Action.Continue ({ state with screen = Screen.ViewList newIdx }) + let newIndex = if selected > 0 then selected - 1 else selected + Action.Continue + ({ state with screen = Screen.ViewList newIndex }) | DownArrow -> - let maxIdx = viewCount - 1 - let newIdx = if selected < maxIdx then selected + 1 else selected - Action.Continue ({ state with screen = Screen.ViewList newIdx }) + let maxIndex = Stdlib.Int.max 0 (viewCount - 1) + let newIndex = + if selected < maxIndex then selected + 1 else selected + Action.Continue + ({ state with screen = Screen.ViewList newIndex }) | Enter -> - Action.Continue ({ state with screen = Screen.ViewDisplay selected }) + Action.Continue + ({ state with + screen = Screen.ViewDisplay(selected, 0) }) | _ -> Action.Continue state - | ViewDisplay _viewIndex -> + | ViewDisplay(viewIndex, focusedRow) -> match key with - | Escape -> Action.Continue ({ state with screen = Screen.ViewList 0 }) + | Escape -> + Action.Continue + ({ state with screen = Screen.ViewList viewIndex }) + | UpArrow -> + let nextRow = + if focusedRow > 0 then focusedRow - 1 else 0 + Action.Continue + ({ state with + screen = Screen.ViewDisplay(viewIndex, nextRow) }) + | DownArrow -> + Action.Continue + ({ state with + screen = + Screen.ViewDisplay(viewIndex, focusedRow + 1) }) | _ -> Action.Continue state -// ── SubApp adapter ── +// ── Terminal session adapter ── -let makeSubApp (state: State) : Darklang.Cli.SubApp = +type Session = + { state: State + terminal: Darklang.Cli.Tui.TerminalSession.State } + +let presentState (session: Session) (state: State) : Session = + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + let terminal = + Darklang.Cli.Tui.TerminalSession.presentAtSize + session.terminal + size + (viewAtSize state size) + Session { state = state; terminal = terminal } + +let makeSubApp (session: Session) : Darklang.Cli.SubApp = Darklang.Cli.SubApp - { onKey = fun key modifiers keyChar -> - match handleKey state key modifiers keyChar with - | Continue s -> (Darklang.Cli.SubAppAction.Continue, makeSubApp s) - | Exit s -> (Darklang.Cli.SubAppAction.Exit, makeSubApp s) - onDisplay = fun () -> renderState state - onSave = fun () -> () } + { onKey = + fun key modifiers keyChar -> + match + handleKey session.state key modifiers keyChar + with + | Continue state -> + let next = presentState session state + ( Darklang.Cli.SubAppAction.Continue + , makeSubApp next ) + | Exit state -> + let next = + Session { state = state; terminal = session.terminal } + ( Darklang.Cli.SubAppAction.Exit + , makeSubApp next ) + // The session presents on launch and state transitions. + onDisplay = fun () -> () + onSave = fun () -> () + onTerminate = + fun () -> + let _stopped = + Darklang.Cli.Tui.TerminalSession.stop session.terminal + () } // ── CLI command entry points ── -let execute (cliState: Darklang.Cli.AppState) (args: List) : Darklang.Cli.AppState = +let slug (entry: ViewEntry) : String = + Stdlib.String.toLowercase + (Stdlib.String.replaceAll entry.name " " "-") + +let execute + (cliState: Darklang.Cli.AppState) + (args: List) + : Darklang.Cli.AppState = let views = allViews () match args with - // Non-interactive: `views list` prints available views - | ["list"] -> - Stdlib.printLine (Darklang.Cli.Colors.boldText "Available views:") + // Non-interactive: `views list` prints available views. + | [ "list" ] -> + Stdlib.printLine + (Darklang.Cli.Colors.boldText "Available views:") Stdlib.printLine "" views - |> Stdlib.List.iter (fun view -> - let slug = Stdlib.String.toLowercase (Stdlib.String.replaceAll view.name " " "-") - Stdlib.printLine (" " ++ Darklang.Cli.Colors.boldText slug ++ " " ++ Darklang.Cli.Colors.dimText view.description)) + |> Stdlib.List.iter (fun entry -> + Stdlib.printLine + (" " + ++ Darklang.Cli.Colors.boldText (slug entry) + ++ " " + ++ Darklang.Cli.Colors.dimText entry.description)) Stdlib.printLine "" cliState - // Non-interactive: `views ` renders a specific view directly - | [viewName] -> - let slug = Stdlib.String.toLowercase viewName + // Non-interactive: `views ` prints one pure dashboard. + | [ viewName ] -> + let requested = Stdlib.String.toLowercase viewName let found = views - |> Stdlib.List.findFirst (fun view -> - let viewSlug = Stdlib.String.toLowercase (Stdlib.String.replaceAll view.name " " "-") - viewSlug == slug) + |> Stdlib.List.findFirst (fun entry -> + slug entry == requested) match found with - | Some view -> - view.render () + | Some entry -> + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + rowsAtSize entry.kind size |> Stdlib.printLines cliState | None -> - Stdlib.printLine (Darklang.Cli.Colors.error ("Unknown view: " ++ viewName)) - Stdlib.printLine "Use 'views list' to see available views." + Stdlib.printLine + (Darklang.Cli.Colors.error + ("Unknown view: " ++ viewName)) + Stdlib.printLine + "Use 'views list' to see available views." cliState - // Interactive: `views` opens the browser + // Interactive: `views` opens the retained browser. | _ -> - Stdlib.print "\u001b[?1049h" match views with | [] -> - Stdlib.print "\u001b[?1049l" - Stdlib.printLine (Darklang.Cli.Colors.dimText "No views available.") + Stdlib.printLine + (Darklang.Cli.Colors.dimText "No views available.") cliState | _ -> - let state = State { screen = Screen.ViewList 0; views = views } - let app = makeSubApp state - { cliState with - currentPage = Darklang.Cli.Page.SubApp app - needsFullRedraw = true } - - -let help (_state: Darklang.Cli.AppState) : Darklang.Cli.AppState = + let state = + State { + screen = Screen.ViewList 0 + views = views + } + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + match + Darklang.Cli.Tui.TerminalSession.startAtSize + size + (viewAtSize state size) + with + | Ok terminal -> + let session = Session { state = state; terminal = terminal } + { cliState with + currentPage = + Darklang.Cli.Page.SubApp (makeSubApp session) } + | Error message -> + Stdlib.printLine message + cliState + + +let help + (state: Darklang.Cli.AppState) + : Darklang.Cli.AppState = [ "Usage: views [name|list]" "Browse and preview available CLI views." "" @@ -193,17 +340,20 @@ let help (_state: Darklang.Cli.AppState) : Darklang.Cli.AppState = " views Render a specific view (e.g. views ai-chats)" "" "Interactive navigation:" - " Up/Down Select a view" + " Up/Down Select a view or scroll its content" " Enter Open the selected view" - " Escape Back to list / exit" - ] |> Stdlib.printLines - _state - - -let complete (_state: Darklang.Cli.AppState) (_args: List) : List = - let views = allViews () - views - |> Stdlib.List.map (fun view -> - Darklang.Cli.Completion.CompletionItem - { value = Stdlib.String.toLowercase (Stdlib.String.replaceAll view.name " " "-") - display = Stdlib.String.toLowercase (Stdlib.String.replaceAll view.name " " "-") }) + " Escape Back to list / exit" ] + |> Stdlib.printLines + state + + +let complete + (_state: Darklang.Cli.AppState) + (_args: List) + : List = + allViews () + |> Stdlib.List.map (fun entry -> + Darklang.Cli.Completion.CompletionItem { + value = slug entry + display = slug entry + }) diff --git a/packages/darklang/cli/apps/views/core.dark b/packages/darklang/cli/apps/views/core.dark index cacc8fe0b9..26b66fce5b 100644 --- a/packages/darklang/cli/apps/views/core.dark +++ b/packages/darklang/cli/apps/views/core.dark @@ -1,24 +1,42 @@ module Darklang.Cli.Apps.Views +/// The renderer selected by a gallery entry. +/// +/// Store a stable value in retained application state rather than a function. +/// This keeps state portable across package/runtime boundaries and makes every +/// renderer callable through one pure dispatcher. +type ViewKind = + | AiChats + | PackageStats + | UiComponents + /// A registered view that can be browsed and rendered from the CLI. type ViewEntry = { name: String description: String - render: Unit -> Unit } + kind: ViewKind } /// All available views. Add new views here. let allViews () : List = [ ViewEntry { name = "AI Chats" description = "Monitor AI coding threads β€” status, cost, tokens" - render = fun () -> AiChats.render () } + kind = ViewKind.AiChats } ViewEntry { name = "Package Stats" description = "Package tree statistics β€” counts by kind and owner" - render = fun () -> PackageStats.render () } + kind = ViewKind.PackageStats } ViewEntry { name = "UI Components" description = "Visual showcase of CLI UI components" - render = fun () -> - let _ = Darklang.CLI.UI.Demo.run () - () } ] + kind = ViewKind.UiComponents } ] + +/// Build one registered dashboard as logical rows without terminal I/O. +let rowsAtSize + (kind: ViewKind) + (size: Darklang.Cli.Tui.Size) + : List = + match kind with + | AiChats -> AiChats.rowsAtSize size + | PackageStats -> PackageStats.rowsAtSize size + | UiComponents -> Darklang.CLI.UI.Demo.rows () diff --git a/packages/darklang/cli/apps/views/package-stats.dark b/packages/darklang/cli/apps/views/package-stats.dark index 1925423536..6da645f1e4 100644 --- a/packages/darklang/cli/apps/views/package-stats.dark +++ b/packages/darklang/cli/apps/views/package-stats.dark @@ -41,24 +41,18 @@ let bar (value: Int) (maxVal: Int) (width: Int) (color: String) : String = (Darklang.Cli.Colors.colorize color filled) ++ (Darklang.Cli.Colors.dimText empty) -let render () : Unit = +/// Build the complete package-statistics dashboard without terminal I/O. +let rowsAtSize + (size: Darklang.Cli.Tui.Size) + : List = let stats = demoStats () + let termCols = Stdlib.Int.max 1 size.width let totalFns = Stdlib.List.fold stats 0 (fun acc s -> acc + s.fnCount) let totalTypes = Stdlib.List.fold stats 0 (fun acc s -> acc + s.typeCount) let totalValues = Stdlib.List.fold stats 0 (fun acc s -> acc + s.valueCount) let totalModules = Stdlib.List.fold stats 0 (fun acc s -> acc + s.submodules) - // Header - Stdlib.printLine "" - Stdlib.printLine - (" " - ++ Darklang.Cli.Colors.boldText "Package Statistics" - ++ " " - ++ Darklang.Cli.Colors.dimText ("β€” " ++ Stdlib.Int.toString (Stdlib.List.length stats) ++ " top-level modules")) - Stdlib.printLine "" - - // Summary cards let summaryLine = " " ++ Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bgGreen ++ Darklang.Cli.Colors.black ++ Darklang.Cli.Colors.bold) (" " ++ Stdlib.Int.toString totalFns ++ " fns ") @@ -68,12 +62,9 @@ let render () : Unit = ++ Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bgMagenta ++ Darklang.Cli.Colors.white ++ Darklang.Cli.Colors.bold) (" " ++ Stdlib.Int.toString totalValues ++ " values ") ++ " " ++ Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.grayBg ++ Darklang.Cli.Colors.white ++ Darklang.Cli.Colors.bold) (" " ++ Stdlib.Int.toString totalModules ++ " modules ") - Stdlib.printLine summaryLine - Stdlib.printLine "" - - // Table header - let termCols = Darklang.Cli.Terminal.getWidth () - Stdlib.printLine (Darklang.Cli.Colors.dimText (Stdlib.String.repeat "─" termCols)) + let divider = + Darklang.Cli.Colors.dimText + (Stdlib.String.repeat "─" termCols) let boldDim = Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.dim let headerLine = @@ -89,38 +80,49 @@ let render () : Unit = ++ Darklang.Cli.Colors.colorize boldDim (padLeft "MODS" 6) ++ " " ++ Darklang.Cli.Colors.colorize boldDim "FUNCTIONS" - Stdlib.printLine headerLine - Stdlib.printLine (Darklang.Cli.Colors.dimText (Stdlib.String.repeat "─" termCols)) - let maxFns = stats |> Stdlib.List.map (fun s -> s.fnCount) |> Stdlib.List.fold 0 (fun acc n -> if n > acc then n else acc) - // Rows - stats - |> Stdlib.List.iter (fun s -> - let total = s.fnCount + s.typeCount + s.valueCount - let nameStr = - if total > 100 then - Darklang.Cli.Colors.boldText (padRight s.name 30) - else - padRight s.name 30 - let fnStr = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.green (padLeft (Stdlib.Int.toString s.fnCount) 6) - let typeStr = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.blue (padLeft (Stdlib.Int.toString s.typeCount) 6) - let valStr = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.magenta (padLeft (Stdlib.Int.toString s.valueCount) 6) - let modStr = Darklang.Cli.Colors.dimText (padLeft (Stdlib.Int.toString s.submodules) 6) - let barStr = bar s.fnCount maxFns 20 Darklang.Cli.Colors.green - - Stdlib.printLine (" " ++ nameStr ++ " " ++ fnStr ++ " " ++ typeStr ++ " " ++ valStr ++ " " ++ modStr ++ " " ++ barStr)) - - Stdlib.printLine (Darklang.Cli.Colors.dimText (Stdlib.String.repeat "─" termCols)) - Stdlib.printLine "" - - // Footer - Stdlib.printLine + let moduleRows = + stats + |> Stdlib.List.map (fun s -> + let total = s.fnCount + s.typeCount + s.valueCount + let nameStr = + if total > 100 then + Darklang.Cli.Colors.boldText (padRight s.name 30) + else + padRight s.name 30 + let fnStr = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.green (padLeft (Stdlib.Int.toString s.fnCount) 6) + let typeStr = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.blue (padLeft (Stdlib.Int.toString s.typeCount) 6) + let valStr = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.magenta (padLeft (Stdlib.Int.toString s.valueCount) 6) + let modStr = Darklang.Cli.Colors.dimText (padLeft (Stdlib.Int.toString s.submodules) 6) + let barStr = bar s.fnCount maxFns 20 Darklang.Cli.Colors.green + + " " ++ nameStr ++ " " ++ fnStr ++ " " ++ typeStr ++ " " ++ valStr ++ " " ++ modStr ++ " " ++ barStr) + + let hints = (" " ++ Darklang.Cli.Colors.dimText "[q]" ++ " quit " ++ Darklang.Cli.Colors.dimText "[s]" ++ " sort by column " ++ Darklang.Cli.Colors.dimText "[/]" ++ " filter") - Stdlib.printLine "" + + Stdlib.List.flatten [ + [ "" + " " + ++ Darklang.Cli.Colors.boldText "Package Statistics" + ++ " " + ++ Darklang.Cli.Colors.dimText + ("β€” " + ++ Stdlib.Int.toString (Stdlib.List.length stats) + ++ " top-level modules") + "" + summaryLine + "" + divider + headerLine + divider ] + moduleRows + [ divider; ""; hints; "" ] + ] diff --git a/packages/darklang/cli/caps/app.dark b/packages/darklang/cli/caps/app.dark index f54409f407..a7018aab1c 100644 --- a/packages/darklang/cli/caps/app.dark +++ b/packages/darklang/cli/caps/app.dark @@ -3,24 +3,80 @@ module Darklang.Cli.Caps.App // The interactive capability editor, wrapped as a CLI SubApp. Composes the http/exec/access rule // editors + flag toggles + profile picker over a working copy of the grant, written via `pmCapsSet`. -let makeSubApp (state: Main.State) : Darklang.Cli.SubApp = +type Session = + { state: Main.State + terminal: Darklang.Cli.Tui.TerminalSession.State + shouldSave: Bool } + +let presentState + (session: Session) + (state: Main.State) + (shouldSave: Bool) + : Session = + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + let terminal = + Darklang.Cli.Tui.TerminalSession.presentAtSize + session.terminal + size + (Main.viewAtSize state size) + Session { + state = state + terminal = terminal + shouldSave = shouldSave + } + +let makeSubApp (session: Session) : Darklang.Cli.SubApp = Darklang.Cli.SubApp { onKey = fun key modifiers keyChar -> - match Main.handleKey state key modifiers keyChar with - | Continue s -> (Darklang.Cli.SubAppAction.Continue, makeSubApp s) - | Save s -> (Darklang.Cli.SubAppAction.Save, makeSubApp s) - | Exit s -> (Darklang.Cli.SubAppAction.Exit, makeSubApp s) - onDisplay = fun () -> Main.render state - onSave = fun () -> Main.save state } + match Main.handleKey session.state key modifiers keyChar with + | Continue state -> + let next = presentState session state false + (Darklang.Cli.SubAppAction.Continue, makeSubApp next) + | Save state -> + let next = presentState session state true + (Darklang.Cli.SubAppAction.Save, makeSubApp next) + | Exit state -> + let next = + Session { + state = state + terminal = session.terminal + // Escape/q discard the working copy. The persisted grant is + // already `state.saved`, so exiting must not rewrite it. + shouldSave = false + } + (Darklang.Cli.SubAppAction.Exit, makeSubApp next) + // The session presents on launch and state transitions. + onDisplay = fun () -> () + onSave = + fun () -> + if session.shouldSave then Main.save session.state else () + onTerminate = + fun () -> + let _stopped = + Darklang.Cli.Tui.TerminalSession.stop session.terminal + () } let execute (cliState: Darklang.Cli.AppState) (_args: List) : Darklang.Cli.AppState = - Stdlib.print "\u001b[?1049h" let state = Main.load () - let app = makeSubApp state - { cliState with - currentPage = Darklang.Cli.Page.SubApp app - needsFullRedraw = true } + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + match + Darklang.Cli.Tui.TerminalSession.startAtSize + size + (Main.viewAtSize state size) + with + | Ok terminal -> + let session = + Session { + state = state + terminal = terminal + shouldSave = false + } + { cliState with + currentPage = Darklang.Cli.Page.SubApp (makeSubApp session) } + | Error message -> + Stdlib.printLine message + cliState let help (state: Darklang.Cli.AppState) : Darklang.Cli.AppState = [ "Usage: caps-edit" diff --git a/packages/darklang/cli/caps/main.dark b/packages/darklang/cli/caps/main.dark index d5347291e5..08a5eb8f69 100644 --- a/packages/darklang/cli/caps/main.dark +++ b/packages/darklang/cli/caps/main.dark @@ -245,46 +245,80 @@ let rowText (row: Row) : String = | RuleRow spec -> Darklang.PrettyPrinter.Capabilities.line spec | AddRuleRow -> "+ Add rule…" -let printOneRow (state: State) (pair: (Int * Row)) : Unit = +let renderOneRow (state: State) (pair: (Int * Row)) : String = let (i, r) = pair let selected = i == state.cursor let prefix = if selected then "β€Ί" else " " let line = $" {prefix} {rowText r}" - let styled = - if selected then Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line - else line - Stdlib.printLine styled + if selected then + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line + else + line // flag checklist on top, then a labelled "Scoped rules" section (existing rules + the "+ Add rule…" -// affordance). The cursor index maps into `rows`; the section label is printed between, not selectable. -let printRows (state: State) : Unit = +// affordance). The cursor index maps into `rows`; the section label sits between them and is not +// selectable. The viewport uses its physical row so long grants keep the selection visible. +let browsingRows (state: State) (height: Int) : List = let flagCount = (Stdlib.List.length flagDomains) let indexed = Stdlib.List.indexedMap (rows state) (fun i r -> (i, r)) - indexed - |> Stdlib.List.filter (fun pair -> Stdlib.Tuple2.first pair < flagCount) - |> Stdlib.List.iter (fun pair -> printOneRow state pair) - Stdlib.printLine "" - Stdlib.printLine (Darklang.Cli.Colors.dimText " Scoped rules") - indexed - |> Stdlib.List.filter (fun pair -> Stdlib.Tuple2.first pair >= flagCount) - |> Stdlib.List.iter (fun pair -> printOneRow state pair) - -let printMenu (title: String) (items: List) (cursor: Int) : Unit = - Stdlib.printLine title - Stdlib.printLine "" + let flagRows = + indexed + |> Stdlib.List.filter (fun pair -> + Stdlib.Tuple2.first pair < flagCount) + |> Stdlib.List.map (fun pair -> renderOneRow state pair) + let ruleRows = + indexed + |> Stdlib.List.filter (fun pair -> + Stdlib.Tuple2.first pair >= flagCount) + |> Stdlib.List.map (fun pair -> renderOneRow state pair) + let allRows = + Stdlib.List.flatten + [ flagRows + [ ""; Darklang.Cli.Colors.dimText " Scoped rules" ] + ruleRows ] + let selectedPhysicalRow = + if state.cursor < flagCount then + state.cursor + else + state.cursor + 2 + + Darklang.Cli.Tui.Layout.viewport + allRows + selectedPhysicalRow + height + +let menuRows + (title: String) + (items: List) + (cursor: Int) + (height: Int) + : List = let indexed = Stdlib.List.indexedMap items (fun i x -> (i, x)) - Stdlib.List.iter indexed (fun pair -> - let (i, x) = pair - let selected = i == cursor - let prefix = if selected then "β€Ί" else " " - let line = $" {prefix} {x}" - let styled = - if selected then Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line - else line - Stdlib.printLine styled) - -let render (state: State) : Unit = - Stdlib.print "\u001b[?25l\u001b[2J\u001b[H" + let itemRows = + Stdlib.List.map indexed (fun (i, item) -> + let selected = i == cursor + let prefix = if selected then "β€Ί" else " " + let line = $" {prefix} {item}" + if selected then + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line + else + line) + let rows = Stdlib.List.append [ title; "" ] itemRows + // Account for the title and blank row before the selected menu item. + Darklang.Cli.Tui.Layout.viewport rows (cursor + 2) height + +/// Construct the complete caps editor view without performing terminal I/O. +let viewAtSize + (state: State) + (size: Darklang.Cli.Tui.Size) + : Darklang.Cli.Tui.View = + let width = Stdlib.Int.max 1 size.width + let contentHeight = Stdlib.Int.max 1 size.height + let titleHeight = if contentHeight >= 3 then 2 else 1 + let helpHeight = if contentHeight >= 2 then 1 else 0 + let bodyHeight = + Stdlib.Int.max 0 (contentHeight - titleHeight - helpHeight) + let dirtyTag = if state.dirty then Darklang.Cli.Colors.colorize Darklang.Cli.Colors.yellow " ● unsaved" else "" @@ -292,32 +326,56 @@ let render (state: State) : Unit = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white ++ Darklang.Cli.Colors.bold) " capabilities " - Stdlib.printLine $"{title}{dirtyTag}" - Stdlib.printLine "" - match state.screen with - | Browsing -> - printRows state - Stdlib.printLine "" - Stdlib.printLine - (Darklang.Cli.Colors.dimText - " ↑↓ move Β· space toggle Β· enter edit/add Β· a add rule Β· d delete Β· p profile Β· w write Β· esc exit") - | AddPickDomain cursor -> - printMenu " Add a rule for which domain?" ruleDomains cursor - Stdlib.printLine "" - Stdlib.printLine - (Darklang.Cli.Colors.dimText " ↑↓ move Β· enter choose Β· esc cancel") - | PickProfile cursor -> - let names = Stdlib.List.map Command.profiles (fun pair -> Stdlib.Tuple2.first pair) - printMenu " Apply a profile (REPLACES the grant):" names cursor - Stdlib.printLine "" - Stdlib.printLine - (Darklang.Cli.Colors.dimText " ↑↓ move Β· enter apply Β· esc cancel") - | EditHttp(_, editor) -> - Stdlib.printLines (HttpRuleEditor.render editor) - | EditExec(_, editor) -> - Stdlib.printLines (ExecRuleEditor.render editor) - | EditAccess(_, editor) -> - Stdlib.printLines (AccessEditor.render editor) + + let (bodyRows, helpText) = + match state.screen with + | Browsing -> + ( browsingRows state bodyHeight + , " ↑↓ move Β· space toggle Β· enter edit/add Β· a add rule Β· d delete Β· p profile Β· w write Β· esc exit" ) + | AddPickDomain cursor -> + ( menuRows + " Add a rule for which domain?" + ruleDomains + cursor + bodyHeight + , " ↑↓ move Β· enter choose Β· esc cancel" ) + | PickProfile cursor -> + let names = + Stdlib.List.map Command.profiles (fun pair -> + Stdlib.Tuple2.first pair) + ( menuRows + " Apply a profile (REPLACES the grant):" + names + cursor + bodyHeight + , " ↑↓ move Β· enter apply Β· esc cancel" ) + | EditHttp(_, editor) -> + (Darklang.Cli.Tui.Layout.fitRows bodyHeight (HttpRuleEditor.render editor), "") + | EditExec(_, editor) -> + (Darklang.Cli.Tui.Layout.fitRows bodyHeight (ExecRuleEditor.render editor), "") + | EditAccess(_, editor) -> + (Darklang.Cli.Tui.Layout.fitRows bodyHeight (AccessEditor.render editor), "") + + let titleRows = + Darklang.Cli.Tui.Layout.fitRows + titleHeight + [ $"{title}{dirtyTag}" ] + let helpRows = + if helpHeight == 0 then + [] + else + [ Darklang.Cli.Colors.dimText helpText ] + let viewRows = + Stdlib.List.flatten [ titleRows; bodyRows; helpRows ] + |> Stdlib.List.take contentHeight + |> Stdlib.List.map (fun row -> + Darklang.Cli.Tui.Text.clipToWidth row width) + + Darklang.Cli.Tui.View { + rows = viewRows + cursor = Darklang.Cli.Tui.Cursor.Hidden + mode = Darklang.Cli.Tui.ViewMode.Fullscreen + } let save (state: State) : Unit = let _ = Command.writeSpecs state.specs diff --git a/packages/darklang/cli/clear.dark b/packages/darklang/cli/clear.dark index cf4fd54bac..b1016a8a19 100644 --- a/packages/darklang/cli/clear.dark +++ b/packages/darklang/cli/clear.dark @@ -3,7 +3,7 @@ module Darklang.Cli.Clear let execute (state: AppState) (args: List) : AppState = Builtin.stdoutClear () - { state with needsFullRedraw = true } + state let complete (_state: AppState) (_args: List) : List = diff --git a/packages/darklang/cli/completionPicker.dark b/packages/darklang/cli/completionPicker.dark index b0fee24974..80dd75727c 100644 --- a/packages/darklang/cli/completionPicker.dark +++ b/packages/darklang/cli/completionPicker.dark @@ -4,11 +4,35 @@ type State = { items: List selectedIndex: Int scrollOffset: Int - commandPrefix: String - hasBeenDisplayed: Bool } + commandPrefix: String } val maxVisibleItems = 10 +val fullHelpText = "↑/↓: navigate Enter: select Esc: cancel" +val compactHelpText = "↑↓ move Enter select Esc cancel" +val minimumHelpText = "↑↓ Enter Esc" + +val minimumHeight = 2 +val minimumWidth = Tui.Text.displayWidth minimumHelpText + +type Layout = + { itemCapacity: Int + showScrollIndicator: Bool } + +let supportsSize (size: Tui.Size) : Bool = + size.height >= minimumHeight && size.width >= minimumWidth + +val unsupportedSizeMessage = + $"Completion picker requires at least {Stdlib.Int.toString minimumWidth} columns and {Stdlib.Int.toString minimumHeight} rows." + +let helpTextAtWidth (width: Int) : String = + if Tui.Text.displayWidth fullHelpText <= width then + fullHelpText + else if Tui.Text.displayWidth compactHelpText <= width then + compactHelpText + else + minimumHelpText + /// Create picker state from completions let create (promptText: String) (items: List) : State = let parsed = Completion.parseInput promptText @@ -23,8 +47,7 @@ let create (promptText: String) (items: List) : State { items = items selectedIndex = 0 scrollOffset = 0 - commandPrefix = commandPrefix - hasBeenDisplayed = false } + commandPrefix = commandPrefix } /// Get the currently selected item's value (what gets inserted) let getSelectedValue (state: State) : Stdlib.Option.Option = @@ -32,48 +55,110 @@ let getSelectedValue (state: State) : Stdlib.Option.Option = | Some item -> Stdlib.Option.Option.Some item.value | None -> Stdlib.Option.Option.None -/// Display the completion picker UI -let display (state: State) : Unit = +/// Reserve the help row and fit completion items and a scroll indicator above it. +let layoutAtSize + (state: State) + (size: Tui.Size) + : Layout = let itemCount = Stdlib.List.length state.items - let visibleCount = Stdlib.Int.min maxVisibleItems itemCount - let hasScrollIndicator = itemCount > maxVisibleItems + let availableRows = Stdlib.Int.max minimumHeight size.height + let helpRowHeight = 1 + let withoutIndicator = + Stdlib.Int.max 1 (availableRows - helpRowHeight) + let initialCapacity = + Stdlib.Int.min maxVisibleItems withoutIndicator + let showScrollIndicator = + itemCount > initialCapacity && availableRows >= 3 + let indicatorHeight = if showScrollIndicator then 1 else 0 + + Layout { + itemCapacity = + Stdlib.Int.max + 1 + (Stdlib.Int.min + maxVisibleItems + (availableRows - helpRowHeight - indicatorHeight)) + showScrollIndicator = showScrollIndicator + } + +/// Number of item rows available at this terminal size. +let visibleCapacity + (state: State) + (size: Tui.Size) + : Int = + (layoutAtSize state size).itemCapacity + + +let effectiveOffset (state: State) (capacity: Int) : Int = + let itemCount = Stdlib.List.length state.items + let maxOffset = Stdlib.Int.max 0 (itemCount - capacity) + let desired = + if state.selectedIndex < state.scrollOffset then + state.selectedIndex + else if state.selectedIndex >= state.scrollOffset + capacity then + state.selectedIndex - capacity + 1 + else + state.scrollOffset - // Move cursor up to overwrite previous display (only after first render) - if state.hasBeenDisplayed then - let lineCount = visibleCount + (if hasScrollIndicator then 1 else 0) + 1 - Stdlib.print $"\u001b[{Stdlib.Int.toString lineCount}A\u001b[J" + Stdlib.Int.min maxOffset (Stdlib.Int.max 0 desired) + + +/// Build the complete inline completion region without terminal I/O. +let viewAtSize + (state: State) + (size: Tui.Size) + : Tui.View = + let width = Stdlib.Int.max 1 size.width + let layout = layoutAtSize state size + let capacity = layout.itemCapacity + let offset = effectiveOffset state capacity + let itemCount = Stdlib.List.length state.items + let visibleCount = Stdlib.Int.min capacity itemCount let visibleItems = state.items - |> Stdlib.List.drop state.scrollOffset + |> Stdlib.List.drop offset |> Stdlib.List.take visibleCount - // Render each visible item - let _ = + let itemRows = visibleItems - |> Stdlib.List.indexedMap (fun index item -> - let isSelected = (state.scrollOffset + index) == state.selectedIndex + |> Stdlib.List.indexedMap (fun visibleIndex item -> + let isSelected = + (offset + visibleIndex) == state.selectedIndex if isSelected then - Stdlib.printLine (Colors.colorize Colors.completionSelection item.display) + Colors.colorize Colors.completionSelection item.display else - Stdlib.printLine item.display) + item.display) - // Show scroll indicator if needed - if hasScrollIndicator then - let start = Stdlib.Int.toString (state.scrollOffset + 1) - let endIdx = Stdlib.Int.toString (state.scrollOffset + visibleCount) - let total = Stdlib.Int.toString itemCount - Stdlib.printLine (Colors.dimText $"({start}-{endIdx} of {total})") + let indicatorRows = + if layout.showScrollIndicator then + let start = Stdlib.Int.toString (offset + 1) + let endIndex = Stdlib.Int.toString (offset + visibleCount) + let total = Stdlib.Int.toString itemCount + [ Colors.dimText $"({start}-{endIndex} of {total})" ] + else + [] - Stdlib.printLine (Colors.dimText "↑/↓: navigate Enter: select Esc: cancel") + let helpRows = + [ Colors.dimText (helpTextAtWidth width) ] + let rows = + Stdlib.List.flatten [ itemRows; indicatorRows; helpRows ] + |> Stdlib.List.map (fun row -> + Tui.Text.clipToWidth row width) -/// Move selection up -let moveUp (state: State) : State = + Tui.View { + rows = rows + cursor = Tui.Cursor.Hidden + mode = Tui.ViewMode.Inline + } + + +/// Move selection up, keeping it within a viewport of `capacity` rows. +let moveUpWithCapacity (capacity: Int) (state: State) : State = if state.selectedIndex > 0 then let newIndex = state.selectedIndex - 1 - // Adjust scroll if needed let newOffset = if newIndex < state.scrollOffset then newIndex else state.scrollOffset @@ -83,17 +168,16 @@ let moveUp (state: State) : State = else state -/// Move selection down -let moveDown (state: State) : State = +/// Move selection down, keeping it within a viewport of `capacity` rows. +let moveDownWithCapacity (capacity: Int) (state: State) : State = let itemCount = Stdlib.List.length state.items if state.selectedIndex < (itemCount - 1) then let newIndex = state.selectedIndex + 1 - // Adjust scroll if needed let newOffset = - if newIndex >= (state.scrollOffset + maxVisibleItems) then - state.scrollOffset + 1 + if newIndex >= (state.scrollOffset + capacity) then + newIndex - capacity + 1 else state.scrollOffset @@ -108,23 +192,79 @@ type KeyResult = | Continue of State | SelectItem of String | Cancel + | Unavailable of message: String /// Handle a key press in the picker -let handleKey (state: State) (key: Stdlib.Cli.Stdin.Key.Key) : KeyResult = - // Mark as displayed for subsequent renders to know to clear previous output - let stateDisplayed = { state with hasBeenDisplayed = true } - - match key with - | UpArrow -> KeyResult.Continue (moveUp stateDisplayed) - | DownArrow -> KeyResult.Continue (moveDown stateDisplayed) - | Enter -> - // Use original state here - no need to mark as displayed since we're exiting - match getSelectedValue state with - | Some value -> KeyResult.SelectItem value - | None -> KeyResult.Cancel - | Escape -> KeyResult.Cancel - | _ -> KeyResult.Continue stateDisplayed +let handleKeyAtSize + (state: State) + (size: Tui.Size) + (key: Stdlib.Cli.Stdin.Key.Key) + : KeyResult = + if Stdlib.Bool.not (supportsSize size) then + KeyResult.Unavailable unsupportedSizeMessage + else + let capacity = visibleCapacity state size + match key with + | UpArrow -> + KeyResult.Continue + (moveUpWithCapacity capacity state) + | DownArrow -> + KeyResult.Continue + (moveDownWithCapacity capacity state) + | Enter -> + match getSelectedValue state with + | Some value -> KeyResult.SelectItem value + | None -> KeyResult.Cancel + | Escape -> KeyResult.Cancel + | _ -> KeyResult.Continue state /// Build the completed command text from selection let buildCompletedCommand (state: State) (selection: String) : String = state.commandPrefix ++ selection + + +/// An active completion picker and its terminal presentation state. +type Session = + { state: State + terminal: Tui.TerminalSession.State } + +let startAtSize + (state: State) + (size: Tui.Size) + : Stdlib.Result.Result = + if Stdlib.Bool.not (supportsSize size) then + Stdlib.Result.Result.Error unsupportedSizeMessage + else + match Tui.TerminalSession.startAtSize size (viewAtSize state size) with + | Ok terminal -> + Stdlib.Result.Result.Ok + (Session { state = state; terminal = terminal }) + | Error message -> + Stdlib.Result.Result.Error message + +let presentAtSize + (session: Session) + (state: State) + (size: Tui.Size) + : Session = + let terminal = + Tui.TerminalSession.presentAtSize + session.terminal + size + (viewAtSize state size) + Session { state = state; terminal = terminal } + +/// Stop the session, erasing the picker region in the same update. +/// +/// Selection, cancel, and Ctrl-C all release the region this way: the picker +/// leaves nothing behind, and the prompt re-anchors where the region began. +let stop (session: Session) : Session = + let cleared = + Tui.View { + rows = [] + cursor = Tui.Cursor.Hidden + mode = Tui.ViewMode.Inline + } + let terminal = + Tui.TerminalSession.stopInlineWithView session.terminal cleared + Session { state = session.state; terminal = terminal } diff --git a/packages/darklang/cli/component.dark b/packages/darklang/cli/component.dark index 59ef43545e..28919d681a 100644 --- a/packages/darklang/cli/component.dark +++ b/packages/darklang/cli/component.dark @@ -16,20 +16,58 @@ type Component<'s> = onKey: 's -> Stdlib.Cli.Stdin.Key.Key -> Stdlib.Cli.Stdin.Modifiers.Modifiers -> Stdlib.Option.Option -> Step<'s> } -let toSubApp (c: Component<'s>) (state: 's) : Cli.SubApp = +/// Convert a component's rendered text into a complete logical terminal view. +/// +/// Components still return a string while they migrate incrementally. Splitting +/// that string here keeps terminal effects and lifecycle control out of feature +/// code without requiring each existing component to understand the renderer. +let view (c: Component<'s>) (state: 's) : Tui.View = + let rendered = c.render state + + let rows = + if rendered == "" then + [] + else + Stdlib.String.split rendered "\n" + + Tui.View { + rows = rows + cursor = Tui.Cursor.Hidden + mode = Tui.ViewMode.Fullscreen + } + + +let toSubApp + (c: Component<'s>) + (state: 's) + (terminal: Tui.TerminalSession.State) + : Cli.SubApp = Cli.SubApp { onKey = fun key modifiers keyChar -> match c.onKey state key modifiers keyChar with - | Stay next -> (Cli.SubAppAction.Continue, toSubApp c next) - | Exit -> (Cli.SubAppAction.Exit, toSubApp c state) - onDisplay = fun () -> Stdlib.print ("\u001b[2J\u001b[H" ++ c.render state) - onSave = fun () -> () } + | Stay next -> + let nextTerminal = + Tui.TerminalSession.present terminal (view c next) + (Cli.SubAppAction.Continue, toSubApp c next nextTerminal) + | Exit -> + (Cli.SubAppAction.Exit, toSubApp c state terminal) + // Presentation happens on launch and state transitions so the returned + // SubApp retains the corresponding renderer state. + onDisplay = fun () -> () + onSave = fun () -> () + onTerminate = + fun () -> + let _stopped = Tui.TerminalSession.stop terminal + () } /// Launch `c` (starting at `init`) as the CLI's current full-screen page. let launch (cliState: Cli.AppState) (c: Component<'s>) (init: 's) : Cli.AppState = - Stdlib.print "\u001b[?1049h" - { cliState with - currentPage = Cli.Page.SubApp (toSubApp c init) - needsFullRedraw = true } + match Tui.TerminalSession.start (view c init) with + | Ok terminal -> + { cliState with + currentPage = Cli.Page.SubApp (toSubApp c init terminal) } + | Error message -> + Stdlib.printLine message + cliState diff --git a/packages/darklang/cli/core.dark b/packages/darklang/cli/core.dark index b0e1217512..b2734c324c 100644 --- a/packages/darklang/cli/core.dark +++ b/packages/darklang/cli/core.dark @@ -10,25 +10,27 @@ type SubAppAction = type SubApp = { onKey: Stdlib.Cli.Stdin.Key.Key -> Stdlib.Cli.Stdin.Modifiers.Modifiers -> Stdlib.Option.Option -> (SubAppAction * SubApp) onDisplay: Unit -> Unit - onSave: Unit -> Unit } + onSave: Unit -> Unit + /// Restore the app's terminal surface when the CLI exits on Ctrl-C, + /// bypassing the app's own exit action. Safe when already stopped. + onTerminate: Unit -> Unit } type Page = | MainPrompt - | InteractiveNav of Packages.NavInteractive.State - | CompletionPicker of CompletionPicker.State + | InteractiveNav of Packages.NavInteractive.Session + | CompletionPicker of CompletionPicker.Session | SubApp of SubApp type AppState = { isExiting: Bool prompt: Prompt.State - needsFullRedraw: Bool packageData: Packages.State currentPage: Page currentBranchId: Uuid accountID: Stdlib.Option.Option accountName: String - /// How many terminal rows the prompt used last frame - previousRenderedRows: Int + /// Retained presentation state while the main prompt owns an inline region. + promptTerminal: Stdlib.Option.Option /// True when invoked from the command line (not the interactive REPL) nonInteractive: Bool /// Cached completion hint β€” skip recompute when prompt text unchanged @@ -37,20 +39,12 @@ type AppState = /// Cached command list and option strings β€” built once at startup allCommandsCache: List commandOptionsCache: List - /// Cached status bar string β€” skip re-render when branch unchanged - cachedStatusBar: String - cachedStatusBarBranch: Uuid /// When true, write telemetry to rundir/logs/telemetry.jsonl telemetryEnabled: Bool } let locationStr (state: AppState) : String = Packages.formatLocation state.packageData.currentLocation -let branchName (state: AppState) : String = - match SCM.Branch.get state.currentBranchId with - | Some b -> b.name - | None -> "main" - let commandOptions () : List = let commands = Registry.allCommands () let allNames = Stdlib.List.map commands (fun cmd -> cmd.name) @@ -117,20 +111,17 @@ let initState () : AppState = AppState { isExiting = false prompt = Prompt.initState () - needsFullRedraw = true packageData = Packages.initState () currentPage = Page.MainPrompt currentBranchId = branchId accountID = accountID accountName = accountName - previousRenderedRows = 0 + promptTerminal = Stdlib.Option.Option.None nonInteractive = false cachedHintInput = "" cachedHintValue = "" allCommandsCache = Registry.allCommands () commandOptionsCache = commandOptions () - cachedStatusBar = "" - cachedStatusBarBranch = Builtin.unwrap (Stdlib.Uuid.parse "00000000-0000-0000-0000-000000000000") telemetryEnabled = (Builtin.environmentGet "DARK_TELEMETRY") == Stdlib.Option.Option.Some "1" } type Msg = @@ -177,65 +168,9 @@ module View = let formatSuccess (message: String) : String = Colors.success message - let formatPromptWithInput (state: AppState) : String = - let hint = Registry.getCompletionHint state state.prompt.text - Prompt.Display.formatPromptWithInput (locationStr state) state.prompt.text hint - let formatWelcome () : String = Registry.getCompactCommandList () -module StatusBar = - /// Initialize the status bar by setting up scroll region to reserve bottom line - let init () : Unit = - let terminalHeight = Terminal.getHeight () - // Set scroll region to all rows except the last one - Stdlib.print (Colors.setScrollRegion 1 (terminalHeight - 1)) - - /// Build the status bar string for the given state - let build (state: AppState) : String = - let terminalWidth = Terminal.getWidth () - - let leftText = " Darklang " - let leftPart = - Colors.colorize (Colors.purpleBg ++ Colors.white ++ Colors.bold) leftText - - let branchText = $" {branchName state} " - let branchPart = - Colors.colorize (Colors.grayBg ++ Colors.white) branchText - - let leftLen = Stdlib.String.length leftText + Stdlib.String.length branchText - let paddingLen = terminalWidth - leftLen - - let padding = - if paddingLen > 0 then - Colors.colorize Colors.grayBg (Stdlib.String.repeat " " paddingLen) - else - "" - - leftPart ++ branchPart ++ padding - - /// Render the status bar at the bottom of the terminal - let render (statusLine: String) : Unit = - let terminalHeight = Terminal.getHeight () - Stdlib.print - (Colors.saveCursor - ++ Colors.moveCursorTo terminalHeight 1 - ++ statusLine - ++ Colors.restoreCursor) - - /// Clear the status bar and reset scroll region - let clear () : Unit = - let terminalHeight = Terminal.getHeight () - // Reset scroll region and clear the status bar line - Stdlib.print - (Colors.saveCursor - ++ Colors.resetScrollRegion - ++ Colors.moveCursorTo terminalHeight 1 - ++ Colors.clearLine - ++ Colors.restoreCursor) - - - module Registry = type CommandHandler = { name: String @@ -557,11 +492,60 @@ module Commands = Registry.executeCommand commandName state args +/// Release the retained prompt before appending command output or handing the +/// terminal to another page. +/// +/// The region shrinks to the prompt rows alone, so the submitted command stays +/// in scrollback while the completion hint does not. +let releasePromptRegion (state: AppState) : AppState = + match state.promptTerminal with + | None -> state + | Some terminal -> + let parked = + Prompt.Display.viewAtSize + state.prompt + (locationStr state) + "" + (Tui.TerminalSession.currentSize ()) + let _stopped = + Tui.TerminalSession.stopInlineWithView terminal parked + { state with promptTerminal = Stdlib.Option.Option.None } + + +/// Erase the rendered prompt region and release its terminal ownership. +/// +/// Used on Ctrl-C and programmatic exit, where nothing from the region should +/// remain in scrollback and the shell prompt should reappear in its place. +let clearPromptRegion (state: AppState) : AppState = + match state.promptTerminal with + | None -> state + | Some terminal -> + let cleared = + Tui.View { + rows = [] + cursor = Tui.Cursor.Hidden + mode = Tui.ViewMode.Inline + } + let _stopped = + Tui.TerminalSession.stopInlineWithView terminal cleared + { state with promptTerminal = Stdlib.Option.Option.None } + + let handleKeyInput (state: AppState) (key: Stdlib.Cli.Stdin.Key.Key) (modifiers: Stdlib.Cli.Stdin.Modifiers.Modifiers) (keyChar: Stdlib.Option.Option) : AppState = // Ctrl+C exits from any page if modifiers.ctrl && key == Stdlib.Cli.Stdin.Key.Key.C then - // Clear the current prompt line so it doesn't linger after exit - Stdlib.print (Colors.carriageReturn ++ Colors.clearLine) + let state = + match state.currentPage with + | MainPrompt -> clearPromptRegion state + | CompletionPicker session -> + let _stopped = CompletionPicker.stop session + state + | InteractiveNav session -> + let _stopped = Tui.TerminalSession.stop session.terminal + state + | SubApp app -> + app.onTerminate () + state { state with isExiting = true } else @@ -572,31 +556,30 @@ let handleKeyInput (state: AppState) (key: Stdlib.Cli.Stdin.Key.Key) (modifiers: | Enter -> // Execute the current command if Stdlib.String.isEmpty (Stdlib.String.trim state.prompt.text) then + let released = releasePromptRegion state Stdlib.printLine "" - { state with - prompt = Prompt.Editing.clear state.prompt - needsFullRedraw = false } + { released with + prompt = Prompt.Editing.clear state.prompt } else + let released = releasePromptRegion state Stdlib.printLine "" let commandToExecute = Stdlib.String.trim state.prompt.text let tCmd = Telemetry.now () - let newState = Commands.parseAndExecute state commandToExecute + let newState = + Commands.parseAndExecute released commandToExecute Telemetry.logWithContext "commandExec" tCmd [("cmd", commandToExecute)] let clearedPrompt = Prompt.Editing.clear newState.prompt let promptWithHistory = Prompt.History.addCommand clearedPrompt commandToExecute { newState with - prompt = promptWithHistory - needsFullRedraw = true } + prompt = promptWithHistory } | Backspace -> { state with - prompt = Prompt.Editing.deleteBeforeCursor state.prompt - needsFullRedraw = false } + prompt = Prompt.Editing.deleteBeforeCursor state.prompt } | Delete -> { state with - prompt = Prompt.Editing.deleteAtCursor state.prompt - needsFullRedraw = false } + prompt = Prompt.Editing.deleteAtCursor state.prompt } | Tab -> // Handle tab completion @@ -615,101 +598,115 @@ let handleKeyInput (state: AppState) (key: Stdlib.Cli.Stdin.Key.Key) (modifiers: { state with prompt = Prompt.Editing.setText state.prompt newPromptText } | multiple -> // Multiple completions - open interactive picker + let released = releasePromptRegion state Stdlib.printLine "" let pickerState = CompletionPicker.create state.prompt.text multiple - { state with - currentPage = Page.CompletionPicker pickerState - needsFullRedraw = true } + let size = Tui.TerminalSession.currentSize () + match CompletionPicker.startAtSize pickerState size with + | Ok session -> + { released with + currentPage = Page.CompletionPicker session } + | Error message -> + Stdlib.printLine message + released | UpArrow -> { state with - prompt = Prompt.History.navigatePrevious state.prompt - needsFullRedraw = false } + prompt = Prompt.History.navigatePrevious state.prompt } | DownArrow -> { state with - prompt = Prompt.History.navigateNext state.prompt - needsFullRedraw = false } + prompt = Prompt.History.navigateNext state.prompt } | LeftArrow -> { state with - prompt = Prompt.Editing.moveCursorLeft state.prompt - needsFullRedraw = false } + prompt = Prompt.Editing.moveCursorLeft state.prompt } | RightArrow -> { state with - prompt = Prompt.Editing.moveCursorRight state.prompt - needsFullRedraw = false } + prompt = Prompt.Editing.moveCursorRight state.prompt } | Home -> { state with - prompt = Prompt.Editing.moveCursorHome state.prompt - needsFullRedraw = false } + prompt = Prompt.Editing.moveCursorHome state.prompt } | End -> { state with - prompt = Prompt.Editing.moveCursorEnd state.prompt - needsFullRedraw = false } + prompt = Prompt.Editing.moveCursorEnd state.prompt } | _ -> // Add character to prompt at cursor position match keyChar with | Some char -> { state with - prompt = Prompt.Editing.insertAtCursor state.prompt char - needsFullRedraw = false } + prompt = Prompt.Editing.insertAtCursor state.prompt char } | None -> state - | InteractiveNav navState -> + | InteractiveNav session -> // Interactive navigation key handling - Packages.NavInteractive.handleKey state key keyChar navState + Packages.NavInteractive.handleKey state key keyChar session - | CompletionPicker pickerState -> + | CompletionPicker session -> // Completion picker key handling - match CompletionPicker.handleKey pickerState key with + let size = Tui.TerminalSession.currentSize () + match + CompletionPicker.handleKeyAtSize + session.state + size + key + with | Continue newPickerState -> + let nextSession = + CompletionPicker.presentAtSize + session + newPickerState + size { state with - currentPage = Page.CompletionPicker newPickerState - needsFullRedraw = true } + currentPage = Page.CompletionPicker nextSession } | SelectItem selection -> // User selected an item - update prompt and return to main - let newPromptText = CompletionPicker.buildCompletedCommand pickerState selection + let _stopped = CompletionPicker.stop session + let newPromptText = + CompletionPicker.buildCompletedCommand + session.state + selection { state with currentPage = Page.MainPrompt - prompt = Prompt.Editing.setText state.prompt newPromptText - needsFullRedraw = true } + prompt = Prompt.Editing.setText state.prompt newPromptText } | Cancel -> // User cancelled - return to main with original prompt + let _stopped = CompletionPicker.stop session { state with - currentPage = Page.MainPrompt - needsFullRedraw = true } + currentPage = Page.MainPrompt } + | Unavailable message -> + let _stopped = CompletionPicker.stop session + Stdlib.printLine message + { state with + currentPage = Page.MainPrompt } | SubApp app -> let (action, newApp) = app.onKey key modifiers keyChar match action with | Continue -> { state with - currentPage = Page.SubApp newApp - needsFullRedraw = true } + currentPage = Page.SubApp newApp } | Save -> newApp.onSave () { state with - currentPage = Page.SubApp newApp - needsFullRedraw = true } + currentPage = Page.SubApp newApp } | Exit -> + newApp.onTerminate () newApp.onSave () - Stdlib.print "\u001b[?1049l" // Launched one-shot (`dark apps`)? Exit to the shell. Inside the REPL? Back to the prompt. if state.nonInteractive then { state with isExiting = true } else { state with - currentPage = Page.MainPrompt - needsFullRedraw = true } + currentPage = Page.MainPrompt } | LaunchCommand cmd -> - // Replace this page by running `cmd` (which sets up its own page, e.g. a foreground app's SubApp). - let launched = Registry.executeCommand cmd state [] - { launched with needsFullRedraw = true } + // Release this app before the replacement command takes ownership. + newApp.onTerminate () + Registry.executeCommand cmd state [] module Update = @@ -720,7 +717,7 @@ module Update = | KeyPressed (key, modifiers, keyChar) -> handleKeyInput state key modifiers keyChar | Exit -> - { state with isExiting = true } + { clearPromptRegion state with isExiting = true } let processInput (state: AppState) (input: String) : AppState = let trimmedInput = Stdlib.String.trim input @@ -733,10 +730,8 @@ module Update = let runInteractiveLoop (state: AppState) : Int = if state.isExiting then - // Clear status bar and prompt line before exiting - StatusBar.clear () - // Move to column 1, clear entire line, then newline for clean shell prompt - Stdlib.print (Colors.moveCursorToColumn 1 ++ "\u001b[2K" ++ "\n") + // Clear any retained prompt region so the shell prompt takes its place. + let _cleared = clearPromptRegion state 0 else let tLoopStart = if state.telemetryEnabled then Telemetry.now () else 0 @@ -744,12 +739,10 @@ let runInteractiveLoop (state: AppState) : Int = let _ = Builtin.interpreterStatsReset () () - // TODO: handle terminal resize and Unicode display width let location = locationStr state + let size = Tui.TerminalSession.currentSize () let tHint = if state.telemetryEnabled then Telemetry.now () else 0 - let inputStartColumn = - Prompt.Display.calculateInputStartColumn location let hintCached = state.cachedHintInput == state.prompt.text let hint = if hintCached then @@ -759,89 +752,72 @@ let runInteractiveLoop (state: AppState) : Int = if state.telemetryEnabled then Telemetry.logWithContext "completionHint" tHint [("inputLen", Stdlib.Int.toString (Stdlib.String.length state.prompt.text)); ("cached", Stdlib.Bool.toString hintCached)] - let promptOutput = - Prompt.Display.formatPromptWithInput location state.prompt.text hint - // Fall back to 80 if the size query returns 0 (can happen at startup) β€” never divide by 0 below. - let terminalWidth = - let w = Terminal.getWidth () - if w <= 0 then 80 else w - // Visual length (0-based char count; inputStartColumn is 1-based so subtract 1) - let visualLength = - (inputStartColumn - 1) + (Stdlib.String.length state.prompt.text) + (Stdlib.String.length hint) - let renderedRows = - if visualLength <= terminalWidth then 1 - else (Stdlib.Int.divide (visualLength - 1) terminalWidth) + 1 - // Display current page let tRender = if state.telemetryEnabled then Telemetry.now () else 0 - match state.currentPage with - | MainPrompt -> - Stdlib.print "\u001b[?25l" // Hide cursor - - // Move up to prompt start if previous render occupied multiple rows - // Skip on full redraw β€” cursor is already positioned after command output - if Stdlib.Bool.not state.needsFullRedraw then - if state.previousRenderedRows > 1 then - Stdlib.print (Colors.moveCursorUp (state.previousRenderedRows - 1)) - - Stdlib.print Colors.carriageReturn - Stdlib.print promptOutput - Stdlib.print "\u001b[J" // Clear any leftover rows - - // Position cursor at correct location within user input - let absoluteCursorPos = (inputStartColumn - 1) + state.prompt.cursorPosition - if renderedRows > 1 then - let cursorRow = Stdlib.Int.divide absoluteCursorPos terminalWidth - let cursorCol = (absoluteCursorPos % terminalWidth) + 1 - let textEndRow = renderedRows - 1 - let rowDiff = textEndRow - cursorRow - if rowDiff > 0 then - Stdlib.print (Colors.moveCursorUp rowDiff) - Stdlib.print (Colors.moveCursorToColumn cursorCol) - else - Stdlib.print (Colors.moveCursorToColumn (absoluteCursorPos + 1)) - - Stdlib.print "\u001b[?25h" // Show cursor - - | InteractiveNav navState -> - // Always display in interactive nav mode - it manages its own screen clearing - Packages.NavInteractive.display navState - - | CompletionPicker pickerState -> - // Display the completion picker - if state.needsFullRedraw then CompletionPicker.display pickerState - - | SubApp app -> - app.onDisplay () - - // Render status bar β€” cache and skip when branch unchanged - let statusBar = - if state.cachedStatusBarBranch == state.currentBranchId && state.cachedStatusBar != "" then - state.cachedStatusBar - else - StatusBar.build state - StatusBar.render statusBar - - if state.telemetryEnabled then + let renderedState = + match state.currentPage with + | MainPrompt -> + let promptView = + Prompt.Display.viewAtSize + state.prompt + location + hint + size + match state.promptTerminal with + | Some terminal -> + let nextTerminal = + Tui.TerminalSession.presentAtSize + terminal + size + promptView + { state with + promptTerminal = + Stdlib.Option.Option.Some nextTerminal } + | None -> + match Tui.TerminalSession.startAtSize size promptView with + | Ok terminal -> + { state with + promptTerminal = + Stdlib.Option.Option.Some terminal } + | Error message -> + Stdlib.printLine message + { state with isExiting = true } + + | InteractiveNav _ -> + // Navigation presents on start and state transitions so its Page + // retains the corresponding renderer history. + state + + | CompletionPicker _ -> + // The retained inline session presents on launch and transitions. + state + + | SubApp app -> + app.onDisplay () + state + + if renderedState.telemetryEnabled then let pageName = - match state.currentPage with + match renderedState.currentPage with | MainPrompt -> "MainPrompt" | InteractiveNav _ -> "InteractiveNav" | CompletionPicker _ -> "CompletionPicker" | SubApp _ -> "SubApp" - Telemetry.logWithContext "render" tRender [("page", pageName); ("fullRedraw", Stdlib.Bool.toString state.needsFullRedraw)] + Telemetry.logWithContext "render" tRender [("page", pageName)] Telemetry.log "preInput" tLoopStart // Read keystroke input - let tReadKey = if state.telemetryEnabled then Telemetry.now () else 0 + let tReadKey = + if renderedState.telemetryEnabled then Telemetry.now () else 0 let keyInput = Stdlib.Cli.Stdin.readKey () // Process the key let keyPressedMsg = Msg.KeyPressed (keyInput.key, keyInput.modifiers, Stdlib.Option.Option.Some keyInput.keyChar) - let newState = Update.updateAppState state keyPressedMsg + let newState = + Update.updateAppState renderedState keyPressedMsg - if state.telemetryEnabled then + if renderedState.telemetryEnabled then Telemetry.logWithContext "readKey" tReadKey [("char", keyInput.keyChar)] let tUpdate = Telemetry.now () Telemetry.logWithContext "update" tUpdate [("char", keyInput.keyChar)] @@ -849,7 +825,11 @@ let runInteractiveLoop (state: AppState) : Int = let interpStats = Builtin.interpreterStatsGet () Telemetry.logWithContext "loopWork" tLoopStart [("workMs", Stdlib.Int.toString loopWorkMs); ("interp", interpStats)] - runInteractiveLoop { newState with previousRenderedRows = renderedRows; cachedHintInput = state.prompt.text; cachedHintValue = hint; cachedStatusBar = statusBar; cachedStatusBarBranch = state.currentBranchId } + runInteractiveLoop { + newState with + cachedHintInput = renderedState.prompt.text + cachedHintValue = hint + } /// Extract the branch flag from args, returning (Some branchId, remainingArgs) when it's present and @@ -928,12 +908,6 @@ let executeCliCommand (args: List) : Int = let _ = Builtin.interpreterStatsReset () () - // Clear screen so leftover output from a previous session doesn't show through - Stdlib.print Terminal.Display.clearScreen - - // Initialize status bar (sets up scroll region to reserve bottom line) - StatusBar.init () - Stdlib.printLine (View.formatWelcome ()) runInteractiveLoop initialState // Otherwise, execute command directly (shell already split args correctly) @@ -944,7 +918,5 @@ let executeCliCommand (args: List) : Int = // If the command launched a full-screen SubApp (a TUI like `apps` / `outliner`), hand off to the // interactive loop so it actually runs, instead of exiting the instant the command returns. match resultState.currentPage with - | SubApp _ -> - StatusBar.init () - runInteractiveLoop resultState + | SubApp _ -> runInteractiveLoop resultState | _ -> 0 diff --git a/packages/darklang/cli/outliner/app.dark b/packages/darklang/cli/outliner/app.dark index ab90f171a4..39b01c1898 100644 --- a/packages/darklang/cli/outliner/app.dark +++ b/packages/darklang/cli/outliner/app.dark @@ -1,25 +1,83 @@ module Darklang.Cli.Outliner.App -/// Wrap Main state into a generic SubApp for the CLI host. -let makeSubApp (state: Main.State) : Darklang.Cli.SubApp = +type Session = + { state: Main.State + terminal: Darklang.Cli.Tui.TerminalSession.State + shouldSave: Bool } + +let presentState + (session: Session) + (state: Main.State) + (shouldSave: Bool) + : Session = + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + let terminal = + Darklang.Cli.Tui.TerminalSession.presentAtSize + session.terminal + size + (Main.viewAtSize state size) + Session { + state = state + terminal = terminal + shouldSave = shouldSave + } + +/// Wrap a retained outliner session for the CLI host. +let makeSubApp (session: Session) : Darklang.Cli.SubApp = Darklang.Cli.SubApp { onKey = fun key modifiers keyChar -> - match Main.handleKey state key modifiers keyChar with - | Continue s -> (Darklang.Cli.SubAppAction.Continue, makeSubApp s) - | Save s -> (Darklang.Cli.SubAppAction.Save, makeSubApp s) - | Exit s -> (Darklang.Cli.SubAppAction.Exit, makeSubApp s) - onDisplay = fun () -> Main.render state - onSave = fun () -> Main.Persistence.save state } + match Main.handleKey session.state key modifiers keyChar with + | Continue state -> + let next = presentState session state false + (Darklang.Cli.SubAppAction.Continue, makeSubApp next) + | Save state -> + let next = presentState session state true + (Darklang.Cli.SubAppAction.Save, makeSubApp next) + | Exit state -> + let next = + Session { + state = state + terminal = session.terminal + // Leaving the picker does not change persisted documents. + shouldSave = false + } + (Darklang.Cli.SubAppAction.Exit, makeSubApp next) + // The session presents on launch and state transitions. + onDisplay = fun () -> () + onSave = + fun () -> + if session.shouldSave then + Main.Persistence.save session.state + else + () + onTerminate = + fun () -> + let _stopped = + Darklang.Cli.Tui.TerminalSession.stop session.terminal + () } /// CLI command: enter the outliner. let execute (cliState: Darklang.Cli.AppState) (_args: List) : Darklang.Cli.AppState = - Stdlib.print "\u001b[?1049h" let state = Main.Persistence.load () - let app = makeSubApp state - { cliState with - currentPage = Darklang.Cli.Page.SubApp app - needsFullRedraw = true } + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + match + Darklang.Cli.Tui.TerminalSession.startAtSize + size + (Main.viewAtSize state size) + with + | Ok terminal -> + let session = + Session { + state = state + terminal = terminal + shouldSave = false + } + { cliState with + currentPage = Darklang.Cli.Page.SubApp (makeSubApp session) } + | Error message -> + Stdlib.printLine message + cliState /// CLI help text. diff --git a/packages/darklang/cli/outliner/list-picker.dark b/packages/darklang/cli/outliner/list-picker.dark index 4ef756c790..42ddae66cc 100644 --- a/packages/darklang/cli/outliner/list-picker.dark +++ b/packages/darklang/cli/outliner/list-picker.dark @@ -3,7 +3,9 @@ module Darklang.Cli.Outliner.ListPicker type State<'item> = { items: List<'item> selected: Int - label: 'item -> String } + /// Prepared display labels retained as data; long-lived CLI state must not + /// retain anonymous lambda bodies. + labels: List } type Result<'item> = | Browsing of State<'item> @@ -11,7 +13,15 @@ type Result<'item> = | Cancelled let make (items: List<'item>) (label: 'item -> String) : State<'item> = - State { items = items; selected = 0; label = label } + State { + items = items + selected = 0 + labels = Stdlib.List.map items label + } + +let labelAt (state: State<'item>) (index: Int) : String = + Stdlib.List.getAt state.labels index + |> Stdlib.Option.withDefault "" let handleKey (state: State<'item>) @@ -39,32 +49,19 @@ let handleKey | None -> Result.Cancelled | _ -> Result.Browsing state -/// Render the list into a layout region. -let renderInRegion (state: State<'item>) (region: Darklang.Cli.UI.Layout.Region) : Unit = - let itemCount = Stdlib.List.length state.items +/// Build a bounded logical viewport for the picker. +let viewRows (state: State<'item>) (height: Int) : List = let selectedIdx = state.selected - let scrollOffset = - if selectedIdx >= region.rows then - Darklang.Cli.Terminal.clampScrollOffset - (selectedIdx - region.rows + 1) - itemCount - region.rows - else - 0 - state.items - |> Stdlib.List.indexedMap (fun idx item -> (idx, item)) - |> Stdlib.List.drop scrollOffset - |> Stdlib.List.take region.rows - |> Stdlib.List.iter (fun pair -> - let (idx, item) = pair - let row = idx - scrollOffset - let isSelected = idx == selectedIdx - let text = state.label item - let prefix = if isSelected then ">" else " " - let line = $" {prefix} {text}" - let styled = + let rows = + state.items + |> Stdlib.List.indexedMap (fun idx _item -> + let isSelected = idx == selectedIdx + let text = labelAt state idx + let prefix = if isSelected then ">" else " " + let line = $" {prefix} {text}" if isSelected then Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line else - line - Darklang.Cli.UI.Layout.printAt region row 0 styled) + line) + + Darklang.Cli.Tui.Layout.viewport rows selectedIdx height diff --git a/packages/darklang/cli/outliner/main.dark b/packages/darklang/cli/outliner/main.dark index 8a30255bf4..1280c1cd36 100644 --- a/packages/darklang/cli/outliner/main.dark +++ b/packages/darklang/cli/outliner/main.dark @@ -207,101 +207,155 @@ let handleKey // --- Rendering --- -let renderTitleBar (titleText: String) (modeText: String) (modeColor: String) (region: Darklang.Cli.UI.Layout.Region) : Unit = - let title = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white ++ Darklang.Cli.Colors.bold) $" {titleText} " - let modeIndicator = Darklang.Cli.Colors.colorize modeColor modeText - Darklang.Cli.UI.Layout.printAt region 0 0 (title ++ " " ++ modeIndicator) - -let renderHelpBar (helpText: String) (region: Darklang.Cli.UI.Layout.Region) : Unit = - let helpLen = Stdlib.String.length helpText +let titleRow (titleText: String) (modeIndicator: String) : String = + let title = + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.purpleBg + ++ Darklang.Cli.Colors.white + ++ Darklang.Cli.Colors.bold) + $" {titleText} " + title ++ " " ++ modeIndicator + +let helpRow (width: Int) (helpText: String) : String = + let clipped = + Darklang.Cli.Tui.Text.clipToWidth helpText width + let helpWidth = + Darklang.Cli.Tui.Text.displayWidth + (Darklang.Cli.Tui.Text.stripSgr clipped) let helpPadding = - if region.cols > helpLen then Stdlib.String.repeat " " (region.cols - helpLen) + if width > helpWidth then Stdlib.String.repeat " " (width - helpWidth) else "" - let styled = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.grayBg ++ Darklang.Cli.Colors.white) (helpText ++ helpPadding) - Darklang.Cli.UI.Layout.printAt region 0 0 styled - -let renderChrome (titleText: String) (modeText: String) (modeColor: String) (helpText: String) (renderBody: Darklang.Cli.UI.Layout.Region -> Unit) : Unit = - let termHeight = Darklang.Cli.Terminal.getHeight () - let termWidth = Darklang.Cli.Terminal.getWidth () - Stdlib.print "\u001b[?25l\u001b[2J\u001b[H" - - let screen = - Darklang.Cli.UI.Layout.Region { top = 1; left = 1; rows = termHeight - 1; cols = termWidth } - - let titleComp = - Darklang.Cli.UI.Layout.fixedSize 2 (fun region -> renderTitleBar titleText modeText modeColor region) - let bodyComp = - Darklang.Cli.UI.Layout.greedy renderBody - let helpComp = - Darklang.Cli.UI.Layout.fixedSize 1 (fun region -> renderHelpBar helpText region) - let components = [titleComp; bodyComp; helpComp] - Darklang.Cli.UI.Layout.vstack screen components - Stdlib.print "\u001b[?25h" - -let render (state: State) : Unit = - match state.screen with - | DocPicker picker -> - renderChrome - "Outliner" - " DOCUMENTS " - (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white) - "Up/Down: select | Enter: open | n: new | d: delete | r: rename | e: export | Esc: exit" - (fun region -> ListPicker.renderInRegion picker region) - - | DocRenaming (_docId, picker, te) -> - let renameIndicator = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.greenBg ++ Darklang.Cli.Colors.black) " RENAME " - renderChrome - "Outliner" - (" DOCUMENTS " ++ " " ++ renameIndicator) - (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white) - "Type to rename | Enter/Esc: finish" - (fun region -> - picker.items - |> Stdlib.List.indexedMap (fun idx item -> (idx, item)) - |> Stdlib.List.iter (fun pair -> - let (idx, item) = pair - let isSelected = idx == picker.selected - let displayText = - match Stdlib.List.getAt picker.items idx with - | Some doc -> - if doc.id == _docId then TextEditor.renderInline te - else picker.label item - | None -> picker.label item - let prefix = if isSelected then ">" else " " - let line = $" {prefix} {displayText}" - let styled = - if isSelected then - Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line - else - line - Darklang.Cli.UI.Layout.printAt region idx 0 styled)) + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.grayBg ++ Darklang.Cli.Colors.white) + (clipped ++ helpPadding) - | Editor editor -> - let doc = activeDoc state - OutlineEditor.render editor doc.title +let renameRows + (docId: Int) + (picker: ListPicker.State) + (editor: TextEditor.State) + (height: Int) + : List = + let rows = + picker.items + |> Stdlib.List.indexedMap (fun idx item -> + let isSelected = idx == picker.selected + let displayText = + if item.id == docId then + TextEditor.renderInline editor + else + ListPicker.labelAt picker idx + let prefix = if isSelected then ">" else " " + let line = $" {prefix} {displayText}" + if isSelected then + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line + else + line) - | ExportPicker picker -> - let doc = activeDoc state - renderChrome - $"Export: {doc.title}" - " EXPORT " - (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white) - "Up/Down: select | Enter: choose | Esc: cancel" - (fun region -> ListPicker.renderInRegion picker region) + Darklang.Cli.Tui.Layout.viewport rows picker.selected height - | ExportPath (ext, te) -> - let doc = activeDoc state - let formatName = - if ext == ".opml" then "OPML" else "Markdown" - renderChrome - $"Export: {doc.title}" +/// Construct the complete outliner view without performing terminal I/O. +let viewAtSize + (state: State) + (size: Darklang.Cli.Tui.Size) + : Darklang.Cli.Tui.View = + let width = Stdlib.Int.max 1 size.width + let contentHeight = Stdlib.Int.max 1 size.height + let titleHeight = if contentHeight >= 3 then 2 else 1 + let helpHeight = if contentHeight >= 2 then 1 else 0 + let bodyHeight = + Stdlib.Int.max 0 (contentHeight - titleHeight - helpHeight) + + let documentsMode = + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white) + " DOCUMENTS " + let exportMode = + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white) " EXPORT " - (Darklang.Cli.Colors.greenBg ++ Darklang.Cli.Colors.black) - "Edit path | Enter: export | Esc: cancel" - (fun region -> - let label = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim " Path: " - Darklang.Cli.UI.Layout.printAt region 0 0 $" Format: {formatName}" - Darklang.Cli.UI.Layout.printAt region 2 0 (label ++ (TextEditor.renderInline te))) + + let (titleText, modeIndicator, helpText, bodyRows) = + match state.screen with + | DocPicker picker -> + ( "Outliner" + , documentsMode + , "Up/Down: select | Enter: open | n: new | d: delete | r: rename | e: export | Esc: exit" + , ListPicker.viewRows picker bodyHeight ) + + | DocRenaming (docId, picker, editor) -> + let renameMode = + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.greenBg ++ Darklang.Cli.Colors.black) + " RENAME " + ( "Outliner" + , documentsMode ++ " " ++ renameMode + , "Type to rename | Enter/Esc: finish" + , renameRows docId picker editor bodyHeight ) + + | Editor editor -> + let doc = activeDoc state + let isEditing = + match editor.editing with + | Some _ -> true + | None -> false + let modeText = if isEditing then " EDIT " else " NAVIGATE " + let modeColor = + if isEditing then + Darklang.Cli.Colors.greenBg ++ Darklang.Cli.Colors.black + else + Darklang.Cli.Colors.grayBg ++ Darklang.Cli.Colors.white + let editorHelp = + if isEditing then + "Type to edit | Left/Right: move cursor | Enter/Esc: finish editing" + else + "Up/Down: move | Enter: edit | o: new | Tab/S-Tab: indent | Space: collapse | C-Up/Down: reorder | Esc: back" + ( doc.title + , Darklang.Cli.Colors.colorize modeColor modeText + , editorHelp + , OutlineEditor.viewRows editor bodyHeight ) + + | ExportPicker picker -> + let doc = activeDoc state + ( $"Export: {doc.title}" + , exportMode + , "Up/Down: select | Enter: choose | Esc: cancel" + , ListPicker.viewRows picker bodyHeight ) + + | ExportPath (ext, editor) -> + let doc = activeDoc state + let formatName = if ext == ".opml" then "OPML" else "Markdown" + let pathLabel = + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim " Path: " + let greenExportMode = + Darklang.Cli.Colors.colorize + (Darklang.Cli.Colors.greenBg ++ Darklang.Cli.Colors.black) + " EXPORT " + ( $"Export: {doc.title}" + , greenExportMode + , "Edit path | Enter: export | Esc: cancel" + , Darklang.Cli.Tui.Layout.fitRows + bodyHeight + [ $" Format: {formatName}" + "" + pathLabel ++ TextEditor.renderInline editor ] ) + + let titleRows = + Darklang.Cli.Tui.Layout.fitRows + titleHeight + [ titleRow titleText modeIndicator ] + let helpRows = + if helpHeight == 0 then [] else [ helpRow width helpText ] + let rows = + Stdlib.List.flatten [ titleRows; bodyRows; helpRows ] + |> Stdlib.List.take contentHeight + |> Stdlib.List.map (fun row -> + Darklang.Cli.Tui.Text.clipToWidth row width) + + Darklang.Cli.Tui.View { + rows = rows + cursor = Darklang.Cli.Tui.Cursor.Hidden + mode = Darklang.Cli.Tui.ViewMode.Fullscreen + } // --- Persistence --- diff --git a/packages/darklang/cli/outliner/next-steps.md b/packages/darklang/cli/outliner/next-steps.md index 88ee951a86..e696010821 100644 --- a/packages/darklang/cli/outliner/next-steps.md +++ b/packages/darklang/cli/outliner/next-steps.md @@ -15,9 +15,11 @@ The outliner is composed of reusable components following a nested TEA pattern: Each component has its own State and Result types. The compositor holds the active child's state in a Screen enum and delegates keys/rendering to it. -Layout negotiation (`Darklang.Cli.UI.Layout`) handles space distribution: -components declare size requests, the parent distributes rows proportionally -via `vstack`, and each child renders into its assigned Region. +Each child returns logical rows. `Darklang.Cli.Tui.Layout.viewport` bounds long +document and outline lists while keeping their selected row visible; the main +view composes title, body, and help rows and clips them to Unicode terminal +width. `Darklang.Cli.Tui.TerminalSession` retains and presents the resulting +complete view. See `read-me/outliner-app-architecture.md` for the full design. @@ -64,13 +66,8 @@ See `read-me/outliner-app-architecture.md` for the full design. - Headings, notes/annotations, links, tags -## Next: Layout System (`Darklang.Cli.UI.Layout`) +## Next: Layout System ### HStack - Horizontal stacking for side-by-side views (e.g. doc picker + editor) -- Column distribution analogous to row distribution in vstack - -### View Data -- Components return view data instead of printing directly -- Enables testing, composition, and split views -- See architecture doc "Later: multi-view composition" section +- Add pure column distribution to `Darklang.Cli.Tui.Layout` diff --git a/packages/darklang/cli/outliner/outline-editor.dark b/packages/darklang/cli/outliner/outline-editor.dark index 827177f7b6..256af9ddac 100644 --- a/packages/darklang/cli/outliner/outline-editor.dark +++ b/packages/darklang/cli/outliner/outline-editor.dark @@ -249,110 +249,45 @@ let handleKey // --- Rendering --- -/// Render the tree body into a layout region. -let renderBody (state: State) (region: Darklang.Cli.UI.Layout.Region) : Unit = +/// Fit the outline into `height` rows while keeping the selected item visible. +let viewRows (state: State) (height: Int) : List = let flat = flattenVisible state.outline - let totalItems = Stdlib.List.length flat let isEditing = match state.editing with | Some _ -> true | None -> false - let scrollOffset = - if state.cursor < 0 then - 0 - else if state.cursor >= region.rows then - Darklang.Cli.Terminal.clampScrollOffset - (state.cursor - region.rows + 1) - totalItems - region.rows - else - 0 - - let visibleItems = + let rows = flat - |> Stdlib.List.drop scrollOffset - |> Stdlib.List.take region.rows - - visibleItems - |> Stdlib.List.indexedMap (fun relIdx vn -> (relIdx, vn)) - |> Stdlib.List.iter (fun pair -> - let (relIdx, vn) = pair - let absoluteIdx = scrollOffset + relIdx - let isSelected = absoluteIdx == state.cursor - let indent = Stdlib.String.repeat " " vn.depth - let bullet = - if vn.isCollapsed && vn.hasChildren then - Darklang.Cli.Colors.colorize Darklang.Cli.Colors.yellow ">" - else if vn.hasChildren then - Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim "-" - else - Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim "*" - let displayText = - match state.editing with - | Some edState -> - if isSelected then TextEditor.renderInline edState - else vn.text - | None -> vn.text - let collapseHint = - if vn.isCollapsed && vn.childCount > 0 then - let countStr = Stdlib.Int.toString vn.childCount - Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim $" ({countStr} hidden)" - else - "" - let line = $"{indent}{bullet} {displayText}{collapseHint}" - let styled = + |> Stdlib.List.indexedMap (fun idx vn -> + let isSelected = idx == state.cursor + let indent = Stdlib.String.repeat " " vn.depth + let bullet = + if vn.isCollapsed && vn.hasChildren then + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.yellow ">" + else if vn.hasChildren then + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim "-" + else + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim "*" + let displayText = + match state.editing with + | Some edState -> + if isSelected then TextEditor.renderInline edState + else vn.text + | None -> vn.text + let collapseHint = + if vn.isCollapsed && vn.childCount > 0 then + let countStr = Stdlib.Int.toString vn.childCount + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim $" ({countStr} hidden)" + else + "" + let line = $"{indent}{bullet} {displayText}{collapseHint}" if isSelected then if isEditing then Darklang.Cli.Colors.colorize Darklang.Cli.Colors.green line else Darklang.Cli.Colors.colorize Darklang.Cli.Colors.selection line else - line - Darklang.Cli.UI.Layout.printAt region relIdx 0 styled) - -let renderTitleBar (title: String) (isEditing: Bool) (region: Darklang.Cli.UI.Layout.Region) : Unit = - let titleText = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.purpleBg ++ Darklang.Cli.Colors.white ++ Darklang.Cli.Colors.bold) $" {title} " - let modeText = - if isEditing then " EDIT " else " NAVIGATE " - let modeColor = - if isEditing then Darklang.Cli.Colors.greenBg ++ Darklang.Cli.Colors.black - else Darklang.Cli.Colors.grayBg ++ Darklang.Cli.Colors.white - let modeIndicator = Darklang.Cli.Colors.colorize modeColor modeText - Darklang.Cli.UI.Layout.printAt region 0 0 (titleText ++ " " ++ modeIndicator) - -let renderHelpBar (helpText: String) (region: Darklang.Cli.UI.Layout.Region) : Unit = - let helpLen = Stdlib.String.length helpText - let helpPadding = - if region.cols > helpLen then Stdlib.String.repeat " " (region.cols - helpLen) - else "" - let styled = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.grayBg ++ Darklang.Cli.Colors.white) (helpText ++ helpPadding) - Darklang.Cli.UI.Layout.printAt region 0 0 styled - -let render (state: State) (title: String) : Unit = - let termHeight = Darklang.Cli.Terminal.getHeight () - let termWidth = Darklang.Cli.Terminal.getWidth () - - Stdlib.print "\u001b[?25l\u001b[2J\u001b[H" - - let screen = - Darklang.Cli.UI.Layout.Region { top = 1; left = 1; rows = termHeight - 1; cols = termWidth } - - let isEditing = - match state.editing with - | Some _ -> true - | None -> false - - let helpText = - if isEditing then "Type to edit | Left/Right: move cursor | Enter/Esc: finish editing" - else "Up/Down: move | Enter: edit | o: new | Tab/S-Tab: indent | Space: collapse | C-Up/Down: reorder | Esc: back" + line) - let titleComp = - Darklang.Cli.UI.Layout.fixedSize 2 (fun region -> renderTitleBar title isEditing region) - let bodyComp = - Darklang.Cli.UI.Layout.greedy (fun region -> renderBody state region) - let helpComp = - Darklang.Cli.UI.Layout.fixedSize 1 (fun region -> renderHelpBar helpText region) - let components = [titleComp; bodyComp; helpComp] - Darklang.Cli.UI.Layout.vstack screen components - Stdlib.print "\u001b[?25h" + Darklang.Cli.Tui.Layout.viewport rows state.cursor height diff --git a/packages/darklang/cli/packages/back.dark b/packages/darklang/cli/packages/back.dark index 2297d203db..7cd74c3508 100644 --- a/packages/darklang/cli/packages/back.dark +++ b/packages/darklang/cli/packages/back.dark @@ -9,7 +9,6 @@ let execute (state: AppState) (args: List) : AppState = Stdlib.printLine (Colors.success ("Back to: " ++ pathStr)) { state with - needsFullRedraw = true prompt = Prompt.Editing.clear state.prompt packageData = { state.packageData with diff --git a/packages/darklang/cli/packages/display.dark b/packages/darklang/cli/packages/display.dark index 56a09989ea..892c4f3866 100644 --- a/packages/darklang/cli/packages/display.dark +++ b/packages/darklang/cli/packages/display.dark @@ -4,8 +4,8 @@ module Darklang.Cli.Packages.Display let getIcon (entityType: EntityType) : String = match entityType with | Module -> "πŸ—‚οΈ" - | Function -> "⚑" - | Type -> "🏷️" + | Function -> "⚑️" + | Type -> "πŸ”–" | Value -> "πŸ’Ž" @@ -13,8 +13,8 @@ let getIcon (entityType: EntityType) : String = let getSectionHeader (entityType: String) : String = match entityType with | "module" -> "πŸ—‚οΈ Modules:" - | "function" -> "⚑ Functions:" - | "type" -> "🏷️ Types:" + | "function" -> "⚑️ Functions:" + | "type" -> "πŸ”– Types:" | "value" -> "πŸ’Ž Values:" | "submodule" -> "πŸ—‚οΈ Submodules:" | _ -> entityType ++ ":" diff --git a/packages/darklang/cli/packages/nav.dark b/packages/darklang/cli/packages/nav.dark index 2475d59916..ca7c7caf30 100644 --- a/packages/darklang/cli/packages/nav.dark +++ b/packages/darklang/cli/packages/nav.dark @@ -11,7 +11,6 @@ let navTo (state: AppState) (location: PackageLocation): AppState = // Update state with new location and history { state with - needsFullRedraw = true prompt = Prompt.Editing.clear state.prompt packageData = { state.packageData with @@ -24,13 +23,16 @@ let execute (state: AppState) (args: List) : AppState = match args with | [] -> // Enter interactive navigation mode - NavInteractive.enterAlternateScreen () let branchId = state.currentBranchId let navState = NavInteractive.buildState branchId state.packageData.currentLocation - { state with - currentPage = Page.InteractiveNav navState - needsFullRedraw = true - prompt = Prompt.Editing.clear state.prompt } + match NavInteractive.start navState with + | Ok session -> + { state with + currentPage = Page.InteractiveNav session + prompt = Prompt.Editing.clear state.prompt } + | Error message -> + Stdlib.printLine message + state | [pathArg] -> // Use traverse to handle the path navigation diff --git a/packages/darklang/cli/packages/navInteractive.dark b/packages/darklang/cli/packages/navInteractive.dark index 5f8a82fc34..67c379992d 100644 --- a/packages/darklang/cli/packages/navInteractive.dark +++ b/packages/darklang/cli/packages/navInteractive.dark @@ -1,12 +1,5 @@ module Darklang.Cli.Packages.NavInteractive -// Terminal screen management -let enterAlternateScreen () : Unit = - Stdlib.print "\u001b[?1049h" // Switch to alternate screen buffer - -let exitAlternateScreen () : Unit = - Stdlib.print "\u001b[?1049l" // Switch back to main screen buffer - // Interactive navigation types type NavItem = { name: String @@ -31,9 +24,111 @@ type State = display: Display searchQuery: String allItems: List + /// Prepared detail rows for the selected item while focus mode is active. + /// Package reads happen during state transitions, never in `view`. + focusRows: List + /// First logical row visible in the independently scrollable preview. + focusScrollOffset: Int branchId: Uuid } +/// A running navigation model and its process-local presentation state. +type Session = + { state: State + terminal: Darklang.Cli.Tui.TerminalSession.State } + + +/// Navigation regions derived from one terminal-size sample. +type Layout = + { width: Int + contentHeight: Int + bodyHeight: Int + helpHeight: Int + itemViewportHeight: Int + focusHeight: Int + focusContentHeight: Int + sideBySide: Bool + listWidth: Int + focusWidth: Int } + + +let helpSections (mode: Mode) (display: Display) : List = + match mode, display with + | Nav, JustName -> + [ "↑↓ Select" + "← Parent" + "β†’ Open module" + "Space View" + "/ Search" + "Enter Choose" + "Esc Close" ] + | Nav, Source -> + [ "↑↓ Select" + "← Parent" + "β†’ Open module" + "Space Hide view" + "PgUp/PgDn Preview" + "Esc Close" ] + | Search, JustName -> + [ "Type to search" + "↑↓ Select" + "Space View" + "Enter Choose" + "Esc Back" ] + | Search, Source -> + [ "Type to search" + "↑↓ Select" + "Space Hide view" + "PgUp/PgDn Preview" + "Esc Back" ] + + +let packHelpRows + (width: Int) + (sections: List) + (current: String) + (rows: List) + : List = + match sections with + | [] -> + if current == "" then rows + else Stdlib.List.pushBack rows current + | section :: remaining -> + let candidate = + if current == "" then section + else current ++ " β€’ " ++ section + + if Darklang.Cli.Tui.Text.displayWidth candidate <= width then + packHelpRows width remaining candidate rows + else if current != "" then + packHelpRows + width + sections + "" + (Stdlib.List.pushBack rows current) + else + let wrapped = + Darklang.Cli.Tui.Text.wrapStyled section width + packHelpRows + width + remaining + "" + (Stdlib.List.append rows wrapped) + + +/// Fit navigation help into complete rows without truncating an action. +let helpRowsAtWidth + (mode: Mode) + (display: Display) + (width: Int) + : List = + packHelpRows + (Stdlib.Int.max 1 width) + (helpSections mode display) + "" + [] + + // Helper functions let modulePathOf (location: PackageLocation) : List = match location with @@ -88,10 +183,15 @@ let buildState (branchId: Uuid) (location: PackageLocation) : State = display = Display.JustName searchQuery = "" allItems = allItems + focusRows = [] + focusScrollOffset = 0 branchId = branchId } -// Truncated view for inline display to avoid flooding screen with large modules -let viewEntityTruncated (branchId: Uuid) (location: PackageLocation) : Unit = +// Load truncated focus rows without printing them. +let loadFocusRows + (branchId: Uuid) + (location: PackageLocation) + : List = let maxItemsPerCategory = 3 let maxTotalLines = 12 @@ -99,56 +199,175 @@ let viewEntityTruncated (branchId: Uuid) (location: PackageLocation) : Unit = | Module path -> let results = Query.allDirectDescendants branchId path let locationStr = Packages.formatLocation location - Stdlib.printLine locationStr let currentPathLength = Stdlib.List.length path let directSubmodules = Query.getDirectSubmodules results currentPathLength - let lineCount = 1 // Start with location line - let lineCount = + let (submoduleRows, lineCount) = if Stdlib.Bool.not (Stdlib.List.isEmpty directSubmodules) then - Stdlib.printLine (Display.getSectionHeader "submodule") let itemsToShow = Stdlib.List.take directSubmodules maxItemsPerCategory - itemsToShow |> Stdlib.List.iter (fun name -> Stdlib.printLine $" {name}/") let remaining = (Stdlib.List.length directSubmodules) - maxItemsPerCategory - if remaining > 0 then - Stdlib.printLine $" ... and {Stdlib.Int.toString remaining} more modules" - lineCount + 1 + (Stdlib.List.length itemsToShow) + (if remaining > 0 then 1 else 0) + let remainingRows = + if remaining > 0 then + [ $" ... and {Stdlib.Int.toString remaining} more modules" ] + else + [] + + let rows = + Stdlib.List.flatten [ + [ Display.getSectionHeader "submodule" ] + itemsToShow |> Stdlib.List.map (fun name -> $" {name}/") + remainingRows + ] + + (rows, 1 + Stdlib.List.length rows) + else + ([], 1) + + let typeRows = + if lineCount < maxTotalLines then + let remainingLines = maxTotalLines - lineCount + let itemsPerRemaining = Stdlib.Int.min 2 remainingLines + + if Stdlib.Bool.not (Stdlib.List.isEmpty results.types) && itemsPerRemaining > 0 then + let typesToShow = Stdlib.List.take results.types itemsPerRemaining + + let remaining = (Stdlib.List.length results.types) - itemsPerRemaining + let remainingRows = + if remaining > 0 then + [ $" ... and {Stdlib.Int.toString remaining} more types" ] + else + [] + + Stdlib.List.flatten [ + [ Display.getSectionHeader "type" ] + typesToShow |> Stdlib.List.map (fun item -> $" {item.location.name}") + remainingRows + ] + else + [] else - lineCount + [] + + Stdlib.List.flatten [ [ locationStr ]; submoduleRows; typeRows ] + | _ -> + // Individual entities are typically small enough to show in full. + View.entityRows branchId location - // Only show other categories if we haven't exceeded line limit - if lineCount < maxTotalLines then - let remainingLines = maxTotalLines - lineCount - let itemsPerRemaining = Stdlib.Int.min 2 remainingLines - // Show types (limited) - if Stdlib.Bool.not (Stdlib.List.isEmpty results.types) && itemsPerRemaining > 0 then - Stdlib.printLine (Display.getSectionHeader "type") - let typesToShow = Stdlib.List.take results.types itemsPerRemaining - typesToShow |> Stdlib.List.iter (fun t -> Stdlib.printLine $" {t.location.name}") +/// Refresh derived focus content after a state transition. +let refreshFocusRows (navState: State) : State = + match navState.display with + | JustName -> + { navState with + focusRows = [] + focusScrollOffset = 0 } + | Source -> + let rows = + match Stdlib.List.getAt navState.items navState.selectedIndex with + | Some selectedItem -> + loadFocusRows navState.branchId selectedItem.location + | None -> [] - let remaining = (Stdlib.List.length results.types) - itemsPerRemaining - if remaining > 0 then - Stdlib.printLine $" ... and {Stdlib.Int.toString remaining} more types" + { navState with + focusRows = rows + focusScrollOffset = 0 } - | _ -> - // For individual entities, show full view since they're typically small - View.viewEntity branchId location -// Display the interactive navigation interface -let display (navState: State) : Unit = - // Clear alternate screen and show header (main screen is preserved) - Stdlib.print "\u001b[?25l\u001b[2J\u001b[H" +/// Calculate navigation regions for the complete terminal viewport. +let layout + (navState: State) + (size: Darklang.Cli.Tui.Size) + : Layout = + let width = Stdlib.Int.max 1 size.width + let contentHeight = Stdlib.Int.max 1 size.height + let headerHeight = + match navState.mode with + | Nav -> 2 + | Search -> 4 + + let helpHeight = + helpRowsAtWidth navState.mode navState.display width + |> Stdlib.List.length + + let hasFocus = + navState.display == Display.Source + && Stdlib.Bool.not (Stdlib.List.isEmpty navState.focusRows) + + let availableBodyHeight = + Stdlib.Int.max + 0 + (contentHeight - headerHeight - helpHeight) + + let sideBySide = + hasFocus && width >= 120 && availableBodyHeight >= 5 + + let bodyHeight = + availableBodyHeight - (if sideBySide then 1 else 0) + + let desiredFocusHeight = + if hasFocus then Stdlib.List.length navState.focusRows + 1 else 0 + + let maximumStackedFocusHeight = + Stdlib.Int.divide (bodyHeight * 60) 100 + + let focusHeight = + if sideBySide then + bodyHeight + else + Stdlib.Int.min desiredFocusHeight maximumStackedFocusHeight + + let itemViewportHeight = + if sideBySide then bodyHeight else bodyHeight - focusHeight + + let listWidth = + if sideBySide then + Stdlib.Int.max + 30 + (Stdlib.Int.divide ((width - 1) * 2) 5) + else + width + + let focusWidth = + if sideBySide then width - listWidth - 1 else width + + Layout { + width = width + contentHeight = contentHeight + bodyHeight = bodyHeight + helpHeight = helpHeight + itemViewportHeight = itemViewportHeight + focusHeight = focusHeight + focusContentHeight = Stdlib.Int.max 0 (focusHeight - 1) + sideBySide = sideBySide + listWidth = listWidth + focusWidth = focusWidth + } + + +/// Build the complete desired navigation view for one terminal-size sample. +let viewAtSize + (navState: State) + (size: Darklang.Cli.Tui.Size) + : Darklang.Cli.Tui.View = + let regions = layout navState size + let clip = fun text -> Darklang.Cli.Tui.Text.clipToWidth text regions.width + let itemClip = + fun text -> + Darklang.Cli.Tui.Text.clipToWidth text regions.listWidth + let focusClip = + fun text -> + Darklang.Cli.Tui.Text.clipToWidth text regions.focusWidth + let horizontalRule (line: String) : String = + Stdlib.String.repeat line regions.width let locationStr = Packages.formatLocation navState.currentLocation - // Mode-specific header let headerPrefix = match navState.mode with - | Nav -> "πŸ“" + | Nav -> "πŸ—‚οΈ" | Search -> "πŸ” Search from" let displaySuffix = @@ -156,77 +375,257 @@ let display (navState: State) : Unit = | JustName -> "" | Source -> " (Focus Mode)" - match navState.mode with - | Nav -> - Stdlib.printLine (Colors.boldText $"{headerPrefix} {locationStr}{displaySuffix}") - Stdlib.printLine (Stdlib.String.repeat "─" 60) - | Search -> - Stdlib.printLine (Colors.boldText $"{headerPrefix} {locationStr}{displaySuffix}") - Stdlib.printLine (Stdlib.String.repeat "━" 60) - Stdlib.printLine $" {navState.searchQuery}_" - Stdlib.printLine (Stdlib.String.repeat "━" 60) - - // Calculate viewport - let viewportHeight = 12 + let headerRows = + match navState.mode with + | Nav -> + [ Colors.boldText (clip $"{headerPrefix} {locationStr}{displaySuffix}") + horizontalRule "─" ] + | Search -> + [ Colors.boldText (clip $"{headerPrefix} {locationStr}{displaySuffix}") + horizontalRule "━" + clip $" {navState.searchQuery}_" + horizontalRule "━" ] + let totalItems = (Stdlib.List.length navState.items) - if totalItems == 0 then - Stdlib.printLine " (empty directory)" - else - // Calculate visible items - let startIndex = navState.scrollOffset - let endIndex = Stdlib.Int.min (startIndex + viewportHeight) totalItems + let itemRows = + if regions.itemViewportHeight <= 0 then + [] + else if totalItems == 0 then + [ " (empty directory)" ] + else + let maxOffset = + Stdlib.Int.max 0 (totalItems - regions.itemViewportHeight) + let desiredOffset = + if navState.selectedIndex < navState.scrollOffset then + navState.selectedIndex + else if + navState.selectedIndex + >= navState.scrollOffset + regions.itemViewportHeight + then + navState.selectedIndex - regions.itemViewportHeight + 1 + else + navState.scrollOffset + let startIndex = + Stdlib.Int.min maxOffset (Stdlib.Int.max 0 desiredOffset) + let endIndex = + Stdlib.Int.min + (startIndex + regions.itemViewportHeight) + totalItems - let visibleItems = navState.items |> Stdlib.List.drop startIndex |> Stdlib.List.take (endIndex - startIndex) + |> Stdlib.List.indexedMap (fun relativeIndex item -> + let absoluteIndex = startIndex + relativeIndex + let isSelected = absoluteIndex == navState.selectedIndex + + let icon = Display.getIcon item.entityType + let cursor = if isSelected then "> " else " " + let nameDisplay = + match item.entityType with + | Module -> item.name ++ "/" + | _ -> item.name + + let line = + match item.entityType with + | Module -> + if isSelected then + Colors.info cursor + ++ icon + ++ Colors.info (" " ++ nameDisplay) + else + cursor ++ icon ++ " " ++ nameDisplay + | _ -> + let line = cursor ++ icon ++ " " ++ nameDisplay + if isSelected then Colors.info line else line + + itemClip line) + + let focusRows = + if regions.focusHeight <= 0 then + [] + else + let totalRows = Stdlib.List.length navState.focusRows + let maxOffset = + Stdlib.Int.max 0 (totalRows - regions.focusContentHeight) + let offset = + Stdlib.Int.min + maxOffset + (Stdlib.Int.max 0 navState.focusScrollOffset) + let visibleRows = + navState.focusRows + |> Stdlib.List.drop offset + |> Stdlib.List.take regions.focusContentHeight + |> Stdlib.List.map focusClip + let startRow = if totalRows == 0 then 0 else offset + 1 + let endRow = + Stdlib.Int.min totalRows (offset + Stdlib.List.length visibleRows) + let position = + if totalRows > regions.focusContentHeight then + let startText = Stdlib.Int.toString startRow + let endText = Stdlib.Int.toString endRow + let totalText = Stdlib.Int.toString totalRows + $"Preview {startText}-{endText} of {totalText} β€’ PgUp/PgDn scroll" + else + $"Preview β€’ {Stdlib.Int.toString totalRows} rows" + let heading = + if regions.sideBySide then + Colors.hint (focusClip position) + else + Colors.hint (focusClip ("─ " ++ position)) + Stdlib.List.push visibleRows heading + + let helpRows = + let styledRows = + helpRowsAtWidth + navState.mode + navState.display + regions.width + |> Stdlib.List.map Colors.hint + Darklang.Cli.Tui.Layout.fitRows regions.helpHeight styledRows + + let fittedItemRows = + Darklang.Cli.Tui.Layout.fitRows + regions.itemViewportHeight + itemRows + + let fittedFocusRows = + Darklang.Cli.Tui.Layout.fitRows + regions.focusHeight + focusRows + + let bodyRows = + if regions.sideBySide then + Stdlib.List.map2shortest + fittedItemRows + fittedFocusRows + (fun itemRow focusRow -> + Darklang.Cli.Tui.Text.fitToWidth + itemRow + regions.listWidth + ++ Colors.dimText "β”‚" + ++ focusClip (" " ++ focusRow)) + else + Stdlib.List.append fittedItemRows fittedFocusRows + + let footerRuleRows = + if regions.sideBySide then [ horizontalRule "─" ] else [] + + let rows = + let desiredRows = + Stdlib.List.flatten [ + headerRows + bodyRows + footerRuleRows + helpRows + ] + Darklang.Cli.Tui.Layout.fitRows regions.contentHeight desiredRows + + Darklang.Cli.Tui.View { + rows = rows + cursor = Darklang.Cli.Tui.Cursor.Hidden + mode = Darklang.Cli.Tui.ViewMode.Fullscreen + } + + +/// Start navigation and present its first complete frame. +let start + (navState: State) + : Stdlib.Result.Result = + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + match + Darklang.Cli.Tui.TerminalSession.startAtSize + size + (viewAtSize navState size) + with + | Ok terminal -> + Session { state = navState; terminal = terminal } + |> Stdlib.Result.Result.Ok + | Error message -> + Stdlib.Result.Result.Error message + + +/// Present the next navigation model using an already sampled terminal size. +let presentAtSize + (session: Session) + (size: Darklang.Cli.Tui.Size) + (navState: State) + : Session = + let terminal = + Darklang.Cli.Tui.TerminalSession.presentAtSize + session.terminal + size + (viewAtSize navState size) + + Session { state = navState; terminal = terminal } + - // Display items - visibleItems - |> Stdlib.List.indexedMap (fun relativeIndex item -> - let absoluteIndex = startIndex + relativeIndex - let isSelected = absoluteIndex == navState.selectedIndex +/// Present the next navigation model while retaining renderer history. +let presentState (session: Session) (navState: State) : Session = + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + presentAtSize session size navState - let icon = Display.getIcon item.entityType - let cursor = if isSelected then "> " else " " - let nameDisplay = - match item.entityType with - | Module -> item.name ++ "/" - | _ -> item.name - let line = cursor ++ icon ++ " " ++ nameDisplay +/// Put a presented navigation model back into the CLI application state. +let show + (state: Cli.AppState) + (session: Session) + (navState: State) + : Cli.AppState = + { state with + currentPage = Page.InteractiveNav (presentState session navState) } + - if isSelected then - Stdlib.printLine (Colors.info line) +/// Put a navigation model presented at an existing size sample into CLI state. +let showAtSize + (state: Cli.AppState) + (session: Session) + (size: Darklang.Cli.Tui.Size) + (navState: State) + : Cli.AppState = + { state with + currentPage = + Page.InteractiveNav (presentAtSize session size navState) } + + +let scrollOffsetForSelection + (selectedIndex: Int) + (scrollOffset: Int) + (viewportHeight: Int) + (totalItems: Int) + : Int = + if viewportHeight <= 0 || totalItems <= 0 then + 0 + else + let offset = + if selectedIndex < scrollOffset then + selectedIndex + else if selectedIndex >= scrollOffset + viewportHeight then + selectedIndex - viewportHeight + 1 else - Stdlib.printLine line - ) - |> Stdlib.List.iter (fun _ -> ()) + scrollOffset + + let maxOffset = Stdlib.Int.max 0 (totalItems - viewportHeight) + Stdlib.Int.min maxOffset (Stdlib.Int.max 0 offset) + + +let withFocusScrollOffset + (navState: State) + (focusContentHeight: Int) + (offset: Int) + : State = + let maxOffset = + Stdlib.Int.max + 0 + (Stdlib.List.length navState.focusRows - focusContentHeight) + { navState with + focusScrollOffset = + Stdlib.Int.min maxOffset (Stdlib.Int.max 0 offset) } - // Show inline view and actions if displaying source and there are items - match navState.display with - | Source -> - if totalItems > 0 then - match Stdlib.List.getAt navState.items navState.selectedIndex with - | Some selectedItem -> - Stdlib.printLines [ ""; (Stdlib.String.repeat "─" 60) ] - viewEntityTruncated navState.branchId selectedItem.location - Stdlib.printLines [ ""; (Stdlib.String.repeat "─" 60) ] - | None -> () - | JustName -> () - - Stdlib.printLine "" - let helpText = - match (navState.mode, navState.display) with - | (Nav, JustName) -> "↑/↓: Navigate β€’ ←: Up β€’ β†’: Enter β€’ Enter: Select β€’ Space: Focus β€’ /: Search β€’ Esc: Exit" - | (Nav, Source) -> "u: Update β€’ β†’: Navigate/View β€’ ←: Up β€’ Space: Unfocus β€’ Esc: Back" - | (Search, JustName) -> "Type to search β€’ ↑/↓: Navigate β€’ Enter: Select β€’ Space: Focus β€’ Esc: Exit" - | (Search, Source) -> "Type to search β€’ u: Update β€’ β†’: Navigate/View β€’ Space: Unfocus β€’ Esc: Back" - Stdlib.printLine (Colors.hint helpText) // Navigation helper functions -let moveUp (navState: State) : State = +let moveUp (navState: State) (viewportHeight: Int) : State = let totalItems = (Stdlib.List.length navState.items) if totalItems > 0 then let newIndex = @@ -236,18 +635,20 @@ let moveUp (navState: State) : State = totalItems - 1 let newScrollOffset = - if newIndex < navState.scrollOffset then + scrollOffsetForSelection newIndex - else navState.scrollOffset + viewportHeight + totalItems { navState with selectedIndex = newIndex scrollOffset = newScrollOffset } + |> refreshFocusRows else navState -let moveDown (navState: State) : State = +let moveDown (navState: State) (viewportHeight: Int) : State = let totalItems = (Stdlib.List.length navState.items) if totalItems > 0 then let newIndex = @@ -256,45 +657,60 @@ let moveDown (navState: State) : State = else 0 - let viewportHeight = 12 let newScrollOffset = - if newIndex >= navState.scrollOffset + viewportHeight then - newIndex - viewportHeight + 1 - else + scrollOffsetForSelection + newIndex navState.scrollOffset + viewportHeight + totalItems { navState with selectedIndex = newIndex scrollOffset = newScrollOffset } + |> refreshFocusRows else navState -let exitToMainPrompt (state: Cli.AppState) : Cli.AppState = - exitAlternateScreen () +let exitToMainPrompt + (state: Cli.AppState) + (session: Session) + : Cli.AppState = + let _stoppedTerminal = + Darklang.Cli.Tui.TerminalSession.stop session.terminal + { state with currentPage = Page.MainPrompt - needsFullRedraw = true prompt = Prompt.Editing.clear state.prompt } -let selectAndExit (state: Cli.AppState) (navState: State) : Cli.AppState = +let selectAndExit + (state: Cli.AppState) + (session: Session) + : Cli.AppState = + let navState = session.state let totalItems = (Stdlib.List.length navState.items) + if totalItems > 0 then match Stdlib.List.getAt navState.items navState.selectedIndex with | Some selectedItem -> - exitAlternateScreen () + let _stoppedTerminal = + Darklang.Cli.Tui.TerminalSession.stop session.terminal let newState = Nav.navTo state selectedItem.location let locationStr = Packages.formatLocation selectedItem.location Stdlib.printLine (Colors.success $"Selected: {locationStr}") { newState with currentPage = Page.MainPrompt - needsFullRedraw = true prompt = Prompt.Editing.clear newState.prompt } - | None -> exitToMainPrompt state + | None -> exitToMainPrompt state session else - exitToMainPrompt state + exitToMainPrompt state session -let navigateIntoModule (state: Cli.AppState) (navState: State) : Cli.AppState = +let navigateIntoModule + (state: Cli.AppState) + (session: Session) + : Cli.AppState = + let navState = session.state let totalItems = (Stdlib.List.length navState.items) + if totalItems > 0 then match Stdlib.List.getAt navState.items navState.selectedIndex with | Some selectedItem -> @@ -302,18 +718,23 @@ let navigateIntoModule (state: Cli.AppState) (navState: State) : Cli.AppState = | Module -> let newState = Nav.navTo state selectedItem.location let newNavState = buildState navState.branchId selectedItem.location - { newState with currentPage = Page.InteractiveNav newNavState } + show newState session newNavState | _ -> state | None -> state else state -let navigateToParent (state: Cli.AppState) (navState: State) : Cli.AppState = +let navigateToParent + (state: Cli.AppState) + (session: Session) + : Cli.AppState = + let navState = session.state + match Traversal.applySegment navState.branchId navState.currentLocation Traversal.PathSegment.Up with | Ok parentLocation -> let newState = Nav.navTo state parentLocation let newNavState = buildState navState.branchId parentLocation - { newState with currentPage = Page.InteractiveNav newNavState } + show newState session newNavState | Error _ -> state // Helper for building full paths in search results @@ -330,6 +751,7 @@ let performSearch (navState: State) : State = items = navState.allItems selectedIndex = 0 scrollOffset = 0 } + |> refreshFocusRows else // Search through package system let currentModule = modulePathOf navState.currentLocation @@ -375,12 +797,17 @@ let performSearch (navState: State) : State = items = searchItems selectedIndex = 0 scrollOffset = 0 } + |> refreshFocusRows // Common key handlers -let handleNavKeys (navState: State) (key: Stdlib.Cli.Stdin.Key.Key) : Stdlib.Option.Option = +let handleNavKeys + (navState: State) + (key: Stdlib.Cli.Stdin.Key.Key) + (viewportHeight: Int) + : Stdlib.Option.Option = match key with - | UpArrow -> Stdlib.Option.Option.Some (moveUp navState) - | DownArrow -> Stdlib.Option.Option.Some (moveDown navState) + | UpArrow -> Stdlib.Option.Option.Some (moveUp navState viewportHeight) + | DownArrow -> Stdlib.Option.Option.Some (moveDown navState viewportHeight) | _ -> Stdlib.Option.Option.None let toggleDisplay (navState: State) : State = @@ -389,14 +816,17 @@ let toggleDisplay (navState: State) : State = | JustName -> Display.Source | Source -> Display.JustName { navState with display = newDisplay } + |> refreshFocusRows // Mode-specific key handlers let handleSearchKeys (state: Cli.AppState) (key: Stdlib.Cli.Stdin.Key.Key) (keyChar: Stdlib.Option.Option) - (navState: State) + (session: Session) : Stdlib.Option.Option = + let navState = session.state + match key with | Escape -> let newNavState = @@ -406,7 +836,8 @@ let handleSearchKeys items = navState.allItems selectedIndex = 0 scrollOffset = 0 } - Stdlib.Option.Option.Some( { state with currentPage = Page.InteractiveNav newNavState }) + |> refreshFocusRows + Stdlib.Option.Option.Some (show state session newNavState) | Backspace -> let newQuery = @@ -416,14 +847,14 @@ let handleSearchKeys "" let newNavState = { navState with searchQuery = newQuery } let searchedNavState = performSearch newNavState - Stdlib.Option.Option.Some ({ state with currentPage = Page.InteractiveNav searchedNavState }) + Stdlib.Option.Option.Some (show state session searchedNavState) | Spacebar -> let newNavState = toggleDisplay navState - Stdlib.Option.Option.Some ({ state with currentPage = Page.InteractiveNav newNavState }) + Stdlib.Option.Option.Some (show state session newNavState) | Enter -> - Stdlib.Option.Option.Some (selectAndExit state navState) + Stdlib.Option.Option.Some (selectAndExit state session) | _ -> match keyChar with @@ -431,26 +862,28 @@ let handleSearchKeys let newQuery = navState.searchQuery ++ char let newNavState = { navState with searchQuery = newQuery } let searchedNavState = performSearch newNavState - Stdlib.Option.Option.Some ({ state with currentPage = Page.InteractiveNav searchedNavState }) + Stdlib.Option.Option.Some (show state session searchedNavState) | None -> Stdlib.Option.Option.None let handleNavModeKeys (state: Cli.AppState) (key: Stdlib.Cli.Stdin.Key.Key) (keyChar: Stdlib.Option.Option) - (navState: State) + (session: Session) : Stdlib.Option.Option = + let navState = session.state + match key with | Spacebar -> let newNavState = toggleDisplay navState - Stdlib.Option.Option.Some({ state with currentPage = Page.InteractiveNav newNavState }) + Stdlib.Option.Option.Some (show state session newNavState) - | RightArrow -> Stdlib.Option.Option.Some (navigateIntoModule state navState) - | LeftArrow -> Stdlib.Option.Option.Some (navigateToParent state navState) + | RightArrow -> Stdlib.Option.Option.Some (navigateIntoModule state session) + | LeftArrow -> Stdlib.Option.Option.Some (navigateToParent state session) - | Enter -> Stdlib.Option.Option.Some (selectAndExit state navState) + | Enter -> Stdlib.Option.Option.Some (selectAndExit state session) - | Escape -> Stdlib.Option.Option.Some (exitToMainPrompt state) + | Escape -> Stdlib.Option.Option.Some (exitToMainPrompt state session) | U -> match navState.display with @@ -459,10 +892,10 @@ let handleNavModeKeys if totalItems > 0 then match Stdlib.List.getAt navState.items navState.selectedIndex with | Some selectedItem -> - exitAlternateScreen () let locationStr = Packages.formatLocation selectedItem.location + let exitedState = exitToMainPrompt state session Stdlib.printLine (Colors.info $"Update mode for {locationStr} (coming soon)") - Stdlib.Option.Option.Some (exitToMainPrompt state) + Stdlib.Option.Option.Some exitedState | None -> Stdlib.Option.Option.None else Stdlib.Option.Option.None @@ -472,23 +905,63 @@ let handleNavModeKeys match keyChar with | Some "/" -> let newNavState = { navState with mode = Mode.Search } - Stdlib.Option.Option.Some({ state with currentPage = Page.InteractiveNav newNavState }) + Stdlib.Option.Option.Some (show state session newNavState) | _ -> Stdlib.Option.Option.None -// Handle key input for interactive navigation -let handleKey (state: Cli.AppState) (key: Stdlib.Cli.Stdin.Key.Key) (keyChar: Stdlib.Option.Option) (navState: State) : Cli.AppState = - // Try common navigation keys first - match handleNavKeys navState key with +// Handle key input for interactive navigation. +let handleKey + (state: Cli.AppState) + (key: Stdlib.Cli.Stdin.Key.Key) + (keyChar: Stdlib.Option.Option) + (session: Session) + : Cli.AppState = + let navState = session.state + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + let regions = layout navState size + let viewportHeight = regions.itemViewportHeight + let previewPageSize = + Stdlib.Int.max 1 (regions.focusContentHeight - 1) + + let previewResult = + match navState.display, key with + | Source, PageUp -> + Stdlib.Option.Option.Some + (withFocusScrollOffset + navState + regions.focusContentHeight + (navState.focusScrollOffset - previewPageSize)) + | Source, PageDown -> + Stdlib.Option.Option.Some + (withFocusScrollOffset + navState + regions.focusContentHeight + (navState.focusScrollOffset + previewPageSize)) + | Source, Home -> + Stdlib.Option.Option.Some + (withFocusScrollOffset navState regions.focusContentHeight 0) + | Source, End -> + Stdlib.Option.Option.Some + (withFocusScrollOffset + navState + regions.focusContentHeight + (Stdlib.List.length navState.focusRows)) + | _, _ -> Stdlib.Option.Option.None + + match previewResult with | Some newNavState -> - { state with currentPage = Page.InteractiveNav newNavState } + showAtSize state session size newNavState | None -> - // Try mode-specific handlers - let modeResult = - match navState.mode with - | Search -> handleSearchKeys state key keyChar navState - | Nav -> handleNavModeKeys state key keyChar navState - - match modeResult with - | Some newState -> newState - | None -> state - + // Try common item-navigation keys next. + match handleNavKeys navState key viewportHeight with + | Some newNavState -> + showAtSize state session size newNavState + | None -> + // Try mode-specific handlers. + let modeResult = + match navState.mode with + | Search -> handleSearchKeys state key keyChar session + | Nav -> handleNavModeKeys state key keyChar session + + match modeResult with + | Some newState -> newState + | None -> state diff --git a/packages/darklang/cli/packages/search.dark b/packages/darklang/cli/packages/search.dark index 05e29b2e21..0b133b23e6 100644 --- a/packages/darklang/cli/packages/search.dark +++ b/packages/darklang/cli/packages/search.dark @@ -217,13 +217,16 @@ let execute (state: Cli.AppState) (args: List) : Cli.AppState = match args with | [] -> // Enter interactive search mode (nav with search enabled) - NavInteractive.enterAlternateScreen () let navState = NavInteractive.buildState state.currentBranchId state.packageData.currentLocation let searchNavState = { navState with mode = NavInteractive.Mode.Search } - { state with - currentPage = Page.InteractiveNav searchNavState - needsFullRedraw = true - prompt = Prompt.Editing.clear state.prompt } + match NavInteractive.start searchNavState with + | Ok session -> + { state with + currentPage = Page.InteractiveNav session + prompt = Prompt.Editing.clear state.prompt } + | Error message -> + Stdlib.printLine message + state | _ -> let parsed = parseArgs args diff --git a/packages/darklang/cli/packages/view.dark b/packages/darklang/cli/packages/view.dark index 507fb33b2d..575aca8636 100644 --- a/packages/darklang/cli/packages/view.dark +++ b/packages/darklang/cli/packages/view.dark @@ -22,14 +22,14 @@ let resolveReplacementName $"" -/// Print a deprecation header for an item, if any. -let printDeprecationHeaderFor +/// Build a deprecation header for an item, if any. +let deprecationHeaderRowsFor (branchId: Uuid) (hash: LanguageTools.ProgramTypes.Hash) (itemKind: LanguageTools.ProgramTypes.ItemKind) - : Unit = + : List = match Builtin.pmGetCurrentDeprecation branchId hash itemKind with - | None -> () + | None -> [] | Some tuple -> let (kind, message) = tuple let (header, replacementLine) = @@ -44,76 +44,104 @@ let printDeprecationHeaderFor | Obsolete -> ( "DEPRECATED (obsolete)" , Stdlib.Option.Option.None ) - Stdlib.printLine (Colors.warning $"⚠ {header}") - match replacementLine with - | Some line -> Stdlib.printLine (Colors.hint line) - | None -> () - if message != "" then - Stdlib.printLine (Colors.dimText $" {message}") - Stdlib.printLine "" + let replacementRows = + match replacementLine with + | Some line -> [ Colors.hint line ] + | None -> [] -let viewEntity (branchId: Uuid) (location: PackageLocation) : Unit = + let messageRows = + if message != "" then + [ Colors.dimText $" {message}" ] + else + [] + + Stdlib.List.flatten [ + [ Colors.warning $"⚠ {header}" ] + replacementRows + messageRows + [ "" ] + ] + + +/// Split rendered source into logical terminal rows. +let sourceRows (source: String) : List = + if source == "" then [ "" ] else Stdlib.String.split source "\n" + + +/// Build the complete textual representation of an entity without printing it. +let entityRows (branchId: Uuid) (location: PackageLocation) : List = match location with | Module path -> - // Display module contents (similar to list but with more detail) let results = Query.allDirectDescendants branchId path let locationStr = Packages.formatLocation location - Stdlib.printLine locationStr - Stdlib.printLine (Stdlib.String.repeat "=" (Stdlib.String.length locationStr)) - Stdlib.printLine "" - - // Display submodules let currentPathLength = Stdlib.List.length path let directSubmodules = Query.getDirectSubmodules results currentPathLength - // Display in order: modules, types, values, functions - if Stdlib.Bool.not (Stdlib.List.isEmpty directSubmodules) then - Stdlib.printLine (Display.getSectionHeader "submodule") - directSubmodules - |> Stdlib.List.iter (fun name -> Stdlib.printLine $" {name}/") - Stdlib.printLine "" - - // Display types - if Stdlib.Bool.not (Stdlib.List.isEmpty results.types) then - Stdlib.printLine (Display.getSectionHeader "type") - results.types - |> Stdlib.List.iter (fun item -> - Stdlib.printLine $" {item.location.name}") - Stdlib.printLine "" - - // Display values - if Stdlib.Bool.not (Stdlib.List.isEmpty results.values) then - Stdlib.printLine (Display.getSectionHeader "value") - results.values - |> Stdlib.List.iter (fun item -> - Stdlib.printLine $" {item.location.name}") - Stdlib.printLine "" - - // Display functions - if Stdlib.Bool.not (Stdlib.List.isEmpty results.fns) then - Stdlib.printLine (Display.getSectionHeader "function") - results.fns - |> Stdlib.List.iter (fun item -> - Stdlib.printLine $" {item.location.name}") - Stdlib.printLine "" + let submoduleRows = + if Stdlib.List.isEmpty directSubmodules then + [] + else + Stdlib.List.flatten [ + [ Display.getSectionHeader "submodule" ] + directSubmodules + |> Stdlib.List.map (fun name -> $" {name}/") + [ "" ] + ] + + let typeRows = + if Stdlib.List.isEmpty results.types then + [] + else + Stdlib.List.flatten [ + [ Display.getSectionHeader "type" ] + results.types + |> Stdlib.List.map (fun item -> $" {item.location.name}") + [ "" ] + ] + + let valueRows = + if Stdlib.List.isEmpty results.values then + [] + else + Stdlib.List.flatten [ + [ Display.getSectionHeader "value" ] + results.values + |> Stdlib.List.map (fun item -> $" {item.location.name}") + [ "" ] + ] + + let functionRows = + if Stdlib.List.isEmpty results.fns then + [] + else + Stdlib.List.flatten [ + [ Display.getSectionHeader "function" ] + results.fns + |> Stdlib.List.map (fun item -> $" {item.location.name}") + [ "" ] + ] + + Stdlib.List.flatten [ + [ locationStr + Stdlib.String.repeat "=" (Stdlib.String.length locationStr) + "" ] + submoduleRows + typeRows + valueRows + functionRows + ] | Type name -> - // Find and display the specific type let modulePath = Stdlib.List.append [name.owner] name.modules let results = Query.searchExactMatch branchId modulePath name.name - // Access the first type directly from results.types match results.types with | [] -> let locationStr = Packages.formatLocation location - Stdlib.printLine $"Type '{locationStr}' not found." + [ $"Type '{locationStr}' not found." ] | item :: _ -> - printDeprecationHeaderFor - branchId - item.entity.hash - LanguageTools.ProgramTypes.ItemKind.Type let ctx = PrettyPrinter.ProgramTypes.Context { branchId = branchId @@ -121,23 +149,24 @@ let viewEntity (branchId: Uuid) (location: PackageLocation) : Unit = currentFunction = Stdlib.Option.Option.None } let prettyPrinted = PrettyPrinter.ProgramTypes.packageType ctx item.entity let highlighted = SyntaxHighlighting.highlightCode prettyPrinted - Stdlib.printLine highlighted + + Stdlib.List.flatten [ + deprecationHeaderRowsFor + branchId + item.entity.hash + LanguageTools.ProgramTypes.ItemKind.Type + sourceRows highlighted + ] | Function name -> - // Find and display the specific function let modulePath = Stdlib.List.append [name.owner] name.modules let results = Query.searchExactMatch branchId modulePath name.name - // Access the first function directly from results.fns match results.fns with | [] -> let locationStr = Packages.formatLocation location - Stdlib.printLine $"Function '{locationStr}' not found." + [ $"Function '{locationStr}' not found." ] | item :: _ -> - printDeprecationHeaderFor - branchId - item.entity.hash - LanguageTools.ProgramTypes.ItemKind.Fn let ctx = PrettyPrinter.ProgramTypes.Context { branchId = branchId @@ -145,28 +174,28 @@ let viewEntity (branchId: Uuid) (location: PackageLocation) : Unit = currentFunction = Stdlib.Option.Option.None } let prettyPrinted = PrettyPrinter.ProgramTypes.packageFn ctx item.entity let highlighted = SyntaxHighlighting.highlightCode prettyPrinted - Stdlib.printLine highlighted - // the capabilities this fn (transitively) needs β€” what it would be allowed to do if run let capsBadge = Caps.Command.badgeForHash (LanguageTools.ProgramTypes.hashToString item.entity.hash) - Stdlib.printLine (Colors.dimText $"capabilities: {capsBadge}") + + Stdlib.List.flatten [ + deprecationHeaderRowsFor + branchId + item.entity.hash + LanguageTools.ProgramTypes.ItemKind.Fn + sourceRows highlighted + [ Colors.dimText $"capabilities: {capsBadge}" ] + ] | Value name -> - // Find and display the specific value let modulePath = Stdlib.List.append [name.owner] name.modules let results = Query.searchExactMatch branchId modulePath name.name - // Access the first value directly from results.values match results.values with | [] -> let locationStr = Packages.formatLocation location - Stdlib.printLine $"Value '{locationStr}' not found." + [ $"Value '{locationStr}' not found." ] | item :: _ -> - printDeprecationHeaderFor - branchId - item.entity.hash - LanguageTools.ProgramTypes.ItemKind.Value let ctx = PrettyPrinter.ProgramTypes.Context { branchId = branchId @@ -174,7 +203,18 @@ let viewEntity (branchId: Uuid) (location: PackageLocation) : Unit = currentFunction = Stdlib.Option.Option.None } let prettyPrinted = PrettyPrinter.ProgramTypes.packageValue ctx item.entity let highlighted = SyntaxHighlighting.highlightCode prettyPrinted - Stdlib.printLine highlighted + + Stdlib.List.flatten [ + deprecationHeaderRowsFor + branchId + item.entity.hash + LanguageTools.ProgramTypes.ItemKind.Value + sourceRows highlighted + ] + + +let viewEntity (branchId: Uuid) (location: PackageLocation) : Unit = + entityRows branchId location |> Stdlib.printLines // VIEW command - view modules, functions, types, or constants diff --git a/packages/darklang/cli/prompt.dark b/packages/darklang/cli/prompt.dark index 2c2e249b53..982955069d 100644 --- a/packages/darklang/cli/prompt.dark +++ b/packages/darklang/cli/prompt.dark @@ -158,31 +158,59 @@ module History = module Display = + let plainPrefix (locationStr: String) : String = + if Stdlib.String.isEmpty locationStr then + "> " + else + locationStr ++ " > " + + let styledPrefix (locationStr: String) : String = + if Stdlib.String.isEmpty locationStr then + "> " + else + Colors.dimText $"{locationStr} " ++ "> " + /// Format the complete prompt with input and optional hint let formatPromptWithInput (locationStr: String) (text: String) (hint: String) : String = - let formattedLocation = - if Stdlib.String.isEmpty locationStr then - "" - else - Colors.dimText $"{locationStr} " - if Stdlib.String.isEmpty hint then - formattedLocation ++ "> " ++ text + styledPrefix locationStr ++ text else - formattedLocation ++ "> " ++ text ++ (Colors.hint hint) + styledPrefix locationStr ++ text ++ (Colors.hint hint) - /// Calculate the column where user input starts (after location + "> ") - let calculateInputStartColumn (locationStr: String) : Int = - let locationLength = - if Stdlib.String.isEmpty locationStr then + /// Build the complete prompt region and Unicode-correct logical cursor. + let viewAtSize + (state: State) + (locationStr: String) + (hint: String) + (size: Tui.Size) + : Tui.View = + let width = Stdlib.Int.max 1 size.width + let styled = + formatPromptWithInput locationStr state.text hint + let beforeCursor = + Stdlib.String.slice + state.text 0 - else - (Stdlib.String.length locationStr) + 1 // +1 for the space after path - - locationLength - + (Stdlib.String.length "> ") - + 1 + state.cursorPosition + let cursorText = + plainPrefix locationStr ++ beforeCursor + let (cursorRow, cursorColumn) = + Tui.Text.positionAfter cursorText width + let wrapped = Tui.Text.wrapStyled styled width + let requiredHeight = + Stdlib.Int.max + (Stdlib.List.length wrapped) + (cursorRow + 1) + let rows = + Tui.Layout.fitRows requiredHeight wrapped + + Tui.View { + rows = rows + cursor = + Tui.Cursor.Visible(cursorRow, cursorColumn) + mode = Tui.ViewMode.Inline + } diff --git a/packages/darklang/cli/scm/review/app.dark b/packages/darklang/cli/scm/review/app.dark index 2b7ad6dc64..d59bd013b7 100644 --- a/packages/darklang/cli/scm/review/app.dark +++ b/packages/darklang/cli/scm/review/app.dark @@ -405,159 +405,254 @@ let kindLabel (kind: String) : String = | "Value" -> Darklang.Cli.Colors.colorize Darklang.Cli.Colors.orange "val" | other -> Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim other -let printLine (region: Darklang.Cli.UI.Layout.Region) (row: Int) (text: String) : Unit = - Darklang.Cli.UI.Layout.printAt region row 0 (text ++ "\u001b[K") - -let clearRows (region: Darklang.Cli.UI.Layout.Region) (startRow: Int) : Unit = - let rows = Stdlib.List.range startRow (region.rows - 1) - Stdlib.List.iter rows (fun row -> Darklang.Cli.UI.Layout.printAt region row 0 "\u001b[K") - -let renderScrollableList - (region: Darklang.Cli.UI.Layout.Region) +/// Repeat a blank logical row without leaking `List.repeat`'s impossible +/// negative-count error into view construction. +let blankRows (count: Int) : List = + match Stdlib.List.repeat (Stdlib.Int.max 0 count) "" with + | Ok rows -> rows + | Error _ -> [] + +/// Fit a row list to an exact logical height. +let fitRows (height: Int) (rows: List) : List = + let target = Stdlib.Int.max 0 height + let visible = Stdlib.List.take rows target + Stdlib.List.append + visible + (blankRows (target - Stdlib.List.length visible)) + +/// Select the portion of a fixed-height item list that contains `selected`. +let visibleItems (items: List<'a>) (selected: Int) - (startRow: Int) + (availableRows: Int) (rowsPerItem: Int) - (renderItem: Darklang.Cli.UI.Layout.Region -> Int -> 'a -> Bool -> Unit) - : Unit = - let itemCount = Stdlib.List.length items - let listRows = region.rows - startRow - let visibleCount = Stdlib.Int.divide listRows rowsPerItem - let scrollOffset = computeScrollOffset selected visibleCount - items - |> Stdlib.List.indexedMap (fun idx item -> (idx, item)) - |> Stdlib.List.drop scrollOffset - |> Stdlib.List.take visibleCount - |> Stdlib.List.iter (fun pair -> - let (idx, item) = pair - let itemRow = ((idx - scrollOffset) * rowsPerItem) + startRow - let isSelected = idx == selected - renderItem region itemRow item isSelected) - let renderedRows = ((Stdlib.Int.min itemCount visibleCount) * rowsPerItem) + startRow - clearRows region renderedRows - -let renderTitleBar (region: Darklang.Cli.UI.Layout.Region) (modeText: String) : Unit = + : List<(Int * 'a)> = + let visibleCount = + if rowsPerItem <= 0 then 0 + else Stdlib.Int.divide (Stdlib.Int.max 0 availableRows) rowsPerItem + + if visibleCount <= 0 then + [] + else + let scrollOffset = computeScrollOffset selected visibleCount + items + |> Stdlib.List.indexedMap (fun idx item -> (idx, item)) + |> Stdlib.List.drop scrollOffset + |> Stdlib.List.take visibleCount + +let titleRow (modeText: String) : String = let title = Darklang.Cli.Colors.colorize (Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.magenta) "Review changes" let mode = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim modeText - printLine region 0 ($" {title} {mode}") + $" {title} {mode}" -let renderHelpBar (region: Darklang.Cli.UI.Layout.Region) (helpText: String) : Unit = - printLine region 0 (Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim helpText) +let helpRow (helpText: String) : String = + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim helpText -let renderBranchListBody (branchList: BranchListState) (region: Darklang.Cli.UI.Layout.Region) : Unit = +let branchListRows + (branchList: BranchListState) + (width: Int) + (height: Int) + : List = let itemCount = Stdlib.List.length branchList.items - printLine region 0 $" {Darklang.Cli.Colors.bold}branches to review{Darklang.Cli.Colors.reset}" + let heading = + $" {Darklang.Cli.Colors.bold}branches to review{Darklang.Cli.Colors.reset}" + if itemCount == 0 then - printLine region 2 (Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim " No branches with changes to review.") - clearRows region 3 + fitRows height + [ heading + "" + Darklang.Cli.Colors.colorize + Darklang.Cli.Colors.dim + " No branches with changes to review." ] else - let dim = Darklang.Cli.Colors.dim ++ Darklang.Cli.Colors.brightBlack - let hBar = Stdlib.String.repeat "─" (Stdlib.Int.max 0 (region.cols - 5)) - renderScrollableList region branchList.items branchList.selected 2 4 (fun r cardRow item isSelected -> - let borderColor = if isSelected then Darklang.Cli.Colors.cyan else dim - let bar = Darklang.Cli.Colors.colorize borderColor "β”‚" - let marker = if isSelected then Darklang.Cli.Colors.colorize Darklang.Cli.Colors.cyan " > " else " " - let nameColor = if isSelected then Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan else Darklang.Cli.Colors.white - let nameText = Darklang.Cli.Colors.colorize nameColor item.branchName - let textDim = if isSelected then "" else Darklang.Cli.Colors.dim - let countText = formatBranchCounts item.wipCount item.committedCount textDim - printLine r cardRow (Darklang.Cli.Colors.colorize borderColor (" β•­" ++ hBar)) - printLine r (cardRow + 1) $"{marker}{bar} {nameText} {countText}" - printLine r (cardRow + 2) $" {bar}" - printLine r (cardRow + 3) (Darklang.Cli.Colors.colorize borderColor (" β•°" ++ hBar))) - -let renderDetailBody (detail: DetailState) (region: Darklang.Cli.UI.Layout.Region) : Unit = - printLine region 0 $" {Darklang.Cli.Colors.dim}review >{Darklang.Cli.Colors.reset} {Darklang.Cli.Colors.cyan}{detail.branchName}{Darklang.Cli.Colors.reset}" + let dim = Darklang.Cli.Colors.dim ++ Darklang.Cli.Colors.brightBlack + let hBar = Stdlib.String.repeat "─" (Stdlib.Int.max 0 (width - 5)) + let cards = + visibleItems branchList.items branchList.selected (height - 2) 4 + |> Stdlib.List.map (fun (idx, item) -> + let isSelected = idx == branchList.selected + let borderColor = if isSelected then Darklang.Cli.Colors.cyan else dim + let bar = Darklang.Cli.Colors.colorize borderColor "β”‚" + let marker = + if isSelected then + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.cyan " > " + else + " " + let nameColor = + if isSelected then + Darklang.Cli.Colors.bold ++ Darklang.Cli.Colors.cyan + else + Darklang.Cli.Colors.white + let nameText = + Darklang.Cli.Colors.colorize nameColor item.branchName + let textDim = if isSelected then "" else Darklang.Cli.Colors.dim + let countText = + formatBranchCounts item.wipCount item.committedCount textDim + [ Darklang.Cli.Colors.colorize borderColor (" β•­" ++ hBar) + $"{marker}{bar} {nameText} {countText}" + $" {bar}" + Darklang.Cli.Colors.colorize borderColor (" β•°" ++ hBar) ]) + |> Stdlib.List.flatten + + fitRows height (Stdlib.List.append [ heading; "" ] cards) + +let detailRows (detail: DetailState) (height: Int) : List = + let heading = + $" {Darklang.Cli.Colors.dim}review >{Darklang.Cli.Colors.reset} {Darklang.Cli.Colors.cyan}{detail.branchName}{Darklang.Cli.Colors.reset}" let itemCount = Stdlib.List.length detail.flatItems + if itemCount == 0 then - printLine region 2 (Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim " No changes on this branch.") - clearRows region 3 + fitRows height + [ heading + "" + Darklang.Cli.Colors.colorize + Darklang.Cli.Colors.dim + " No changes on this branch." ] else - renderScrollableList region detail.flatItems detail.selected 2 1 (fun r row item isSelected -> - let styled = - match item with - | ModuleHeader modPath -> - let prefix = if isSelected then " > " else " " - $"{prefix}{Darklang.Cli.Colors.bold}{Darklang.Cli.Colors.magenta}{modPath}{Darklang.Cli.Colors.reset}" - | ItemEntry pkg -> - let prefix = if isSelected then " > " else " " - let propText = - if pkg.propagatedCount > 0 then - Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim ($" ({Stdlib.Int.toString pkg.propagatedCount} propagated)") - else - "" - $"{prefix}{kindLabel pkg.kind} {pkg.name}{propText}" - printLine r row styled) - -let renderDiffBody (diffState: DiffState) (region: Darklang.Cli.UI.Layout.Region) : Unit = + let items = + visibleItems detail.flatItems detail.selected (height - 2) 1 + |> Stdlib.List.map (fun (idx, item) -> + let isSelected = idx == detail.selected + match item with + | ModuleHeader modPath -> + let prefix = if isSelected then " > " else " " + $"{prefix}{Darklang.Cli.Colors.bold}{Darklang.Cli.Colors.magenta}{modPath}{Darklang.Cli.Colors.reset}" + | ItemEntry pkg -> + let prefix = if isSelected then " > " else " " + let propText = + if pkg.propagatedCount > 0 then + Darklang.Cli.Colors.colorize + Darklang.Cli.Colors.dim + ($" ({Stdlib.Int.toString pkg.propagatedCount} propagated)") + else + "" + $"{prefix}{kindLabel pkg.kind} {pkg.name}{propText}") + + fitRows height (Stdlib.List.append [ heading; "" ] items) + +let diffRows (diffState: DiffState) (height: Int) : List = let branchName = match diffState.returnTo with | ReturnToDetail detail -> detail.branchName | ReturnToCommits commitList -> commitList.branchName - printLine region 0 $" {Darklang.Cli.Colors.dim}review > {branchName} >{Darklang.Cli.Colors.reset} {Darklang.Cli.Colors.cyan}{diffState.itemName}{Darklang.Cli.Colors.reset}" - let listRows = region.rows - 2 - let lineCount = Stdlib.List.length diffState.diffLines - diffState.diffLines - |> Stdlib.List.indexedMap (fun idx line -> (idx, line)) - |> Stdlib.List.drop diffState.scrollOffset - |> Stdlib.List.take listRows - |> Stdlib.List.iter (fun pair -> - let (idx, diffLine) = pair - let row = (idx - diffState.scrollOffset) + 2 - let styled = + + let heading = + $" {Darklang.Cli.Colors.dim}review > {branchName} >{Darklang.Cli.Colors.reset} {Darklang.Cli.Colors.cyan}{diffState.itemName}{Darklang.Cli.Colors.reset}" + + let lines = + diffState.diffLines + |> Stdlib.List.drop diffState.scrollOffset + |> Stdlib.List.take (height - 2) + |> Stdlib.List.map (fun diffLine -> match diffLine with | Added text -> Darklang.Cli.Colors.colorize Darklang.Cli.Colors.green (" + " ++ text) | Removed text -> Darklang.Cli.Colors.colorize Darklang.Cli.Colors.red (" - " ++ text) - | Same text -> " " ++ text - printLine region row styled) - clearRows region ((Stdlib.Int.min lineCount listRows) + 2) + | Same text -> " " ++ text) + + fitRows height (Stdlib.List.append [ heading; "" ] lines) -let renderCommitListBody (commitList: CommitListState) (region: Darklang.Cli.UI.Layout.Region) : Unit = - printLine region 0 $" {Darklang.Cli.Colors.dim}review > {commitList.branchName} >{Darklang.Cli.Colors.reset} {Darklang.Cli.Colors.bold}commits{Darklang.Cli.Colors.reset}" +let commitListRows + (commitList: CommitListState) + (height: Int) + : List = + let heading = + $" {Darklang.Cli.Colors.dim}review > {commitList.branchName} >{Darklang.Cli.Colors.reset} {Darklang.Cli.Colors.bold}commits{Darklang.Cli.Colors.reset}" let itemCount = Stdlib.List.length commitList.commits + if itemCount == 0 then - printLine region 2 (Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim " No commits on this branch.") - clearRows region 3 + fitRows height + [ heading + "" + Darklang.Cli.Colors.colorize + Darklang.Cli.Colors.dim + " No commits on this branch." ] else - renderScrollableList region commitList.commits commitList.selected 2 1 (fun r row entry isSelected -> - let prefix = if isSelected then " > " else " " - let hashText = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.yellow entry.hash - let opsText = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim ($"{Stdlib.Int.toString entry.opCount} ops") - let dateText = Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim entry.date - printLine r row $"{prefix}{hashText} {entry.message} {opsText} {dateText}") - -let render (state: AppModel) : Unit = - let termHeight = Darklang.Cli.Terminal.getHeight () - let termWidth = Darklang.Cli.Terminal.getWidth () - Stdlib.print "\u001b[?25l\u001b[2J\u001b[H" - let screen = - Darklang.Cli.UI.Layout.Region { top = 1; left = 1; rows = termHeight - 1; cols = termWidth } - let (mode, bodyFn, helpText) = + let entries = + visibleItems commitList.commits commitList.selected (height - 2) 1 + |> Stdlib.List.map (fun (idx, entry) -> + let prefix = if idx == commitList.selected then " > " else " " + let hashText = + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.yellow entry.hash + let opsText = + Darklang.Cli.Colors.colorize + Darklang.Cli.Colors.dim + ($"{Stdlib.Int.toString entry.opCount} ops") + let dateText = + Darklang.Cli.Colors.colorize Darklang.Cli.Colors.dim entry.date + $"{prefix}{hashText} {entry.message} {opsText} {dateText}") + + fitRows height (Stdlib.List.append [ heading; "" ] entries) + +/// Build review's complete desired screen without performing terminal I/O. +let viewAtSize + (state: AppModel) + (size: Darklang.Cli.Tui.Size) + : Darklang.Cli.Tui.View = + let width = Stdlib.Int.max 1 size.width + let contentHeight = Stdlib.Int.max 1 size.height + let titleHeight = if contentHeight >= 3 then 2 else 1 + let helpHeight = if contentHeight >= 2 then 1 else 0 + let bodyHeight = + Stdlib.Int.max 0 (contentHeight - titleHeight - helpHeight) + + let (mode, bodyRows, helpText) = match state.screen with | BranchList branchList -> - ("", (fun r -> renderBranchListBody branchList r), "Up/Down: navigate | Right/Enter: open | r: refresh | Left/Esc: exit") + ("", branchListRows branchList width bodyHeight, "Up/Down: navigate | Right/Enter: open | r: refresh | Left/Esc: exit") | Detail detail -> - (" Pending ", (fun r -> renderDetailBody detail r), "Up/Down: navigate | Right/Enter: diff | c: commits | Left/Esc: back") + (" Pending ", detailRows detail bodyHeight, "Up/Down: navigate | Right/Enter: diff | c: commits | Left/Esc: back") | DiffView diffState -> - (" DIFF ", (fun r -> renderDiffBody diffState r), "Up/Down: scroll | Left/Esc: back") + (" DIFF ", diffRows diffState bodyHeight, "Up/Down: scroll | Left/Esc: back") | CommitList commitList -> - (" COMMITS ", (fun r -> renderCommitListBody commitList r), "Up/Down: navigate | Right/Enter: view | Left/Esc: back") - let titleComp = Darklang.Cli.UI.Layout.fixedSize 2 (fun r -> renderTitleBar r mode) - let bodyComp = Darklang.Cli.UI.Layout.greedy bodyFn - let helpComp = Darklang.Cli.UI.Layout.fixedSize 1 (fun r -> renderHelpBar r helpText) - Darklang.Cli.UI.Layout.vstack screen [ titleComp; bodyComp; helpComp ] - Stdlib.print "\u001b[?25h" + (" COMMITS ", commitListRows commitList bodyHeight, "Up/Down: navigate | Right/Enter: view | Left/Esc: back") + + let titleRows = fitRows titleHeight [ titleRow mode ] + let helpRows = if helpHeight == 0 then [] else [ helpRow helpText ] + let rows = + Stdlib.List.flatten [ titleRows; bodyRows; helpRows ] + |> Stdlib.List.take contentHeight + |> Stdlib.List.map (fun row -> + Darklang.Cli.Tui.Text.clipToWidth row width) + + Darklang.Cli.Tui.View { + rows = rows + cursor = Darklang.Cli.Tui.Cursor.Hidden + mode = Darklang.Cli.Tui.ViewMode.Fullscreen + } // ── SubApp wrapper ── -let makeSubApp (state: AppModel) : Darklang.Cli.SubApp = +type Session = + { state: AppModel + terminal: Darklang.Cli.Tui.TerminalSession.State } + +let makeSubApp (session: Session) : Darklang.Cli.SubApp = Darklang.Cli.SubApp { onKey = fun key modifiers keyChar -> - match handleKey state key modifiers keyChar with - | Continue s -> (Darklang.Cli.SubAppAction.Continue, makeSubApp s) - | Exit s -> (Darklang.Cli.SubAppAction.Exit, makeSubApp s) - onDisplay = fun () -> render state - onSave = fun () -> () } + match handleKey session.state key modifiers keyChar with + | Continue nextState -> + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + let nextTerminal = + Darklang.Cli.Tui.TerminalSession.presentAtSize + session.terminal + size + (viewAtSize nextState size) + let nextSession = + Session { state = nextState; terminal = nextTerminal } + (Darklang.Cli.SubAppAction.Continue, makeSubApp nextSession) + | Exit nextState -> + let nextSession = + Session { state = nextState; terminal = session.terminal } + (Darklang.Cli.SubAppAction.Exit, makeSubApp nextSession) + // Presentation happens on launch and state transitions so the returned + // SubApp retains the corresponding renderer history. + onDisplay = fun () -> () + onSave = fun () -> () + onTerminate = + fun () -> + let _stopped = + Darklang.Cli.Tui.TerminalSession.stop session.terminal + () } let buildDetailForBranch (branchId: Uuid) : Screen = let branchName = @@ -650,14 +745,26 @@ let execute (cliState: Darklang.Cli.AppState) (args: List) : Darklang.Cl else printBranchDetail branchId cliState else - Stdlib.print "\u001b[?1049h" let screen = if showAll then Screen.BranchList (BranchListState { items = loadBranchList (); selected = 0 }) else buildDetailForBranch branchId - let app = makeSubApp (AppModel { screen = screen; branchId = branchId; showAll = showAll }) - { cliState with currentPage = Darklang.Cli.Page.SubApp app; needsFullRedraw = true } + let state = + AppModel { screen = screen; branchId = branchId; showAll = showAll } + let size = Darklang.Cli.Tui.TerminalSession.currentSize () + match + Darklang.Cli.Tui.TerminalSession.startAtSize + size + (viewAtSize state size) + with + | Ok terminal -> + let session = Session { state = state; terminal = terminal } + { cliState with + currentPage = Darklang.Cli.Page.SubApp (makeSubApp session) } + | Error message -> + Stdlib.printLine message + cliState let help (_state: Darklang.Cli.AppState) : Darklang.Cli.AppState = [ "Usage: review [--all]" diff --git a/packages/darklang/cli/tests/tests.dark b/packages/darklang/cli/tests/tests.dark index 796140eb8c..2b56c7a5ce 100644 --- a/packages/darklang/cli/tests/tests.dark +++ b/packages/darklang/cli/tests/tests.dark @@ -155,6 +155,81 @@ let testEvalStringExpression (): TestResult = TestResult.Fail $"Expected 'helloworld', got '{output}'" +let completionPickerState (itemCount: Int) : CompletionPicker.State = + let items = + Stdlib.List.range 1 itemCount + |> Stdlib.List.map (fun index -> + Completion.simple $"item-{Stdlib.Int.toString index}") + CompletionPicker.create "" items + + +let testCompletionPickerTwoRows (): TestResult = + let size = Tui.Size { width = 80; height = 2 } + let state = completionPickerState 3 + let layout = CompletionPicker.layoutAtSize state size + let view = CompletionPicker.viewAtSize state size + + match Stdlib.List.last view.rows with + | Some helpRow -> + if layout.itemCapacity == 1 + && Stdlib.Bool.not layout.showScrollIndicator + && Stdlib.List.length view.rows == 2 + && Stdlib.String.contains helpRow "Enter" + then + TestResult.Pass + else + TestResult.Fail + "Two-row picker should show one item and one complete help row" + | None -> + TestResult.Fail "Two-row picker should include a help row" + + +let testCompletionPickerNarrowHelp (): TestResult = + let width = CompletionPicker.minimumWidth + let help = CompletionPicker.helpTextAtWidth width + + if CompletionPicker.supportsSize (Tui.Size { width = width; height = 2 }) + && Stdlib.Bool.not + (CompletionPicker.supportsSize + (Tui.Size { width = width - 1; height = 2 })) + && help == CompletionPicker.minimumHelpText + && Tui.Text.displayWidth help <= width + then + TestResult.Pass + else + TestResult.Fail + "Narrow picker should use an untruncated minimum help row" + + +let testCompletionPickerLongList (): TestResult = + let size = Tui.Size { width = 80; height = 3 } + let state = completionPickerState 5 + let layout = CompletionPicker.layoutAtSize state size + let view = CompletionPicker.viewAtSize state size + + if layout.itemCapacity == 1 + && layout.showScrollIndicator + && Stdlib.List.length view.rows == 3 + then + TestResult.Pass + else + TestResult.Fail + "Long picker should reserve rows for its indicator and help" + + +let testFullscreenRowCancelsDeferredWrap (): TestResult = + let output = + Tui.Frame.renderRowChangeAtWidth + 3 + (Tui.Frame.RowChange { row = 0; content = "abc" }) + + if Stdlib.String.endsWith output "\r" then + TestResult.Pass + else + TestResult.Fail + "Fullscreen rows should return to column one after filling the terminal" + + type TestFunction = Unit -> TestResult let allTests (): List = @@ -177,6 +252,11 @@ let allTests (): List = ("Eval Simple Expression", fun () -> testEvalSimpleExpression ()) ("Eval String Expression", fun () -> testEvalStringExpression ()) + + ("Completion Picker Two Rows", fun () -> testCompletionPickerTwoRows ()) + ("Completion Picker Narrow Help", fun () -> testCompletionPickerNarrowHelp ()) + ("Completion Picker Long List", fun () -> testCompletionPickerLongList ()) + ("Fullscreen Deferred Wrap", fun () -> testFullscreenRowCancelsDeferredWrap ()) ] type TestSummary = diff --git a/packages/darklang/cli/tui/core.dark b/packages/darklang/cli/tui/core.dark new file mode 100644 index 0000000000..7869c0e1fd --- /dev/null +++ b/packages/darklang/cli/tui/core.dark @@ -0,0 +1,39 @@ +/// Shared, side-effect-free view types for interactive CLI applications. +module Darklang.Cli.Tui + + +/// Current terminal viewport dimensions. +type Size = + { width: Int + height: Int } + + +/// Determines how a logical view occupies the terminal. +type ViewMode = + /// Own the complete terminal using its alternate screen. + | Fullscreen + /// Occupy the view's logical rows while preserving normal terminal scrollback. + | Inline + + +/// Cursor placement after rendering a view. +/// +/// Row and column coordinates are zero-based. +type Cursor = + /// Do not show the terminal cursor. + | Hidden + /// Show the cursor at a logical row and column in the view. + | Visible of row: Int * column: Int + + +/// The complete logical view to render. +/// +/// Rows may contain supported ANSI text styling, but no cursor movement, +/// clearing, or terminal mode controls. +type View = + { /// Rows to render, ordered from top to bottom. + rows: List + /// Cursor state to apply after presenting the frame. + cursor: Cursor + /// How the frame occupies the terminal. + mode: ViewMode } diff --git a/packages/darklang/cli/tui/frame.dark b/packages/darklang/cli/tui/frame.dark new file mode 100644 index 0000000000..5fb27e71c6 --- /dev/null +++ b/packages/darklang/cli/tui/frame.dark @@ -0,0 +1,172 @@ +/// Builds terminal updates by comparing complete logical views. +module Darklang.Cli.Tui.Frame + + +/// One logical row that changed between two views. +type RowChange = + { /// Zero-based row in the logical view. + row: Int + /// Complete styled content for the row, or an empty string to clear it. + content: String } + + +/// Compare both row lists once while accumulating changes in reverse order. +let changedRowsFrom + (row: Int) + (previousRows: List) + (nextRows: List) + (changes: List) + : List = + match previousRows, nextRows with + | [], [] -> + Stdlib.List.reverse changes + | previous :: previousTail, next :: nextTail -> + let changes = + if previous == next then + changes + else + Stdlib.List.push changes (RowChange { row = row; content = next }) + + changedRowsFrom (row + 1) previousTail nextTail changes + | _previous :: previousTail, [] -> + changedRowsFrom + (row + 1) + previousTail + [] + (Stdlib.List.push changes (RowChange { row = row; content = "" })) + | [], next :: nextTail -> + changedRowsFrom + (row + 1) + [] + nextTail + (Stdlib.List.push changes (RowChange { row = row; content = next })) + + +/// Return complete rows whose content changed. +/// +/// Comparing through the longer view ensures rows removed from the next view +/// are returned with empty content and erased from the terminal. +let changedRows + (previousRows: List) + (nextRows: List) + : List = + changedRowsFrom 0 previousRows nextRows [] + + +/// Traverse both lists once while accumulating a complete repaint. +let fullRedrawRowsFrom + (row: Int) + (previousRows: List) + (nextRows: List) + (changes: List) + : List = + match previousRows, nextRows with + | [], [] -> + Stdlib.List.reverse changes + | _previous :: previousTail, next :: nextTail -> + fullRedrawRowsFrom + (row + 1) + previousTail + nextTail + (Stdlib.List.push changes (RowChange { row = row; content = next })) + | _previous :: previousTail, [] -> + fullRedrawRowsFrom + (row + 1) + previousTail + [] + (Stdlib.List.push changes (RowChange { row = row; content = "" })) + | [], next :: nextTail -> + fullRedrawRowsFrom + (row + 1) + [] + nextTail + (Stdlib.List.push changes (RowChange { row = row; content = next })) + + +/// Return every next row plus empty changes for rows that disappeared. +/// +/// This is used after resize: every retained row is repainted, while content +/// outside a now-shorter logical view is explicitly erased. +let fullRedrawRows + (previousRows: List) + (nextRows: List) + : List = + fullRedrawRowsFrom 0 previousRows nextRows [] + + +/// Render one complete row update. +/// +/// Terminal coordinates are one-based. The row is erased before its content +/// is written: erasing afterwards would clear the final cell of a full-width +/// row, because the terminal parks in deferred-wrap state on that cell. The +/// style reset precedes the erase so blank cells never inherit styling, and a +/// final reset contains any style left open by the logical row. Returning to +/// column one cancels deferred wrap before the next terminal update. +let renderRowChangeAtWidth + (width: Int) + (change: RowChange) + : String = + let terminalRow = Stdlib.Int.toString (change.row + 1) + let content = Text.normalizeToWidth change.content width + $"\u001b[{terminalRow};1H\u001b[0m\u001b[K{content}\u001b[0m\r" + + +/// Render the desired final cursor state. +let renderCursor (cursor: Tui.Cursor) : String = + match cursor with + | Hidden -> "" + | Visible(row, column) -> + let terminalRow = Stdlib.Int.toString (Stdlib.Int.max 0 row + 1) + let terminalColumn = Stdlib.Int.toString (Stdlib.Int.max 0 column + 1) + $"\u001b[{terminalRow};{terminalColumn}H\u001b[?25h" + + +/// Produce one buffered terminal update for a complete desired view. +/// +/// The caller writes this returned string once. Terminal session modes are +/// deliberately excluded: the terminal session owns those effects. +let renderAtWidth + (width: Int) + (previous: Stdlib.Option.Option) + (next: Tui.View) + : String = + let previousRows = + match previous with + | None -> [] + | Some view -> view.rows + + let rowChanges = changedRows previousRows next.rows + + let cursorChanged = + match previous with + | None -> true + | Some view -> view.cursor != next.cursor + + if Stdlib.List.isEmpty rowChanges && Stdlib.Bool.not cursorChanged then + "" + else + let rows = + rowChanges + |> Stdlib.List.map (renderRowChangeAtWidth width) + |> Stdlib.String.join "" + + "\u001b[?25l" ++ rows ++ renderCursor next.cursor + + +/// Produce a complete repaint while clearing rows removed from the prior view. +let renderFullAtWidth + (width: Int) + (previous: Stdlib.Option.Option) + (next: Tui.View) + : String = + let previousRows = + match previous with + | None -> [] + | Some view -> view.rows + + let rows = + fullRedrawRows previousRows next.rows + |> Stdlib.List.map (renderRowChangeAtWidth width) + |> Stdlib.String.join "" + + "\u001b[?25l" ++ rows ++ renderCursor next.cursor diff --git a/packages/darklang/cli/tui/inlineFrame.dark b/packages/darklang/cli/tui/inlineFrame.dark new file mode 100644 index 0000000000..3388257a3c --- /dev/null +++ b/packages/darklang/cli/tui/inlineFrame.dark @@ -0,0 +1,135 @@ +/// Build updates for an inline region anchored at the terminal cursor. +/// +/// Inline rows move with scrollback, so updates address rows relative to the +/// cursor left by the previous view. +module Darklang.Cli.Tui.InlineFrame + + +let cursorRow (view: Tui.View) : Int = + match view.cursor with + | Hidden -> + Stdlib.Int.max 0 (Stdlib.List.length view.rows - 1) + | Visible(row, _) -> + Stdlib.Int.max 0 row + + +let moveRows (fromRow: Int) (toRow: Int) : String = + let distance = toRow - fromRow + if distance < 0 then + $"\u001b[{Stdlib.Int.toString (0 - distance)}A" + else if distance > 0 then + $"\u001b[{Stdlib.Int.toString distance}B" + else + "" + + +/// Reset, clear, and rewrite one inline row. +/// +/// Clearing before writing preserves the final cell of full-width content. +/// Resets prevent background colors and row styling from leaking. +let renderContentAtWidth (width: Int) (content: String) : String = + let content = Text.normalizeToWidth content width + "\r\u001b[0m\u001b[K" ++ content ++ "\u001b[0m" + + +let finishAtCursor + (currentRow: Int) + (view: Tui.View) + : String = + match view.cursor with + | Hidden -> + let bottom = + Stdlib.Int.max 0 (Stdlib.List.length view.rows - 1) + moveRows currentRow bottom ++ "\r" + | Visible(row, column) -> + let targetRow = Stdlib.Int.max 0 row + let targetColumn = Stdlib.Int.max 0 column + 1 + moveRows currentRow targetRow + ++ $"\u001b[{Stdlib.Int.toString targetColumn}G" + ++ "\u001b[?25h" + + +/// Repaint a complete inline region. +/// +/// Rendering starts from the cursor position left by the previous view and +/// clears rows that no longer exist. +let renderFullAtWidth + (width: Int) + (previous: Stdlib.Option.Option) + (next: Tui.View) + : String = + let previousHeight = + match previous with + | None -> 0 + | Some view -> Stdlib.List.length view.rows + let nextHeight = Stdlib.List.length next.rows + let repaintHeight = Stdlib.Int.max previousHeight nextHeight + + if repaintHeight == 0 then + "" + else + let startRow = + match previous with + | None -> 0 + | Some view -> cursorRow view + let toTop = moveRows startRow 0 + let physicalRows = + Stdlib.List.range 0 (repaintHeight - 1) + let painted = + physicalRows + |> Stdlib.List.map (fun row -> + let content = + Stdlib.List.getAt next.rows row + |> Stdlib.Option.withDefault "" + let separator = + if row < repaintHeight - 1 then "\n" else "" + renderContentAtWidth width content ++ separator) + |> Stdlib.String.join "" + let finalPhysicalRow = repaintHeight - 1 + + "\u001b[?25l" + ++ toTop + ++ painted + ++ finishAtCursor finalPhysicalRow next + + +/// Rewrite changed rows relative to the previous cursor, then place the next +/// cursor. +let renderAtWidth + (width: Int) + (previous: Stdlib.Option.Option) + (next: Tui.View) + : String = + match previous with + | None -> renderFullAtWidth width previous next + | Some previousView -> + if Stdlib.List.length previousView.rows + != Stdlib.List.length next.rows + then + renderFullAtWidth width previous next + else + let changes = + Frame.changedRows previousView.rows next.rows + let cursorChanged = previousView.cursor != next.cursor + + if Stdlib.List.isEmpty changes + && Stdlib.Bool.not cursorChanged + then + "" + else + let startRow = cursorRow previousView + let rendered = + Stdlib.List.fold + changes + ("", startRow) + (fun state change -> + let (output, currentRow) = state + ( output + ++ moveRows currentRow change.row + ++ renderContentAtWidth width change.content + , change.row )) + let (output, currentRow) = rendered + + "\u001b[?25l" + ++ output + ++ finishAtCursor currentRow next diff --git a/packages/darklang/cli/tui/layout.dark b/packages/darklang/cli/tui/layout.dark new file mode 100644 index 0000000000..d176b223ef --- /dev/null +++ b/packages/darklang/cli/tui/layout.dark @@ -0,0 +1,49 @@ +/// Small, pure layout helpers shared by terminal views. +module Darklang.Cli.Tui.Layout + + +/// Repeat blank logical rows. +let blankRows (count: Int) : List = + match Stdlib.List.repeat (Stdlib.Int.max 0 count) "" with + | Ok rows -> rows + | Error _ -> [] + + +/// Truncate or pad a row list to an exact logical height. +let fitRows (height: Int) (rows: List) : List = + let target = Stdlib.Int.max 0 height + let visible = Stdlib.List.take rows target + Stdlib.List.append + visible + (blankRows (target - Stdlib.List.length visible)) + + +/// Fit rows to `height`, scrolling as needed to keep `selectedRow` visible. +/// +/// `selectedRow` indexes `rows`, including any headings or separators. +let viewport + (rows: List) + (selectedRow: Int) + (height: Int) + : List = + let viewportHeight = Stdlib.Int.max 0 height + + if viewportHeight == 0 then + [] + else + let rowCount = Stdlib.List.length rows + let maxOffset = Stdlib.Int.max 0 (rowCount - viewportHeight) + let desiredOffset = + if selectedRow >= viewportHeight then + selectedRow - viewportHeight + 1 + else + 0 + let offset = + Stdlib.Int.min maxOffset (Stdlib.Int.max 0 desiredOffset) + + let visible = + rows + |> Stdlib.List.drop offset + |> Stdlib.List.take viewportHeight + + fitRows viewportHeight visible diff --git a/packages/darklang/cli/tui/terminalSession.dark b/packages/darklang/cli/tui/terminalSession.dark new file mode 100644 index 0000000000..3014e71f25 --- /dev/null +++ b/packages/darklang/cli/tui/terminalSession.dark @@ -0,0 +1,395 @@ +/// Manage the state and lifecycle of one terminal UI session. +module Darklang.Cli.Tui.TerminalSession + + +/// The terminal surface currently owned by the session. +type Status = + | Stopped + | RunningInline + | RunningFullscreen + + +/// Retained presentation state for one terminal UI session. +type State = + { status: Status + previousView: Stdlib.Option.Option + terminalSize: Stdlib.Option.Option } + + +/// A state transition and terminal update prepared without writing. +/// +/// This keeps lifecycle behavior testable without a real terminal. +type Prepared = + { state: State + output: String } + + +module Ansi = + val enterAlternateScreen = "\u001b[?1049h" + val exitAlternateScreen = "\u001b[?1049l" + val showCursor = "\u001b[?25h" + val resetStyle = "\u001b[0m" + val resetScrollRegion = "\u001b[r" + val beginSynchronizedOutput = "\u001b[?2026h" + val endSynchronizedOutput = "\u001b[?2026l" + + val restoreInlineModes = + // Inline sessions do not own an enclosing application's scroll region. + // Normal cleanup restores only modes the inline renderer changes. + resetStyle ++ showCursor + + val restoreFullscreenModes = + resetStyle + ++ resetScrollRegion + ++ showCursor + ++ exitAlternateScreen + ++ showCursor + + // Fallback restoration always ends a possibly interrupted synchronized + // update before resetting terminal state. + // Emergency recovery is deliberately stronger: an interrupted process may + // have left an unknown scroll region behind. + val restoreInline = + endSynchronizedOutput + ++ resetStyle + ++ resetScrollRegion + ++ showCursor + + val restoreFullscreen = endSynchronizedOutput ++ restoreFullscreenModes + + /// Wrap a non-empty update in synchronized-output markers. + /// + /// Supporting terminals display the update atomically; other terminals ignore + /// the markers. + let wrapUpdate (output: String) : String = + if output == "" then + "" + else + beginSynchronizedOutput ++ output ++ endSynchronizedOutput + + +/// Create a stopped session with no retained view. +let init () : State = + State { + status = Status.Stopped + previousView = Stdlib.Option.Option.None + terminalSize = Stdlib.Option.Option.None + } + + +let armRestoreGuard (sequence: String) : Unit = + Builtin.cliTerminalRestoreArm sequence + + +let disarmRestoreGuard () : Unit = + Builtin.cliTerminalRestoreDisarm () + + +/// Return the session status for a view mode. +let statusForMode (mode: Tui.ViewMode) : Status = + match mode with + | Fullscreen -> Status.RunningFullscreen + | Inline -> Status.RunningInline + + +/// Return the fallback restoration sequence for a view mode. +let restoreForMode (mode: Tui.ViewMode) : String = + match mode with + | Fullscreen -> Ansi.restoreFullscreen + | Inline -> Ansi.restoreInline + + +/// Return whether presenting a mode moves to a different terminal surface. +let changesSurface (status: Status) (mode: Tui.ViewMode) : Bool = + match status, statusForMode mode with + | RunningInline, RunningInline + | RunningFullscreen, RunningFullscreen -> false + | Stopped, RunningInline + | Stopped, RunningFullscreen + | RunningInline, RunningFullscreen + | RunningFullscreen, RunningInline -> true + | Stopped, Stopped + | RunningInline, Stopped + | RunningFullscreen, Stopped -> false + + +/// Build the terminal control prefix needed to present a view mode. +let transitionOutput (status: Status) (mode: Tui.ViewMode) : String = + match status, mode with + | Stopped, Fullscreen + | RunningInline, Fullscreen -> Ansi.enterAlternateScreen + | RunningFullscreen, Inline -> Ansi.restoreFullscreenModes + | Stopped, Inline + | RunningInline, Inline + | RunningFullscreen, Fullscreen -> "" + + +/// Read and normalize the current terminal viewport dimensions. +let currentSize () : Tui.Size = + let (width, height) = Darklang.Cli.Terminal.getSize () + Tui.Size { + width = Stdlib.Int.max 1 width + height = Stdlib.Int.max 1 height + } + + +/// Constrain a logical view to the sampled terminal. +/// +/// Fullscreen rows are truncated to the physical surface. Inline views retain +/// a cursor-containing viewport when their content is taller than the +/// terminal, preserving relative addressing without scrolling beyond it. +/// +/// Dynamic row content is sanitized and clipped only when a changed row is +/// emitted. Keeping raw logical rows here makes unchanged frames cheap while +/// the renderer remains the single terminal-safety boundary. +let normalizeView + (size: Tui.Size) + (view: Tui.View) + : Tui.View = + let width = Stdlib.Int.max 1 size.width + let height = Stdlib.Int.max 1 size.height + + match view.mode with + | Fullscreen -> + let rows = + if Stdlib.List.length view.rows <= height then + view.rows + else + Stdlib.List.take view.rows height + let cursor = + match view.cursor with + | Hidden -> Tui.Cursor.Hidden + | Visible(row, column) -> + Tui.Cursor.Visible( + Stdlib.Int.min (height - 1) (Stdlib.Int.max 0 row), + Stdlib.Int.min (width - 1) (Stdlib.Int.max 0 column)) + + { view with + rows = rows + cursor = cursor } + | Inline -> + let rowCount = Stdlib.List.length view.rows + let maxOffset = Stdlib.Int.max 0 (rowCount - height) + let desiredOffset = + match view.cursor with + | Hidden -> 0 + | Visible(row, _) -> + if row >= height then row - height + 1 else 0 + let offset = + Stdlib.Int.min maxOffset (Stdlib.Int.max 0 desiredOffset) + let visibleRows = + view.rows + |> Stdlib.List.drop offset + |> Stdlib.List.take height + let cursor = + match view.cursor with + | Hidden -> Tui.Cursor.Hidden + | Visible(row, column) -> + Tui.Cursor.Visible( + Stdlib.Int.min + (height - 1) + (Stdlib.Int.max 0 (row - offset)), + Stdlib.Int.min (width - 1) (Stdlib.Int.max 0 column)) + + { view with rows = visibleRows; cursor = cursor } + + +/// Prepare a complete view for presentation at a known terminal size. +/// +/// Moving between terminal surfaces or changing viewport size invalidates the +/// retained view so the surface receives a complete redraw. Otherwise only +/// changed rows are emitted. +let preparePresentAtSize + (state: State) + (size: Tui.Size) + (next: Tui.View) + : Prepared = + let next = normalizeView size next + let surfaceChanged = changesSurface state.status next.mode + let sizeChanged = + state.terminalSize != Stdlib.Option.Option.Some size + + let frameOutput = + match next.mode with + | Fullscreen -> + if surfaceChanged then + Frame.renderAtWidth size.width Stdlib.Option.Option.None next + else if sizeChanged then + Frame.renderFullAtWidth size.width state.previousView next + else + Frame.renderAtWidth size.width state.previousView next + | Inline -> + if surfaceChanged then + InlineFrame.renderAtWidth + size.width + Stdlib.Option.Option.None + next + else if sizeChanged then + InlineFrame.renderFullAtWidth + size.width + state.previousView + next + else + InlineFrame.renderAtWidth size.width state.previousView next + + let output = + Ansi.wrapUpdate + (transitionOutput state.status next.mode ++ frameOutput) + + Prepared { + state = + State { + status = statusForMode next.mode + previousView = Stdlib.Option.Option.Some next + terminalSize = Stdlib.Option.Option.Some size + } + output = output + } + + +/// Present a view at a previously sampled terminal size. +/// +/// This is useful when a layout needs to use the same size sample that the +/// session records for resize detection. +let presentAtSize + (state: State) + (size: Tui.Size) + (next: Tui.View) + : State = + let prepared = preparePresentAtSize state size next + let surfaceChanged = changesSurface state.status next.mode + + // Arm before entering a new surface. When leaving fullscreen, retain its + // stronger guard until the queued exit sequence has reached the terminal. + if surfaceChanged then + match state.status, next.mode with + | RunningFullscreen, Inline -> () + | _, mode -> + armRestoreGuard (restoreForMode mode) + + if prepared.output == "" then + () + else + Stdlib.print prepared.output + + // `arm` waits for earlier queued output, so this only weakens the guard after + // the fullscreen exit and inline redraw have physically completed. + if surfaceChanged then + match state.status, next.mode with + | RunningFullscreen, Inline -> + armRestoreGuard Ansi.restoreInline + | _ -> () + + prepared.state + + +/// Present a complete view using one logical stdout write. +/// +/// Empty updates are not written. `Stdlib.print` queues each non-empty update +/// as one complete value for the host's single `Console.Write`. +let present (state: State) (next: Tui.View) : State = + presentAtSize state (currentSize ()) next + + +/// Start a terminal session at a previously sampled size. +/// +/// Terminal support is checked before writing any terminal control sequences. +/// Continuing an existing session uses `presentAtSize`. +let startAtSize + (size: Tui.Size) + (next: Tui.View) + : Stdlib.Result.Result = + match TerminalSupport.current () with + | Available -> + presentAtSize (init ()) size next + |> Stdlib.Result.Result.Ok + | Unavailable reason -> + reason + |> TerminalSupport.unavailableMessage + |> Stdlib.Result.Result.Error + + +/// Start a terminal session using the current terminal size. +let start (next: Tui.View) : Stdlib.Result.Result = + match TerminalSupport.current () with + | Available -> + present (init ()) next + |> Stdlib.Result.Result.Ok + | Unavailable reason -> + reason + |> TerminalSupport.unavailableMessage + |> Stdlib.Result.Result.Error + + +/// Prepare normal terminal restoration and reset the session. +/// +/// Calling this on a stopped session is safe and produces no output. +let prepareStop (state: State) : Prepared = + let output = + match state.status with + | Stopped -> "" + | RunningInline -> Ansi.wrapUpdate Ansi.restoreInlineModes + | RunningFullscreen -> Ansi.wrapUpdate Ansi.restoreFullscreenModes + + Prepared { state = init (); output = output } + + +/// Stop the session and restore terminal state with one stdout write. +let stop (state: State) : State = + let prepared = prepareStop state + + match state.status with + | Stopped -> () + | RunningInline + | RunningFullscreen -> + Stdlib.print prepared.output + // The builtin waits until the queued restoration has reached stdout. + disarmRestoreGuard () + + prepared.state + + +/// Prepare inline cleanup that first replaces the region with a final view. +/// +/// Interactive prompts use this before printing command output or exiting. +/// The final view keeps only what belongs in scrollback β€” presentation-only +/// rows such as completion hints are dropped, or every row for a discarded +/// prompt β€” and the cursor parks on the final view's last row so subsequent +/// output begins on the following line. +let prepareStopInlineWithView + (state: State) + (final: Tui.View) + : Prepared = + match state.status with + | RunningInline -> + let (width, final) = + match state.terminalSize with + | Some size -> (Stdlib.Int.max 1 size.width, normalizeView size final) + | None -> (1, final) + let parked = { final with cursor = Tui.Cursor.Hidden } + let parkOutput = + InlineFrame.renderAtWidth width state.previousView parked + + Prepared { + state = init () + output = + Ansi.wrapUpdate + (parkOutput ++ Ansi.restoreInlineModes) + } + | Stopped + | RunningFullscreen -> + prepareStop state + + +/// Stop an inline session on a final view with one logical terminal update. +let stopInlineWithView (state: State) (final: Tui.View) : State = + let prepared = prepareStopInlineWithView state final + + match state.status with + | Stopped -> () + | RunningInline + | RunningFullscreen -> + Stdlib.print prepared.output + disarmRestoreGuard () + + prepared.state diff --git a/packages/darklang/cli/tui/terminalSupport.dark b/packages/darklang/cli/tui/terminalSupport.dark new file mode 100644 index 0000000000..04b67abc92 --- /dev/null +++ b/packages/darklang/cli/tui/terminalSupport.dark @@ -0,0 +1,59 @@ +/// Decide whether a terminal can run an interactive TUI. +module Darklang.Cli.Tui.TerminalSupport + + +/// Terminal facts reported by the native host. +type Facts = + { inputIsTerminal: Bool + outputIsTerminal: Bool + terminalName: String } + + +/// Whether an interactive TUI may start. +type Availability = + | Available + | Unavailable of reason: String + + +/// Evaluate terminal support from host facts. +/// +/// Both input and output must be terminals: the UI reads keys from input and +/// writes terminal control sequences to output. +let evaluate (facts: Facts) : Availability = + let terminalName = + facts.terminalName + |> Stdlib.String.trim + |> Stdlib.String.toLowercase + + if Stdlib.Bool.not facts.inputIsTerminal then + Availability.Unavailable "standard input is not a terminal" + else if Stdlib.Bool.not facts.outputIsTerminal then + Availability.Unavailable "standard output is not a terminal" + else if terminalName == "" then + Availability.Unavailable "TERM is not set" + else if terminalName == "dumb" || terminalName == "unknown" then + Availability.Unavailable + $"TERM={facts.terminalName} does not support an interactive terminal UI" + else + Availability.Available + + +/// Read the current terminal facts from the native host. +let currentFacts () : Facts = + let (inputIsTerminal, outputIsTerminal, terminalName) = + Builtin.cliTerminalSessionInfo () + Facts { + inputIsTerminal = inputIsTerminal + outputIsTerminal = outputIsTerminal + terminalName = terminalName + } + + +/// Check whether an interactive TUI may start in the current terminal. +let current () : Availability = + evaluate (currentFacts ()) + + +/// Format an error without terminal control sequences. +let unavailableMessage (reason: String) : String = + $"Interactive terminal UI unavailable: {reason}." diff --git a/packages/darklang/cli/tui/text.dark b/packages/darklang/cli/tui/text.dark new file mode 100644 index 0000000000..8de78248d9 --- /dev/null +++ b/packages/darklang/cli/tui/text.dark @@ -0,0 +1,279 @@ +/// Unicode-aware text measurements for terminal UI layout. +module Darklang.Cli.Tui.Text + + +/// Measure text and report whether it contains control characters. +/// +/// The width is valid only when the control-character flag is false. +let inspect (text: String) : (Int * Bool) = + Builtin.cliTerminalInspectText text + + +/// Return the number of terminal columns occupied by plain, single-line text. +/// +/// Measure text before adding ANSI styling. Terminal control sequences are not +/// text and must not be passed to this function. +let displayWidth (text: String) : Int = + let (width, _containsControl) = inspect text + width + + +/// Consume a CSI sequence after its `ESC [` prefix. +/// +/// Only numeric SGR parameters are retained. Every other CSI sequence is +/// consumed and discarded so dynamic content cannot move the cursor, erase +/// terminal state, or change terminal modes. +let consumeCsi + (chars: List) + (sequence: String) + (validSgrParameters: Bool) + : (String * List) = + match chars with + | [] -> ("", []) + | char :: remaining -> + let text = Stdlib.Char.toString char + let nextSequence = sequence ++ text + let ascii = Stdlib.Char.toAsciiCode char + let isFinal = + match ascii with + | Some code -> code >= 64 && code <= 126 + | None -> false + + if isFinal then + if text == "m" && validSgrParameters then + (nextSequence, remaining) + else + ("", remaining) + else + let validParameter = + Stdlib.Char.isDigit char + || text == ";" + || text == ":" + consumeCsi + remaining + nextSequence + (validSgrParameters && validParameter) + + +/// Consume one terminal escape from dynamic row content. +/// +/// Valid SGR styling is returned. Unsupported or incomplete escapes are +/// removed. Non-CSI escapes drop only their ESC byte so ordinary following +/// text remains visible. +let consumeEscape + (chars: List) + : (String * List) = + match chars with + | char :: remaining when Stdlib.Char.toString char == "[" -> + consumeCsi remaining "\u001b[" true + | _ -> ("", chars) + + +let isControl (char: Char) : Bool = + match Stdlib.Char.toAsciiCode char with + | Some code -> code < 32 || code == 127 + | None -> false + + +let clipStyledChars + (chars: List) + (remainingWidth: Int) + (result: String) + : String = + match chars with + | [] -> result + | char :: remaining -> + let text = Stdlib.Char.toString char + + if text == "\u001b" then + let (sequence, afterSequence) = consumeEscape remaining + clipStyledChars afterSequence remainingWidth (result ++ sequence) + else if isControl char then + clipStyledChars remaining remainingWidth result + else + let width = displayWidth text + if width > remainingWidth then + result + else + clipStyledChars remaining (remainingWidth - width) (result ++ text) + + +/// Clip a plain or SGR-styled row to a terminal-column width. +/// +/// Dark `Char` values are extended grapheme clusters, so clipping cannot split +/// combining sequences, flags, or joined emoji. SGR sequences are retained and +/// consume no terminal columns. +let clipToWidth (text: String) (maxWidth: Int) : String = + if maxWidth <= 0 then + "" + else + clipStyledChars (Stdlib.String.toList text) maxWidth "" + + +/// Fast path rows that are already plain and within bounds. +/// +/// Styled, dynamic-control, and oversized rows go through the strict Dark +/// parser. Ordinary unchanged content needs only one native metrics call. +let normalizeToWidth (text: String) (maxWidth: Int) : String = + let width = Stdlib.Int.max 0 maxWidth + let (measuredWidth, containsControl) = inspect text + if Stdlib.Bool.not containsControl && measuredWidth <= width then + text + else + clipToWidth text width + + +let stripSgrChars (chars: List) (result: String) : String = + match chars with + | [] -> result + | char :: remaining -> + let text = Stdlib.Char.toString char + + if text == "\u001b" then + let (_sequence, afterSequence) = consumeEscape remaining + stripSgrChars afterSequence result + else if isControl char then + stripSgrChars remaining result + else + stripSgrChars remaining (result ++ text) + + +/// Remove SGR styling from a logical row. +let stripSgr (text: String) : String = + stripSgrChars (Stdlib.String.toList text) "" + + +/// Pad or clip text to exactly the requested terminal-column width. +let fitToWidth (text: String) (targetWidth: Int) : String = + let clipped = clipToWidth text targetWidth + let padding = Stdlib.Int.max 0 (targetWidth - displayWidth (stripSgr clipped)) + clipped ++ Stdlib.String.repeat " " padding + + +let nextActiveStyle (active: String) (sequence: String) : String = + if sequence == "\u001b[0m" then + "" + else + active ++ sequence + + +let wrapStyledChars + (chars: List) + (width: Int) + (activeStyle: String) + (current: String) + (currentWidth: Int) + (wrapPending: Bool) + (completed: List) + : List = + match chars with + | [] -> + Stdlib.List.reverse (Stdlib.List.push completed current) + | char :: remaining -> + let text = Stdlib.Char.toString char + + if text == "\u001b" then + let (sequence, afterSequence) = consumeEscape remaining + wrapStyledChars + afterSequence + width + (nextActiveStyle activeStyle sequence) + (current ++ sequence) + currentWidth + wrapPending + completed + else if isControl char then + wrapStyledChars + remaining + width + activeStyle + current + currentWidth + wrapPending + completed + else + let charWidth = displayWidth text + let shouldWrap = + wrapPending + || (currentWidth > 0 && currentWidth + charWidth > width) + let nextCompleted = + if shouldWrap then Stdlib.List.push completed current + else completed + let row = + if shouldWrap then activeStyle ++ text + else current ++ text + let rowWidth = + if shouldWrap then charWidth + else currentWidth + charWidth + + wrapStyledChars + remaining + width + activeStyle + row + rowWidth + (rowWidth >= width) + nextCompleted + + +/// Wrap plain or SGR-styled text into explicit terminal-width rows. +/// +/// Styling that spans a wrap is reapplied at the beginning of the next row, +/// because the frame renderer resets SGR state after writing every row. +let wrapStyled (text: String) (maxWidth: Int) : List = + let width = Stdlib.Int.max 1 maxWidth + wrapStyledChars + (Stdlib.String.toList text) + width + "" + "" + 0 + false + [] + + +let positionAfterChars + (chars: List) + (width: Int) + (row: Int) + (column: Int) + (wrapPending: Bool) + : (Int * Int) = + match chars with + | [] -> + if wrapPending then (row + 1, 0) + else (row, column) + | char :: remaining -> + let text = Stdlib.Char.toString char + let charWidth = displayWidth text + let startRow = if wrapPending then row + 1 else row + let startColumn = if wrapPending then 0 else column + let shouldWrap = + startColumn > 0 && startColumn + charWidth > width + let nextRow = if shouldWrap then startRow + 1 else startRow + let nextColumn = + if shouldWrap then charWidth + else startColumn + charWidth + + positionAfterChars + remaining + width + nextRow + nextColumn + (nextColumn >= width) + + +/// Return the zero-based terminal cursor immediately after plain text. +/// +/// A cursor exactly after the final column is represented at column zero of +/// the following row, matching terminal wrap behavior. +let positionAfter + (text: String) + (maxWidth: Int) + : (Int * Int) = + positionAfterChars + (Stdlib.String.toList text) + (Stdlib.Int.max 1 maxWidth) + 0 + 0 + false diff --git a/packages/darklang/cli/ui/demo.dark b/packages/darklang/cli/ui/demo.dark index c61e35e7c7..7dec675ad2 100644 --- a/packages/darklang/cli/ui/demo.dark +++ b/packages/darklang/cli/ui/demo.dark @@ -10,45 +10,39 @@ val demoContext = hasFocus = true theme = "default" } -let printSection (title: String) : Unit = - Stdlib.printLine "" - Stdlib.printLine (Core.Rendering.bold ("━━━ " ++ title ++ " ━━━")) +let section (title: String) : List = + [ ""; Core.Rendering.bold ("━━━ " ++ title ++ " ━━━") ] -let run (unit: Unit) : Int = - Stdlib.printLine (Core.Rendering.bold "Darklang CLI UI Component Demo") - Stdlib.printLine (Core.Rendering.dim "Visual verification of component rendering") +/// Render the component showcase as data so retained terminal applications can +/// compose, clip, and diff it without capturing printed output. +let rows (unit: Unit) : List = + let header = + [ Core.Rendering.bold "Darklang CLI UI Component Demo" + Core.Rendering.dim "Visual verification of component rendering" ] // Box styles - printSection "Box Styles" let bounds = Core.Types.Bounds { position = Core.Types.Position { x = 0; y = 0 } dimensions = Core.Types.Dimensions { width = 30; height = 5 } } let singleBox = Core.Rendering.drawBoxWithStyle bounds "Single" ["Content"] Core.Rendering.BoxStyle.Single - singleBox |> Stdlib.List.iter Stdlib.printLine - let doubleBox = Core.Rendering.drawBoxWithStyle bounds "Double" ["Content"] Core.Rendering.BoxStyle.Double - doubleBox |> Stdlib.List.iter Stdlib.printLine let roundedBox = Core.Rendering.drawBoxWithStyle bounds "Rounded" ["Content"] Core.Rendering.BoxStyle.Rounded - roundedBox |> Stdlib.List.iter Stdlib.printLine // Progress - printSection "Progress Bar" let progress = Components.Progress.createProgressBar 75 0 100 Core.Types.Color.Success let progressLines = Components.Progress.renderProgressBar progress demoContext - progressLines |> Stdlib.List.iter Stdlib.printLine // Spinner frames - printSection "Spinner Styles" let dotsFrames = Components.Spinner.getFrames Components.Spinner.SpinnerStyle.Dots let lineFrames = Components.Spinner.getFrames Components.Spinner.SpinnerStyle.Line - Stdlib.printLine ("Dots: " ++ Stdlib.String.join (Stdlib.List.take dotsFrames 6) " ") - Stdlib.printLine ("Line: " ++ Stdlib.String.join lineFrames " ") + let spinnerLines = + [ "Dots: " ++ Stdlib.String.join (Stdlib.List.take dotsFrames 6) " " + "Line: " ++ Stdlib.String.join lineFrames " " ] // Table - printSection "Table" let col1 = Components.Table.createColumn "Name" 15 let col2 = Components.Table.createColumn "Status" 10 let cols = [col1; col2] @@ -56,48 +50,34 @@ let run (unit: Unit) : Int = let table = Components.Table.createTable cols rows let tableWithSelection = Components.Table.selectRow table 1 let tableLines = Components.Table.renderTable tableWithSelection demoContext - tableLines |> Stdlib.List.iter Stdlib.printLine // Buttons - printSection "Buttons" let btn1 = Components.Button.createButton "Submit" Core.Types.Color.Success (fun () -> ()) let btn2 = Components.Button.createButton "Cancel" Core.Types.Color.Error (fun () -> ()) let btn3 = Components.Button.disableButton (Components.Button.createButton "Disabled" Core.Types.Color.Secondary (fun () -> ())) let btn1Lines = Components.Button.renderButton btn1 demoContext - btn1Lines |> Stdlib.List.iter Stdlib.printLine let btn2Lines = Components.Button.renderButton btn2 demoContext - btn2Lines |> Stdlib.List.iter Stdlib.printLine let btn3Lines = Components.Button.renderButton btn3 demoContext - btn3Lines |> Stdlib.List.iter Stdlib.printLine // Card - printSection "Card" let card = Components.Card.createCard "User Profile" ["Name: Alice"; "Role: Admin"; "Status: Active"] 35 8 let cardWithColors = Components.Card.setCardColors card Core.Types.Color.Primary Core.Types.Color.Default let cardWithShadow = Components.Card.enableCardShadow cardWithColors let cardLines = Components.Card.renderCard cardWithShadow demoContext - cardLines |> Stdlib.List.iter Stdlib.printLine // Messages - printSection "Messages" let successMsg = Components.Message.createMessage "Success" "Operation completed!" Core.Types.Color.Success 40 let warningMsg = Components.Message.createMessage "Warning" "Check your input." Core.Types.Color.Warning 40 let errorMsg = Components.Message.createMessage "Error" "Something went wrong." Core.Types.Color.Error 40 let successLines = Components.Message.renderMessage successMsg demoContext - successLines |> Stdlib.List.iter Stdlib.printLine let warningLines = Components.Message.renderMessage warningMsg demoContext - warningLines |> Stdlib.List.iter Stdlib.printLine let errorLines = Components.Message.renderMessage errorMsg demoContext - errorLines |> Stdlib.List.iter Stdlib.printLine // Toast - printSection "Toast" let toast = Components.Message.createToast "File saved successfully" Core.Types.Color.Success 3000 let toastLines = Components.Message.renderToast toast demoContext - toastLines |> Stdlib.List.iter Stdlib.printLine // ListView - printSection "ListView" let items = [ Components.ListView.ListItem { text = "Item 1"; enabled = true; data = "1" }; Components.ListView.ListItem { text = "Item 2"; enabled = true; data = "2" }; @@ -107,73 +87,99 @@ let run (unit: Unit) : Int = let listView = Components.ListView.createListView items 30 6 let listViewWithSelection = Components.ListView.selectItem listView 1 let listViewLines = Components.ListView.renderListView listViewWithSelection demoContext - listViewLines |> Stdlib.List.iter Stdlib.printLine // Dividers - printSection "Dividers" let divider1 = Components.Divider.createDivider "─" 40 Core.Types.Color.Default let divider2 = Components.Divider.createDivider "═" 40 Core.Types.Color.Primary let divider3 = Components.Divider.createDivider "Β·" 40 Core.Types.Color.Secondary let divider1Lines = Components.Divider.renderDivider divider1 demoContext - divider1Lines |> Stdlib.List.iter Stdlib.printLine let divider2Lines = Components.Divider.renderDivider divider2 demoContext - divider2Lines |> Stdlib.List.iter Stdlib.printLine let divider3Lines = Components.Divider.renderDivider divider3 demoContext - divider3Lines |> Stdlib.List.iter Stdlib.printLine // StatusBar - printSection "StatusBar" let statusBar = Components.StatusBar.createStatusBar "Ready" "Ln 42, Col 8" 50 let statusBarWithCenter = Components.StatusBar.setCenterText statusBar "main.dark" let statusBarLines = Components.StatusBar.renderStatusBar statusBarWithCenter demoContext - statusBarLines |> Stdlib.List.iter Stdlib.printLine // Pagination - printSection "Pagination" let pagination = Components.Pagination.createPagination 100 10 let paginationPage3 = Components.Pagination.goToPage pagination 3 let paginationLines = Components.Pagination.renderPagination paginationPage3 demoContext - paginationLines |> Stdlib.List.iter Stdlib.printLine // Form Components - printSection "Form Components" - - // TextInput - Stdlib.printLine "TextInput:" let textInput = Components.Forms.createTextInput "Enter name..." 20 let textInputLines = Components.Forms.renderTextInput textInput demoContext - textInputLines |> Stdlib.List.iter Stdlib.printLine - // Checkbox - Stdlib.printLine "Checkboxes:" let checkbox1 = Components.Forms.createCheckbox "Remember me" true let checkbox2 = Components.Forms.createCheckbox "Subscribe to newsletter" false let checkbox1Lines = Components.Forms.renderCheckbox checkbox1 demoContext - checkbox1Lines |> Stdlib.List.iter Stdlib.printLine let checkbox2Lines = Components.Forms.renderCheckbox checkbox2 demoContext - checkbox2Lines |> Stdlib.List.iter Stdlib.printLine - // Radio Group - Stdlib.printLine "Radio Group:" let radio = Components.Forms.createRadioGroup ["Option A"; "Option B"; "Option C"] 1 let radioLines = Components.Forms.renderRadioGroup radio demoContext - radioLines |> Stdlib.List.iter Stdlib.printLine // Confirm Dialog - printSection "Confirm Dialog" let confirmDialog = Components.Modal.createConfirmDialog "Delete File?" "Are you sure?" "Yes" "No" let dialogVisible = Components.Modal.showConfirmDialog confirmDialog let dialogLines = Components.Modal.renderConfirmDialog dialogVisible demoContext - dialogLines |> Stdlib.List.iter Stdlib.printLine // Colors - printSection "Colors" - Stdlib.printLine (Core.Rendering.colorize Core.Types.Color.Primary "β–ˆβ–ˆ Primary") - Stdlib.printLine (Core.Rendering.colorize Core.Types.Color.Success "β–ˆβ–ˆ Success") - Stdlib.printLine (Core.Rendering.colorize Core.Types.Color.Warning "β–ˆβ–ˆ Warning") - Stdlib.printLine (Core.Rendering.colorize Core.Types.Color.Error "β–ˆβ–ˆ Error") - Stdlib.printLine (Core.Rendering.colorize Core.Types.Color.Info "β–ˆβ–ˆ Info") - - Stdlib.printLine "" - Stdlib.printLine (Core.Rendering.dim "Demo complete.") + let colorLines = + [ Core.Rendering.colorize Core.Types.Color.Primary "β–ˆβ–ˆ Primary" + Core.Rendering.colorize Core.Types.Color.Success "β–ˆβ–ˆ Success" + Core.Rendering.colorize Core.Types.Color.Warning "β–ˆβ–ˆ Warning" + Core.Rendering.colorize Core.Types.Color.Error "β–ˆβ–ˆ Error" + Core.Rendering.colorize Core.Types.Color.Info "β–ˆβ–ˆ Info" ] + + Stdlib.List.flatten [ + header + section "Box Styles" + singleBox + doubleBox + roundedBox + section "Progress Bar" + progressLines + section "Spinner Styles" + spinnerLines + section "Table" + tableLines + section "Buttons" + btn1Lines + btn2Lines + btn3Lines + section "Card" + cardLines + section "Messages" + successLines + warningLines + errorLines + section "Toast" + toastLines + section "ListView" + listViewLines + section "Dividers" + divider1Lines + divider2Lines + divider3Lines + section "StatusBar" + statusBarLines + section "Pagination" + paginationLines + section "Form Components" + [ "TextInput:" ] + textInputLines + [ "Checkboxes:" ] + checkbox1Lines + checkbox2Lines + [ "Radio Group:" ] + radioLines + section "Confirm Dialog" + dialogLines + section "Colors" + colorLines + [ ""; Core.Rendering.dim "Demo complete." ] + ] + +let run (unit: Unit) : Int = + rows () |> Stdlib.printLines 0 diff --git a/packages/darklang/cli/ui/layout.dark b/packages/darklang/cli/ui/layout.dark deleted file mode 100644 index 2c58cecba5..0000000000 --- a/packages/darklang/cli/ui/layout.dark +++ /dev/null @@ -1,132 +0,0 @@ -module Darklang.Cli.UI.Layout - - -// --- Types --- - -/// What a component wants. -type SizeRequest = - { minRows: Int - minCols: Int - preferredRows: Int - preferredCols: Int } - -/// What a component gets. -type Region = - { top: Int - left: Int - rows: Int - cols: Int } - -/// A renderable component that can negotiate its size. -type Component = - { sizeRequest: Unit -> SizeRequest - render: Region -> Unit } - - -// --- Rendering primitives --- - -/// Move cursor to a position and print a string, truncated to fit the region. -let printAt (region: Region) (row: Int) (col: Int) (text: String) : Unit = - if row >= 0 && row < region.rows then - let absRow = region.top + row - let absCol = region.left + col - let maxLen = Stdlib.Int.max 0 (region.cols - col) - let truncated = - if Stdlib.String.length text > maxLen then - Stdlib.String.slice text 0 maxLen - else - text - Stdlib.print (Darklang.Cli.Colors.moveCursorTo absRow absCol) - Stdlib.print truncated - else - () - -/// Fill a row with a repeated character, within the region. -let fillRow (region: Region) (row: Int) (ch: String) : Unit = - let line = Stdlib.String.repeat ch region.cols - printAt region row 0 line - -/// Fill the entire region with spaces (clear it). -let clearRegion (region: Region) : Unit = - (Stdlib.List.range 0 (region.rows - 1)) - |> Stdlib.List.iter (fun row -> fillRow region row " ") - - -// --- Layout negotiation --- - -/// Distribute `totalRows` among components based on their size requests. -/// Each gets at least min, then remaining space is split toward preferred. -let distributeRows (totalRows: Int) (requests: List) : List = - let count = Stdlib.List.length requests - if count == 0 then - [] - else - // First pass: give everyone their minimum - let mins = Stdlib.List.map requests (fun r -> r.minRows) - let totalMin = Stdlib.List.fold mins 0 (fun acc n -> acc + n) - let remaining = Stdlib.Int.max 0 (totalRows - totalMin) - // Second pass: distribute remaining toward preferred, proportionally - let wants = - Stdlib.List.map requests (fun r -> - Stdlib.Int.max 0 (r.preferredRows - r.minRows)) - let totalWant = Stdlib.List.fold wants 0 (fun acc n -> acc + n) - if totalWant == 0 then - mins - else - // Give each component its min + share of remaining proportional to want - (Stdlib.List.map2 mins wants (fun minR want -> - let share = - if totalWant > 0 then - Stdlib.Int.divide (want * remaining) totalWant - else - 0 - minR + share)) - |> Stdlib.Option.withDefault mins - - -// --- Layout combinators --- - -/// Stack components vertically within a region. -/// Negotiates row distribution based on size requests. -let vstack (region: Region) (components: List) : Unit = - let requests = Stdlib.List.map components (fun c -> c.sizeRequest ()) - let rowAllocs = distributeRows region.rows requests - // Assign regions and render - let pairs = - (Stdlib.List.map2 components rowAllocs (fun c rows -> - (c, rows))) - |> Stdlib.Option.withDefault [] - let _finalTop = - pairs - |> Stdlib.List.fold region.top (fun currentTop pair -> - let (c, rows) = pair - let childRegion = - Region - { top = currentTop - left = region.left - rows = rows - cols = region.cols } - c.render childRegion - currentTop + rows) - () - -/// A fixed-height component: always requests exactly N rows. -let fixedSize (n: Int) (renderFn: Region -> Unit) : Component = - Component - { sizeRequest = fun () -> - SizeRequest { minRows = n; minCols = 0; preferredRows = n; preferredCols = 0 } - render = renderFn } - -/// A flexible component: needs at least `min` rows, prefers `preferred`. -let flex (min: Int) (preferred: Int) (renderFn: Region -> Unit) : Component = - Component - { sizeRequest = fun () -> - SizeRequest { minRows = min; minCols = 0; preferredRows = preferred; preferredCols = 0 } - render = renderFn } - -/// A greedy component: wants all available space, min 1 row. -let greedy (renderFn: Region -> Unit) : Component = - Component - { sizeRequest = fun () -> - SizeRequest { minRows = 1; minCols = 0; preferredRows = 999; preferredCols = 0 } - render = renderFn } diff --git a/packages/darklang/cli/utils/colors.dark b/packages/darklang/cli/utils/colors.dark index a88020a63c..28c41c2ea0 100644 --- a/packages/darklang/cli/utils/colors.dark +++ b/packages/darklang/cli/utils/colors.dark @@ -8,19 +8,6 @@ val italic = "\u001b[3m" val underline = "\u001b[4m" val reverse = "\u001b[7m" -// Cursor and line control -val clearLine = "\u001b[K" -val carriageReturn = "\r" -let moveCursorToColumn (col: Int) : String = $"\u001b[{Stdlib.Int.toString col}G" -let moveCursorUp (n: Int) : String = $"\u001b[{Stdlib.Int.toString n}A" -val saveCursor = "\u001b[s" -val restoreCursor = "\u001b[u" -/// Move cursor to absolute position (row, col). Used to jump to the status bar without disrupting where the user is typing. -let moveCursorTo (row: Int) (col: Int) : String = $"\u001b[{Stdlib.Int.toString row};{Stdlib.Int.toString col}H" -/// Define which rows can scroll. Content outside this region stays fixed. Used to keep the status bar pinned at the bottom. -let setScrollRegion (top: Int) (bottom: Int) : String = $"\u001b[{Stdlib.Int.toString top};{Stdlib.Int.toString bottom}r" -val resetScrollRegion = "\u001b[r" - // Standard colors val black = "\u001b[30m" val red = "\u001b[31m" diff --git a/packages/darklang/cli/utils/terminal.dark b/packages/darklang/cli/utils/terminal.dark index bd02ff47cb..8164ad7522 100644 --- a/packages/darklang/cli/utils/terminal.dark +++ b/packages/darklang/cli/utils/terminal.dark @@ -1,34 +1,5 @@ module Darklang.Cli.Terminal - -/// Display control constants -module Display = - val clearScreen = "\x1b[2J\x1b[H" - val enterAltScreen = "\x1b[?1049h" - val exitAltScreen = "\x1b[?1049l" - -/// Gets terminal height -let getHeight () : Int = - Builtin.cliGetTerminalHeight () - -/// Gets terminal width -let getWidth () : Int = - Builtin.cliGetTerminalWidth () - -/// Calculates viewport height for scrollable content -let getViewportHeight (reservedLines: Int) : Int = - let terminalHeight = getHeight () - let availableHeight = Stdlib.Int.max 1 (terminalHeight - reservedLines) - availableHeight - -/// Calculate scroll offset bounds for safe scrolling -let calculateScrollBounds (totalItems: Int) (viewportHeight: Int) : (Int * Int) = - let maxOffset = Stdlib.Int.max 0 (totalItems - viewportHeight) - (0, maxOffset) - -/// Clamp scroll offset to safe bounds -let clampScrollOffset (offset: Int) (totalItems: Int) (viewportHeight: Int) : Int = - let (minOffset, maxOffset) = calculateScrollBounds totalItems viewportHeight - if offset < minOffset then minOffset - else if offset > maxOffset then maxOffset - else offset \ No newline at end of file +/// Samples terminal width and height in one native call. +let getSize () : (Int * Int) = + Builtin.cliTerminalSize () diff --git a/packages/darklang/stdlib/cli/daemon.dark b/packages/darklang/stdlib/cli/daemon.dark index 79c4472681..eccfcf132c 100644 --- a/packages/darklang/stdlib/cli/daemon.dark +++ b/packages/darklang/stdlib/cli/daemon.dark @@ -25,10 +25,8 @@ let logPath (name: String) : String = // The daemon's last `n` log lines (newest at the bottom); empty if it never logged. let tailLog (name: String) (n: Int) : List = - match File.readLines (logPath name) with - | Ok lines -> - let total = Stdlib.List.length lines - if total <= n then lines else Stdlib.List.drop lines (total - n) + match File.readLastLines (logPath name) n with + | Ok lines -> lines | Error _ -> [] diff --git a/packages/darklang/stdlib/cli/file.dark b/packages/darklang/stdlib/cli/file.dark index 85d91328fb..4474a40ffc 100644 --- a/packages/darklang/stdlib/cli/file.dark +++ b/packages/darklang/stdlib/cli/file.dark @@ -102,6 +102,96 @@ let readLines (path: String) : Result.Result, Posix.Error> = |> Stdlib.Result.map (fun text -> Stdlib.String.splitOnNewline text) +let countNewlines (blob: Blob) : Int = + blob + |> Stdlib.Blob.toList + |> Stdlib.List.fold 0 (fun count byte -> + if byte == 10uy then count + 1 else count) + + +let dropThroughFirstNewline (bytes: List) : List = + match bytes with + | [] -> [] + | byte :: remaining -> + if byte == 10uy then remaining + else dropThroughFirstNewline remaining + + +let readTailChunks + (fd: Int) + (cursor: Int) + (requiredNewlines: Int) + (foundNewlines: Int) + (chunks: List) + : Result.Result<(Blob * Bool), Posix.Error> = + if cursor <= 0 || foundNewlines >= requiredNewlines then + Stdlib.Result.Result.Ok( + (Stdlib.Blob.concat chunks, cursor <= 0)) + else + let chunkSize = 1024 + let start = Stdlib.Int.max 0 (cursor - chunkSize) + let bytesToRead = cursor - start + + (Posix.fdSeekFromStart fd start) + |> Stdlib.Result.andThen (fun _position -> + (Posix.fdRead fd bytesToRead) + |> Stdlib.Result.andThen (fun chunk -> + readTailChunks + fd + start + requiredNewlines + (foundNewlines + countNewlines chunk) + (Stdlib.List.push chunks chunk))) + + +/// Read the last `count` lines without loading the whole file. +let readLastLines + (path: String) + (count: Int) + : Result.Result, Posix.Error> = + if count <= 0 then + Stdlib.Result.Result.Ok [] + else + (size path) + |> Stdlib.Result.andThen (fun fileSize -> + match Posix.openFile path (Posix.OpenFlags.rdonly ()) 0 with + | Error e -> Stdlib.Result.Result.Error e + | Ok fd -> + let tailResult = + readTailChunks fd fileSize count 0 [] + let _closed = Builtin.posixFdClose fd + + tailResult + |> Stdlib.Result.andThen (fun (blob, startedAtBeginning) -> + let bytes = + if startedAtBeginning then + Stdlib.Blob.toList blob + else + blob + |> Stdlib.Blob.toList + |> dropThroughFirstNewline + + match + bytes + |> Stdlib.Blob.fromList + |> Stdlib.Blob.toString + with + | Error message -> + Stdlib.Result.Result.Error + (Posix.Error { + errno = -1 + message = $"File contents not valid UTF-8: {message}" + }) + | Ok text -> + let lines = Stdlib.String.splitOnNewline text + let total = Stdlib.List.length lines + if total <= count then + Stdlib.Result.Result.Ok lines + else + Stdlib.Result.Result.Ok + (Stdlib.List.drop lines (total - count)))) + + /// Write text to a file, creating it if it doesn't exist or overwriting if it does. let writeText (path: String) (content: String) : Result.Result = let flags = diff --git a/packages/darklang/stdlib/cli/posix.dark b/packages/darklang/stdlib/cli/posix.dark index 034c8fc157..da48f11f02 100644 --- a/packages/darklang/stdlib/cli/posix.dark +++ b/packages/darklang/stdlib/cli/posix.dark @@ -37,13 +37,19 @@ module OpenFlags = let openFile (path: String) (flags: Int) (mode: Int) : Result.Result = Builtin.posixOpen path flags mode + +/// Read up to `count` bytes from an open file descriptor. +let fdRead (fd: Int) (count: Int) : Result.Result = + Builtin.posixFdRead fd count + + /// Read all bytes from a file descriptor until EOF. /// Accumulates chunks (as Blobs) and concats once at EOF. let fdReadAll (fd: Int) (acc: List) : Result.Result = - (Builtin.posixFdRead fd 4096) + (fdRead fd 4096) |> Stdlib.Result.andThen (fun chunk -> if Stdlib.Blob.length chunk == 0 then Stdlib.Result.Result.Ok(Stdlib.Blob.concat acc) @@ -51,6 +57,11 @@ let fdReadAll fdReadAll fd (Stdlib.List.append acc [ chunk ])) +/// Seek to a byte offset from the start of an open file. +let fdSeekFromStart (fd: Int) (offset: Int) : Result.Result = + Builtin.posixFdSeek fd offset 0 + + /// Stat a file. Returns mode, size, and modification time. let stat (path: String) : Result.Result = (Builtin.posixStat path)