@saadnvd1/agent-os 0.3.737 → 0.3.742

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/components/Chat/Activity.test.ts +2 -1
  2. package/components/Chat/Activity.tsx +102 -64
  3. package/components/Chat/ChatPanel.tsx +16 -3
  4. package/components/Chat/LiveOutput.test.ts +36 -0
  5. package/components/Chat/Messages.test.ts +13 -1
  6. package/components/Chat/Messages.tsx +19 -2
  7. package/components/Chat/StepOutcomes.tsx +66 -0
  8. package/components/Chat/Tools.test.ts +52 -0
  9. package/components/Chat/Tools.tsx +63 -9
  10. package/data/chat/useChat.ts +10 -1
  11. package/lib/chat/drivers/claude-mapper.ts +10 -0
  12. package/lib/chat/drivers/claude.ts +9 -1
  13. package/lib/chat/drivers/edit-guard.ts +311 -0
  14. package/lib/chat/drivers/opencode-config.ts +111 -0
  15. package/lib/chat/drivers/opencode-mapper.ts +125 -13
  16. package/lib/chat/drivers/opencode-plugin.ts +78 -0
  17. package/lib/chat/drivers/opencode-server.ts +7 -2
  18. package/lib/chat/drivers/opencode.ts +108 -9
  19. package/lib/chat/drivers/pi-edit-guard.ts +87 -0
  20. package/lib/chat/drivers/pi-mapper.ts +91 -2
  21. package/lib/chat/drivers/pi.ts +95 -4
  22. package/lib/chat/drivers/transient-retry.ts +87 -0
  23. package/lib/chat/drivers/turn-items.ts +74 -1
  24. package/lib/chat/drivers/turn-outcome.ts +73 -1
  25. package/lib/chat/events.ts +17 -1
  26. package/lib/chat/group.ts +23 -3
  27. package/lib/chat/model-wait-live.ts +19 -0
  28. package/lib/chat/model-wait.ts +88 -0
  29. package/lib/chat/page.ts +3 -0
  30. package/lib/chat/progress.ts +169 -0
  31. package/lib/chat/registry.ts +5 -0
  32. package/lib/chat/runner-watch.ts +68 -0
  33. package/lib/chat/runner.ts +4 -70
  34. package/lib/chat/tool-outcome.ts +45 -0
  35. package/lib/chat/tools.ts +14 -0
  36. package/package.json +1 -1
  37. package/scripts/screenshots/chat-verify.ts +68 -0
  38. package/scripts/screenshots/seed.ts +9 -0
