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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
All notable changes to Agentctl are documented here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com); versions follow [SemVer](https://semver.org).

## [0.12.0] — 2026-10-26

### Added

- Resume: `o` sorts the list by the next column (last used · name · status · branch · work · cwd), `O` reverses it; the header marks the sorted column (▼ natural order, ▲ reversed).
- Resume: **WORK** column — the `~/ClaudWork/<folder>` a session worked in (the folder its own tool calls name most), shown on terminals ≥ 100 columns; `/` filters on it too.

## [0.11.2] — 2026-10-23

### Changed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ src/core/ zero ink/react imports — pure data
paths.ts claudeHome / projectsDir / sessionsDir (honors AGENTCTL_HOME);
claudeHomes / projectsDirs / sessionsDirs = all ~/.claude* profiles
decode.ts encoded-cwd → cwd, FS-verified recursive pruned search
jsonlScan.ts streaming scan: first prompt, custom-title, ai-title, first ts
jsonlScan.ts streaming scan: first prompt, custom-title, ai-title, first ts, ClaudWork folder
liveState.ts ~/.claude/sessions/<pid>.json + kill -0 + ps comm check
git.ts read .git/HEAD (or gitdir-file) → branch / short SHA
search.ts async generator full-text search across all sessions
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ Annotate the highlighted session in place: `e` name · `n` note · `l` labels ·
`u` due date · `d` done. `c` copies a resume command, `h` hides a session, `x` deletes it (twice),
`v` shows the hidden ones, `H` shows/hides done ones, `T` filters tool runs. (`l` pre-fills the issue
key from the branch.)
`o` sorts by the next column (last used · name · status · branch · work · cwd), `O` reverses it. The
**WORK** column shows the `~/ClaudWork/<folder>` a session worked in.

`space` marks sessions — `h`, `x` and `d` then act on every marked one at once, and the cursor stays
where it was instead of jumping back to the top.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@roypadina/agentctl",
"version": "0.11.2",
"version": "0.12.0",
"description": "Start and resume Claude Code & Codex sessions from one terminal menu — fuzzy-resume past sessions with transcript preview.",
"type": "module",
"license": "MIT",
Expand Down
54 changes: 38 additions & 16 deletions src/cli/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Box, Text, useApp, useInput, render as inkRender } from 'ink';
import TextInput from 'ink-text-input';
import { spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { listSessions } from '../core/sessionRepo.js';
import { basename } from 'node:path';
import { listSessions, sortRecords, SORT_COLUMNS, type SortColumn } from '../core/sessionRepo.js';
import { readTranscript } from '../core/transcript.js';
import { searchSessions, type SearchMatch } from '../core/search.js';
import { formatDate, timeAgo } from './format.js';
Expand Down Expand Up @@ -90,6 +91,8 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
const [sessionView, setSessionView] = useState<SessionView>('normal');
/** null = show both. Tool runs (claude -p, the SDK, MCP) crowd out the sessions you actually sat in. */
const [kindFilter, setKindFilter] = useState<SessionKind | null>(null);
const [sortCol, setSortCol] = useState<SortColumn>('used');
const [sortDesc, setSortDesc] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
/** Transient footer note after `c` — cleared by the next keypress. */
const [copied, setCopied] = useState<string | null>(null);
Expand All @@ -108,10 +111,10 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
if (sessionView === 'hidden') return a?.hidden === true && a?.deleted !== true;
return a?.hidden !== true && a?.deleted !== true;
};
const visible = records.filter(r =>
inView(r) && !(hideDone && r.annotation?.done) && (!kindFilter || r.kind === kindFilter));
const visible = sortRecords(records.filter(r =>
inView(r) && !(hideDone && r.annotation?.done) && (!kindFilter || r.kind === kindFilter)), sortCol, sortDesc);
const haystack = (r: SessionRecord) =>
`${r.name} ${tildify(r.cwd)} ${r.id} ${(r.annotation?.flags ?? []).map(f => '#' + f).join(' ')}` +
`${r.name} ${tildify(r.cwd)} ${r.workDir ? basename(r.workDir) : ''} ${r.id} ${(r.annotation?.flags ?? []).map(f => '#' + f).join(' ')}` +
` ${(r.annotation?.labels ?? []).join(' ')} ${r.annotation?.note ?? ''}`;
const filtered = filter ? fuzzyRank(filter, visible, haystack).map(x => x.item) : visible;
const clamped = Math.min(cursor, Math.max(0, filtered.length - 1));
Expand Down Expand Up @@ -366,6 +369,11 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
return;
}
if (input === 'H') { setHideDone(v => !v); setCursor(0); return; }
if (input === 'o') {
setSortCol(c => SORT_COLUMNS[(SORT_COLUMNS.indexOf(c) + 1) % SORT_COLUMNS.length]);
setSortDesc(false); setCursor(0); return;
}
if (input === 'O') { setSortDesc(v => !v); setCursor(0); return; }
if (input === 'T') {
setKindFilter(k => (k === null ? 'interactive' : k === 'interactive' ? 'tool' : null));
setCursor(0);
Expand Down Expand Up @@ -458,7 +466,9 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
const wBranch = cols >= 90 ? 12 : 8;
const wUsed = 14; // "Jun 09 10:43"
const showId = cols >= 110;
const fixedCols = 2 + 2 + (showExtras ? 9 : 0) + (showId ? 9 : 0) + 1 + wBranch + 1 + wUsed + 1 + 2;
// ClaudWork folder; 0 = column hidden (also when no session has one — no column of dashes)
const wWork = cols >= 100 && records.some(r => r.workDir) ? 20 : 0;
const fixedCols = 2 + 2 + (showExtras ? 9 : 0) + (showId ? 9 : 0) + 1 + wBranch + 1 + wUsed + 1 + (wWork ? wWork + 1 : 0) + 2;
const wName = Math.max(10, Math.min(44, Math.floor((tableInner - fixedCols) * 0.6)));
// The list is the ONLY elastic block on screen, so its height is whatever the fixed chrome
// and the (content-sized) details pane leave over. Guessing a constant here is what pushed
Expand Down Expand Up @@ -538,6 +548,12 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
</Text>
</>
) : null}
{sortCol !== 'used' || sortDesc ? (
<>
<Text dimColor> · sort </Text>
<Text color="yellow">{sortCol} {sortDesc ? '▲' : '▼'}</Text>
</>
) : null}
{filter ? (
<>
<Text dimColor> · filter </Text>
Expand Down Expand Up @@ -609,9 +625,9 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
{mode === 'list' || mode === 'filter' || mode === 'annotate' ? (
<Box marginTop={1} flexDirection="column">
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<TableHeader wName={wName} wBranch={wBranch} wUsed={wUsed} extras={showExtras} showId={showId} />
<TableHeader wName={wName} wBranch={wBranch} wUsed={wUsed} wWork={wWork} extras={showExtras} showId={showId} sortCol={sortCol} sortDesc={sortDesc} />
{view.map((r, i) => (
<Row key={r.id} r={r} selected={start + i === clamped} marked={marked.has(r.id)} wName={wName} wBranch={wBranch} wUsed={wUsed} bar={listBar[i]} extras={showExtras} showId={showId} />
<Row key={r.id} r={r} selected={start + i === clamped} marked={marked.has(r.id)} wName={wName} wBranch={wBranch} wUsed={wUsed} wWork={wWork} bar={listBar[i]} extras={showExtras} showId={showId} />
))}
{filtered.length === 0 ? (
<Text dimColor>{records.length === 0 ? '(no sessions yet)' : `(no matches for "${filter}")`}</Text>
Expand Down Expand Up @@ -715,6 +731,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
<HelpRow k="H" v="show / hide the sessions marked done" />
<HelpRow k="T" v="tool runs: everything → interactive only → tool runs only" />
<HelpRow k="v" v="show the hidden sessions (deleted are CLI/app only)" />
<HelpRow k="o / O" v="sort by the next column — used · name · status · branch · work · cwd / reverse" />
<HelpSection t="annotate the highlighted session" />
<HelpRow k="e / n" v="name it / attach a note" />
<HelpRow k="l / f" v="labels — Jira key, repo, topic (l pre-fills it from the branch) / flags" />
Expand Down Expand Up @@ -885,20 +902,24 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
);
}

function TableHeader({ wName, wBranch, wUsed, extras, showId }: {
wName: number; wBranch: number; wUsed: number; extras: boolean; showId: boolean;
function TableHeader({ wName, wBranch, wUsed, wWork, extras, showId, sortCol, sortDesc }: {
wName: number; wBranch: number; wUsed: number; wWork: number; extras: boolean; showId: boolean;
sortCol: SortColumn; sortDesc: boolean;
}) {
// ▼ = the column's natural order (newest, busy first, A→Z), ▲ = reversed.
const h = (col: SortColumn, label: string) => (sortCol === col ? `${label}${sortDesc ? '▲' : '▼'}` : label);
return (
<Box>
<Box width={4} flexShrink={0}><Text dimColor bold>ST</Text></Box>
<Box width={4} flexShrink={0}><Text dimColor bold>{h('status', 'ST')}</Text></Box>
{extras ? <Box width={2} flexShrink={0}><Text dimColor bold> </Text></Box> : null}
{extras ? <Box width={2} flexShrink={0}><Text dimColor bold> </Text></Box> : null}
<Box width={wName} marginRight={1} flexShrink={0}><Text dimColor bold>NAME</Text></Box>
<Box width={wName} marginRight={1} flexShrink={0}><Text dimColor bold>{h('name', 'NAME')}</Text></Box>
{extras ? <Box width={5} flexShrink={0}><Text dimColor bold> </Text></Box> : null}
{showId ? <Box width={9} flexShrink={0}><Text dimColor bold>ID</Text></Box> : null}
<Box width={wBranch} marginRight={1} flexShrink={0}><Text dimColor bold>BRANCH</Text></Box>
<Box width={wUsed} marginRight={1} flexShrink={0}><Text dimColor bold>LAST USED</Text></Box>
<Box flexGrow={1} minWidth={0}><Text dimColor bold>CWD</Text></Box>
<Box width={wBranch} marginRight={1} flexShrink={0}><Text dimColor bold>{h('branch', 'BRANCH')}</Text></Box>
<Box width={wUsed} marginRight={1} flexShrink={0}><Text dimColor bold>{h('used', 'LAST USED')}</Text></Box>
{wWork ? <Box width={wWork} marginRight={1} flexShrink={0}><Text dimColor bold>{h('work', 'WORK')}</Text></Box> : null}
<Box flexGrow={1} minWidth={0}><Text dimColor bold>{h('cwd', 'CWD')}</Text></Box>
<Box width={1} marginLeft={1} flexShrink={0}><Text dimColor> </Text></Box>
</Box>
);
Expand All @@ -917,9 +938,9 @@ function badgeMarks(r: SessionRecord): Array<{ ch: string; color: string }> {
return out;
}

function Row({ r, selected, marked, wName, wBranch, wUsed, bar, extras, showId }: {
function Row({ r, selected, marked, wName, wBranch, wUsed, wWork, bar, extras, showId }: {
r: SessionRecord; selected: boolean; marked: boolean;
wName: number; wBranch: number; wUsed: number;
wName: number; wBranch: number; wUsed: number; wWork: number;
bar?: string; extras: boolean; showId: boolean;
}) {
return (
Expand All @@ -943,6 +964,7 @@ function Row({ r, selected, marked, wName, wBranch, wUsed, bar, extras, showId }
{showId ? <Box width={9} flexShrink={0}><Text dimColor>{r.id.slice(0, 8)}</Text></Box> : null}
<Box width={wBranch} marginRight={1} flexShrink={0}><Text color="magenta" wrap="truncate-end">{r.gitBranch ?? '–'}</Text></Box>
<Box width={wUsed} marginRight={1} flexShrink={0}><Text color="cyan" wrap="truncate-end">{formatDate(r.lastUpdatedAt)}</Text></Box>
{wWork ? <Box width={wWork} marginRight={1} flexShrink={0}><Text color="blue" wrap="truncate-end">{r.workDir ? basename(r.workDir) : '–'}</Text></Box> : null}
<Box flexGrow={1} minWidth={0}><Text color="green" wrap="truncate-middle">{tildify(r.cwd)}</Text></Box>
{bar ? <Box width={1} marginLeft={1} flexShrink={0}><Text dimColor>{bar}</Text></Box> : null}
</Box>
Expand Down
18 changes: 17 additions & 1 deletion src/core/jsonlScan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,17 @@ export interface ScanResult {
* project directory name only ever encodes where `claude` was launched.
*/
lastCwd: string | null;
/**
* The `~/ClaudWork/<folder>` this session worked in: the folder its own tool calls name most.
* Bash `cd` never sticks in Claude Code, so `lastCwd` stays the launch dir for ad-hoc work.
*/
workDirName: string | null;
}

// Assistant records hold only the model's text and tool inputs, so a ClaudWork path there is
// one it chose to work in — not one quoted back from a memory file or a system reminder.
const WORK_DIR_RE = /ClaudWork\/(\w[\w.-]*)/g; // \w first: never `.` or `..`

/** Anything that is not the interactive CLI was driven by a tool, not typed by a human. */
export function kindOf(entrypoint: string | null | undefined): SessionKind {
return entrypoint && entrypoint !== 'cli' ? 'tool' : 'interactive';
Expand Down Expand Up @@ -105,6 +114,7 @@ export async function scanJsonl(path: string): Promise<ScanResult> {
let corruptLines = 0;
let entrypoint: string | null = null;
let lastCwd: string | null = null;
const workDirHits = new Map<string, number>();
try {
for await (const line of rl) {
if (!line) continue;
Expand Down Expand Up @@ -135,9 +145,15 @@ export async function scanJsonl(path: string): Promise<ScanResult> {
entrypoint = o.entrypoint;
}
if (typeof o.cwd === 'string' && o.cwd) lastCwd = o.cwd;
if (o.type === 'assistant' && line.includes('ClaudWork/')) {
for (const m of line.matchAll(WORK_DIR_RE)) workDirHits.set(m[1], (workDirHits.get(m[1]) ?? 0) + 1);
}
}
} finally {
stream.destroy();
}
return { firstPrompt, customTitle, aiTitle, firstTimestamp, corruptLines, entrypoint, lastCwd };
let workDirName: string | null = null;
let best = 0;
for (const [name, n] of workDirHits) if (n > best) { best = n; workDirName = name; }
return { firstPrompt, customTitle, aiTitle, firstTimestamp, corruptLines, entrypoint, lastCwd, workDirName };
}
35 changes: 35 additions & 0 deletions src/core/sessionRepo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { projectsDirs } from './paths.js';
import { decodeCwd } from './decode.js';
Expand Down Expand Up @@ -55,6 +56,38 @@ export async function listSessions(opts: ListOptions = {}): Promise<SessionRecor
return opts.limit ? out.slice(0, opts.limit) : out;
}

export type SortColumn = 'used' | 'name' | 'status' | 'branch' | 'work' | 'cwd';
/** Order `o` cycles through in the TUI; the first is the default. */
export const SORT_COLUMNS: SortColumn[] = ['used', 'name', 'status', 'branch', 'work', 'cwd'];

const STATUS_RANK = { busy: 0, idle: 1, inactive: 2 } as const;

/**
* Sort by one column. Natural order (`desc` false): newest first for `used`, busy → idle →
* inactive for `status`, A→Z for the text columns; `desc` flips it. Rows without a value
* (no branch, no work folder) always sink to the bottom. Ties keep the incoming order.
*/
export function sortRecords(records: SessionRecord[], col: SortColumn, desc = false): SessionRecord[] {
const key = (r: SessionRecord): string | number | undefined => {
switch (col) {
case 'used': return -r.lastUpdatedAt.getTime();
case 'status': return STATUS_RANK[r.status];
case 'name': return r.name.toLowerCase();
case 'branch': return r.gitBranch?.toLowerCase();
case 'work': return r.workDir?.toLowerCase();
case 'cwd': return r.cwd.toLowerCase();
}
};
return records
.map((r, i) => ({ r, i, k: key(r) }))
.sort((a, b) => {
if (a.k === undefined || b.k === undefined) return a.k === b.k ? a.i - b.i : a.k === undefined ? 1 : -1;
const c = typeof a.k === 'number' ? a.k - (b.k as number) : (a.k as string).localeCompare(b.k as string);
return (desc ? -c : c) || a.i - b.i;
})
.map(x => x.r);
}

function buildRecord(
id: string,
jsonlPath: string,
Expand All @@ -72,12 +105,14 @@ function buildRecord(
const launchCwd = live?.cwd ?? decoded.cwd;
const working = scan.lastCwd && existsSync(scan.lastCwd) ? scan.lastCwd : null;
const cwd = working ?? launchCwd;
const workDir = scan.workDirName ? join(homedir(), 'ClaudWork', scan.workDirName) : null;
return {
id,
name: annotation?.name ?? transcriptName,
transcriptName,
cwd,
launchCwd,
workDir: workDir && existsSync(workDir) ? workDir : undefined,
// A directory the transcript names and that exists is not a guess, however the name decoded.
cwdDecodeConfident: working !== null || live !== null || decoded.confident,
jsonlPath,
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface SessionRecord {
* `cwd` — you start in ~/Code and end up working three directories down.
*/
launchCwd: string;
/** `~/ClaudWork/<folder>` the session did its work in, when that folder still exists. */
workDir?: string;
cwdDecodeConfident: boolean;
jsonlPath: string;
sizeBytes: number;
Expand Down
6 changes: 6 additions & 0 deletions tests/core/jsonlScan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const here = dirname(fileURLToPath(import.meta.url));
const fixtures = join(here, '..', 'fixtures', 'transcripts');

describe('scanJsonl', () => {
it('workDirName = the ClaudWork folder the assistant names most, ignoring user text', async () => {
const r = await scanJsonl(join(fixtures, 'claudwork.jsonl'));
expect(r.workDirName).toBe('2026-02-02-real-task');
expect((await scanJsonl(join(fixtures, 'simple.jsonl'))).workDirName).toBeNull();
});

it('returns first user prompt and first timestamp', async () => {
const r = await scanJsonl(join(fixtures, 'simple.jsonl'));
expect(r.firstPrompt).toBe('hello world');
Expand Down
28 changes: 28 additions & 0 deletions tests/core/sortRecords.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { sortRecords } from '../../src/core/sessionRepo.js';
import type { SessionRecord } from '../../src/core/types.js';

const rec = (id: string, o: Partial<SessionRecord>): SessionRecord => ({
id, name: id, transcriptName: id, cwd: '/tmp', launchCwd: '/tmp', cwdDecodeConfident: true,
jsonlPath: '/x.jsonl', sizeBytes: 0, startedAt: new Date(0), lastUpdatedAt: new Date(0),
active: false, status: 'inactive', kind: 'interactive', ...o,
});
const ids = (rs: SessionRecord[]) => rs.map(r => r.id).join(',');

describe('sortRecords', () => {
const rs = [
rec('a', { name: 'zeta', lastUpdatedAt: new Date(1), workDir: '/h/ClaudWork/b-task', status: 'idle' }),
rec('b', { name: 'Alpha', lastUpdatedAt: new Date(3), status: 'busy' }),
rec('c', { name: 'mid', lastUpdatedAt: new Date(2), workDir: '/h/ClaudWork/a-task' }),
];
it('used: newest first, reversed with desc', () => {
expect(ids(sortRecords(rs, 'used'))).toBe('b,c,a');
expect(ids(sortRecords(rs, 'used', true))).toBe('a,c,b');
});
it('name: case-insensitive A→Z', () => expect(ids(sortRecords(rs, 'name'))).toBe('b,c,a'));
it('status: busy, idle, inactive', () => expect(ids(sortRecords(rs, 'status'))).toBe('b,a,c'));
it('work: rows without a folder sink in both directions', () => {
expect(ids(sortRecords(rs, 'work'))).toBe('c,a,b');
expect(ids(sortRecords(rs, 'work', true))).toBe('a,c,b');
});
});
Loading
Loading