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
2 changes: 1 addition & 1 deletion docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ the harness records cumulative-total deltas rather than per-request prompts
|-----|--------|
| `R` | Set the date range — `all` · `30d` (or `30`) · `2m` · `1y` · `2026` · `2026-05` · `start..end` |
| `a` | Back to all time, keeping the current selection where possible |
| `s` | Sort picker for the visible list (`j`/`k` move · `Enter` · `Esc`) |
| `s` | Sort picker for the visible list (`j`/`k` move · `Enter` · `Esc`). Sessions offer **Start Date** (`created_at`, default) and, everywhere except the Time overview's **Days** pane, **Last Activity** (`ended_at`, including subagent activity where tracked) — a single day's list is read by start time, and activity can run into a later day than the one the row is filed under, so ranking by it there is deliberately left out. The Date column follows whichever is active, and its header shows "Last act" under the latter |
| `f` or `/` | Live filter — fuzzy (fzf-style) over sessions (title/project/id/**note**) and projects; model lists (`P`, `w`) match word-anchored (letters may scatter inside a word, a new word only joins at its first letter — `opus48` works, `opus` no longer drags in `qwen3-c`**`o`**`der-`**`p`**`l`**`us`**), routes by substring. Non-ASCII (`ä`, `界`) can be typed. While filtering: `↑`/`↓` select · `Enter` keep · `Esc` cancel · `Ctrl-U` clear |
| `x` | Clear the filter |

Expand Down
6 changes: 6 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ trends. What each tool's records support on top:
per tool call and MCP server · ¹ headerless: the OTEL export captures no prompt text ·
² with the optional `tool` column.</sub>

Every harness also derives when a session was last active (not just when it started),
including activity from its subagent subtree where tracked. This timestamp
(`ended_at`) feeds the sessions list's **Last Activity** sort (`s`) — the alternative
to sorting by when a session started, offered everywhere except the Time overview's
Days pane (see [`docs/keys.md`](keys.md#scope--filter)).

### Token-only harnesses

The whole TUI works the same everywhere — with two differences for the token-only
Expand Down
21 changes: 15 additions & 6 deletions src/opentab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,16 @@ class Workflow:
# with the exporting machine's label so the consolidated view can tag/group by box.
# A second, orthogonal dimension to `source` (a session has both a tool and a host).
machine: str = ""
# When the session's LAST recorded activity happened, same local
# "YYYY-MM-DD HH:MM:SS" string as created_at. Each backend fills it from data it
# already reads (a time_updated column, the max event timestamp of the parse it
# runs anyway) -- never a new scan. Empty = the backend can't know (an old
# --export, a schema without the column). Purely for the "(until 16:42)" hint on
# the detail line; the headline duration is worked_seconds, not this span.
# When the session's LAST recorded activity happened (root plus its whole subagent
# subtree), same local "YYYY-MM-DD HH:MM:SS" string as created_at. Each backend
# fills it from data it already reads (a time_updated column, the max event
# timestamp of the parse it runs anyway) -- never a new scan. Empty = the backend
# can't know (an old --export, a schema without the column), in which case the
# "last_activity" sort falls back to created_at. Feeds the "(until 16:42)" hint on
# the detail line and the sessions list's "last_activity" sort option alike -- one
# timestamp, both readers. Distinct from ProjectSummary/MachineSummary.last_active,
# which is the most recent *session's* created_at across a group of sessions, not
# activity inside a single one.
ended_at: str = ""
# How long the agent ACTUALLY worked, in seconds -- the sum of its working bursts
# with the idle gaps (you reading/composing the next prompt) removed, computed at
Expand Down Expand Up @@ -99,6 +103,11 @@ class ProjectSummary:
subagents: int
unpriced_tokens: int
last_active: str = "" # created_at of the project's most recent session
# Most recent activity across every session in the project (ended_at-or-created_at
# per session, then maxed) -- distinct from last_active, which is the newest
# session's own START. A project whose newest session is still short but whose
# OLDER session ran a long subagent tail can rank higher here than by last_active.
last_activity: str = ""
ignored: bool = False


Expand Down
57 changes: 51 additions & 6 deletions src/opentab/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,25 @@ class App:
# its sessions, its model mix, and which projects ran on it. "Harnesses" is injected
# after Overview by current_tabs like every other scope (the fleet is always combined).
machine_tabs = ("Overview", "Sessions", "Models", "Projects")
sort_options = ("cost", "tokens", "date", "duration", "subagents", "project", "title")
project_sort_options = ("cost", "tokens", "sessions", "subagents", "project", "recency")
sort_options = (
"cost",
"tokens",
"date",
"last_activity",
"duration",
"subagents",
"project",
"title",
)
project_sort_options = (
"cost",
"tokens",
"sessions",
"subagents",
"project",
"recency",
"last_activity",
)
subagent_sort_options = ("cost", "tokens", "date", "title", "model", "agent", "depth")
# The P overlay's price table sorts by model name, the blended eff column, your
# usage share, or any of the four list-price columns. "eff" is the default and
Expand Down Expand Up @@ -886,6 +903,7 @@ def projects_for_workflows(
subagents=sum(w.subagents for w in workflows),
unpriced_tokens=sum(w.unpriced_tokens for w in workflows),
last_active=max(w.created_at for w in workflows),
last_activity=max((w.ended_at or w.created_at) for w in workflows),
ignored=directory in self.ignored_projects,
)
for directory, workflows in grouped.items()
Expand Down Expand Up @@ -914,6 +932,8 @@ def sorted_projects(self, rows: list[ProjectSummary]) -> list[ProjectSummary]:
return sorted(rows, key=lambda p: p.directory.lower(), reverse=desc)
if sort_by == "recency":
return sorted(rows, key=lambda p: p.last_active, reverse=desc)
if sort_by == "last_activity":
return sorted(rows, key=lambda p: p.last_activity, reverse=desc)
return sorted(rows, key=lambda p: (p.cost, p.tokens), reverse=desc)

# --- Machines mode (one row per box; a fleet adds the pulled ones) -------
Expand Down Expand Up @@ -3960,7 +3980,7 @@ def handle_source_menu_key(self, key: int | str) -> bool:

def sorted_workflows(self, rows: list[Workflow]) -> list[Workflow]:
sort_by = self.session_sort_key()
desc = self.sort_descending(sort_by, self.sort_reverse)
desc = self.sort_descending(sort_by, self.session_sort_reverse())
if sort_by == "cost":
return sorted(rows, key=lambda item: (item.total_cost, item.total_tokens), reverse=desc)
if sort_by == "tokens":
Expand Down Expand Up @@ -3988,6 +4008,12 @@ def sorted_workflows(self, rows: list[Workflow]) -> list[Workflow]:
return sorted(
by_cost, key=lambda item: self.project_root(item.directory).lower(), reverse=desc
)
if sort_by == "last_activity":
return sorted(
rows,
key=lambda item: (item.ended_at or item.created_at, item.created_at),
reverse=desc,
)
return sorted(rows, key=lambda item: item.created_at, reverse=desc)

def zoom_scope_workflows(self, include_ignored: bool = False) -> list[Workflow]:
Expand Down Expand Up @@ -4200,12 +4226,31 @@ def in_subagent_sort_context(self) -> bool:
# The session view's Subagents tab; its own sort pair, like project lists.
return self.view == "session" and self.on_subagents_tab

def active_session_sort_options(self) -> tuple[str, ...]:
# "last_activity" is a Months/Years feature, per spec, deliberately not Days:
# a single Day's Sessions list is read by start time, and an activity can run
# into a LATER day than the one the row is filed under -- ranking the list by
# a timestamp that can point outside its own scope would be more confusing
# than useful there, even though the values themselves are perfectly valid.
if self.browse_mode == "time" and self.focus == "days":
return tuple(k for k in self.sort_options if k != "last_activity")
return self.sort_options

# The three lists' active sort keys, each validated against its own vocabulary.
# Headers and sorters read these directly (never each other's, and never the
# context-dependent effective_sort_by) so that when a project list and a session
# list share the screen neither borrows the other's sort arrow.
def session_sort_key(self) -> str:
return self.sort_by if self.sort_by in self.sort_options else self.sort_options[0]
options = self.active_session_sort_options()
return self.sort_by if self.sort_by in options else options[0]

def session_sort_reverse(self) -> bool:
# The direction belongs to the stored column; falling back to a different
# EFFECTIVE key (active_session_sort_options() dropped "last_activity" while
# the Days pane is focused) must not carry that column's own reversed flag
# onto the fallback column's natural order -- e.g. a direction flip saved
# for "last_activity" must not silently sort Cost ascending on the Days pane.
return self.sort_reverse if self.sort_by in self.active_session_sort_options() else False

def project_sort_key(self) -> str:
return (
Expand Down Expand Up @@ -4349,7 +4394,7 @@ def apply_header_sort(self, key: str, target: str) -> None:
self.project_sort_reverse = False
self.project_index = 0
else:
if key not in self.sort_options:
if key not in self.active_session_sort_options():
return
if self.sort_by == key:
self.sort_reverse = not self.sort_reverse
Expand Down Expand Up @@ -6928,7 +6973,7 @@ def current_sort_options(self) -> tuple[str, ...]:
if self.view == "session" and self.on_subagents_tab:
return self.subagent_sort_options
if self.view != "session" and self.on_sessions_tab:
return self.sort_options
return self.active_session_sort_options()
return ()

def workflows_for_day(self, day: str, source: list[Workflow] | None = None) -> list[Workflow]:
Expand Down
33 changes: 28 additions & 5 deletions src/opentab/tui/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@ def draw_keybar(self, stdscr: curses.window, y: int, width: int, parts) -> None:
def sort_heading(self, key: str, label: str) -> str:
if self.session_sort_key() != key:
return label
desc = self.sort_descending(key, self.sort_reverse)
desc = self.sort_descending(key, self.session_sort_reverse())
return f"{label} {'v' if desc else '^'}"

def project_sort_heading(self, key: str, label: str) -> str:
Expand Down Expand Up @@ -972,6 +972,28 @@ def session_started(self, workflow: Workflow) -> str:
def session_date_label(self) -> str:
return "Started" if self._scope_spans_days() else "Time"

def session_date_column(self) -> tuple[str, str]:
# The Date column's (sort key, header label) pair -- swaps to the activity
# timestamp under a "last_activity" sort so the visible order is legible: the
# column you're sorted by is the one shown, not always the session's start.
# "Last act" (not "Last act.") is deliberate: the header field is `:<10` and
# sort_heading() always appends a " v"/" ^" arrow, so anything over 8 chars
# overflows the column and shifts every header after it -- "Started"/"Time"
# both clear it too, just with room to spare.
if self.session_sort_key() == "last_activity":
return ("last_activity", "Last act")
return ("date", self.session_date_label())

def session_date_cell(self, workflow: Workflow) -> str:
if self.session_sort_key() != "last_activity":
return self.session_started(workflow)
# Always a date, never a bare clock time: App.active_session_sort_options()
# makes "last_activity" unreachable in a single-day scope (browse_mode=="time"
# and focus=="days" -- the one case _scope_spans_days() is False), so a session
# sorted by activity is always shown across a scope wide enough that a clock
# time alone would be ambiguous anyway.
return (workflow.ended_at or workflow.created_at)[:10]

def _mark_session_header(self, lines: list[str], columns: tuple) -> None:
# The just-appended line is a session-list column header (browse preview);
# record it in _line_sort_headers so the paint loop makes it click-sortable
Expand Down Expand Up @@ -1102,7 +1124,7 @@ def session_columns(self, sessions: list[Workflow], width: int) -> tuple[bool, i
return False, 0, False

def session_header_text(self, models: bool, proj_w: int, dur: bool = True) -> str:
header = f" {self.sort_heading('date', self.session_date_label()):<10} "
header = f" {self.sort_heading(*self.session_date_column()):<10} "
if dur:
header += f"{self.sort_heading('duration', 'Worked'):>8} "
header += (
Expand Down Expand Up @@ -1142,7 +1164,7 @@ def session_duration(self, workflow: Workflow) -> str:
def session_row_text(
self, workflow: Workflow, marker: str, models: bool, proj_w: int, dur: bool = True
) -> str:
text = f"{marker} {self.session_started(workflow):<10} "
text = f"{marker} {self.session_date_cell(workflow):<10} "
if dur:
text += f"{self.session_duration(workflow):>8} "
text += (
Expand All @@ -1163,7 +1185,7 @@ def session_row_text(

def session_sort_columns(self, proj_w: int, dur: bool = True) -> tuple:
# (sort_key, label) in drawn order, for the clickable headers of both frames.
columns = [("date", self.session_date_label()), *self.SESSION_SORT_COLUMNS]
columns = [self.session_date_column(), *self.SESSION_SORT_COLUMNS]
if dur:
columns.insert(1, ("duration", "Worked")) # right after the date cell
if proj_w:
Expand Down Expand Up @@ -5167,7 +5189,8 @@ def draw_theme_menu(self, stdscr: curses.window, scr_h: int, scr_w: int) -> None
SORT_LABELS = {
"cost": "Cost",
"tokens": "Tokens",
"date": "Date",
"date": "Start Date",
"last_activity": "Last Activity",
"duration": "Worked",
"recency": "Recency",
"subagents": "Subagents",
Expand Down
5 changes: 4 additions & 1 deletion tests/_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import opentab as ot


def workflow(id, created_at, title=None, cost=1.0, tokens=100, directory="/tmp/project"):
def workflow(
id, created_at, title=None, cost=1.0, tokens=100, directory="/tmp/project", ended_at=""
):
return ot.Workflow(
id=id,
title=title or id,
Expand All @@ -19,6 +21,7 @@ def workflow(id, created_at, title=None, cost=1.0, tokens=100, directory="/tmp/p
model_count=1,
total_tokens=tokens,
unpriced_tokens=0,
ended_at=ended_at,
)


Expand Down
27 changes: 27 additions & 0 deletions tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,33 @@ def test_prices_sort_is_persisted_in_state():
assert restored.prices_sort == "cache_write" and restored.prices_sort_reverse


def test_last_activity_sort_is_persisted_in_state():
# No dedicated save/restore code exists for this -- sort_by/project_sort_by are
# already generic (state.py validates against app.sort_options/
# project_sort_options), so "last_activity" persists for free once it's part of
# those tuples. This locks that in.
app = app_with([workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")])
app.sort_by = "last_activity"
app.project_sort_by = "last_activity"
old_xdg = os.environ.get("XDG_STATE_HOME")
with tempfile.TemporaryDirectory() as tmp:
os.environ["XDG_STATE_HOME"] = tmp
try:
ot.save_state(app)
restored = app_with(
[workflow("a", "2026-06-01 12:00:00", ended_at="2026-06-05 09:00:00")]
)
assert restored.sort_by == "cost" and restored.project_sort_by == "cost"
ot.apply_state(restored, restored.args, ot.load_state())
finally:
if old_xdg is None:
os.environ.pop("XDG_STATE_HOME", None)
else:
os.environ["XDG_STATE_HOME"] = old_xdg
assert restored.sort_by == "last_activity"
assert restored.project_sort_by == "last_activity"


def test_machines_browse_mode_is_restored_fleet_or_not():
from tests._support import fleet_app

Expand Down
Loading
Loading