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.
Files changed (52) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +15 -7
  3. package/package.json +8 -7
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/runner-dependencies.ts +4 -1
  14. package/src/cli/tui-runner.tsx +17 -2
  15. package/src/events/bash-result-detail.ts +13 -6
  16. package/src/events/observation-text-log.ts +26 -1
  17. package/src/events/stdout-event-printer.ts +18 -2
  18. package/src/events/types.ts +14 -2
  19. package/src/model/fake-model-client.ts +177 -0
  20. package/src/model/model-client.ts +3 -0
  21. package/src/model/openai-chat-model-client.ts +54 -15
  22. package/src/model/openai-chat-stream.ts +95 -72
  23. package/src/observation/observation-builder.ts +54 -6
  24. package/src/session/session-catalog.ts +17 -11
  25. package/src/session/session-store.ts +2 -0
  26. package/src/tools/bash-guard.ts +131 -0
  27. package/src/tools/bash-task.ts +129 -90
  28. package/src/tools/bash.ts +75 -13
  29. package/src/tools/delete.ts +182 -0
  30. package/src/tools/edit.ts +68 -9
  31. package/src/tools/registry.ts +49 -3
  32. package/src/tools/shell-process.ts +296 -0
  33. package/src/tools/task-input.ts +229 -0
  34. package/src/tools/task-output-tool.ts +4 -1
  35. package/src/tools/terminal-screen.ts +105 -0
  36. package/src/tools/turn-undo-manager.ts +794 -0
  37. package/src/tools/types.ts +45 -0
  38. package/src/tools/write.ts +65 -14
  39. package/src/tui/app.tsx +161 -45
  40. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  41. package/src/tui/components/background-tasks.tsx +3 -2
  42. package/src/tui/components/bash-confirmation.tsx +27 -0
  43. package/src/tui/components/context-status.tsx +11 -1
  44. package/src/tui/components/footer.tsx +8 -5
  45. package/src/tui/components/prompt-input.tsx +13 -1
  46. package/src/tui/components/resume-session-picker.tsx +292 -46
  47. package/src/tui/components/timeline.tsx +10 -0
  48. package/src/tui/context-format.ts +17 -0
  49. package/src/tui/event-store.ts +76 -4
  50. package/src/tui/slash-commands.ts +28 -0
  51. package/src/tui/tui-projection-store.ts +246 -7
  52. package/src/tui/tui-session-controller.ts +19 -1
@@ -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 {
@@ -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
- _call,
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
- throwIfTurnCancelled(context.signal);
133
- await writeFile(absolutePath, input.content, "utf8");
134
- const newSha256 = sha256Text(input.content);
135
- const writtenInfo = await stat(absolutePath);
136
- options.snapshots.set(absolutePath, {
137
- sha256: newSha256,
138
- mtimeMs: writtenInfo.mtimeMs,
139
- source: "write",
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
- | { ok: true; exists: true; sha256: string; content: string }
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 { Timeline, TimelineRow } from "./components/timeline";
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 | TimelineItem>>(
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
- state.status === "running" ? Math.max(1, windowSize.rows - 1) : undefined
705
- }
706
- overflow={state.status === "running" ? "hidden" : "visible"}
748
+ maxHeight={Math.max(1, windowSize.rows - 1)}
749
+ overflow="hidden"
707
750
  >
708
- <Box marginTop={1} flexDirection="column">
709
- <Box
710
- maxHeight={
711
- state.backgroundTasks.length === 0
712
- ? LIVE_TIMELINE_MAX_ROWS
713
- : LIVE_TIMELINE_WITH_TASKS_MAX_ROWS
714
- }
715
- overflow="hidden"
716
- >
717
- <Timeline items={log.live} />
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
- {state.backgroundTasks.length === 0 ? null : (
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
- {showModelPicker ? (
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={isRunning || isSessionOperation || isCopying}
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 = 5;
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} {taskDescription(task)}
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
+ }