Skip to content

[tui] Port - #2

Merged
gocanto merged 404 commits into
chore/laravel-prompts-portfrom
chore/console-shell-go-port
Jun 17, 2026
Merged

gocanto merged 404 commits into
chore/laravel-prompts-portfrom
chore/console-shell-go-port

Conversation

@gocanto

@gocanto gocanto commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • create the console/shell workspace and build output under console/bin
  • port TS TUI primitives into Go packages across terminal, key, theme, environment, prompts, output, status, and form builder layers
  • expose a root Go API and wire console/shell/portal/main.go to the new shell package

Phase commits

  1. chore(shell): move app stub into console shell workspace
  2. feat(shell): port terminal key string and theme primitives
  3. feat(shell): port prompt environment and typed input engine
  4. feat(shell): port prompt lifecycle and basic prompts
  5. feat(shell): port choice search and suggest prompts
  6. feat(shell): port output helpers and tables
  7. feat(shell): port status helpers
  8. feat(shell): port form builder
  9. feat(shell): expose public Go shell API

Verification

  • go test ./... from console/shell
  • pnpm --filter @ollin/shell build
  • pnpm --filter @ollin/shell test
  • pnpm --filter @ollin/shell typecheck
  • console/bin/shell version
  • console/bin/shell

Notes

  • Left existing unrelated TS/pnpm worktree changes unstaged and out of this PR.
  • Skipped repo-wide make format-all because it is TS-only and would operate on unrelated dirty TS files.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive Go-based terminal user interface (TUI) library under console/shell, featuring modules for environment management, forms, keyboard input, structured outputs (grids, tables, datatables), interactive prompts, and status indicators (spinners, progress bars, streams, tasks). Feedback on the implementation highlights several critical areas for improvement: resolving a state-retention bug in datatable validation, deferring choice rendering until after successful validation, preventing terminal flooding in stream rendering, and adding a redraw mechanism for task logging. Additionally, the review recommends addressing concurrency race conditions in the global environment state, refactoring recursive key handling to loops to prevent stack overflows, and optimizing $O(N^2)$ and $O(M \cdot N)$ complexity bottlenecks in string wrapping and character insertion.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +47 to +66
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
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The submitted variable is not reset to nil at the start of each read attempt. If a previous attempt succeeded but failed validation, and the subsequent attempt is cancelled, submitted remains non-nil, causing the cancelled row to be rendered as successfully submitted. Reset submitted to nil at the start of the reader closure.

	var submitted *dataTableSelection
	return prompt.UntilValid(
		prompt.Options[any]{Message: options.Message, Default: defaultValue, Required: options.Required, Validate: options.Validate},
		func(_ int) (any, error) {
			submitted = nil
			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
		},

Comment on lines +70 to +95
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,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The submitted choice frame is rendered before validation. If validation fails, the terminal will display a "submitted" frame followed by a validation error and another prompt, messing up the TUI layout. Use the onValid callback of UntilValid to render the submitted frame only after successful validation. This issue also applies to Multiselect, Confirm, Search, and MultiSearch.

func Select(options ChoiceOptions) (any, error) {
	if options.Scroll == 0 {
		options.Scroll = 5
	}
	validate := preserveInvalidChoiceDefault(&options)
	defaultValue := transformedChoiceDefault(options)

	var submittedLabel string
	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 {
				submittedLabel = label
			} else {
				submittedLabel = ""
			}
			return value, nil
		},
		func(any) error {
			if submittedLabel != "" {
				renderSubmittedChoice(options.Message, submittedLabel)
			}
			return nil
		},
	)

Comment on lines +59 to +61
func (stream *Stream) render() {
environment.Current().Output.Write(RenderStreamFrame(stream.buffer.StableValue(), stream.buffer.Fading, StreamFadeStyles(false), 60))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The render method writes the new stream frame to the output but never erases the previously rendered frame. Since RenderStreamFrame renders the entire stream history, each append will print the entire history again, flooding the terminal with duplicated output. Track the previously rendered frame and erase it using EraseRenderedFrame before writing the new frame.

Comment on lines +114 to +135
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Logger has no mechanism to trigger a redraw of the task frame when new logs are written. As a result, logs written during the execution of the task are completely invisible to the user until the task finishes, and the spinner remains static. Consider passing a redraw/render callback to the Logger so it can refresh the frame on state changes, similar to how Progress works.

Comment on lines +52 to +65
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()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The With function modifies the global current environment variable. If multiple goroutines call With concurrently, they will overwrite each other's environments and restore incorrect states, leading to race conditions. Consider using a context-based approach (context.Context) to propagate environment-scoped values instead of a global variable.

Comment on lines +38 to +54
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()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using recursion to handle non-revertible Ctrl+U key presses can lead to deep recursion and potential stack overflow if there are many consecutive Ctrl+U inputs (e.g., in scripted/automated testing). Refactor this to use a loop instead.

Suggested change
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) ReadKey() (string, bool, error) {
for {
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")
}
}

Comment on lines +59 to +85
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)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling VisibleWidth(result+string(char)) inside a loop over every character is highly inefficient ($O(N^2)$) because VisibleWidth parses the entire accumulated string from scratch on every iteration. Keep a running visible width counter and use runewidth.RuneWidth(char) to update it in $O(N)$ time.

	result := ""
	activeCodes := ""
	markerWidth := VisibleWidth(suffix)
	currentWidth := 0

	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 {
			charWidth := runewidth.RuneWidth(char)
			if currentWidth+charWidth+markerWidth > width {
				if activeCodes != "" {
					result += ansiCloseSequence(activeCodes)
				}

				return result + suffix
			}

			result += string(char)
			currentWidth += charWidth
		}
	}

Comment on lines +172 to +185
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling insertAt in a loop allocates a new slice and copies elements on every single character of the inserted string, resulting in $O(M \cdot N)$ complexity. Perform a single allocation of the required size instead.

func insertPrintable(value []string, cursor int, pressed string) (cursorResult, bool) {
	if !IsPrintable(pressed) {
		return cursorResult{}, false
	}

	chars := Characters(pressed)
	next := make([]string, 0, len(value)+len(chars))
	next = append(next, value[:cursor]...)
	next = append(next, chars...)
	next = append(next, value[cursor:]...)

	return cursorResult{cursor: cursor + len(chars), value: next}, true
}

@gocanto gocanto changed the title Port TUI shell to Go console workspace [tui] Port Jun 13, 2026
@gocanto
gocanto marked this pull request as draft June 13, 2026 13:10
@gocanto
gocanto marked this pull request as ready for review June 17, 2026 05:40
@gocanto
gocanto merged commit 4b4620e into chore/laravel-prompts-port Jun 17, 2026
1 check passed
@gocanto
gocanto deleted the chore/console-shell-go-port branch June 17, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant