tinker-agent 1.5.0 → 1.6.0

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 (48) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +13 -5
  3. package/package.json +7 -5
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/tui-runner.tsx +21 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +190 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +301 -134
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/assistant-markdown.tsx +27 -26
  34. package/src/tui/components/background-tasks.tsx +7 -2
  35. package/src/tui/components/bash-confirmation.tsx +27 -0
  36. package/src/tui/components/context-status.tsx +11 -1
  37. package/src/tui/components/file-viewer.tsx +2 -2
  38. package/src/tui/components/footer.tsx +9 -12
  39. package/src/tui/components/memory-browser.tsx +1 -1
  40. package/src/tui/components/prompt-input.tsx +13 -1
  41. package/src/tui/components/resume-session-picker.tsx +3 -1
  42. package/src/tui/components/timeline.tsx +19 -11
  43. package/src/tui/context-format.ts +17 -0
  44. package/src/tui/event-store.ts +75 -3
  45. package/src/tui/shiki-highlighter.ts +104 -0
  46. package/src/tui/slash-commands.ts +28 -0
  47. package/src/tui/tui-projection-store.ts +277 -5
  48. package/src/tui/tui-session-controller.ts +32 -8
@@ -0,0 +1,135 @@
1
+ import { lexer, walkTokens, type Token } from "marked";
2
+
3
+ export type MarkdownSectionFrame = Readonly<{
4
+ markdown: string;
5
+ start: number;
6
+ end: number;
7
+ }>;
8
+
9
+ export type MarkdownSectionFramerResult = Readonly<{
10
+ content: string;
11
+ tail: string;
12
+ sealedEnd: number;
13
+ framingStopped: boolean;
14
+ }>;
15
+
16
+ const ATX_HEADING_LINE = /^ {0,3}#{1,6}(?:[\t ]+|$)/u;
17
+
18
+ export class MarkdownSectionFramer {
19
+ private source = "";
20
+ private scanOffset = 0;
21
+ private sealedEnd = 0;
22
+ private framingStopped = false;
23
+
24
+ push(content: string): readonly MarkdownSectionFrame[] {
25
+ if (content === "") {
26
+ return [];
27
+ }
28
+ this.source += content;
29
+ if (this.framingStopped) {
30
+ return [];
31
+ }
32
+
33
+ const frames: MarkdownSectionFrame[] = [];
34
+ while (true) {
35
+ const newline = this.source.indexOf("\n", this.scanOffset);
36
+ if (newline === -1) {
37
+ break;
38
+ }
39
+ const lineStart = this.scanOffset;
40
+ const lineEnd = newline + 1;
41
+ this.scanOffset = lineEnd;
42
+ const line = this.source.slice(lineStart, newline).replace(/\r$/u, "");
43
+ if (!ATX_HEADING_LINE.test(line) || !this.isTopLevelHeading(lineStart, lineEnd)) {
44
+ continue;
45
+ }
46
+
47
+ const section = this.source.slice(this.sealedEnd, lineStart);
48
+ if (section.trim() === "") {
49
+ continue;
50
+ }
51
+ if (hasDocumentLevelMarkdownDependency(section)) {
52
+ this.framingStopped = true;
53
+ break;
54
+ }
55
+
56
+ frames.push(
57
+ Object.freeze({
58
+ markdown: section,
59
+ start: this.sealedEnd,
60
+ end: lineStart,
61
+ }),
62
+ );
63
+ this.sealedEnd = lineStart;
64
+ }
65
+ return frames;
66
+ }
67
+
68
+ finish(): MarkdownSectionFramerResult {
69
+ return Object.freeze({
70
+ content: this.source,
71
+ tail: this.source.slice(this.sealedEnd),
72
+ sealedEnd: this.sealedEnd,
73
+ framingStopped: this.framingStopped,
74
+ });
75
+ }
76
+
77
+ reset(): void {
78
+ this.source = "";
79
+ this.scanOffset = 0;
80
+ this.sealedEnd = 0;
81
+ this.framingStopped = false;
82
+ }
83
+
84
+ private isTopLevelHeading(lineStart: number, lineEnd: number): boolean {
85
+ const markdown = normalizeMarkedSource(this.source.slice(this.sealedEnd, lineEnd));
86
+ const candidateOffset = normalizeMarkedSource(
87
+ this.source.slice(this.sealedEnd, lineStart),
88
+ ).length;
89
+ let tokenOffset = 0;
90
+ for (const token of lexer(markdown, { gfm: true })) {
91
+ if (tokenOffset === candidateOffset && token.type === "heading") {
92
+ return true;
93
+ }
94
+ tokenOffset += token.raw.length;
95
+ }
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function normalizeMarkedSource(source: string): string {
101
+ return source.replace(/\r\n|\r/gu, "\n");
102
+ }
103
+
104
+ function hasDocumentLevelMarkdownDependency(markdown: string): boolean {
105
+ let found = false;
106
+ void walkTokens(lexer(markdown, { gfm: true }), (token: Token) => {
107
+ if (found) {
108
+ return;
109
+ }
110
+ if (token.type === "def") {
111
+ found = true;
112
+ return;
113
+ }
114
+ if (
115
+ (token.type === "text" || token.type === "link" || token.type === "image") &&
116
+ containsReferenceSyntax(token.raw)
117
+ ) {
118
+ found = true;
119
+ }
120
+ });
121
+ return found;
122
+ }
123
+
124
+ function containsReferenceSyntax(source: string): boolean {
125
+ const bracket = /!?\[(?:\\.|[^\]\\\n])+\](?:[\t ]*\[(?:\\.|[^\]\\\n])*\])?/gu;
126
+ for (const match of source.matchAll(bracket)) {
127
+ const value = match[0];
128
+ const end = (match.index ?? 0) + value.length;
129
+ const hasSecondLabel = /\][\t ]*\[/u.test(value);
130
+ if (hasSecondLabel || !/^[\t ]*\(/u.test(source.slice(end))) {
131
+ return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
@@ -1,38 +1,39 @@
1
+ import { createContext, memo, useContext, type ReactNode } from "react";
2
+ import { MarkdownText, type RenderOptions } from "@assistant-ui/react-ink-markdown";
1
3
  import {
2
- MarkdownText,
3
- type RenderOptions,
4
- useShikiHighlighter,
5
- } from "@assistant-ui/react-ink-markdown";
4
+ getPreparedShikiHighlighter,
5
+ type TuiShikiHighlighter,
6
+ } from "../shiki-highlighter";
6
7
 
7
8
  export type AssistantMarkdownProps = {
8
9
  text: string;
9
10
  };
10
11
 
11
- const highlightedLanguages = [
12
- "typescript",
13
- "javascript",
14
- "tsx",
15
- "jsx",
16
- "json",
17
- "bash",
18
- "shellscript",
19
- "python",
20
- "markdown",
21
- "html",
22
- "css",
23
- "yaml",
24
- "diff",
25
- ];
26
-
27
12
  const tableOptions = {
28
13
  tableTruncate: false,
29
14
  } satisfies Pick<RenderOptions, "tableTruncate">;
30
15
 
31
- export function AssistantMarkdown(props: AssistantMarkdownProps) {
32
- const highlighter = useShikiHighlighter({
33
- theme: "github-dark",
34
- langs: highlightedLanguages,
35
- });
16
+ const HighlighterContext = createContext<TuiShikiHighlighter | undefined>(undefined);
17
+
18
+ // The runner resolves Shiki initialization before mounting App, so immutable
19
+ // messages can safely move into Ink Static on their first rendered frame.
20
+ export function AssistantMarkdownProvider(props: { children: ReactNode }) {
21
+ const highlighter = getPreparedShikiHighlighter();
22
+ return (
23
+ <HighlighterContext.Provider value={highlighter}>
24
+ {props.children}
25
+ </HighlighterContext.Provider>
26
+ );
27
+ }
28
+
29
+ // Memoized on `text`: settled assistant messages are immutable, so an
30
+ // unchanged text guarantees an unchanged render and the whole markdown
31
+ // subtree (including the markdansi re-run inside MarkdownText) can be
32
+ // skipped for unrelated frames.
33
+ export const AssistantMarkdown = memo(function AssistantMarkdown(
34
+ props: AssistantMarkdownProps,
35
+ ) {
36
+ const highlighter = useContext(HighlighterContext);
36
37
 
37
38
  return (
38
39
  <MarkdownText
@@ -44,4 +45,4 @@ export function AssistantMarkdown(props: AssistantMarkdownProps) {
44
45
  tableBorder="unicode"
45
46
  />
46
47
  );
47
- }
48
+ });
@@ -2,9 +2,11 @@ import { Box, Text } from "ink";
2
2
  import type { ShellTaskSnapshot, ShellTaskStatus } from "../../tools/bash-task";
3
3
 
4
4
  export type BackgroundTasksProps = {
5
- tasks: ShellTaskSnapshot[];
5
+ tasks: readonly ShellTaskSnapshot[];
6
6
  };
7
7
 
8
+ const MAX_VISIBLE_TASKS = 5;
9
+
8
10
  export function BackgroundTasks(props: BackgroundTasksProps) {
9
11
  if (props.tasks.length === 0) {
10
12
  return null;
@@ -13,13 +15,15 @@ export function BackgroundTasks(props: BackgroundTasksProps) {
13
15
  const runningCount = props.tasks.filter(
14
16
  (task) => task.status === "running" || task.status === "stopping",
15
17
  ).length;
18
+ const visibleTasks = props.tasks.slice(0, MAX_VISIBLE_TASKS);
19
+ const omittedTaskCount = props.tasks.length - visibleTasks.length;
16
20
 
17
21
  return (
18
22
  <Box flexDirection="column">
19
23
  <Text bold>
20
24
  Background tasks · {runningCount} running / {props.tasks.length} total
21
25
  </Text>
22
- {props.tasks.map((task) => (
26
+ {visibleTasks.map((task) => (
23
27
  <Box key={task.taskId} flexDirection="column">
24
28
  <Text color={colorForStatus(task.status)}>
25
29
  {symbolForStatus(task.status)} {task.status} {taskDescription(task)}
@@ -28,6 +32,7 @@ export function BackgroundTasks(props: BackgroundTasksProps) {
28
32
  <Text dimColor>{taskTiming(task)}</Text>
29
33
  </Box>
30
34
  ))}
35
+ {omittedTaskCount === 0 ? null : <Text dimColor>+{omittedTaskCount} more</Text>}
31
36
  </Box>
32
37
  );
33
38
  }
@@ -0,0 +1,27 @@
1
+ import { Box, Text, useInput } from "ink";
2
+
3
+ export type BashConfirmationProps = {
4
+ command: string;
5
+ reason: string;
6
+ onDecision(decision: "allow" | "deny"): void;
7
+ };
8
+
9
+ export function BashConfirmation(props: BashConfirmationProps) {
10
+ useInput((input) => {
11
+ const normalized = input.toLowerCase();
12
+ if (normalized === "y") {
13
+ props.onDecision("allow");
14
+ } else if (normalized === "n") {
15
+ props.onDecision("deny");
16
+ }
17
+ });
18
+
19
+ return (
20
+ <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
21
+ <Text color="yellow">Dangerous Bash command</Text>
22
+ <Text>{props.command}</Text>
23
+ <Text dimColor>{props.reason}</Text>
24
+ <Text>y allow / n deny / Esc cancel turn</Text>
25
+ </Box>
26
+ );
27
+ }
@@ -1,8 +1,12 @@
1
1
  import { Box, Text } from "ink";
2
+ import type { BashGuardSnapshot } from "../../agent/runtime-session";
2
3
  import type { TuiProjectionState } from "../event-store";
3
4
  import { formatContextUsageLine, formatTokenCount } from "../context-format";
4
5
 
5
- export function ContextStatus(props: { state: TuiProjectionState }) {
6
+ export function ContextStatus(props: {
7
+ state: TuiProjectionState;
8
+ bashGuard: BashGuardSnapshot;
9
+ }) {
6
10
  const usage = props.state.contextUsage;
7
11
  const profile = props.state.contextProfile;
8
12
  const budget = props.state.contextBudget;
@@ -14,6 +18,12 @@ export function ContextStatus(props: { state: TuiProjectionState }) {
14
18
  <Text> model: {props.state.modelName}</Text>
15
19
  <Text> workspace: {props.state.workspaceRoot}</Text>
16
20
  <Text> </Text>
21
+ <Text bold>Bash guard</Text>
22
+ <Text>
23
+ {" mode: "}
24
+ {props.bashGuard.mode} (source: {props.bashGuard.source})
25
+ </Text>
26
+ <Text> </Text>
17
27
  <Text bold>Context</Text>
18
28
  {usage === undefined || profile === undefined || budget === undefined ? (
19
29
  <Text color="yellow"> measurement unavailable</Text>
@@ -18,7 +18,7 @@ export type FileViewerProps = {
18
18
  export function FileViewer(props: FileViewerProps) {
19
19
  const windowSize = useWindowSize();
20
20
  const { stdout } = useStdout();
21
- const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
21
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
22
22
  const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
23
23
  const bodyRows = Math.max(1, rows - VIEWER_CHROME_ROWS);
24
24
  const normalizedLines = useMemo(
@@ -141,7 +141,7 @@ export function FileViewerLoading(props: {
141
141
  viewportColumns?: number;
142
142
  }) {
143
143
  const windowSize = useWindowSize();
144
- const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
144
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
145
145
  const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
146
146
 
147
147
  useInput((_input, key) => {
@@ -1,13 +1,13 @@
1
- import { StatusMessage } from "@inkjs/ui";
2
- import { Text } from "ink";
1
+ import { Spinner, StatusMessage } from "@inkjs/ui";
3
2
 
4
3
  export type FooterProps = {
5
4
  status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
6
5
  workedForMs?: number;
7
- elapsedMs?: number;
6
+ yolo?: boolean;
8
7
  };
9
8
 
10
9
  export function Footer(props: FooterProps) {
10
+ const suffix = props.yolo ? " · yolo" : "";
11
11
  if (props.status === "done") {
12
12
  if (props.workedForMs === undefined) {
13
13
  throw new Error("Done footer requires workedForMs");
@@ -16,31 +16,28 @@ export function Footer(props: FooterProps) {
16
16
  return (
17
17
  <StatusMessage variant="success">
18
18
  Worked for {formatDuration(props.workedForMs)}
19
+ {suffix}
19
20
  </StatusMessage>
20
21
  );
21
22
  }
22
23
 
23
24
  if (props.status === "failed") {
24
- return <StatusMessage variant="error">failed</StatusMessage>;
25
+ return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
25
26
  }
26
27
 
27
28
  if (props.status === "running") {
28
- if (props.elapsedMs === undefined) {
29
- return <Text color="yellow">• Running</Text>;
30
- }
31
-
32
- return <Text color="yellow">{`• Running ${formatDuration(props.elapsedMs)}`}</Text>;
29
+ return <Spinner label={`Running${suffix}`} />;
33
30
  }
34
31
 
35
32
  if (props.status === "cancelling") {
36
- return <StatusMessage variant="info">cancelling</StatusMessage>;
33
+ return <StatusMessage variant="info">cancelling{suffix}</StatusMessage>;
37
34
  }
38
35
 
39
36
  if (props.status === "cancelled") {
40
- return <StatusMessage variant="info">cancelled</StatusMessage>;
37
+ return <StatusMessage variant="info">cancelled{suffix}</StatusMessage>;
41
38
  }
42
39
 
43
- return <StatusMessage variant="info">idle</StatusMessage>;
40
+ return <StatusMessage variant="info">idle{suffix}</StatusMessage>;
44
41
  }
45
42
 
46
43
  function formatDuration(durationMs: number): string {
@@ -20,7 +20,7 @@ export type MemoryBrowserProps = {
20
20
 
21
21
  export function MemoryBrowser(props: MemoryBrowserProps) {
22
22
  const windowSize = useWindowSize();
23
- const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
23
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
24
24
  const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
25
25
  const bodyRows = Math.max(1, rows - BROWSER_CHROME_ROWS);
26
26
  const contentRef = useRef<DOMElement>(null);
@@ -9,7 +9,10 @@ import { ImageNotRecognizedError } from "../../image/image-probe";
9
9
  import type { ImportedImageAsset } from "../../image/image-asset-store";
10
10
  import type { ImageAssetRef } from "../../image/image-types";
11
11
  import { runtimeIdFactory, type RuntimeIdFactory } from "../../ids/runtime-id";
12
- import { formatContextUsageLine } from "../context-format";
12
+ import {
13
+ formatContextUsageLine,
14
+ formatLatestProviderCacheRate,
15
+ } from "../context-format";
13
16
  import {
14
17
  type FileMentionMatch,
15
18
  findFileMention,
@@ -722,6 +725,9 @@ export function PromptInput(props: PromptInputProps) {
722
725
  const showFileSuggestions = filePopupActive;
723
726
  const showSlashSuggestions = suggestions.length > 0 && !locked;
724
727
  const showSuggestions = showFileSuggestions || showSlashSuggestions;
728
+ const cacheRate = formatLatestProviderCacheRate(
729
+ props.contextUsage?.lastProviderUsage,
730
+ );
725
731
  return (
726
732
  <Box flexDirection="column">
727
733
  <Box width="100%" borderStyle="single" borderLeft={false} borderRight={false}>
@@ -745,6 +751,12 @@ export function PromptInput(props: PromptInputProps) {
745
751
  </Text>
746
752
  </>
747
753
  )}
754
+ {cacheRate === undefined ? null : (
755
+ <>
756
+ <Text dimColor> · </Text>
757
+ <Text dimColor>{cacheRate}</Text>
758
+ </>
759
+ )}
748
760
  </Box>
749
761
  )}
750
762
  {showFileSuggestions ? (
@@ -10,6 +10,7 @@ export type ResumeSessionPickerProps = {
10
10
  isResuming?: boolean;
11
11
  error?: string;
12
12
  now?: Date;
13
+ viewportRows?: number;
13
14
  visibleItemCount?: number;
14
15
  onCancel: () => void;
15
16
  onSelect: (session: SessionSummary) => void;
@@ -29,7 +30,8 @@ export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
29
30
  }
30
31
 
31
32
  function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
32
- const { rows } = useWindowSize();
33
+ const windowSize = useWindowSize();
34
+ const rows = props.viewportRows ?? windowSize.rows - 1;
33
35
  const visibleItemCount = Math.min(
34
36
  props.sessions.length,
35
37
  Math.max(
@@ -1,32 +1,31 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { Fragment } from "react";
3
3
  import type { TimelineItem } from "../event-store";
4
+ import type { AssistantStreamSectionItem } from "../tui-projection-store";
4
5
  import { AssistantMarkdown } from "./assistant-markdown";
5
6
  import { BashResultView } from "./bash-result-view";
6
7
  import { DiffView } from "./diff-view";
7
8
 
8
9
  export type TimelineProps = {
9
- items: TimelineItem[];
10
+ items: readonly TimelineItem[];
10
11
  };
11
12
 
12
13
  export function Timeline(props: TimelineProps) {
13
14
  return (
14
15
  <Box flexDirection="column">
15
- <Text bold>Timeline</Text>
16
- {props.items.length === 0 ? (
17
- <Text color="gray">idle</Text>
18
- ) : (
19
- props.items.map((item) => renderTimelineItem(item))
20
- )}
16
+ {props.items.map((item) => (
17
+ <TimelineRow key={item.id} item={item} />
18
+ ))}
21
19
  </Box>
22
20
  );
23
21
  }
24
22
 
25
- function renderTimelineItem(item: TimelineItem) {
23
+ export function TimelineRow(props: { item: TimelineItem }) {
24
+ const { item } = props;
26
25
  if (item.label !== undefined) {
27
26
  if (item.label === "assistant") {
28
27
  return (
29
- <Fragment key={item.id}>
28
+ <Fragment>
30
29
  <Text color="gray">- {item.label}</Text>
31
30
  <AssistantMarkdown text={item.text} />
32
31
  </Fragment>
@@ -34,7 +33,7 @@ function renderTimelineItem(item: TimelineItem) {
34
33
  }
35
34
 
36
35
  return (
37
- <Fragment key={item.id}>
36
+ <Fragment>
38
37
  <Text color="gray">- {item.label}</Text>
39
38
  {item.userPrompt === undefined ? (
40
39
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
@@ -48,7 +47,7 @@ function renderTimelineItem(item: TimelineItem) {
48
47
  }
49
48
 
50
49
  return (
51
- <Fragment key={item.id}>
50
+ <Fragment>
52
51
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
53
52
  {renderItemBash(item)}
54
53
  {renderItemDiff(item)}
@@ -56,6 +55,15 @@ function renderTimelineItem(item: TimelineItem) {
56
55
  );
57
56
  }
58
57
 
58
+ export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
59
+ return (
60
+ <Fragment>
61
+ {props.item.showAssistantLabel ? <Text color="gray">- assistant</Text> : null}
62
+ <AssistantMarkdown text={props.item.markdown} />
63
+ </Fragment>
64
+ );
65
+ }
66
+
59
67
  function renderUserPrompt(prompt: NonNullable<TimelineItem["userPrompt"]>) {
60
68
  const chars = [...prompt.text];
61
69
  const fragments: React.ReactNode[] = [];
@@ -1,4 +1,21 @@
1
1
  import type { ContextUsageSnapshot } from "../agent/context-meter";
2
+ import type { ModelUsage } from "../model/model-client";
3
+
4
+ export function formatLatestProviderCacheRate(
5
+ usage: ModelUsage | undefined,
6
+ ): string | undefined {
7
+ const hit = usage?.promptCacheHitTokens;
8
+ const miss = usage?.promptCacheMissTokens;
9
+ if (hit === undefined || miss === undefined || hit + miss === 0) {
10
+ return undefined;
11
+ }
12
+ // Floor instead of round: an append turn is never a true 100% hit, and
13
+ // rounding would display 99.5%+ as a misleading "cache 100%". A genuine
14
+ // full hit (miss === 0, e.g. an identical resent request) still floors to
15
+ // exactly 100. The min() guards against float rounding when miss > 0.
16
+ const percent = Math.min(99, Math.floor((hit / (hit + miss)) * 100));
17
+ return `cache ${miss === 0 ? 100 : percent}%`;
18
+ }
2
19
 
3
20
  export function formatContextUsageLine(
4
21
  usage: Pick<
@@ -3,7 +3,7 @@ import {
3
3
  bashResultDetail,
4
4
  type BashDisplayDetail,
5
5
  } from "../events/bash-result-detail";
6
- import type { AgentEvent } from "../events/types";
6
+ import type { AgentEvent, ModelRequestFailedData } from "../events/types";
7
7
  import type { ContextUsageSnapshot } from "../agent/context-meter";
8
8
  import type {
9
9
  ModelContextBudget,
@@ -162,12 +162,21 @@ export function reduceTuiProjection(
162
162
  })
163
163
  : updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
164
164
  ...item,
165
- text: `model iteration ${event.iterationNumber} · retrying`,
165
+ text: `model iteration ${event.iterationNumber} · retrying (attempt ${event.data.attemptNumber}/${event.data.maxAttempts})`,
166
166
  status: "running",
167
167
  })),
168
168
  );
169
169
  case "model.request.failed":
170
- return state;
170
+ if (event.data.retryDisposition !== "scheduled") {
171
+ return state;
172
+ }
173
+ return updateActiveTurn(state, event, policy, (turn) =>
174
+ updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
175
+ ...item,
176
+ text: `model iteration ${requireEventNumber(event.iterationNumber, "model.request.failed iterationNumber")} · ${modelRequestRetryText(event.data)}`,
177
+ status: "running",
178
+ })),
179
+ );
171
180
  case "model.request.finished":
172
181
  return updateActiveTurn(state, event, policy, (turn) =>
173
182
  updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
@@ -214,6 +223,37 @@ export function reduceTuiProjection(
214
223
  status: event.data.ok ? "ok" : "failed",
215
224
  })),
216
225
  );
226
+ case "tool.confirmation.requested":
227
+ return updateActiveTurn(state, event, policy, (turn) =>
228
+ updateTurnItem(
229
+ turn,
230
+ toolCallRef(
231
+ requireEventString(
232
+ event.toolCallId,
233
+ "tool.confirmation.requested toolCallId",
234
+ ),
235
+ ),
236
+ (item) => ({
237
+ ...item,
238
+ text: `Bash confirmation requested · ${event.data.reason}`,
239
+ status: "running",
240
+ }),
241
+ ),
242
+ );
243
+ case "tool.confirmation.resolved":
244
+ return updateActiveTurn(state, event, policy, (turn) =>
245
+ appendTurnItem(turn, {
246
+ id: `confirmation-${event.toolCallId}-${event.eventSequence}`,
247
+ label: "bash guard",
248
+ text: `${event.data.decision} · ${event.data.reason}`,
249
+ status:
250
+ event.data.decision === "allow"
251
+ ? "ok"
252
+ : event.data.decision === "cancelled"
253
+ ? "cancelled"
254
+ : "failed",
255
+ }),
256
+ );
217
257
  case "bash.task.backgrounded":
218
258
  case "bash.task.stopping":
219
259
  case "bash.task.finished":
@@ -393,6 +433,19 @@ function formatSurfaceComponent(component: string): string {
393
433
 
394
434
  export const applyAgentEvent = reduceTuiProjection;
395
435
 
436
+ export function timelineStreamItems(state: TuiProjectionState): TimelineItem[] {
437
+ return [
438
+ ...state.notices,
439
+ ...state.recentTurns.flatMap((turn) => turn.items),
440
+ ...(state.activeTurn?.items ?? []),
441
+ ];
442
+ }
443
+
444
+ export function firstRunningIndex(items: readonly TimelineItem[]): number {
445
+ const index = items.findIndex((item) => item.status === "running");
446
+ return index === -1 ? items.length : index;
447
+ }
448
+
396
449
  export function visibleTimelineItems(state: TuiProjectionState): TimelineItem[] {
397
450
  const items = [...state.notices];
398
451
 
@@ -666,6 +719,21 @@ export function completedModelRequestText(
666
719
  return `model iteration ${iterationNumber} -> assistant response`;
667
720
  }
668
721
 
722
+ function modelRequestRetryText(data: ModelRequestFailedData): string {
723
+ const attempt = `attempt ${data.attemptNumber + 1}/${data.maxAttempts}`;
724
+ if (data.retryDelayMs !== undefined && data.retryDelayMs > 0) {
725
+ const seconds = Math.max(1, Math.round(data.retryDelayMs / 1000));
726
+ const reason =
727
+ data.code === "provider_rate_limited"
728
+ ? "rate limited"
729
+ : data.code === "provider_unavailable"
730
+ ? "provider unavailable"
731
+ : data.code;
732
+ return `${reason} · retrying in ${seconds}s (${attempt})`;
733
+ }
734
+ return `retrying (${attempt})`;
735
+ }
736
+
669
737
  export function toolCallStartedProjection(input: {
670
738
  name: string;
671
739
  args: unknown;
@@ -761,6 +829,8 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
761
829
  ? base
762
830
  : `${base} -> ${raw.bytesWritten} bytes`;
763
831
  }
832
+ case "delete":
833
+ return `${base} -> deleted`;
764
834
  case "web_search":
765
835
  return raw.resultCount === undefined
766
836
  ? base
@@ -844,6 +914,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
844
914
  case "read":
845
915
  case "write":
846
916
  case "edit":
917
+ case "delete":
847
918
  case "glob":
848
919
  case "grep":
849
920
  case "task_list":
@@ -877,6 +948,7 @@ function toolRawResultDiff(
877
948
  diffTruncated: raw.patchTruncated === true,
878
949
  };
879
950
  case "read":
951
+ case "delete":
880
952
  case "glob":
881
953
  case "grep":
882
954
  case "bash":