openclacky 1.5.13 → 1.5.15

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -0
  3. data/lib/clacky/agent/chunk_index.rb +83 -0
  4. data/lib/clacky/agent/history_navigation.rb +239 -0
  5. data/lib/clacky/agent/session_serializer.rb +53 -98
  6. data/lib/clacky/agent.rb +477 -297
  7. data/lib/clacky/agent_config.rb +5 -3
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +7 -0
  10. data/lib/clacky/brand_config.rb +27 -19
  11. data/lib/clacky/cli.rb +92 -12
  12. data/lib/clacky/cli_guidance.rb +48 -0
  13. data/lib/clacky/client.rb +2 -2
  14. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  15. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -1
  16. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +307 -98
  17. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  18. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  19. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  20. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  21. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  22. data/lib/clacky/extension/api_extension.rb +18 -6
  23. data/lib/clacky/extension/verifier.rb +1 -1
  24. data/lib/clacky/json_ui_controller.rb +4 -2
  25. data/lib/clacky/media/openai_compat.rb +70 -30
  26. data/lib/clacky/message_format/bedrock.rb +6 -1
  27. data/lib/clacky/message_format/open_ai.rb +22 -5
  28. data/lib/clacky/message_format/open_ai_responses.rb +7 -3
  29. data/lib/clacky/plain_ui_controller.rb +1 -1
  30. data/lib/clacky/prompts/base.md +1 -1
  31. data/lib/clacky/providers.rb +44 -13
  32. data/lib/clacky/rich_ui/components/composer_guidance.rb +49 -0
  33. data/lib/clacky/rich_ui/rich_ui_controller.rb +24 -4
  34. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +13 -0
  35. data/lib/clacky/search_config.rb +3 -3
  36. data/lib/clacky/server/channel/channel_manager.rb +25 -7
  37. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  38. data/lib/clacky/server/dir_picker.rb +67 -4
  39. data/lib/clacky/server/git_panel.rb +10 -2
  40. data/lib/clacky/server/http_server.rb +391 -75
  41. data/lib/clacky/server/preview.rb +351 -0
  42. data/lib/clacky/server/session_registry.rb +4 -0
  43. data/lib/clacky/server/web_ui_controller.rb +10 -5
  44. data/lib/clacky/session_manager.rb +52 -1
  45. data/lib/clacky/skill.rb +48 -0
  46. data/lib/clacky/tools/browser.rb +176 -19
  47. data/lib/clacky/tools/web_search.rb +91 -8
  48. data/lib/clacky/ui2/components/command_suggestions.rb +3 -1
  49. data/lib/clacky/ui2/components/input_area.rb +37 -4
  50. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  51. data/lib/clacky/ui2/screen_buffer.rb +1 -0
  52. data/lib/clacky/ui2/ui_controller.rb +72 -12
  53. data/lib/clacky/ui_interface.rb +1 -1
  54. data/lib/clacky/utils/file_processor.rb +19 -7
  55. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  56. data/lib/clacky/utils/model_pricing.rb +118 -68
  57. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  58. data/lib/clacky/version.rb +1 -1
  59. data/lib/clacky/web/app.css +998 -168
  60. data/lib/clacky/web/app.js +19 -0
  61. data/lib/clacky/web/components/chat-navigator.js +489 -202
  62. data/lib/clacky/web/components/code-editor.js +191 -5
  63. data/lib/clacky/web/components/composer.js +3 -1
  64. data/lib/clacky/web/components/datepicker.js +19 -19
  65. data/lib/clacky/web/components/mentions.js +11 -14
  66. data/lib/clacky/web/components/model-picker.js +66 -23
  67. data/lib/clacky/web/components/quote-select.js +341 -0
  68. data/lib/clacky/web/core/aside.js +157 -8
  69. data/lib/clacky/web/core/ext.js +16 -0
  70. data/lib/clacky/web/features/billing/view.js +75 -21
  71. data/lib/clacky/web/features/extensions/store.js +4 -1
  72. data/lib/clacky/web/features/model-tester/store.js +37 -4
  73. data/lib/clacky/web/features/skills/store.js +18 -1
  74. data/lib/clacky/web/features/skills/view.js +128 -7
  75. data/lib/clacky/web/features/workspace/store.js +80 -7
  76. data/lib/clacky/web/features/workspace/view.js +474 -43
  77. data/lib/clacky/web/i18n.js +158 -23
  78. data/lib/clacky/web/index.html +57 -29
  79. data/lib/clacky/web/sessions.js +517 -127
  80. data/lib/clacky/web/settings.js +155 -6
  81. data/lib/clacky/web/utils.js +34 -0
  82. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  83. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  84. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  85. data/lib/clacky/web/ws-dispatcher.js +161 -4
  86. data/lib/clacky.rb +2 -0
  87. metadata +13 -1
