@giovannijecha/jecode 0.4.0 → 0.5.0

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
@@ -3,7 +3,7 @@
3
3
  // Everything mutable about a session lives in `state` here. The view is a pure
4
4
  // function of it, so the only job of a key handler is to change state and ask
5
5
  // for a repaint — never to draw.
6
- import { begin } from "./activity.js";
6
+ import { activityStatus, begin } from "./activity.js";
7
7
  import * as overlay from "./overlay.js";
8
8
  import { options as completionOptions } from "./complete.js";
9
9
  import { decoder } from "./keys.js";
@@ -19,6 +19,7 @@ import { appState } from "./app-state.js";
19
19
  import { appInput } from "./app-input.js";
20
20
  import { appWorkflows } from "./app-workflows.js";
21
21
  import { sessionPermissions } from "../permissions.js";
22
+ import { resumePicker } from "./resume.js";
22
23
  const FRAME_MS = 16;
23
24
  const SPIN_MS = 80;
24
25
  /** How long a lone escape waits to prove it is not the start of a sequence. */
@@ -30,6 +31,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
30
31
  const transcript = transcriptRenderer();
31
32
  const workspace = await workspaceLabel(session.config.root);
32
33
  const state = appState();
34
+ state.blocks.push(...session.conversation.transcript);
33
35
  const permissions = sessionPermissions(session.tools, session.config.autoApprove);
34
36
  let closed;
35
37
  let frameTimer;
@@ -43,23 +45,28 @@ export async function runApp(session, transcriptRoot, environment = {}) {
43
45
  const done = new Promise((resolve) => {
44
46
  closed = resolve;
45
47
  });
46
- const view = () => ({
47
- blocks: state.blocks,
48
- editor: state.editor,
49
- scroll: state.scroll,
50
- unseen: state.unseen,
51
- pal: session.palette,
52
- footer: footerInfo(session, workspace),
53
- status: state.status,
54
- feedback: state.feedback,
55
- readiness: turnBlocker(session),
56
- spin: state.spin,
57
- reducedMotion: session.config.reducedMotion,
58
- now: Date.now(),
59
- modal: overlay.shown(state.open),
60
- menu: completionOptions(state.completing),
61
- menuIndex: state.completing?.index,
62
- });
48
+ const view = () => {
49
+ const now = Date.now();
50
+ return {
51
+ blocks: state.blocks,
52
+ editor: state.editor,
53
+ scroll: state.scroll,
54
+ unseen: state.unseen,
55
+ pal: session.palette,
56
+ footer: footerInfo(session, workspace),
57
+ status: state.activity === undefined
58
+ ? undefined
59
+ : activityStatus(state.activity, state.status ?? state.activity.label, now),
60
+ feedback: state.feedback,
61
+ readiness: turnBlocker(session),
62
+ spin: state.spin,
63
+ reducedMotion: session.config.reducedMotion,
64
+ now,
65
+ modal: overlay.shown(state.open),
66
+ menu: completionOptions(state.completing),
67
+ menuIndex: state.completing?.index,
68
+ };
69
+ };
63
70
  const draw = () => {
64
71
  frameTimer = undefined;
65
72
  if (!live)
@@ -201,6 +208,44 @@ export async function runApp(session, transcriptRoot, environment = {}) {
201
208
  invalidate: () => paint.invalidate(),
202
209
  transcriptChanged: (block) => transcript.invalidate(block),
203
210
  });
211
+ const resumeAtLaunch = session.resume === undefined
212
+ ? undefined
213
+ : openResumedSession(session.resume);
214
+ async function openResumedSession(launch) {
215
+ while (live) {
216
+ const index = await new Promise((resolve) => {
217
+ state.open = { picker: resumePicker(launch.candidates, session.palette), settle: resolve };
218
+ render();
219
+ });
220
+ if (index === undefined) {
221
+ quit();
222
+ return;
223
+ }
224
+ const candidate = launch.candidates[index];
225
+ if (candidate === undefined)
226
+ continue;
227
+ const activity = startActivity("command", "Opening session");
228
+ if (activity === undefined)
229
+ return;
230
+ try {
231
+ await launch.open(candidate.id);
232
+ state.blocks.splice(0, state.blocks.length, ...session.conversation.transcript);
233
+ state.past.length = 0;
234
+ state.scroll = 0;
235
+ state.follow = true;
236
+ state.unseen = 0;
237
+ state.lastMaxScroll = 0;
238
+ transcript.invalidate();
239
+ paint.invalidate();
240
+ finishActivity(activity);
241
+ return;
242
+ }
243
+ catch (error) {
244
+ feedback.show({ text: error.message, tone: "error", timeoutMs: 6_000 });
245
+ finishActivity(activity);
246
+ }
247
+ }
248
+ }
204
249
  terminal.enter(session.config.reducedMotion);
205
250
  stopResize = terminal.onResize(() => {
206
251
  paint.invalidate();
@@ -228,5 +273,12 @@ export async function runApp(session, transcriptRoot, environment = {}) {
228
273
  render();
229
274
  });
230
275
  draw();
231
- await done;
276
+ try {
277
+ await resumeAtLaunch;
278
+ if (live)
279
+ await done;
280
+ }
281
+ finally {
282
+ await session.persistence?.close();
283
+ }
232
284
  }
@@ -31,22 +31,8 @@ export function renderReasoning(block, width, pal) {
31
31
  : { text: block.text, truncated: false };
32
32
  const content = markdown(source.text, inner, pal, inner);
33
33
  const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
34
- const action = expanded
35
- ? "ctrl+o compact"
36
- : block.live === true && block.expanded === true
37
- ? "full when done"
38
- : source.truncated || content.length > REASONING_PREVIEW_ROWS
39
- ? "ctrl+o full"
40
- : undefined;
41
34
  return [
42
35
  "",
43
- row(width, [
44
- {
45
- text: block.live === true ? "thinking" : "thought",
46
- fg: pal.ink.bright,
47
- bold: true,
48
- },
49
- ], action === undefined ? [] : [{ text: action, fg: pal.ink.dim }], undefined, PAD),
50
36
  ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })), [], undefined, PAD)),
