@giovannijecha/jecode 0.7.2 → 0.7.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.
package/dist/tui/app.js CHANGED
@@ -21,7 +21,7 @@ import { appWorkflows } from "./app-workflows.js";
21
21
  import { sessionPermissions } from "../permissions.js";
22
22
  import { resumePicker } from "./resume.js";
23
23
  const FRAME_MS = 16;
24
- const SPIN_MS = 80;
24
+ const ACTIVITY_REFRESH_MS = 1_000;
25
25
  /** How long a lone escape waits to prove it is not the start of a sequence. */
26
26
  const ESCAPE_MS = 25;
27
27
  export async function runApp(session, transcriptRoot, environment = {}) {
@@ -36,7 +36,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
36
36
  const permissions = sessionPermissions(session.tools, session.config.autoApprove);
37
37
  let closed;
38
38
  let frameTimer;
39
- let spinTimer;
39
+ let activityTimer;
40
40
  let escapeTimer;
41
41
  let stopResize = () => { };
42
42
  let stopInput = () => { };
@@ -70,8 +70,6 @@ export async function runApp(session, transcriptRoot, environment = {}) {
70
70
  : activityStatus(state.activity, state.status ?? state.activity.label, now),
71
71
  feedback: state.feedback,
72
72
  readiness: turnBlocker(session),
73
- spin: state.spin,
74
- reducedMotion: session.config.reducedMotion,
75
73
  now,
76
74
  modal: overlay.shown(state.open),
77
75
  menu: completionOptions(state.completing),
@@ -98,6 +96,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
98
96
  }
99
97
  state.lastMaxScroll = frame.maxScroll;
100
98
  paint.paint(frame.rows, frame.cursor);
99
+ if (frame.transcriptPending)
100
+ render();
101
101
  };
102
102
  // Streaming produces a token at a time; painting at that rate is wasted
103
103
  // work, so repaints coalesce onto one frame.
@@ -144,8 +144,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
144
144
  live = false;
145
145
  safely(stopInput);
146
146
  safely(stopResize);
147
- if (spinTimer !== undefined)
148
- clearInterval(spinTimer);
147
+ if (activityTimer !== undefined)
148
+ clearInterval(activityTimer);
149
149
  if (frameTimer !== undefined)
150
150
  clearTimeout(frameTimer);
151
151
  if (escapeTimer !== undefined)
@@ -194,9 +194,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
194
194
  const activity = begin(kind, label);
195
195
  state.activity = activity;
196
196
  state.status = label;
197
- spinTimer = setInterval(() => guard(() => {
198
- if (!session.config.reducedMotion)
199
- state.spin++;
197
+ activityTimer = setInterval(() => guard(() => {
200
198
  let activeTool;
201
199
  for (let index = state.blocks.length - 1; index >= 0; index--) {
202
200
  const block = state.blocks[index];
@@ -206,16 +204,16 @@ export async function runApp(session, transcriptRoot, environment = {}) {
206
204
  break;
207
205
  }
208
206
  render(activeTool);
209
- }), session.config.reducedMotion ? 1_000 : SPIN_MS);
207
+ }), ACTIVITY_REFRESH_MS);
210
208
  render();
211
209
  return activity;
212
210
  }
