@giovannijecha/jecode 0.3.2 → 0.4.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.
@@ -1,10 +1,11 @@
1
- // The persistent settings hub. Every interaction uses the shared dock picker
2
- // and field contracts; this module owns choices and persistence, not drawing.
3
- import { modelsCommand, providersCommand } from "./provider-commands.js";
4
- import { credentialsCommand } from "./credential-commands.js";
5
- import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
1
+ // The persistent settings hub. Provider access and model selection reuse the
2
+ // same command flows exposed directly through /providers and /models.
3
+ import { saveCommandSettings } from "./command-settings.js";
4
+ import { modelsCommand } from "./model-command.js";
6
5
  import { providerFailure } from "./provider-errors.js";
7
- import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
6
+ import { providerLabel } from "./provider-label.js";
7
+ import { providersCommand } from "./provider-commands.js";
8
+ import { EFFORTS } from "./settings.js";
8
9
  import { of } from "./tui/editor.js";
9
10
  import { heading } from "./tui/picker.js";
10
11
  /** A focused path to the same saved reasoning default exposed by /settings. */
@@ -22,7 +23,7 @@ export async function settingsCommand(session, host) {
22
23
  while (true) {
23
24
  const values = settingsValues(session);
24
25
  const items = settingsItems(values);
25
- const index = await choose(settingsPicker(values, session.palette, selected));
26
+ const index = await choose(settingsPicker(values, selected));
26
27
  if (index === undefined)
27
28
  return;
28
29
  const action = items[index]?.action;
@@ -30,14 +31,8 @@ export async function settingsCommand(session, host) {
30
31
  return;
31
32
  selected = index;
32
33
  switch (action) {
33
- case "provider":
34
- await providerSetting(session, host);
35
- break;
36
- case "ollamaConnection":
37
- await ollamaConnectionSetting(session, host, (patch) => persist(host, patch));
38
- break;
39
34
  case "model":
40
- await modelSetting(session, host);
35
+ await modelsCommand(session, host, { announce: false });
41
36
  break;
42
37
  case "effort":
43
38
  await effortSetting(session, host);
@@ -51,45 +46,43 @@ export async function settingsCommand(session, host) {
51
46
  case "reducedMotion":
52
47
  await motionSetting(session, host);
53
48
  break;
54
- case "credentials":
55
- await credentialsCommand(session, host);
49
+ case "providers":
50
+ await providersCommand(session, host);
56
51
  break;
57
52
  }
58
53
  }
59
54
  }
60
- export function settingsPicker(values, pal, index = 0, store = settingsLabel()) {
55
+ export function settingsPicker(values, index = 0) {
61
56
  return {
62
- title: heading("settings", store, pal),
63
- description: "Changes apply now · flags and environment win at launch",
57
+ title: [],
64
58
  options: settingsItems(values).map((item) => item.option),
65
59
  index,
66
60
  };
67
61
  }
68
62
  function settingsItems(values) {
69
63
  return [
70
- { action: "provider", option: { label: "provider", hint: values.provider } },
71
- ...(values.ollamaConnection === undefined
72
- ? []
73
- : [{
74
- action: "ollamaConnection",
75
- option: { label: "ollama connection", hint: values.ollamaConnection },
76
- }]),
77
- { action: "model", option: { label: "model", hint: values.model || "choose a model" } },
78
- { action: "effort", option: { label: "effort", hint: values.effort } },
64
+ {
65
+ action: "model",
66
+ option: {
67
+ label: "model",
68
+ value: `${providerLabel(values.provider)} · ${values.model || "choose a model"}`,
69
+ },
70
+ },
71
+ { action: "effort", option: { label: "effort", value: values.effort } },
79
72
  ...(values.maxTokens === undefined
80
73
  ? []
81
74
  : [{
82
75
  action: "maxTokens",
83
- option: { label: "max output tokens", hint: String(values.maxTokens) },
76
+ option: { label: "max output tokens", value: String(values.maxTokens) },
84
77
  }]),
85
- { action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
78
+ { action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
86
79
  {
87
80
  action: "reducedMotion",
88
- option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
81
+ option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
89
82
  },
90
83
  {
91
- action: "credentials",
92
- option: { label: "authentication", hint: "manage API keys and accounts" },
84
+ action: "providers",
85
+ option: { label: "providers", hint: "manage access and connections" },
93
86
  },
94
87
  ];
95
88
  }
@@ -97,65 +90,18 @@ function settingsValues(session) {
97
90
  return {
98
91
  provider: session.provider.id,
99
92
  model: session.model,
100
- ...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
101
93
  effort: session.config.effort,
102
94
  ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
103
95
  maxSteps: session.config.maxSteps,
104
96
  reducedMotion: session.config.reducedMotion,
105
97
  };
106
98
  }
107
- async function providerSetting(session, host) {
108
- const before = {
109
- provider: session.provider,
110
- model: session.model,
111
- providerId: session.config.providerId,
112
- configModel: session.config.model,
113
- effort: session.config.effort,
114
- };
115
- if (!(await providersCommand(session, host, { announce: false, save: false })))
116
- return;
117
- const current = readSettings();
118
- const models = { ...current.models };
119
- if (session.model !== "")
120
- models[session.provider.id] = session.model;
121
- const patch = {
122
- provider: session.provider.id,
123
- models,
124
- ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
125
- };
126
- if (await persist(host, patch))
127
- return;
128
- session.provider = before.provider;
129
- session.model = before.model;
130
- session.config.providerId = before.providerId;
131
- session.config.model = before.configModel;
132
- session.config.effort = before.effort;
133
- }
134
- async function modelSetting(session, host) {
135
- const before = {
136
- model: session.model,
137
- configModel: session.config.model,
138
- effort: session.config.effort,
139
- };
140
- if (!(await modelsCommand(session, host, { announce: false, save: false })))
141
- return;
142
- const current = readSettings();
143
- const models = { ...current.models, [session.provider.id]: session.model };
144
- const patch = {
145
- models,
146
- ...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
147
- };
148
- if (await persist(host, patch))
149
- return;
150
- session.model = before.model;
151
- session.config.model = before.configModel;
152
- session.config.effort = before.effort;
153
- }
154
99
  async function effortSetting(session, host) {
155
100
  const choose = chooser(host);
156
101
  if (choose === undefined)
157
102
  return;
158
103
  const efforts = await availableEfforts(session, host);
104
+ throwIfAborted(host.signal);
159
105
  if (efforts === undefined)
160
106
  return;
161
107
  if (efforts.length === 0) {
@@ -168,12 +114,12 @@ async function effortSetting(session, host) {
168
114
  }
169
115
  const current = session.config.effort;
170
116
  const index = await choose({
171
- title: heading("effort", "saved default", session.palette),
117
+ title: [],
172
118
  options: efforts.map((value) => ({ label: value })),
173
119
  index: Math.max(0, efforts.findIndex((value) => value === current)),
174
120
  });
175
121
  const value = index === undefined ? undefined : efforts[index];
176
- if (value === undefined || !(await persist(host, { effort: value })))
122
+ if (value === undefined || !(await saveCommandSettings(host, { effort: value })))
177
123
  return;
178
124
  session.config.effort = value;
179
125
  return value;
@@ -181,11 +127,15 @@ async function effortSetting(session, host) {
181
127
  async function availableEfforts(session, host) {
182
128
  if (session.provider.efforts === undefined)
183
129
  return EFFORTS;
184
- host.status?.(`Asking ${session.provider.id}`);
130
+ host.status?.(`Asking ${providerLabel(session.provider.id)}`);
185
131
  try {
186
- return await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
132
+ throwIfAborted(host.signal);
133
+ const efforts = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
134
+ throwIfAborted(host.signal);
135
+ return efforts;
187
136
  }
188
137
  catch (error) {
138
+ throwIfAborted(host.signal);
189
139
  host.emit({
190
140
  kind: "notice",
191
141
  text: providerFailure(session.provider, error, true),
@@ -203,12 +153,12 @@ async function motionSetting(session, host) {
203
153
  return;
204
154
  const values = [false, true];
205
155
  const index = await choose({
206
- title: heading("reduced motion", "saved default", session.palette),
156
+ title: [],
207
157
  options: values.map((value) => ({ label: value ? "on" : "off" })),
208
158
  index: session.config.reducedMotion ? 1 : 0,
209
159
  });
210
160
  const value = index === undefined ? undefined : values[index];
211
- if (value === undefined || !(await persist(host, { reducedMotion: value })))
161
+ if (value === undefined || !(await saveCommandSettings(host, { reducedMotion: value })))
212
162
  return;
213
163
  session.config.reducedMotion = value;
214
164
  host.refreshSettings?.();
@@ -231,23 +181,18 @@ async function numberSetting(session, host, name, label) {
231
181
  host.emit({ kind: "notice", text: `${label} must be a positive integer`, tone: "error" });
232
182
  return;
233
183
  }
234
- if (!(await persist(host, { [name]: value })))
184
+ if (!(await saveCommandSettings(host, { [name]: value })))
235
185
  return;
236
186
  session.config[name] = value;
237
187
  }
238
- async function persist(host, patch) {
239
- try {
240
- await updateSettings(patch);
241
- return true;
242
- }
243
- catch (error) {
244
- host.emit({ kind: "notice", text: `could not save settings · ${error.message}`, tone: "error" });
245
- return false;
246
- }
247
- }
248
188
  function chooser(host) {
249
189
  if (host.choose === undefined) {
250
190
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
251
191
  }
252
192
  return host.choose;
253
193
  }
194
+ function throwIfAborted(signal) {
195
+ if (signal?.aborted !== true)
196
+ return;
197
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
198
+ }
@@ -59,11 +59,16 @@ export function appWorkflows(options) {
59
59
  state.closeWhenIdle = true;
60
60
  }
61
61
  catch (error) {
62
- feedback.show({
63
- text: activity.control.signal.aborted ? "interrupted" : error.message,
64
- tone: activity.control.signal.aborted ? "warn" : "error",
65
- timeoutMs: activity.control.signal.aborted ? 4_200 : 6_000,
66
- });
62
+ // Command cancellation is already visible through the dock closing and
63
+ // the activity ending. Keep it silent instead of replacing the footer
64
+ // with a redundant warning.
65
+ if (!activity.control.signal.aborted) {
66
+ feedback.show({
67
+ text: error.message,
68
+ tone: "error",
69
+ timeoutMs: 6_000,
70
+ });
71
+ }
67
72
  }
68
73
  finally {
69
74
  options.finishActivity(activity);
package/dist/tui/app.js CHANGED
@@ -26,7 +26,7 @@ const ESCAPE_MS = 25;
26
26
  export async function runApp(session, transcriptRoot, environment = {}) {
27
27
  const terminal = environment.screen ?? realScreen;
28
28
  const paint = environment.paint ?? painter();
29
- const keys = decoder();
29
+ const keys = decoder({ ctrlBackspaceIsBs: process.env["WT_SESSION"] !== undefined });
30
30
  const transcript = transcriptRenderer();
31
31
  const workspace = await workspaceLabel(session.config.root);
32
32
  const state = appState();
@@ -7,7 +7,7 @@ export function renderComposer(editor, width, maxInputRows, pal, right = "") {
7
7
  const first = Math.max(0, Math.min(laid.lines.length - room, laid.cursor.row));
8
8
  const shown = laid.lines.slice(first, first + room);
9
9
  return {
10
- rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.muted }] : [])),
10
+ rows: shown.map((line, index) => row(width, [{ text: line, fg: pal.ink.bright }], index === 0 && right !== "" ? [{ text: right, fg: pal.ink.dim }] : [])),
11
11
  cursor: { row: laid.cursor.row - first, col: laid.cursor.col },
12
12
  };
13
13
  }
@@ -5,7 +5,7 @@ export function renderFooter(info, status, width, pal) {
5
5
  const right = fitSegs(status, rightLimit);
6
6
  const leftRoom = Math.max(0, width - plainLen(right) - (right.length === 0 ? 0 : 1));
7
7
  const left = identity(info, leftRoom);
8
- return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.muted }], right)];
8
+ return [row(width, left === "" ? [] : [{ text: left, fg: pal.ink.dim }], right)];
9
9
  }
10
10
  function identity(info, cols) {
11
11
  if (cols <= 0)
@@ -16,28 +16,40 @@ export function menuWindow(length, selected, visible) {
16
16
  return { first, last: Math.min(count, first + room) };
17
17
  }
18
18
  function renderEntry(entry, labelWidth, width, pal) {
19
- // Colour terminals use one quiet selection band and keep every label on the
20
- // composer's content edge. Monochrome has no band, so it alone reserves a
21
- // fixed arrow column to keep selection visible without shifting peer rows.
19
+ // Colour terminals spend focus on the active label instead of painting a
20
+ // full-width band. Monochrome has no colour, so it alone reserves a fixed
21
+ // arrow column to keep selection visible without shifting peer rows.
22
22
  const monochrome = !hasColor();
23
23
  const selectedMark = monochrome ? (entry.selected ? "→ " : " ") : "";
24
- const fg = entry.selected ? pal.ink.bright : pal.ink.fg;
24
+ const fg = entry.selected ? pal.focus : pal.ink.fg;
25
25
  const primary = [
26
- { text: selectedMark, fg: entry.selected ? pal.accent : fg },
27
- { text: entry.label, fg },
26
+ { text: selectedMark, fg },
27
+ { text: entry.label, fg, bold: entry.selected || undefined },
28
28
  ];
29
29
  if (width > 40 && entry.description !== undefined) {
30
30
  const gap = Math.max(2, labelWidth - primaryWidth(entry));
31
- primary.push({ text: `${" ".repeat(gap)}${entry.description}`, fg: entry.selected ? pal.ink.fg : pal.ink.muted });
31
+ primary.push({
32
+ text: `${" ".repeat(gap)}${entry.description}`,
33
+ fg: entry.selected ? pal.ink.bright : pal.ink.muted,
34
+ });
32
35
  }
33
- const right = width > 40 && entry.hint !== undefined
34
- ? [{
35
- text: elide(entry.hint, Math.max(1, Math.floor(width / 4))),
36
- fg: entry.selected ? pal.ink.fg : pal.ink.muted,
37
- }]
38
- : [];
39
- const ground = entry.selected && !monochrome ? pal.surface.inset : undefined;
40
- return row(width, primary, right, ground);
36
+ const value = entry.value === undefined
37
+ ? undefined
38
+ : entry.selected && entry.adjustable === true
39
+ ? `‹ ${entry.value} ›`
40
+ : entry.value;
41
+ const summary = [entry.hint, value]
42
+ .filter((part) => part !== undefined)
43
+ .join(" · ");
44
+ const rightColor = entry.selected ? pal.focus : pal.ink.muted;
45
+ const right = summary === "" || (width <= 40 && entry.value === undefined)
46
+ ? []
47
+ : [{
48
+ text: elide(summary, Math.max(1, Math.floor(entry.value === undefined ? width / 4 : width * 0.45))),
49
+ fg: rightColor,
50
+ bold: entry.selected || undefined,
51
+ }];
52
+ return row(width, primary, right);
41
53
  }
42
54
  function primaryWidth(entry) {
43
55
  return plainLen([{ text: entry.label }]);
@@ -46,8 +46,8 @@ export function renderReasoning(block, width, pal) {
46
46
  fg: pal.ink.bright,
47
47
  bold: true,
48
48
  },
49
- ], action === undefined ? [] : [{ text: action, fg: pal.ink.muted }], undefined, PAD),
50
- ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
49
+ ], action === undefined ? [] : [{ text: action, fg: pal.ink.dim }], undefined, PAD),
50
+ ...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.dim, italic: true })), [], undefined, PAD)),
51
51
  ];