@@ -34,7 +34,9 @@ module Clacky
34
34
  Actions: open | navigate | snapshot | act | screenshot | tabs | focus | close | status
35
35
  Workflow: open → snapshot(interactive:true) → act(ref=...). New tab from `open` is auto-selected; only use `focus` to switch back to a previously-opened tab.
36
36
  snapshot: returns hierarchical a11y tree truncated to ~8KB. Use query="text" to seek, or offset=N to page.
37
- act kinds: click | dblclick | type | fill | press | hover | scroll | drag | select | wait | evaluate | click_at
37
+ act kinds: click | dblclick | type | fill | fill_form | press | hover | scroll | drag | select | wait | evaluate | click_at
38
+ act auto-waits for network/DOM to settle and returns the post-action page state, so do NOT follow an act with a snapshot — read the result instead. Pass include_snapshot:false to skip it on trivial acts.
39
+ fill_form: ALWAYS prefer this over several individual fill/click calls on a form — one call, fewer turns. fill/type accept submit_key:"Enter" to submit in the same call.
38
40
  evaluate: `js` is a JS expression, e.g. "document.title". For multi-line / async logic use an IIFE: "(async () => { const r = await fetch(...); return r.status })()". Result is JSON-encoded.
39
41
  screenshot: expensive — pass `ref` to capture one element instead of the whole page.
40
42
  DESC
@@ -48,11 +50,24 @@ module Clacky
48
50
  },
49
51
  kind: {
50
52
  type: "string",
51
- enum: %w[click dblclick type fill press hover drag select scroll wait evaluate click_at],
53
+ enum: %w[click dblclick type fill fill_form press hover drag select scroll wait evaluate click_at],
52
54
  description: "act: interaction kind"
53
55
  },
54
56
  ref: { type: "string", description: "element ref from snapshot (e.g. 'e1')" },
55
57
  text: { type: "string", description: "act type/fill text" },
58
+ submit_key: { type: "string", description: "act fill/type: key to press after filling (e.g. 'Enter')" },
59
+ elements: {
60
+ type: "array",
61
+ description: "act fill_form: [{ref, value}, ...] — fill a whole form in one call",
62
+ items: {
63
+ type: "object",
64
+ properties: {
65
+ ref: { type: "string", description: "element ref from snapshot" },
66
+ value: { type: "string", description: "value; 'true'/'false' for checkboxes and toggles" }
67
+ },
68
+ required: ["ref", "value"]
69
+ }
70
+ },
56
71
  key: { type: "string", description: "act press key (e.g. 'Enter')" },
57
72
  direction: { type: "string", enum: %w[up down left right], description: "act scroll" },
58
73
  amount: { type: "integer", description: "act scroll pixels (default 300)" },
@@ -70,7 +85,11 @@ module Clacky
70
85
  depth: { type: "integer", description: "snapshot: max tree depth" },
71
86
  query: { type: "string", description: "snapshot: return window around first match" },
72
87
  offset: { type: "integer", description: "snapshot: skip N lines (paging)" },
73
- full_page: { type: "boolean", description: "screenshot: full page" }
88
+ full_page: { type: "boolean", description: "screenshot: full page" },
89
+ include_snapshot: {
90
+ type: "boolean",
91
+ description: "act: return the post-action page snapshot (default true). Set false for trivial acts."
92
+ }
74
93
  },
75
94
  required: ["action"]
76
95
  }
@@ -90,6 +109,14 @@ module Clacky
90
109
  "Target closed",
91
110
  "page is detached"
92
111
  ].freeze
112
+ # chrome-devtools-mcp resolves refs against the last snapshot it took for a
113
+ # page, so a page that has never been snapshotted rejects every ref call.
114
+ MISSING_SNAPSHOT_ERROR = "No snapshot found for page"
115
+ # Raised when a ref exists in no/older snapshot — the AI needs fresh refs.
116
+ STALE_REF_ERRORS = [
117
+ "not found on page",
118
+ "no longer exists on the page"
119
+ ].freeze
93
120
 
94
121
  def execute(action:, profile: nil, working_dir: nil, **opts)
95
122
  bypass = action.to_s == "status" ||
@@ -103,6 +130,23 @@ module Clacky
103
130
  { error: classify_browser_error(e) }
104
131
  end
105
132
 
