tinker-agent 1.3.0 → 1.5.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 +39 -1
- package/README.md +271 -72
- package/bin/tinker.js +75 -25
- package/package.json +12 -3
- package/src/agent/runtime-session.ts +113 -15
- package/src/cli/command-line.ts +291 -0
- package/src/cli/config.ts +158 -262
- package/src/cli/index.ts +33 -21
- package/src/cli/main.ts +213 -0
- package/src/cli/model-profiles.ts +226 -72
- package/src/cli/output.ts +113 -0
- package/src/cli/package-metadata.ts +36 -0
- package/src/cli/prompt-source.ts +229 -0
- package/src/cli/public-cli-contract.ts +69 -0
- package/src/cli/public-config-contract.ts +732 -0
- package/src/cli/run-runner.ts +17 -12
- package/src/cli/runner-dependencies.ts +108 -0
- package/src/cli/tui-memory.ts +67 -0
- package/src/cli/tui-runner.tsx +79 -49
- package/src/context/context-policy.ts +2 -2
- package/src/events/stdout-event-printer.ts +1 -0
- package/src/mcp/mcp-manager.ts +2 -19
- package/src/mcp/mcp-tool-executor.ts +3 -4
- package/src/memory/contracts.ts +148 -0
- package/src/memory/embedding-client.ts +105 -0
- package/src/memory/memory-coordinator.ts +556 -0
- package/src/memory/memory-extractor.ts +231 -0
- package/src/memory/memory-log.ts +88 -0
- package/src/memory/memory-search-tool.ts +100 -0
- package/src/memory/memory-store.ts +687 -0
- package/src/memory/vector.ts +153 -0
- package/src/model/fake-model-client.ts +971 -3
- package/src/model/model-context-profile.ts +0 -30
- package/src/observation/observation-builder.ts +20 -0
- package/src/session/session-store.ts +123 -0
- package/src/tools/bash.ts +8 -25
- package/src/tools/grep.ts +9 -1
- package/src/tools/registry.ts +19 -1
- package/src/tools/ripgrep.ts +24 -27
- package/src/tools/types.ts +16 -0
- package/src/tools/web-fetch/index.ts +2 -15
- package/src/tui/app.tsx +72 -2
- package/src/tui/clipboard.ts +22 -0
- package/src/tui/components/footer.tsx +9 -4
- package/src/tui/components/memory-browser.tsx +151 -0
- package/src/tui/components/prompt-input.tsx +6 -3
- package/src/tui/event-store.ts +9 -2
- package/src/tui/slash-commands.ts +88 -24
- package/src/tui/workspace-file-search.ts +78 -71
package/src/tui/app.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, Text, useApp, useInput } from "ink";
|
|
2
|
-
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
3
3
|
import { TurnCancelledError } from "../agent/turn-cancellation";
|
|
4
|
+
import { boundedMemoryError, type StoredMemorySummary } from "../memory/contracts";
|
|
4
5
|
import {
|
|
5
6
|
ContextManagerError,
|
|
6
7
|
type ContextCompactionResult,
|
|
@@ -18,6 +19,7 @@ import { BackgroundTasks } from "./components/background-tasks";
|
|
|
18
19
|
import { Header } from "./components/header";
|
|
19
20
|
import { ModelPicker } from "./components/model-picker";
|
|
20
21
|
import { FileViewer, FileViewerLoading } from "./components/file-viewer";
|
|
22
|
+
import { MemoryBrowser } from "./components/memory-browser";
|
|
21
23
|
import {
|
|
22
24
|
PromptInput,
|
|
23
25
|
type PromptMaintenanceAction,
|
|
@@ -42,12 +44,14 @@ import type { SessionSummary } from "../session/session-catalog";
|
|
|
42
44
|
import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
|
|
43
45
|
import { loadViewFile, type ViewFile } from "./view-file";
|
|
44
46
|
import { writeClipboardText } from "./clipboard";
|
|
47
|
+
import type { WorkspaceFileLister } from "./workspace-file-search";
|
|
45
48
|
|
|
46
49
|
export type AppProps = {
|
|
47
50
|
sessionController: TuiSessionController;
|
|
48
51
|
readGitBranch?: (workspaceRoot: string) => Promise<string | undefined>;
|
|
49
52
|
history?: PromptHistory;
|
|
50
53
|
projectSlashCommands?: readonly ProjectSlashCommand[];
|
|
54
|
+
fileLister?: WorkspaceFileLister;
|
|
51
55
|
profiles?: ModelProfiles;
|
|
52
56
|
persistDefaultProfile?: (profileName: string) => Promise<void>;
|
|
53
57
|
readViewFile?: (workspaceRoot: string, filePath: string) => Promise<ViewFile>;
|
|
@@ -57,6 +61,9 @@ export type AppProps = {
|
|
|
57
61
|
) => Promise<string | undefined>;
|
|
58
62
|
writeClipboard?: (markdown: string) => Promise<void>;
|
|
59
63
|
onQuit?: () => void;
|
|
64
|
+
initialNotice?: string;
|
|
65
|
+
listStoredMemories?: () => readonly StoredMemorySummary[];
|
|
66
|
+
memoryDisabledNotice?: string;
|
|
60
67
|
};
|
|
61
68
|
|
|
62
69
|
type ResumePickerState =
|
|
@@ -97,7 +104,7 @@ export function App(props: AppProps) {
|
|
|
97
104
|
const [isSessionOperation, setIsSessionOperation] = useState(false);
|
|
98
105
|
const [isCopying, setIsCopying] = useState(false);
|
|
99
106
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
100
|
-
const [notice, setNotice] = useState<string | undefined>(
|
|
107
|
+
const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
|
|
101
108
|
const [showStatus, setShowStatus] = useState(false);
|
|
102
109
|
const [showSkills, setShowSkills] = useState(false);
|
|
103
110
|
const [showMcp, setShowMcp] = useState(false);
|
|
@@ -109,6 +116,9 @@ export function App(props: AppProps) {
|
|
|
109
116
|
ModelPickerState | undefined
|
|
110
117
|
>(undefined);
|
|
111
118
|
const [fileView, setFileView] = useState<FileViewState | undefined>(undefined);
|
|
119
|
+
const [memoryView, setMemoryView] = useState<
|
|
120
|
+
readonly StoredMemorySummary[] | undefined
|
|
121
|
+
>(undefined);
|
|
112
122
|
const [viewError, setViewError] = useState<string | undefined>(undefined);
|
|
113
123
|
const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
|
|
114
124
|
const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
|
|
@@ -130,6 +140,8 @@ export function App(props: AppProps) {
|
|
|
130
140
|
|
|
131
141
|
const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
|
|
132
142
|
|
|
143
|
+
const runningElapsedMs = useElapsedMs(state.activeTurn?.startedAt);
|
|
144
|
+
|
|
133
145
|
useEffect(() => {
|
|
134
146
|
if (readGitBranch === undefined) {
|
|
135
147
|
return;
|
|
@@ -274,6 +286,20 @@ export function App(props: AppProps) {
|
|
|
274
286
|
setFileView(undefined);
|
|
275
287
|
};
|
|
276
288
|
|
|
289
|
+
const openMemoryView = () => {
|
|
290
|
+
if (props.listStoredMemories === undefined) {
|
|
291
|
+
setNotice(props.memoryDisabledNotice ?? "memory disabled: not configured");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const snapshot = props.listStoredMemories();
|
|
296
|
+
setNotice(undefined);
|
|
297
|
+
setMemoryView(snapshot);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
setNotice(`memory unavailable: ${boundedMemoryError(error)}`);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
277
303
|
const openFileView = (filePath: string) => {
|
|
278
304
|
const requestId = fileViewRequest.current + 1;
|
|
279
305
|
fileViewRequest.current = requestId;
|
|
@@ -452,6 +478,10 @@ export function App(props: AppProps) {
|
|
|
452
478
|
openFileView(command.filePath);
|
|
453
479
|
return true;
|
|
454
480
|
}
|
|
481
|
+
if (command.type === "memory") {
|
|
482
|
+
openMemoryView();
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
455
485
|
if (command.type === "copy") {
|
|
456
486
|
copyLastResponse();
|
|
457
487
|
return true;
|
|
@@ -581,6 +611,8 @@ export function App(props: AppProps) {
|
|
|
581
611
|
<FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
|
|
582
612
|
) : fileView?.status === "ready" ? (
|
|
583
613
|
<FileViewer file={fileView.file} onClose={closeFileView} />
|
|
614
|
+
) : memoryView !== undefined ? (
|
|
615
|
+
<MemoryBrowser memories={memoryView} onClose={() => setMemoryView(undefined)} />
|
|
584
616
|
) : resumePicker?.status === "loading" ? (
|
|
585
617
|
<ResumeSessionPickerLoading onCancel={closeResumePicker} />
|
|
586
618
|
) : resumePicker?.status === "ready" ? (
|
|
@@ -626,6 +658,7 @@ export function App(props: AppProps) {
|
|
|
626
658
|
<Footer
|
|
627
659
|
status={isCancelling ? "cancelling" : state.status}
|
|
628
660
|
workedForMs={state.workedForMs}
|
|
661
|
+
elapsedMs={runningElapsedMs}
|
|
629
662
|
/>
|
|
630
663
|
</Box>
|
|
631
664
|
<Box marginTop={1} flexDirection="column">
|
|
@@ -647,6 +680,7 @@ export function App(props: AppProps) {
|
|
|
647
680
|
isDisabled={isRunning || isSessionOperation || isCopying}
|
|
648
681
|
history={props.history}
|
|
649
682
|
commands={availableCommands}
|
|
683
|
+
fileLister={props.fileLister}
|
|
650
684
|
importImage={binding.importImage}
|
|
651
685
|
verifyImageAssets={binding.verifyImageAssets}
|
|
652
686
|
onSubmit={onSubmit}
|
|
@@ -667,6 +701,42 @@ function errorMessage(error: unknown): string {
|
|
|
667
701
|
return error instanceof Error ? error.message : String(error);
|
|
668
702
|
}
|
|
669
703
|
|
|
704
|
+
const ELAPSED_TICK_MS = 1_000;
|
|
705
|
+
|
|
706
|
+
// The wall clock, quantized to whole ticks so a render pass reads one stable
|
|
707
|
+
// value and re-renders at most once per second.
|
|
708
|
+
function readElapsedClockMs(): number {
|
|
709
|
+
return Math.floor(Date.now() / ELAPSED_TICK_MS) * ELAPSED_TICK_MS;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Counts up while a turn is active. The interval only exists while `startedAt`
|
|
713
|
+
// is defined, so an idle TUI keeps no timer running.
|
|
714
|
+
function useElapsedMs(startedAt: string | undefined): number | undefined {
|
|
715
|
+
const subscribe = useCallback(
|
|
716
|
+
(onClockTick: () => void) => {
|
|
717
|
+
if (startedAt === undefined) {
|
|
718
|
+
return () => undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const timer = setInterval(onClockTick, ELAPSED_TICK_MS);
|
|
722
|
+
return () => clearInterval(timer);
|
|
723
|
+
},
|
|
724
|
+
[startedAt],
|
|
725
|
+
);
|
|
726
|
+
const nowMs = useSyncExternalStore(subscribe, readElapsedClockMs, readElapsedClockMs);
|
|
727
|
+
|
|
728
|
+
if (startedAt === undefined) {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const startedAtMs = Date.parse(startedAt);
|
|
733
|
+
if (!Number.isFinite(startedAtMs)) {
|
|
734
|
+
throw new Error(`Invalid turn start timestamp: ${startedAt}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return Math.max(0, nowMs - startedAtMs);
|
|
738
|
+
}
|
|
739
|
+
|
|
670
740
|
export function formatContextCompactionNotice(result: ContextCompactionResult): string {
|
|
671
741
|
if (result.status === "unchanged") {
|
|
672
742
|
if (result.outcome === "below_target") {
|
package/src/tui/clipboard.ts
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import clipboard from "clipboardy";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
|
|
3
5
|
export async function writeClipboardText(text: string): Promise<void> {
|
|
4
6
|
await clipboard.write(text);
|
|
5
7
|
}
|
|
8
|
+
|
|
9
|
+
export function clipboardWriterForEnvironment(
|
|
10
|
+
env: NodeJS.ProcessEnv,
|
|
11
|
+
): ((text: string) => Promise<void>) | undefined {
|
|
12
|
+
const filePath = env.TINKER_TEST_CLIPBOARD_FILE;
|
|
13
|
+
if (filePath === undefined || filePath === "") {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
if (env.TINKER_TEST_FAKE_MODEL === undefined || env.TINKER_TEST_FAKE_MODEL === "") {
|
|
17
|
+
throw new Error("TINKER_TEST_CLIPBOARD_FILE requires TINKER_TEST_FAKE_MODEL.");
|
|
18
|
+
}
|
|
19
|
+
if (!path.isAbsolute(filePath)) {
|
|
20
|
+
throw new Error("TINKER_TEST_CLIPBOARD_FILE must be an absolute path.");
|
|
21
|
+
}
|
|
22
|
+
return (text) =>
|
|
23
|
+
writeFile(filePath, text, {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
mode: 0o600,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Text } from "ink";
|
|
|
4
4
|
export type FooterProps = {
|
|
5
5
|
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
6
6
|
workedForMs?: number;
|
|
7
|
+
elapsedMs?: number;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
export function Footer(props: FooterProps) {
|
|
@@ -14,7 +15,7 @@ export function Footer(props: FooterProps) {
|
|
|
14
15
|
|
|
15
16
|
return (
|
|
16
17
|
<StatusMessage variant="success">
|
|
17
|
-
Worked for {
|
|
18
|
+
Worked for {formatDuration(props.workedForMs)}
|
|
18
19
|
</StatusMessage>
|
|
19
20
|
);
|
|
20
21
|
}
|
|
@@ -24,7 +25,11 @@ export function Footer(props: FooterProps) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
if (props.status === "running") {
|
|
27
|
-
|
|
28
|
+
if (props.elapsedMs === undefined) {
|
|
29
|
+
return <Text color="yellow">• Running</Text>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return <Text color="yellow">{`• Running ${formatDuration(props.elapsedMs)}`}</Text>;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
if (props.status === "cancelling") {
|
|
@@ -38,9 +43,9 @@ export function Footer(props: FooterProps) {
|
|
|
38
43
|
return <StatusMessage variant="info">idle</StatusMessage>;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
function
|
|
46
|
+
function formatDuration(durationMs: number): string {
|
|
42
47
|
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
43
|
-
throw new Error(`Invalid
|
|
48
|
+
throw new Error(`Invalid duration: ${durationMs}`);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
const totalSeconds = Math.floor(durationMs / 1000);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Box,
|
|
3
|
+
Text,
|
|
4
|
+
useBoxMetrics,
|
|
5
|
+
useInput,
|
|
6
|
+
useWindowSize,
|
|
7
|
+
type DOMElement,
|
|
8
|
+
} from "ink";
|
|
9
|
+
import { useRef, useState } from "react";
|
|
10
|
+
import type { StoredMemorySummary } from "../../memory/contracts";
|
|
11
|
+
|
|
12
|
+
const BROWSER_CHROME_ROWS = 3;
|
|
13
|
+
|
|
14
|
+
export type MemoryBrowserProps = {
|
|
15
|
+
readonly memories: readonly StoredMemorySummary[];
|
|
16
|
+
readonly onClose: () => void;
|
|
17
|
+
readonly viewportRows?: number;
|
|
18
|
+
readonly viewportColumns?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function MemoryBrowser(props: MemoryBrowserProps) {
|
|
22
|
+
const windowSize = useWindowSize();
|
|
23
|
+
const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
|
|
24
|
+
const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
|
|
25
|
+
const bodyRows = Math.max(1, rows - BROWSER_CHROME_ROWS);
|
|
26
|
+
const contentRef = useRef<DOMElement>(null);
|
|
27
|
+
const { height: measuredContentRows, hasMeasured } = useBoxMetrics(contentRef);
|
|
28
|
+
const totalLines = props.memories.length === 0 ? 0 : measuredContentRows;
|
|
29
|
+
const maxTopLine = Math.max(0, totalLines - bodyRows);
|
|
30
|
+
const [topLine, setTopLine] = useState(0);
|
|
31
|
+
const visibleTopLine = clamp(topLine, 0, maxTopLine);
|
|
32
|
+
const visibleEnd = Math.min(totalLines, visibleTopLine + bodyRows);
|
|
33
|
+
|
|
34
|
+
useInput((input, key) => {
|
|
35
|
+
if (key.escape) {
|
|
36
|
+
props.onClose();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.home) {
|
|
40
|
+
setTopLine(0);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (key.end) {
|
|
44
|
+
setTopLine(maxTopLine);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (key.pageUp || key.pageDown) {
|
|
48
|
+
const direction = key.pageUp ? -1 : 1;
|
|
49
|
+
setTopLine((current) => clamp(current + direction * bodyRows, 0, maxTopLine));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const direction =
|
|
53
|
+
key.upArrow || (input === "k" && !key.ctrl && !key.meta)
|
|
54
|
+
? -1
|
|
55
|
+
: key.downArrow || (input === "j" && !key.ctrl && !key.meta)
|
|
56
|
+
? 1
|
|
57
|
+
: 0;
|
|
58
|
+
if (direction !== 0) {
|
|
59
|
+
setTopLine((current) => clamp(current + direction, 0, maxTopLine));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box width={columns} height={rows} flexDirection="column" overflow="hidden">
|
|
65
|
+
<Text bold>Global memory</Text>
|
|
66
|
+
<Text dimColor wrap="truncate-end">
|
|
67
|
+
↑/↓ or j/k · PgUp/PgDn · Home/End · Esc close
|
|
68
|
+
</Text>
|
|
69
|
+
<Box
|
|
70
|
+
height={bodyRows}
|
|
71
|
+
width={columns}
|
|
72
|
+
position="relative"
|
|
73
|
+
overflow="hidden"
|
|
74
|
+
flexDirection="column"
|
|
75
|
+
>
|
|
76
|
+
{props.memories.length === 0 ? (
|
|
77
|
+
<Text dimColor>No stored memories.</Text>
|
|
78
|
+
) : (
|
|
79
|
+
<Box
|
|
80
|
+
ref={contentRef}
|
|
81
|
+
position="absolute"
|
|
82
|
+
top={-visibleTopLine}
|
|
83
|
+
width={columns}
|
|
84
|
+
flexDirection="column"
|
|
85
|
+
>
|
|
86
|
+
{props.memories.map((memory, index) => (
|
|
87
|
+
<Box
|
|
88
|
+
key={memory.memoryId}
|
|
89
|
+
flexDirection="column"
|
|
90
|
+
marginBottom={index === props.memories.length - 1 ? 0 : 1}
|
|
91
|
+
>
|
|
92
|
+
<Text dimColor wrap="truncate-middle">
|
|
93
|
+
{formatMemoryCreatedAt(memory.createdAt)} · {memory.sourceWorkspace}
|
|
94
|
+
</Text>
|
|
95
|
+
<Text>{normalizeMemoryDisplayText(memory.text)}</Text>
|
|
96
|
+
</Box>
|
|
97
|
+
))}
|
|
98
|
+
</Box>
|
|
99
|
+
)}
|
|
100
|
+
</Box>
|
|
101
|
+
<Text dimColor wrap="truncate-end">
|
|
102
|
+
{props.memories.length === 0
|
|
103
|
+
? "0 memories"
|
|
104
|
+
: memoryBrowserStatus(
|
|
105
|
+
visibleTopLine,
|
|
106
|
+
visibleEnd,
|
|
107
|
+
hasMeasured ? totalLines : 0,
|
|
108
|
+
props.memories.length,
|
|
109
|
+
)}
|
|
110
|
+
</Text>
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function normalizeMemoryDisplayText(text: string): string {
|
|
116
|
+
return (
|
|
117
|
+
text
|
|
118
|
+
.replaceAll(/\r\n?/g, "\n")
|
|
119
|
+
.replaceAll("\t", " ")
|
|
120
|
+
// eslint-disable-next-line no-control-regex
|
|
121
|
+
.replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "\uFFFD")
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatMemoryCreatedAt(createdAt: string): string {
|
|
126
|
+
const date = new Date(createdAt);
|
|
127
|
+
const year = String(date.getFullYear()).padStart(4, "0");
|
|
128
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
129
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
130
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
131
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
132
|
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function memoryBrowserStatus(
|
|
136
|
+
topLine: number,
|
|
137
|
+
endLine: number,
|
|
138
|
+
totalLines: number,
|
|
139
|
+
memoryCount: number,
|
|
140
|
+
): string {
|
|
141
|
+
if (totalLines === 0) {
|
|
142
|
+
return `0 lines · ${memoryCount} ${memoryCount === 1 ? "memory" : "memories"}`;
|
|
143
|
+
}
|
|
144
|
+
return `${topLine + 1}–${endLine} / ${totalLines} lines · ${memoryCount} ${
|
|
145
|
+
memoryCount === 1 ? "memory" : "memories"
|
|
146
|
+
}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
150
|
+
return Math.min(Math.max(value, minimum), maximum);
|
|
151
|
+
}
|
|
@@ -222,7 +222,8 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
222
222
|
if (mention === undefined || locked) {
|
|
223
223
|
return;
|
|
224
224
|
}
|
|
225
|
-
|
|
225
|
+
const importImage = props.importImage;
|
|
226
|
+
if (importImage === undefined) {
|
|
226
227
|
insertFilePath(filePath);
|
|
227
228
|
return;
|
|
228
229
|
}
|
|
@@ -236,8 +237,10 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
236
237
|
phase: { kind: "attaching", operationId },
|
|
237
238
|
error: undefined,
|
|
238
239
|
}));
|
|
239
|
-
void
|
|
240
|
-
.
|
|
240
|
+
void Promise.resolve()
|
|
241
|
+
.then(() =>
|
|
242
|
+
importImage(filePath, controller.signal, captured.attachments.length + 1),
|
|
243
|
+
)
|
|
241
244
|
.then((imported) => {
|
|
242
245
|
setState((current) => {
|
|
243
246
|
if (
|
package/src/tui/event-store.ts
CHANGED
|
@@ -697,8 +697,8 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
|
|
|
697
697
|
if (input.name === "Grep") {
|
|
698
698
|
return `Grep ${toolPattern(input.args) ?? ""}`.trim();
|
|
699
699
|
}
|
|
700
|
-
if (input.name === "WebSearch") {
|
|
701
|
-
return
|
|
700
|
+
if (input.name === "WebSearch" || input.name === "MemorySearch") {
|
|
701
|
+
return `${input.name} ${toolQuery(input.args) ?? ""}`.trim();
|
|
702
702
|
}
|
|
703
703
|
if (input.name === "WebFetch") {
|
|
704
704
|
return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
|
|
@@ -779,6 +779,11 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
779
779
|
return raw.mode === "search"
|
|
780
780
|
? `${base} -> ${raw.page.hits.length} historical match${raw.page.hits.length === 1 ? "" : "es"}`
|
|
781
781
|
: `${base} -> ${raw.page.returnedBytes} historical bytes`;
|
|
782
|
+
case "memory_search":
|
|
783
|
+
if (!raw.ok) {
|
|
784
|
+
return base;
|
|
785
|
+
}
|
|
786
|
+
return `${base} -> ${raw.matches.length} derived memor${raw.matches.length === 1 ? "y" : "ies"}`;
|
|
782
787
|
case "skill":
|
|
783
788
|
if (!raw.ok) {
|
|
784
789
|
return `${base} failed -> ${boundedToolError(raw.error)}`;
|
|
@@ -846,6 +851,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
|
|
|
846
851
|
case "web_search":
|
|
847
852
|
case "web_fetch":
|
|
848
853
|
case "recall":
|
|
854
|
+
case "memory_search":
|
|
849
855
|
case "skill":
|
|
850
856
|
case "mcp":
|
|
851
857
|
case "generic":
|
|
@@ -880,6 +886,7 @@ function toolRawResultDiff(
|
|
|
880
886
|
case "web_search":
|
|
881
887
|
case "web_fetch":
|
|
882
888
|
case "recall":
|
|
889
|
+
case "memory_search":
|
|
883
890
|
case "skill":
|
|
884
891
|
case "mcp":
|
|
885
892
|
case "generic":
|
|
@@ -1,30 +1,78 @@
|
|
|
1
1
|
export type SlashCommand = {
|
|
2
|
-
name: string;
|
|
3
|
-
description: string;
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly description: string;
|
|
4
|
+
readonly usage?: string;
|
|
4
5
|
};
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
export type BuiltInSlashCommand = SlashCommand & {
|
|
8
|
+
readonly usage: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
|
|
12
|
+
{
|
|
13
|
+
name: "status",
|
|
14
|
+
usage: "/status",
|
|
15
|
+
description: "Show session and context details",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "skills",
|
|
19
|
+
usage: "/skills",
|
|
20
|
+
description: "Show available and active Agent Skills",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "mcp",
|
|
24
|
+
usage: "/mcp",
|
|
25
|
+
description: "Show MCP servers and runtime tools",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "memory",
|
|
29
|
+
usage: "/memory",
|
|
30
|
+
description: "Browse stored global memories",
|
|
31
|
+
},
|
|
10
32
|
{
|
|
11
33
|
name: "compact",
|
|
34
|
+
usage: "/compact [retire]",
|
|
12
35
|
description: "Swap tool output or retire a cold history prefix",
|
|
13
36
|
},
|
|
14
|
-
{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ name: "
|
|
20
|
-
{
|
|
21
|
-
|
|
37
|
+
{
|
|
38
|
+
name: "clear",
|
|
39
|
+
usage: "/clear",
|
|
40
|
+
description: "Start a new session and clear conversation",
|
|
41
|
+
},
|
|
42
|
+
{ name: "fork", usage: "/fork", description: "Clone the current session" },
|
|
43
|
+
{
|
|
44
|
+
name: "view",
|
|
45
|
+
usage: "/view <path>",
|
|
46
|
+
description: "View a local UTF-8 text file",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "copy",
|
|
50
|
+
usage: "/copy",
|
|
51
|
+
description: "Copy the last response as Markdown",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "model",
|
|
55
|
+
usage: "/model [profile-name]",
|
|
56
|
+
description: "Switch model profile (new session)",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "resume",
|
|
60
|
+
usage: "/resume [session-id]",
|
|
61
|
+
description: "Choose or resume a session",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "session",
|
|
65
|
+
usage: "/session delete <session-id> --confirm",
|
|
66
|
+
description: "Manage stored sessions",
|
|
67
|
+
},
|
|
68
|
+
{ name: "quit", usage: "/quit", description: "Exit the TUI" },
|
|
22
69
|
];
|
|
23
70
|
|
|
24
71
|
export type ParsedSlashCommand =
|
|
25
72
|
| { type: "status" }
|
|
26
73
|
| { type: "skills" }
|
|
27
74
|
| { type: "mcp" }
|
|
75
|
+
| { type: "memory" }
|
|
28
76
|
| { type: "compact" }
|
|
29
77
|
| { type: "compact_retire" }
|
|
30
78
|
| { type: "clear" }
|
|
@@ -48,12 +96,12 @@ export class SlashCommandError extends Error {
|
|
|
48
96
|
export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
49
97
|
const trimmed = input.trim();
|
|
50
98
|
if (trimmed === "/view") {
|
|
51
|
-
throw
|
|
99
|
+
throw slashCommandUsageError("view");
|
|
52
100
|
}
|
|
53
101
|
if (trimmed.startsWith("/view ") || trimmed.startsWith("/view\t")) {
|
|
54
102
|
const filePath = trimmed.slice(5).trim();
|
|
55
103
|
if (filePath === "") {
|
|
56
|
-
throw
|
|
104
|
+
throw slashCommandUsageError("view");
|
|
57
105
|
}
|
|
58
106
|
return { type: "view", filePath };
|
|
59
107
|
}
|
|
@@ -70,7 +118,13 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
70
118
|
if (tokens.length === 1) {
|
|
71
119
|
return { type: "mcp" };
|
|
72
120
|
}
|
|
73
|
-
throw
|
|
121
|
+
throw slashCommandUsageError("mcp");
|
|
122
|
+
}
|
|
123
|
+
if (command === "/memory") {
|
|
124
|
+
if (tokens.length === 1) {
|
|
125
|
+
return { type: "memory" };
|
|
126
|
+
}
|
|
127
|
+
throw slashCommandUsageError("memory");
|
|
74
128
|
}
|
|
75
129
|
if (command === "/compact") {
|
|
76
130
|
if (tokens.length === 1) {
|
|
@@ -79,25 +133,25 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
79
133
|
if (tokens.length === 2 && tokens[1] === "retire") {
|
|
80
134
|
return { type: "compact_retire" };
|
|
81
135
|
}
|
|
82
|
-
throw
|
|
136
|
+
throw slashCommandUsageError("compact");
|
|
83
137
|
}
|
|
84
138
|
if (command === "/clear") {
|
|
85
139
|
if (tokens.length === 1) {
|
|
86
140
|
return { type: "clear" };
|
|
87
141
|
}
|
|
88
|
-
throw
|
|
142
|
+
throw slashCommandUsageError("clear");
|
|
89
143
|
}
|
|
90
144
|
if (command === "/fork") {
|
|
91
145
|
if (tokens.length === 1) {
|
|
92
146
|
return { type: "fork" };
|
|
93
147
|
}
|
|
94
|
-
throw
|
|
148
|
+
throw slashCommandUsageError("fork");
|
|
95
149
|
}
|
|
96
150
|
if (command === "/copy") {
|
|
97
151
|
if (tokens.length === 1) {
|
|
98
152
|
return { type: "copy" };
|
|
99
153
|
}
|
|
100
|
-
throw
|
|
154
|
+
throw slashCommandUsageError("copy");
|
|
101
155
|
}
|
|
102
156
|
if (command === "/quit" && tokens.length === 1) {
|
|
103
157
|
return { type: "quit" };
|
|
@@ -109,7 +163,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
109
163
|
if (tokens.length === 2) {
|
|
110
164
|
return { type: "model_switch", profileName: tokens[1] };
|
|
111
165
|
}
|
|
112
|
-
throw
|
|
166
|
+
throw slashCommandUsageError("model");
|
|
113
167
|
}
|
|
114
168
|
if (command === "/resume") {
|
|
115
169
|
if (tokens.length === 1) {
|
|
@@ -118,7 +172,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
118
172
|
if (tokens.length === 2) {
|
|
119
173
|
return { type: "resume", sessionId: parsePublicSessionId(tokens[1]) };
|
|
120
174
|
}
|
|
121
|
-
throw
|
|
175
|
+
throw slashCommandUsageError("resume");
|
|
122
176
|
}
|
|
123
177
|
if (command === "/session") {
|
|
124
178
|
if (tokens.length === 4 && tokens[1] === "delete" && tokens[3] === "--confirm") {
|
|
@@ -127,7 +181,7 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
|
|
|
127
181
|
sessionId: parsePublicSessionId(tokens[2]),
|
|
128
182
|
};
|
|
129
183
|
}
|
|
130
|
-
throw
|
|
184
|
+
throw slashCommandUsageError("session");
|
|
131
185
|
}
|
|
132
186
|
throw new SlashCommandError(`Unknown command: ${trimmed}`);
|
|
133
187
|
}
|
|
@@ -167,4 +221,14 @@ function parsePublicSessionId(value: string): SessionId {
|
|
|
167
221
|
throw new SlashCommandError(`Invalid session ID: ${value}`);
|
|
168
222
|
}
|
|
169
223
|
}
|
|
224
|
+
|
|
225
|
+
function slashCommandUsageError(
|
|
226
|
+
name: (typeof SLASH_COMMANDS)[number]["name"],
|
|
227
|
+
): SlashCommandError {
|
|
228
|
+
const command = SLASH_COMMANDS.find((candidate) => candidate.name === name);
|
|
229
|
+
if (command === undefined) {
|
|
230
|
+
throw new Error(`Missing built-in slash command declaration for ${name}.`);
|
|
231
|
+
}
|
|
232
|
+
return new SlashCommandError(`Usage: ${command.usage}`);
|
|
233
|
+
}
|
|
170
234
|
import { parseSessionId, type SessionId } from "../ids/runtime-id";
|