@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/README.md +87 -198
- package/README_CN.md +87 -187
- package/package.json +1 -1
- package/src/agent.js +13 -22
- package/src/clipboard.js +116 -13
- package/src/colors.js +16 -8
- package/src/index.js +1 -1
- package/src/tui.js +200 -169
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
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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,
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
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
|
|
14
|
+
const HELP = `hncode ${VERSION} — a Kimi Code-style coding agent.
|
|
15
15
|
|
|
16
16
|
Usage: hncode [options] [command]
|
|
17
17
|
|