@hncode/hncode 0.1.5 → 0.1.7

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 +137 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
@@ -38,7 +38,7 @@ const VERSION = '0.1.0';
38
38
  export const COMMANDS = [
39
39
  { name: 'yolo', aliases: ['yes'], desc: 'Ask When Needed mode: anything inside the workspace (edits, writes, commands) runs automatically; paths outside it, destructive commands, questions and plans still ask.', priority: 101 },
40
40
  { name: 'permission', desc: 'Select permission mode', priority: 100 },
41
- { name: 'settings', aliases: ['config'], desc: 'Open settings (model / permission / theme / statusline)', priority: 100 },
41
+ { name: 'settings', aliases: ['config'], desc: 'Open settings (model / permission / statusline)', priority: 100 },
42
42
  { name: 'plan', desc: 'Toggle plan mode', priority: 100, argumentHint: '[on|off|clear]' },
43
43
  { name: 'focus', desc: 'Toggle Focus mode (minimal tools first, full tools after)', priority: 98, argumentHint: '[on|off]' },
44
44
  { name: 'auto', desc: 'Never Ask mode: never interrupts you; everything runs and is decided automatically.', priority: 99 },
@@ -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(/^\//, '');
@@ -2562,11 +2561,10 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2562
2561
  { label: 'effort', sub: 'switch thinking effort' },
2563
2562
  { label: 'permission', sub: 'select permission mode' },
2564
2563
  { label: 'provider', sub: 'manage AI providers' },
2565
- { label: 'theme', sub: 'set the terminal UI theme' },
2566
2564
  { label: 'statusline', sub: 'configure status line items' },
2567
2565
  { label: 'add-dir', sub: 'add an additional workspace directory' },
2568
2566
  ],
2569
- 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; },
2570
2568
  });
2571
2569
  return;
2572
2570
 
@@ -2757,18 +2755,60 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2757
2755
  return true;
2758
2756
  }
2759
2757
  openForm({
2760
- title: `Add ${pit.label}`,
2758
+ title: `Edit ${pit.label}`,
2761
2759
  fields: [
2762
- { key: 'api_key', label: 'API Key', kind: 'mask' },
2760
+ { key: 'name', label: 'Provider Name', value: pit.name },
2763
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') },
2764
2764
  ],
2765
2765
  type: null,
2766
- hint: 'Tab next field · Enter save · Esc cancel (base URL prefilled)',
2767
- onSubmit: (values) => {
2768
- const name = pit.providerId;
2769
- const baseUrl = values.base_url || pit.base_url;
2770
- const protocol = /anthropic/i.test(baseUrl) ? 'anthropic' : 'openai';
2771
- 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
+ }
2772
2812
  },
2773
2813
  });
2774
2814
  return true;
@@ -2816,10 +2856,21 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2816
2856
  }
2817
2857
 
2818
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
+
2819
2865
  state.chat = [];
2820
2866
  Object.assign(session, { id: sess.newId(), title: '', messages: [], createdAt: Date.now(), rounds: 0, steps: 0, lastTurnMs: 0 });
2821
2867
  state.objective = '';
2822
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');
2823
2874
  // A fresh session has no history, so the context gauge must go back to 0
2824
2875
  // (otherwise it kept showing the PREVIOUS session's usage until the next
2825
2876
  // turn reported a new estimate). The window size is a property of the
@@ -2917,8 +2968,17 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
2917
2968
  return;
2918
2969
  }
2919
2970
  case 'title': {
2920
- if (raw) { session.title = raw.slice(0, 200); saveSession(session); app(`Session title set to: ${session.title}`); }
2921
- 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
+ }
2922
2982
  return;
2923
2983
  }
2924
2984
  case 'compact': {
@@ -3431,10 +3491,8 @@ async function dispatch(cmdRaw, arg, state, cfg, session, h) {
3431
3491
  return;
3432
3492
  }
3433
3493
  }
3434
- // Unknown command: treat as plain text and send to AI.
3435
- // Remove the leading / so it's sent as regular text.
3436
- const textToSend = '/' + raw;
3437
- submit(textToSend);
3494
+ // Unknown command: show error message
3495
+ appErr(`Unknown command: /${cmdRaw.replace(/^\//, '')}`);
3438
3496
  return;
