openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -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 +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -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,13 +102,75 @@ 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
100
111
  end
101
112
  end
102
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)
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")
172
+ end
173
+
103
174
  # ── Configured searcher (~/.clacky/searchers/<provider>.rb) ────────────
104
175
 
105
176
  private def search_custom(query, max_results)
@@ -16,7 +16,8 @@ module Clacky
16
16
  { command: "/clear", description: "Clear chat history and restart session" },
17
17
  { command: "/config", description: "Open configuration (models, API keys, settings)" },
18
18
  { command: "/model", description: "Quickly switch the current model" },
19
- { command: "/goal", description: "Set a standing goal for autonomous multi-turn work" },
19
+ { command: "/think", description: "Set the thinking (reasoning) effort level" },
20
+ { command: "/goal", description: "Set a standing goal for autonomous multi-turn work", argument_hint: "<goal text>" },
20
21
  { command: "/undo", description: "Undo the last task and restore previous state" },
21
22
  { command: "/help", description: "Show help information" },
22
23
  { 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
@@ -145,15 +146,18 @@ module Clacky
145
146
  # @param working_dir [String] Working directory
146
147
  # @param mode [String] Permission mode
147
148
  # @param model [String] AI model name
149
+ # @param reasoning_effort [String, nil] Thinking level; nil (explicit) appends nothing,
150
+ # :_unset (default) leaves the previous value untouched
148
151
  # @param tasks [Integer] Number of completed tasks
149
152
  # @param cost [Float] Total cost
150
153
  # @param cost_source [Symbol, nil] :api / :price / :default — :default renders as N/A
151
154
  # @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)
155
+ 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
156
  @sessionbar_info[:session_id] = session_id if session_id
154
157
  @sessionbar_info[:working_dir] = working_dir if working_dir
155
158
  @sessionbar_info[:mode] = mode if mode
156
159
  @sessionbar_info[:model] = model if model
160
+ @sessionbar_info[:reasoning_effort] = reasoning_effort unless reasoning_effort == :_unset
157
161
  @sessionbar_info[:tasks] = tasks if tasks
158
162
  @sessionbar_info[:cost] = cost if cost
159
163
  @sessionbar_info[:cost_source] = cost_source if cost_source
@@ -184,10 +188,23 @@ module Clacky
184
188
  @command_suggestions.select_next
185
189
  return { action: nil }
186
190
  when :enter
187
- # Accept selected command and submit immediately
191
+ # Accept selected command; submit immediately — unless the
192
+ # command takes arguments (argument_hint), in which case only
193
+ # complete the name so the user can type the arguments first
194
+ # (same behavior as Tab).
188
195
  if @command_suggestions.has_suggestions?
189
196
  selected = @command_suggestions.selected_command_text
190
197
  if selected
198
+ hint = @command_suggestions.selected_argument_hint
199
+ if hint && !hint.empty?
200
+ completed = "#{selected} "
201
+ @lines = [completed]
202
+ @line_index = 0
203
+ @cursor_position = completed.length
204
+ @command_suggestions.hide
205
+ set_tips("Usage: #{selected} #{hint}", type: :info)
206
+ return { action: nil }
207
+ end
191
208
  # Replace current input with selected command
192
209
  @lines = [selected]
193
210
  @line_index = 0
@@ -1187,6 +1204,9 @@ module Clacky
1187
1204
  parts << theme.format_text(@sessionbar_info[:model], :statusbar_secondary)
1188
1205
  end
1189
1206
 
1207
+ # Thinking level (nil renders as off = provider default)
1208
+ parts << theme.format_text((@sessionbar_info[:reasoning_effort] || 'off').to_s, :statusbar_secondary)
1209
+
1190
1210
  # Tasks count
1191
1211
  parts << theme.format_text("#{@sessionbar_info[:tasks]} tasks", :statusbar_secondary)
1192
1212
 
@@ -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