@mjasnikovs/pi-task 0.38.20 → 0.38.21

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,4 +1,4 @@
1
- import { SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
1
+ import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
2
  import { registerBridgeCommand } from '../remote/bridge.js';
3
3
  import { readPkgVersion } from '../shared/pkg-version.js';
4
4
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
@@ -74,22 +74,43 @@ export const SECTIONS = [
74
74
  { key: 'checks', title: 'after each task' },
75
75
  { key: 'research', title: 'research' },
76
76
  { key: 'reasoning', title: 'reasoning' },
77
- { key: 'timeouts', title: 'timeouts' },
78
77
  { key: 'unattended', title: 'unattended' },
79
78
  { key: 'logging', title: 'logging' },
80
- { key: 'extensions', title: 'child extensions' }
79
+ { key: 'extensions', title: 'child extensions' },
80
+ // Last on purpose. It is the longest block (a fixed timeout plus one row
81
+ // per live tool, so it grows with the host) and the least often changed —
82
+ // in front of `unattended` it pushed every short section off the screen.
83
+ { key: 'timeouts', title: 'timeouts' }
81
84
  ];
82
85
  /** Marks a header row, so onChange can ignore one and tests can find them. */
83
86
  export const SECTION_ID_PREFIX = 'section:';
84
- /** An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it. */
87
+ /**
88
+ * An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it,
89
+ * and {@link SkipInertRows} steps the cursor straight over it.
90
+ *
91
+ * Upper case, and styled muted by {@link makeTheme}, because the dashed
92
+ * lower-case form it replaces was the same case, colour and weight as the
93
+ * setting labels underneath it — eight headings that read as nine more rows.
94
+ */
85
95
  function sectionHeader(title) {
86
96
  return {
87
97
  id: SECTION_ID_PREFIX + title,
88
- label: `── ${title} ──`,
98
+ label: title.toUpperCase(),
89
99
  description: '',
90
100
  currentValue: ''
91
101
  };
92
102
  }
103
+ /**
104
+ * A blank row between two sections.
105
+ *
106
+ * `SettingsList` renders exactly one line per item, so the only way to put air
107
+ * above a heading is to hand it an empty row. It carries the header prefix so
108
+ * everything that already treats a header as scenery — the inert check, the
109
+ * cursor skip, the headless rendering — covers it with no second rule.
110
+ */
111
+ function sectionGap(title) {
112
+ return { id: `${SECTION_ID_PREFIX}gap:${title}`, label: '', description: '', currentValue: '' };
113
+ }
93
114
  /**
94
115
  * The shared pair for a boolean setting: shown as on/off, stored as a boolean.
95
116
  * Every non-enum row uses this, so a boolean cannot be given a bespoke parser by
@@ -345,7 +366,7 @@ export function applyReasoningLevel(cfg, group, chosen) {
345
366
  /** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
346
367
  const OVERLAY_WIDTH = 68;
347
368
  /** Settings rows shown at once before the list scrolls. */
348
- const MAX_VISIBLE = 9;
369
+ const MAX_VISIBLE = 11;
349
370
  /**
350
371
  * Tallest body the settings list can render, so {@link BorderedBox} can pad
351
372
  * every frame to it and hold the border still. Mirrors SettingsList's own
@@ -356,9 +377,19 @@ export function settingsBodyHeight(descriptions, maxVisible, wrapWidth) {
356
377
  const tallestDescription = Math.max(0, ...descriptions.map(d => wrapTextWithAnsi(d, wrapWidth).length));
357
378
  return 1 + maxVisible + 1 + 1 + tallestDescription + 1 + 1;
358
379
  }
359
- function makeTheme(theme) {
380
+ function makeTheme(theme, isHeader) {
360
381
  return {
361
- label: (text, selected) => selected ? theme.fg('accent', theme.bold(text)) : theme.fg('text', text),
382
+ label: (text, selected) => {
383
+ // Headers are scenery, so they are rendered quieter than the rows
384
+ // they title rather than louder. `isHeader` is asked by text
385
+ // because SettingsListTheme only ever sees the padded label —
386
+ // matching on the text is what keeps the styling in one place
387
+ // instead of pre-colouring the string back in panelItems, which
388
+ // has no theme to colour it with.
389
+ if (isHeader(text))
390
+ return theme.fg('muted', theme.bold(text));
391
+ return selected ? theme.fg('accent', theme.bold(text)) : theme.fg('text', text);
392
+ },
362
393
  // A filled/hollow dot makes the on/off column scannable at a glance
363
394
  // without reading a word on every row. Enum values (an engine name, a
364
395
  // duration) are real content, so they stay readable rather than muted.
@@ -376,6 +407,70 @@ function makeTheme(theme) {
376
407
  hint: text => theme.fg('dim', text)
377
408
  };
378
409
  }
410
+ /** The arrow key SettingsList moves down on, under the default bindings. */
411
+ const DOWN_KEY = '\x1b[B';
412
+ /**
413
+ * Moves the cursor over the section headers and the blank rows between them.
414
+ *
415
+ * Those rows are decoration: they carry no `values`, so Enter already does
416
+ * nothing on them. Without this they were still stops on the way down — with a
417
+ * heading AND a blank line per section that is sixteen dead keypresses in a
418
+ * thirty-row menu, and the panel opens with the cursor parked on a heading that
419
+ * has no description to show.
420
+ *
421
+ * It drives the list through its own public `handleInput` — pressing the very
422
+ * key the user pressed, N times — rather than reaching for the private
423
+ * `selectedIndex`. The mirror it keeps cannot drift: with search off and no
424
+ * submenus, up and down are the only two things that move that index.
425
+ */
426
+ class SkipInertRows {
427
+ list;
428
+ selectable;
429
+ index = 0;
430
+ constructor(list,
431
+ /** True where a row can be selected, in the list's own order. */
432
+ selectable) {
433
+ this.list = list;
434
+ this.selectable = selectable;
435
+ // The first row is a header, so the panel would open on it. Only
436
+ // synthesise the keypress if it is actually bound to "down" — feeding
437
+ // a key the list ignores would move the mirror and not the cursor.
438
+ if (!getKeybindings().matches(DOWN_KEY, 'tui.select.down'))
439
+ return;
440
+ while (this.index < selectable.length && !selectable[this.index]) {
441
+ this.list.handleInput(DOWN_KEY);
442
+ this.index++;
443
+ }
444
+ }
445
+ render(width) {
446
+ return this.list.render(width);
447
+ }
448
+ invalidate() {
449
+ this.list.invalidate();
450
+ }
451
+ handleInput(data) {
452
+ const kb = getKeybindings();
453
+ const step = kb.matches(data, 'tui.select.down') ? 1
454
+ : kb.matches(data, 'tui.select.up') ? -1
455
+ : 0;
456
+ if (step === 0) {
457
+ this.list.handleInput(data);
458
+ return;
459
+ }
460
+ const n = this.selectable.length;
461
+ let target = this.index;
462
+ for (let moved = 1; moved <= n; moved++) {
463
+ target = (target + step + n) % n;
464
+ if (this.selectable[target]) {
465
+ for (let i = 0; i < moved; i++)
466
+ this.list.handleInput(data);
467
+ this.index = target;
468
+ return;
469
+ }
470
+ }
471
+ // Every row is scenery. Nothing to select, so nothing to move.
472
+ }
473
+ }
379
474
  /**
380
475
  * Builds the framed settings panel. Split out of the command handler so the
381
476
  * exact component the overlay shows can be rendered to a string in a test or a
@@ -392,8 +487,10 @@ export function createSettingsPanel(items, theme,
392
487
  * beside seven rows still claiming `inherit` — which is what it did.
393
488
  */
394
489
  onChange, onCancel) {
395
- const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme), (id, newValue) => onChange(id, newValue, list), onCancel);
396
- return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
490
+ // A row with no `values` is a header or the blank line above one.
491
+ const headerLabels = new Set(items.filter(i => i.values === undefined).map(i => i.label));
492
+ const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
493
+ return new BorderedBox(new SkipInertRows(list, items.map(i => (i.values?.length ?? 0) > 0)), CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
397
494
  }
