Skip to content

Commit 03fa661

Browse files
committed
fix(hitl): complete HITL interrupt rendering pipeline for Go TUI
- Fix options format mismatch: normalize dict options to strings in component_mapper before sending to Go TUI select component - Fix GraphInterrupt callback: suppress interrupt exceptions in protocol_callback so TUI shows checkmark instead of error icon - Fix cancel/error resume: check action field in component_response before resuming graph, return None for cancel/error actions - Fix Ctrl+C trapped: global ctrl+c handler fires before overlay component key interception, cancels active component first - Fix inline flow text visibility: flush streamBuf to scrollback before rendering HITL component; collect flushed assistant message for tea.Println in TypeDone handler (was silently stored in state) - Fix threshold slider invisible: auto-derive step=(max-min)/100 when Python omits step field instead of failing Init - Fix cell type selector Enter: Enter on last cluster auto-submits; on non-last clusters advances to next (like Tab) - Improve ask_user tool docstring with component selection examples - Bump version to 1.1.403
1 parent d88cda2 commit 03fa661

File tree

10 files changed

+156
-26
lines changed

10 files changed

+156
-26
lines changed

lobster-tui/internal/biocomp/celltype/celltype.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,15 @@ func (c *CellTypeSelectorComponent) handleEditMode(km tea.KeyPressMsg) *biocomp.
125125
c.exitEditMode()
126126
return c.submitAll()
127127
case "enter":
128-
// Accept current input and exit edit mode.
128+
// Accept current input. On the last cluster, auto-submit.
129+
// On non-last clusters, move to the next one (like tab).
129130
c.exitEditMode()
131+
if c.cursor >= len(c.clusters)-1 {
132+
return c.submitAll()
133+
}
134+
c.cursor++
135+
c.adjustOffset()
136+
c.enterEditMode()
130137
return nil
131138
default:
132139
// Forward to the active textinput.

lobster-tui/internal/biocomp/threshold/threshold.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ func (c *ThresholdSliderComponent) Init(data json.RawMessage) error {
4242
return fmt.Errorf("threshold_slider: min (%g) must be less than max (%g)", d.Min, d.Max)
4343
}
4444
if d.Step <= 0 {
45-
return fmt.Errorf("threshold_slider: step must be positive, got %g", d.Step)
45+
// Python mapper often omits step. Auto-derive a sensible default.
46+
d.Step = (d.Max - d.Min) / 100
47+
if d.Step <= 0 {
48+
d.Step = 0.01
49+
}
4650
}
4751
c.data = d
4852
c.value = clamp(d.Default, d.Min, d.Max)

lobster-tui/internal/biocomp/threshold/threshold_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,19 @@ func TestThresholdInitInvalid(t *testing.T) {
7979
t.Error("expected error for min == max")
8080
}
8181

