@hncode/hncode 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/colors.js +2 -2
  3. package/src/tui.js +136 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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/colors.js CHANGED
@@ -39,8 +39,8 @@ const THEMES = {
39
39
  hover: [190, 245, 255], selBg: [0, 70, 84]
40
40
  },
41
41
  dark: {
42
- fg: [0, 200, 230], bg: [0, 18, 26],
43
- teal: [0, 200, 230], cyan: [90, 225, 255], blue: [90, 150, 255], border: [80, 140, 190],
42
+ fg: [255, 255, 255], bg: [0, 18, 26], // White foreground for normal text
43
+ teal: [0, 184, 219], cyan: [0, 215, 255], blue: [66, 135, 245], border: [70, 130, 180],
44
44
  hover: [190, 245, 255], selBg: [0, 70, 84]
45
45
  },
46
46
  light: {
package/src/tui.js CHANGED
@@ -55,7 +55,7 @@ export const COMMANDS = [
55
55
  { name: 'init', desc: 'Analyze the codebase and generate AGENTS.md', priority: 70 },
56
56
  { name: 'fork', desc: 'Fork the current session into a copy without switching to it', priority: 80 },
57
57
  { name: 'undo', desc: 'Withdraw the last prompt from the transcript', priority: 80, argumentHint: '[count]' },
58
- { name: 'title', aliases: ['rename'], desc: 'Set or show session title', priority: 60, argumentHint: '<title>' },
58
+ { name: 'title', aliases: ['rename'], desc: 'Set or show session title (also sets window title)', priority: 60, argumentHint: '<title>' },
59
59
  { name: 'status', desc: 'Show current session and runtime status', priority: 60 },
60
60
  { name: 'usage', desc: 'Show session tokens + context window', priority: 60 },
61
61
  { name: 'mcp', desc: 'Show MCP server status', priority: 60 },
@@ -1278,8 +1278,8 @@ function markdownLineToRows(line, width, fg, codeFg, isContinuation) {
1278
1278
  for (const w of wrapWords(t, width)) out.push(col(inlineMarkdown(w, codeFg), fg));
1279
1279
  return out;
1280
1280
  }
1281
- // Plain
1282
- for (const w of wrapWords(t, width)) out.push(w);
1281
+ // Plain - apply default foreground color
1282
+ for (const w of wrapWords(t, width)) out.push(col(w, fg));
1283
1283
  return out;
1284
1284
  }
1285
1285
 
@@ -1714,7 +1714,6 @@ export function composeFrame(state, cols, rows) {
1714
1714
  const hint = ed.hint || 'Ctrl+S save · Esc cancel · Enter newline';
1715
1715
  lines.push(col(hint, C.gray));
1716
1716
  lines.push('');
1717
- const viewH = Math.max(1, bodyH - (ed.notice ? 6 : 5));
1718
1717
  // Keep the caret row in view.
1719
1718
  let top = Math.max(0, ed.top || 0);
1720
1719
  if (ed.caretRow < top) top = ed.caretRow;
@@ -2498,7 +2497,7 @@ function ensureMenuVisible(state) {
2498
2497
  }
2499
2498
 
2500
2499
  // ---- command dispatch ----
2501
- async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2500
+ async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdout, renderFrame) {
2502
2501
  const { addChat, openPicker, openForm, notice, sendPrompt, quit, saveSession, openEditor } = h;
2503
2502
  const entry = findCommand(cmdRaw);
2504
2503
  const cmd = entry ? entry.name : String(cmdRaw || '').replace(/^\//, '');
@@ -2565,7 +2564,7 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2565
2564
  { label: 'statusline', sub: 'configure status line items' },
2566
2565
  { label: 'add-dir', sub: 'add an additional workspace directory' },
2567
2566
  ],
2568
- onPick: (it) => { dispatch(it.label, '', state, cfg, session, h); return true; },
2567
+ onPick: (it) => { dispatch(it.label, '', state, cfg, session, h, submit, stdout); return true; },
2569
2568
  });
2570
2569
  return;
2571
2570
 
@@ -2756,18 +2755,60 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2756
2755
  return true;
2757
2756
  }
