209 lines
6.4 KiB
JavaScript
209 lines
6.4 KiB
JavaScript
/**
|
|
* TimeoutRow Component - "Pro" Protocol
|
|
* Interactive component for timeout recovery actions
|
|
*
|
|
* @module ui/components/TimeoutRow
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { Box, Text, useInput } from 'ink';
|
|
|
|
const { useState } = React;
|
|
const h = React.createElement;
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// TIMEOUT ROW - Non-destructive timeout handling
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* TimeoutRow Component
|
|
* Displays interactive recovery options when a request times out
|
|
*
|
|
* @param {Object} props
|
|
* @param {Function} props.onRetry - Called when user selects Retry
|
|
* @param {Function} props.onCancel - Called when user selects Cancel
|
|
* @param {Function} props.onSaveLogs - Called when user selects Save Logs
|
|
* @param {string} props.lastGoodText - Last successful text before timeout
|
|
* @param {number} props.elapsedTime - Time elapsed before timeout (seconds)
|
|
*/
|
|
export const TimeoutRow = ({
|
|
onRetry,
|
|
onCancel,
|
|
onSaveLogs,
|
|
lastGoodText = '',
|
|
elapsedTime = 120
|
|
}) => {
|
|
const [selectedAction, setSelectedAction] = useState(0);
|
|
const actions = [
|
|
{ key: 'r', label: '[R]etry', color: 'yellow', action: onRetry },
|
|
{ key: 'c', label: '[C]ancel', color: 'gray', action: onCancel },
|
|
{ key: 's', label: '[S]ave Logs', color: 'blue', action: onSaveLogs }
|
|
];
|
|
|
|
// Handle keyboard input
|
|
useInput((input, key) => {
|
|
const lowerInput = input.toLowerCase();
|
|
|
|
// Direct key shortcuts
|
|
if (lowerInput === 'r' && onRetry) {
|
|
onRetry();
|
|
return;
|
|
}
|
|
if (lowerInput === 'c' && onCancel) {
|
|
onCancel();
|
|
return;
|
|
}
|
|
if (lowerInput === 's' && onSaveLogs) {
|
|
onSaveLogs();
|
|
return;
|
|
}
|
|
|
|
// Arrow key navigation
|
|
if (key.leftArrow) {
|
|
setSelectedAction(prev => Math.max(0, prev - 1));
|
|
}
|
|
if (key.rightArrow) {
|
|
setSelectedAction(prev => Math.min(actions.length - 1, prev + 1));
|
|
}
|
|
|
|
// Enter to confirm selected action
|
|
if (key.return) {
|
|
const action = actions[selectedAction]?.action;
|
|
if (action) action();
|
|
}
|
|
});
|
|
|
|
return h(Box, {
|
|
flexDirection: 'column',
|
|
marginTop: 1,
|
|
paddingLeft: 2
|
|
},
|
|
// Warning indicator
|
|
h(Box, { marginBottom: 0 },
|
|
h(Text, { color: 'yellow', bold: true }, '⚠ '),
|
|
h(Text, { color: 'yellow' }, `Request timed out (${elapsedTime}s)`)
|
|
),
|
|
|
|
// Action buttons
|
|
h(Box, { marginTop: 0 },
|
|
...actions.map((action, i) =>
|
|
h(Box, { key: action.key, marginRight: 2 },
|
|
h(Text, {
|
|
color: i === selectedAction ? 'white' : action.color,
|
|
inverse: i === selectedAction,
|
|
bold: i === selectedAction
|
|
}, ` ${action.label} `)
|
|
)
|
|
)
|
|
),
|
|
|
|
// Context info (dimmed)
|
|
lastGoodText ? h(Box, { marginTop: 0 },
|
|
h(Text, { color: 'gray', dimColor: true },
|
|
`${lastGoodText.split('\n').length} paragraphs preserved`)
|
|
) : null
|
|
);
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// RUN STATES - State machine for assistant responses
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
export const RUN_STATES = {
|
|
IDLE: 'idle',
|
|
STREAMING: 'streaming',
|
|
WAITING_FOR_TOOL: 'waiting_for_tool',
|
|
COMPLETE: 'complete',
|
|
TIMED_OUT: 'timed_out',
|
|
CANCELLED: 'cancelled'
|
|
};
|
|
|
|
/**
|
|
* Create a new Run object
|
|
* @param {string} id - Unique run ID
|
|
* @param {string} prompt - Original user prompt
|
|
* @returns {Object} New run object
|
|
*/
|
|
export function createRun(id, prompt) {
|
|
return {
|
|
id,
|
|
prompt,
|
|
state: RUN_STATES.IDLE,
|
|
partialText: '',
|
|
lastCheckpoint: '',
|
|
startTime: Date.now(),
|
|
lastActivityTime: Date.now(),
|
|
tokensReceived: 0,
|
|
error: null,
|
|
metadata: {}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update run state with new data
|
|
* @param {Object} run - Current run object
|
|
* @param {Object} updates - Updates to apply
|
|
* @returns {Object} Updated run object
|
|
*/
|
|
export function updateRun(run, updates) {
|
|
return {
|
|
...run,
|
|
...updates,
|
|
lastActivityTime: Date.now()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Checkpoint the run for potential resume
|
|
* @param {Object} run - Current run object
|
|
* @returns {Object} Run with checkpoint set
|
|
*/
|
|
export function checkpointRun(run) {
|
|
// Find last complete paragraph for clean resume point
|
|
const text = run.partialText || '';
|
|
const paragraphs = text.split('\n\n');
|
|
const completeParagraphs = paragraphs.slice(0, -1).join('\n\n');
|
|
|
|
return {
|
|
...run,
|
|
lastCheckpoint: completeParagraphs || text
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate overlap for resume deduplication
|
|
* @param {string} checkpoint - Last checkpointed text
|
|
* @param {string} newText - New text from resumed generation
|
|
* @returns {string} Deduplicated combined text
|
|
*/
|
|
export function deduplicateResume(checkpoint, newText) {
|
|
if (!checkpoint || !newText) return newText || checkpoint || '';
|
|
|
|
// Find overlap at end of checkpoint / start of newText
|
|
const checkpointLines = checkpoint.split('\n');
|
|
const newLines = newText.split('\n');
|
|
|
|
// Look for matching lines to find overlap point
|
|
let overlapStart = 0;
|
|
for (let i = 0; i < newLines.length && i < 10; i++) {
|
|
const line = newLines[i].trim();
|
|
if (line && checkpointLines.some(cl => cl.trim() === line)) {
|
|
overlapStart = i + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Return checkpoint + non-overlapping new content
|
|
const uniqueNewContent = newLines.slice(overlapStart).join('\n');
|
|
return checkpoint + (uniqueNewContent ? '\n\n' + uniqueNewContent : '');
|
|
}
|
|
|
|
export default {
|
|
TimeoutRow,
|
|
RUN_STATES,
|
|
createRun,
|
|
updateRun,
|
|
checkpointRun,
|
|
deduplicateResume
|
|
};
|