Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0265b19
Add conversation history drawer and per-conversation code state
jlee-kitware Jun 25, 2026
e3dc15a
Snapshot hand edits into undo/redo history (debounced)
jlee-kitware Jun 25, 2026
8f53de6
Make undo/redo editor-only (do not replay the script)
jlee-kitware Jun 25, 2026
1af8f15
Disable Run when the editor matches the rendered scene
jlee-kitware Jun 25, 2026
b9db7b4
Make the history drawer a temporary overlay
jlee-kitware Jun 25, 2026
a028b05
Fix history drawer to truly overlay (clear permanent)
jlee-kitware Jun 25, 2026
8be37aa
Move the prompt box below the generated code (Claude-style)
jlee-kitware Jun 25, 2026
8e3eb24
Add New conversation to drawer top; drop prompt nav arrows
jlee-kitware Jun 25, 2026
c0e46eb
User-controlled history drawer, smaller New button, silence ResizeObs…
jlee-kitware Jun 25, 2026
b51ea37
Slim down the New conversation button
jlee-kitware Jun 25, 2026
6eab23e
Move New to a + icon in the Recents header
jlee-kitware Jun 25, 2026
9421e03
Show Recents by default; disable New on a fresh entry
jlee-kitware Jun 25, 2026
b13d8a5
Refine the current editor code, not the model's last output
jlee-kitware Jun 25, 2026
0ea02f0
Reset the model context when starting a new conversation
jlee-kitware Jun 25, 2026
fe8fde1
Unify code history into one per-conversation timeline
jlee-kitware Jun 26, 2026
ac8ce2a
Add sessions model: multiple conversations with non-destructive New
jlee-kitware Jun 26, 2026
dbab5f3
Replace "Generate Code" button with an inline send arrow
jlee-kitware Jun 26, 2026
5b3aeb5
Persist sessions to disk and reopen the most recent on startup
jlee-kitware Jun 26, 2026
a3785b5
Recents: show a conversation's turns, restore sort/favorites, stop re…
jlee-kitware Jun 26, 2026
9454d11
Sessions: pin-to-top, rename, and delete via a per-row menu
jlee-kitware Jun 26, 2026
0a0378f
Clear the prompt box on send; show the prompt inline with the explana…
jlee-kitware Jun 26, 2026
d27b063
Turn the Explanation pane into a clickable Conversation transcript
jlee-kitware Jun 26, 2026
a97876f
Revisiting a turn lands on its latest code, not the original generation
jlee-kitware Jun 26, 2026
9650893
Show the model's working steps as a collapsible trace per turn
jlee-kitware Jun 27, 2026
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
276 changes: 252 additions & 24 deletions src/vtk_prompt/controllers/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,70 @@ def on_conversation_file_data_change(
app.generate_code()


def _checkpoints(app: Any) -> list:
"""Turn index -> position on the single per-conversation code timeline.

Server-side backing data (not UI state). Each conversation has one linear
code history covering both LLM generations and manual edits; a checkpoint
anchors each turn to the version that turn produced.
"""
if not hasattr(app, "_conversation_checkpoints"):
app._conversation_checkpoints = []
return app._conversation_checkpoints


def record_turn_checkpoint(app: Any) -> None:
"""Anchor the newest turn to the current code version (one per turn)."""
nav = app.state.conversation_navigation or []
cps = _checkpoints(app)
pos = app.state.code_history_pos
if len(cps) > len(nav):
del cps[len(nav):]
while len(cps) < len(nav):
cps.append(pos)
if nav:
cps[-1] = pos


def sync_editor_code_into_conversation(app: Any) -> None:
"""Make the latest assistant turn reflect the current editor code.

A refinement prompt should mutate the code that is actually on screen,
including manual edits, rather than the model's previous output. Rewrite the
<code> block of the most recent assistant message to the current editor
contents (minus the display-only renderer banner) so the model refines from
there. Only applies when viewing the most recent turn.
"""
convo = getattr(app.prompt_client, "conversation", None)
if not convo:
return
nav = app.state.conversation_navigation or []
# Only sync when refining the most recent turn (not history, not new entry).
if not nav or app.state.conversation_index != len(nav) - 1:
return
code = app.state.generated_code or ""
prefix = EXPLAIN_RENDERER + "\n"
if code.startswith(prefix):
code = code[len(prefix):]
code = code.strip()
if not code:
return
for msg in reversed(convo):
if msg.get("role") == "assistant":
content = msg.get("content", "")
if "<code>" in content and "</code>" in content:
msg["content"] = re.sub(
r"<code>.*?</code>",
lambda _m: "<code>" + code + "</code>",
content,
count=1,
flags=re.DOTALL,
)
else:
msg["content"] = content + "\n<code>" + code + "</code>"
return


def navigate_conversation_left(app: Any) -> None:
"""Navigate to previous conversation pair."""
if not app.state.conversation_navigation:
Expand Down Expand Up @@ -86,6 +150,54 @@ def navigate_conversation_right(app: Any) -> None:
_update_navigation_state(app)


def navigate_to_conversation(app: Any, target_index: int) -> None:
"""Navigate directly to a specific conversation pair by index."""
if not app.state.conversation_navigation:
return
if target_index < 0 or target_index >= len(app.state.conversation_navigation):
return
app.state.conversation_index = target_index
_process_conversation_pair(app, target_index)
_update_navigation_state(app)


def start_new_conversation(app: Any) -> None:
"""Start a brand-new conversation: reset the model context and the thread.

"New" means unrelated to what came before, so we clear the client's
conversation (the LLM context) and the file pointer (else the next query
would reload it), along with the displayed thread, per-conversation code
state, and the editor. The prior conversation is discarded from the UI;
preserving multiple conversations is what the sessions model would add.
"""
if app.prompt_client:
app.prompt_client.conversation = []
app.prompt_client.conversation_file = None
app._conversation_checkpoints = []
app.state.conversation = []
app.state.conversation_navigation = []
app.state.conversation_index = 0
app.state.conversation_file = None
app.state.query_text = ""
app.state.generated_code = ""
app.state.generated_explanation = ""
app.state.code_history = []
app.state.code_history_labels = []
app.state.code_history_pos = -1
_update_navigation_state(app)


def toggle_favorite_conversation(app: Any, conversation_index: int) -> None:
"""Toggle whether a conversation pair (by index) is favorited."""
favorites = list(getattr(app.state, "favorited_conversations", None) or [])
if conversation_index in favorites:
favorites.remove(conversation_index)
else:
favorites.append(conversation_index)
# Replace the whole list so trame detects the state change.
app.state.favorited_conversations = favorites


def save_conversation(app: Any) -> str:
"""Save current conversation history as JSON string."""
if hasattr(app, "prompt_client") and app.prompt_client is not None:
Expand All @@ -106,31 +218,121 @@ def _parse_assistant_content(content: str) -> tuple[str | None, str | None]:
return None, None


def _clean_prompt(user_content: str) -> str:
"""Strip the extra-instructions wrapper and a leading 'Request:' for display."""
content = (user_content or "").strip()
if EXTRA_INSTRUCTIONS_TAG in content:
content = content.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip()
if content[:8].lower() == "request:":
content = content[8:].strip()
return content


def _is_real_prompt(content: str) -> bool:
"""Return True for a real user turn (not a synthetic retry/error message)."""
content = (content or "").strip()
return (EXTRA_INSTRUCTIONS_TAG in content) or content[:8].lower() == "request:"


def _summarize(text: str, limit: int = 160) -> str:
text = " ".join((text or "").split())
return text if len(text) <= limit else text[:limit] + "..."


def _build_trace(body: list, final_idx: int | None) -> list:
"""Collect the model's working steps for a turn: tool calls, results, retries.

``body`` is the messages between a real user prompt and the next one;
``final_idx`` is the index in ``body`` of the turn's final response, which is
excluded (it is shown as the explanation, not part of the trace).
"""
results: dict[str, str] = {}
for message in body:
if message.get("role") == "tool" and message.get("tool_call_id"):
results[message["tool_call_id"]] = message.get("content", "")

steps: list[dict] = []
for i, message in enumerate(body):
role = message.get("role")
if role == "assistant" and message.get("tool_calls"):
for call in message["tool_calls"]:
fn = call.get("function", {})
steps.append({
"kind": "tool",
"name": fn.get("name", "tool"),
"detail": _summarize(fn.get("arguments", ""), 120),
"result": _summarize(results.get(call.get("id", ""), ""), 200),
})
elif role == "user":
steps.append({
"kind": "note", "name": "Retry",
"detail": _summarize(message.get("content", "")), "result": "",
})
elif role == "assistant" and i != final_idx:
explanation, _ = _parse_assistant_content(message.get("content", ""))
text = explanation or re.sub(
r"</?(explanation|code)>", "", message.get("content", "")
)
if text.strip():
steps.append({
"kind": "note", "name": "Attempt",
"detail": _summarize(text), "result": "",
})
return steps


def build_conversation_navigation(app: Any) -> None:
"""Build list of conversation pairs (user message + assistant response) for navigation."""
if not app.state.conversation:
"""Build per-turn navigation pairs, each with its prompt, explanation, and trace.

The stored conversation interleaves a real prompt with the model's tool calls,
tool results, and any retry messages before the final response. Turns are
delimited by real prompts; everything in between becomes that turn's trace,
and the last response in the span is the turn's answer.
"""
msgs = app.state.conversation or []
if not msgs:
app.state.conversation_navigation = []
app.state.conversation_index = 0
_update_navigation_state(app)
return

pairs = []
current_user = None
starts = [
i for i, m in enumerate(msgs)
if m.get("role") == "user" and _is_real_prompt(m.get("content", ""))
]
if not starts: # plain conversations without wrapper markers: every user msg
starts = [i for i, m in enumerate(msgs) if m.get("role") == "user"]

for message in app.state.conversation:
if message.get("role") == "user":
current_user = message
elif message.get("role") == "assistant" and current_user:
pairs.append({"user": current_user, "assistant": message})
current_user = None
pairs = []
for si, start in enumerate(starts):
end = starts[si + 1] if si + 1 < len(starts) else len(msgs)
user_msg = msgs[start]
body = msgs[start + 1:end]

final_idx = None
for j in range(len(body) - 1, -1, -1):
if body[j].get("role") == "assistant" and not body[j].get("tool_calls"):
final_idx = j
break
if final_idx is None:
for j in range(len(body) - 1, -1, -1):
if body[j].get("role") == "assistant":
final_idx = j
break
final_msg = (
body[final_idx] if final_idx is not None else {"role": "assistant", "content": ""}
)
explanation, _ = _parse_assistant_content(final_msg.get("content", ""))
pairs.append({
"user": user_msg,
"assistant": final_msg,
"prompt": _clean_prompt(user_msg.get("content", "")),
"explanation": explanation or "",
"trace": _build_trace(body, final_idx),
})

app.state.conversation_navigation = pairs
# Reset index to last pair if we have pairs
if pairs:
app.state.conversation_index = len(pairs) - 1
else:
app.state.conversation_index = 0

app.state.conversation_index = len(pairs) - 1 if pairs else 0
_update_navigation_state(app)


Expand Down Expand Up @@ -170,23 +372,49 @@ def _process_conversation_pair(app: Any, pair_index: int | None = None) -> None:
assistant_content = pair["assistant"].get("content", "")
explanation, code = _parse_assistant_content(assistant_content)

if explanation and code:
# Set explanation and code in UI state
if explanation:
app.state.generated_explanation = explanation
app.state.generated_code = EXPLAIN_RENDERER + "\n" + code

# Execute the code to display visualization
app._execute_with_renderer(code)

# Process user message for query text
# Process user message for query text (also used as a timeline label).
user_content = pair["user"].get("content", "").strip()
if EXTRA_INSTRUCTIONS_TAG in user_content:
parts = user_content.split(EXTRA_INSTRUCTIONS_TAG, 1)
query_text = parts[1].strip() if len(parts) > 1 else user_content
else:
query_text = user_content
app.state.current_prompt = query_text

# Jump the single per-conversation code timeline to this turn. Land on the
# latest version belonging to the turn: its generation plus any manual edits
# made while on it. That is the last position before the next turn's anchor
# (or the end of history for the most recent turn), so revisiting a turn no
# longer reverts to its original generated code.
cps = _checkpoints(app)
history = app.state.code_history or []
if pair_index < len(cps) and 0 <= cps[pair_index] < len(history):
start = cps[pair_index]
target = len(history) - 1
for later in cps[pair_index + 1:]:
if later > start:
target = later - 1
break
if target < start:
target = start
app.state.code_history_pos = target
app.state.generated_code = history[target]
elif code:
# No anchored version yet (e.g. a freshly loaded conversation): seed one
# on the timeline and anchor this turn to it.
from .generation import push_code_snapshot

app.state.generated_code = EXPLAIN_RENDERER + "\n" + code
push_code_snapshot(app, app.state.generated_code, label=query_text)
while len(cps) <= pair_index:
cps.append(app.state.code_history_pos)
cps[pair_index] = app.state.code_history_pos

app.state.query_text = query_text
if app.state.generated_code:
app._execute_with_renderer(app.state.generated_code)


def _process_loaded_conversation(
Expand Down
Loading
Loading