Skip to content

Commit 511586d

Browse files
committed
More progress
1 parent 607390b commit 511586d

24 files changed

+1828
-152
lines changed

apps/cli/src/ui/App.tsx

Lines changed: 229 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Header from "./components/Header.js"
1616
import ChatHistoryItem from "./components/ChatHistoryItem.js"
1717
import LoadingText from "./components/LoadingText.js"
1818
import ToastDisplay from "./components/ToastDisplay.js"
19+
import TodoDisplay from "./components/TodoDisplay.js"
1920
import { useToast } from "./hooks/useToast.js"
2021
import {
2122
AutocompleteInput,
@@ -54,6 +55,7 @@ import type {
5455
SlashCommandResult,
5556
ModeResult,
5657
TaskHistoryItem,
58+
ToolData,
5759
} from "./types.js"
5860
import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js"
5961

@@ -243,6 +245,9 @@ function AppInner({
243245
const seenMessageIds = useRef<Set<string>>(new Set())
244246
const firstTextMessageSkipped = useRef(false)
245247

248+
// Track pending command for injecting into command_output toolData
249+
const pendingCommandRef = useRef<string | null>(null)
250+
246251
// Track Ctrl+C presses for "press again to exit" behavior
247252
const [showExitHint, setShowExitHint] = useState(false)
248253
const exitHintTimeout = useRef<NodeJS.Timeout | null>(null)
@@ -260,6 +265,9 @@ function AppInner({
260265
// Manual focus override: 'scroll' | 'input' | null (null = auto-determine)
261266
const [manualFocus, setManualFocus] = useState<"scroll" | "input" | null>(null)
262267

268+
// State for TODO list viewer (shown via Ctrl+T shortcut)
269+
const [showTodoViewer, setShowTodoViewer] = useState(false)
270+
263271
// Autocomplete picker state (received from AutocompleteInput via callback)
264272
// eslint-disable-next-line @typescript-eslint/no-explicit-any
265273
const [pickerState, setPickerState] = useState<AutocompletePickerState<any>>({
@@ -430,7 +438,32 @@ function AppInner({
430438
return
431439
}
432440

441+
// Ctrl+T to toggle TODO list viewer
442+
if (matchesGlobalSequence(input, key, "ctrl-t")) {
443+
// Close picker if open
444+
if (pickerState.isOpen) {
445+
autocompleteRef.current?.closePicker()
446+
followupAutocompleteRef.current?.closePicker()
447+
}
448+
// Toggle TODO viewer
449+
setShowTodoViewer((prev) => {
450+
const newValue = !prev
451+
if (newValue && currentTodos.length === 0) {
452+
showInfo("No TODO list available", 2000)
453+
return false
454+
}
455+
return newValue
456+
})
457+
return
458+
}
459+
433460
// Escape key to cancel/pause task when loading (streaming)
461+
// Escape key to close TODO viewer
462+
if (key.escape && showTodoViewer) {
463+
setShowTodoViewer(false)
464+
return
465+
}
466+
434467
if (key.escape && isLoading && hostRef.current) {
435468
// If picker is open, let the picker handle escape first
436469
if (pickerState.isOpen) {
@@ -599,12 +632,23 @@ function AppInner({
599632
let toolName: string | undefined
600633
let toolDisplayName: string | undefined
601634
let toolDisplayOutput: string | undefined
635+
let toolData: ToolData | undefined
602636

603637
if (say === "command_output") {
604638
role = "tool"
605639
toolName = "execute_command"
606640
toolDisplayName = "bash"
607641
toolDisplayOutput = text
642+
// Create toolData for command output, including the pending command if available
643+
const trackedCommand = pendingCommandRef.current
644+
toolInspectorLog("say:command_output", { ts, trackedCommand, outputLength: text?.length })
645+
toolData = {
646+
tool: "execute_command",
647+
command: trackedCommand || undefined,
648+
output: text,
649+
}
650+
// Clear the pending command after using it
651+
pendingCommandRef.current = null
608652
} else if (say === "tool") {
609653
role = "tool"
610654
try {
@@ -621,6 +665,8 @@ function AppInner({
621665
toolName = toolInfo.tool
622666
toolDisplayName = toolInfo.tool
623667
toolDisplayOutput = formatToolOutput(toolInfo)
668+
// Extract structured toolData for rich rendering
669+
toolData = extractToolData(toolInfo)
624670

625671
// Special handling for update_todo_list tool
626672
if (toolName === "update_todo_list" || toolName === "updateTodoList") {
@@ -643,6 +689,7 @@ function AppInner({
643689
originalType: say,
644690
todos,
645691
previousTodos: prevTodos,
692+
toolData,
646693
})
647694
return
648695
}
@@ -665,6 +712,7 @@ function AppInner({
665712
toolDisplayOutput,
666713
partial,
667714
originalType: say,
715+
toolData,
668716
})
669717
},
670718
[addMessage, verbose, currentTodos, setTodos],
@@ -707,9 +755,52 @@ function AppInner({
707755
seenMessageIds.current.add(messageId)
708756
setComplete(true)
709757
setLoading(false)
758+
759+
// Parse the completion result and add a message for CompletionTool to render
760+
try {
761+
const completionInfo = JSON.parse(text) as Record<string, unknown>
762+
const toolData: ToolData = {
763+
tool: "attempt_completion",
764+
result: completionInfo.result as string | undefined,
765+
content: completionInfo.result as string | undefined,
766+
}
767+
768+
addMessage({
769+
id: messageId,
770+
role: "tool",
771+
content: text,
772+
toolName: "attempt_completion",
773+
toolDisplayName: "Task Complete",
774+
toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
775+
originalType: ask,
776+
toolData,
777+
})
778+
} catch {
779+
// If parsing fails, still add a basic completion message
780+
addMessage({
781+
id: messageId,
782+
role: "tool",
783+
content: text || "Task completed",
784+
toolName: "attempt_completion",
785+
toolDisplayName: "Task Complete",
786+
toolDisplayOutput: "✅ Task completed",
787+
originalType: ask,
788+
toolData: {
789+
tool: "attempt_completion",
790+
content: text,
791+
},
792+
})
793+
}
710794
return
711795
}
712796

797+
// Track pending command BEFORE nonInteractive handling
798+
// This ensures we capture the command text for later injection into command_output toolData
799+
if (ask === "command") {
800+
toolInspectorLog("ask:command:tracking", { ts, text })
801+
pendingCommandRef.current = text
802+
}
803+
713804
if (nonInteractive && ask !== "followup") {
714805
seenMessageIds.current.add(messageId)
715806

@@ -718,6 +809,9 @@ function AppInner({
718809
let toolDisplayName: string | undefined
719810
let toolDisplayOutput: string | undefined
720811
let formattedContent = text || ""
812+
let toolData: ToolData | undefined
813+
let todos: TodoItem[] | undefined
814+
let previousTodos: TodoItem[] | undefined
721815

722816
try {
723817
const toolInfo = JSON.parse(text) as Record<string, unknown>
@@ -734,6 +828,19 @@ function AppInner({
734828
toolDisplayName = toolInfo.tool as string
735829
toolDisplayOutput = formatToolOutput(toolInfo)
736830
formattedContent = formatToolAskMessage(toolInfo)
831+
// Extract structured toolData for rich rendering
832+
toolData = extractToolData(toolInfo)
833+
834+
// Special handling for update_todo_list tool - extract todos
835+
if (toolName === "update_todo_list" || toolName === "updateTodoList") {
836+
const parsedTodos = parseTodosFromToolInfo(toolInfo)
837+
if (parsedTodos && parsedTodos.length > 0) {
838+
todos = parsedTodos
839+
// Capture previous todos before updating global state
840+
previousTodos = [...currentTodos]
841+
setTodos(parsedTodos)
842+
}
843+
}
737844
} catch {
738845
// Use raw text if not valid JSON
739846
}
@@ -746,6 +853,9 @@ function AppInner({
746853
toolDisplayName,
747854
toolDisplayOutput,
748855
originalType: ask,
856+
toolData,
857+
todos,
858+
previousTodos,
749859
})
750860
} else {
751861
addMessage({
@@ -786,6 +896,7 @@ function AppInner({
786896
// Use raw text if not valid JSON
787897
}
788898
}
899+
// Note: ask === "command" is handled above before the nonInteractive block
789900

790901
seenMessageIds.current.add(messageId)
791902

@@ -796,7 +907,7 @@ function AppInner({
796907
suggestions,
797908
})
798909
},
799-
[addMessage, setPendingAsk, setComplete, setLoading, nonInteractive],
910+
[addMessage, setPendingAsk, setComplete, setLoading, nonInteractive, currentTodos, setTodos],
800911
)
801912

802913
// Handle extension messages
@@ -1282,7 +1393,7 @@ function AppInner({
12821393
) : isScrollAreaActive ? (
12831394
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
12841395
) : isInputAreaActive ? (
1285-
<Text color={theme.dimText}>? for shortcuts • Ctrl+M mode</Text>
1396+
<Text color={theme.dimText}>? for shortcuts</Text>
12861397
) : null
12871398

12881399
// Get render function for picker items based on active trigger
@@ -1428,7 +1539,14 @@ function AppInner({
14281539
prompt="› "
14291540
/>
14301541
<HorizontalLine active={isInputAreaActive} />
1431-
{pickerState.isOpen ? (
1542+
{showTodoViewer ? (
1543+
<Box flexDirection="column" height={PICKER_HEIGHT}>
1544+
<TodoDisplay todos={currentTodos} showProgress={true} title="TODO List" />
1545+
<Box height={1}>
1546+
<Text color={theme.dimText}>Ctrl+T to close</Text>
1547+
</Box>
1548+
</Box>
1549+
) : pickerState.isOpen ? (
14321550
<Box flexDirection="column" height={PICKER_HEIGHT}>
14331551
<PickerSelect
14341552
results={pickerState.results}
@@ -1464,6 +1582,114 @@ export function App(props: TUIAppProps) {
14641582
)
14651583
}
14661584

1585+
/**
1586+
* Extract structured ToolData from parsed tool JSON
1587+
* This provides rich data for tool-specific renderers
1588+
*/
1589+
function extractToolData(toolInfo: Record<string, unknown>): ToolData {
1590+
const toolName = (toolInfo.tool as string) || "unknown"
1591+
1592+
// Base tool data with common fields
1593+
const toolData: ToolData = {
1594+
tool: toolName,
1595+
path: toolInfo.path as string | undefined,
1596+
isOutsideWorkspace: toolInfo.isOutsideWorkspace as boolean | undefined,
1597+
isProtected: toolInfo.isProtected as boolean | undefined,
1598+
content: toolInfo.content as string | undefined,
1599+
reason: toolInfo.reason as string | undefined,
1600+
}
1601+
1602+
// Extract diff-related fields
1603+
if (toolInfo.diff !== undefined) {
1604+
toolData.diff = toolInfo.diff as string
1605+
}
1606+
if (toolInfo.diffStats !== undefined) {
1607+
const stats = toolInfo.diffStats as { added?: number; removed?: number }
1608+
if (typeof stats.added === "number" && typeof stats.removed === "number") {
1609+
toolData.diffStats = { added: stats.added, removed: stats.removed }
1610+
}
1611+
}
1612+
1613+
// Extract search-related fields
1614+
if (toolInfo.regex !== undefined) {
1615+
toolData.regex = toolInfo.regex as string
1616+
}
1617+
if (toolInfo.filePattern !== undefined) {
1618+
toolData.filePattern = toolInfo.filePattern as string
1619+
}
1620+
if (toolInfo.query !== undefined) {
1621+
toolData.query = toolInfo.query as string
1622+
}
1623+
1624+
// Extract mode-related fields
1625+
if (toolInfo.mode !== undefined) {
1626+
toolData.mode = toolInfo.mode as string
1627+
}
1628+
if (toolInfo.mode_slug !== undefined) {
1629+
toolData.mode = toolInfo.mode_slug as string
1630+
}
1631+
1632+
// Extract command-related fields
1633+
if (toolInfo.command !== undefined) {
1634+
toolData.command = toolInfo.command as string
1635+
}
1636+
if (toolInfo.output !== undefined) {
1637+
toolData.output = toolInfo.output as string
1638+
}
1639+
1640+
// Extract browser-related fields
1641+
if (toolInfo.action !== undefined) {
1642+
toolData.action = toolInfo.action as string
1643+
}
1644+
if (toolInfo.url !== undefined) {
1645+
toolData.url = toolInfo.url as string
1646+
}
1647+
if (toolInfo.coordinate !== undefined) {
1648+
toolData.coordinate = toolInfo.coordinate as string
1649+
}
1650+
1651+
// Extract batch file operations
1652+
if (Array.isArray(toolInfo.files)) {
1653+
toolData.batchFiles = (toolInfo.files as Array<Record<string, unknown>>).map((f) => ({
1654+
path: (f.path as string) || "",
1655+
lineSnippet: f.lineSnippet as string | undefined,
1656+
isOutsideWorkspace: f.isOutsideWorkspace as boolean | undefined,
1657+
key: f.key as string | undefined,
1658+
content: f.content as string | undefined,
1659+
}))
1660+
}
1661+
1662+
// Extract batch diff operations
1663+
if (Array.isArray(toolInfo.batchDiffs)) {
1664+
toolData.batchDiffs = (toolInfo.batchDiffs as Array<Record<string, unknown>>).map((d) => ({
1665+
path: (d.path as string) || "",
1666+
changeCount: d.changeCount as number | undefined,
1667+
key: d.key as string | undefined,
1668+
content: d.content as string | undefined,
1669+
diffStats: d.diffStats as { added: number; removed: number } | undefined,
1670+
diffs: d.diffs as Array<{ content: string; startLine?: number }> | undefined,
1671+
}))
1672+
}
1673+
1674+
// Extract question/completion fields
1675+
if (toolInfo.question !== undefined) {
1676+
toolData.question = toolInfo.question as string
1677+
}
1678+
if (toolInfo.result !== undefined) {
1679+
toolData.result = toolInfo.result as string
1680+
}
1681+
1682+
// Extract additional display hints
1683+
if (toolInfo.lineNumber !== undefined) {
1684+
toolData.lineNumber = toolInfo.lineNumber as number
1685+
}
1686+
if (toolInfo.additionalFileCount !== undefined) {
1687+
toolData.additionalFileCount = toolInfo.additionalFileCount as number
1688+
}
1689+
1690+
return toolData
1691+
}
1692+
14671693
/**
14681694
* Format tool output for display (used in the message body, header shows tool name separately)
14691695
*/

0 commit comments

Comments
 (0)