openclacky 1.5.12 → 1.5.13

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Clacky
6
+ # Reassembles an OpenAI Responses API event stream into the non-streaming
7
+ # response shape that MessageFormat::OpenAIResponses.parse_response consumes,
8
+ # while invoking on_chunk(input_tokens:, output_tokens:) for progress updates.
9
+ #
10
+ # The Responses API uses typed SSE events rather than the Chat Completions
11
+ # delta-based format. Key event types:
12
+ #
13
+ # response.created -> response object created
14
+ # response.output_item.added -> new output item (message or function_call)
15
+ # response.output_text.delta -> text content delta
16
+ # response.output_text.done -> text content complete
17
+ # response.reasoning_text.delta -> reasoning content delta (OpenAI official / DeepSeek)
18
+ # response.function_call_arguments.delta -> tool call argument delta
19
+ # response.output_item.done -> output item complete
20
+ # response.completed -> final event with full response + usage
21
+ # response.done -> terminal event (OpenAI official API)
22
+ # response.incomplete -> response ended prematurely
23
+ #
24
+ # Unlike Chat Completions, there is no "[DONE]" sentinel; the stream ends
25
+ # with a "response.completed" / "response.done" (or "response.incomplete") event.
26
+ class OpenAIResponsesStreamAggregator
27
+ def initialize(on_chunk: nil)
28
+ @on_chunk = on_chunk
29
+ @text = +""
30
+ @reasoning_text = +""
31
+ @function_calls = {} # call_id -> { name:, arguments: }
32
+ @call_id_by_item_id = {} # item_id -> call_id (for delta routing)
33
+ @usage = nil
34
+ @status = nil
35
+ @incomplete_details = nil
36
+ @last_input_tokens = 0
37
+ @last_output_tokens = 0
38
+ @parse_failures = 0
39
+ @frames_seen = 0
40
+ @bytes_seen = 0
41
+ end
42
+
43
+ attr_reader :parse_failures, :frames_seen, :bytes_seen
44
+
45
+ def saw_done?
46
+ !@status.nil?
47
+ end
48
+
49
+ def handle(data_str)
50
+ @bytes_seen += data_str.bytesize
51
+ @frames_seen += 1
52
+ data = parse_or_nil(data_str)
53
+ return unless data
54
+
55
+ event_type = data["type"]
56
+
57
+ case event_type
58
+ when "response.output_text.delta"
59
+ @text << data["delta"].to_s
60
+ emit_estimate_progress
61
+ # OpenAI's official API emits response.reasoning_text.delta; keep
62
+ # response.reasoning.delta as a tolerant alias for providers that
63
+ # rename it.
64
+ when "response.reasoning_text.delta", "response.reasoning.delta"
65
+ @reasoning_text << data["delta"].to_s
66
+ when "response.output_item.added"
67
+ handle_item_added(data)
68
+ when "response.function_call_arguments.delta"
69
+ handle_function_call_delta(data)
70
+ when "response.output_item.done"
71
+ handle_item_done(data)
72
+ when "response.completed", "response.done"
73
+ # OpenAI's official API sends response.completed followed by
74
+ # response.done as the terminal event; DeepSeek sends only
75
+ # response.completed. Either one marks a successful end.
76
+ @status = "completed"
77
+ response = data["response"]
78
+ @usage ||= response["usage"] if response && response["usage"]
79
+ # Also sync output items from the final response if our incremental
80
+ # tracking missed anything (e.g. provider sent complete items only).
81
+ sync_from_final_output(response["output"]) if response && response["output"]
82
+ emit_usage_progress(@usage) if @usage
83
+ when "response.incomplete"
84
+ @status = "incomplete"
85
+ # Preserve incomplete_details (e.g. reason: "max_output_tokens" vs
86
+ # "content_filter") so finish_reason mapping can distinguish a token
87
+ # limit from a safety-filtered response.
88
+ response = data["response"]
89
+ @incomplete_details = response["incomplete_details"] if response.is_a?(Hash) && response["incomplete_details"]
90
+ when "response.created", "response.in_progress"
91
+ # Informational events, no action needed
92
+ end
93
+ end
94
+
95
+ # Render into the non-streaming Responses API response shape
96
+ # so MessageFormat::OpenAIResponses.parse_response works unchanged.
97
+ def to_h
98
+ output = []
99
+
100
+ # Message item with text content
101
+ unless @text.empty?
102
+ output << {
103
+ "type" => "message",
104
+ "role" => "assistant",
105
+ "content" => [{ "type" => "output_text", "text" => @text.to_s }]
106
+ }
107
+ end
108
+
109
+ # Reasoning item. Both providers stream reasoning via
110
+ # response.reasoning(_text).delta; emit it as a top-level "reasoning"
111
+ # item (DeepSeek shape) so MessageFormat::OpenAIResponses#extract_reasoning
112
+ # can consume it. Without this the streaming path silently drops
113
+ # reasoning content while the non-streaming path keeps it.
114
+ unless @reasoning_text.empty?
115
+ output << {
116
+ "type" => "reasoning",
117
+ "content" => [{ "type" => "reasoning_text", "text" => @reasoning_text.to_s }]
118
+ }
119
+ end
120
+
121
+ # Function call items
122
+ @function_calls.each_value do |fc|
123
+ output << {
124
+ "type" => "function_call",
125
+ "call_id" => fc[:call_id],
126
+ "name" => fc[:name],
127
+ "arguments" => fc[:arguments].to_s
128
+ }
129
+ end
130
+
131
+ result = {
132
+ "output" => output,
133
+ "status" => @status || "completed",
134
+ "usage" => @usage || {}
135
+ }
136
+ result["incomplete_details"] = @incomplete_details if @incomplete_details
137
+ result
138
+ end
139
+
140
+ private def handle_item_added(data)
141
+ item = data["item"]
142
+ return unless item
143
+
144
+ if item["type"] == "function_call"
145
+ call_id = item["call_id"]
146
+ item_id = item["id"]
147
+ @call_id_by_item_id[item_id] = call_id if item_id
148
+ @function_calls[call_id] = {
149
+ call_id: call_id,
150
+ name: item["name"],
151
+ arguments: +""
152
+ }
153
+ end
154
+ end
155
+
156
+ private def handle_function_call_delta(data)
157
+ # The delta event carries item_id to identify which function call
158
+ # the arguments belong to. Map it to the call_id we registered in
159
+ # handle_item_added. Fall back to the most recent call if mapping fails.
160
+ item_id = data["item_id"]
161
+ call_id = @call_id_by_item_id[item_id] || @function_calls.keys.last
162
+
163
+ return unless call_id && @function_calls[call_id]
164
+
165
+ @function_calls[call_id][:arguments] << data["delta"].to_s
166
+ end
167
+
168
+ private def handle_item_done(data)
169
+ item = data["item"]
170
+ return unless item
171
+
172
+ if item["type"] == "function_call"
173
+ call_id = item["call_id"]
174
+ fc = @function_calls[call_id]
175
+ # Use the complete arguments from the done event if available -
176
+ # more reliable than accumulated deltas.
177
+ if fc && item["arguments"]
178
+ fc[:arguments] = item["arguments"]
179
+ end
180
+ end
181
+ end
182
+
183
+ private def sync_from_final_output(output)
184
+ return unless output.is_a?(Array)
185
+
186
+ # Sync per-item: only backfill what incremental tracking missed.
187
+ # A provider may send complete message items but only deltas for
188
+ # function calls (or vice versa), so don't require both to be empty.
189
+ output.each do |item|
190
+ case item["type"]
191
+ when "message"
192
+ content = item["content"]
193
+ next unless content.is_a?(Array)
194
+
195
+ content.each do |block|
196
+ case block["type"]
197
+ when "output_text"
198
+ @text << block["text"].to_s if @text.empty?
199
+ when "reasoning"
200
+ backfill_reasoning(block["content"], block["summary"])
201
+ end
202
+ end
203
+ when "reasoning"
204
+ backfill_reasoning(item["content"], item["summary"])
205
+ when "function_call"
206
+ next if @function_calls.key?(item["call_id"])
207
+ @function_calls[item["call_id"]] = {
208
+ call_id: item["call_id"],
209
+ name: item["name"],
210
+ arguments: item["arguments"].to_s
211
+ }
212
+ end
213
+ end
214
+ end
215
+
216
+ # Backfill @reasoning_text from the final response snapshot when no
217
+ # reasoning delta was seen. Handles both item shapes: OpenAI's official
218
+ # reasoning block inside the message item, and DeepSeek's top-level
219
+ # reasoning item. Prefers full reasoning text; falls back to the summary
220
+ # (mirrors MessageFormat::OpenAIResponses#extract_reasoning).
221
+ private def backfill_reasoning(content_blocks, summary_blocks = nil)
222
+ return unless @reasoning_text.empty?
223
+
224
+ return if append_typed_blocks(content_blocks, "reasoning_text")
225
+
226
+ append_typed_blocks(content_blocks, "summary_text") || append_typed_blocks(summary_blocks, "summary_text")
227
+ end
228
+
229
+ # Append every block of the given type to @reasoning_text.
230
+ # Returns true when at least one block matched.
231
+ private def append_typed_blocks(blocks, type)
232
+ return false unless blocks.is_a?(Array)
233
+
234
+ found = false
235
+ blocks.each do |block|
236
+ next unless block.is_a?(Hash) && block["type"] == type
237
+
238
+ @reasoning_text << block["text"].to_s if block["text"]
239
+ found = true
240
+ end
241
+ found
242
+ end
243
+
244
+ private def parse_or_nil(s)
245
+ # Some providers / proxies append a "[DONE]" sentinel (inherited from
246
+ # Chat Completions SSE) even for the Responses API. Silently ignore it
247
+ # instead of logging a parse failure.
248
+ return nil if s.to_s.strip == "[DONE]"
249
+
250
+ JSON.parse(s)
251
+ rescue JSON::ParserError => e
252
+ @parse_failures += 1
253
+ if @parse_failures == 1
254
+ Clacky::Logger.warn("stream.parse_failure",
255
+ provider: "openai-responses",
256
+ error: "#{e.class}: #{e.message}",
257
+ frame_head: s.to_s[0, 200],
258
+ frame_bytes: s.to_s.bytesize
259
+ )
260
+ end
261
+ nil
262
+ end
263
+
264
+ private def emit_estimate_progress
265
+ return unless @on_chunk
266
+ output = approximate_output_tokens
267
+ return if output == @last_output_tokens
268
+ @last_output_tokens = output
269
+ @on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
270
+ rescue => e
271
+ Clacky::Logger.warn("[OpenAIResponsesStreamAggregator] on_chunk: #{e.class}: #{e.message}")
272
+ end
273
+
274
+ # Rough char/4 estimate; replaced by the real count when the upstream
275
+ # emits the response.completed event with usage data.
276
+ private def approximate_output_tokens
277
+ total_chars = @text.bytesize + @reasoning_text.bytesize +
278
+ @function_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
279
+ (total_chars / 4.0).ceil
280
+ end
281
+
282
+ private def emit_usage_progress(u)
283
+ return unless @on_chunk
284
+ total_prompt = u["input_tokens"].to_i
285
+ output = u["output_tokens"].to_i
286
+ return if total_prompt == @last_input_tokens && output == @last_output_tokens
287
+ @last_input_tokens = total_prompt
288
+ @last_output_tokens = output
289
+ @on_chunk.call(input_tokens: total_prompt, output_tokens: output)
290
+ rescue => e
291
+ Clacky::Logger.warn("[OpenAIResponsesStreamAggregator] on_chunk: #{e.class}: #{e.message}")
292
+ end
293
+ end
294
+ end
@@ -31,6 +31,7 @@ module Clacky
31
31
  "api" => "bedrock",
