diff --git a/src/application/task-service.ts b/src/application/task-service.ts index 7a2b488..0726d63 100644 --- a/src/application/task-service.ts +++ b/src/application/task-service.ts @@ -142,6 +142,23 @@ export class TaskService { return this.updateStatus(id, 'cancelled'); } + async clone(id: string): Promise { + const src = await this.get(id); + return this.create({ + title: src.title, + description: src.description, + priority: src.priority, + assignee: src.assignee, + labels: [...src.labels], + depends_on: src.depends_on.length ? [...src.depends_on] : undefined, + max_attempts: src.max_attempts, + workspace_mode: src.workspace_mode, + review_criteria: src.review_criteria ? [...src.review_criteria] : undefined, + scope: src.scope ? [...src.scope] : undefined, + goalId: src.goalId, + }); + } + async retry(id: string): Promise { const task = await this.get(id); diff --git a/src/cli/commands/tui.ts b/src/cli/commands/tui.ts index dfa9602..e9a4791 100644 --- a/src/cli/commands/tui.ts +++ b/src/cli/commands/tui.ts @@ -27,6 +27,16 @@ export function registerTuiCommand(program: Command, container: Container): void await container.orchestrator.runTask(taskId); }; + const onCloneTask = async (taskId: string) => { + const cloned = await container.taskService.clone(taskId); + await container.orchestrator.runTask(cloned.id).catch((dispatchErr) => { + const msg = dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr); + // Attach cloned task so App can show "cloned but dispatch failed" vs "clone itself failed" + throw Object.assign(new Error(msg), { cloned }); + }); + return cloned; + }; + const onCreateTask = async (title: string, opts?: { priority?: number; description?: string; attachments?: string[] }) => { return container.taskService.create({ title, @@ -265,6 +275,7 @@ export function registerTuiCommand(program: Command, container: Container): void agents, state, onRunTask, + onCloneTask, onCreateTask, onCancelTask, onRetryTask, diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4788d27..6d2b722 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -20,6 +20,9 @@ import { TaskRow, STATUS_ORDER, GoalSectionRow, UngroupedSectionRow } from './co import { AgentRow, AGENT_STATUS_ORDER, TeamSectionRow, UnassignedSectionRow } from './components/AgentList.js'; import { GoalRow, GOAL_STATUS_ORDER } from './components/GoalList.js'; import { DetailPanel, SectionDivider } from './components/DetailPanel.js'; +import { DEFAULT_TUI_SETTINGS, loadTuiSettings, saveTuiSettings, type WrapMode } from '../infrastructure/storage/tui-settings.js'; +import { detailScrollReducer } from './detail-scroll.js'; +import { colCapLine, wrapToWidth } from './text-wrap.js'; import { Header } from './components/Header.js'; import type { HeaderStats, HeaderTokens } from './components/Header.js'; import { TABS } from './components/TabBar.js'; @@ -61,6 +64,8 @@ const TASK_LIST_LIMIT = 10; const MAX_RUN_MAP_SIZE = 500; /** Max characters for detail strings in status messages (prevents multi-MB objects) */ const MAX_DETAIL_LEN = 2048; +/** Max characters for summary text (what's rendered inline in the activity feed / logs). */ +const MAX_SUMMARY_LEN = 2048; /** Max status messages kept in the activity feed */ const MAX_MESSAGES = 500; /** Max toast notifications in the queue (FIFO — oldest removed) */ @@ -119,6 +124,7 @@ export interface AppProps { agents?: Agent[]; state: OrchestratorState; onRunTask?: (taskId: string) => Promise; + onCloneTask?: (taskId: string) => Promise; onCreateTask?: (title: string, opts?: { priority?: number; description?: string; attachments?: string[] }) => Promise; onCancelTask?: (taskId: string) => Promise; onRetryTask?: (taskId: string) => Promise; @@ -313,7 +319,7 @@ function entityListChanged(prev: { id: string; updated_at?: string }[], next: { export function App({ projectName, tasks: initialTasks, agents: initialAgents = [], state: initialState, - onRunTask, onCreateTask, onCancelTask, onRetryTask, onAssignTask, + onRunTask, onCloneTask, onCreateTask, onCancelTask, onRetryTask, onAssignTask, onRunAll, onDisableAgent, onEnableAgent, onSubscribeEvents, onRefreshTasks, onRefreshAgents, onRefreshState, onLoadHistory, onAddAgent, onDeleteAgent, onApproveTask, onRejectTask, onDeleteTask, @@ -457,6 +463,11 @@ export function App({ const [toasts, setToasts] = useState([]); const toastSeq = useRef(0); + const showInfoToast = useCallback((message: string, type: ToastType = 'info') => { + const id = `info_${++toastSeq.current}`; + setToasts((prev) => [...prev.slice(-(MAX_TOASTS - 1)), { id, type, title: '', message, ts: Date.now() }]); + }, []); + // Tab flash state — flash the TASKS tab pill when a task event fires on another tab const [flashTab, setFlashTab] = useState<{ tab: ViewId; color: string } | undefined>(); const activeViewRef = useRef(activeView); @@ -513,6 +524,17 @@ export function App({ const [goalDetailScroll, setGoalDetailScroll] = useState(0); const [suggestionIndex, setSuggestionIndex] = useState(0); + // Task detail wrap mode + scroll + focus (persisted via tui-settings.json) + const [detailWrapMode, setDetailWrapMode] = useState(DEFAULT_TUI_SETTINGS.wrapMode); + const [detailReadingWidth, setDetailReadingWidth] = useState(DEFAULT_TUI_SETTINGS.readingWidth); + const [detailFocus, setDetailFocus] = useState<'list' | 'detail'>('list'); + const [detailScroll, setDetailScroll] = useState(0); + const [detailScrollMax, setDetailScrollMax] = useState(0); + + // Dashboard activity feed scrollback + const [activityFocus, setActivityFocus] = useState<'list' | 'activity'>('list'); + const [activityScroll, setActivityScroll] = useState(0); + // Teams state (refreshed alongside other data) const [liveTeams, setLiveTeams] = useState([]); @@ -690,6 +712,46 @@ export function App({ // onGetGoalProgress is stable by contract (defined once in tui.ts command handler) const onGetGoalProgressRef = useRef(onGetGoalProgress); onGetGoalProgressRef.current = onGetGoalProgress; + // Load TUI settings (wrap mode, reading width) on mount. + useEffect(() => { + let cancelled = false; + loadTuiSettings().then((s) => { + if (cancelled) return; + setDetailWrapMode(s.wrapMode); + setDetailReadingWidth(s.readingWidth); + }).catch(() => {}); + return () => { cancelled = true; }; + }, []); + + // Reset detail scroll/focus when the selected task changes. + useEffect(() => { + setDetailScroll(0); + setDetailFocus('list'); + }, [taskSelectedIndex]); + + // Drop focus + scroll on the dashboard activity feed when the view changes or detail opens. + useEffect(() => { + if (detailOpen || activeView === 'logs') { + setActivityFocus('list'); + setActivityScroll(0); + } + }, [detailOpen, activeView]); + + // Clamp scroll when max shrinks (content reduced). + useEffect(() => { + setDetailScroll((o) => Math.min(o, detailScrollMax)); + }, [detailScrollMax]); + + // Persist wrap mode changes (debounced). + const saveTuiSettingsTimer = useRef(null); + useEffect(() => { + if (saveTuiSettingsTimer.current) clearTimeout(saveTuiSettingsTimer.current); + saveTuiSettingsTimer.current = setTimeout(() => { + saveTuiSettings({ wrapMode: detailWrapMode }).catch(() => {}); + }, 300); + return () => { if (saveTuiSettingsTimer.current) clearTimeout(saveTuiSettingsTimer.current); }; + }, [detailWrapMode]); + useEffect(() => { if (!selectedGoal || !onGetGoalProgressRef.current) { setGoalProgressReport(undefined); return; } let cancelled = false; @@ -875,7 +937,7 @@ export function App({ if (entry.type === 'error') { text = typeof entry.data === 'string' ? entry.data : JSON.stringify(entry.data); - text = text.slice(0, 200); + text = text.slice(0, MAX_SUMMARY_LEN); color = tuiColors.red; msgType = 'error'; } else if (entry.type === 'file_changed') { @@ -1906,11 +1968,83 @@ export function App({ return; } + // ── Wrap mode: active on task detail AND logs tab (both show long text) ── + const isTaskDetail = detailOpen && activeView === 'tasks' && !!selectedTask; + const isLogsView = activeView === 'logs' && !detailOpen && !showAgentPicker && !showTypePicker; + // Activity feed is shown on non-logs tabs when no detail is open and there are messages + const isActivityFeed = !detailOpen && activeView !== 'logs' && messages.length > 0 && inputMode === 'none'; + if ((isTaskDetail || isLogsView || isActivityFeed) && input === 'w') { + setDetailWrapMode((m) => (m === 'reading' ? 'wide' : m === 'wide' ? 'off' : 'reading')); + setDetailScroll(0); + return; + } + + // ── Dashboard activity feed: Tab to focus, arrows/PgUp/PgDn to scroll ── + if (isActivityFeed) { + if (key.tab) { + setActivityFocus((f) => (f === 'list' ? 'activity' : 'list')); + return; + } + if (activityFocus === 'activity') { + const feedLen = activityFilteredMessages.length; + const feedMax = Math.max(0, feedLen - 1); + const pageSize = Math.max(1, contentH - 3); + if (key.escape) { setActivityFocus('list'); setActivityScroll(0); return; } + if (key.upArrow) { setActivityScroll((o) => Math.min(feedMax, o + 1)); return; } + if (key.downArrow) { setActivityScroll((o) => Math.max(0, o - 1)); return; } + if (key.pageUp) { setActivityScroll((o) => Math.min(feedMax, o + pageSize)); return; } + if (key.pageDown) { setActivityScroll((o) => Math.max(0, o - pageSize)); return; } + if (input === 'g') { setActivityScroll(feedMax); return; } + if (input === 'G') { setActivityScroll(0); return; } + } + } + if (isTaskDetail) { + // Tab: toggle focus list ↔ detail + if (key.tab) { + setDetailFocus((f) => (f === 'list' ? 'detail' : 'list')); + return; + } + // Detail-focused: scroll keys + Esc returns focus to list + if (detailFocus === 'detail') { + if (key.escape) { + setDetailFocus('list'); + return; + } + if (key.upArrow) { + setDetailScroll((o) => detailScrollReducer({ offset: o }, { type: 'UP' }, detailScrollMax).offset); + return; + } + if (key.downArrow) { + setDetailScroll((o) => detailScrollReducer({ offset: o }, { type: 'DOWN' }, detailScrollMax).offset); + return; + } + if (key.pageUp) { + setDetailScroll((o) => detailScrollReducer({ offset: o }, { type: 'PAGE_UP', pageSize: Math.max(1, contentH - 2) }, detailScrollMax).offset); + return; + } + if (key.pageDown) { + setDetailScroll((o) => detailScrollReducer({ offset: o }, { type: 'PAGE_DOWN', pageSize: Math.max(1, contentH - 2) }, detailScrollMax).offset); + return; + } + // Home / End (Ink exposes these as ctrl+a/ctrl+e on some terms; use raw keys here) + if (input === 'g') { // g — jump to oldest + setDetailScroll(detailScrollMax); + return; + } + if (input === 'G') { // G — jump to newest (pin) + setDetailScroll(0); + return; + } + } + } + // Escape: close detail panel or deselect (never quit — use Q to quit) if (key.escape) { if (detailOpen) { setDetailOpen(false); setGoalDetailScroll(0); + setDetailFocus('list'); + setDetailScroll(0); return; } if (activeView === 'logs' && logSelectedIndex >= 0) { @@ -2243,10 +2377,38 @@ export function App({ } } - // R: run selected task (only in tasks view) - if ((input === 'r' || input === 'R') && activeView === 'tasks' && selectedTask && onRunTask) { + // Shift+R: clone task and run the clone (works on terminal tasks too) + if (input === 'R' && activeView === 'tasks' && selectedTask && onCloneTask) { + const src = selectedTask; + showInfoToast(`Cloning "${src.title}" \u2026`); + onCloneTask(src.id).then( + (cloned) => { + addMessage(`\u2713 Cloned & dispatched "${cloned.title}" (${cloned.id})`, tuiColors.green); + showInfoToast(`Cloned & dispatched "${cloned.title}"`, 'done'); + refreshAll(); + }, + (err) => { + const e = err as Error & { cloned?: Task }; + if (e.cloned) { + const msg = e instanceof Error ? e.message : String(e); + addMessage(`Cloned "${e.cloned.title}" (${e.cloned.id}) \u2014 dispatch failed: ${msg}`, tuiColors.yellow); + showInfoToast(`Cloned "${e.cloned.title}" \u2014 dispatch failed`, 'info'); + refreshAll(); + } else { + const msg = e instanceof Error ? e.message : String(e); + addMessage(`Failed to clone: ${msg}`, tuiColors.red); + showInfoToast(`Failed to clone: ${msg}`, 'failed'); + } + }, + ); + return; + } + + // r: run selected task (only in tasks view) + if (input === 'r' && activeView === 'tasks' && selectedTask && onRunTask) { if (!RUNNABLE.has(selectedTask.status)) { - addMessage(`Cannot run "${selectedTask.title}" \u2014 status is ${selectedTask.status}`, tuiColors.yellow); + const hint = onCloneTask ? ' \u2014 press Shift+R to clone & rerun' : ''; + showInfoToast(`Cannot run "${selectedTask.title}" \u2014 status is ${selectedTask.status}${hint}`); return; } addMessage(`Running "${selectedTask.title}"...`, tuiColors.green); @@ -2479,6 +2641,8 @@ export function App({ agentMsgCounts={agentMsgCounts} taskTitleMap={taskTitleMap} width={ruleW} + wrapMode={detailWrapMode} + readingWidth={detailReadingWidth} /> {showAgentPicker && ( @@ -2547,7 +2711,12 @@ export function App({ + taskTitleMap={taskTitleMap} + wrapMode={detailWrapMode} + readingWidth={detailReadingWidth} + scrollOffset={detailScroll} + focused={detailFocus === 'detail'} + onReportScrollMax={setDetailScrollMax} /> ) : showGoalDetail ? ( <> @@ -2569,17 +2738,25 @@ export function App({ ) : messages.length > 0 && activeView !== 'logs' ? ( <> {(() => { - const suffixText = ` F:${activityFilterLabel.toUpperCase()} \u2502 ${activityFilteredMessages.length}/${messages.length}`; - return 0 ? ` \u25B2${activityScroll}` : ''; + const focusSuffix = activityFocus === 'activity' ? ' \u25B8' : ''; + const suffixText = ` F:${activityFilterLabel.toUpperCase()} \u2502 ${activityFilteredMessages.length}/${messages.length}${scrollBadge}${focusSuffix}`; + const labelColor = activityFocus === 'activity' ? tuiColors.amber : undefined; + return F: {activityFilterLabel.toUpperCase()} {'\u2502'} {activityFilteredMessages.length}/{messages.length} + {activityScroll > 0 && {'\u25B2'}{activityScroll}} + {activityFocus === 'activity' && {'\u25B8'}} } />; })()} + agents={sortedAgents} agentNameMap={agentNameMap} agentColorMap={agentColorMap} + wrapMode={detailWrapMode} readingWidth={detailReadingWidth} + scrollOffset={activityScroll} + focused={activityFocus === 'activity'} /> ) : activeView === 'goals' ? ( @@ -2622,6 +2799,13 @@ export function App({ canToggleShowAll={activeView === 'tasks' && sortedTasks.length > TASK_LIST_LIMIT} showAllActive={showAllTasks} hasDetail={!!(showTaskDetail || showAgentDetail || showGoalDetail)} + detailWrap={(showTaskDetail || (!detailOpen && !inInput && messages.length > 0)) ? detailWrapMode : undefined} + detailFocus={ + showTaskDetail ? detailFocus + : (!detailOpen && !inInput && messages.length > 0 && activeView !== 'logs') + ? activityFocus + : undefined + } itemCount={activeView === 'goals' ? sortedGoals.length : activeView === 'tasks' ? sortedTasks.length : activeView === 'agents' ? liveAgents.length : messages.length} itemLabel={activeView === 'goals' ? 'goals' : activeView === 'tasks' ? 'tasks' : activeView === 'agents' ? 'agents' : 'events'} width={W} @@ -3210,7 +3394,7 @@ function getMsgTextColor(msgType: MsgType, fallback: string): string { /* ── Logs Content ────────────────────────────────────── */ -function LogsContent({ messages, height, agents, logAgentFilter, logTypeFilter, selectedIndex, scrollOffset, agentNameMap, agentColorMap, agentMsgCounts, taskTitleMap, width }: { +function LogsContent({ messages, height, agents, logAgentFilter, logTypeFilter, selectedIndex, scrollOffset, agentNameMap, agentColorMap, agentMsgCounts, taskTitleMap, width, wrapMode, readingWidth }: { messages: StatusMessage[]; height: number; agents: Agent[]; @@ -3223,6 +3407,8 @@ function LogsContent({ messages, height, agents, logAgentFilter, logTypeFilter, agentMsgCounts: Map; taskTitleMap: Map; width: number; + wrapMode: WrapMode; + readingWidth: number; }) { const now = useNow(); @@ -3372,10 +3558,16 @@ function LogsContent({ messages, height, agents, logAgentFilter, logTypeFilter, const badgeLabel = taskTitle && width > 80 ? `#${taskTitle.slice(0, 20)}` : ''; const badgeW = badgeLabel ? badgeLabel.length + 3 : 0; // space + ` #title ` const textW = Math.max(10, (width - 2) - prefixW - badgeW); - const displayText = capLine(msg.text, textW); + const effTextW = wrapMode === 'reading' ? Math.min(textW, readingWidth) : textW; + const wrapped: string[] = wrapMode === 'off' + ? [colCapLine(msg.text, textW)] + : wrapToWidth(msg.text, effTextW); + const displayText = wrapped[0] ?? ''; + const continuationLines = wrapped.slice(1); return ( - + + {/* Left border — agent color accent for sessions */} {sessionStart && showAgentBadge ? '┌' : showConnector ? '│' : ' '} @@ -3431,6 +3623,23 @@ function LogsContent({ messages, height, agents, logAgentFilter, logTypeFilter, )} + {continuationLines.map((line, k) => ( + + + + + + + {' '}{'\u00B7'.padEnd(agentColW)} + + + {' '} + + {line} + + + ))} + ); }) )} @@ -3440,21 +3649,60 @@ function LogsContent({ messages, height, agents, logAgentFilter, logTypeFilter, /* ── Activity Feed ────────────────────────────────────── */ -function ActivityFeed({ messages, height, width, agents, agentNameMap, agentColorMap }: { +function ActivityFeed({ messages, height, width, agents, agentNameMap, agentColorMap, wrapMode, readingWidth, scrollOffset = 0, focused = false, onReportScrollMax }: { messages: StatusMessage[]; height: number; width: number; agents: Agent[]; agentNameMap: Map; agentColorMap: Map; + wrapMode: WrapMode; + readingWidth: number; + scrollOffset?: number; + focused?: boolean; + onReportScrollMax?: (max: number) => void; }) { const now = useNow(); - const visible = messages.slice(-height); // Available text width: total - paddingX(2) - border(1) - ts(5) - agent(9) - icon(2) const textW = Math.max(10, width - 2 - 17); + const effTextW = wrapMode === 'reading' ? Math.min(textW, readingWidth) : textW; + + // Slice messages so that the total rendered rows (after wrap) still fits `height`. + // Walk newest-first, accumulating wrapped-row counts until we fill height. + // scrollOffset: number of newest messages to skip (0 = pinned to bottom / tail). + const clampedOffset = Math.max(0, Math.min(messages.length - 1, scrollOffset)); + const endIdx = messages.length - clampedOffset; // exclusive + const picked: { msg: StatusMessage; rows: string[] }[] = []; + let used = 0; + for (let k = endIdx - 1; k >= 0 && used < height; k--) { + const m = messages[k]!; + const rows: string[] = wrapMode === 'off' + ? [colCapLine(m.text, textW)] + : wrapToWidth(m.text, effTextW); + picked.unshift({ msg: m, rows }); + used += rows.length; + } + const scrollMax = Math.max(0, messages.length - 1); + useEffect(() => { + if (onReportScrollMax) onReportScrollMax(scrollMax); + }, [scrollMax, onReportScrollMax]); + // Trim earliest picked entry if it overflows height + while (used > height && picked.length > 0) { + const first = picked[0]!; + const overflow = used - height; + if (first.rows.length > overflow) { + first.rows = first.rows.slice(overflow); + used -= overflow; + } else { + used -= first.rows.length; + picked.shift(); + } + } + const visible = picked.map((p) => p.msg); + const visibleRows = picked.map((p) => p.rows); // Pad with empty rows so the component always renders exactly `height` rows - const padRows = Math.max(0, height - visible.length); + const padRows = Math.max(0, height - used); // Pre-compute agent group index for zebra striping let groupIdx = 0; @@ -3484,29 +3732,38 @@ function ActivityFeed({ messages, height, width, agents, agentNameMap, agentColo const rowBg = getMsgBg(msgType) ?? (isOddGroup ? '#1a1a1a' : undefined); const relTs = relativeTime(msg.ts, now); - const displayText = capLine(msg.text, textW); + const rows = visibleRows[i]!; return ( - - {/* Left border accent — colored stripe per agent */} - - {!isContinuation && agentName ? '▍' : isContinuation ? '▏' : ' '} - - {/* Relative timestamp — dimmed on continuation */} - - - {isContinuation ? ' ' : relTs.padStart(4)} - - - - {agentName && !isContinuation ? ( - {' '}{agentName.slice(0, 8)} - ) : ( - {AGENT_INDENT} - )} - - {icon} - {displayText} + + {rows.map((line, r) => { + const isHeader = r === 0; + return ( + + {/* Left border accent */} + + {isHeader && !isContinuation && agentName ? '▍' : '▏'} + + {/* Relative timestamp — only on header row */} + + + {isHeader && !isContinuation ? relTs.padStart(4) : ' '} + + + + {isHeader && agentName && !isContinuation ? ( + {' '}{agentName.slice(0, 8)} + ) : ( + {AGENT_INDENT} + )} + + + {isHeader ? `${icon} ` : ' '} + + {line} + + ); + })} ); })} @@ -3605,18 +3862,20 @@ function LogDetailPanel({ message, height, width, agents, agentNameMap, agentCol /* ── Section Labels ───────────────────────────────────── */ -function SectionLabel({ label, width, suffix, suffixLen = 0 }: { label: string; width: number; suffix?: React.ReactNode; suffixLen?: number }) { +function SectionLabel({ label, width, suffix, suffixLen = 0, color }: { label: string; width: number; suffix?: React.ReactNode; suffixLen?: number; color?: string }) { // Chip-style section label: ━━━━[ LABEL ]━━ suffix ━━━━━━━━━━━━ const chipText = ` ${label} `; const leftRuleLen = 3; const usedLen = leftRuleLen + chipText.length + 2; + const chipColor = color ?? tuiColors.dim; + const ruleColor = color ?? tuiColors.ghost; if (!suffix) { const rightRuleLen = Math.max(0, width - usedLen); return ( - {heavyRule(leftRuleLen)} - {chipText} - {heavyRule(rightRuleLen)} + {heavyRule(leftRuleLen)} + {chipText} + {heavyRule(rightRuleLen)} ); } @@ -3624,11 +3883,11 @@ function SectionLabel({ label, width, suffix, suffixLen = 0 }: { label: string; const trailLen = Math.max(0, width - usedLen - gapLen - suffixLen); return ( - {heavyRule(leftRuleLen)} - {chipText} - {heavyRule(gapLen)} + {heavyRule(leftRuleLen)} + {chipText} + {heavyRule(gapLen)} {suffix} - {heavyRule(trailLen)} + {heavyRule(trailLen)} ); } @@ -3989,7 +4248,7 @@ function formatAgentOutput(raw: string): { summary: string | null; detail: strin // Claude API message: {"type":"message","role":"assistant","content":[...]} if (parsed.type === 'message' && parsed.role === 'assistant') { const text = extractTextFromContent(parsed.content); - if (text) return { summary: text.slice(0, 200), detail: detail() }; + if (text) return { summary: text.slice(0, MAX_SUMMARY_LEN), detail: detail() }; // Empty assistant messages (tool_use-only or empty content) — skip return { summary: null, detail: '' }; } @@ -3998,7 +4257,7 @@ function formatAgentOutput(raw: string): { summary: string | null; detail: strin if (parsed.type === 'assistant' || parsed.role === 'assistant') { const content = parsed.message?.content ?? parsed.content; const text = extractTextFromContent(content); - if (text) return { summary: text.slice(0, 200), detail: detail() }; + if (text) return { summary: text.slice(0, MAX_SUMMARY_LEN), detail: detail() }; // Empty assistant messages (tool_use-only or empty content) — skip return { summary: null, detail: '' }; } @@ -4026,7 +4285,7 @@ function formatAgentOutput(raw: string): { summary: string | null; detail: strin // Result / done if (parsed.type === 'result') { const text = typeof parsed.result === 'string' ? parsed.result : null; - return { summary: text ? `\u2713 ${text.slice(0, 180)}` : '\u2713 Agent finished', detail: detail() }; + return { summary: text ? `\u2713 ${text.slice(0, MAX_SUMMARY_LEN)}` : '\u2713 Agent finished', detail: detail() }; } // Rate limit event @@ -4040,7 +4299,7 @@ function formatAgentOutput(raw: string): { summary: string | null; detail: strin if (parsed.message) { const content = parsed.message.content ?? parsed.message; const text = extractTextFromContent(content); - if (text) return { summary: text.slice(0, 200), detail: detail() }; + if (text) return { summary: text.slice(0, MAX_SUMMARY_LEN), detail: detail() }; } return { summary: `[${parsed.subtype}]`, detail: detail() }; } @@ -4048,7 +4307,7 @@ function formatAgentOutput(raw: string): { summary: string | null; detail: strin // Generic: try content field if (parsed.content) { const text = extractTextFromContent(parsed.content); - if (text) return { summary: text.slice(0, 200), detail: detail() }; + if (text) return { summary: text.slice(0, MAX_SUMMARY_LEN), detail: detail() }; } // Fallback: show type or truncate diff --git a/src/tui/components/ToastBanner.tsx b/src/tui/components/ToastBanner.tsx index df57d15..e291354 100644 --- a/src/tui/components/ToastBanner.tsx +++ b/src/tui/components/ToastBanner.tsx @@ -12,13 +12,15 @@ import { tuiColors, LOZENGE } from '../colors.js'; /* ── Types ───────────────────────────────────────── */ -export type ToastType = 'done' | 'failed' | 'review'; +export type ToastType = 'done' | 'failed' | 'review' | 'info'; export interface Toast { id: string; type: ToastType; title: string; agentName?: string; + /** Custom message — overrides the default per-type message when set. */ + message?: string; ts: number; } @@ -38,29 +40,34 @@ const AUTO_DISMISS_MS: Record = { done: 4000, failed: 8000, review: 6000, + info: 4000, }; const ICON: Record = { done: '\u2713', // ✓ failed: '\u2715', // ✕ review: LOZENGE, // ◈ + info: '\u25CF', // ● }; const FG: Record = { done: tuiColors.green, failed: tuiColors.red, review: tuiColors.blue, + info: tuiColors.amber, }; const BG: Record = { done: tuiColors.successBg, failed: tuiColors.errorBg, review: tuiColors.infoBg, + info: tuiColors.warnBg, }; /* ── Helpers ─────────────────────────────────────── */ function toastMessage(toast: Toast): string { + if (toast.message !== undefined) return toast.message; const agent = toast.agentName ?? 'Agent'; switch (toast.type) { case 'done': @@ -69,6 +76,8 @@ function toastMessage(toast: Toast): string { return 'Task failed \u2014 press Enter for details'; case 'review': return 'Task ready for review \u2014 press A to approve'; + case 'info': + return ''; } } diff --git a/test/unit/application/task-service.test.ts b/test/unit/application/task-service.test.ts index ee86d5e..5d4651d 100644 --- a/test/unit/application/task-service.test.ts +++ b/test/unit/application/task-service.test.ts @@ -281,6 +281,42 @@ describe('TaskService', () => { }); }); + describe('clone', () => { + it('creates a new todo task copying fields from source', async () => { + const src = makeTask({ + status: 'done', + title: 'Original', + description: 'desc', + priority: 2, + labels: ['a', 'b'], + goalId: 'gol_x', + attempts: 3, + }); + taskStore = createMockTaskStore([src]); + service = new TaskService(taskStore, eventBus, DEFAULT_CONFIG); + + const cloned = await service.clone('tsk_test1'); + expect(cloned.id).not.toBe(src.id); + expect(cloned.status).toBe('todo'); + expect(cloned.title).toBe('Original'); + expect(cloned.description).toBe('desc'); + expect(cloned.priority).toBe(2); + expect(cloned.labels).toEqual(['a', 'b']); + expect(cloned.goalId).toBe('gol_x'); + expect(cloned.attempts).toBe(0); + }); + + it('works on terminal statuses (done, failed, cancelled)', async () => { + for (const status of ['done', 'failed', 'cancelled'] as const) { + const src = makeTask({ id: `tsk_${status}`, status }); + taskStore = createMockTaskStore([src]); + service = new TaskService(taskStore, eventBus, DEFAULT_CONFIG); + const cloned = await service.clone(src.id); + expect(cloned.status).toBe('todo'); + } + }); + }); + describe('delete', () => { it('deletes a non-running task', async () => { const existing = makeTask({ status: 'done' });