@cueloop/client 0.1.0-alpha.77 → 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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
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";
@@ -1,5 +1,11 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { intralineRunsByRow, wordLevelChanges, type IntralineRun } from "./diff-intraline";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import * as intraline from "./diff-intraline";
3
+ import {
4
+ createIntralineResolver,
5
+ intralineRunsByRow,
6
+ wordLevelChanges,
7
+ type IntralineRun,
8
+ } from "./diff-intraline";
3
9
  import type { DiffRow } from "./view-diff";
4
10
 
5
11
  function line(kind: DiffRow["kind"], text: string): DiffRow {
@@ -163,3 +169,70 @@ describe("intralineRunsByRow", () => {
163
169
  expect(changedText(runsByRow.get(3)!)).toBe("FOUR");
164
170
  });
165
171
  });
172
+
173
+ describe("createIntralineResolver", () => {
174
+ test("resolves each row to the same runs the eager pass would, on demand", () => {
175
+ // Arrange - context around two separate change blocks
176
+ const rows = [
177
+ line("ctx", "header"),
178
+ line("del", "one two"),
179
+ line("add", "one TWO"),
180
+ line("ctx", "middle"),
181
+ line("del", "alpha beta"),
182
+ line("add", "alpha GAMMA"),
183
+ line("ctx", "footer"),
184
+ ];
185
+ const eager = intralineRunsByRow(rows);
186
+
187
+ // Act
188
+ const resolver = createIntralineResolver(rows);
189
+
190
+ // Assert - lazy row-by-row output matches the eager map, including undefined for context rows
191
+ for (let index = 0; index < rows.length; index++) {
192
+ expect(resolver.runsForRow(index)).toEqual(eager.get(index));
193
+ }
194
+ });
195
+
196
+ test("a second call for the same row returns the cached runs", () => {
197
+ const rows = [line("del", "one two"), line("add", "one TWO")];
198
+ const resolver = createIntralineResolver(rows);
199
+
200
+ expect(resolver.runsForRow(0)).toBe(resolver.runsForRow(0));
201
+ });
202
+
203
+ // The performance guard: a virtualized diff must word-diff only the change blocks whose rows are on
204
+ // screen. This is deterministic (a call count, not a wall-clock), so it fails on any machine if a
205
+ // future change makes the resolver eager again - the regression the nav-latency benchmark would show.
206
+ test("word-diffs only the accessed block, not the whole diff", () => {
207
+ // Arrange - 50 independent change blocks, each a single del/add pair separated by context
208
+ const rows: DiffRow[] = [];
209
+
210
+ for (let block = 0; block < 50; block++) {
211
+ rows.push(
212
+ line("ctx", `ctx ${block}`),
213
+ line("del", `old ${block}`),
214
+ line("add", `new ${block}`),
215
+ );
216
+ }
217
+ const spy = spyOn(intraline, "wordLevelChanges");
218
+
219
+ try {
220
+ // Act - the eager pass word-diffs every block
221
+ spy.mockClear();
222
+ intralineRunsByRow(rows);
223
+ const eagerCalls = spy.mock.calls.length;
224
+
225
+ // Act - the resolver word-diffs only the one block whose row is asked for
226
+ spy.mockClear();
227
+ const resolver = createIntralineResolver(rows);
228
+ resolver.runsForRow(1); // a row in block 0
229
+ const oneBlockCalls = spy.mock.calls.length;
230
+
231
+ // Assert - eager pays for all 50 blocks; lazy pays for one
232
+ expect(eagerCalls).toBe(50);
233
+ expect(oneBlockCalls).toBe(1);
234
+ } finally {
235
+ spy.mockRestore();
236
+ }
237
+ });
238
+ });
@@ -36,16 +36,16 @@ function stripTrailingNewline(text: string): string {
36
36
  return text.replace(/\n$/, "");
37
37
  }
38
38
 