32
32
  "default_model" => "abs-claude-sonnet-5",
33
33
  "models" => [
34
+ "abs-claude-fable-5-1",
34
35
  "abs-claude-fable-5",
35
36
  "abs-claude-opus-5",
36
37
  "abs-claude-opus-4-8",
@@ -44,6 +45,7 @@ module Clacky
44
45
  "dsk-deepseek-v4-flash",
45
46
  "dsk-deepseek-v4-flash-vision-exp",
46
47
  "or-gemini-3-1-pro",
48
+ "or-gemini-3-8-flash",
47
49
  "or-gemini-3-7-flash",
48
50
  "or-gemini-3-6-flash",
49
51
  "or-gemini-3-5-flash"
@@ -97,34 +99,38 @@ module Clacky
97
99
  # routes them to Vertex AI Gemini (generateContent with inline
98
100
  # audio parts). The gateway returns transcription text.
99
101
  "stt_models" => [
102
+ "or-stt-gemini-3-8-flash",
100
103
  "or-stt-gemini-3-7-flash",
101
104
  "or-stt-gemini-3-6-flash",
102
105
  "or-stt-gemini-3-5-flash",
103
106
  "or-stt-gemini-1-5-pro"
104
107
  ],
105
108
  "stt_model_aliases" => {
109
+ "or-stt-gemini-3-8-flash" => "Gemini 3.8 Flash STT",
106
110
  "or-stt-gemini-3-7-flash" => "Gemini 3.7 Flash STT",
107
111
  "or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
108
112
  "or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
109
113
  "or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
110
114
  },
111
- "default_stt_model" => "or-stt-gemini-3-7-flash",
115
+ "default_stt_model" => "or-stt-gemini-3-8-flash",
112
116
  # Video understanding models served by the openclacky gateway, which
113
117
  # routes video frames to Gemini (generateContent with inline image
114
118
  # parts). The gateway returns analysis text.
115
119
  "video_understanding_models" => [
120
+ "or-gemini-3-8-flash",
116
121
  "or-gemini-3-7-flash",
117
122
  "or-gemini-3-6-flash",
118
123
  "or-gemini-3-5-flash",
119
124
  "or-gemini-3-1-pro"
120
125
  ],
121
126
  "video_understanding_model_aliases" => {
127
+ "or-gemini-3-8-flash" => "Gemini 3.8 Flash",
122
128
  "or-gemini-3-7-flash" => "Gemini 3.7 Flash",
123
129
  "or-gemini-3-6-flash" => "Gemini 3.6 Flash",
124
130
  "or-gemini-3-5-flash" => "Gemini 3.5 Flash",
125
131
  "or-gemini-3-1-pro" => "Gemini 3.1 Pro"
126
132
  },
127
- "default_video_understanding_model" => "or-gemini-3-7-flash",
133
+ "default_video_understanding_model" => "or-gemini-3-8-flash",
128
134
  # Default OCR sidecar — used when the primary model is text-only.
129
135
  # Candidates are derived from the provider's vision-capable models;
130
136
  # this just picks the cheap+fast default to surface in "auto" mode.
@@ -148,6 +154,7 @@ module Clacky
148
154
  # themselves, so they're intentionally not listed here as keys —
149
155
  # no injection happens when the default model is already lite-class.
150
156
  "lite_models" => {
157
+ "abs-claude-fable-5-1" => "abs-claude-haiku-4-5",
151
158
  "abs-claude-fable-5" => "abs-claude-haiku-4-5",
152
159
  "abs-claude-opus-5" => "abs-claude-haiku-4-5",
153
160
  "abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
@@ -162,6 +169,7 @@ module Clacky
162
169
  # Fallback chain: if a model is unavailable, try the next one in order.
163
170
  # Keys are primary model names; values are the fallback model to use instead.
164
171
  "fallback_models" => {
172
+ "abs-claude-fable-5-1" => "abs-claude-fable-5",
165
173
  "abs-claude-fable-5" => "abs-claude-opus-5",
166
174
  "abs-claude-opus-5" => "abs-claude-opus-4-8",
167
175
  "abs-claude-sonnet-5" => "abs-claude-sonnet-4-6",
@@ -276,7 +284,7 @@ module Clacky
276
284
  "base_url" => "https://open.bigmodel.cn/api/paas/v4",
277
285
  "api" => "openai-completions",
278
286
  "default_model" => "glm-5.3",
279
- "models" => ["glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
287
+ "models" => ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
280
288
  # Zhipu / Z.ai expose four functionally-equivalent endpoints:
281
289
  # two regional sites (mainland open.bigmodel.cn + international api.z.ai)
282
290
  # each with a general-billing and a Coding-Plan subpath. They share the
@@ -292,10 +300,12 @@ module Clacky
292
300
  { "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
293
301
  { "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
294
302
  ].freeze,
295
- # GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
303
+ # GLM models are text-only except the vision-capable SKUs: glm-5v-turbo
304
+ # ("v" = visual) and glm-5.3-flash (GLM-5's first natively-multimodal model).
296
305
  "capabilities" => { "vision" => false }.freeze,
297
306
  "model_capabilities" => {
298
- "glm-5v-turbo" => { "vision" => true }.freeze
307
+ "glm-5v-turbo" => { "vision" => true }.freeze,
308
+ "glm-5.3-flash" => { "vision" => true }.freeze
299
309
  }.freeze,
300
310
  "default_ocr_model" => "glm-5v-turbo",
301
311
  "website_url" => "https://open.bigmodel.cn/console/overview"
@@ -446,8 +456,9 @@ module Clacky
446
456
  "name" => "Qwen (Alibaba)",
447
457
  "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
448
458
  "api" => "openai-completions",
449
- "default_model" => "qwen3.7-max",
459
+ "default_model" => "qwen3.8-max",
450
460
  "models" => [
461
+ "qwen3.8-max",
451
462
  "qwen3.7-max",
452
463
  "qwen3.6-plus",
453
464
  "qwen3.6-max",
@@ -466,6 +477,7 @@ module Clacky
466
477
  }.freeze,
467
478
  "default_ocr_model" => "qwen3.6-flash",
468
479
  "lite_models" => {
480
+ "qwen3.8-max" => "qwen3.6-flash",
469
481
  "qwen3.7-max" => "qwen3.6-flash",
470
482
  "qwen3.6-plus" => "qwen3.6-flash",
471
483
  "qwen3.6-max" => "qwen3.6-flash",
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Server
5
+ # Directory-picker quick-access places + WSL/Windows drive detection.
6
+ # Extracted from HttpServer to keep the path/place helpers cohesive.
7
+ module DirPicker
8
+ # Windows "special" folders under C:\Users that aren't real user profiles.
9
+ WINDOWS_PROFILE_DIRS = ["Public", "Default", "Default User", "All Users"].freeze
10
+
11
+ # Sidebar quick-access favorites for the directory picker (Finder-style).
12
+ # Translated client-side via `id`. On WSL every favorite targets the
13
+ # Windows profile (/mnt/<drive>/Users/<name>/...) so they match where a
14
+ # Windows browser actually saves files; elsewhere they stay under Dir.home.
15
+ private def dir_picker_places
16
+ home = Dir.home
17
+ win_home = wsl_windows_home
18
+ places = []
19
+
20
+ [["home", win_home || home],
21
+ ["desktop", File.join(win_home || home, "Desktop")],
22
+ ["downloads", File.join(win_home || home, "Downloads")],
23
+ ["documents", File.join(win_home || home, "Documents")]].each do |id, path|
24
+ places << { id: id, path: path, kind: "favorite" } if Dir.exist?(path)
25
+ end
26
+
27
+ places.concat(dir_picker_drives)
28
+ places
29
+ end
30
+
31
+ # Drive letters exposed to the picker so Windows users can reach D:/E:/…
32
+ # directly. Under WSL these are the /mnt/<drive> mounts; on native
33
+ # Windows the actual drive letters. Empty on macOS/Linux.
34
+ private def dir_picker_drives
35
+ if wsl?
36
+ mounts = "/mnt"
37
+ return [] unless Dir.exist?(mounts)
38
+
39
+ drives = []
40
+ Dir.children(mounts).sort.each do |drive|
41
+ next unless drive.match?(/\A[a-zA-Z]\z/)
42
+ path = File.join(mounts, drive)
43
+ next unless Dir.exist?(path)
44
+ drives << { id: "drive_#{drive.downcase}", path: path, letter: drive.upcase, kind: "drive" }
45
+ end
46
+ drives
47
+ elsif Gem.win_platform?
48
+ drives = []
49
+ ("A".."Z").each do |letter|
50
+ path = "#{letter}:/"
51
+ next unless Dir.exist?(path)
52
+ drives << { id: "drive_#{letter.downcase}", path: path, letter: letter, kind: "drive" }
53
+ end
54
+ drives
55
+ else
56
+ []
57
+ end
58
+ end
59
+
60
+ # True when running inside Windows Subsystem for Linux.
61
+ private def wsl?
62
+ return true if ENV["WSL_DISTRO_NAME"]
63
+ return true if File.exist?("/proc/sys/fs/binfmt_misc/WSLInterop")
64
+ version = begin
65
+ File.read("/proc/version")
66
+ rescue StandardError
67
+ ""
68
+ end
69
+ version.downcase.include?("microsoft")
70
+ end
71
+
72
+ # Best-effort path to the Windows user profile under WSL, or nil when it
73
+ # can't be determined (then callers fall back to Dir.home). Scans every
74
+ # mounted drive's Users dir instead of assuming the system drive is C:.
75
+ # Username sources are tried most-trusted first:
76
+ # 1. WINDOWS_USERNAME / USERNAME env (explicitly set or WSLENV-forwarded)
77
+ # 2. the live Windows %USERNAME%, read via cmd.exe interop
78
+ # 3. USER (the WSL Linux username, which usually matches the Windows one)
79
+ # 4. the first non-system profile dir found (last-resort guess)
80
+ private def wsl_windows_home
81
+ return nil unless wsl?
82
+
83
+ roots = wsl_windows_users_roots
84
+ wsl_profile_for(roots, ENV["WINDOWS_USERNAME"]) ||
85
+ wsl_profile_for(roots, ENV["USERNAME"]) ||
86
+ wsl_profile_for(roots, wsl_windows_username) ||
87
+ wsl_profile_for(roots, ENV["USER"]) ||
88
+ wsl_first_user_profile(roots)
89
+ end
90
+
91
+ # The first mounted profile dir named exactly `username`, or nil. WSL
92
+ # never inherits Windows env vars by default, which is why `username`
93
+ # may also come from cmd.exe interop rather than ENV.
94
+ private def wsl_profile_for(roots, username)
95
+ return nil if username.nil? || username.empty?
96
+
97
+ roots.each do |root|
98
+ candidate = File.join(root, username)
99
+ return candidate if Dir.exist?(candidate)
100
+ end
101
+ nil
102
+ end
103
+
104
+ # Last-resort guess: the first non-system profile dir under any mounted
105
+ # Users root. Filesystem enumeration order is arbitrary, so on
106
+ # multi-profile machines this may pick an inactive profile — every
107
+ # username source in wsl_windows_home exists to keep this unreached.
108
+ private def wsl_first_user_profile(roots)
109
+ roots.each do |root|
110
+ profiles = Dir.children(root).select do |name|
111
+ full = File.join(root, name)
112
+ File.directory?(full) &&
113
+ !WINDOWS_PROFILE_DIRS.include?(name) &&
114
+ !name.start_with?(".")
115
+ end
116
+ return File.join(root, profiles.first) if profiles.any?
117
+ end
118
+ nil
119
+ end
120
+
121
+ # The live Windows-side %USERNAME%, read via cmd.exe interop. Returns
122
+ # nil when interop is disabled or cmd.exe is unreachable; callers must
123
+ # still validate against the mounted Users directory. Cached —
124
+ # including failures — because interop availability doesn't change
125
+ # within a process and the spawn otherwise costs 100ms+ on every
126
+ # directory listing.
127
+ private def wsl_windows_username
128
+ return @wsl_windows_username if defined?(@wsl_windows_username)
129
+
130
+ @wsl_windows_username = begin
131
+ require "open3"
132
+ out, _err, status = Open3.capture3("cmd.exe", "/c", "echo %USERNAME%", binmode: true)
133
+ status.success? ? out.lines.first&.strip : nil
134
+ rescue StandardError
135
+ nil
136
+ end
137
+ end
138
+
139
+ # Every mounted drive that exposes a Windows Users directory, e.g.
140
+ # /mnt/c/Users and /mnt/d/Users.
141
+ private def wsl_windows_users_roots
142
+ mounts = "/mnt"
143
+ return [] unless Dir.exist?(mounts)
144
+
145
+ roots = []
146
+ Dir.children(mounts).each do |drive|
147
+ root = File.join(mounts, drive, "Users")
148
+ roots << root if Dir.exist?(root)
149
+ end
150
+ roots
151
+ end
152
+ end
153
+ end
154
+ end
@@ -7,8 +7,8 @@ module Clacky
7
7
  # Read-mostly git operations scoped to a session's working directory, backing
8
8
  # the official "git" WebUI panel. Commands run with explicit argv (no shell),
9
9
  # so user-supplied values (paths, messages) cannot inject. Write operations
10
- # are limited to a guarded `commit`; history-rewriting / remote-mutating
11
- # commands are never exposed here.
10
+ # are limited to a guarded `commit` and a single-file `restore`;
11
+ # history-rewriting / remote-mutating commands are never exposed here.
12
12
  module GitPanel
13
13
  module_function
14
14
 
@@ -56,14 +56,24 @@ module Clacky
56
56
  { branch: branch, ahead: ahead, behind: behind, files: files }
57
57
  end
58
58
 
59
- # Unified diff. `file` (optional, relative) limits to one path; omitted =
60
- # whole working tree (tracked changes). `--` guards path from being read
61
- # as an option.
59
+ # Unified diff for one file, or the whole tree when `file` is nil.
60
+ # Baseline is HEAD, not the index, so changes the agent staged with
61
+ # `git add` still show up. Untracked files have no baseline at all, so
62
+ # they diff against /dev/null via --no-index (rendered as pure
63
+ # additions); the trailing `--` keeps a leading-dash filename from
64
+ # being read as an option in that mode too.
62
65
  def diff(dir, file: nil)
63
- args = ["diff"]
64
- args += ["--", file] if file && !file.empty?
65
- out, _err, _ok = git(dir, *args)
66
- out
66
+ if file && !file.empty?
67
+ others, _e, _ok = git(dir, "ls-files", "--others", "--exclude-standard", "--", file)
68
+ if others.strip.empty?
69
+ out, _e, _ok = git(dir, "diff", "HEAD", "--", file)
70
+ else
71
+ out, _e, _ok = git(dir, "diff", "--no-index", "--", "/dev/null", file)
72
+ end
73
+ out
74
+ else
75
+ git(dir, "diff", "HEAD")[0]
76
+ end
67
77
  end
68
78
 
69
79
  # Recent commits: [{ hash:, short:, author:, date:, subject: }].
@@ -92,6 +102,48 @@ module Clacky
92
102
  end
93
103
  end
94
104
 
105
+ # Discard uncommitted changes to one file and return it to its HEAD
106
+ # state (same baseline the diff API uses, so "what you saw is what you
107
+ # lose"). Files that never existed in HEAD (staged-new or untracked)
108
+ # have no earlier version, so they are removed instead. argv-only and
109
+ # guarded against path traversal; history rewriting and remote-mutating
110
+ # commands remain out of bounds.
111
+ def restore(dir, file:)
112
+ path = file.to_s.strip
113
+ return { ok: false, error: "file is required" } if path.empty?
114
+ return { ok: false, error: "invalid path" } if invalid_path?(path)
115
+
116
+ head_tree, _e, _ok = git(dir, "ls-tree", "HEAD", "--", path)
117
+ if head_tree.include?("\t#{path}")
118
+ _o, err, ok = git(dir, "checkout", "HEAD", "--", path)
119
+ return ok ? { ok: true } : { ok: false, error: "git checkout failed: #{err.strip}" }
120
+ end
121
+
122
+ in_index, _e, idx_ok = git(dir, "ls-files", "--", path)
123
+ if idx_ok && !in_index.strip.empty?
124
+ _o, err, ok = git(dir, "rm", "-f", "--", path)
125
+ return ok ? { ok: true } : { ok: false, error: "git rm failed: #{err.strip}" }
126
+ end
127
+
128
+ target = File.expand_path(path, dir.to_s)
129
+ root = File.expand_path(dir.to_s)
130
+ return { ok: false, error: "invalid path" } unless target.start_with?(root + File::SEPARATOR)
131
+
132
+ begin
133
+ File.delete(target)
134
+ { ok: true }
135
+ rescue Errno::ENOENT
136
+ { ok: true }
137
+ rescue StandardError => e
138
+ { ok: false, error: e.message }
139
+ end
140
+ end
141
+
142
+ # Relative repo paths only: no absolute paths, no traversal segments.
143
+ def invalid_path?(path)
144
+ path.start_with?("/") || path.split("/").include?("..")
145
+ end
146
+
95
147
  # Stage `files` (relative paths) and commit with `message`. Returns
96
148
  # { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
97
149
  # argv so paths/message cannot inject; no --no-verify, no amend.