From 627f5dd5c63aab430141e5709f35d4247be007c0 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 16:52:15 +0800 Subject: [PATCH 001/404] chore(shell): move app stub into console shell workspace --- console/.gitignore | 1 + console/shell/.gitignore | 2 ++ console/shell/go.mod | 3 +++ console/shell/package.json | 10 ++++++++++ console/shell/portal/main.go | 7 +++++++ pnpm-lock.yaml | 2 ++ pnpm-workspace.yaml | 1 + turbo.json | 2 +- 8 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 console/.gitignore create mode 100644 console/shell/.gitignore create mode 100644 console/shell/go.mod create mode 100644 console/shell/package.json create mode 100644 console/shell/portal/main.go diff --git a/console/.gitignore b/console/.gitignore new file mode 100644 index 00000000..e660fd93 --- /dev/null +++ b/console/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/console/shell/.gitignore b/console/shell/.gitignore new file mode 100644 index 00000000..3515c4b9 --- /dev/null +++ b/console/shell/.gitignore @@ -0,0 +1,2 @@ +*.test +coverage.out diff --git a/console/shell/go.mod b/console/shell/go.mod new file mode 100644 index 00000000..904edf14 --- /dev/null +++ b/console/shell/go.mod @@ -0,0 +1,3 @@ +module github.com/oullin/tui + +go 1.26.3 diff --git a/console/shell/package.json b/console/shell/package.json new file mode 100644 index 00000000..ab6212f2 --- /dev/null +++ b/console/shell/package.json @@ -0,0 +1,10 @@ +{ + "name": "@ollin/shell", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "go build -o ../bin/shell ./portal", + "test": "go test ./...", + "typecheck": "go test ./... -run '^$'" + } +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go new file mode 100644 index 00000000..6480e315 --- /dev/null +++ b/console/shell/portal/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("Oullin TUI shell") +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e78e003a..92d2dac4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,8 @@ importers: specifier: ^4.1.5 version: 4.1.8(@types/node@25.9.2)(vite@8.0.16(@types/node@25.9.2)) + console/shell: {} + packages/acceptance: dependencies: '@ollin/tui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff9eb2d5..5d048023 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "packages/*" + - "console/shell" onlyBuiltDependencies: - "@opentui/core" diff --git a/turbo.json b/turbo.json index 7e037ab3..184e420f 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**", "console/bin/**"] }, "test": { "dependsOn": ["^test"], From 2423fe2edcf168a4349c1e9f9b82e06ab82e4b06 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 16:55:28 +0800 Subject: [PATCH 002/404] feat(shell): port terminal key string and theme primitives --- console/shell/go.mod | 4 + console/shell/go.sum | 4 + console/shell/internal/key/key.go | 175 ++++++++++ console/shell/internal/key/key_test.go | 61 ++++ console/shell/internal/strings/strings.go | 300 ++++++++++++++++++ .../shell/internal/strings/strings_test.go | 97 ++++++ console/shell/internal/terminal/terminal.go | 93 ++++++ .../shell/internal/terminal/terminal_test.go | 39 +++ console/shell/internal/theme/box.go | 79 +++++ console/shell/internal/theme/styles.go | 63 ++++ console/shell/internal/theme/theme.go | 121 +++++++ console/shell/internal/theme/theme_test.go | 47 +++ 12 files changed, 1083 insertions(+) create mode 100644 console/shell/go.sum create mode 100644 console/shell/internal/key/key.go create mode 100644 console/shell/internal/key/key_test.go create mode 100644 console/shell/internal/strings/strings.go create mode 100644 console/shell/internal/strings/strings_test.go create mode 100644 console/shell/internal/terminal/terminal.go create mode 100644 console/shell/internal/terminal/terminal_test.go create mode 100644 console/shell/internal/theme/box.go create mode 100644 console/shell/internal/theme/styles.go create mode 100644 console/shell/internal/theme/theme.go create mode 100644 console/shell/internal/theme/theme_test.go diff --git a/console/shell/go.mod b/console/shell/go.mod index 904edf14..efdb8bef 100644 --- a/console/shell/go.mod +++ b/console/shell/go.mod @@ -1,3 +1,7 @@ module github.com/oullin/tui go 1.26.3 + +require github.com/mattn/go-runewidth v0.0.24 + +require github.com/clipperhouse/uax29/v2 v2.2.0 // indirect diff --git a/console/shell/go.sum b/console/shell/go.sum new file mode 100644 index 00000000..dfdcfc87 --- /dev/null +++ b/console/shell/go.sum @@ -0,0 +1,4 @@ +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= diff --git a/console/shell/internal/key/key.go b/console/shell/internal/key/key.go new file mode 100644 index 00000000..775aca55 --- /dev/null +++ b/console/shell/internal/key/key.go @@ -0,0 +1,175 @@ +package key + +type Value []string + +type Event struct { + Name string + Sequence string + Ctrl bool + Meta bool + Shift bool +} + +var ( + Up = Value{"\x1b[A"} + ShiftUp = Value{"\x1b[1;2A"} + PageUp = Value{"\x1b[5~"} + Down = Value{"\x1b[B"} + ShiftDown = Value{"\x1b[1;2B"} + PageDown = Value{"\x1b[6~"} + Right = Value{"\x1b[C"} + Left = Value{"\x1b[D"} + UpArrow = Value{"\x1bOA"} + DownArrow = Value{"\x1bOB"} + RightArrow = Value{"\x1bOC"} + LeftArrow = Value{"\x1bOD"} + Escape = Value{"\x1b"} + Delete = Value{"\x1b[3~"} + Backspace = Value{"\x7f"} + Enter = Value{"\n"} + Space = Value{" "} + Tab = Value{"\t"} + ShiftTab = Value{"\x1b[Z"} + Home = Value{"\x1b[1~", "\x1bOH", "\x1b[H", "\x1b[7~"} + End = Value{"\x1b[4~", "\x1bOF", "\x1b[F", "\x1b[8~"} + CtrlC = Value{"\x03"} + CtrlP = Value{"\x10"} + CtrlN = Value{"\x0e"} + CtrlF = Value{"\x06"} + CtrlB = Value{"\x02"} + CtrlH = Value{"\x08"} + CtrlA = Value{"\x01"} + CtrlD = Value{"\x04"} + CtrlE = Value{"\x05"} + CtrlU = Value{"\x15"} + OptionBackspace = Value{"\x1b\x7f"} +) + +var named = map[string]Value{ + "up": Up, + "shiftUp": ShiftUp, + "pageUp": PageUp, + "down": Down, + "shiftDown": ShiftDown, + "pageDown": PageDown, + "right": Right, + "left": Left, + "upArrow": UpArrow, + "downArrow": DownArrow, + "rightArrow": RightArrow, + "leftArrow": LeftArrow, + "escape": Escape, + "delete": Delete, + "backspace": Backspace, + "enter": Enter, + "space": Space, + "tab": Tab, + "shiftTab": ShiftTab, + "home": Home, + "end": End, + "ctrlC": CtrlC, + "ctrlP": CtrlP, + "ctrlN": CtrlN, + "ctrlF": CtrlF, + "ctrlB": CtrlB, + "ctrlH": CtrlH, + "ctrlA": CtrlA, + "ctrlD": CtrlD, + "ctrlE": CtrlE, + "ctrlU": CtrlU, + "optionBackspace": OptionBackspace, +} + +var ctrlKeys = map[string]Value{ + "a": CtrlA, + "b": CtrlB, + "c": CtrlC, + "d": CtrlD, + "e": CtrlE, + "f": CtrlF, + "h": CtrlH, + "n": CtrlN, + "p": CtrlP, + "u": CtrlU, +} + +var aliases = map[string]Value{ + "pagedown": PageDown, + "pageup": PageUp, + "return": Enter, +} + +func First(value Value) string { + if len(value) == 0 { + return "" + } + + return value[0] +} + +func FromEvent(event Event) string { + name := lowerASCII(event.Name) + + if event.Ctrl && name != "" { + if value, ok := ctrlKeys[name]; ok { + return First(value) + } + } + + if event.Meta && event.Name == "backspace" { + return First(OptionBackspace) + } + + if event.Shift && name == "tab" { + return First(ShiftTab) + } + + if name != "" { + if value, ok := aliases[name]; ok { + return First(value) + } + } + + if event.Name != "" { + if value, ok := named[event.Name]; ok { + return First(value) + } + } + + if event.Sequence == " " { + return First(Space) + } + + if event.Sequence == "\r" || event.Sequence == "\n" { + return First(Enter) + } + + if event.Name != "" { + return event.Name + } + + return event.Sequence +} + +func OneOf(keys []Value, match string) (string, bool) { + for _, value := range keys { + for _, candidate := range value { + if candidate == match { + return match, true + } + } + } + + return "", false +} + +func lowerASCII(value string) string { + bytes := []byte(value) + for index, char := range bytes { + if char >= 'A' && char <= 'Z' { + bytes[index] = char + ('a' - 'A') + } + } + + return string(bytes) +} diff --git a/console/shell/internal/key/key_test.go b/console/shell/internal/key/key_test.go new file mode 100644 index 00000000..713ec8a3 --- /dev/null +++ b/console/shell/internal/key/key_test.go @@ -0,0 +1,61 @@ +package key + +import "testing" + +func TestFromEventMapsNamedKeys(t *testing.T) { + if got := FromEvent(Event{Name: "enter"}); got != First(Enter) { + t.Fatalf("FromEvent() = %q, want %q", got, First(Enter)) + } +} + +func TestFromEventMapsAliases(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "return", want: First(Enter)}, + {name: "pageup", want: First(PageUp)}, + {name: "pagedown", want: First(PageDown)}, + } + + for _, test := range tests { + if got := FromEvent(Event{Name: test.name}); got != test.want { + t.Fatalf("FromEvent(%q) = %q, want %q", test.name, got, test.want) + } + } +} + +func TestFromEventMapsSequencesAndModifiers(t *testing.T) { + tests := []struct { + event Event + want string + }{ + {event: Event{Sequence: " "}, want: First(Space)}, + {event: Event{Sequence: "\n"}, want: First(Enter)}, + {event: Event{Ctrl: true, Name: "c"}, want: First(CtrlC)}, + {event: Event{Ctrl: true, Name: "n"}, want: First(CtrlN)}, + {event: Event{Ctrl: true, Name: "U"}, want: First(CtrlU)}, + {event: Event{Meta: true, Name: "backspace"}, want: First(OptionBackspace)}, + {event: Event{Shift: true, Name: "tab"}, want: First(ShiftTab)}, + } + + for _, test := range tests { + if got := FromEvent(test.event); got != test.want { + t.Fatalf("FromEvent(%+v) = %q, want %q", test.event, got, test.want) + } + } +} + +func TestOneOfMatchesGroupedKeys(t *testing.T) { + if got, ok := OneOf([]Value{Home, End}, Home[2]); !ok || got != Home[2] { + t.Fatalf("OneOf(Home[2]) = %q, %v", got, ok) + } + + if got, ok := OneOf([]Value{Home, End}, End[3]); !ok || got != End[3] { + t.Fatalf("OneOf(End[3]) = %q, %v", got, ok) + } + + if _, ok := OneOf([]Value{Home, End}, First(Enter)); ok { + t.Fatalf("OneOf(Enter) matched unexpectedly") + } +} diff --git a/console/shell/internal/strings/strings.go b/console/shell/internal/strings/strings.go new file mode 100644 index 00000000..4d1bcbb2 --- /dev/null +++ b/console/shell/internal/strings/strings.go @@ -0,0 +1,300 @@ +package strings + +import ( + stdstrings "strings" + "unicode/utf8" + + "github.com/mattn/go-runewidth" +) + +const esc = "\x1b" + +type AnsiSegment struct { + Codes string + Text string +} + +var resetCodes = map[string]bool{ + "0": true, + "22": true, + "23": true, + "24": true, + "27": true, + "29": true, + "39": true, + "49": true, +} + +func VisibleWidth(value string) int { + return runewidth.StringWidth(ParseAnsiText(value)) +} + +func Truncate(value string, width int, marker ...string) string { + suffix := "..." + if len(marker) > 0 { + suffix = marker[0] + } + + if width <= 0 { + return "" + } + + if VisibleWidth(value) <= width { + return value + } + + if width <= VisibleWidth(suffix) { + clipped := "" + for _, char := range suffix { + next := clipped + string(char) + if VisibleWidth(next) > width { + break + } + clipped = next + } + + return clipped + } + + result := "" + activeCodes := "" + markerWidth := VisibleWidth(suffix) + + for _, segment := range ParseAnsiSegments(value) { + if segment.Codes != activeCodes { + if activeCodes != "" { + result += ansiCloseSequence(activeCodes) + } + if segment.Codes != "" { + result += segment.Codes + } + activeCodes = segment.Codes + } + + for _, char := range segment.Text { + if VisibleWidth(result+string(char))+markerWidth > width { + if activeCodes != "" { + result += ansiCloseSequence(activeCodes) + } + + return result + suffix + } + + result += string(char) + } + } + + return result +} + +func ParseAnsiSegments(value string) []AnsiSegment { + segments := make([]AnsiSegment, 0) + currentCodes := "" + currentText := "" + + for index := 0; index < len(value); { + if value[index] == '\x1b' && index+1 < len(value) && value[index+1] == '[' { + if currentText != "" { + segments = append(segments, AnsiSegment{Codes: currentCodes, Text: currentText}) + currentText = "" + } + + start := index + index += 2 + for index < len(value) && value[index] != 'm' { + index++ + } + + if index < len(value) && value[index] == 'm' { + index++ + sequence := value[start:index] + code := sequence[2 : len(sequence)-1] + if resetCodes[code] { + currentCodes = "" + } else { + currentCodes = sequence + } + continue + } + + currentText += value[start:] + break + } + + runeValue, size := utf8.DecodeRuneInString(value[index:]) + currentText += string(runeValue) + index += size + } + + if currentText != "" { + segments = append(segments, AnsiSegment{Codes: currentCodes, Text: currentText}) + } + + return segments +} + +func ParseAnsiText(value string) string { + var builder stdstrings.Builder + + for _, segment := range ParseAnsiSegments(value) { + builder.WriteString(segment.Text) + } + + return builder.String() +} + +func Wrap(value string, width int) []string { + plainLines := plainWrap(ParseAnsiText(value), width) + characters := codedCharacters(value) + result := make([]string, 0, len(plainLines)) + characterIndex := 0 + + for _, plainLine := range plainLines { + line := "" + activeCodes := "" + + for _, plainCharacter := range plainLine { + for characterIndex < len(characters) && characters[characterIndex].Text != string(plainCharacter) { + if characters[characterIndex].Text == " " { + characterIndex++ + continue + } + + break + } + + if characterIndex >= len(characters) { + line += string(plainCharacter) + continue + } + + character := characters[characterIndex] + if character.Codes != activeCodes { + if activeCodes != "" { + line += "\x1b[0m" + } + if character.Codes != "" { + line += character.Codes + } + activeCodes = character.Codes + } + + line += string(plainCharacter) + characterIndex++ + } + + if activeCodes != "" { + line += ansiCloseSequence(activeCodes) + } + + result = append(result, line) + } + + return result +} + +func ansiCloseSequence(codes string) string { + code := codes[2 : len(codes)-1] + + switch code { + case "1", "2": + return esc + "[22m" + case "3": + return esc + "[23m" + case "4": + return esc + "[24m" + case "7": + return esc + "[27m" + case "9": + return esc + "[29m" + default: + return esc + "[0m" + } +} + +func codedCharacters(value string) []AnsiSegment { + characters := make([]AnsiSegment, 0) + + for _, segment := range ParseAnsiSegments(value) { + for _, char := range segment.Text { + characters = append(characters, AnsiSegment{Codes: segment.Codes, Text: string(char)}) + } + } + + return characters +} + +func plainWrap(value string, width int) []string { + if width <= 0 { + return []string{value} + } + + lines := make([]string, 0) + for _, originalLine := range stdstrings.Split(value, "\n") { + line := "" + + for _, word := range splitWhitespace(originalLine) { + if VisibleWidth(line+word) <= width { + line += word + continue + } + + if line != "" { + lines = append(lines, stdstrings.TrimRight(line, " \t\r\n")) + line = "" + } + + if VisibleWidth(word) > width { + chunk := "" + for _, char := range word { + next := chunk + string(char) + if VisibleWidth(next) > width { + if chunk != "" { + lines = append(lines, chunk) + } + chunk = "" + } + chunk += string(char) + } + line = chunk + continue + } + + line = stdstrings.TrimLeft(word, " \t\r\n") + } + + lines = append(lines, stdstrings.TrimRight(line, " \t\r\n")) + } + + return lines +} + +func splitWhitespace(value string) []string { + if value == "" { + return []string{""} + } + + parts := make([]string, 0) + current := "" + currentWhitespace := isWhitespace(rune(value[0])) + + for _, char := range value { + whitespace := isWhitespace(char) + if current != "" && whitespace != currentWhitespace { + parts = append(parts, current) + current = "" + } + current += string(char) + currentWhitespace = whitespace + } + + if current != "" { + parts = append(parts, current) + } + + return parts +} + +func isWhitespace(char rune) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} diff --git a/console/shell/internal/strings/strings_test.go b/console/shell/internal/strings/strings_test.go new file mode 100644 index 00000000..920c086f --- /dev/null +++ b/console/shell/internal/strings/strings_test.go @@ -0,0 +1,97 @@ +package strings + +import ( + "reflect" + stdstrings "strings" + "testing" +) + +func TestVisibleWidthIgnoresANSI(t *testing.T) { + if got := VisibleWidth("\x1b[31mPrompts\x1b[39m"); got != 7 { + t.Fatalf("VisibleWidth() = %d, want 7", got) + } +} + +func TestTruncate(t *testing.T) { + if got := Truncate("Ollin Prompts", 10); got != "Ollin P..." { + t.Fatalf("Truncate() = %q", got) + } +} + +func TestTruncateClosesANSIStyles(t *testing.T) { + result := Truncate("\x1b[31mOllin Prompts\x1b[39m", 10) + + if got := ParseAnsiText(result); got != "Ollin P..." { + t.Fatalf("ParseAnsiText(truncated) = %q", got) + } + if !stdstrings.Contains(result, "\x1b[31m") { + t.Fatalf("expected red ANSI open sequence in %q", result) + } + if !stdstrings.Contains(result, "\x1b[0m...") { + t.Fatalf("expected reset before marker in %q", result) + } + if stdstrings.HasSuffix(result, "\x1b[39m") { + t.Fatalf("did not expect foreground reset suffix in %q", result) + } +} + +func TestTruncateClipsMarkers(t *testing.T) { + if got := Truncate("Ollin Prompts", 1, "…"); got != "…" { + t.Fatalf("Truncate(marker) = %q", got) + } + if got := Truncate("Ollin Prompts", 1, "東京"); got != "" { + t.Fatalf("Truncate(wide marker) = %q", got) + } +} + +func TestWrap(t *testing.T) { + if got := Wrap("Ollin Prompts", 7); !reflect.DeepEqual(got, []string{"Ollin", "Prompts"}) { + t.Fatalf("Wrap() = %#v", got) + } + + if got := Wrap("πŸ˜€", 1); !reflect.DeepEqual(got, []string{"πŸ˜€"}) { + t.Fatalf("Wrap(wide) = %#v", got) + } +} + +func TestParseAnsi(t *testing.T) { + if got := ParseAnsiText("\x1b[32mDone\x1b[39m"); got != "Done" { + t.Fatalf("ParseAnsiText() = %q", got) + } + + if got := ParseAnsiSegments("Hello, World!"); !reflect.DeepEqual(got, []AnsiSegment{{Text: "Hello, World!", Codes: ""}}) { + t.Fatalf("ParseAnsiSegments(plain) = %#v", got) + } + + want := []AnsiSegment{{Text: "Hello ", Codes: ""}, {Text: "Bold", Codes: "\x1b[1m"}, {Text: " World", Codes: ""}} + if got := ParseAnsiSegments("Hello \x1b[1mBold\x1b[0m World"); !reflect.DeepEqual(got, want) { + t.Fatalf("ParseAnsiSegments(styled) = %#v", got) + } + + want = []AnsiSegment{{Text: "Colored", Codes: "\x1b[38;2;255;100;50m"}} + if got := ParseAnsiSegments("\x1b[38;2;255;100;50mColored\x1b[0m"); !reflect.DeepEqual(got, want) { + t.Fatalf("ParseAnsiSegments(rgb) = %#v", got) + } +} + +func TestWrapPreservesANSI(t *testing.T) { + result := Wrap("\x1b[31mHello World\x1b[0m", 5) + + if len(result) != 2 { + t.Fatalf("Wrap() len = %d, want 2: %#v", len(result), result) + } + for _, want := range []string{"Hello", "World"} { + found := false + for _, line := range result { + found = found || stdstrings.Contains(line, want) + } + if !found { + t.Fatalf("expected wrapped output to contain %q: %#v", want, result) + } + } + for _, line := range result { + if !stdstrings.Contains(line, "\x1b[31m") || !stdstrings.HasSuffix(line, "\x1b[0m") { + t.Fatalf("wrapped line does not preserve ANSI: %q", line) + } + } +} diff --git a/console/shell/internal/terminal/terminal.go b/console/shell/internal/terminal/terminal.go new file mode 100644 index 00000000..b446c297 --- /dev/null +++ b/console/shell/internal/terminal/terminal.go @@ -0,0 +1,93 @@ +package terminal + +import ( + "io" + "math" + "os" +) + +type Size struct { + Columns int + Rows int +} + +type Color [3]int + +func DefaultSize() Size { + return Size{Columns: 80, Rows: 24} +} + +func ClearSequence() string { + return "\x1b[H\x1b[J" +} + +func TitleSequence(title string) string { + return "\x1b]0;" + title + "\x07" +} + +func EraseLineSequence() string { + return "\x1b[2K" +} + +func ErasePreviousLinesSequence(count int) string { + lines := int(math.Max(0, math.Floor(float64(count)))) + result := "" + for range lines { + result += "\x1b[1A\x1b[2K" + } + + return result +} + +func CursorToStartSequence() string { + return "\r" +} + +func HideCursorSequence() string { + return "\x1b[?25l" +} + +func ShowCursorSequence() string { + return "\x1b[?25h" +} + +func WriteClear(writer io.Writer) error { + _, err := io.WriteString(writer, ClearSequence()) + return err +} + +func WriteTitle(writer io.Writer, title string) error { + _, err := io.WriteString(writer, TitleSequence(title)) + return err +} + +func SupportsTrueColor(value ...string) bool { + colorTerm := os.Getenv("COLORTERM") + if len(value) > 0 { + colorTerm = value[0] + } + + return colorTerm == "truecolor" || colorTerm == "24bit" +} + +func DefaultForegroundColor() Color { + return Color{204, 204, 204} +} + +func DefaultBackgroundColor() Color { + return Color{0, 0, 0} +} + +func ParseColor(value []int, fallback Color) Color { + if len(value) != 3 { + return fallback + } + + for _, part := range value { + if part < 0 || part > 255 { + return fallback + } + } + + return Color{value[0], value[1], value[2]} +} diff --git a/console/shell/internal/terminal/terminal_test.go b/console/shell/internal/terminal/terminal_test.go new file mode 100644 index 00000000..c6ccf2a5 --- /dev/null +++ b/console/shell/internal/terminal/terminal_test.go @@ -0,0 +1,39 @@ +package terminal + +import "testing" + +func TestControlSequences(t *testing.T) { + if got := ClearSequence(); got != "\x1b[H\x1b[J" { + t.Fatalf("ClearSequence() = %q", got) + } + if got := TitleSequence("Demo"); got != "\x1b]0;Demo\x07" { + t.Fatalf("TitleSequence() = %q", got) + } + if got := CursorToStartSequence() + EraseLineSequence() + ErasePreviousLinesSequence(2) + ErasePreviousLinesSequence(-1) + HideCursorSequence() + ShowCursorSequence(); got != "\r\x1b[2K\x1b[1A\x1b[2K\x1b[1A\x1b[2K\x1b[?25l\x1b[?25h" { + t.Fatalf("combined sequences = %q", got) + } +} + +func TestSupportsTrueColor(t *testing.T) { + if !SupportsTrueColor("truecolor") { + t.Fatalf("expected truecolor to be supported") + } + if !SupportsTrueColor("24bit") { + t.Fatalf("expected 24bit to be supported") + } + if SupportsTrueColor("256color") { + t.Fatalf("expected 256color to be unsupported") + } +} + +func TestParseColor(t *testing.T) { + if got := ParseColor([]int{1, 2, 3}, DefaultForegroundColor()); got != (Color{1, 2, 3}) { + t.Fatalf("ParseColor() = %#v", got) + } + if got := ParseColor([]int{0, 0, 300}, DefaultBackgroundColor()); got != DefaultBackgroundColor() { + t.Fatalf("ParseColor(invalid) = %#v", got) + } + if got := ParseColor([]int{1, 2}, DefaultForegroundColor()); got != DefaultForegroundColor() { + t.Fatalf("ParseColor(short) = %#v", got) + } +} diff --git a/console/shell/internal/theme/box.go b/console/shell/internal/theme/box.go new file mode 100644 index 00000000..f7245da2 --- /dev/null +++ b/console/shell/internal/theme/box.go @@ -0,0 +1,79 @@ +package theme + +import ( + stdstrings "strings" + + text "github.com/oullin/tui/internal/strings" +) + +const defaultBoxWidth = 60 + +type BoxOptions struct { + Body string + BorderStyle func(string) string + Info string + Title string + Width int +} + +func RenderBox(options BoxOptions) string { + borderStyle := options.BorderStyle + if borderStyle == nil { + borderStyle = func(value string) string { return value } + } + + width := options.Width + if width == 0 { + width = defaultBoxWidth + } + + bodyLines := stdstrings.Split(options.Body, "\n") + contentWidth := width + if titleWidth := text.VisibleWidth(options.Title); titleWidth > contentWidth { + contentWidth = titleWidth + } + for _, line := range bodyLines { + if lineWidth := text.VisibleWidth(line); lineWidth > contentWidth { + contentWidth = lineWidth + } + } + + titleWidth := text.VisibleWidth(options.Title) + titleLabel := "" + if titleWidth > 0 { + titleLabel = " " + options.Title + " " + } + + topBorderWidth := contentWidth - titleWidth + if titleWidth == 0 { + topBorderWidth += 2 + } + + renderedInfo := "" + if options.Info != "" { + renderedInfo = text.Truncate(options.Info, contentWidth-1) + } + + bottomBorderWidth := contentWidth + 2 + if renderedInfo != "" { + bottomBorderWidth = contentWidth - text.VisibleWidth(renderedInfo) + } + + lines := []string{ + borderStyle(" β”Œ") + titleLabel + borderStyle(stdstrings.Repeat("─", topBorderWidth)+"┐"), + } + + for _, line := range bodyLines { + lines = append(lines, borderStyle(" β”‚")+" "+padVisible(line, contentWidth)+" "+borderStyle("β”‚")) + } + + bottom := " β””" + stdstrings.Repeat("─", bottomBorderWidth) + if renderedInfo != "" { + bottom += " " + renderedInfo + " " + } + bottom += "β”˜" + + lines = append(lines, borderStyle(bottom)) + + return stdstrings.Join(lines, "\n") +} diff --git a/console/shell/internal/theme/styles.go b/console/shell/internal/theme/styles.go new file mode 100644 index 00000000..f573253f --- /dev/null +++ b/console/shell/internal/theme/styles.go @@ -0,0 +1,63 @@ +package theme + +import "fmt" + +func ansi(open int, close int, value string) string { + if value == "" { + return "" + } + + return fmt.Sprintf("\x1b[%dm%s\x1b[%dm", open, value, close) +} + +func Dim(value string) string { + return ansi(2, 22, value) +} + +func Cyan(value string) string { + return ansi(36, 39, value) +} + +func Green(value string) string { + return ansi(32, 39, value) +} + +func Inverse(value string) string { + return ansi(7, 27, value) +} + +func Black(value string) string { + return ansi(30, 39, value) +} + +func Red(value string) string { + return ansi(31, 39, value) +} + +func Strikethrough(value string) string { + return ansi(9, 29, value) +} + +func White(value string) string { + return ansi(37, 39, value) +} + +func Yellow(value string) string { + return ansi(33, 39, value) +} + +func BackgroundCyan(value string) string { + return ansi(46, 49, value) +} + +func BackgroundRed(value string) string { + return ansi(41, 49, value) +} + +func ForegroundRGB(value string, red int, green int, blue int) string { + if value == "" { + return "" + } + + return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", red, green, blue, value) +} diff --git a/console/shell/internal/theme/theme.go b/console/shell/internal/theme/theme.go new file mode 100644 index 00000000..d424d367 --- /dev/null +++ b/console/shell/internal/theme/theme.go @@ -0,0 +1,121 @@ +package theme + +import ( + "fmt" + stdstrings "strings" + + text "github.com/oullin/tui/internal/strings" +) + +const ( + SymbolQuestion = "?" + SymbolAnswer = ">" + SymbolError = "!" + SymbolInfo = "i" + SymbolSelected = "●" + SymbolUnselected = "β—‹" + SymbolPointer = "β€Ί" + SymbolSuccess = "βœ“" + SymbolWarning = "!" +) + +func RenderQuestion(message string, hint string) string { + suffix := "" + if hint != "" { + suffix = " " + hint + } + + return fmt.Sprintf("%s %s%s ", SymbolQuestion, message, suffix) +} + +func RenderError(message string) string { + return Red(fmt.Sprintf("%s %s", SymbolError, message)) + "\n" +} + +func RenderTable(headers []string, rows [][]string) string { + columnCount := len(headers) + for _, row := range rows { + if len(row) > columnCount { + columnCount = len(row) + } + } + + if columnCount <= 0 { + return "" + } + + widths := make([]int, columnCount) + for index := range columnCount { + widths[index] = text.VisibleWidth(valueAt(headers, index)) + for _, row := range rows { + width := text.VisibleWidth(valueAt(row, index)) + if width > widths[index] { + widths[index] = width + } + } + } + + renderRow := func(columns []string) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = padVisible(valueAt(columns, index), width) + } + + return " β”‚ " + stdstrings.Join(parts, " β”‚ ") + " β”‚" + } + + renderBorder := func(left string, middle string, right string) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = stdstrings.Repeat("─", width+2) + } + + return " " + left + stdstrings.Join(parts, middle) + right + } + + top := renderBorder("β”Œ", "┬", "┐") + divider := renderBorder("β”œ", "β”Ό", "─") + bottom := renderBorder("β””", "β”΄", "β”˜") + + if len(headers) == 0 { + lines := append([]string{top}, mapRows(rows, renderRow)...) + lines = append(lines, bottom) + return stdstrings.Join(lines, "\n") + } + + dimmedHeaders := make([]string, len(headers)) + for index, header := range headers { + dimmedHeaders[index] = Dim(header) + } + + lines := []string{top, renderRow(dimmedHeaders), divider} + lines = append(lines, mapRows(rows, renderRow)...) + lines = append(lines, bottom) + return stdstrings.Join(lines, "\n") +} + +func valueAt(values []string, index int) string { + if index < 0 || index >= len(values) { + return "" + } + + return values[index] +} + +func padVisible(value string, width int) string { + padding := width - text.VisibleWidth(value) + if padding < 0 { + padding = 0 + } + + return value + stdstrings.Repeat(" ", padding) +} + +func mapRows(rows [][]string, render func([]string) string) []string { + result := make([]string, len(rows)) + for index, row := range rows { + result[index] = render(row) + } + + return result +} diff --git a/console/shell/internal/theme/theme_test.go b/console/shell/internal/theme/theme_test.go new file mode 100644 index 00000000..6b7c89a8 --- /dev/null +++ b/console/shell/internal/theme/theme_test.go @@ -0,0 +1,47 @@ +package theme + +import ( + stdstrings "strings" + "testing" +) + +func TestStyles(t *testing.T) { + if got := Red("Error"); got != "\x1b[31mError\x1b[39m" { + t.Fatalf("Red() = %q", got) + } + if got := Dim(""); got != "" { + t.Fatalf("Dim(empty) = %q", got) + } + if got := ForegroundRGB("Text", 1, 2, 3); got != "\x1b[38;2;1;2;3mText\x1b[0m" { + t.Fatalf("ForegroundRGB() = %q", got) + } +} + +func TestRenderQuestionAndError(t *testing.T) { + if got := RenderQuestion("Name?", "required"); got != "? Name? required " { + t.Fatalf("RenderQuestion() = %q", got) + } + if got := RenderError("Required"); got != "\x1b[31m! Required\x1b[39m\n" { + t.Fatalf("RenderError() = %q", got) + } +} + +func TestRenderTable(t *testing.T) { + result := RenderTable([]string{"Name", "Role"}, [][]string{{"Ada", "Admin"}, {"Linus", "User"}}) + + for _, want := range []string{"β”Œβ”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”", "\x1b[2mName\x1b[22m", "Ada", "Linus", "β””β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜"} { + if !stdstrings.Contains(result, want) { + t.Fatalf("RenderTable() missing %q in:\n%s", want, result) + } + } +} + +func TestRenderBox(t *testing.T) { + result := RenderBox(BoxOptions{Body: "Ready", Title: "Task", Width: 10}) + + for _, want := range []string{" β”Œ Task ──────┐", " β”‚ Ready β”‚", " β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"} { + if !stdstrings.Contains(result, want) { + t.Fatalf("RenderBox() missing %q in:\n%s", want, result) + } + } +} From 8f9e74476e170827551d76e9f66221730df93074 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 16:58:23 +0800 Subject: [PATCH 003/404] feat(shell): port prompt environment and typed input engine --- .../shell/internal/environment/environment.go | 82 ++++++ .../internal/environment/environment_test.go | 121 +++++++++ console/shell/internal/environment/errors.go | 25 ++ .../internal/environment/memory_output.go | 33 +++ .../internal/environment/scripted_input.go | 34 +++ console/shell/internal/typedvalue/lines.go | 168 ++++++++++++ .../shell/internal/typedvalue/typedvalue.go | 247 ++++++++++++++++++ .../internal/typedvalue/typedvalue_test.go | 139 ++++++++++ 8 files changed, 849 insertions(+) create mode 100644 console/shell/internal/environment/environment.go create mode 100644 console/shell/internal/environment/environment_test.go create mode 100644 console/shell/internal/environment/errors.go create mode 100644 console/shell/internal/environment/memory_output.go create mode 100644 console/shell/internal/environment/scripted_input.go create mode 100644 console/shell/internal/typedvalue/lines.go create mode 100644 console/shell/internal/typedvalue/typedvalue.go create mode 100644 console/shell/internal/typedvalue/typedvalue_test.go diff --git a/console/shell/internal/environment/environment.go b/console/shell/internal/environment/environment.go new file mode 100644 index 00000000..022b3d32 --- /dev/null +++ b/console/shell/internal/environment/environment.go @@ -0,0 +1,82 @@ +package environment + +import "sync" + +type Output interface { + Write(content string) +} + +type Input interface { + ReadKey() (string, bool, error) + ReadLine(message string) (string, error) +} + +type Environment struct { + Input Input + Output Output + ErrorOutput Output + Interactive bool +} + +type Patch struct { + Input Input + Output Output + ErrorOutput Output + Interactive *bool +} + +var ( + current = Environment{ + Input: NewScriptedInput(nil), + Output: NewMemoryOutput(), + ErrorOutput: NewMemoryOutput(), + Interactive: false, + } + currentMu sync.Mutex +) + +func Current() Environment { + currentMu.Lock() + defer currentMu.Unlock() + + return current +} + +func Configure(patch Patch) { + currentMu.Lock() + defer currentMu.Unlock() + + current = merge(current, patch) +} + +func With(patch Patch, callback func() error) error { + currentMu.Lock() + previous := current + current = merge(current, patch) + currentMu.Unlock() + + defer func() { + currentMu.Lock() + current = previous + currentMu.Unlock() + }() + + return callback() +} + +func merge(environment Environment, patch Patch) Environment { + if patch.Input != nil { + environment.Input = patch.Input + } + if patch.Output != nil { + environment.Output = patch.Output + } + if patch.ErrorOutput != nil { + environment.ErrorOutput = patch.ErrorOutput + } + if patch.Interactive != nil { + environment.Interactive = *patch.Interactive + } + + return environment +} diff --git a/console/shell/internal/environment/environment_test.go b/console/shell/internal/environment/environment_test.go new file mode 100644 index 00000000..ea30ef9f --- /dev/null +++ b/console/shell/internal/environment/environment_test.go @@ -0,0 +1,121 @@ +package environment + +import ( + "errors" + "testing" +) + +func TestWithRestoresPreviousEnvironmentWhenCallbackFails(t *testing.T) { + output := NewMemoryOutput() + errorOutput := NewMemoryOutput() + previous := Current() + interactive := false + + Configure(Patch{Output: output, ErrorOutput: errorOutput, Interactive: &interactive}) + + scopedOutput := NewMemoryOutput() + scopedInteractive := true + err := With(Patch{Output: scopedOutput, Interactive: &scopedInteractive}, func() error { + current := Current() + if current.Output != scopedOutput { + t.Fatalf("Current().Output was not scoped output") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not preserved") + } + if !current.Interactive { + t.Fatalf("Current().Interactive = false, want true") + } + + return errors.New("failed") + }) + + if err == nil || err.Error() != "failed" { + t.Fatalf("With() error = %v", err) + } + + current := Current() + if current.Output != output { + t.Fatalf("Current().Output was not restored") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not restored") + } + if current.Interactive { + t.Fatalf("Current().Interactive = true, want false") + } + + Configure(Patch{ + Input: previous.Input, + Output: previous.Output, + ErrorOutput: previous.ErrorOutput, + Interactive: &previous.Interactive, + }) +} + +func TestConfigureMergesPartialPromptConfiguration(t *testing.T) { + previous := Current() + output := NewMemoryOutput() + errorOutput := NewMemoryOutput() + interactive := false + + Configure(Patch{Output: output, ErrorOutput: errorOutput, Interactive: &interactive}) + + nextOutput := NewMemoryOutput() + Configure(Patch{Output: nextOutput}) + + current := Current() + if current.Output != nextOutput { + t.Fatalf("Current().Output was not updated") + } + if current.ErrorOutput != errorOutput { + t.Fatalf("Current().ErrorOutput was not preserved") + } + if current.Interactive { + t.Fatalf("Current().Interactive = true, want false") + } + + Configure(Patch{ + Input: previous.Input, + Output: previous.Output, + ErrorOutput: previous.ErrorOutput, + Interactive: &previous.Interactive, + }) +} + +func TestMemoryOutputCapturesAndClears(t *testing.T) { + output := NewMemoryOutput() + + output.Write("first") + output.Write(" second") + + if got := output.Text(); got != "first second" { + t.Fatalf("Text() = %q", got) + } + + output.Clear() + + if got := output.Text(); got != "" { + t.Fatalf("Text() after Clear() = %q", got) + } +} + +func TestScriptedInputDrainsForKeysAndLines(t *testing.T) { + input := NewScriptedInput([]string{"a", "line"}) + + if got, ok, err := input.ReadKey(); err != nil || !ok || got != "a" { + t.Fatalf("ReadKey() = %q, %v, %v", got, ok, err) + } + + if got, err := input.ReadLine("Question"); err != nil || got != "line" { + t.Fatalf("ReadLine() = %q, %v", got, err) + } + + if got, ok, err := input.ReadKey(); err != nil || ok || got != "" { + t.Fatalf("ReadKey(empty) = %q, %v, %v", got, ok, err) + } + + if got, err := input.ReadLine("Question"); err != nil || got != "" { + t.Fatalf("ReadLine(empty) = %q, %v", got, err) + } +} diff --git a/console/shell/internal/environment/errors.go b/console/shell/internal/environment/errors.go new file mode 100644 index 00000000..3d84cdaf --- /dev/null +++ b/console/shell/internal/environment/errors.go @@ -0,0 +1,25 @@ +package environment + +type PromptValidationError struct { + Message string +} + +func (err PromptValidationError) Error() string { + return err.Message +} + +type CancelHandler func(fallback string) (string, error) + +var cancelHandler CancelHandler + +func CancelUsing(handler CancelHandler) { + cancelHandler = handler +} + +func CancelPrompt(fallback string) (string, error) { + if cancelHandler == nil { + return fallback, nil + } + + return cancelHandler(fallback) +} diff --git a/console/shell/internal/environment/memory_output.go b/console/shell/internal/environment/memory_output.go new file mode 100644 index 00000000..f12a2c89 --- /dev/null +++ b/console/shell/internal/environment/memory_output.go @@ -0,0 +1,33 @@ +package environment + +import "sync" + +type MemoryOutput struct { + mu sync.Mutex + buffer string +} + +func NewMemoryOutput() *MemoryOutput { + return &MemoryOutput{} +} + +func (output *MemoryOutput) Write(content string) { + output.mu.Lock() + defer output.mu.Unlock() + + output.buffer += content +} + +func (output *MemoryOutput) Clear() { + output.mu.Lock() + defer output.mu.Unlock() + + output.buffer = "" +} + +func (output *MemoryOutput) Text() string { + output.mu.Lock() + defer output.mu.Unlock() + + return output.buffer +} diff --git a/console/shell/internal/environment/scripted_input.go b/console/shell/internal/environment/scripted_input.go new file mode 100644 index 00000000..76a2b37a --- /dev/null +++ b/console/shell/internal/environment/scripted_input.go @@ -0,0 +1,34 @@ +package environment + +type ScriptedInput struct { + queued []string +} + +func NewScriptedInput(lines []string) *ScriptedInput { + queued := make([]string, len(lines)) + copy(queued, lines) + + return &ScriptedInput{queued: queued} +} + +func (input *ScriptedInput) ReadKey() (string, bool, error) { + if len(input.queued) == 0 { + return "", false, nil + } + + value := input.queued[0] + input.queued = input.queued[1:] + + return value, true, nil +} + +func (input *ScriptedInput) ReadLine(_ string) (string, error) { + if len(input.queued) == 0 { + return "", nil + } + + value := input.queued[0] + input.queued = input.queued[1:] + + return value, nil +} diff --git a/console/shell/internal/typedvalue/lines.go b/console/shell/internal/typedvalue/lines.go new file mode 100644 index 00000000..37a58aa9 --- /dev/null +++ b/console/shell/internal/typedvalue/lines.go @@ -0,0 +1,168 @@ +package typedvalue + +import text "github.com/oullin/tui/internal/strings" + +type LineRange struct { + Start int + End int +} + +type VisibleLineWindow struct { + Lines []string + Start int + Total int +} + +func VisibleLines(value string, cursor int, rows int) string { + lines := VisibleLineWindowFor(value, cursor, rows).Lines + result := "" + for index, line := range lines { + if index > 0 { + result += "\n" + } + result += line + } + + return result +} + +func VisibleLineWindowFor(value string, cursor int, rows int, width ...int) VisibleLineWindow { + valueCharacters := Characters(value) + wrapWidth := 0 + if len(width) > 0 { + wrapWidth = width[0] + } + ranges := lineRanges(valueCharacters, wrapWidth) + + if rows <= 0 { + return VisibleLineWindow{ + Lines: linesForRanges(valueCharacters, ranges), + Start: 0, + Total: len(ranges), + } + } + + line := currentLine(ranges, cursor) + start := max(0, line-rows+1) + end := start + rows + if end > len(ranges) { + end = len(ranges) + } + + return VisibleLineWindow{ + Lines: linesForRanges(valueCharacters, ranges[start:end]), + Start: start, + Total: len(ranges), + } +} + +func MoveLine(value []string, cursor int, direction int, width int) int { + ranges := lineRanges(value, width) + index := currentLine(ranges, cursor) + if index < 0 || index >= len(ranges) { + return cursor + } + + targetIndex := index + direction + if targetIndex < 0 { + return 0 + } + if targetIndex >= len(ranges) { + return len(value) + } + + current := ranges[index] + target := ranges[targetIndex] + column := min(cursor-current.Start, current.End-current.Start) + targetColumn := min(column, target.End-target.Start) + + return target.Start + targetColumn +} + +func MoveToLineBoundary(value []string, cursor int, boundary string) int { + ranges := lineRanges(value, 0) + index := currentLine(ranges, cursor) + if index < 0 || index >= len(ranges) { + return cursor + } + + if boundary == "start" { + return ranges[index].Start + } + + return ranges[index].End +} + +func lineRanges(value []string, width int) []LineRange { + ranges := make([]LineRange, 0) + start := 0 + + for index, char := range value { + if char == "\n" { + ranges = append(ranges, wrappedRanges(value, start, index, width)...) + start = index + 1 + } + } + + ranges = append(ranges, wrappedRanges(value, start, len(value), width)...) + + return ranges +} + +func wrappedRanges(value []string, start int, end int, width int) []LineRange { + if width <= 0 || start == end { + return []LineRange{{Start: start, End: end}} + } + + ranges := make([]LineRange, 0) + rangeStart := start + rangeWidth := 0 + + for index := start; index < end; index++ { + characterWidth := text.VisibleWidth(value[index]) + if rangeWidth > 0 && rangeWidth+characterWidth > width { + ranges = append(ranges, LineRange{Start: rangeStart, End: index}) + rangeStart = index + rangeWidth = 0 + } + + rangeWidth += characterWidth + } + + ranges = append(ranges, LineRange{Start: rangeStart, End: end}) + + return ranges +} + +func currentLine(ranges []LineRange, cursor int) int { + for index, lineRange := range ranges { + if cursor <= lineRange.End { + return index + } + } + + return len(ranges) - 1 +} + +func linesForRanges(value []string, ranges []LineRange) []string { + lines := make([]string, len(ranges)) + for index, lineRange := range ranges { + lines[index] = FromCharacters(value[lineRange.Start:lineRange.End]) + } + + return lines +} + +func min(left int, right int) int { + if left < right { + return left + } + return right +} + +func max(left int, right int) int { + if left > right { + return left + } + return right +} diff --git a/console/shell/internal/typedvalue/typedvalue.go b/console/shell/internal/typedvalue/typedvalue.go new file mode 100644 index 00000000..9b602a6b --- /dev/null +++ b/console/shell/internal/typedvalue/typedvalue.go @@ -0,0 +1,247 @@ +package typedvalue + +import ( + "unicode" + + "github.com/oullin/tui/internal/key" +) + +type State struct { + Cursor int + Value string +} + +type AppliedKey struct { + Cursor int + Value string + Cancelled bool + Submitted bool +} + +func InitialState(value string) State { + return State{Cursor: CharacterLength(value), Value: value} +} + +func ApplyKey(state State, pressed string, allowNewLine bool, wrapWidth ...int) AppliedKey { + value := Characters(state.Value) + cursor := clamp(state.Cursor, 0, len(value)) + width := 0 + if len(wrapWidth) > 0 { + width = wrapWidth[0] + } + + if pressed == key.First(key.CtrlC) { + return result(value, cursor, true, false) + } + + if pressed == key.First(key.CtrlD) && allowNewLine { + return result(value, cursor, false, true) + } + + if pressed == key.First(key.Enter) { + if !allowNewLine { + return result(value, cursor, false, true) + } + + value = insertAt(value, cursor, "\n") + return result(value, cursor+1, false, false) + } + + if moved, ok := moveCursor(value, cursor, pressed, allowNewLine, width); ok { + return result(value, moved, false, false) + } + + if next, ok := deleteKey(value, cursor, pressed, allowNewLine); ok { + return result(next.value, next.cursor, false, false) + } + + if next, ok := insertPrintable(value, cursor, pressed); ok { + return result(next.value, next.cursor, false, false) + } + + return result(value, cursor, false, false) +} + +func Characters(value string) []string { + characters := make([]string, 0) + for _, char := range value { + characters = append(characters, string(char)) + } + + return characters +} + +func FromCharacters(value []string) string { + result := "" + for _, char := range value { + result += char + } + + return result +} + +func CharacterLength(value string) int { + return len(Characters(value)) +} + +func IsPrintable(value string) bool { + if value == "" { + return false + } + + for _, char := range value { + if char < 32 || char == 127 { + return false + } + } + + return true +} + +type cursorResult struct { + cursor int + value []string +} + +func result(value []string, cursor int, cancelled bool, submitted bool) AppliedKey { + return AppliedKey{ + Cancelled: cancelled, + Cursor: cursor, + Submitted: submitted, + Value: FromCharacters(value), + } +} + +func moveCursor(value []string, cursor int, pressed string, allowNewLine bool, wrapWidth int) (int, bool) { + switch pressed { + case key.First(key.Left), key.First(key.LeftArrow), key.First(key.CtrlB): + return max(0, cursor-1), true + case key.First(key.Right), key.First(key.RightArrow), key.First(key.CtrlF): + return min(len(value), cursor+1), true + } + + if allowNewLine { + switch pressed { + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return MoveLine(value, cursor, -1, wrapWidth), true + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return MoveLine(value, cursor, 1, wrapWidth), true + } + } + + if _, ok := key.OneOf([]key.Value{key.Home, key.CtrlA}, pressed); ok { + if allowNewLine { + return MoveToLineBoundary(value, cursor, "start"), true + } + return 0, true + } + + if _, ok := key.OneOf([]key.Value{key.End, key.CtrlE}, pressed); ok { + if allowNewLine { + return MoveToLineBoundary(value, cursor, "end"), true + } + return len(value), true + } + + return 0, false +} + +func deleteKey(value []string, cursor int, pressed string, allowNewLine bool) (cursorResult, bool) { + switch pressed { + case key.First(key.Delete): + return cursorResult{cursor: cursor, value: deleteRange(value, cursor, cursor+1)}, true + case key.First(key.CtrlU): + start := 0 + if allowNewLine { + start = MoveToLineBoundary(value, cursor, "start") + } + return cursorResult{cursor: start, value: deleteRange(value, start, cursor)}, true + case key.First(key.Backspace), key.First(key.CtrlH): + if cursor == 0 { + return cursorResult{cursor: cursor, value: value}, true + } + return cursorResult{cursor: cursor - 1, value: deleteRange(value, cursor-1, cursor)}, true + case key.First(key.OptionBackspace): + start := previousWordStart(value, cursor) + return cursorResult{cursor: start, value: deleteRange(value, start, cursor)}, true + default: + return cursorResult{}, false + } +} + +func insertPrintable(value []string, cursor int, pressed string) (cursorResult, bool) { + if !IsPrintable(pressed) { + return cursorResult{}, false + } + + next := append([]string{}, value...) + nextCursor := cursor + for _, char := range Characters(pressed) { + next = insertAt(next, nextCursor, char) + nextCursor++ + } + + return cursorResult{cursor: nextCursor, value: next}, true +} + +func insertAt(value []string, cursor int, char string) []string { + next := append([]string{}, value[:cursor]...) + next = append(next, char) + next = append(next, value[cursor:]...) + return next +} + +func deleteRange(value []string, start int, end int) []string { + start = clamp(start, 0, len(value)) + end = clamp(end, start, len(value)) + next := append([]string{}, value[:start]...) + next = append(next, value[end:]...) + return next +} + +func previousWordStart(value []string, cursor int) int { + index := clamp(cursor, 0, len(value)) + for index > 0 && isSpace([]rune(value[index-1])[0]) { + index-- + } + + if index == 0 { + return 0 + } + + char := []rune(value[index-1])[0] + if isWord(char) { + for index > 0 && isWord([]rune(value[index-1])[0]) { + index-- + } + return index + } + + for index > 0 { + previous := []rune(value[index-1])[0] + if isWord(previous) || isSpace(previous) { + break + } + index-- + } + + return index +} + +func isWord(char rune) bool { + return unicode.IsLetter(char) || unicode.IsMark(char) || unicode.IsNumber(char) +} + +func isSpace(char rune) bool { + return unicode.IsSpace(char) +} + +func clamp(value int, low int, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} diff --git a/console/shell/internal/typedvalue/typedvalue_test.go b/console/shell/internal/typedvalue/typedvalue_test.go new file mode 100644 index 00000000..0b71ea42 --- /dev/null +++ b/console/shell/internal/typedvalue/typedvalue_test.go @@ -0,0 +1,139 @@ +package typedvalue + +import ( + "reflect" + "testing" + + "github.com/oullin/tui/internal/key" +) + +func apply(keys []string) string { + state := State{} + for _, pressed := range keys { + next := ApplyKey(state, pressed, false) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return state.Value +} + +func applyMultiline(keys []string) AppliedKey { + state := State{} + var next AppliedKey + for _, pressed := range keys { + next = ApplyKey(state, pressed, true) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return next +} + +func applyMultilineState(value string, cursor int, keys []string, wrapWidth ...int) State { + state := State{Cursor: cursor, Value: value} + for _, pressed := range keys { + next := ApplyKey(state, pressed, true, wrapWidth...) + state = State{Cursor: next.Cursor, Value: next.Value} + } + + return state +} + +func TestTypedValueInsertionAndDeletion(t *testing.T) { + if got := apply([]string{"Je", "ss"}); got != "Jess" { + t.Fatalf("apply(insert) = %q", got) + } + + if got := apply([]string{"J", "e", "z", key.First(key.Backspace), "s", "s"}); got != "Jess" { + t.Fatalf("apply(backspace) = %q", got) + } + + if got := apply([]string{"J", "e", "z", key.First(key.Left), key.First(key.Delete), "s", "s"}); got != "Jess" { + t.Fatalf("apply(delete) = %q", got) + } + + if got := apply([]string{"J", "e", "s", "s", key.First(key.Delete)}); got != "Jess" { + t.Fatalf("apply(delete at end) = %q", got) + } +} + +func TestTypedValueMovementAndCancellation(t *testing.T) { + if got := apply([]string{"J", "a", "n", "e", key.First(key.Left), key.First(key.Left), key.First(key.CtrlU), "D", "o"}); got != "Done" { + t.Fatalf("apply(ctrl-u) = %q", got) + } + + if got := apply([]string{"J", "z", "e", key.First(key.CtrlB), key.First(key.CtrlH), key.First(key.CtrlF), "s", "s"}); got != "Jess" { + t.Fatalf("apply(emacs movement) = %q", got) + } + + got := ApplyKey(State{Cursor: 3, Value: "Ada"}, key.First(key.CtrlC), false) + want := AppliedKey{Cancelled: true, Cursor: 3, Submitted: false, Value: "Ada"} + if got != want { + t.Fatalf("ApplyKey(ctrl-c) = %#v, want %#v", got, want) + } +} + +func TestTypedValueUnicodeAndPreviousWordDeletion(t *testing.T) { + if got := applyMultilineState("πŸ˜€", len("πŸ˜€"), []string{"!"}); got != (State{Cursor: 2, Value: "πŸ˜€!"}) { + t.Fatalf("unicode clamp = %#v", got) + } + + if got := applyMultilineState("πŸ˜€ hello", len([]rune("πŸ˜€ hello")), []string{key.First(key.OptionBackspace)}); got != (State{Cursor: 2, Value: "πŸ˜€ "}) { + t.Fatalf("previous word after emoji = %#v", got) + } + + if got := applyMultilineState("hello --", len([]rune("hello --")), []string{key.First(key.OptionBackspace)}); got != (State{Cursor: 6, Value: "hello "}) { + t.Fatalf("previous punctuation group = %#v", got) + } +} + +func TestTypedValueLineMovement(t *testing.T) { + if got := apply([]string{"A", "r", key.Home[0], "J", key.End[0], "c", "h", "e", "r"}); got != "JArcher" { + t.Fatalf("home/end = %q", got) + } + + if got := applyMultiline([]string{"A", key.First(key.Enter), "B", key.First(key.CtrlD)}); !got.Submitted || got.Value != "A\nB" { + t.Fatalf("multiline submit = %#v", got) + } + + tests := []struct { + name string + got State + want State + }{ + {name: "up", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.Up)}), want: State{Cursor: 2, Value: "abc\nde\nfghi"}}, + {name: "down", got: applyMultilineState("abc\nde\nfghi", 2, []string{key.First(key.Down)}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "down long", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.Down)}), want: State{Cursor: 9, Value: "abc\nde\nfghi"}}, + {name: "ctrl-p", got: applyMultilineState("abc\nde\nfghi", 6, []string{key.First(key.CtrlP)}), want: State{Cursor: 2, Value: "abc\nde\nfghi"}}, + {name: "ctrl-n", got: applyMultilineState("abc\nde\nfghi", 2, []string{key.First(key.CtrlN)}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "wrapped up", got: applyMultilineState("abcdefghijkl", 7, []string{key.First(key.Up)}, 5), want: State{Cursor: 2, Value: "abcdefghijkl"}}, + {name: "wrapped down", got: applyMultilineState("abcdefghijkl", 2, []string{key.First(key.Down)}, 5), want: State{Cursor: 7, Value: "abcdefghijkl"}}, + {name: "wrapped up from end", got: applyMultilineState("abcdefghijkl", 11, []string{key.First(key.Up)}, 5), want: State{Cursor: 6, Value: "abcdefghijkl"}}, + {name: "home line", got: applyMultilineState("abc\nde\nfghi", 5, []string{key.Home[0]}), want: State{Cursor: 4, Value: "abc\nde\nfghi"}}, + {name: "end line", got: applyMultilineState("abc\nde\nfghi", 5, []string{key.End[0]}), want: State{Cursor: 6, Value: "abc\nde\nfghi"}}, + {name: "ctrl-u line", got: applyMultilineState("abc\nde\nfghi", 9, []string{key.First(key.CtrlU)}), want: State{Cursor: 7, Value: "abc\nde\nhi"}}, + } + + for _, test := range tests { + if test.got != test.want { + t.Fatalf("%s = %#v, want %#v", test.name, test.got, test.want) + } + } + + if got := applyMultilineState("abc\nde\nfghi", 9, []string{key.First(key.CtrlA), "X", key.First(key.CtrlE), "Y"}); got != (State{Cursor: 13, Value: "abc\nde\nXfghiY"}) { + t.Fatalf("ctrl-a/ctrl-e edit = %#v", got) + } +} + +func TestVisibleLineWindow(t *testing.T) { + got := VisibleLineWindowFor("A\nB\nC", 5, 2) + want := VisibleLineWindow{Lines: []string{"B", "C"}, Start: 1, Total: 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("VisibleLineWindowFor() = %#v, want %#v", got, want) + } + + got = VisibleLineWindowFor("abcdefghijkl", 12, 2, 5) + want = VisibleLineWindow{Lines: []string{"fghij", "kl"}, Start: 1, Total: 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("VisibleLineWindowFor(wrapped) = %#v, want %#v", got, want) + } +} From 2cf7fa46b576428410cdee01b457a6eaa576a740 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:05:30 +0800 Subject: [PATCH 004/404] feat(shell): port prompt lifecycle and basic prompts --- console/shell/internal/environment/errors.go | 4 + console/shell/internal/prompt/lifecycle.go | 71 +++++ console/shell/internal/prompt/validation.go | 90 +++++++ console/shell/internal/prompts/basic_test.go | 260 +++++++++++++++++++ console/shell/internal/prompts/number.go | 230 ++++++++++++++++ console/shell/internal/prompts/render.go | 218 ++++++++++++++++ console/shell/internal/prompts/text.go | 252 ++++++++++++++++++ 7 files changed, 1125 insertions(+) create mode 100644 console/shell/internal/prompt/lifecycle.go create mode 100644 console/shell/internal/prompt/validation.go create mode 100644 console/shell/internal/prompts/basic_test.go create mode 100644 console/shell/internal/prompts/number.go create mode 100644 console/shell/internal/prompts/render.go create mode 100644 console/shell/internal/prompts/text.go diff --git a/console/shell/internal/environment/errors.go b/console/shell/internal/environment/errors.go index 3d84cdaf..c0312899 100644 --- a/console/shell/internal/environment/errors.go +++ b/console/shell/internal/environment/errors.go @@ -1,5 +1,9 @@ package environment +import "errors" + +var ErrKeyInputUnsupported = errors.New("key input is unsupported") + type PromptValidationError struct { Message string } diff --git a/console/shell/internal/prompt/lifecycle.go b/console/shell/internal/prompt/lifecycle.go new file mode 100644 index 00000000..73c6c215 --- /dev/null +++ b/console/shell/internal/prompt/lifecycle.go @@ -0,0 +1,71 @@ +package prompt + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/theme" +) + +type Options[T any] struct { + Message string + Default T + Required Required + Validate Validator[T] +} + +type Reader[T any] func(attempt int) (T, error) +type OnValid[T any] func(T) error + +func UntilValid[T any](options Options[T], read Reader[T], onValid OnValid[T]) (T, error) { + env := environment.Current() + + if !env.Interactive { + value := options.Default + if err := EnsureValid(value, options.Required, options.Validate); err != nil { + return value, err + } + + return value, nil + } + + attempt := 0 + for { + value, err := read(attempt) + if err != nil { + var validation environment.PromptValidationError + if errors.As(err, &validation) { + env.ErrorOutput.Write(theme.RenderError(validation.Message)) + attempt++ + continue + } + + return value, err + } + + if err := EnsureValid(value, options.Required, options.Validate); err != nil { + var validation environment.PromptValidationError + if errors.As(err, &validation) { + env.ErrorOutput.Write(theme.RenderError(validation.Message)) + options.Default = value + attempt++ + continue + } + + return value, err + } + + if onValid != nil { + if err := onValid(value); err != nil { + return value, err + } + } + + return value, nil + } +} + +func Ask(message string, hint string) (string, error) { + env := environment.Current() + return env.Input.ReadLine(theme.RenderQuestion(message, hint)) +} diff --git a/console/shell/internal/prompt/validation.go b/console/shell/internal/prompt/validation.go new file mode 100644 index 00000000..ecdb1cc7 --- /dev/null +++ b/console/shell/internal/prompt/validation.go @@ -0,0 +1,90 @@ +package prompt + +import ( + "errors" + "reflect" + + "github.com/oullin/tui/internal/environment" +) + +type Required struct { + Enabled bool + Message string +} + +type Validator[T any] func(T) (string, error) + +func RequiredMessage(value any, required Required) string { + if !required.Enabled || !isInvalidRequiredValue(value) { + return "" + } + + if required.Message != "" { + return required.Message + } + + return "Required." +} + +func ValidationMessage[T any](value T, validator Validator[T]) (string, error) { + if validator == nil { + return "", nil + } + + message, err := validator(value) + if err != nil { + return "", err + } + + return message, nil +} + +func EnsureValid[T any](value T, required Required, validator Validator[T]) error { + if message := RequiredMessage(value, required); message != "" { + return environment.PromptValidationError{Message: message} + } + + message, err := ValidationMessage(value, validator) + if err != nil { + return err + } + if message != "" { + return environment.PromptValidationError{Message: message} + } + + return nil +} + +func isInvalidRequiredValue(value any) bool { + if value == nil { + return true + } + + switch typed := value.(type) { + case string: + return typed == "" + case bool: + return !typed + } + + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Slice, reflect.Array: + return reflected.Len() == 0 + default: + return false + } +} + +func ValidationError(message string) error { + return environment.PromptValidationError{Message: message} +} + +func IsValidationError(err error) (string, bool) { + var validation environment.PromptValidationError + if ok := errors.As(err, &validation); ok { + return validation.Message, true + } + + return "", false +} diff --git a/console/shell/internal/prompts/basic_test.go b/console/shell/internal/prompts/basic_test.go new file mode 100644 index 00000000..536f5a5a --- /dev/null +++ b/console/shell/internal/prompts/basic_test.go @@ -0,0 +1,260 @@ +package prompts + +import ( + "errors" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + textutil "github.com/oullin/tui/internal/strings" +) + +type lineOnlyInput struct { + asked string + line string +} + +func (input *lineOnlyInput) ReadKey() (string, bool, error) { + return "", false, environment.ErrKeyInputUnsupported +} + +func (input *lineOnlyInput) ReadLine(message string) (string, error) { + input.asked = message + return input.line, nil +} + +func withTestEnvironment(input environment.Input, callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + interactive := true + return environment.With(environment.Patch{Input: input, Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + return callback(output) + }) +} + +func TestTextPromptReadsAndRendersTypedValues(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"A", "d", "a", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{Message: "Name"}) + if err != nil { + return err + } + if result != "Ada" { + t.Fatalf("Text() = %q", result) + } + if !strings.Contains(output.Text(), "\x1b[36mName\x1b[39m") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "Ada") { + t.Fatalf("text output missing rendered prompt/value:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextRetriesValidationAndRendersSubmittedOnce(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"J", "e", "s", key.First(key.Enter), "s", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{ + Message: "Name", + Validate: func(value string) (string, error) { + if value != "Jess" { + return "Invalid name.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "Jess" { + t.Fatalf("Text() = %q", result) + } + if !strings.Contains(output.Text(), "Invalid name.") { + t.Fatalf("output missing validation error:\n%s", output.Text()) + } + if count := strings.Count(output.Text(), "β”Œ \x1b[2mName\x1b[22m"); count != 1 { + t.Fatalf("submitted frame count = %d", count) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextNonInteractiveDefaultTransformAndValidation(t *testing.T) { + output := environment.NewMemoryOutput() + interactive := false + err := environment.With(environment.Patch{Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + result, err := Text(TextOptions{ + Message: "Name", + Default: " Ada ", + Transform: func(value string) (string, error) { + return strings.TrimSpace(value), nil + }, + Validate: func(value string) (string, error) { + if value != "Ada" { + return "Unexpected value.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "Ada" { + t.Fatalf("Text() = %q", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTextareaPromptAcceptsMultilineInput(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"A", key.First(key.Enter), "B", key.First(key.CtrlD)}), func(output *environment.MemoryOutput) error { + result, err := Textarea(TextOptions{Message: "Description"}) + if err != nil { + return err + } + if result != "A\nB" { + t.Fatalf("Textarea() = %q", result) + } + if !strings.Contains(output.Text(), "Ctrl+D to submit") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "β”‚ A") || !strings.Contains(textutil.ParseAnsiText(output.Text()), "β”‚ B") { + t.Fatalf("textarea output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPasswordPromptMasksValues(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"s", "e", "c", "r", "e", "t", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Password(TextOptions{Message: "Password"}) + if err != nil { + return err + } + if result != "secret" { + t.Fatalf("Password() = %q", result) + } + if strings.Contains(output.Text(), "secret") || !strings.Contains(output.Text(), "β€’β€’β€’β€’β€’β€’") { + t.Fatalf("password output leaked value or missed mask:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestNumberPromptParsesRetriesAndSteps(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"1", "2", "a", key.First(key.Enter), "4", "2", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Number(NumberOptions{Message: "Amount"}) + if err != nil { + return err + } + if result != 42 { + t.Fatalf("Number() = %#v", result) + } + if !strings.Contains(output.Text(), "Must be a number") { + t.Fatalf("number output missing validation error:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Up), key.First(key.Up), key.First(key.Down), key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := Number(NumberOptions{Message: "Count", Step: 2, HasStep: true}) + if err != nil { + return err + } + if result != 1 { + t.Fatalf("stepped Number() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPausePromptWaitsForEnterAndFallsBackToLineInput(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"x", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Pause("Continue") + if err != nil { + return err + } + if !result { + t.Fatalf("Pause() = false") + } + if !strings.Contains(output.Text(), "\x1b[36mContinue\x1b[39m") || !strings.HasSuffix(output.Text(), "\n") { + t.Fatalf("pause output missing frame/newline:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + input := &lineOnlyInput{} + err = withTestEnvironment(input, func(_ *environment.MemoryOutput) error { + result, err := Pause("Continue") + if err != nil { + return err + } + if !result || !strings.Contains(input.asked, "Continue") { + t.Fatalf("line fallback result=%v asked=%q", result, input.asked) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCancellationUsesCustomHandlerAfterRendering(t *testing.T) { + environment.CancelUsing(func(string) (string, error) { + return "Manual", nil + }) + defer environment.CancelUsing(nil) + + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.CtrlC)}), func(output *environment.MemoryOutput) error { + result, err := Text(TextOptions{Message: "Name"}) + if err != nil { + return err + } + if result != "Manual" { + t.Fatalf("Text(cancelled) = %q", result) + } + if !strings.Contains(output.Text(), "Cancelled.") { + t.Fatalf("cancelled output missing message:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestPromptValidationHelpers(t *testing.T) { + if got := prompt.RequiredMessage("", prompt.Required{Enabled: true}); got != "Required." { + t.Fatalf("RequiredMessage(empty) = %q", got) + } + if got := prompt.RequiredMessage("", prompt.Required{Enabled: true, Message: "Choose something."}); got != "Choose something." { + t.Fatalf("RequiredMessage(custom) = %q", got) + } + if got := prompt.RequiredMessage("value", prompt.Required{Enabled: true}); got != "" { + t.Fatalf("RequiredMessage(value) = %q", got) + } + + _, err := Text(TextOptions{Message: "Name", Required: prompt.Required{Enabled: true}}) + var validation environment.PromptValidationError + if err != nil && !errors.As(err, &validation) { + t.Fatalf("expected validation error, got %v", err) + } +} diff --git a/console/shell/internal/prompts/number.go b/console/shell/internal/prompts/number.go new file mode 100644 index 00000000..3ec2c5a3 --- /dev/null +++ b/console/shell/internal/prompts/number.go @@ -0,0 +1,230 @@ +package prompts + +import ( + "errors" + "fmt" + "math" + "strconv" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type NumberOptions struct { + Message string + Placeholder string + Default string + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Hint string + Min float64 + HasMin bool + Max float64 + HasMax bool + Step float64 + HasStep bool + Transform func(any) (any, error) +} + +func Number(options NumberOptions) (any, error) { + shouldRenderSubmittedFrame := false + defaultValue, defaultErr := numberDefault(options) + if defaultErr != nil { + return nil, defaultErr + } + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (any, error) { + answer, err := readNumberValue(options.Message, options) + if err != nil { + return nil, err + } + + shouldRenderSubmittedFrame = !answer.Cancelled + if answer.Value == "" && options.HasDefault { + return numberDefault(options) + } + + parsed, err := ParseNumberInput(answer.Value, options) + if err != nil { + return nil, err + } + + return transformNumber(options, parsed) + }, + func(value any) error { + if shouldRenderSubmittedFrame { + renderSubmittedNumberValue(options.Message, value) + } + return nil + }, + ) +} + +func numberDefault(options NumberOptions) (any, error) { + if !options.HasDefault { + return "", nil + } + + parsed, err := ParseNumberInput(options.Default, options) + if err != nil { + return nil, err + } + + return transformNumber(options, parsed) +} + +func transformNumber(options NumberOptions, value any) (any, error) { + if options.Transform == nil { + return value, nil + } + + return options.Transform(value) +} + +func readNumberValue(message string, options NumberOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.State{} + if options.HasDefault { + state = typedvalue.InitialState(options.Default) + } + + renderNumberValue(message, state.Value, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + if answer == "" && options.HasDefault { + answer = options.Default + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + if pressed == key.First(key.Up) || pressed == key.First(key.UpArrow) { + state.Value = steppedNumberValue(state.Value, 1, options) + state.Cursor = typedvalue.CharacterLength(state.Value) + renderNumberValue(message, state.Value, options) + continue + } + + if pressed == key.First(key.Down) || pressed == key.First(key.DownArrow) { + state.Value = steppedNumberValue(state.Value, -1, options) + state.Cursor = typedvalue.CharacterLength(state.Value) + renderNumberValue(message, state.Value, options) + continue + } + + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledNumberValue(message, state.Value, options) + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderNumberValue(message, state.Value, options) + } +} + +func ParseNumberInput(input string, options NumberOptions) (any, error) { + normalized := stdstrings.TrimSpace(input) + if normalized == "" { + return "", nil + } + + numeric, number := parseFloat(normalized) + if !numeric { + return nil, prompt.ValidationError("Must be a number") + } + + if options.HasMin && number < options.Min { + return nil, prompt.ValidationError(fmt.Sprintf("Must be at least %s", formatNumber(options.Min))) + } + + if options.HasMax && number > options.Max { + return nil, prompt.ValidationError(fmt.Sprintf("Must be less than %s", formatNumber(options.Max))) + } + + return int(math.Trunc(number)), nil +} + +func steppedNumberValue(value string, direction int, options NumberOptions) string { + step := numberStep(options) + if value == "" { + if direction == 1 { + if options.HasMin { + return formatNumber(options.Min) + } + return "1" + } + if options.HasMax { + return formatNumber(options.Max) + } + return "0" + } + + numeric, number := parseFloat(value) + if !numeric { + return value + } + + next := math.Trunc(number) + float64(step*direction) + if options.HasMin && next < options.Min { + next = options.Min + } + if options.HasMax && next > options.Max { + next = options.Max + } + + return formatNumber(next) +} + +func numberStep(options NumberOptions) int { + if options.HasStep && options.Step > 0 { + truncated := int(math.Trunc(options.Step)) + if truncated > 1 { + return truncated + } + } + + return 1 +} + +func parseFloat(value string) (bool, float64) { + if stdstrings.TrimSpace(value) == "" { + return false, 0 + } + number, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsInf(number, 0) || math.IsNaN(number) { + return false, 0 + } + return true, number +} + +func formatNumber(value float64) string { + if math.Trunc(value) == value { + return strconv.FormatInt(int64(value), 10) + } + + return strconv.FormatFloat(value, 'f', -1, 64) +} diff --git a/console/shell/internal/prompts/render.go b/console/shell/internal/prompts/render.go new file mode 100644 index 00000000..3ace43de --- /dev/null +++ b/console/shell/internal/prompts/render.go @@ -0,0 +1,218 @@ +package prompts + +import ( + "fmt" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +const textareaContentWidth = 60 + +func renderTypedValue(message string, state typedvalue.State, options TextOptions) { + env := environment.Current() + if options.AllowNewLine { + env.Output.Write(renderTextareaFrame(message, state, options) + "\n") + if options.Hint != "" { + env.Output.Write(theme.Dim(options.Hint) + "\n") + } + return + } + + display := theme.Dim(options.Placeholder) + if state.Value != "" { + display = typedvalue.VisibleLines(state.Value, state.Cursor, options.Rows) + } + + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: display, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedTypedValue(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: value, Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledTypedValue(message string, value string, options TextOptions) { + env := environment.Current() + display := value + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderTextareaFrame(message string, state typedvalue.State, options TextOptions) string { + return theme.RenderBox(theme.BoxOptions{Body: textareaBody(state, options), BorderStyle: theme.Cyan, Info: "Ctrl+D to submit", Title: theme.Cyan(message)}) +} + +func renderSubmittedTextareaFrame(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: value, Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledTextareaFrame(message string, value string, options TextOptions) { + env := environment.Current() + body := textareaCancelledBody(value, options) + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func textareaBody(state typedvalue.State, options TextOptions) string { + rows := options.Rows + if rows <= 0 { + rows = 0 + } + + if state.Value == "" { + return placeholderBody(options, rows) + } + + window := typedvalue.VisibleLineWindowFor(state.Value, state.Cursor, rows, textareaContentWidth) + if rows <= 0 { + return stdstrings.Join(window.Lines, "\n") + } + + padded := append([]string{}, window.Lines...) + for len(padded) < rows { + padded = append(padded, "") + } + if len(padded) > rows { + padded = padded[:rows] + } + + return stdstrings.Join(renderScrollbarRows(padded, window.Start, rows, window.Total), "\n") +} + +func placeholderBody(options TextOptions, rows int) string { + lines := []string{theme.Dim(options.Placeholder)} + for rows > 0 && len(lines) < rows { + lines = append(lines, "") + } + + return stdstrings.Join(lines, "\n") +} + +func textareaCancelledBody(value string, options TextOptions) string { + text := value + if text == "" { + text = options.Placeholder + } + + lines := stdstrings.Split(text, "\n") + for index, line := range lines { + lines[index] = theme.Strikethrough(theme.Dim(line)) + } + + return stdstrings.Join(lines, "\n") +} + +func renderPasswordValue(message string, value string, options TextOptions) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: passwordDisplay(value, options), BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedPasswordValue(message string, value string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: maskPassword(value), Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledPasswordValue(message string, value string, options TextOptions) { + env := environment.Current() + display := maskPassword(value) + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func maskPassword(value string) string { + return stdstrings.Repeat("β€’", typedvalue.CharacterLength(value)) +} + +func passwordDisplay(value string, options TextOptions) string { + if value != "" { + return maskPassword(value) + } + + return theme.Dim(options.Placeholder) +} + +func renderNumberValue(message string, value string, options NumberOptions) { + body := renderNumberBody(value, options, func(text string) string { return text }) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(message)}) + "\n") +} + +func renderSubmittedNumberValue(message string, value any) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: fmt.Sprint(value), Title: theme.Dim(message)}) + "\n") +} + +func renderCancelledNumberValue(message string, value string, options NumberOptions) { + env := environment.Current() + display := value + if display == "" { + display = options.Placeholder + } + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(display)), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderNumberBody(value string, options NumberOptions, style func(string) string) string { + display := value + if display == "" { + display = theme.Dim(options.Placeholder) + } + + return display + " " + renderNumberArrows(value, options, style) +} + +func renderNumberArrows(value string, options NumberOptions, style func(string) string) string { + numeric, number := parseFloat(value) + up := style("β–²") + down := style("β–Ό") + if numeric && options.HasMax && number >= options.Max { + up = theme.Dim("β–²") + } + if numeric && options.HasMin && number <= options.Min { + down = theme.Dim("β–Ό") + } + if value != "" && !numeric { + return theme.Dim("β–²") + theme.Dim("β–Ό") + } + + return up + down +} + +func renderPause(message string) string { + return theme.RenderBox(theme.BoxOptions{Body: "", BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) +} + +func renderScrollbarRows(lines []string, start int, rows int, total int) []string { + if rows <= 0 || total <= rows { + return lines + } + + result := make([]string, len(lines)) + thumbStart := start * rows / total + thumbSize := rows * rows / total + if thumbSize < 1 { + thumbSize = 1 + } + if thumbStart+thumbSize > rows { + thumbStart = rows - thumbSize + } + + for index, line := range lines { + bar := theme.Dim("β”‚") + if index >= thumbStart && index < thumbStart+thumbSize { + bar = theme.Cyan("┃") + } + padding := textareaContentWidth - strings.VisibleWidth(line) + if padding < 0 { + padding = 0 + } + result[index] = line + stdstrings.Repeat(" ", padding) + " " + bar + } + + return result +} diff --git a/console/shell/internal/prompts/text.go b/console/shell/internal/prompts/text.go new file mode 100644 index 00000000..95cc43ee --- /dev/null +++ b/console/shell/internal/prompts/text.go @@ -0,0 +1,252 @@ +package prompts + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type TextOptions struct { + Message string + Placeholder string + Default string + Required prompt.Required + Validate prompt.Validator[string] + Hint string + Transform func(string) (string, error) + AllowNewLine bool + Rows int +} + +type typedReadResult struct { + Cancelled bool + Value string +} + +func Text(options TextOptions) (string, error) { + options.AllowNewLine = false + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readTypedValue(options.Message, options) + if err != nil { + return "", err + } + + value := answer.Value + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func Textarea(options TextOptions) (string, error) { + options.AllowNewLine = true + if options.Rows == 0 { + options.Rows = 5 + } + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readTypedValue(options.Message, options) + if err != nil { + return "", err + } + + value := answer.Value + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTextareaFrame(options.Message, value) + } + return nil + }, + ) +} + +func Password(options TextOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidTextDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + answer, err := readPasswordValue(options.Message, options) + if err != nil { + return "", err + } + shouldRenderSubmittedFrame = !answer.Cancelled + return transformText(options, answer.Value) + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedPasswordValue(options.Message, value) + } + return nil + }, + ) +} + +func readTypedValue(message string, options TextOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + + renderTypedValue(message, state, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + if answer == "" { + answer = options.Default + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + next := typedvalue.ApplyKey(state, pressed, options.AllowNewLine, textareaContentWidth) + if next.Cancelled { + if options.AllowNewLine { + renderCancelledTextareaFrame(message, state.Value, options) + } else { + renderCancelledTypedValue(message, state.Value, options) + } + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderTypedValue(message, state, options) + } +} + +func readPasswordValue(message string, options TextOptions) (typedReadResult, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + + renderPasswordValue(message, state.Value, options) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(message, options.Hint)) + if lineErr != nil { + return typedReadResult{}, lineErr + } + return typedReadResult{Value: answer}, nil + } + return typedReadResult{}, err + } + if !ok { + return typedReadResult{Value: state.Value}, nil + } + + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledPasswordValue(message, state.Value, options) + value, cancelErr := environment.CancelPrompt(state.Value) + return typedReadResult{Cancelled: true, Value: value}, cancelErr + } + + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + if next.Submitted { + return typedReadResult{Value: state.Value}, nil + } + + renderPasswordValue(message, state.Value, options) + } +} + +func Pause(message string) (bool, error) { + env := environment.Current() + if !env.Interactive { + return false, nil + } + + env.Output.Write(renderPause(message)) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + _, lineErr := prompt.Ask(message, "") + return lineErr == nil, lineErr + } + return false, err + } + if !ok || pressed == key.First(key.Enter) { + env.Output.Write("\n") + return true, nil + } + } +} + +func transformedDefault(options TextOptions) string { + value, err := transformText(options, options.Default) + if err != nil { + return options.Default + } + return value +} + +func transformText(options TextOptions, value string) (string, error) { + if options.Transform == nil { + return value, nil + } + + return options.Transform(value) +} + +func preserveInvalidTextDefault(options *TextOptions) prompt.Validator[string] { + return func(value string) (string, error) { + if options.Validate == nil { + return "", nil + } + + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + } + + return message, nil + } +} From 8a97d4d54ff41a19a6dfe6cf6ef48ba04446242d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:09:48 +0800 Subject: [PATCH 005/404] feat(shell): port choice search and suggest prompts --- console/shell/internal/prompts/choices.go | 689 +++++++++++++++++ .../shell/internal/prompts/choices_test.go | 249 ++++++ .../shell/internal/prompts/search_suggest.go | 728 ++++++++++++++++++ 3 files changed, 1666 insertions(+) create mode 100644 console/shell/internal/prompts/choices.go create mode 100644 console/shell/internal/prompts/choices_test.go create mode 100644 console/shell/internal/prompts/search_suggest.go diff --git a/console/shell/internal/prompts/choices.go b/console/shell/internal/prompts/choices.go new file mode 100644 index 00000000..cb0a7faf --- /dev/null +++ b/console/shell/internal/prompts/choices.go @@ -0,0 +1,689 @@ +package prompts + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strconv" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type Choice struct { + Label string + Value any + Disabled bool + DisabledReason string + Hint string +} + +type ChoiceOptions struct { + Message string + Choices []Choice + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Hint string + Scroll int + Transform func(any) (any, error) +} + +type MultiChoiceOptions struct { + Message string + Choices []Choice + Defaults []any + Required prompt.Required + Validate prompt.Validator[[]any] + Hint string + Scroll int + Transform func([]any) ([]any, error) +} + +type ConfirmOptions struct { + Message string + Default bool + HasDefault bool + Yes string + No string + Required prompt.Required + Validate prompt.Validator[bool] + Hint string + Transform func(bool) (bool, error) +} + +func NewStringChoices(values ...string) []Choice { + choices := make([]Choice, len(values)) + for index, value := range values { + choices[index] = Choice{Label: value, Value: value} + } + + return choices +} + +func Select(options ChoiceOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + validate := preserveInvalidChoiceDefault(&options) + defaultValue := transformedChoiceDefault(options) + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: requiredDefault(options.Required, true), Validate: validate}, + func(_ int) (any, error) { + value, label, submitted, err := readSelectedChoice(options) + if err != nil { + return nil, err + } + value, err = transformChoice(options, value) + if err != nil { + return nil, err + } + if submitted { + renderSubmittedChoice(options.Message, label) + } + return value, nil + }, + nil, + ) +} + +func Multiselect(options MultiChoiceOptions) ([]any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + + defaultValue := options.Defaults + if options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[[]any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) ([]any, error) { + value, labels, submitted, err := readMultipleChoices(options) + if err != nil { + return nil, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoices(options.Message, labels) + } + return value, nil + }, + nil, + ) +} + +func Confirm(options ConfirmOptions) (bool, error) { + if options.Yes == "" { + options.Yes = "Yes" + } + if options.No == "" { + options.No = "No" + } + defaultValue := true + if options.HasDefault { + defaultValue = options.Default + } + if options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[bool]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (bool, error) { + value, submitted, err := readConfirm(options) + if err != nil { + return false, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return false, err + } + } + if submitted { + renderSubmittedConfirm(options, value) + } + return value, nil + }, + nil, + ) +} + +func readSelectedChoice(options ChoiceOptions) (any, string, bool, error) { + env := environment.Current() + selected := defaultChoiceIndex(options.Choices, options.Default, options.HasDefault) + + renderSelectedChoice(options.Message, options.Choices, selected, options.Scroll) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(choiceLineQuestion(options.Message, options.Choices, options.Hint)) + if lineErr != nil { + return nil, "", false, lineErr + } + if stdstrings.TrimSpace(answer) == "" && options.HasDefault { + choice := choiceByValue(options.Choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, false, nil + } + } + choice := findChoice(options.Choices, answer) + if choice == nil || choice.Disabled { + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + return choice.Value, choice.Label, false, nil + } + return nil, "", false, err + } + if !ok { + if options.HasDefault { + choice := choiceByValue(options.Choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, false, nil + } + } + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + + if pressed == key.First(key.CtrlC) { + renderCancelledChoice(options.Message, options.Choices, selected, options.Scroll) + choice := options.Choices[selected] + value, cancelErr := environment.CancelPrompt(fmt.Sprint(choice.Value)) + if cancelErr != nil { + return nil, "", false, cancelErr + } + return valueOrOriginal(choice.Value, value), choice.Label, false, nil + } + + if numeric, ok := choiceIndex(pressed); ok && numeric >= 0 && numeric < len(options.Choices) && !options.Choices[numeric].Disabled { + choice := options.Choices[numeric] + return choice.Value, choice.Label, true, nil + } + + if action, ok := navigationDirection(pressed); ok { + selected = moveChoiceHighlight(options.Choices, selected, action) + renderSelectedChoice(options.Message, options.Choices, selected, options.Scroll) + continue + } + + if pressed == key.First(key.Enter) { + choice := options.Choices[selected] + if choice.Disabled { + return nil, "", false, prompt.ValidationError("Please select a valid option.") + } + return choice.Value, choice.Label, true, nil + } + } +} + +func readMultipleChoices(options MultiChoiceOptions) ([]any, []string, bool, error) { + env := environment.Current() + selected := firstEnabledIndex(options.Choices) + marked := markedChoiceIndexes(options.Choices, options.Defaults) + + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(choiceLineQuestion(options.Message, options.Choices, options.Hint)) + if lineErr != nil { + return nil, nil, false, lineErr + } + if stdstrings.TrimSpace(answer) == "" { + return options.Defaults, nil, false, nil + } + return choicesFromCommaSeparated(options.Choices, answer), nil, false, nil + } + return nil, nil, false, err + } + if !ok || pressed == key.First(key.Enter) { + return markedChoiceValues(options.Choices, marked), markedChoiceLabels(options.Choices, marked), true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledChoices(options.Message, options.Choices, selected, marked, options.Scroll) + return markedChoiceValues(options.Choices, marked), markedChoiceLabels(options.Choices, marked), false, nil + } + if stdstrings.Contains(pressed, ",") { + return choicesFromCommaSeparated(options.Choices, pressed), nil, false, nil + } + if numeric, ok := choiceIndex(pressed); ok && numeric >= 0 && numeric < len(options.Choices) && !options.Choices[numeric].Disabled { + toggleMarked(marked, numeric) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if action, ok := navigationDirection(pressed); ok { + selected = moveChoiceHighlight(options.Choices, selected, action) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if pressed == key.First(key.CtrlA) { + marked = toggleAllEnabledChoices(options.Choices, marked) + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + if pressed == key.First(key.Space) { + if !options.Choices[selected].Disabled { + toggleMarked(marked, selected) + } + renderMultipleChoices(options.Message, options.Choices, selected, marked, options.Scroll) + continue + } + } +} + +func readConfirm(options ConfirmOptions) (bool, bool, error) { + env := environment.Current() + confirmed := true + if options.HasDefault { + confirmed = options.Default + } + renderActiveConfirm(options, confirmed) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + suffix := " [Y/n]" + if options.HasDefault && !options.Default { + suffix = " [y/N]" + } + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message+suffix, options.Hint)) + if lineErr != nil { + return false, false, lineErr + } + normalized := stdstrings.ToLower(stdstrings.TrimSpace(answer)) + if normalized == "" && options.HasDefault { + return options.Default, false, nil + } + return normalized == "y" || normalized == "yes" || normalized == stdstrings.ToLower(options.Yes), false, nil + } + return false, false, err + } + if !ok { + return confirmed, true, nil + } + switch stdstrings.ToLower(pressed) { + case "y": + confirmed = true + renderActiveConfirm(options, confirmed) + case "n": + confirmed = false + renderActiveConfirm(options, confirmed) + default: + if pressed == key.First(key.Enter) { + return confirmed, true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledConfirm(options, confirmed) + return confirmed, false, nil + } + if isConfirmToggleKey(pressed) { + confirmed = !confirmed + renderActiveConfirm(options, confirmed) + } + } + } +} + +func renderSelectedChoice(message string, choices []Choice, selected int, scroll int) { + rows := choiceRows(choices, selected, nil, false) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderMultipleChoices(message string, choices []Choice, selected int, marked map[int]bool, scroll int) { + rows := choiceRows(choices, selected, marked, true) + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Cyan, Info: "Use the space bar to select options.", Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledChoice(message string, choices []Choice, selected int, scroll int) { + rows := choiceRows(choices, selected, nil, false) + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderCancelledChoices(message string, choices []Choice, selected int, marked map[int]bool, scroll int) { + rows := choiceRows(choices, selected, marked, true) + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: windowedRows(rows, selected, scroll), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSubmittedChoice(message string, label string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: label, Title: theme.Dim(message)}) + "\n") +} + +func renderSubmittedChoices(message string, labels []string) { + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(labels, "\n"), Title: theme.Dim(message)}) + "\n") +} + +func renderActiveConfirm(options ConfirmOptions, confirmed bool) { + yes := "β—‹ " + options.Yes + no := "β—‹ " + options.No + if confirmed { + yes = theme.Green("●") + " " + options.Yes + } else { + no = theme.Green("●") + " " + options.No + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: yes + " / " + no, BorderStyle: theme.Cyan, Info: options.Hint, Title: theme.Cyan(options.Message)}) + "\n") +} + +func renderCancelledConfirm(options ConfirmOptions, confirmed bool) { + yes := "β—‹ " + options.Yes + no := "β—‹ " + options.No + if confirmed { + yes = "● " + options.Yes + } else { + no = "● " + options.No + } + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: yes + " / " + no, BorderStyle: theme.Red, Title: options.Message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSubmittedConfirm(options ConfirmOptions, value bool) { + label := options.No + if value { + label = options.Yes + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: label, Title: theme.Dim(options.Message)}) + "\n") +} + +func choiceRows(choices []Choice, selected int, marked map[int]bool, multiple bool) []string { + rows := make([]string, len(choices)) + for index, choice := range choices { + pointer := " " + if index == selected { + pointer = "β€Ί" + } + marker := "β—‹" + if multiple { + if marked[index] { + marker = "β—Ό" + } else { + marker = "β—»" + } + } else if index == selected { + marker = "●" + } + label := choice.Label + if choice.Hint != "" { + label += " " + choice.Hint + } + if choice.Disabled { + reason := "disabled" + if choice.DisabledReason != "" { + reason = choice.DisabledReason + } + label += " (" + reason + ")" + } + if index == selected { + rows[index] = theme.Cyan(pointer+" "+marker) + " " + label + } else { + rows[index] = " " + marker + " " + label + } + } + return rows +} + +func windowedRows(rows []string, selected int, scroll int) string { + if scroll <= 0 || scroll >= len(rows) { + return stdstrings.Join(rows, "\n") + } + start := selected - (scroll-1)/2 + if start < 0 { + start = 0 + } + if start+scroll > len(rows) { + start = len(rows) - scroll + } + window := append([]string{}, rows[start:start+scroll]...) + return stdstrings.Join(renderScrollbarRows(window, start, scroll, len(rows)), "\n") +} + +func choiceLineQuestion(message string, choices []Choice, hint string) string { + rendered := make([]string, len(choices)) + for index, choice := range choices { + rendered[index] = fmt.Sprintf(" %d. %s", index+1, choice.Label) + } + return theme.RenderQuestion(message+"\n"+stdstrings.Join(rendered, "\n")+"\n", hint) +} + +func firstEnabledIndex(choices []Choice) int { + for index, choice := range choices { + if !choice.Disabled { + return index + } + } + return 0 +} + +func defaultChoiceIndex(choices []Choice, value any, hasDefault bool) int { + if !hasDefault { + return firstEnabledIndex(choices) + } + for index, choice := range choices { + if !choice.Disabled && reflect.DeepEqual(choice.Value, value) { + return index + } + } + return firstEnabledIndex(choices) +} + +func choiceByValue(choices []Choice, value any) *Choice { + for index, choice := range choices { + if !choice.Disabled && reflect.DeepEqual(choice.Value, value) { + return &choices[index] + } + } + return nil +} + +func findChoice(choices []Choice, answer string) *Choice { + normalized := stdstrings.TrimSpace(answer) + if numeric, err := strconv.Atoi(normalized); err == nil { + index := numeric - 1 + if index >= 0 && index < len(choices) { + return &choices[index] + } + } + for index, choice := range choices { + if choice.Label == normalized || fmt.Sprint(choice.Value) == normalized { + return &choices[index] + } + } + return nil +} + +func moveChoiceHighlight(choices []Choice, current int, direction int) int { + if len(choices) == 0 { + return 0 + } + index := current + for range choices { + index = (index + direction + len(choices)) % len(choices) + if !choices[index].Disabled { + return index + } + } + return current +} + +func navigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.Tab), "j", "l", key.First(key.Right), key.First(key.RightArrow), key.First(key.CtrlN), key.First(key.CtrlF): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.ShiftTab), "k", "h", key.First(key.Left), key.First(key.LeftArrow), key.First(key.CtrlP), key.First(key.CtrlB): + return -1, true + default: + return 0, false + } +} + +func choiceIndex(value string) (int, bool) { + numeric, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + return numeric - 1, true +} + +func markedChoiceIndexes(choices []Choice, defaults []any) map[int]bool { + marked := map[int]bool{} + for index, choice := range choices { + for _, value := range defaults { + if reflect.DeepEqual(choice.Value, value) { + marked[index] = true + } + } + } + return marked +} + +func markedChoiceValues(choices []Choice, marked map[int]bool) []any { + indexes := sortedMarkedIndexes(marked) + values := make([]any, 0, len(indexes)) + for _, index := range indexes { + values = append(values, choices[index].Value) + } + return values +} + +func markedChoiceLabels(choices []Choice, marked map[int]bool) []string { + indexes := sortedMarkedIndexes(marked) + labels := make([]string, 0, len(indexes)) + for _, index := range indexes { + labels = append(labels, choices[index].Label) + } + return labels +} + +func sortedMarkedIndexes(marked map[int]bool) []int { + indexes := make([]int, 0, len(marked)) + for index, enabled := range marked { + if enabled { + indexes = append(indexes, index) + } + } + sort.Ints(indexes) + return indexes +} + +func toggleMarked(marked map[int]bool, index int) { + if marked[index] { + delete(marked, index) + } else { + marked[index] = true + } +} + +func toggleAllEnabledChoices(choices []Choice, marked map[int]bool) map[int]bool { + allMarked := true + for index, choice := range choices { + if !choice.Disabled && !marked[index] { + allMarked = false + break + } + } + next := map[int]bool{} + if !allMarked { + for index, choice := range choices { + if !choice.Disabled { + next[index] = true + } + } + } + return next +} + +func choicesFromCommaSeparated(choices []Choice, answer string) []any { + parts := stdstrings.Split(answer, ",") + values := make([]any, 0, len(parts)) + for _, part := range parts { + choice := findChoice(choices, part) + if choice != nil && !choice.Disabled { + values = append(values, choice.Value) + } + } + return values +} + +func isConfirmToggleKey(pressed string) bool { + _, ok := navigationDirection(pressed) + return ok || pressed == key.First(key.PageDown) || pressed == key.First(key.PageUp) +} + +func valueOrOriginal(original any, value string) any { + if value == fmt.Sprint(original) { + return original + } + return value +} + +func transformChoice(options ChoiceOptions, value any) (any, error) { + if options.Transform == nil { + return value, nil + } + return options.Transform(value) +} + +func transformedChoiceDefault(options ChoiceOptions) any { + if !options.HasDefault { + return nil + } + value, err := transformChoice(options, options.Default) + if err != nil { + return options.Default + } + return value +} + +func preserveInvalidChoiceDefault(options *ChoiceOptions) prompt.Validator[any] { + return func(value any) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + options.HasDefault = true + } + return message, nil + } +} + +func requiredDefault(required prompt.Required, enabled bool) prompt.Required { + if required.Enabled || required.Message != "" { + return required + } + return prompt.Required{Enabled: enabled} +} + +func currentInputState(value string) typedvalue.State { + return typedvalue.State{Cursor: typedvalue.CharacterLength(value), Value: value} +} diff --git a/console/shell/internal/prompts/choices_test.go b/console/shell/internal/prompts/choices_test.go new file mode 100644 index 00000000..362569a5 --- /dev/null +++ b/console/shell/internal/prompts/choices_test.go @@ -0,0 +1,249 @@ +package prompts + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + textutil "github.com/oullin/tui/internal/strings" +) + +func TestConfirmSelectsAndToggles(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"y", key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := Confirm(ConfirmOptions{Message: "Continue?", Default: false, HasDefault: true}) + if err != nil { + return err + } + if !result { + t.Fatalf("Confirm() = false") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Left), "l", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Confirm(ConfirmOptions{Message: "Continue?"}) + if err != nil { + return err + } + if !result { + t.Fatalf("Confirm(toggle) = false") + } + if !strings.Contains(output.Text(), "\x1b[36mContinue?\x1b[39m") || !strings.Contains(output.Text(), "\x1b[32m●\x1b[39m Yes") { + t.Fatalf("confirm output missing active frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSelectNavigatesDefaultsAndTransforms(t *testing.T) { + choices := NewStringChoices("first", "second", "third") + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Select(ChoiceOptions{Message: "Pick one", Choices: choices}) + if err != nil { + return err + } + if result != "second" { + t.Fatalf("Select() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick one") || !strings.Contains(output.Text(), "second") { + t.Fatalf("select output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + interactive := false + err = environment.With(environment.Patch{Output: environment.NewMemoryOutput(), ErrorOutput: environment.NewMemoryOutput(), Interactive: &interactive}, func() error { + result, err := Select(ChoiceOptions{ + Message: "Pick one", + Choices: choices, + Default: "second", + HasDefault: true, + Transform: func(value any) (any, error) { + return strings.ToUpper(value.(string)), nil + }, + Validate: func(value any) (string, error) { + if value == "SECOND" { + return "", nil + } + return "Unexpected value.", nil + }, + }) + if err != nil { + return err + } + if result != "SECOND" { + t.Fatalf("noninteractive Select() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMultiselectTogglesChoices(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Space), key.First(key.Down), key.First(key.Space), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Multiselect(MultiChoiceOptions{Message: "Pick many", Choices: NewStringChoices("first", "second")}) + if err != nil { + return err + } + if !reflect.DeepEqual(result, []any{"first", "second"}) { + t.Fatalf("Multiselect() = %#v", result) + } + if !strings.Contains(output.Text(), "Use the space bar") { + t.Fatalf("multiselect output missing hint:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSearchFiltersAndValidatesSelections(t *testing.T) { + colors := func(query string) []Choice { + all := []Choice{{Label: "Red", Value: "red"}, {Label: "Green", Value: "green"}, {Label: "Blue", Value: "blue"}} + if query == "" { + return all + } + filtered := make([]Choice, 0) + for _, choice := range all { + if strings.Contains(strings.ToLower(choice.Label), strings.ToLower(query)) { + filtered = append(filtered, choice) + } + } + return filtered + } + + err := withTestEnvironment(environment.NewScriptedInput([]string{"u", "e", key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Search(SearchOptions{Message: "Favorite color?", Source: colors}) + if err != nil { + return err + } + if result != "blue" { + t.Fatalf("Search() = %#v", result) + } + if !strings.Contains(textutil.ParseAnsiText(output.Text()), "ue") || !strings.Contains(output.Text(), "Blue") { + t.Fatalf("search output missing query/result:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter), key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Search(SearchOptions{ + Message: "Favorite color?", + Source: colors, + Validate: func(value any) (string, error) { + if value == "red" { + return "Please choose green.", nil + } + return "", nil + }, + }) + if err != nil { + return err + } + if result != "green" { + t.Fatalf("validated Search() = %#v", result) + } + if !strings.Contains(output.Text(), "Please choose green.") { + t.Fatalf("search output missing validation error:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMultiSearchMarksHighlightedChoices(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Space), key.First(key.Down), key.First(key.Space), key.First(key.Enter)}), func(_ *environment.MemoryOutput) error { + result, err := MultiSearch(MultiSearchOptions{Message: "Pick colors", Choices: NewStringChoices("red", "green", "blue")}) + if err != nil { + return err + } + if len(result) != 2 || result[0] == result[1] { + t.Fatalf("MultiSearch() = %#v", result) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestSuggestAndAutocomplete(t *testing.T) { + err := withTestEnvironment(environment.NewScriptedInput([]string{"b", key.First(key.Tab), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Suggest(SuggestOptions{Message: "Favorite color?", Options: []string{"Red", "Green", "Blue"}}) + if err != nil { + return err + } + if result != "Blue" { + t.Fatalf("Suggest() = %q", result) + } + if !strings.Contains(output.Text(), "Blue") { + t.Fatalf("suggest output missing match:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withTestEnvironment(environment.NewScriptedInput([]string{"b", key.First(key.Tab), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := Autocomplete(SuggestOptions{Message: "Favorite color?", Options: []string{"Red", "Green", "Blue"}}) + if err != nil { + return err + } + if result != "Blue" { + t.Fatalf("Autocomplete() = %q", result) + } + if !strings.Contains(textutil.ParseAnsiText(output.Text()), "Blue") { + t.Fatalf("autocomplete output missing completion:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestChoiceRequiredValidation(t *testing.T) { + interactive := false + err := environment.With(environment.Patch{Output: environment.NewMemoryOutput(), ErrorOutput: environment.NewMemoryOutput(), Interactive: &interactive}, func() error { + _, err := Select(ChoiceOptions{Message: "Pick one", Choices: NewStringChoices("first", "second")}) + var validation environment.PromptValidationError + if err == nil || !errorsAs(err, &validation) || validation.Message != "Required." { + t.Fatalf("expected required validation error, got %v", err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func errorsAs(err error, target *environment.PromptValidationError) bool { + if validation, ok := err.(environment.PromptValidationError); ok { + *target = validation + return true + } + return false +} + +var _ = prompt.Required{} diff --git a/console/shell/internal/prompts/search_suggest.go b/console/shell/internal/prompts/search_suggest.go new file mode 100644 index 00000000..ea591b65 --- /dev/null +++ b/console/shell/internal/prompts/search_suggest.go @@ -0,0 +1,728 @@ +package prompts + +import ( + "errors" + "reflect" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type SearchSource func(query string) []Choice + +type SearchOptions struct { + Message string + Choices []Choice + Source SearchSource + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Placeholder string + Scroll int + Transform func(any) (any, error) +} + +type MultiSearchOptions struct { + Message string + Choices []Choice + Source SearchSource + Defaults []any + Required prompt.Required + Validate prompt.Validator[[]any] + Placeholder string + Scroll int + Transform func([]any) ([]any, error) +} + +type SuggestSource func(query string) []string + +type SuggestOptions struct { + Message string + Options []string + Source SuggestSource + Placeholder string + Default string + Required prompt.Required + Validate prompt.Validator[string] + Hint string + Scroll int + Transform func(string) (string, error) +} + +func Search(options SearchOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + validate := preserveInvalidSearchDefault(&options) + defaultValue := options.Default + if options.HasDefault && options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: requiredDefault(options.Required, true), Validate: validate}, + func(attempt int) (any, error) { + value, label, submitted, err := readSearchChoice(options, attempt) + if err != nil { + return nil, err + } + if value == nil { + return nil, prompt.ValidationError("Please select a valid option.") + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoice(options.Message, label) + } + return value, nil + }, + nil, + ) +} + +func MultiSearch(options MultiSearchOptions) ([]any, error) { + if options.Scroll == 0 { + options.Scroll = 5 + } + return prompt.UntilValid( + prompt.Options[[]any]{Message: options.Message, Default: options.Defaults, Required: options.Required, Validate: options.Validate}, + func(_ int) ([]any, error) { + value, labels, submitted, err := readMultiSearchChoices(options) + if err != nil { + return nil, err + } + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if submitted { + renderSubmittedChoices(options.Message, labels) + } + return value, nil + }, + nil, + ) +} + +func Suggest(options SuggestOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidSuggestDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedSuggestDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + value, rendered, err := readSuggestionValue(options) + if err != nil { + return "", err + } + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = rendered + if options.Transform != nil { + return options.Transform(value) + } + return value, nil + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func Autocomplete(options SuggestOptions) (string, error) { + shouldRenderSubmittedFrame := false + validate := preserveInvalidSuggestDefault(&options) + + return prompt.UntilValid( + prompt.Options[string]{Message: options.Message, Default: transformedSuggestDefault(options), Required: options.Required, Validate: validate}, + func(_ int) (string, error) { + value, rendered, err := readAutocompleteValue(options) + if err != nil { + return "", err + } + if value == "" { + value = options.Default + } + shouldRenderSubmittedFrame = rendered + if options.Transform != nil { + return options.Transform(value) + } + return value, nil + }, + func(value string) error { + if shouldRenderSubmittedFrame { + renderSubmittedTypedValue(options.Message, value) + } + return nil + }, + ) +} + +func readSearchChoice(options SearchOptions, attempt int) (any, string, bool, error) { + env := environment.Current() + state := typedvalue.State{} + choices := resolveSearchChoices(options, "") + highlighted := -1 + if attempt > 0 && len(choices) > 0 { + highlighted = 0 + } + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return nil, "", false, lineErr + } + if stdstrings.TrimSpace(answer) == "" && options.HasDefault { + return options.Default, "", false, nil + } + choice := findChoice(resolveSearchChoices(options, answer), answer) + if choice == nil { + return nil, "", false, nil + } + return choice.Value, choice.Label, false, nil + } + return nil, "", false, err + } + if !ok { + return options.Default, "", false, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + if highlighted >= 0 && highlighted < len(choices) && !choices[highlighted].Disabled { + return choices[highlighted].Value, choices[highlighted].Label, false, nil + } + return options.Default, "", false, nil + } + if action, ok := searchNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0 || attempt > 0) { + choices = resolveSearchChoices(options, state.Value) + highlighted = moveSearchHighlight(choices, highlighted, action, options.Scroll) + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + if clearsHighlight(pressed) && highlighted >= 0 { + highlighted = -1 + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Enter) { + choices = resolveSearchChoices(options, state.Value) + if highlighted >= 0 && highlighted < len(choices) { + choice := choices[highlighted] + return choice.Value, choice.Label, !choice.Disabled, nil + } + if state.Value == "" && options.HasDefault { + choice := choiceByValue(choices, options.Default) + if choice != nil { + return choice.Value, choice.Label, true, nil + } + return options.Default, "", false, nil + } + highlighted = -1 + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + return options.Default, "", false, nil + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + choices = resolveSearchChoices(options, state.Value) + renderSearchChoices(options.Message, state.Value, choices, highlighted, nil, false, options.Placeholder, options.Scroll) + } +} + +func readMultiSearchChoices(options MultiSearchOptions) ([]any, []string, bool, error) { + env := environment.Current() + state := typedvalue.State{} + choices := resolveMultiSearchChoices(options, "") + highlighted := -1 + selected := map[any]string{} + for _, value := range options.Defaults { + if choice := choiceByValue(choices, value); choice != nil { + selected[choice.Value] = choice.Label + } + } + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return nil, nil, false, lineErr + } + if stdstrings.TrimSpace(answer) == "" { + return options.Defaults, nil, false, nil + } + return choicesFromCommaSeparated(resolveMultiSearchChoices(options, answer), answer), nil, false, nil + } + return nil, nil, false, err + } + if !ok || pressed == key.First(key.Enter) { + values, labels := selectedSearchValues(selected) + return values, labels, true, nil + } + if pressed == key.First(key.CtrlC) { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + values, labels := selectedSearchValues(selected) + return values, labels, false, nil + } + if action, ok := searchNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0) { + choices = resolveMultiSearchChoices(options, state.Value) + highlighted = moveSearchHighlight(choices, highlighted, action, options.Scroll) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Space) && highlighted >= 0 && highlighted < len(choices) { + toggleSearchSelection(selected, choices[highlighted]) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSearch(options.Message, state.Value, options.Placeholder) + return options.Defaults, nil, false, nil + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + choices = resolveMultiSearchChoices(options, state.Value) + renderSearchChoices(options.Message, state.Value, choices, highlighted, searchMarked(choices, selected), true, options.Placeholder, options.Scroll) + } +} + +func readSuggestionValue(options SuggestOptions) (string, bool, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + highlighted := -1 + matches := resolveSuggestions(options, state.Value) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, options.Hint)) + return answer, false, lineErr + } + return "", false, err + } + if !ok { + return state.Value, true, nil + } + if action, ok := suggestionNavigationDirection(pressed); ok && (highlighted >= 0 || action > 0) { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, action, options.Scroll) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if clearsHighlight(pressed) && highlighted >= 0 { + highlighted = -1 + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Tab) && highlighted < 0 { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, 1, options.Scroll) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + continue + } + if pressed == key.First(key.Enter) { + if highlighted >= 0 && highlighted < len(matches) { + return matches[highlighted], true, nil + } + return state.Value, true, nil + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Cancelled { + renderCancelledSuggestion(options.Message, state.Value, options.Placeholder) + value, cancelErr := environment.CancelPrompt(state.Value) + return value, true, cancelErr + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = -1 + matches = resolveSuggestions(options, state.Value) + renderSuggestions(options.Message, state.Value, matches, highlighted, options.Placeholder, options.Scroll) + } +} + +func readAutocompleteValue(options SuggestOptions) (string, bool, error) { + env := environment.Current() + state := typedvalue.InitialState(options.Default) + highlighted := 0 + matches := resolveSuggestions(options, state.Value) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, options.Hint)) + return answer, false, lineErr + } + return "", false, err + } + if !ok { + return state.Value, true, nil + } + if direction, ok := autocompleteNavigationDirection(pressed); ok { + matches = resolveSuggestions(options, state.Value) + highlighted = moveSuggestionHighlight(matches, highlighted, direction, 0) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + if pressed == key.First(key.Tab) && canAcceptAutocomplete(state) { + matches = resolveSuggestions(options, state.Value) + if highlighted >= 0 && highlighted < len(matches) && typedvalue.CharacterLength(matches[highlighted]) > typedvalue.CharacterLength(state.Value) { + state = typedvalue.InitialState(matches[highlighted]) + matches = resolveSuggestions(options, state.Value) + } else { + highlighted = 0 + } + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + if (pressed == key.First(key.Right) || pressed == key.First(key.RightArrow)) && canAcceptAutocomplete(state) { + matches = resolveSuggestions(options, state.Value) + if highlighted >= 0 && highlighted < len(matches) { + state = typedvalue.InitialState(matches[highlighted]) + } + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + continue + } + next := typedvalue.ApplyKey(state, pressed, false) + if next.Submitted { + return state.Value, true, nil + } + if next.Cancelled { + renderCancelledSuggestion(options.Message, state.Value, options.Placeholder) + value, cancelErr := environment.CancelPrompt(state.Value) + return value, true, cancelErr + } + state = typedvalue.State{Cursor: next.Cursor, Value: next.Value} + highlighted = 0 + matches = resolveSuggestions(options, state.Value) + renderAutocomplete(options.Message, state, matches, highlighted, options.Placeholder) + } +} + +func renderSearchChoices(message string, query string, choices []Choice, highlighted int, marked map[int]bool, multi bool, placeholder string, scroll int) { + bodyLines := []string{queryOrPlaceholder(query, placeholder)} + if len(choices) == 0 { + bodyLines = append(bodyLines, theme.Dim(" No results.")) + } else { + rows := make([]string, len(choices)) + for index, choice := range choices { + pointer := " " + if index == highlighted { + pointer = "β€Ί" + } + prefix := pointer + " " + if multi { + if marked[index] { + prefix += "β—Ό " + } else { + prefix += "β—» " + } + } + label := choice.Label + if choice.Disabled { + label = theme.Dim(label) + } + if index == highlighted { + rows[index] = theme.Cyan(prefix) + label + } else { + rows[index] = " " + prefix + label + } + } + bodyLines = append(bodyLines, windowedRows(rows, max(highlighted, 0), scroll)) + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(bodyLines, "\n"), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledSearch(message string, query string, placeholder string) { + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(queryOrPlaceholder(query, placeholder))), BorderStyle: theme.Red, Title: theme.Dim(message)}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func renderSuggestions(message string, query string, matches []string, highlighted int, placeholder string, scroll int) { + bodyLines := []string{queryOrPlaceholder(query, placeholder)} + rows := make([]string, len(matches)) + for index, match := range matches { + pointer := " " + if index == highlighted { + pointer = "β€Ί" + rows[index] = theme.Cyan(pointer) + " " + match + } else { + rows[index] = " " + theme.Dim(match) + } + } + if len(rows) > 0 { + bodyLines = append(bodyLines, windowedRows(rows, max(highlighted, 0), scroll)) + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: stdstrings.Join(bodyLines, "\n"), BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderAutocomplete(message string, state typedvalue.State, matches []string, highlighted int, placeholder string) { + body := queryOrPlaceholder(state.Value, placeholder) + if canAcceptAutocomplete(state) && highlighted >= 0 && highlighted < len(matches) && stdstrings.HasPrefix(stdstrings.ToLower(matches[highlighted]), stdstrings.ToLower(state.Value)) { + match := matches[highlighted] + if len(match) > len(state.Value) { + body = state.Value + theme.Inverse(match[len(state.Value):len(state.Value)+1]) + theme.Dim(match[len(state.Value)+1:]) + } else { + body = match + } + } + environment.Current().Output.Write(theme.RenderBox(theme.BoxOptions{Body: body, BorderStyle: theme.Cyan, Title: theme.Cyan(message)}) + "\n") +} + +func renderCancelledSuggestion(message string, value string, placeholder string) { + env := environment.Current() + env.Output.Write(theme.RenderBox(theme.BoxOptions{Body: theme.Strikethrough(theme.Dim(queryOrPlaceholder(value, placeholder))), BorderStyle: theme.Red, Title: message}) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func resolveSearchChoices(options SearchOptions, query string) []Choice { + if options.Source != nil { + return options.Source(query) + } + return options.Choices +} + +func resolveMultiSearchChoices(options MultiSearchOptions, query string) []Choice { + if options.Source != nil { + return options.Source(query) + } + return options.Choices +} + +func resolveSuggestions(options SuggestOptions, query string) []string { + values := options.Options + if options.Source != nil { + values = options.Source(query) + } + if query == "" { + return values + } + filtered := make([]string, 0, len(values)) + lower := stdstrings.ToLower(query) + for _, value := range values { + if stdstrings.Contains(stdstrings.ToLower(value), lower) { + filtered = append(filtered, value) + } + } + return filtered +} + +func queryOrPlaceholder(query string, placeholder string) string { + if query != "" { + return query + } + return theme.Dim(placeholder) +} + +func searchNavigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return -1, true + case key.First(key.PageDown), key.First(key.End): + return 5, true + case key.First(key.PageUp), key.Home[0]: + return -5, true + default: + return 0, false + } +} + +func suggestionNavigationDirection(pressed string) (int, bool) { + if direction, ok := searchNavigationDirection(pressed); ok { + return direction, true + } + switch pressed { + case key.First(key.Tab): + return 1, true + case key.First(key.ShiftTab): + return -1, true + default: + return 0, false + } +} + +func autocompleteNavigationDirection(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow): + return 1, true + case key.First(key.Up), key.First(key.UpArrow): + return -1, true + default: + return 0, false + } +} + +func moveSearchHighlight(choices []Choice, highlighted int, direction int, scroll int) int { + if len(choices) == 0 { + return -1 + } + index := highlighted + if index < 0 { + if direction < 0 { + index = len(choices) + } else { + index = -1 + } + } + step := 1 + if direction < 0 { + step = -1 + } + attempts := abs(direction) + if attempts == 0 { + attempts = 1 + } + for range attempts { + index = (index + step + len(choices)) % len(choices) + for choices[index].Disabled { + index = (index + step + len(choices)) % len(choices) + } + } + return index +} + +func moveSuggestionHighlight(matches []string, highlighted int, direction int, _ int) int { + if len(matches) == 0 { + return -1 + } + if highlighted < 0 { + if direction < 0 { + highlighted = len(matches) + } else { + highlighted = -1 + } + } + next := (highlighted + direction) % len(matches) + if next < 0 { + next += len(matches) + } + return next +} + +func clearsHighlight(pressed string) bool { + return pressed == key.First(key.Left) || pressed == key.First(key.LeftArrow) || pressed == key.First(key.Right) || pressed == key.First(key.RightArrow) || pressed == key.First(key.CtrlB) || pressed == key.First(key.CtrlF) +} + +func canAcceptAutocomplete(state typedvalue.State) bool { + return state.Cursor >= typedvalue.CharacterLength(state.Value) +} + +func preserveInvalidSearchDefault(options *SearchOptions) prompt.Validator[any] { + return func(value any) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + options.HasDefault = true + } + return message, nil + } +} + +func preserveInvalidSuggestDefault(options *SuggestOptions) prompt.Validator[string] { + return func(value string) (string, error) { + if options.Validate == nil { + return "", nil + } + message, err := options.Validate(value) + if err != nil { + return "", err + } + if message != "" { + options.Default = value + } + return message, nil + } +} + +func transformedSuggestDefault(options SuggestOptions) string { + if options.Transform == nil { + return options.Default + } + value, err := options.Transform(options.Default) + if err != nil { + return options.Default + } + return value +} + +func searchMarked(choices []Choice, selected map[any]string) map[int]bool { + marked := map[int]bool{} + for index, choice := range choices { + if _, ok := selected[choice.Value]; ok { + marked[index] = true + } + } + return marked +} + +func toggleSearchSelection(selected map[any]string, choice Choice) { + if choice.Disabled { + return + } + if _, ok := selected[choice.Value]; ok { + delete(selected, choice.Value) + return + } + selected[choice.Value] = choice.Label +} + +func selectedSearchValues(selected map[any]string) ([]any, []string) { + values := make([]any, 0, len(selected)) + labels := make([]string, 0, len(selected)) + for value, label := range selected { + values = append(values, value) + labels = append(labels, label) + } + return values, labels +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} + +func choiceEqual(left any, right any) bool { + return reflect.DeepEqual(left, right) +} From 2d8020f26973b4046fd6fc433893c157e65c2a18 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:12:16 +0800 Subject: [PATCH 006/404] feat(shell): port output helpers and tables --- console/shell/internal/output/datatable.go | 300 +++++++++++++++++++ console/shell/internal/output/notes.go | 96 ++++++ console/shell/internal/output/notify.go | 90 ++++++ console/shell/internal/output/output_test.go | 178 +++++++++++ console/shell/internal/output/table.go | 152 ++++++++++ console/shell/internal/output/terminal.go | 14 + 6 files changed, 830 insertions(+) create mode 100644 console/shell/internal/output/datatable.go create mode 100644 console/shell/internal/output/notes.go create mode 100644 console/shell/internal/output/notify.go create mode 100644 console/shell/internal/output/output_test.go create mode 100644 console/shell/internal/output/table.go create mode 100644 console/shell/internal/output/terminal.go diff --git a/console/shell/internal/output/datatable.go b/console/shell/internal/output/datatable.go new file mode 100644 index 00000000..0369d3a4 --- /dev/null +++ b/console/shell/internal/output/datatable.go @@ -0,0 +1,300 @@ +package output + +import ( + "errors" + "fmt" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/theme" + "github.com/oullin/tui/internal/typedvalue" +) + +type DataTableRow struct { + Cells map[string]any + Value any +} + +type DataTableOptions struct { + Message string + Headers []string + Rows []DataTableRow + Default any + HasDefault bool + Required prompt.Required + Validate prompt.Validator[any] + Transform func(any) (any, error) + Filter func(string, DataTableRow) bool + Scroll int +} + +func DataTable(options DataTableOptions) (any, error) { + if options.Scroll == 0 { + options.Scroll = 10 + } + if len(options.Headers) == 0 { + options.Headers = deriveHeaders(options.Rows) + } + defaultValue := options.Default + if options.HasDefault && options.Transform != nil { + transformed, err := options.Transform(defaultValue) + if err == nil { + defaultValue = transformed + } + } + var submitted *dataTableSelection + return prompt.UntilValid( + prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate}, + func(_ int) (any, error) { + selection, err := readDataTableSelection(options) + if err != nil { + return nil, err + } + value := selection.Value + if options.Transform != nil { + value, err = options.Transform(value) + if err != nil { + return nil, err + } + } + if selection.Submitted && !selection.Cancelled { + submitted = &selection + } + return value, nil + }, + func(any) error { + if submitted != nil { + renderSubmittedDataTableFrame(options.Message, options.Headers, submitted.Row) + } + return nil + }, + ) +} + +type dataTableSelection struct { + Cancelled bool + Submitted bool + Row DataTableRow + Value any +} + +func readDataTableSelection(options DataTableOptions) (dataTableSelection, error) { + env := environment.Current() + rows := visibleRows(options, "") + selected := defaultDataTableIndex(rows, options.Default, options.HasDefault) + query := "" + mode := "browse" + renderDataTableFrame(options, rows, selected, mode, query) + + for { + pressed, ok, err := env.Input.ReadKey() + if err != nil { + if errors.Is(err, environment.ErrKeyInputUnsupported) { + answer, lineErr := env.Input.ReadLine(theme.RenderQuestion(options.Message, "")) + if lineErr != nil { + return dataTableSelection{}, lineErr + } + rows = visibleRows(options, answer) + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + return dataTableSelection{Row: rows[0], Value: dataTableRowValue(rows[0], 0)}, nil + } + return dataTableSelection{}, err + } + if !ok || pressed == key.First(key.Enter) { + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + row := rows[selected] + return dataTableSelection{Submitted: true, Row: row, Value: dataTableRowValue(row, selected)}, nil + } + if pressed == key.First(key.CtrlC) { + if len(rows) == 0 { + return dataTableSelection{}, prompt.ValidationError("Please select a valid option.") + } + row := rows[selected] + renderCancelledDataTableFrame(options, rows, selected) + return dataTableSelection{Cancelled: true, Row: row, Value: dataTableRowValue(row, selected)}, nil + } + if pressed == "/" { + mode = "search" + query = "" + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + if mode == "search" { + if pressed == key.First(key.Escape) { + mode = "browse" + query = "" + rows = visibleRows(options, query) + selected = clampSelection(selected, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + next := typedvalue.ApplyKey(typedvalue.State{Cursor: typedvalue.CharacterLength(query), Value: query}, pressed, false) + if !next.Submitted && !next.Cancelled { + query = next.Value + rows = visibleRows(options, query) + selected = clampSelection(0, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + } + if direction, ok := dataTableNavigation(pressed); ok { + selected = clampSelection(selected+direction, rows) + renderDataTableFrame(options, rows, selected, mode, query) + continue + } + } +} + +func renderDataTableFrame(options DataTableOptions, rows []DataTableRow, selected int, mode string, query string) { + title := options.Message + if mode == "search" || query != "" { + title += " " + query + } + if len(rows) == 0 { + environment.Current().Output.Write(title + "\n" + theme.RenderTable(nil, [][]string{{"No results found."}}) + "\n") + return + } + windowStart := 0 + windowEnd := len(rows) + if options.Scroll > 0 && options.Scroll < len(rows) { + windowStart = selected - (options.Scroll-1)/2 + if windowStart < 0 { + windowStart = 0 + } + if windowStart+options.Scroll > len(rows) { + windowStart = len(rows) - options.Scroll + } + windowEnd = windowStart + options.Scroll + } + rendered := make([][]string, windowEnd-windowStart) + for index := windowStart; index < windowEnd; index++ { + pointer := " " + if index == selected { + pointer = "β€Ί" + } + rendered[index-windowStart] = append([]string{pointer}, dataTableRowCells(options.Headers, rows[index])...) + } + headers := append([]string{""}, options.Headers...) + environment.Current().Output.Write(title + "\n" + theme.RenderTable(headers, rendered) + "\n") +} + +func renderSubmittedDataTableFrame(message string, headers []string, row DataTableRow) { + environment.Current().Output.Write(message + "\n" + stdstrings.Join(dataTableRowCells(headers, row), ", ") + "\n") +} + +func renderCancelledDataTableFrame(options DataTableOptions, rows []DataTableRow, selected int) { + rendered := make([][]string, len(rows)) + for index, row := range rows { + pointer := " " + if index == selected { + pointer = "β€Ί" + } + cells := dataTableRowCells(options.Headers, row) + for cellIndex, cell := range cells { + cells[cellIndex] = theme.Strikethrough(theme.Dim(cell)) + } + rendered[index] = append([]string{pointer}, cells...) + } + env := environment.Current() + env.Output.Write(options.Message + "\n") + env.Output.Write(theme.Dim("/ Search") + "\n") + env.Output.Write(theme.RenderTable(append([]string{""}, options.Headers...), rendered) + "\n") + env.ErrorOutput.Write(theme.Red(" ⚠ Cancelled.") + "\n") +} + +func visibleRows(options DataTableOptions, query string) []DataTableRow { + rows := make([]DataTableRow, 0, len(options.Rows)) + for _, row := range options.Rows { + if query == "" { + rows = append(rows, row) + continue + } + if options.Filter != nil { + if options.Filter(query, row) { + rows = append(rows, row) + } + continue + } + if stdstrings.Contains(stdstrings.ToLower(stdstrings.Join(dataTableRowCells(options.Headers, row), " ")), stdstrings.ToLower(query)) { + rows = append(rows, row) + } + } + return rows +} + +func deriveHeaders(rows []DataTableRow) []string { + if len(rows) == 0 { + return nil + } + headers := make([]string, 0, len(rows[0].Cells)) + for key := range rows[0].Cells { + if key != "value" { + headers = append(headers, key) + } + } + return headers +} + +func dataTableRowCells(headers []string, row DataTableRow) []string { + if len(headers) == 0 { + cells := make([]string, 0, len(row.Cells)) + for _, value := range row.Cells { + cells = append(cells, stringifyCell(value)) + } + return cells + } + cells := make([]string, len(headers)) + for index, header := range headers { + cells[index] = stringifyCell(row.Cells[header]) + } + return cells +} + +func dataTableRowValue(row DataTableRow, index int) any { + if row.Value != nil { + return row.Value + } + return index +} + +func defaultDataTableIndex(rows []DataTableRow, value any, hasDefault bool) int { + if !hasDefault { + return 0 + } + for index, row := range rows { + if fmt.Sprint(dataTableRowValue(row, index)) == fmt.Sprint(value) { + return index + } + } + return 0 +} + +func dataTableNavigation(pressed string) (int, bool) { + switch pressed { + case key.First(key.Down), key.First(key.DownArrow), key.First(key.CtrlN): + return 1, true + case key.First(key.Up), key.First(key.UpArrow), key.First(key.CtrlP): + return -1, true + default: + return 0, false + } +} + +func clampSelection(selected int, rows []DataTableRow) int { + if len(rows) == 0 { + return 0 + } + if selected < 0 { + return 0 + } + if selected >= len(rows) { + return len(rows) - 1 + } + return selected +} diff --git a/console/shell/internal/output/notes.go b/console/shell/internal/output/notes.go new file mode 100644 index 00000000..df75486e --- /dev/null +++ b/console/shell/internal/output/notes.go @@ -0,0 +1,96 @@ +package output + +import ( + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" +) + +type NoteType string + +const ( + NoteAlert NoteType = "alert" + NoteError NoteType = "error" + NoteInfo NoteType = "info" + NoteIntro NoteType = "intro" + NoteOutro NoteType = "outro" + NoteWarning NoteType = "warning" +) + +func Note(message string, noteType NoteType) { + environment.Current().Output.Write(stdstrings.Join(renderNoteLines(message, noteType), "\n") + "\n") +} + +func Error(message string) { + Note(message, NoteError) +} + +func Warning(message string) { + Note(message, NoteWarning) +} + +func Alert(message string) { + Note(message, NoteAlert) +} + +func Info(message string) { + Note(message, NoteInfo) +} + +func Intro(message string) { + Note(message, NoteIntro) +} + +func Outro(message string) { + Note(message, NoteOutro) +} + +func renderNoteLines(message string, noteType NoteType) []string { + lines := stdstrings.Split(message, "\n") + if noteType == NoteIntro || noteType == NoteOutro { + lines = paddedIntroLines(lines) + } + + for index, line := range lines { + lines[index] = renderNoteLine(line, noteType) + } + + return lines +} + +func paddedIntroLines(lines []string) []string { + padded := make([]string, len(lines)) + longest := 0 + for index, line := range lines { + padded[index] = " " + line + " " + if width := strings.VisibleWidth(padded[index]); width > longest { + longest = width + } + } + for index, line := range padded { + padding := longest - strings.VisibleWidth(line) + if padding > 0 { + padded[index] = line + stdstrings.Repeat(" ", padding) + } + } + return padded +} + +func renderNoteLine(line string, noteType NoteType) string { + switch noteType { + case NoteIntro, NoteOutro: + return " " + theme.BackgroundCyan(theme.Black(line)) + case NoteWarning: + return theme.Yellow(" " + line) + case NoteError: + return theme.Red(" " + line) + case NoteAlert: + return " " + theme.BackgroundRed(theme.White(" "+line+" ")) + case NoteInfo: + return theme.Green(" " + line) + default: + return " " + line + } +} diff --git a/console/shell/internal/output/notify.go b/console/shell/internal/output/notify.go new file mode 100644 index 00000000..600c4819 --- /dev/null +++ b/console/shell/internal/output/notify.go @@ -0,0 +1,90 @@ +package output + +import "fmt" + +type NotificationCommand struct { + Bin string + Args []string +} + +type NotificationRuntime struct { + CommandExists func(string) bool + Execute func(NotificationCommand) bool +} + +func NotificationCommandFor(platform string, title string, body string, subtitle string, sound string, icon string) *NotificationCommand { + commands := NotificationCommands(platform, title, body, subtitle, sound, icon) + if len(commands) == 0 { + return nil + } + return &commands[0] +} + +func NotificationCommands(platform string, title string, body string, subtitle string, sound string, icon string) []NotificationCommand { + switch platform { + case "darwin": + script := fmt.Sprintf("display notification %q with title %q", body, title) + if subtitle != "" { + script += fmt.Sprintf(" subtitle %q", subtitle) + } + if sound != "" { + script += fmt.Sprintf(" sound name %q", sound) + } + return []NotificationCommand{{Bin: "osascript", Args: []string{"-e", script}}} + case "linux": + args := []string{} + if icon != "" { + args = append(args, "--icon", icon) + } + args = append(args, title) + if body != "" { + args = append(args, body) + } + message := title + if body != "" { + message += ": " + body + } + return []NotificationCommand{ + {Bin: "notify-send", Args: args}, + {Bin: "kdialog", Args: []string{"--passivepopup", message, "5", "--title", title}}, + } + default: + return nil + } +} + +func NotifyForPlatform(platform string, title string, body string, subtitle string, sound string, icon string, runtime NotificationRuntime) bool { + commands := NotificationCommands(platform, title, body, subtitle, sound, icon) + command := availableNotificationCommand(platform, commands, runtime.CommandExists) + if command != nil { + if runtime.Execute == nil { + return false + } + return runtime.Execute(*command) + } + + if body != "" { + Note(title+": "+body, NoteInfo) + } else { + Note(title, NoteInfo) + } + return false +} + +func availableNotificationCommand(platform string, commands []NotificationCommand, exists func(string) bool) *NotificationCommand { + if len(commands) == 0 { + return nil + } + if platform != "linux" { + return &commands[0] + } + if exists == nil { + return nil + } + for index, command := range commands { + if exists(command.Bin) { + return &commands[index] + } + } + return nil +} diff --git a/console/shell/internal/output/output_test.go b/console/shell/internal/output/output_test.go new file mode 100644 index 00000000..53f3aca4 --- /dev/null +++ b/console/shell/internal/output/output_test.go @@ -0,0 +1,178 @@ +package output + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + textutil "github.com/oullin/tui/internal/strings" +) + +func withOutput(callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + return environment.With(environment.Patch{Output: output, ErrorOutput: output}, func() error { + return callback(output) + }) +} + +func TestNoteHelpersRenderStyledLines(t *testing.T) { + err := withOutput(func(output *environment.MemoryOutput) error { + Note("Hello\nWorld", "") + Warning("Careful") + Error("Nope") + Alert("Heads up") + Info("Facts") + Intro("東京\nA") + Outro("Done") + + rendered := output.Text() + if !strings.Contains(rendered, "Hello") || !strings.Contains(rendered, "\x1b[33m Careful\x1b[39m") || !strings.Contains(rendered, "\x1b[31m Nope\x1b[39m") { + t.Fatalf("note output missing styled lines:\n%s", rendered) + } + if !strings.Contains(textutil.ParseAnsiText(rendered), " 東京 ") || !strings.Contains(textutil.ParseAnsiText(rendered), " A ") { + t.Fatalf("intro output missing visible-width padding:\n%s", rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTableGridAndTerminalHelpers(t *testing.T) { + err := withOutput(func(output *environment.MemoryOutput) error { + Table([]string{"Name", "Runtime"}, [][]any{{"Ollin", "OpenTUI"}}) + Grid([]any{"A", "B"}, 80) + Title("Prompt") + Clear() + + rendered := output.Text() + for _, want := range []string{"Name", "Runtime", "Ollin", "OpenTUI", "β”Œβ”€β”€β”€β”¬β”€β”€β”€β”", "β”‚ A β”‚ B β”‚", "\x1b]0;Prompt\x07", "\x1b[H\x1b[J"} { + if !strings.Contains(rendered, want) { + t.Fatalf("output missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRenderGridBalancedRows(t *testing.T) { + got := RenderGrid([]any{"component-alpha", "api-client", "theme-box", "status-task", "prompt-input", "stream-log"}, 50) + want := strings.Join([]string{ + " β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”", + " β”‚ component-alpha β”‚ api-client β”‚", + " β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€", + " β”‚ theme-box β”‚ status-task β”‚", + " β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€", + " β”‚ prompt-input β”‚ stream-log β”‚", + " β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜", + }, "\n") + if got != want { + t.Fatalf("RenderGrid() =\n%s\nwant\n%s", got, want) + } +} + +func TestNotificationCommandsAndFallback(t *testing.T) { + command := NotificationCommandFor("darwin", "Deploy", "Done", "Preview", "Glass", "") + if command == nil || command.Bin != "osascript" || !strings.Contains(command.Args[1], `display notification "Done"`) || !strings.Contains(command.Args[1], `sound name "Glass"`) { + t.Fatalf("darwin command = %#v", command) + } + + commands := NotificationCommands("linux", "Deploy", "Done", "", "", "/tmp/icon.png") + wantFirst := NotificationCommand{Bin: "notify-send", Args: []string{"--icon", "/tmp/icon.png", "Deploy", "Done"}} + if !reflect.DeepEqual(commands[0], wantFirst) { + t.Fatalf("linux command = %#v, want %#v", commands[0], wantFirst) + } + + executed := []NotificationCommand{} + ok := NotifyForPlatform("linux", "Deploy", "Done", "", "", "", NotificationRuntime{ + CommandExists: func(bin string) bool { return bin == "kdialog" }, + Execute: func(command NotificationCommand) bool { + executed = append(executed, command) + return true + }, + }) + if !ok || len(executed) != 1 || executed[0].Bin != "kdialog" { + t.Fatalf("NotifyForPlatform linux ok=%v executed=%#v", ok, executed) + } + + err := withOutput(func(output *environment.MemoryOutput) error { + if NotifyForPlatform("freebsd", "Deploy", "Done", "", "", "", NotificationRuntime{}) { + t.Fatalf("unsupported notification returned true") + } + if !strings.Contains(output.Text(), "Deploy: Done") { + t.Fatalf("fallback note missing:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestDataTableSelectsFiltersAndCancels(t *testing.T) { + rows := []DataTableRow{ + {Cells: map[string]any{"Name": "Alpha", "Runtime": "Node"}, Value: "alpha"}, + {Cells: map[string]any{"Name": "Beta", "Runtime": "OpenTUI"}, Value: "beta"}, + } + err := withDataTableEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" { + t.Fatalf("DataTable() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick project") || !strings.Contains(output.Text(), "β€Ί") || !strings.Contains(output.Text(), "Bob, Developer") && !strings.Contains(output.Text(), "Beta, OpenTUI") { + t.Fatalf("datatable output missing expected frame/summary:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withDataTableEnvironment(environment.NewScriptedInput([]string{"/", "B", key.First(key.Enter)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" { + t.Fatalf("filtered DataTable() = %#v", result) + } + if !strings.Contains(output.Text(), "Pick project B") { + t.Fatalf("datatable output missing search query:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withDataTableEnvironment(environment.NewScriptedInput([]string{key.First(key.Down), key.First(key.CtrlC)}), func(output *environment.MemoryOutput) error { + result, err := DataTable(DataTableOptions{Message: "Pick project", Headers: []string{"Name", "Runtime"}, Rows: rows}) + if err != nil { + return err + } + if result != "beta" || !strings.Contains(output.Text(), "Cancelled.") || !strings.Contains(output.Text(), "/ Search") { + t.Fatalf("cancelled DataTable() = %#v output:\n%s", result, output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func withDataTableEnvironment(input environment.Input, callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + interactive := true + return environment.With(environment.Patch{Input: input, Output: output, ErrorOutput: output, Interactive: &interactive}, func() error { + return callback(output) + }) +} diff --git a/console/shell/internal/output/table.go b/console/shell/internal/output/table.go new file mode 100644 index 00000000..2089ccf3 --- /dev/null +++ b/console/shell/internal/output/table.go @@ -0,0 +1,152 @@ +package output + +import ( + "fmt" + "math" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + text "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/theme" +) + +type TableRow map[string]any + +func Table(headers []string, rows [][]any) { + renderedRows := make([][]string, len(rows)) + for rowIndex, row := range rows { + renderedRows[rowIndex] = make([]string, len(row)) + for cellIndex, cell := range row { + renderedRows[rowIndex][cellIndex] = stringifyCell(cell) + } + } + + environment.Current().Output.Write(theme.RenderTable(headers, renderedRows) + "\n") +} + +func TableObjects(headers []string, rows []TableRow) { + if len(headers) == 0 && len(rows) > 0 { + for key := range rows[0] { + headers = append(headers, key) + } + } + + renderedRows := make([][]any, len(rows)) + for rowIndex, row := range rows { + renderedRows[rowIndex] = make([]any, len(headers)) + for headerIndex, header := range headers { + renderedRows[rowIndex][headerIndex] = row[header] + } + } + Table(headers, renderedRows) +} + +func Grid(items []any, maxWidth int) { + rendered := RenderGrid(items, maxWidth) + if rendered == "" { + return + } + + environment.Current().Output.Write(rendered + "\n") +} + +func RenderGrid(items []any, maxWidth int) string { + if len(items) == 0 { + return "" + } + + values := make([]string, len(items)) + for index, item := range items { + values[index] = fmt.Sprint(item) + } + + width := maxWidth + if width <= 0 { + width = 1 + } + availableWidth := width - 2 + cellWidth := 0 + for _, value := range values { + if valueWidth := text.VisibleWidth(value) + 4; valueWidth > cellWidth { + cellWidth = valueWidth + } + } + maxColumns := int(math.Max(1, math.Floor(float64((availableWidth-1)/(cellWidth+1))))) + columnCount := max(1, balancedColumnCount(len(values), maxColumns)) + rows := chunkItems(values, columnCount) + widths := make([]int, columnCount) + for column := range columnCount { + for _, row := range rows { + if width := text.VisibleWidth(row[column]); width > widths[column] { + widths[column] = width + } + } + } + + renderedRows := []string{horizontal("β”Œ", "┬", "┐", widths)} + for index, row := range rows { + if index > 0 { + renderedRows = append(renderedRows, horizontal("β”œ", "β”Ό", "─", widths)) + } + cells := make([]string, len(widths)) + for column, width := range widths { + cells[column] = padVisible(row[column], width) + } + renderedRows = append(renderedRows, "β”‚ "+stdstrings.Join(cells, " β”‚ ")+" β”‚") + } + renderedRows = append(renderedRows, horizontal("β””", "β”΄", "β”˜", widths)) + + for index, line := range renderedRows { + renderedRows[index] = " " + line + } + + return stdstrings.Join(renderedRows, "\n") +} + +func stringifyCell(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func padVisible(value string, width int) string { + padding := width - text.VisibleWidth(value) + if padding < 0 { + padding = 0 + } + return value + stdstrings.Repeat(" ", padding) +} + +func balancedColumnCount(itemCount int, maxColumns int) int { + if itemCount <= maxColumns { + return itemCount + } + for columns := maxColumns; columns >= 1; columns-- { + remainder := itemCount % columns + if remainder == 0 || remainder >= int(math.Ceil(float64(columns)/2)) { + return columns + } + } + return maxColumns +} + +func chunkItems(items []string, columnCount int) [][]string { + rows := make([][]string, 0) + for index := 0; index < len(items); index += columnCount { + row := append([]string{}, items[index:min(index+columnCount, len(items))]...) + for len(row) < columnCount { + row = append(row, "") + } + rows = append(rows, row) + } + return rows +} + +func horizontal(left string, middle string, right string, widths []int) string { + parts := make([]string, len(widths)) + for index, width := range widths { + parts[index] = stdstrings.Repeat("─", width+2) + } + return left + stdstrings.Join(parts, middle) + right +} diff --git a/console/shell/internal/output/terminal.go b/console/shell/internal/output/terminal.go new file mode 100644 index 00000000..ad42c6bd --- /dev/null +++ b/console/shell/internal/output/terminal.go @@ -0,0 +1,14 @@ +package output + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +func Clear() { + environment.Current().Output.Write(terminal.ClearSequence()) +} + +func Title(value string) { + environment.Current().Output.Write(terminal.TitleSequence(value)) +} From 62af37babe7fa8ca104115bcdb864ce9d39ea8c7 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:14:21 +0800 Subject: [PATCH 007/404] feat(shell): port status helpers --- console/shell/internal/status/frame.go | 21 ++ console/shell/internal/status/progress.go | 154 ++++++++++++++ console/shell/internal/status/spinner.go | 52 +++++ console/shell/internal/status/status_test.go | 194 ++++++++++++++++++ console/shell/internal/status/stream.go | 133 ++++++++++++ console/shell/internal/status/task.go | 205 +++++++++++++++++++ 6 files changed, 759 insertions(+) create mode 100644 console/shell/internal/status/frame.go create mode 100644 console/shell/internal/status/progress.go create mode 100644 console/shell/internal/status/spinner.go create mode 100644 console/shell/internal/status/status_test.go create mode 100644 console/shell/internal/status/stream.go create mode 100644 console/shell/internal/status/task.go diff --git a/console/shell/internal/status/frame.go b/console/shell/internal/status/frame.go new file mode 100644 index 00000000..91c96708 --- /dev/null +++ b/console/shell/internal/status/frame.go @@ -0,0 +1,21 @@ +package status + +import ( + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +func RenderedFrameLineCount(frame string) int { + trimmed := stdstrings.TrimSuffix(frame, "\n") + if trimmed == "" { + return 0 + } + return len(stdstrings.Split(trimmed, "\n")) +} + +func EraseRenderedFrame(frame string) { + count := RenderedFrameLineCount(frame) + environment.Current().Output.Write(terminal.ErasePreviousLinesSequence(count)) +} diff --git a/console/shell/internal/status/progress.go b/console/shell/internal/status/progress.go new file mode 100644 index 00000000..8f1a6991 --- /dev/null +++ b/console/shell/internal/status/progress.go @@ -0,0 +1,154 @@ +package status + +import ( + "fmt" + "math" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/theme" +) + +type Progress struct { + current int + label string + hint string + renderedFrame string + cursorHidden bool + Total int +} + +func NewProgress(total int, label string, hint string) *Progress { + if total < 1 { + total = 1 + } + return &Progress{Total: total, label: label, hint: hint} +} + +func (progress *Progress) Start() { + progress.render("active") +} + +func (progress *Progress) Advance(step int) { + progress.current += step + if progress.current < 0 { + progress.current = 0 + } + if progress.current > progress.Total { + progress.current = progress.Total + } + progress.render("active") +} + +func (progress *Progress) Finish() { + progress.render("submit") + progress.restore() +} + +func (progress *Progress) Fail() { + progress.render("error") + progress.restore() +} + +func (progress *Progress) Label(value string) *Progress { + progress.label = value + return progress +} + +func (progress *Progress) Hint(value string) *Progress { + progress.hint = value + return progress +} + +func (progress *Progress) Current() int { + return progress.current +} + +func (progress *Progress) Percentage() float64 { + return float64(progress.current) / float64(progress.Total) +} + +func (progress *Progress) Value() bool { + return true +} + +func (progress *Progress) render(state string) { + output := environment.Current().Output + if !progress.cursorHidden { + output.Write(terminal.HideCursorSequence()) + progress.cursorHidden = true + } + if progress.renderedFrame != "" { + EraseRenderedFrame(progress.renderedFrame) + } + frame := RenderProgressFrame(progress.current, progress.Total, progress.label, progress.hint, state, 60) + output.Write(frame) + progress.renderedFrame = frame +} + +func (progress *Progress) restore() { + if progress.renderedFrame != "" { + EraseRenderedFrame(progress.renderedFrame) + progress.renderedFrame = "" + } + if progress.cursorHidden { + environment.Current().Output.Write(terminal.ShowCursorSequence()) + progress.cursorHidden = false + } +} + +func RunProgress[T any, R any](label string, steps []T, callback func(T, *Progress) (R, error)) ([]R, error) { + bar := NewProgress(len(steps), label, "") + results := make([]R, 0, len(steps)) + bar.Start() + for _, step := range steps { + result, err := callback(step, bar) + if err != nil { + bar.Fail() + return results, err + } + results = append(results, result) + bar.Advance(1) + } + bar.Finish() + return results, nil +} + +func RenderProgressFrame(current int, total int, label string, hint string, state string, width int) string { + if total < 1 { + total = 1 + } + filled := int(math.Ceil(float64(width) * float64(current) / float64(total))) + body := "" + for range filled { + body += "β–ˆ" + } + lines := []string{theme.RenderBox(theme.BoxOptions{Body: body, Info: FormatProgressFraction(current, total), Title: label, Width: width})} + if state == "active" { + if hint != "" { + lines = append(lines, " "+hint) + } else { + lines = append(lines, "") + } + } + return joinLines(lines) + "\n" +} + +func FormatProgressFraction(current int, total int) string { + return fmt.Sprintf("%s / %s", formatProgressNumber(current), formatProgressNumber(total)) +} + +func formatProgressNumber(value int) string { + text := fmt.Sprintf("%d", value) + if len(text) <= 3 { + return text + } + out := "" + for index, char := range text { + if index > 0 && (len(text)-index)%3 == 0 { + out += "," + } + out += string(char) + } + return out +} diff --git a/console/shell/internal/status/spinner.go b/console/shell/internal/status/spinner.go new file mode 100644 index 00000000..b59de372 --- /dev/null +++ b/console/shell/internal/status/spinner.go @@ -0,0 +1,52 @@ +package status + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +var SpinnerFrames = []string{"β ‚", "β ’", "⠐", "β °", "β  ", "β €", "β „", "β †"} + +const StaticSpinnerFrame = "β Ά" + +func SpinnerFrame(count int) string { + index := count % len(SpinnerFrames) + if index < 0 { + index += len(SpinnerFrames) + } + return SpinnerFrames[index] +} + +func RenderSpinnerFrame(message string, count ...int) string { + frame := StaticSpinnerFrame + if len(count) > 0 { + frame = SpinnerFrame(count[0]) + } + return " " + frame + " " + message + "\n" +} + +func Spin[T any](message string, callback func() (T, error)) (T, error) { + var zero T + if callback == nil { + return zero, errMissingCallback("spinner") + } + + output := environment.Current().Output + frame := RenderSpinnerFrame(message) + output.Write(terminal.HideCursorSequence()) + output.Write(frame) + defer output.Write(terminal.ShowCursorSequence()) + defer EraseRenderedFrame(frame) + + return callback() +} + +type missingCallbackError string + +func (err missingCallbackError) Error() string { + return string(err) +} + +func errMissingCallback(kind string) error { + return missingCallbackError("a " + kind + " callback is required") +} diff --git a/console/shell/internal/status/status_test.go b/console/shell/internal/status/status_test.go new file mode 100644 index 00000000..40fc718b --- /dev/null +++ b/console/shell/internal/status/status_test.go @@ -0,0 +1,194 @@ +package status + +import ( + "errors" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + textutil "github.com/oullin/tui/internal/strings" +) + +func withStatusOutput(callback func(output *environment.MemoryOutput) error) error { + output := environment.NewMemoryOutput() + return environment.With(environment.Patch{Output: output, ErrorOutput: output}, func() error { + return callback(output) + }) +} + +func TestSpinnerFramesAndCleanup(t *testing.T) { + if got := SpinnerFrame(3); got != "β °" { + t.Fatalf("SpinnerFrame() = %q", got) + } + if got := RenderSpinnerFrame("Working", 3); got != " β ° Working\n" { + t.Fatalf("RenderSpinnerFrame() = %q", got) + } + + err := withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := Spin("Working", func() (string, error) { return "done", nil }) + if err != nil { + return err + } + if result != "done" { + t.Fatalf("Spin() = %q", result) + } + rendered := output.Text() + for _, want := range []string{"\x1b[?25l", " β Ά Working", "\x1b[1A\x1b[2K", "\x1b[?25h"} { + if !strings.Contains(rendered, want) { + t.Fatalf("spinner output missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestProgressManualAndRunner(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + bar := NewProgress(2, "Adding States", "") + bar.Start() + bar.Label("ALABAMA").Hint("alabama").Advance(1) + if bar.Current() != 1 || !bar.Value() { + t.Fatalf("progress current/value = %d/%v", bar.Current(), bar.Value()) + } + bar.Finish() + if !strings.Contains(output.Text(), "ALABAMA") || !strings.Contains(output.Text(), "1 / 2") || !strings.Contains(output.Text(), "\x1b[?25h") { + t.Fatalf("progress output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := RunProgress("Uppercasing States", []string{"Alabama", "Alaska"}, func(step string, bar *Progress) (string, error) { + return strings.ToUpper(step), nil + }) + if err != nil { + return err + } + if strings.Join(result, ",") != "ALABAMA,ALASKA" { + t.Fatalf("RunProgress() = %#v", result) + } + if !strings.Contains(output.Text(), "2 / 2") { + t.Fatalf("progress runner output missing completion:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestStreamAppendsClosesAndRenders(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + stream := NewStream() + if err := stream.Append("hello"); err != nil { + return err + } + if err := stream.Write(" world"); err != nil { + return err + } + stream.Close() + if stream.Value() != "hello world" || !stream.Closed() { + t.Fatalf("stream value/closed = %q/%v", stream.Value(), stream.Closed()) + } + if err := stream.Append(" later"); err == nil { + t.Fatalf("expected append after close to fail") + } + if !strings.Contains(output.Text(), "\x1b[?25l") || !strings.Contains(output.Text(), "\x1b[?25h") || !strings.Contains(textutil.ParseAnsiText(output.Text()), " hello world") { + t.Fatalf("stream output missing expected frame:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTaskLoggerAndFrames(t *testing.T) { + err := withStatusOutput(func(output *environment.MemoryOutput) error { + result, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Log("line one") + logger.Log("line two") + logger.Log("line three") + return "done", nil + }, 2, false, "") + if err != nil { + return err + } + if result != "done" { + t.Fatalf("Task() = %q", result) + } + rendered := output.Text() + if strings.Contains(rendered, "line one") || !strings.Contains(rendered, "line two") || !strings.Contains(rendered, "line three") || !strings.Contains(rendered, "\x1b[?25h") { + t.Fatalf("task output missing bounded logs:\n%s", rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = withStatusOutput(func(output *environment.MemoryOutput) error { + _, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Success("created") + logger.Warning("check this") + logger.Error("failed optional step") + return "done", nil + }, 10, true, "") + if err != nil { + return err + } + rendered := output.Text() + for _, want := range []string{" β€’ Running...", "βœ” created", "⚠ check this", "✘ failed optional step"} { + if !strings.Contains(rendered, want) { + t.Fatalf("task summary missing %q:\n%s", want, rendered) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestTaskFailureRestoresCursor(t *testing.T) { + failure := errors.New("failed") + err := withStatusOutput(func(output *environment.MemoryOutput) error { + _, err := Task("Running...", func(logger *Logger) (string, error) { + logger.Line("before\x1b[1G\x1b[2Kafter") + return "", failure + }, 10, false, "") + if !errors.Is(err, failure) { + t.Fatalf("Task() error = %v", err) + } + if !strings.Contains(output.Text(), "beforeafter") || !strings.Contains(output.Text(), "\x1b[?25h") { + t.Fatalf("failed task output missing cleanup/sanitized log:\n%s", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestFrameLineCountingAndErasing(t *testing.T) { + frame := RenderTaskFrame(TaskFrameOptions{Label: "Running...", Limit: 2, Lines: []string{"line one"}}) + if got := RenderedFrameLineCount(frame); got != 4 { + t.Fatalf("RenderedFrameLineCount() = %d", got) + } + err := withStatusOutput(func(output *environment.MemoryOutput) error { + EraseRenderedFrame(frame) + if output.Text() != strings.Repeat("\x1b[1A\x1b[2K", 4) { + t.Fatalf("EraseRenderedFrame() output = %q", output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/console/shell/internal/status/stream.go b/console/shell/internal/status/stream.go new file mode 100644 index 00000000..90960933 --- /dev/null +++ b/console/shell/internal/status/stream.go @@ -0,0 +1,133 @@ +package status + +import ( + "errors" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + text "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/theme" +) + +type Stream struct { + closed bool + buffer *StreamBuffer +} + +func NewStream() *Stream { + environment.Current().Output.Write(terminal.HideCursorSequence()) + return &Stream{buffer: NewStreamBuffer(10)} +} + +func (stream *Stream) Write(content string) error { + return stream.Append(content) +} + +func (stream *Stream) Append(content string) error { + if stream.closed { + return errors.New("stream is closed") + } + stream.buffer.Append(content) + stream.render() + return nil +} + +func (stream *Stream) Close() { + if stream.closed { + return + } + for stream.buffer.FlushNext() { + stream.render() + } + stream.closed = true + environment.Current().Output.Write(terminal.ShowCursorSequence()) +} + +func (stream *Stream) Closed() bool { + return stream.closed +} + +func (stream *Stream) Value() string { + return stream.buffer.Value() +} + +func (stream *Stream) Lines() []string { + return StreamLines(stream.Value(), 60) +} + +func (stream *Stream) render() { + environment.Current().Output.Write(RenderStreamFrame(stream.buffer.StableValue(), stream.buffer.Fading, StreamFadeStyles(false), 60)) +} + +type StreamBuffer struct { + Fading []string + message string + fadeLimit int +} + +func NewStreamBuffer(fadeLimit int) *StreamBuffer { + return &StreamBuffer{fadeLimit: fadeLimit} +} + +func (buffer *StreamBuffer) Append(message string) { + buffer.Fading = append(buffer.Fading, message) + for len(buffer.Fading) > buffer.fadeLimit { + buffer.message += buffer.Fading[0] + buffer.Fading = buffer.Fading[1:] + } +} + +func (buffer *StreamBuffer) FlushNext() bool { + if len(buffer.Fading) == 0 { + return false + } + buffer.message += buffer.Fading[0] + buffer.Fading = buffer.Fading[1:] + return true +} + +func (buffer *StreamBuffer) Value() string { + return buffer.message + stdstrings.Join(buffer.Fading, "") +} + +func (buffer *StreamBuffer) StableValue() string { + return buffer.message +} + +type FadeStyle func(string) string + +func StreamFadeStyles(trueColor bool) []FadeStyle { + if trueColor { + return []FadeStyle{ + func(value string) string { return theme.ForegroundRGB(value, 100, 50, 0) }, + func(value string) string { return theme.ForegroundRGB(value, 50, 25, 0) }, + } + } + return []FadeStyle{func(value string) string { return value }, theme.Dim} +} + +func RenderStreamFrame(value string, fading []string, styles []FadeStyle, width int) string { + rendered := value + for index, chunk := range fading { + style := styles[len(styles)-1] + if index < len(styles) { + style = styles[index] + } + rendered += style(chunk) + } + lines := StreamLines(rendered, width) + for index, line := range lines { + lines[index] = " " + line + } + return stdstrings.Join(lines, "\n") + "\n" +} + +func StreamLines(value string, width int) []string { + parts := stdstrings.Split(value, "\n") + lines := make([]string, 0) + for _, part := range parts { + lines = append(lines, text.Wrap(part, width)...) + } + return lines +} diff --git a/console/shell/internal/status/task.go b/console/shell/internal/status/task.go new file mode 100644 index 00000000..0bd753db --- /dev/null +++ b/console/shell/internal/status/task.go @@ -0,0 +1,205 @@ +package status + +import ( + "regexp" + stdstrings "strings" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/terminal" +) + +type StableMessage struct { + Type string + Message string +} + +type Logger struct { + LabelValue string + SubLabelValue string + Lines []string + Stable []StableMessage + limit int + stableLimit int + partialBuffer string + partialStart int + hasPartial bool +} + +func NewLogger(limit int, label string, subLabel string) *Logger { + if limit < 0 { + limit = 0 + } + if limit == 0 { + limit = 10 + } + return &Logger{LabelValue: label, SubLabelValue: subLabel, limit: limit, stableLimit: limit} +} + +func (logger *Logger) Limit() int { + return logger.limit +} + +func (logger *Logger) Line(message string) { + logger.writeLines(stdstrings.TrimRight(message, " \t\r\n")) +} + +func (logger *Logger) Log(message string) { + logger.Line(message) +} + +func (logger *Logger) Label(message string) { + logger.LabelValue = message +} + +func (logger *Logger) SubLabel(message string) { + logger.SubLabelValue = message +} + +func (logger *Logger) Partial(chunk string) { + logger.partialBuffer += chunk + if !logger.hasPartial { + logger.partialStart = len(logger.Lines) + logger.hasPartial = true + } + logger.Lines = logger.Lines[:logger.partialStart] + logger.writeLines(logger.partialBuffer) + if logger.partialStart > len(logger.Lines) { + logger.partialStart = len(logger.Lines) + } +} + +func (logger *Logger) CommitPartial() { + logger.partialBuffer = "" + logger.partialStart = 0 + logger.hasPartial = false +} + +func (logger *Logger) Info(message string) { + logger.Line(message) +} + +func (logger *Logger) Success(message string) { + logger.stable("success", message) +} + +func (logger *Logger) Warning(message string) { + logger.stable("warning", message) +} + +func (logger *Logger) Error(message string) { + logger.stable("error", message) +} + +func (logger *Logger) stable(kind string, message string) { + logger.Stable = append(logger.Stable, StableMessage{Type: kind, Message: message}) + logger.Lines = nil + logger.partialBuffer = "" + logger.hasPartial = false + for len(logger.Stable) > logger.stableLimit { + logger.Stable = logger.Stable[1:] + } +} + +func (logger *Logger) writeLines(message string) { + for _, line := range stdstrings.Split(message, "\n") { + if line != "" { + logger.Lines = append(logger.Lines, sanitizeTaskLine(line)) + } + } + for len(logger.Lines) > logger.limit { + logger.Lines = logger.Lines[1:] + } +} + +func Task[T any](label string, callback func(*Logger) (T, error), limit int, keepSummary bool, subLabel string) (T, error) { + var zero T + if callback == nil { + return zero, errMissingCallback("task") + } + logger := NewLogger(limit, label, subLabel) + output := environment.Current().Output + output.Write(terminal.HideCursorSequence()) + defer output.Write(terminal.ShowCursorSequence()) + + frame := RenderTaskFrame(TaskFrameOptions{Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable}) + output.Write(frame) + + result, err := callback(logger) + EraseRenderedFrame(frame) + if err != nil { + output.Write(RenderTaskFrame(TaskFrameOptions{Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable})) + return zero, err + } + output.Write(RenderTaskFrame(TaskFrameOptions{Finished: true, KeepSummary: keepSummary, Label: logger.LabelValue, SubLabel: logger.SubLabelValue, Limit: logger.Limit(), Lines: logger.Lines, StableMessages: logger.Stable})) + return result, nil +} + +type TaskFrameOptions struct { + FrameCount int + Finished bool + KeepSummary bool + Label string + Limit int + Lines []string + StableMessages []StableMessage + SubLabel string +} + +func RenderTaskFrame(options TaskFrameOptions) string { + if options.Finished && options.KeepSummary && len(options.StableMessages) > 0 { + lines := []string{" β€’ " + options.Label} + for _, message := range options.StableMessages { + lines = append(lines, renderStableMessage(message)) + } + lines = append(lines, "") + return joinLines(lines) + "\n" + } + + output := []string{stdstrings.TrimRight(RenderSpinnerFrame(options.Label, options.FrameCount), "\n")} + if options.FrameCount == 0 { + output[0] = stdstrings.TrimRight(RenderSpinnerFrame(options.Label), "\n") + } + if options.SubLabel != "" { + output = append(output, " "+options.SubLabel) + } + for _, message := range options.StableMessages { + output = append(output, renderStableMessage(message)) + } + if len(options.StableMessages) > 0 || len(options.Lines) > 0 { + output = append(output, " "+stdstrings.Repeat("─", 60)) + } else { + output = append(output, "") + } + visible := options.Lines + if len(visible) > options.Limit { + visible = visible[len(visible)-options.Limit:] + } + for _, line := range visible { + output = append(output, " "+line) + } + for remaining := options.Limit - len(visible); remaining > 0; remaining-- { + output = append(output, "") + } + return joinLines(output) + "\n" +} + +func renderStableMessage(message StableMessage) string { + symbol := "⚠" + if message.Type == "success" { + symbol = "βœ”" + } + if message.Type == "error" { + symbol = "✘" + } + return " " + symbol + " " + message.Message +} + +var taskControlPattern = regexp.MustCompile(`\x1b\[1G|\x1b\[2K|\r`) + +func sanitizeTaskLine(line string) string { + return taskControlPattern.ReplaceAllString(line, "") +} + +func joinLines(lines []string) string { + return stdstrings.Join(lines, "\n") +} From 53af67d16dd76163201e533f8457878a05683857 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:17:50 +0800 Subject: [PATCH 008/404] feat(shell): port form builder --- console/shell/internal/form/builder.go | 148 ++++++++++++++++ console/shell/internal/form/form_test.go | 214 +++++++++++++++++++++++ console/shell/internal/form/output.go | 87 +++++++++ console/shell/internal/form/previous.go | 55 ++++++ console/shell/internal/form/prompts.go | 111 ++++++++++++ console/shell/internal/form/responses.go | 70 ++++++++ console/shell/internal/form/revert.go | 58 ++++++ console/shell/internal/form/status.go | 30 ++++ 8 files changed, 773 insertions(+) create mode 100644 console/shell/internal/form/builder.go create mode 100644 console/shell/internal/form/form_test.go create mode 100644 console/shell/internal/form/output.go create mode 100644 console/shell/internal/form/previous.go create mode 100644 console/shell/internal/form/prompts.go create mode 100644 console/shell/internal/form/responses.go create mode 100644 console/shell/internal/form/revert.go create mode 100644 console/shell/internal/form/status.go diff --git a/console/shell/internal/form/builder.go b/console/shell/internal/form/builder.go new file mode 100644 index 00000000..a2e58169 --- /dev/null +++ b/console/shell/internal/form/builder.go @@ -0,0 +1,148 @@ +package form + +import "errors" + +type StepFunc func(responses *Responses, previous any, name string) (any, error) +type ConditionFunc func(responses *Responses) (bool, error) + +type Step struct { + Condition ConditionFunc + IgnoreWhenReverting bool + Name string + Run StepFunc +} + +type Builder struct { + responses *Responses + steps []Step +} + +func New() *Builder { + return &Builder{responses: NewResponses()} +} + +func (builder *Builder) Add(run StepFunc, name ...string) *Builder { + return builder.addStep(trueCondition, run, firstName(name), false) +} + +func (builder *Builder) AddSideEffect(run func() error, name ...string) *Builder { + return builder.addStep(trueCondition, SideEffect(run), firstName(name), true) +} + +func (builder *Builder) AddIf(condition ConditionFunc, run StepFunc, name ...string) *Builder { + return builder.addStep(condition, run, firstName(name), false) +} + +func (builder *Builder) AddSideEffectIf(condition ConditionFunc, run func() error, name ...string) *Builder { + return builder.addStep(condition, SideEffect(run), firstName(name), true) +} + +func (builder *Builder) Submit() (*Responses, error) { + index := 0 + wasReverted := false + + for index < len(builder.steps) { + step := builder.steps[index] + + if wasReverted && index > 0 { + ignore, err := shouldIgnoreWhenReverting(step, builder.responses) + if err != nil { + return nil, err + } + if ignore { + index-- + continue + } + } + + wasReverted = false + shouldRun, err := shouldRun(step, builder.responses) + if err != nil { + return nil, err + } + if !shouldRun { + builder.responses.set(index, step.Name, nil) + index++ + continue + } + + previous := builder.responses.previous(index, step.Name) + value, err := runWithRevert(index > 0, func() (any, error) { + return step.Run(builder.responses, previous, step.Name) + }) + if err != nil { + if !errors.Is(err, ErrReverted) { + return nil, err + } + wasReverted = true + } else { + builder.responses.set(index, step.Name, value) + } + + if wasReverted { + index-- + } else { + index++ + } + } + + return builder.responses, nil +} + +func (builder *Builder) addStep(condition ConditionFunc, run StepFunc, name string, ignoreWhenReverting bool) *Builder { + if condition == nil { + condition = trueCondition + } + builder.steps = append(builder.steps, Step{ + Condition: condition, + IgnoreWhenReverting: ignoreWhenReverting, + Name: name, + Run: run, + }) + return builder +} + +func SideEffect(callback func() error) StepFunc { + return func(*Responses, any, string) (any, error) { + if callback == nil { + return nil, nil + } + + return nil, callback() + } +} + +func Bool(condition bool) ConditionFunc { + return func(*Responses) (bool, error) { + return condition, nil + } +} + +func trueCondition(*Responses) (bool, error) { + return true, nil +} + +func shouldRun(step Step, responses *Responses) (bool, error) { + if step.Condition == nil { + return true, nil + } + + return step.Condition(responses) +} + +func shouldIgnoreWhenReverting(step Step, responses *Responses) (bool, error) { + run, err := shouldRun(step, responses) + if err != nil || !run { + return true, err + } + + return step.IgnoreWhenReverting, nil +} + +func firstName(values []string) string { + if len(values) == 0 { + return "" + } + + return values[0] +} diff --git a/console/shell/internal/form/form_test.go b/console/shell/internal/form/form_test.go new file mode 100644 index 00000000..2fa96e86 --- /dev/null +++ b/console/shell/internal/form/form_test.go @@ -0,0 +1,214 @@ +package form + +import ( + "reflect" + "strings" + "testing" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompts" +) + +func withFormEnvironment(keys []string, callback func(*environment.MemoryOutput) error) error { + memoryOutput := environment.NewMemoryOutput() + interactive := true + return environment.With( + environment.Patch{ + Input: environment.NewScriptedInput(keys), + Output: memoryOutput, + ErrorOutput: memoryOutput, + Interactive: &interactive, + }, + func() error { + return callback(memoryOutput) + }, + ) +} + +func TestBuilderRunsChainedStepsAndReturnsPositionalResponses(t *testing.T) { + err := withFormEnvironment([]string{"A", "d", "a", key.First(key.Enter), key.First(key.Enter), key.First(key.Enter)}, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "Name"}). + Select(prompts.ChoiceOptions{Message: "Runtime", Choices: prompts.NewStringChoices("TS", "JS")}). + Confirm(prompts.ConfirmOptions{Message: "Active"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "Ada" || responses.At(1) != "TS" || responses.At(2) != true { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderKeysNamedResponsesAndPassesPreviousAndName(t *testing.T) { + err := withFormEnvironment([]string{"A", "d", "a", key.First(key.Enter)}, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "Name"}, "name"). + Add(func(values *Responses, previous any, name string) (any, error) { + if previous == nil { + previous = "new" + } + return name + ":" + previous.(string) + ":Hello " + values.Named("name").(string), nil + }, "greeting"). + Submit() + if err != nil { + return err + } + + if responses.Named("name") != "Ada" || responses.Named("greeting") != "greeting:new:Hello Ada" { + t.Fatalf("named responses = %#v", responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderStoresNilForSkippedConditionalSteps(t *testing.T) { + err := withFormEnvironment([]string{"n"}, func(*environment.MemoryOutput) error { + responses, err := New(). + Confirm(prompts.ConfirmOptions{Message: "Include details?", Default: false, HasDefault: true}, "include"). + AddIf(func(values *Responses) (bool, error) { + return values.Named("include") == true, nil + }, func(*Responses, any, string) (any, error) { + return "details", nil + }, "details"). + Submit() + if err != nil { + return err + } + + if responses.Named("include") != false || responses.Named("details") != nil { + t.Fatalf("named responses = %#v", responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRevertsToPreviousStepAndReusesPriorResponse(t *testing.T) { + err := withFormEnvironment([]string{ + "A", key.First(key.Enter), + "B", key.First(key.CtrlU), + key.First(key.Backspace), "C", key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "First"}). + Text(prompts.TextOptions{Message: "Second"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "C" || responses.At(1) != "D" { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderReusesPreviousPromptDefaultsByType(t *testing.T) { + err := withFormEnvironment([]string{ + "4", key.First(key.Enter), + key.First(key.Space), key.First(key.Enter), + key.First(key.CtrlU), + key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Number(prompts.NumberOptions{Message: "Count"}). + Multiselect(prompts.MultiChoiceOptions{Message: "Flags", Choices: prompts.NewStringChoices("alpha", "beta")}). + Text(prompts.TextOptions{Message: "Done"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != 4 || !reflect.DeepEqual(responses.At(1), []any{"alpha"}) || responses.At(2) != "D" { + t.Fatalf("responses = %#v", responses.Values()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRevertsPastIgnoredSideEffectSteps(t *testing.T) { + err := withFormEnvironment([]string{ + "A", key.First(key.Enter), + "B", key.First(key.CtrlU), + key.First(key.Backspace), "C", key.First(key.Enter), + "D", key.First(key.Enter), + }, func(*environment.MemoryOutput) error { + responses, err := New(). + Text(prompts.TextOptions{Message: "First"}). + Note("Skipping back over this", output.NoteType(""), "note"). + Text(prompts.TextOptions{Message: "Second"}). + Submit() + if err != nil { + return err + } + + if responses.At(0) != "C" || responses.Named("note") != nil || responses.At(2) != "D" { + t.Fatalf("positional=%#v named=%#v", responses.Values(), responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderDoesNotRevertTheFirstStep(t *testing.T) { + err := withFormEnvironment([]string{key.First(key.CtrlU), "A", key.First(key.Enter)}, func(output *environment.MemoryOutput) error { + responses, err := New().Text(prompts.TextOptions{Message: "First"}).Submit() + if err != nil { + return err + } + + if responses.At(0) != "A" || !strings.Contains(output.Text(), "This cannot be reverted.") { + t.Fatalf("response=%#v output=%q", responses.Values(), output.Text()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestBuilderRunsOutputAndStatusAdapters(t *testing.T) { + err := withFormEnvironment(nil, func(memoryOutput *environment.MemoryOutput) error { + responses, err := New(). + Info("Preparing", "info"). + Spin("Working", func() (any, error) { return "done", nil }, "spin"). + Submit() + if err != nil { + return err + } + + rendered := memoryOutput.Text() + if responses.Named("info") != nil || responses.Named("spin") != "done" || !strings.Contains(rendered, "Preparing") || !strings.Contains(rendered, "Working") { + t.Fatalf("named=%#v output=%q", responses.Names(), rendered) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/console/shell/internal/form/output.go b/console/shell/internal/form/output.go new file mode 100644 index 00000000..d3cf86b5 --- /dev/null +++ b/console/shell/internal/form/output.go @@ -0,0 +1,87 @@ +package form + +import "github.com/oullin/tui/internal/output" + +func (builder *Builder) Alert(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Alert(message) + return nil + }, name...) +} + +func (builder *Builder) Clear(name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Clear() + return nil + }, name...) +} + +func (builder *Builder) Error(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Error(message) + return nil + }, name...) +} + +func (builder *Builder) Grid(items []any, maxWidth int, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Grid(items, maxWidth) + return nil + }, name...) +} + +func (builder *Builder) Info(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Info(message) + return nil + }, name...) +} + +func (builder *Builder) Intro(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Intro(message) + return nil + }, name...) +} + +func (builder *Builder) Note(message string, noteType output.NoteType, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Note(message, noteType) + return nil + }, name...) +} + +func (builder *Builder) Outro(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Outro(message) + return nil + }, name...) +} + +func (builder *Builder) Table(headers []string, rows [][]any, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Table(headers, rows) + return nil + }, name...) +} + +func (builder *Builder) TableObjects(headers []string, rows []output.TableRow, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.TableObjects(headers, rows) + return nil + }, name...) +} + +func (builder *Builder) Title(value string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Title(value) + return nil + }, name...) +} + +func (builder *Builder) Warning(message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + output.Warning(message) + return nil + }, name...) +} diff --git a/console/shell/internal/form/previous.go b/console/shell/internal/form/previous.go new file mode 100644 index 00000000..a448fea0 --- /dev/null +++ b/console/shell/internal/form/previous.go @@ -0,0 +1,55 @@ +package form + +import "fmt" + +func PreviousString(previous any, defaultValue string) string { + value, ok := previous.(string) + if !ok { + return defaultValue + } + + return value +} + +func PreviousNumber(previous any, defaultValue string) string { + switch value := previous.(type) { + case nil: + return defaultValue + case string: + return value + case int: + return fmt.Sprint(value) + case int64: + return fmt.Sprint(value) + case float64: + return fmt.Sprint(value) + default: + return defaultValue + } +} + +func PreviousBool(previous any, defaultValue bool) bool { + value, ok := previous.(bool) + if !ok { + return defaultValue + } + + return value +} + +func PreviousSlice(previous any, defaultValue []any) []any { + value, ok := previous.([]any) + if !ok { + return append([]any{}, defaultValue...) + } + + return append([]any{}, value...) +} + +func PreviousValue(previous any, defaultValue any) any { + if previous == nil { + return defaultValue + } + + return previous +} diff --git a/console/shell/internal/form/prompts.go b/console/shell/internal/form/prompts.go new file mode 100644 index 00000000..3dfc719e --- /dev/null +++ b/console/shell/internal/form/prompts.go @@ -0,0 +1,111 @@ +package form + +import ( + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/prompts" +) + +func (builder *Builder) Text(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Text(options) + }, name...) +} + +func (builder *Builder) Textarea(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Textarea(options) + }, name...) +} + +func (builder *Builder) Password(options prompts.TextOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Password(options) + }, name...) +} + +func (builder *Builder) Number(options prompts.NumberOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + previousValue := PreviousNumber(previous, options.Default) + options.Default = previousValue + options.HasDefault = options.HasDefault || previousValue != "" + return prompts.Number(options) + }, name...) +} + +func (builder *Builder) Pause(message string, name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return prompts.Pause(message) + }, name...) +} + +func (builder *Builder) Confirm(options prompts.ConfirmOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + if previous != nil { + options.Default = PreviousBool(previous, options.Default) + } else if !options.HasDefault { + options.Default = true + } + options.HasDefault = true + return prompts.Confirm(options) + }, name...) +} + +func (builder *Builder) Select(options prompts.ChoiceOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return prompts.Select(options) + }, name...) +} + +func (builder *Builder) Multiselect(options prompts.MultiChoiceOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Defaults = PreviousSlice(previous, options.Defaults) + return prompts.Multiselect(options) + }, name...) +} + +func (builder *Builder) Search(options prompts.SearchOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return prompts.Search(options) + }, name...) +} + +func (builder *Builder) Multisearch(options prompts.MultiSearchOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Defaults = PreviousSlice(previous, options.Defaults) + return prompts.MultiSearch(options) + }, name...) +} + +func (builder *Builder) Suggest(options prompts.SuggestOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Suggest(options) + }, name...) +} + +func (builder *Builder) Autocomplete(options prompts.SuggestOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousString(previous, options.Default) + return prompts.Autocomplete(options) + }, name...) +} + +func (builder *Builder) DataTable(options output.DataTableOptions, name ...string) *Builder { + return builder.Add(func(_ *Responses, previous any, _ string) (any, error) { + options.Default = PreviousValue(previous, options.Default) + options.HasDefault = options.HasDefault || previous != nil + return output.DataTable(options) + }, name...) +} + +func Required(value bool) prompt.Required { + return prompt.Required{Enabled: value} +} diff --git a/console/shell/internal/form/responses.go b/console/shell/internal/form/responses.go new file mode 100644 index 00000000..dbb4c73f --- /dev/null +++ b/console/shell/internal/form/responses.go @@ -0,0 +1,70 @@ +package form + +type Responses struct { + named map[string]any + positional []any +} + +func NewResponses() *Responses { + return &Responses{named: map[string]any{}} +} + +func (responses *Responses) At(index int) any { + if responses == nil || index < 0 || index >= len(responses.positional) { + return nil + } + + return responses.positional[index] +} + +func (responses *Responses) Named(name string) any { + if responses == nil || name == "" { + return nil + } + + return responses.named[name] +} + +func (responses *Responses) Values() []any { + if responses == nil { + return nil + } + + values := make([]any, len(responses.positional)) + copy(values, responses.positional) + + return values +} + +func (responses *Responses) Names() map[string]any { + if responses == nil { + return nil + } + + values := make(map[string]any, len(responses.named)) + for key, value := range responses.named { + values[key] = value + } + + return values +} + +func (responses *Responses) previous(index int, name string) any { + if name != "" { + return responses.Named(name) + } + + return responses.At(index) +} + +func (responses *Responses) set(index int, name string, value any) { + if name != "" { + responses.named[name] = value + return + } + + for len(responses.positional) <= index { + responses.positional = append(responses.positional, nil) + } + responses.positional[index] = value +} diff --git a/console/shell/internal/form/revert.go b/console/shell/internal/form/revert.go new file mode 100644 index 00000000..7a21ab4c --- /dev/null +++ b/console/shell/internal/form/revert.go @@ -0,0 +1,58 @@ +package form + +import ( + "errors" + + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/theme" +) + +var ErrReverted = errors.New("form reverted") + +type revertInput struct { + canRevert bool + previous environment.Input +} + +func runWithRevert(canRevert bool, callback func() (any, error)) (any, error) { + env := environment.Current() + if env.Input == nil { + return callback() + } + + return callbackWithInput(revertInput{canRevert: canRevert, previous: env.Input}, callback) +} + +func callbackWithInput(input environment.Input, callback func() (any, error)) (any, error) { + returnValue := any(nil) + err := environment.With(environment.Patch{Input: input}, func() error { + value, callbackErr := callback() + returnValue = value + return callbackErr + }) + + return returnValue, err +} + +func (input revertInput) ReadKey() (string, bool, error) { + pressed, ok, err := input.previous.ReadKey() + if err != nil || !ok { + return pressed, ok, err + } + + if pressed != key.First(key.CtrlU) { + return pressed, ok, nil + } + + if input.canRevert { + return "", false, ErrReverted + } + + environment.Current().ErrorOutput.Write(theme.Red(" ⚠ This cannot be reverted.") + "\n") + return input.ReadKey() +} + +func (input revertInput) ReadLine(message string) (string, error) { + return input.previous.ReadLine(message) +} diff --git a/console/shell/internal/form/status.go b/console/shell/internal/form/status.go new file mode 100644 index 00000000..38c6d7bb --- /dev/null +++ b/console/shell/internal/form/status.go @@ -0,0 +1,30 @@ +package form + +import "github.com/oullin/tui/internal/status" + +func (builder *Builder) Progress(total int, message string, name ...string) *Builder { + return builder.AddSideEffect(func() error { + bar := status.NewProgress(total, message, "") + bar.Start() + bar.Finish() + return nil + }, name...) +} + +func (builder *Builder) Spin(message string, callback func() (any, error), name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.Spin(message, callback) + }, name...) +} + +func (builder *Builder) Stream(name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.NewStream(), nil + }, name...) +} + +func (builder *Builder) Task(label string, callback func(*status.Logger) (any, error), limit int, keepSummary bool, subLabel string, name ...string) *Builder { + return builder.Add(func(*Responses, any, string) (any, error) { + return status.Task(label, callback, limit, keepSummary, subLabel) + }, name...) +} From 80207285693068d0c4d14a2cf562d968026a0d1b Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:19:13 +0800 Subject: [PATCH 009/404] feat(shell): expose public Go shell API --- console/shell/exports.go | 174 ++++++++++++++++++++++++++++++++++ console/shell/exports_test.go | 50 ++++++++++ console/shell/portal/main.go | 23 ++++- 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 console/shell/exports.go create mode 100644 console/shell/exports_test.go diff --git a/console/shell/exports.go b/console/shell/exports.go new file mode 100644 index 00000000..ec4e0572 --- /dev/null +++ b/console/shell/exports.go @@ -0,0 +1,174 @@ +package tui + +import ( + "github.com/oullin/tui/internal/environment" + "github.com/oullin/tui/internal/form" + "github.com/oullin/tui/internal/key" + "github.com/oullin/tui/internal/output" + "github.com/oullin/tui/internal/prompt" + "github.com/oullin/tui/internal/prompts" + "github.com/oullin/tui/internal/status" + textutil "github.com/oullin/tui/internal/strings" + "github.com/oullin/tui/internal/terminal" + "github.com/oullin/tui/internal/typedvalue" +) + +type Environment = environment.Environment +type Input = environment.Input +type Output = environment.Output +type Patch = environment.Patch +type MemoryOutput = environment.MemoryOutput +type ScriptedInput = environment.ScriptedInput + +var CurrentEnvironment = environment.Current +var Configure = environment.Configure +var WithEnvironment = environment.With +var NewMemoryOutput = environment.NewMemoryOutput +var NewScriptedInput = environment.NewScriptedInput +var CancelUsing = environment.CancelUsing +var CancelPrompt = environment.CancelPrompt + +type KeyValue = key.Value +type KeyEvent = key.Event + +var ( + KeyUp = key.Up + KeyDown = key.Down + KeyRight = key.Right + KeyLeft = key.Left + KeyEscape = key.Escape + KeyDelete = key.Delete + KeyBackspace = key.Backspace + KeyEnter = key.Enter + KeySpace = key.Space + KeyTab = key.Tab + KeyShiftTab = key.ShiftTab + KeyCtrlC = key.CtrlC + KeyCtrlA = key.CtrlA + KeyCtrlD = key.CtrlD + KeyCtrlU = key.CtrlU + KeyOptionBackspace = key.OptionBackspace +) + +var KeyFirst = key.First +var KeyFromEvent = key.FromEvent +var KeyOneOf = key.OneOf + +type Required = prompt.Required +type Validator[T any] = prompt.Validator[T] +type PromptValidationError = environment.PromptValidationError + +var ValidationError = prompt.ValidationError +var EnsureValid = prompt.EnsureValid[any] + +type TextOptions = prompts.TextOptions +type NumberOptions = prompts.NumberOptions +type Choice = prompts.Choice +type ChoiceOptions = prompts.ChoiceOptions +type MultiChoiceOptions = prompts.MultiChoiceOptions +type ConfirmOptions = prompts.ConfirmOptions +type SearchSource = prompts.SearchSource +type SearchOptions = prompts.SearchOptions +type MultiSearchOptions = prompts.MultiSearchOptions +type SuggestSource = prompts.SuggestSource +type SuggestOptions = prompts.SuggestOptions + +var Text = prompts.Text +var Textarea = prompts.Textarea +var Password = prompts.Password +var Number = prompts.Number +var Pause = prompts.Pause +var NewStringChoices = prompts.NewStringChoices +var Select = prompts.Select +var Multiselect = prompts.Multiselect +var Confirm = prompts.Confirm +var Search = prompts.Search +var Multisearch = prompts.MultiSearch +var Suggest = prompts.Suggest +var Autocomplete = prompts.Autocomplete + +type FormBuilder = form.Builder +type FormResponses = form.Responses +type FormStep = form.Step +type FormStepFunc = form.StepFunc +type FormConditionFunc = form.ConditionFunc + +var Form = form.New +var FormBool = form.Bool +var ErrFormReverted = form.ErrReverted + +type NoteType = output.NoteType +type TableRow = output.TableRow +type DataTableRow = output.DataTableRow +type DataTableOptions = output.DataTableOptions +type NotificationCommand = output.NotificationCommand +type NotificationRuntime = output.NotificationRuntime + +const ( + NoteAlert = output.NoteAlert + NoteError = output.NoteError + NoteInfo = output.NoteInfo + NoteIntro = output.NoteIntro + NoteOutro = output.NoteOutro + NoteWarning = output.NoteWarning +) + +var Alert = output.Alert +var Clear = output.Clear +var DataTable = output.DataTable +var Error = output.Error +var Grid = output.Grid +var Info = output.Info +var Intro = output.Intro +var Note = output.Note +var NotifyForPlatform = output.NotifyForPlatform +var NotificationCommandFor = output.NotificationCommandFor +var NotificationCommands = output.NotificationCommands +var Outro = output.Outro +var Table = output.Table +var TableObjects = output.TableObjects +var Title = output.Title +var Warning = output.Warning + +type Progress = status.Progress +type Stream = status.Stream +type Logger = status.Logger + +var NewProgress = status.NewProgress +var RunProgress = status.RunProgress[string, string] +var Spin = status.Spin[any] +var NewStream = status.NewStream +var Task = status.Task[any] + +type AnsiSegment = textutil.AnsiSegment + +var VisibleWidth = textutil.VisibleWidth +var Truncate = textutil.Truncate +var ParseAnsiSegments = textutil.ParseAnsiSegments +var ParseAnsiText = textutil.ParseAnsiText +var Wrap = textutil.Wrap + +type TerminalSize = terminal.Size +type TerminalColor = terminal.Color + +var DefaultTerminalSize = terminal.DefaultSize +var ClearTerminalSequence = terminal.ClearSequence +var TerminalTitleSequence = terminal.TitleSequence +var EraseLineSequence = terminal.EraseLineSequence +var ErasePreviousLinesSequence = terminal.ErasePreviousLinesSequence +var CursorToStartSequence = terminal.CursorToStartSequence +var HideCursorSequence = terminal.HideCursorSequence +var ShowCursorSequence = terminal.ShowCursorSequence +var SupportsTrueColor = terminal.SupportsTrueColor +var DefaultForegroundColor = terminal.DefaultForegroundColor +var DefaultBackgroundColor = terminal.DefaultBackgroundColor +var ParseTerminalColor = terminal.ParseColor + +type TypedValueState = typedvalue.State +type AppliedKey = typedvalue.AppliedKey + +var InitialTypedValueState = typedvalue.InitialState +var ApplyTypedKey = typedvalue.ApplyKey +var TypedValueCharacters = typedvalue.Characters +var TypedValueFromCharacters = typedvalue.FromCharacters +var TypedValueCharacterLength = typedvalue.CharacterLength diff --git a/console/shell/exports_test.go b/console/shell/exports_test.go new file mode 100644 index 00000000..6ade9760 --- /dev/null +++ b/console/shell/exports_test.go @@ -0,0 +1,50 @@ +package tui_test + +import ( + "strings" + "testing" + + tui "github.com/oullin/tui" +) + +func TestPublicAPIConsumesFormAndOutput(t *testing.T) { + memoryOutput := tui.NewMemoryOutput() + interactive := true + err := tui.WithEnvironment(tui.Patch{ + Input: tui.NewScriptedInput([]string{"A", "d", "a", tui.KeyFirst(tui.KeyEnter)}), + Output: memoryOutput, + ErrorOutput: memoryOutput, + Interactive: &interactive, + }, func() error { + responses, err := tui.Form(). + Text(tui.TextOptions{Message: "Name"}). + Info("Saved", "note"). + Submit() + if err != nil { + return err + } + if responses.At(0) != "Ada" || responses.Named("note") != nil { + t.Fatalf("responses positional=%#v named=%#v", responses.Values(), responses.Names()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(memoryOutput.Text(), "Saved") { + t.Fatalf("output missing note: %q", memoryOutput.Text()) + } +} + +func TestPublicAPIExportsCoreHelpers(t *testing.T) { + if tui.KeyFromEvent(tui.KeyEvent{Name: "u", Ctrl: true}) != tui.KeyFirst(tui.KeyCtrlU) { + t.Fatalf("ctrl-u key mapping mismatch") + } + if tui.VisibleWidth("\x1b[31mTokyo\x1b[39m") != 5 { + t.Fatalf("visible width mismatch") + } + if tui.ClearTerminalSequence() == "" || tui.HideCursorSequence() == "" { + t.Fatalf("terminal sequences should be exported") + } +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go index 6480e315..daa1b660 100644 --- a/console/shell/portal/main.go +++ b/console/shell/portal/main.go @@ -1,7 +1,26 @@ package main -import "fmt" +import ( + "fmt" + "os" + + tui "github.com/oullin/tui" +) + +type stdOutput struct{} func main() { - fmt.Println("Oullin TUI shell") + tui.Configure(tui.Patch{Output: stdOutput{}, ErrorOutput: stdOutput{}}) + + if len(os.Args) > 1 && os.Args[1] == "version" { + fmt.Println("@ollin/shell 0.1.0") + return + } + + tui.Intro("Oullin TUI shell") + tui.Info("Go console shell workspace is ready.") +} + +func (stdOutput) Write(content string) { + fmt.Print(content) } From 0a0e9b08197d75b94c5fd12467c446a4d54d0dd5 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:24:29 +0800 Subject: [PATCH 010/404] feat(shell): wire portal to real stdio prompts --- console/shell/exports.go | 4 + console/shell/go.mod | 6 +- console/shell/go.sum | 4 + console/shell/internal/environment/stdio.go | 89 +++++++++++++++++++ .../shell/internal/environment/stdio_test.go | 43 +++++++++ console/shell/portal/main.go | 74 +++++++++++++-- console/shell/portal/main_test.go | 49 ++++++++++ 7 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 console/shell/internal/environment/stdio.go create mode 100644 console/shell/internal/environment/stdio_test.go create mode 100644 console/shell/portal/main_test.go diff --git a/console/shell/exports.go b/console/shell/exports.go index ec4e0572..545c15bf 100644 --- a/console/shell/exports.go +++ b/console/shell/exports.go @@ -19,12 +19,16 @@ type Output = environment.Output type Patch = environment.Patch type MemoryOutput = environment.MemoryOutput type ScriptedInput = environment.ScriptedInput +type StdioInput = environment.StdioInput +type WriterOutput = environment.WriterOutput var CurrentEnvironment = environment.Current var Configure = environment.Configure var WithEnvironment = environment.With var NewMemoryOutput = environment.NewMemoryOutput var NewScriptedInput = environment.NewScriptedInput +var NewStdioInput = environment.NewStdioInput +var NewWriterOutput = environment.NewWriterOutput var CancelUsing = environment.CancelUsing var CancelPrompt = environment.CancelPrompt diff --git a/console/shell/go.mod b/console/shell/go.mod index efdb8bef..baf0c3bd 100644 --- a/console/shell/go.mod +++ b/console/shell/go.mod @@ -4,4 +4,8 @@ go 1.26.3 require github.com/mattn/go-runewidth v0.0.24 -require github.com/clipperhouse/uax29/v2 v2.2.0 // indirect +require ( + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect +) diff --git a/console/shell/go.sum b/console/shell/go.sum index dfdcfc87..967f6c6b 100644 --- a/console/shell/go.sum +++ b/console/shell/go.sum @@ -2,3 +2,7 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdoh github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= diff --git a/console/shell/internal/environment/stdio.go b/console/shell/internal/environment/stdio.go new file mode 100644 index 00000000..bc23b4c6 --- /dev/null +++ b/console/shell/internal/environment/stdio.go @@ -0,0 +1,89 @@ +package environment + +import ( + "bufio" + "errors" + "io" + stdstrings "strings" +) + +type StdioInput struct { + prompt io.Writer + reader *bufio.Reader +} + +type WriterOutput struct { + writer io.Writer +} + +func NewStdioInput(reader io.Reader, prompt io.Writer) *StdioInput { + return &StdioInput{prompt: prompt, reader: bufio.NewReader(reader)} +} + +func NewWriterOutput(writer io.Writer) WriterOutput { + return WriterOutput{writer: writer} +} + +func (input *StdioInput) ReadKey() (string, bool, error) { + value, err := input.reader.ReadByte() + if errors.Is(err, io.EOF) { + return "", false, nil + } + if err != nil { + return "", false, err + } + + if value == '\r' { + return "\n", true, nil + } + + if value == '\x1b' { + return input.readEscapeSequence(value), true, nil + } + + return string(value), true, nil +} + +func (input *StdioInput) ReadLine(message string) (string, error) { + if input.prompt != nil && message != "" { + if _, err := io.WriteString(input.prompt, message); err != nil { + return "", err + } + } + + value, err := input.reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + + return stdstrings.TrimRight(value, "\r\n"), nil +} + +func (input *StdioInput) readEscapeSequence(first byte) string { + sequence := []byte{first} + + for input.reader.Buffered() > 0 { + next, err := input.reader.ReadByte() + if err != nil { + break + } + sequence = append(sequence, next) + if isEscapeSequenceFinal(next) { + break + } + } + + return string(sequence) +} + +func isEscapeSequenceFinal(value byte) bool { + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || value == '~' +} + +func (output WriterOutput) Write(content string) { + if output.writer == nil || content == "" { + return + } + + _, _ = io.WriteString(output.writer, content) +} diff --git a/console/shell/internal/environment/stdio_test.go b/console/shell/internal/environment/stdio_test.go new file mode 100644 index 00000000..15a007b4 --- /dev/null +++ b/console/shell/internal/environment/stdio_test.go @@ -0,0 +1,43 @@ +package environment + +import ( + "bytes" + "testing" +) + +func TestStdioInputReadsKeysLinesAndEscapeSequences(t *testing.T) { + var prompt bytes.Buffer + input := NewStdioInput(bytes.NewBufferString("A\r\x1b[Ahello\n"), &prompt) + + pressed, ok, err := input.ReadKey() + if err != nil || !ok || pressed != "A" { + t.Fatalf("first key = %q ok=%v err=%v", pressed, ok, err) + } + + pressed, ok, err = input.ReadKey() + if err != nil || !ok || pressed != "\n" { + t.Fatalf("enter key = %q ok=%v err=%v", pressed, ok, err) + } + + pressed, ok, err = input.ReadKey() + if err != nil || !ok || pressed != "\x1b[A" { + t.Fatalf("escape key = %q ok=%v err=%v", pressed, ok, err) + } + + line, err := input.ReadLine("Name: ") + if err != nil || line != "hello" { + t.Fatalf("line = %q err=%v", line, err) + } + if prompt.String() != "Name: " { + t.Fatalf("prompt = %q", prompt.String()) + } +} + +func TestWriterOutputWritesToWriter(t *testing.T) { + var output bytes.Buffer + NewWriterOutput(&output).Write("hello") + + if output.String() != "hello" { + t.Fatalf("output = %q", output.String()) + } +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go index daa1b660..3be99276 100644 --- a/console/shell/portal/main.go +++ b/console/shell/portal/main.go @@ -5,22 +5,80 @@ import ( "os" tui "github.com/oullin/tui" + "golang.org/x/term" ) -type stdOutput struct{} - func main() { - tui.Configure(tui.Patch{Output: stdOutput{}, ErrorOutput: stdOutput{}}) + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} - if len(os.Args) > 1 && os.Args[1] == "version" { +func run(args []string) error { + if len(args) > 0 && args[0] == "version" { fmt.Println("@ollin/shell 0.1.0") - return + return nil + } + + configureStdioEnvironment() + + if len(args) > 0 && args[0] == "form" { + return runFormCommand() } tui.Intro("Oullin TUI shell") - tui.Info("Go console shell workspace is ready.") + tui.Info("Commands: version, form") + return nil +} + +func configureStdioEnvironment() { + interactive := true + tui.Configure(tui.Patch{ + Input: tui.NewStdioInput(os.Stdin, os.Stdout), + Output: tui.NewWriterOutput(os.Stdout), + ErrorOutput: tui.NewWriterOutput(os.Stderr), + Interactive: &interactive, + }) } -func (stdOutput) Write(content string) { - fmt.Print(content) +func runFormCommand() error { + restore, err := enableRawInput() + if err != nil { + return err + } + defer restore() + + responses, err := tui.Form(). + Text(tui.TextOptions{Message: "Project name", Required: tui.Required{Enabled: true}}). + Select(tui.ChoiceOptions{Message: "Runtime", Choices: tui.NewStringChoices("Go", "TypeScript"), Default: "Go", HasDefault: true}). + Confirm(tui.ConfirmOptions{Message: "Create project?", Default: true, HasDefault: true}). + Submit() + if err != nil { + return err + } + + tui.Table([]string{"Field", "Value"}, [][]any{ + {"Project", responses.At(0)}, + {"Runtime", responses.At(1)}, + {"Create", responses.At(2)}, + }) + + return nil +} + +func enableRawInput() (func(), error) { + file := os.Stdin + if !term.IsTerminal(int(file.Fd())) { + return func() {}, nil + } + + state, err := term.MakeRaw(int(file.Fd())) + if err != nil { + return nil, err + } + + return func() { + _ = term.Restore(int(file.Fd()), state) + }, nil } diff --git a/console/shell/portal/main_test.go b/console/shell/portal/main_test.go new file mode 100644 index 00000000..373c37f0 --- /dev/null +++ b/console/shell/portal/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "bytes" + "os/exec" + "strings" + "testing" +) + +func TestPortalVersionCommand(t *testing.T) { + output := runPortal(t, nil, "version") + + if strings.TrimSpace(output) != "@ollin/shell 0.1.0" { + t.Fatalf("version output = %q", output) + } +} + +func TestPortalDefaultCommandShowsRealCommands(t *testing.T) { + output := runPortal(t, nil) + + if !strings.Contains(output, "Oullin TUI shell") || !strings.Contains(output, "Commands: version, form") { + t.Fatalf("default output = %q", output) + } +} + +func TestPortalFormCommandUsesRealStdio(t *testing.T) { + output := runPortal(t, []byte("Ada\n\n\n"), "form") + + for _, want := range []string{"Project name", "Runtime", "Create project?", "Project", "Ada", "Runtime", "Go", "Create", "true"} { + if !strings.Contains(output, want) { + t.Fatalf("form output missing %q:\n%s", want, output) + } + } +} + +func runPortal(t *testing.T, stdin []byte, args ...string) string { + t.Helper() + + commandArgs := append([]string{"run", "."}, args...) + command := exec.Command("go", commandArgs...) + command.Stdin = bytes.NewReader(stdin) + + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("go run . %s failed: %v\n%s", strings.Join(args, " "), err, output) + } + + return string(output) +} From f154363b841ff99f6ed193118723b025fbed6a54 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:31:11 +0800 Subject: [PATCH 011/404] feat(shell): load portal config with viper --- console/shell/go.mod | 12 +++ console/shell/go.sum | 25 +++++ console/shell/portal/config.go | 157 ++++++++++++++++++++++++++++++ console/shell/portal/main.go | 25 +++-- console/shell/portal/main_test.go | 55 +++++++++++ 5 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 console/shell/portal/config.go diff --git a/console/shell/go.mod b/console/shell/go.mod index baf0c3bd..40c58976 100644 --- a/console/shell/go.mod +++ b/console/shell/go.mod @@ -6,6 +6,18 @@ require github.com/mattn/go-runewidth v0.0.24 require ( github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/console/shell/go.sum b/console/shell/go.sum index 967f6c6b..47f62dab 100644 --- a/console/shell/go.sum +++ b/console/shell/go.sum @@ -1,8 +1,33 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/console/shell/portal/config.go b/console/shell/portal/config.go new file mode 100644 index 00000000..9fc40811 --- /dev/null +++ b/console/shell/portal/config.go @@ -0,0 +1,157 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + stdstrings "strings" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + defaultCommand = "home" + versionCommand = "version" + formCommand = "form" +) + +type portalConfig struct { + Command string + ProjectDefault string + RuntimeDefault string + CreateDefault bool + Interactive bool +} + +func loadPortalConfig(args []string) (portalConfig, error) { + settings := viper.New() + settings.SetEnvPrefix("OLLIN_SHELL") + settings.SetEnvKeyReplacer(stdstrings.NewReplacer(".", "_", "-", "_")) + settings.AutomaticEnv() + + settings.SetDefault("command", defaultCommand) + settings.SetDefault("form.project", "") + settings.SetDefault("form.runtime", "Go") + settings.SetDefault("form.create", true) + settings.SetDefault("interactive", true) + + flags := pflag.NewFlagSet("shell", pflag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.String("config", "", "Path to a shell config file.") + flags.String("command", "", "Command to run.") + flags.String("project", "", "Default project name for the form command.") + flags.String("runtime", "", "Default runtime for the form command.") + flags.Bool("create", true, "Default create confirmation for the form command.") + flags.Bool("interactive", true, "Enable interactive prompts.") + flags.Bool("version", false, "Print the shell version.") + + if err := flags.Parse(args); err != nil { + return portalConfig{}, err + } + + if err := settings.BindPFlag("config", flags.Lookup("config")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("command", flags.Lookup("command")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.project", flags.Lookup("project")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.runtime", flags.Lookup("runtime")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("form.create", flags.Lookup("create")); err != nil { + return portalConfig{}, err + } + if err := settings.BindPFlag("interactive", flags.Lookup("interactive")); err != nil { + return portalConfig{}, err + } + + if configPath := settings.GetString("config"); configPath != "" { + settings.SetConfigFile(configPath) + if !hasKnownConfigExtension(configPath) { + settings.SetConfigType("yaml") + } + if err := settings.ReadInConfig(); err != nil { + return portalConfig{}, err + } + } else { + settings.SetConfigName("shell") + settings.SetConfigType("yaml") + settings.AddConfigPath(".") + if home, err := os.UserHomeDir(); err == nil { + settings.AddConfigPath(home + "/.config/ollin") + } + if err := settings.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { + return portalConfig{}, err + } + } + } + + if flags.Changed("version") && settings.GetBool("version") { + settings.Set("command", versionCommand) + } + + if command := firstPositionalCommand(flags.Args()); command != "" && !flags.Changed("command") { + settings.Set("command", command) + } + + config := portalConfig{ + Command: normalizeCommand(settings.GetString("command")), + ProjectDefault: settings.GetString("form.project"), + RuntimeDefault: settings.GetString("form.runtime"), + CreateDefault: settings.GetBool("form.create"), + Interactive: settings.GetBool("interactive"), + } + if err := config.validate(); err != nil { + return portalConfig{}, err + } + + return config, nil +} + +func firstPositionalCommand(args []string) string { + if len(args) == 0 { + return "" + } + + return args[0] +} + +func normalizeCommand(command string) string { + command = stdstrings.TrimSpace(stdstrings.ToLower(command)) + if command == "" { + return defaultCommand + } + + return command +} + +func hasKnownConfigExtension(path string) bool { + switch stdstrings.ToLower(filepath.Ext(path)) { + case ".json", ".toml", ".yaml", ".yml", ".env", ".hcl", ".ini", ".properties", ".props", ".prop": + return true + default: + return false + } +} + +func (config portalConfig) validate() error { + switch config.Command { + case defaultCommand, versionCommand, formCommand: + default: + return fmt.Errorf("unknown command %q", config.Command) + } + + if config.RuntimeDefault != "Go" && config.RuntimeDefault != "TypeScript" { + return fmt.Errorf("unsupported runtime %q", config.RuntimeDefault) + } + + return nil +} diff --git a/console/shell/portal/main.go b/console/shell/portal/main.go index 3be99276..78c6f1a3 100644 --- a/console/shell/portal/main.go +++ b/console/shell/portal/main.go @@ -16,15 +16,20 @@ func main() { } func run(args []string) error { - if len(args) > 0 && args[0] == "version" { + config, err := loadPortalConfig(args) + if err != nil { + return err + } + + if config.Command == versionCommand { fmt.Println("@ollin/shell 0.1.0") return nil } - configureStdioEnvironment() + configureStdioEnvironment(config) - if len(args) > 0 && args[0] == "form" { - return runFormCommand() + if config.Command == formCommand { + return runFormCommand(config) } tui.Intro("Oullin TUI shell") @@ -32,8 +37,8 @@ func run(args []string) error { return nil } -func configureStdioEnvironment() { - interactive := true +func configureStdioEnvironment(config portalConfig) { + interactive := config.Interactive tui.Configure(tui.Patch{ Input: tui.NewStdioInput(os.Stdin, os.Stdout), Output: tui.NewWriterOutput(os.Stdout), @@ -42,7 +47,7 @@ func configureStdioEnvironment() { }) } -func runFormCommand() error { +func runFormCommand(config portalConfig) error { restore, err := enableRawInput() if err != nil { return err @@ -50,9 +55,9 @@ func runFormCommand() error { defer restore() responses, err := tui.Form(). - Text(tui.TextOptions{Message: "Project name", Required: tui.Required{Enabled: true}}). - Select(tui.ChoiceOptions{Message: "Runtime", Choices: tui.NewStringChoices("Go", "TypeScript"), Default: "Go", HasDefault: true}). - Confirm(tui.ConfirmOptions{Message: "Create project?", Default: true, HasDefault: true}). + Text(tui.TextOptions{Message: "Project name", Default: config.ProjectDefault, Required: tui.Required{Enabled: true}}). + Select(tui.ChoiceOptions{Message: "Runtime", Choices: tui.NewStringChoices("Go", "TypeScript"), Default: config.RuntimeDefault, HasDefault: true}). + Confirm(tui.ConfirmOptions{Message: "Create project?", Default: config.CreateDefault, HasDefault: true}). Submit() if err != nil { return err diff --git a/console/shell/portal/main_test.go b/console/shell/portal/main_test.go index 373c37f0..9baadaa4 100644 --- a/console/shell/portal/main_test.go +++ b/console/shell/portal/main_test.go @@ -2,7 +2,9 @@ package main import ( "bytes" + "os" "os/exec" + "path/filepath" "strings" "testing" ) @@ -33,12 +35,65 @@ func TestPortalFormCommandUsesRealStdio(t *testing.T) { } } +func TestPortalBindsViperFlagsIntoFormDefaults(t *testing.T) { + output := runPortal(t, []byte("\n\n\n"), "form", "--project", "Flag App", "--runtime", "TypeScript", "--create=false") + + for _, want := range []string{"Flag App", "TypeScript", "false"} { + if !strings.Contains(output, want) { + t.Fatalf("flag-backed form output missing %q:\n%s", want, output) + } + } +} + +func TestPortalBindsViperEnvIntoCommandAndDefaults(t *testing.T) { + output := runPortalWithEnv(t, []byte("\n\n\n"), []string{ + "OLLIN_SHELL_COMMAND=form", + "OLLIN_SHELL_FORM_PROJECT=Env App", + "OLLIN_SHELL_FORM_RUNTIME=TypeScript", + "OLLIN_SHELL_FORM_CREATE=false", + }) + + for _, want := range []string{"Env App", "TypeScript", "false"} { + if !strings.Contains(output, want) { + t.Fatalf("env-backed form output missing %q:\n%s", want, output) + } + } +} + +func TestPortalReadsViperConfigFile(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "shell") + err := os.WriteFile(configPath, []byte(strings.Join([]string{ + "command: form", + "form:", + " project: Config App", + " runtime: TypeScript", + " create: false", + "", + }, "\n")), 0o600) + if err != nil { + t.Fatal(err) + } + + output := runPortal(t, []byte("\n\n\n"), "--config", configPath) + + for _, want := range []string{"Config App", "TypeScript", "false"} { + if !strings.Contains(output, want) { + t.Fatalf("config-backed form output missing %q:\n%s", want, output) + } + } +} + func runPortal(t *testing.T, stdin []byte, args ...string) string { + return runPortalWithEnv(t, stdin, nil, args...) +} + +func runPortalWithEnv(t *testing.T, stdin []byte, env []string, args ...string) string { t.Helper() commandArgs := append([]string{"run", "."}, args...) command := exec.Command("go", commandArgs...) command.Stdin = bytes.NewReader(stdin) + command.Env = append(os.Environ(), env...) output, err := command.CombinedOutput() if err != nil { From 6055e3bc70c3ef2d7381bf8030e15369526eae53 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 17:33:39 +0800 Subject: [PATCH 012/404] chore(shell): port make dev and format targets --- Makefile | 20 ++++++++++++++------ package.json | 3 +++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 556706e9..5b32f14e 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ FMT_RUN := docker run --rm \ -e HOST_PROJECT_PATH=$(ROOT_PATH) \ $(FMT_IMAGE) -TS_GLOBS := '*.ts' '*.tsx' '*.mts' '*.cts' +TS_GLOBS := '*.ts' '*.tsx' '*.vue' '*.mts' '*.cts' .DEFAULT_GOAL := help @@ -18,10 +18,14 @@ define step endef define run_oxfmt -@cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z $(1) --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi +@cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z $(1) --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS/Vue files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi endef -.PHONY: help format format-all fresh +define run_in + @cd "$(1)" && $(2) +endef + +.PHONY: help dev format format-all fresh help: @awk 'BEGIN {FS = ":.*?## "; printf "\nUsage: \033[36mmake \033[0m\n"} \ @@ -29,12 +33,16 @@ help: /^[a-zA-Z0-9_.-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @echo "" +##@ Development +dev: ## Run the docs dev server + $(call run_in,$(ROOT_PATH),pnpm --filter docs dev) + ##@ Formatting -format: ## Format untracked + modified TS files via oxfmt/oxlint +format: ## Format + lint untracked + modified TS/Vue files via oxfmt/oxlint $(call run_oxfmt,--others --modified) -format-all: ## Format every non-ignored TS file in the repo via oxfmt/oxlint - @cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z --cached --others --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi +format-all: ## Format + lint every non-ignored TS/Vue file in the repo via oxfmt/oxlint + @cd "$(ROOT_PATH)" && tmp=$$(mktemp); git ls-files -z --cached --others --exclude-standard -- $(TS_GLOBS) | while IFS= read -r -d '' f; do [ -f "$$f" ] && printf '%s\0' "$$f"; done > "$$tmp"; if [ ! -s "$$tmp" ]; then echo "No TS/Vue files to format."; rm -f "$$tmp"; else xargs -0 $(FMT_RUN) format < "$$tmp"; rc=$$?; rm -f "$$tmp"; exit $$rc; fi ##@ Maintenance fresh: ## Clean generated state, reinstall dependencies, then run local gates diff --git a/package.json b/package.json index 5c4cbe50..12299eda 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "scripts": { "build": "turbo build", + "dev": "make dev", + "format": "make format", + "format-all": "make format-all", "test": "turbo test", "typecheck": "turbo typecheck" }, From 453d4afff26a7bd3f331502edf4a76805493b92e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Sat, 13 Jun 2026 21:13:01 +0800 Subject: [PATCH 013/404] feat(tui): wire prompt lifecycle and provision caches --- .gitignore | 4 + .npmrc | 1 + Makefile | 2 +- UPSTREAM.md | 22 +- console/.gitignore | 1 - console/shell/package.json | 10 - package.json | 6 +- packages/artefacts/src/index.ts | 6 +- packages/artefacts/tsconfig.json | 2 +- packages/artefacts/tsconfig/acceptance.json | 2 +- packages/artefacts/tsconfig/tui.json | 2 +- .../theme/components/TerminalOutput.vue | 19 +- .../theme/components/ui/card/Card.vue | 5 +- .../theme/components/ui/card/CardHeader.vue | 7 +- .../components/ui/separator/Separator.vue | 7 +- packages/docs/tsconfig.json | 2 +- packages/tui/src/contracts/output.ts | 2 +- packages/tui/src/index.ts | 4 +- packages/tui/src/output/data-table.ts | 32 +- packages/tui/src/output/data-table/rows.ts | 2 +- packages/tui/src/prompt.ts | 4 + packages/tui/src/prompt/fallback.ts | 45 + packages/tui/src/prompt/lifecycle.ts | 16 +- packages/tui/src/prompt/validate-using.ts | 17 + packages/tui/src/prompt/validation.ts | 7 +- packages/tui/src/prompts/number.ts | 67 +- packages/tui/src/prompts/number/input.ts | 16 +- packages/tui/src/prompts/number/render.ts | 19 +- packages/tui/src/prompts/password.ts | 47 +- packages/tui/src/prompts/password/input.ts | 9 +- packages/tui/src/prompts/password/render.ts | 13 +- packages/tui/src/prompts/password/types.ts | 1 + packages/tui/src/prompts/pause.ts | 6 +- packages/tui/src/prompts/search/index.ts | 91 +- packages/tui/src/prompts/search/read-multi.ts | 13 +- .../src/prompts/search/read-multi/result.ts | 1 + .../tui/src/prompts/search/read-single.ts | 21 +- .../src/prompts/search/read-single/result.ts | 1 + packages/tui/src/prompts/search/render.ts | 24 +- packages/tui/src/prompts/select/confirm.ts | 28 +- packages/tui/src/prompts/select/index.ts | 83 +- .../tui/src/prompts/select/read-multiple.ts | 21 +- .../tui/src/prompts/select/read-selected.ts | 12 +- packages/tui/src/prompts/select/render.ts | 14 +- .../tui/src/prompts/suggest/ghost-text.ts | 8 +- packages/tui/src/prompts/suggest/index.ts | 87 +- .../src/prompts/suggest/read-autocomplete.ts | 19 +- packages/tui/src/prompts/suggest/read.ts | 18 +- .../prompts/suggest/render-autocomplete.ts | 10 +- packages/tui/src/prompts/suggest/render.ts | 21 +- packages/tui/src/prompts/text.ts | 53 +- packages/tui/src/prompts/textarea.ts | 55 +- packages/tui/src/status/progress.ts | 11 +- packages/tui/src/status/signals.ts | 57 + packages/tui/src/status/spinner.ts | 10 +- packages/tui/src/status/stream.ts | 6 + packages/tui/src/status/task.ts | 8 + packages/tui/src/theme.ts | 24 +- packages/tui/src/theme/box.ts | 6 +- packages/tui/src/typed-value.ts | 11 +- packages/tui/src/typed-value/cursor.ts | 16 + packages/tui/src/typed-value/lines.ts | 27 + packages/tui/src/typed-value/render.ts | 20 +- .../tui/src/typed-value/textarea-frame.ts | 12 +- packages/tui/tsconfig.build.json | 2 +- pnpm-lock.yaml | 2460 ++++++++++++++++- pnpm-workspace.yaml | 7 +- provision/.gitignore | 1 + {console/shell => provision/go}/.gitignore | 0 {console/shell => provision/go}/exports.go | 0 .../shell => provision/go}/exports_test.go | 0 {console/shell => provision/go}/go.mod | 0 {console/shell => provision/go}/go.sum | 0 .../go}/internal/environment/environment.go | 0 .../internal/environment/environment_test.go | 0 .../go}/internal/environment/errors.go | 0 .../go}/internal/environment/memory_output.go | 0 .../internal/environment/scripted_input.go | 0 .../go}/internal/environment/stdio.go | 0 .../go}/internal/environment/stdio_test.go | 0 .../go}/internal/form/builder.go | 0 .../go}/internal/form/form_test.go | 0 .../go}/internal/form/output.go | 0 .../go}/internal/form/previous.go | 0 .../go}/internal/form/prompts.go | 0 .../go}/internal/form/responses.go | 0 .../go}/internal/form/revert.go | 0 .../go}/internal/form/status.go | 0 .../go}/internal/key/key.go | 0 .../go}/internal/key/key_test.go | 0 .../go}/internal/output/datatable.go | 0 .../go}/internal/output/notes.go | 0 .../go}/internal/output/notify.go | 0 .../go}/internal/output/output_test.go | 0 .../go}/internal/output/table.go | 0 .../go}/internal/output/terminal.go | 0 .../go}/internal/prompt/lifecycle.go | 0 .../go}/internal/prompt/validation.go | 0 .../go}/internal/prompts/basic_test.go | 0 .../go}/internal/prompts/choices.go | 0 .../go}/internal/prompts/choices_test.go | 0 .../go}/internal/prompts/number.go | 0 .../go}/internal/prompts/render.go | 0 .../go}/internal/prompts/search_suggest.go | 0 .../go}/internal/prompts/text.go | 0 .../go}/internal/status/frame.go | 0 .../go}/internal/status/progress.go | 0 .../go}/internal/status/spinner.go | 0 .../go}/internal/status/status_test.go | 0 .../go}/internal/status/stream.go | 0 .../go}/internal/status/task.go | 0 .../go}/internal/strings/strings.go | 0 .../go}/internal/strings/strings_test.go | 0 .../go}/internal/terminal/terminal.go | 0 .../go}/internal/terminal/terminal_test.go | 0 .../go}/internal/theme/box.go | 0 .../go}/internal/theme/styles.go | 0 .../go}/internal/theme/theme.go | 0 .../go}/internal/theme/theme_test.go | 0 .../go}/internal/typedvalue/lines.go | 0 .../go}/internal/typedvalue/typedvalue.go | 0 .../internal/typedvalue/typedvalue_test.go | 0 provision/go/package.json | 10 + .../shell => provision/go}/portal/config.go | 0 .../shell => provision/go}/portal/main.go | 0 .../go}/portal/main_test.go | 0 provision/ts/package.json | 14 + provision/ts/src/cache.ts | 14 + provision/ts/src/index.ts | 1 + provision/ts/tsconfig.json | 11 + turbo.json | 10 +- 131 files changed, 3242 insertions(+), 481 deletions(-) create mode 100644 .npmrc delete mode 100644 console/.gitignore delete mode 100644 console/shell/package.json create mode 100644 packages/tui/src/prompt/fallback.ts create mode 100644 packages/tui/src/prompt/validate-using.ts create mode 100644 packages/tui/src/status/signals.ts create mode 100644 packages/tui/src/typed-value/cursor.ts create mode 100644 provision/.gitignore rename {console/shell => provision/go}/.gitignore (100%) rename {console/shell => provision/go}/exports.go (100%) rename {console/shell => provision/go}/exports_test.go (100%) rename {console/shell => provision/go}/go.mod (100%) rename {console/shell => provision/go}/go.sum (100%) rename {console/shell => provision/go}/internal/environment/environment.go (100%) rename {console/shell => provision/go}/internal/environment/environment_test.go (100%) rename {console/shell => provision/go}/internal/environment/errors.go (100%) rename {console/shell => provision/go}/internal/environment/memory_output.go (100%) rename {console/shell => provision/go}/internal/environment/scripted_input.go (100%) rename {console/shell => provision/go}/internal/environment/stdio.go (100%) rename {console/shell => provision/go}/internal/environment/stdio_test.go (100%) rename {console/shell => provision/go}/internal/form/builder.go (100%) rename {console/shell => provision/go}/internal/form/form_test.go (100%) rename {console/shell => provision/go}/internal/form/output.go (100%) rename {console/shell => provision/go}/internal/form/previous.go (100%) rename {console/shell => provision/go}/internal/form/prompts.go (100%) rename {console/shell => provision/go}/internal/form/responses.go (100%) rename {console/shell => provision/go}/internal/form/revert.go (100%) rename {console/shell => provision/go}/internal/form/status.go (100%) rename {console/shell => provision/go}/internal/key/key.go (100%) rename {console/shell => provision/go}/internal/key/key_test.go (100%) rename {console/shell => provision/go}/internal/output/datatable.go (100%) rename {console/shell => provision/go}/internal/output/notes.go (100%) rename {console/shell => provision/go}/internal/output/notify.go (100%) rename {console/shell => provision/go}/internal/output/output_test.go (100%) rename {console/shell => provision/go}/internal/output/table.go (100%) rename {console/shell => provision/go}/internal/output/terminal.go (100%) rename {console/shell => provision/go}/internal/prompt/lifecycle.go (100%) rename {console/shell => provision/go}/internal/prompt/validation.go (100%) rename {console/shell => provision/go}/internal/prompts/basic_test.go (100%) rename {console/shell => provision/go}/internal/prompts/choices.go (100%) rename {console/shell => provision/go}/internal/prompts/choices_test.go (100%) rename {console/shell => provision/go}/internal/prompts/number.go (100%) rename {console/shell => provision/go}/internal/prompts/render.go (100%) rename {console/shell => provision/go}/internal/prompts/search_suggest.go (100%) rename {console/shell => provision/go}/internal/prompts/text.go (100%) rename {console/shell => provision/go}/internal/status/frame.go (100%) rename {console/shell => provision/go}/internal/status/progress.go (100%) rename {console/shell => provision/go}/internal/status/spinner.go (100%) rename {console/shell => provision/go}/internal/status/status_test.go (100%) rename {console/shell => provision/go}/internal/status/stream.go (100%) rename {console/shell => provision/go}/internal/status/task.go (100%) rename {console/shell => provision/go}/internal/strings/strings.go (100%) rename {console/shell => provision/go}/internal/strings/strings_test.go (100%) rename {console/shell => provision/go}/internal/terminal/terminal.go (100%) rename {console/shell => provision/go}/internal/terminal/terminal_test.go (100%) rename {console/shell => provision/go}/internal/theme/box.go (100%) rename {console/shell => provision/go}/internal/theme/styles.go (100%) rename {console/shell => provision/go}/internal/theme/theme.go (100%) rename {console/shell => provision/go}/internal/theme/theme_test.go (100%) rename {console/shell => provision/go}/internal/typedvalue/lines.go (100%) rename {console/shell => provision/go}/internal/typedvalue/typedvalue.go (100%) rename {console/shell => provision/go}/internal/typedvalue/typedvalue_test.go (100%) create mode 100644 provision/go/package.json rename {console/shell => provision/go}/portal/config.go (100%) rename {console/shell => provision/go}/portal/main.go (100%) rename {console/shell => provision/go}/portal/main_test.go (100%) create mode 100644 provision/ts/package.json create mode 100644 provision/ts/src/cache.ts create mode 100644 provision/ts/src/index.ts create mode 100644 provision/ts/tsconfig.json diff --git a/.gitignore b/.gitignore index 584c0313..989f5fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .turbo/ +provision/.cache/ .DS_Store coverage/ *.tsbuildinfo @@ -7,3 +8,6 @@ packages/artefacts/.cache/ packages/artefacts/.logs/ packages/artefacts/dist/ packages/tui/dist/ +packages/docs/src/.vitepress/cache/ +packages/docs/src/.vitepress/dist/ +packages/docs/src/api/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..94c622a1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +store-dir=provision/.cache/pnpm/store diff --git a/Makefile b/Makefile index 5b32f14e..d5e20598 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ format-all: ## Format + lint every non-ignored TS/Vue file in the repo via oxfmt fresh: ## Clean generated state, reinstall dependencies, then run local gates $(call step,Removing generated dependency/cache/build state) @test -n "$(ROOT_PATH)" && test "$(ROOT_PATH)" != "/" || { echo "Refusing to clean unsafe ROOT_PATH='$(ROOT_PATH)'"; exit 1; } - rm -rf "$(ROOT_PATH)/node_modules" "$(ROOT_PATH)"/packages/*/node_modules "$(ROOT_PATH)/.turbo" "$(ROOT_PATH)/packages/artefacts/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" "$(ROOT_PATH)/packages/tui/dist" + rm -rf "$(ROOT_PATH)/node_modules" "$(ROOT_PATH)"/packages/*/node_modules "$(ROOT_PATH)/provision/.cache" "$(ROOT_PATH)/packages/artefacts/.logs" "$(ROOT_PATH)/packages/artefacts/dist" "$(ROOT_PATH)/packages/tui/dist" $(call step,Removing TypeScript build info files) find "$(ROOT_PATH)" -name '*.tsbuildinfo' -type f -delete $(call step,Installing dependencies) diff --git a/UPSTREAM.md b/UPSTREAM.md index 450495e7..60f3d9d5 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -13,20 +13,24 @@ TypeScript and OpenTUI internals. | --- | --- | | Helper functions | `packages/tui/src/index.ts` | | Prompt lifecycle and fallback behavior | `packages/tui/src/prompt.ts`, `packages/tui/src/environment.ts`, `packages/tui/src/environment/*` | +| Prompt fallback reset and predicate condition lifecycle | `packages/tui/src/prompt/fallback.ts`, `packages/tui/src/prompt.ts`, `packages/tui/src/index.ts` | | Prompt cancellation callback lifecycle | `packages/tui/src/prompt/cancel.ts`, `packages/tui/tests/prompt.test.ts` | | Prompt revert rejection lifecycle | `packages/tui/src/prompt/revert.ts`, `packages/tui/src/form/builder/revert.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/tests/form.test.ts` | | Prompt validation retry default lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts` | | Prompt post-validation submitted-frame lifecycle | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompts/number.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts` | -| Prompt non-interactive transform lifecycle before validation | `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/text.test.ts`, `packages/tui/tests/number.test.ts`, `packages/tui/tests/choices.test.ts`, `packages/tui/tests/search.test.ts` | +| Prompt non-interactive raw-default validation lifecycle | `packages/tui/src/prompt/lifecycle.ts` | | Prompt validation error styling | `packages/tui/src/theme.ts`, `packages/tui/tests/prompt.test.ts` | +| Prompt validation warning line styling | `packages/tui/src/theme.ts`, `packages/tui/src/prompt/lifecycle.ts` | | Key constants and event mapping | `packages/tui/src/key.ts`, `packages/tui/src/key/*` | | Terminal helpers | `packages/tui/src/terminal.ts` | | Text input | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts` | | Text input defaults, transforms, validation, editing keys, non-interactive behavior, and failed reads | `packages/tui/src/prompts/text.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/tests/text.test.ts` | | Text active, submitted, cancelled, placeholder, and validation-safe rendering | `packages/tui/src/prompts/text.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/render.ts`, `packages/tui/tests/text.test.ts` | +| Text active-frame redraw and submitted-frame replacement | `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/render.ts`, `packages/tui/src/prompts/text.ts` | | Textarea input | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts` | | Textarea defaults, transforms, validation, editing keys, line navigation, and non-interactive behavior | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/tests/textarea.test.ts` | | Textarea active, submitted, cancelled, scrollbar, placeholder, and validation-safe rendering | `packages/tui/src/prompts/textarea.ts`, `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/tests/textarea.test.ts` | +| Textarea active-frame redraw and cursor-aware visible window rendering | `packages/tui/src/typed-value.ts`, `packages/tui/src/typed-value/lines.ts`, `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/src/prompts/textarea.ts` | | Textarea control-key line navigation and empty-line movement | `packages/tui/src/typed-value/edit/navigation.ts`, `packages/tui/tests/textarea.test.ts` | | Textarea boxed renderer and placeholder styling | `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/tests/textarea.test.ts` | | Textarea scrollbar rendering | `packages/tui/src/typed-value/textarea-frame.ts`, `packages/tui/src/typed-value/lines.ts`, `packages/tui/tests/textarea.test.ts` | @@ -35,8 +39,11 @@ TypeScript and OpenTUI internals. | Password input | `packages/tui/src/prompts/password.ts`, `packages/tui/src/typed-value.ts` | | Password input transforms, validation retry, editing keys, cancellation, masking, and non-interactive behavior | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/tests/password.test.ts` | | Password active, submitted, cancelled, placeholder, masked, and validation-safe rendering | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompts/password/render.ts`, `packages/tui/tests/password.test.ts` | +| Password active-frame redraw and masked cursor rendering | `packages/tui/src/prompts/password.ts`, `packages/tui/src/prompts/password/input.ts`, `packages/tui/src/prompts/password/render.ts` | | Number input | `packages/tui/src/prompts/number.ts`, `packages/tui/src/typed-value.ts` | | Number active, submitted, cancelled, arrow, placeholder, default, and validation-safe rendering | `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/render.ts`, `packages/tui/tests/number.test.ts` | +| Number default renderer right-aligned arrows | `packages/tui/src/prompts/number/render.ts` | +| Number active-frame redraw and cursor rendering | `packages/tui/src/prompts/number.ts`, `packages/tui/src/prompts/number/input.ts`, `packages/tui/src/prompts/number/render.ts` | | Confirm, select, multiselect | `packages/tui/src/prompts/select/*` | | Confirm active, submitted, cancelled, custom-label, fallback, and non-interactive behavior | `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/src/prompts/select/render-confirm.ts`, `packages/tui/tests/choices.test.ts` | | Confirm submitted frame renders only after validation passes | `packages/tui/src/prompts/select/confirm.ts`, `packages/tui/src/prompts/select/read-confirm.ts`, `packages/tui/tests/choices.test.ts` | @@ -47,19 +54,24 @@ TypeScript and OpenTUI internals. | Select line-mode and exhausted-input default fallback with validation retry | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | +| Select active-frame redraw and submitted-frame replacement | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/src/prompts/select/render.ts` | | Select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-selected.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select active boxed layout and row styling | `packages/tui/src/prompts/select/render.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Multi-select submitted frame renders only after validation passes | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | +| Multi-select active-frame redraw and submitted-frame replacement | `packages/tui/src/prompts/select/index.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/src/prompts/select/render.ts` | | Multi-select cancel final frame | `packages/tui/src/prompts/select/render.ts`, `packages/tui/src/prompts/select/read-multiple.ts`, `packages/tui/tests/choices.test.ts` | | Suggest, autocomplete | `packages/tui/src/prompts/suggest/*` | | Suggest active, submitted, cancelled, placeholder, info, scrolling, and validation-safe rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.ts`, `packages/tui/tests/suggest.test.ts` | +| Suggest active-frame redraw and cursor rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read.ts`, `packages/tui/src/prompts/suggest/render.ts` | | Suggest and autocomplete label-first info rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/options.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | | Autocomplete styled cursor-aware ghost text and Unicode-safe completion acceptance | `packages/tui/src/prompts/suggest/ghost-text.ts`, `packages/tui/src/prompts/suggest/autocomplete.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/suggest.test.ts` | | Autocomplete active, submitted, cancelled, placeholder, ghost-text, and validation-safe rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/tests/suggest.test.ts` | +| Autocomplete active-frame redraw and cursor fallback rendering | `packages/tui/src/prompts/suggest/index.ts`, `packages/tui/src/prompts/suggest/read-autocomplete.ts`, `packages/tui/src/prompts/suggest/render-autocomplete.ts`, `packages/tui/src/prompts/suggest/ghost-text.ts` | | Search, multisearch | `packages/tui/src/prompts/search/*` | | Search and multi-search label-first helper argument surface with info | `packages/tui/src/prompts/search/index.ts`, `packages/tui/tests/search.test.ts` | | Search active boxed layout and row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | +| Search active-frame redraw and cursor rendering | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/render.ts` | | Search and multisearch empty-result rendering | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Search highlighted-result row styling | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/theme/styles.ts`, `packages/tui/tests/search.test.ts` | | Search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/tests/search.test.ts` | @@ -68,6 +80,7 @@ TypeScript and OpenTUI internals. | Multi-search active boxed layout and selected-count footer | `packages/tui/src/prompts/search/render.ts`, `packages/tui/tests/search.test.ts` | | Multi-search submitted final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Multi-search submitted frame renders only after validation passes | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/read-multi/result.ts`, `packages/tui/tests/search.test.ts` | +| Multi-search active-frame redraw and cursor rendering | `packages/tui/src/prompts/search/index.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/src/prompts/search/render.ts` | | Multi-search cancel final frame | `packages/tui/src/prompts/search/render.ts`, `packages/tui/src/prompts/search/read-multi.ts`, `packages/tui/tests/search.test.ts` | | Pause | `packages/tui/src/prompts/pause.ts` | | Pause themed key-driven frame | `packages/tui/src/prompts/pause.ts`, `packages/tui/src/prompts/pause/render.ts`, `packages/tui/tests/pause.test.ts` | @@ -131,26 +144,33 @@ TypeScript and OpenTUI internals. | Stream frame rendering, wrapping, fade styles, and close flushing | `packages/tui/src/status/stream.ts`, `packages/tui/src/status/stream/*` | | Form builder root-aligned manual stream overload | `packages/tui/src/form/builder/status.ts`, `packages/tui/tests/form.test.ts` | | Status cursor visibility cleanup | `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.ts` | +| Status signal cleanup for cursor and rendered-frame restoration | `packages/tui/src/status/signals.ts`, `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/stream.ts`, `packages/tui/src/status/task.ts` | | Status rendered-frame cleanup | `packages/tui/src/status/frame.ts`, `packages/tui/src/status/spinner.ts`, `packages/tui/src/status/task.ts` | | Progress cursor visibility and rendered-frame redraw cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/src/status/frame.ts` | | Progress signal cleanup | `packages/tui/src/status/progress.ts`, `packages/tui/tests/progress.test.ts` | +| Progress prompt-disable and manual advance semantics | `packages/tui/src/status/progress.ts` | | Terminal clear/title, cursor visibility, true-color detection, default colors, and raw input restoration | `packages/tui/src/terminal.ts`, `packages/tui/src/terminal/*`, `packages/tui/src/environment/raw-key.ts` | | Terminal clear/title and notification helper return values | `packages/tui/src/output/terminal.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/tests/terminal.test.ts`, `packages/tui/tests/notify.test.ts` | | Terminal raw key alias normalization | `packages/tui/src/environment/raw-key/normalize.ts`, `packages/tui/tests/environment.test.ts` | | Terminal split escape-sequence buffering | `packages/tui/src/environment/raw-key.ts`, `packages/tui/src/environment/raw-key/normalize.ts`, `packages/tui/tests/environment.test.ts` | | Red, strikethrough, and border-styled frames | `packages/tui/src/theme/styles.ts`, `packages/tui/src/theme/box.ts` | +| Default box renderer terminal-width cap | `packages/tui/src/theme/box.ts` | | ANSI segment parsing, ANSI-aware wrapping, truncation, Unicode width | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | | ANSI truncation style cleanup and visible-width markers | `packages/tui/src/strings.ts`, `packages/tui/tests/strings.test.ts` | | Key-mode search default submission | `packages/tui/src/prompts/search/read-single.ts`, `packages/tui/src/prompts/search/read-single/result.ts`, `packages/tui/tests/search.test.ts` | | OpenTUI adapter | `packages/tui/src/opentui.ts` | | Type contracts | `packages/tui/src/types.ts`, `packages/tui/src/contracts/*` | | Public ESM package runtime and type-consumption surface | `packages/tui/src/index.ts`, `packages/tui/src/form/builder/index.ts`, `packages/tui/src/output/notify.ts`, `packages/tui/src/output/notify/*`, `packages/acceptance/tests/package.test.ts` | +| Provision workspace and tool cache isolation | `provision/go`, `provision/ts`, `provision/.cache`, `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `.npmrc` | | Data table prompt argument validation layer | `packages/tui/src/output/validators/data-table.ts`, `packages/tui/src/output/data-table.ts`, `packages/tui/tests/table.test.ts` | +| Data table filter callback lifecycle | `packages/tui/src/contracts/output.ts`, `packages/tui/src/output/data-table/rows.ts`, `packages/tui/src/output/data-table.ts` | | Table row cell normalization validation layer | `packages/tui/src/output/validators/table.ts`, `packages/tui/src/output/table.ts`, `packages/tui/tests/table.test.ts` | | Choice option shape validation layer | `packages/tui/src/concerns/validators/choice.ts`, `packages/tui/src/concerns/choices.ts`, `packages/tui/tests/choices.test.ts` | | Required value validation layer | `packages/tui/src/validators/required.ts`, `packages/tui/tests/prompt.test.ts` | | Key match validation layer | `packages/tui/src/key/validators/match.ts`, `packages/tui/src/key/match.ts`, `packages/tui/tests/key.test.ts` | | Form builder number empty-default lifecycle | `packages/tui/src/form/builder/prompts/basic.ts`, `packages/tui/tests/form.test.ts` | +| Prompt fallback and global validation lifecycle hooks | `packages/tui/src/prompt/fallback.ts`, `packages/tui/src/prompt/validate-using.ts`, `packages/tui/src/prompt/lifecycle.ts`, `packages/tui/src/prompts/*`, `packages/tui/src/output/data-table.ts` | +| Boxed default table rendering | `packages/tui/src/theme.ts`, `packages/tui/src/output/table.ts`, `packages/tui/src/output/data-table/render.ts` | ## Remaining Exact-Parity Work diff --git a/console/.gitignore b/console/.gitignore deleted file mode 100644 index e660fd93..00000000 --- a/console/.gitignore +++ /dev/null @@ -1 +0,0 @@ -bin/ diff --git a/console/shell/package.json b/console/shell/package.json deleted file mode 100644 index ab6212f2..00000000 --- a/console/shell/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@ollin/shell", - "version": "0.1.0", - "private": true, - "scripts": { - "build": "go build -o ../bin/shell ./portal", - "test": "go test ./...", - "typecheck": "go test ./... -run '^$'" - } -} diff --git a/package.json b/package.json index 12299eda..db47d0bd 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "private": true, "type": "module", "scripts": { - "build": "turbo build", + "build": "XDG_CACHE_HOME=provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=provision/.cache/playwright turbo run build --cache-dir provision/.cache/turbo --log-file provision/.cache/turbo/logs/build.json", "dev": "make dev", "format": "make format", "format-all": "make format-all", - "test": "turbo test", - "typecheck": "turbo typecheck" + "test": "XDG_CACHE_HOME=provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=provision/.cache/playwright turbo run test --cache-dir provision/.cache/turbo --log-file provision/.cache/turbo/logs/test.json", + "typecheck": "XDG_CACHE_HOME=provision/.cache/xdg PLAYWRIGHT_BROWSERS_PATH=provision/.cache/playwright turbo run typecheck --cache-dir provision/.cache/turbo --log-file provision/.cache/turbo/logs/typecheck.json" }, "devDependencies": { "@ollin/artefacts": "workspace:*", diff --git a/packages/artefacts/src/index.ts b/packages/artefacts/src/index.ts index 479bc05a..c838b1e5 100644 --- a/packages/artefacts/src/index.ts +++ b/packages/artefacts/src/index.ts @@ -3,9 +3,13 @@ import { fileURLToPath } from 'node:url'; export const artefactsRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); +export const workspaceRoot = resolve(artefactsRoot, '../..'); + +export const provisionRoot = resolve(workspaceRoot, 'provision'); + export const artefactsPath = (...segments: string[]): string => resolve(artefactsRoot, ...segments); -export const cacheDir = (...segments: string[]): string => artefactsPath('.cache', ...segments); +export const cacheDir = (...segments: string[]): string => resolve(provisionRoot, '.cache', ...segments); export const distDir = (...segments: string[]): string => artefactsPath('dist', ...segments); diff --git a/packages/artefacts/tsconfig.json b/packages/artefacts/tsconfig.json index 1d7ebf18..7519ca98 100644 --- a/packages/artefacts/tsconfig.json +++ b/packages/artefacts/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@ollin/tsconfig/base.json", "compilerOptions": { "baseUrl": ".", - "tsBuildInfoFile": ".cache/tsbuild/artefacts.tsbuildinfo", + "tsBuildInfoFile": "../../provision/.cache/tsbuild/artefacts.tsbuildinfo", "paths": { "#artefacts/*": ["./src/*"] } diff --git a/packages/artefacts/tsconfig/acceptance.json b/packages/artefacts/tsconfig/acceptance.json index fe189ce5..7de753e2 100644 --- a/packages/artefacts/tsconfig/acceptance.json +++ b/packages/artefacts/tsconfig/acceptance.json @@ -1,5 +1,5 @@ { "compilerOptions": { - "tsBuildInfoFile": "../.cache/tsbuild/acceptance.tsbuildinfo" + "tsBuildInfoFile": "../../../provision/.cache/tsbuild/acceptance.tsbuildinfo" } } diff --git a/packages/artefacts/tsconfig/tui.json b/packages/artefacts/tsconfig/tui.json index 70bc18e1..058cde9e 100644 --- a/packages/artefacts/tsconfig/tui.json +++ b/packages/artefacts/tsconfig/tui.json @@ -1,5 +1,5 @@ { "compilerOptions": { - "tsBuildInfoFile": "../.cache/tsbuild/tui.tsbuildinfo" + "tsBuildInfoFile": "../../../provision/.cache/tsbuild/tui.tsbuildinfo" } } diff --git a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue index ec1558b1..87dc66ef 100644 --- a/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue +++ b/packages/docs/src/.vitepress/theme/components/TerminalOutput.vue @@ -33,6 +33,7 @@ const props = withDefaults( ); const viewport = ref(null); + let terminal: Terminal | null = null; let observer: IntersectionObserver | null = null; let timers: ReturnType[] = []; @@ -230,21 +231,9 @@ watch(output, () => {