2758
2757
  openForm({
2759
- title: `Add ${pit.label}`,
2758
+ title: `Edit ${pit.label}`,
2760
2759
  fields: [
2761
- { key: 'api_key', label: 'API Key', kind: 'mask' },
2760
+ { key: 'name', label: 'Provider Name', value: pit.name },
2762
2761
  { key: 'base_url', label: 'Base URL', value: pit.base_url },
2762
+ { key: 'api_key', label: 'API Key', kind: 'mask', value: pit.api_key || '' },
2763
+ { key: 'type', label: 'Type', options: ['OpenAI', 'Anthropic'], value: pit.type || (pit.protocol === 'anthropic' ? 'Anthropic' : 'OpenAI') },
2763
2764
  ],
2764
2765
  type: null,
2765
- hint: 'Tab next field · Enter save · Esc cancel (base URL prefilled)',
2766
- onSubmit: (values) => {
2767
- const name = pit.providerId;
2768
- const baseUrl = values.base_url || pit.base_url;
2769
- const protocol = /anthropic/i.test(baseUrl) ? 'anthropic' : 'openai';
2770
- registerProvider(state, cfg, h, name, baseUrl, values.api_key, protocol, protocol === 'anthropic' ? 'Anthropic' : 'OpenAI');
2766
+ hint: 'Tab next field · Enter save · Esc cancel',
2767
+ onSubmit: async (values) => {
2768
+ if (!values.name) { appErr('Cancelled: provider name is required.'); return; }
2769
+ const protocol = values.type === 'Anthropic' ? 'anthropic' : 'openai';
2770
+ registerProvider(state, cfg, h, values.name, values.base_url, values.api_key, protocol, values.type);
2771
+
2772
+ // Auto-fetch models and configure context/thinking settings
2773
+ try {
2774
+ const models = await fetchModels({
2775
+ baseUrl: values.base_url,
2776
+ apiKey: values.api_key,
2777
+ protocol
2778
+ });
2779
+
2780
+ if (models.length > 0) {
2781
+ // Add each model with its configuration
2782
+ for (const m of models) {
2783
+ const key = modelKey(values.name, m.id);
2784
+ if (!cfg.raw.models[key]) {
2785
+ addModel(values.name, m.id, {
2786
+ display_name: m.display,
2787
+ contextLength: m.contextLength,
2788
+ maxTokens: m.maxTokens,
2789
+ });
2790
+
2791
+ // Configure model entry in raw config
2792
+ if (!cfg.raw.models) cfg.raw.models = {};
2793
+ cfg.raw.models[key] = {
2794
+ provider: values.name,
2795
+ model: m.id,
2796
+ display_name: m.display,
2797
+ context_length: m.contextLength,
2798
+ max_tokens: m.maxTokens,
2799
+ reasoning: m.reasoning,
2800
+ efforts: m.efforts,
2801
+ };
2802
+ }
2803
+ }
2804
+
2805
+ app(`Added ${models.length} model(s) with auto-configured context windows and thinking capabilities.`);
2806
+ } else {
2807
+ app(`Provider added. No models found at this endpoint.`);
2808
+ }
2809
+ } catch (error) {
2810
+ app(`Provider added. Failed to fetch models: ${error.message}`);
2811
+ }
2771
2812
  },
2772
2813
  });
2773
2814
  return true;
@@ -2815,10 +2856,21 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2815
2856
  }
2816
2857
 
2817
2858
  case 'new':
2859
+ // Save current session's todos before switching
2860
+ if (session && Array.isArray(state.todos)) {
2861
+ session.todos = state.todos.slice();
2862
+ saveSession(session);
2863
+ }
2864
+
2818
2865
  state.chat = [];
2819
2866
  Object.assign(session, { id: sess.newId(), title: '', messages: [], createdAt: Date.now(), rounds: 0, steps: 0, lastTurnMs: 0 });
2820
2867
  state.objective = '';
2821
2868
  state.rounds = 0; state.steps = 0; state.lastTurnMs = 0;
2869
+ // New session starts with empty TODO list
2870
+ state.todos = [];
2871
+ session.todos = [];
2872
+ // Set terminal window title to default (Untitled)
2873
+ stdout.write('\x1b]0;Untitled\x07');
2822
2874
  // A fresh session has no history, so the context gauge must go back to 0