133
+ # Hidden once the user turns the browser off, so the model never sees an
134
+ # action it would only get rejected for. An unconfigured browser stays
135
+ # visible — that path guides the user into browser-setup.
136
+ #
137
+ # Checked once at registration: the tools array carries the prompt-cache
138
+ # breakpoint, so it must stay byte-identical for the whole process.
139
+ # Toggling the browser takes effect on the next restart.
140
+ def self.available?
141
+ return true unless File.exist?(BROWSER_CONFIG_PATH)
142
+
143
+ config = YAMLCompat.safe_load(File.read(BROWSER_CONFIG_PATH),
144
+ permitted_classes: [Date, Time, Symbol])
145
+ config.is_a?(Hash) && config["enabled"] == true
146
+ rescue StandardError
147
+ true
148
+ end
149
+
106
150
  def format_call(args)
107
151
  action = args[:action] || args["action"] || "browser"
108
152
  "browser(#{action})"
@@ -142,13 +186,14 @@ module Clacky
142
186
  end
143
187
 
144
188
  output = result[:output].to_s
145
- output = compress_snapshot(output) if action == "snapshot"
146
- max_chars = action == "snapshot" ? MAX_SNAPSHOT_CHARS : MAX_LLM_OUTPUT_CHARS
189
+ has_snapshot = action == "snapshot" || result[:snapshot_included]
190
+ output = compress_snapshot(output) if has_snapshot
191
+ max_chars = has_snapshot ? MAX_SNAPSHOT_CHARS : MAX_LLM_OUTPUT_CHARS
147
192
 
148
193
  {
149
194
  action: action,
150
195
  success: result[:success],
151
- stdout: truncate_output(output, max_chars),
196
+ stdout: truncate_output(output, max_chars, paged: action == "snapshot"),
152
197
  profile: result[:profile]
153
198
  }.compact
154
199
  end
@@ -302,6 +347,7 @@ module Clacky
302
347
  private def do_user_act(opts)
303
348
  kind = (opts[:kind] || opts["kind"] || "click").to_s
304
349
  ref = opts[:ref] || opts["ref"]
350
+ snap = include_snapshot?(opts)
305
351
 
306
352
  # Page-scoped MCP calls all benefit from an explicit pageId. We pass it
307
353
  # transparently via with_page so the AI never has to think about it.
@@ -311,31 +357,48 @@ module Clacky
311
357
  return uid if uid.is_a?(Hash)
312
358
  args = { uid: uid }
313
359
  args[:dblClick] = true if kind == "dblclick"
314
- mcp_call("click", with_page(args))
360
+ return act_result(kind, mcp_call("click", with_page(with_snapshot(args, snap))), opts, snap)
315
361
 
316
362
  when "fill", "type"
317
363
  uid = require_ref(ref)
318
364
  return uid if uid.is_a?(Hash)
319
- mcp_call("fill", with_page({ uid: uid, value: opts[:text] || opts["text"] || "" }))
365
+ submit = opts[:submit_key] || opts["submit_key"]
366
+ fill_args = { uid: uid, value: (opts[:text] || opts["text"] || "").to_s }
367
+ if submit && !submit.to_s.empty?
368
+ mcp_call("fill", with_page(fill_args))
369
+ result = mcp_call("press_key", with_page(with_snapshot({ key: submit.to_s }, snap)))
370
+ else
371
+ result = mcp_call("fill", with_page(with_snapshot(fill_args, snap)))
372
+ end
373
+ return act_result(kind, result, opts, snap)
374
+
375
+ when "fill_form"
376
+ elements = normalize_form_elements(opts[:elements] || opts["elements"])
377
+ return elements if elements.is_a?(Hash)
378
+ result = mcp_call("fill_form", with_page(with_snapshot({ elements: elements }, snap)))
379
+ return act_result(kind, result, opts, snap)
320
380
 
321
381
  when "press"
322
- mcp_call("press_key", with_page({ key: opts[:key] || opts["key"] || "Enter" }))
382
+ args = { key: (opts[:key] || opts["key"] || "Enter").to_s }
383
+ return act_result(kind, mcp_call("press_key", with_page(with_snapshot(args, snap))), opts, snap)
323
384
 
324
385
  when "hover"
325
386
  uid = require_ref(ref)
326
387
  return uid if uid.is_a?(Hash)
327
- mcp_call("hover", with_page({ uid: uid }))
388
+ return act_result(kind, mcp_call("hover", with_page(with_snapshot({ uid: uid }, snap))), opts, snap)
328
389
 
329
390
  when "drag"
330
391
  uid = require_ref(ref)
331
392
  return uid if uid.is_a?(Hash)
332
- mcp_call("drag", with_page({ from_uid: uid, to_uid: opts[:target_ref] || opts["target_ref"] || "" }))
393
+ args = { from_uid: uid, to_uid: (opts[:target_ref] || opts["target_ref"] || "").to_s }
394
+ return act_result(kind, mcp_call("drag", with_page(with_snapshot(args, snap))), opts, snap)
333
395
 
