TUI Gen 5: Major overhaul - Todo List, improved streaming, ESC to abort, cleaner UI
This commit is contained in:
1769
bin/opencode-ink.mjs
1769
bin/opencode-ink.mjs
File diff suppressed because it is too large
Load Diff
4101
bin/opencode-ink.mjs.backup
Normal file
4101
bin/opencode-ink.mjs.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -683,7 +683,40 @@ function showProjectMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start with project menu
|
// Start with project menu
|
||||||
showProjectMenu();
|
// Start with auth check then project menu
|
||||||
|
(async () => {
|
||||||
|
const qwen = getQwen();
|
||||||
|
const authed = await qwen.checkAuth();
|
||||||
|
if (!authed) {
|
||||||
|
print(`\n${c.yellow}Authentication required. Launching web login...${c.reset}\n`);
|
||||||
|
|
||||||
|
const authScript = path.join(__dirname, 'auth.js');
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const child = spawn('node', [authScript], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
print(`\n${c.green}Authentication successful! Starting TUI...${c.reset}\n`);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
print(`\n${c.red}Authentication failed or was cancelled.${c.reset}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-check auth
|
||||||
|
const recheck = await qwen.checkAuth();
|
||||||
|
if (!recheck) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showProjectMenu();
|
||||||
|
})();
|
||||||
|
|
||||||
function prompt() {
|
function prompt() {
|
||||||
const promptStr = selectingAgent ? `${c.cyan}#${c.reset} ` : `${c.green}❯${c.reset} `;
|
const promptStr = selectingAgent ? `${c.cyan}#${c.reset} ` : `${c.green}❯${c.reset} `;
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import { Box, Text } from 'ink';
|
|||||||
const h = React.createElement;
|
const h = React.createElement;
|
||||||
|
|
||||||
const ChatBubble = ({ role, content, meta, width, children }) => {
|
const ChatBubble = ({ role, content, meta, width, children }) => {
|
||||||
|
// Calculate safe content width accounting for gutter
|
||||||
|
const contentWidth = width ? width - 2 : undefined; // Account for left gutter only
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// USER MESSAGE (RIGHT ALIGNED) - RAIL STYLE
|
// USER MESSAGE - Clean text-focused presentation
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
return h(Box, { width: width, flexDirection: 'row', justifyContent: 'flex-end', marginBottom: 1, overflow: 'hidden' },
|
return h(Box, {
|
||||||
h(Box, { flexDirection: 'row', paddingRight: 1 },
|
width: width,
|
||||||
h(Text, { color: 'cyan', wrap: 'wrap' }, content),
|
flexDirection: 'row',
|
||||||
h(Box, { marginLeft: 1, borderStyle: 'single', borderLeft: false, borderTop: false, borderBottom: false, borderRightColor: 'cyan' })
|
justifyContent: 'flex-end',
|
||||||
)
|
marginBottom: 1,
|
||||||
|
paddingLeft: 2
|
||||||
|
},
|
||||||
|
h(Text, { color: 'cyan', wrap: 'wrap' }, content)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,27 +32,39 @@ const ChatBubble = ({ role, content, meta, width, children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// ERROR - RED GUTTER
|
// ERROR - CLEAN GUTTER STYLE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
if (role === 'error') {
|
if (role === 'error') {
|
||||||
// Strip redundant "Error: " prefix if present in content
|
// Strip redundant "Error: " prefix if present in content
|
||||||
const cleanContent = content.replace(/^Error:\s*/i, '');
|
const cleanContent = content.replace(/^Error:\s*/i, '');
|
||||||
return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' },
|
return h(Box, {
|
||||||
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'red' }),
|
width: width,
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 1
|
||||||
|
},
|
||||||
|
h(Box, { width: 1, marginRight: 1, backgroundColor: 'red' }),
|
||||||
h(Text, { color: 'red', wrap: 'wrap' }, cleanContent)
|
h(Text, { color: 'red', wrap: 'wrap' }, cleanContent)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// ASSISTANT - LEFT GUTTER RAIL
|
// ASSISTANT - Clean text-focused style (Opencode-like)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' },
|
return h(Box, {
|
||||||
// Left Gutter
|
width: width,
|
||||||
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'green' }),
|
flexDirection: 'row',
|
||||||
|
marginBottom: 1
|
||||||
// Content
|
},
|
||||||
h(Box, { flexDirection: 'column', paddingRight: 2, flexGrow: 1 },
|
// Clean left gutter similar to opencode
|
||||||
children ? children : h(Text, { wrap: 'wrap' }, content)
|
h(Box, { width: 2, marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'green' }),
|
||||||
|
|
||||||
|
// Content area - text focused, no borders
|
||||||
|
h(Box, {
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 10
|
||||||
|
},
|
||||||
|
children ? children : h(Text, { color: 'white', wrap: 'wrap' }, content)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,28 +13,54 @@ const ThinkingBlock = ({
|
|||||||
if (lines.length === 0 && !isThinking) return null;
|
if (lines.length === 0 && !isThinking) return null;
|
||||||
|
|
||||||
// Show only last few lines to avoid clutter
|
// Show only last few lines to avoid clutter
|
||||||
const visibleLines = lines.slice(-3);
|
const visibleLines = lines.slice(-3); // Show cleaner view
|
||||||
const hiddenCount = Math.max(0, lines.length - 3);
|
const hiddenCount = Math.max(0, lines.length - 3);
|
||||||
|
|
||||||
return h(Box, {
|
return h(Box, {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
width: width,
|
width: width,
|
||||||
marginBottom: 1,
|
marginBottom: 1,
|
||||||
overflow: 'hidden'
|
paddingLeft: 1 // Only left padding, no borders like opencode
|
||||||
},
|
},
|
||||||
// Left Gutter (Dimmed)
|
// Clean left gutter similar to opencode
|
||||||
h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'gray', borderDimColor: true }),
|
h(Box, {
|
||||||
|
width: 2,
|
||||||
|
marginRight: 1,
|
||||||
|
borderStyle: 'single',
|
||||||
|
borderRight: false,
|
||||||
|
borderTop: false,
|
||||||
|
borderBottom: false,
|
||||||
|
borderLeftColor: isThinking ? 'yellow' : 'gray'
|
||||||
|
}),
|
||||||
|
|
||||||
h(Box, { flexDirection: 'column' },
|
h(Box, { flexDirection: 'column', flexGrow: 1 },
|
||||||
h(Text, { color: 'gray', dimColor: true },
|
// Header with minimal stats - opencode style
|
||||||
isThinking
|
h(Box, { marginBottom: 0.5, flexDirection: 'row' },
|
||||||
? `🧠 Thinking${stats.activeAgent ? ` (${stats.activeAgent})` : ''}... (${stats.chars} chars)`
|
h(Text, { color: isThinking ? 'yellow' : 'gray', dimColor: !isThinking },
|
||||||
: `💭 Thought Process (${stats.chars} chars)`
|
isThinking ? '💭 thinking...' : '💭 thinking'
|
||||||
|
),
|
||||||
|
stats.activeAgent && h(Text, { color: 'magenta', marginLeft: 1 }, `(${stats.activeAgent})`),
|
||||||
|
h(Text, { color: 'gray', marginLeft: 1, dimColor: true }, `(${stats.chars} chars)`)
|
||||||
),
|
),
|
||||||
|
// Thinking lines with cleaner presentation
|
||||||
visibleLines.map((line, i) =>
|
visibleLines.map((line, i) =>
|
||||||
h(Text, { key: i, color: 'gray', dimColor: true, wrap: 'truncate' }, ` ${line}`)
|
h(Text, {
|
||||||
|
key: i,
|
||||||
|
color: 'gray',
|
||||||
|
dimColor: true,
|
||||||
|
wrap: 'truncate'
|
||||||
|
},
|
||||||
|
` ${line.substring(0, width - 4)}` // Cleaner indentation
|
||||||
|
)
|
||||||
),
|
),
|
||||||
hiddenCount > 0 ? h(Text, { color: 'gray', dimColor: true, italic: true }, ` ...${hiddenCount} more`) : null
|
// Hidden count indicator
|
||||||
|
hiddenCount > 0 && h(Text, {
|
||||||
|
color: 'gray',
|
||||||
|
dimColor: true,
|
||||||
|
marginLeft: 2
|
||||||
|
},
|
||||||
|
`+${hiddenCount} steps`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
101
bin/ui/components/TodoList.mjs
Normal file
101
bin/ui/components/TodoList.mjs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
|
||||||
|
const h = React.createElement;
|
||||||
|
|
||||||
|
const TodoList = ({ tasks = [], onAddTask, onCompleteTask, onDeleteTask, width = 60 }) => {
|
||||||
|
const [newTask, setNewTask] = useState('');
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const handleAddTask = () => {
|
||||||
|
if (newTask.trim()) {
|
||||||
|
onAddTask && onAddTask(newTask.trim());
|
||||||
|
setNewTask('');
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingTasks = tasks.filter(t => t.status !== 'completed');
|
||||||
|
const completedTasks = tasks.filter(t => t.status === 'completed');
|
||||||
|
const progress = tasks.length > 0 ? Math.round((completedTasks.length / tasks.length) * 100) : 0;
|
||||||
|
|
||||||
|
return h(Box, { flexDirection: 'column', width: width, borderStyle: 'round', borderColor: 'gray', padding: 1 },
|
||||||
|
// Header with title and progress
|
||||||
|
h(Box, { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 1 },
|
||||||
|
h(Text, { bold: true, color: 'white' }, '📋 Tasks'),
|
||||||
|
h(Text, { color: 'cyan' }, `${progress}%`)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
h(Box, { marginBottom: 1 },
|
||||||
|
h(Box, {
|
||||||
|
width: width - 4, // Account for padding
|
||||||
|
height: 1,
|
||||||
|
borderStyle: 'single',
|
||||||
|
borderColor: 'gray',
|
||||||
|
flexDirection: 'row'
|
||||||
|
},
|
||||||
|
h(Box, {
|
||||||
|
width: Math.max(1, Math.floor((width - 6) * progress / 100)),
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: 'green'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Add new task
|
||||||
|
h(Box, { marginBottom: 1 },
|
||||||
|
isAdding
|
||||||
|
? h(Box, { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
h(Text, { color: 'green', marginRight: 1 }, '●'),
|
||||||
|
h(Box, { flexGrow: 1 },
|
||||||
|
h(TextInput, {
|
||||||
|
value: newTask,
|
||||||
|
onChange: setNewTask,
|
||||||
|
onSubmit: handleAddTask,
|
||||||
|
placeholder: 'Add new task...'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: h(Box, { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
h(Text, { color: 'green', marginRight: 1 }, '➕'),
|
||||||
|
h(Text, { color: 'gray', dimColor: true, onClick: () => setIsAdding(true) }, 'Add task')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Tasks list
|
||||||
|
h(Box, { flexDirection: 'column', flexGrow: 1 },
|
||||||
|
// Pending tasks
|
||||||
|
pendingTasks.map((task, index) =>
|
||||||
|
h(Box, {
|
||||||
|
key: task.id || index,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 0.5
|
||||||
|
},
|
||||||
|
h(Box, {
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
borderStyle: 'round',
|
||||||
|
borderColor: 'gray',
|
||||||
|
marginRight: 1,
|
||||||
|
onClick: () => onCompleteTask && onCompleteTask(task.id)
|
||||||
|
},
|
||||||
|
h(Text, { color: 'gray' }, '○')
|
||||||
|
),
|
||||||
|
h(Box, { flexGrow: 1 },
|
||||||
|
h(Text, { color: 'white' }, task.content)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Completed tasks (show collapsed by default)
|
||||||
|
completedTasks.length > 0 && h(Box, { marginTop: 1 },
|
||||||
|
h(Text, { color: 'gray', dimColor: true, bold: true }, `✓ ${completedTasks.length} completed`)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TodoList;
|
||||||
Reference in New Issue
Block a user