@hncode/hncode 0.1.7 → 0.1.9

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/src/clipboard.js CHANGED
@@ -9,6 +9,9 @@
9
9
  // Non-Windows paths (pbcopy/pbpaste/xclip) are cheap enough to run per call.
10
10
 
11
11
  import cp from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
12
15
 
13
16
  const SENT = '@@HNCODE_CLIP@@';
14
17
  let helper = null; // long-lived PowerShell child
@@ -59,10 +62,10 @@ function helperRun(script, timeoutMs) {
59
62
  warmClipboard();
60
63
  if (!helper) return Promise.resolve(null);
61
64
  return new Promise((resolve) => {
62
- const timer = setTimeout(() => { resolve(null); }, timeoutMs);
65
+ const timer = setTimeout(() => { helperBusy = false; resolve(null); }, timeoutMs);
63
66
  helperQueue.push((out) => { clearTimeout(timer); resolve(out); });
64
67
  try { helper.stdin.write(script + `\n'${SENT}'\n`); }
65
- catch { clearTimeout(timer); resolve(null); }
68
+ catch { clearTimeout(timer); helperBusy = false; resolve(null); }
66
69
  });
67
70
  }
68
71
 
@@ -98,17 +101,117 @@ export async function readText(timeoutMs = 3000) {
98
101
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
99
102
  }
100
103
 
