Skip to content

Commit 3c887ac

Browse files
Merge pull request #732 from corbitsdev/cl-7288-make-corbits-resume-a-named-recent-10-picker
Show named recent sessions in the resume picker
2 parents afe3944 + d853603 commit 3c887ac

18 files changed

Lines changed: 281 additions & 58 deletions

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2323
or interrupted worker and returns immediately. `wait_agents` collects the
2424
reply. `send_input` steers only an in-flight running turn. Closed workers
2525
stay closed.
26+
- `corbits resume` lists completed, failed, and crashed sessions alongside
27+
in-progress ones, ordered by last persist rather than start time.
28+
`--force` is no longer required to see finished threads. The picker shows
29+
the 10 most recent sessions and type-to-filter narrows that list.
2630

2731
### Fixed
2832

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ corbits resume <session-id>
7575
```
7676

7777
Plain `corbits` always starts a fresh conversation. `corbits resume` opens a
78-
picker of saved sessions for the working directory.
78+
picker of the 10 most recently persisted sessions for this checkout,
79+
including completed ones. Type to filter by name.
7980

8081
### Mid-run steering
8182

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
316316
| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
317317
| _(no verb)_ | — | Interactive session; optional trailing task text |
318318
| `exec` / `run` | — | Run a prompt (non-interactive / one-shot) |
319-
| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel) |
319+
| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel). Lists the 10 most recently persisted sessions, completed included. Type to filter. `--force` is not required to see finished threads. |
320320
| `--resume` | — | Open the interactive session picker |
321321
| `resume <session-id>` | — | Reopen a specific session |
322322
| `resume --pick` / `--list` | — | Interactive session picker |

‎docs/PRODUCT.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ Local multi-model capability checks use this path (`bun run eval:capability`); s
7676
$ corbits resume
7777
```
7878

79-
Opens a picker of saved conversations for the working directory. Plain
79+
Opens a picker of the 10 most recently persisted conversations for this
80+
checkout, including completed ones. Type to filter by name. Plain
8081
`corbits` always starts a fresh conversation; `corbits resume <session-id>`
8182
is the direct, explicit resume path.
8283

‎docs/TUI.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,11 @@ in `mouse-reporting-disabled.test.ts` for both `runListModal` and
432432
`runProviderSetup`). This is intentional: these surfaces never need
433433
click-to-expand or drag-to-scroll, so leaving mouse reporting off lets the
434434
terminal's own text selection and copy work by default, with no Alt+M dance
435-
required.
435+
required. The resume picker lists the 10 most recently persisted sessions
436+
for this checkout — completed, failed, and crashed included. Recency is
437+
the last write to `run.json`, not start time. Type to filter by name
438+
(printable keys claim the `>` row, same as the model picker); `--force`
439+
is not a list filter.
436440

437441
## The prompt box
438442

‎src/session/index.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ export interface SessionSummary {
202202
sessionId: string;
203203
task: string;
204204
startedAt: number;
205+
/** Last persist time (`run.json` mtime, else session-dir mtime). Sort key for resume. */
206+
updatedAt: number;
205207
status: RunState["status"];
206208
}
207209

@@ -230,7 +232,19 @@ async function collectSessionIds(cwd: string, home: string): Promise<string[]> {
230232
return [...ids];
231233
}
232234

