Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
or interrupted worker and returns immediately. `wait_agents` collects the
reply. `send_input` steers only an in-flight running turn. Closed workers
stay closed.
- `corbits resume` lists completed, failed, and crashed sessions alongside
in-progress ones, ordered by last persist rather than start time.
`--force` is no longer required to see finished threads. The picker shows
the 10 most recent sessions and type-to-filter narrows that list.

### Fixed

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ corbits resume <session-id>
```

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

### Mid-run steering

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts`
| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| _(no verb)_ | — | Interactive session; optional trailing task text |
| `exec` / `run` | — | Run a prompt (non-interactive / one-shot) |
| `resume` / `continue` | — | Open the session picker for this folder (project-keyed to this checkout's git toplevel) |
| `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. |
| `--resume` | — | Open the interactive session picker |
| `resume <session-id>` | — | Reopen a specific session |
| `resume --pick` / `--list` | — | Interactive session picker |
Expand Down
3 changes: 2 additions & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ Local multi-model capability checks use this path (`bun run eval:capability`); s
$ corbits resume
```

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

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

## The prompt box

Expand Down
26 changes: 22 additions & 4 deletions src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ export interface SessionSummary {
sessionId: string;
task: string;
startedAt: number;
/** Last persist time (`run.json` mtime, else session-dir mtime). Sort key for resume. */
updatedAt: number;
status: RunState["status"];
}

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

/** List on-disk sessions for a project, newest first. */
async function sessionUpdatedAt(dir: string, fallbackMs: number): Promise<number> {
try {
return (await stat(join(dir, "run.json"))).mtimeMs;
} catch {
try {
return (await stat(dir)).mtimeMs;
} catch {
return fallbackMs;
}
}
}

/** List on-disk sessions for a project, most recently persisted first. */
export async function listSessions(
cwd: string,
home: string = homedir(),
Expand All @@ -240,12 +254,14 @@ export async function listSessions(
const summaries: SessionSummary[] = [];
for (const entry of entries) {
await migrateLegacySessionIfNeeded(cwd, entry, home);
const dir = sessionDir(cwd, entry, home);
const loaded = await loadState(cwd, entry, home);
if (loaded.kind === "ok") {
summaries.push({
sessionId: entry,
task: loaded.state.task,
startedAt: loaded.state.startedAt,
updatedAt: await sessionUpdatedAt(dir, loaded.state.startedAt),
status: loaded.state.status,
});
continue;
Expand All @@ -258,20 +274,22 @@ export async function listSessions(
// and therefore isn't actually running: report it as crashed rather
// than fabricating liveness.
try {
const dirStat = await stat(sessionDir(cwd, entry, home));
const dirStat = await stat(dir);
await stat(sessionContextDir(cwd, entry, home));
const startedAt = dirStat.birthtimeMs > 0 ? dirStat.birthtimeMs : dirStat.mtimeMs;
summaries.push({
sessionId: entry,
task: "(conversation)",
startedAt: dirStat.birthtimeMs > 0 ? dirStat.birthtimeMs : dirStat.mtimeMs,
startedAt,
updatedAt: dirStat.mtimeMs,
status: "crashed",
});
} catch {
// Not a resumable session directory.
}
}

summaries.sort((a, b) => b.startedAt - a.startedAt);
summaries.sort((a, b) => b.updatedAt - a.updatedAt);
return Promise.all(
summaries.map(async (row) => ({
...row,
Expand Down
59 changes: 58 additions & 1 deletion src/session/list-sessions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

Expand Down Expand Up @@ -203,3 +203,60 @@ test("listSessions stays silent when many sibling runs failed with an error", as
expect(logged).not.toContain("unreadable session state");
expect(logged).not.toContain(home);
});

async function writeRun(
sessionId: string,
body: { status: string; task: string; startedAt: number; turnsUsed?: number },
): Promise<string> {
await initSessionDir(cwd, sessionId, home);
const dir = sessionDir(cwd, sessionId, home);
await writeFile(join(dir, "run.json"), JSON.stringify({ turnsUsed: 1, ...body }));
return join(dir, "run.json");
}

test("listSessions includes completed and failed sessions", async () => {
const doneId = generateSessionId();
const failedId = generateSessionId();
await writeRun(doneId, { status: "done", task: "finished work", startedAt: 1 });
await writeRun(failedId, { status: "failed", task: "broke", startedAt: 2 });
const listed = await listSessions(cwd, home);
expect(listed.find((s) => s.sessionId === doneId)?.status).toBe("done");
expect(listed.find((s) => s.sessionId === failedId)?.status).toBe("failed");
});

test("listSessions sorts by run.json mtime, not startedAt", async () => {
const olderStart = generateSessionId();
const newerStart = generateSessionId();
const olderPath = await writeRun(olderStart, {
status: "done",
task: "started first, touched last",
startedAt: 1_000,
});
const newerPath = await writeRun(newerStart, {
status: "running",
task: "started later, stale",
startedAt: 9_000,
});
const now = Date.now();
await utimes(newerPath, now / 1000 - 60, now / 1000 - 60);
await utimes(olderPath, now / 1000, now / 1000);
const listed = await listSessions(cwd, home);
expect(listed[0]?.sessionId).toBe(olderStart);
expect(listed[1]?.sessionId).toBe(newerStart);
expect(listed[0]?.updatedAt).toBeGreaterThan(listed[1]?.updatedAt ?? 0);
});

test("listSessions reports updatedAt from run.json mtime", async () => {
const sessionId = generateSessionId();
const path = await writeRun(sessionId, {
status: "done",
task: "mtime title",
startedAt: 1,
});
const stamp = Date.now() - 120_000;
await utimes(path, stamp / 1000, stamp / 1000);
const listed = await listSessions(cwd, home);
const row = listed.find((s) => s.sessionId === sessionId);
expect(row?.updatedAt).toBeGreaterThanOrEqual(stamp - 2000);
expect(row?.updatedAt).toBeLessThanOrEqual(stamp + 2000);
});
41 changes: 39 additions & 2 deletions src/tui/list-modal.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";

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

let harness: Harness | undefined;

Expand All @@ -10,7 +10,7 @@ afterEach(() => {
harness = undefined;
});

async function mountModal(): Promise<{
async function mountModal(overrides: Partial<ListModalConfig> = {}): Promise<{
choice: Promise<string | null>;
harness: Harness;
}> {
Expand All @@ -22,6 +22,7 @@ async function mountModal(): Promise<{
{ id: "s-2", label: "Second session" },
],
createRenderer: async () => harness!.renderer,
...overrides,
});
await harness.renderOnce();
return { choice, harness };
Expand Down Expand Up @@ -62,4 +63,40 @@ describe("runListModal", () => {
harness.pressKey("Escape");
await choice;
});

test("type-to-filter narrows the list and Enter selects the match", async () => {
const { choice, harness } = await mountModal({ typeToFilter: true });
await harness.renderOnce();
expect(harness.captureCharFrame()).toContain("Second session");
for (const ch of "Second") {
harness.pressKey(ch);
}
await harness.renderOnce();
const frame = harness.captureCharFrame();
expect(frame).toContain("Second session");
expect(frame).not.toContain("First session");
harness.pressKey("Enter");
expect(await choice).toBe("s-2");
});

test("type-to-filter no-match Enter stays open", async () => {
const { choice, harness } = await mountModal({ typeToFilter: true });
await harness.renderOnce();
for (const ch of "zzzzz") {
harness.pressKey(ch);
}
await harness.renderOnce();
expect(harness.captureCharFrame()).toContain("(no matches)");
harness.pressKey("Enter");
await harness.renderOnce();
const afterEnter = harness.captureCharFrame();
expect(afterEnter).toContain("(no matches)");
expect(afterEnter).toContain(">");
for (let i = 0; i < 5; i++) {
harness.pressKey("Backspace");
}
await harness.renderOnce();
harness.pressKey("Enter");
expect(await choice).toBe("s-1");
});
});
10 changes: 9 additions & 1 deletion src/tui/list-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export interface ListModalConfig {
readonly heading?: readonly string[];
readonly options: readonly ResidualCatalogEntry[];
readonly activeIndex?: number;
/**
* Claim printable keys for a `>` filter row so the list narrows as you type.
* Off by default so other satellite lists keep j/k navigation.
*/
readonly typeToFilter?: boolean;
/** Renderer factory override for headless mounting in tests. */
readonly createRenderer?: () => Promise<CliRenderer>;
}
Expand Down Expand Up @@ -100,8 +105,11 @@ export async function runListModal(config: ListModalConfig): Promise<string | nu
itemIds,
frameId: "overlay-list-modal",
activeIndex: config.activeIndex ?? 0,
...(config.typeToFilter === true ? { typeToFilter: true } : {}),
onAccept: (selection) => {
settle(residualIdFromSelection(selection, itemIds) ?? null);
const id = residualIdFromSelection(selection, itemIds);
if (id === undefined) return;
settle(id);
},
});

Expand Down
41 changes: 40 additions & 1 deletion src/tui/overlays.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Wave 5: primary overlays — open / navigate / Esc restore + resize floors.
*/
import { describe, expect, test } from "bun:test";
import { rgbToHex } from "@opentui/core";
import { rgbToHex, type KeyEvent } from "@opentui/core";
import { IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR } from "./geometry/index";
import { focusOwner, scrollLease } from "./focus/index";
import { withTestRenderer } from "./harness";
Expand All @@ -18,6 +18,7 @@ import {
clearShellOverlayHooks,
closeInsetOverlay,
createAppShell,
handleListFilterKey,
moveOverlaySelection,
openListOverlay,
pageOverlaySelection,
Expand Down Expand Up @@ -450,6 +451,44 @@ describe("overlay accept callbacks", () => {
});
});

describe("type-to-filter list overlay", () => {
test("no-match Enter leaves overlayList set and does not echo Chose (no matches)", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
});
try {
openListOverlay(shell, {
kind: "resume",
items: ["First session", "Second session"],
itemIds: ["s-1", "s-2"],
typeToFilter: true,
});
const press = (seq: string): boolean =>
handleListFilterKey(shell, {
name: seq,
sequence: seq,
ctrl: false,
meta: false,
option: false,
} as unknown as KeyEvent);
for (const ch of "zzzzz") press(ch);
expect(shell.overlayItems).toEqual(["(no matches)"]);
acceptOverlaySelection(shell);
expect(shell.overlayList).not.toBeNull();
expect(shell.overlayItems).toEqual(["(no matches)"]);
expect(shell.streamLog.some((row) => /Chose \(no matches\)/.test(row.text))).toBe(false);
} finally {
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});
});

describe("resize mid-overlay", () => {
test("80×24 ↔ larger keeps floors; closed restores idle floor", async () => {
await withTestRenderer(
Expand Down
12 changes: 12 additions & 0 deletions src/tui/palette-paint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { KeyEvent } from "@opentui/core";
import { withTestRenderer } from "./harness";
import type { PaletteCommand } from "./command-catalog";
import {
acceptOverlaySelection,
createAppShell,
handlePaletteFilterKey,
moveOverlaySelection,
Expand Down Expand Up @@ -183,6 +184,17 @@ describe("palette filters as you type", () => {
expect(shell.overlayKind).toBe("palette");
});
});

test("type-to-filter no-match Enter leaves the palette open", async () => {
await withPalette((shell) => {
for (const ch of "zzqq") press(shell, ch);
expect(shell.overlayItems).toEqual(["(no matches)"]);
acceptOverlaySelection(shell);
expect(shell.overlayKind).toBe("palette");
expect(shell.overlayList).not.toBeNull();
expect(shell.overlayItems).toEqual(["(no matches)"]);
});
});
});

const DESCRIBED_CATALOG: readonly PaletteCommand[] = [
Expand Down
Loading
Loading