@giovannijecha/jecode 0.3.2 → 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.
Files changed (54) hide show
  1. package/README.md +63 -23
  2. package/dist/batch.js +22 -2
  3. package/dist/cli-info.js +3 -0
  4. package/dist/command-settings.js +19 -0
  5. package/dist/commands.js +11 -14
  6. package/dist/config.js +2 -0
  7. package/dist/controller.js +3 -0
  8. package/dist/conversation.js +208 -0
  9. package/dist/credential-commands.js +63 -80
  10. package/dist/credentials.js +7 -1
  11. package/dist/launch.js +19 -0
  12. package/dist/model-command.js +171 -0
  13. package/dist/permission-command.js +52 -53
  14. package/dist/provider-commands.js +71 -228
  15. package/dist/provider-errors.js +4 -3
  16. package/dist/provider-label.js +2 -2
  17. package/dist/providers/ollama.js +8 -3
  18. package/dist/sessions/codec.js +344 -0
  19. package/dist/sessions/lease.js +76 -0
  20. package/dist/sessions/runtime.js +73 -0
  21. package/dist/sessions/store.js +368 -0
  22. package/dist/settings-command.js +43 -98
  23. package/dist/start.js +67 -4
  24. package/dist/transcript-types.js +6 -0
  25. package/dist/tui/activity.js +3 -0
  26. package/dist/tui/app-input.js +9 -6
  27. package/dist/tui/app-workflows.js +37 -8
  28. package/dist/tui/app.js +72 -20
  29. package/dist/tui/components/composer.js +1 -1
  30. package/dist/tui/components/footer.js +1 -1
  31. package/dist/tui/components/menu.js +27 -15
  32. package/dist/tui/components/messages.js +1 -15
  33. package/dist/tui/components/prompt.js +2 -2
  34. package/dist/tui/components/status.js +5 -2
  35. package/dist/tui/components/tool.js +22 -5
  36. package/dist/tui/editor.js +54 -7
  37. package/dist/tui/feedback.js +5 -2
  38. package/dist/tui/field.js +1 -1
  39. package/dist/tui/help.js +4 -1
  40. package/dist/tui/input.js +4 -0
  41. package/dist/tui/keys.js +14 -3
  42. package/dist/tui/overlay.js +6 -0
  43. package/dist/tui/picker.js +26 -10
  44. package/dist/tui/resume.js +24 -0
  45. package/dist/tui/session-view.js +2 -2
  46. package/dist/tui/turn.js +2 -3
  47. package/dist/tui/view.js +1 -1
  48. package/dist/ui/diff.js +2 -0
  49. package/dist/ui/inline.js +2 -2
  50. package/dist/ui/markdown.js +7 -7
  51. package/dist/ui/render.js +2 -0
  52. package/dist/ui/theme.js +4 -4
  53. package/dist/usage.js +9 -0
  54. package/package.json +1 -1
