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 "[2J[H"
- 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)