diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 7efa75e2..5c855d3d 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -226,7 +226,7 @@ pi --fork # Fork specific session file or ID into a new session - Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→ - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all -- Press `l` to label entries as bookmarks +- Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps **`/fork`** - Create a new session file from the current branch. Opens a selector, copies history up to the selected point, and places that message in the editor for modification. diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index bbf01bd8..148789a9 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -128,6 +128,8 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 |--------|---------|-------------| | `app.tree.foldOrUp` | `ctrl+left`, `alt+left` | Fold current branch segment, or jump to the previous segment start | | `app.tree.unfoldOrDown` | `ctrl+right`, `alt+right` | Unfold current branch segment, or jump to the next segment start or branch end | +| `app.tree.editLabel` | `shift+l` | Edit the label on the selected tree node | +| `app.tree.toggleLabelTimestamp` | `shift+t` | Toggle label timestamps in the tree | ## Custom Configuration diff --git a/packages/coding-agent/docs/tree.md b/packages/coding-agent/docs/tree.md index cfc4d6ed..f22b40ce 100644 --- a/packages/coding-agent/docs/tree.md +++ b/packages/coding-agent/docs/tree.md @@ -35,6 +35,8 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l | ↑/↓ | Navigate (depth-first order) | | ←/→ | Page up/down | | Ctrl+←/Ctrl+→ or Alt+←/Alt+→ | Fold/unfold and jump between branch segments | +| Shift+L | Set or clear a label on the selected node | +| Shift+T | Toggle label timestamps | | Enter | Select node | | Escape/Ctrl+C | Cancel | | Ctrl+U | Toggle: user messages only | @@ -49,6 +51,7 @@ Sessions are stored as trees where each entry has an `id` and `parentId`. The "l - Height: half terminal height - Current leaf marked with `← active` - Labels shown inline: `[label-name]` +- `Shift+T` shows the latest label-change timestamp next to labeled nodes - Foldable branch starts show `⊟` in the connector. Folded branches show `⊞` - Active path marker `•` appears after the fold indicator when applicable - Search and filter changes reset all folds diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 32343d7d..3baa7290 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -32,6 +32,8 @@ export interface AppKeybindings { "app.session.resume": true; "app.tree.foldOrUp": true; "app.tree.unfoldOrDown": true; + "app.tree.editLabel": true; + "app.tree.toggleLabelTimestamp": true; "app.session.togglePath": true; "app.session.toggleSort": true; "app.session.rename": true; @@ -101,6 +103,14 @@ export const KEYBINDINGS = { defaultKeys: ["ctrl+right", "alt+right"], description: "Unfold tree branch or move down", }, + "app.tree.editLabel": { + defaultKeys: "shift+l", + description: "Edit tree label", + }, + "app.tree.toggleLabelTimestamp": { + defaultKeys: "shift+t", + description: "Toggle tree label timestamps", + }, "app.session.togglePath": { defaultKeys: "ctrl+p", description: "Toggle session path display", @@ -176,6 +186,8 @@ const KEYBINDING_NAME_MIGRATIONS = { resume: "app.session.resume", treeFoldOrUp: "app.tree.foldOrUp", treeUnfoldOrDown: "app.tree.unfoldOrDown", + treeEditLabel: "app.tree.editLabel", + treeToggleLabelTimestamp: "app.tree.toggleLabelTimestamp", toggleSessionPath: "app.session.togglePath", toggleSessionSort: "app.session.toggleSort", renameSession: "app.session.rename", diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 1f908e46..87890003 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -154,6 +154,8 @@ export interface SessionTreeNode { children: SessionTreeNode[]; /** Resolved label for this entry, if any */ label?: string; + /** Timestamp of the latest label change for this entry, if any */ + labelTimestamp?: string; } export interface SessionContext { @@ -669,6 +671,7 @@ export class SessionManager { private fileEntries: FileEntry[] = []; private byId: Map = new Map(); private labelsById: Map = new Map(); + private labelTimestampsById: Map = new Map(); private leafId: string | null = null; private constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) { @@ -746,6 +749,7 @@ export class SessionManager { private _buildIndex(): void { this.byId.clear(); this.labelsById.clear(); + this.labelTimestampsById.clear(); this.leafId = null; for (const entry of this.fileEntries) { if (entry.type === "session") continue; @@ -754,8 +758,10 @@ export class SessionManager { if (entry.type === "label") { if (entry.label) { this.labelsById.set(entry.targetId, entry.label); + this.labelTimestampsById.set(entry.targetId, entry.timestamp); } else { this.labelsById.delete(entry.targetId); + this.labelTimestampsById.delete(entry.targetId); } } } @@ -1007,8 +1013,10 @@ export class SessionManager { this._appendEntry(entry); if (label) { this.labelsById.set(targetId, label); + this.labelTimestampsById.set(targetId, entry.timestamp); } else { this.labelsById.delete(targetId); + this.labelTimestampsById.delete(targetId); } return entry.id; } @@ -1067,7 +1075,8 @@ export class SessionManager { // Create nodes with resolved labels for (const entry of entries) { const label = this.labelsById.get(entry.id); - nodeMap.set(entry.id, { entry, children: [], label }); + const labelTimestamp = this.labelTimestampsById.get(entry.id); + nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp }); } // Build tree @@ -1179,10 +1188,10 @@ export class SessionManager { // Collect labels for entries in the path const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); - const labelsToWrite: Array<{ targetId: string; label: string }> = []; + const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = []; for (const [targetId, label] of this.labelsById) { if (pathEntryIds.has(targetId)) { - labelsToWrite.push({ targetId, label }); + labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! }); } } @@ -1191,12 +1200,12 @@ export class SessionManager { const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; let parentId = lastEntryId; const labelEntries: LabelEntry[] = []; - for (const { targetId, label } of labelsToWrite) { + for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", id: generateId(new Set(pathEntryIds)), parentId, - timestamp: new Date().toISOString(), + timestamp: labelTimestamp, targetId, label, }; @@ -1229,12 +1238,12 @@ export class SessionManager { // In-memory mode: replace current session with the path + labels const labelEntries: LabelEntry[] = []; let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; - for (const { targetId, label } of labelsToWrite) { + for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), parentId, - timestamp: new Date().toISOString(), + timestamp: labelTimestamp, targetId, label, }; diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index c21c97fc..e4626a26 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -13,7 +13,7 @@ import { import type { SessionTreeNode } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -import { keyHint } from "./keybinding-hints.js"; +import { keyHint, keyText } from "./keybinding-hints.js"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { @@ -58,6 +58,7 @@ class TreeList implements Component { private searchQuery = ""; private toolCallMap: Map = new Map(); private multipleRoots = false; + private showLabelTimestamps = false; private activePathIds: Set = new Set(); private visibleParentMap: Map = new Map(); private visibleChildrenMap: Map = new Map(); @@ -567,28 +568,36 @@ class TreeList implements Component { return this.filteredNodes[this.selectedIndex]?.node; } - updateNodeLabel(entryId: string, label: string | undefined): void { + updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void { for (const flatNode of this.flatNodes) { if (flatNode.node.entry.id === entryId) { flatNode.node.label = label; + flatNode.node.labelTimestamp = label ? (labelTimestamp ?? new Date().toISOString()) : undefined; break; } } } - private getFilterLabel(): string { + private getStatusLabels(): string { + let labels = ""; switch (this.filterMode) { case "no-tools": - return " [no-tools]"; + labels += " [no-tools]"; + break; case "user-only": - return " [user]"; + labels += " [user]"; + break; case "labeled-only": - return " [labeled]"; + labels += " [labeled]"; + break; case "all": - return " [all]"; - default: - return ""; + labels += " [all]"; + break; } + if (this.showLabelTimestamps) { + labels += " [+label time]"; + } + return labels; } render(width: number): string[] { @@ -596,7 +605,7 @@ class TreeList implements Component { if (this.filteredNodes.length === 0) { lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width)); - lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getFilterLabel()}`), width)); + lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getStatusLabels()}`), width)); return lines; } @@ -667,9 +676,13 @@ class TreeList implements Component { const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : ""; const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; + const labelTimestamp = + this.showLabelTimestamps && flatNode.node.label && flatNode.node.labelTimestamp + ? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `) + : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content; + let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content; if (isSelected) { line = theme.bg("selectedBg", line); } @@ -678,7 +691,7 @@ class TreeList implements Component { lines.push( truncateToWidth( - theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`), + theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), width, ), ); @@ -772,6 +785,31 @@ class TreeList implements Component { return isSelected ? theme.bold(result) : result; } + private formatLabelTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const time = `${hours}:${minutes}`; + + if ( + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + ) { + return time; + } + + const month = date.getMonth() + 1; + const day = date.getDate(); + if (date.getFullYear() === now.getFullYear()) { + return `${month}/${day} ${time}`; + } + + const year = date.getFullYear().toString().slice(-2); + return `${year}/${month}/${day} ${time}`; + } + private extractContent(content: unknown): string { const maxLen = 200; if (typeof content === "string") return content.slice(0, maxLen); @@ -945,11 +983,13 @@ class TreeList implements Component { this.foldedNodes.clear(); this.applyFilter(); } - } else if (matchesKey(keyData, "shift+l")) { + } else if (kb.matches(keyData, "app.tree.editLabel")) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onLabelEdit) { this.onLabelEdit(selected.node.entry.id, selected.node.label); } + } else if (kb.matches(keyData, "app.tree.toggleLabelTimestamp")) { + this.showLabelTimestamps = !this.showLabelTimestamps; } else { const hasControlChars = [...keyData].some((ch) => { const code = ch.charCodeAt(0); @@ -1140,8 +1180,10 @@ export class TreeSelectorComponent extends Container implements Focusable { this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild( new TruncatedText( - theme.fg("muted", " ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. Shift+L: label. ") + - theme.fg("muted", "^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)"), + theme.fg( + "muted", + ` ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. ${keyText("app.tree.editLabel")}: label. ^D/^T/^U/^L/^A: filters (^O/⇧^O cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`, + ), 0, 0, ), diff --git a/packages/coding-agent/test/session-manager/labels.test.ts b/packages/coding-agent/test/session-manager/labels.test.ts index e349aa10..e703abf8 100644 --- a/packages/coding-agent/test/session-manager/labels.test.ts +++ b/packages/coding-agent/test/session-manager/labels.test.ts @@ -43,9 +43,15 @@ describe("SessionManager labels", () => { session.appendLabelChange(msgId, "first"); session.appendLabelChange(msgId, "second"); - session.appendLabelChange(msgId, "third"); + const lastLabelId = session.appendLabelChange(msgId, "third"); expect(session.getLabel(msgId)).toBe("third"); + + const entries = session.getEntries(); + const lastLabelEntry = entries.find((e) => e.id === lastLabelId) as LabelEntry; + const tree = session.getTree(); + const msgNode = tree.find((n) => n.entry.id === msgId); + expect(msgNode?.labelTimestamp).toBe(lastLabelEntry.timestamp); }); it("labels are included in tree nodes", () => { @@ -70,18 +76,23 @@ describe("SessionManager labels", () => { timestamp: 2, }); - session.appendLabelChange(msg1Id, "start"); - session.appendLabelChange(msg2Id, "response"); + const msg1LabelId = session.appendLabelChange(msg1Id, "start"); + const msg2LabelId = session.appendLabelChange(msg2Id, "response"); + const entries = session.getEntries(); + const msg1LabelEntry = entries.find((e) => e.id === msg1LabelId) as LabelEntry; + const msg2LabelEntry = entries.find((e) => e.id === msg2LabelId) as LabelEntry; const tree = session.getTree(); // Find the message nodes (skip label entries) const msg1Node = tree.find((n) => n.entry.id === msg1Id); expect(msg1Node?.label).toBe("start"); + expect(msg1Node?.labelTimestamp).toBe(msg1LabelEntry.timestamp); // msg2 is a child of msg1 const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id); expect(msg2Node?.label).toBe("response"); + expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp); }); it("labels are preserved in createBranchedSession", () => { @@ -106,8 +117,11 @@ describe("SessionManager labels", () => { timestamp: 2, }); - session.appendLabelChange(msg1Id, "important"); - session.appendLabelChange(msg2Id, "also-important"); + const msg1LabelId = session.appendLabelChange(msg1Id, "important"); + const msg2LabelId = session.appendLabelChange(msg2Id, "also-important"); + const originalEntries = session.getEntries(); + const msg1LabelEntry = originalEntries.find((e) => e.id === msg1LabelId) as LabelEntry; + const msg2LabelEntry = originalEntries.find((e) => e.id === msg2LabelId) as LabelEntry; // Branch from msg2 (in-memory mode returns null, but updates internal state) session.createBranchedSession(msg2Id); @@ -120,6 +134,12 @@ describe("SessionManager labels", () => { const entries = session.getEntries(); const labelEntries = entries.filter((e) => e.type === "label") as LabelEntry[]; expect(labelEntries).toHaveLength(2); + + const tree = session.getTree(); + const msg1Node = tree.find((n) => n.entry.id === msg1Id); + const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id); + expect(msg1Node?.labelTimestamp).toBe(msg1LabelEntry.timestamp); + expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp); }); it("labels not on path are not preserved in createBranchedSession", () => { diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 81adb314..0cb2f51b 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -248,6 +248,36 @@ describe("TreeSelectorComponent", () => { }); }); + describe("label timestamps", () => { + test("toggles label timestamps for labeled nodes", () => { + const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")]; + const tree = buildTree(entries); + const labelDate = new Date(2026, 2, 28, 14, 32, 0); + tree[0]!.label = "checkpoint"; + tree[0]!.labelTimestamp = labelDate.toISOString(); + + const selector = new TreeSelectorComponent( + tree, + "asst-1", + 24, + () => {}, + () => {}, + ); + + const list = selector.getTreeList(); + let render = list.render(200).join("\n"); + expect(render).toContain("[checkpoint]"); + expect(render).not.toContain("3/28 14:32"); + expect(render).not.toContain("[+label time]"); + + selector.handleInput("T"); + + render = list.render(200).join("\n"); + expect(render).toContain("3/28 14:32"); + expect(render).toContain("[+label time]"); + }); + }); + describe("empty filter preservation", () => { test("preserves selection when switching to empty labeled filter and back", () => { // Tree with no labels