51
37
  ];
52
38
  }
@@ -6,7 +6,10 @@ export function renderStatus(info, pal) {
6
6
  if (urgent !== undefined)
7
7
  return withUnseen(feedbackSegments(urgent, pal), info.unseen, pal);
8
8
  if (info.status !== undefined) {
9
- return withUnseen([{ text: "esc to interrupt", fg: pal.ink.dim }], info.unseen, pal);
9
+ return withUnseen([
10
+ { text: info.status, fg: pal.ink.muted },
11
+ { text: " · esc to interrupt", fg: pal.ink.dim, optional: true },
12
+ ], info.unseen, pal);
10
13
  }
11
14
  if (info.feedback !== undefined) {
12
15
  return withUnseen(feedbackSegments(info.feedback, pal), info.unseen, pal);
@@ -2,6 +2,7 @@
2
2
  import { hasColor, row } from "../../ui/render.js";
3
3
  const OUTPUT_ROWS = 8;
4
4
  const LIVE_OUTPUT_ROWS = 6;
5
+ const DIFF_ROWS = 15;
5
6
  export function renderTool(block, width, pal, context = {}) {
6
7
  const shown = visibleDetails(block);
7
8
  const right = liveLabel(block, context);
@@ -31,8 +32,24 @@ function visibleDetails(block) {
31
32
  return [{ kind: "gap", text: note }, ...all.slice(-limit)];
32
33
  }
33
34
  // The compact transcript is an audit of what changed, not a code excerpt.
34
- // Context and gap rows remain in semantic state for the explicit full view.
35
- return all.filter((detail) => detail.kind === "add" || detail.kind === "del");
35
+ // One shared budget applies to writes and edits. Keep both ends so a large
36
+ // replacement cannot show only deletions while hiding all new content.
37
+ // Context, omitted changes, and gap rows remain in semantic state for the
38
+ // explicit full view.
39
+ const changed = all.filter((detail) => detail.kind === "add" || detail.kind === "del");
40
+ if (changed.length <= DIFF_ROWS)
41
+ return changed;
42
+ const leading = Math.ceil(DIFF_ROWS / 2);
43
+ const trailing = DIFF_ROWS - leading;
44
+ const hidden = changed.length - DIFF_ROWS;
45
+ return [
46
+ ...changed.slice(0, leading),
47
+ {
48
+ kind: "gap",
49
+ text: `… ${hidden} more changed ${hidden === 1 ? "line" : "lines"} · ctrl+o expand`,
50
+ },
51
+ ...changed.slice(-trailing),
52
+ ];
36
53
  }
37
54
  function renderDetail(detail, tone, width, pal) {
38
55
  const lead = { text: " " };
@@ -50,6 +50,9 @@ export function commandFeedback(block) {
50
50
  }
51
51
  /** Explain why a model turn cannot start, without exposing configuration internals. */
52
52
  export function turnBlocker(session) {
53
+ if (session.persistence?.failure !== undefined) {
54
+ return { text: "session could not be saved · /new to retry", tone: "error" };
55
+ }
53
56
  const blocked = session.provider.blocked();
54
57
  const auth = session.provider.auth;
55
58
  const expected = auth.kind === "api-key"
@@ -0,0 +1,24 @@
1
+ import { heading } from "./picker.js";
2
+ export function resumePicker(candidates, palette) {
3
+ return {
4
+ title: heading("resume", "saved conversations", palette),
5
+ searchable: true,
6
+ visible: 8,
7
+ options: candidates.map((candidate) => ({
8
+ label: candidate.preview,
9
+ hint: stamp(candidate.updatedAt),
10
+ value: `${candidate.turns} ${candidate.turns === 1 ? "turn" : "turns"}`,
11
+ })),
12
+ index: 0,
13
+ };
14
+ }
15
+ function stamp(value) {
16
+ const date = new Date(value);
17
+ if (!Number.isFinite(date.getTime()))
18
+ return value.replace("T", " ").slice(0, 16);
19
+ const two = (part) => String(part).padStart(2, "0");
20
+ return [
21
+ `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`,
22
+ `${two(date.getHours())}:${two(date.getMinutes())}`,
23
+ ].join(" ");
24
+ }
package/dist/tui/turn.js CHANGED
@@ -5,9 +5,8 @@
5
5
  // neither has to know how the other is built.
6
6
  import { condense, diff } from "../ui/diff.js";
7
7
  import { promptFor } from "./approve.js";
8
- // Semantic activity labels remain useful state even though the quiet footer
9
- // reduces them to one stable interruption hint. Reasoning and tools identify
10
- // the live work in the transcript itself.
8
+ // Semantic activity labels feed the footer's compact state and timer while
9
+ // reasoning and tools keep the detailed work visible in the transcript.
11
10
  const WAITING = "Waiting";
12
11
  const THINKING = "Thinking";
13
12
  const WRITING = "Writing";
package/dist/ui/diff.js CHANGED
@@ -96,6 +96,8 @@ function common(a, b) {
96
96
  // A trailing newline is a property of the file, not a line of it: counting it
97
97
  // as one would report every append as touching two lines instead of one.
98
98
  function lines(text) {
99
+ if (text === "")
100
+ return [];
99
101
  const split = text.split("\n");
100
102
  if (split.length > 1 && split[split.length - 1] === "")
101
103
  split.pop();
package/dist/ui/render.js CHANGED
@@ -72,6 +72,8 @@ export function fitSegs(segs, cols) {
72
72
  used += w;
73
73
  continue;
74
74
  }
75
+ if (safe.optional === true)
76
+ continue;
75
77
  const room = cols - used;
76
78
  if (room > 0)
77
79
  out.push({ ...safe, text: elide(safe.text, room) });
package/dist/usage.js CHANGED
@@ -19,6 +19,15 @@ export function recordUsage(total, next) {
19
19
  total.cacheWriteInputTokens += next.cacheWriteInputTokens;
20
20
  total.reasoningTokens += next.reasoningTokens;
21
21
  }
22
+ export function usageFromHistory(messages) {
23
+ const total = emptyUsage();
24
+ for (const message of messages) {
25
+ if (message.role === "assistant" && message.usage !== undefined) {
26
+ recordUsage(total, message.usage);
27
+ }
28
+ }
29
+ return total;
30
+ }
22
31
  export function formatTokens(value) {
23
32
  if (value < 1_000)
24
33
  return String(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {