compact-agent 1.32.1 → 1.33.0

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/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { buildCommitPrompt, buildPRPrompt, printDiff, printLog } from './git-wor
24
24
  import { buildReviewPrompt, buildTDDPrompt, buildSecurityReviewPrompt, runAudit, printAuditReport, buildPlanPrompt, buildE2EPrompt, buildBuildFixPrompt, buildEvalPrompt } from './evaluation.js';
25
25
  import { printRules } from './rules.js';
26
26
  import { buildOrchestrationPrompt } from './orchestration.js';
27
- import { printBanner as printThemedBanner, theme, sym, formatDuration, installScreenReaderDispatch, uninstallScreenReaderDispatch, setPalette, getPaletteId, listPalettes, isPaletteId, PALETTES } from './theme.js';
27
+ import { printBanner as printThemedBanner, theme, sym, formatDuration, installScreenReaderDispatch, uninstallScreenReaderDispatch, setPalette, getPaletteId, listPalettes, isPaletteId, PALETTES, expandLastThinking } from './theme.js';
28
28
  import { saveExport } from './export.js';
29
29
  // New feature modules
30
30
  import { buildVerifyPrompt, saveCheckpoint, listCheckpoints } from './verification.js';
@@ -472,7 +472,8 @@ export function handleSlashCommand(input, config, messages, session, mode) {
472
472
  console.log(d(' ') + c('/perm-reset') + d(' — clear the per-tool always-allow list'));
473
473
  console.log(d(' ') + c('/sandbox [level]') + d(' — OS-native bash sandbox (off / standard / strict)'));
474
474
  console.log(d(' ') + c('/dry-run') + d(' — toggle dry-run mode'));
475
- console.log(d(' ') + c('/thinking') + d(' — toggle thinking/reasoning display'));
475
+ console.log(d(' ') + c('/thinking') + d(' — toggle thinking display (live + auto-collapse)'));
476
+ console.log(d(' ') + c('/think') + d(' — re-expand the most recent collapsed thinking'));
476
477
  console.log(d(' ') + c('/cd <path>') + d(' — change directory'));
477
478
  console.log(d(' ') + c('/hooks') + d(' — list configured hooks'));
478
479
  console.log(d(' ') + c('/reset-hooks') + d(' — wipe hooks.json and re-seed ECC hooks for current install'));
@@ -1211,10 +1212,53 @@ export function handleSlashCommand(input, config, messages, session, mode) {
1211
1212
  console.log(chalk.green(` Show thinking: ${thinkingStatus}`));
1212
1213
  if (config.showThinking) {
1213
1214
  console.log(chalk.dim(' Model reasoning/chain-of-thought will be displayed when available.'));
1215
+ console.log(chalk.dim(' Streams live in a │-bordered panel, then collapses to a one-liner.'));
1216
+ console.log(chalk.dim(' Re-expand the most recent thinking block with /think.'));
1214
1217
  console.log(chalk.dim(' Works with DeepSeek, OpenRouter reasoning models, and others.'));
1215
1218
  }
1216
1219
  return { handled: true };
1217
1220
  }
1221
+ case '/think': {
1222
+ // /think (no args) → re-expand the most recent thinking block
1223
+ // (the one that just collapsed to a one-liner footer)
1224
+ // /think on|off → alias for /thinking (toggles show-thinking)
1225
+ //
1226
+ // Mental model: /thinking is the *setting* (display reasoning
1227
+ // at all? yes/no), /think is the *action* (show me that
1228
+ // reasoning again now). Both names appear in the wild — Claude
1229
+ // Code uses /think, other CLIs use /thinking — so we support
1230
+ // both rather than picking a winner.
1231
+ const sub = (args || '').trim().toLowerCase();
1232
+ if (sub === 'on' || sub === 'off') {
1233
+ const wantOn = sub === 'on';
1234
+ if (config.showThinking !== wantOn) {
1235
+ config.showThinking = wantOn;
1236
+ saveConfig(config);
1237
+ }
1238
+ console.log(chalk.green(` Show thinking: ${wantOn ? chalk.yellow('ON') : chalk.green('OFF')}`));
1239
+ return { handled: true };
1240
+ }
1241
+ if (sub === 'toggle' || sub === '') {
1242
+ // Empty args → expand last thinking. If there is none yet
1243
+ // (no model turn this session has emitted reasoning),
1244
+ // surface a helpful hint instead of silently no-op'ing.
1245
+ const ok = expandLastThinking();
1246
+ if (!ok) {
1247
+ console.log(chalk.dim(' No thinking captured yet this session.'));
1248
+ if (config.showThinking === false) {
1249
+ console.log(chalk.dim(' /thinking is currently OFF — run /thinking to enable.'));
1250
+ }
1251
+ else {
1252
+ console.log(chalk.dim(' The current model may not emit reasoning tokens.'));
1253
+ console.log(chalk.dim(' Try a reasoning model: deepseek-r1, o1-mini, etc.'));
1254
+ }
1255
+ }
1256
+ return { handled: true };
1257
+ }
1258
+ console.log(chalk.dim(' /think — re-expand the most recent thinking'));
1259
+ console.log(chalk.dim(' /think on | off — enable/disable thinking display'));
1260
+ return { handled: true };
1261
+ }
1218
1262
  case '/cd':
1219
1263
  if (args) {
1220
1264
  try {
@@ -2492,11 +2536,17 @@ export function handleSlashCommand(input, config, messages, session, mode) {
2492
2536
  saveConfig(config);
2493
2537
  // Screen-reader mode is special: install/uninstall the stdout filter
2494
2538
  // immediately so the toggle takes effect for the very next log line.
2539
+ // Also flip animations off when SR is on — in-place ANSI repaints
2540
+ // (spinners + collapse transitions) read as a flood of new content
2541
+ // events to NVDA/JAWS and drown out actual response text.
2495
2542
  if (field === 'screenReader') {
2496
2543
  if (v === 'on')
2497
2544
  installScreenReaderDispatch(applyScreenReader);
2498
2545
  else
2499
2546
  uninstallScreenReaderDispatch();
2547
+ void import('./animations.js').then(({ setAnimationConfig }) => {
2548
+ setAnimationConfig({ screenReader: v === 'on' });
2549
+ });
2500
2550
  }
2501
2551
  console.log(chalk.green(` ${label}: ${v.toUpperCase()}`));
2502
2552
  };
@@ -2653,6 +2703,20 @@ async function main() {
2653
2703
  installScreenReaderDispatch(applyScreenReader);
2654
2704
  console.log('[notice] screen-reader mode is ON — ANSI colors are stripped for NVDA/JAWS compatibility. Turn off with: /accessibility screen-reader off');
2655
2705
  }
2706
+ // ── Animation config ─────────────────────────────────────
2707
+ // Wire the global animation flag now that we know the screen-reader
2708
+ // setting. In-place ANSI repaints (used by tool/thinking spinners and
2709
+ // collapse/settle transitions) generate a flood of new content events
2710
+ // for screen readers, so they're force-off in that mode. Sighted
2711
+ // users get them by default; the CROWCODER_ANIMATIONS=0 env var still
2712
+ // overrides for users who specifically don't want the motion.
2713
+ {
2714
+ const { setAnimationConfig } = await import('./animations.js');
2715
+ setAnimationConfig({
2716
+ enabled: process.env.CROWCODER_ANIMATIONS !== '0',
2717
+ screenReader: config.voice?.accessibility?.screenReader === true,
2718
+ });
2719
+ }
2656
2720
  // Create session
2657
2721
  const mode = { current: 'dev' };
2658
2722
  const session = createSession(process.cwd(), config.model, config.provider, mode.current);
@@ -2797,8 +2861,26 @@ async function main() {
2797
2861
  const hotkeyListener = function hotkeyListener(_str, key) {
2798
2862
  if (!key)
2799
2863
  return;
2800
- const name = String(key.name || '').toLowerCase();
2801
- if (!INTERCEPT.has(name))
2864
+ // Node's readline emitter sets `key.name` for named keys (tab,
2865
+ // space, escape, f1-f12, letters, etc.) but leaves it
2866
+ // UNDEFINED for many printable ASCII chars including '/', ',',
2867
+ // and '.'. For those keys only `key.sequence` is reliable.
2868
+ // Look up against both — name preferred, sequence as fallback —
2869
+ // so '/' (initial Node REPL parse delivers no name, just
2870
+ // sequence) and the Alt+,/. handlers actually fire.
2871
+ const name = (key.name || '').toLowerCase();
2872
+ const seq = (key.sequence || '');
2873
+ const lookup = name || seq;
2874
+ if (!INTERCEPT.has(lookup))
2875
+ return;
2876
+ // While the command palette / inline-suggest is open it takes
2877
+ // exclusive control of stdin via its own `data` listener. The
2878
+ // hotkey listener must bail entirely — otherwise Esc would
2879
+ // trigger the rewind chord, Tab/F-keys would print status
2880
+ // overlays, and Space/'/' would try to open a second picker on
2881
+ // top of the first. The data-level handler in the picker sees
2882
+ // the bytes first and finishes its work; we just stand down.
2883
+ if (pickerActive)
2802
2884
  return;
2803
2885
  const shift = !!key.shift;
2804
2886
  const meta = !!key.meta;
@@ -2808,7 +2890,7 @@ async function main() {
2808
2890
  // - bare ',' or '.' is regular typing; only Alt+,/. is ours
2809
2891
  // - bare Tab is completion; only Shift+Tab is ours
2810
2892
  // - Shift+Esc / Ctrl+Esc / Alt+Esc aren't ours
2811
- if ((name === ',' || name === '.') && !meta)
2893
+ if ((lookup === ',' || lookup === '.') && !meta)
2812
2894
  return;
2813
2895
  if (name === 'tab' && !shift)
2814
2896
  return;
@@ -2823,7 +2905,7 @@ async function main() {
2823
2905
  // Slash autocomplete: '/' at empty prompt opens the picker
2824
2906
  // pre-filtered to '/'. Modified variants (Ctrl+/, etc.) are
2825
2907
  // not ours.
2826
- if (name === '/' && (shift || ctrl || meta))
2908
+ if (lookup === '/' && (shift || ctrl || meta))
2827
2909
  return;
2828
2910
  const a = getAccessibilityConfig(config);
2829
2911
  const tts = getTtsConfig(config);
@@ -2848,7 +2930,7 @@ async function main() {
2848
2930
  name === 'f11' || name === 'f12' || shift ||
2849
2931
  // Productivity bindings (Shift+Tab, Esc, Alt+,/.) work regardless
2850
2932
  // of voice state — they touch config / readline, not audio.
2851
- name === 'tab' || name === 'escape' || name === ',' || name === '.';
2933
+ name === 'tab' || name === 'escape' || lookup === ',' || lookup === '.' || lookup === '/';
2852
2934
  // F5–F10 (bare) are DICTATION/PLAYBACK hotkeys — they only make
2853
2935
  // sense when voice features are enabled. Bail early to avoid
2854
2936
  // spurious ffmpeg spawns and "TTS not configured" log lines.
@@ -2997,15 +3079,26 @@ async function main() {
2997
3079
  // Any other shifted F-key: no-op (don't fall through to bare).
2998
3080
  return;
2999
3081
  }
3000
- // ── Space (bare): command palette at empty prompt ──
3001
- // Pressing Space when the input buffer is empty opens the
3002
- // command palette — an alt-screen picker showing every slash
3003
- // command, arrow-key navigable, type-to-filter, Enter to run.
3004
- // When the buffer has content (the user is typing a real
3005
- // message that begins with a space-separated word) the keypress
3006
- // listener stays out of the way and lets readline handle the
3007
- // space normally.
3008
- if (name === 'space' || name === '/') {
3082
+ // ── Space (bare) / '/' (bare): command palette at empty prompt ──
3083
+ // Two distinct UX shapes that share the same trigger guards:
3084
+ //
3085
+ // Space → full-screen browse picker (alt-screen takeover).
3086
+ // User explicitly asked to browse every command, so
3087
+ // a big sortable list with descriptions, category
3088
+ // hints, and a footer is exactly what they want.
3089
+ //
3090
+ // '/' inline dropdown rendered directly below the
3091
+ // prompt (no alt-screen). The user is in the middle
3092
+ // of typing a command — they need to KEEP seeing
3093
+ // their chat history and the prompt context, not
3094
+ // have a full-screen widget yanked over them. The
3095
+ // dropdown narrows as they type and disappears on
3096
+ // Esc or Backspace-to-empty.
3097
+ //
3098
+ // Both branches share the trigger guards (no buffer content,
3099
+ // no active stream) and the pickerActive interlock that
3100
+ // prevents stacking two pickers.
3101
+ if (name === 'space' || lookup === '/') {
3009
3102
  if (pickerActive)
3010
3103
  return;
3011
3104
  const buf = rl.line ?? '';
@@ -3018,7 +3111,7 @@ async function main() {
3018
3111
  // pass through.
3019
3112
  if (name === 'space' && buf.length > 0)
3020
3113
  return;
3021
- if (name === '/' && buf !== '' && buf !== '/')
3114
+ if (lookup === '/' && buf !== '' && buf !== '/')
3022
3115
  return;
3023
3116
  // Mid-stream is suppressed by the input guard already;
3024
3117
  // this listener still fires but we shouldn't open a picker
@@ -3026,14 +3119,15 @@ async function main() {
3026
3119
  const turnCtl = globalThis.__turnAbortCtl;
3027
3120
  if (turnCtl && !turnCtl.signal.aborted)
3028
3121
  return;
3029
- const triggerChar = name === '/' ? '/' : ' ';
3030
- // Open the palette. The picker is async and takes stdin into
3031
- // raw mode for its lifetime we fire-and-forget here.
3122
+ const isSlash = lookup === '/';
3123
+ // Take the interlock. The async branch sets pickerActive=false
3124
+ // in a finally so any error path still releases it.
3032
3125
  pickerActive = true;
3033
- // The trigger character is already in readline's buffer at
3034
- // this point (the keypress listener is an observer, not a
3035
- // gate). Clear it so the prompt is clean once the picker
3036
- // exits.
3126
+ // Clear the trigger char from readline's buffer so the prompt
3127
+ // is clean. For '/' we'll re-render it ourselves at the
3128
+ // inline-suggest anchor; for Space we don't need any
3129
+ // character on the prompt because the alt-screen picker
3130
+ // covers everything.
3037
3131
  try {
3038
3132
  const rlAny = rl;
3039
3133
  rlAny.line = '';
@@ -3042,50 +3136,81 @@ async function main() {
3042
3136
  catch { /* noop */ }
3043
3137
  void (async () => {
3044
3138
  try {
3045
- const { pick } = await import('./picker.js');
3046
3139
  const { COMMAND_CATALOG } = await import('./command-palette.js');
3047
- const items = COMMAND_CATALOG.map((c) => ({
3048
- label: c.command,
3049
- hint: c.category,
3050
- description: c.description,
3051
- value: c.command,
3052
- }));
3053
- const selected = await pick(items, {
3054
- title: 'compact-agent · command palette',
3055
- footer: 'type to filter · ↑↓ to navigate · Enter to run · Esc to cancel',
3056
- // '/' trigger: pre-fill filter so the user's mental
3057
- // model ("I typed / and now I see slash commands") is
3058
- // preserved. Space trigger: blank filter, browse all.
3059
- initialFilter: name === '/' ? '/' : undefined,
3060
- });
3061
- if (selected) {
3062
- globalThis.__crowcoderQueuedInput = selected + '\n';
3063
- try {
3064
- rl.emit('line', '');
3140
+ if (isSlash) {
3141
+ // '/' → inline dropdown below the prompt.
3142
+ const { inlineSuggest } = await import('./inline-suggest.js');
3143
+ const result = await inlineSuggest(rl, COMMAND_CATALOG.map((c) => ({
3144
+ command: c.command,
3145
+ description: c.description,
3146
+ })), '/');
3147
+ if (result.accepted && result.command) {
3148
+ // Trailing space "fill but don't submit" (Tab
3149
+ // pathway): plant the command in rl.line so the
3150
+ // user can type args before Enter.
3151
+ if (result.command.endsWith(' ')) {
3152
+ const cmd = result.command;
3153
+ try {
3154
+ const rlAny = rl;
3155
+ rlAny.line = cmd;
3156
+ rlAny.cursor = cmd.length;
3157
+ rlAny._refreshLine?.();
3158
+ }
3159
+ catch { /* noop */ }
3160
+ }
3161
+ else {
3162
+ // Enter → submit immediately via the queued-input
3163
+ // sentinel pattern (mirrors how the space picker
3164
+ // submits).
3165
+ globalThis.__crowcoderQueuedInput = result.command + '\n';
3166
+ try {
3167
+ rl.emit('line', '');
3168
+ }
3169
+ catch { /* noop */ }
3170
+ }
3065
3171
  }
3066
- catch { /* noop */ }
3067
- }
3068
- else if (triggerChar === '/') {
3069
- // Cancel from '/'-triggered picker: restore the '/'
3070
- // to the buffer so the user can keep typing the
3071
- // command manually (e.g. they wanted /model claude-
3072
- // sonnet-4 directly, not the picker). Don't resolve
3073
- // rl.question leave readline waiting for input.
3074
- try {
3075
- const rlAny = rl;
3076
- rlAny.line = '/';
3077
- rlAny._refreshLine?.();
3172
+ else {
3173
+ // Cancelled. Restore rl.line to whatever the user
3174
+ // had typed so they can keep editing (could be '/',
3175
+ // '/he', or '' if they backspaced all the way out).
3176
+ try {
3177
+ const rlAny = rl;
3178
+ rlAny.line = result.filter;
3179
+ rlAny.cursor = result.filter.length;
3180
+ rlAny._refreshLine?.();
3181
+ }
3182
+ catch { /* noop */ }
3078
3183
  }
3079
- catch { /* noop */ }
3080
3184
  }
3081
3185
  else {
3082
- // Cancel from space-triggered picker: prompt is clean,
3083
- // resolve readline with empty so the loop iterates
3084
- // back to a fresh prompt.
3085
- try {
3086
- rl.emit('line', '');
3186
+ // Space full-screen browse picker (unchanged).
3187
+ const { pick } = await import('./picker.js');
3188
+ const items = COMMAND_CATALOG.map((c) => ({
3189
+ label: c.command,
3190
+ hint: c.category,
3191
+ description: c.description,
3192
+ value: c.command,
3193
+ }));
3194
+ const selected = await pick(items, {
3195
+ title: 'compact-agent · command palette',
3196
+ footer: 'type to filter · ↑↓ to navigate · Enter to run · Esc to cancel',
3197
+ });
3198
+ if (selected) {
3199
+ globalThis.__crowcoderQueuedInput = selected + '\n';
3200
+ try {
3201
+ rl.emit('line', '');
3202
+ }
3203
+ catch { /* noop */ }
3204
+ }
3205
+ else {
3206
+ // Cancel from space-triggered picker: prompt is
3207
+ // clean, resolve readline with empty so the loop
3208
+ // iterates back to a fresh prompt.
3209
+ try {
3210
+ rl.emit('line', '');
3211
+ }
3212
+ catch { /* noop */ }
3087
3213
  }
3088
- catch { /* noop */ }
3089
3214
  }
3090
3215
  }
3091
3216
  finally {
@@ -3165,13 +3290,13 @@ async function main() {
3165
3290
  // deterministic; higher = more creative. Step ± 0.1, clamped
3166
3291
  // to [0.0, 2.0]. Saved immediately so the next API call uses
3167
3292
  // the new value. Persisted so the setting survives restarts.
3168
- if ((name === ',' || name === '.') && meta) {
3293
+ if ((lookup === ',' || lookup === '.') && meta) {
3169
3294
  const cur = typeof config.temperature === 'number' ? config.temperature : 0.3;
3170
- const step = name === ',' ? -0.1 : +0.1;
3295
+ const step = lookup === ',' ? -0.1 : +0.1;
3171
3296
  const next = Math.max(0, Math.min(2.0, Math.round((cur + step) * 100) / 100));
3172
3297
  config.temperature = next;
3173
3298
  saveConfig(config);
3174
- const label = name === ',' ? 'Alt+,' : 'Alt+.';
3299
+ const label = lookup === ',' ? 'Alt+,' : 'Alt+.';
3175
3300
  announce(label, `Temperature ${next.toFixed(2)} (lower = more careful, higher = more creative).`);
3176
3301
  return;
3177
3302
  }