@crouton-kit/humanloop 0.4.9 → 0.4.11

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 (125) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +4 -4
  3. package/dist/cli.js +5 -3
  4. package/dist/editor/terminal-review.d.ts +21 -1
  5. package/dist/editor/terminal-review.js +88 -19
  6. package/dist/inbox/controller.js +34 -25
  7. package/dist/inbox/deck-factories.d.ts +3 -1
  8. package/dist/inbox/deck-factories.js +2 -1
  9. package/dist/inbox/deck-schema.d.ts +2 -1
  10. package/dist/inbox/deck-schema.js +3 -2
  11. package/dist/inbox/maintenance.js +1 -10
  12. package/dist/inbox/scan.js +1 -1
  13. package/dist/inbox/tickets.js +1 -1
  14. package/dist/inbox/tui.js +6 -16
  15. package/dist/inbox/visual.d.ts +3 -11
  16. package/dist/inbox/visual.js +15 -59
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/render/termrender.d.ts +18 -1
  20. package/dist/render/termrender.js +66 -5
  21. package/dist/render/version.d.ts +1 -1
  22. package/dist/render/version.js +1 -1
  23. package/dist/tui/ansi.d.ts +29 -5
  24. package/dist/tui/ansi.js +123 -27
  25. package/dist/tui/app.js +24 -29
  26. package/dist/tui/input.js +13 -0
  27. package/dist/tui/render.d.ts +3 -0
  28. package/dist/tui/render.js +71 -29
  29. package/dist/types.d.ts +14 -5
  30. package/dist/web/assets/{abnfDiagram-VRR7QNED-CeahG6AG.js → abnfDiagram-VRR7QNED-C3G7ucis.js} +1 -1
  31. package/dist/web/assets/{arc-CDoEbwHt.js → arc-CfHbBVfI.js} +1 -1
  32. package/dist/web/assets/architecture-TIHT7OUA-XeXU8J5n.js +1 -0
  33. package/dist/web/assets/{architectureDiagram-ZJ3FMSHR-BPjR0pOT.js → architectureDiagram-ZJ3FMSHR-BCs4FMRj.js} +1 -1
  34. package/dist/web/assets/{blockDiagram-677ZJIJ3-COPZ3wOy.js → blockDiagram-677ZJIJ3-Ddt1PlNC.js} +1 -1
  35. package/dist/web/assets/{c4Diagram-LMCZKHZV-BDlHqm5m.js → c4Diagram-LMCZKHZV-BWwkKdPZ.js} +1 -1
  36. package/dist/web/assets/channel-BfMifkFv.js +1 -0
  37. package/dist/web/assets/{chunk-32BRIVSS-ChE1ajNa.js → chunk-32BRIVSS-BjyMBLnt.js} +1 -1
  38. package/dist/web/assets/{chunk-52WLFC77-DN_FDyhc.js → chunk-52WLFC77-DwnRc5bP.js} +1 -1
  39. package/dist/web/assets/{chunk-C7G6YPKG-B3RJ96Zq.js → chunk-C7G6YPKG-Cv5J31rb.js} +1 -1
  40. package/dist/web/assets/{chunk-EX3LRPZG-DgUeuz17.js → chunk-EX3LRPZG-Ds-y37uL.js} +1 -1
  41. package/dist/web/assets/{chunk-FWX5IMBZ-BLUVTbLv.js → chunk-FWX5IMBZ-BzgaWqkI.js} +2 -2
  42. package/dist/web/assets/{chunk-HOUHSVGY-Be0i0s6_.js → chunk-HOUHSVGY-CB_Rjcvz.js} +1 -1
  43. package/dist/web/assets/{chunk-ICXQ74PX-3WXYMfu9.js → chunk-ICXQ74PX-a8BpKR5q.js} +1 -1
  44. package/dist/web/assets/{chunk-MOJQB5TN-D-pOUBYm.js → chunk-MOJQB5TN-b09YHDGP.js} +1 -1
  45. package/dist/web/assets/{chunk-OGEWGWER-B-il3nZ9.js → chunk-OGEWGWER-CK9U88Zp.js} +1 -1
  46. package/dist/web/assets/{chunk-PUDLZKDR-D6N4Hutq.js → chunk-PUDLZKDR-DBp4NxwM.js} +1 -1
  47. package/dist/web/assets/{chunk-Q4XR5HBZ-9cq52Pkk.js → chunk-Q4XR5HBZ-Cn7tHzZm.js} +1 -1
  48. package/dist/web/assets/{chunk-RYQCIY6F-BVYSEout.js → chunk-RYQCIY6F-DS8RMCZK.js} +1 -1
  49. package/dist/web/assets/{chunk-V7JOEXUC-BuJn3hRD.js → chunk-V7JOEXUC-BCTcSsiS.js} +1 -1
  50. package/dist/web/assets/{chunk-VAUOI2AC-C6vQq47C.js → chunk-VAUOI2AC-DQVUYSsu.js} +1 -1
  51. package/dist/web/assets/{chunk-VR4S4FIN-DqY4BkmV.js → chunk-VR4S4FIN-D5E0Nzn2.js} +1 -1
  52. package/dist/web/assets/{chunk-WYO6CB5R-DgJ7qTeI.js → chunk-WYO6CB5R-reDXIQan.js} +1 -1
  53. package/dist/web/assets/{chunk-XXDRQBXY-BA7v7GnL.js → chunk-XXDRQBXY-BFea61gW.js} +1 -1
  54. package/dist/web/assets/{chunk-ZGVPDNZ5-BbkNKCvq.js → chunk-ZGVPDNZ5-BbVer9LG.js} +1 -1
  55. package/dist/web/assets/classDiagram-OUVF2IWQ-B3fMukMS.js +1 -0
  56. package/dist/web/assets/classDiagram-v2-EOCWNBFH-B3fMukMS.js +1 -0
  57. package/dist/web/assets/{cose-bilkent-JH36ORCC-CXK095PF.js → cose-bilkent-JH36ORCC-Do23kUxV.js} +1 -1
  58. package/dist/web/assets/{cynefin-VYW2F7L2-C_VXlLiV.js → cynefin-VYW2F7L2-B51RrEfW.js} +1 -1
  59. package/dist/web/assets/{cynefinDiagram-TSTJHNR4-C1xlbL_1.js → cynefinDiagram-TSTJHNR4-BMRRvJi_.js} +1 -1
  60. package/dist/web/assets/{dagre-VKFMJZFB-CCTbik65.js → dagre-VKFMJZFB-Cq3oNHAr.js} +1 -1
  61. package/dist/web/assets/{diagram-FQU43EPY-DsT-Ov2v.js → diagram-FQU43EPY-B_OlWSFb.js} +1 -1
  62. package/dist/web/assets/{diagram-G47NLZAW-DtoIPU9F.js → diagram-G47NLZAW-CinfsHac.js} +1 -1
  63. package/dist/web/assets/{diagram-NH7WQ7WH-OFu5gDhD.js → diagram-NH7WQ7WH-B1z5z_xR.js} +1 -1
  64. package/dist/web/assets/{diagram-OA4YK3LP-Dd7dF-gL.js → diagram-OA4YK3LP-CaXxWKyb.js} +1 -1
  65. package/dist/web/assets/{diagram-WEI45ONY-Mux4SvIA.js → diagram-WEI45ONY-DJ_ofe0i.js} +1 -1
  66. package/dist/web/assets/{dist-9QOA8OIw.js → dist-D6gNQP9S.js} +1 -1
  67. package/dist/web/assets/{ebnfDiagram-CCIWWBDH-BrKI9mhn.js → ebnfDiagram-CCIWWBDH-CKdV58eP.js} +1 -1
  68. package/dist/web/assets/{erDiagram-Q63AITRT-ByTXMLhH.js → erDiagram-Q63AITRT-DCTuIs_r.js} +1 -1
  69. package/dist/web/assets/eventmodeling-45OFAUF4-BW_ZjAEZ.js +1 -0
  70. package/dist/web/assets/flowDiagram-23GEKE2U-kuTegDJX.js +1 -0
  71. package/dist/web/assets/{ganttDiagram-NO4QXBWP-DrERNlg-.js → ganttDiagram-NO4QXBWP-a2WUc8wp.js} +1 -1
  72. package/dist/web/assets/{gitGraph-TEB2WS4Q-DD6JbRZi.js → gitGraph-TEB2WS4Q-DMKf1qeU.js} +1 -1
  73. package/dist/web/assets/{gitGraphDiagram-IHSO6WYX-Btg9UWYR.js → gitGraphDiagram-IHSO6WYX-DW54jWiu.js} +1 -1
  74. package/dist/web/assets/{index-CkGfXbfm.js → index-BteexwNd.js} +4 -4
  75. package/dist/web/assets/index-DOlqkLKF.css +2 -0
  76. package/dist/web/assets/{info-DKCQHKI2-e7fTIPJt.js → info-DKCQHKI2-BeOi6-NO.js} +1 -1
  77. package/dist/web/assets/{infoDiagram-FWYZ7A6U-D8qwevDR.js → infoDiagram-FWYZ7A6U-DnmfPppA.js} +1 -1
  78. package/dist/web/assets/{ishikawaDiagram-FXEZZL3T-DVk__o1X.js → ishikawaDiagram-FXEZZL3T-CPNmCU4V.js} +1 -1
  79. package/dist/web/assets/{journeyDiagram-5HDEW3XC-BAqtMRPh.js → journeyDiagram-5HDEW3XC-BJ1dpMIR.js} +1 -1
  80. package/dist/web/assets/{kanban-definition-HUTT4EX6-BSx4RvJG.js → kanban-definition-HUTT4EX6-CTUMFpnW.js} +1 -1
  81. package/dist/web/assets/{line-U7gCrM1H.js → line-BxjdmOYH.js} +1 -1
  82. package/dist/web/assets/{linear-LChDqo68.js → linear-BTFwxw2k.js} +1 -1
  83. package/dist/web/assets/{mermaid-parser.core-DN-im0ZM.js → mermaid-parser.core-xq_aCbXg.js} +3 -3
  84. package/dist/web/assets/{mermaid.core-BtXRl0Mb.js → mermaid.core-CJ7BhoQw.js} +3 -3
  85. package/dist/web/assets/{mindmap-definition-LN4V7U3C-CyKxTVZ0.js → mindmap-definition-LN4V7U3C-CGKOonoC.js} +1 -1
  86. package/dist/web/assets/{packet-7NZHBO7P-DqwDWu78.js → packet-7NZHBO7P-DcZTUk_n.js} +1 -1
  87. package/dist/web/assets/{pegDiagram-2B236MQR-BtsNWFkr.js → pegDiagram-2B236MQR-BQhasiHR.js} +1 -1
  88. package/dist/web/assets/{pie-RZYD4A2V-BHZhIJNY.js → pie-RZYD4A2V-CoNqq_hi.js} +1 -1
  89. package/dist/web/assets/{pieDiagram-ENE6RG2P-Bg2vN06Z.js → pieDiagram-ENE6RG2P-DjXgajVD.js} +1 -1
  90. package/dist/web/assets/{quadrantDiagram-ABIIQ3AL-Cy3idFFa.js → quadrantDiagram-ABIIQ3AL-DnMCZ8Lo.js} +1 -1
  91. package/dist/web/assets/{radar-I7S5WNFK-YJjo6vJv.js → radar-I7S5WNFK-DNuRmGvt.js} +1 -1
  92. package/dist/web/assets/{railroad-3IZDKUUU-CmVtviO7.js → railroad-3IZDKUUU-BWCSIZRZ.js} +1 -1
  93. package/dist/web/assets/railroad-abnf-AHOZXSZD-CUq87fi8.js +1 -0
  94. package/dist/web/assets/railroad-ebnf-EBAXGLYW-Bx6VqkgS.js +1 -0
  95. package/dist/web/assets/railroad-peg-LSFZ7HO6-DkT6zLYM.js +1 -0
  96. package/dist/web/assets/{railroadDiagram-RFXS5EU6-H_2GxnX4.js → railroadDiagram-RFXS5EU6-COtqBRRI.js} +1 -1
  97. package/dist/web/assets/{requirementDiagram-TGXJPOKE-DapbEtLD.js → requirementDiagram-TGXJPOKE-Cgm9XId4.js} +1 -1
  98. package/dist/web/assets/{sankeyDiagram-HTMAVEWB-CCQHHs1d.js → sankeyDiagram-HTMAVEWB-CCOMrkbd.js} +1 -1
  99. package/dist/web/assets/{sequenceDiagram-DBY2YBRQ-CgVVV-1t.js → sequenceDiagram-DBY2YBRQ-CL0wy8uA.js} +1 -1
  100. package/dist/web/assets/{src-BfumX_U8.js → src-CvIIQCAG.js} +1 -1
  101. package/dist/web/assets/{stateDiagram-2N3HPSRC-BBCtLj7x.js → stateDiagram-2N3HPSRC-C5b_pAiI.js} +1 -1
  102. package/dist/web/assets/stateDiagram-v2-6OUMAXLB-CbW-xCPn.js +1 -0
  103. package/dist/web/assets/{swimlanes-5IMT3BWC-DO2t3ahL.js → swimlanes-5IMT3BWC-J11uWvqa.js} +1 -1
  104. package/dist/web/assets/swimlanesDiagram-G3AALYLV-DpbNWekf.js +8 -0
  105. package/dist/web/assets/{timeline-definition-FHXFAJF6-lUeG8UJD.js → timeline-definition-FHXFAJF6-Dsvj6fbD.js} +1 -1
  106. package/dist/web/assets/{treeView-QDETBFTQ-Btl8LjLd.js → treeView-QDETBFTQ-FH3Xeeef.js} +1 -1
  107. package/dist/web/assets/{treemap-6X3UGDF4-DrCUe046.js → treemap-6X3UGDF4-D1TKNacZ.js} +1 -1
  108. package/dist/web/assets/{vennDiagram-L72KCM5P-DC1qneaM.js → vennDiagram-L72KCM5P-D4zm__Fz.js} +1 -1
  109. package/dist/web/assets/{wardley-OPB4EBWU-BuUed_9_.js → wardley-OPB4EBWU-BRLeItQR.js} +1 -1
  110. package/dist/web/assets/{wardleyDiagram-EHGQE667-CaHmI1FN.js → wardleyDiagram-EHGQE667-B7j5UliQ.js} +1 -1
  111. package/dist/web/assets/{xychartDiagram-FW5EYKEG-ZhKRrmhh.js → xychartDiagram-FW5EYKEG-Bi0MfuVK.js} +1 -1
  112. package/dist/web/index.html +2 -2
  113. package/package.json +1 -1
  114. package/dist/web/assets/architecture-TIHT7OUA-dmDc2lc6.js +0 -1
  115. package/dist/web/assets/channel-Cz6ImOzU.js +0 -1
  116. package/dist/web/assets/classDiagram-OUVF2IWQ-CDIgAUln.js +0 -1
  117. package/dist/web/assets/classDiagram-v2-EOCWNBFH-CDIgAUln.js +0 -1
  118. package/dist/web/assets/eventmodeling-45OFAUF4-DODcRvgw.js +0 -1
  119. package/dist/web/assets/flowDiagram-23GEKE2U-BOjPmrMg.js +0 -1
  120. package/dist/web/assets/index-BT22-qAj.css +0 -2
  121. package/dist/web/assets/railroad-abnf-AHOZXSZD-BwPWzzAs.js +0 -1
  122. package/dist/web/assets/railroad-ebnf-EBAXGLYW-Cc8VfiVd.js +0 -1
  123. package/dist/web/assets/railroad-peg-LSFZ7HO6-DivliSK5.js +0 -1
  124. package/dist/web/assets/stateDiagram-v2-6OUMAXLB-CBzJzUEN.js +0 -1
  125. package/dist/web/assets/swimlanesDiagram-G3AALYLV-CNLJY6SU.js +0 -8
