tinker-agent 2.5.0 → 2.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 +42 -1
- package/README.md +30 -7
- package/package.json +1 -1
- package/src/agent/loop.ts +22 -0
- package/src/agent/runtime-session.ts +158 -0
- package/src/cli/main.ts +2 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/tui-runner.tsx +16 -1
- package/src/events/stdout-event-printer.ts +14 -0
- package/src/events/types.ts +9 -0
- 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-clone-helpers.ts +249 -0
- package/src/session/session-compatibility-codec.ts +401 -0
- package/src/session/session-store-contracts.ts +304 -0
- package/src/session/session-store-filesystem.ts +245 -0
- package/src/session/session-store-record-codecs.ts +1064 -0
- package/src/session/session-store-value-codecs.ts +156 -0
- package/src/session/session-store.ts +234 -3023
- package/src/session/session-tool-result-codec.ts +594 -0
- package/src/tools/ask-user.ts +100 -0
- package/src/tools/grep.ts +1 -3
- package/src/tools/registry.ts +30 -0
- package/src/tools/types.ts +71 -0
- package/src/tui/app.tsx +35 -5
- package/src/tui/components/ask-user.tsx +61 -0
- package/src/tui/components/footer.tsx +14 -1
- package/src/tui/components/prompt-input.tsx +4 -0
- 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
package/src/tools/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAskUserToolExecutor } from "./ask-user";
|
|
1
2
|
import { createBashToolExecutor } from "./bash";
|
|
2
3
|
import { ShellTaskManager } from "./bash-task";
|
|
3
4
|
import { createCwdState } from "./cwd-state";
|
|
@@ -86,6 +87,11 @@ export class ToolRuntime {
|
|
|
86
87
|
): Promise<"allow" | "deny">;
|
|
87
88
|
},
|
|
88
89
|
private readonly contextMaintenance?: ContextMaintenanceHandle,
|
|
90
|
+
private readonly askUser?: (
|
|
91
|
+
call: ToolCall,
|
|
92
|
+
request: Parameters<NonNullable<ToolExecutionContext["askUser"]>>[0],
|
|
93
|
+
signal: AbortSignal,
|
|
94
|
+
) => ReturnType<NonNullable<ToolExecutionContext["askUser"]>>,
|
|
89
95
|
) {}
|
|
90
96
|
|
|
91
97
|
async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
|
|
@@ -117,6 +123,9 @@ export class ToolRuntime {
|
|
|
117
123
|
...(this.contextMaintenance === undefined
|
|
118
124
|
? {}
|
|
119
125
|
: { contextMaintenance: this.contextMaintenance }),
|
|
126
|
+
...(this.askUser === undefined
|
|
127
|
+
? {}
|
|
128
|
+
: { askUser: (request) => this.askUser!(call, request, context.signal) }),
|
|
120
129
|
...(this.bashGuard === undefined
|
|
121
130
|
? {}
|
|
122
131
|
: {
|
|
@@ -173,9 +182,17 @@ export function createDefaultTooling(options: {
|
|
|
173
182
|
toolingConfig?: PublicToolingConfig;
|
|
174
183
|
memorySearch?: ToolExecutor;
|
|
175
184
|
memoryGet?: ToolExecutor;
|
|
185
|
+
memoryCreate?: ToolExecutor;
|
|
186
|
+
memoryUpdate?: ToolExecutor;
|
|
187
|
+
memoryDelete?: ToolExecutor;
|
|
176
188
|
enableTurnUndo?: boolean;
|
|
177
189
|
imageAssetStore?: ImageAssetStore;
|
|
178
190
|
supportsViewImage?: boolean;
|
|
191
|
+
askUser?: (
|
|
192
|
+
call: ToolCall,
|
|
193
|
+
request: Parameters<NonNullable<ToolExecutionContext["askUser"]>>[0],
|
|
194
|
+
signal: AbortSignal,
|
|
195
|
+
) => ReturnType<NonNullable<ToolExecutionContext["askUser"]>>;
|
|
179
196
|
bashGuard?: {
|
|
180
197
|
readonly surface: "tui" | "one-shot";
|
|
181
198
|
confirm(
|
|
@@ -201,6 +218,9 @@ export function createDefaultTooling(options: {
|
|
|
201
218
|
...(options.homeRoot === undefined ? {} : { homeRoot: options.homeRoot }),
|
|
202
219
|
});
|
|
203
220
|
|
|
221
|
+
if (options.askUser !== undefined) {
|
|
222
|
+
registry.register(createAskUserToolExecutor());
|
|
223
|
+
}
|
|
204
224
|
registry.register(
|
|
205
225
|
createGlobToolExecutor({
|
|
206
226
|
workspaceRoot: options.workspaceRoot,
|
|
@@ -247,6 +267,15 @@ export function createDefaultTooling(options: {
|
|
|
247
267
|
if (options.memoryGet !== undefined) {
|
|
248
268
|
registry.register(options.memoryGet);
|
|
249
269
|
}
|
|
270
|
+
if (options.memoryCreate !== undefined) {
|
|
271
|
+
registry.register(options.memoryCreate);
|
|
272
|
+
}
|
|
273
|
+
if (options.memoryUpdate !== undefined) {
|
|
274
|
+
registry.register(options.memoryUpdate);
|
|
275
|
+
}
|
|
276
|
+
if (options.memoryDelete !== undefined) {
|
|
277
|
+
registry.register(options.memoryDelete);
|
|
278
|
+
}
|
|
250
279
|
if (options.skillCatalog !== undefined) {
|
|
251
280
|
if (options.skillCatalog.skills.size === 0) {
|
|
252
281
|
throw new Error("An empty Agent Skill catalog must not register tooling.");
|
|
@@ -321,6 +350,7 @@ export function createDefaultTooling(options: {
|
|
|
321
350
|
registry,
|
|
322
351
|
options.bashGuard,
|
|
323
352
|
options.runtimeSession.contextMaintenance,
|
|
353
|
+
options.askUser,
|
|
324
354
|
),
|
|
325
355
|
snapshots,
|
|
326
356
|
taskManager,
|
package/src/tools/types.ts
CHANGED
|
@@ -428,6 +428,58 @@ export type MemoryGetRawResult =
|
|
|
428
428
|
error: string;
|
|
429
429
|
};
|
|
430
430
|
|
|
431
|
+
export type MemoryCreateRawResult =
|
|
432
|
+
| {
|
|
433
|
+
ok: true;
|
|
434
|
+
status: "created" | "already_exists";
|
|
435
|
+
memoryId: string;
|
|
436
|
+
createdAt: string;
|
|
437
|
+
}
|
|
438
|
+
| {
|
|
439
|
+
ok: false;
|
|
440
|
+
error: string;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export type MemoryUpdateRawResult =
|
|
444
|
+
| {
|
|
445
|
+
ok: true;
|
|
446
|
+
status: "updated";
|
|
447
|
+
memoryId: string;
|
|
448
|
+
}
|
|
449
|
+
| {
|
|
450
|
+
ok: false;
|
|
451
|
+
code: "memory_not_found";
|
|
452
|
+
error: string;
|
|
453
|
+
}
|
|
454
|
+
| {
|
|
455
|
+
ok: false;
|
|
456
|
+
code: "memory_duplicate";
|
|
457
|
+
conflictMemoryId: string;
|
|
458
|
+
error: string;
|
|
459
|
+
}
|
|
460
|
+
| {
|
|
461
|
+
ok: false;
|
|
462
|
+
code?: undefined;
|
|
463
|
+
error: string;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
export type MemoryDeleteRawResult =
|
|
467
|
+
| {
|
|
468
|
+
ok: true;
|
|
469
|
+
status: "deleted";
|
|
470
|
+
memoryId: string;
|
|
471
|
+
}
|
|
472
|
+
| {
|
|
473
|
+
ok: false;
|
|
474
|
+
code: "memory_not_found";
|
|
475
|
+
error: string;
|
|
476
|
+
}
|
|
477
|
+
| {
|
|
478
|
+
ok: false;
|
|
479
|
+
code?: undefined;
|
|
480
|
+
error: string;
|
|
481
|
+
};
|
|
482
|
+
|
|
431
483
|
export type SkillRawResult =
|
|
432
484
|
| {
|
|
433
485
|
ok: true;
|
|
@@ -488,6 +540,20 @@ export type WaitRawResult =
|
|
|
488
540
|
error: string;
|
|
489
541
|
};
|
|
490
542
|
|
|
543
|
+
export type AskUserRequest = {
|
|
544
|
+
readonly question: string;
|
|
545
|
+
readonly options: readonly { readonly description: string }[];
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
export type AskUserResponse =
|
|
549
|
+
| { readonly outcome: "selected"; readonly answer: string }
|
|
550
|
+
| { readonly outcome: "dismissed" };
|
|
551
|
+
|
|
552
|
+
export type AskUserRawResult =
|
|
553
|
+
| { ok: true; outcome: "selected"; answer: string }
|
|
554
|
+
| { ok: true; outcome: "dismissed" }
|
|
555
|
+
| { ok: false; error: string };
|
|
556
|
+
|
|
491
557
|
export type ToolRawResultByKind = {
|
|
492
558
|
read: ReadFileRawResult;
|
|
493
559
|
view_image: ViewImageRawResult;
|
|
@@ -508,7 +574,11 @@ export type ToolRawResultByKind = {
|
|
|
508
574
|
context_maintenance: ContextMaintenanceRawResult;
|
|
509
575
|
memory_search: MemorySearchRawResult;
|
|
510
576
|
memory_get: MemoryGetRawResult;
|
|
577
|
+
memory_create: MemoryCreateRawResult;
|
|
578
|
+
memory_update: MemoryUpdateRawResult;
|
|
579
|
+
memory_delete: MemoryDeleteRawResult;
|
|
511
580
|
wait: WaitRawResult;
|
|
581
|
+
ask_user: AskUserRawResult;
|
|
512
582
|
skill: SkillRawResult;
|
|
513
583
|
mcp: McpToolRawResult;
|
|
514
584
|
generic: GenericToolRawResult;
|
|
@@ -553,6 +623,7 @@ export function defineToolExecutor<TKind extends ToolRawResultKind>(
|
|
|
553
623
|
|
|
554
624
|
export type ToolExecutionContext = {
|
|
555
625
|
signal: AbortSignal;
|
|
626
|
+
askUser?: (request: AskUserRequest) => Promise<AskUserResponse>;
|
|
556
627
|
contextMaintenance?: ContextMaintenanceHandle;
|
|
557
628
|
confirmBashCommand?: (request: {
|
|
558
629
|
command: string;
|
package/src/tui/app.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { Footer } from "./components/footer";
|
|
|
25
25
|
import { AssistantMarkdownProvider } from "./components/assistant-markdown";
|
|
26
26
|
import { ContextStatus } from "./components/context-status";
|
|
27
27
|
import { BackgroundTasks } from "./components/background-tasks";
|
|
28
|
+
import { AskUser } from "./components/ask-user";
|
|
28
29
|
import { BashConfirmation } from "./components/bash-confirmation";
|
|
29
30
|
import { Header } from "./components/header";
|
|
30
31
|
import { ModelPicker } from "./components/model-picker";
|
|
@@ -67,6 +68,7 @@ import {
|
|
|
67
68
|
|
|
68
69
|
export type AppProps = {
|
|
69
70
|
sessionController: TuiSessionController;
|
|
71
|
+
version?: string;
|
|
70
72
|
readGitBranch?: (workspaceRoot: string) => Promise<string | undefined>;
|
|
71
73
|
history?: PromptHistory;
|
|
72
74
|
projectSlashCommands?: readonly ProjectSlashCommand[];
|
|
@@ -141,6 +143,11 @@ export function App(props: AppProps) {
|
|
|
141
143
|
() => binding.bashGuard(),
|
|
142
144
|
() => binding.bashGuard(),
|
|
143
145
|
);
|
|
146
|
+
const askUser = useSyncExternalStore(
|
|
147
|
+
(listener) => binding.subscribeAskUser(listener),
|
|
148
|
+
() => binding.askUser(),
|
|
149
|
+
() => binding.askUser(),
|
|
150
|
+
);
|
|
144
151
|
const promptScheduler = useSyncExternalStore(
|
|
145
152
|
(listener) => binding.subscribePromptScheduler?.(listener) ?? (() => undefined),
|
|
146
153
|
() => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
|
|
@@ -275,7 +282,7 @@ export function App(props: AppProps) {
|
|
|
275
282
|
setIsCancelling(true);
|
|
276
283
|
setNotice("Cancelling current turn...");
|
|
277
284
|
},
|
|
278
|
-
{ isActive: executionRunning },
|
|
285
|
+
{ isActive: executionRunning && askUser.pending === undefined },
|
|
279
286
|
);
|
|
280
287
|
|
|
281
288
|
const closeResumePicker = () => {
|
|
@@ -905,9 +912,11 @@ export function App(props: AppProps) {
|
|
|
905
912
|
status={
|
|
906
913
|
isCancelling
|
|
907
914
|
? "cancelling"
|
|
908
|
-
:
|
|
909
|
-
? "
|
|
910
|
-
:
|
|
915
|
+
: askUser.pending !== undefined
|
|
916
|
+
? "waiting_for_answer"
|
|
917
|
+
: executionRunning
|
|
918
|
+
? "running"
|
|
919
|
+
: state.status
|
|
911
920
|
}
|
|
912
921
|
workedForMs={state.workedForMs}
|
|
913
922
|
yolo={bashGuard.mode === "yolo"}
|
|
@@ -915,7 +924,26 @@ export function App(props: AppProps) {
|
|
|
915
924
|
/>
|
|
916
925
|
</Box>
|
|
917
926
|
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
|
918
|
-
{
|
|
927
|
+
{askUser.pending !== undefined ? (
|
|
928
|
+
<AskUser
|
|
929
|
+
question={askUser.pending.question}
|
|
930
|
+
options={askUser.pending.options}
|
|
931
|
+
onSelect={(selectedIndex) => {
|
|
932
|
+
void binding
|
|
933
|
+
.resolveAskUser({ outcome: "selected", selectedIndex })
|
|
934
|
+
.catch((error: unknown) =>
|
|
935
|
+
setNotice(`Answer failed: ${errorMessage(error)}`),
|
|
936
|
+
);
|
|
937
|
+
}}
|
|
938
|
+
onDismiss={() => {
|
|
939
|
+
void binding
|
|
940
|
+
.resolveAskUser({ outcome: "dismissed" })
|
|
941
|
+
.catch((error: unknown) =>
|
|
942
|
+
setNotice(`Dismiss failed: ${errorMessage(error)}`),
|
|
943
|
+
);
|
|
944
|
+
}}
|
|
945
|
+
/>
|
|
946
|
+
) : bashGuard.pending !== undefined ? (
|
|
919
947
|
<BashConfirmation
|
|
920
948
|
command={bashGuard.pending.command}
|
|
921
949
|
reason={bashGuard.pending.reason}
|
|
@@ -940,6 +968,7 @@ export function App(props: AppProps) {
|
|
|
940
968
|
<PromptInput
|
|
941
969
|
modelName={binding.modelName}
|
|
942
970
|
reasoningEffort={reasoningEffort?.effort}
|
|
971
|
+
version={props.version}
|
|
943
972
|
workspaceRoot={binding.workspaceRoot}
|
|
944
973
|
gitBranch={gitBranch}
|
|
945
974
|
contextUsage={state.contextUsage}
|
|
@@ -947,6 +976,7 @@ export function App(props: AppProps) {
|
|
|
947
976
|
isSessionOperation ||
|
|
948
977
|
isCopying ||
|
|
949
978
|
isCancelling ||
|
|
979
|
+
askUser.pending !== undefined ||
|
|
950
980
|
bashGuard.pending !== undefined
|
|
951
981
|
}
|
|
952
982
|
history={props.history}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
export type AskUserProps = {
|
|
5
|
+
question: string;
|
|
6
|
+
options: readonly { readonly description: string }[];
|
|
7
|
+
onSelect(selectedIndex: number): void;
|
|
8
|
+
onDismiss(): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function AskUser(props: AskUserProps) {
|
|
12
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
13
|
+
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (key.escape) {
|
|
16
|
+
props.onDismiss();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (key.upArrow) {
|
|
20
|
+
setSelectedIndex((current) =>
|
|
21
|
+
current === 0 ? props.options.length - 1 : current - 1,
|
|
22
|
+
);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (key.downArrow) {
|
|
26
|
+
setSelectedIndex((current) => (current + 1) % props.options.length);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (key.return) {
|
|
30
|
+
props.onSelect(selectedIndex);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (/^[1-6]$/.test(input)) {
|
|
34
|
+
const index = Number(input) - 1;
|
|
35
|
+
if (index < props.options.length) {
|
|
36
|
+
props.onSelect(index);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
43
|
+
<Text color="cyan" bold>
|
|
44
|
+
Tinker asks
|
|
45
|
+
</Text>
|
|
46
|
+
<Text>{props.question}</Text>
|
|
47
|
+
<Box flexDirection="column" marginTop={1}>
|
|
48
|
+
{props.options.map((option, index) => (
|
|
49
|
+
<Text key={`${index}:${option.description}`}>
|
|
50
|
+
<Text color={index === selectedIndex ? "cyan" : undefined}>
|
|
51
|
+
{index === selectedIndex ? "❯" : " "} {index + 1}. {option.description}
|
|
52
|
+
</Text>
|
|
53
|
+
</Text>
|
|
54
|
+
))}
|
|
55
|
+
</Box>
|
|
56
|
+
<Text dimColor>
|
|
57
|
+
↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc skip
|
|
58
|
+
</Text>
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
2
2
|
|
|
3
3
|
export type FooterProps = {
|
|
4
|
-
status:
|
|
4
|
+
status:
|
|
5
|
+
| "idle"
|
|
6
|
+
| "running"
|
|
7
|
+
| "waiting_for_answer"
|
|
8
|
+
| "cancelling"
|
|
9
|
+
| "cancelled"
|
|
10
|
+
| "done"
|
|
11
|
+
| "failed";
|
|
5
12
|
workedForMs?: number;
|
|
6
13
|
yolo?: boolean;
|
|
7
14
|
pendingFollowUps?: number;
|
|
@@ -26,6 +33,12 @@ export function Footer(props: FooterProps) {
|
|
|
26
33
|
return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
if (props.status === "waiting_for_answer") {
|
|
37
|
+
return (
|
|
38
|
+
<StatusMessage variant="info">Waiting for your selection{suffix}</StatusMessage>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
if (props.status === "running") {
|
|
30
43
|
const queued =
|
|
31
44
|
props.pendingFollowUps === undefined || props.pendingFollowUps === 0
|
|
@@ -61,6 +61,7 @@ export type PromptSubmissionOutcome =
|
|
|
61
61
|
export type PromptInputProps = {
|
|
62
62
|
modelName: string;
|
|
63
63
|
reasoningEffort?: string;
|
|
64
|
+
version?: string;
|
|
64
65
|
workspaceRoot: string;
|
|
65
66
|
gitBranch?: string;
|
|
66
67
|
contextUsage?: ContextUsageSnapshot;
|
|
@@ -776,6 +777,9 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
776
777
|
<Text color={FOOTER_COLORS.cacheRate}>{cacheRate}</Text>
|
|
777
778
|
</>
|
|
778
779
|
)}
|
|
780
|
+
{props.version === undefined ? null : (
|
|
781
|
+
<Text dimColor> · tinker {props.version}</Text>
|
|
782
|
+
)}
|
|
779
783
|
</Text>
|
|
780
784
|
</Box>
|
|
781
785
|
)}
|
|
@@ -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
|
-
}
|