Merge pull request #74 from patherly/feat/command-output-capture
feat: command output capture for troubleshooting sessions
This commit was merged in pull request #74.
This commit is contained in:
@@ -133,6 +133,10 @@ def _create_node_from_original(
|
||||
new_node["action"] = original_node.get("action", "")
|
||||
if decision and decision.get("action_performed"):
|
||||
new_node["action"] = decision["action_performed"]
|
||||
if decision and decision.get("command_output"):
|
||||
output = decision["command_output"].strip()
|
||||
if output:
|
||||
new_node["action"] += f"\n\nCommand Output:\n{output}"
|
||||
elif node_type == "solution":
|
||||
new_node["solution"] = original_node.get("solution", "")
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class DecisionRecord(BaseModel):
|
||||
answer: Optional[str] = None
|
||||
action_performed: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
command_output: Optional[str] = Field(None, max_length=10000)
|
||||
automation_used: Optional[bool] = False
|
||||
timestamp: datetime
|
||||
entered_at: Optional[datetime] = None
|
||||
|
||||
@@ -81,6 +81,30 @@ def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_command_output(decision: dict[str, Any]) -> str | None:
|
||||
"""Extract and normalize command_output from a decision dict."""
|
||||
output = decision.get("command_output")
|
||||
if not isinstance(output, str):
|
||||
return None
|
||||
output = output.strip()
|
||||
return output if output else None
|
||||
|
||||
|
||||
def _find_node_commands(tree_snapshot: dict[str, Any], node_id: str) -> list[str]:
|
||||
"""Find the commands list for a node in the tree snapshot."""
|
||||
def _search(node: dict[str, Any]) -> list[str] | None:
|
||||
if node.get("id") == node_id:
|
||||
return node.get("commands") or []
|
||||
for child in node.get("children", []):
|
||||
result = _search(child)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
structure = tree_snapshot.get("tree_structure") or tree_snapshot
|
||||
return _search(structure) or []
|
||||
|
||||
|
||||
def _get_outcome_label(session: Session) -> str | None:
|
||||
"""Map stored outcome enum to human-friendly label."""
|
||||
outcome = getattr(session, "outcome", None)
|
||||
@@ -137,6 +161,14 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f"**Answer:** {answer}")
|
||||
if notes:
|
||||
lines.append(f"**Notes:** {notes}")
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
lines.append(f"**Commands Run:** {', '.join(f'`{c}`' for c in commands)}")
|
||||
lines.append("**Output:**")
|
||||
lines.append("```")
|
||||
lines.append(command_output)
|
||||
lines.append("```")
|
||||
if duration_seconds is not None:
|
||||
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
@@ -190,6 +222,13 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f" Answer: {answer}")
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
lines.append(f" Commands Run: {', '.join(commands)}")
|
||||
lines.append(" Output:")
|
||||
for output_line in command_output.splitlines():
|
||||
lines.append(f" {output_line}")
|
||||
if duration_seconds is not None:
|
||||
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
||||
|
||||
@@ -253,6 +292,12 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
||||
if notes:
|
||||
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
cmd_html = ", ".join(f"<code>{html.escape(c)}</code>" for c in commands)
|
||||
html_parts.append(f'<p><strong>Commands Run:</strong> {cmd_html}</p>')
|
||||
html_parts.append(f'<pre><code>{html.escape(command_output)}</code></pre>')
|
||||
if duration_seconds is not None:
|
||||
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
@@ -304,6 +349,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(line)
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
lines.append(f" Commands: {', '.join(commands)}")
|
||||
lines.append(" Output:")
|
||||
for output_line in command_output.splitlines():
|
||||
lines.append(f" {output_line}")
|
||||
else:
|
||||
lines.append("No steps recorded.")
|
||||
lines.append("")
|
||||
|
||||
239
docs/plans/2026-02-12-issue-57-command-output-capture.md
Normal file
239
docs/plans/2026-02-12-issue-57-command-output-capture.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Issue #57: Command Output Capture — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Engineers run commands during troubleshooting sessions but the output is lost — exports say "ran this command" but not what it returned. This feature adds a "Paste Output" textarea on action nodes and custom action steps so command output is captured in session data and included in all exports and session review.
|
||||
|
||||
**Scope:** Built-in action nodes AND custom action steps.
|
||||
**Migration:** None required — `decisions` is already a JSONB array with flexible dict entries.
|
||||
|
||||
---
|
||||
|
||||
## Public Interfaces / Type Changes
|
||||
|
||||
- **Backend:** Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord` in `session.py`
|
||||
- **Frontend:** Add `command_output?: string | null` to `DecisionRecord` type in `session.ts`
|
||||
- **API:** No endpoint changes — `PUT /api/v1/sessions/{id}` continues to accept full decisions array; now includes optional `command_output`
|
||||
- **Validation:** Backend enforces 10,000 character hard limit (returns 422 on overflow); frontend shows live character count
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Backend (3 files)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/schemas/session.py` | Add `command_output` field to `DecisionRecord` |
|
||||
| `backend/app/services/export_service.py` | Render `command_output` in all 4 export formats with command context |
|
||||
| `backend/app/core/session_to_tree.py` | Include command output when converting session decisions to forked tree nodes |
|
||||
|
||||
### Frontend (3 files)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/types/session.ts` | Add `command_output` to `DecisionRecord` type |
|
||||
| `frontend/src/pages/TreeNavigationPage.tsx` | Add capture UI for both built-in action nodes and custom action steps |
|
||||
| `frontend/src/pages/SessionDetailPage.tsx` | Render `command_output` in decision review and clipboard copy |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backend Schema
|
||||
|
||||
**File:** `backend/app/schemas/session.py`
|
||||
|
||||
- Add `command_output: Optional[str] = Field(None, max_length=10000)` to `DecisionRecord`
|
||||
- The `max_length=10000` provides backend validation — requests exceeding this return 422
|
||||
|
||||
### Step 2: Frontend Type
|
||||
|
||||
**File:** `frontend/src/types/session.ts`
|
||||
|
||||
- Add `command_output?: string | null` to the `DecisionRecord` type
|
||||
- This ensures the field is not dropped by TypeScript typing during round-trips
|
||||
|
||||
### Step 3: TreeNavigationPage — Capture UI for Built-in Action Nodes
|
||||
|
||||
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
|
||||
|
||||
**State:**
|
||||
- Add `const [commandOutput, setCommandOutput] = useState('')` (same pattern as existing `notes` state)
|
||||
- Clear `commandOutput` when node changes (same place `notes` is cleared)
|
||||
- When revisiting a step, preload existing `command_output` from the decision record if present
|
||||
|
||||
**UI — on action nodes with `currentNode.commands?.length > 0`:**
|
||||
- Render a collapsible "Paste Output (Optional)" section below the commands display
|
||||
- Inside: a textarea with:
|
||||
- Placeholder: `"Paste command output here..."`
|
||||
- Monospace font (`font-mono`), consistent with command code block styling
|
||||
- `bg-white/10` background styling to match existing design
|
||||
- Live character count display: `"{count} / 10,000"` shown below the textarea
|
||||
- Max length enforced on the frontend at 10,000 characters
|
||||
- Use a `Terminal` icon from `lucide-react` for the section label
|
||||
|
||||
**Persistence — in `handleContinue()`:**
|
||||
- Add `command_output: commandOutput.trim() || null` to the decision record pushed to the session
|
||||
- Empty or whitespace-only input is normalized to `null` (treated as not provided)
|
||||
|
||||
### Step 4: TreeNavigationPage — Capture UI for Custom Action Steps
|
||||
|
||||
**File:** `frontend/src/pages/TreeNavigationPage.tsx`
|
||||
|
||||
Custom action steps create their decision record at insertion time, which is different from built-in action nodes. The output capture UI and behavior should be the same as Step 3, but persistence requires updating the existing decision rather than creating a new one.
|
||||
|
||||
**UI:**
|
||||
- Same collapsible "Paste Output (Optional)" section as built-in action nodes
|
||||
- Available when a custom action step has commands defined
|
||||
|
||||
**Persistence:**
|
||||
- Before `handleContinueToDescendant` or `handleCustomBranchComplete` is called, update the current custom step's decision record with `command_output: commandOutput.trim() || null`
|
||||
- Persist the updated decisions array to the backend before navigation/completion transitions
|
||||
- This is a wrapper flow around the existing custom step logic — not a replacement of it
|
||||
|
||||
### Step 5: Export Service — All 4 Formats
|
||||
|
||||
**File:** `backend/app/services/export_service.py`
|
||||
|
||||
**Helpers to add:**
|
||||
- A helper to safely extract and normalize `command_output` from a decision dict (strip whitespace, return `None` if empty)
|
||||
- A helper to resolve the commands associated with a step for context display:
|
||||
1. First look up the tree snapshot action node by `node_id`
|
||||
2. Fallback to custom step metadata by `node_id`
|
||||
3. Fallback to no command list (just show the output)
|
||||
|
||||
**Export rendering per format** (all guarded by `if command_output := decision.get("command_output")`):
|
||||
|
||||
**Markdown (`_generate_markdown_export`):**
|
||||
```
|
||||
**Commands Run:** `ping 8.8.8.8`, `tracert 8.8.8.8`
|
||||
**Output:**
|
||||
```
|
||||
{output}
|
||||
```
|
||||
```
|
||||
|
||||
**Text (`_generate_text_export`):**
|
||||
```
|
||||
Commands Run: ping 8.8.8.8, tracert 8.8.8.8
|
||||
Output:
|
||||
{output with each line indented}
|
||||
```
|
||||
|
||||
**HTML (`_generate_html_export`):**
|
||||
```html
|
||||
<p><strong>Commands Run:</strong> <code>ping 8.8.8.8</code>, <code>tracert 8.8.8.8</code></p>
|
||||
<pre><code>{html.escape(output)}</code></pre>
|
||||
```
|
||||
|
||||
**PSA (`_generate_psa_export`):**
|
||||
```
|
||||
Commands: ping 8.8.8.8, tracert 8.8.8.8
|
||||
Output:
|
||||
{output with each line indented}
|
||||
```
|
||||
|
||||
### Step 6: SessionDetailPage — Review Display
|
||||
|
||||
**File:** `frontend/src/pages/SessionDetailPage.tsx`
|
||||
|
||||
**Decision review:**
|
||||
- After the `action_performed` rendering for each decision, check for `command_output`
|
||||
- If present, render in a `<pre>` block with monospace styling and preserved whitespace
|
||||
- Label: "Command Output" with consistent styling
|
||||
|
||||
**Clipboard copy (`copyTicketNotes`):**
|
||||
- After the action performed line, add:
|
||||
```
|
||||
Output:
|
||||
{decision.command_output}
|
||||
```
|
||||
- Only include if `command_output` is present and non-empty
|
||||
|
||||
### Step 7: Session-to-Tree Conversion
|
||||
|
||||
**File:** `backend/app/core/session_to_tree.py`
|
||||
|
||||
- When building node descriptions from decisions, check for `command_output`
|
||||
- If present, append the output text to the node description so forked trees retain the captured output
|
||||
- Format: include a "Command Output:" label followed by the output text
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases & Failure Modes
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Existing sessions without `command_output` | Render normally with no errors — field is optional |
|
||||
| Output exceeds 10,000 characters | Frontend prevents input beyond limit; backend returns 422 if somehow exceeded |
|
||||
| Empty or whitespace-only input | Normalized to `null` — treated as not provided |
|
||||
| Multiline output, JSON, special characters | Preserved as-is; HTML export escapes all content |
|
||||
| Steps without commands | Output can still be stored; export shows output even without command context |
|
||||
| Multi-command action nodes | One shared output field per step (not per command) |
|
||||
| Revisiting a completed step | Preloads the previously captured output |
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
|
||||
### Backend API Tests (`test_sessions.py`)
|
||||
|
||||
1. Update a session with `command_output` in a decision record → verify it round-trips correctly on GET
|
||||
2. Submit `command_output` exceeding 10,000 characters → verify 422 response
|
||||
3. Submit empty string and whitespace-only `command_output` → verify stored as `null`
|
||||
|
||||
### Export Tests
|
||||
|
||||
4. Markdown export includes command context and fenced code block for output
|
||||
5. Text export includes output block with preserved line breaks
|
||||
6. HTML export includes escaped `<pre><code>` block
|
||||
7. PSA export includes compact command context and indented output
|
||||
8. Multi-command action node exports with single shared output block
|
||||
9. Export of session without any `command_output` renders cleanly (no errors, no empty blocks)
|
||||
|
||||
### Custom Action Step Tests
|
||||
|
||||
10. Insert custom action step with commands → capture output → continue → verify output stored in decision
|
||||
11. Custom step output appears in all export formats
|
||||
|
||||
### Frontend Behavior Tests
|
||||
|
||||
12. Action node with commands shows the "Paste Output" section (collapsed by default)
|
||||
13. Custom action step with commands shows the "Paste Output" section
|
||||
14. Action node WITHOUT commands does NOT show the "Paste Output" section
|
||||
15. Character count updates live as user types
|
||||
16. Revisiting a step preloads previously captured output
|
||||
17. Session detail page renders output block with monospace formatting
|
||||
18. "Copy to clipboard" includes command output when present
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist (Manual)
|
||||
|
||||
1. `cd frontend && npm run build` — confirm no TypeScript errors
|
||||
2. Start a session on a tree with action nodes that have commands:
|
||||
- Paste output into the textarea
|
||||
- Click Continue
|
||||
- Verify output persists in the session data
|
||||
3. Start a session and add a custom action step with commands:
|
||||
- Paste output
|
||||
- Continue to next step
|
||||
- Verify output persists
|
||||
4. Complete a session → check SessionDetailPage shows the command output with proper formatting
|
||||
5. Export in all 4 formats → verify output appears correctly formatted in each
|
||||
6. Use "Copy to clipboard" on a step with output → verify output is included
|
||||
7. Run a session on a tree WITHOUT commands on action nodes → verify no output section appears
|
||||
8. Test with existing sessions that have no `command_output` → verify they render and export without errors
|
||||
9. Test pasting large output (near 10,000 chars) → verify character count and limit work
|
||||
10. Test pasting multiline output with special characters → verify preservation in review and exports
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- One shared output field per step, not per individual command
|
||||
- Maximum stored output is 10,000 characters
|
||||
- v1 does not include syntax highlighting or image paste
|
||||
- No feature flag gating — ships directly
|
||||
- Collapsed-by-default UI keeps the interface clean for steps where output isn't needed
|
||||
@@ -224,6 +224,7 @@ export function SessionDetailPage() {
|
||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join('\n'))
|
||||
setCopiedStepIndex(index)
|
||||
@@ -439,6 +440,14 @@ export function SessionDetailPage() {
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||
{decision.command_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
|
||||
import { Plus, CheckCircle, ArrowRight, Clock, Terminal } from 'lucide-react'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
@@ -33,6 +33,8 @@ export function TreeNavigationPage() {
|
||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
||||
const [notes, setNotes] = useState<string>('')
|
||||
const [commandOutput, setCommandOutput] = useState<string>('')
|
||||
const [commandOutputOpen, setCommandOutputOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
@@ -122,6 +124,35 @@ export function TreeNavigationPage() {
|
||||
},
|
||||
})
|
||||
|
||||
// Inject command_output into the last decision (for custom steps) before continuing
|
||||
const updateLastDecisionWithCommandOutput = async () => {
|
||||
const output = commandOutput.trim() || null
|
||||
if (!output || !session || decisions.length === 0) return
|
||||
const updatedDecisions = [...decisions]
|
||||
updatedDecisions[updatedDecisions.length - 1] = {
|
||||
...updatedDecisions[updatedDecisions.length - 1],
|
||||
command_output: output,
|
||||
}
|
||||
setDecisions(updatedDecisions)
|
||||
try {
|
||||
await sessionsApi.update(session.id, { decisions: updatedDecisions })
|
||||
} catch (err) {
|
||||
console.error('Failed to update decision with command output:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomContinueToDescendant = async () => {
|
||||
await updateLastDecisionWithCommandOutput()
|
||||
setCommandOutput('')
|
||||
setCommandOutputOpen(false)
|
||||
customStepFlow.handleContinueToDescendant()
|
||||
}
|
||||
|
||||
const handleCustomBranchCompleteWithOutput = async () => {
|
||||
await updateLastDecisionWithCommandOutput()
|
||||
customStepFlow.handleCustomBranchComplete()
|
||||
}
|
||||
|
||||
const handleScratchpadSave = async (content: string) => {
|
||||
if (!session) return
|
||||
await sessionsApi.updateScratchpad(session.id, content)
|
||||
@@ -218,6 +249,8 @@ export function TreeNavigationPage() {
|
||||
setCurrentNodeId(nextNodeId)
|
||||
setCurrentStepEnteredAt(exitedAt)
|
||||
setNotes('')
|
||||
setCommandOutput('')
|
||||
setCommandOutputOpen(false)
|
||||
|
||||
try {
|
||||
await sessionsApi.update(session.id, {
|
||||
@@ -243,6 +276,7 @@ export function TreeNavigationPage() {
|
||||
answer: null,
|
||||
action_performed: actionPerformed || node.title || 'Action completed',
|
||||
notes: notes || null,
|
||||
command_output: commandOutput.trim() || null,
|
||||
automation_used: false,
|
||||
timestamp: exitedAt,
|
||||
entered_at: enteredAt,
|
||||
@@ -259,6 +293,8 @@ export function TreeNavigationPage() {
|
||||
setCurrentNodeId(node.next_node_id)
|
||||
setCurrentStepEnteredAt(exitedAt)
|
||||
setNotes('')
|
||||
setCommandOutput('')
|
||||
setCommandOutputOpen(false)
|
||||
|
||||
try {
|
||||
await sessionsApi.update(session.id, {
|
||||
@@ -280,6 +316,7 @@ export function TreeNavigationPage() {
|
||||
answer: null,
|
||||
action_performed: node.title || 'Session completed',
|
||||
notes: notes || null,
|
||||
command_output: commandOutput.trim() || null,
|
||||
automation_used: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: [],
|
||||
@@ -321,11 +358,16 @@ export function TreeNavigationPage() {
|
||||
const handleGoBack = () => {
|
||||
if (pathTaken.length <= 1) return
|
||||
const newPath = pathTaken.slice(0, -1)
|
||||
const removedDecision = decisions[decisions.length - 1]
|
||||
const newDecisions = decisions.slice(0, -1)
|
||||
setPathTaken(newPath)
|
||||
setDecisions(newDecisions)
|
||||
setCurrentNodeId(newPath[newPath.length - 1])
|
||||
setCurrentStepEnteredAt(new Date().toISOString())
|
||||
// Preload fields from the removed decision when revisiting
|
||||
const prevOutput = removedDecision?.command_output || ''
|
||||
setCommandOutput(prevOutput)
|
||||
setCommandOutputOpen(!!prevOutput)
|
||||
}
|
||||
|
||||
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||
@@ -614,6 +656,37 @@ export function TreeNavigationPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Command Output Capture */}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
|
||||
className="flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Paste Output (Optional)</span>
|
||||
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{commandOutputOpen && (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
value={commandOutput}
|
||||
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
|
||||
placeholder="Paste command output here..."
|
||||
rows={4}
|
||||
maxLength={10000}
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-white/10 bg-white/10 px-3 py-2',
|
||||
'font-mono text-sm text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-right text-xs text-white/30">
|
||||
{commandOutput.length.toLocaleString()} / 10,000
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -625,7 +698,7 @@ export function TreeNavigationPage() {
|
||||
<div className="mt-6 border-t border-purple-700 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={customStepFlow.handleContinueToDescendant}
|
||||
onClick={handleCustomContinueToDescendant}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md bg-white px-4 py-3 text-sm font-medium text-black',
|
||||
'hover:bg-white/90'
|
||||
@@ -656,7 +729,7 @@ export function TreeNavigationPage() {
|
||||
Add Another Step
|
||||
</button>
|
||||
<button
|
||||
onClick={customStepFlow.handleCustomBranchComplete}
|
||||
onClick={handleCustomBranchCompleteWithOutput}
|
||||
disabled={isCompleting}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
|
||||
@@ -696,6 +769,37 @@ export function TreeNavigationPage() {
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
{/* Command Output Capture */}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCommandOutputOpen(!commandOutputOpen)}
|
||||
className="flex items-center gap-1.5 text-sm text-white/50 hover:text-white"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
<span>Paste Output (Optional)</span>
|
||||
<span className="text-xs">{commandOutputOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{commandOutputOpen && (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
value={commandOutput}
|
||||
onChange={(e) => setCommandOutput(e.target.value.slice(0, 10000))}
|
||||
placeholder="Paste command output here..."
|
||||
rows={4}
|
||||
maxLength={10000}
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-white/10 bg-white/10 px-3 py-2',
|
||||
'font-mono text-sm text-white placeholder:text-white/40',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-right text-xs text-white/30">
|
||||
{commandOutput.length.toLocaleString()} / 10,000
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentNode.expected_outcome && (
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface DecisionRecord {
|
||||
answer: string | null
|
||||
action_performed: string | null
|
||||
notes: string | null
|
||||
command_output?: string | null
|
||||
automation_used: boolean
|
||||
timestamp: string
|
||||
entered_at?: string | null
|
||||
|
||||
Reference in New Issue
Block a user