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.
Files changed (36) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +30 -7
  3. package/package.json +1 -1
  4. package/src/agent/loop.ts +22 -0
  5. package/src/agent/runtime-session.ts +158 -0
  6. package/src/cli/main.ts +2 -0
  7. package/src/cli/public-config-contract.ts +1 -1
  8. package/src/cli/tui-runner.tsx +16 -1
  9. package/src/events/stdout-event-printer.ts +14 -0
  10. package/src/events/types.ts +9 -0
  11. package/src/memory/contracts.ts +46 -0
  12. package/src/memory/memory-coordinator.ts +445 -4
  13. package/src/memory/memory-create-tool.ts +117 -0
  14. package/src/memory/memory-delete-tool.ts +88 -0
  15. package/src/memory/memory-store.ts +239 -0
  16. package/src/memory/memory-update-tool.ts +142 -0
  17. package/src/observation/observation-builder.ts +70 -0
  18. package/src/session/session-clone-helpers.ts +249 -0
  19. package/src/session/session-compatibility-codec.ts +401 -0
  20. package/src/session/session-store-contracts.ts +304 -0
  21. package/src/session/session-store-filesystem.ts +245 -0
  22. package/src/session/session-store-record-codecs.ts +1064 -0
  23. package/src/session/session-store-value-codecs.ts +156 -0
  24. package/src/session/session-store.ts +234 -3023
  25. package/src/session/session-tool-result-codec.ts +594 -0
  26. package/src/tools/ask-user.ts +100 -0
  27. package/src/tools/grep.ts +1 -3
  28. package/src/tools/registry.ts +30 -0
  29. package/src/tools/types.ts +71 -0
  30. package/src/tui/app.tsx +35 -5
  31. package/src/tui/components/ask-user.tsx +61 -0
  32. package/src/tui/components/footer.tsx +14 -1
  33. package/src/tui/components/prompt-input.tsx +4 -0
  34. package/src/tui/components/resume-session-picker.tsx +118 -37
  35. package/src/tui/event-store.ts +57 -0
  36. package/src/tui/tui-session-controller.ts +8 -0
@@ -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,
@@ -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
- : executionRunning
909
- ? "running"
910
- : state.status
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
- {bashGuard.pending !== undefined ? (
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: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
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 = 3;
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 flexDirection="column">
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={props.isSelected && selectable ? "cyan" : undefined}
381
- bold={props.isSelected && selectable}
382
- dimColor={!selectable}
461
+ color={selected ? "cyan" : undefined}
462
+ bold={selected}
463
+ dimColor={props.isDisabled}
383
464
  wrap="truncate-end"
384
465
  >
385
- {marker}
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 "just now";
484
+ return "now";
420
485
  }
421
486
 
422
487
  const elapsedMinutes = Math.floor(elapsedSeconds / 60);
423
488
  if (elapsedMinutes < 60) {
424
- return formatAgo(elapsedMinutes, "minute");
489
+ return `${elapsedMinutes}m ago`;
425
490
  }
426
491
 
427
492
  const elapsedHours = Math.floor(elapsedMinutes / 60);
428
493
  if (elapsedHours < 24) {
429
- return formatAgo(elapsedHours, "hour");
494
+ return `${elapsedHours}h ago`;
430
495
  }
431
496
 
432
- return formatAgo(Math.floor(elapsedHours / 24), "day");
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 sessionStatusText(session: SessionSummary): string {
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 "resumable";
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 `Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} results · ${input.matchCount} matches total`;
567
+ return withDetail(
568
+ `Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} results · ${input.matchCount} matches total`,
569
+ );
491
570
  }
492
- return `${input.matchCount} ${input.matchCount === 1 ? "match" : "matches"}`;
571
+ return withDetail(
572
+ `${input.matchCount} ${input.matchCount === 1 ? "match" : "matches"}`,
573
+ );
493
574
  }
494
575
  if (input.totalCount > MAX_DISPLAYED_SESSIONS) {
495
- return `Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} recent · ${input.totalCount} sessions total`;
576
+ return withDetail(
577
+ `Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} recent · ${input.totalCount} sessions total`,
578
+ );
496
579
  }
497
- return formatWindowStatus(input.windowStart, input.windowEnd, input.totalCount);
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
- }