213
211
  function finishActivity(activity) {
214
212
  if (state.activity !== activity)
215
213
  return;
216
- if (spinTimer !== undefined)
217
- clearInterval(spinTimer);
218
- spinTimer = undefined;
214
+ if (activityTimer !== undefined)
215
+ clearInterval(activityTimer);
216
+ activityTimer = undefined;
219
217
  state.activity = undefined;
220
218
  state.status = undefined;
221
219
  state.open = overlay.cancel(state.open);
@@ -295,7 +293,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
295
293
  terminal.enter(session.config.reducedMotion);
296
294
  stopResize = terminal.onResize(() => guard(() => {
297
295
  paint.invalidate();
298
- draw();
296
+ render();
299
297
  }));
300
298
  stopInput = terminal.onInput((chunk) => guard(() => {
301
299
  if (escapeTimer !== undefined)
@@ -13,8 +13,6 @@ export function render(block, width, pal, context = {}) {
13
13
  case "tool":
14
14
  return renderTool(block, width, pal, {
15
15
  continues: context.previous?.kind === "tool",
16
- spin: context.spin,
17
- reducedMotion: context.reducedMotion,
18
16
  now: context.now,
19
17
  });
20
18
  case "notice":
@@ -10,7 +10,7 @@ export function renderTool(block, width, pal, context = {}) {
10
10
  ...(context.continues === true ? [] : [""]),
11
11
  row(width, [
12
12
  { text: " " },
13
- { text: `${stateGlyph(block, context)} `, fg: statusInk(block.tone, pal), bold: true },
13
+ { text: `${stateGlyph(block)} `, fg: statusInk(block.tone, pal), bold: true },
14
14
  { text: block.name, fg: pal.ink.bright, bold: true },
15
15
  ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
16
16
  ], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
@@ -81,13 +81,9 @@ function emphasized(text, emphasis, fg) {
81
81
  ...(end === text.length ? [] : [{ text: text.slice(end), fg }]),
82
82
  ];
83
83
  }
84
- function stateGlyph(block, context) {
85
- if (block.tone === "pending") {
86
- if (block.startedAt === undefined || context.reducedMotion === true)
87
- return "◌";
88
- const frames = ["◐", "◓", "◑", "◒"];
89
- return frames[Math.floor((context.spin ?? 0) / 2) % frames.length];
90
- }
84
+ function stateGlyph(block) {
85
+ if (block.tone === "pending")
86
+ return "◌";
91
87
  if (block.tone === "fail")
92
88
  return hasColor() ? "●" : "×";
93
89
  if (block.tone === "deny")
@@ -1,13 +1,14 @@
1
1
  // Small projections of the live session used by the shell and footer.
2
2
  import { credentialSource } from "../credentials.js";
3
3
  import { providerFailure } from "../provider-errors.js";
4
- export function controllerOptions(session, tools = session.tools) {
4
+ export function controllerOptions(session, contextPolicy, tools = session.tools) {
5
5
  return {
6
6
  provider: session.provider,
7
7
  tools,
8
8
  model: session.model,
9
9
  system: session.system,
10
10
  maxTokens: session.config.maxTokens,
11
+ contextPolicy,
11
12
  effort: session.config.effort,
12
13
  maxSteps: session.config.maxSteps,
13
14
  toolContext: { root: session.config.root },
@@ -1,138 +1,210 @@
1
1
  // Incremental transcript layout for long-running sessions.
2
2
  import { render } from "./blocks.js";
3
+ const MAX_LAYOUTS = 2;
4
+ const REFLOW_BLOCKS_PER_FRAME = 128;
3
5
  /**
4
- * Keep block rows and cumulative row ends between frames.
6
+ * Keep recent widths and reflow a bounded working set on each frame.
5
7
  *
6
- * The app mutates semantic blocks while streaming, so it marks that one block
7
- * dirty. Appends then cost one render, stable frames cost no block renders,
8
- * and viewport selection starts with a binary search instead of flattening or
9
- * scanning the complete transcript.
8
+ * The visible window is always rendered before it is returned. Older rows use
9
+ * their last known height only to guide scrolling while the remaining blocks
10
+ * are refreshed over later frames. This keeps resize responsive without a
11
+ * worker, hidden loop, or unbounded multi-width cache.
10
12
  */
11
13
  export function transcriptRenderer(draw = render) {
12
- let source;
13
- let layoutWidth = -1;
14
- let layoutPalette;
15
- let entries = [];
16
- let ends = [];
17
- let positions = new WeakMap();
18
- const dirty = new Set();
14
+ let layouts = [];
19
15
  return {
20
16
  viewport(blocks, width, height, scroll, palette, state = {}) {
21
- sync(blocks, width, palette, state);
22
- const visibleHeight = Math.max(0, height);
23
- const totalRows = ends.at(-1) ?? 0;
24
- const maxScroll = Math.max(0, totalRows - visibleHeight);
25
- const offset = Math.min(Math.max(0, scroll), maxScroll);
26
- const start = Math.max(0, totalRows - visibleHeight - offset);
27
- const end = start + visibleHeight;
28
- const rows = [];
29
- let index = firstEndingAfter(start);
30
- let at = index === 0 ? 0 : (ends[index - 1] ?? 0);
31
- while (index < entries.length && at < end) {
32
- const blockRows = entries[index]?.rows ?? [];
33
- rows.push(...blockRows.slice(Math.max(0, start - at), Math.min(blockRows.length, end - at)));
34
- at = ends[index] ?? at;
35
- index++;
36
- }
37
- while (rows.length < visibleHeight)
38
- rows.unshift("");
39
- return { rows, maxScroll };
17
+ const layout = layoutFor(blocks, width, palette);
18
+ // Reflowing rows below a scroll-locked viewport would move the content
19
+ // being read as estimates become exact. Visible rows still resolve on
20
+ // demand; background work resumes when the user follows the tail again.
21
+ if (scroll === 0)
22
+ reflowBatch(layout, state);
23
+ const window = reflowWindow(layout, Math.max(0, height), scroll, state);
24
+ return {
25
+ rows: visibleRows(layout, window, Math.max(0, height)),
26
+ maxScroll: window.maxScroll,
27
+ pending: scroll === 0 && layout.pending > 0,
28
+ };
40
29
  },
41
30
  invalidate(block) {
42
- if (block !== undefined) {
43
- dirty.add(block);
31
+ if (block === undefined) {
32
+ layouts = [];
44
33
  return;
45
34
  }
46
- source = undefined;
47
- entries = [];
48
- ends = [];
49
- positions = new WeakMap();
50
- dirty.clear();
35
+ for (const layout of layouts) {
36
+ const index = layout.positions.get(block);
37
+ if (index === undefined)
38
+ continue;
39
+ const entry = layout.entries[index];
40
+ if (entry?.rows !== undefined) {
41
+ entry.rows = undefined;
42
+ layout.pending++;
43
+ }
44
+ layout.next = Math.max(layout.next, index);
45
+ }
51
46
  },
52
47
  };
53
- function sync(blocks, width, palette, state) {
54
- if (source !== blocks ||
55
- layoutWidth !== width ||
56
- layoutPalette !== palette ||
57
- blocks.length < entries.length ||
58
- edgeChanged(blocks)) {
59
- rebuild(blocks, width, palette, state);
60
- return;
48
+ function layoutFor(blocks, width, palette) {
49
+ const cachedAt = layouts.findIndex((layout) => layout.width === width && layout.palette === palette);
50
+ if (cachedAt !== -1) {
51
+ const cached = layouts[cachedAt];
52
+ layouts.splice(cachedAt, 1);
53
+ if (compatible(cached, blocks)) {
54
+ append(cached, blocks);
55
+ layouts.push(cached);
56
+ return cached;
57
+ }
61
58
  }
62
- append(blocks, width, palette, state);
63
- refreshDirty(width, palette, state);
59
+ const seed = [...layouts].reverse().find((layout) => compatiblePrefix(layout, blocks));
60
+ const created = createLayout(blocks, width, palette, seed);
61
+ layouts.push(created);
62
+ if (layouts.length > MAX_LAYOUTS)
63
+ layouts.shift();
64
+ return created;
64
65
  }
65
- function rebuild(blocks, width, palette, state) {
66
- source = blocks;
67
- layoutWidth = width;
68
- layoutPalette = palette;
69
- entries = [];
70
- ends = [];
71
- positions = new WeakMap();
72
- dirty.clear();
73
- append(blocks, width, palette, state);
66
+ function createLayout(blocks, width, palette, seed) {
67
+ const layout = {
68
+ source: blocks,
69
+ width,
70
+ palette,
71
+ entries: [],
72
+ ends: [],
73
+ positions: new WeakMap(),
74
+ pending: 0,
75
+ next: -1,
76
+ };
77
+ append(layout, blocks, seed);
78
+ return layout;
74
79
  }
75
- function append(blocks, width, palette, state) {
76
- let total = ends.at(-1) ?? 0;
77
- for (let index = entries.length; index < blocks.length; index++) {
80
+ function append(layout, blocks, seed) {
81
+ const firstAdded = layout.entries.length;
82
+ let total = layout.ends.at(-1) ?? 0;
83
+ for (let index = firstAdded; index < blocks.length; index++) {
78
84
  const block = blocks[index];
79
85
  if (block === undefined)
80
86
  continue;
81
- const rows = draw(block, width, palette, { ...state, previous: blocks[index - 1] });
82
- entries.push({ block, rows });
83
- positions.set(block, index);
84
- dirty.delete(block);
85
- total += rows.length;
86
- ends.push(total);
87
+ const seeded = seed?.entries[index];
88
+ const rowCount = seeded?.block === block ? seeded.rowCount : estimatedRows(block);
89
+ layout.entries.push({ block, rowCount });
90
+ layout.positions.set(block, index);
91
+ layout.pending++;
92
+ total += rowCount;
93
+ layout.ends.push(total);
94
+ }
95
+ if (layout.entries.length > firstAdded) {
96
+ layout.next = Math.max(layout.next, layout.entries.length - 1);
87
97
  }
88
98
  }
89
- function refreshDirty(width, palette, state) {
90
- if (dirty.size === 0)
91
- return;
92
- const changed = [...dirty]
93
- .map((block) => positions.get(block))
94
- .filter((index) => index !== undefined)
95
- .sort((left, right) => left - right);
96
- dirty.clear();
97
- if (changed.length === 0)
98
- return;
99
- const first = changed[0];
100
- for (const index of changed) {
101
- const entry = entries[index];
102
- if (entry !== undefined) {
103
- entry.rows = draw(entry.block, width, palette, {
104
- ...state,
105
- previous: entries[index - 1]?.block,
106
- });
99
+ function reflowBatch(layout, state) {
100
+ let remaining = REFLOW_BLOCKS_PER_FRAME;
101
+ let firstChanged = layout.entries.length;
102
+ let index = layout.next;
103
+ while (index >= 0 && remaining > 0 && layout.pending > 0) {
104
+ if (renderEntry(layout, index, state)) {
105
+ firstChanged = Math.min(firstChanged, index);
106
+ remaining--;
107
107
  }
108
+ index--;
108
109
  }
109
- recomputeEnds(first);
110
+ layout.next = index;
111
+ if (firstChanged < layout.entries.length)
112
+ recomputeEnds(layout, firstChanged);
110
113
  }
111
- function recomputeEnds(from) {
112
- let total = from === 0 ? 0 : (ends[from - 1] ?? 0);
113
- for (let index = from; index < entries.length; index++) {
114
- total += entries[index]?.rows.length ?? 0;
115
- ends[index] = total;
114
+ function reflowWindow(layout, height, scroll, state) {
115
+ while (true) {
116
+ const window = visibleWindow(layout, height, scroll);
117
+ let firstChanged = layout.entries.length;
118
+ for (let index = window.first; index <= window.last; index++) {
119
+ if (renderEntry(layout, index, state))
120
+ firstChanged = Math.min(firstChanged, index);
121
+ }
122
+ if (firstChanged === layout.entries.length)
123
+ return window;
124
+ recomputeEnds(layout, firstChanged);
116
125
  }
117
126
  }
118
- function edgeChanged(blocks) {
119
- if (entries.length === 0)
127
+ function renderEntry(layout, index, state) {
128
+ const entry = layout.entries[index];
129
+ if (entry === undefined || entry.rows !== undefined)
120
130
  return false;
121
- const lastShared = Math.min(blocks.length, entries.length) - 1;
122
- return lastShared < 0 ||
123
- entries[0]?.block !== blocks[0] ||
124
- entries[lastShared]?.block !== blocks[lastShared];
131
+ entry.rows = draw(entry.block, layout.width, layout.palette, {
132
+ ...state,
133
+ previous: layout.entries[index - 1]?.block,
134
+ });
135
+ entry.rowCount = entry.rows.length;
136
+ layout.pending--;
137
+ return true;
138
+ }
139
+ function recomputeEnds(layout, from) {
140
+ let total = from === 0 ? 0 : (layout.ends[from - 1] ?? 0);
141
+ for (let index = from; index < layout.entries.length; index++) {
142
+ total += layout.entries[index]?.rowCount ?? 0;
143
+ layout.ends[index] = total;
144
+ }
125
145
  }
126
- function firstEndingAfter(row) {
127
- let low = 0;
128
- let high = ends.length;
129
- while (low < high) {
130
- const middle = Math.floor((low + high) / 2);
131
- if ((ends[middle] ?? 0) <= row)
132
- low = middle + 1;
133
- else
134
- high = middle;
146
+ function visibleWindow(layout, height, scroll) {
147
+ const totalRows = layout.ends.at(-1) ?? 0;
148
+ const maxScroll = Math.max(0, totalRows - height);
149
+ const offset = Math.min(Math.max(0, scroll), maxScroll);
150
+ const start = Math.max(0, totalRows - height - offset);
151
+ const end = start + height;
152
+ const first = firstEndingAfter(layout.ends, start);
153
+ let last = first - 1;
154
+ let at = first === 0 ? 0 : (layout.ends[first - 1] ?? 0);
155
+ while (last + 1 < layout.entries.length && at < end) {
156
+ last++;
157
+ at = layout.ends[last] ?? at;
135
158
  }
136
- return low;
159
+ return { first, last, start, end, maxScroll };
160
+ }
161
+ function visibleRows(layout, window, height) {
162
+ const rows = [];
163
+ let index = window.first;
164
+ let at = index === 0 ? 0 : (layout.ends[index - 1] ?? 0);
165
+ while (index <= window.last && at < window.end) {
166
+ const blockRows = layout.entries[index]?.rows ?? [];
167
+ rows.push(...blockRows.slice(Math.max(0, window.start - at), Math.min(blockRows.length, window.end - at)));
168
+ at = layout.ends[index] ?? at;
169
+ index++;
170
+ }
171
+ while (rows.length < height)
172
+ rows.unshift("");
173
+ return rows;
174
+ }
175
+ }
176
+ function compatible(layout, blocks) {
177
+ return layout.source === blocks &&
178
+ blocks.length >= layout.entries.length &&
179
+ !edgeChanged(layout, blocks);
180
+ }
181
+ function compatiblePrefix(layout, blocks) {
182
+ if (layout.entries.length === 0 || blocks.length === 0)
183
+ return true;
184
+ const shared = Math.min(layout.entries.length, blocks.length);
185
+ return layout.entries[0]?.block === blocks[0] &&
186
+ layout.entries[shared - 1]?.block === blocks[shared - 1];
187
+ }
188
+ function edgeChanged(layout, blocks) {
189
+ if (layout.entries.length === 0)
190
+ return false;
191
+ const lastShared = Math.min(blocks.length, layout.entries.length) - 1;
192
+ return lastShared < 0 ||
193
+ layout.entries[0]?.block !== blocks[0] ||
194
+ layout.entries[lastShared]?.block !== blocks[lastShared];
195
+ }
196
+ function estimatedRows(block) {
197
+ return block.kind === "user" ? 4 : 2;
198
+ }
199
+ function firstEndingAfter(ends, row) {
200
+ let low = 0;
201
+ let high = ends.length;
202
+ while (low < high) {
203
+ const middle = Math.floor((low + high) / 2);
204
+ if ((ends[middle] ?? 0) <= row)
205
+ low = middle + 1;
206
+ else
207
+ high = middle;
137
208
  }
209
+ return low;
138
210
  }
package/dist/tui/view.js CHANGED
@@ -25,11 +25,16 @@ export function compose(view, size, transcript = transcriptRenderer()) {
25
25
  // Any spare height belongs above the conversation, so short sessions grow
26
26
  // upward from the fixed dock rhythm instead of leaving a changing hole
27
27
  // beneath the latest reply.
28
- const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal, { spin: view.spin, reducedMotion: view.reducedMotion, now: view.now });
28
+ const viewport = transcript.viewport(view.blocks, width, transcriptHeight, view.scroll, view.pal, { now: view.now });
29
29
  const cursor = dock.cursor === undefined
30
30
  ? undefined
31
31
  : { row: transcriptHeight + dock.cursor.row, col: dock.cursor.col };
32
- return { rows: [...viewport.rows, ...dock.rows], cursor, maxScroll: viewport.maxScroll };
32
+ return {
33
+ rows: [...viewport.rows, ...dock.rows],
34
+ cursor,
35
+ maxScroll: viewport.maxScroll,
36
+ transcriptPending: viewport.pending,
37
+ };
33
38
  }
34
39
  function dockRows(view, width, height) {
35
40
  const status = renderStatus({
@@ -82,5 +87,5 @@ function tooSmall(height, width, view) {
82
87
  { text: elide(`need ${MIN_COLS}×${MIN_ROWS}`, Math.max(1, width)), fg: view.pal.ink.dim },
83
88
  ]);
84
89
  }
85
- return { rows, maxScroll: 0 };
90
+ return { rows, maxScroll: 0, transcriptPending: false };
86
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,6 +45,7 @@
45
45
  "start": "node src/main.ts",
46
46
  "tui:lab": "node dev/tui-lab.ts",
47
47
  "bench:transcript": "node dev/benchmark-transcript.ts",
48
+ "bench:search": "node dev/benchmark-search.ts",
48
49
  "typecheck": "tsc --noEmit",
49
50
  "test": "npm run build:release && node --test",
50
51
  "coverage": "npm run build:release && node --test --experimental-test-coverage --test-coverage-include=\"src/**/*.ts\" --test-coverage-lines=80 --test-coverage-branches=75 --test-coverage-functions=75",