@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.
Files changed (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. 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 ? tokens.elevated : undefined,
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 { allowEventLoopUpdates } from "../test-support";
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
- allowEventLoopUpdates();
49
- await setup.waitForVisualIdle();
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
@@ -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), matching VSCode's "expand all lines".
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
- runEditorHandOff: mock(),
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 public/private choice overlay instead of publishing directly", () => {
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.setMode).toHaveBeenCalledWith({ type: "shareChoice", index: 0 });
406
+ expect(deps.openShareDialog).toHaveBeenCalled();
398
407
  expect(deps.controller.share).not.toHaveBeenCalled();
399
408
  });
400
409
  });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
11
- import { isAddressed, isAgentNote, type Thread, type VerdictKind } from "@cueloop/schema";
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" | "agent" | "tree";
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
- runEditorHandOff: () => void;
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(session), summary: "" });
371
+ deps.setMode({ type: "submit", verdict: deps.defaultVerdict, summary: "" });
387
372
  }
388
373
 
389
- // share opens the public/private choice overlay; the App owns the choice keys and publishes on pick
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.setMode({ type: "shareChoice", index: 0 });
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.runEditorHandOff();
438
+ deps.openBodyEditor();
454
439
  }
455
440
 
456
441
  function handleEditCard(_intent: IntentOfType<"editCard">, deps: IntentDispatchDeps): void {
@@ -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
@@ -87,7 +87,6 @@ export interface KeyState {
87
87
  | "submit"
88
88
  | "confirm"
89
89
  | "prompt"
90
- | "shareChoice"
91
90
  | "spanActions"
92
91
  | "completion-prompt"
93
92
  | "completion-counting";
@@ -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
+ });