Commit f4fb084

mo khan <mo@mokhan.ca>
2026-02-05 02:18:34
feat: use design/build mode to provide consistent spacing
1 parent 114dc44
.elelem/backlog/015-consistent-terminal-spacing.md
@@ -15,14 +15,67 @@ This story involves:
 2. Establishing spacing rules (e.g., 1 blank line between sections)
 3. Ensuring consistent application of those rules
 
+# DESIGN
+
+**Root cause:** Spacing is caller-side (each method decides its own prefix/suffix spacing)
+instead of boundary-aware (spacing happens at transitions).
+
+**Scenarios causing inconsistency:**
+- Dots running → `stop_dots` adds newline + `markdown` adds 2 newlines = 3 lines
+- No dots → `markdown` adds 2 newlines = 2 lines  
+- `header` returns `\n...` + `say` adds newline = double spacing
+
+**Solution:** One flag (`@at_line_start`), one method (`gap`).
+
+`gap` is idempotent: "ensure we're at a blank line". Call it anywhere between 
+sections. If already at line start, it's a no-op. If mid-content, it adds one newline.
+
+**Changes:**
+- Terminal tracks cursor state via `@at_line_start`
+- `gap` method: `newline unless @at_line_start`
+- `markdown` uses `gap` instead of `newline(n: 2)`
+- `header` drops its `\n` prefix (caller uses `gap`)
+
+**Trade-offs:**
+- Simplicity ✓ - 1 flag, 1 method, 3 file changes
+- No plugin changes needed - existing `say`/`print` calls just work
+- Idempotent - safe to call `gap` multiple times
+
 # SEE ALSO
 
-* [ ] lib/elelem/terminal.rb - Primary output methods
-* [ ] lib/elelem/agent.rb - May have direct output calls
+* [ ] lib/elelem/terminal.rb - Primary output methods (say, print, markdown, newline)
+* [ ] lib/elelem/agent.rb - REPL loop and turn processing with terminal calls
+* [ ] lib/elelem/toolbox.rb - header() method prepends \n to output
+* [ ] lib/elelem/plugins/read.rb - after hook uses terminal.say and display_file
+* [ ] lib/elelem/plugins/write.rb - after hook uses terminal.say and display_file
+* [ ] lib/elelem/plugins/execute.rb - streaming print and after hook
+* [ ] lib/elelem/plugins/tools.rb - markdown output for tool listings
+* [ ] lib/elelem/plugins/context.rb - multi-line output for context display
+* [ ] lib/elelem/plugins/builtins.rb - /clear and /help command output
+* [ ] lib/elelem/plugins/provider.rb - provider switching messages
 
 # Tasks
 
-* [ ] TBD (filled in design mode)
+## Terminal (lib/elelem/terminal.rb)
+* [x] Add `@at_line_start = true` in initialize
+* [x] Add `gap` method: `newline unless @at_line_start` (idempotent blank line)
+* [x] Update `say` to set `@at_line_start = true` after output
+* [x] Update `print` to set `@at_line_start = false` (mid-line content)
+* [x] Update `newline` to set `@at_line_start = true`
+* [x] Update `stop_dots` - already calls newline, will inherit correct state
+* [x] Update `markdown` - replace `newline(n: 2)` with `gap`
+
+## Toolbox (lib/elelem/toolbox.rb)
+* [x] Update `header` - remove leading `\n` from return string
+
+## Agent (lib/elelem/agent.rb)
+* [x] Add `terminal.gap` before `terminal.say toolbox.header(...)` in process method
+
+## Testing
+* [x] Add spec for `gap` idempotence: calling twice produces one blank line
+* [ ] Visual audit: conversation with tool calls
+* [ ] Visual audit: multi-tool execution
+* [ ] Visual audit: streaming execute output
 
 # Acceptance Criteria
 
lib/elelem/agent.rb
@@ -63,6 +63,7 @@ module Elelem
 
     def process(tool_call)
       name, args = tool_call[:name], tool_call[:arguments]
+      terminal.gap
       terminal.say toolbox.header(name, args)
       toolbox.run(name.to_s, args)
     end
lib/elelem/terminal.rb
@@ -6,6 +6,7 @@ module Elelem
       @commands = commands
       @quiet = quiet
       @dots_thread = nil
+      @at_line_start = true
       setup_completion unless @quiet
     end
 
@@ -22,7 +23,7 @@ module Elelem
     def markdown(text)
       return if @quiet || blank?(text)
 
-      newline(n: 2)
+      gap
       width = $stdout.winsize[1] rescue 80
       IO.popen(["glow", "-s", "dark", "-w", width.to_s, "-"], "r+") do |io|
         io.write(text)
@@ -34,21 +35,32 @@ module Elelem
     end
 
     def print(text)
-      return if @quiet || blank?(text)
+      return if blank?(text)
 
-      stop_dots
-      $stdout.print text
+      unless @quiet
+        stop_dots
+        $stdout.print text
+      end
+      @at_line_start = false
     end
 
     def say(text)
-      return if @quiet || blank?(text)
+      return if blank?(text)
 
-      stop_dots
-      $stdout.puts text
+      unless @quiet
+        stop_dots
+        $stdout.puts text
+      end
+      @at_line_start = true
     end
 
     def newline(n: 1)
       n.times { $stdout.puts("") }
+      @at_line_start = true
+    end
+
+    def gap
+      newline unless @at_line_start
     end
 
     def display_file(path, fallback: nil)
lib/elelem/toolbox.rb
@@ -28,7 +28,7 @@ module Elelem
       tool = tool_for(name)
       color = tool ? "36" : "33"
       name = tool&.name || "#{name}?"
-      "\n#{state} \e[#{color}m#{name}\e[0m(#{args})"
+      "#{state} \e[#{color}m#{name}\e[0m(#{args})"
     end
 
     def run(name, args)
spec/elelem/terminal_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Elelem::Terminal do
+  subject(:terminal) { described_class.new(quiet: true) }
+
+  describe "#gap" do
+    it "outputs newline when not at line start" do
+      terminal.instance_variable_set(:@at_line_start, false)
+      expect { terminal.gap }.to output("\n").to_stdout
+    end
+
+    it "outputs nothing when already at line start" do
+      terminal.instance_variable_set(:@at_line_start, true)
+      expect { terminal.gap }.not_to output.to_stdout
+    end
+
+    it "is idempotent - calling twice produces one newline" do
+      terminal.instance_variable_set(:@at_line_start, false)
+      expect { terminal.gap; terminal.gap }.to output("\n").to_stdout
+    end
+  end
+
+  describe "@at_line_start tracking" do
+    it "starts true (cursor at line start)" do
+      expect(terminal.instance_variable_get(:@at_line_start)).to be true
+    end
+
+    it "becomes true after say" do
+      terminal.instance_variable_set(:@at_line_start, false)
+      terminal.say("hello")
+      expect(terminal.instance_variable_get(:@at_line_start)).to be true
+    end
+
+    it "becomes false after print" do
+      terminal.instance_variable_set(:@at_line_start, true)
+      terminal.print("hello")
+      expect(terminal.instance_variable_get(:@at_line_start)).to be false
+    end
+
+    it "becomes true after newline" do
+      terminal.instance_variable_set(:@at_line_start, false)
+      terminal.newline
+      expect(terminal.instance_variable_get(:@at_line_start)).to be true
+    end
+  end
+end