@@ -5,7 +5,7 @@ describe("currentActivity while compacting", () => {
5
5
  it("says so, with the size going in and when it began", () => {
6
6
  expect(
7
7
  currentActivity([], "running", { since: 5, before: 182_000 })
8
- ).toEqual({
8
+ ).toMatchObject({
9
9
  label: "Compacting context… 182k tokens",
10
10
  since: 5,
11
11
  compacting: true,
@@ -18,5 +18,6 @@ describe("currentActivity while compacting", () => {
18
18
  it("is plain work without it, and nothing once the turn is over", () => {
19
19
  expect(currentActivity([], "running")?.compacting).toBeUndefined();
20
20
  expect(currentActivity([], "idle", { since: 5 })).toBeNull();
21
+ expect(currentActivity([], "running")?.label).toBe("Waiting for the model");
21
22
  });
22
23
  });
@@ -1,91 +1,129 @@
1
1
  "use client";
2
2
 
3
- import { Loader2, MessageCircleQuestion, Shrink, Square } from "lucide-react";
4
- import type { ChatItem, ChatState, Compacting } from "@/lib/chat/events";
3
+ import {
4
+ AlertTriangle,
5
+ Loader2,
6
+ MessageCircleQuestion,
7
+ Shrink,
8
+ Square,
9
+ } from "lucide-react";
10
+ import type {
11
+ ChatItem,
12
+ ChatState,
13
+ Compacting,
14
+ ModelWait,
15
+ } from "@/lib/chat/events";
5
16
  import { formatTokens } from "@/lib/chat/context";
6
17
  import { formatElapsed } from "@/lib/chat/elapsed";
18
+ import { currentActivity, type Phase } from "@/lib/chat/progress";
19
+ import { cn } from "@/lib/utils";
7
20
  import { useNow } from "./useNow";
8
21
 
9
- type Tool = Extract<ChatItem, { kind: "tool" }>;
22
+ export { currentActivity };
10
23
 
11
- // What the turn is doing now and for how long, from its items: the latest
12
- // running step, else whether it's writing or thinking.
13
- export function currentActivity(
14
- items: ChatItem[],
15
- state: ChatState,
16
- compacting: Compacting | null = null
17
- ): { label: string; since?: number; compacting?: boolean } | null {
18
- if (state === "waiting") return { label: "Waiting for your answer" };
19
- if (state !== "running") return null;
20
- if (compacting)
21
- return {
22
- label: `Compacting context…${compacting.before ? ` ${formatTokens(compacting.before)} tokens` : ""}`,
23
- since: compacting.since,
24
- compacting: true,
25
- };
26
- const lastUser = items.findLast((i) => i.kind === "user");
27
- const tool = items.findLast(
28
- (i): i is Tool => i.kind === "tool" && i.status === "running"
29
- );
30
- if (tool) return { label: tool.title, since: tool.createdAt };
31
- const last = items.at(-1);
32
- const writing =
33
- last && (last.kind === "assistant" || last.kind === "reasoning");
34
- return {
35
- label:
36
- writing && last.streaming
37
- ? last.kind === "assistant"
38
- ? "Writing"
39
- : "Thinking"
40
- : "Working",
41
- since: lastUser?.createdAt,
42
- };
43
- }
24
+ const PHASE: Record<Phase, string> = {
25
+ planning: "Planning",
26
+ coding: "Coding",
27
+ testing: "Testing",
28
+ error: "Error",
29
+ };
44
30
 
45
- // One line above the composer while a turn runs, so a long wait never
46
- // looks like nothing is happening.
31
+ // Up to three lines above the composer while a turn runs, so a long wait
32
+ // never looks like nothing is happening: what it's doing and for how long,
33
+ // the phase and step count, and a hint when a model call drags on.
47
34
  export function ActivityLine({
48
35
  items,
49
36
  state,
50
37
  compacting = null,
38
+ modelWait = null,
39
+ model,
51
40
  onStop,
52
41
  }: {
53
42
  items: ChatItem[];
54
43
  state: ChatState;
55
44
  compacting?: Compacting | null;
45
+ modelWait?: ModelWait | null;
46
+ model?: string;
56
47
  onStop: () => void;
57
48
  }) {
58
- const activity = currentActivity(items, state, compacting);
59
- const now = useNow(!!activity?.since);
49
+ const now = useNow(state === "running");
50
+ const activity = currentActivity(
51
+ items,
52
+ state,
53
+ compacting,
54
+ modelWait,
55
+ model,
56
+ now
57
+ );
60
58
  if (!activity) return null;
61
59
  const waiting = state === "waiting";
60
+ const failed = activity.phase === "error" && activity.kind === "waiting";
62
61
  return (
63
- <div className="text-muted-foreground flex min-h-9 items-center gap-2 px-2 text-xs">
64
- {waiting ? (
65
- <MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
66
- ) : activity.compacting ? (
67
- <Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
68
- ) : (
69
- <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
70
- )}
71
- <span className="text-foreground/80 min-w-0 flex-1 truncate">
72
- {activity.label}
73
- </span>
74
- {activity.since && (
75
- <span className="shrink-0 font-mono tabular-nums">
76
- {formatElapsed(now - activity.since)}
62
+ <div className="text-muted-foreground px-2 text-xs">
63
+ <div className="flex min-h-9 items-center gap-2">
64
+ {waiting ? (
65
+ <MessageCircleQuestion className="text-primary h-3.5 w-3.5 shrink-0" />
66
+ ) : activity.compacting ? (
67
+ <Shrink className="h-3.5 w-3.5 shrink-0 animate-pulse text-blue-600 dark:text-blue-400" />
68
+ ) : failed ? (
69
+ <AlertTriangle className="text-destructive h-3.5 w-3.5 shrink-0" />
70
+ ) : (
71
+ <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
72
+ )}
73
+ <span className="text-foreground/80 min-w-0 flex-1 truncate">
74
+ {activity.label}
77
75
  </span>
76
+ {activity.tokens && (
77
+ <span className="shrink-0 whitespace-nowrap">
78
+ {formatTokens(activity.tokens)}+ tokens
79
+ </span>
80
+ )}
81
+ {activity.since && (
82
+ <span className="shrink-0 font-mono tabular-nums">
83
+ {formatElapsed(now - activity.since)}
84
+ </span>
85
+ )}
86
+ {!waiting && (
87
+ <button
88
+ type="button"
89
+ onClick={onStop}
90
+ aria-label="Stop"
91
+ className="hover:text-foreground hover:bg-foreground/[0.06] -my-1 flex h-11 shrink-0 items-center gap-1 rounded-md px-2 md:my-0 md:h-7"
92
+ >
93
+ <Square className="h-3 w-3 fill-current" />
94
+ Stop
95
+ </button>
96
+ )}
97
+ </div>
98
+ {!waiting && activity.steps > 0 && activity.phase && (
99
+ <div className="flex items-center gap-1.5 pb-1 pl-5 text-[11px]">
100
+ <span
101
+ className={cn(
102
+ "shrink-0 font-medium",
103
+ activity.phase === "error"
104
+ ? "text-destructive"
105
+ : "text-foreground/70"
106
+ )}
107
+ >
108
+ {PHASE[activity.phase]}
109
+ </span>
110
+ <span className="shrink-0">
111
+ · {activity.steps} {activity.steps === 1 ? "step" : "steps"}
112
+ </span>
113
+ {activity.lastStep && (
114
+ <span className="min-w-0 flex-1 truncate">
115
+ · {activity.lastStep}
116
+ </span>
117
+ )}
118
+ {activity.turnSince && (
119
+ <span className="ml-auto shrink-0 pl-2 font-mono tabular-nums">
120
+ turn {formatElapsed(now - activity.turnSince)}
121
+ </span>
122
+ )}
123
+ </div>
78
124
  )}
79
- {!waiting && (
80
- <button
81
- type="button"
82
- onClick={onStop}
83
- aria-label="Stop"
84
- className="hover:text-foreground hover:bg-foreground/[0.06] -my-1 flex h-11 shrink-0 items-center gap-1 rounded-md px-2 md:my-0 md:h-7"
85
- >
86
- <Square className="h-3 w-3 fill-current" />
87
- Stop
88
- </button>
125
+ {activity.hint && (
126
+ <p className="pb-1.5 pl-5 text-[11px]">{activity.hint}</p>
89
127
  )}
90
128
  </div>
91
129
  );
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { StepCounts } from "@/lib/chat/tool-outcome";
3
4
  import {
4
5
  memo,
5
6
  useCallback,
@@ -32,6 +33,7 @@ import { sentPrompts } from "@/lib/chat/history";
32
33
  import { quoteInsert, quoteMarkdown } from "@/lib/chat/quote";
33
34
  import { OrchestratorBar } from "@/components/Orchestrator/OrchestratorBar";
34
35
  import { ActivityLine } from "./Activity";
36
+ import { modelLabel as pickedModelLabel } from "./ModelPicker";
35
37
  import { Approval } from "./Approval";
36
38
  import { BackgroundTasks } from "./BackgroundTasks";
37
39
  import { SubagentCard } from "./Subagent";
@@ -99,7 +101,15 @@ interface ItemActions {
99
101
  onQuote?: (text: string) => void;
100
102
  }
101
103
 
102
- function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
104
+ function Item({
105
+ item,
106
+ actions,
107
+ steps,
108
+ }: {
109
+ item: ChatItem;
110
+ actions: ItemActions;
111
+ steps?: StepCounts;
112
+ }) {
103
113
  switch (item.kind) {
104
114
  case "user":
105
115
  return (
@@ -119,7 +129,7 @@ function Item({ item, actions }: { item: ChatItem; actions: ItemActions }) {
119
129
  case "todos":
120
130
  return <Todos item={item} />;
121
131
  case "turn_end":
122
- return <TurnEnd item={item} />;
132
+ return <TurnEnd item={item} steps={steps} />;
123
133
  case "error":
124
134
  return <ErrorMessage item={item} />;
125
135
  case "command_output":
@@ -209,7 +219,7 @@ const Block = memo(
209
219
  </UndoneBlock>
210
220
  ) : (
211
221
  <div data-item={b.item.id}>
212
- <Item item={b.item} actions={actions} />
222
+ <Item item={b.item} actions={actions} steps={b.steps} />
213
223
  </div>
214
224
  );
215
225
  },
@@ -310,6 +320,7 @@ export function ChatPanel({
310
320
  requestFiles,
311
321
  search,
312
322
  compacting,
323
+ modelWait,
313
324
  loadedFor,
314
325
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
315
326
  const startFresh = useFreshStart();
@@ -787,6 +798,8 @@ export function ChatPanel({
787
798
  items={items}
788
799
  state={state}
789
800
  compacting={compacting}
801
+ modelWait={modelWait}
802
+ model={pickedModelLabel(models, model, modelLabel)}
790
803
  onStop={interrupt}
791
804
  />
792
805
  <BackgroundTasks
@@ -0,0 +1,36 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { ToolItem } from "@/lib/chat/group";
5
+ import { ToolGroup } from "./Tools";
6
+
7
+ const tool = (over: Partial<ToolItem>): ToolItem => ({
8
+ id: "t1",
9
+ kind: "tool",
10
+ name: "Bash",
11
+ title: "npm test",
12
+ input: { command: "npm test" },
13
+ status: "running",
14
+ createdAt: Date.now(),
15
+ ...over,
16
+ });
17
+
18
+ const html = (t: ToolItem) =>
19
+ renderToStaticMarkup(createElement(ToolGroup, { tools: [t] }));
20
+
21
+ describe("a running step", () => {
22
+ it("shows its output so far without being opened", () => {
23
+ const out = html(tool({ output: "PASS a.test.ts\nRUNS b.test.ts" }));
24
+ expect(out).toContain('data-testid="tool-output"');
25
+ expect(out).toContain("RUNS b.test.ts");
26
+ });
27
+
28
+ it("shows no output block before it prints anything", () => {
29
+ expect(html(tool({}))).not.toContain("tool-output");
30
+ });
31
+
32
+ it("keeps a finished step collapsed", () => {
33
+ const out = html(tool({ status: "done", output: "ok" }));
34
+ expect(out).not.toContain("tool-output");
35
+ });
36
+ });
@@ -1,7 +1,7 @@
1
1
  import { createElement } from "react";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
3
  import { describe, expect, it, vi } from "vitest";
4
- import { AssistantMessage, UserMessage } from "./Messages";
4
+ import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
5
5
 
6
6
  const item = {
7
7
  id: "a1",
@@ -119,3 +119,15 @@ describe("UserMessage code blocks", () => {
119
119
  }
120
120
  });
121
121
  });
122
+
123
+ describe("TurnEnd steps", () => {
124
+ it("says what a stopped turn got through", () => {
125
+ const html = renderToStaticMarkup(
126
+ createElement(TurnEnd, {
127
+ item: { id: "e", kind: "turn_end", interrupted: true } as never,
128
+ steps: { total: 5, ok: 3, failed: 1, stopped: 1, running: 0 },
129
+ })
130
+ );
131
+ expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
132
+ });
133
+ });
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import type { StepCounts } from "@/lib/chat/tool-outcome";
3
4
  import { memo } from "react";
4
5
  import { useSnapshot } from "valtio";
5
6
  import {
@@ -378,7 +379,23 @@ export function Todos({ item }: { item: Of<"todos"> }) {
378
379
  );
379
380
  }
380
381
 
381
- export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
382
+ // What a stopped or failed turn got through.
383
+ export function stepsLabel(s: StepCounts): string {
384
+ const done = s.ok + s.failed;
385
+ const parts = [`${done} of ${s.total} steps done`];
386
+ if (s.failed) parts.push(`${s.failed} failed`);
387
+ const unfinished = s.stopped + s.running;
388
+ if (unfinished) parts.push(`${unfinished} not run`);
389
+ return parts.join(", ");
390
+ }
391
+
392
+ export function TurnEnd({
393
+ item,
394
+ steps,
395
+ }: {
396
+ item: Of<"turn_end">;
397
+ steps?: StepCounts;
398
+ }) {
382
399
  const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
383
400
  const early = !item.interrupted && item.ended;
384
401
  const label = item.interrupted
@@ -407,7 +424,7 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
407
424
  )}
408
425
  <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
409
426
  <span className="bg-foreground/[0.06] h-px flex-1" />
410
- {label}
427
+ {steps ? `${label} · ${stepsLabel(steps)}` : label}
411
428
  <TurnCopy turnEndId={item.id} />
412
429
  <TurnBookmark turnEndId={item.id} />
413
430
  <span className="bg-foreground/[0.06] h-px flex-1" />
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import type { ToolItem } from "@/lib/chat/group";
4
+ import { toolOutcome } from "@/lib/chat/tool-outcome";
5
+ import { cn } from "@/lib/utils";
6
+ import { StatusIcon } from "./Tools";
7
+
8
+ export const MAX_CHIPS = 24;
9
+
10
+ const WORD = {
11
+ running: "running",
12
+ done: "ok",
13
+ error: "failed",
14
+ stopped: "not run",
15
+ } as const;
16
+
17
+ // One line per collapsed group: how each step ended, so the results aren't
18
+ // lost behind the fold.
19
+ export function StepOutcomes({
20
+ tools,
21
+ onOpen,
22
+ }: {
23
+ tools: ToolItem[];
24
+ onOpen: () => void;
25
+ }) {
26
+ const shown = tools.slice(0, MAX_CHIPS);
27
+ return (
28
+ <button
29
+ type="button"
30
+ onClick={onOpen}
31
+ aria-label="Show steps"
32
+ className="flex min-h-11 w-full flex-wrap items-center gap-x-3 gap-y-1 px-2 pb-1.5 text-left md:min-h-0"
33
+ >
34
+ {shown.map((t) => {
35
+ const code = toolOutcome(t);
36
+ return (
37
+ <span
38
+ key={t.id}
39
+ title={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
40
+ aria-label={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
41
+ className="flex min-w-0 items-center gap-1"
42
+ >
43
+ <StatusIcon status={t.status} />
44
+ {code && (
45
+ <span
46
+ className={cn(
47
+ "font-mono text-[11px]",
48
+ t.status === "error"
49
+ ? "text-destructive"
50
+ : "text-muted-foreground"
51
+ )}
52
+ >
53
+ {code}
54
+ </span>
55
+ )}
56
+ </span>
57
+ );
58
+ })}
59
+ {tools.length > shown.length && (
60
+ <span className="text-muted-foreground/70 font-mono text-[11px]">
61
+ +{tools.length - shown.length} more
62
+ </span>
63
+ )}
64
+ </button>
65
+ );
66
+ }
@@ -0,0 +1,52 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it } from "vitest";
4
+ import type { ToolItem } from "@/lib/chat/group";
5
+ import { MAX_CHIPS } from "./StepOutcomes";
6
+ import { ToolGroup } from "./Tools";
7
+
8
+ const tool = (id: string, over: Partial<ToolItem> = {}): ToolItem => ({
9
+ id,
10
+ kind: "tool",
11
+ name: "Bash",
12
+ title: `check ${id}`,
13
+ input: {},
14
+ status: "done",
15
+ createdAt: 0,
16
+ ...over,
17
+ });
18
+
19
+ const render = (tools: ToolItem[]) =>
20
+ renderToStaticMarkup(createElement(ToolGroup, { tools }));
21
+
22
+ describe("collapsed ToolGroup", () => {
23
+ it("keeps each step's outcome in view", () => {
24
+ const html = render([
25
+ tool("a", { output: "HTTP/2 200 \n" }),
26
+ tool("b", { status: "error", output: "Exit code 7" }),
27
+ tool("c", { status: "error", deferred: true, outcome: "HTTP 404" }),
28
+ tool("d", { status: "stopped" }),
29
+ ]);
30
+ expect(html).toContain("4 steps");
31
+ expect(html).toContain("1 ok");
32
+ expect(html).toContain("2 failed");
33
+ expect(html).toContain("1 not run");
34
+ expect(html).toContain("check a: ok, HTTP 200");
35
+ expect(html).toContain("check b: failed, exit 7");
36
+ expect(html).toContain("check c: failed, HTTP 404");
37
+ expect(html).toContain("check d: not run");
38
+ });
39
+
40
+ it("shows no strip while a step runs", () => {
41
+ expect(render([tool("a", { status: "running" })])).not.toContain(
42
+ "Show steps"
43
+ );
44
+ });
45
+
46
+ it("caps the strip", () => {
47
+ const tools = Array.from({ length: MAX_CHIPS + 3 }, (_, i) =>
48
+ tool(`x${i}`)
49
+ );
50
+ expect(render(tools)).toContain("+3 more");
51
+ });
52
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext, useEffect } from "react";
3
+ import { createContext, useContext, useEffect, useRef } from "react";
4
4
  import { Check, ChevronRight, Layers, Loader2, Square, X } from "lucide-react";
5
5
  import type { FileDiff, ToolBody } from "@/lib/chat/events";
6
6
  import type { ToolItem } from "@/lib/chat/group";
@@ -8,6 +8,8 @@ import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
8
8
  import { needsBody } from "@/lib/chat/bodyCache";
9
9
  import { cn } from "@/lib/utils";
10
10
  import { formatElapsed } from "@/lib/chat/elapsed";
11
+ import { stepCounts, toolOutcome } from "@/lib/chat/tool-outcome";
12
+ import { StepOutcomes } from "./StepOutcomes";
11
13
  import { Highlighted } from "./Code";
12
14
  import { BackgroundButton, BackgroundState, useBackground } from "./background";
13
15
  import { useNow } from "./useNow";
@@ -109,6 +111,36 @@ export function ToolPreview({
109
111
  );
110
112
  }
111
113
 
114
+ // A step's output. While it runs the view follows the end, unless the
115
+ // reader scrolled up to read.
116
+ export function LiveOutput({
117
+ text,
118
+ running,
119
+ }: {
120
+ text: string;
121
+ running: boolean;
122
+ }) {
123
+ const ref = useRef<HTMLPreElement>(null);
124
+ const pinned = useRef(true);
125
+ useEffect(() => {
126
+ const el = ref.current;
127
+ if (running && el && pinned.current) el.scrollTop = el.scrollHeight;
128
+ }, [text, running]);
129
+ return (
130
+ <pre
131
+ ref={ref}
132
+ data-testid="tool-output"
133
+ onScroll={(e) => {
134
+ const el = e.currentTarget;
135
+ pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
136
+ }}
137
+ className="text-muted-foreground max-h-64 overflow-auto rounded-lg px-2 font-mono text-[11px] whitespace-pre-wrap"
138
+ >
139
+ {text}
140
+ </pre>
141
+ );
142
+ }
143
+
112
144
  function ToolDetail({ tool }: { tool: ToolItem }) {
113
145
  const body = useToolBody(tool);
114
146
  if (body === undefined && tool.hasDiff)
@@ -121,9 +153,7 @@ function ToolDetail({ tool }: { tool: ToolItem }) {
121
153
  <div className="space-y-2 pb-2 pl-6">
122
154
  <ToolPreview name={tool.name} input={tool.input} diff={body?.diff} />
123
155
  {body?.output && !body.diff && (
124
- <pre className="text-muted-foreground max-h-64 overflow-auto rounded-lg px-2 font-mono text-[11px] whitespace-pre-wrap">
125
- {body.output}
126
- </pre>
156
+ <LiveOutput text={body.output} running={tool.status === "running"} />
127
157
  )}
128
158
  {body === undefined && tool.hasOutput && (
129
159
  <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" />
@@ -146,6 +176,10 @@ function Elapsed({ tool }: { tool: ToolItem }) {
146
176
  );
147
177
  }
148
178
 
179
+ // A running step that has printed something: its output shows as it comes.
180
+ const streaming = (tool: ToolItem) =>
181
+ tool.status === "running" && !!tool.output && !tool.deferred;
182
+
149
183
  function ToolRow({ tool }: { tool: ToolItem }) {
150
184
  const [open, setOpen] = useRowState(
151
185
  `tool:${tool.id}`,
@@ -163,6 +197,18 @@ function ToolRow({ tool }: { tool: ToolItem }) {
163
197
  <span className="min-w-0 flex-1 truncate font-mono text-xs">
164
198
  {tool.title}
165
199
  </span>
200
+ {toolOutcome(tool) && (
201
+ <span
202
+ className={cn(
203
+ "shrink-0 font-mono text-[11px]",
204
+ tool.status === "error"
205
+ ? "text-destructive"
206
+ : "text-muted-foreground"
207
+ )}
208
+ >
209
+ {toolOutcome(tool)}
210
+ </span>
211
+ )}
166
212
  {tool.backgroundTaskId ? (
167
213
  <BackgroundState tool={tool} />
168
214
  ) : (
@@ -172,7 +218,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
172
218
  <BackgroundButton tool={tool} />
173
219
  <BookmarkButton itemId={tool.id} />
174
220
  </div>
175
- {open && <ToolDetail tool={tool} />}
221
+ {(open || streaming(tool)) && <ToolDetail tool={tool} />}
176
222
  </div>
177
223
  );
178
224
  }
@@ -180,8 +226,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
180
226
  // A run of tool calls: one quiet line, expandable to every step.
181
227
  export function ToolGroup({ tools }: { tools: ToolItem[] }) {
182
228
  const running = tools.some((t) => t.status === "running");
183
- const failed = tools.filter((t) => t.status === "error").length;
184
- const stopped = tools.filter((t) => t.status === "stopped").length;
229
+ const { ok, failed, stopped } = stepCounts(tools);
185
230
  const edits = tools.filter((t) => t.diff || t.hasDiff).length;
186
231
  const moved = tools.filter((t) => t.backgroundTaskId).length;
187
232
  const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
@@ -211,13 +256,14 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
211
256
  {!running &&
212
257
  edits > 0 &&
213
258
  ` · ${edits} edit${edits === 1 ? "" : "s"}`}
259
+ {!running && (failed > 0 || stopped > 0) && ` · ${ok} ok`}
214
260
  </span>
215
261
  {running && <Elapsed tool={latest} />}
216
262
  {failed > 0 && (
217
263
  <span className="text-destructive">{failed} failed</span>
218
264
  )}
219
- {stopped > 0 && failed === 0 && (
220
- <span className="text-muted-foreground/70">stopped</span>
265
+ {stopped > 0 && (
266
+ <span className="text-muted-foreground/70">{stopped} not run</span>
221
267
  )}
222
268
  {!running && moved > 0 && (
223
269
  <span className="text-muted-foreground/70">
@@ -227,6 +273,14 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
227
273
  </button>
228
274
  {running && <BackgroundButton tool={latest} />}
229
275
  </div>
276
+ {!open && !running && (
277
+ <StepOutcomes tools={tools} onOpen={() => setOpen(true)} />
278
+ )}
279
+ {!open && streaming(latest) && (
280
+ <div className="pb-1">
281
+ <ToolDetail tool={latest} />
282
+ </div>
283
+ )}
230
284
  {open && (
231
285
  <div className="pb-1">
232
286
  {tools.map((t) => (