@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79
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/package.json +14 -13
- package/src/App.diff.test.tsx +9 -4
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +75 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.ts +0 -1
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Switch } from "./Switch";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Primitives/Switch" };
|
|
7
|
+
|
|
8
|
+
export const On: Story = {
|
|
9
|
+
render: () => <Switch on onToggle={() => {}} theme={DARK} />,
|
|
10
|
+
expectedColors: [DARK.accent],
|
|
11
|
+
size: { width: 8, height: 1 },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const Off: Story = {
|
|
15
|
+
render: () => <Switch on={false} onToggle={() => {}} theme={DARK} />,
|
|
16
|
+
size: { width: 8, height: 1 },
|
|
17
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On/off switch: a pill with a knob that sits right (accent) when on and left
|
|
3
|
+
* (dim) when off. `onToggle` unifies a mouse click and keyboard activation, so
|
|
4
|
+
* the dialog grammar and a pointer both flip it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { MouseEvent } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "../../theme";
|
|
10
|
+
import { useComponentTheme } from "../theme-context";
|
|
11
|
+
|
|
12
|
+
export interface SwitchProps {
|
|
13
|
+
on: boolean;
|
|
14
|
+
onToggle: () => void;
|
|
15
|
+
theme?: Theme;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Switch({ on, onToggle, theme }: SwitchProps): React.ReactNode {
|
|
19
|
+
const tokens = useComponentTheme(theme);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<box
|
|
23
|
+
onMouseUp={(event: MouseEvent) => {
|
|
24
|
+
event.stopPropagation();
|
|
25
|
+
onToggle();
|
|
26
|
+
}}
|
|
27
|
+
style={{ flexShrink: 0 }}
|
|
28
|
+
>
|
|
29
|
+
<text fg={on ? tokens.accent : tokens.textDim}>{on ? "( ●)" : "(● )"}</text>
|
|
30
|
+
</box>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface TreeProps {
|
|
|
13
13
|
showStatus?: boolean;
|
|
14
14
|
/** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
|
|
15
15
|
hideIcons?: boolean;
|
|
16
|
+
/** The selected row's backdrop; defaults to `elevated` (invisible on an elevated dialog, use `border` there). */
|
|
17
|
+
selectedBackground?: string;
|
|
16
18
|
indentWidth?: number;
|
|
17
19
|
icons?: TreeIcons;
|
|
18
20
|
onSelect?: (id: string) => void;
|
|
@@ -41,6 +43,7 @@ export function Tree({
|
|
|
41
43
|
flattenEmptyDirectories,
|
|
42
44
|
showStatus,
|
|
43
45
|
hideIcons,
|
|
46
|
+
selectedBackground,
|
|
44
47
|
indentWidth = 2,
|
|
45
48
|
icons = NERD_TREE_ICONS,
|
|
46
49
|
onSelect,
|
|
@@ -48,6 +51,7 @@ export function Tree({
|
|
|
48
51
|
theme,
|
|
49
52
|
}: TreeProps): React.ReactNode {
|
|
50
53
|
const tokens = theme ?? DARK;
|
|
54
|
+
const selectedBackdrop = selectedBackground ?? tokens.elevated;
|
|
51
55
|
const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
|
|
52
56
|
|
|
53
57
|
return (
|
|
@@ -71,7 +75,7 @@ export function Tree({
|
|
|
71
75
|
flexDirection: "row",
|
|
72
76
|
paddingLeft: 1 + row.depth * indentWidth,
|
|
73
77
|
paddingRight: 1,
|
|
74
|
-
backgroundColor: selected ?
|
|
78
|
+
backgroundColor: selected ? selectedBackdrop : undefined,
|
|
75
79
|
}}
|
|
76
80
|
onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
|
|
77
81
|
>
|
|
@@ -10,3 +10,8 @@ import type { BorderStyle } from "@opentui/core";
|
|
|
10
10
|
|
|
11
11
|
/** Corner style shared by every bordered frame in the app: square, not rounded. */
|
|
12
12
|
export const FRAME_BORDER_STYLE: BorderStyle = "single";
|
|
13
|
+
|
|
14
|
+
/** Every frame's border title renders lowercase, so the convention holds without each caller minding case. */
|
|
15
|
+
export function frameTitle(title: string | undefined): string | undefined {
|
|
16
|
+
return title?.toLowerCase();
|
|
17
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI state for the share dialog, held in a zustand store rather than juggled
|
|
3
|
+
* across component state: which category is open (share externally / export),
|
|
4
|
+
* whether the link wizard is showing, and the wizard's draft (name, auth,
|
|
5
|
+
* allowlist). The links themselves live on the thread; this is only the
|
|
6
|
+
* ephemeral surface state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createStore } from "zustand/vanilla";
|
|
10
|
+
import { useStore } from "zustand";
|
|
11
|
+
|
|
12
|
+
export type ShareCategory = "external" | "export";
|
|
13
|
+
|
|
14
|
+
export interface ShareWizardDraft {
|
|
15
|
+
/** The link being edited, or null when creating a new one. */
|
|
16
|
+
editingId: string | null;
|
|
17
|
+
name: string;
|
|
18
|
+
requireAuth: boolean;
|
|
19
|
+
allowlist: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ShareDialogState {
|
|
23
|
+
category: ShareCategory;
|
|
24
|
+
/** Null = the links list; a draft = the new/edit wizard. */
|
|
25
|
+
wizard: ShareWizardDraft | null;
|
|
26
|
+
setCategory: (category: ShareCategory) => void;
|
|
27
|
+
openWizard: (draft: ShareWizardDraft) => void;
|
|
28
|
+
closeWizard: () => void;
|
|
29
|
+
setName: (name: string) => void;
|
|
30
|
+
setRequireAuth: (requireAuth: boolean) => void;
|
|
31
|
+
setAllowlist: (allowlist: string[]) => void;
|
|
32
|
+
/** Reset to the opening state (links list, external tab) when the dialog opens. */
|
|
33
|
+
reset: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fresh wizard draft, prefilled with the thread name for a new link. */
|
|
37
|
+
export function newWizardDraft(threadName: string): ShareWizardDraft {
|
|
38
|
+
return { editingId: null, name: threadName, requireAuth: false, allowlist: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const shareDialogStore = createStore<ShareDialogState>((set) => ({
|
|
42
|
+
category: "external",
|
|
43
|
+
wizard: null,
|
|
44
|
+
setCategory: (category) => set({ category }),
|
|
45
|
+
openWizard: (wizard) => set({ wizard }),
|
|
46
|
+
closeWizard: () => set({ wizard: null }),
|
|
47
|
+
setName: (name) => set((state) => (state.wizard ? { wizard: { ...state.wizard, name } } : {})),
|
|
48
|
+
setRequireAuth: (requireAuth) =>
|
|
49
|
+
set((state) => (state.wizard ? { wizard: { ...state.wizard, requireAuth } } : {})),
|
|
50
|
+
setAllowlist: (allowlist) =>
|
|
51
|
+
set((state) => (state.wizard ? { wizard: { ...state.wizard, allowlist } } : {})),
|
|
52
|
+
reset: () => set({ category: "external", wizard: null }),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
/** Subscribe a component to the share dialog store. */
|
|
56
|
+
export function useShareDialog<T>(selector: (state: ShareDialogState) => T): T {
|
|
57
|
+
return useStore(shareDialogStore, selector);
|
|
58
|
+
}
|
|
@@ -11,7 +11,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
11
11
|
import { RGBA } from "@opentui/core";
|
|
12
12
|
import { componentFilesMissingStories, loadStories } from "./story";
|
|
13
13
|
import { AnsiScreen } from "./AnsiScreen";
|
|
14
|
-
import {
|
|
14
|
+
import { settle } from "../test-support";
|
|
15
15
|
|
|
16
16
|
const stories = await loadStories();
|
|
17
17
|
|
|
@@ -45,8 +45,9 @@ describe("stories catalog", () => {
|
|
|
45
45
|
// Act
|
|
46
46
|
const setup = await testRender(node, size);
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
// settle, not just visual-idle: async stories (a file read, a virtualizer's
|
|
49
|
+
// first measure) commit after React's own scheduler, invisible to idle
|
|
50
|
+
await settle(setup);
|
|
50
51
|
const frame = setup.captureCharFrame();
|
|
51
52
|
|
|
52
53
|
// Assert
|
package/src/config.test.ts
CHANGED
|
@@ -143,6 +143,24 @@ describe("loadConfig", () => {
|
|
|
143
143
|
}
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
test("[ui] default_verdict defaults to approve and parses request_changes", () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-verdict-"));
|
|
149
|
+
const path = join(dir, "config.toml");
|
|
150
|
+
|
|
151
|
+
writeFileSync(path, `[ui]\ndefault_verdict = "request_changes"\n`);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Assert
|
|
155
|
+
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.defaultVerdict).toBe(
|
|
156
|
+
"approve",
|
|
157
|
+
);
|
|
158
|
+
expect(loadConfig({ userConfigPath: path }).ui.defaultVerdict).toBe("request_changes");
|
|
159
|
+
} finally {
|
|
160
|
+
rmSync(dir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
146
164
|
test("[skills] path overrides the default and a malformed value never discards the config", () => {
|
|
147
165
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-skills-"));
|
|
148
166
|
const path = join(dir, "config.toml");
|
package/src/config.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
12
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
12
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
13
14
|
import { dirname } from "node:path";
|
|
14
15
|
import * as v from "valibot";
|
|
@@ -107,6 +108,7 @@ export interface CueloopConfig {
|
|
|
107
108
|
theme: ThemeName;
|
|
108
109
|
/** How the Changes diff renders when wide: old|new side by side or one stacked column. */
|
|
109
110
|
diffView: DiffViewMode;
|
|
111
|
+
defaultVerdict: VerdictKind;
|
|
110
112
|
/** Session ids the user has pinned to the top of the sidebar; client-local view state. */
|
|
111
113
|
pins: string[];
|
|
112
114
|
/** The last pane layout a bare launch restores; unset until the user changes one. */
|
|
@@ -162,7 +164,6 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
|
162
164
|
share: ["S"],
|
|
163
165
|
quit: ["q"],
|
|
164
166
|
walk: ["w"],
|
|
165
|
-
leader: ["ctrl+g"],
|
|
166
167
|
};
|
|
167
168
|
|
|
168
169
|
const SkillsSectionSchema = v.object({ path: v.optional(v.string()) });
|
|
@@ -212,6 +213,10 @@ const UiSchema = v.object({
|
|
|
212
213
|
editor: v.fallback(v.optional(v.string()), undefined),
|
|
213
214
|
theme: v.fallback(v.optional(v.string()), undefined),
|
|
214
215
|
diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
|
|
216
|
+
default_verdict: v.fallback(
|
|
217
|
+
v.optional(v.picklist(["comment", "approve", "request_changes"])),
|
|
218
|
+
undefined,
|
|
219
|
+
),
|
|
215
220
|
pins: v.fallback(v.optional(v.array(v.string())), undefined),
|
|
216
221
|
layout: v.fallback(
|
|
217
222
|
v.optional(
|
|
@@ -279,6 +284,7 @@ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>
|
|
|
279
284
|
// "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
|
|
280
285
|
if (parsed.diff_view !== undefined)
|
|
281
286
|
ui.diffView = parsed.diff_view === "unified" ? "stacked" : parsed.diff_view;
|
|
287
|
+
if (parsed.default_verdict !== undefined) ui.defaultVerdict = parsed.default_verdict;
|
|
282
288
|
if (parsed.pins !== undefined) ui.pins = parsed.pins;
|
|
283
289
|
if (parsed.layout !== undefined) {
|
|
284
290
|
ui.layout = {
|
|
@@ -358,6 +364,7 @@ export function loadConfig(
|
|
|
358
364
|
autoClose: "off",
|
|
359
365
|
theme: DEFAULT_THEME_NAME,
|
|
360
366
|
diffView: "split",
|
|
367
|
+
defaultVerdict: "approve",
|
|
361
368
|
pins: [],
|
|
362
369
|
},
|
|
363
370
|
authors: {},
|
package/src/diff-fold.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Per-file fold state for the diff sheet, applied over the flat diff rows so the
|
|
3
3
|
* cursor, curation, and rendering all read one array. A collapsed file shows only
|
|
4
4
|
* its file band; an expanded file shows its whole contents with the diff woven in
|
|
5
|
-
* (unchanged lines become context rows),
|
|
5
|
+
* (unchanged lines become context rows), an "expand all lines" view.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { DiffRow } from "./view-diff";
|
|
@@ -93,6 +93,7 @@ function baseController(): ReviewController {
|
|
|
93
93
|
curationItems: mock(() => []),
|
|
94
94
|
restoreCuration: mock(),
|
|
95
95
|
edit: mock(),
|
|
96
|
+
saveEditedBody: mock(),
|
|
96
97
|
annotate: mock(() => undefined),
|
|
97
98
|
addComment: mock(() => undefined),
|
|
98
99
|
commentOnWorkbench: mock(() => Promise.resolve()),
|
|
@@ -108,7 +109,13 @@ function baseController(): ReviewController {
|
|
|
108
109
|
walkLeave: mock(),
|
|
109
110
|
submit: mock(),
|
|
110
111
|
share: mock(),
|
|
112
|
+
unshare: mock(),
|
|
111
113
|
setShareAccess: mock(),
|
|
114
|
+
shareLinks: mock(() => []),
|
|
115
|
+
createShareLink: mock(),
|
|
116
|
+
updateShareLink: mock(),
|
|
117
|
+
deleteShareLink: mock(),
|
|
118
|
+
copyShareLink: mock(),
|
|
112
119
|
treeRows: mock(() => []),
|
|
113
120
|
goToEntry: mock(),
|
|
114
121
|
branch: mock(),
|
|
@@ -136,6 +143,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
136
143
|
inboxCursor: 0,
|
|
137
144
|
mode: { type: "normal" },
|
|
138
145
|
session: null,
|
|
146
|
+
defaultVerdict: "approve",
|
|
139
147
|
focusedAnnotationId: undefined,
|
|
140
148
|
selectedCurationId: undefined,
|
|
141
149
|
railTab: "review",
|
|
@@ -155,9 +163,10 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
155
163
|
setSelectedCurationId: mock(),
|
|
156
164
|
setPulsedAnnotationId: mock(),
|
|
157
165
|
selectCardFromDocument: mock(),
|
|
158
|
-
|
|
166
|
+
openBodyEditor: mock(),
|
|
159
167
|
openCardEdit: mock(),
|
|
160
168
|
toggleDiffView: mock(),
|
|
169
|
+
openShareDialog: mock(),
|
|
161
170
|
...overrides,
|
|
162
171
|
};
|
|
163
172
|
}
|
|
@@ -385,7 +394,7 @@ describe("openSubmit", () => {
|
|
|
385
394
|
});
|
|
386
395
|
|
|
387
396
|
describe("share", () => {
|
|
388
|
-
test("opens the
|
|
397
|
+
test("opens the share dialog instead of publishing directly", () => {
|
|
389
398
|
// Arrange
|
|
390
399
|
const deps = makeDeps();
|
|
391
400
|
const dispatch = createIntentDispatch(deps);
|
|
@@ -394,7 +403,7 @@ describe("share", () => {
|
|
|
394
403
|
dispatch({ type: "share" });
|
|
395
404
|
|
|
396
405
|
// Assert
|
|
397
|
-
expect(deps.
|
|
406
|
+
expect(deps.openShareDialog).toHaveBeenCalled();
|
|
398
407
|
expect(deps.controller.share).not.toHaveBeenCalled();
|
|
399
408
|
});
|
|
400
409
|
});
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
11
|
-
import { isAddressed,
|
|
11
|
+
import { isAddressed, type Thread, type VerdictKind } from "@cueloop/schema";
|
|
12
12
|
import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
|
|
13
13
|
import type { DiffRow } from "./view-diff";
|
|
14
14
|
import type { ReviewController } from "./thread-controller";
|
|
@@ -18,7 +18,7 @@ import { quickActionBody, type QuickAction } from "./config";
|
|
|
18
18
|
import { VERDICTS } from "./components/ConfirmCard";
|
|
19
19
|
|
|
20
20
|
/** Which pane of the session tree / review the keyboard grammar is aimed at. */
|
|
21
|
-
export type RailTab = "review" | "
|
|
21
|
+
export type RailTab = "review" | "tree";
|
|
22
22
|
|
|
23
23
|
/** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
|
|
24
24
|
export type Mode =
|
|
@@ -40,7 +40,6 @@ export type Mode =
|
|
|
40
40
|
| { type: "renameThread"; sessionId: string; text: string }
|
|
41
41
|
| { type: "nameSelf"; text: string }
|
|
42
42
|
| { type: "renameSelf"; text: string }
|
|
43
|
-
| { type: "shareChoice"; index: number }
|
|
44
43
|
| { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
|
|
45
44
|
|
|
46
45
|
/** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
|
|
@@ -51,23 +50,6 @@ export function activeSpanState(mode: Mode): SpanState | null {
|
|
|
51
50
|
return mode.type === "span" || mode.type === "spanActions" ? mode.span : null;
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
/**
|
|
55
|
-
* Annotations that still count as feedback: agent notes never do, and an
|
|
56
|
-
* annotation a revision already addressed is settled - it neither blocks the
|
|
57
|
-
* verdict default nor re-enters the next feedback document.
|
|
58
|
-
*/
|
|
59
|
-
export function reviewerAnnotations(session: Thread) {
|
|
60
|
-
return session.annotations.filter(
|
|
61
|
-
(annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function defaultVerdict(session: Thread): VerdictKind {
|
|
66
|
-
return reviewerAnnotations(session).length || session.workingCopy !== undefined
|
|
67
|
-
? "request_changes"
|
|
68
|
-
: "approve";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
53
|
/**
|
|
72
54
|
* Everything the reducer reads or writes, supplied fresh each render: the
|
|
73
55
|
* controller and exit hook, the derived reads (view rows, cursor, mode,
|
|
@@ -86,6 +68,7 @@ export interface IntentDispatchDeps {
|
|
|
86
68
|
inboxCursor: number;
|
|
87
69
|
mode: Mode;
|
|
88
70
|
session: Thread | null;
|
|
71
|
+
defaultVerdict: VerdictKind;
|
|
89
72
|
focusedAnnotationId: string | undefined;
|
|
90
73
|
/** The curation item selected for undo, if any. */
|
|
91
74
|
selectedCurationId: string | undefined;
|
|
@@ -115,10 +98,12 @@ export interface IntentDispatchDeps {
|
|
|
115
98
|
setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
|
|
116
99
|
|
|
117
100
|
selectCardFromDocument: (annotationId: string) => void;
|
|
118
|
-
|
|
101
|
+
openBodyEditor: () => void;
|
|
119
102
|
openCardEdit: (annotationId: string) => void;
|
|
120
103
|
/** Flip split/stacked diff and persist it (App-owned); split lays out only when wide/zoomed. */
|
|
121
104
|
toggleDiffView: () => void;
|
|
105
|
+
/** Open the share dialog (App-owned); the dialog owns its own keys and links. */
|
|
106
|
+
openShareDialog: () => void;
|
|
122
107
|
}
|
|
123
108
|
|
|
124
109
|
type IntentOfType<Kind extends Intent["type"]> = Extract<Intent, { type: Kind }>;
|
|
@@ -383,12 +368,12 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
|
|
|
383
368
|
|
|
384
369
|
if (!session) return;
|
|
385
370
|
deps.liveInput.current = "";
|
|
386
|
-
deps.setMode({ type: "submit", verdict: defaultVerdict
|
|
371
|
+
deps.setMode({ type: "submit", verdict: deps.defaultVerdict, summary: "" });
|
|
387
372
|
}
|
|
388
373
|
|
|
389
|
-
// share opens the
|
|
374
|
+
// share opens the share dialog; it owns its own keys, links list, and publish wizard
|
|
390
375
|
function handleShare(_intent: IntentOfType<"share">, deps: IntentDispatchDeps): void {
|
|
391
|
-
deps.
|
|
376
|
+
deps.openShareDialog();
|
|
392
377
|
}
|
|
393
378
|
|
|
394
379
|
function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
|
|
@@ -450,7 +435,7 @@ function handleRestoreCuration(
|
|
|
450
435
|
}
|
|
451
436
|
|
|
452
437
|
function handleEdit(_intent: IntentOfType<"edit">, deps: IntentDispatchDeps): void {
|
|
453
|
-
deps.
|
|
438
|
+
deps.openBodyEditor();
|
|
454
439
|
}
|
|
455
440
|
|
|
456
441
|
function handleEditCard(_intent: IntentOfType<"editCard">, deps: IntentDispatchDeps): void {
|
package/src/key-bindings.ts
CHANGED
|
@@ -35,9 +35,6 @@ export type HintMode =
|
|
|
35
35
|
| "walk"
|
|
36
36
|
| "read-only";
|
|
37
37
|
|
|
38
|
-
/** Display glyph for the Agent-tab terminal detach chord (ctrl+], App-owned). */
|
|
39
|
-
export const AGENT_DETACH_HINT = "⌃]";
|
|
40
|
-
|
|
41
38
|
export interface CheatsheetEntry {
|
|
42
39
|
keys: string;
|
|
43
40
|
label: string;
|
|
@@ -453,9 +450,6 @@ export class KeyBindings {
|
|
|
453
450
|
build({ overlay: "none", spanMode: true }, "span", "Selection"),
|
|
454
451
|
build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
|
|
455
452
|
build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
|
|
456
|
-
// The Agent-tab terminal chord is owned by App (not the rebindable keymap),
|
|
457
|
-
// so it is listed statically rather than resolved from the active keys.
|
|
458
|
-
{ title: "Agent terminal", entries: [{ keys: AGENT_DETACH_HINT, label: "detach" }] },
|
|
459
453
|
];
|
|
460
454
|
|
|
461
455
|
this.context = saved;
|
package/src/keymap.ts
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native SyntaxStyle for the inline markdown editor: maps each markdown
|
|
3
|
+
* token group to a theme color and attribute set, and resolves the styleId the
|
|
4
|
+
* edit buffer needs for `addHighlightByCharRange`. Cached per theme, mirroring
|
|
5
|
+
* syntaxStyleFor. Syntax punctuation (the "marker" group) is dimmed rather than
|
|
6
|
+
* hidden, since the editor shows raw source.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SyntaxStyle, type StyleDefinitionInput } from "@opentui/core";
|
|
10
|
+
import { type Theme } from "./theme";
|
|
11
|
+
import { MARKDOWN_HIGHLIGHT_GROUPS, type MarkdownHighlightGroup } from "./markdown-highlight";
|
|
12
|
+
|
|
13
|
+
/** A markdown SyntaxStyle plus the lookup from token group to its native styleId. */
|
|
14
|
+
export interface MarkdownEditorStyle {
|
|
15
|
+
style: SyntaxStyle;
|
|
16
|
+
styleIdFor: (group: MarkdownHighlightGroup) => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function markdownGroupStyles(theme: Theme): Record<MarkdownHighlightGroup, StyleDefinitionInput> {
|
|
20
|
+
return {
|
|
21
|
+
heading: { bold: true },
|
|
22
|
+
marker: { fg: theme.textDim },
|
|
23
|
+
link: { fg: theme.blue, underline: true },
|
|
24
|
+
code: { fg: theme.textDim },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const markdownEditorStyleCache = new WeakMap<Theme, MarkdownEditorStyle>();
|
|
29
|
+
|
|
30
|
+
/** Build (or reuse) the markdown editor's SyntaxStyle for a theme, with a group-to-styleId lookup. */
|
|
31
|
+
export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
|
|
32
|
+
const cached = markdownEditorStyleCache.get(theme);
|
|
33
|
+
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
const groupStyles = markdownGroupStyles(theme);
|
|
36
|
+
const style = SyntaxStyle.fromStyles(groupStyles);
|
|
37
|
+
const styleIds = new Map<MarkdownHighlightGroup, number>();
|
|
38
|
+
|
|
39
|
+
for (const group of MARKDOWN_HIGHLIGHT_GROUPS) {
|
|
40
|
+
styleIds.set(group, style.getStyleId(group) ?? 0);
|
|
41
|
+
}
|
|
42
|
+
const entry: MarkdownEditorStyle = {
|
|
43
|
+
style,
|
|
44
|
+
styleIdFor: (group) => styleIds.get(group) ?? 0,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
markdownEditorStyleCache.set(theme, entry);
|
|
48
|
+
|
|
49
|
+
return entry;
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
layoutFrontmatterGrid,
|
|
4
|
+
layoutMarkdownTable,
|
|
5
|
+
parseFrontmatterRows,
|
|
6
|
+
parseMarkdownTable,
|
|
7
|
+
} from "./markdown-grid";
|
|
8
|
+
|
|
9
|
+
/** The plain text of a grid line, segments concatenated. */
|
|
10
|
+
function lineText(line: { text: string }[]): string {
|
|
11
|
+
return line.map((segment) => segment.text).join("");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("parseMarkdownTable", () => {
|
|
15
|
+
test("reads header cells, alignments, and body rows", () => {
|
|
16
|
+
const table = parseMarkdownTable("| Name | Size |\n| :--- | ---: |\n| a | 1 |\n| bb | 22 |");
|
|
17
|
+
|
|
18
|
+
expect(table.header).toEqual(["Name", "Size"]);
|
|
19
|
+
expect(table.aligns).toEqual(["left", "right"]);
|
|
20
|
+
expect(table.rows).toEqual([
|
|
21
|
+
["a", "1"],
|
|
22
|
+
["bb", "22"],
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("center alignment reads from a :---: delimiter", () => {
|
|
27
|
+
expect(parseMarkdownTable("| a |\n| :---: |\n| x |").aligns).toEqual(["center"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("an escaped pipe stays inside its cell instead of splitting the column", () => {
|
|
31
|
+
const table = parseMarkdownTable("| Name | Note |\n| --- | --- |\n| a | b \\| c |");
|
|
32
|
+
|
|
33
|
+
expect(table.rows).toEqual([["a", "b | c"]]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a blank cell between border pipes survives the border strip", () => {
|
|
37
|
+
expect(parseMarkdownTable("| | b |\n| --- | --- |\n| x | y |").header).toEqual(["", "b"]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("layoutMarkdownTable", () => {
|
|
42
|
+
test("bolds the header, rules under it, and right-aligns a numeric column", () => {
|
|
43
|
+
const lines = layoutMarkdownTable(
|
|
44
|
+
parseMarkdownTable("| Name | Size |\n| --- | ---: |\n| a | 1 |"),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(lines[0]![0]!.bold).toBe(true);
|
|
48
|
+
expect(lineText(lines[0]!)).toBe("Name Size");
|
|
49
|
+
expect(lines[1]![0]!.dim).toBe(true);
|
|
50
|
+
expect(lineText(lines[1]!)).toBe("─".repeat("Name Size".length));
|
|
51
|
+
// "a" padded to the "Name" column, "1" right-aligned under "Size"
|
|
52
|
+
expect(lineText(lines[2]!)).toBe("a" + " ".repeat(8) + "1");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("parseFrontmatterRows", () => {
|
|
57
|
+
test("splits top-level key: value pairs", () => {
|
|
58
|
+
expect(parseFrontmatterRows("title: Plan\nowner: me")).toEqual([
|
|
59
|
+
{ key: "title", value: "Plan" },
|
|
60
|
+
{ key: "owner", value: "me" },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("folds an indented continuation into the row above", () => {
|
|
65
|
+
expect(parseFrontmatterRows("tags:\n - one\n - two")).toEqual([
|
|
66
|
+
{ key: "tags", value: "- one\n- two" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("layoutFrontmatterGrid", () => {
|
|
72
|
+
test("draws a full border with a bold key and a rule between rows", () => {
|
|
73
|
+
const lines = layoutFrontmatterGrid(
|
|
74
|
+
[
|
|
75
|
+
{ key: "title", value: "Plan" },
|
|
76
|
+
{ key: "owner", value: "me" },
|
|
77
|
+
],
|
|
78
|
+
40,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(lineText(lines[0]!).startsWith("┌")).toBe(true);
|
|
82
|
+
expect(lineText(lines.at(-1)!).startsWith("└")).toBe(true);
|
|
83
|
+
// the key cell is bold, the border pieces are dim
|
|
84
|
+
const firstRow = lines[1]!;
|
|
85
|
+
|
|
86
|
+
expect(firstRow.find((segment) => segment.text.includes("title"))!.bold).toBe(true);
|
|
87
|
+
expect(firstRow[0]!.dim).toBe(true);
|
|
88
|
+
// a divider separates the two rows
|
|
89
|
+
expect(lines.some((line) => lineText(line).startsWith("├"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("wraps a long value across cell lines, keeping the key on the first", () => {
|
|
93
|
+
const lines = layoutFrontmatterGrid([{ key: "desc", value: "one two three four five" }], 24);
|
|
94
|
+
const bodyLines = lines.filter((line) => lineText(line).startsWith("│"));
|
|
95
|
+
|
|
96
|
+
expect(bodyLines.length).toBeGreaterThan(1);
|
|
97
|
+
expect(bodyLines[0]!.some((segment) => segment.text.includes("desc"))).toBe(true);
|
|
98
|
+
// the continuation line keeps the key column blank
|
|
99
|
+
expect(bodyLines[1]!.some((segment) => segment.text.includes("desc"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|