82-
// Step <= 0
82+
// Step <= 0 should auto-derive (not error) — Python mapper omits step.
8383
data, _ = json.Marshal(map[string]any{
8484
"label": "test", "min": 0.0, "max": 1.0, "step": 0.0, "default": 0.5,
8585
})
86-
if err := c.Init(data); err == nil {
87-
t.Error("expected error for step <= 0")
86+
if err := c.Init(data); err != nil {
87+
t.Errorf("step=0 should auto-derive, got error: %v", err)
8888
}
8989

9090
data, _ = json.Marshal(map[string]any{
9191
"label": "test", "min": 0.0, "max": 1.0, "step": -0.1, "default": 0.5,
9292
})
93-
if err := c.Init(data); err == nil {
94-
t.Error("expected error for negative step")
93+
if err := c.Init(data); err != nil {
94+
t.Errorf("step<0 should auto-derive, got error: %v", err)
9595
}
9696
}
9797

lobster-tui/internal/chat/content.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,16 @@ func (m *Model) flushStreamBuffer() {
127127
m.streamBuf.Reset()
128128
m.appendBlock(BlockText{Text: text})
129129
}
130+
131+
// collectLastAssistantMessage returns a single-element slice containing the
132+
// last assistant message, or nil if none exists. Used by the TypeDone handler
133+
// to feed inlinePrintMessagesCmd after flushStreamBuffer has put text into
134+
// m.messages in-place (which otherwise never gets tea.Println'd).
135+
func (m *Model) collectLastAssistantMessage() []ChatMessage {
136+
for i := len(m.messages) - 1; i >= 0; i-- {
137+
if m.messages[i].Role == "assistant" {
138+
return []ChatMessage{m.messages[i]}
139+
}
140+
}
141+
return nil
142+
}

lobster-tui/internal/chat/model.go

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -613,8 +613,10 @@ func (m Model) viewInline() tea.View {
613613
b.WriteByte('\n')
614614
}
615615

616-
// Inline BioComp component (rendered as a block above the composer).
617-
if m.activeComponent != nil && m.activeComponent.Component.Mode() == "inline" {
616+
// BioComp component (rendered as a block above the composer).
617+
// In inline TUI mode, all components render inline regardless of their
618+
// declared mode — there is no overlay layer to composite onto.
619+
if m.activeComponent != nil {
618620
comp := m.activeComponent.Component
619621
inlineView, panicked := safeView(comp, m.width, m.height)
620622
if panicked {
@@ -760,21 +762,35 @@ func (m Model) handleProtocol(msg protocolMsg) (tea.Model, tea.Cmd) {
760762
return m, waitForProtocolMsg(m.handler)
761763
}
762764

765+
if dp.Summary == "interrupt" {
766+
// HITL interrupt: flush streamed text to scrollback so the
767+
// supervisor's question is visible while the component renders.
768+
// Do NOT clear tool feed or reset streaming state — the tool
769+
// is still logically running (paused at interrupt).
770+
m.flushStreamBuffer()
771+
toPrint := m.collectLastAssistantMessage()
772+
m.rebuildViewportWithMode(true)
773+
printCmd := m.inlinePrintMessagesCmd(toPrint)
774+
return m, tea.Batch(waitForProtocolMsg(m.handler), printCmd)
775+
}
776+
763777
// Flush any remaining streamed text into typed blocks, then append
764778
// buffered handoff lines that belong directly beneath the response.
765779
m.flushStreamBuffer()
766780

767-
toAppend := make([]ChatMessage, 0, len(m.pendingHandoffs))
781+
// Collect the finalized assistant message for printing. In inline
782+
// flow mode, flushStreamBuffer puts text into m.messages in-place
783+
// but nothing prints it to scrollback — we must do that explicitly.
784+
toPrint := m.collectLastAssistantMessage()
768785
if len(m.pendingHandoffs) > 0 {
769-
toAppend = append(toAppend, m.pendingHandoffs...)
786+
m.messages = append(m.messages, m.pendingHandoffs...)
787+
toPrint = append(toPrint, m.pendingHandoffs...)
770788
m.pendingHandoffs = m.pendingHandoffs[:0]
771789
}
772790
m.isStreaming = false
773791
m.isCanceling = false
774-
printCmd := m.appendMessages(toAppend, true)
775-
if len(toAppend) == 0 {
776-
m.rebuildViewportWithMode(true)
777-
}
792+
m.rebuildViewportWithMode(true)
793+
printCmd := m.inlinePrintMessagesCmd(toPrint)
778794
return m, tea.Batch(waitForProtocolMsg(m.handler), printCmd)
779795

780796
case protocol.TypeStatus:
@@ -1029,6 +1045,16 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
10291045
return m, mouseCaptureCmd(m.mouseCapture)
10301046
}
10311047

1048+
// Global keys that must ALWAYS work, even with an active component.
1049+
if msg.String() == "ctrl+c" && m.activeComponent != nil {
1050+
m.sendComponentResponse(m.activeComponent.MsgID, "cancel", map[string]any{"reason": "ctrl+c"})
1051+
m.activeComponent = nil
1052+
m.pendingChangeEvent = nil
1053+
m.changeDebounceActive = false
1054+
m.recalculateViewportHeight()
1055+
// Fall through to the normal ctrl+c handler below.
1056+
}
1057+
10321058
// Intercept keys for active BioComp overlay component.
10331059
if m.activeComponent != nil && m.activeComponent.Component.Mode() == "overlay" {
10341060
result, panicked := safeHandleMsg(m.activeComponent.Component, msg)
@@ -1912,17 +1938,25 @@ func (m Model) layoutReservedRowsLegacy() int {
19121938
if tf := renderToolFeed(m.toolFeed, m.styles, m.width, m.inline); tf != "" {
19131939
rows += lineCount(tf)
19141940
}
1915-
if m.activeComponent != nil && m.activeComponent.Component.Mode() == "inline" {
1916-
inlineView := m.activeComponent.Component.View(m.width, m.height)
1917-
if inlineView != "" {
1918-
rows += lineCount(inlineView)
1941+
if m.activeComponent != nil {
1942+
if m.inline {
1943+
// In inline mode, all components render as inline blocks.
1944+
inlineView := m.activeComponent.Component.View(m.width, m.height)
1945+
if inlineView != "" {
1946+
rows += lineCount(inlineView)
1947+
}
1948+
} else if m.activeComponent.Component.Mode() == "inline" {
1949+
inlineView := m.activeComponent.Component.View(m.width, m.height)
1950+
if inlineView != "" {
1951+
rows += lineCount(inlineView)
1952+
}
19191953
}
19201954
}
19211955
if m.progressActive {
19221956
rows += lineCount(renderProgressBar(m.progressLabel, m.progressCurrent, m.progressTotal, m.width, m.styles))
19231957
}
19241958

1925-
if m.activeComponent != nil && m.activeComponent.Component.Mode() == "overlay" {
1959+
if m.activeComponent != nil && m.activeComponent.Component.Mode() == "overlay" && !m.inline {
19261960
_, oh := biocomp.OverlaySize(m.width, m.height, "small")
19271961
rows += oh
19281962
} else if m.pendingConfirm != nil {
@@ -2048,9 +2082,25 @@ func (m *Model) recalculateComposerDimensions() {
20482082
func (m *Model) recalculateComposerHeight() {
20492083
height := m.composerHeightForValue(m.input.Value())
20502084
if m.input.Height() == height {
2085+
// Height unchanged, but check if content fits and scroll is off.
2086+
if height > 0 && m.input.ScrollYOffset() > 0 {
2087+
totalVisual := m.composerHeightForValue(m.input.Value())
2088+
if totalVisual <= height {
2089+
m.input.MoveToBegin()
2090+
m.input.MoveToEnd()
2091+
}
2092+
}
20512093
return
20522094
}
20532095
m.input.SetHeight(height)
2096+
// When all content fits in the new height, reset scroll to show
2097+
// everything from the top. MoveToBegin zeroes the viewport offset;
2098+
// MoveToEnd restores the cursor to where the user is typing.
2099+
totalVisual := m.composerHeightForValue(m.input.Value())
2100+
if totalVisual <= height && m.input.ScrollYOffset() > 0 {
2101+
m.input.MoveToBegin()
2102+
m.input.MoveToEnd()
2103+
}
20542104
}
20552105

20562106
func (m Model) composerHeightForValue(value string) int {

lobster/cli_internal/go_tui_launcher.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,11 @@ def _handle_user_query(
844844
return
845845

846846
# --- HITL interrupt: render component and await user response ---
847+
# Flush any streamed text to scrollback BEFORE rendering the
848+
# component so the supervisor's question is visible while the
849+
# user interacts with the HITL widget.
850+
bridge.send("done", {"summary": "interrupt"})
851+
847852
response = _handle_interrupt(bridge, interrupt_event)
848853
if response is None:
849854
# Interrupt was cancelled or timed out.
@@ -904,8 +909,14 @@ def _handle_interrupt(bridge: _LightBridge, interrupt_event: dict) -> Optional[d
904909
"selected": payload.get("value", ""),
905910
"index": payload.get("index", 0),
906911
}
907-
# component_response → pass through data
908-
return payload.get("data", payload)
912+
# component_response → check action before accepting.
913+
data = payload.get("data", payload)
914+
action = data.get("action", "submit") if isinstance(data, dict) else "submit"
915+
if action in ("cancel", "error"):
916+
# Component was cancelled or failed to render — don't resume
917+
# the graph with garbage data.
918+
return None
919+
return data
909920

910921
if resp_type == "quit":
911922
return None

lobster/services/interaction/component_mapper.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,17 @@ def map_question(
6060
# Explicit options list -> select component.
6161
options = ctx.get("options")
6262
if isinstance(options, list) and len(options) > 0:
63+
# Normalize dicts to strings — Go TUI select expects []string.
64+
str_options = [
65+
opt.get("label", opt.get("name", str(opt)))
66+
if isinstance(opt, dict)
67+
else str(opt)
68+
for opt in options
69+
]
6370
return ComponentSelection(
6471
component="select",
65-
data={"question": question, "options": options},
66-
fallback_prompt=_build_select_fallback(question, options),
72+
data={"question": question, "options": str_options},
73+
fallback_prompt=_build_select_fallback(question, str_options),
6774
)
6875

6976
# Cluster data -> cell_type_selector.

lobster/tools/user_interaction.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,22 @@ def create_ask_user_tool(llm: Optional["BaseChatModel"] = None):
2828

2929
@tool
3030
def ask_user(question: str, context: Optional[Dict[str, Any]] = None) -> str:
31-
"""Ask the user a clarification question. Renders an interactive component."""
31+
"""Pause execution and ask the user a question. Use ONLY when user input
32+
is genuinely required (ambiguous intent, approval gate, or domain choice).
33+
The question might come from a sub-agent that needs user input and asks you to provide this to them
34+
35+
Pass structured context for automatic component selection:
36+
- {"options": ["A", "B", "C"]} → dropdown select
37+
- {"clusters": [{id, size, markers}]} → cell-type annotation panel
38+
- {"min": 0, "max": 1, "threshold": 0.05} → threshold slider
39+
- (no context or confirm phrasing) → yes/no or free-text
40+
41+
Args:
42+
question: Clear, specific question for the user.
43+
context: Structured hints for component selection (see above).
44+
45+
Returns:
46+
JSON string with the user's response."""
3247
from langgraph.types import interrupt
3348

3449
from lobster.services.interaction.component_mapper import map_question

lobster/ui/callbacks/protocol_callback.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,29 @@ def on_tool_error(
163163
if tool_name == "unknown_tool":
164164
self.current_tool = None
165165
return
166+
167+
# GraphInterrupt/Interrupt is control flow (HITL interrupt), not an error.
168+
# Emit as "complete" so the TUI shows ✓ instead of ✗.
169+
# Check full MRO to catch subclasses (GraphInterrupt → GraphBubbleUp).
170+
_interrupt_names = {"GraphInterrupt", "Interrupt", "GraphBubbleUp"}
171+
error_mro = {cls.__name__ for cls in type(error).__mro__}
172+
if _interrupt_names & error_mro:
173+
self._emit(
174+
"tool_execution",
175+
{
176+
"tool": tool_name,
177+
"agent": (
178+
str(run_state.get("agent"))
179+
if run_state is not None
180+
else self.current_agent or "unknown"
181+
),
182+
"status": "complete",
183+
"summary": "awaiting user input",
184+
"tool_call_id": run_key,
185+
},
186+
)
187+
return
188+
166189
self._emit(
167190
"tool_execution",
168191
{

lobster/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.1.401"
1+
__version__ = "1.1.403"

0 commit comments

Comments
 (0)