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.
- package/CHANGELOG.md +48 -1
- package/README.md +13 -5
- package/package.json +7 -5
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/tui-runner.tsx +21 -2
- package/src/events/observation-text-log.ts +21 -0
- package/src/events/stdout-event-printer.ts +11 -0
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +190 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +11 -0
- package/src/session/session-store.ts +1 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash.ts +31 -0
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +47 -3
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +13 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +301 -134
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +9 -12
- package/src/tui/components/memory-browser.tsx +1 -1
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +19 -11
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +75 -3
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +277 -5
- 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
|
-
|
|
3
|
-
type
|
|
4
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
{
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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<
|
package/src/tui/event-store.ts
CHANGED
|
@@ -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
|
-
|
|
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":
|