ompchamber 3.0.0 → 3.1.1
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 +30 -8
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-5yfre1kw.css +1 -0
- package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- 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/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- 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/server/lib/lifecycle/restart.test.ts +0 -21
- /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hoist every nested at-rule out of the style rule that contains it.
|
|
8
|
+
*
|
|
9
|
+
* Bun's CSS minifier keeps the nesting it is handed, and for a target that is
|
|
10
|
+
* not the browser (`target: 'bun'`, which this build needs because its
|
|
11
|
+
* entrypoint is the SERVER) it re-nests a rule's nested at-rule into
|
|
12
|
+
* `@supports (…) { & { … } }`. Chrome does not apply a nested rule whose parent
|
|
13
|
+
* selector names a pseudo-element, so `&` under `::placeholder` or
|
|
14
|
+
* `::-webkit-scrollbar-thumb` is dropped and the rule's earlier fallback
|
|
15
|
+
* declaration wins instead — measured on the built bundle: the composer
|
|
16
|
+
* placeholder painted at full ink rather than 40%, every scrollbar thumb opaque
|
|
17
|
+
* rather than 20%.
|
|
18
|
+
*
|
|
19
|
+
* Tailwind v4 emits its colour fallbacks in exactly that shape (`color:
|
|
20
|
+
* var(--x); @supports (color: color-mix(…)) { color: color-mix(…) }`), so one
|
|
21
|
+
* minifier decision reached 16 rules: every placeholder utility in the app and
|
|
22
|
+
* every scrollbar rule, base and xterm included. The rsbuild pipeline this
|
|
23
|
+
* replaced compiled for browsers and flattened the nesting itself.
|
|
24
|
+
*
|
|
25
|
+
* Flattening here is what makes the bundle immune: the emitted CSS is
|
|
26
|
+
* `@supports (…) { .sel::placeholder { … } }` at the top level, which no
|
|
27
|
+
* minifier has anything left to re-nest and every browser applies. Declaration
|
|
28
|
+
* order is preserved exactly — a rule's declarations stay in the segments they
|
|
29
|
+
* were written in, and each at-rule is hoisted to the position it occupied — so
|
|
30
|
+
* the cascade is unchanged.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import postcss from 'postcss';
|
|
34
|
+
import type { AtRule, ChildNode, Container, Plugin, Rule } from 'postcss';
|
|
35
|
+
|
|
36
|
+
/** Resolve a nested selector against the rule it was written inside. */
|
|
37
|
+
function resolveSelector(parentSelector: string, nestedSelector: string): string {
|
|
38
|
+
if (nestedSelector.includes('&')) return nestedSelector.replaceAll('&', parentSelector);
|
|
39
|
+
return `${parentSelector} ${nestedSelector}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve `&` in the rules directly inside `container`, descending through
|
|
44
|
+
* at-rules only: `&` still refers to the nearest enclosing STYLE rule, and an
|
|
45
|
+
* intermediate one is hoisted on a later pass.
|
|
46
|
+
*
|
|
47
|
+
* Runs BEFORE `wrapBareDeclarations` — the rule that wraps bare declarations
|
|
48
|
+
* already carries the parent's resolved selector, and resolving it again would
|
|
49
|
+
* turn `::placeholder` into `::placeholder ::placeholder`.
|
|
50
|
+
*/
|
|
51
|
+
function resolveAmpersands(container: Container, parentSelector: string): void {
|
|
52
|
+
for (const node of container.nodes ?? []) {
|
|
53
|
+
if (node.type === 'rule') node.selector = resolveSelector(parentSelector, node.selector);
|
|
54
|
+
else if (node.type === 'atrule') resolveAmpersands(node, parentSelector);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Wrap an at-rule's bare declarations in `parentSelector`, keeping them in place. */
|
|
59
|
+
function wrapBareDeclarations(atRule: AtRule, parentSelector: string): void {
|
|
60
|
+
const nodes = [...(atRule.nodes ?? [])];
|
|
61
|
+
if (!nodes.some((node) => node.type === 'decl')) return;
|
|
62
|
+
const rebuilt: ChildNode[] = [];
|
|
63
|
+
let declarations: ChildNode[] = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (declarations.length === 0) return;
|
|
66
|
+
const rule = postcss.rule({ selector: parentSelector });
|
|
67
|
+
rule.append(declarations);
|
|
68
|
+
rebuilt.push(rule);
|
|
69
|
+
declarations = [];
|
|
70
|
+
};
|
|
71
|
+
for (const node of nodes) {
|
|
72
|
+
if (node.type === 'decl') declarations.push(node);
|
|
73
|
+
else {
|
|
74
|
+
flush();
|
|
75
|
+
rebuilt.push(node);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
atRule.removeAll();
|
|
80
|
+
atRule.append(rebuilt);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Hoist `rule`'s nested at-rules into its own container. Reports whether it moved any. */
|
|
84
|
+
function hoistNestedAtRules(rule: Rule): boolean {
|
|
85
|
+
const container = rule.parent;
|
|
86
|
+
// Snapshot: reparenting the declarations empties `rule.nodes` mid-iteration.
|
|
87
|
+
const nodes = [...(rule.nodes ?? [])];
|
|
88
|
+
if (!container || !nodes.some((node) => node.type === 'atrule')) return false;
|
|
89
|
+
|
|
90
|
+
const replacement: ChildNode[] = [];
|
|
91
|
+
let declarations: ChildNode[] = [];
|
|
92
|
+
const flush = () => {
|
|
93
|
+
if (declarations.length === 0) return;
|
|
94
|
+
const segment = postcss.rule({ selector: rule.selector, raws: { ...rule.raws } });
|
|
95
|
+
segment.append(declarations);
|
|
96
|
+
replacement.push(segment);
|
|
97
|
+
declarations = [];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const node of nodes) {
|
|
101
|
+
if (node.type !== 'atrule') {
|
|
102
|
+
declarations.push(node);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
flush();
|
|
106
|
+
const hoisted = node.clone();
|
|
107
|
+
resolveAmpersands(hoisted, rule.selector);
|
|
108
|
+
wrapBareDeclarations(hoisted, rule.selector);
|
|
109
|
+
replacement.push(hoisted);
|
|
110
|
+
}
|
|
111
|
+
flush();
|
|
112
|
+
|
|
113
|
+
container.insertBefore(rule, replacement);
|
|
114
|
+
rule.remove();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* PostCSS plugin: hoist nested at-rules out of the rules containing them.
|
|
120
|
+
*
|
|
121
|
+
* `OnceExit` rather than `Once` so Tailwind's own pass has already expanded
|
|
122
|
+
* `@theme` and every utility before there is a rule to flatten; register it
|
|
123
|
+
* LAST for the same reason.
|
|
124
|
+
*/
|
|
125
|
+
export function flattenNestedAtRules(): Plugin {
|
|
126
|
+
return {
|
|
127
|
+
postcssPlugin: 'ompchamber-flatten-nested-at-rules',
|
|
128
|
+
OnceExit(root) {
|
|
129
|
+
// A hoist can expose another nested at-rule in the segment that received
|
|
130
|
+
// it, so repeat until a full pass moves nothing.
|
|
131
|
+
let hoisted = true;
|
|
132
|
+
while (hoisted) {
|
|
133
|
+
hoisted = false;
|
|
134
|
+
const rules: Rule[] = [];
|
|
135
|
+
root.walkRules((rule) => {
|
|
136
|
+
rules.push(rule);
|
|
137
|
+
});
|
|
138
|
+
// Reverse document order: a nested rule is walked after its parent, so
|
|
139
|
+
// the inner one is flattened first and its parent is then still intact.
|
|
140
|
+
for (let index = rules.length - 1; index >= 0; index -= 1) {
|
|
141
|
+
if (hoistNestedAtRules(rules[index])) hoisted = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
3
|
+
import { isCliManaged, launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
4
4
|
|
|
5
5
|
const ENTRY = '/repo/src/server/index.ts';
|
|
6
6
|
|
|
@@ -30,3 +30,22 @@ describe('resolveLaunchMode', () => {
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
// Every command that would END a server decides with this predicate:
|
|
35
|
+
// `restart`/`update` may replace it, and `stop` stops it by default. Guessing
|
|
36
|
+
// wrong here kills a `bun run dev` the user owns — that launcher exits with its
|
|
37
|
+
// child — or leaves a daemon serving the build that was just replaced.
|
|
38
|
+
describe('isCliManaged', () => {
|
|
39
|
+
test('owns the instances the CLI started', () => {
|
|
40
|
+
expect(isCliManaged('daemon')).toBe(true);
|
|
41
|
+
expect(isCliManaged('foreground')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('leaves every instance OMPChamber did not start alone', () => {
|
|
45
|
+
// `bun run dev`, `bun run start`, a manual run, a systemd/pm2 unit.
|
|
46
|
+
expect(isCliManaged('direct')).toBe(false);
|
|
47
|
+
// A probed or unreadable record never authorises a kill either.
|
|
48
|
+
expect(isCliManaged('unknown')).toBe(false);
|
|
49
|
+
expect(isCliManaged(undefined)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -38,3 +38,29 @@ export function resolveLaunchMode(argv: readonly string[] = Bun.argv): InstanceL
|
|
|
38
38
|
const value = flag?.slice(LAUNCH_MODE_FLAG.length);
|
|
39
39
|
return value === 'daemon' || value === 'foreground' ? value : 'direct';
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the CLI owns this instance's lifecycle — the one question every
|
|
44
|
+
* command that would end a server asks: `restart`/`update` may replace it, and
|
|
45
|
+
* `stop` stops it by default.
|
|
46
|
+
*
|
|
47
|
+
* A `direct` server runs under a launcher the CLI does not own, and that
|
|
48
|
+
* launcher exits with its child: `bun run dev` is a supervisor process whose
|
|
49
|
+
* only job is to mirror the script it spawned, so signalling the server ends
|
|
50
|
+
* the user's dev loop with it. Leaving those alone by default is what keeps
|
|
51
|
+
* `ompchamber stop` from killing a loop it did not start; an explicit
|
|
52
|
+
* `--port <port>` (or `--all`) still ends one, deliberately.
|
|
53
|
+
*/
|
|
54
|
+
export function isCliManaged(launchMode: string | null | undefined): boolean {
|
|
55
|
+
return launchMode === 'daemon' || launchMode === 'foreground';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why an instance the CLI does not own is left alone, without the action. Each
|
|
60
|
+
* command that would end a server appends its own next step, so the two cannot
|
|
61
|
+
* describe the same `launchMode` differently.
|
|
62
|
+
*/
|
|
63
|
+
export function unmanagedReason(launchMode: string | null | undefined): string {
|
|
64
|
+
if (launchMode === 'direct') return 'runs from source (`bun run dev` / `bun run start`)';
|
|
65
|
+
return 'is not managed by the ompchamber CLI';
|
|
66
|
+
}
|
|
@@ -25,7 +25,7 @@ import { join as joinPath, resolve as resolvePath } from 'node:path';
|
|
|
25
25
|
|
|
26
26
|
import { resolveBunBin } from '@/server/lib/lifecycle/bun';
|
|
27
27
|
import { listInstanceRecords } from '@/server/lib/lifecycle/instance';
|
|
28
|
-
import { resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
|
|
28
|
+
import { isCliManaged, resolveLaunchMode, unmanagedReason } from '@/server/lib/lifecycle/launch-mode';
|
|
29
29
|
|
|
30
30
|
/** Package root of the running copy: src/server/lib/lifecycle -> four levels up. */
|
|
31
31
|
const PKG_ROOT = resolvePath(import.meta.dir, '..', '..', '..', '..');
|
|
@@ -45,20 +45,15 @@ export interface SelfRestartPlan {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* One-line reason an instance is left running, shared by the CLI and console.
|
|
49
|
+
* The clause comes from `unmanagedReason` so `stop`, `restart` and `update`
|
|
50
|
+
* cannot classify the same `launchMode` differently.
|
|
51
51
|
*/
|
|
52
|
-
export function isAutoRestartable(launchMode: string | null | undefined): boolean {
|
|
53
|
-
return launchMode === 'daemon' || launchMode === 'foreground';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** One-line reason an instance is left running, shared by the CLI and console. */
|
|
57
52
|
export function skipRestartNote(launchMode: string | null | undefined): string {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
53
|
+
const nextStep = launchMode === 'direct'
|
|
54
|
+
? 'start it again to apply the update'
|
|
55
|
+
: 'restart it yourself to apply the update';
|
|
56
|
+
return `this instance ${unmanagedReason(launchMode)} — ${nextStep}`;
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
/** The record this process wrote for itself; falls back to the env it was launched with. */
|
|
@@ -89,7 +84,7 @@ export function scheduleSelfRestart(): SelfRestartPlan {
|
|
|
89
84
|
return { restarting: false, message: 'This instance could not be identified — restart it yourself to apply the update.' };
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
if (!
|
|
87
|
+
if (!isCliManaged(self.launchMode)) {
|
|
93
88
|
return { restarting: false, message: skipRestartNote(self.launchMode) };
|
|
94
89
|
}
|
|
95
90
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read one session's todo state from its transcript.
|
|
8
|
+
*
|
|
9
|
+
* The list is omp's, and its only durable home is the session JSONL — there is
|
|
10
|
+
* no separate todo file and no chamber-side copy to fall out of date. So the
|
|
11
|
+
* read is: locate the session file, parse it, and take the deepest committed
|
|
12
|
+
* snapshot on the active branch (`latestTodoSnapshot`).
|
|
13
|
+
*
|
|
14
|
+
* Two properties of this read are deliberate:
|
|
15
|
+
*
|
|
16
|
+
* - **It is a full-file parse, cached by size+mtime.** Measured on real
|
|
17
|
+
* sessions (7.2 MB / 1905 entries) a full parse is ~15 ms, and the todos
|
|
18
|
+
* may sit megabytes from EOF — a `todo` call near the start of a long
|
|
19
|
+
* session stays the live list for the rest of it, so a bounded tail window
|
|
20
|
+
* would report "no todos" for exactly the sessions that have the most. omp
|
|
21
|
+
* flushes each entry as it appends, so a poll sees a new list within the
|
|
22
|
+
* second it was written.
|
|
23
|
+
* - **It never creates an omp process.** The session is read-only here; a
|
|
24
|
+
* panel that spawned a child per poll would fight the chat's own session
|
|
25
|
+
* wrapper for the same session file.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
29
|
+
import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
|
|
30
|
+
import { latestTodoSnapshot, todoProgress } from '@/shared/lib/chat/todo/snapshot';
|
|
31
|
+
import type { SessionTodoState } from '@/shared/types/todo';
|
|
32
|
+
|
|
33
|
+
/** Sessions larger than this are refused rather than parsed; a transcript past
|
|
34
|
+
* it is pathological, and the read runs behind an HTTP request. */
|
|
35
|
+
const MAX_TODO_SESSION_BYTES = 512 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
interface TodoCacheEntry {
|
|
38
|
+
size: number;
|
|
39
|
+
mtimeMs: number;
|
|
40
|
+
state: SessionTodoState;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cache keyed by session id. Hangs off `globalThis` for the same reason the
|
|
45
|
+
* session-file caches do: a `bun --hot` reload re-evaluates modules but keeps
|
|
46
|
+
* `globalThis`, and a cache that vanished on every edit would re-parse a
|
|
47
|
+
* multi-megabyte file per poll during development.
|
|
48
|
+
*/
|
|
49
|
+
interface TodoCacheHost {
|
|
50
|
+
entries: Map<string, TodoCacheEntry>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
declare global {
|
|
54
|
+
// eslint-disable-next-line no-var
|
|
55
|
+
var __ompChamberTodoCache: TodoCacheHost | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cache(): TodoCacheHost {
|
|
59
|
+
globalThis.__ompChamberTodoCache ??= { entries: new Map() };
|
|
60
|
+
return globalThis.__ompChamberTodoCache;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EMPTY_TODO_STATE: SessionTodoState = { snapshot: null, progress: todoProgress([]) };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The todo state of one session. Returns an empty state (never a throw) when
|
|
67
|
+
* the transcript cannot be read: a session whose file is gone is a normal
|
|
68
|
+
* condition for a sidebar entry the user has not opened yet, and the panel
|
|
69
|
+
* reports "no todos" rather than an error for it.
|
|
70
|
+
*/
|
|
71
|
+
export async function readSessionTodos(sessionId: string): Promise<SessionTodoState> {
|
|
72
|
+
const filePath = await findSessionFileById(sessionId);
|
|
73
|
+
if (!filePath) return EMPTY_TODO_STATE;
|
|
74
|
+
|
|
75
|
+
let size: number;
|
|
76
|
+
let mtimeMs: number;
|
|
77
|
+
try {
|
|
78
|
+
const stats = await Bun.file(filePath).stat();
|
|
79
|
+
if (stats.size > MAX_TODO_SESSION_BYTES) return EMPTY_TODO_STATE;
|
|
80
|
+
size = stats.size;
|
|
81
|
+
mtimeMs = stats.mtimeMs;
|
|
82
|
+
} catch {
|
|
83
|
+
return EMPTY_TODO_STATE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const slot = cache().entries.get(sessionId);
|
|
87
|
+
if (slot && slot.size === size && slot.mtimeMs === mtimeMs) return slot.state;
|
|
88
|
+
|
|
89
|
+
let body: string;
|
|
90
|
+
try {
|
|
91
|
+
body = await Bun.file(filePath).text();
|
|
92
|
+
} catch {
|
|
93
|
+
return EMPTY_TODO_STATE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const snapshot = latestTodoSnapshot(parseJsonlLenient<Record<string, unknown>>(body));
|
|
97
|
+
const state: SessionTodoState = { snapshot, progress: todoProgress(snapshot?.phases ?? []) };
|
|
98
|
+
cache().entries.set(sessionId, { size, mtimeMs, state });
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Files panel's "New file" branch.
|
|
8
|
+
*
|
|
9
|
+
* Two rules are load-bearing and neither is visible from the happy path: the
|
|
10
|
+
* name may not carry a path separator (the folder is named by `path`, and a
|
|
11
|
+
* separator would turn a name into an arbitrary write under the root), and an
|
|
12
|
+
* existing file is REFUSED rather than truncated. The second one is the one a
|
|
13
|
+
* naive `Bun.write` gets wrong: `Bun.write` creates or truncates, so a name
|
|
14
|
+
* that collides with a real file would silently blank it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { action } from '@/server/routes/fs/action';
|
|
21
|
+
|
|
22
|
+
/** Probes live in the repo root — the one directory the root allow-list always accepts. */
|
|
23
|
+
const DIR = path.join(process.cwd(), 'tmp-create-probe');
|
|
24
|
+
const NESTED = path.join(DIR, 'sub');
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(DIR, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function create(folderPath: string, name: string) {
|
|
31
|
+
const form = new FormData();
|
|
32
|
+
form.append('actionType', 'create');
|
|
33
|
+
form.append('root', process.cwd());
|
|
34
|
+
form.append('path', folderPath);
|
|
35
|
+
form.append('name', name);
|
|
36
|
+
const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
|
|
37
|
+
return { status: res.status, body: (await res.json()) as { success?: boolean; path?: string; error?: string } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('fs create', () => {
|
|
41
|
+
test('creates an empty file in the named folder and reports its relative path', async () => {
|
|
42
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
43
|
+
const { status, body } = await create('tmp-create-probe', 'notes.md');
|
|
44
|
+
|
|
45
|
+
expect(status).toBe(200);
|
|
46
|
+
expect(body).toEqual({ success: true, path: 'tmp-create-probe/notes.md' });
|
|
47
|
+
expect(fs.readFileSync(path.join(DIR, 'notes.md'), 'utf8')).toBe('');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('creates inside a nested folder path, which is how a subfolder is targeted', async () => {
|
|
51
|
+
fs.mkdirSync(NESTED, { recursive: true });
|
|
52
|
+
const { status, body } = await create('tmp-create-probe/sub', 'index.ts');
|
|
53
|
+
|
|
54
|
+
expect(status).toBe(200);
|
|
55
|
+
expect(body.path).toBe('tmp-create-probe/sub/index.ts');
|
|
56
|
+
expect(fs.existsSync(path.join(NESTED, 'index.ts'))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('refuses an existing name instead of truncating it', async () => {
|
|
60
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(DIR, 'keep.txt'), 'important');
|
|
62
|
+
|
|
63
|
+
const { status, body } = await create('tmp-create-probe', 'keep.txt');
|
|
64
|
+
|
|
65
|
+
expect(status).toBe(409);
|
|
66
|
+
expect(body.error).toContain('already exists');
|
|
67
|
+
expect(fs.readFileSync(path.join(DIR, 'keep.txt'), 'utf8')).toBe('important');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('refuses a name carrying a path separator', async () => {
|
|
71
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
72
|
+
for (const name of ['../escape.txt', 'sub/escape.txt', 'sub\\escape.txt']) {
|
|
73
|
+
const { status } = await create('tmp-create-probe', name);
|
|
74
|
+
expect(status).toBe(400);
|
|
75
|
+
}
|
|
76
|
+
expect(fs.existsSync(path.join(process.cwd(), 'escape.txt'))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('refuses an empty or whitespace-only name', async () => {
|
|
80
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
81
|
+
expect((await create('tmp-create-probe', ' ')).status).toBe(400);
|
|
82
|
+
expect((await create('tmp-create-probe', '.')).status).toBe(400);
|
|
83
|
+
expect((await create('tmp-create-probe', '..')).status).toBe(400);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('refuses a folder that resolves outside the root', async () => {
|
|
87
|
+
const { status } = await create('../outside', 'x.txt');
|
|
88
|
+
expect(status).toBe(403);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -37,6 +37,29 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
37
37
|
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
38
38
|
await Bun.write(fullPath, content);
|
|
39
39
|
return json({ success: true });
|
|
40
|
+
} else if (actionType === 'create') {
|
|
41
|
+
// The Files panel's "New file": an empty file beside the folder that was
|
|
42
|
+
// right-clicked. Only a NAME is accepted, never a path — a separator
|
|
43
|
+
// would let the panel write anywhere under the root, and the folder is
|
|
44
|
+
// already named by `path`.
|
|
45
|
+
const name = ((formData.get('name') as string) ?? '').trim();
|
|
46
|
+
if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) {
|
|
47
|
+
return json({ error: 'Enter a file name without slashes' }, { status: 400 });
|
|
48
|
+
}
|
|
49
|
+
const target = resolveWithinRoot(scopedRoot, path.join(filePath || '.', name));
|
|
50
|
+
if (!target) {
|
|
51
|
+
return json({ error: 'Invalid path' }, { status: 403 });
|
|
52
|
+
}
|
|
53
|
+
// Creating must never overwrite: a name that already exists is reported,
|
|
54
|
+
// not truncated — the panel offers Rename for that, and an empty write
|
|
55
|
+
// here would destroy the file the user meant to keep.
|
|
56
|
+
if ((await Bun.file(target).stat().catch(() => null)) !== null) {
|
|
57
|
+
return json({ error: `${name} already exists` }, { status: 409 });
|
|
58
|
+
}
|
|
59
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
60
|
+
await Bun.write(target, '');
|
|
61
|
+
// Relative to the LISTED root, which is the identity the tree renders by.
|
|
62
|
+
return json({ success: true, path: path.relative(scopedRoot, target).split(path.sep).join('/') });
|
|
40
63
|
} else if (actionType === 'delete') {
|
|
41
64
|
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
|
42
65
|
return json({ success: true });
|
|
@@ -2,6 +2,7 @@ import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
|
|
|
2
2
|
import * as ompState from '@/server/routes/omp/state';
|
|
3
3
|
import * as ompSidebar from '@/server/routes/omp/sidebar';
|
|
4
4
|
import * as ompSessionStats from '@/server/routes/omp/session-stats';
|
|
5
|
+
import * as ompSessionTodos from '@/server/routes/omp/session-todos';
|
|
5
6
|
import * as ompCommands from '@/server/routes/omp/commands';
|
|
6
7
|
import * as ompExtensions from '@/server/routes/omp/extensions';
|
|
7
8
|
import * as ompLogin from '@/server/routes/omp/login';
|
|
@@ -12,6 +13,7 @@ export const ompBindings: HandlerBinding[] = [
|
|
|
12
13
|
...bindingsFor(ompState, '/api/omp/state'),
|
|
13
14
|
...bindingsFor(ompSidebar, '/api/omp/sidebar'),
|
|
14
15
|
...bindingsFor(ompSessionStats, '/api/omp/session-stats'),
|
|
16
|
+
...bindingsFor(ompSessionTodos, '/api/omp/session-todos'),
|
|
15
17
|
...bindingsFor(ompCommands, '/api/omp/commands'),
|
|
16
18
|
...bindingsFor(ompExtensions, '/api/omp/extensions'),
|
|
17
19
|
...bindingsFor(ompLogin, '/api/omp/login'),
|
|
@@ -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 {
|