3439
3497
  }
3440
3498
  }
@@ -3718,7 +3776,8 @@ export async function startTUI(opts) {
3718
3776
 
3719
3777
  const state = makeState({ cfg, session, opts });
3720
3778
  cfg.effort = state.reasoning ? state.effort : '';
3721
- setTheme(state.theme);
3779
+ // Force dark theme (theme removed from state)
3780
+ setTheme('dark');
3722
3781
 
3723
3782
  // Calculate initial context usage from loaded session
3724
3783
  if (session && session.messages && session.messages.length) {
@@ -4000,7 +4059,7 @@ export async function startTUI(opts) {
4000
4059
  if (cmd) {
4001
4060
  state.menuOpen = false; state.menuList = []; state.menuSel = 0; state.menuOffset = 0;
4002
4061
  state.input = ''; state.caret = 0;
4003
- dispatch(cmd.name, '', state, cfg, session, host);
4062
+ dispatch(cmd.name, '', state, cfg, session, host, submit, stdout);
4004
4063
  if (state._quit) { quit(); return true; }
4005
4064
  renderFrame();
4006
4065
  }
@@ -4021,6 +4080,16 @@ export async function startTUI(opts) {
4021
4080
  renderFrame();
4022
4081
  return true;
4023
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
+ }
4024
4093
  case 'pickerItem': {
4025
4094
  if (!state.picker) return false;
4026
4095
  state.picker.sel = hb.index;
@@ -4286,25 +4355,29 @@ export async function startTUI(opts) {
4286
4355
  return;
4287
4356
  }
4288
4357
 
4289
- let text = '';
4290
- let via = 'none';
4291
-
4292
- // Try fast path first (oneshot)
4358
+ // Always try fast path first (readOnce is instant)
4293
4359
  try {
4294
- ({ 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
+ }
4295
4367
  } catch {}
4296
4368
 
4297
- // If fast path failed and we need to try slow path
4298
- if (!text && !fastPathOnly) {
4299
- const result = await readText();
4300
- text = result.text;
4301
- 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;
4302
4374
  }
4303
4375
 
4304
- if (!text) { notice('Clipboard empty or unavailable', 'error'); renderFrame(); return; }
4305
- insertComposerPaste(text);
4306
- if (via === 'helper') notice(`Pasted via helper (${via})`, 'info');
4307
- 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');
4308
4381
  renderFrame();
4309
4382
  }
4310
4383
  // Ctrl-S: inject the queued messages (plus the current draft) into the RUNNING
@@ -5115,6 +5188,7 @@ export async function startTUI(opts) {
5115
5188
  state.historyIdx = -1;
5116
5189
  if (!text) { renderFrame(); return; }
5117
5190
  if (!state.running) state.turnStart = Date.now();
5191
+
5118
5192
  if (state.history[state.history.length - 1] !== text) state.history.push(text);
5119
5193
  // Commands are handled immediately, never queued: queuing them delayed a
5120
5194
  // /plan or /model until the running turn finished, which is not what
@@ -5123,7 +5197,7 @@ export async function startTUI(opts) {
5123
5197
  const sp = text.indexOf(' ');
5124
5198
  const c = sp === -1 ? text : text.slice(0, sp);
5125
5199
  const arg = sp === -1 ? '' : text.slice(sp + 1).trim();
5126
- dispatch(c, arg, state, cfg, session, host);
5200
+ dispatch(c, arg, state, cfg, session, host, submit, stdout);
5127
5201
  if (state._quit) { quit(); return; }
5128
5202
  renderFrame();
5129
5203
  return;
@@ -5158,6 +5232,12 @@ export async function startTUI(opts) {
5158
5232
  // model stops narrating what it is about to do and why.
5159
5233
  const basePrompt = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
5160
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
+
5161
5241
  if (cfg.calmMode) {
5162
5242
  sysText += '\n\n' + CALM_MODE_INSTRUCTION;
5163
5243
  }
@@ -5474,6 +5554,24 @@ export async function startTUI(opts) {
5474
5554
  } else {
5475
5555
  state.chat.push({ role: 'assistant', text: text || '' });
5476
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
+
5477
5575
  anchorScroll();
5478
5576
  }
5479
5577