Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.1
0.6.2
28 changes: 19 additions & 9 deletions packages/tui/internal/components/textarea.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,16 +650,18 @@ func (t *TextArea) Update(msg tea.Msg) (*TextArea, tea.Cmd) {
return t, nil
}

// View renders the textarea into a string
// View renders the textarea into a string. Output is always exactly
// “t.Height“ lines tall so consumers can rely on consistent layout
// regardless of focus state or content length.
func (t *TextArea) View() string {
var content string

if len(t.value) == 0 || (len(t.value) == 1 && len(t.value[0]) == 0) {
if t.Placeholder != "" {
content = t.placeholderView()
} else {
content = ""
}
// Empty textarea: render the placeholder view (which pads to height
// and shows a cursor on line 1 when focused). We always render the
// box — even when unfocused without a placeholder — so the layout
// is stable and the user can see where to click/tab to type.
content = t.placeholderView()
} else {
// Use viewport for scrolling if available
if t.viewport != nil {
Expand All @@ -677,9 +679,17 @@ func (t *TextArea) View() string {
}
}

// Apply panel styling (background and padding)
// Don't set Height here - let it size naturally based on content + padding
return t.Style.Panel.Width(t.Width).Render(content)
// Pin the rendered block to t.Height so empty/full/focused/unfocused
// textareas always occupy the same vertical space. Without an explicit
// .Height(), lipgloss sizes the block to fit content + padding, which
// produced a 2-line discrepancy between the placeholderView and viewport
// paths (and a much larger one when content lines wrapped or the
// content was much shorter than t.Height).
height := t.Height
if height < 1 {
height = 1
}
return t.Style.Panel.Width(t.Width).Height(height).Render(content)
}

// renderLineWithCursor renders a line with the cursor positioned correctly
Expand Down
10 changes: 9 additions & 1 deletion packages/tui/internal/screens/scenarios/business_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ func (e ScenarioEditor) handleBusinessContextMode(msg tea.KeyMsg) (ScenarioEdito
e.bizContextSelected = true // Keep business context selected when exiting edit mode
return e, nil

case "ctrl+x":
// Clear the entire business context text area. (Ctrl+L is the
// global model selector, so we use Ctrl+X for "erase".)
if e.bizTextArea != nil {
e.bizTextArea.SetValue("")
}
return e, nil

default:
// Pass through to TextArea
if e.bizTextArea != nil {
Expand All @@ -50,7 +58,7 @@ func (e ScenarioEditor) handleBusinessContextMode(msg tea.KeyMsg) (ScenarioEdito
func (e ScenarioEditor) renderBusinessContextView(t theme.Theme) string {
title := lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("\nEdit Business Context")

help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Esc save and exit Ctrl+S save Standard text editing keys")
help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Esc save and exit Ctrl+X clear ↑↓ move cursor Standard text editing keys")
errorLine := ""
if e.errorMsg != "" {
errorLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Error()).Render("⚠ " + e.errorMsg)
Expand Down
201 changes: 85 additions & 116 deletions packages/tui/internal/screens/scenarios/edit.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package scenarios

import (
"encoding/json"
"errors"
"fmt"
"strconv"
Expand All @@ -17,11 +16,9 @@ import (
const (
editFieldScenario = 0
editFieldExpectedOutcome = 1
editFieldFilePath = 2
editFieldKwargs = 3
editFieldMultiTurnToggle = 4
editFieldMaxTurns = 5
editFieldSave = 6
editFieldMultiTurnToggle = 2
editFieldMaxTurns = 3
editFieldSave = 4
)

// handleEditMode handles keyboard input in edit/add mode
Expand All @@ -39,21 +36,54 @@ func (e ScenarioEditor) handleEditMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd)
if e.expectedOutcomeTextArea != nil {
e.expectedOutcomeTextArea.Blur()
}
if e.kwargsTextArea != nil {
e.kwargsTextArea.Blur()
}
return e, nil

case "tab", "down":
case "tab":
e.currentField = e.nextField(e.currentField, +1)
e.updateTextAreaFocus()
return e, nil

case "shift+tab", "up":
case "shift+tab":
e.currentField = e.nextField(e.currentField, -1)
e.updateTextAreaFocus()
return e, nil

case "down":
// On non-textarea fields (toggle, max-turns, save), arrows act as
// field navigation. Inside textareas, the arrow is forwarded to
// the textarea component for cursor movement.
if !isTextAreaField(e.currentField) {
e.currentField = e.nextField(e.currentField, +1)
e.updateTextAreaFocus()
return e, nil
}
return e.forwardToFocusedTextArea(msg)

case "up":
if !isTextAreaField(e.currentField) {
e.currentField = e.nextField(e.currentField, -1)
e.updateTextAreaFocus()
return e, nil
}
return e.forwardToFocusedTextArea(msg)

case "ctrl+x":
// Clear the entire content of the focused text area. Only acts on
// multi-line textareas — toggles / single-line buffers ignore it.
// (Ctrl+L is the global model selector, Ctrl+U / Ctrl+K are the
// textarea's delete-before/after-cursor.)
switch e.currentField {
case editFieldScenario:
if e.scenarioTextArea != nil {
e.scenarioTextArea.SetValue("")
}
case editFieldExpectedOutcome:
if e.expectedOutcomeTextArea != nil {
e.expectedOutcomeTextArea.SetValue("")
}
}
return e, nil

case "ctrl+s":
// Save via shortcut
e.syncTextAreasToEditing()
Expand All @@ -76,45 +106,13 @@ func (e ScenarioEditor) handleEditMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd)
if e.expectedOutcomeTextArea != nil {
e.expectedOutcomeTextArea.Blur()
}
if e.kwargsTextArea != nil {
e.kwargsTextArea.Blur()
}
return e, func() tea.Msg { return ScenarioEditorMsg{Action: "saved"} }

default:
// Route by current field.
switch e.currentField {
case editFieldScenario:
if e.scenarioTextArea != nil {
updatedTextArea, taCmd := e.scenarioTextArea.Update(msg)
*e.scenarioTextArea = *updatedTextArea
return e, taCmd
}
case editFieldExpectedOutcome:
if e.expectedOutcomeTextArea != nil {
updatedTextArea, taCmd := e.expectedOutcomeTextArea.Update(msg)
*e.expectedOutcomeTextArea = *updatedTextArea
return e, taCmd
}
case editFieldFilePath:
s := msg.String()
switch s {
case "backspace":
if len(e.filePathBuffer) > 0 {
e.filePathBuffer = e.filePathBuffer[:len(e.filePathBuffer)-1]
}
default:
if len(s) == 1 {
e.filePathBuffer += s
}
}
return e, nil
case editFieldKwargs:
if e.kwargsTextArea != nil {
updatedTextArea, taCmd := e.kwargsTextArea.Update(msg)
*e.kwargsTextArea = *updatedTextArea
return e, taCmd
}
case editFieldScenario, editFieldExpectedOutcome:
return e.forwardToFocusedTextArea(msg)
case editFieldMultiTurnToggle:
switch msg.String() {
case " ", "space", "enter", "x", "X":
Expand Down Expand Up @@ -161,16 +159,42 @@ func (e ScenarioEditor) handleEditMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd)
if e.expectedOutcomeTextArea != nil {
e.expectedOutcomeTextArea.Blur()
}
if e.kwargsTextArea != nil {
e.kwargsTextArea.Blur()
}
return e, func() tea.Msg { return ScenarioEditorMsg{Action: "saved"} }
}
}
return e, nil
}
}

// isTextAreaField reports whether the field at index `f` is rendered as a
// multi-line textarea (where arrow keys must move the cursor inside the
// text, not switch fields).
func isTextAreaField(f int) bool {
return f == editFieldScenario || f == editFieldExpectedOutcome
}

// forwardToFocusedTextArea sends the given key message to whichever
// textarea owns the currently-focused field. Used to plumb arrow keys
// (and similar) through to the textarea component without re-entering
// the editor's outer keyboard switch.
func (e ScenarioEditor) forwardToFocusedTextArea(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd) {
switch e.currentField {
case editFieldScenario:
if e.scenarioTextArea != nil {
updatedTextArea, taCmd := e.scenarioTextArea.Update(msg)
*e.scenarioTextArea = *updatedTextArea
return e, taCmd
}
case editFieldExpectedOutcome:
if e.expectedOutcomeTextArea != nil {
updatedTextArea, taCmd := e.expectedOutcomeTextArea.Update(msg)
*e.expectedOutcomeTextArea = *updatedTextArea
return e, taCmd
}
}
return e, nil
}

// multiTurnOnEdit reports whether the currently-editing scenario has multi-turn on
// (the JSON-null sentinel means "not set yet" and defaults to on).
func (e *ScenarioEditor) multiTurnOnEdit() bool {
Expand All @@ -197,9 +221,9 @@ func (e *ScenarioEditor) nextField(cur, step int) int {

func (e *ScenarioEditor) numFields() int {
if e.multiTurnOnEdit() {
return 7
return 5
}
return 6
return 4
}

// updateTextAreaFocus manages focus between TextAreas based on currentField
Expand All @@ -218,13 +242,6 @@ func (e *ScenarioEditor) updateTextAreaFocus() {
e.expectedOutcomeTextArea.Blur()
}
}
if e.kwargsTextArea != nil {
if e.currentField == editFieldKwargs {
e.kwargsTextArea.Focus()
} else {
e.kwargsTextArea.Blur()
}
}
}

// syncTextAreasToEditing copies TextArea contents to the editing struct.
Expand Down Expand Up @@ -254,28 +271,6 @@ func (e *ScenarioEditor) validateEditing() error {
e.editing.Dataset = nil
e.editing.DatasetSampleSize = nil

// File path single-line buffer. Empty -> nil pointer (omitted from JSON).
trimmedFilePath := strings.TrimSpace(e.filePathBuffer)
if trimmedFilePath == "" {
e.editing.FilePath = nil
} else {
e.editing.FilePath = &trimmedFilePath
}

// Parse the kwargs JSON textarea. Empty string -> nil map.
if e.kwargsTextArea != nil {
raw := strings.TrimSpace(e.kwargsTextArea.GetValue())
if raw == "" {
e.editing.AvailableKwargs = nil
} else {
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return fmt.Errorf("available kwargs must be valid JSON object: %v", err)
}
e.editing.AvailableKwargs = parsed
}
}

// Apply multi-turn + max_turns defaults and bounds.
mt := e.multiTurnOnEdit()
e.editing.MultiTurn = &mt
Expand Down Expand Up @@ -323,9 +318,6 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
usedHeight += 2 // title (1 line) + blank line
usedHeight += 2 // scenario label + blank
usedHeight += 2 // expected outcome label + blank
usedHeight += 2 // file path line + blank
usedHeight += 2 // kwargs label + blank
usedHeight += 5 // kwargs textarea (fixed-height)
usedHeight += 2 // multi-turn + blank
if multiTurnOn {
usedHeight += 2 // max-turns line + blank
Expand All @@ -340,6 +332,15 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
if textAreaHeight < 4 {
textAreaHeight = 4
}
// Cap the per-textarea height so a tall terminal doesn't blow up the
// box to 25+ lines for a one-line scenario. 10 lines is plenty for
// typical "say hi, then upload, then approve" runbooks while still
// leaving room for both fields plus the toggle / max-turns / save
// rows below.
const maxTextAreaHeight = 10
if textAreaHeight > maxTextAreaHeight {
textAreaHeight = maxTextAreaHeight
}

// Field 0: scenario TextArea
scenLabel := "Scenario (free-text goal or stepped plan)"
Expand Down Expand Up @@ -369,35 +370,7 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
outText = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Text()).Render("TextArea not available")
}

// Field 2: file path (PYTHON protocol convenience — auto-merged into kwargs pool)
filePathLabelText := "File Path (PYTHON only, default 'file_path' kwarg)"
filePathBuf := e.filePathBuffer
if filePathBuf == "" {
filePathBuf = "_"
}
filePathRaw := fmt.Sprintf("%s: [ %s ]", filePathLabelText, filePathBuf)
var filePathLine string
if e.currentField == editFieldFilePath {
filePathLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("▶ " + filePathRaw)
} else {
filePathLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Text()).Render(" " + filePathRaw)
}

