tinker-agent 1.0.65
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/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { TuiProjectionState } from "../event-store";
|
|
3
|
+
import { formatContextUsageLine, formatTokenCount } from "../context-format";
|
|
4
|
+
|
|
5
|
+
export function ContextStatus(props: { state: TuiProjectionState }) {
|
|
6
|
+
const usage = props.state.contextUsage;
|
|
7
|
+
const profile = props.state.contextProfile;
|
|
8
|
+
const budget = props.state.contextBudget;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<Box flexDirection="column">
|
|
12
|
+
<Text bold>Session</Text>
|
|
13
|
+
<Text> id: {props.state.sessionId}</Text>
|
|
14
|
+
<Text> model: {props.state.modelName}</Text>
|
|
15
|
+
<Text> workspace: {props.state.workspaceRoot}</Text>
|
|
16
|
+
<Text> </Text>
|
|
17
|
+
<Text bold>Context</Text>
|
|
18
|
+
{usage === undefined || profile === undefined || budget === undefined ? (
|
|
19
|
+
<Text color="yellow"> measurement unavailable</Text>
|
|
20
|
+
) : (
|
|
21
|
+
<>
|
|
22
|
+
<Text> used: {formatContextUsageLine(usage).slice("context ".length)}</Text>
|
|
23
|
+
<Text> pressure: {usage.pressure}</Text>
|
|
24
|
+
<Text>
|
|
25
|
+
{" trigger: "}
|
|
26
|
+
{formatTokenCount(usage.triggerTokens)} ({usage.triggerRatio * 100}%)
|
|
27
|
+
</Text>
|
|
28
|
+
<Text> model window: {formatTokenCount(profile.contextWindowTokens)}</Text>
|
|
29
|
+
<Text>
|
|
30
|
+
{" request max output: "}
|
|
31
|
+
{formatTokenCount(usage.requestMaxOutputTokens)}
|
|
32
|
+
</Text>
|
|
33
|
+
<Text>
|
|
34
|
+
{" model max output: "}
|
|
35
|
+
{formatTokenCount(profile.maxSupportedOutputTokens)}
|
|
36
|
+
</Text>
|
|
37
|
+
<Text> </Text>
|
|
38
|
+
<Text bold>Measurement</Text>
|
|
39
|
+
<Text> source: {formatSource(usage.source)}</Text>
|
|
40
|
+
{usage.source === "estimated_full" ||
|
|
41
|
+
usage.lastProviderUsage === undefined ? (
|
|
42
|
+
<>
|
|
43
|
+
<Text> provider anchor: not available</Text>
|
|
44
|
+
{usage.rawFullEstimate === undefined ? null : (
|
|
45
|
+
<>
|
|
46
|
+
<Text>
|
|
47
|
+
{" raw full: "}
|
|
48
|
+
{formatTokenCount(usage.rawFullEstimate.totalTokens)}
|
|
49
|
+
</Text>
|
|
50
|
+
<Text>
|
|
51
|
+
{" breakdown: kernel "}
|
|
52
|
+
{formatTokenCount(usage.rawFullEstimate.kernelTokens)}, user{" "}
|
|
53
|
+
{formatTokenCount(usage.rawFullEstimate.userTokens)}, assistant{" "}
|
|
54
|
+
{formatTokenCount(usage.rawFullEstimate.assistantTokens)}, tool{" "}
|
|
55
|
+
{formatTokenCount(usage.rawFullEstimate.toolTokens)}, schema{" "}
|
|
56
|
+
{formatTokenCount(usage.rawFullEstimate.toolSchemaTokens)}, protocol{" "}
|
|
57
|
+
{formatTokenCount(usage.rawFullEstimate.protocolTokens)}
|
|
58
|
+
</Text>
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
</>
|
|
62
|
+
) : (
|
|
63
|
+
<ProviderUsage usage={usage.lastProviderUsage} />
|
|
64
|
+
)}
|
|
65
|
+
<Text> </Text>
|
|
66
|
+
<Text bold>Estimator</Text>
|
|
67
|
+
<Text> correction factor: {formatFactor(usage.correctionFactor)}</Text>
|
|
68
|
+
<Text> samples: {usage.calibrationSampleCount}/8</Text>
|
|
69
|
+
{usage.rawDeltaTokens === undefined ? null : (
|
|
70
|
+
<Text> raw pending delta: {formatTokenCount(usage.rawDeltaTokens)}</Text>
|
|
71
|
+
)}
|
|
72
|
+
{usage.guardedDeltaTokens === undefined ? null : (
|
|
73
|
+
<Text>
|
|
74
|
+
{" guarded pending delta: "}
|
|
75
|
+
{formatTokenCount(usage.guardedDeltaTokens)}
|
|
76
|
+
</Text>
|
|
77
|
+
)}
|
|
78
|
+
<Text> prefix: sha256:{shortHash(usage.prefixHash)}</Text>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ProviderUsage(props: {
|
|
86
|
+
usage: NonNullable<TuiProjectionState["contextUsage"]>["lastProviderUsage"];
|
|
87
|
+
}) {
|
|
88
|
+
const usage = props.usage;
|
|
89
|
+
if (usage === undefined) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Text> prompt: {formatTokenCount(usage.promptTokens)}</Text>
|
|
95
|
+
<Text> completion: {formatTokenCount(usage.completionTokens)}</Text>
|
|
96
|
+
<Text> total: {formatTokenCount(usage.totalTokens)}</Text>
|
|
97
|
+
{usage.promptCacheHitTokens === undefined ? null : (
|
|
98
|
+
<Text> cache hit: {formatTokenCount(usage.promptCacheHitTokens)}</Text>
|
|
99
|
+
)}
|
|
100
|
+
{usage.promptCacheMissTokens === undefined ? null : (
|
|
101
|
+
<Text> cache miss: {formatTokenCount(usage.promptCacheMissTokens)}</Text>
|
|
102
|
+
)}
|
|
103
|
+
{usage.reasoningTokens === undefined ? null : (
|
|
104
|
+
<Text> reasoning: {formatTokenCount(usage.reasoningTokens)}</Text>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatSource(
|
|
111
|
+
source: NonNullable<TuiProjectionState["contextUsage"]>["source"],
|
|
112
|
+
): string {
|
|
113
|
+
if (source === "provider_measured") {
|
|
114
|
+
return "provider measured";
|
|
115
|
+
}
|
|
116
|
+
return source === "measured_plus_estimated_delta"
|
|
117
|
+
? "measured + estimated delta"
|
|
118
|
+
: "full estimated";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatFactor(value: number): string {
|
|
122
|
+
return value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function shortHash(value: string): string {
|
|
126
|
+
return `${value.slice(0, 12)}${value.length > 12 ? "..." : ""}`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { DiffHunk } from "../../tools/types";
|
|
3
|
+
|
|
4
|
+
export type DiffViewProps = {
|
|
5
|
+
hunks: DiffHunk[];
|
|
6
|
+
truncated?: boolean;
|
|
7
|
+
maxLines?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_LINES = 20;
|
|
11
|
+
|
|
12
|
+
type DiffRow =
|
|
13
|
+
| {
|
|
14
|
+
kind: "add" | "remove" | "context";
|
|
15
|
+
lineNumber: number;
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
| { kind: "separator" };
|
|
19
|
+
|
|
20
|
+
export function DiffView(props: DiffViewProps) {
|
|
21
|
+
const maxLines = props.maxLines ?? DEFAULT_MAX_LINES;
|
|
22
|
+
const rows = diffRows(props.hunks);
|
|
23
|
+
const contentRowCount = rows.filter((row) => row.kind !== "separator").length;
|
|
24
|
+
const visibleRows = takeContentRows(rows, maxLines);
|
|
25
|
+
const hiddenLines = contentRowCount - maxLines;
|
|
26
|
+
const gutterWidth = gutterWidthFor(props.hunks);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
30
|
+
{visibleRows.map((row, index) => renderDiffRow(row, index, gutterWidth))}
|
|
31
|
+
{hiddenLines > 0 ? (
|
|
32
|
+
<Text color="gray">
|
|
33
|
+
{" ".repeat(gutterWidth)} … +{hiddenLines} more line
|
|
34
|
+
{hiddenLines === 1 ? "" : "s"}
|
|
35
|
+
</Text>
|
|
36
|
+
) : null}
|
|
37
|
+
{props.truncated === true && hiddenLines <= 0 ? (
|
|
38
|
+
<Text color="gray">{" ".repeat(gutterWidth)} … diff truncated</Text>
|
|
39
|
+
) : null}
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function diffRows(hunks: DiffHunk[]): DiffRow[] {
|
|
45
|
+
const rows: DiffRow[] = [];
|
|
46
|
+
|
|
47
|
+
hunks.forEach((hunk, hunkIndex) => {
|
|
48
|
+
if (hunkIndex > 0) {
|
|
49
|
+
rows.push({ kind: "separator" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let oldLine = hunk.oldStart;
|
|
53
|
+
let newLine = hunk.newStart;
|
|
54
|
+
|
|
55
|
+
for (const line of hunk.lines) {
|
|
56
|
+
const text = expandTabs(line.slice(1));
|
|
57
|
+
|
|
58
|
+
if (line.startsWith("+")) {
|
|
59
|
+
rows.push({ kind: "add", lineNumber: newLine, text });
|
|
60
|
+
newLine += 1;
|
|
61
|
+
} else if (line.startsWith("-")) {
|
|
62
|
+
rows.push({ kind: "remove", lineNumber: oldLine, text });
|
|
63
|
+
oldLine += 1;
|
|
64
|
+
} else {
|
|
65
|
+
rows.push({ kind: "context", lineNumber: newLine, text });
|
|
66
|
+
oldLine += 1;
|
|
67
|
+
newLine += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function takeContentRows(rows: DiffRow[], maxLines: number): DiffRow[] {
|
|
76
|
+
const visible: DiffRow[] = [];
|
|
77
|
+
let shown = 0;
|
|
78
|
+
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
if (row.kind === "separator") {
|
|
81
|
+
visible.push(row);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (shown >= maxLines) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
visible.push(row);
|
|
90
|
+
shown += 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
while (visible.length > 0 && visible[visible.length - 1]?.kind === "separator") {
|
|
94
|
+
visible.pop();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return visible;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderDiffRow(row: DiffRow, index: number, gutterWidth: number) {
|
|
101
|
+
if (row.kind === "separator") {
|
|
102
|
+
return (
|
|
103
|
+
<Text key={index} color="gray">
|
|
104
|
+
{" ".repeat(gutterWidth)} ···
|
|
105
|
+
</Text>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const gutter = String(row.lineNumber).padStart(gutterWidth);
|
|
110
|
+
const sign = row.kind === "add" ? "+" : row.kind === "remove" ? "-" : " ";
|
|
111
|
+
const color = colorForRow(row.kind);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Text key={index} wrap="truncate-end">
|
|
115
|
+
<Text color="gray">{gutter} </Text>
|
|
116
|
+
<Text color={color}>
|
|
117
|
+
{sign} {row.text}
|
|
118
|
+
</Text>
|
|
119
|
+
</Text>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function colorForRow(kind: "add" | "remove" | "context"): string {
|
|
124
|
+
if (kind === "add") {
|
|
125
|
+
return "green";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (kind === "remove") {
|
|
129
|
+
return "red";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return "gray";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function gutterWidthFor(hunks: DiffHunk[]): number {
|
|
136
|
+
let maxLineNumber = 1;
|
|
137
|
+
|
|
138
|
+
for (const hunk of hunks) {
|
|
139
|
+
maxLineNumber = Math.max(
|
|
140
|
+
maxLineNumber,
|
|
141
|
+
hunk.oldStart + hunk.oldLines,
|
|
142
|
+
hunk.newStart + hunk.newLines,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return String(maxLineNumber).length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function expandTabs(text: string): string {
|
|
150
|
+
return text.replaceAll("\t", " ");
|
|
151
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Box, Text, useInput, useStdout, useWindowSize } from "ink";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
|
+
import type { ViewFile } from "../view-file";
|
|
4
|
+
|
|
5
|
+
const VIEWER_CHROME_ROWS = 3;
|
|
6
|
+
const HORIZONTAL_SCROLL_COLUMNS = 8;
|
|
7
|
+
const MOUSE_SCROLL_LINES = 3;
|
|
8
|
+
const ENABLE_MOUSE = "\u001b[?1000h\u001b[?1006h";
|
|
9
|
+
const DISABLE_MOUSE = "\u001b[?1006l\u001b[?1000l";
|
|
10
|
+
|
|
11
|
+
export type FileViewerProps = {
|
|
12
|
+
file: ViewFile;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
viewportRows?: number;
|
|
15
|
+
viewportColumns?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function FileViewer(props: FileViewerProps) {
|
|
19
|
+
const windowSize = useWindowSize();
|
|
20
|
+
const { stdout } = useStdout();
|
|
21
|
+
const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
|
|
22
|
+
const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
|
|
23
|
+
const bodyRows = Math.max(1, rows - VIEWER_CHROME_ROWS);
|
|
24
|
+
const normalizedLines = useMemo(
|
|
25
|
+
() => props.file.lines.map((line) => line.replaceAll("\t", " ")),
|
|
26
|
+
[props.file.lines],
|
|
27
|
+
);
|
|
28
|
+
const totalLines = normalizedLines.length;
|
|
29
|
+
const lineNumberWidth = String(Math.max(1, totalLines)).length;
|
|
30
|
+
const contentWidth = Math.max(1, columns - lineNumberWidth - 3);
|
|
31
|
+
const maxLineWidth = useMemo(
|
|
32
|
+
() =>
|
|
33
|
+
normalizedLines.reduce(
|
|
34
|
+
(maximum, line) => Math.max(maximum, Bun.stringWidth(line)),
|
|
35
|
+
0,
|
|
36
|
+
),
|
|
37
|
+
[normalizedLines],
|
|
38
|
+
);
|
|
39
|
+
const maxTopLine = Math.max(0, totalLines - bodyRows);
|
|
40
|
+
const maxHorizontalOffset = Math.max(0, maxLineWidth - contentWidth);
|
|
41
|
+
const [topLine, setTopLine] = useState(0);
|
|
42
|
+
const [horizontalOffset, setHorizontalOffset] = useState(0);
|
|
43
|
+
const visibleTopLine = clamp(topLine, 0, maxTopLine);
|
|
44
|
+
const visibleHorizontalOffset = clamp(horizontalOffset, 0, maxHorizontalOffset);
|
|
45
|
+
const visibleEnd = Math.min(totalLines, visibleTopLine + bodyRows);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!stdout.isTTY) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
stdout.write(ENABLE_MOUSE);
|
|
52
|
+
return () => {
|
|
53
|
+
stdout.write(DISABLE_MOUSE);
|
|
54
|
+
};
|
|
55
|
+
}, [stdout]);
|
|
56
|
+
|
|
57
|
+
useInput((input, key) => {
|
|
58
|
+
if (key.escape) {
|
|
59
|
+
props.onClose();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mouseDirection = parseMouseWheelInput(input);
|
|
64
|
+
if (mouseDirection !== 0) {
|
|
65
|
+
setTopLine((current) =>
|
|
66
|
+
clamp(current + mouseDirection * MOUSE_SCROLL_LINES, 0, maxTopLine),
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (key.home) {
|
|
72
|
+
setTopLine(0);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key.end) {
|
|
76
|
+
setTopLine(maxTopLine);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key.pageUp || key.pageDown) {
|
|
80
|
+
const direction = key.pageUp ? -1 : 1;
|
|
81
|
+
setTopLine((current) => clamp(current + direction * bodyRows, 0, maxTopLine));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (key.leftArrow || key.rightArrow) {
|
|
85
|
+
const direction = key.leftArrow ? -1 : 1;
|
|
86
|
+
setHorizontalOffset((current) =>
|
|
87
|
+
clamp(current + direction * HORIZONTAL_SCROLL_COLUMNS, 0, maxHorizontalOffset),
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const verticalDirection =
|
|
93
|
+
key.upArrow || (input === "k" && !key.ctrl && !key.meta)
|
|
94
|
+
? -1
|
|
95
|
+
: key.downArrow || (input === "j" && !key.ctrl && !key.meta)
|
|
96
|
+
? 1
|
|
97
|
+
: 0;
|
|
98
|
+
if (verticalDirection !== 0) {
|
|
99
|
+
setTopLine((current) => clamp(current + verticalDirection, 0, maxTopLine));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Box width={columns} height={rows} flexDirection="column" overflow="hidden">
|
|
105
|
+
<Text bold wrap="truncate-middle">
|
|
106
|
+
View: {props.file.displayPath}
|
|
107
|
+
</Text>
|
|
108
|
+
<Text dimColor wrap="truncate-end">
|
|
109
|
+
↑/↓ or j/k · PgUp/PgDn · Home/End · ←/→ · mouse wheel · Esc close
|
|
110
|
+
</Text>
|
|
111
|
+
<Box height={bodyRows} flexDirection="column" overflow="hidden">
|
|
112
|
+
{totalLines === 0 ? (
|
|
113
|
+
<Text dimColor>(empty file)</Text>
|
|
114
|
+
) : (
|
|
115
|
+
normalizedLines.slice(visibleTopLine, visibleEnd).map((line, index) => {
|
|
116
|
+
const lineNumber = visibleTopLine + index + 1;
|
|
117
|
+
return (
|
|
118
|
+
<Box key={lineNumber} width={columns} overflow="hidden">
|
|
119
|
+
<Text
|
|
120
|
+
dimColor
|
|
121
|
+
>{`${String(lineNumber).padStart(lineNumberWidth)} │ `}</Text>
|
|
122
|
+
<Text wrap="truncate-end">
|
|
123
|
+
{sliceByColumns(line, visibleHorizontalOffset, contentWidth)}
|
|
124
|
+
</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
);
|
|
127
|
+
})
|
|
128
|
+
)}
|
|
129
|
+
</Box>
|
|
130
|
+
<Text dimColor wrap="truncate-end">
|
|
131
|
+
{viewerStatus(visibleTopLine, visibleEnd, totalLines, visibleHorizontalOffset)}
|
|
132
|
+
</Text>
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function FileViewerLoading(props: {
|
|
138
|
+
filePath: string;
|
|
139
|
+
onCancel: () => void;
|
|
140
|
+
viewportRows?: number;
|
|
141
|
+
viewportColumns?: number;
|
|
142
|
+
}) {
|
|
143
|
+
const windowSize = useWindowSize();
|
|
144
|
+
const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
|
|
145
|
+
const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
|
|
146
|
+
|
|
147
|
+
useInput((_input, key) => {
|
|
148
|
+
if (key.escape) {
|
|
149
|
+
props.onCancel();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Box width={columns} height={rows} flexDirection="column" overflow="hidden">
|
|
155
|
+
<Text bold wrap="truncate-middle">
|
|
156
|
+
View: {props.filePath}
|
|
157
|
+
</Text>
|
|
158
|
+
<Text dimColor>Loading and validating UTF-8 text… · Esc cancel</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function parseMouseWheelInput(input: string): -1 | 0 | 1 {
|
|
164
|
+
const normalized = input.startsWith("\u001b") ? input.slice(1) : input;
|
|
165
|
+
const match = /^\[<(\d+);\d+;\d+M$/.exec(normalized);
|
|
166
|
+
if (match === null) {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const button = Number(match[1]);
|
|
171
|
+
if ((button & 64) === 0) {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
return (button & 1) === 0 ? -1 : 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function viewerStatus(
|
|
178
|
+
topLine: number,
|
|
179
|
+
endLine: number,
|
|
180
|
+
totalLines: number,
|
|
181
|
+
horizontalOffset: number,
|
|
182
|
+
): string {
|
|
183
|
+
if (totalLines === 0) {
|
|
184
|
+
return "0 lines · 0 bytes";
|
|
185
|
+
}
|
|
186
|
+
const percentage = Math.floor((endLine / totalLines) * 100);
|
|
187
|
+
const horizontal = horizontalOffset === 0 ? "" : ` · column ${horizontalOffset + 1}`;
|
|
188
|
+
return `${topLine + 1}–${endLine} / ${totalLines} · ${percentage}%${horizontal}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sliceByColumns(value: string, start: number, width: number): string {
|
|
192
|
+
let currentColumn = 0;
|
|
193
|
+
let result = "";
|
|
194
|
+
|
|
195
|
+
for (const character of value) {
|
|
196
|
+
const characterWidth = Bun.stringWidth(character);
|
|
197
|
+
const nextColumn = currentColumn + characterWidth;
|
|
198
|
+
if (nextColumn > start && currentColumn < start + width) {
|
|
199
|
+
result += character;
|
|
200
|
+
}
|
|
201
|
+
currentColumn = nextColumn;
|
|
202
|
+
if (currentColumn >= start + width) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
211
|
+
return Math.min(Math.max(value, minimum), maximum);
|
|
212
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { StatusMessage } from "@inkjs/ui";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
|
|
4
|
+
export type FooterProps = {
|
|
5
|
+
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
6
|
+
workedForMs?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function Footer(props: FooterProps) {
|
|
10
|
+
if (props.status === "done") {
|
|
11
|
+
if (props.workedForMs === undefined) {
|
|
12
|
+
throw new Error("Done footer requires workedForMs");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<StatusMessage variant="success">
|
|
17
|
+
Worked for {formatWorkedDuration(props.workedForMs)}
|
|
18
|
+
</StatusMessage>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (props.status === "failed") {
|
|
23
|
+
return <StatusMessage variant="error">failed</StatusMessage>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (props.status === "running") {
|
|
27
|
+
return <Text color="yellow">• Running</Text>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (props.status === "cancelling") {
|
|
31
|
+
return <StatusMessage variant="info">cancelling</StatusMessage>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (props.status === "cancelled") {
|
|
35
|
+
return <StatusMessage variant="info">cancelled</StatusMessage>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return <StatusMessage variant="info">idle</StatusMessage>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatWorkedDuration(durationMs: number): string {
|
|
42
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
43
|
+
throw new Error(`Invalid worked duration: ${durationMs}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const totalSeconds = Math.floor(durationMs / 1000);
|
|
47
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
48
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
49
|
+
const seconds = totalSeconds % 60;
|
|
50
|
+
|
|
51
|
+
if (hours > 0) {
|
|
52
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (minutes > 0) {
|
|
56
|
+
return `${minutes}m ${seconds}s`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return `${seconds}s`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import type { SessionId } from "../../ids/runtime-id";
|
|
4
|
+
|
|
5
|
+
export type HeaderProps = {
|
|
6
|
+
modelName: string;
|
|
7
|
+
workspaceRoot: string;
|
|
8
|
+
sessionId: SessionId;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function Header(props: HeaderProps) {
|
|
12
|
+
return (
|
|
13
|
+
<Box flexDirection="column">
|
|
14
|
+
<Text bold>Tinker</Text>
|
|
15
|
+
<Text color="gray">
|
|
16
|
+
model={props.modelName} workspace={path.basename(props.workspaceRoot)} session=
|
|
17
|
+
{props.sessionId}
|
|
18
|
+
</Text>
|
|
19
|
+
</Box>
|
|
20
|
+
);
|
|
21
|
+
}
|