398
495
  /** The full settings row list for the current config, in menu order. */
399
496
  export function panelItems(cfg, installed, tools = []) {
@@ -421,6 +518,8 @@ export function panelItems(cfg, installed, tools = []) {
421
518
  // all, so with nothing installed the heading would otherwise sit alone.
422
519
  if (rows.length === 0)
423
520
  continue;
521
+ if (out.length > 0)
522
+ out.push(sectionGap(title));
424
523
  out.push(sectionHeader(title), ...rows);
425
524
  }
426
525
  return out;
@@ -449,8 +548,13 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
449
548
  // Built from panelItems, not a second hand-written walk of the same
450
549
  // tables: the two renderings used to be able to disagree about what a
451
550
  // setting said, and a headless run is the one place nobody would notice.
452
- const lines = panelItems(cfg, installed, tools).map(i => i.values === undefined ?
453
- `[${i.label.replace(/─/g, '').trim()}]`
551
+ const lines = panelItems(cfg, installed, tools)
552
+ // The blank rows between sections are there to give the TUI air.
553
+ // One line of `|`-joined text has none to give, and an empty label
554
+ // would print as a stray `[]`.
555
+ .filter(i => i.label !== '')
556
+ .map(i => i.values === undefined ?
557
+ `[${i.label.trim()}]`
454
558
  : `${i.label.padEnd(22)} ${i.currentValue}`);
455
559
  ctx.ui.notify(lines.join(' | '), 'info');
456
560
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.20",
3
+ "version": "0.38.21",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",