package/dist/api.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface AskOpts {
13
13
  */
14
14
  export declare function ask(deck: Deck, opts?: AskOpts): Promise<ResolutionEnvelope>;
15
15
  /** Sugar: a single `kind:'notify'` acknowledgement. */
16
- export declare function notify(title: string, body?: string): Promise<void>;
16
+ export declare function notify(title: string, subtitle: string, body?: string): Promise<void>;
17
17
  export interface InboxOpts {
18
18
  cols?: number;
19
19
  rows?: number;
package/dist/api.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { resolveInteractionDir } from './tui/app.js';
5
5
  import { InboxController } from './inbox/controller.js';
6
6
  import { deckPath, atomicWriteJson, stampCanvasNode } from './inbox/convention.js';
7
- import { resolveDeckBodyPaths } from './inbox/deck-schema.js';
7
+ import { resolveDeckBodyPaths, validateDeck } from './inbox/deck-schema.js';
8
8
  import { notifyDeck } from './inbox/deck-factories.js';
9
9
  import { buildSummary } from './summary.js';
10
10
  const RESPONSE_SCHEMA_ID = 'humanloop.response/v2';
@@ -23,7 +23,7 @@ export async function ask(deck, opts = {}) {
23
23
  // resolved BEFORE the deck is ever written to disk, so every reader
24
24
  // downstream (terminal render, the live-reload poller, the browser server)
25
25
  // only ever sees a plain `body`.
26
- deck = resolveDeckBodyPaths(deck, dir);
26
+ deck = validateDeck(resolveDeckBodyPaths(deck, dir));
27
27
  stampCanvasNode(deck);
28
28
  atomicWriteJson(deckPath(dir), deck);
29
29
  const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
@@ -42,8 +42,8 @@ export async function ask(deck, opts = {}) {
42
42
  };
43
43
  }
44
44
  /** Sugar: a single `kind:'notify'` acknowledgement. */
45
- export async function notify(title, body) {
46
- const deck = notifyDeck(title, body !== undefined ? { body } : {});
45
+ export async function notify(title, subtitle, body) {
46
+ const deck = notifyDeck(title, { subtitle, ...(body !== undefined ? { body } : {}) });
47
47
  await ask(deck, {});
48
48
  }
49
49
  /** Open the centralized inbox controller in the current human terminal. */
package/dist/cli.js CHANGED
@@ -85,7 +85,7 @@ root.command('register').description('Register a root owned by a host.').require
85
85
  });
86
86
  root.command('unregister').description('Remove a matching root registration without deleting tickets.').requiredOption('--root <path>').requiredOption('--owner <owner>').action((options) => emit({ removed: unregisterInboxRoot(resolve(options.root), options.owner) }));
87
87
  root.command('list').description('List registered roots and availability.').action(() => emit(listInboxRoots()));
88
- program.command('deck').command('ask').description('Submit a durable deck ticket; it never changes tmux. --inline blocks in this terminal instead.').option('--root <path>').option('--inline', 'present the deck in this terminal and block until it is answered').action(async (options) => {
88
+ program.command('deck').command('ask').description('Submit a durable deck ticket; every interaction needs a one-sentence subtitle stating the decision, recommendation/status, and stakes. --inline blocks in this terminal instead.').option('--root <path>').option('--inline', 'present the deck in this terminal and block until it is answered').action(async (options) => {
89
89
  try {
90
90
  const body = objectInput();
91
91
  const deck = validateDeck(body.deck);
@@ -102,11 +102,13 @@ program.command('deck').command('ask').description('Submit a durable deck ticket
102
102
  fail(error);
103
103
  }
104
104
  });
105
- program.command('review').command('open').description('Submit a durable anchored review; it never changes tmux. --inline blocks in this terminal instead.').option('--root <path>').option('--inline', 'run the review in this terminal and block until submitted').action(async (options) => {
105
+ program.command('review').command('open').description('Submit a durable anchored review; subtitle is a required one-sentence recommendation/status and stakes. --inline blocks in this terminal instead.').option('--root <path>').option('--inline', 'run the review in this terminal and block until submitted').action(async (options) => {
106
106
  try {
107
107
  const body = objectInput();
108
108
  if (typeof body.file !== 'string' || !existsSync(resolve(body.file)))
109
109
  throw new Error('file must be an existing markdown path');
110
+ if (typeof body.subtitle !== 'string' || body.subtitle.trim() === '')
111
+ throw new Error('subtitle is required and must be a non-empty plain-English sentence stating the recommendation/status and stakes');
110
112
  const absFile = resolve(body.file);
111
113
  const output = typeof body.output === 'string' ? resolve(body.output) : `${absFile}.feedback.json`;
112
114
  if (options.inline) {
@@ -118,7 +120,7 @@ program.command('review').command('open').description('Submit a durable anchored
118
120
  if (typeof body.title !== 'string' || !body.title)
119
121
  throw new Error('title is required');
120
122
  const registration = options.root === undefined ? managedInboxRoot() : requireRegisteredRoot(options.root);
121
- emit({ ...submitReview({ root: registration.root, id: typeof body.id === 'string' ? body.id : randomUUID(), review: { file: absFile, output, title: body.title, source: typeof body.source === 'object' && body.source !== null ? body.source : {} } }), queued: true });
123
+ emit({ ...submitReview({ root: registration.root, id: typeof body.id === 'string' ? body.id : randomUUID(), review: { file: absFile, output, title: body.title, subtitle: body.subtitle, source: typeof body.source === 'object' && body.source !== null ? body.source : {} } }), queued: true });
122
124
  }
123
125
  catch (error) {
124
126
  fail(error);
@@ -50,6 +50,9 @@ export interface ReviewState {
50
50
  selectionAnchor: number | null;
51
51
  /** Scroll offset into the rendered (termrender) lines. */
52
52
  scroll: number;
53
+ /** Horizontal pan offset, in display cells, for rows wider than the body
54
+ * rectangle (diagrams). Rows that fit never move. */
55
+ hscroll: number;
53
56
  mode: 'view' | 'list' | 'compose' | 'help';
54
57
  compose: ComposeState | null;
55
58
  listIndex: number;
@@ -130,9 +133,26 @@ export declare function textDelete(buffer: string, cursor: number): {
130
133
  export declare function textHome(buffer: string, cursor: number): number;
131
134
  export declare function textEnd(buffer: string, cursor: number): number;
132
135
  export declare function textVertical(buffer: string, cursor: number, delta: number): number;
133
- /** Width the document is rendered at for a given terminal width: 2-col left
136
+ /** Width prose is rendered at for a given terminal width: 2-col left
134
137
  * margin + 2-col gutter before the content, capped for readability. */
135
138
  export declare function renderWidthForCols(cols: number): number;
139
+ /** The body rectangle a rendered row is painted into: the terminal minus the
140
+ * 4-col gutter prefix. Diagram blocks render at this width (no prose cap),
141
+ * and every emitted body row is contained within it. */
142
+ export declare function contentWidthForCols(cols: number): number;
143
+ /** The mapped render this surface paints: prose at the readability cap,
144
+ * diagrams at the full body width. */
145
+ export declare function renderReviewDoc(content: string, cols: number): RenderedDoc;
146
+ /** Widest painted row, in display cells, for a body window. */
147
+ export declare function widestVisibleRow(doc: RenderedDoc, scroll: number, bodyHeight: number): number;
148
+ /** How far the body can pan right, given what is currently on screen. Zero
149
+ * when nothing visible overflows — which is also what makes h/l contextual:
150
+ * the keys only pan while an overflowing row is in view. */
151
+ export declare function maxHScroll(state: ReviewState, doc: RenderedDoc, cols: number, rows: number): number;
152
+ /** Pan the body horizontally, clamped to what is currently reachable. The
153
+ * stored offset is only ever clamped against the live window, so vertical
154
+ * scrolling and resize can never leave it pointing past the content. */
155
+ export declare function panBy(state: ReviewState, delta: number, doc: RenderedDoc, cols: number, rows: number): ReviewState;
136
156
  /** Reserved (non-body) rows for each mode's fixed header/footer chrome. Kept in
137
157
  * sync with renderReviewFrame so scroll math can clamp before a repaint — the
138
158
  * compose case counts the same hard-wrapped input lines the frame renders. */
@@ -7,10 +7,10 @@ import { waitForParkedReviewSubmit } from './parked.js';
7
7
  import { openBrowser } from '../browser/open.js';
8
8
  import { startReviewWebServer } from '../browser/server.js';
9
9
  import { buildDraftFeedbackResult, buildFinalFeedbackResult, readReviewDraft, writeReviewDraft } from './feedback.js';
10
- import { renderMarkdownWithMap } from '../render/termrender.js';
10
+ import { renderMarkdownBlockAware } from '../render/termrender.js';
11
11
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress } from '../tui/terminal.js';
12
12
  import { diffFrame, renderInputBuffer } from '../tui/render.js';
13
- import { BOLD, CYAN, DIM, ESC, RESET, YELLOW, hline, sanitize, truncate } from '../tui/ansi.js';
13
+ import { BOLD, CYAN, DIM, ESC, RESET, YELLOW, clipLine, hline, panLine, sanitize, truncate, visibleWidth } from '../tui/ansi.js';
14
14
  /** Derive leaf anchor units from a mapped render: dedupe runs of consecutive
15
15
  * rows carrying the identical source span. A mapped row without a leaf span
16
16
  * (an unmapped block) anchors at its whole block's range, so every content
@@ -113,6 +113,7 @@ export function initReviewState(sourceLines, anchor, comments, version) {
113
113
  activeUnit: comments.length > 0 ? unitIndexForLine(safe.units, comments[0].line) : 0,
114
114
  selectionAnchor: null,
115
115
  scroll: 0,
116
+ hscroll: 0,
116
117
  mode: 'view',
117
118
  compose: null,
118
119
  listIndex: 0,
@@ -342,11 +343,64 @@ export function textVertical(buffer, cursor, delta) {
342
343
  return Math.min(nextEnd, nextStart + col);
343
344
  }
344
345
  // ── Rendering (pure) ─────────────────────────────────────────────────────────
345
- /** Width the document is rendered at for a given terminal width: 2-col left
346
+ /** Width prose is rendered at for a given terminal width: 2-col left
346
347
  * margin + 2-col gutter before the content, capped for readability. */
347
348
  export function renderWidthForCols(cols) {
348
349
  return Math.min(Math.max(20, cols - 6), 120);
349
350
  }
351
+ /** The body rectangle a rendered row is painted into: the terminal minus the
352
+ * 4-col gutter prefix. Diagram blocks render at this width (no prose cap),
353
+ * and every emitted body row is contained within it. */
354
+ export function contentWidthForCols(cols) {
355
+ return Math.max(20, cols - 4);
356
+ }
357
+ /** The mapped render this surface paints: prose at the readability cap,
358
+ * diagrams at the full body width. */
359
+ export function renderReviewDoc(content, cols) {
360
+ return renderMarkdownBlockAware(content, renderWidthForCols(cols), contentWidthForCols(cols));
361
+ }
362
+ /** Body rows as they are actually painted. Vertical-scroll indicators replace
363
+ * their endpoint rows, so content behind one cannot advertise or claim h/l. */
364
+ function paintedBodyRows(doc, scroll, bodyHeight) {
365
+ const rows = [];
366
+ for (let i = 0; i < bodyHeight; i++) {
367
+ const abs = scroll + i;
368
+ rows.push(abs < doc.lines.length ? { abs, line: doc.lines[abs] } : { abs: null, line: '' });
369
+ }
370
+ if (scroll > 0 && rows.length > 0)
371
+ rows[0] = { abs: null, line: ` ${DIM}↑ ${scroll} more above${RESET}` };
372
+ const remaining = doc.lines.length - (scroll + bodyHeight);
373
+ if (remaining > 0 && rows.length > 0)
374
+ rows[rows.length - 1] = { abs: null, line: ` ${DIM}↓ ${remaining} more below${RESET}` };
375
+ return rows;
376
+ }
377
+ /** Widest painted row, in display cells, for a body window. */
378
+ export function widestVisibleRow(doc, scroll, bodyHeight) {
379
+ let widest = 0;
380
+ for (const row of paintedBodyRows(doc, scroll, bodyHeight))
381
+ widest = Math.max(widest, visibleWidth(row.line));
382
+ return widest;
383
+ }
384
+ /** How far the body can pan right, given what is currently on screen. Zero
385
+ * when nothing visible overflows — which is also what makes h/l contextual:
386
+ * the keys only pan while an overflowing row is in view. */
387
+ export function maxHScroll(state, doc, cols, rows) {
388
+ const bodyHeight = Math.max(1, rows - reservedRows(state, cols));
389
+ const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
390
+ const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
391
+ return Math.max(0, widestVisibleRow(doc, scroll, bodyHeight) - contentWidthForCols(cols));
392
+ }
393
+ /** Pan the body horizontally, clamped to what is currently reachable. The
394
+ * stored offset is only ever clamped against the live window, so vertical
395
+ * scrolling and resize can never leave it pointing past the content. */
396
+ export function panBy(state, delta, doc, cols, rows) {
397
+ const max = maxHScroll(state, doc, cols, rows);
398
+ const next = Math.max(0, Math.min(state.hscroll + delta, max));
399
+ return next === state.hscroll ? state : { ...state, hscroll: next };
400
+ }
401
+ /** Cells one h/l press pans by — a chunk large enough to cross a diagram in a
402
+ * few presses, small enough to keep orientation. */
403
+ const PAN_STEP = 8;
350
404
  // Single-hue anchor tint (a dark neutral step below the CYAN gutter bar).
351
405
  // termrender's only reset is `\x1b[0m`, so re-arming the background after each
352
406
  // reset keeps the tint alive across inner styling. Heading rows carry their
@@ -409,7 +463,7 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
409
463
  else if (state.mode === 'help') {
410
464
  footer.push(' Keys:');
411
465
  footer.push(` ${DIM}j/k${RESET} move anchor ${DIM}shift+j/k${RESET} extend selection`);
412
- footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document`);
466
+ footer.push(` ${DIM}u/d, pgup/pgdn${RESET} scroll document ${DIM}h/l${RESET} pan visible wide diagrams`);
413
467
  footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
414
468
  footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space s${RESET} submit review`);
415
469
  footer.push(` ${DIM}space w${RESET} hand off to browser`);
@@ -425,35 +479,46 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows) {
425
479
  const bodyHeight = Math.max(1, rows - reserved);
426
480
  const maxScroll = Math.max(0, doc.lines.length - bodyHeight);
427
481
  const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
482
+ const contentW = contentWidthForCols(cols);
483
+ // Effective pan: the stored offset, clamped to what this window can reach.
484
+ // Scrolling into narrow content parks the diagram offset rather than losing
485
+ // it, so scrolling back restores the same horizontal position.
486
+ const overflow = Math.max(0, widestVisibleRow(doc, scroll, bodyHeight) - contentW);
487
+ const hscroll = Math.max(0, Math.min(state.hscroll, overflow));
428
488
  const { lo, hi } = selectedUnitBounds(state);
429
489
  const commented = commentedUnitSet(state);
430
490
  const body = [];
431
- for (let i = 0; i < bodyHeight; i++) {
432
- const abs = scroll + i;
433
- if (abs >= doc.lines.length) {
434
- body.push('');
491
+ for (const source of paintedBodyRows(doc, scroll, bodyHeight)) {
492
+ if (source.abs === null) {
493
+ body.push(source.line);
435
494
  continue;
436
495
  }
437
- const row = doc.lines[abs];
438
- const u = state.rowUnits[abs] ?? null;
496
+ // Every body row is windowed into the body rectangle: a diagram wider than
497
+ // the pane pans instead of spilling past the right edge.
498
+ const row = panLine(source.line, hscroll, contentW);
499
+ const u = state.rowUnits[source.abs] ?? null;
500
+ // Tint the prose column at minimum, and a wide row across what it occupies.
501
+ const tintW = Math.min(contentW, Math.max(docW, visibleWidth(row)));
439
502
  if (u !== null && u >= lo && u <= hi)
440
- body.push(` ${CYAN}▌${RESET} ${tintRow(row, docW)}`);
503
+ body.push(` ${CYAN}▌${RESET} ${tintRow(row, tintW)}`);
441
504
  else if (u !== null && commented.has(u))
442
505
  body.push(` ${YELLOW}▎${RESET} ${row}`);
443
506
  else
444
507
  body.push(` ${row}`);
445
508
  }
446
- if (scroll > 0 && body.length > 0)
447
- body[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
448
- const remaining = doc.lines.length - (scroll + bodyHeight);
449
- if (remaining > 0 && body.length > 0)
450
- body[body.length - 1] = ` ${DIM}↓ ${remaining} more below${RESET}`;
509
+ // Contextual pan hint: only while something on screen actually overflows.
510
+ // Appends to the existing footer line, so the reserved row count is unchanged.
511
+ if (overflow > 0 && state.mode === 'view' && footer.length > 0) {
512
+ footer[0] = `${footer[0]} ${DIM}h/l${RESET} pan`;
513
+ }
451
514
  while (body.length < bodyHeight)
452
515
  body.push('');
453
516
  const lines = [...header, ...body, ...footer];
454
517
  while (lines.length < rows)
455
518
  lines.push('');
456
- return lines.slice(0, rows);
519
+ // The frame is contained by construction: no row may exceed the terminal, or
520
+ // it wraps and breaks the differ's one-line-per-row model.
521
+ return lines.slice(0, rows).map((line) => clipLine(line, cols));
457
522
  }
458
523
  function diskDraftResult(absFile, outPath) {
459
524
  const draft = readReviewDraft(outPath);
@@ -537,7 +602,7 @@ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
537
602
  const draft = readReviewDraft(outPath);
538
603
  setupTerminal();
539
604
  let { cols, rows } = getTerminalSize();
540
- let doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
605
+ let doc = renderReviewDoc(content, cols);
541
606
  let state = initReviewState(sourceLines, deriveAnchorUnits(doc), draft?.comments ?? [], draft?.version ?? 0);
542
607
  let prevFrame = [];
543
608
  let spaceArmed = false;
@@ -575,7 +640,7 @@ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
575
640
  const finish = (result) => finishWith({ type: 'done', result });
576
641
  const onResize = () => {
577
642
  ({ cols, rows } = getTerminalSize());
578
- doc = renderMarkdownWithMap(content, renderWidthForCols(cols));
643
+ doc = renderReviewDoc(content, cols);
579
644
  // Unit SOURCE ranges are width-independent under the same renderer (only
580
645
  // their rendered-row bounds move), but a mid-session renderer→fallback
581
646
  // transition can reshape the list — so remap the anchor by its source
@@ -775,6 +840,10 @@ function runTerminalReviewSession(absFile, content, outPath, fileLabel, opts) {
775
840
  state = scrollBy(state, -bodyH(), bodyH(), doc.lines.length);
776
841
  else if (input === 'd' || key.pageDown)
777
842
  state = scrollBy(state, bodyH(), bodyH(), doc.lines.length);
843
+ else if ((input === 'h' || key.leftArrow) && maxHScroll(state, doc, cols, rows) > 0)
844
+ state = panBy(state, -PAN_STEP, doc, cols, rows);
845
+ else if ((input === 'l' || key.rightArrow) && maxHScroll(state, doc, cols, rows) > 0)
846
+ state = panBy(state, PAN_STEP, doc, cols, rows);
778
847
  else if (input === '?')
779
848
  state = openHelp(state);
780
849
  else if (key.escape) {
@@ -19,7 +19,7 @@ import { validateDeck } from './deck-schema.js';
19
19
  import { ReviewAdapter } from './review-adapter.js';
20
20
  import { editBufferInEditor } from '../editor/roundtrip.js';
21
21
  import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
22
- import { cancelVisualRequest, readVisualResult, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
22
+ import { cancelVisualRequest, latestVisualRequestForInteraction, readVisualResult, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
23
23
  import { kickInboxMaintenance } from './maintenance.js';
24
24
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
25
25
  export class InboxController {
@@ -129,7 +129,9 @@ export class InboxController {
129
129
  // Hard-clip the list line to its column: an overflowing row would eat
130
130
  // the pad and push the divider out of alignment for that row alone.
131
131
  const left = clipLine(list[i] ?? '', geometry.listWidth);
132
- const right = detail[i] ?? '';
132
+ // Clip the detail column too: a deck/preview row is only ever entitled to
133
+ // its own panel, so an over-wide row cannot run past the pane's edge.
134
+ const right = clipLine(detail[i] ?? '', geometry.detailWidth);
133
135
  const pad = ' '.repeat(Math.max(0, geometry.listWidth - visibleWidth(left)));
134
136
  lines.push(`${left}${pad}${divider}${right}`);
135
137
  }
@@ -249,13 +251,6 @@ export class InboxController {
249
251
  this.invalidate();
250
252
  return;
251
253
  }
252
- const root = ticketRoot(item.dir);
253
- if (root !== null) {
254
- // A newly acquired claim makes every older running generation stale
255
- // before the panel can mint its own work. This only dispatches cleanup.
256
- const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
257
- void retirement.delivery.finally(kickInboxMaintenance);
258
- }
259
254
  // Notifications use the same canonical deck panel as every other deck:
260
255
  // opening is not acknowledgement; panel completion is.
261
256
  this.activeDeck = deck;
@@ -640,19 +635,28 @@ export class InboxController {
640
635
  watcher = undefined;
641
636
  settle(outcome);
642
637
  };
643
- let started;
638
+ let durableRequestId;
639
+ let delivery;
644
640
  try {
645
- started = startVisualRequest({ root, dir, claimToken, request });
641
+ const adopted = latestVisualRequestForInteraction(root, dir, request.interaction);
642
+ if (adopted !== null) {
643
+ durableRequestId = adopted.requestId;
644
+ }
645
+ else {
646
+ const started = startVisualRequest({ root, dir, claimToken, request });
647
+ durableRequestId = started.request.requestId;
648
+ delivery = started.delivery;
649
+ }
646
650
  }
647
651
  catch (error) {
648
652
  finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
649
- return { result, cancel: () => { } };
653
+ return { result, detach: () => { }, cancel: () => { } };
650
654
  }
651
655
  const reread = () => {
652
656
  if (settled)
653
657
  return;
654
658
  try {
655
- const outcome = readVisualResult(root, dir, request.requestId);
659
+ const outcome = readVisualResult(root, dir, durableRequestId);
656
660
  if (outcome === null)
657
661
  return;
658
662
  finish(outcome.status === 'ready'
@@ -666,20 +670,26 @@ export class InboxController {
666
670
  try {
667
671
  // Install the watch before the first durable reread so a publication in
668
672
  // the narrow setup window is either observed or found by that reread.
669
- watcher = watch(join(visualsDir(dir), request.requestId), () => reread());
673
+ watcher = watch(join(visualsDir(dir), durableRequestId), (_event, file) => {
674
+ // Rereading takes a short directory lock, which itself emits watch events on Linux.
675
+ // Only the immutable result can settle this handle; ignoring lock/request churn avoids a self-triggered event loop.
676
+ if (file !== null && file !== 'result.json')
677
+ return;
678
+ reread();
679
+ });
670
680
  watcher.once('error', (error) => finish({ status: 'error', error: error.message }));
671
681
  reread();
672
682
  }
673
683
  catch (error) {
674
684
  finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
675
685
  }
676
- void started.delivery.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
686
+ void delivery?.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
677
687
  return {
678
688
  result,
689
+ detach: () => finish({ status: 'error', error: 'Visual view detached' }),
679
690
  cancel: () => {
680
- watcher?.close();
681
- watcher = undefined;
682
- void cancelVisualRequest(root, dir, request.requestId).finally(kickInboxMaintenance);
691
+ finish({ status: 'error', error: 'Visual request canceled' });
692
+ void cancelVisualRequest(root, dir, durableRequestId).finally(kickInboxMaintenance);
683
693
  },
684
694
  };
685
695
  }
@@ -764,7 +774,8 @@ export class InboxController {
764
774
  const selected = this.items[this.selectedIndex];
765
775
  if (selected === undefined)
766
776
  return [` ${DIM}Select a pending interaction.${RESET}`];
767
- const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
777
+ const source = sourceLabel(selected.source);
778
+ const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind}${source === undefined ? '' : ` · ${source}`}${RESET}`];
768
779
  if (selected.kind === 'review') {
769
780
  lines.push('', ` ${DIM}${selected.file}${RESET}`);
770
781
  const draft = readJson(progressPath(selected.dir))?.comments;
@@ -788,9 +799,8 @@ export class InboxController {
788
799
  if (deck !== null) {
789
800
  for (const interaction of deck.interactions) {
790
801
  lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
791
- if (interaction.subtitle)
792
- for (const rendered of renderMarkdown(interaction.subtitle, Math.max(1, width - 2)))
793
- lines.push(` ${rendered}`);
802
+ for (const rendered of renderMarkdown(interaction.subtitle, Math.max(1, width - 2)))
803
+ lines.push(` ${rendered}`);
794
804
  if (interaction.body)
795
805
  for (const rendered of renderMarkdown(interaction.body, Math.max(1, width - 2)))
796
806
  lines.push(` ${rendered}`);
@@ -901,6 +911,5 @@ function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1b
901
911
  function isAnswerBearingDeck(deck) {
902
912
  return deck.interactions.some((interaction) => interaction.kind !== 'notify');
903
913
  }
904
- /** Prefer a human-meaningful source label, falling back to the raw node id
905
- * before an opaque "unknown source" a crouter ticket always carries nodeId. */
906
- function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }
914
+ /** Machine node IDs remain stored for routing, but passive detail shows only authored labels. */
915
+ function sourceLabel(source) { return source.sessionName?.trim() || source.askedBy?.trim() || undefined; }
@@ -1,6 +1,8 @@
1
1
  import type { Deck } from '../types.js';
2
2
  export interface NotifyDeckOpts {
3
+ /** One plain-English sentence stating the notification's status and stakes. */
4
+ subtitle: string;
3
5
  body?: string;
4
6
  }
5
7
  /** Build a validated single-option notify deck. id: 'notify', kind: 'notify'. */
6
- export declare function notifyDeck(title: string, opts?: NotifyDeckOpts): Deck;
8
+ export declare function notifyDeck(title: string, opts: NotifyDeckOpts): Deck;
@@ -1,11 +1,12 @@
1
1
  import { validateDeck } from './deck-schema.js';
2
2
  /** Build a validated single-option notify deck. id: 'notify', kind: 'notify'. */
3
- export function notifyDeck(title, opts = {}) {
3
+ export function notifyDeck(title, opts) {
4
4
  return validateDeck({
5
5
  title,
6
6
  interactions: [{
7
7
  id: 'notify',
8
8
  title,
9
+ subtitle: opts.subtitle,
9
10
  ...(opts.body !== undefined ? { body: opts.body } : {}),
10
11
  kind: 'notify',
11
12
  options: [{ id: 'ok', label: 'OK' }],
@@ -23,7 +23,7 @@ export declare const deckSchema: z.ZodObject<{
23
23
  interactions: z.ZodArray<z.ZodObject<{
24
24
  id: z.ZodString;
25
25
  title: z.ZodString;
26
- subtitle: z.ZodOptional<z.ZodString>;
26
+ subtitle: z.ZodString;
27
27
  body: z.ZodOptional<z.ZodString>;
28
28
  bodyPath: z.ZodOptional<z.ZodString>;
29
29
  options: z.ZodArray<z.ZodObject<{
@@ -54,6 +54,7 @@ export declare const reviewDescriptorSchema: z.ZodObject<{
54
54
  file: z.ZodString;
55
55
  output: z.ZodString;
56
56
  title: z.ZodString;
57
+ subtitle: z.ZodString;
57
58
  source: z.ZodObject<{
58
59
  sessionName: z.ZodOptional<z.ZodString>;
59
60
  askedBy: z.ZodOptional<z.ZodString>;
@@ -8,7 +8,7 @@ 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().trim().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().trim().min(1), 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() });
@@ -27,7 +27,8 @@ export const reviewDescriptorSchema = z.object({
27
27
  schema: z.literal('humanloop.review/v1'),
28
28
  file: z.string().min(1).refine(isAbsolute, 'file must be absolute'),
29
29
  output: z.string().min(1).refine(isAbsolute, 'output must be absolute'),
30
- title: z.string().min(1),
30
+ title: z.string().trim().min(1),
31
+ subtitle: z.string().trim().min(1),
31
32
  source: reviewSourceSchema,
32
33
  blockedSince: z.string().datetime({ offset: true }),
33
34
  }).strict();
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { inboxRootsDirectory, listInboxRoots } from './registry.js';
6
6
  import { reconcileCompletions } from './completion.js';
7
- import { dispatchVisualCleanup, listVisualCleanupObligationsForRoot, reconcileStaleVisualRequestsForRoot } from './visual.js';
7
+ import { dispatchVisualCleanup, listVisualCleanupObligationsForRoot } from './visual.js';
8
8
  const LEASE_STALE_MS = 300_000;
9
9
  function leasePath() { return join(dirname(inboxRootsDirectory()), 'maintenance.lock'); }
10
10
  function roots() { return listInboxRoots().filter((root) => root.available).map((root) => root.root); }
@@ -107,15 +107,6 @@ async function repairOnce() {
107
107
  }
108
108
  catch { /* receipt remains durable for the next pass */ }
109
109
  }
110
- const retirements = allRoots.flatMap((root) => {
111
- try {
112
- return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
113
- }
114
- catch {
115
- return [];
116
- }
117
- });
118
- await Promise.all(retirements);
119
110
  const tasks = dueTasks(allRoots);
120
111
  await Promise.all(tasks.filter((task) => Date.parse(task.nextAttemptAt) <= Date.now()).map((task) => dispatchVisualCleanup(task.root, task.dir, task.requestId)));
121
112
  return dueTasks(roots());
@@ -45,7 +45,7 @@ function reviewSummary(dir, id) {
45
45
  catch {
46
46
  return null;
47
47
  }
48
- return { dir, id, kind: 'review', title: review.title, file: review.file, output: review.output, source: review.source, blockedSince: review.blockedSince, claim: claimSummary(dir) };
48
+ return { dir, id, kind: 'review', title: review.title, subtitle: review.subtitle, file: review.file, output: review.output, source: review.source, blockedSince: review.blockedSince, claim: claimSummary(dir) };
49
49
  }
50
50
  /** Read all unresolved deck/review tickets, newest first. Progress never affects visibility. */
51
51
  export function scanInbox(roots) {
@@ -124,7 +124,7 @@ export function submitReview(opts) {
124
124
  try {
125
125
  if (hasTicketProtocolState(dir))
126
126
  throw new Error(`ticket protocol state already exists: ${dir}`);
127
- const descriptor = validateReviewProjection(dir, { schema: 'humanloop.review/v1', file: source, output: resolve(opts.review.output ?? `${dir}/feedback.json`), title: opts.review.title, source: opts.review.source, blockedSince: opts.review.blockedSince ?? new Date().toISOString() });
127
+ const descriptor = validateReviewProjection(dir, { schema: 'humanloop.review/v1', file: source, output: resolve(opts.review.output ?? `${dir}/feedback.json`), title: opts.review.title, subtitle: opts.review.subtitle, source: opts.review.source, blockedSince: opts.review.blockedSince ?? new Date().toISOString() });
128
128
  publishRequest(reviewPath(dir), descriptor);
129
129
  signalInboxActivity();
130
130
  return { id: opts.id, dir, kind: 'review' };
package/dist/inbox/tui.js CHANGED
@@ -37,24 +37,14 @@ export function buildInboxLines(items, width, selectedIndex, height = Number.MAX
37
37
  function buildItemLines(item, index, selectedIndex, contentWidth) {
38
38
  const kind = item.kind === 'deck' ? item.interactionKind ?? 'decision' : 'review';
39
39
  const icon = KIND_ICON[kind] ?? '·';
40
- // Source and title share the row budget: an unbounded source (a long node
41
- // or session name) would floor the title at its minimum and overflow the
42
- // column, bending the panel divider. Shrink the source only when the row
43
- // cannot hold it alongside the title's 10-col minimum.
44
- const rawSource = item.source.sessionName ?? item.source.askedBy ?? item.source.nodeId ?? '';
40
+ // The inbox is a title-first triage surface. Origin details remain available
41
+ // after opening a ticket, rather than consuming the list's scarce row width.
45
42
  const age = formatTimeAgo(item.blockedSince);
46
- const source = truncateRow(rawSource, Math.max(8, contentWidth - age.length - 8 - 10));
47
43
  const cursor = index === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
48
- const titleWidth = Math.max(10, contentWidth - source.length - age.length - 8);
49
- let row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} `;
50
- if (source)
51
- row += `${ansiColor(source, 'yellow')} ${DIM}·${RESET} `;
52
- row += `${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
53
- if (item.claim)
54
- return [row, ` ${DIM}${truncateRow(`claimed by ${item.claim.owner}`, contentWidth - 6)}${RESET}`];
55
- if (item.subtitle)
56
- return [row, ` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`];
57
- return [row];
44
+ const titleWidth = Math.max(1, contentWidth - age.length - 6);
45
+ const row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} ${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
46
+ const subtitle = item.claim ? `claimed by ${item.claim.owner} · ${item.subtitle}` : item.subtitle;
47
+ return [row, ` ${DIM}${truncateRow(subtitle, contentWidth - 6)}${RESET}`];
58
48
  }
59
49
  /** Select a contiguous row window that always contains the selected ticket. */
60
50
  function visibleWindow(rows, selectedIndex, capacity) {
@@ -107,6 +107,9 @@ export declare function submitVisualResult(root: string, dir: string, raw: Visua
107
107
  export declare function dispatchVisualCleanup(root: string, dir: string, requestId: string): Promise<VisualCleanupDeliveryResult>;
108
108
  /** State-first cancellation for one handle; the returned promise is cleanup delivery only. */
109
109
  export declare function cancelVisualRequest(root: string, dir: string, requestId: string): Promise<VisualCleanupDeliveryResult>;
110
+ /** Return the latest generation for one unchanged interaction. A canceled latest
111
+ * generation is authoritative and cannot resurrect an older completed result. */
112
+ export declare function latestVisualRequestForInteraction(root: string, dir: string, interaction: Interaction | CanonicalInteraction): VisualProtocolRequest | null;
110
113
  /** State-first cancel every running request, then dispatch every owed cleanup (including start failures). */
111
114
  export declare function cancelVisualRequestsForTicket(root: string, dir: string): Promise<{
112
115
  canceled: number;
@@ -114,17 +117,6 @@ export declare function cancelVisualRequestsForTicket(root: string, dir: string)
114
117
  }>;
115
118
  /** Durable tasks the next controller-owned retry executor should schedule. */
116
119
  export declare function listVisualCleanupObligations(root: string, dir: string): VisualCleanupTask[];
117
- export interface VisualRequestReconciliation {
118
- retired: number;
119
- cleanupOwed: number;
120
- /** Cleanup delivery only; reconciliation never redelivers a start. */
121
- delivery: Promise<VisualCleanupDeliveryResult[]>;
122
- }
123
- /** Retire abandoned generations under the ticket claim boundary. A live claim is
124
- * authoritative; a newly acquired claim retires every older generation first. */
125
- export declare function reconcileVisualRequestsForTicket(root: string, dir: string, currentClaimToken?: string): VisualRequestReconciliation;
126
120
  /** Enumerate direct ticket children only, including resolved tickets whose cleanup is still owed. */
127
121
  export declare function listVisualCleanupObligationsForRoot(root: string): VisualCleanupTask[];
128
- /** Startup reconciliation only retires stale/missing-claim work; live owners stay untouched. */
129
- export declare function reconcileStaleVisualRequestsForRoot(root: string): VisualRequestReconciliation[];
130
122
  export {};