334
396
  when "select"
335
397
  uid = require_ref(ref)
336
398
  return uid if uid.is_a?(Hash)
337
399
  values = Array(opts[:values] || opts["values"] || [])
338
- mcp_call("fill", with_page({ uid: uid, value: values.first.to_s }))
400
+ args = { uid: uid, value: values.first.to_s }
401
+ return act_result(kind, mcp_call("fill", with_page(with_snapshot(args, snap))), opts, snap)
339
402
 
340
403
  when "scroll"
341
404
  direction = opts[:direction] || opts["direction"] || "down"
@@ -352,7 +415,8 @@ module Clacky
352
415
  sleep(ms.to_i / 1000.0)
353
416
  return { action: "act", success: true, profile: "user", output: "Waited #{ms}ms" }
354
417
  elsif sel
355
- mcp_call("wait_for", with_page({ text: [sel] }))
418
+ # wait_for always attaches a snapshot server-side.
419
+ return act_result(kind, mcp_call("wait_for", with_page({ text: [sel] })), opts, true)
356
420
  else
357
421
  sleep(1)
358
422
  end
@@ -367,8 +431,8 @@ module Clacky
367
431
  x = opts[:x] || opts["x"]
368
432
  y = opts[:y] || opts["y"]
369
433
  return { error: "click_at requires x and y coordinates" } unless x && y
370
- result = mcp_call("click_at", with_page({ x: x.to_f, y: y.to_f }))
371
- return { action: "act", success: true, profile: "user", output: extract_message(result).to_s }
434
+ args = { x: x.to_f, y: y.to_f }
435
+ return act_result(kind, mcp_call("click_at", with_page(with_snapshot(args, snap))), opts, snap)
372
436
 
373
437
  else
374
438
  return { error: "Unknown act kind: #{kind}" }
@@ -377,6 +441,72 @@ module Clacky
377
441
  { action: "act", success: true, profile: "user", output: "#{kind} completed." }
378
442
  end
379
443
 
444
+ # act defaults to returning the post-action snapshot: chrome-devtools-mcp
445
+ # renders it in the same response, so a follow-up snapshot call is waste.
446
+ private def include_snapshot?(opts)
447
+ val = opts.key?(:include_snapshot) ? opts[:include_snapshot] : opts["include_snapshot"]
448
+ return true if val.nil?
449
+ !(val == false || val.to_s == "false")
450
+ end
451
+
452
+ private def with_snapshot(args, enabled)
453
+ enabled ? args.merge(includeSnapshot: true) : args
454
+ end
455
+
456
+ private def normalize_form_elements(raw)
457
+ list = Array(raw)
458
+ return { error: "fill_form requires a non-empty `elements` array of {ref, value}" } if list.empty?
459
+
460
+ normalized = list.map do |entry|
461
+ next nil unless entry.is_a?(Hash)
462
+ uid = entry[:ref] || entry["ref"] || entry[:uid] || entry["uid"]
463
+ value = entry[:value] || entry["value"] || entry[:text] || entry["text"]
464
+ next nil if uid.nil? || uid.to_s.empty?
465
+ { uid: uid.to_s, value: value.to_s }
466
+ end.compact
467
+
468
+ return { error: "fill_form `elements` entries must each have a `ref`" } if normalized.empty?
469
+ normalized
470
+ end
471
+
472
+ # Render an act response: the MCP status line plus the post-action
473
+ # snapshot when one was attached.
474
+ private def act_result(kind, result, opts, snapshot_requested)
475
+ message = extract_act_message(result).strip
476
+ message = "#{kind} completed." if message.empty?
477
+
478
+ parts = [message]
479
+ included = false
480
+
481
+ if snapshot_requested
482
+ node = extract_snapshot(result)
483
+ if node.is_a?(Hash) && !node.empty?
484
+ text = build_ai_snapshot(node,
485
+ interactive: opts[:interactive] || opts["interactive"],
486
+ compact: opts[:compact] || opts["compact"],
487
+ max_depth: opts[:depth] || opts["depth"])
488
+ unless text.empty?
489
+ parts << "## Page snapshot after #{kind}\n#{text}"
490
+ included = true
491
+ end
492
+ end
493
+ end
494
+
495
+ { action: "act", success: true, profile: "user",
496
+ output: parts.join("\n\n"), snapshot_included: included }
497
+ end
498
+
499
+ # Like extract_message, but drops the MCP-rendered snapshot block from the
500
+ # text fallback so it is not duplicated alongside our own rendering.
501
+ private def extract_act_message(result)
502
+ return "" unless result.is_a?(Hash)
503
+
504
+ structured = result["structuredContent"]
505
+ return structured["message"].to_s if structured.is_a?(Hash) && structured["message"]
506
+
507
+ extract_text_content(result).split("## Latest page snapshot").first.to_s
508
+ end
509
+
380
510
  # Merge pageId into MCP args when we know the current page. Safe no-op
