Skip to content

Commit 8bf8173

Browse files
drorIvryclaude
andauthored
feat(multi-turn): runbook-aware per-turn attach_kwargs (v0.6.2) (#170)
* feat(multi-turn): runbook-aware per-turn attach_kwargs (v0.6.2) Restores LLM-driven per-turn attach so a runbook scenario like "1. say hello, 2. upload the file, 3. confirm" attaches file_path only on the upload turn, not every turn. Static always-forward (v0.6.1) was the wrong default - it forced the entrypoint to detect the upload moment from message text. Strengthens the driver prompt to: - treat the goal as a stepped runbook when it looks numbered, executing the next not-yet-completed step on each turn - explicitly map "this turn's step needs side-data" to emit the relevant key in attach_kwargs - include a worked 3-turn example so the LLM has a concrete pattern Also adds a server-side WARN that fires when a scenario text mentions a file path (regex heuristic) but the kwargs pool is empty - surfaces the mistake of writing the path in the description but not filling the File Path field, surfacing it in logs. Bumps VERSION to 0.6.2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: drop path-mention regex heuristic; LLM is the only mechanism Per review: don't try to second-guess the LLM driver with a regex over scenario text. If the customer hasn't populated file_path / available_kwargs the conversation just runs without side-data and the operator can read the existing pool-keys log line to diagnose. Removes _PATH_MENTION_RE, its WARN, and the associated test class. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(prompt): generalize runbook detection beyond numbered steps A runbook can be plain prose ("first do A, then B, finally C"), comma- separated ("say hi, upload, confirm"), or fully descriptive — not just numbered. Updates the goal block to instruct the driver to infer discrete actions and their order from any natural form. Swaps the worked example to a non-numbered runbook so the LLM doesn't anchor on numbering as the trigger for stepwise execution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tui): arrow keys move cursor in textareas; Ctrl+L clears the field Two scenario-editor UX bugs: 1. Up/Down arrows were intercepted at the editor level and used to switch fields. The textarea component already binds up/down to LinePrevious/ LineNext (textarea.go:41-42), so the user could never actually move the cursor inside multi-line fields. Removes the arrow-key bindings from field navigation; Tab / Shift+Tab continue to work for that. 2. No way to clear a textarea quickly. Adds Ctrl+L which clears the currently-focused multi-line field (Scenario, Expected Outcome, Available Kwargs JSON) in the scenario editor and the Business Context textarea. Ctrl+U / Ctrl+K stay bound to delete-before/after- cursor, matching shell semantics. Help text in both views updated to reflect the new bindings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tui): use Ctrl+X (not Ctrl+L) to clear textarea fields Ctrl+L is the global model selector (keyboard_controller.go:48), so the clear-field shortcut needs a different binding. Switched to Ctrl+X — free across the global router, the textarea component's keymap, and existing screen handlers; "X" carries an erase / cut-all mnemonic. Help text updated in both the scenario editor and the Business Context view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tui+driver): drop kwargs/file_path UI; LLM extracts both key+value TUI: - Removes File Path single-line input and Available Kwargs JSON textarea from the scenario editor. Customers describe everything in the runbook text; the LLM driver pulls structured side-data out of it. - Empty focused textarea now renders a cursor and pads to its full height (textarea View() previously collapsed to "" when both value and placeholder were empty), so an empty Scenario / Expected Outcome field visually indicates you can type there. - ScenarioData retains AvailableKwargs / FilePath for JSON-only / legacy carry-through (loader and POST builder still propagate them so existing scenarios.json files keep working as a fallback). Driver / runtime: - DriverMessageResult.attach_kwargs flips from List[str] (key names resolved against a pool) to Dict[str, Any] (literal key+value extracted by the LLM directly from the runbook text). - DRIVER_PROMPT now teaches the driver to extract values VERBATIM from the goal text on the relevant runbook step and emit them as a JSON object under attach_kwargs; empty {} on chit-chat / approval steps. - run_multi_turn forwards driver_result.attach_kwargs as-is, with Scenario.file_path / Scenario.available_kwargs acting only as a legacy-JSON fallback that the LLM extraction overrides per turn. Tests reworked for the new Dict-shaped attach_kwargs and in-prompt extraction guidance. 133 Python tests pass; TUI builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(logging): escape braces so business_context with {var} doesn't crash Loguru runs message.format(*args, **kwargs) on the log message whenever any kwargs are present — including extra={...}. Our codebase pervasively uses logger.X(f"... {val}", extra={...}) and val often carries user-controlled text (business_context with {customer_name} template variables, LLM-generated messages, object repr embedding such text). The literal {name} in the f-string-evaluated message is then interpreted as a placeholder and crashes with KeyError before any sink sees the record. Reproduction: business_context = "Hello {customer_name}" crashes the evaluation pipeline on any logger call that interpolates this value into an f-string and passes extra=. Fix: monkey-patch loguru._logger.Logger once at config-import time so each level method (trace, debug, info, success, warning, error, critical, exception) escapes { -> {{ and } -> }} in the message before delegating, only when args/kwargs are present (no-op for pure-fstring calls). Loguru's .format() unescapes them so the emitted message text is identical to the original. Patch is on the class so bind()-clones inherit it. Idempotent. Adds rogue/tests/test_safe_format_logging.py with 8 regression cases. 141 Python tests pass (133 + 8 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address branch-review feedback for v0.6.2 - run_multi_turn: per-turn precedence (LLM extraction suppresses legacy fallback for that turn). _resolve_per_turn_kwargs() helper extracted with TestResolvePerTurnKwargs covering all 4 quadrants + aliasing. - safe_format: try-format-first / escape-on-KeyError-or-IndexError so loguru's documented format-style API still works. Patches Logger.log. Tests pin the install side-effect and the format-style preservation. - prompts.py: driver worked-example reflowed to valid JSON, <example> tags instead of code fences. - textarea View only pads to full height when focused or has placeholder. - Up/Down arrows switch fields on non-textarea fields; fall through to cursor nav inside textareas via forwardToFocusedTextArea helper. - Scenario.file_path / available_kwargs Field descriptions marked legacy. 149 Python tests pass (was 141). TUI builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tui): pin textarea View output to t.Height + cap editor textarea size The scenario editor's two textareas were rendering at visibly different heights — Scenario huge / Expected Outcome tiny — even though both SetSize() calls used the same value. Two compounding causes: 1. textarea View() picked one of three rendering paths (placeholderView, viewport with content, "" for empty unfocused). Each produced a different total visible height because the lipgloss panel wrapping them had no explicit .Height() — natural sizing meant the panel sized to fit the content, so the empty + unfocused path collapsed to 3 lines while the non-empty viewport path filled however many lines the content + padding implied. Fixed: always wrap in .Height(t.Height) so every state renders at exactly t.Height visible lines. Empty textareas now also always go through placeholderView (which pads to height) so the box is visible even when unfocused with no placeholder. 2. The editor's per-textarea height was (availableHeight - usedHeight) / 2, which on a tall terminal landed at 25+ lines per field — a single short scenario looked lost in a huge box. Added a 10-line cap that keeps the form readable while still giving runbooks room. Net effect: both Scenario and Expected Outcome textareas always render at the same fixed visible height regardless of which is focused or how much content each holds. Full-height boxes appear immediately in Add New Scenario so the user can see where to type. TUI builds clean, go vet clean. No Python changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(tui): smart-quote auto-correction in View() docstring Pre-commit reformatter swapped \`\`t.Height\`\` for typographic quotes. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(driver): non-adversarial driver prompt for policy runs The multi-turn driver was always adversarial — pressuring refusals, escalating, invoking authority/emotion/deadlines. That made every policy scenario feel like a hostile interrogation even when the scenario author just wanted to verify a happy-path runbook ("greet, upload the file, approve"). Cooperative scenarios were polluted by the rogue's pushback tactics; agents under test would clamp down or refuse perfectly reasonable steps. Reframe: the runbook IS the script. The rogue walks through it play-by-play as a normal cooperative customer. Authors who want pressure-testing put it in the runbook explicitly ("ask for refund, then insist when refused, then threaten to leave"). Default behavior is honest, matter-of-fact runbook execution. Changes to DRIVER_PROMPT: - Persona intro: "real human customer ... NOT trying to trick the bot, pressure it, or test its limits". Drops "adversarially test". - Removed entire ## Tactics section (escalation, refusal-pushback, authority/emotion/deadline invocation, threaten-to-leave/manager). - Trimmed ## How a real person talks (DO): dropped the antagonistic registers (annoyed, skeptical, wheedling, mildly pushy, demanding) and the explicit "push back on refusals" guidance. Added explicit "if the bot refuses, accept calmly and move on UNLESS the runbook says to push back". - Kept ## What an AI sounds like (DON'T) verbatim — anti-AI-polite guidance is still useful regardless of adversarial framing. - Kept runbook stepwise framing and attach_kwargs extraction. Multi-turn driver is only used for policy mode; red-team / prompt- injection have separate code paths and are unaffected. New test asserts the prompt is non-adversarial: no "## Tactics", no "Escalate pressure", no "threaten to leave", no "invoke authority". Existing prompt tests untouched. 150 Python tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 50fa46d commit 8bf8173

15 files changed

Lines changed: 595 additions & 235 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.6.1
1+
0.6.2

packages/tui/internal/components/textarea.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -650,16 +650,18 @@ func (t *TextArea) Update(msg tea.Msg) (*TextArea, tea.Cmd) {
650650
return t, nil
651651
}
652652

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

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

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

685695
// renderLineWithCursor renders a line with the cursor positioned correctly

packages/tui/internal/screens/scenarios/business_context.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ func (e ScenarioEditor) handleBusinessContextMode(msg tea.KeyMsg) (ScenarioEdito
3535
e.bizContextSelected = true // Keep business context selected when exiting edit mode
3636
return e, nil
3737

38+
case "ctrl+x":
39+
// Clear the entire business context text area. (Ctrl+L is the
40+
// global model selector, so we use Ctrl+X for "erase".)
41+
if e.bizTextArea != nil {
42+
e.bizTextArea.SetValue("")
43+
}
44+
return e, nil
45+
3846
default:
3947
// Pass through to TextArea
4048
if e.bizTextArea != nil {
@@ -50,7 +58,7 @@ func (e ScenarioEditor) handleBusinessContextMode(msg tea.KeyMsg) (ScenarioEdito
5058
func (e ScenarioEditor) renderBusinessContextView(t theme.Theme) string {
5159
title := lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("\nEdit Business Context")
5260

53-
help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Esc save and exit Ctrl+S save Standard text editing keys")
61+
help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Esc save and exit Ctrl+X clear ↑↓ move cursor Standard text editing keys")
5462
errorLine := ""
5563
if e.errorMsg != "" {
5664
errorLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Error()).Render("⚠ " + e.errorMsg)

packages/tui/internal/screens/scenarios/edit.go

Lines changed: 85 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package scenarios
22

33
import (
4-
"encoding/json"
54
"errors"
65
"fmt"
76
"strconv"
@@ -17,11 +16,9 @@ import (
1716
const (
1817
editFieldScenario = 0
1918
editFieldExpectedOutcome = 1
20-
editFieldFilePath = 2
21-
editFieldKwargs = 3
22-
editFieldMultiTurnToggle = 4
23-
editFieldMaxTurns = 5
24-
editFieldSave = 6
19+
editFieldMultiTurnToggle = 2
20+
editFieldMaxTurns = 3
21+
editFieldSave = 4
2522
)
2623

2724
// handleEditMode handles keyboard input in edit/add mode
@@ -39,21 +36,54 @@ func (e ScenarioEditor) handleEditMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd)
3936
if e.expectedOutcomeTextArea != nil {
4037
e.expectedOutcomeTextArea.Blur()
4138
}
42-
if e.kwargsTextArea != nil {
43-
e.kwargsTextArea.Blur()
44-
}
4539
return e, nil
4640

47-
case "tab", "down":
41+
case "tab":
4842
e.currentField = e.nextField(e.currentField, +1)
4943
e.updateTextAreaFocus()
5044
return e, nil
5145

52-
case "shift+tab", "up":
46+
case "shift+tab":
5347
e.currentField = e.nextField(e.currentField, -1)
5448
e.updateTextAreaFocus()
5549
return e, nil
5650

51+
case "down":
52+
// On non-textarea fields (toggle, max-turns, save), arrows act as
53+
// field navigation. Inside textareas, the arrow is forwarded to
54+
// the textarea component for cursor movement.
55+
if !isTextAreaField(e.currentField) {
56+
e.currentField = e.nextField(e.currentField, +1)
57+
e.updateTextAreaFocus()
58+
return e, nil
59+
}
60+
return e.forwardToFocusedTextArea(msg)
61+
62+
case "up":
63+
if !isTextAreaField(e.currentField) {
64+
e.currentField = e.nextField(e.currentField, -1)
65+
e.updateTextAreaFocus()
66+
return e, nil
67+
}
68+
return e.forwardToFocusedTextArea(msg)
69+
70+
case "ctrl+x":
71+
// Clear the entire content of the focused text area. Only acts on
72+
// multi-line textareas — toggles / single-line buffers ignore it.
73+
// (Ctrl+L is the global model selector, Ctrl+U / Ctrl+K are the
74+
// textarea's delete-before/after-cursor.)
75+
switch e.currentField {
76+
case editFieldScenario:
77+
if e.scenarioTextArea != nil {
78+
e.scenarioTextArea.SetValue("")
79+
}
80+
case editFieldExpectedOutcome:
81+
if e.expectedOutcomeTextArea != nil {
82+
e.expectedOutcomeTextArea.SetValue("")
83+
}
84+
}
85+
return e, nil
86+
5787
case "ctrl+s":
5888
// Save via shortcut
5989
e.syncTextAreasToEditing()
@@ -76,45 +106,13 @@ func (e ScenarioEditor) handleEditMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd)
76106
if e.expectedOutcomeTextArea != nil {
77107
e.expectedOutcomeTextArea.Blur()
78108
}
79-
if e.kwargsTextArea != nil {
80-
e.kwargsTextArea.Blur()
81-
}
82109
return e, func() tea.Msg { return ScenarioEditorMsg{Action: "saved"} }
83110

84111
default:
85112
// Route by current field.
86113
switch e.currentField {
87-
case editFieldScenario:
88-
if e.scenarioTextArea != nil {
89-
updatedTextArea, taCmd := e.scenarioTextArea.Update(msg)
90-
*e.scenarioTextArea = *updatedTextArea
91-
return e, taCmd
92-
}
93-
case editFieldExpectedOutcome:
94-
if e.expectedOutcomeTextArea != nil {
95-
updatedTextArea, taCmd := e.expectedOutcomeTextArea.Update(msg)
96-
*e.expectedOutcomeTextArea = *updatedTextArea
97-
return e, taCmd
98-
}
99-
case editFieldFilePath:
100-
s := msg.String()
101-
switch s {
102-
case "backspace":
103-
if len(e.filePathBuffer) > 0 {
104-
e.filePathBuffer = e.filePathBuffer[:len(e.filePathBuffer)-1]
105-
}
106-
default:
107-
if len(s) == 1 {
108-
e.filePathBuffer += s
109-
}
110-
}
111-
return e, nil
112-
case editFieldKwargs:
113-
if e.kwargsTextArea != nil {
114-
updatedTextArea, taCmd := e.kwargsTextArea.Update(msg)
115-
*e.kwargsTextArea = *updatedTextArea
116-
return e, taCmd
117-
}
114+
case editFieldScenario, editFieldExpectedOutcome:
115+
return e.forwardToFocusedTextArea(msg)
118116
case editFieldMultiTurnToggle:
119117
switch msg.String() {
120118
case " ", "space", "enter", "x", "X":
@@ -161,16 +159,42 @@ func (e ScenarioEditor) handleEditMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd)
161159
if e.expectedOutcomeTextArea != nil {
162160
e.expectedOutcomeTextArea.Blur()
163161
}
164-
if e.kwargsTextArea != nil {
165-
e.kwargsTextArea.Blur()
166-
}
167162
return e, func() tea.Msg { return ScenarioEditorMsg{Action: "saved"} }
168163
}
169164
}
170165
return e, nil
171166
}
172167
}
173168

169+
// isTextAreaField reports whether the field at index `f` is rendered as a
170+
// multi-line textarea (where arrow keys must move the cursor inside the
171+
// text, not switch fields).
172+
func isTextAreaField(f int) bool {
173+
return f == editFieldScenario || f == editFieldExpectedOutcome
174+
}
175+
176+
// forwardToFocusedTextArea sends the given key message to whichever
177+
// textarea owns the currently-focused field. Used to plumb arrow keys
178+
// (and similar) through to the textarea component without re-entering
179+
// the editor's outer keyboard switch.
180+
func (e ScenarioEditor) forwardToFocusedTextArea(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd) {
181+
switch e.currentField {
182+
case editFieldScenario:
183+
if e.scenarioTextArea != nil {
184+
updatedTextArea, taCmd := e.scenarioTextArea.Update(msg)
185+
*e.scenarioTextArea = *updatedTextArea
186+
return e, taCmd
187+
}
188+
case editFieldExpectedOutcome:
189+
if e.expectedOutcomeTextArea != nil {
190+
updatedTextArea, taCmd := e.expectedOutcomeTextArea.Update(msg)
191+
*e.expectedOutcomeTextArea = *updatedTextArea
192+
return e, taCmd
193+
}
194+
}
195+
return e, nil
196+
}
197+
174198
// multiTurnOnEdit reports whether the currently-editing scenario has multi-turn on
175199
// (the JSON-null sentinel means "not set yet" and defaults to on).
176200
func (e *ScenarioEditor) multiTurnOnEdit() bool {
@@ -197,9 +221,9 @@ func (e *ScenarioEditor) nextField(cur, step int) int {
197221

198222
func (e *ScenarioEditor) numFields() int {
199223
if e.multiTurnOnEdit() {
200-
return 7
224+
return 5
201225
}
202-
return 6
226+
return 4
203227
}
204228

205229
// updateTextAreaFocus manages focus between TextAreas based on currentField
@@ -218,13 +242,6 @@ func (e *ScenarioEditor) updateTextAreaFocus() {
218242
e.expectedOutcomeTextArea.Blur()
219243
}
220244
}
221-
if e.kwargsTextArea != nil {
222-
if e.currentField == editFieldKwargs {
223-
e.kwargsTextArea.Focus()
224-
} else {
225-
e.kwargsTextArea.Blur()
226-
}
227-
}
228245
}
229246

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

257-
// File path single-line buffer. Empty -> nil pointer (omitted from JSON).
258-
trimmedFilePath := strings.TrimSpace(e.filePathBuffer)
259-
if trimmedFilePath == "" {
260-
e.editing.FilePath = nil
261-
} else {
262-
e.editing.FilePath = &trimmedFilePath
263-
}
264-
265-
// Parse the kwargs JSON textarea. Empty string -> nil map.
266-
if e.kwargsTextArea != nil {
267-
raw := strings.TrimSpace(e.kwargsTextArea.GetValue())
268-
if raw == "" {
269-
e.editing.AvailableKwargs = nil
270-
} else {
271-
var parsed map[string]any
272-
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
273-
return fmt.Errorf("available kwargs must be valid JSON object: %v", err)
274-
}
275-
e.editing.AvailableKwargs = parsed
276-
}
277-
}
278-
279274
// Apply multi-turn + max_turns defaults and bounds.
280275
mt := e.multiTurnOnEdit()
281276
e.editing.MultiTurn = &mt
@@ -323,9 +318,6 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
323318
usedHeight += 2 // title (1 line) + blank line
324319
usedHeight += 2 // scenario label + blank
325320
usedHeight += 2 // expected outcome label + blank
326-
usedHeight += 2 // file path line + blank
327-
usedHeight += 2 // kwargs label + blank
328-
usedHeight += 5 // kwargs textarea (fixed-height)
329321
usedHeight += 2 // multi-turn + blank
330322
if multiTurnOn {
331323
usedHeight += 2 // max-turns line + blank
@@ -340,6 +332,15 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
340332
if textAreaHeight < 4 {
341333
textAreaHeight = 4
342334
}
335+
// Cap the per-textarea height so a tall terminal doesn't blow up the
336+
// box to 25+ lines for a one-line scenario. 10 lines is plenty for
337+
// typical "say hi, then upload, then approve" runbooks while still
338+
// leaving room for both fields plus the toggle / max-turns / save
339+
// rows below.
340+
const maxTextAreaHeight = 10
341+
if textAreaHeight > maxTextAreaHeight {
342+
textAreaHeight = maxTextAreaHeight
343+
}
343344

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

372-
// Field 2: file path (PYTHON protocol convenience — auto-merged into kwargs pool)
373-
filePathLabelText := "File Path (PYTHON only, default 'file_path' kwarg)"
374-
filePathBuf := e.filePathBuffer
375-
if filePathBuf == "" {
376-
filePathBuf = "_"
377-
}
378-
filePathRaw := fmt.Sprintf("%s: [ %s ]", filePathLabelText, filePathBuf)
379-
var filePathLine string
380-
if e.currentField == editFieldFilePath {
381-
filePathLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("▶ " + filePathRaw)
382-
} else {
383-
filePathLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Text()).Render(" " + filePathRaw)
384-
}
385-
386-
// Field 3: available kwargs (JSON, PYTHON protocol only)
387-
kwargsLabel := "Available Kwargs (PYTHON only, JSON object)"
388-
if e.currentField == editFieldKwargs {
389-
kwargsLabel = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("▶ Available Kwargs (PYTHON only, JSON object)")
390-
}
391-
392-
var kwargsText string
393-
if e.kwargsTextArea != nil {
394-
e.kwargsTextArea.SetSize(e.width-4, 5)
395-
kwargsText = e.kwargsTextArea.View()
396-
} else {
397-
kwargsText = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Text()).Render("TextArea not available")
398-
}
399-
400-
// Field 3: multi-turn toggle
373+
// Field 2: multi-turn toggle
401374
multiTurnCheckbox := "[ ]"
402375
if multiTurnOn {
403376
multiTurnCheckbox = "[x]"
@@ -430,7 +403,7 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
430403
saveLabel = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Primary()).Bold(true).Render("▶ Save")
431404
}
432405

433-
help := lipgloss.NewStyle().Background(t.Background()).Foreground(t.TextMuted()).Render("Tab/↑↓ switch fields Space toggle Ctrl+S save Esc cancel")
406+
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")
434407
errorLine := ""
435408
if e.errorMsg != "" {
436409
errorLine = lipgloss.NewStyle().Background(t.Background()).Foreground(t.Error()).Render("⚠ " + e.errorMsg)
@@ -442,10 +415,6 @@ func (e ScenarioEditor) renderEditView(t theme.Theme) string {
442415
parts = append(parts, "")
443416
parts = append(parts, outLabel, outText)
444417
parts = append(parts, "")
445-
parts = append(parts, filePathLine)
446-
parts = append(parts, "")
447-
parts = append(parts, kwargsLabel, kwargsText)
448-
parts = append(parts, "")
449418
parts = append(parts, multiTurnLine)
450419
if multiTurnOn {
451420
parts = append(parts, maxTurnsLine)

0 commit comments

Comments
 (0)