ompchamber 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -5
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
- package/dist/client/static/js/index-n4wkqj7t.css +1 -0
- package/package.json +1 -1
- package/src/client/components/common/code-editor/handler.test.ts +274 -0
- package/src/client/components/common/code-editor/handler.ts +175 -0
- package/src/client/components/common/code-editor/index.tsx +185 -220
- package/src/client/components/common/code-surface/index.tsx +109 -12
- package/src/client/components/common/lazy-panels.tsx +5 -1
- package/src/client/components/common/viewport.ts +5 -3
- package/src/client/components/layout/RightActivityBar.tsx +2 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
- package/src/client/components/layout/desktop-layout/index.tsx +24 -4
- package/src/client/components/mobile/RightSidebar.tsx +11 -4
- package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
- package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
- package/src/client/components/workspace/browser-panel/index.tsx +6 -6
- package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
- package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
- package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
- package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
- package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
- package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
- package/src/client/components/workspace/editor/index.tsx +153 -53
- package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
- package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
- package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
- package/src/client/components/workspace/todo-panel/index.tsx +161 -0
- package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
- package/src/client/hooks/editor/scroll-container.test.ts +102 -0
- package/src/client/hooks/editor/scroll-container.ts +26 -0
- package/src/client/hooks/editor/use-code-window.ts +2 -20
- package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
- package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
- package/src/client/hooks/editor/use-editor-find.ts +321 -0
- package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
- package/src/client/hooks/editor/use-editor-history.ts +134 -0
- package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
- package/src/client/hooks/editor/use-occurrences.ts +98 -0
- package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
- package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
- package/src/client/hooks/workspace/session-todos.ts +102 -0
- package/src/client/tailwind.css +30 -0
- package/src/server/lib/omp/session/todos.ts +100 -0
- package/src/server/routes/fs/action-create.test.ts +90 -0
- package/src/server/routes/fs/action.ts +23 -0
- package/src/server/routes/omp/index.ts +2 -0
- package/src/server/routes/omp/session-todos.ts +80 -0
- package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
- package/src/shared/lib/chat/todo/progress.ts +97 -0
- package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
- package/src/shared/lib/chat/todo/snapshot.ts +215 -0
- package/src/shared/lib/code/editor/commands.test.ts +155 -0
- package/src/shared/lib/code/editor/commands.ts +260 -0
- package/src/shared/lib/code/editor/edits.test.ts +100 -0
- package/src/shared/lib/code/editor/edits.ts +122 -0
- package/src/shared/lib/code/editor/find-status.test.ts +50 -0
- package/src/shared/lib/code/editor/find-status.ts +36 -0
- package/src/shared/lib/code/editor/find.test.ts +203 -0
- package/src/shared/lib/code/editor/find.ts +224 -0
- package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
- package/src/shared/lib/code/editor/keymap.test.ts +109 -0
- package/src/shared/lib/code/editor/keymap.ts +151 -0
- package/src/shared/lib/code/editor/layers.test.ts +77 -0
- package/src/shared/lib/code/editor/layers.ts +67 -0
- package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
- package/src/shared/lib/code/editor/line-edits.ts +159 -0
- package/src/shared/lib/code/editor/lines.test.ts +81 -0
- package/src/shared/lib/code/editor/lines.ts +83 -0
- package/src/shared/lib/code/editor/marks.test.ts +86 -0
- package/src/shared/lib/code/editor/marks.ts +106 -0
- package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
- package/src/shared/lib/code/editor/occurrences.ts +111 -0
- package/src/shared/lib/code/editor/palette.test.ts +91 -0
- package/src/shared/lib/code/editor/palette.ts +92 -0
- package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
- package/src/shared/lib/code/editor/reset-css.ts +32 -0
- package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
- package/src/shared/lib/code/editor/text-commands.ts +154 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
- package/src/shared/lib/code/syntax-highlight.ts +29 -17
- package/src/shared/lib/code/windowed-highlight.ts +52 -1
- package/src/shared/lib/ui/key-binding.test.ts +115 -0
- package/src/shared/lib/ui/key-binding.ts +143 -0
- package/src/shared/lib/util/platform.test.ts +87 -0
- package/src/shared/lib/util/platform.ts +30 -0
- package/src/shared/lib/workspace/keymap.test.ts +82 -0
- package/src/shared/lib/workspace/keymap.ts +30 -0
- package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
- package/src/shared/lib/workspace/right-panels.ts +8 -0
- package/src/shared/lib/workspace/session-state/store.ts +0 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/todo.ts +108 -0
- package/dist/client/static/js/index-ranztes9.css +0 -1
- /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /api/omp/session-todos?sessionId=<uuid> — the session's live todo list.
|
|
8
|
+
*
|
|
9
|
+
* Read-only projection of oh-my-pi's own persisted `todo` state: the deepest
|
|
10
|
+
* committed snapshot on the session transcript's active branch. omp is the
|
|
11
|
+
* owner of this list and there is no second copy — see
|
|
12
|
+
* `@/server/lib/omp/session/todos`.
|
|
13
|
+
*
|
|
14
|
+
* MOCK mode answers a deterministic demo list so the right-panel view renders
|
|
15
|
+
* its full range of states (in progress, blocked, completed, abandoned) without
|
|
16
|
+
* an omp install.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
|
|
20
|
+
import type { LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
21
|
+
import { isMockMode } from '@/server/mock.server';
|
|
22
|
+
import { readSessionTodos } from '@/server/lib/omp/session/todos';
|
|
23
|
+
import { todoProgress } from '@/shared/lib/chat/todo/snapshot';
|
|
24
|
+
import type { SessionTodosPayload, TodoPhase } from '@/shared/types/todo';
|
|
25
|
+
|
|
26
|
+
/** Demo list under MOCK=true: one phase of every status the panel must draw. */
|
|
27
|
+
const MOCK_PHASES: TodoPhase[] = [
|
|
28
|
+
{
|
|
29
|
+
name: 'Server data',
|
|
30
|
+
tasks: [
|
|
31
|
+
{ content: 'Read the session transcript for the deepest todo snapshot', status: 'completed' },
|
|
32
|
+
{ content: 'Serve the list over /api/omp/session-todos', status: 'in_progress' },
|
|
33
|
+
{ content: 'Cache the parse by size + mtime', status: 'pending' },
|
|
34
|
+
{ content: 'Wire the panel poll to omp:session-updated', status: 'blocked', blocker: 'waiting on the event name' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'Panel',
|
|
39
|
+
tasks: [
|
|
40
|
+
{ content: 'Draw phase groups with per-status glyphs', status: 'pending' },
|
|
41
|
+
{ content: 'Collapse closed tasks behind a toggle', status: 'abandoned' },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export async function loader({ request }: LoaderFunctionArgs) {
|
|
47
|
+
const url = new URL(request.url);
|
|
48
|
+
const sessionId = url.searchParams.get('sessionId');
|
|
49
|
+
|
|
50
|
+
if (isMockMode()) {
|
|
51
|
+
return json(
|
|
52
|
+
{
|
|
53
|
+
sessionId: sessionId ?? 'mock',
|
|
54
|
+
snapshot: {
|
|
55
|
+
phases: MOCK_PHASES,
|
|
56
|
+
sourceEntryId: 'mock-entry',
|
|
57
|
+
source: 'toolResult',
|
|
58
|
+
updatedAt: new Date().toISOString(),
|
|
59
|
+
op: 'start',
|
|
60
|
+
storage: 'session',
|
|
61
|
+
},
|
|
62
|
+
progress: todoProgress(MOCK_PHASES),
|
|
63
|
+
generatedAt: new Date().toISOString(),
|
|
64
|
+
isMock: true,
|
|
65
|
+
} satisfies SessionTodosPayload,
|
|
66
|
+
{ headers: NO_STORE_HEADERS },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!sessionId) return json({ error: 'sessionId is required' }, { status: 400 });
|
|
71
|
+
|
|
72
|
+
const state = await readSessionTodos(sessionId);
|
|
73
|
+
const payload: SessionTodosPayload = {
|
|
74
|
+
sessionId,
|
|
75
|
+
...state,
|
|
76
|
+
generatedAt: new Date().toISOString(),
|
|
77
|
+
isMock: false,
|
|
78
|
+
};
|
|
79
|
+
return json(payload, { headers: NO_STORE_HEADERS });
|
|
80
|
+
}
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renderer-side parser for one `todo` tool call: reads the human-readable
|
|
8
|
+
* checklist omp returns as the tool's text output (and the `list` argument as a
|
|
9
|
+
* fallback) into phase groups for the chat timeline's tool card.
|
|
10
|
+
*
|
|
11
|
+
* This is NOT the authoritative list — a tool card shows what that ONE call
|
|
12
|
+
* did, while the session's live list is the deepest committed snapshot on the
|
|
13
|
+
* active branch (`./snapshot.ts`, surfaced by the right-panel Todo view). The
|
|
14
|
+
* two must not be merged: a card that displayed the session-wide list would
|
|
15
|
+
* attribute every other call's work to this one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
1
18
|
import type { ToolCallData } from '@/shared/types';
|
|
2
19
|
|
|
3
20
|
export interface ParsedTask {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Display-side helpers for a todo list: the one-line progress summary, the
|
|
8
|
+
* per-task visual state, and the "which task is the agent on" lookup the panel
|
|
9
|
+
* highlights.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is presentational — the parse itself lives in `snapshot.ts`.
|
|
12
|
+
* The status → presentation mapping is a table rather than a chain of `if`s
|
|
13
|
+
* because there are five statuses and three consumers (icon, label, class
|
|
14
|
+
* list), and a missing case in any one of them is how a `blocked` task ends up
|
|
15
|
+
* rendered as a plain pending one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { TodoItem, TodoPhase, TodoProgress, TodoStatus } from '@/shared/types/todo';
|
|
19
|
+
|
|
20
|
+
export interface TodoStatusVisual {
|
|
21
|
+
/** Short label for the row's status pill. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** Tailwind text color for the icon and label. */
|
|
24
|
+
tone: string;
|
|
25
|
+
/** True when the row is a live (in-progress) task — highlighted, not dimmed. */
|
|
26
|
+
active: boolean;
|
|
27
|
+
/** True when the task is out of the way (completed / abandoned). */
|
|
28
|
+
closed: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STATUS_VISUALS: Record<TodoStatus, TodoStatusVisual> = {
|
|
32
|
+
pending: { label: 'Pending', tone: 'text-ink/45', active: false, closed: false },
|
|
33
|
+
in_progress: { label: 'In progress', tone: 'text-ink', active: true, closed: false },
|
|
34
|
+
completed: { label: 'Completed', tone: 'text-success', active: false, closed: true },
|
|
35
|
+
abandoned: { label: 'Abandoned', tone: 'text-error/70', active: false, closed: true },
|
|
36
|
+
blocked: { label: 'Blocked', tone: 'text-warning', active: false, closed: false },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function todoStatusVisual(status: TodoStatus): TodoStatusVisual {
|
|
40
|
+
return STATUS_VISUALS[status] ?? STATUS_VISUALS.pending;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One-line summary of a snapshot: `4/11 done · 1 in progress · 2 blocked`.
|
|
45
|
+
*
|
|
46
|
+
* Counts CLOSED (completed + abandoned) against the total, matching omp's own
|
|
47
|
+
* HUD counter — an abandoned task is deliberately not coming back, so leaving
|
|
48
|
+
* it in the denominator is what makes a list read as permanently stuck.
|
|
49
|
+
*/
|
|
50
|
+
export function todoProgressLabel(progress: TodoProgress): string {
|
|
51
|
+
if (progress.total === 0) return 'No tasks';
|
|
52
|
+
const parts = [`${progress.closed}/${progress.total} done`];
|
|
53
|
+
if (progress.inProgress > 0) parts.push(`${progress.inProgress} in progress`);
|
|
54
|
+
if (progress.blocked > 0) parts.push(`${progress.blocked} blocked`);
|
|
55
|
+
if (progress.abandoned > 0) parts.push(`${progress.abandoned} abandoned`);
|
|
56
|
+
return parts.join(' · ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Percentage of the list that is closed, rounded to a whole number. */
|
|
60
|
+
export function todoProgressPercent(progress: TodoProgress): number {
|
|
61
|
+
if (progress.total === 0) return 0;
|
|
62
|
+
return Math.round((progress.closed / progress.total) * 100);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The row the panel marks as current: the in-progress task, else the first
|
|
67
|
+
* pending one. Identified by phase index + task index rather than by content,
|
|
68
|
+
* because two phases may legitimately hold tasks with identical text (omp
|
|
69
|
+
* itself keys its own task lookup by content, and that ambiguity is its
|
|
70
|
+
* problem, not one to inherit here).
|
|
71
|
+
*/
|
|
72
|
+
export function currentTaskLocation(phases: readonly TodoPhase[]): { phase: number; task: number } | null {
|
|
73
|
+
for (let phase = 0; phase < phases.length; phase++) {
|
|
74
|
+
const tasks = phases[phase].tasks;
|
|
75
|
+
for (let task = 0; task < tasks.length; task++) {
|
|
76
|
+
if (tasks[task].status === 'in_progress') return { phase, task };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (let phase = 0; phase < phases.length; phase++) {
|
|
80
|
+
const tasks = phases[phase].tasks;
|
|
81
|
+
for (let task = 0; task < tasks.length; task++) {
|
|
82
|
+
if (tasks[task].status === 'pending') return { phase, task };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Tasks of one phase that are closed, for the phase header's `n/m` badge. */
|
|
89
|
+
export function closedTaskCount(phase: TodoPhase): number {
|
|
90
|
+
return phase.tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Blocker note of a task, trimmed, or undefined when there is nothing to show. */
|
|
94
|
+
export function taskNote(task: TodoItem): string | undefined {
|
|
95
|
+
const note = task.blocker?.trim() || task.details?.trim();
|
|
96
|
+
return note ? note : undefined;
|
|
97
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { latestTodoSnapshot, todoProgress } from '@/shared/lib/chat/todo/snapshot';
|
|
9
|
+
import type { TodoPhase, TodoStatus } from '@/shared/types/todo';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The reader has to match oh-my-pi's own branch rehydration, and every rule it
|
|
13
|
+
* implements was verified against a real session written by omp 18.3.0:
|
|
14
|
+
*
|
|
15
|
+
* - a `todo` toolResult carries `message.details.phases`;
|
|
16
|
+
* - `op:"view"` results carry phases but commit nothing (omp skips them);
|
|
17
|
+
* - a `user_todo_edit` custom entry carries `data.phases` and is durable;
|
|
18
|
+
* - the list is read off the ACTIVE BRANCH (leaf → root via `parentId`), so a
|
|
19
|
+
* rewound fork must not resurface.
|
|
20
|
+
*
|
|
21
|
+
* The failure mode of getting any of these wrong is a panel that quietly shows
|
|
22
|
+
* a stale plan, which no other surface would contradict.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const phases = (...tasks: Array<[string, TodoStatus]>): TodoPhase[] => [
|
|
26
|
+
{ name: 'Phase', tasks: tasks.map(([content, status]) => ({ content, status })) },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function entry(id: string, parentId: string | null, body: Record<string, unknown>) {
|
|
30
|
+
return { id, parentId, timestamp: '2026-09-25T00:00:00.000Z', ...body };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function todoResult(id: string, parentId: string | null, details: Record<string, unknown>) {
|
|
34
|
+
return entry(id, parentId, {
|
|
35
|
+
type: 'message',
|
|
36
|
+
message: { role: 'toolResult', toolName: 'todo', content: [{ type: 'text', text: '' }], details },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('latestTodoSnapshot', () => {
|
|
41
|
+
test('reads the deepest committed snapshot on the active branch', () => {
|
|
42
|
+
const entries = [
|
|
43
|
+
todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending'], ['two', 'pending']) }),
|
|
44
|
+
todoResult('a2', 'a1', { op: 'done', phases: phases(['one', 'completed'], ['two', 'in_progress']) }),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const snapshot = latestTodoSnapshot(entries);
|
|
48
|
+
|
|
49
|
+
expect(snapshot?.sourceEntryId).toBe('a2');
|
|
50
|
+
expect(snapshot?.source).toBe('toolResult');
|
|
51
|
+
expect(snapshot?.op).toBe('done');
|
|
52
|
+
expect(snapshot?.phases[0].tasks[0].status).toBe('completed');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('skips a `view` result — it reports the list without committing it', () => {
|
|
56
|
+
const entries = [
|
|
57
|
+
todoResult('a1', null, { op: 'init', phases: phases(['one', 'completed']) }),
|
|
58
|
+
// A view result can be arbitrarily stale (it is echoed, not committed).
|
|
59
|
+
todoResult('a2', 'a1', { op: 'view', phases: phases(['one', 'pending']) }),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
expect(latestTodoSnapshot(entries)?.sourceEntryId).toBe('a1');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('skips an errored result', () => {
|
|
66
|
+
const entries = [
|
|
67
|
+
todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending']) }),
|
|
68
|
+
{
|
|
69
|
+
...todoResult('a2', 'a1', { op: 'done', phases: phases(['one', 'completed']) }),
|
|
70
|
+
message: {
|
|
71
|
+
role: 'toolResult',
|
|
72
|
+
toolName: 'todo',
|
|
73
|
+
isError: true,
|
|
74
|
+
details: { op: 'done', phases: phases(['one', 'completed']) },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
expect(latestTodoSnapshot(entries)?.sourceEntryId).toBe('a1');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a `user_todo_edit` custom entry outranks an older tool result', () => {
|
|
83
|
+
const entries = [
|
|
84
|
+
todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending']) }),
|
|
85
|
+
entry('a2', 'a1', {
|
|
86
|
+
type: 'custom',
|
|
87
|
+
customType: 'user_todo_edit',
|
|
88
|
+
data: { phases: phases(['one', 'abandoned']) },
|
|
89
|
+
}),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const snapshot = latestTodoSnapshot(entries);
|
|
93
|
+
|
|
94
|
+
expect(snapshot?.source).toBe('custom');
|
|
95
|
+
expect(snapshot?.phases[0].tasks[0].status).toBe('abandoned');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a snapshot on an abandoned branch is ignored (rewind)', () => {
|
|
99
|
+
// a2 was rewound past: the leaf is b2, whose parent chain never reaches a2.
|
|
100
|
+
const entries = [
|
|
101
|
+
todoResult('a1', null, { op: 'init', phases: phases(['old', 'in_progress']) }),
|
|
102
|
+
todoResult('a2', 'a1', { op: 'done', phases: phases(['old', 'completed']) }),
|
|
103
|
+
todoResult('b1', 'a1', { op: 'append', phases: phases(['new', 'pending']) }),
|
|
104
|
+
todoResult('b2', 'b1', { op: 'start', phases: phases(['new', 'in_progress']) }),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const snapshot = latestTodoSnapshot(entries);
|
|
108
|
+
|
|
109
|
+
expect(snapshot?.sourceEntryId).toBe('b2');
|
|
110
|
+
expect(snapshot?.phases[0].tasks[0].content).toBe('new');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('returns null when the transcript holds no todo state', () => {
|
|
114
|
+
const entries = [
|
|
115
|
+
entry('a1', null, { type: 'message', message: { role: 'user', content: 'hello' } }),
|
|
116
|
+
todoResult('a2', 'a1', { op: 'view', phases: phases(['one', 'pending']) }),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
expect(latestTodoSnapshot(entries)).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('rejects a malformed snapshot rather than rendering a half list', () => {
|
|
123
|
+
const entries = [
|
|
124
|
+
todoResult('a1', null, { op: 'init', phases: [{ name: 'Phase', tasks: [{ content: 'x', status: 'nonsense' }] }] }),
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
expect(latestTodoSnapshot(entries)).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('carries storage and timestamp when the entry records them', () => {
|
|
131
|
+
const entries = [todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending']), storage: 'memory' })];
|
|
132
|
+
|
|
133
|
+
const snapshot = latestTodoSnapshot(entries);
|
|
134
|
+
|
|
135
|
+
expect(snapshot?.storage).toBe('memory');
|
|
136
|
+
expect(snapshot?.updatedAt).toBe('2026-09-25T00:00:00.000Z');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('todoProgress', () => {
|
|
141
|
+
test('counts closed as completed plus abandoned', () => {
|
|
142
|
+
const progress = todoProgress(
|
|
143
|
+
phases(['a', 'completed'], ['b', 'abandoned'], ['c', 'in_progress'], ['d', 'blocked'], ['e', 'pending']),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(progress).toEqual({
|
|
147
|
+
total: 5,
|
|
148
|
+
completed: 1,
|
|
149
|
+
closed: 2,
|
|
150
|
+
inProgress: 1,
|
|
151
|
+
pending: 1,
|
|
152
|
+
blocked: 1,
|
|
153
|
+
abandoned: 1,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse oh-my-pi's persisted `todo` state out of a session transcript.
|
|
8
|
+
*
|
|
9
|
+
* omp keeps the authoritative list in its own session file, not in an
|
|
10
|
+
* in-memory HUD: the `todo` tool commits a snapshot to the entry it appends,
|
|
11
|
+
* and omp reads the deepest snapshot back off the ACTIVE BRANCH when it
|
|
12
|
+
* rehydrates (`getLatestTodoPhasesFromEntries` → `syncTodoPhasesFromBranch`).
|
|
13
|
+
* This module performs the same read, so the chamber's panel shows what the
|
|
14
|
+
* agent itself would resume with.
|
|
15
|
+
*
|
|
16
|
+
* Two rules are load-bearing and each was verified against omp 18.3.0:
|
|
17
|
+
*
|
|
18
|
+
* - **The active branch, not file order.** A session file is a tree: every
|
|
19
|
+
* entry carries `parentId`, and a rewind/fork leaves entries on the file
|
|
20
|
+
* that the current leaf no longer descends from. omp reads `getBranch()`
|
|
21
|
+
* (leaf → root), so file order would resurrect a todo list the agent had
|
|
22
|
+
* already rewound past.
|
|
23
|
+
* - **A `view` snapshot is not a snapshot.** `todo op=view` returns the
|
|
24
|
+
* current list without committing it; a `view` result's phases may be
|
|
25
|
+
* stale, so omp skips those results entirely and so does this reader.
|
|
26
|
+
* Errored results are skipped for the same reason.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
30
|
+
import type {
|
|
31
|
+
TodoItem,
|
|
32
|
+
TodoOperation,
|
|
33
|
+
TodoPhase,
|
|
34
|
+
TodoProgress,
|
|
35
|
+
TodoSnapshot,
|
|
36
|
+
} from '@/shared/types/todo';
|
|
37
|
+
|
|
38
|
+
const TODO_STATUSES: Record<string, true> = {
|
|
39
|
+
pending: true,
|
|
40
|
+
in_progress: true,
|
|
41
|
+
completed: true,
|
|
42
|
+
abandoned: true,
|
|
43
|
+
blocked: true,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Custom-entry type omp writes for a durable, non-tool todo mutation. */
|
|
47
|
+
const USER_TODO_EDIT_CUSTOM_TYPE = 'user_todo_edit';
|
|
48
|
+
|
|
49
|
+
/** Whether an unknown value is one omp-persisted phase. */
|
|
50
|
+
export function isTodoPhase(value: unknown): value is TodoPhase {
|
|
51
|
+
if (!isRecord(value) || typeof value.name !== 'string' || !Array.isArray(value.tasks)) return false;
|
|
52
|
+
return value.tasks.every((task) => {
|
|
53
|
+
if (!isRecord(task) || typeof task.content !== 'string') return false;
|
|
54
|
+
return typeof task.status === 'string' && TODO_STATUSES[task.status] === true;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Narrow an unknown array to a validated phase list, or undefined. */
|
|
59
|
+
function toPhases(value: unknown): TodoPhase[] | undefined {
|
|
60
|
+
if (!Array.isArray(value) || !value.every(isTodoPhase)) return undefined;
|
|
61
|
+
return value as TodoPhase[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One JSONL entry as this reader needs it. */
|
|
65
|
+
interface TodoEntryLike {
|
|
66
|
+
id?: unknown;
|
|
67
|
+
parentId?: unknown;
|
|
68
|
+
timestamp?: unknown;
|
|
69
|
+
type?: unknown;
|
|
70
|
+
customType?: unknown;
|
|
71
|
+
data?: unknown;
|
|
72
|
+
message?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The phases an entry commits, or undefined when it commits none.
|
|
77
|
+
*
|
|
78
|
+
* Mirrors omp's own `aVn`: a `user_todo_edit` custom entry carries `data.phases`
|
|
79
|
+
* (already-normalized), and a `todo` toolResult carries `details.phases` unless
|
|
80
|
+
* it errored or was a pure `view` read.
|
|
81
|
+
*/
|
|
82
|
+
function committedPhases(entry: TodoEntryLike): TodoPhase[] | undefined {
|
|
83
|
+
if (entry.type === 'custom' && entry.customType === USER_TODO_EDIT_CUSTOM_TYPE) {
|
|
84
|
+
const data = isRecord(entry.data) ? entry.data : undefined;
|
|
85
|
+
return toPhases(data?.phases);
|
|
86
|
+
}
|
|
87
|
+
if (entry.type !== 'message') return undefined;
|
|
88
|
+
const message = isRecord(entry.message) ? entry.message : undefined;
|
|
89
|
+
if (!message || message.role !== 'toolResult' || message.toolName !== 'todo') return undefined;
|
|
90
|
+
if (message.isError === true) return undefined;
|
|
91
|
+
const details = isRecord(message.details) ? message.details : undefined;
|
|
92
|
+
if (details?.op === 'view') return undefined;
|
|
93
|
+
return toPhases(details?.phases);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The chain of entry ids from the session's leaf back to the root. */
|
|
97
|
+
function activeBranchIds(entries: TodoEntryLike[]): Set<string> {
|
|
98
|
+
const parentOf = new Map<string, string | null>();
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (typeof entry.id !== 'string') continue;
|
|
101
|
+
parentOf.set(entry.id, typeof entry.parentId === 'string' ? entry.parentId : null);
|
|
102
|
+
}
|
|
103
|
+
const branch = new Set<string>();
|
|
104
|
+
// The leaf is the last entry in file order: omp appends as it advances, so
|
|
105
|
+
// the newest entry is where the conversation currently stands.
|
|
106
|
+
let cursor: string | null | undefined = undefined;
|
|
107
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
108
|
+
const id = entries[i]?.id;
|
|
109
|
+
if (typeof id === 'string') {
|
|
110
|
+
cursor = id;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
while (typeof cursor === 'string' && !branch.has(cursor)) {
|
|
115
|
+
branch.add(cursor);
|
|
116
|
+
cursor = parentOf.get(cursor) ?? null;
|
|
117
|
+
}
|
|
118
|
+
return branch;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The deepest committed todo snapshot on the active branch, or null when this
|
|
123
|
+
* transcript holds none. The returned `sourceEntryId` is the entry a later
|
|
124
|
+
* snapshot has to beat — omp's own identity for the list.
|
|
125
|
+
*/
|
|
126
|
+
export function latestTodoSnapshot(entries: readonly unknown[]): TodoSnapshot | null {
|
|
127
|
+
const typed = entries as TodoEntryLike[];
|
|
128
|
+
const branch = activeBranchIds(typed);
|
|
129
|
+
|
|
130
|
+
for (let i = typed.length - 1; i >= 0; i--) {
|
|
131
|
+
const entry = typed[i];
|
|
132
|
+
const id = entry?.id;
|
|
133
|
+
// An entry with no id cannot be placed on the branch; a legacy file may
|
|
134
|
+
// still carry usable snapshots, so it is read but never claims identity.
|
|
135
|
+
if (typeof id === 'string' && !branch.has(id)) continue;
|
|
136
|
+
const phases = committedPhases(entry);
|
|
137
|
+
if (!phases) continue;
|
|
138
|
+
return {
|
|
139
|
+
phases,
|
|
140
|
+
sourceEntryId: typeof id === 'string' ? id : '',
|
|
141
|
+
source: entry.type === 'custom' ? 'custom' : 'toolResult',
|
|
142
|
+
...(typeof entry.timestamp === 'string' ? { updatedAt: entry.timestamp } : {}),
|
|
143
|
+
...readOp(entry),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Op + storage recorded on the snapshot entry, when it carries them. */
|
|
150
|
+
function readOp(entry: TodoEntryLike): { op?: TodoOperation; storage?: 'session' | 'memory' } {
|
|
151
|
+
const details =
|
|
152
|
+
entry.type === 'message'
|
|
153
|
+
? (isRecord(entry.message) ? (isRecord(entry.message.details) ? entry.message.details : undefined) : undefined)
|
|
154
|
+
: undefined;
|
|
155
|
+
if (!details) return {};
|
|
156
|
+
const op = typeof details.op === 'string' ? (details.op as TodoOperation) : undefined;
|
|
157
|
+
const storage = details.storage === 'session' || details.storage === 'memory' ? details.storage : undefined;
|
|
158
|
+
return { ...(op ? { op } : {}), ...(storage ? { storage } : {}) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Counter roll-up over every task in a snapshot. */
|
|
162
|
+
export function todoProgress(phases: readonly TodoPhase[]): TodoProgress {
|
|
163
|
+
const progress: TodoProgress = {
|
|
164
|
+
total: 0,
|
|
165
|
+
completed: 0,
|
|
166
|
+
closed: 0,
|
|
167
|
+
inProgress: 0,
|
|
168
|
+
pending: 0,
|
|
169
|
+
blocked: 0,
|
|
170
|
+
abandoned: 0,
|
|
171
|
+
};
|
|
172
|
+
for (const phase of phases) {
|
|
173
|
+
for (const task of phase.tasks) {
|
|
174
|
+
progress.total += 1;
|
|
175
|
+
switch (task.status) {
|
|
176
|
+
case 'completed':
|
|
177
|
+
progress.completed += 1;
|
|
178
|
+
progress.closed += 1;
|
|
179
|
+
break;
|
|
180
|
+
case 'abandoned':
|
|
181
|
+
progress.abandoned += 1;
|
|
182
|
+
progress.closed += 1;
|
|
183
|
+
break;
|
|
184
|
+
case 'in_progress':
|
|
185
|
+
progress.inProgress += 1;
|
|
186
|
+
break;
|
|
187
|
+
case 'blocked':
|
|
188
|
+
progress.blocked += 1;
|
|
189
|
+
break;
|
|
190
|
+
default:
|
|
191
|
+
progress.pending += 1;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return progress;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The task the agent is on right now: an `in_progress` one if any, else the
|
|
201
|
+
* first `pending`. omp picks the same way for its own "next actionable" row.
|
|
202
|
+
*/
|
|
203
|
+
export function nextActionableTask(phases: readonly TodoPhase[]): TodoItem | undefined {
|
|
204
|
+
for (const phase of phases) {
|
|
205
|
+
for (const task of phase.tasks) {
|
|
206
|
+
if (task.status === 'in_progress') return task;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const phase of phases) {
|
|
210
|
+
for (const task of phase.tasks) {
|
|
211
|
+
if (task.status === 'pending') return task;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|