381
511
  # if the cache is empty — the MCP server then falls back to its own
382
512
  # selected-page state, matching the old behaviour.
@@ -460,11 +590,19 @@ module Clacky
460
590
  # -----------------------------------------------------------------------
461
591
 
462
592
  # Delegate to BrowserManager. Auto-retries once on transient page-context errors
463
- # (closed/detached selected page, or "no active page" right after open).
593
+ # (closed/detached selected page, or "no active page" right after open) and on
594
+ # a missing page snapshot (fresh tab that ref-based calls cannot resolve yet).
464
595
  private def mcp_call(tool_name, arguments = {})
465
596
  Clacky::BrowserManager.instance.mcp_call(tool_name, arguments)
466
597
  rescue RuntimeError => e
467
598
  msg = e.message.to_s
599
+
600
+ if msg.include?(MISSING_SNAPSHOT_ERROR)
601
+ # Prime the page snapshot the MCP server resolves refs against, then retry once.
602
+ return Clacky::BrowserManager.instance.mcp_call(tool_name, arguments) if prime_page_snapshot
603
+ raise RuntimeError, "The page has no snapshot yet. Use action=snapshot first, then retry."
604
+ end
605
+
468
606
  if RETRYABLE_PAGE_ERRORS.any? { |frag| msg.include?(frag) }
469
607
  # Try to recover by re-selecting the most recent page, then retry once.
470
608
  recovered = recover_selected_page
@@ -474,9 +612,23 @@ module Clacky
474
612
  end
475
613
  raise RuntimeError, "The browser tab is no longer available. Use action=open to open a new tab, then retry."
476
614
  end
615
+
616
+ if STALE_REF_ERRORS.any? { |frag| msg.include?(frag) }
617
+ raise RuntimeError, "#{msg} The page changed — use action=snapshot to get fresh refs, then retry."
618
+ end
619
+
477
620
  raise
478
621
  end
479
622
 
623
+ # Take a snapshot purely so the MCP server has refs to resolve against.
624
+ # Returns true when the page now has one.
625
+ private def prime_page_snapshot
626
+ Clacky::BrowserManager.instance.mcp_call("take_snapshot", with_page({}))
627
+ true
628
+ rescue StandardError
629
+ false
630
+ end
631
+
480
632
  # Pick the currently selected page, or fall back to the most recent one,
481
633
  # and re-issue select_page so subsequent calls have a valid context.
482
634
  # Returns the chosen pageId, or nil if there are no pages at all.
@@ -762,7 +914,7 @@ module Clacky
762
914
  collapsed.join
763
915
  end
764
916
 
765
- private def truncate_output(output, max_chars)
917
+ private def truncate_output(output, max_chars, paged: true)
766
918
  return output if output.length <= max_chars
767
919
 
768
920
  lines = output.lines
@@ -774,8 +926,13 @@ module Clacky
774
926
  first_part << line
775
927
  acc += line.length
776
928
  end
777
- hint = "\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
778
- "rerun with query=\"keyword\" or offset=#{first_part.size} to see more] ...\n"
929
+ hint = if paged
930
+ "\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
931
+ "rerun with query=\"keyword\" or offset=#{first_part.size} to see more] ...\n"
932
+ else
933
+ "\n... [truncated: #{first_part.size}/#{lines.size} lines shown — " \
934
+ "use action=snapshot with query=\"keyword\" if you need the rest] ...\n"
935
+ end
779
936
  first_part.join + hint
780
937
  end
781
938
  end
@@ -30,10 +30,14 @@ module Clacky
30
30
  }
31
31
 
32
32
  # Ordered list of search providers to try in sequence.
33
- # cn.bing.com is accessible in mainland China without VPN.
34
- PROVIDERS = %i[duckduckgo bing].freeze
33
+ # Parallel is the primary no-key provider; the HTML scrapers remain as
34
+ # fallbacks, with cn.bing.com accessible in mainland China without VPN.
35
+ PROVIDERS = %i[parallel duckduckgo bing].freeze
35
36
 
36
37
  SEARCHER_TIMEOUT = 30
38
+ PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp"
39
+ PARALLEL_SNIPPET_MAX_CHARS = 400
40
+ PROVIDER_COOLDOWN_SECONDS = 600
37
41
 
