@crouton-kit/humanloop 0.4.1 → 0.4.3

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 (112) hide show
  1. package/dist/editor/review.d.ts +5 -4
  2. package/dist/editor/review.js +14 -1
  3. package/dist/editor/terminal-review.d.ts +76 -0
  4. package/dist/editor/terminal-review.js +491 -0
  5. package/dist/inbox/deck-factories.js +1 -0
  6. package/dist/inbox/deck-schema.d.ts +1 -1
  7. package/dist/inbox/deck-schema.js +2 -2
  8. package/dist/inbox/scan.js +1 -1
  9. package/dist/types.d.ts +2 -1
  10. package/dist/web/assets/abnfDiagram-VRR7QNED-B71KRG4-.js +1 -0
  11. package/dist/web/assets/arc-BzH4jnTf.js +1 -0
  12. package/dist/web/assets/architecture-TIHT7OUA-CZWJOUVd.js +1 -0
  13. package/dist/web/assets/architectureDiagram-ZJ3FMSHR-D8ZCrOlr.js +36 -0
  14. package/dist/web/assets/array-BifhSqXX.js +1 -0
  15. package/dist/web/assets/blockDiagram-677ZJIJ3-DJrvEHzG.js +132 -0
  16. package/dist/web/assets/c4Diagram-LMCZKHZV-BhpT2Gsz.js +10 -0
  17. package/dist/web/assets/channel-C_TWBgFl.js +1 -0
  18. package/dist/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  19. package/dist/web/assets/chunk-32BRIVSS-ECC6qGeB.js +1 -0
  20. package/dist/web/assets/chunk-52WLFC77-DavNmYy5.js +10 -0
  21. package/dist/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  22. package/dist/web/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  23. package/dist/web/assets/chunk-C7G6YPKG-BAknDHkJ.js +1 -0
  24. package/dist/web/assets/chunk-EX3LRPZG-D8FzKvzP.js +231 -0
  25. package/dist/web/assets/chunk-FWX5IMBZ-DmneLaBP.js +2 -0
  26. package/dist/web/assets/chunk-HOUHSVGY-DfL8sRH8.js +1 -0
  27. package/dist/web/assets/chunk-ICXQ74PX-BDsAibh6.js +2 -0
  28. package/dist/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  29. package/dist/web/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  30. package/dist/web/assets/chunk-MOJQB5TN-CNuZ6pa2.js +88 -0
  31. package/dist/web/assets/chunk-OGEWGWER-BzCEWO5x.js +1 -0
  32. package/dist/web/assets/chunk-PUDLZKDR-C9EdjbBR.js +156 -0
  33. package/dist/web/assets/chunk-Q4XR5HBZ-BXjEOFZL.js +70 -0
  34. package/dist/web/assets/chunk-RYQCIY6F-Bjx13MLc.js +1 -0
  35. package/dist/web/assets/chunk-V7JOEXUC-Tvu7hSiY.js +206 -0
  36. package/dist/web/assets/chunk-VAUOI2AC-CtE8-0Em.js +1 -0
  37. package/dist/web/assets/chunk-VR4S4FIN-BxDOvumS.js +1 -0
  38. package/dist/web/assets/chunk-WYO6CB5R-NSErcIKL.js +127 -0
  39. package/dist/web/assets/chunk-XXDRQBXY-CvnzTWOn.js +1 -0
  40. package/dist/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  41. package/dist/web/assets/chunk-ZGVPDNZ5-c3Lgoc6U.js +62 -0
  42. package/dist/web/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  43. package/dist/web/assets/classDiagram-OUVF2IWQ-BraUhK-5.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-EOCWNBFH-BraUhK-5.js +1 -0
  45. package/dist/web/assets/cose-bilkent-JH36ORCC-FucxEJcd.js +1 -0
  46. package/dist/web/assets/cynefin-VYW2F7L2-Dao-QhOj.js +1 -0
  47. package/dist/web/assets/cynefinDiagram-TSTJHNR4-CTzeNNi6.js +62 -0
  48. package/dist/web/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  49. package/dist/web/assets/dagre-CXRCoUWR.js +1 -0
  50. package/dist/web/assets/dagre-VKFMJZFB-DHJBohq9.js +4 -0
  51. package/dist/web/assets/defaultLocale-C8Fc0cco.js +1 -0
  52. package/dist/web/assets/diagram-FQU43EPY-BJD429Ew.js +3 -0
  53. package/dist/web/assets/diagram-G47NLZAW-Rr7qWaPB.js +24 -0
  54. package/dist/web/assets/diagram-NH7WQ7WH-DWt2lASp.js +24 -0
  55. package/dist/web/assets/diagram-OA4YK3LP-BEffXGoK.js +30 -0
  56. package/dist/web/assets/diagram-WEI45ONY-DmptqIxd.js +41 -0
  57. package/dist/web/assets/dist-RLVSx5kB.js +1 -0
  58. package/dist/web/assets/ebnfDiagram-CCIWWBDH-CJAdBcoT.js +1 -0
  59. package/dist/web/assets/erDiagram-Q63AITRT-NlmP932t.js +85 -0
  60. package/dist/web/assets/eventmodeling-45OFAUF4-BKX8_y74.js +1 -0
  61. package/dist/web/assets/flowDiagram-23GEKE2U-CBN8yaFm.js +1 -0
  62. package/dist/web/assets/ganttDiagram-NO4QXBWP-DhaQF0vK.js +292 -0
  63. package/dist/web/assets/gitGraph-TEB2WS4Q-jJerQW_A.js +1 -0
  64. package/dist/web/assets/gitGraphDiagram-IHSO6WYX-B7ujXqgi.js +106 -0
  65. package/dist/web/assets/graphlib-B8gBHxth.js +1 -0
  66. package/dist/web/assets/{index-YFwgZZMg.js → index-BMZ4RcHs.js} +5 -4
  67. package/dist/web/assets/{index-BxJZgs8l.css → index-DV35aYD7.css} +1 -1
  68. package/dist/web/assets/info-DKCQHKI2-9B9vE5Rs.js +1 -0
  69. package/dist/web/assets/infoDiagram-FWYZ7A6U-QVQfmq5Q.js +2 -0
  70. package/dist/web/assets/init-D6jRqBbL.js +1 -0
  71. package/dist/web/assets/ishikawaDiagram-FXEZZL3T-DINx8lcq.js +70 -0
  72. package/dist/web/assets/journeyDiagram-5HDEW3XC-CCimY8Ca.js +139 -0
  73. package/dist/web/assets/kanban-definition-HUTT4EX6-CiyVpYz7.js +89 -0
  74. package/dist/web/assets/katex-CddkPoXu.js +257 -0
  75. package/dist/web/assets/line-BS6nDp3b.js +1 -0
  76. package/dist/web/assets/linear-Djl12XgD.js +1 -0
  77. package/dist/web/assets/map-DsCK-0Cs.js +1 -0
  78. package/dist/web/assets/mermaid-parser.core-BbWtntfd.js +7 -0
  79. package/dist/web/assets/mermaid.core-BxoR3aGT.js +11 -0
  80. package/dist/web/assets/mindmap-definition-LN4V7U3C-CP5EmYtx.js +96 -0
  81. package/dist/web/assets/ordinal-hYBb2elL.js +1 -0
  82. package/dist/web/assets/packet-7NZHBO7P-BZxF_2Fs.js +1 -0
  83. package/dist/web/assets/path-BWPyau1x.js +1 -0
  84. package/dist/web/assets/pegDiagram-2B236MQR-qzgVPCkt.js +1 -0
  85. package/dist/web/assets/pie-RZYD4A2V-BBSGE3OQ.js +1 -0
  86. package/dist/web/assets/pieDiagram-ENE6RG2P-C-IRFSgk.js +39 -0
  87. package/dist/web/assets/quadrantDiagram-ABIIQ3AL-T5ncf2ka.js +7 -0
  88. package/dist/web/assets/radar-I7S5WNFK-BEbxioN2.js +1 -0
  89. package/dist/web/assets/railroad-3IZDKUUU-CylJQRva.js +1 -0
  90. package/dist/web/assets/railroad-abnf-AHOZXSZD-CFDo5gvn.js +1 -0
  91. package/dist/web/assets/railroad-ebnf-EBAXGLYW-Cg-yyAK9.js +1 -0
  92. package/dist/web/assets/railroad-peg-LSFZ7HO6-DcopMOfK.js +1 -0
  93. package/dist/web/assets/railroadDiagram-RFXS5EU6-CBYIviLP.js +1 -0
  94. package/dist/web/assets/requirementDiagram-TGXJPOKE-DnUCDDfN.js +84 -0
  95. package/dist/web/assets/rough.esm-CSKSodPl.js +1 -0
  96. package/dist/web/assets/sankeyDiagram-HTMAVEWB-BPcUd5rv.js +40 -0
  97. package/dist/web/assets/sequenceDiagram-DBY2YBRQ-B_KozvYN.js +162 -0
  98. package/dist/web/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  99. package/dist/web/assets/src-COFaHe7g.js +1 -0
  100. package/dist/web/assets/stateDiagram-2N3HPSRC-CU0lc0nI.js +1 -0
  101. package/dist/web/assets/stateDiagram-v2-6OUMAXLB-CSRqotH1.js +1 -0
  102. package/dist/web/assets/swimlanes-5IMT3BWC-ChpfV6mg.js +2 -0
  103. package/dist/web/assets/swimlanesDiagram-G3AALYLV-Cob34HwG.js +8 -0
  104. package/dist/web/assets/timeline-definition-FHXFAJF6-D6Uo2p7h.js +120 -0
  105. package/dist/web/assets/treeView-QDETBFTQ-BqyWL8XS.js +1 -0
  106. package/dist/web/assets/treemap-6X3UGDF4-DZt75S3Q.js +1 -0
  107. package/dist/web/assets/vennDiagram-L72KCM5P-Bv7XNrPm.js +34 -0
  108. package/dist/web/assets/wardley-OPB4EBWU-CeEs_UEB.js +1 -0
  109. package/dist/web/assets/wardleyDiagram-EHGQE667-C4cUF7Ft.js +78 -0
  110. package/dist/web/assets/xychartDiagram-FW5EYKEG-BntLSyoW.js +7 -0
  111. package/dist/web/index.html +2 -2
  112. package/package.json +1 -1
