Commit f4fb084
Changed files (5)
.elelem
lib
elelem
spec
elelem
.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