38
42
  def execute(query:, max_results: 10, working_dir: nil)
39
43
  if query.nil? || query.strip.empty?
@@ -57,8 +61,13 @@ module Clacky
57
61
  error: nil
58
62
  }
59
63
  rescue StandardError => e
60
- # DuckDuckGo failed — suppress it for 10 minutes
61
- @ddg_unavailable_until = Time.now + 600 if provider == :duckduckgo
64
+ # Avoid paying the same network/handshake failure on every search.
65
+ if provider == :parallel
66
+ reset_parallel_client
67
+ @parallel_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
68
+ elsif provider == :duckduckgo
69
+ @ddg_unavailable_until = Time.now + PROVIDER_COOLDOWN_SECONDS
70
+ end
62
71
  last_error = e
63
72
  next
64
73
  end
@@ -93,11 +102,73 @@ module Clacky
93
102
  # broken key or script look like a low-quality result set.
94
103
  return [:custom] if Clacky::SearchConfig.script_path
95
104
 
96
- if @ddg_unavailable_until && Time.now < @ddg_unavailable_until
97
- PROVIDERS.drop(1)
98
- else
99
- PROVIDERS
105
+ PROVIDERS.reject do |provider|
106
+ unavailable_until = case provider
107
+ when :parallel then @parallel_unavailable_until
108
+ when :duckduckgo then @ddg_unavailable_until
109
+ end
110
+ unavailable_until && Time.now < unavailable_until
111
+ end
112
+ end
113
+
114
+ # ── Parallel Free MCP ──────────────────────────────────────────────────
115
+
116
+ private def search_parallel(query, max_results)
117
+ result = parallel_client.call_tool(
118
+ "web_search",
119
+ {
120
+ "objective" => query[0, 5000],
121
+ "search_queries" => [query[0, 200]],
122
+ "session_id" => parallel_logical_session_id
123
+ }
124
+ )
125
+
126
+ if result["isError"]
127
+ message = extract_parallel_content_text(result)
128
+ raise(message.empty? ? "Parallel search failed" : message)
100
129
  end
130
+
131
+ payload = result["structuredContent"]
132
+ payload ||= JSON.parse(extract_parallel_content_text(result))
133
+ raise "Parallel returned malformed search results" unless payload.is_a?(Hash) && payload["results"].is_a?(Array)
134
+
135
+ payload["results"].filter_map do |item|
136
+ next unless item.is_a?(Hash)
137
+
138
+ url = item["url"].to_s
139
+ next if url.empty?
140
+
141
+ snippet = Array(item["excerpts"]).join(" ").gsub(/\s+/, " ").strip
142
+ {
143
+ title: item["title"].to_s,
144
+ url: url,
145
+ snippet: snippet[0, PARALLEL_SNIPPET_MAX_CHARS]
146
+ }
147
+ end.first(max_results)
148
+ rescue JSON::ParserError
149
+ raise "Parallel returned malformed JSON"
150
+ end
151
+
152
+ private def parallel_client
153
+ @parallel_client ||= Clacky::Mcp::Client.from_spec(
154
+ "parallel-free",
155
+ { "type" => "http", "url" => PARALLEL_ENDPOINT }
156
+ ).tap(&:start)
157
+ end
158
+
159
+ private def reset_parallel_client
160
+ @parallel_client&.stop
161
+ @parallel_client = nil
162
+ end
163
+
164
+ private def parallel_logical_session_id
165
+ @parallel_logical_session_id ||= SecureRandom.uuid
166
+ end
167
+
168
+ private def extract_parallel_content_text(result)
169
+ Array(result["content"]).filter_map do |item|
170
+ item["text"] if item.is_a?(Hash) && item["type"] == "text"
171
+ end.join("\n")
101
172
  end
102
173
 
103
174
  # ── Configured searcher (~/.clacky/searchers/<provider>.rb) ────────────
@@ -346,6 +417,18 @@ module Clacky
346
417
  "[OK] Found #{count} results#{provider}"
347
418
  end
348
419
  end
420
+
421
+ def ui_result(result)
422
+ {
423
+ type: "web_search",
424
+ query: result[:query],
425
+ count: result[:count],
426
+ provider: result[:provider],
427
+ results: result[:results],
428
+ error: result[:error]
429
+ }
430
+ end
431
+
349
432
  end
350
433
  end
351
434
  end
@@ -14,9 +14,11 @@ module Clacky
14
14
  # System commands available by default