101
- // Read clipboard as image (base64 PNG). Returns null if clipboard has no image.
102
- export function readImage() {
103
- if (!win()) return null;
104
+ // Unified clipboard read: returns text to insert into the composer, handling
105
+ // three clipboard shapes:
106
+ // 1. copied FILES -> absolute path(s)
107
+ // 2. plain TEXT -> the text itself
108
+ // 3. an IMAGE (screenshot) -> saved to a temp .png, returns its path
109
+ //
110
+ // SPEED: a cold PowerShell costs ~2.9s on Windows (measured), while a command on
111
+ // the already-warm, long-lived helper (see warmClipboard) costs ~10ms. The TUI
112
+ // pre-warms the helper at start, so we run this probe on THAT shell. Only if the
113
+ // helper is unavailable do we fall back to a one-shot PowerShell.
114
+ //
115
+ // The probe prints a tagged result that can be parsed cheaply:
116
+ // F::<path> a copied file path (one line per file)
117
+ // T::<base64> base64-encoded clipboard text
118
+ // I::<path> a clipboard image saved to a temp png
119
+ // (empty) nothing usable on the clipboard
120
+ //
121
+ // NOTE: no `-join` here. A backtick-n inside the JS string produced a broken
122
+ // PowerShell expression (silently returned nothing), which made FILE pastes fall
123
+ // back to the slow cold spawn. Emitting one F:: line per file is simpler and
124
+ // also supports multi-file copies.
125
+ const CLIP_PROBE = [
126
+ "$ErrorActionPreference='SilentlyContinue'",
127
+ "$f=Get-Clipboard -Format FileDropList",
128
+ "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_.FullName};return}",
129
+ "$t=Get-Clipboard -Raw",
130
+ "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
131
+ "$i=Get-Clipboard -Image",
132
+ "if($null -ne $i){$p=Join-Path $env:TEMP ('hncode-paste-'+[guid]::NewGuid().ToString('N')+'.png');$i.Save($p,[System.Drawing.Imaging.ImageFormat]::Png);'I::'+$p}",
133
+ ].join('; ');
134
+
135
+ // Parse the probe output into { text, via }. Handles one-or-more F:: lines.
136
+ function parseClipResult(out) {
137
+ const lines = String(out == null ? '' : out).replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
138
+ if (!lines.length) return null;
139
+ const fileLines = lines.filter((l) => l.startsWith('F::'));
140
+ if (fileLines.length) return { text: fileLines.map((l) => l.slice(3).trim()).join('\n'), via: 'files' };
141
+ const imgLine = lines.find((l) => l.startsWith('I::'));
142
+ if (imgLine) return { text: imgLine.slice(3).trim(), via: 'image' };
143
+ const txtLine = lines.find((l) => l.startsWith('T::'));
144
+ if (txtLine) {
145
+ try { return { text: Buffer.from(txtLine.slice(3).trim(), 'base64').toString('utf8'), via: 'text' }; }
146
+ catch { return null; }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // Run the probe on the warm helper (fast path). Resolves null if it is not ready.
152
+ async function readViaHelper() {
153
+ warmClipboard();
154
+ if (!helper) return null;
155
+ // Wait briefly for any in-flight command (e.g. a concurrent readText) to drain,
156
+ // so we still hit the warm shell instead of falling back to a cold spawn.
157
+ for (let i = 0; i < 20 && helperBusy; i++) {
158
+ await new Promise((r) => setTimeout(r, 25));
159
+ }
160
+ if (helperBusy) return null;
161
+ helperBusy = true;
162
+ const out = await helperRun(CLIP_PROBE, 3000);
163
+ return out == null ? null : parseClipResult(out);
164
+ }
165
+
166
+ export function readClipboardContent() {
167
+ if (!win()) {
168
+ try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
169
+ }
170
+ // Synchronous API is required by the caller, so use a one-shot PowerShell here
171
+ // and let the async path (readClipboardContentAsync) use the warm helper.
172
+ const scriptFile = clipScriptPath();
173
+ if (!scriptFile) return { text: '', via: 'none' };
104
174
  try {
105
- // PowerShell: Get-Clipboard -Image, save to temp file, read as base64
106
- const b64 = cp.execSync(
107
- 'powershell -NoProfile -Command "$img = Get-Clipboard -Image; if ($null -ne $img) { $path = [System.IO.Path]::GetTempFileName() + \'.png\'; $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); $bytes = [System.IO.File]::ReadAllBytes($path); Remove-Item $path; Write-Host ([Convert]::ToBase64String($bytes)) }"',
108
- { encoding: 'ascii', windowsHide: true, timeout: 8000 },
175
+ const out = cp.execSync(
176
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`,
177
+ { encoding: 'utf8', windowsHide: true, timeout: 8000 },
109
178
  );
110
- const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
111
- if (s.length > 0) return s;
112
- return null;
113
- } catch { return null; }
179
+ return parseClipResult(out) || { text: '', via: 'none' };
180
+ } catch { return { text: '', via: 'none' }; }
181
+ }
182
+
183
+ // Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
184
+ export async function readClipboardContentAsync() {
185
+ if (!win()) {
186
+ try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
187
+ }
188
+ const viaHelper = await readViaHelper();
189
+ if (viaHelper) return viaHelper;
190
+ return readClipboardContent();
191
+ }
192
+
193
+ // The PowerShell logic for the one-shot fallback lives in a temp .ps1 file:
194
+ // passing a multi-line script via -Command "..." mangles its own quotes on
195
+ // Windows, so we write the script once and re-use it by path.
196
+ let _clipScript = null;
197
+ function clipScriptPath() {
198
+ if (_clipScript) return _clipScript;
199
+ const p = path.join(os.tmpdir(), 'hncode-clipread.ps1');
200
+ const script = [
201
+ "$ErrorActionPreference = 'SilentlyContinue'",
202
+ '$files = Get-Clipboard -Format FileDropList',
203
+ 'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_.FullName }; exit }',
204
+ '$txt = Get-Clipboard -Raw',
205
+ 'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
206
+ '$img = Get-Clipboard -Image',
207
+ 'if ($null -ne $img) {',
208
+ ' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
209
+ ' $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)',
210
+ ' "I::" + $path',
211
+ '}',
212
+ '',
213
+ ].join('\n');
214
+ try { fs.writeFileSync(p, script, 'utf8'); } catch { return null; }
215
+ _clipScript = p;
216
+ return p;
114
217
  }
package/src/colors.js CHANGED
@@ -81,11 +81,14 @@ export const C = {
81
81
  selBg: TRUECOLOR ? brgb(0, 70, 84) : '\x1b[48;5;23m', // Original dark cyan background
82
82
  scrollTrack: '\x1b[90m', // dim gutter
83
83
  scrollThumb: TRUECOLOR ? rgb(0, 184, 219) : '\x1b[38;5;37m', // brand cyan-blue
84
- // Scrollbar interaction states. hover: a touch whiter/brighter than the brand
85
- // colour; active (pressed/dragging): a touch darker. Idle returns to
86
- // scrollThumb (the brand colour) — the caller swaps back automatically.
84
+ // Scrollbar interaction states:
85
+ // hover -> a touch whiter/brighter than the brand colour
86
+ // active -> BOLD + the brand colour (pressed/dragging)
87
+ // The pressed state used to be a DARKER colour, which on a dark background
88
+ // read as "the thumb disappeared". Making it bold-and-bright means a press is
89
+ // always unmistakably visible. Idle swaps back to scrollThumb automatically.
87
90
  scrollThumbHover: TRUECOLOR ? rgb(120, 226, 245) : '\x1b[38;5;123m',
88
- scrollThumbActive: TRUECOLOR ? rgb(0, 140, 168) : '\x1b[38;5;31m',
91
+ scrollThumbActive: '\x1b[1m' + (TRUECOLOR ? rgb(0, 220, 255) : '\x1b[38;5;51m'),
89
92
  // Hover highlight for clickable rows (menu items, picker options, form
90
93
  // fields, composer rows). An explicit bright foreground + BOLD rather than a
91
94
  // bare BOLD: BOLD only brightens the 16-colour palette (it does nothing to a
@@ -129,12 +132,17 @@ export function setTheme(name) {
129
132
  C.bgPanel = brgb(...t.bg);
130
133
  C.selBg = brgb(...t.selBg);
131
134
 
132
- // Scrollbar colors - adjust based on theme brightness
133
- const isLight = t.bg[0] > 128; // Simple brightness check
134
- C.scrollTrack = isLight ? '\x1b[90m' : '\x1b[90m'; // dim gutter (works for both)
135
+ // Scrollbar colours. The active (pressed/dragging) thumb is BOLD + a brighter
136
+ // shade of the theme colour. It used to be a DARKER shade, which on a dark
137
+ // background read as "the thumb vanished" mid-drag.
138
+ C.scrollTrack = '\x1b[90m'; // dim gutter
135
139
  C.scrollThumb = rgb(...t.teal);
136
140
  C.scrollThumbHover = rgb(...t.hover);
137
- C.scrollThumbActive = rgb(t.teal[0] * 0.7, t.teal[1] * 0.7, t.teal[2] * 0.7);
141
+ C.scrollThumbActive = '\x1b[1m' + rgb(
142
+ Math.min(255, Math.round(t.teal[0] * 0.6 + 255 * 0.4)),
143
+ Math.min(255, Math.round(t.teal[1] * 0.6 + 255 * 0.4)),
144
+ Math.min(255, Math.round(t.teal[2] * 0.6 + 255 * 0.4)),
145
+ );
138
146
 
139
147
  // Hover stays visible on this theme's background (bright on dark, dark on light).
140
148
  C.hover = '\x1b[1m' + rgb(...(t.hover || [190, 245, 255]));
package/src/index.js CHANGED
@@ -11,7 +11,7 @@ import { setToolsList } from './llm.js';
11
11
 
12
12
  export const VERSION = '0.1.0';
13
13
 
14
- const HELP = `hncode ${VERSION} — a kimi-code-cli-style coding agent.
14
+ const HELP = `hncode ${VERSION} — a Kimi Code-style coding agent.
15
15
 
16
16
  Usage: hncode [options] [command]
17
17