// Field 3: available kwargs (JSON, PYTHON protocol only)
kwargsLabel := "Available Kwargs (PYTHON only, JSON object)"
if e.currentField == editFieldKwargs {
kwargsLabel = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("▶ Available Kwargs (PYTHON only, JSON object)")
}

var kwargsText string
if e.kwargsTextArea != nil {
e.kwargsTextArea.SetSize(e.width-4, 5)
kwargsText = e.kwargsTextArea.View()
} else {
kwargsText = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Text()).Render("TextArea not available")
}

// Field 3: multi-turn toggle
// Field 2: multi-turn toggle
multiTurnCheckbox := "[ ]"
if multiTurnOn {
multiTurnCheckbox = "[x]"
Expand Down Expand Up @@ -430,7 +403,7 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
saveLabel = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("▶ Save")
}

help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Tab/↑↓ switch fields Space toggle Ctrl+S save Esc cancel")
help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Tab/Shift+Tab switch fields ↑↓ move cursor in text Ctrl+X clear field Space toggle Ctrl+S save Esc cancel")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
errorLine := ""
if e.errorMsg != "" {
errorLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Error()).Render("⚠ " + e.errorMsg)
Expand All @@ -442,10 +415,6 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
parts = append(parts, "")
parts = append(parts, outLabel, outText)
parts = append(parts, "")
parts = append(parts, filePathLine)
parts = append(parts, "")
parts = append(parts, kwargsLabel, kwargsText)
parts = append(parts, "")
parts = append(parts, multiTurnLine)
if multiTurnOn {
parts = append(parts, maxTurnsLine)
Expand Down
Loading
Loading