39
- /**
40
- * Char-precise intra-line runs for modified diff rows. Within a change block - a
41
- * run of deletions immediately followed by additions - lines are aligned to their
42
- * real counterpart (see alignedPairs) and each matched pair is word-diffed so the
43
- * removed/added words are marked changed. Rows with no counterpart (pure add, pure
44
- * delete, or an unmatched line in a shifted block) are omitted, so the caller
45
- * renders those as a single run. Each row's runs concatenate back to that row's text.
46
- */
47
- export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
48
- const runsByRow = new Map<number, IntralineRun[]>();
39
+ /** A change block: a run of deletion rows immediately followed by addition rows. */
40
+ interface IntralineBlock {
41
+ deletionStart: number;
42
+ additionStart: number;
43
+ end: number;
44
+ }
45
+
46
+ /** The change blocks in row order, found in one cheap pass (no word diff yet). */
47
+ function intralineBlocks(rows: DiffRow[]): IntralineBlock[] {
48
+ const blocks: IntralineBlock[] = [];
49
49
  let index = 0;
50
50
 
51
51
  while (index < rows.length) {
@@ -59,22 +59,91 @@ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]>
59
59
  const additionStart = index;
60
60
 
61
61
  while (index < rows.length && rows[index]!.kind === "add") index++;
62
- const deletionTexts = rows
63
- .slice(deletionStart, additionStart)
64
- .map((row) => stripTrailingNewline(row.text));
65
- const additionTexts = rows
66
- .slice(additionStart, index)
67
- .map((row) => stripTrailingNewline(row.text));
68
-
69
- for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
70
- const changes = wordLevelChanges(
71
- deletionTexts[deletionOffset]!,
72
- additionTexts[additionOffset]!,
73
- );
74
-
75
- runsByRow.set(deletionStart + deletionOffset, sideRuns(changes, "removed"));
76
- runsByRow.set(additionStart + additionOffset, sideRuns(changes, "added"));
77
- }
62
+ blocks.push({ deletionStart, additionStart, end: index });
63
+ }
64
+
65
+ return blocks;
66
+ }
67
+
68
+ /** Word-diff one change block into [rowIndex, runs] pairs; unmatched rows get no entry. */
69
+ function blockRunEntries(rows: DiffRow[], block: IntralineBlock): Array<[number, IntralineRun[]]> {
70
+ const deletionTexts = rows
71
+ .slice(block.deletionStart, block.additionStart)
72
+ .map((row) => stripTrailingNewline(row.text));
73
+ const additionTexts = rows
74
+ .slice(block.additionStart, block.end)
75
+ .map((row) => stripTrailingNewline(row.text));
76
+ const entries: Array<[number, IntralineRun[]]> = [];
77
+
78
+ for (const [deletionOffset, additionOffset] of alignedPairs(deletionTexts, additionTexts)) {
79
+ const changes = wordLevelChanges(
80
+ deletionTexts[deletionOffset]!,
81
+ additionTexts[additionOffset]!,
82
+ );
83
+
84
+ entries.push([block.deletionStart + deletionOffset, sideRuns(changes, "removed")]);
85
+ entries.push([block.additionStart + additionOffset, sideRuns(changes, "added")]);
86
+ }
87
+
88
+ return entries;
89
+ }
90
+
91
+ /** The change block whose rows cover `rowIndex`, by binary search over the ordered blocks. */
92
+ function blockContaining(blocks: IntralineBlock[], rowIndex: number): number | undefined {
93
+ let low = 0;
94
+ let high = blocks.length - 1;
95
+
96
+ while (low <= high) {
97
+ const mid = (low + high) >> 1;
98
+ const block = blocks[mid]!;
99
+
100
+ if (rowIndex < block.deletionStart) high = mid - 1;
101
+ else if (rowIndex >= block.end) low = mid + 1;
102
+ else return mid;
103
+ }
104
+
105
+ return undefined;
106
+ }
107
+
108
+ /** A row's intra-line runs, resolved on demand. */
109
+ export interface IntralineResolver {
110
+ runsForRow(rowIndex: number): IntralineRun[] | undefined;
111
+ }
112
+
113
+ /**
114
+ * Char-precise intra-line runs for modified diff rows, computed lazily. The change blocks are found
115
+ * up front in one cheap pass; a block is word-diffed only when a row inside it is first asked for,
116
+ * so a virtualized diff pays for the rows on screen, not for the whole file. Within a change block -
117
+ * deletions immediately followed by additions - lines align to their real counterpart (alignedPairs)
118
+ * and each matched pair is word-diffed; unmatched rows get no runs, so the caller renders those whole.
119
+ */
120
+ export function createIntralineResolver(rows: DiffRow[]): IntralineResolver {
121
+ const blocks = intralineBlocks(rows);
122
+ const computed = new Set<number>();
123
+ const runsByRow = new Map<number, IntralineRun[]>();
124
+
125
+ return {
126
+ runsForRow(rowIndex) {
127
+ const blockIndex = blockContaining(blocks, rowIndex);
128
+
129
+ if (blockIndex === undefined) return undefined;
130
+ if (!computed.has(blockIndex)) {
131
+ computed.add(blockIndex);
132
+ for (const [row, runs] of blockRunEntries(rows, blocks[blockIndex]!))
133
+ runsByRow.set(row, runs);
134
+ }
135
+
136
+ return runsByRow.get(rowIndex);
137
+ },
138
+ };
139
+ }
140
+
141
+ /** Every modified row's intra-line runs, eagerly. Prefer createIntralineResolver on a hot path. */
142
+ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]> {
143
+ const runsByRow = new Map<number, IntralineRun[]>();
144
+
145
+ for (const block of intralineBlocks(rows)) {
146
+ for (const [row, runs] of blockRunEntries(rows, block)) runsByRow.set(row, runs);
78
147
  }
79
148
 
80
149
  return runsByRow;
@@ -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;
@@ -59,8 +59,8 @@ describe("submit overlay", () => {
59
59
  const keyState = state({ overlay: "submit" });
60
60
  const table: [string, Intent[]][] = [
61
61
  ["escape", [{ type: "closeOverlay" }]],
62
- ["return", [{ type: "submitVerdict" }]],
63
- ["enter", [{ type: "submitVerdict" }]],
62
+ ["return", []],
63
+ ["enter", []],
64
64
  ["left", [{ type: "cycleVerdict", direction: -1 }]],
65
65
  ["right", [{ type: "cycleVerdict", direction: 1 }]],
66
66
  ["j", []],
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";
@@ -193,7 +192,6 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
193
192
  const name = key.name;
194
193
 
195
194
  if (name === "escape") return [{ type: "closeOverlay" }];
196
- if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
197
195
  if (name === "left" || name === "right") {
198
196
  return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
199
197
  }
@@ -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
+ });