@giovannijecha/jecode 0.3.1 → 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 }];
@@ -2,7 +2,6 @@
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 = 12;
6
5
  export function renderTool(block, width, pal, context = {}) {
7
6
  const shown = visibleDetails(block);
8
7
  const right = liveLabel(block, context);
@@ -12,7 +11,7 @@ export function renderTool(block, width, pal, context = {}) {
12
11
  { text: " " },
13
12
  { text: `${stateGlyph(block, context)} `, fg: statusInk(block.tone, pal), bold: true },
14
13
  { text: block.name, fg: pal.ink.bright, bold: true },
15
- ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.accent }]),
14
+ ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
16
15
  ], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
17
16
  ...shown.map((detail) => renderDetail(detail, block.tone, width, pal)),
18
17
  ];
@@ -31,54 +30,9 @@ function visibleDetails(block) {
31
30
  : `… ${hidden} earlier lines · ctrl+o expand`;
32
31
  return [{ kind: "gap", text: note }, ...all.slice(-limit)];
33
32
  }
34
- return diffWindow(all);
35
- }
36
- /** Keep change rows and their nearest context; never turn a diff into a tail. */
37
- function diffWindow(all) {
38
- if (all.length <= DIFF_ROWS)
39
- return [...all];
40
- const important = all.flatMap((detail, index) => detail.kind === "keep" ? [] : [index]);
41
- const chosen = new Set();
42
- const budgeted = important.length <= DIFF_ROWS
43
- ? important
44
- : [
45
- ...important.slice(0, Math.ceil(DIFF_ROWS / 2)),
46
- ...important.slice(-Math.floor(DIFF_ROWS / 2)),
47
- ];
48
- for (const index of budgeted)
49
- chosen.add(index);
50
- for (let radius = 1; chosen.size < DIFF_ROWS && radius < all.length; radius++) {
51
- for (const index of important) {
52
- for (const candidate of [index - radius, index + radius]) {
53
- if (candidate < 0 || candidate >= all.length || chosen.has(candidate))
54
- continue;
55
- chosen.add(candidate);
56
- if (chosen.size >= DIFF_ROWS)
57
- break;
58
- }
59
- if (chosen.size >= DIFF_ROWS)
60
- break;
61
- }
62
- }
63
- const indexes = [...chosen].sort((left, right) => left - right);
64
- const out = [];
65
- let previous = -1;
66
- for (const index of indexes) {
67
- if (index > previous + 1) {
68
- out.push({ kind: "gap", text: `… ${index - previous - 1} lines hidden · ctrl+o expand` });
69
- }
70
- const detail = all[index];
71
- if (detail !== undefined)
72
- out.push(detail);
73
- previous = index;
74
- }
75
- if (previous < all.length - 1) {
76
- out.push({
77
- kind: "gap",
78
- text: `… ${all.length - previous - 1} lines hidden · ctrl+o expand`,
79
- });
80
- }
81
- return out;
33
+ // 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");
82
36
  }
83
37
  function renderDetail(detail, tone, width, pal) {
84
38
  const lead = { text: " " };
@@ -88,7 +42,7 @@ function renderDetail(detail, tone, width, pal) {
88
42
  return row(width, [lead, rail, { text: detail.text === "" ? " " : detail.text, fg }]);
89
43
  }
90
44
  if (detail.kind === "gap") {
91
- 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 }]);
92
46
  }
93
47
  const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
94
48
  const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
@@ -96,7 +50,7 @@ function renderDetail(detail, tone, width, pal) {
96
50
  ? pal.ink.added
97
51
  : detail.kind === "del"
98
52
  ? pal.ink.removed
99
- : pal.ink.muted;
53
+ : pal.ink.dim;
100
54
  return row(width, [lead, rail, { text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]);
101
55
  }
102
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":