@k-leumas/queque-cli 0.4.2 → 0.5.1

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.
@@ -22,37 +22,181 @@ _qq_cleanup() {
22
22
  trap - EXIT ERR INT
23
23
  }
24
24
 
25
- typeset -g QQ_DAEMON_PREWARMED="${QQ_DAEMON_PREWARMED:-0}"
25
+ # ---------------------------------------------------------------------------
26
+ # Installed-tool handoff (spike 001, D-01, D-19).
27
+ # Writes the names zsh can resolve into the session dir ($1) next to
28
+ # request.json, so the qq client can flag candidates that use missing tools:
29
+ # commands.txt — keys of $commands (external commands on PATH)
30
+ # builtins.txt — keys of $builtins (never flagged)
31
+ # functions.txt — function names (minus `_`-prefixed internals) AND alias
32
+ # names. One file on purpose (D-01 amended) — do not split it.
33
+ # `rehash` runs on every enabled call so a tool installed earlier in this
34
+ # shell session is listed (no mtime fingerprint, no background job — D-19).
35
+ # Side effect, accepted: `rehash` also drops manual `hash name=path` entries.
36
+ # Names only: one expansion per file, no per-entry loop, no ordering in zsh
37
+ # (both cost 100+ ms; sort in Node if ever needed).
38
+ # Every line is guarded: the widget's ERR trap deletes the session dir
39
+ # (request.json included) on any failing command, so nothing here may leave a
40
+ # non-zero status, and stderr stays off the ZLE line. A failed write removes
41
+ # all three files, so failure means "no files" and the client shows no flags.
42
+ # Kill switch: QQ_TOOL_AWARENESS=0 in the shell writes nothing.
43
+ # ---------------------------------------------------------------------------
44
+ _qq_write_tool_handoff() {
45
+ local dir="$1"
46
+ if [[ "${QQ_TOOL_AWARENESS:-}" == 0 ]]; then
47
+ return 0
48
+ fi
49
+ zmodload zsh/parameter 2>/dev/null || return 0
50
+ rehash 2>/dev/null || true
51
+ {
52
+ print -rl -- ${(k)commands} > "$dir/commands.txt" &&
53
+ print -rl -- ${(k)builtins} > "$dir/builtins.txt" &&
54
+ print -rl -- ${(k)functions:#_*} ${(k)aliases} > "$dir/functions.txt"
55
+ } 2>/dev/null || {
56
+ rm -f -- "$dir/commands.txt" "$dir/builtins.txt" "$dir/functions.txt" 2>/dev/null || true
57
+ }
58
+ return 0
59
+ }
26
60
 
27
- _qq_log() {
28
- if [[ -z "$QQ_DEBUG_LOG_FILE" ]]; then
61
+ # ---------------------------------------------------------------------------
62
+ # Instant first frame (quick 261011-jbh, LD-1).
63
+ # The moment `??` fires, the widget paints the `queque › <lbuffer>` header and
64
+ # a `thinking…` row into the modal surface, before Node starts:
65
+ # inline — `_qq_paint_inline_instant_frame` prints the scroll reserve, a
66
+ # fresh cursor save, an erase, the frame, and a restore, so the
67
+ # cursor ends on the reserve top. The reserve is byte-identical to
68
+ # the client's inlineScrollReserve(MODAL_VIEWPORT_LINES)
69
+ # (src/client/instant-frame.ts; tests/zsh-widget.test.ts z5 guards
70
+ # the drift), so the client's own reserve moves nothing.
71
+ # Zellij — `_qq_write_pane_instant_frame` writes the frame to
72
+ # `$tmpdir/instant-frame.txt` (0700 session dir) and the pane
73
+ # command becomes `/bin/sh -c "$QQ_PANE_PAINT_SCRIPT"`, which cats
74
+ # the file and execs the unchanged client argv. The lbuffer never
75
+ # reaches the zellij or sh argv.
76
+ # The Ink client clears the frame in its first visible write (takeover). The
77
+ # rows mirror Ink's loading frame: header (only for a non-empty lbuffer), the
78
+ # FILTER row and the content margin (blank here), then the loading text.
79
+ # Control characters are stripped from the lbuffer before it reaches the
80
+ # terminal. Plain stdout only (no tty-device redirects; ZLE tracks it).
81
+ # Every line is guarded like the handoff helper: the widget's ERR trap deletes
82
+ # the session dir on any failing command, so every list ends in status 0 and
83
+ # stderr stays off the ZLE line. No subprocesses.
84
+ # Kill switch: QQ_INSTANT_FRAME=0 in the shell paints nothing and launches the
85
+ # client exactly as before.
86
+ # Constants use `typeset -g`, not `-r`: the widget is re-sourced in dev.
87
+ # ---------------------------------------------------------------------------
88
+ typeset -g QQ_MODAL_VIEWPORT_LINES=18 # mirrors MODAL_VIEWPORT_LINES (modal-layout.ts)
89
+ typeset -g QQ_MODAL_MAX_COLUMNS=80 # mirrors MODAL_MAX_WIDTH (CandidateSelect / Modal)
90
+ typeset -g QQ_MODAL_PADDING_COLUMNS=1 # Modal paddingX on each side
91
+ typeset -g QQ_ZELLIJ_FRAME_COLUMNS=2 # floating pane border columns
92
+ typeset -g QQ_INSTANT_FRAME_ROWS_ABOVE_LOADING=2 # FILTER row + content marginTop
93
+ typeset -g QQ_INSTANT_FRAME_LOADING_TEXT='thinking…' # LoadingSpinner FRAMES[0]
94
+ typeset -g QQ_INSTANT_FRAME_FILE='instant-frame.txt'
95
+ typeset -g QQ_PANE_PAINT_SCRIPT='cat "$1" 2>/dev/null; shift; exec "$@"'
96
+ typeset -g QQ_INSTANT_FRAME_PAINTED="${QQ_INSTANT_FRAME_PAINTED:-0}"
97
+
98
+ # _qq_instant_frame lbuffer columns — sets REPLY to the frame rows joined by
99
+ # CR LF (empty with QQ_INSTANT_FRAME=0). Non-numeric or zero columns mean 80.
100
+ _qq_instant_frame() {
101
+ REPLY=''
102
+ if [[ "${QQ_INSTANT_FRAME:-}" == 0 ]]; then
29
103
  return 0
30
104
  fi
31
- local message="$1"
32
- print -r -- "$(date '+%Y-%m-%dT%H:%M:%S%z') [zsh] $message" >> "$QQ_DEBUG_LOG_FILE" 2>/dev/null
105
+ local lbuffer="${1//[[:cntrl:]]/}"
106
+ local columns="${2:-}"
107
+ if [[ "$columns" != <1-> ]] || (( columns > QQ_MODAL_MAX_COLUMNS )); then
108
+ columns=$QQ_MODAL_MAX_COLUMNS
109
+ fi
110
+ local width=$(( columns - 2 * QQ_MODAL_PADDING_COLUMNS ))
111
+ if (( width < 1 )); then
112
+ width=1
113
+ fi
114
+ local pad="${(l:QQ_MODAL_PADDING_COLUMNS:)}"
115
+ local dim=$'\e[2m' undim=$'\e[22m'
116
+ local -a rows
117
+ if [[ -n "${1:-}" ]]; then
118
+ local text="queque › ${lbuffer}"
119
+ if (( ${#text} > width )); then
120
+ text="${text[1,width-1]}…"
121
+ fi
122
+ rows+=("${pad}${dim}${text}${undim}")
123
+ fi
124
+ repeat $QQ_INSTANT_FRAME_ROWS_ABOVE_LOADING rows+=('')
125
+ rows+=("${pad}${dim}${QQ_INSTANT_FRAME_LOADING_TEXT}${undim}")
126
+ REPLY="${(pj:\r\n:)rows}"
127
+ return 0
33
128
  }
34
129
 
35
- _qq_prewarm_daemon() {
36
- if [[ "$QQ_DAEMON_PREWARMED" == 1 ]]; then
130
+ # _qq_inline_instant_frame_bytes lbuffer columns — sets REPLY to the inline
131
+ # paint: reserve (newlines, CSI <n> A, CR, ESC 7) + CSI J + frame + ESC 8.
132
+ # Empty when the frame is empty.
133
+ _qq_inline_instant_frame_bytes() {
134
+ _qq_instant_frame "${1:-}" "${2:-}"
135
+ if [[ -z "$REPLY" ]]; then
136
+ return 0
137
+ fi
138
+ local frame="$REPLY"
139
+ REPLY="${(pl:QQ_MODAL_VIEWPORT_LINES::\n:)}"$'\e['"${QQ_MODAL_VIEWPORT_LINES}A"$'\r\e7\e[J'"${frame}"$'\e8'
140
+ return 0
141
+ }
142
+
143
+ # _qq_paint_inline_instant_frame lbuffer — prints the inline paint in one
144
+ # write when stdout is a terminal. QQ_INSTANT_FRAME_PAINTED=1 only when that
145
+ # print succeeded (the widget then clears it if the client exits non-zero).
146
+ _qq_paint_inline_instant_frame() {
147
+ QQ_INSTANT_FRAME_PAINTED=0
148
+ if [[ ! -t 1 ]]; then
149
+ return 0
150
+ fi
151
+ _qq_inline_instant_frame_bytes "${1:-}" "${COLUMNS:-}"
152
+ if [[ -z "$REPLY" ]]; then
37
153
  return 0
38
154
  fi
155
+ if print -rn -- "$REPLY" 2>/dev/null; then
156
+ QQ_INSTANT_FRAME_PAINTED=1
157
+ fi
158
+ return 0
159
+ }
39
160
 
40
- if [[ -z "$QQ_DEV_ROOT" ]]; then
161
+ # _qq_write_pane_instant_frame dir lbuffer pane_columns — writes the frame +
162
+ # CSI H to dir/instant-frame.txt for the Zellij pane wrapper. A failed write
163
+ # removes the file, so failure means "no file" and the pane launches as before.
164
+ _qq_write_pane_instant_frame() {
165
+ local dir="$1"
166
+ _qq_instant_frame "${2:-}" "${3:-}"
167
+ if [[ -z "$REPLY" ]]; then
41
168
  return 0
42
169
  fi
170
+ {
171
+ print -rn -- "${REPLY}"$'\e[H' > "$dir/$QQ_INSTANT_FRAME_FILE"
172
+ } 2>/dev/null || {
173
+ rm -f -- "$dir/$QQ_INSTANT_FRAME_FILE" 2>/dev/null || true
174
+ }
175
+ return 0
176
+ }
43
177
 
44
- local cli_path="$QQ_DEV_ROOT/dist/cli/main.js"
45
- if [[ ! -f "$cli_path" ]]; then
178
+ typeset -g QQ_DAEMON_PREWARMED="${QQ_DAEMON_PREWARMED:-0}"
179
+
180
+ _qq_log() {
181
+ if [[ -z "$QQ_DEBUG_LOG_FILE" ]]; then
46
182
  return 0
47
183
  fi
184
+ local message="$1"
185
+ print -r -- "$(date '+%Y-%m-%dT%H:%M:%S%z') [zsh] $message" >> "$QQ_DEBUG_LOG_FILE" 2>/dev/null
186
+ }
48
187
 
49
- if ! command -v node >/dev/null 2>&1; then
188
+ _qq_prewarm_daemon() {
189
+ if [[ "$QQ_DAEMON_PREWARMED" == 1 ]]; then
50
190
  return 0
51
191
  fi
52
192
 
53
193
  QQ_DAEMON_PREWARMED=1
54
194
  (
55
- command node "$cli_path" daemon --ensure >/dev/null 2>&1
195
+ if [[ -n "$QQ_DEV_ROOT" && -f "$QQ_DEV_ROOT/dist/cli/main.js" ]] && command -v node >/dev/null 2>&1; then
196
+ command node "$QQ_DEV_ROOT/dist/cli/main.js" daemon --ensure
197
+ elif command -v qq >/dev/null 2>&1; then
198
+ command qq daemon --ensure
199
+ fi
56
200
  ) >/dev/null 2>&1 &!
57
201
  }
58
202
  #
@@ -188,10 +332,40 @@ qq-question-widget() {
188
332
 
189
333
  # Second `?` detected — consume trigger and capture buffers
190
334
  _qq_log "trigger fired lbuffer=${LBUFFER} rbuffer=${RBUFFER}"
335
+ QQ_INSTANT_FRAME_PAINTED=0
191
336
  _qq_capture_buffers
192
337
  LBUFFER="$QQ_LBUFFER"
193
338
  RBUFFER="$QQ_RBUFFER"
194
339
 
340
+ # Build the qq invocation.
341
+ # qq may be a shell function (dev mode) or an installed binary. zellij run
342
+ # uses exec — it does not start a shell, so shell functions are invisible.
343
+ # Use node + the script path directly when QQ_DEV_ROOT is set; otherwise
344
+ # fall back to a bare qq (assumes a globally-installed binary).
345
+ local -a qq_cmd
346
+ if [[ -n "${QQ_DEV_ROOT:-}" && -f "${QQ_DEV_ROOT}/dist/cli/main.js" ]]; then
347
+ qq_cmd=("node" "${QQ_DEV_ROOT}/dist/cli/main.js")
348
+ else
349
+ # whence -p searches PATH only, bypassing shell functions — prevents the dev
350
+ # qq() function from shadowing the installed Homebrew binary.
351
+ local _qq_bin
352
+ _qq_bin=$(whence -p qq 2>/dev/null)
353
+ if [[ -z "$_qq_bin" ]]; then
354
+ zle -M "queque: qq not found — run: qq init zsh >> ~/.zshrc && source ~/.zshrc"
355
+ return 0
356
+ fi
357
+ qq_cmd=("$_qq_bin")
358
+ fi
359
+ _qq_log "qq_cmd=${qq_cmd[*]}"
360
+
361
+ # Instant first frame (LD-1): paint the header and loading row now, before
362
+ # mktemp, jq and the tool handoff, so the modal shows something before Node
363
+ # starts. The Zellij pane gets its frame through a file further down.
364
+ if [[ -z "$ZELLIJ" ]]; then
365
+ _qq_paint_inline_instant_frame "$QQ_LBUFFER"
366
+ _qq_log "instant frame painted=${QQ_INSTANT_FRAME_PAINTED}"
367
+ fi
368
+
195
369
  # Create a private session directory (CR-006 fix).
196
370
  # Using mktemp -d + chmod 700 prevents symlink-redirect attacks on temp paths.
197
371
  local tmpdir req_file
@@ -220,27 +394,8 @@ qq-question-widget() {
220
394
  "rbuffer": $(printf '%s' "$QQ_RBUFFER" | jq -Rs .)
221
395
  }
222
396
  JSON
223
-
224
- # Build the qq invocation.
225
- # qq may be a shell function (dev mode) or an installed binary. zellij run
226
- # uses exec — it does not start a shell, so shell functions are invisible.
227
- # Use node + the script path directly when QQ_DEV_ROOT is set; otherwise
228
- # fall back to a bare qq (assumes a globally-installed binary).
229
- local -a qq_cmd
230
- if [[ -n "${QQ_DEV_ROOT:-}" && -f "${QQ_DEV_ROOT}/dist/cli/main.js" ]]; then
231
- qq_cmd=("node" "${QQ_DEV_ROOT}/dist/cli/main.js")
232
- else
233
- # whence -p searches PATH only, bypassing shell functions — prevents the dev
234
- # qq() function from shadowing the installed Homebrew binary.
235
- local _qq_bin
236
- _qq_bin=$(whence -p qq 2>/dev/null)
237
- if [[ -z "$_qq_bin" ]]; then
238
- zle -M "queque: qq not found — run: qq init zsh >> ~/.zshrc && source ~/.zshrc"
239
- return 0
240
- fi
241
- qq_cmd=("$_qq_bin")
242
- fi
243
- _qq_log "qq_cmd=${qq_cmd[*]}"
397
+ _qq_write_tool_handoff "$tmpdir"
398
+ _qq_log "tool handoff written"
244
399
 
245
400
  if [[ -n "$ZELLIJ" ]]; then
246
401
  # ---- Zellij path: FIFO + floating pane ----
@@ -258,10 +413,26 @@ JSON
258
413
  export QQ_PANE_WIDTH="$qq_pane_width"
259
414
  export QQ_PANE_HEIGHT="$qq_pane_height"
260
415
 
416
+ # Instant first frame (LD-1): the frame goes to a file in the 0700 session
417
+ # dir; the pane wrapper cats it, then execs the unchanged client argv. A
418
+ # non-numeric pane width (e.g. a percentage) falls back to the modal width.
419
+ local qq_frame_columns=0
420
+ if [[ "$qq_pane_width" == <1-> ]]; then
421
+ qq_frame_columns=$(( qq_pane_width - QQ_ZELLIJ_FRAME_COLUMNS ))
422
+ fi
423
+ _qq_write_pane_instant_frame "$tmpdir" "$QQ_LBUFFER" "$qq_frame_columns"
424
+ local -a pane_cmd
425
+ if [[ -s "$tmpdir/$QQ_INSTANT_FRAME_FILE" ]]; then
426
+ pane_cmd=(/bin/sh -c "$QQ_PANE_PAINT_SCRIPT" queque-instant-frame "$tmpdir/$QQ_INSTANT_FRAME_FILE" "${qq_cmd[@]}")
427
+ else
428
+ pane_cmd=("${qq_cmd[@]}")
429
+ fi
430
+ _qq_log "pane instant frame=${#pane_cmd[@]}"
431
+
261
432
  # Launch the floating pane backgrounded+disowned (D-06).
262
433
  # Launch at config height; qq client shrinks the pane once content is rendered.
263
434
  zellij run --floating --close-on-exit --width "$qq_pane_width" --height "$qq_pane_height" --name "qq" -- \
264
- "${qq_cmd[@]}" client --request-file "$req_file" --result-file "$fifo_path" \
435
+ "${pane_cmd[@]}" client --request-file "$req_file" --result-file "$fifo_path" \
265
436
  2>>"${QQ_DEBUG_LOG_FILE:-/tmp/qq-${UID}-debug.log}" &!
266
437
 
267
438
  # Block on FIFO read with a 30 s timeout (D-05).
@@ -279,8 +450,16 @@ JSON
279
450
 
280
451
  # Run the client as a foreground child. ZLE yields the terminal so Ink
281
452
  # can open /dev/tty and render the selection UI inline.
453
+ local qq_client_status=0
282
454
  "${qq_cmd[@]}" client --request-file "$req_file" --result-file "$result_file" \
283
- 2>>"${QQ_DEBUG_LOG_FILE:-/tmp/qq-${UID}-debug.log}"
455
+ 2>>"${QQ_DEBUG_LOG_FILE:-/tmp/qq-${UID}-debug.log}" || qq_client_status=$?
456
+
457
+ # A client that crashed (before or after Ink) cannot clear the instant
458
+ # frame: restore the widget's own fresh save (the reserve top) and erase.
459
+ if [[ "$QQ_INSTANT_FRAME_PAINTED" == 1 && "$qq_client_status" != 0 ]]; then
460
+ print -rn -- $'\e8\e[J' 2>/dev/null || true
461
+ _qq_log "instant frame cleared after client status=${qq_client_status}"
462
+ fi
284
463
 
285
464
  _qq_apply_result "$result_file" || true
286
465
  fi