tinker-agent 1.5.1 → 1.7.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 +52 -1
- package/README.md +15 -7
- package/package.json +8 -7
- 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/runner-dependencies.ts +4 -1
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/bash-result-detail.ts +13 -6
- package/src/events/observation-text-log.ts +26 -1
- package/src/events/stdout-event-printer.ts +18 -2
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +177 -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 +54 -6
- package/src/session/session-catalog.ts +17 -11
- package/src/session/session-store.ts +2 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash-task.ts +129 -90
- package/src/tools/bash.ts +75 -13
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +49 -3
- package/src/tools/shell-process.ts +296 -0
- package/src/tools/task-input.ts +229 -0
- package/src/tools/task-output-tool.ts +4 -1
- package/src/tools/terminal-screen.ts +105 -0
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +45 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/background-tasks.tsx +3 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +292 -46
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +76 -4
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +19 -1
package/src/tools/types.ts
CHANGED
|
@@ -72,6 +72,13 @@ export type EditFileRawResult = {
|
|
|
72
72
|
error?: string;
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
export type DeleteFileRawResult = {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
filePath: string;
|
|
78
|
+
absolutePath?: string;
|
|
79
|
+
error?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
75
82
|
export type GlobRawResult = {
|
|
76
83
|
ok: boolean;
|
|
77
84
|
pattern: string;
|
|
@@ -123,6 +130,10 @@ export type BashRawResult = {
|
|
|
123
130
|
backgrounded?: boolean;
|
|
124
131
|
backgroundedDueToTimeout?: boolean;
|
|
125
132
|
returnCodeInterpretation?: string;
|
|
133
|
+
tty: boolean;
|
|
134
|
+
screenRows?: number;
|
|
135
|
+
screenColumns?: number;
|
|
136
|
+
screen?: string;
|
|
126
137
|
error?: string;
|
|
127
138
|
};
|
|
128
139
|
|
|
@@ -145,9 +156,36 @@ export type TaskOutputRawResult = {
|
|
|
145
156
|
truncated?: boolean;
|
|
146
157
|
omittedLines?: number;
|
|
147
158
|
outputFilePath?: string;
|
|
159
|
+
screenRows?: number;
|
|
160
|
+
screenColumns?: number;
|
|
161
|
+
screen?: string;
|
|
148
162
|
error?: string;
|
|
149
163
|
};
|
|
150
164
|
|
|
165
|
+
export type TaskInputRawResult =
|
|
166
|
+
| {
|
|
167
|
+
ok: true;
|
|
168
|
+
taskId: string;
|
|
169
|
+
task: ShellTaskSnapshot;
|
|
170
|
+
status: ShellTaskStatus;
|
|
171
|
+
writtenBytes: number;
|
|
172
|
+
waitedMs: number;
|
|
173
|
+
screenRows: number;
|
|
174
|
+
screenColumns: number;
|
|
175
|
+
screen: string;
|
|
176
|
+
outputBytes: number;
|
|
177
|
+
outputLines: number;
|
|
178
|
+
outputFilePath: string;
|
|
179
|
+
}
|
|
180
|
+
| {
|
|
181
|
+
ok: false;
|
|
182
|
+
taskId: string;
|
|
183
|
+
task?: ShellTaskSnapshot;
|
|
184
|
+
status?: ShellTaskStatus;
|
|
185
|
+
writtenBytes?: number;
|
|
186
|
+
error: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
151
189
|
export type TaskStopRawResult = {
|
|
152
190
|
ok: boolean;
|
|
153
191
|
taskId: string;
|
|
@@ -313,11 +351,13 @@ export type ToolRawResultByKind = {
|
|
|
313
351
|
read: ReadFileRawResult;
|
|
314
352
|
write: WriteFileRawResult;
|
|
315
353
|
edit: EditFileRawResult;
|
|
354
|
+
delete: DeleteFileRawResult;
|
|
316
355
|
glob: GlobRawResult;
|
|
317
356
|
grep: GrepRawResult;
|
|
318
357
|
bash: BashRawResult;
|
|
319
358
|
task_list: TaskListRawResult;
|
|
320
359
|
task_output: TaskOutputRawResult;
|
|
360
|
+
task_input: TaskInputRawResult;
|
|
321
361
|
task_stop: TaskStopRawResult;
|
|
322
362
|
web_search: WebSearchRawResult;
|
|
323
363
|
web_fetch: WebFetchRawResult;
|
|
@@ -367,6 +407,11 @@ export function defineToolExecutor<TKind extends ToolRawResultKind>(
|
|
|
367
407
|
|
|
368
408
|
export type ToolExecutionContext = {
|
|
369
409
|
signal: AbortSignal;
|
|
410
|
+
confirmBashCommand?: (request: {
|
|
411
|
+
command: string;
|
|
412
|
+
reason: string;
|
|
413
|
+
}) => Promise<"allow" | "deny">;
|
|
414
|
+
bashGuardSurface?: "tui" | "one-shot";
|
|
370
415
|
};
|
|
371
416
|
|
|
372
417
|
export class ToolExecutionFatalError extends Error {
|
package/src/tools/write.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { computeFilePatch } from "./file-diff";
|
|
|
5
5
|
import { ensureParentDirectory } from "./ensure-parent-directory";
|
|
6
6
|
import { sha256Bytes, sha256Text } from "./hash";
|
|
7
7
|
import { resolveWorkspacePath } from "./path-safety";
|
|
8
|
+
import type { TurnUndoManager } from "./turn-undo-manager";
|
|
8
9
|
import { defineToolExecutor } from "./types";
|
|
9
10
|
import type {
|
|
10
11
|
FileSnapshotStore,
|
|
@@ -21,6 +22,7 @@ type WriteArgs = {
|
|
|
21
22
|
export type WriteToolOptions = {
|
|
22
23
|
workspaceRoot: string;
|
|
23
24
|
snapshots: FileSnapshotStore;
|
|
25
|
+
undoManager?: TurnUndoManager;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor {
|
|
@@ -47,7 +49,7 @@ export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor
|
|
|
47
49
|
},
|
|
48
50
|
async execute(
|
|
49
51
|
args,
|
|
50
|
-
|
|
52
|
+
call,
|
|
51
53
|
context: ToolExecutionContext,
|
|
52
54
|
): Promise<WriteFileRawResult> {
|
|
53
55
|
throwIfTurnCancelled(context.signal);
|
|
@@ -118,10 +120,24 @@ export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor
|
|
|
118
120
|
oldContent = target.content;
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
const undoCapture = await options.undoManager?.captureBeforeMutation({
|
|
124
|
+
turnId: call.turnId,
|
|
125
|
+
turnNumber: call.turnNumber,
|
|
126
|
+
absolutePath,
|
|
127
|
+
displayPath: input.file_path,
|
|
128
|
+
loadBefore: async () =>
|
|
129
|
+
target.exists
|
|
130
|
+
? { state: "present", bytes: target.bytes }
|
|
131
|
+
: { state: "absent" },
|
|
132
|
+
});
|
|
133
|
+
|
|
121
134
|
throwIfTurnCancelled(context.signal);
|
|
122
135
|
try {
|
|
123
136
|
await ensureParentDirectory(absolutePath);
|
|
124
137
|
} catch (error) {
|
|
138
|
+
if (undoCapture !== undefined) {
|
|
139
|
+
await options.undoManager?.recordMutationFailure(undoCapture);
|
|
140
|
+
}
|
|
125
141
|
return {
|
|
126
142
|
ok: false,
|
|
127
143
|
filePath: input.file_path,
|
|
@@ -129,15 +145,39 @@ export function createWriteToolExecutor(options: WriteToolOptions): ToolExecutor
|
|
|
129
145
|
error: `Failed to create parent directory: ${errorMessage(error)}`,
|
|
130
146
|
};
|
|
131
147
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
let newSha256: string;
|
|
149
|
+
try {
|
|
150
|
+
throwIfTurnCancelled(context.signal);
|
|
151
|
+
await writeFile(absolutePath, input.content, "utf8");
|
|
152
|
+
const expectedSha256 = sha256Text(input.content);
|
|
153
|
+
const written = await targetFileState(absolutePath);
|
|
154
|
+
if (!written.ok) {
|
|
155
|
+
throw new Error(`Failed to verify written file: ${written.error}`);
|
|
156
|
+
}
|
|
157
|
+
if (!written.exists) {
|
|
158
|
+
throw new Error("Failed to verify written file: File does not exist.");
|
|
159
|
+
}
|
|
160
|
+
if (written.sha256 !== expectedSha256) {
|
|
161
|
+
throw new Error("File changed while Write was being verified.");
|
|
162
|
+
}
|
|
163
|
+
newSha256 = written.sha256;
|
|
164
|
+
if (undoCapture !== undefined) {
|
|
165
|
+
options.undoManager?.recordMutationResult(undoCapture, {
|
|
166
|
+
state: "present",
|
|
167
|
+
sha256: newSha256,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
options.snapshots.set(absolutePath, {
|
|
171
|
+
sha256: newSha256,
|
|
172
|
+
mtimeMs: written.mtimeMs,
|
|
173
|
+
source: "write",
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (undoCapture !== undefined) {
|
|
177
|
+
await options.undoManager?.recordMutationFailure(undoCapture);
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
141
181
|
|
|
142
182
|
const patch = computeFilePatch({
|
|
143
183
|
filePath: input.file_path,
|
|
@@ -184,11 +224,16 @@ function parseWriteArgs(
|
|
|
184
224
|
};
|
|
185
225
|
}
|
|
186
226
|
|
|
187
|
-
async function targetFileState(
|
|
188
|
-
absolutePath: string,
|
|
189
|
-
): Promise<
|
|
227
|
+
async function targetFileState(absolutePath: string): Promise<
|
|
190
228
|
| { ok: true; exists: false }
|
|
191
|
-
| {
|
|
229
|
+
| {
|
|
230
|
+
ok: true;
|
|
231
|
+
exists: true;
|
|
232
|
+
sha256: string;
|
|
233
|
+
content: string;
|
|
234
|
+
bytes: Buffer;
|
|
235
|
+
mtimeMs: number;
|
|
236
|
+
}
|
|
192
237
|
| { ok: false; error: string }
|
|
193
238
|
> {
|
|
194
239
|
try {
|
|
@@ -198,11 +243,17 @@ async function targetFileState(
|
|
|
198
243
|
}
|
|
199
244
|
|
|
200
245
|
const bytes = await readFile(absolutePath);
|
|
246
|
+
const currentInfo = await stat(absolutePath);
|
|
247
|
+
if (currentInfo.mtimeMs > info.mtimeMs) {
|
|
248
|
+
return { ok: false, error: "File changed while it was being read." };
|
|
249
|
+
}
|
|
201
250
|
return {
|
|
202
251
|
ok: true,
|
|
203
252
|
exists: true,
|
|
204
253
|
sha256: sha256Bytes(bytes),
|
|
205
254
|
content: bytes.toString("utf8"),
|
|
255
|
+
bytes,
|
|
256
|
+
mtimeMs: currentInfo.mtimeMs,
|
|
206
257
|
};
|
|
207
258
|
} catch (error) {
|
|
208
259
|
if (isNotFound(error)) {
|
package/src/tui/app.tsx
CHANGED
|
@@ -19,12 +19,12 @@ import { ContextBudgetExceededError } from "../model/model-request-preflight";
|
|
|
19
19
|
import { ModelRequestMediaAggregateError } from "../model/model-client";
|
|
20
20
|
import type { SessionId } from "../ids/runtime-id";
|
|
21
21
|
import { readLastAssistantResponse } from "../session/session-last-response-reader";
|
|
22
|
-
import type { TimelineItem } from "./event-store";
|
|
23
22
|
import type { PromptHistory } from "./prompt-history";
|
|
24
23
|
import { Footer } from "./components/footer";
|
|
25
24
|
import { AssistantMarkdownProvider } from "./components/assistant-markdown";
|
|
26
25
|
import { ContextStatus } from "./components/context-status";
|
|
27
26
|
import { BackgroundTasks } from "./components/background-tasks";
|
|
27
|
+
import { BashConfirmation } from "./components/bash-confirmation";
|
|
28
28
|
import { Header } from "./components/header";
|
|
29
29
|
import { ModelPicker } from "./components/model-picker";
|
|
30
30
|
import { FileViewer, FileViewerLoading } from "./components/file-viewer";
|
|
@@ -42,7 +42,11 @@ import {
|
|
|
42
42
|
ResumeSessionPicker,
|
|
43
43
|
ResumeSessionPickerLoading,
|
|
44
44
|
} from "./components/resume-session-picker";
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
AssistantStreamSectionRow,
|
|
47
|
+
Timeline,
|
|
48
|
+
TimelineRow,
|
|
49
|
+
} from "./components/timeline";
|
|
46
50
|
import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
|
|
47
51
|
import {
|
|
48
52
|
resolveProjectSlashCommand,
|
|
@@ -54,6 +58,11 @@ import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
|
|
|
54
58
|
import { loadViewFile, type ViewFile } from "./view-file";
|
|
55
59
|
import { writeClipboardText } from "./clipboard";
|
|
56
60
|
import type { WorkspaceFileLister } from "./workspace-file-search";
|
|
61
|
+
import type { TurnUndoBarrierReason, TurnUndoResult } from "../tools/turn-undo-manager";
|
|
62
|
+
import {
|
|
63
|
+
isAssistantStreamSectionItem,
|
|
64
|
+
type TuiCommittedItem,
|
|
65
|
+
} from "./tui-projection-store";
|
|
57
66
|
|
|
58
67
|
export type AppProps = {
|
|
59
68
|
sessionController: TuiSessionController;
|
|
@@ -122,6 +131,11 @@ export function App(props: AppProps) {
|
|
|
122
131
|
binding.projectionStore.getLogSnapshot,
|
|
123
132
|
binding.projectionStore.getLogSnapshot,
|
|
124
133
|
);
|
|
134
|
+
const bashGuard = useSyncExternalStore(
|
|
135
|
+
(listener) => binding.subscribeBashGuard(listener),
|
|
136
|
+
() => binding.bashGuard(),
|
|
137
|
+
() => binding.bashGuard(),
|
|
138
|
+
);
|
|
125
139
|
const [isRunning, setIsRunning] = useState(false);
|
|
126
140
|
const [isSessionOperation, setIsSessionOperation] = useState(false);
|
|
127
141
|
const [isCopying, setIsCopying] = useState(false);
|
|
@@ -156,7 +170,7 @@ export function App(props: AppProps) {
|
|
|
156
170
|
write(clearTerminal);
|
|
157
171
|
setStaticRenderEpoch((current) => current + 1);
|
|
158
172
|
}, [write]);
|
|
159
|
-
const staticItems = useMemo<Array<typeof STATIC_HEADER |
|
|
173
|
+
const staticItems = useMemo<Array<typeof STATIC_HEADER | TuiCommittedItem>>(
|
|
160
174
|
() => [STATIC_HEADER, ...log.committed],
|
|
161
175
|
[log.committed],
|
|
162
176
|
);
|
|
@@ -549,6 +563,19 @@ export function App(props: AppProps) {
|
|
|
549
563
|
setShowStatus(true);
|
|
550
564
|
return true;
|
|
551
565
|
}
|
|
566
|
+
if (command.type === "yolo_status") {
|
|
567
|
+
setNotice(`Bash guard: ${bashGuard.mode} (source: ${bashGuard.source}).`);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if (command.type === "yolo") {
|
|
571
|
+
binding.setYoloMode(command.enabled);
|
|
572
|
+
setNotice(
|
|
573
|
+
command.enabled
|
|
574
|
+
? "YOLO enabled for this session; dangerous Bash commands will run without confirmation."
|
|
575
|
+
: "YOLO disabled; dangerous Bash commands require confirmation.",
|
|
576
|
+
);
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
552
579
|
if (command.type === "skills") {
|
|
553
580
|
setShowSkills(true);
|
|
554
581
|
return true;
|
|
@@ -579,6 +606,22 @@ export function App(props: AppProps) {
|
|
|
579
606
|
.finally(() => setIsSessionOperation(false));
|
|
580
607
|
return true;
|
|
581
608
|
}
|
|
609
|
+
if (command.type === "undo") {
|
|
610
|
+
setIsSessionOperation(true);
|
|
611
|
+
void props.sessionController
|
|
612
|
+
.undo()
|
|
613
|
+
.then((result) => {
|
|
614
|
+
if (result.status === "restored" || result.status === "incomplete") {
|
|
615
|
+
setGitBranchRefresh((current) => current + 1);
|
|
616
|
+
}
|
|
617
|
+
setNotice(formatTurnUndoNotice(result));
|
|
618
|
+
})
|
|
619
|
+
.catch((error: unknown) => {
|
|
620
|
+
setNotice(errorMessage(error));
|
|
621
|
+
})
|
|
622
|
+
.finally(() => setIsSessionOperation(false));
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
582
625
|
if (command.type === "clear") {
|
|
583
626
|
setIsSessionOperation(true);
|
|
584
627
|
void props.sessionController
|
|
@@ -676,6 +719,8 @@ export function App(props: AppProps) {
|
|
|
676
719
|
workspaceRoot={binding.workspaceRoot}
|
|
677
720
|
sessionId={binding.sessionId}
|
|
678
721
|
/>
|
|
722
|
+
) : isAssistantStreamSectionItem(item) ? (
|
|
723
|
+
<AssistantStreamSectionRow key={item.id} item={item} />
|
|
679
724
|
) : (
|
|
680
725
|
<TimelineRow key={item.id} item={item} />
|
|
681
726
|
)
|
|
@@ -700,55 +745,71 @@ export function App(props: AppProps) {
|
|
|
700
745
|
) : (
|
|
701
746
|
<Box
|
|
702
747
|
flexDirection="column"
|
|
703
|
-
maxHeight={
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
overflow={state.status === "running" ? "hidden" : "visible"}
|
|
748
|
+
maxHeight={Math.max(1, windowSize.rows - 1)}
|
|
749
|
+
overflow="hidden"
|
|
707
750
|
>
|
|
708
|
-
<Box
|
|
709
|
-
<Box
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
751
|
+
<Box flexDirection="column" flexShrink={1} minHeight={0} overflow="hidden">
|
|
752
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
753
|
+
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
|
754
|
+
<Box
|
|
755
|
+
maxHeight={
|
|
756
|
+
state.backgroundTasks.length === 0
|
|
757
|
+
? LIVE_TIMELINE_MAX_ROWS
|
|
758
|
+
: LIVE_TIMELINE_WITH_TASKS_MAX_ROWS
|
|
759
|
+
}
|
|
760
|
+
overflow="hidden"
|
|
761
|
+
>
|
|
762
|
+
<Timeline items={log.live} />
|
|
763
|
+
</Box>
|
|
764
|
+
</Box>
|
|
765
|
+
{state.backgroundTasks.length === 0 ? null : (
|
|
766
|
+
<Box
|
|
767
|
+
marginTop={1}
|
|
768
|
+
maxHeight={BACKGROUND_TASKS_MAX_ROWS}
|
|
769
|
+
overflow="hidden"
|
|
770
|
+
flexShrink={0}
|
|
771
|
+
>
|
|
772
|
+
<BackgroundTasks tasks={state.backgroundTasks} />
|
|
773
|
+
</Box>
|
|
774
|
+
)}
|
|
775
|
+
{showStatus ? (
|
|
776
|
+
<Box marginTop={1} flexShrink={0}>
|
|
777
|
+
<ContextStatus state={state} bashGuard={bashGuard} />
|
|
778
|
+
</Box>
|
|
779
|
+
) : null}
|
|
780
|
+
{showSkills ? (
|
|
781
|
+
<Box marginTop={1} flexShrink={0}>
|
|
782
|
+
<SkillsPanel snapshot={binding.skills()} />
|
|
783
|
+
</Box>
|
|
784
|
+
) : null}
|
|
785
|
+
{showMcp ? (
|
|
786
|
+
<Box marginTop={1} flexShrink={0}>
|
|
787
|
+
<McpPanel snapshot={binding.mcp()} />
|
|
788
|
+
</Box>
|
|
789
|
+
) : null}
|
|
718
790
|
</Box>
|
|
719
791
|
</Box>
|
|
720
|
-
{
|
|
721
|
-
<Box
|
|
722
|
-
marginTop={1}
|
|
723
|
-
maxHeight={BACKGROUND_TASKS_MAX_ROWS}
|
|
724
|
-
overflow="hidden"
|
|
725
|
-
>
|
|
726
|
-
<BackgroundTasks tasks={state.backgroundTasks} />
|
|
727
|
-
</Box>
|
|
728
|
-
)}
|
|
729
|
-
{showStatus ? (
|
|
730
|
-
<Box marginTop={1}>
|
|
731
|
-
<ContextStatus state={state} />
|
|
732
|
-
</Box>
|
|
733
|
-
) : null}
|
|
734
|
-
{showSkills ? (
|
|
735
|
-
<Box marginTop={1}>
|
|
736
|
-
<SkillsPanel snapshot={binding.skills()} />
|
|
737
|
-
</Box>
|
|
738
|
-
) : null}
|
|
739
|
-
{showMcp ? (
|
|
740
|
-
<Box marginTop={1}>
|
|
741
|
-
<McpPanel snapshot={binding.mcp()} />
|
|
742
|
-
</Box>
|
|
743
|
-
) : null}
|
|
744
|
-
<Box marginTop={1}>
|
|
792
|
+
<Box marginTop={1} flexShrink={0}>
|
|
745
793
|
<Footer
|
|
746
794
|
status={isCancelling ? "cancelling" : state.status}
|
|
747
795
|
workedForMs={state.workedForMs}
|
|
796
|
+
yolo={bashGuard.mode === "yolo"}
|
|
748
797
|
/>
|
|
749
798
|
</Box>
|
|
750
|
-
<Box marginTop={1} flexDirection="column">
|
|
751
|
-
{
|
|
799
|
+
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
|
800
|
+
{bashGuard.pending !== undefined ? (
|
|
801
|
+
<BashConfirmation
|
|
802
|
+
command={bashGuard.pending.command}
|
|
803
|
+
reason={bashGuard.pending.reason}
|
|
804
|
+
onDecision={(decision) => {
|
|
805
|
+
void binding
|
|
806
|
+
.resolveBashConfirmation(decision)
|
|
807
|
+
.catch((error: unknown) =>
|
|
808
|
+
setNotice(`Bash confirmation failed: ${errorMessage(error)}`),
|
|
809
|
+
);
|
|
810
|
+
}}
|
|
811
|
+
/>
|
|
812
|
+
) : showModelPicker ? (
|
|
752
813
|
<ModelPicker
|
|
753
814
|
profiles={profileList}
|
|
754
815
|
currentProfileName={binding.profileName}
|
|
@@ -763,7 +824,12 @@ export function App(props: AppProps) {
|
|
|
763
824
|
workspaceRoot={binding.workspaceRoot}
|
|
764
825
|
gitBranch={gitBranch}
|
|
765
826
|
contextUsage={state.contextUsage}
|
|
766
|
-
isDisabled={
|
|
827
|
+
isDisabled={
|
|
828
|
+
isRunning ||
|
|
829
|
+
isSessionOperation ||
|
|
830
|
+
isCopying ||
|
|
831
|
+
bashGuard.pending !== undefined
|
|
832
|
+
}
|
|
767
833
|
history={props.history}
|
|
768
834
|
commands={availableCommands}
|
|
769
835
|
fileLister={props.fileLister}
|
|
@@ -811,6 +877,56 @@ export function formatContextCompactionNotice(result: ContextCompactionResult):
|
|
|
811
877
|
return `Context compacted: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.addedOverrideCount} observations swapped, ${before} -> ${after} estimated tokens (-${reduction}%).`;
|
|
812
878
|
}
|
|
813
879
|
|
|
880
|
+
export function formatTurnUndoNotice(result: TurnUndoResult): string {
|
|
881
|
+
if (result.status === "nothing") {
|
|
882
|
+
return "Nothing to undo in this active session.";
|
|
883
|
+
}
|
|
884
|
+
if (result.status === "unavailable") {
|
|
885
|
+
return `Cannot undo turn ${result.turnNumber}: ${formatUndoBarrierReason(result.reason)}`;
|
|
886
|
+
}
|
|
887
|
+
if (result.status === "refused") {
|
|
888
|
+
const count = result.conflicts.length;
|
|
889
|
+
return [
|
|
890
|
+
`Undo refused: ${count} ${count === 1 ? "file" : "files"} changed after turn ${result.turnNumber}.`,
|
|
891
|
+
...result.conflicts.map(
|
|
892
|
+
(conflict) => `- ${conflict.displayPath}: ${conflict.detail}`,
|
|
893
|
+
),
|
|
894
|
+
].join("\n");
|
|
895
|
+
}
|
|
896
|
+
if (result.status === "incomplete") {
|
|
897
|
+
return `Undo incomplete for turn ${result.turnNumber}: ${formatPartialUndoCount(result)} before ${result.failedPath} failed: ${sentenceDetail(result.detail)}\nRun /undo again to retry.`;
|
|
898
|
+
}
|
|
899
|
+
return `Restored workspace to before turn ${result.turnNumber}: ${formatFileCount(result.restoredFileCount)} restored, ${formatFileCount(result.deletedFileCount)} deleted.`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function formatUndoBarrierReason(reason: TurnUndoBarrierReason): string {
|
|
903
|
+
if (reason.kind === "file-too-large" || reason.kind === "turn-too-large") {
|
|
904
|
+
return "undo snapshot capacity was exceeded.";
|
|
905
|
+
}
|
|
906
|
+
return `undo snapshot could not be captured for ${reason.displayPath}: ${sentenceDetail(reason.detail)}`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function formatPartialUndoCount(
|
|
910
|
+
result: Extract<TurnUndoResult, { status: "incomplete" }>,
|
|
911
|
+
): string {
|
|
912
|
+
const completed: string[] = [];
|
|
913
|
+
if (result.restoredFileCount > 0) {
|
|
914
|
+
completed.push(`${formatFileCount(result.restoredFileCount)} restored`);
|
|
915
|
+
}
|
|
916
|
+
if (result.deletedFileCount > 0) {
|
|
917
|
+
completed.push(`${formatFileCount(result.deletedFileCount)} deleted`);
|
|
918
|
+
}
|
|
919
|
+
return completed.length === 0 ? "no files restored" : completed.join(" and ");
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function formatFileCount(count: number): string {
|
|
923
|
+
return `${count} ${count === 1 ? "file" : "files"}`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function sentenceDetail(detail: string): string {
|
|
927
|
+
return /[.!?]$/.test(detail) ? detail : `${detail}.`;
|
|
928
|
+
}
|
|
929
|
+
|
|
814
930
|
export function formatContextCompactionFailureNotice(error: unknown): string {
|
|
815
931
|
if (!(error instanceof ContextManagerError)) {
|
|
816
932
|
return "Context compaction failed.";
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ export type BackgroundTasksProps = {
|
|
|
5
5
|
tasks: readonly ShellTaskSnapshot[];
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
const MAX_VISIBLE_TASKS =
|
|
8
|
+
const MAX_VISIBLE_TASKS = 2;
|
|
9
9
|
|
|
10
10
|
export function BackgroundTasks(props: BackgroundTasksProps) {
|
|
11
11
|
if (props.tasks.length === 0) {
|
|
@@ -26,7 +26,8 @@ export function BackgroundTasks(props: BackgroundTasksProps) {
|
|
|
26
26
|
{visibleTasks.map((task) => (
|
|
27
27
|
<Box key={task.taskId} flexDirection="column">
|
|
28
28
|
<Text color={colorForStatus(task.status)}>
|
|
29
|
-
{symbolForStatus(task.status)} {task.status}
|
|
29
|
+
{symbolForStatus(task.status)} {task.status}
|
|
30
|
+
{task.tty ? " tty" : ""} {taskDescription(task)}
|
|
30
31
|
{taskResult(task) === undefined ? "" : ` ${taskResult(task)}`}
|
|
31
32
|
</Text>
|
|
32
33
|
<Text dimColor>{taskTiming(task)}</Text>
|
|
@@ -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
|
+
}
|