tinker-agent 2.6.0 → 2.8.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 +40 -1
- package/package.json +1 -1
- package/src/agent/loop.ts +22 -0
- package/src/agent/runtime-session.ts +174 -0
- package/src/cli/tui-runner.tsx +14 -1
- package/src/events/stdout-event-printer.ts +14 -0
- package/src/events/types.ts +10 -1
- package/src/memory/contracts.ts +46 -0
- package/src/memory/memory-coordinator.ts +445 -4
- package/src/memory/memory-create-tool.ts +117 -0
- package/src/memory/memory-delete-tool.ts +88 -0
- package/src/memory/memory-store.ts +239 -0
- package/src/memory/memory-update-tool.ts +142 -0
- package/src/observation/observation-builder.ts +70 -0
- package/src/session/session-tool-result-codec.ts +4 -0
- package/src/tools/ask-user.ts +100 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/context-maintenance.ts +1 -1
- package/src/tools/read.ts +1 -1
- package/src/tools/registry.ts +30 -0
- package/src/tools/types.ts +71 -0
- package/src/tools/wait.ts +1 -3
- package/src/tui/app.tsx +33 -5
- package/src/tui/components/ask-user.tsx +61 -0
- package/src/tui/components/footer.tsx +14 -1
- package/src/tui/components/resume-session-picker.tsx +118 -37
- package/src/tui/event-store.ts +57 -0
- package/src/tui/tui-session-controller.ts +8 -0
|
@@ -15,10 +15,16 @@ import {
|
|
|
15
15
|
type LineEditorState,
|
|
16
16
|
} from "../line-editor";
|
|
17
17
|
|
|
18
|
-
const SESSION_ROWS =
|
|
18
|
+
const SESSION_ROWS = 1;
|
|
19
19
|
const BROWSE_CHROME_ROWS = 3;
|
|
20
20
|
const SEARCH_CHROME_ROWS = 4;
|
|
21
21
|
const MAX_DISPLAYED_SESSIONS = 20;
|
|
22
|
+
const MARKER_COLUMN_WIDTH = 2;
|
|
23
|
+
const TIME_COLUMN_WIDTH = 10;
|
|
24
|
+
const TURN_COLUMN_WIDTH = 11;
|
|
25
|
+
const STATUS_COLUMN_WIDTH = 13;
|
|
26
|
+
const PROFILE_COLUMN_WIDTH = 16;
|
|
27
|
+
const SESSION_ROW_BACKGROUNDS = ["#1c1c1c", "#101010"] as const;
|
|
22
28
|
|
|
23
29
|
export type ResumeSessionPickerProps = {
|
|
24
30
|
sessions: readonly SessionSummary[];
|
|
@@ -26,6 +32,7 @@ export type ResumeSessionPickerProps = {
|
|
|
26
32
|
error?: string;
|
|
27
33
|
now?: Date;
|
|
28
34
|
viewportRows?: number;
|
|
35
|
+
viewportColumns?: number;
|
|
29
36
|
visibleItemCount?: number;
|
|
30
37
|
onCancel: () => void;
|
|
31
38
|
onSelect: (session: SessionSummary) => void;
|
|
@@ -56,6 +63,7 @@ export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
|
|
|
56
63
|
function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
57
64
|
const windowSize = useWindowSize();
|
|
58
65
|
const rows = props.viewportRows ?? windowSize.rows - 1;
|
|
66
|
+
const columns = Math.max(1, props.viewportColumns ?? windowSize.columns);
|
|
59
67
|
|
|
60
68
|
const displayedFor = (value: string): readonly SessionSummary[] => {
|
|
61
69
|
const nextCandidates =
|
|
@@ -287,7 +295,7 @@ function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
|
287
295
|
|
|
288
296
|
const now = props.now ?? new Date();
|
|
289
297
|
return (
|
|
290
|
-
<Box flexDirection="column">
|
|
298
|
+
<Box width={columns} flexDirection="column" overflow="hidden">
|
|
291
299
|
<Text bold>Resume session</Text>
|
|
292
300
|
{state.mode === "search" ? <SearchLine editor={state.editor} /> : null}
|
|
293
301
|
<Text dimColor>
|
|
@@ -303,6 +311,8 @@ function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
|
303
311
|
session={session}
|
|
304
312
|
isSelected={windowStart + offset === selectedIndex}
|
|
305
313
|
now={now}
|
|
314
|
+
rowIndex={windowStart + offset}
|
|
315
|
+
columns={columns}
|
|
306
316
|
/>
|
|
307
317
|
))}
|
|
308
318
|
<Text
|
|
@@ -318,6 +328,7 @@ function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
|
318
328
|
windowStart,
|
|
319
329
|
windowEnd,
|
|
320
330
|
totalCount: props.sessions.length,
|
|
331
|
+
selectedSession,
|
|
321
332
|
})
|
|
322
333
|
: `Resume failed: ${singleLine(props.error)}`}
|
|
323
334
|
</Text>
|
|
@@ -368,38 +379,92 @@ function SessionOption(props: {
|
|
|
368
379
|
session: SessionSummary;
|
|
369
380
|
isSelected: boolean;
|
|
370
381
|
now: Date;
|
|
382
|
+
rowIndex: number;
|
|
383
|
+
columns: number;
|
|
371
384
|
}) {
|
|
372
385
|
const selectable = isSessionSelectable(props.session);
|
|
373
386
|
const marker = props.isSelected ? "❯ " : " ";
|
|
374
387
|
const preview =
|
|
375
388
|
singleLine(props.session.firstUserPromptPreview ?? "") || "(no prompt)";
|
|
389
|
+
const profile = singleLine(props.session.profileName ?? "") || "—";
|
|
376
390
|
|
|
377
391
|
return (
|
|
378
|
-
<Box
|
|
392
|
+
<Box
|
|
393
|
+
width={props.columns}
|
|
394
|
+
height={SESSION_ROWS}
|
|
395
|
+
overflow="hidden"
|
|
396
|
+
backgroundColor={
|
|
397
|
+
SESSION_ROW_BACKGROUNDS[props.rowIndex % SESSION_ROW_BACKGROUNDS.length]
|
|
398
|
+
}
|
|
399
|
+
>
|
|
400
|
+
<SessionCell
|
|
401
|
+
value={marker}
|
|
402
|
+
width={MARKER_COLUMN_WIDTH}
|
|
403
|
+
isSelected={props.isSelected}
|
|
404
|
+
isDisabled={!selectable}
|
|
405
|
+
/>
|
|
406
|
+
<SessionCell
|
|
407
|
+
value={formatRelativeTime(props.session.updatedAt, props.now)}
|
|
408
|
+
width={TIME_COLUMN_WIDTH}
|
|
409
|
+
isSelected={props.isSelected}
|
|
410
|
+
isDisabled={!selectable}
|
|
411
|
+
padRight
|
|
412
|
+
/>
|
|
413
|
+
<SessionCell
|
|
414
|
+
value={`${props.session.turnCount} ${props.session.turnCount === 1 ? "turn" : "turns"}`}
|
|
415
|
+
width={TURN_COLUMN_WIDTH}
|
|
416
|
+
isSelected={props.isSelected}
|
|
417
|
+
isDisabled={!selectable}
|
|
418
|
+
padRight
|
|
419
|
+
/>
|
|
420
|
+
<SessionCell
|
|
421
|
+
value={sessionStatusLabel(props.session)}
|
|
422
|
+
width={STATUS_COLUMN_WIDTH}
|
|
423
|
+
isSelected={props.isSelected}
|
|
424
|
+
isDisabled={!selectable}
|
|
425
|
+
padRight
|
|
426
|
+
/>
|
|
427
|
+
<SessionCell
|
|
428
|
+
value={profile}
|
|
429
|
+
width={PROFILE_COLUMN_WIDTH}
|
|
430
|
+
isSelected={props.isSelected}
|
|
431
|
+
isDisabled={!selectable}
|
|
432
|
+
padRight
|
|
433
|
+
/>
|
|
434
|
+
<SessionCell
|
|
435
|
+
value={preview}
|
|
436
|
+
isSelected={props.isSelected}
|
|
437
|
+
isDisabled={!selectable}
|
|
438
|
+
/>
|
|
439
|
+
</Box>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function SessionCell(props: {
|
|
444
|
+
value: string;
|
|
445
|
+
width?: number;
|
|
446
|
+
isSelected: boolean;
|
|
447
|
+
isDisabled: boolean;
|
|
448
|
+
padRight?: boolean;
|
|
449
|
+
}) {
|
|
450
|
+
const selected = props.isSelected && !props.isDisabled;
|
|
451
|
+
return (
|
|
452
|
+
<Box
|
|
453
|
+
width={props.width}
|
|
454
|
+
minWidth={props.width === undefined ? 0 : undefined}
|
|
455
|
+
flexGrow={props.width === undefined ? 1 : 0}
|
|
456
|
+
flexShrink={props.width === undefined ? 1 : 0}
|
|
457
|
+
paddingRight={props.padRight === true ? 1 : 0}
|
|
458
|
+
overflow="hidden"
|
|
459
|
+
>
|
|
379
460
|
<Text
|
|
380
|
-
color={
|
|
381
|
-
bold={
|
|
382
|
-
dimColor={
|
|
461
|
+
color={selected ? "cyan" : undefined}
|
|
462
|
+
bold={selected}
|
|
463
|
+
dimColor={props.isDisabled}
|
|
383
464
|
wrap="truncate-end"
|
|
384
465
|
>
|
|
385
|
-
{
|
|
386
|
-
{formatRelativeTime(props.session.updatedAt, props.now)} ·{" "}
|
|
387
|
-
{props.session.turnCount} {props.session.turnCount === 1 ? "turn" : "turns"} ·{" "}
|
|
388
|
-
{sessionStatusText(props.session)}
|
|
466
|
+
{props.value}
|
|
389
467
|
</Text>
|
|
390
|
-
<Box marginLeft={2} overflow="hidden">
|
|
391
|
-
<Text dimColor={!selectable} wrap="truncate-end">
|
|
392
|
-
{preview}
|
|
393
|
-
</Text>
|
|
394
|
-
</Box>
|
|
395
|
-
<Box marginLeft={2} overflow="hidden">
|
|
396
|
-
<Box flexGrow={1} overflow="hidden">
|
|
397
|
-
<Text dimColor wrap="truncate-end">
|
|
398
|
-
{singleLine(props.session.modelName)}
|
|
399
|
-
</Text>
|
|
400
|
-
</Box>
|
|
401
|
-
<Text dimColor> {shortSessionId(props.session.sessionId)}</Text>
|
|
402
|
-
</Box>
|
|
403
468
|
</Box>
|
|
404
469
|
);
|
|
405
470
|
}
|
|
@@ -416,20 +481,20 @@ export function formatRelativeTime(updatedAt: string, now: Date): string {
|
|
|
416
481
|
|
|
417
482
|
const elapsedSeconds = Math.max(0, Math.floor((now.getTime() - timestamp) / 1000));
|
|
418
483
|
if (elapsedSeconds < 60) {
|
|
419
|
-
return "
|
|
484
|
+
return "now";
|
|
420
485
|
}
|
|
421
486
|
|
|
422
487
|
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
|
423
488
|
if (elapsedMinutes < 60) {
|
|
424
|
-
return
|
|
489
|
+
return `${elapsedMinutes}m ago`;
|
|
425
490
|
}
|
|
426
491
|
|
|
427
492
|
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
|
428
493
|
if (elapsedHours < 24) {
|
|
429
|
-
return
|
|
494
|
+
return `${elapsedHours}h ago`;
|
|
430
495
|
}
|
|
431
496
|
|
|
432
|
-
return
|
|
497
|
+
return `${Math.floor(elapsedHours / 24)}d ago`;
|
|
433
498
|
}
|
|
434
499
|
|
|
435
500
|
function initialSelectedIndex(sessions: readonly SessionSummary[]): number {
|
|
@@ -453,10 +518,14 @@ function keepSelectionVisible(
|
|
|
453
518
|
return clamp(nextWindowStart, 0, maxWindowStart);
|
|
454
519
|
}
|
|
455
520
|
|
|
456
|
-
function
|
|
521
|
+
function sessionStatusLabel(session: SessionSummary): string {
|
|
522
|
+
return session.status;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function sessionStatusDetail(session: SessionSummary): string | undefined {
|
|
457
526
|
switch (session.status) {
|
|
458
527
|
case "resumable":
|
|
459
|
-
return
|
|
528
|
+
return undefined;
|
|
460
529
|
case "interrupted":
|
|
461
530
|
return "interrupted · completes record; no tool retry";
|
|
462
531
|
case "current":
|
|
@@ -481,20 +550,36 @@ function formatFooter(input: {
|
|
|
481
550
|
windowStart: number;
|
|
482
551
|
windowEnd: number;
|
|
483
552
|
totalCount: number;
|
|
553
|
+
selectedSession?: SessionSummary;
|
|
484
554
|
}): string {
|
|
555
|
+
const detail =
|
|
556
|
+
input.selectedSession === undefined
|
|
557
|
+
? undefined
|
|
558
|
+
: sessionStatusDetail(input.selectedSession);
|
|
559
|
+
const withDetail = (status: string) =>
|
|
560
|
+
detail === undefined ? status : `${status} · ${detail}`;
|
|
561
|
+
|
|
485
562
|
if (input.searching) {
|
|
486
563
|
if (input.matchCount === 0) {
|
|
487
564
|
return `No sessions match "${singleLine(input.query)}" · Esc to clear search`;
|
|
488
565
|
}
|
|
489
566
|
if (input.matchCount > MAX_DISPLAYED_SESSIONS) {
|
|
490
|
-
return
|
|
567
|
+
return withDetail(
|
|
568
|
+
`Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} results · ${input.matchCount} matches total`,
|
|
569
|
+
);
|
|
491
570
|
}
|
|
492
|
-
return
|
|
571
|
+
return withDetail(
|
|
572
|
+
`${input.matchCount} ${input.matchCount === 1 ? "match" : "matches"}`,
|
|
573
|
+
);
|
|
493
574
|
}
|
|
494
575
|
if (input.totalCount > MAX_DISPLAYED_SESSIONS) {
|
|
495
|
-
return
|
|
576
|
+
return withDetail(
|
|
577
|
+
`Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} recent · ${input.totalCount} sessions total`,
|
|
578
|
+
);
|
|
496
579
|
}
|
|
497
|
-
return
|
|
580
|
+
return withDetail(
|
|
581
|
+
formatWindowStatus(input.windowStart, input.windowEnd, input.totalCount),
|
|
582
|
+
);
|
|
498
583
|
}
|
|
499
584
|
|
|
500
585
|
function formatWindowStatus(start: number, end: number, total: number): string {
|
|
@@ -515,7 +600,3 @@ function clamp(value: number, minimum: number, maximum: number): number {
|
|
|
515
600
|
function singleLine(value: string): string {
|
|
516
601
|
return value.replace(/\s+/g, " ").trim();
|
|
517
602
|
}
|
|
518
|
-
|
|
519
|
-
function formatAgo(value: number, unit: "minute" | "hour" | "day"): string {
|
|
520
|
-
return `${value} ${unit}${value === 1 ? "" : "s"} ago`;
|
|
521
|
-
}
|
package/src/tui/event-store.ts
CHANGED
|
@@ -282,6 +282,33 @@ export function reduceTuiProjection(
|
|
|
282
282
|
: "failed",
|
|
283
283
|
}),
|
|
284
284
|
);
|
|
285
|
+
case "tool.user_question.requested":
|
|
286
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
287
|
+
updateTurnItem(
|
|
288
|
+
turn,
|
|
289
|
+
toolCallRef(requireEventString(event.toolCallId, "AskUser toolCallId")),
|
|
290
|
+
(item) => ({ ...item, text: `AskUser -> ${event.data.question}` }),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
case "tool.user_question.resolved":
|
|
294
|
+
return updateActiveTurn(state, event, policy, (turn) =>
|
|
295
|
+
appendTurnItem(turn, {
|
|
296
|
+
id: `answer-${event.toolCallId}-${event.eventSequence}`,
|
|
297
|
+
label: "answer",
|
|
298
|
+
text:
|
|
299
|
+
event.data.outcome === "selected"
|
|
300
|
+
? event.data.answer
|
|
301
|
+
: event.data.outcome === "dismissed"
|
|
302
|
+
? "dismissed"
|
|
303
|
+
: "cancelled",
|
|
304
|
+
status:
|
|
305
|
+
event.data.outcome === "selected"
|
|
306
|
+
? "ok"
|
|
307
|
+
: event.data.outcome === "dismissed"
|
|
308
|
+
? "info"
|
|
309
|
+
: "cancelled",
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
285
312
|
case "bash.task.backgrounded":
|
|
286
313
|
case "bash.task.stopping":
|
|
287
314
|
case "bash.task.finished":
|
|
@@ -800,6 +827,12 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
|
|
|
800
827
|
if (input.name === "MemorySearch") {
|
|
801
828
|
return `MemorySearch ${memorySearchDetail(input.args)}`.trim();
|
|
802
829
|
}
|
|
830
|
+
if (input.name === "MemoryCreate") {
|
|
831
|
+
return "MemoryCreate";
|
|
832
|
+
}
|
|
833
|
+
if (input.name === "MemoryUpdate" || input.name === "MemoryDelete") {
|
|
834
|
+
return `${input.name} ${memoryMutationId(input.args) ?? ""}`.trim();
|
|
835
|
+
}
|
|
803
836
|
if (input.name === "WebFetch") {
|
|
804
837
|
return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
|
|
805
838
|
}
|
|
@@ -816,6 +849,10 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
|
|
|
816
849
|
if (input.name === "Wait") {
|
|
817
850
|
return `Wait ${toolSeconds(input.args) ?? ""}`.trim();
|
|
818
851
|
}
|
|
852
|
+
if (input.name === "AskUser") {
|
|
853
|
+
const question = stringProperty(asRecord(input.args), "question");
|
|
854
|
+
return `AskUser${question === undefined ? "" : ` -> ${question}`}`;
|
|
855
|
+
}
|
|
819
856
|
if (input.name === "Skill") {
|
|
820
857
|
return `Skill ${stringProperty(asRecord(input.args), "name") ?? ""}`.trim();
|
|
821
858
|
}
|
|
@@ -915,6 +952,12 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
915
952
|
return base;
|
|
916
953
|
}
|
|
917
954
|
return raw.memory === null ? `${base} -> not found` : `${base} -> found`;
|
|
955
|
+
case "memory_create":
|
|
956
|
+
case "memory_update":
|
|
957
|
+
case "memory_delete":
|
|
958
|
+
return raw.ok
|
|
959
|
+
? `${name} ${raw.memoryId} -> ${raw.status.replaceAll("_", " ")}`
|
|
960
|
+
: base;
|
|
918
961
|
case "skill":
|
|
919
962
|
if (!raw.ok) {
|
|
920
963
|
return `${base} failed -> ${boundedToolError(raw.error)}`;
|
|
@@ -954,6 +997,8 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
|
|
|
954
997
|
}
|
|
955
998
|
case "wait":
|
|
956
999
|
return raw.ok ? `${base} -> done` : base;
|
|
1000
|
+
case "ask_user":
|
|
1001
|
+
return base;
|
|
957
1002
|
case "mcp":
|
|
958
1003
|
case "generic":
|
|
959
1004
|
return base;
|
|
@@ -1005,7 +1050,11 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
|
|
|
1005
1050
|
case "context_maintenance":
|
|
1006
1051
|
case "memory_search":
|
|
1007
1052
|
case "memory_get":
|
|
1053
|
+
case "memory_create":
|
|
1054
|
+
case "memory_update":
|
|
1055
|
+
case "memory_delete":
|
|
1008
1056
|
case "wait":
|
|
1057
|
+
case "ask_user":
|
|
1009
1058
|
case "skill":
|
|
1010
1059
|
case "mcp":
|
|
1011
1060
|
case "generic":
|
|
@@ -1047,7 +1096,11 @@ function toolRawResultDiff(
|
|
|
1047
1096
|
case "context_maintenance":
|
|
1048
1097
|
case "memory_search":
|
|
1049
1098
|
case "memory_get":
|
|
1099
|
+
case "memory_create":
|
|
1100
|
+
case "memory_update":
|
|
1101
|
+
case "memory_delete":
|
|
1050
1102
|
case "wait":
|
|
1103
|
+
case "ask_user":
|
|
1051
1104
|
case "skill":
|
|
1052
1105
|
case "mcp":
|
|
1053
1106
|
case "generic":
|
|
@@ -1127,6 +1180,10 @@ function memorySearchDetail(args: unknown): string {
|
|
|
1127
1180
|
return parts.join(" ");
|
|
1128
1181
|
}
|
|
1129
1182
|
|
|
1183
|
+
function memoryMutationId(args: unknown): string | undefined {
|
|
1184
|
+
return stringProperty(asRecord(args), "id");
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1130
1187
|
function toolKeywords(args: unknown): string | undefined {
|
|
1131
1188
|
const limit = 120;
|
|
1132
1189
|
const value = asRecord(args).keywords;
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
import type {
|
|
6
6
|
ExecuteTurnInput,
|
|
7
7
|
AcceptedTurn,
|
|
8
|
+
AskUserSnapshot,
|
|
9
|
+
AskUserResolution,
|
|
8
10
|
BashGuardSnapshot,
|
|
9
11
|
RuntimeSession,
|
|
10
12
|
RuntimeSkillsSnapshot,
|
|
@@ -52,6 +54,9 @@ export type TuiSessionBinding = {
|
|
|
52
54
|
subscribeBashGuard(listener: () => void): () => void;
|
|
53
55
|
setYoloMode(enabled: boolean): void;
|
|
54
56
|
resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
|
|
57
|
+
askUser(): AskUserSnapshot;
|
|
58
|
+
subscribeAskUser(listener: () => void): () => void;
|
|
59
|
+
resolveAskUser(response: AskUserResolution): Promise<void>;
|
|
55
60
|
};
|
|
56
61
|
|
|
57
62
|
export type TuiSessionController = {
|
|
@@ -262,6 +267,9 @@ export function managedTuiBinding(input: {
|
|
|
262
267
|
setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
|
|
263
268
|
resolveBashConfirmation: (decision) =>
|
|
264
269
|
input.runtimeSession.resolveBashConfirmation(decision),
|
|
270
|
+
askUser: () => input.runtimeSession.askUser(),
|
|
271
|
+
subscribeAskUser: (listener) => input.runtimeSession.subscribeAskUser(listener),
|
|
272
|
+
resolveAskUser: (response) => input.runtimeSession.resolveAskUser(response),
|
|
265
273
|
};
|
|
266
274
|
}
|
|
267
275
|
|