@hncode/hncode 0.1.10 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/tools/edit.js CHANGED
@@ -129,7 +129,7 @@ function checkStale(p, normContent, normOld, ctx) {
129
129
  // (no region covers it). Tell the AI to read the target lines first.
130
130
  if (curFileHash === entry.fileHash) {
131
131
  const ranges = entry.regions.map((r) => `${r.start}-${r.end}`).join(', ') || 'none';
132
- return `Edit rejected: you have not read the lines you are editing in ${p} (Read so far: lines ${ranges}). Read the target lines first (Read with line_offset / n_lines), then Edit.`;
132
+ return `Edit rejected: you have not read the lines you are editing in ${p}. Read the target lines first (Read with line_offset / n_lines), then Edit.`;
133
133
  }
134
134
 
135
135
  // (3) The file changed on disk since it was Read and the edit target is not in
package/src/tui.js CHANGED
@@ -795,8 +795,8 @@ function keyArgument(name, args, workspace) {
795
795
  }
796
796
  if (text.length > MAX_ARG) {
797
797
  text = (k === 'path' || k === 'file_path')
798
- ? '…' + text.slice(text.length - (MAX_ARG - 1))
799
- : text.slice(0, MAX_ARG - 1) + '…';
798
+ ? '…' + text.slice(text.length - (MAX_ARG - 1))
799
+ : text.slice(0, MAX_ARG - 1) + '…';
800
800
  }
801
801
  // Display paths with forward slashes (consistent across platforms).
802
802
  if (k === 'path' || k === 'file_path') text = text.replace(/\\/g, '/');
@@ -807,10 +807,74 @@ function keyArgument(name, args, workspace) {
807
807
  // "● Using Read (src/tui.js)" while running, "● Used Read (src/tui.js)" when done.
808
808
  // The verb is plain (white) / green once finished; the tool NAME is always the
809
809
  // theme (cyan) colour; the key argument is dim/gray.
810
- export function formatToolLine(msg, workspace) {
810
+ export function formatToolLine(msg, workspace, spin) {
811
811
  const name = msg.toolName || '';
812
812
  const arg = keyArgument(name, msg.toolArgs, workspace);
813
813
  const verb = msg.pending ? 'Using' : 'Used';
814
+ // Pending tool calls have a sweep animation (like "Working...")
815
+ // Once the tool finishes (pending=false) the name shows in plain cyan.
816
+ let nameDisplay;
817
+ if (msg.pending) {
818
+ // Multi-stage sweep animation for tool names (like the "Working..." line):
819
+ // 0.5s l→c sweep (pale cyan → cyan), 0.5s c→l fade back
820
+ // 0.5s c→b sweep (cyan → cyan-blue), 0.5s b→c fade back
821
+ // → loops straight back to l→c. ~2s per full cycle.
822
+ const SWEEP = 6; // 0.5s
823
+ const HALF = SWEEP + SWEEP; // 12 ticks per colour pair (sweep + sweep back)
824
+ const CYCLE = HALF * 2; // 24 ticks for the full loop (~2s)
825
+ const inCycle = spin % CYCLE;
826
+ const inHalf = inCycle % HALF; // 0..23 within this colour pair's half
827
+ const phase = inCycle < HALF ? 0 : 1; // 0 = l↔c, 1 = c↔b
828
+
829
+ // Colours as plain RGB triples (mirroring Working's orange→yellow/red pattern):
830
+ // Base cyan (c), pale cyan (l = c + 25% toward white), cyan-blue (b = c with reduced green)
831
+ const c = [0, 215, 255]; // pure cyan (like ORANGE base)
832
+ const l = [64, 239, 255]; // pale cyan (like YELLOW: c + white)
833
+ const b = [0, 180, 255]; // cyan-blue (like RED: c with less green)
834
+ const base = c; // always cyan, like Working's constant ORANGE
835
+ const target = phase === 0 ? l : b; // pale cyan, then cyan-blue
836
+
837
+ const chars = name;
838
+ const n = chars.length;
839
+
840
+ // A character fades over a band this many characters wide.
841
+ const BAND_CHARS = Math.max(2, Math.min(4, n / 3));
842
+ const BAND = BAND_CHARS / n;
843
+
844
+ // Interpolate base ↔ target by `t` (0 = base, 1 = target).
845
+ const mix = (t) => {
846
+ const k = Math.max(0, Math.min(1, t));
847
+ return lerpColor(
848
+ base[0] + (target[0] - base[0]) * k,
849
+ base[1] + (target[1] - base[1]) * k,
850
+ base[2] + (target[2] - base[2]) * k,
851
+ base[0] + (target[0] - base[0]) * k,
852
+ base[1] + (target[1] - base[1]) * k,
853
+ base[2] + (target[2] - base[2]) * k,
854
+ 0,
855
+ );
856
+ };
857
+
858
+ const out = [];
859
+ for (let i = 0; i < n; i++) {
860
+ const charAt = n > 0 ? i / n : 0;
861
+ let t; // 0 = base, 1 = target
862
+ if (inHalf < SWEEP) {
863
+ // Sweep in: base → target, left to right.
864
+ const head = (inHalf + 1) / SWEEP; // 1/SWEEP … 1
865
+ t = (head - charAt) / BAND;
866
+ } else {
867
+ // Sweep back: target → base, left to right.
868
+ const head = (inHalf - SWEEP + 1) / SWEEP; // 1/SWEEP … 1
869
+ t = 1 - (head - charAt) / BAND;
870
+ }
871
+ out.push(col(chars[i], mix(t)));
872
+ }
873
+ nameDisplay = out.join('');
874
+ } else {
875
+ // Non-pending: show in plain cyan
876
+ nameDisplay = col(name, C.cyan);
877
+ }
814
878
  // Show streamed size for Write tool
815
879
  let sizeStr = '';
816
880
  if (name === 'Write' && typeof msg.streamContent === 'string' && msg.streamContent.length) {
@@ -832,7 +896,7 @@ export function formatToolLine(msg, workspace) {
832
896
  // so the verb itself stays plain white and the tool name keeps the theme
833
897
  // colour.
834
898
  const argText = arg ? ' ' + col(`(${arg})`, C.gray) : '';
835
- return col(verb, C.white) + ' ' + col(name, C.cyan) + argText + col(sizeStr, C.gray) + diffStr;
899
+ return col(verb, C.white) + ' ' + nameDisplay + argText + col(sizeStr, C.gray) + diffStr;
836
900
  }
837
901
 
838
902
  // ---- live TODO panel (kimi-code's todo-panel) ----
@@ -915,24 +979,63 @@ function renderTodoPanel(state, w, hoverTop, dragTop) {
915
979
  // One rule line, one line per queued message, one dim hint line. Queued input is
916
980
  // held while the agent is streaming and is injected by Ctrl-S (or by the agent
917
981
  // itself at the next tool boundary).
918
- function renderQueuePanel(state, w) {
982
+ // Supports auto-collapse and drag-to-resize like the todo panel.
983
+ const QUEUE_MAX_VISIBLE = 5;
984
+ function renderQueuePanel(state, w, hoverTop, dragTop) {
919
985
  const queued = state.queued || [];
920
986
  if (!queued.length) return [];
921
- const out = [col('─'.repeat(w), C.border)];
922
- for (const text of queued) {
987
+
988
+ const out = [];
989
+ const ruleColor = dragTop ? C.scrollThumbActive : (hoverTop ? C.hover : C.border);
990
+ out.push(col('─'.repeat(w), ruleColor));
991
+ out.push(col(' Queue', C.cyan + C.bold));
992
+
993
+ const want = queueRowCount(state);
994
+ const rows = queued.slice(0, want);
995
+ for (const text of rows) {
923
996
  // Collapse to a single line, like kimi's queue pane does.
924
997
  const single = String(text).replace(/\s+/g, ' ').trim();
925
998
  out.push(col(' ', C.cyan) + col('❯ ', C.cyan) + col(fitAnsi(single, Math.max(1, w - 6)), C.white));
926
999
  }
927
- const hint = state.running ? '↑ to edit · ctrl-s to steer immediately' : '↑ to edit · will send now';
928
- out.push(col(' ' + hint, C.gray));
1000
+
1001
+ const hidden = queued.length - rows.length;
1002
+ if (hidden > 0) {
1003
+ const hint = state.running
1004
+ ? `… +${hidden} more · ↑ to edit · ctrl-s to steer immediately · drag top rule to resize`
1005
+ : `… +${hidden} more · ↑ to edit · will send now · drag top rule to resize`;
1006
+ out.push(col(' ' + hint, C.gray));
1007
+ } else {
1008
+ const hint = state.running ? '↑ to edit · ctrl-s to steer immediately' : '↑ to edit · will send now';
1009
+ out.push(col(' ' + hint, C.gray));
1010
+ }
1011
+
929
1012
  return out;
930
1013
  }
931
1014
 
932
- // Height (in rows) the queue pane will occupy, for layout math.
933
- function queuePanelHeight(state) {
934
- const n = (state.queued || []).length;
935
- return n ? n + 2 : 0; // rule + items + hint
1015
+ // How many queue ROWS the panel shows.
1016
+ // * `state.queueRows` (set by dragging the top rule) wins when present;
1017
+ // * otherwise show all items up to QUEUE_MAX_VISIBLE.
1018
+ // Always clamped to [1, queued.length]: at least ONE row, never more than exist.
1019
+ export function queueRowCount(state) {
1020
+ const queued = state.queued || [];
1021
+ if (!queued.length) return 0;
1022
+ const manual = state.queueRows;
1023
+ let rows;
1024
+ if (typeof manual === 'number' && Number.isFinite(manual)) {
1025
+ rows = Math.round(manual);
1026
+ } else {
1027
+ rows = Math.min(queued.length, QUEUE_MAX_VISIBLE);
1028
+ }
1029
+ return Math.max(1, Math.min(queued.length, rows));
1030
+ }
1031
+
1032
+ // Height (in rows) the queue panel will occupy, for layout math.
1033
+ export function queuePanelHeight(state) {
1034
+ const queued = state.queued || [];
1035
+ if (!queued.length) return 0;
1036
+ const rows = queueRowCount(state);
1037
+ const extra = queued.length > rows ? 1 : 0; // the "… +N more" hint
1038
+ return 2 + rows + extra; // rule + heading + rows (+ hint)
936
1039
  }
937
1040
 
938
1041
  // How many todo ROWS the panel shows.
@@ -963,11 +1066,11 @@ export function todoPanelHeight(state) {
963
1066
 
964
1067
  // Render one chat message into display lines.
965
1068
  // Returns { lines: [{text, ind, color}] } (ind = indent string for continuation).
966
- function messageLines(msg, width, workspace, expanded) {
1069
+ function messageLines(msg, width, workspace, expanded, spin) {
967
1070
  // Tool calls render as a single "● Using/Used Name (arg)" line (kimi style).
968
1071
  // The body already carries its own ANSI colours, so mark it pre-colored.
969
1072
  if (msg.role === 'tool') {
970
- const body = formatToolLine(msg, workspace);
1073
+ const body = formatToolLine(msg, workspace, spin);
971
1074
  // Red bullet for a failed call (a Bash non-zero exit / [error: …]).
972
1075
  const failed = msg.failed === true || (msg.role === 'tool' && msg.failed === true);
973
1076
  const pre = col('● ', msg.pending ? C.orange : (failed ? C.red : C.green));
@@ -1452,11 +1555,11 @@ function renderChatLines(state, w) {
1452
1555
  // The row WIDTH is keyed (not `w`) because the rows are laid out to it:
1453
1556
  // `boxText` for a bordered message (inside the box padding), `innerW` otherwise.
1454
1557
  const rowW = bordered ? boxText : innerW;
1455
- const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${msg.pending ? 1 : 0}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
1456
- + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`;
1558
+ const key = `${rowW}\u0000${expanded ? 1 : 0}\u0000${msg.pending ? (state.spin || 0) : 0}\u0000${msg.text || ''}\u0000${msg.streamContent || ''}`
1559
+ + `\u0000${msg.liveOutput ? msg.liveOutput.length : 0}\u0000${msg.failed ? 1 : 0}\u0000${JSON.stringify(msg.toolArgs || null)}`;
1457
1560
  let cached = msg._cache;
1458
1561
  if (!cached || cached.key !== key) {
1459
- const rows = messageLines(msg, rowW, state.cwd, expanded);
1562
+ const rows = messageLines(msg, rowW, state.cwd, expanded, state.spin);
1460
1563
  cached = { key, rows: rows.map(rowToLine) };
1461
1564
  msg._cache = cached;
1462
1565
  }
@@ -1533,11 +1636,11 @@ function fmtDuration(ms) {
1533
1636
  const mon = Math.floor(ms / 2592000000) % 12;
1534
1637
  const year = Math.floor(ms / 31536000000);
1535
1638
  const parts = [];
1536
- if (year) parts.push(year + 'year' + (year > 1 ? 's' : ''));
1537
- if (mon) parts.push(mon + 'mon' + (mon > 1 ? 's' : '') + ' ');
1538
- if (day) parts.push(day + 'day' + (day > 1 ? 's' : '') + ' ');
1539
- if (hour) parts.push(hour + 'hour' + (hour > 1 ? 's' : '') + ' ');
1540
- if (min) parts.push(min + 'min' + (min > 1 ? 's' : '') + ' ');
1639
+ if (year) parts.push(year + 'y' + (year > 1 ? 's' : ''));
1640
+ if (mon) parts.push(mon + 'm' + (mon > 1 ? 's' : '') + ' ');
1641
+ if (day) parts.push(day + 'd' + (day > 1 ? 's' : '') + ' ');
1642
+ if (hour) parts.push(hour + 'h' + (hour > 1 ? 's' : '') + ' ');
1643
+ if (min) parts.push(min + 'm' + (min > 1 ? 's' : '') + ' ');
1541
1644
  // Seconds are always shown (at least "0s").
1542
1645
  parts.push(sec + 's');
1543
1646
  return parts.join('');
@@ -2023,11 +2126,66 @@ export function composeFrame(state, cols, rows) {
2023
2126
  const workMsg = state.workMsg || WORKING_MESSAGES[0];
2024
2127
  const spin = state.spin || 0;
2025
2128
 
2026
- // ---- turn-finish animation ----
2129
+ // ---- turn-start animation ----
2130
+ // 0.5s: type out "Working..." in grey (chars appear left→right)
2131
+ // 0.5s: fill with orange (chars change colour left→right)
2132
+ // After 1s total: start the normal pulse animation (yellow/red cycle)
2133
+ if (state.startAnim) {
2134
+ // 1s total: 0.5s types `Working... [0s]` out in grey, then 0.5s sweeps the
2135
+ // PHRASE grey → orange. The `[0s]` tail types out WITH the phrase but is
2136
+ // EXCLUDED from the colour sweep — it stays grey the whole time.
2137
+ const DUR = 1000;
2138
+ const t = Math.min(1, (Date.now() - state.startAnim.start) / DUR);
2139
+ const phase = t < 0.5 ? 'type' : 'color';
2140
+ const phaseT = t < 0.5 ? t * 2 : (t - 0.5) * 2; // 0→1 within each phase
2141
+
2142
+ const phrase = workMsg;
2143
+ const tailText = state.turnStart ? ` [${fmtDuration(Date.now() - state.turnStart)}]` : '';
2144
+ const totalChars = phrase.length + tailText.length;
2145
+ const out = [];
2146
+
2147
+ if (phase === 'type') {
2148
+ // Type the WHOLE row (phrase + tail) left → right, all grey.
2149
+ const typedCount = Math.floor(phaseT * totalChars);
2150
+ for (let i = 0; i < phrase.length; i++) {
2151
+ out.push(i < typedCount ? col(phrase[i], C.gray) : ' ');
2152
+ }
2153
+ const tailTyped = Math.max(0, typedCount - phrase.length);
2154
+ for (let i = 0; i < tailText.length; i++) {
2155
+ out.push(i < tailTyped ? col(tailText[i], C.gray) : ' ');
2156
+ }
2157
+ // The tail is already inside `out`, so do NOT append `elapsed` here.
2158
+ lines.push(col(frame, C.orange) + ' ' + out.join(''));
2159
+ } else {
2160
+ // Colour sweep: only the PHRASE goes grey → orange. The tail stays grey
2161
+ // (it is appended as the colored `elapsed` chunk after `out`).
2162
+ const BAND_CHARS = Math.max(2, Math.min(4, phrase.length / 3));
2163
+ const BAND = BAND_CHARS / phrase.length;
2164
+ // head sweeps from just left of the phrase (0) to just right of it (1),
2165
+ // extended by BAND on both ends so every char crosses the band smoothly.
2166
+ const head = -BAND + phaseT * (1 + 2 * BAND);
2167
+ const GREY = [150, 150, 150];
2168
+ const ORANGE = [255, 140, 0];
2169
+ for (let i = 0; i < phrase.length; i++) {
2170
+ const charAt = i / phrase.length;
2171
+ let tChar = (head - charAt) / BAND;
2172
+ tChar = Math.max(0, Math.min(1, tChar));
2173
+ const r = Math.round(GREY[0] + (ORANGE[0] - GREY[0]) * tChar);
2174
+ const g = Math.round(GREY[1] + (ORANGE[1] - GREY[1]) * tChar);
2175
+ const b = Math.round(GREY[2] + (ORANGE[2] - GREY[2]) * tChar);
2176
+ out.push(col(phrase[i], lerpColor(r, g, b, r, g, b, 0)));
2177
+ }
2178
+ lines.push(col(frame, C.orange) + ' ' + out.join('') + elapsed);
2179
+ }
2180
+ if ((Date.now() - state.startAnim.start) >= 1000) {
2181
+ state.startAnim = null;
2182
+ }
2183
+ }
2184
+ // ---- turn-finish animation ----
2027
2185
  // 0.5s morph: `[turn took 1s]` grows in from the LEFT, covering the working
2028
2186
  // phrase one character at a time; once the phrase is fully covered, the
2029
2187
  // rest of the final text is simply appended. The whole row fades to grey.
2030
- if (state.finishAnim) {
2188
+ else if (state.finishAnim) {
2031
2189
  const DUR = 500;
2032
2190
  const t = Math.min(1, (Date.now() - state.finishAnim.start) / DUR);
2033
2191
  const wordFrom = state.finishAnim.wordFrom || '';
@@ -2055,7 +2213,7 @@ export function composeFrame(state, cols, rows) {
2055
2213
  const g = Math.round(ORANGE[1] + (GREY[1] - ORANGE[1]) * t);
2056
2214
  const b = Math.round(ORANGE[2] + (GREY[2] - ORANGE[2]) * t);
2057
2215
  const animColor = lerpColor(r, g, b, r, g, b, 0);
2058
- lines.push(col(frame, animColor) + ' ' + col(`[${word}${tail}`, animColor));
2216
+ lines.push(col(frame, animColor) + ' ' + col('[', animColor) + col(word, animColor) + col(tail, C.gray));
2059
2217
  } else {
2060
2218
 
2061
2219
  // Timing (spinner ticks every 80ms, so 0.5s ≈ 6 ticks):
@@ -2122,7 +2280,7 @@ export function composeFrame(state, cols, rows) {
2122
2280
  }
2123
2281
  }
2124
2282
 
2125
- if (state.todos && state.todos.length) {
2283
+ if (!dialog && state.todos && state.todos.length) {
2126
2284
  // The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
2127
2285
  // the mouse handler can hover/press it; the row offset is corrected to final
2128
2286
  // screen coordinates by composeFrame's hitbox pass (like every other hit).
@@ -2133,7 +2291,13 @@ export function composeFrame(state, cols, rows) {
2133
2291
 
2134
2292
  // Queued (not yet sent) messages, directly above the composer: these are what
2135
2293
  // Ctrl-S steers into the running turn. See renderQueuePanel.
2136
- for (const l of renderQueuePanel(state, w)) lines.push(l);
2294
+ if (!dialog && state.queued && state.queued.length) {
2295
+ // The panel's top rule is a DRAG HANDLE. Record its screen row as a hitbox so
2296
+ // the mouse handler can hover/press it.
2297
+ const queueTopRow = lines.length;
2298
+ addHit(queueTopRow, 0, w - 1, { kind: 'queueResize' });
2299
+ for (const l of renderQueuePanel(state, w, state.queueResizeHover, state.queueResizeDrag)) lines.push(l);
2300
+ }
2137
2301
 
2138
2302
  // Approval pending overlay - same width as the composer, placed above it.
2139
2303
  // The border characters are coloured INDIVIDUALLY and each content row is
@@ -2441,6 +2605,16 @@ export function makeState({ cfg, session, opts }) {
2441
2605
  todoResizeHover: false,
2442
2606
  todoResizeDrag: false,
2443
2607
  todoResizeStart: null,
2608
+ // Queue panel resize: the top rule is a drag handle. `queueRows` is the
2609
+ // manual row count (undefined = automatic), and the two flags drive its
2610
+ // hover/press styling.
2611
+ queueRows: undefined,
2612
+ queueResizeHover: false,
2613
+ queueResizeDrag: false,
2614
+ queueResizeStart: null,
2615
+ // Turn-start animation: while non-null, the Working row renders the typing
2616
+ // and color-fill animation for 1s before starting the normal pulse cycle.
2617
+ startAnim: null, // { start }
2444
2618
  hoverHit: null,
2445
2619
  input: '',
2446
2620
  caret: 0,
@@ -2778,107 +2952,124 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, ren
2778
2952
  },
2779
2953
  onPick: (it) => {
2780
2954
  if (it.action === 'add') {
2781
- app('Loading provider list…');
2782
- fetchCatalog().then((catalog) => {
2783
- const entries = catalog
2784
- ? Object.values(catalog)
2785
- .filter((p) => p && p.id && p.api)
2786
- .map((p) => ({ id: p.id, name: p.name || p.id, api: p.api, doc: p.doc || '' }))
2787
- .sort((a, b) => String(a.name).localeCompare(String(b.name)))
2788
- : [];
2789
- const items = entries.map((p) => ({
2790
- label: p.name,
2791
- sub: p.api.replace(/^https?:\/\//, ''),
2792
- providerId: p.id,
2793
- base_url: p.api,
2794
- action: 'pickKnown',
2795
- }));
2796
- items.push({ label: '+ Custom provider…', sub: 'enter base URL, key and protocol yourself', action: 'custom' });
2797
- openPicker({
2798
- title: 'Add a provider',
2799
- items,
2800
- searchable: true,
2801
- hint: '↑↓ navigate · type to search · Enter select · Esc cancel',
2802
- onPick: (pit) => {
2803
- if (pit.action === 'custom') {
2804
- openForm({
2805
- title: 'Add A Custom Provider',
2806
- fields: [
2807
- { key: 'name', label: 'Provider Name' },
2808
- { key: 'base_url', label: 'Base URL' },
2809
- { key: 'api_key', label: 'API Key', kind: 'mask' },
2810
- ],
2811
- type: null,
2812
- hint: 'Tab next field · ↑/↓ field · ←/→ type · Enter save · Esc cancel',
2813
- onSubmit: (values, type) => {
2814
- if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
2815
- if (!type) { appErr('Cancelled: choose a Type (OpenAI / Anthropic).'); return; }
2816
- const protocol = type === 'Anthropic' ? 'anthropic' : 'openai';
2817
- registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, type);
2818
- },
2819
- });
2820
- return true;
2821
- }
2955
+ // Show a submenu to choose between known providers and custom
2956
+ openPicker({
2957
+ title: 'Add a provider',
2958
+ items: [
2959
+ { label: 'Known provider', sub: 'browse models.dev catalog', action: 'known' },
2960
+ { label: 'Custom provider', sub: 'enter base URL, key and protocol yourself', action: 'custom' },
2961
+ ],
2962
+ searchable: false,
2963
+ hint: '↑↓ navigate · Enter select · Esc cancel',
2964
+ onPick: (subIt) => {
2965
+ if (subIt.action === 'custom') {
2822
2966
  openForm({
2823
- title: `Edit ${pit.label}`,
2967
+ title: 'Add A Custom Provider',
2824
2968
  fields: [
2825
- { key: 'name', label: 'Provider Name', value: pit.name },
2826
- { key: 'base_url', label: 'Base URL', value: pit.base_url },
2827
- { key: 'api_key', label: 'API Key', kind: 'mask', value: pit.api_key || '' },
2828
- { key: 'type', label: 'Type', options: ['OpenAI', 'Anthropic'], value: pit.type || (pit.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') },
2969
+ { key: 'name', label: 'Provider Name' },
2970
+ { key: 'base_url', label: 'Base URL' },
2971
+ { key: 'api_key', label: 'API Key', kind: 'mask' },
2829
2972
  ],
2830
2973
  type: null,
2831
- hint: 'Tab next field · Enter save · Esc cancel',
2832
- onSubmit: async (values) => {
2974
+ hint: 'Tab next field · ↑/↓ field · ←/→ type · Enter save · Esc cancel',
2975
+ onSubmit: (values, type) => {
2833
2976
  if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
2834
- const protocol = values.type === 'Anthropic' ? 'anthropic' : 'openai';
2835
- registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, values.type);
2836
-
2837
- // Auto-fetch models and configure context/thinking settings
2838
- try {
2839
- const models = await fetchModels({
2840
- baseUrl: values.base_url,
2841
- apiKey: values.api_key,
2842
- protocol
2843
- });
2844
-
2845
- if (models.length > 0) {
2846
- // Add each model with its configuration
2847
- for (const m of models) {
2848
- const key = modelKey(values.name, m.id);
2849
- if (!cfg.raw.models[key]) {
2850
- addModel(values.name, m.id, {
2851
- display_name: m.display,
2852
- contextLength: m.contextLength,
2853
- maxTokens: m.maxTokens,
2854
- });
2977
+ if (!type) { appErr('Cancelled: choose a Type (OpenAI / Anthropic).'); return; }
2978
+ const protocol = type === 'Anthropic' ? 'anthropic' : 'openai';
2979
+ registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, type);
2980
+ },
2981
+ });
2982
+ return true;
2983
+ }
2984
+ // Known provider: load catalog and show list
2985
+ app('Loading provider list…');
2986
+ fetchCatalog().then((catalog) => {
2987
+ const entries = catalog
2988
+ ? Object.values(catalog)
2989
+ .filter((p) => p && p.id && p.api)
2990
+ .map((p) => ({ id: p.id, name: p.name || p.id, api: p.api, doc: p.doc || '' }))
2991
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)))
2992
+ : [];
2993
+ const items = entries.map((p) => ({
2994
+ label: p.name,
2995
+ sub: p.api.replace(/^https?:\/\//, ''),
2996
+ providerId: p.id,
2997
+ base_url: p.api,
2998
+ action: 'pickKnown',
2999
+ }));
3000
+ if (!items.length) {
3001
+ appErr('No known providers found in catalog.');
3002
+ return;
3003
+ }
3004
+ openPicker({
3005
+ title: 'Select a known provider',
3006
+ items,
3007
+ searchable: true,
3008
+ hint: '↑↓ navigate · type to search · Enter select · Esc cancel',
3009
+ onPick: (pit) => {
3010
+ openForm({
3011
+ title: `Edit ${pit.label}`,
3012
+ fields: [
3013
+ { key: 'name', label: 'Provider Name', value: pit.name },
3014
+ { key: 'base_url', label: 'Base URL', value: pit.base_url },
3015
+ { key: 'api_key', label: 'API Key', kind: 'mask', value: pit.api_key || '' },
3016
+ { key: 'type', label: 'Type', options: ['OpenAI', 'Anthropic'], value: pit.type || (pit.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') },
3017
+ ],
3018
+ type: null,
3019
+ hint: 'Tab next field · Enter save · Esc cancel',
3020
+ onSubmit: async (values) => {
3021
+ if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
3022
+ const protocol = values.type === 'Anthropic' ? 'anthropic' : 'openai';
3023
+ registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, values.type);
3024
+
3025
+ // Auto-fetch models and configure context/thinking settings
3026
+ try {
3027
+ const models = await fetchModels({
3028
+ baseUrl: values.base_url,
3029
+ apiKey: values.api_key,
3030
+ protocol
3031
+ });
3032
+
3033
+ if (models.length > 0) {
3034
+ // Add each model with its configuration
3035
+ for (const m of models) {
3036
+ const key = modelKey(values.name, m.id);
3037
+ if (!cfg.raw.models[key]) {
3038
+ addModel(values.name, m.id, {
3039
+ display_name: m.display,
3040
+ contextLength: m.contextLength,
3041
+ maxTokens: m.maxTokens,
3042
+ });
3043
+
3044
+ // Configure model entry in raw config
3045
+ if (!cfg.raw.models) cfg.raw.models = {};
3046
+ cfg.raw.models[key] = {
3047
+ provider: values.name,
3048
+ model: m.id,
3049
+ display_name: m.display,
3050
+ context_length: m.contextLength,
3051
+ max_tokens: m.maxTokens,
3052
+ reasoning: m.reasoning,
3053
+ efforts: m.efforts,
3054
+ };
3055
+ }
3056
+ }
2855
3057
 
2856
- // Configure model entry in raw config
2857
- if (!cfg.raw.models) cfg.raw.models = {};
2858
- cfg.raw.models[key] = {
2859
- provider: values.name,
2860
- model: m.id,
2861
- display_name: m.display,
2862
- context_length: m.contextLength,
2863
- max_tokens: m.maxTokens,
2864
- reasoning: m.reasoning,
2865
- efforts: m.efforts,
2866
- };
3058
+ app(`Added ${models.length} model(s) with auto-configured context windows and thinking capabilities.`);
3059
+ } else {
3060
+ app(`Provider added. No models found at this endpoint.`);
2867
3061
  }
3062
+ } catch (error) {
3063
+ app(`Provider added. Failed to fetch models: ${error.message}`);
2868
3064
  }
2869
-
2870
- app(`Added ${models.length} model(s) with auto-configured context windows and thinking capabilities.`);
2871
- } else {
2872
- app(`Provider added. No models found at this endpoint.`);
2873
- }
2874
- } catch (error) {
2875
- app(`Provider added. Failed to fetch models: ${error.message}`);
2876
- }
3065
+ },
3066
+ });
3067
+ return true;
2877
3068
  },
2878
3069
  });
2879
- return true;
2880
- },
2881
- });
3070
+ });
3071
+ return true;
3072
+ },
2882
3073
  });
2883
3074
  return true;
2884
3075
  }
@@ -4186,6 +4377,14 @@ export async function startTUI(opts) {
4186
4377
  renderFrame();
4187
4378
  return;
4188
4379
  }
4380
+ // The queue panel's top rule is also a resize handle.
4381
+ const hitQueue = hitAt(t);
4382
+ if (hitQueue && hitQueue.kind === 'queueResize') {
4383
+ state.queueResizeDrag = true;
4384
+ state.queueResizeStart = { row: t.row, rows: queueRowCount(state) };
4385
+ renderFrame();
4386
+ return;
4387
+ }
4189
4388
  // Don't start selection on single click, wait for drag.
4190
4389
  const cell = mouseToCell(t);
4191
4390
  // Pressing the padding above the transcript must NOT arm a selection:
@@ -4207,6 +4406,9 @@ export async function startTUI(opts) {
4207
4406
  // The todo resize handle gets its own hover flag so its rule lights up.
4208
4407
  const onTodoRule = !!(hb && hb.kind === 'todoResize');
4209
4408
  if (onTodoRule !== state.todoResizeHover) { state.todoResizeHover = onTodoRule; renderFrame(); }
4409
+ // The queue resize handle also gets a hover flag.
4410
+ const onQueueRule = !!(hb && hb.kind === 'queueResize');
4411
+ if (onQueueRule !== state.queueResizeHover) { state.queueResizeHover = onQueueRule; renderFrame(); }
4210
4412
  // The composer rows stay click targets (mousedown places the caret), but
4211
4413
  // they are NOT highlighted on hover — tinting the prompt you are typing
4212
4414
  // is noise, not feedback.
@@ -4229,6 +4431,16 @@ export async function startTUI(opts) {
4229
4431
  if (clamped !== state.todoRows) { state.todoRows = clamped; renderFrame(); }
4230
4432
  return;
4231
4433
  }
4434
+ // Dragging the queue rule resizes the panel: moving UP shows more rows.
4435
+ if (state.queueResizeDrag) {
4436
+ const start = state.queueResizeStart || { row: t.row, rows: queueRowCount(state) };
4437
+ const delta = start.row - (t.row || 1); // up = positive = more rows
4438
+ const want = start.rows + delta;
4439
+ const total = (state.queued || []).length;
4440
+ const clamped = Math.max(1, Math.min(total || 1, want));
4441
+ if (clamped !== state.queueRows) { state.queueRows = clamped; renderFrame(); }
4442
+ return;
4443
+ }
4232
4444
  // Composer text selection: drag from a composer mousedown.
4233
4445
  if (state._composerMouseDown) {
4234
4446
  const hb = hitAt(t);
@@ -4271,6 +4483,14 @@ export async function startTUI(opts) {
4271
4483
  renderFrame();
4272
4484
  return;
4273
4485
  }
4486
+ if (state.queueResizeDrag) {
4487
+ // Finish the resize. Keep the hover flag in sync with where the pointer is.
4488
+ state.queueResizeDrag = false;
4489
+ state.queueResizeStart = null;
4490
+ state.queueResizeHover = !!(hitAt(t) && hitAt(t).kind === 'queueResize');
4491
+ renderFrame();
4492
+ return;
4493
+ }
4274
4494
  if (state.sbDrag) {
4275
4495
  state.sbDrag = false;
4276
4496
  state.sbHover = isOnScrollbar(t);
@@ -5201,7 +5421,11 @@ export async function startTUI(opts) {
5201
5421
  state.menuOpen = false; state.menuList = []; state.menuSel = 0;
5202
5422
  state.historyIdx = -1;
5203
5423
  if (!text) { renderFrame(); return; }
5204
- if (!state.running) state.turnStart = Date.now();
5424
+ if (!state.running) {
5425
+ state.turnStart = Date.now();
5426
+ // Start animation: 0.5s type out + 0.5s fill with orange = 1s total
5427
+ state.startAnim = { start: Date.now() };
5428
+ }
5205
5429
 
5206
5430
  if (state.history[state.history.length - 1] !== text) state.history.push(text);
5207
5431
  // Commands are handled immediately, never queued: queuing them delayed a
@@ -5515,6 +5739,8 @@ export async function startTUI(opts) {
5515
5739
  await new Promise((r) => setTimeout(r, 20));
5516
5740
  }
5517
5741
  state.finishAnim = null;
5742
+ // Also clear startAnim if it's still active (should be cleared already by composeFrame)
5743
+ state.startAnim = null;
5518
5744
  state.running = false;
5519
5745
 
5520
5746
  if (turnMs > 0) {