52
52
  }
53
53
  export function reasoningPreviewSource(text, width) {
@@ -7,10 +7,10 @@ export const PROMPT_WIDTH = textWidth(MARK);
7
7
  export function promptLine(text, cursor, width, pal, options = {}) {
8
8
  const right = options.right === undefined || options.right === ""
9
9
  ? []
10
- : [{ text: options.right, fg: pal.ink.muted }];
10
+ : [{ text: options.right, fg: pal.ink.dim }];
11
11
  const laid = layout(text, cursor, width, options);
12
12
  const content = laid.visible === "" && options.placeholder !== undefined
13
- ? { text: options.placeholder, fg: pal.ink.muted }
13
+ ? { text: options.placeholder, fg: pal.ink.dim }
14
14
  : { text: laid.visible, fg: pal.ink.bright };
15
15
  return {
16
16
  row: row(width, [{ text: MARK, fg: pal.accent }, content], right),
@@ -6,7 +6,7 @@ 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.muted }], info.unseen, pal);
9
+ return withUnseen([{ text: "esc to interrupt", fg: pal.ink.dim }], info.unseen, pal);
10
10
  }
11
11
  if (info.feedback !== undefined) {
12
12
  return withUnseen(feedbackSegments(info.feedback, pal), info.unseen, pal);
@@ -30,7 +30,7 @@ function feedbackSegments(feedback, pal) {
30
30
  function withUnseen(status, unseen, pal) {
31
31
  return unseen === 0
32
32
  ? status
33
- : [...status, { text: " · ", fg: pal.ink.muted }, ...unseenSegments(unseen, pal)];
33
+ : [...status, { text: " · ", fg: pal.ink.dim }, ...unseenSegments(unseen, pal)];
34
34
  }
35
35
  function unseenSegments(unseen, pal) {
36
36
  return [{ text: `${unseen} new ↓`, fg: pal.accent, bold: true }];
@@ -11,7 +11,7 @@ export function renderTool(block, width, pal, context = {}) {
11
11
  { text: " " },
12
12
  { text: `${stateGlyph(block, context)} `, fg: statusInk(block.tone, pal), bold: true },
13
13
  { text: block.name, fg: pal.ink.bright, bold: true },
14
- ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.accent }]),
14
+ ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
15
15
  ], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