2823
2875
  // (otherwise it kept showing the PREVIOUS session's usage until the next
2824
2876
  // turn reported a new estimate). The window size is a property of the
@@ -2916,8 +2968,17 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2916
2968
  return;
2917
2969
  }
2918
2970
  case 'title': {
2919
- if (raw) { session.title = raw.slice(0, 200); saveSession(session); app(`Session title set to: ${session.title}`); }
2920
- else app(`Session title: ${session.title || 'not set'}`);
2971
+ if (raw) {
2972
+ session.title = raw.slice(0, 200);
2973
+ saveSession(session);
2974
+ // Set terminal window title - use OSC sequence
2975
+ // Only show "Untitled" prefix if user hasn't set a custom title
2976
+ const displayTitle = session.title || 'Untitled';
2977
+ stdout.write(`\x1b]0;${displayTitle}\x07`);
2978
+ app(`Session title set to: "${displayTitle}"`);
2979
+ } else {
2980
+ app(`Session title: ${session.title || 'not set'}`);
2981
+ }
2921
2982
  return;
2922
2983
  }
2923
2984
  case 'compact': {
@@ -3430,10 +3491,8 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
3430
3491
  return;
3431
3492
  }
3432
3493
  }
3433
- // Unknown command: treat as plain text and send to AI.
3434
- // Remove the leading / so it's sent as regular text.
3435
- const textToSend = '/' + raw;
3436
- submit(textToSend);
3494
+ // Unknown command: show error message
3495
+ appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
3437
3496
  return;
3438
3497
  }
3439
3498
  }