233-
/** List on-disk sessions for a project, newest first. */
235+
async function sessionUpdatedAt(dir: string, fallbackMs: number): Promise<number> {
236+
try {
237+
return (await stat(join(dir, "run.json"))).mtimeMs;
238+
} catch {
239+
try {
240+
return (await stat(dir)).mtimeMs;
241+
} catch {
242+
return fallbackMs;
243+
}
244+
}
245+
}
246+
247+
/** List on-disk sessions for a project, most recently persisted first. */
234248
export async function listSessions(
235249
cwd: string,
236250
home: string = homedir(),
@@ -240,12 +254,14 @@ export async function listSessions(
240254
const summaries: SessionSummary[] = [];
241255
for (const entry of entries) {
242256
await migrateLegacySessionIfNeeded(cwd, entry, home);
257+
const dir = sessionDir(cwd, entry, home);
243258
const loaded = await loadState(cwd, entry, home);
244259
if (loaded.kind === "ok") {
245260
summaries.push({
246261
sessionId: entry,
247262
task: loaded.state.task,
248263
startedAt: loaded.state.startedAt,
264+
updatedAt: await sessionUpdatedAt(dir, loaded.state.startedAt),
249265
status: loaded.state.status,
250266
});
251267
continue;
@@ -258,20 +274,22 @@ export async function listSessions(
258274
// and therefore isn't actually running: report it as crashed rather
259275
// than fabricating liveness.
260276
try {
261-
const dirStat = await stat(sessionDir(cwd, entry, home));
277+
const dirStat = await stat(dir);
262278
await stat(sessionContextDir(cwd, entry, home));
279+
const startedAt = dirStat.birthtimeMs > 0 ? dirStat.birthtimeMs : dirStat.mtimeMs;
263280
summaries.push({
264281
sessionId: entry,
265282
task: "(conversation)",
266-
startedAt: dirStat.birthtimeMs > 0 ? dirStat.birthtimeMs : dirStat.mtimeMs,
283+
startedAt,
284+
updatedAt: dirStat.mtimeMs,
267285
status: "crashed",
268286
});
269287
} catch {
270288
// Not a resumable session directory.
271289
}
272290
}
273291

274-
summaries.sort((a, b) => b.startedAt - a.startedAt);
292+
summaries.sort((a, b) => b.updatedAt - a.updatedAt);
275293
return Promise.all(
276294
summaries.map(async (row) => ({
277295
...row,

‎src/session/list-sessions.test.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, expect, test } from "bun:test";
2-
import { mkdir, rm, writeFile } from "node:fs/promises";
2+
import { mkdir, rm, utimes, writeFile } from "node:fs/promises";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
55

@@ -203,3 +203,60 @@ test("listSessions stays silent when many sibling runs failed with an error", as
203203
expect(logged).not.toContain("unreadable session state");
204204
expect(logged).not.toContain(home);
205205
});
206+
207+
async function writeRun(
208+
sessionId: string,
209+
body: { status: string; task: string; startedAt: number; turnsUsed?: number },
210+
): Promise<string> {
211+
await initSessionDir(cwd, sessionId, home);
212+
const dir = sessionDir(cwd, sessionId, home);
213+
await writeFile(join(dir, "run.json"), JSON.stringify({ turnsUsed: 1, ...body }));
214+
return join(dir, "run.json");
215+
}
216+
217+
test("listSessions includes completed and failed sessions", async () => {
218+
const doneId = generateSessionId();
219+
const failedId = generateSessionId();
220+
await writeRun(doneId, { status: "done", task: "finished work", startedAt: 1 });
221+
await writeRun(failedId, { status: "failed", task: "broke", startedAt: 2 });
222+
const listed = await listSessions(cwd, home);
223+
expect(listed.find((s) => s.sessionId === doneId)?.status).toBe("done");
224+
expect(listed.find((s) => s.sessionId === failedId)?.status).toBe("failed");
225+
});
226+
227+
test("listSessions sorts by run.json mtime, not startedAt", async () => {
228+
const olderStart = generateSessionId();
229+
const newerStart = generateSessionId();
230+
const olderPath = await writeRun(olderStart, {
231+
status: "done",
232+
task: "started first, touched last",
233+
startedAt: 1_000,
234+
});
235+
const newerPath = await writeRun(newerStart, {
236+
status: "running",
237+
task: "started later, stale",
238+
startedAt: 9_000,
239+
});
240+
const now = Date.now();
241+
await utimes(newerPath, now / 1000 - 60, now / 1000 - 60);
242+
await utimes(olderPath, now / 1000, now / 1000);
243+
const listed = await listSessions(cwd, home);
244+
expect(listed[0]?.sessionId).toBe(olderStart);
245+
expect(listed[1]?.sessionId).toBe(newerStart);
246+
expect(listed[0]?.updatedAt).toBeGreaterThan(listed[1]?.updatedAt ?? 0);
247+
});
248+
249+
test("listSessions reports updatedAt from run.json mtime", async () => {
250+
const sessionId = generateSessionId();
251+
const path = await writeRun(sessionId, {
252+
status: "done",
253+
task: "mtime title",
254+
startedAt: 1,
255+
});
256+
const stamp = Date.now() - 120_000;
257+
await utimes(path, stamp / 1000, stamp / 1000);
258+
const listed = await listSessions(cwd, home);
259+
const row = listed.find((s) => s.sessionId === sessionId);
260+
expect(row?.updatedAt).toBeGreaterThanOrEqual(stamp - 2000);
261+
expect(row?.updatedAt).toBeLessThanOrEqual(stamp + 2000);
262+
});

‎src/tui/list-modal.test.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { afterEach, describe, expect, test } from "bun:test";
22

33
import { createHarness, type Harness } from "./harness.js";
4-
import { runListModal } from "./list-modal.js";
4+
import { runListModal, type ListModalConfig } from "./list-modal.js";
55

66
let harness: Harness | undefined;
77

@@ -10,7 +10,7 @@ afterEach(() => {
1010
harness = undefined;
1111
});
1212

13-
async function mountModal(): Promise<{
13+
async function mountModal(overrides: Partial<ListModalConfig> = {}): Promise<{
1414
choice: Promise<string | null>;
1515
harness: Harness;
1616
}> {
@@ -22,6 +22,7 @@ async function mountModal(): Promise<{
2222
{ id: "s-2", label: "Second session" },
2323
],
2424
createRenderer: async () => harness!.renderer,
25+
...overrides,
2526
});
2627
await harness.renderOnce();
2728
return { choice, harness };
@@ -62,4 +63,40 @@ describe("runListModal", () => {
6263
harness.pressKey("Escape");
6364
await choice;
6465
});
66+
67+
test("type-to-filter narrows the list and Enter selects the match", async () => {
68+
const { choice, harness } = await mountModal({ typeToFilter: true });
69+
await harness.renderOnce();
70+
expect(harness.captureCharFrame()).toContain("Second session");
71+
for (const ch of "Second") {
72+
harness.pressKey(ch);
73+
}
74+
await harness.renderOnce();
75+
const frame = harness.captureCharFrame();
76+
expect(frame).toContain("Second session");
77+
expect(frame).not.toContain("First session");
78+
harness.pressKey("Enter");
79+
expect(await choice).toBe("s-2");
80+
});
81+
82+
test("type-to-filter no-match Enter stays open", async () => {
83+
const { choice, harness } = await mountModal({ typeToFilter: true });
84+
await harness.renderOnce();
85+
for (const ch of "zzzzz") {
86+
harness.pressKey(ch);
87+
}
88+
await harness.renderOnce();
89+
expect(harness.captureCharFrame()).toContain("(no matches)");
90+
harness.pressKey("Enter");
91+
await harness.renderOnce();
92+
const afterEnter = harness.captureCharFrame();
93+
expect(afterEnter).toContain("(no matches)");
94+
expect(afterEnter).toContain(">");
95+
for (let i = 0; i < 5; i++) {
96+
harness.pressKey("Backspace");
97+
}
98+
await harness.renderOnce();
99+
harness.pressKey("Enter");
100+
expect(await choice).toBe("s-1");
101+
});
65102
});

‎src/tui/list-modal.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export interface ListModalConfig {
2828
readonly heading?: readonly string[];
2929
readonly options: readonly ResidualCatalogEntry[];
3030
readonly activeIndex?: number;
31+
/**
32+
* Claim printable keys for a `>` filter row so the list narrows as you type.
33+
* Off by default so other satellite lists keep j/k navigation.
34+
*/
35+
readonly typeToFilter?: boolean;
3136
/** Renderer factory override for headless mounting in tests. */
3237
readonly createRenderer?: () => Promise<CliRenderer>;
3338
}
@@ -100,8 +105,11 @@ export async function runListModal(config: ListModalConfig): Promise<string | nu
100105
itemIds,
101106
frameId: "overlay-list-modal",
102107
activeIndex: config.activeIndex ?? 0,
108+
...(config.typeToFilter === true ? { typeToFilter: true } : {}),
103109
onAccept: (selection) => {
104-
settle(residualIdFromSelection(selection, itemIds) ?? null);
110+
const id = residualIdFromSelection(selection, itemIds);
111+
if (id === undefined) return;
112+
settle(id);
105113
},
106114
});
107115

‎src/tui/overlays.test.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Wave 5: primary overlays — open / navigate / Esc restore + resize floors.
33
*/
44
import { describe, expect, test } from "bun:test";
5-
import { rgbToHex } from "@opentui/core";
5+
import { rgbToHex, type KeyEvent } from "@opentui/core";
66
import { IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR } from "./geometry/index";
77
import { focusOwner, scrollLease } from "./focus/index";
88
import { withTestRenderer } from "./harness";
@@ -18,6 +18,7 @@ import {
1818
clearShellOverlayHooks,
1919
closeInsetOverlay,
2020
createAppShell,
21+
handleListFilterKey,
2122
moveOverlaySelection,
2223
openListOverlay,
2324
pageOverlaySelection,
@@ -450,6 +451,44 @@ describe("overlay accept callbacks", () => {
450451
});
451452
});
452453

454+
describe("type-to-filter list overlay", () => {
455+
test("no-match Enter leaves overlayList set and does not echo Chose (no matches)", async () => {
456+
await withTestRenderer(
457+
async (h) => {
458+
const shell = createAppShell(h.renderer, {
459+
terminal: { columns: 80, rows: 24 },
460+
wireKeys: false,
461+
});
462+
try {
463+
openListOverlay(shell, {
464+
kind: "resume",
465+
items: ["First session", "Second session"],
466+
itemIds: ["s-1", "s-2"],
467+
typeToFilter: true,
468+
});
469+
const press = (seq: string): boolean =>
470+
handleListFilterKey(shell, {
471+
name: seq,
472+
sequence: seq,
473+
ctrl: false,
474+
meta: false,
475+
option: false,
476+
} as unknown as KeyEvent);
477+
for (const ch of "zzzzz") press(ch);
478+
expect(shell.overlayItems).toEqual(["(no matches)"]);
479+
acceptOverlaySelection(shell);
480+
expect(shell.overlayList).not.toBeNull();
481+
expect(shell.overlayItems).toEqual(["(no matches)"]);
482+
expect(shell.streamLog.some((row) => /Chose \(no matches\)/.test(row.text))).toBe(false);
483+
} finally {
484+
shell.dispose();
485+
}
486+
},
487+
{ width: 80, height: 24 },
488+
);
489+
});
490+
});
491+
453492
describe("resize mid-overlay", () => {
454493
test("80×24 ↔ larger keeps floors; closed restores idle floor", async () => {
455494
await withTestRenderer(

0 commit comments

Comments
 (0)