@@ -24,9 +24,10 @@ export declare function reviewVimscript(): string;
24
24
  */
25
25
  export declare function formatFeedbackSummary(result: FeedbackResult, feedbackJsonPath: string): string;
26
26
  /**
27
- * Open a markdown file in a clean, read-only Neovim/Vim review session. The
28
- * human anchors comments to source lines/selections with native vim motions
29
- * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
30
- * drafts survive exit; canonical ticket finalization belongs to the controller.
27
+ * Open a markdown file for review. Humanloop's own terminal surface
28
+ * (`launchTerminalReview`) is the default: it renders Markdown (including
29
+ * Mermaid) via termrender and anchors comments to source lines. Passing an
30
+ * explicit `opts.editor` opts into the legacy read-only Neovim/Vim session
31
+ * (`launchNeovimReview`), kept only as an explicit compatibility path.
31
32
  */
32
33
  export declare function launchReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -5,6 +5,7 @@ import { resolve, join } from 'path';
5
5
  import { openBrowser } from '../browser/open.js';
6
6
  import { startReviewWebServer } from '../browser/server.js';
7
7
  import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
8
+ import { launchTerminalReview } from './terminal-review.js';
8
9
  function resolveEditor(override) {
9
10
  const candidates = override ? [override] : ['nvim', 'vim'];
10
11
  for (const bin of candidates) {
@@ -598,13 +599,25 @@ async function stopReviewServer(handle) {
598
599
  if (handle !== null)
599
600
  await handle.stop();
600
601
  }
602
+ /**
603
+ * Open a markdown file for review. Humanloop's own terminal surface
604
+ * (`launchTerminalReview`) is the default: it renders Markdown (including
605
+ * Mermaid) via termrender and anchors comments to source lines. Passing an
606
+ * explicit `opts.editor` opts into the legacy read-only Neovim/Vim session
607
+ * (`launchNeovimReview`), kept only as an explicit compatibility path.
608
+ */
609
+ export async function launchReview(file, opts) {
610
+ if (opts.editor !== undefined)
611
+ return launchNeovimReview(file, opts);
612
+ return launchTerminalReview(file, opts);
613
+ }
601
614
  /**
602
615
  * Open a markdown file in a clean, read-only Neovim/Vim review session. The
603
616
  * human anchors comments to source lines/selections with native vim motions
604
617
  * and explicitly submits a proposal. Blocks until the editor exits. Autosaved
605
618
  * drafts survive exit; canonical ticket finalization belongs to the controller.
606
619
  */
607
- export async function launchReview(file, opts) {
620
+ async function launchNeovimReview(file, opts) {
608
621
  const absFile = resolve(file);
609
622
  if (!existsSync(absFile)) {
610
623
  throw new Error(`Markdown file not found: ${absFile}`);
@@ -0,0 +1,76 @@
1
+ import type { FeedbackComment, FeedbackResult } from '../types.js';
2
+ import type { ReviewOptions } from './review.js';
3
+ export interface ComposeState {
4
+ buffer: string;
5
+ cursor: number;
6
+ /** Id of the comment being edited, or null when composing a new comment. */
7
+ editingId: string | null;
8
+ /** Submode to return to once compose closes (Escape or a successful save). */
9
+ returnMode: 'view' | 'list';
10
+ anchor: {
11
+ line: number;
12
+ endLine: number;
13
+ };
14
+ }
15
+ export interface ReviewState {
16
+ sourceLines: string[];
17
+ comments: FeedbackComment[];
18
+ version: number;
19
+ /** 1-based active source line. */
20
+ activeLine: number;
21
+ /** Set while a Shift+j/k range is being extended from this line. */
22
+ selectionAnchor: number | null;
23
+ /** Scroll offset into the rendered (termrender) lines. */
24
+ scroll: number;
25
+ mode: 'view' | 'list' | 'compose' | 'help';
26
+ compose: ComposeState | null;
27
+ listIndex: number;
28
+ }
29
+ export declare function initReviewState(sourceLines: string[], comments: FeedbackComment[], version: number): ReviewState;
30
+ export declare function currentAnchor(state: ReviewState): {
31
+ line: number;
32
+ endLine: number;
33
+ };
34
+ export declare function moveActiveLine(state: ReviewState, delta: number, extend: boolean): ReviewState;
35
+ export declare function scrollBy(state: ReviewState, delta: number, bodyHeight: number, totalLines: number): ReviewState;
36
+ export declare function openComposeNew(state: ReviewState): ReviewState;
37
+ export declare function openComposeEdit(state: ReviewState, index: number): ReviewState;
38
+ export declare function closeCompose(state: ReviewState): ReviewState;
39
+ /** Commits the current compose buffer as a new comment or an edit to an existing one. Empty text cancels. */
40
+ export declare function commitCompose(state: ReviewState, now?: string): ReviewState;
41
+ export declare function openList(state: ReviewState): ReviewState;
42
+ export declare function closeList(state: ReviewState): ReviewState;
43
+ export declare function moveListIndex(state: ReviewState, delta: number): ReviewState;
44
+ export declare function deleteAtListIndex(state: ReviewState): ReviewState;
45
+ export declare function undoLast(state: ReviewState): ReviewState;
46
+ export declare function openHelp(state: ReviewState): ReviewState;
47
+ export declare function closeHelp(state: ReviewState): ReviewState;
48
+ export declare function textInsert(buffer: string, cursor: number, text: string): {
49
+ buffer: string;
50
+ cursor: number;
51
+ };
52
+ export declare function textBackspace(buffer: string, cursor: number): {
53
+ buffer: string;
54
+ cursor: number;
55
+ };
56
+ export declare function textDelete(buffer: string, cursor: number): {
57
+ buffer: string;
58
+ cursor: number;
59
+ };
60
+ export declare function textHome(buffer: string, cursor: number): number;
61
+ export declare function textEnd(buffer: string, cursor: number): number;
62
+ export declare function textVertical(buffer: string, cursor: number, delta: number): number;
63
+ /** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
64
+ * sync with renderReviewFrame so scroll math can clamp before a repaint. */
65
+ export declare function reservedRows(state: ReviewState): number;
66
+ export declare function renderReviewFrame(state: ReviewState, fileLabel: string, renderedLines: string[], cols: number, rows: number): string[];
67
+ /**
68
+ * Open a Markdown file in humanloop's own terminal review surface: the
69
+ * document renders via termrender (Mermaid included), source-line anchors
70
+ * drive comments independent of rendered scroll position, and the human
71
+ * explicitly submits a proposal. Never edits the source file. Autosaves the
72
+ * comment draft to `opts.output` (the `progress.json` convention). Canonical
73
+ * ticket finalization stays with the caller's `onPropose` (the adapter calls
74
+ * `completeReview`); this function never writes `response.json`.
75
+ */
76
+ export declare function launchTerminalReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
@@ -0,0 +1,491 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename, resolve } from 'node:path';
4
+ import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
5
+ import { renderMarkdown } from '../render/termrender.js';
6
+ import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
7
+ import { diffFrame, renderInputBuffer } from '../tui/render.js';
8
+ import { BOLD, CYAN, DIM, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
9
+ export function initReviewState(sourceLines, comments, version) {
10
+ return {
11
+ sourceLines,
12
+ comments: [...comments],
13
+ version,
14
+ activeLine: comments.length > 0 ? comments[0].line : 1,
15
+ selectionAnchor: null,
16
+ scroll: 0,
17
+ mode: 'view',
18
+ compose: null,
19
+ listIndex: 0,
20
+ };
21
+ }
22
+ export function currentAnchor(state) {
23
+ if (state.selectionAnchor === null)
24
+ return { line: state.activeLine, endLine: state.activeLine };
25
+ return { line: Math.min(state.selectionAnchor, state.activeLine), endLine: Math.max(state.selectionAnchor, state.activeLine) };
26
+ }
27
+ export function moveActiveLine(state, delta, extend) {
28
+ const total = Math.max(1, state.sourceLines.length);
29
+ const next = Math.max(1, Math.min(total, state.activeLine + delta));
30
+ if (!extend)
31
+ return { ...state, activeLine: next, selectionAnchor: null };
32
+ return { ...state, activeLine: next, selectionAnchor: state.selectionAnchor ?? state.activeLine };
33
+ }
34
+ export function scrollBy(state, delta, bodyHeight, totalLines) {
35
+ const maxScroll = Math.max(0, totalLines - bodyHeight);
36
+ return { ...state, scroll: Math.max(0, Math.min(state.scroll + delta, maxScroll)) };
37
+ }
38
+ export function openComposeNew(state) {
39
+ return { ...state, mode: 'compose', compose: { buffer: '', cursor: 0, editingId: null, returnMode: 'view', anchor: currentAnchor(state) } };
40
+ }
41
+ export function openComposeEdit(state, index) {
42
+ const comment = state.comments[index];
43
+ if (comment === undefined)
44
+ return state;
45
+ const buffer = comment.comment;
46
+ return {
47
+ ...state,
48
+ mode: 'compose',
49
+ compose: { buffer, cursor: [...buffer].length, editingId: comment.id, returnMode: 'list', anchor: { line: comment.line, endLine: comment.endLine } },
50
+ };
51
+ }
52
+ export function closeCompose(state) {
53
+ const returnMode = state.compose?.returnMode ?? 'view';
54
+ return { ...state, mode: returnMode, compose: null };
55
+ }
56
+ /** Commits the current compose buffer as a new comment or an edit to an existing one. Empty text cancels. */
57
+ export function commitCompose(state, now = new Date().toISOString()) {
58
+ const c = state.compose;
59
+ if (c === null || c === undefined)
60
+ return state;
61
+ const text = c.buffer.trim();
62
+ if (text.length === 0)
63
+ return closeCompose(state);
64
+ if (c.editingId !== null) {
65
+ const comments = state.comments.map((cm) => (cm.id === c.editingId ? { ...cm, comment: text } : cm));
66
+ return { ...state, comments, mode: c.returnMode, compose: null };
67
+ }
68
+ const lineText = state.sourceLines.slice(c.anchor.line - 1, c.anchor.endLine).join('\n');
69
+ const comment = { id: randomUUID(), line: c.anchor.line, endLine: c.anchor.endLine, lineText, comment: text, createdAt: now };
70
+ return { ...state, comments: [...state.comments, comment], mode: c.returnMode, compose: null };
71
+ }
72
+ export function openList(state) {
73
+ return { ...state, mode: 'list', listIndex: Math.max(0, Math.min(state.listIndex, state.comments.length - 1)) };
74
+ }
75
+ export function closeList(state) {
76
+ return { ...state, mode: 'view' };
77
+ }
78
+ export function moveListIndex(state, delta) {
79
+ if (state.comments.length === 0)
80
+ return state;
81
+ const next = Math.max(0, Math.min(state.comments.length - 1, state.listIndex + delta));
82
+ return { ...state, listIndex: next };
83
+ }
84
+ export function deleteAtListIndex(state) {
85
+ if (state.comments.length === 0)
86
+ return state;
87
+ const comments = state.comments.filter((_, i) => i !== state.listIndex);
88
+ const listIndex = Math.max(0, Math.min(state.listIndex, comments.length - 1));
89
+ return { ...state, comments, listIndex };
90
+ }
91
+ export function undoLast(state) {
92
+ if (state.comments.length === 0)
93
+ return state;
94
+ return { ...state, comments: state.comments.slice(0, -1) };
95
+ }
96
+ export function openHelp(state) {
97
+ return { ...state, mode: 'help' };
98
+ }
99
+ export function closeHelp(state) {
100
+ return { ...state, mode: 'view' };
101
+ }
102
+ // ── Multi-line text-buffer helpers (flat string + code-point cursor) ────────
103
+ function codePoints(s) {
104
+ return [...s];
105
+ }
106
+ // A single physical keypress (including Enter) almost always arrives as its
107
+ // own 'data' event, so parseKeypress's per-event key flags cover the normal
108
+ // typing path. A chunk longer than one character is a paste (or, rarely,
109
+ // several keystrokes the OS coalesced into one read) and is never re-parsed
110
+ // for key semantics — an embedded CR there means a pasted newline, not a
111
+ // commit. Mirrors tui/input.ts's paste cleaning: strip escape sequences,
112
+ // normalize CR/CRLF to LF, drop remaining control bytes, keep LF.
113
+ function cleanPastedText(input) {
114
+ return input
115
+ .replace(/\x1b\[20[01]~/g, '')
116
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
117
+ .replace(/\r\n?/g, '\n')
118
+ .replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
119
+ }
120
+ export function textInsert(buffer, cursor, text) {
121
+ const chars = codePoints(buffer);
122
+ const at = Math.max(0, Math.min(cursor, chars.length));
123
+ const next = [...chars.slice(0, at), ...codePoints(text), ...chars.slice(at)].join('');
124
+ return { buffer: next, cursor: at + codePoints(text).length };
125
+ }
126
+ export function textBackspace(buffer, cursor) {
127
+ if (cursor <= 0)
128
+ return { buffer, cursor };
129
+ const chars = codePoints(buffer);
130
+ return { buffer: [...chars.slice(0, cursor - 1), ...chars.slice(cursor)].join(''), cursor: cursor - 1 };
131
+ }
132
+ export function textDelete(buffer, cursor) {
133
+ const chars = codePoints(buffer);
134
+ if (cursor >= chars.length)
135
+ return { buffer, cursor };
136
+ return { buffer: [...chars.slice(0, cursor), ...chars.slice(cursor + 1)].join(''), cursor };
137
+ }
138
+ function lineBoundsAt(chars, cursor) {
139
+ let start = cursor;
140
+ while (start > 0 && chars[start - 1] !== '\n')
141
+ start--;
142
+ let end = cursor;
143
+ while (end < chars.length && chars[end] !== '\n')
144
+ end++;
145
+ return { start, end };
146
+ }
147
+ export function textHome(buffer, cursor) {
148
+ return lineBoundsAt(codePoints(buffer), cursor).start;
149
+ }
150
+ export function textEnd(buffer, cursor) {
151
+ return lineBoundsAt(codePoints(buffer), cursor).end;
152
+ }
153
+ export function textVertical(buffer, cursor, delta) {
154
+ const chars = codePoints(buffer);
155
+ const { start, end } = lineBoundsAt(chars, cursor);
156
+ const col = cursor - start;
157
+ if (delta < 0) {
158
+ if (start === 0)
159
+ return cursor;
160
+ const prevEnd = start - 1; // the '\n' just before this line
161
+ const prevStart = lineBoundsAt(chars, prevEnd).start;
162
+ return Math.min(prevEnd, prevStart + col);
163
+ }
164
+ if (end >= chars.length)
165
+ return cursor;
166
+ const nextStart = end + 1;
167
+ const nextEnd = lineBoundsAt(chars, nextStart).end;
168
+ return Math.min(nextEnd, nextStart + col);
169
+ }
170
+ // ── Rendering (pure) ─────────────────────────────────────────────────────────
171
+ /** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
172
+ * sync with renderReviewFrame so scroll math can clamp before a repaint. */
173
+ export function reservedRows(state) {
174
+ const header = 3; // blank, title, divider
175
+ if (state.mode === 'view')
176
+ return header + 2;
177
+ if (state.mode === 'compose') {
178
+ const c = state.compose;
179
+ const bufLines = c ? Math.max(1, c.buffer.split('\n').length) : 1;
180
+ return header + 3 + bufLines;
181
+ }
182
+ if (state.mode === 'list')
183
+ return header + Math.max(1, state.comments.length) + 2;
184
+ return header + 8; // help
185
+ }
186
+ export function renderReviewFrame(state, fileLabel, renderedLines, cols, rows) {
187
+ const maxW = Math.min(Math.max(20, cols - 4), 120);
188
+ const header = [
189
+ '',
190
+ ` ${BOLD}${CYAN}Review — ${fileLabel}${RESET} ${DIM}${state.comments.length} comment${state.comments.length === 1 ? '' : 's'}${RESET}`,
191
+ ` ${DIM}${hline(maxW)}${RESET}`,
192
+ ];
193
+ const footer = [];
194
+ if (state.mode === 'compose' && state.compose) {
195
+ const c = state.compose;
196
+ const label = c.anchor.line === c.anchor.endLine ? `L${c.anchor.line}` : `L${c.anchor.line}-${c.anchor.endLine}`;
197
+ footer.push(` ${YELLOW}${c.editingId !== null ? 'Edit comment' : 'Comment'} on ${label}:${RESET}`);
198
+ for (const l of renderInputBuffer(c.buffer, c.cursor, Math.max(10, maxW - 2)))
199
+ footer.push(` ${l}`);
200
+ footer.push('');
201
+ footer.push(` ${DIM}enter${RESET} save ${DIM}^J/⌥⏎${RESET} newline ${DIM}esc${RESET} cancel`);
202
+ }
203
+ else if (state.mode === 'list') {
204
+ if (state.comments.length === 0) {
205
+ footer.push(` ${DIM}(no comments yet — space c to add one)${RESET}`);
206
+ }
207
+ else {
208
+ state.comments.forEach((c, i) => {
209
+ const loc = c.line === c.endLine ? `L${c.line}` : `L${c.line}-${c.endLine}`;
210
+ const cursor = i === state.listIndex ? `${CYAN}▸${RESET}` : ' ';
211
+ const text = truncate(c.comment.replace(/\n/g, ' / '), Math.max(10, maxW - 14));
212
+ footer.push(` ${cursor} ${DIM}[${loc}]${RESET} ${text}`);
213
+ });
214
+ }
215
+ footer.push('');
216
+ footer.push(` ${DIM}j/k${RESET} move ${DIM}enter/e${RESET} edit ${DIM}d${RESET} delete ${DIM}q/esc${RESET} close`);
217
+ }
218
+ else if (state.mode === 'help') {
219
+ footer.push(' Keys:');
220
+ footer.push(` ${DIM}j/k${RESET} move anchor line ${DIM}shift+j/k${RESET} extend range`);
221
+ footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
222
+ footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
223
+ footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
224
+ footer.push(` ${DIM}?${RESET} toggle this help ${DIM}esc${RESET} close/cancel`);
225
+ footer.push('');
226
+ footer.push(` ${DIM}esc / ?${RESET} close`);
227
+ }
228
+ else {
229
+ const anchor = currentAnchor(state);
230
+ const label = anchor.line === anchor.endLine ? `L${anchor.line}` : `L${anchor.line}-${anchor.endLine}`;
231
+ const preview = truncate(sanitize(state.sourceLines.slice(anchor.line - 1, anchor.endLine).join(' / ')), Math.max(10, maxW - 20));
232
+ footer.push(` ${DIM}Anchor:${RESET} ${CYAN}${label}${RESET} ${DIM}${preview}${RESET}`);
233
+ footer.push(` ${DIM}j/k${RESET} anchor ${DIM}shift-j/k${RESET} range ${DIM}u/d${RESET} scroll ${DIM}space c${RESET} comment ` +
234
+ `${DIM}space l${RESET} list ${DIM}space u${RESET} undo ${DIM}space s${RESET} submit ${DIM}?${RESET} help ${DIM}esc${RESET} close`);
235
+ }
236
+ const reserved = header.length + footer.length;
237
+ const bodyHeight = Math.max(1, rows - reserved);
238
+ const maxScroll = Math.max(0, renderedLines.length - bodyHeight);
239
+ const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
240
+ const body = renderedLines.slice(scroll, scroll + bodyHeight).map((l) => ` ${l}`);
241
+ if (scroll > 0 && body.length > 0)
242
+ body[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
243
+ const remaining = renderedLines.length - (scroll + bodyHeight);
244
+ if (remaining > 0 && body.length > 0)
245
+ body[body.length - 1] = ` ${DIM}↓ ${remaining} more below${RESET}`;
246
+ while (body.length < bodyHeight)
247
+ body.push('');
248
+ const lines = [...header, ...body, ...footer];
249
+ while (lines.length < rows)
250
+ lines.push('');
251
+ return lines.slice(0, rows);
252
+ }
253
+ // ── Host loop (impure — the only part that touches the real TTY) ───────────
254
+ /**
255
+ * Open a Markdown file in humanloop's own terminal review surface: the
256
+ * document renders via termrender (Mermaid included), source-line anchors
257
+ * drive comments independent of rendered scroll position, and the human
258
+ * explicitly submits a proposal. Never edits the source file. Autosaves the
259
+ * comment draft to `opts.output` (the `progress.json` convention). Canonical
260
+ * ticket finalization stays with the caller's `onPropose` (the adapter calls
261
+ * `completeReview`); this function never writes `response.json`.
262
+ */
263
+ export async function launchTerminalReview(file, opts) {
264
+ const absFile = resolve(file);
265
+ const content = readFileSync(absFile, 'utf8');
266
+ const sourceLines = content.split('\n');
267
+ const outPath = resolve(opts.output);
268
+ const draft = readReviewDraft(outPath);
269
+ let state = initReviewState(sourceLines, draft?.comments ?? [], draft?.version ?? 0);
270
+ const fileLabel = basename(absFile);
271
+ if (opts.signal?.aborted)
272
+ return buildDraftFeedbackResult(absFile, state.comments, draft?.savedAt);
273
+ setupTerminal();
274
+ let { cols, rows } = getTerminalSize();
275
+ let renderedWidth = Math.min(Math.max(20, cols - 4), 120);
276
+ let renderedLines = renderMarkdown(content, renderedWidth);
277
+ let prevFrame = [];
278
+ let spaceArmed = false;
279
+ let settled = false;
280
+ return new Promise((resolvePromise) => {
281
+ const persist = () => {
282
+ state = { ...state, version: state.version + 1 };
283
+ writeReviewDraft(outPath, state.comments, state.version);
284
+ };
285
+ const paint = (clear = false) => {
286
+ const lines = renderReviewFrame(state, fileLabel, renderedLines, cols, rows);
287
+ if (clear) {
288
+ prevFrame = [];
289
+ process.stdout.write('\x1b[2J\x1b[H');
290
+ }
291
+ const { writes, nextPrevFrame } = diffFrame(prevFrame, lines, rows, cols);
292
+ process.stdout.write('\x1b[?2026h');
293
+ for (const w of writes)
294
+ process.stdout.write(w);
295
+ process.stdout.write('\x1b[?2026l');
296
+ prevFrame = nextPrevFrame;
297
+ };
298
+ const finish = (result) => {
299
+ if (settled)
300
+ return;
301
+ settled = true;
302
+ process.stdin.removeListener('data', onData);
303
+ process.stdout.removeListener('resize', onResize);
304
+ opts.signal?.removeEventListener('abort', onAbort);
305
+ restoreTerminal();
306
+ resolvePromise(result);
307
+ };
308
+ const onResize = () => {
309
+ ({ cols, rows } = getTerminalSize());
310
+ renderedWidth = Math.min(Math.max(20, cols - 4), 120);
311
+ renderedLines = renderMarkdown(content, renderedWidth);
312
+ paint(true);
313
+ };
314
+ const onAbort = () => {
315
+ finish(buildDraftFeedbackResult(absFile, state.comments));
316
+ };
317
+ const submit = async () => {
318
+ persist();
319
+ const proposal = buildFinalFeedbackResult(absFile, state.comments);
320
+ if (!opts.signal?.aborted)
321
+ await opts.onPropose?.(proposal);
322
+ finish(proposal);
323
+ };
324
+ const requestClose = async () => {
325
+ persist();
326
+ await opts.onClose?.();
327
+ finish(buildDraftFeedbackResult(absFile, state.comments));
328
+ };
329
+ const handleComposeKey = (input, key) => {
330
+ const c = state.compose;
331
+ if (c === null)
332
+ return;
333
+ if (key.escape) {
334
+ state = closeCompose(state);
335
+ return;
336
+ }
337
+ if (key.return) {
338
+ state = commitCompose(state);
339
+ persist();
340
+ return;
341
+ }
342
+ if (key.newline) {
343
+ const r = textInsert(c.buffer, c.cursor, '\n');
344
+ state = { ...state, compose: { ...c, ...r } };
345
+ return;
346
+ }
347
+ if (key.backspace) {
348
+ const r = textBackspace(c.buffer, c.cursor);
349
+ state = { ...state, compose: { ...c, ...r } };
350
+ return;
351
+ }
352
+ if (key.del) {
353
+ const r = textDelete(c.buffer, c.cursor);
354
+ state = { ...state, compose: { ...c, ...r } };
355
+ return;
356
+ }
357
+ if (key.leftArrow) {
358
+ state = { ...state, compose: { ...c, cursor: Math.max(0, c.cursor - 1) } };
359
+ return;
360
+ }
361
+ if (key.rightArrow) {
362
+ state = { ...state, compose: { ...c, cursor: Math.min([...c.buffer].length, c.cursor + 1) } };
363
+ return;
364
+ }
365
+ if (key.home) {
366
+ state = { ...state, compose: { ...c, cursor: textHome(c.buffer, c.cursor) } };
367
+ return;
368
+ }
369
+ if (key.end) {
370
+ state = { ...state, compose: { ...c, cursor: textEnd(c.buffer, c.cursor) } };
371
+ return;
372
+ }
373
+ if (key.upArrow) {
374
+ state = { ...state, compose: { ...c, cursor: textVertical(c.buffer, c.cursor, -1) } };
375
+ return;
376
+ }
377
+ if (key.downArrow) {
378
+ state = { ...state, compose: { ...c, cursor: textVertical(c.buffer, c.cursor, 1) } };
379
+ return;
380
+ }
381
+ if (input.length === 1 && !key.ctrl) {
382
+ const r = textInsert(c.buffer, c.cursor, input);
383
+ state = { ...state, compose: { ...c, ...r } };
384
+ return;
385
+ }
386
+ if (input.length > 1) {
387
+ const clean = cleanPastedText(input);
388
+ if (clean.length === 0)
389
+ return;
390
+ const r = textInsert(c.buffer, c.cursor, clean);
391
+ state = { ...state, compose: { ...c, ...r } };
392
+ }
393
+ };
394
+ const handleListKey = (input, key) => {
395
+ if (key.escape || input === 'q') {
396
+ state = closeList(state);
397
+ return;
398
+ }
399
+ if (input === 'j' || key.downArrow) {
400
+ state = moveListIndex(state, 1);
401
+ return;
402
+ }
403
+ if (input === 'k' || key.upArrow) {
404
+ state = moveListIndex(state, -1);
405
+ return;
406
+ }
407
+ if (input === 'e' || key.return) {
408
+ if (state.comments.length > 0)
409
+ state = openComposeEdit(state, state.listIndex);
410
+ return;
411
+ }
412
+ if (input === 'd') {
413
+ if (state.comments.length > 0) {
414
+ state = deleteAtListIndex(state);
415
+ persist();
416
+ }
417
+ return;
418
+ }
419
+ };
420
+ const onData = (data) => {
421
+ if (settled)
422
+ return;
423
+ const { input, key } = parseKeypress(data);
424
+ if (key.meta && input === 'i') {
425
+ void requestClose();
426
+ return;
427
+ }
428
+ if (state.mode === 'compose') {
429
+ handleComposeKey(input, key);
430
+ paint();
431
+ return;
432
+ }
433
+ if (spaceArmed) {
434
+ spaceArmed = false;
435
+ if (input === 'c')
436
+ state = openComposeNew(state);
437
+ else if (input === 'l')
438
+ state = openList(state);
439
+ else if (input === 'u') {
440
+ state = undoLast(state);
441
+ persist();
442
+ }
443
+ else if (input === 's') {
444
+ void submit();
445
+ return;
446
+ }
447
+ paint();
448
+ return;
449
+ }
450
+ if (state.mode === 'list') {
451
+ handleListKey(input, key);
452
+ paint();
453
+ return;
454
+ }
455
+ if (state.mode === 'help') {
456
+ if (input === '?' || key.escape)
457
+ state = closeHelp(state);
458
+ paint();
459
+ return;
460
+ }
461
+ // view mode
462
+ if (input === ' ') {
463
+ spaceArmed = true;
464
+ return;
465
+ }
466
+ if (input === 'j' || key.downArrow)
467
+ state = moveActiveLine(state, 1, false);
468
+ else if (input === 'k' || key.upArrow)
469
+ state = moveActiveLine(state, -1, false);
470
+ else if (input === 'J')
471
+ state = moveActiveLine(state, 1, true);
472
+ else if (input === 'K')
473
+ state = moveActiveLine(state, -1, true);
474
+ else if (input === 'u' || key.pageUp)
475
+ state = scrollBy(state, -Math.max(1, rows - reservedRows(state)), Math.max(1, rows - reservedRows(state)), renderedLines.length);
476
+ else if (input === 'd' || key.pageDown)
477
+ state = scrollBy(state, Math.max(1, rows - reservedRows(state)), Math.max(1, rows - reservedRows(state)), renderedLines.length);
478
+ else if (input === '?')
479
+ state = openHelp(state);
480
+ else if (key.escape) {
481
+ finish(buildDraftFeedbackResult(absFile, state.comments));
482
+ return;
483
+ }
484
+ paint();
485
+ };
486
+ process.stdin.on('data', onData);
487
+ process.stdout.on('resize', onResize);
488
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
489
+ paint(true);
490
+ });
491
+ }
@@ -2,6 +2,7 @@ import { validateDeck } from './deck-schema.js';
2
2
  /** Build a validated single-option notify deck. id: 'notify', kind: 'notify'. */
3
3
  export function notifyDeck(title, opts = {}) {
4
4
  return validateDeck({
5
+ title,
5
6
  interactions: [{
6
7
  id: 'notify',
7
8
  title,
@@ -12,7 +12,7 @@ export declare const preAnswerSchema: z.ZodObject<{
12
12
  label: z.ZodOptional<z.ZodString>;
13
13
  }, z.core.$strip>;
14
14
  export declare const deckSchema: z.ZodObject<{
15
- title: z.ZodOptional<z.ZodString>;
15
+ title: z.ZodString;
16
16
  source: z.ZodOptional<z.ZodObject<{
17
17
  sessionName: z.ZodOptional<z.ZodString>;
18
18
  askedBy: z.ZodOptional<z.ZodString>;
@@ -8,11 +8,11 @@ export const interactionOptionSchema = z.object({ id: z.string().min(1), label:
8
8
  export const preAnswerSchema = z.object({ selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), label: z.string().optional() });
9
9
  const interactionSchema = z.object({
10
10
  id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
11
- title: z.string().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
11
+ title: z.string().trim().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
12
12
  options: z.array(interactionOptionSchema), multiSelect: z.boolean().optional(), allowFreetext: z.boolean().optional(), freetextLabel: z.string().optional(), kind: z.enum(INTERACTION_KINDS).optional(), preAnswered: preAnswerSchema.optional(),
13
13
  });
14
14
  const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(), visual: z.literal('humanloop.visual/v1').optional() });
15
- export const deckSchema = z.object({ title: z.string().optional(), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
15
+ export const deckSchema = z.object({ title: z.string().trim().min(1), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
16
16
  const seen = new Set();
17
17
  input.interactions.forEach((interaction, index) => {
18
18
  if (interaction.body !== undefined && interaction.bodyPath !== undefined)
@@ -32,7 +32,7 @@ function deckSummary(dir, id) {
32
32
  return null;
33
33
  }
34
34
  }
35
- return { dir, id, kind: 'deck', title: deck.title ?? first.title, subtitle: first.subtitle, interactionKind: first.kind, source: deck.source ?? {}, blockedSince, claim: claimSummary(dir) };
35
+ return { dir, id, kind: 'deck', title: deck.title, subtitle: first.subtitle, interactionKind: first.kind, source: deck.source ?? {}, blockedSince, claim: claimSummary(dir) };
36
36
  }
37
37
  function reviewSummary(dir, id) {
38
38
  const raw = readJson(reviewPath(dir));
package/dist/types.d.ts CHANGED
@@ -72,7 +72,8 @@ export interface DeckSource {
72
72
  visual?: 'humanloop.visual/v1';
73
73
  }
74
74
  export interface Deck {
75
- title?: string;
75
+ /** Short inbox topic for the deck as a whole. */
76
+ title: string;
76
77
  source?: DeckSource;
77
78
  interactions: Interaction[];
78
79
  }