@@ -3717,7 +3776,8 @@ export async function startTUI(opts) {
3717
3776
 
3718
3777
  const state = makeState({ cfg, session, opts });
3719
3778
  cfg.effort = state.reasoning ? state.effort : '';
3720
- setTheme(state.theme);
3779
+ // Force dark theme (theme removed from state)
3780
+ setTheme('dark');
3721
3781
 
3722
3782
  // Calculate initial context usage from loaded session
3723
3783
  if (session && session.messages && session.messages.length) {
@@ -3999,7 +4059,7 @@ export async function startTUI(opts) {
3999
4059
  if (cmd) {
4000
4060
  state.menuOpen = false; state.menuList = []; state.menuSel = 0; state.menuOffset = 0;
4001
4061
  state.input = ''; state.caret = 0;
4002
- dispatch(cmd.name, '', state, cfg, session, host);
4062
+ dispatch(cmd.name, '', state, cfg, session, host, submit, stdout);
4003
4063
  if (state._quit) { quit(); return true; }
4004
4064
  renderFrame();
4005
4065
  }
@@ -4020,6 +4080,16 @@ export async function startTUI(opts) {
4020
4080
  renderFrame();
4021
4081
  return true;
4022
4082
  }
4083
+ case 'editor': {
4084
+ if (!state.editor) return false;
4085
+ const ed = state.editor;
4086
+ const ls = ed.text.split('\n');
4087
+ // Set caret to clicked position
4088
+ ed.caretRow = hb.row;
4089
+ ed.caretCol = Math.min((ls[ed.caretRow] || '').length, 1000); // Cap at reasonable length
4090
+ renderFrame();
4091
+ return true;
4092
+ }
4023
4093
  case 'pickerItem': {
4024
4094
  if (!state.picker) return false;
4025
4095
  state.picker.sel = hb.index;
@@ -4285,25 +4355,29 @@ export async function startTUI(opts) {
4285
4355
  return;
4286
4356
  }
4287
4357
 
4288
- let text = '';
4289
- let via = 'none';
4290
-
4291
- // Try fast path first (oneshot)
4358
+ // Always try fast path first (readOnce is instant)
4292
4359
  try {
4293
- ({ text, via } = { text: readOnce(), via: 'oneshot' });
4360
+ const text = readOnce();
4361
+ if (text) {
4362
+ insertComposerPaste(text);
4363
+ notice('Pasted instantly!', 'info');
4364
+ renderFrame();
4365
+ return;
4366
+ }
4294
4367
  } catch {}
4295
4368
 
4296
- // If fast path failed and we need to try slow path
4297
- if (!text && !fastPathOnly) {
4298
- const result = await readText();
4299
- text = result.text;
4300
- via = result.via;
4369
+ // Fast path failed - only use slow path for Ctrl+Shift+V, not for right-click
4370
+ if (fastPathOnly) {
4371
+ notice('Clipboard empty or unavailable (fast path only)', 'error');
4372
+ renderFrame();
4373
+ return;
4301
4374
  }
4302
4375
 
4303
- if (!text) { notice('Clipboard empty or unavailable', 'error'); renderFrame(); return; }
4304
- insertComposerPaste(text);
4305
- if (via === 'helper') notice(`Pasted via helper (${via})`, 'info');
4306
- else if (via === 'oneshot') notice('Pasted instantly!', 'info');
4376
+ // Slow path for Ctrl+Shift+V
4377
+ const result = await readText();
4378
+ if (!result.text) { notice('Clipboard empty or unavailable', 'error'); renderFrame(); return; }
4379
+ insertComposerPaste(result.text);
4380
+ notice(`Pasted via ${result.via}`, 'info');
4307
4381
  renderFrame();
4308
4382
  }
4309
4383
  // Ctrl-S: inject the queued messages (plus the current draft) into the RUNNING
@@ -5114,6 +5188,7 @@ export async function startTUI(opts) {
5114
5188
  state.historyIdx = -1;
5115
5189
  if (!text) { renderFrame(); return; }
5116
5190
  if (!state.running) state.turnStart = Date.now();
5191
+
5117
5192
  if (state.history[state.history.length - 1] !== text) state.history.push(text);
5118
5193
  // Commands are handled immediately, never queued: queuing them delayed a
5119
5194
  // /plan or /model until the running turn finished, which is not what
@@ -5122,7 +5197,7 @@ export async function startTUI(opts) {
5122
5197
  const sp = text.indexOf(' ');
5123
5198
  const c = sp === -1 ? text : text.slice(0, sp);
5124
5199
  const arg = sp === -1 ? '' : text.slice(sp + 1).trim();
5125
- dispatch(c, arg, state, cfg, session, host);
5200
+ dispatch(c, arg, state, cfg, session, host, submit, stdout);
5126
5201
  if (state._quit) { quit(); return; }
5127
5202
  renderFrame();
5128
5203
  return;
@@ -5157,6 +5232,12 @@ export async function startTUI(opts) {
5157
5232
  // model stops narrating what it is about to do and why.
5158
5233
  const basePrompt = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
5159
5234
  let sysText = basePrompt;
5235
+
5236
+ // Auto-generate title on first turn (if not already set)
5237
+ if (!session.title && session.messages && session.messages.length === 0) {
5238
+ sysText += '\n\n[IMPORTANT: Please generate a concise, descriptive title for this conversation based on the user\'s request. Return ONLY the title text, nothing else. Example: Fix color rendering issue or Implement plugin system]';
5239
+ }
5240
+
5160
5241
  if (cfg.calmMode) {
5161
5242
  sysText += '\n\n' + CALM_MODE_INSTRUCTION;
5162
5243
  }
@@ -5473,6 +5554,24 @@ export async function startTUI(opts) {
5473
5554
  } else {
5474
5555
  state.chat.push({ role: 'assistant', text: text || '' });
5475
5556
  }
5557
+
5558
+ // Check if this is the first assistant response and we need to parse title
5559
+ if (!state.running && !session.title && state.chat.some(m => m.role === 'assistant')) {
5560
+ // Look for title in the assistant's response
5561
+ const assistantMsg = state.chat.find(m => m.role === 'assistant');
5562
+ if (assistantMsg && assistantMsg.text) {
5563
+ const text = assistantMsg.text.trim();
5564
+ // More lenient heuristic: short text (< 80 chars), no newlines, optional ending punctuation
5565
+ const cleanText = text.replace(/[.!?]$/, '').trim();
5566
+ if (cleanText.length < 80 && !text.includes('\n') && cleanText.length > 2) {
5567
+ session.title = cleanText;
5568
+ saveSession(session);
5569
+ stdout.write(`\x1b]0;${cleanText}\x07`);
5570
+ app(`Auto-generated title: "${cleanText}"`);
5571
+ }
5572
+ }
5573
+ }
5574
+
5476
5575
  anchorScroll();
5477
5576
  }
5478
5577