15
15
  SYSTEM_COMMANDS = [
16
16
  { command: "/clear", description: "Clear chat history and restart session" },
17
+ { command: "/input-mode", description: "Input behavior: steer or interrupt" },
17
18
  { command: "/config", description: "Open configuration (models, API keys, settings)" },
18
19
  { command: "/model", description: "Quickly switch the current model" },
19
- { command: "/goal", description: "Set a standing goal for autonomous multi-turn work" },
20
+ { command: "/think", description: "Set the thinking (reasoning) effort level" },
21
+ { command: "/goal", description: "Set a standing goal for autonomous multi-turn work", argument_hint: "<goal text>" },
20
22
  { command: "/undo", description: "Undo the last task and restore previous state" },
21
23
  { command: "/help", description: "Show help information" },
22
24
  { command: "/exit", description: "Exit the chat session" },
@@ -62,6 +62,7 @@ module Clacky
62
62
  working_dir: nil,
63
63
  mode: nil,
64
64
  model: nil,
65
+ reasoning_effort: nil, # nil = provider default; appended to model name when set
65
66
  tasks: 0,
66
67
  cost: 0.0,
67
68
  cost_source: nil, # nil / :api / :price / :default — :default means pricing unknown, show N/A
@@ -89,6 +90,10 @@ module Clacky
89
90
  "#{theme.symbol(:user)} "
90
91
  end
91
92
 
93
+ def guidance_lines
94
+ @guidance_lines_provider&.call || []
95
+ end
96
+
92
97
  def required_height
93
98
  # When paused (InlineInput active), don't take up any space
94
99
  return 0 if @paused
@@ -103,6 +108,7 @@ module Clacky
103
108
 
104
109
  # Images
105
110
  height += @files.size
111
+ height += guidance_lines.size
106
112
 
107
113
  # Calculate height considering wrapped lines
108
114
  # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
@@ -145,15 +151,20 @@ module Clacky
145
151
  # @param working_dir [String] Working directory
146
152
  # @param mode [String] Permission mode
147
153
  # @param model [String] AI model name
154
+ # @param reasoning_effort [String, nil] Thinking level; nil (explicit) appends nothing,
155
+ # :_unset (default) leaves the previous value untouched
148
156
  # @param tasks [Integer] Number of completed tasks
149
157
  # @param cost [Float] Total cost
150
158
  # @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
151
159
  # @param status [String] Workspace status ('idle' or 'working')
152
- def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, cost_source: nil, status: nil)
160
+ attr_accessor :guidance_lines_provider
161
+
162
+ def update_sessionbar(session_id: nil, working_dir: nil, mode: nil, model: nil, reasoning_effort: :_unset, tasks: nil, cost: nil, cost_source: nil, status: nil)
153
163
  @sessionbar_info[:session_id] = session_id if session_id
154
164
  @sessionbar_info[:working_dir] = working_dir if working_dir
155
165
  @sessionbar_info[:mode] = mode if mode
156
166
  @sessionbar_info[:model] = model if model
167
+ @sessionbar_info[:reasoning_effort] = reasoning_effort unless reasoning_effort == :_unset
157
168
  @sessionbar_info[:tasks] = tasks if tasks
158
169
  @sessionbar_info[:cost] = cost if cost
159
170
  @sessionbar_info[:cost_source] = cost_source if cost_source
@@ -184,10 +195,23 @@ module Clacky
184
195
  @command_suggestions.select_next
185
196
  return { action: nil }
186
197
  when :enter
187
- # Accept selected command and submit immediately
198
+ # Accept selected command; submit immediately — unless the
199
+ # command takes arguments (argument_hint), in which case only
200
+ # complete the name so the user can type the arguments first
201
+ # (same behavior as Tab).
188
202
  if @command_suggestions.has_suggestions?
189
203
  selected = @command_suggestions.selected_command_text
190
204
  if selected
205
+ hint = @command_suggestions.selected_argument_hint
206
+ if hint && !hint.empty?
207
+ completed = "#{selected} "
208
+ @lines = [completed]
209
+ @line_index = 0
210
+ @cursor_position = completed.length
211
+ @command_suggestions.hide
212
+ set_tips("Usage: #{selected} #{hint}", type: :info)
213
+ return { action: nil }
214
+ end
191
215
  # Replace current input with selected command
192
216
  @lines = [selected]
193
217
  @line_index = 0
@@ -311,6 +335,12 @@ module Clacky
311
335
  current_row += 1
312
336
  end
313
337
 
338
+ guidance_lines.each do |line|
339
+ move_cursor(current_row, 0)
340
+ print_with_padding(@pastel.dim(line))
341
+ current_row += 1
342
+ end
343
+
314
344
  # Input lines with auto-wrap support
315
345
  current_row = render_input_lines(current_row)
316
346
 
