Skip to content

Commit 88e4e1c

Browse files
author
CodeKing
committed
feat: terminal command UX overhaul - unified output, auto-collapse, scroll improvements, status/duration persistence
1 parent f39d747 commit 88e4e1c

23 files changed

Lines changed: 2542 additions & 58 deletions

packages/core/src/message-utils/consolidateCommands.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,23 @@ export function consolidateCommands(messages: ClineMessage[]): ClineMessage[] {
103103
const isDuplicate = previous && previous.type !== type && previous.text === text
104104

105105
if (text.length > 0 && !isDuplicate) {
106-
// Add a newline before adding the text if there's already content
107-
if (
108-
previous &&
109-
consolidatedText.length >
110-
consolidatedText.indexOf(COMMAND_OUTPUT_STRING) + COMMAND_OUTPUT_STRING.length
111-
) {
112-
consolidatedText += "\n"
106+
// command_output messages are cumulative — each streaming
107+
// partial contains the full output so far. Replace the
108+
// output portion after COMMAND_OUTPUT_STRING rather than
109+
// appending so that repeated cumulative snapshots don't
110+
// duplicate content.
111+
const outputStartIdx = consolidatedText.lastIndexOf(COMMAND_OUTPUT_STRING)
112+
const hasOutput =
113+
outputStartIdx !== -1 &&
114+
consolidatedText.length > outputStartIdx + COMMAND_OUTPUT_STRING.length
115+
116+
if (hasOutput) {
117+
// Replace everything after "Output:" with the new (superset) text.
118+
consolidatedText =
119+
consolidatedText.slice(0, outputStartIdx + COMMAND_OUTPUT_STRING.length) + "\n" + text
120+
} else {
121+
consolidatedText += "\n" + text
113122
}
114-
consolidatedText += text
115123
}
116124

117125
previous = { type, text }

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ export const globalSettingsSchema = z.object({
220220
includeTaskHistoryInEnhance: z.boolean().optional(),
221221
historyPreviewCollapsed: z.boolean().optional(),
222222
reasoningBlockCollapsed: z.boolean().optional(),
223+
autoCollapseLongMessages: z.boolean().optional(),
224+
longMessageCollapseThreshold: z.number().int().min(5).max(500).optional(),
223225
/**
224226
* Font size (in pixels) for the Zoo Code chat/webview UI.
225227
* When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`.

packages/types/src/vscode-extension-host.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,8 @@ export type ExtensionState = Pick<
310310
| "openRouterImageGenerationSelectedModel"
311311
| "includeTaskHistoryInEnhance"
312312
| "reasoningBlockCollapsed"
313+
| "autoCollapseLongMessages"
314+
| "longMessageCollapseThreshold"
313315
| "chatFontSize"
314316
| "enterBehavior"
315317
| "includeCurrentTime"

src/core/tools/ExecuteCommandTool.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ export async function executeCommandInTerminal(
281281
}
282282

283283
let accumulatedOutput = ""
284+
// Record the epoch-ms when the shell execution starts so we can persist
285+
// timing markers alongside the exit code marker in the final command_output.
286+
let commandStartTime = 0
284287
// Bound accumulated output buffer size to prevent unbounded memory growth for long-running commands.
285288
// The interceptor preserves full output; this buffer is only for UI display (100KB limit).
286289
const maxAccumulatedOutputSize = 100_000
@@ -394,17 +397,39 @@ export async function executeCommandInTerminal(
394397
result = Terminal.compressTerminalOutput(output ?? "")
395398
latestCompressedOutput = result
396399

400+
// Embed exit code in the persisted command_output text so the webview
401+
// can derive command failure/success status after remount (e.g. chat
402+
// switch). `exitDetails` is always set before onCompleted fires
403+
// because shell_execution_complete always precedes the completed event
404+
// in both the VSCode and Execa terminal implementations.
405+
// The marker is appended to the *persisted* output only — `result`
406+
// (used for the LLM tool result) stays clean.
407+
let persistedOutput = result
408+
const commandEndTime = Date.now()
409+
// Embed timing markers so the webview can compute duration after
410+
// remount (e.g. chat switch, extension reload). The markers are
411+
// appended to the *persisted* output only — `result` (used for the
412+
// LLM tool result) stays clean.
413+
if (commandStartTime > 0) {
414+
persistedOutput += `\n[__START_TIME__:${commandStartTime}]`
415+
persistedOutput += `\n[__END_TIME__:${commandEndTime}]`
416+
}
417+
if (exitDetails?.exitCode !== undefined) {
418+
persistedOutput += `\n[__EXIT_CODE__:${exitDetails.exitCode}]`
419+
}
420+
397421
// Preserve order: wait for queued partial updates, then emit the final
398422
// non-partial command_output update.
399423
await commandOutputSayChain
400-
await queueCommandOutputMessage(result, false, true)
424+
await queueCommandOutputMessage(persistedOutput, false, true)
401425
completed = true
402426
} finally {
403427
// Signal that onCompleted has finished, so the main code can safely use persistedResult
404428
resolveOnCompleted?.()
405429
}
406430
},
407431
onShellExecutionStarted: (pid: number | undefined) => {
432+
commandStartTime = Date.now()
408433
const status: CommandExecutionStatus = { executionId, status: "started", pid, command }
409434
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
410435
},

src/core/webview/ClineProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2294,6 +2294,8 @@ export class ClineProvider
22942294
maxTotalImageSize,
22952295
historyPreviewCollapsed,
22962296
reasoningBlockCollapsed,
2297+
autoCollapseLongMessages,
2298+
longMessageCollapseThreshold,
22972299
chatFontSize,
22982300
enterBehavior,
22992301
cloudUserInfo,
@@ -2458,6 +2460,8 @@ export class ClineProvider
24582460
settingsImportedAt: this.settingsImportedAt,
24592461
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
24602462
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
2463+
autoCollapseLongMessages: autoCollapseLongMessages ?? true,
2464+
longMessageCollapseThreshold: longMessageCollapseThreshold ?? 10,
24612465
chatFontSize,
24622466
enterBehavior: enterBehavior ?? "send",
24632467
cloudUserInfo,
@@ -2665,6 +2669,8 @@ export class ClineProvider
26652669
maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,
26662670
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
26672671
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
2672+
autoCollapseLongMessages: stateValues.autoCollapseLongMessages ?? true,
2673+
longMessageCollapseThreshold: stateValues.longMessageCollapseThreshold ?? 10,
26682674
chatFontSize: stateValues.chatFontSize,
26692675
enterBehavior: stateValues.enterBehavior ?? "send",
26702676
cloudUserInfo,

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
ClineAskUseMcpServer,
1313
ClineSayTool,
1414
} from "@roo-code/types"
15+
import type { CollapseDecision } from "@src/utils/messageSize"
1516

1617
import { Mode } from "@roo/modes"
1718

@@ -43,7 +44,7 @@ import { BatchFilePermission } from "./BatchFilePermission"
4344
import { BatchDiffApproval } from "./BatchDiffApproval"
4445
import { ProgressIndicator } from "./ProgressIndicator"
4546
import { Markdown } from "./Markdown"
46-
import { CommandExecution } from "./CommandExecution"
47+
import { CommandExecution, parseCommandAndOutput } from "./CommandExecution"
4748
import { CommandExecutionError } from "./CommandExecutionError"
4849
import { AutoApprovedRequestLimitWarning } from "./AutoApprovedRequestLimitWarning"
4950
import { InProgressRow, CondensationResultRow, CondensationErrorRow, TruncationResultRow } from "./context-management"
@@ -109,10 +110,13 @@ function getPreviousTodos(messages: ClineMessage[], currentMessageTs: number): a
109110
return []
110111
}
111112

113+
import { MessageCollapsePreview } from "./MessageCollapsePreview"
114+
112115
interface ChatRowProps {
113116
message: ClineMessage
114117
lastModifiedMessage?: ClineMessage
115118
isExpanded: boolean
119+
collapseDecision?: CollapseDecision | null
116120
isLast: boolean
117121
isStreaming: boolean
118122
onToggleExpand: (ts: number) => void
@@ -138,7 +142,7 @@ const ChatRow = memo(
138142
const prevHeightRef = useRef(0)
139143

140144
const [chatrow, { height }] = useSize(
141-
<div className="px-[15px] py-[10px] pr-[6px]">
145+
<div data-message-row={message.ts} className="px-[15px] py-[10px] pr-[6px]">
142146
<ChatRowContent {...props} />
143147
</div>,
144148
)
@@ -170,6 +174,7 @@ export const ChatRowContent = ({
170174
message,
171175
lastModifiedMessage,
172176
isExpanded,
177+
collapseDecision,
173178
isLast,
174179
isStreaming,
175180
onToggleExpand,
@@ -277,17 +282,41 @@ export const ChatRowContent = ({
277282
case "error":
278283
case "mistake_limit_reached":
279284
return [null, null] // These will be handled by ErrorRow component
280-
case "command":
285+
case "command": {
286+
const { exitCode } = parseCommandAndOutput(message.text)
287+
const hasExited = !isCommandExecuting && exitCode !== undefined
288+
const commandFailed = hasExited && exitCode !== 0
289+
const commandSucceeded = hasExited && exitCode === 0
290+
281291
return [
282292
isCommandExecuting ? (
283293
<ProgressIndicator />
284294
) : (
285-
<TerminalSquare className="size-4" aria-label="Terminal icon" />
295+
<TerminalSquare
296+
className="size-4"
297+
aria-label="Terminal icon"
298+
style={{ color: commandFailed ? errorColor : commandSucceeded ? successColor : undefined }}
299+
/>
300+
),
301+
isCommandExecuting ? (
302+
<span style={{ color: normalColor, fontWeight: "bold" }}>
303+
{t("chat:commandExecution.running")}
304+
</span>
305+
) : commandFailed ? (
306+
<span style={{ color: errorColor, fontWeight: "bold" }}>
307+
{t("chat:commandExecution.failed")}
308+
</span>
309+
) : commandSucceeded ? (
310+
<span style={{ color: successColor, fontWeight: "bold" }}>
311+
{t("chat:commandExecution.completed")}
312+
</span>
313+
) : (
314+
<span style={{ color: normalColor, fontWeight: "bold" }}>
315+
{t("chat:commandExecution.running")}
316+
</span>
286317
),
287-
<span style={{ color: normalColor, fontWeight: "bold" }}>
288-
{t("chat:commandExecution.running")}
289-
</span>,
290318
]
319+
}
291320
case "use_mcp_server":
292321
const mcpServerUse = safeJsonParse<ClineAskUseMcpServer>(message.text)
293322
if (mcpServerUse === undefined) {
@@ -1008,6 +1037,19 @@ export const ChatRowContent = ({
10081037
}
10091038
}
10101039

1040+
// Auto-collapse: when message is collapsed and has a collapse decision,
1041+
// render the preview instead of full content.
1042+
if (!isExpanded && collapseDecision) {
1043+
return (
1044+
<MessageCollapsePreview
1045+
message={message}
1046+
decision={collapseDecision}
1047+
onExpand={() => onToggleExpand(message.ts)}
1048+
isCommandExecuting={isCommandExecuting}
1049+
/>
1050+
)
1051+
}
1052+
10111053
switch (message.type) {
10121054
case "say":
10131055
switch (message.say) {

0 commit comments

Comments
 (0)