16
16
  ...shown.map((detail) => renderDetail(detail, block.tone, width, pal)),
17
17
  ];
@@ -42,7 +42,7 @@ function renderDetail(detail, tone, width, pal) {
42
42
  return row(width, [lead, rail, { text: detail.text === "" ? " " : detail.text, fg }]);
43
43
  }
44
44
  if (detail.kind === "gap") {
45
- return row(width, [lead, rail, { text: detail.text, fg: pal.ink.muted, italic: true }]);
45
+ return row(width, [lead, rail, { text: detail.text, fg: pal.ink.dim, italic: true }]);
46
46
  }
47
47
  const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
48
48
  const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
@@ -50,7 +50,7 @@ function renderDetail(detail, tone, width, pal) {
50
50
  ? pal.ink.added
51
51
  : detail.kind === "del"
52
52
  ? pal.ink.removed
53
- : pal.ink.muted;
53
+ : pal.ink.dim;
54
54
  return row(width, [lead, rail, { text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]);
55
55
  }
56
56
  function emphasized(text, emphasis, fg) {
@@ -43,15 +43,25 @@ export function wordLeft(state) {
43
43
  return { ...state, cursor: startOfWordBefore(state.text, state.cursor) };
44
44
  }
45
45
  export function wordRight(state) {
46
- return { ...state, cursor: endOfWordAfter(state.text, state.cursor) };
46
+ return { ...state, cursor: startOfWordAfter(state.text, state.cursor) };
47
47
  }
48
48
  /** ctrl+w — delete the word behind the cursor. */
49
49
  export function killWord(state) {
50
- const from = startOfWordBefore(state.text, state.cursor);
50
+ const from = startOfDeletionBefore(state.text, state.cursor);
51
51
  if (from === state.cursor)
52
52
  return state;
53
53
  return { text: state.text.slice(0, from) + state.text.slice(state.cursor), cursor: from };
54
54
  }
55
+ /** ctrl+delete — delete the word ahead of the cursor. */
56
+ export function killNextWord(state) {
57
+ const to = endOfDeletionAfter(state.text, state.cursor);
58
+ if (to === state.cursor)
59
+ return state;
60
+ return {
61
+ text: state.text.slice(0, state.cursor) + state.text.slice(to),
62
+ cursor: state.cursor,
63
+ };
64
+ }
55
65
  /** ctrl+u — delete everything behind the cursor. */
56
66
  export function killToStart(state) {
57
67
  return { text: state.text.slice(state.cursor), cursor: 0 };
@@ -89,17 +99,54 @@ function after(text, cursor) {
89
99
  }
90
100
  function startOfWordBefore(text, cursor) {
91
101
  let i = cursor;
92
- while (i > 0 && text[i - 1] === " ")
102
+ while (i > 0 && whitespace(text[i - 1]))
93
103
  i--;
94
- while (i > 0 && text[i - 1] !== " ")
104
+ while (i > 0 && !whitespace(text[i - 1]))
95
105
  i--;
96
106
  return i;
97
107
  }
98
- function endOfWordAfter(text, cursor) {
108
+ function startOfWordAfter(text, cursor) {
99
109
  let i = cursor;
100
- while (i < text.length && text[i] === " ")
110
+ while (i < text.length && !whitespace(text[i]))
101
111
  i++;
102
- while (i < text.length && text[i] !== " ")
112
+ while (i < text.length && whitespace(text[i]))
103
113
  i++;
104
114
  return i;
105
115
  }
116
+ function startOfDeletionBefore(text, cursor) {
117
+ if (text[cursor - 1] === "\n")
118
+ return cursor - 1;
119
+ let i = cursor;
120
+ while (i > 0 && horizontalWhitespace(text[i - 1]))
121
+ i--;
122
+ if (text[i - 1] === "\n")
123
+ return i;
124
+ while (i > 0 && !whitespace(text[i - 1]))
125
+ i--;
126
+ return i;
127
+ }
128
+ function endOfDeletionAfter(text, cursor) {
129
+ if (text[cursor] === "\n")
130
+ return cursor + 1;
131
+ let i = cursor;
132
+ if (horizontalWhitespace(text[i])) {
133
+ while (i < text.length && horizontalWhitespace(text[i]))
134
+ i++;
135
+ if (text[i] === "\n")
136
+ return i;
137
+ while (i < text.length && !whitespace(text[i]))
138
+ i++;
139
+ return i;
140
+ }
141
+ while (i < text.length && !whitespace(text[i]))
142
+ i++;
143
+ while (i < text.length && horizontalWhitespace(text[i]))
144
+ i++;
145
+ return i;
146
+ }
147
+ function horizontalWhitespace(value) {
148
+ return value !== undefined && value !== "\n" && /\s/u.test(value);
149
+ }
150
+ function whitespace(value) {
151
+ return value !== undefined && /\s/u.test(value);
152
+ }
@@ -61,8 +61,8 @@ export function turnBlocker(session) {
61
61
  if (blocked !== undefined) {
62
62
  return {
63
63
  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`,
64
+ ? `${providerLabel(session.provider.id)} needs sign-in · /providers`
65
+ : `${providerLabel(session.provider.id)} needs an API key · /providers`,
66
66
  tone: "warn",
67
67
  };
68
68
  }
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;