@@ -348,7 +378,7 @@ module Clacky
348
378
 
349
379
  def position_cursor(start_row)
350
380
  # Calculate which wrapped line the cursor is on
351
- cursor_row = start_row + 2 + @files.size # session_bar + separator + images
381
+ cursor_row = start_row + 2 + guidance_lines.size + @files.size # session_bar + separator + images
352
382
  # Use effective content width (respecting MAX_CONTENT_WIDTH_RATIO)
353
383
  content_width = effective_content_width(@width)
354
384
 
@@ -420,7 +450,7 @@ module Clacky
420
450
  # Clear tips from state and screen
421
451
  @tips_message = nil
422
452
  # Tips row: start_row + session_bar(1) + separator(1) + images + lines + separator(1)
423
- tips_row = @last_render_row + 2 + @files.size + @lines.size + 1
453
+ tips_row = @last_render_row + 2 + guidance_lines.size + @files.size + @lines.size + 1
424
454
  move_cursor(tips_row, 0)
425
455
  clear_line
426
456
  flush
@@ -1187,6 +1217,9 @@ module Clacky
1187
1217
  parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
1188
1218
  end
1189
1219
 
1220
+ # Thinking level (nil renders as off = provider default)
1221
+ parts << theme.format_text((@sessionbar_info[:reasoning_effort] || 'off').to_s, :statusbar_secondary)
1222
+
1190
1223
  # Tasks count
1191
1224
  parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
1192
1225
 
@@ -59,8 +59,18 @@ module Clacky
59
59
 
60
60
  # Adjust height based on mode
61
61
  if @mode == :menu
62
- visible_items = [@choices.length, 15].min
63
- @height = visible_items + 4 # +4 for title, borders, and instructions
62
+ # Choices marked sticky: true (e.g. /config action buttons) are
63
+ # always rendered below the scrollable area, so they never scroll
64
+ # out of view no matter how many models precede them.
65
+ @scroll_count = @choices.count { |c| !c[:sticky] }
66
+ @sticky_count = @choices.length - @scroll_count
67
+ @visible_items = [@scroll_count, 15].min
68
+ @height = @visible_items + @sticky_count + 4 # +4 for title, borders, and instructions
69
+ # Scroll window: draw_menu_choices only renders this slice, so a
70
+ # menu with more choices than fit (e.g. /config with many models)
71
+ # scrolls instead of painting over the bottom border.
72
+ @window_start = 0
73
+ clamp_scroll_window
64
74
  else
65
75
  # Form mode - adjust height based on number of fields
66
76
  # Each field takes 2 rows (label + input)
@@ -369,11 +379,17 @@ module Clacky
369
379
  end
370
380
  end
371
381
 
372
- # Draw menu choices
382
+ # Draw menu choices (only the visible scroll window, then sticky tail)
373
383
  private def draw_menu_choices(start_row, start_col)
374
- @choices.each_with_index do |choice, index|
375
- row = start_row + index
376
- draw_menu_choice(choice, index, row, start_col)
384
+ window_end = [@window_start + @visible_items, @scroll_count].min
385
+ (@window_start...window_end).each do |index|
386
+ draw_menu_choice(@choices[index], index, start_row + index - @window_start, start_col)
387
+ end
388
+
389
+ sticky_row = start_row + (window_end - @window_start)
390
+ (@scroll_count...@choices.length).each do |index|
391
+ draw_menu_choice(@choices[index], index, sticky_row, start_col)
392
+ sticky_row += 1
377
393
  end
378
394
  end
379
395
 
@@ -438,6 +454,19 @@ module Clacky
438
454
  # Skip disabled choices
439
455
  break unless @choices[@selected_index][:disabled]
440
456
  end
457
+ clamp_scroll_window
458
+ end
459
+
460
+ # Keep the cursor inside the visible window, scrolling when needed.
461
+ # Sticky choices are always on screen, so they never move the window.
462
+ private def clamp_scroll_window
463
+ return if @selected_index >= @scroll_count
464
+
465
+ if @selected_index < @window_start
466
+ @window_start = @selected_index
467
+ elsif @selected_index >= @window_start + @visible_items
468
+ @window_start = @selected_index - @visible_items + 1
469
+ end
441
470
  end
442
471
  end
443
472
  end
@@ -266,6 +266,7 @@ module Clacky
266
266
  when "\u0004" then :ctrl_d
267
267
  when "\u0005" then :ctrl_e
268
268
  when "\u0006" then :ctrl_f
269
+ when "\u0007" then :ctrl_g
269
270
  when "\u000B" then :ctrl_k
270
271
  when "\u000C" then :ctrl_l
271
272
  when "\u000F" then :ctrl_o