@@ -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"
@@ -61,8 +64,8 @@ export function turnBlocker(session) {
61
64
  if (blocked !== undefined) {
62
65
  return {
63
66
  text: auth.kind === "oauth"
64
- ? `${providerLabel(session.provider.id)} needs ${auth.label} sign-in · /settings`
65
- : `${providerLabel(session.provider.id)} needs an API key · /settings`,
67
+ ? `${providerLabel(session.provider.id)} needs sign-in · /providers`
68
+ : `${providerLabel(session.provider.id)} needs an API key · /providers`,
66
69
  tone: "warn",
67
70
  };
68
71
  }
package/dist/tui/field.js CHANGED
@@ -7,7 +7,7 @@ import { row } from "../ui/render.js";
7
7
  import { promptCursor, promptLine } from "./components/prompt.js";
8
8
  export function panel(field, width, pal) {
9
9
  const { ink } = pal;
10
- const head = row(width, field.title, field.right === undefined ? [] : [{ text: field.right, fg: ink.muted }]);
10
+ const head = row(width, field.title, field.right === undefined ? [] : [{ text: field.right, fg: ink.dim }]);
11
11
  const line = promptLine(field.editor.text, field.editor.cursor, width, pal, {
12
12
  secret: field.secret,
13
13
  }).row;
package/dist/tui/help.js CHANGED
@@ -4,6 +4,9 @@ import { textWidth } from "../ui/width.js";
4
4
  const CONTROL_WIDTH = 18;
5
5
  const CONTROLS = [
6
6
  { key: "up / down", description: "move through menus or history" },
7
+ { key: "left / right", description: "move cursor or change a value" },
8
+ { key: "ctrl+left / right", description: "move cursor by word" },
9
+ { key: "ctrl+backspace/del", description: "delete a word" },
7
10
  { key: "enter / tab", description: "select or send · complete" },
8
11
  { key: "alt+enter", description: "insert a new line" },
9
12
  { key: "esc", description: "close UI or interrupt work" },
@@ -16,7 +19,7 @@ export function panel(width, pal, maxRows = CONTROLS.length + 1) {
16
19
  const heading = row(width, [
17
20
  { text: "help ", fg: pal.accent, bold: true },
18
21
  { text: "keyboard controls", fg: pal.ink.fg },
19
- ], [{ text: "esc close", fg: pal.ink.muted }]);
22
+ ], [{ text: "esc close", fg: pal.ink.dim }]);
20
23
  const controls = CONTROLS.map((control) => {
21
24
  const gap = Math.max(2, CONTROL_WIDTH - textWidth(control.key));
22
25
  return row(width, [
package/dist/tui/input.js CHANGED
@@ -26,6 +26,10 @@ export function applyKey(state, key) {
26
26
  return edit.wordLeft(state);
27
27
  case "wordright":
28
28
  return edit.wordRight(state);
29
+ case "deletewordleft":
30
+ return edit.killWord(state);
31
+ case "deletewordright":
32
+ return edit.killNextWord(state);
29
33
  case "home":
30
34
  return edit.home(state);
31
35
  case "end":
package/dist/tui/keys.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // guessed at; and a bracketed paste arrives as a delimited run that must not be
6
6
  // interpreted key by key, or a pasted newline submits half the paste.
7
7
  const ESC = String.fromCharCode(27);
8
+ const BS = String.fromCharCode(8);
8
9
  const DEL = String.fromCharCode(127);
9
10
  const PASTE_START = "[200~";
10
11
  const PASTE_END = "[201~";
@@ -35,10 +36,18 @@ const SEQUENCES = {
35
36
  "[7~": "home",
36
37
  "[8~": "end",
37
38
  "[3~": "delete",
39
+ "[3;5~": "deletewordright",
38
40
  "[5~": "pageup",
39
41
  "[6~": "pagedown",
40
42
  "[1;5C": "wordright",
41
43
  "[1;5D": "wordleft",
44
+ // Explicit modified-key forms used by terminals with CSI-u support.
45
+ "[127;5u": "deletewordleft",
46
+ "[8;5u": "deletewordleft",
47
+ // Readline-compatible Alt bindings. VS Code also sends Alt+D for
48
+ // Ctrl+Delete and Ctrl+W for Ctrl+Backspace in its integrated terminal.
49
+ d: "deletewordright",
50
+ [DEL]: "deletewordleft",
42
51
  "[Z": "backtab",
43
52
  // alt+enter, which arrives as an escape followed by the return itself. It is
44
53
  // how a multi-line message gets written when enter is what sends one.
@@ -48,10 +57,10 @@ const CONTROL = {
48
57
  "\r": "enter",
49
58
  "\n": "enter",
50
59
  "\t": "tab",
51
- "\b": "backspace",
60
+ [BS]: "backspace",
52
61
  [DEL]: "backspace",
53
62
  };
54
- export function decoder() {
63
+ export function decoder(options = {}) {
55
64
  let held = "";
56
65
  let pasting = false;
57
66
  let pasted = "";
@@ -121,7 +130,9 @@ export function decoder() {
121
130
  keys.push({ name: "escape", text: "", ctrl: false });
122
131
  continue;
123
132
  }
124
- const named = CONTROL[ch];
133
+ const named = ch === BS && options.ctrlBackspaceIsBs === true
134
+ ? "deletewordleft"
135
+ : CONTROL[ch];
125
136
  if (named !== undefined) {
126
137
  held = held.slice(1);
127
138
  keys.push({ name: named, text: "", ctrl: false });
@@ -47,6 +47,12 @@ function handlePicker(open, key) {
47
47
  case "down":
48
48
  open.picker = picker.move(open.picker, 1);
49
49
  break;
50
+ case "left":
51
+ open.picker = picker.adjust(open.picker, -1);
52
+ break;
53
+ case "right":
54
+ open.picker = picker.adjust(open.picker, 1);
55
+ break;
50
56
  case "home":
51
57
  open.picker = picker.edge(open.picker, "home");
52
58
  break;
@@ -19,6 +19,12 @@ export function edge(picker, end) {
19
19
  export function page(picker, direction, rows = WINDOW) {
20
20
  return move(picker, direction * Math.max(1, rows));
21
21
  }
22
+ export function adjust(picker, step) {
23
+ const index = selected(picker);
24
+ return index === undefined || picker.adjust === undefined
25
+ ? picker
26
+ : picker.adjust(index, step);
27
+ }
22
28
  export function type(picker, text) {
23
29
  if (picker.searchable !== true || text === "")
24
30
  return picker;
@@ -45,18 +51,21 @@ export function byKey(picker, text) {
45
51
  const found = picker.options.findIndex((option) => option.key === typed);
46
52
  return found === -1 ? undefined : found;
47
53
  }
48
- export function panel(picker, width, pal, maxRows = WINDOW + 3) {
54
+ export function panel(picker, width, pal, maxRows = (picker.visible ?? WINDOW) + 3) {
49
55
  return layout(picker, width, pal, maxRows).rows;
50
56
  }
51
57
  /** Caret for the shared query row, relative to the unframed picker body. */
52
- export function caret(picker, width, maxRows = WINDOW + 3) {
58
+ export function caret(picker, width, maxRows = (picker.visible ?? WINDOW) + 3) {
53
59
  return layout(picker, width, undefined, maxRows).cursor;
54
60
  }
55
61
  function layout(picker, width, pal, maxRows) {
56
62
  const found = matches(picker);
57
63
  const note = picker.description ?? picker.footer;
58
- const fixed = 1 + (note === undefined ? 0 : 1) + (picker.searchable === true ? 1 : 0);
59
- const optionRoom = Math.max(1, Math.min(WINDOW, maxRows - fixed));
64
+ const hasTitle = picker.title.length > 0;
65
+ const fixed = (hasTitle ? 1 : 0) +
66
+ (note === undefined ? 0 : 1) +
67
+ (picker.searchable === true ? 1 : 0);
68
+ const optionRoom = Math.max(1, Math.min(picker.visible ?? WINDOW, maxRows - fixed));
60
69
  const selectedAt = Math.max(0, found.findIndex((entry) => entry.index === picker.index));
61
70
  const { first, last } = menuWindow(found.length, selectedAt, optionRoom);
62
71
  const shown = found.slice(first, last);
@@ -67,14 +76,19 @@ function layout(picker, width, pal, maxRows) {
67
76
  ? [row(width, [{ text: "no matches", fg: colors.ink.muted }])]
68
77
  : renderMenuRows(shown.map(({ option, index }) => ({
69
78
  label: option.label,
79
+ description: option.description,
70
80
  hint: option.hint,
81
+ value: option.value,
82
+ adjustable: option.adjustable,
71
83
  selected: index === picker.index,
72
84
  })), width, colors);
73
85
  const progress = found.length > shown.length || picker.searchable === true
74
86
  ? `${found.length === 0 ? "0" : `${first + 1}–${first + shown.length}`} / ${found.length}` +
75
87
  (found.length === picker.options.length ? "" : ` · ${picker.options.length} total`)
76
88
  : "";
77
- const titleRight = picker.right ?? (picker.searchable === true ? undefined : progress);
89
+ const titleRight = hasTitle
90
+ ? picker.right ?? (picker.searchable === true ? undefined : progress)
91
+ : undefined;
78
92
  const visibleTitleRight = titleRight === undefined
79
93
  ? undefined
80
94
  : elide(titleRight, Math.max(1, Math.floor(width * 0.55)));
@@ -87,14 +101,16 @@ function layout(picker, width, pal, maxRows) {
87
101
  const queryCursor = picker.searchable === true
88
102
  ? promptCursor(picker.query ?? "", (picker.query ?? "").length, width, { right: progress })
89
103
  : undefined;
90
- const queryOffset = 1 + (note === undefined ? 0 : 1);
104
+ const queryOffset = (hasTitle ? 1 : 0) + (note === undefined ? 0 : 1);
91
105
  return {
92
106
  rows: colors === undefined
93
107
  ? []
94
108
  : [
95
- row(width, picker.title, visibleTitleRight === undefined || visibleTitleRight === ""
96
- ? []
97
- : [{ text: visibleTitleRight, fg: colors.ink.muted }]),
109
+ ...(hasTitle
110
+ ? [row(width, picker.title, visibleTitleRight === undefined || visibleTitleRight === ""
111
+ ? []
112
+ : [{ text: visibleTitleRight, fg: colors.ink.dim }])]
113
+ : []),
98
114
  ...(note === undefined ? [] : [row(width, [{ text: note, fg: colors.ink.muted }])]),
99
115
  ...(queryRow === undefined ? [] : [queryRow.row]),
100
116
  ...options,
@@ -113,7 +129,7 @@ export function heading(label, about, pal) {
113
129
  function matches(picker) {
114
130
  const query = (picker.query ?? "").trim().toLocaleLowerCase();
115
131
  return picker.options.flatMap((option, index) => {
116
- const haystack = `${option.label} ${option.hint ?? ""}`.toLocaleLowerCase();
132
+ const haystack = `${option.label} ${option.hint ?? ""} ${option.value ?? ""}`.toLocaleLowerCase();
117
133
  return query === "" || haystack.includes(query) ? [{ option, index }] : [];
118
134
  });
119
135
  }
@@ -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
+ }
@@ -28,13 +28,13 @@ export function turnFailure(session, error, aborted) {
28
28
  if (/\b401\b/.test(text)) {
29
29
  const auth = session.provider.auth;
30
30
  if (auth.kind === "oauth") {
31
- text += ` · reconnect ${auth.label} in /settings`;
31
+ text += ` · reconnect ${auth.label} in /providers`;
32
32
  }
33
33
  else {
34
34
  const source = credentialSource(auth.keyVar);
35
35
  text += source === "environment"
36
36
  ? ` · update ${auth.keyVar} in the environment and restart`
37
- : " · check credentials with /settings";
37
+ : " · check access with /providers";
38
38
  }
39
39
  }
40
40
  return { kind: "notice", text, tone: "error" };
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/tui/view.js CHANGED
@@ -79,7 +79,7 @@ function tooSmall(height, width, view) {
79
79
  ]);
80
80
  if (middle + 1 < height) {
81
81
  rows[middle + 1] = row(width, [
82
- { text: elide(`need ${MIN_COLS}×${MIN_ROWS}`, Math.max(1, width)), fg: view.pal.ink.muted },
82
+ { text: elide(`need ${MIN_COLS}×${MIN_ROWS}`, Math.max(1, width)), fg: view.pal.ink.dim },
83
83
  ]);
84
84
  }
85
85
  return { rows, maxScroll: 0 };
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/inline.js CHANGED
@@ -21,7 +21,7 @@ export function inline(text, base, pal, bold = false) {
21
21
  }
22
22
  const [, mono, strong, strongAlt, emphasis, label] = match;
23
23
  if (mono !== undefined)
24
- segs.push({ text: mono, fg: pal.accentSoft });
24
+ segs.push({ text: mono, fg: pal.technical });
25
25
  else if (strong !== undefined)
26
26
  segs.push({ text: strong, fg: ink.bright, bold: true });
27
27
  else if (strongAlt !== undefined)
@@ -29,7 +29,7 @@ export function inline(text, base, pal, bold = false) {
29
29
  else if (emphasis !== undefined)
30
30
  segs.push({ text: emphasis, fg: ink.bright });
31
31
  else if (label !== undefined)
32
- segs.push({ text: label, fg: pal.accent });
32
+ segs.push({ text: label, fg: pal.technical });
33
33
  last = match.index + match[0].length;
34
34
  }
35
35
  if (last < text.length) {
@@ -63,7 +63,7 @@ export function markdown(text, max, pal, proseMax = max) {
63
63
  const depth = Math.min(2, Math.floor(bullet[1].length / 2));
64
64
  const mark = [
65
65
  { text: " ".repeat(depth) },
66
- { text: "- ", fg: pal.accent },
66
+ { text: "- ", fg: pal.technical },
67
67
  ];
68
68
  const hang = [{ text: `${" ".repeat(depth)} ` }];
69
69
  rows.push(...emit(inline(bullet[2], ink.fg, pal), prose - depth * 2 - 2, mark, hang));
@@ -72,7 +72,7 @@ export function markdown(text, max, pal, proseMax = max) {
72
72
  const ordered = ORDERED.exec(line);
73
73
  if (ordered !== null) {
74
74
  const label = `${ordered[2]}. `;
75
- const mark = [{ text: label, fg: pal.accent }];
75
+ const mark = [{ text: label, fg: pal.technical }];
76
76
  const hang = [{ text: " ".repeat(label.length) }];
77
77
  rows.push(...emit(inline(ordered[3], ink.fg, pal), prose - label.length, mark, hang));
78
78
  continue;
@@ -120,13 +120,13 @@ function code(lines, start, max, pal, out) {
120
120
  break;
121
121
  body.push(elide(line.replace(/\t/g, " "), max - 2));
122
122
  }
123
- out.push({ segs: [{ text: `\`\`\`${lang}`, fg: ink.muted }] });
123
+ out.push({ segs: [{ text: `\`\`\`${lang}`, fg: ink.dim }] });
124
124
  const roles = {
125
- plain: ink.added,
126
- comment: ink.muted,
125
+ plain: ink.fg,
126
+ comment: ink.dim,
127
127
  string: ink.added,
128
128
  number: ink.attention,
129
- keyword: pal.accent,
129
+ keyword: pal.technical,
130
130
  };
131
131
  for (const tokens of highlight(body, lang)) {
132
132
  out.push({
@@ -136,7 +136,7 @@ function code(lines, start, max, pal, out) {
136
136
  ],
137
137
  });
138
138
  }
139
- out.push({ segs: [{ text: "```", fg: ink.muted }] });
139
+ out.push({ segs: [{ text: "```", fg: ink.dim }] });
140
140
  return i;
141
141
  }
142
142
  /** Flow one inline run into rows, with an opener and a hanging indent. */
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/ui/theme.js CHANGED
@@ -5,20 +5,20 @@
5
5
  // Components depend on these roles rather than embedding presentation values.
6
6
  export const STEEL = {
7
7
  accent: [102, 155, 210],
8
- accentSoft: [131, 213, 245],
8
+ technical: [78, 201, 232],
9
9
  focus: [102, 155, 210],
10
10
  rule: [53, 80, 110],
11
11
  ink: {
12
- fg: [212, 218, 225],
12
+ fg: [220, 224, 229],
13
13
  bright: [235, 239, 244],
14
- muted: [112, 124, 137],
14
+ muted: [156, 169, 183],
15
+ dim: [112, 124, 137],
15
16
  attention: [230, 191, 95],
16
17
  added: [134, 203, 146],
17
18
  removed: [232, 112, 112],
18
19
  },
19
20
  surface: {
20
21
  subtle: [31, 38, 47],
21
- inset: [42, 52, 66],
22
22
  added: [22, 55, 34],
23
23
  removed: [62, 24, 27],
24
24
  attention: [62, 50, 19],
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.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {