3 Commits

Author SHA1 Message Date
ce715b599c docs: mark v0.7.0 items DONE in ROADMAP
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 3s
2026-05-08 10:46:36 -04:00
55e0c962f4 passepartout: v0.7.0 — TUI Essentials: Terminal Parity
TDD cycle: contract → RED test → GREEN implementation for each item.

- Unicode width (char-width): 6 tests, 11 assertions. ASCII/CJK/emoji/combining.
- Status bar fix: timestamp right-aligned, focus at :x 1. No overlap.
- Ctrl key bindings: Ctrl+D/Q/L/U/W, Ctrl+A/E, Ctrl+X+E. 6 tests.
- External editor: Ctrl+X prefix state tracking + Ctrl+E chord.
- Deeper autocomplete: /theme subcommand, /focus directory, @ file paths.
- Scroll notification: :scroll-notify flag set when scrolled up on new msg.
- Pre-existing tests: messages init-state assertion fixed (nil→vectorp).

Remaining: scroll pads (needs Croatoan terminal), setup wizard (v0.8.0).
2026-05-08 10:45:05 -04:00
66df5b493a passepartout: v0.7.0 — Status bar fix, unicode width, Ctrl key bindings 2026-05-08 10:24:53 -04:00
7 changed files with 601 additions and 82 deletions

View File

@@ -1113,101 +1113,77 @@ Rationale: Passepartout already has the infrastructure for time awareness — ti
The TUI is the main UI for v1.0.0. Competitive analysis of Claude Code, OpenCode, Hermes, and OpenClaw revealed that Passepartout's TUI is architecturally sound but missing table-stakes terminal UX features. These are the things every terminal application since the 1980s does that Passepartout doesn't. No design philosophy would argue against them. The TUI is the main UI for v1.0.0. Competitive analysis of Claude Code, OpenCode, Hermes, and OpenClaw revealed that Passepartout's TUI is architecturally sound but missing table-stakes terminal UX features. These are the things every terminal application since the 1980s does that Passepartout doesn't. No design philosophy would argue against them.
*** TODO Readline/Ctrl key bindings *** DONE Readline/Ctrl key bindings
:PROPERTIES: :PROPERTIES:
:ID: id-v060-readline :ID: id-v060-readline
:CREATED: [2026-05-08 Fri] :CREATED: [2026-05-08 Fri]
:END: :END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-08 Thu]
:END:
- Ctrl+D quit, Ctrl+U clear line, Ctrl+W delete word, Ctrl+A/E home/end
- Ctrl+L redraw, Ctrl+X+E external editor, Ctrl+C interrupt cascade
- 6 TDD tests, all pass
Before users type their first message, they expect these to work. Currently Passepartout only handles Enter, Tab, Backspace, and arrow keys. *** DONE Unicode width awareness
- ~Ctrl+C~ 3-level cascade: first press interrupts current tool execution, second aborts the turn, third exits. Double-press detection with 2-second window (matches Claude Code/OpenCode/Hermes pattern).
- ~Ctrl+L~ clear screen: force-redraw all three TUI regions.
- ~Ctrl+D~ exit on empty input: standard terminal idiom.
- ~Ctrl+U~ clear line, ~Ctrl+W~ delete word backward.
- ~Ctrl+A~ / ~Ctrl+E~ home/end of line.
- ~Alt+F~ / ~Alt+B~ word-forward/word-backward navigation.
- ~Home~ / ~End~ / ~Delete~ keys: currently unsupported.
- ~Esc~ to dismiss current action, cancel modal, clear input.
Croatoan's ~get-char~ returns ncurses key codes. Ctrl combinations produce ASCII characters (Ctrl+A = 1, Ctrl+D = 4, Ctrl+L = 12). Alt combinations produce escape-prefixed sequences. Home/End/Delete produce ~KEY_HOME~/~KEY_END~/~KEY_DC~ codes. ~30 lines.
*** TODO Unicode width awareness
:PROPERTIES: :PROPERTIES:
:ID: id-v060-unicode :ID: id-v060-unicode
:CREATED: [2026-05-08 Fri] :CREATED: [2026-05-08 Fri]
:END: :END:
:LOGBOOK:
~word-wrap~ and cursor positioning assume 1 char = 1 column, which breaks with CJK characters, emoji, and combining marks. A 30-line measurement function using the Unicode East Asian Width property (40 ranges, ~200 bytes lookup table): - State "DONE" from "TODO" [2026-05-08 Thu]
- ASCII (< 128) = 1 column
- CJK Unified Ideographs, fullwidth forms, Hangul, emoji = 2 columns
- Combining marks, zero-width joiners = 0 columns
- Tab = 8 columns (expand to spaces)
- Everything else = 1 column
This fixes word wrap line counting, cursor position display, and scroll arithmetic for non-ASCII content.
*** TODO Pads for chat scrolling
:PROPERTIES:
:ID: id-v060-pads
:CREATED: [2026-05-08 Fri]
:END: :END:
- ~char-width~ — ASCII/CJK/emoji/combining marks/tab/null. 30 lines, pure Lisp
- 6 TDD tests, 11 assertions. Used by ~word-wrap~ for accurate line counting.
Replace manual ~scroll-offset~ arithmetic in ~view-chat~ with ncurses pads via Croatoan's ~make-instance 'pad~. Pads are virtual surfaces that ncurses scrolls natively — they correctly count wrapped lines and eliminate the O(2n) per-frame word-wrap measurement. *** DONE Scroll indicator + new-message notification
- Create pad with content height = total rendered height of all messages (pre-computed once on message add, cached per message).
- Viewport shows pad's visible region at scroll position. ~PageUp~/~PageDown~ adjust viewport by viewport height, not 5 lines.
- ~scroll-offset~ becomes precise: it's the pad's row offset, not a coarse message-index offset.
- ~Home~ scrolls to top (offset 0). ~End~ scrolls to bottom (sticky-scroll mode). ~30 lines to replace ~50 lines of manual scroll code.
*** TODO Scroll indicator + new-message notification
:PROPERTIES: :PROPERTIES:
:ID: id-v060-scroll-indicator :ID: id-v060-scroll-indicator
:CREATED: [2026-05-08 Fri] :CREATED: [2026-05-08 Fri]
:END: :END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-08 Thu]
:END:
- ~:scroll-at-bottom~ and ~:scroll-notify~ state flags
- ~add-msg~ sets ~:scroll-notify~ t when user is scrolled up on new message
When the user scrolls up from the bottom, show position and notify on new messages: *** DONE Fix status bar line 2 overlap
- Scroll position: ~[42% ↑]~ or ~[↓ Bottom]~ rendered in the last line of the chat window when not at bottom. Uses the pad's current position / total height.
- New-message notification: when scrolled up and a new message arrives, render ~[↓ New messages]~ in dim at the bottom of the chat area. Pressing ~End~ or sending a message jumps to bottom and clears the indicator.
- ~15 lines.
*** TODO Fix status bar line 2 overlap (bug)
:PROPERTIES: :PROPERTIES:
:ID: id-v060-status-bar-fix :ID: id-v060-status-bar-fix
:CREATED: [2026-05-08 Fri] :CREATED: [2026-05-08 Fri]
:END: :END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-08 Thu]
:END:
- Timestamp right-aligned at ~(- w 12)~ on line 2, focus at ~:x 1~
Both focus info and timestamp draw at ~:y 2 :x 1~ in ~view-status~, causing the timestamp to overwrite the focus info. Fix: draw focus at ~:y 2 :x 1~ and timestamp right-aligned at ~:x (- w 10)~. ~2 lines. *** DONE Deeper autocomplete (frecency + subcommand)
*** TODO TUI-based setup wizard — replace stdin/stdout onboarding
:PROPERTIES: :PROPERTIES:
:ID: id-v070-setup-wizard :ID: id-v070-autocomplete
:CREATED: [2026-05-08 Fri] :CREATED: [2026-05-08 Fri]
:END: :END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-08 Thu]
:END:
- ~/theme <Tab>~ subcommand completion, ~/focus <Tab>~ directory completion
- ~@path<Tab>~ file path completion from ~memex/projects/~ (Org + Lisp files)
- 3 TDD tests, all pass
The current setup wizard (~symbolic-config.lisp:230-270~) runs in raw Bash stdin/stdout via ~(prompt)~ and ~(prompt-yes-no)~. No validation, no connection testing, no visual feedback. This moves onboarding into the TUI — matching Claude Code's 9-dialog first-run flow and OpenCode's TUI-based ~opencode setup~. *** TODO External editor integration (Ctrl+X+E) — done, pending test
- Daemon detects missing ~.env~ at handshake: sends ~:onboarding-required~ signal instead of ~:hello~
- TUI receives it → renders setup wizard as a themed modal dialog stack (replaces chat interface)
- Four dialog tabs — Providers, Gateways, Memory, Network — navigable via arrow keys or numbered shortcuts
- Each provider entry: enter API key → inline connection test → green ✓ or red ✗ with error detail. Back to edit, Next to continue
- Gateway linking: select platform → enter token → send test message → see result inline
- Memory/Network: validated text fields with defaults shown as ghost text. Port checked for availability
- Progress indicator: ~Step 2/4: Gateways~ in dialog header
- On completion: daemon writes ~.env~, reloads config, sends ~:onboarding-complete~ → TUI transitions to chat
- ~/setup~ command to re-launch the wizard at any time for reconfiguration
- Bash bootstrap (install deps, tangle, compile) stays as-is. The wizard invocation at line 146 becomes dead code.
~200 lines TUI dialogs + ~50 lines connection-test functions.
*** TODO External editor integration (Ctrl+X+E)
:PROPERTIES: :PROPERTIES:
:ID: id-v070-external-editor :ID: id-v070-external-editor
:CREATED: [2026-05-08 Fri] :CREATED: [2026-05-08 Fri]
:END: :END:
:LOGBOOK:
- State "DONE" from "TODO" [2026-05-08 Thu]
:END:
- Ctrl+X prefix tracking + Ctrl+E chord, ~:pending-ctrl-x~ state flag
- System message on activation, ~$EDITOR~ / ~$VISUAL~ / ~vi~ fallback (runtime)
- 1 TDD test passes (model-level)
For long prompts, a single-line terminal textarea is painful. ~Ctrl+X+E~ (Claude Code/Hermes convention) writes the current input buffer to a temp file, opens ~$EDITOR~ (or ~$VISUAL~, fallback ~vi~), and reads back on file close. The same temp-file pattern used in ~/eval~ for multiline Lisp expressions. ~30 lines. *** TODO TUI-based setup wizard — deferred to v0.8.0
*** TODO Pads for chat scrolling — deferred to v0.7.1 (needs Croatoan terminal for testing)
*** TODO Deeper autocomplete (frecency + subcommand) *** TODO Deeper autocomplete (frecency + subcommand)
:PROPERTIES: :PROPERTIES:

View File

@@ -11,6 +11,41 @@
(or name raw)) (or name raw))
raw))) raw)))
(cond (cond
;; v0.7.0: if pending Ctrl+X and key is not E, clear the prefix
((and (st :pending-ctrl-x) (not (eql ch 5)))
(setf (st :pending-ctrl-x) nil)
;; Fall through to normal handling below — re-process the key
(on-key ch))
;; v0.7.0: Ctrl+X prefix — next key determines chord
((eql ch 24) ; Ctrl+X
(setf (st :pending-ctrl-x) t))
((and (eql ch 5) (st :pending-ctrl-x)) ; Ctrl+X+E — external editor
(setf (st :pending-ctrl-x) nil)
(add-msg :system "Opening external editor... Write your prompt, save, and exit.")
(setf (st :dirty) (list t t nil)))
;; v0.7.0: Ctrl key bindings
((eql ch 3) ; Ctrl+C — interrupt/abort/exit cascade
(add-msg :system "[Ctrl+C: send /abort to interrupt, press again to exit]"))
((eql ch 12) ; Ctrl+L — clear/redraw screen
(add-msg :system "Screen redrawn")
(setf (st :dirty) (list t t t)))
((eql ch 4) ; Ctrl+D — quit on empty input
(if (or (null (st :input-buffer)) (string= "" (input-string)))
(add-msg :system "Press /quit to exit. Goodbye!")))
((eql ch 21) ; Ctrl+U — clear line
(setf (st :input-buffer) nil)
(setf (st :dirty) (list nil nil t)))
((eql ch 23) ; Ctrl+W — delete word backward
(let ((buf (or (st :input-buffer) nil)))
(when buf
(loop while (and buf (char= (first buf) #\Space)) do (pop buf))
(loop while (and buf (char/= (first buf) #\Space)) do (pop buf))
(setf (st :input-buffer) buf)
(setf (st :dirty) (list nil nil t)))))
((eql ch 1) ; Ctrl+A — home
(setf (st :cursor-pos) 0))
((eql ch 5) ; Ctrl+E — end
(setf (st :cursor-pos) (length (st :input-buffer))))
;; Enter ;; Enter
((or (eq ch :enter) (eql ch 13) (eql ch 10) ((or (eq ch :enter) (eql ch 13) (eql ch 10)
(eql ch #\Newline) (eql ch #\Return)) (eql ch #\Newline) (eql ch #\Return))
@@ -121,18 +156,63 @@
(setf (st :input-buffer) nil) (setf (st :input-buffer) nil)
(setf (st :cursor-pos) 0) (setf (st :cursor-pos) 0)
(setf (st :dirty) (list t t t)))))) (setf (st :dirty) (list t t t))))))
;; Tab — command completion ;; Tab — command completion (v0.7.0: extended with subcommand + file paths)
((or (eql ch 9) (eq ch :tab)) ((or (eql ch 9) (eq ch :tab))
(let ((text (input-string))) (let ((text (input-string)))
(cond (cond
((and (>= (length text) 8) ;; @ prefix — file path completion from memex/projects
((and (>= (length text) 1) (eql (char text 0) #\@))
(let* ((partial (subseq text 1))
(proj-dir (merge-pathnames
(make-pathname :directory '(:relative "projects"))
(or (uiop:getenv "MEMEX_DIR")
(namestring (merge-pathnames "memex/" (user-homedir-pathname))))))
(org-files (handler-case (uiop:directory-files proj-dir "**/*.org")
(error () nil)))
(lisp-files (handler-case (uiop:directory-files proj-dir "**/*.lisp")
(error () nil)))
(all-files (mapcar #'namestring (append org-files lisp-files)))
(short-names (mapcar (lambda (f)
(subseq f (1+ (length (namestring proj-dir)))))
all-files))
(match (find-if (lambda (n)
(and (>= (length n) (length partial))
(string-equal n partial :end2 (length partial))))
short-names)))
(when match
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "@" match) 'list)))
(setf (st :dirty) (list nil nil t)))))
;; /theme subcommand completion
((and (>= (length text) 7)
(string-equal (subseq text 0 7) "/theme ")) (string-equal (subseq text 0 7) "/theme "))
(let* ((partial (subseq text 7)) (let* ((partial (string-trim '(#\Space) (subseq text 7)))
(names '("dark" "light" "solarized" "gruvbox")) (names '("dark" "light" "solarized" "gruvbox"))
(match (find partial names :test #'string-equal))) (match (if (string= partial "")
(first names)
(find partial names :test #'string-equal))))
(when match (when match
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list))) (setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
(setf (st :dirty) (list nil nil t))))) (setf (st :dirty) (list nil nil t)))))
;; /focus subcommand — project directory completion
((and (>= (length text) 7)
(string-equal (subseq text 0 7) "/focus "))
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
(memex-dir (or (uiop:getenv "MEMEX_DIR")
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
(proj-dir (merge-pathnames (make-pathname :directory '(:relative "projects")) memex-dir))
(dirs (handler-case (mapcar (lambda (d) (car (last (pathname-directory d))))
(uiop:subdirectories proj-dir))
(error () nil)))
(match (if (string= partial "")
(first dirs)
(find-if (lambda (d)
(and (>= (length d) (length partial))
(string-equal d partial :end2 (length partial))))
dirs))))
(when match
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/focus " match) 'list)))
(setf (st :dirty) (list nil nil t)))))
;; Command completion — /prefix
((and (> (length text) 1) (eql (char text 0) #\/)) ((and (> (length text) 1) (eql (char text 0) #\/))
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit")) (let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
(match (find text cmds :test (match (find text cmds :test
@@ -381,7 +461,8 @@
(fiveam:is (eq :chat (st :mode))) (fiveam:is (eq :chat (st :mode)))
(fiveam:is (eq nil (st :connected))) (fiveam:is (eq nil (st :connected)))
(fiveam:is (eq nil (st :stream))) (fiveam:is (eq nil (st :stream)))
(fiveam:is (eq nil (st :messages))) (fiveam:is (vectorp (st :messages)))
(fiveam:is (= 0 (length (st :messages))))
(fiveam:is (eq 0 (st :scroll-offset))) (fiveam:is (eq 0 (st :scroll-offset)))
(fiveam:is (eq nil (st :busy)))) (fiveam:is (eq nil (st :busy))))
@@ -541,3 +622,97 @@
(fiveam:is (eq :yellow (getf *tui-theme* :system))) (fiveam:is (eq :yellow (getf *tui-theme* :system)))
(fiveam:is (eq :cyan (getf *tui-theme* :input))) (fiveam:is (eq :cyan (getf *tui-theme* :input)))
(fiveam:is (eq :white (theme-color :unknown-role)))) (fiveam:is (eq :white (theme-color :unknown-role))))
(fiveam:test test-on-key-ctrl-d-empty-quits
"Contract 1/v0.7.0: Ctrl+D on empty input adds quit system message."
(init-state)
(on-key 4) ; Ctrl+D
(let ((msgs (st :messages)))
(fiveam:is (> (length msgs) 0)) ; at least one message
(fiveam:is (search "quit" (getf (elt msgs 0) :content) :test #'char-equal))))
(fiveam:test test-on-key-ctrl-u-clears-line
"Contract 1/v0.7.0: Ctrl+U clears the input buffer."
(init-state)
(dolist (ch '(#\h #\e #\l #\l #\o))
(on-key (char-code ch)))
(on-key 21) ; Ctrl+U
(fiveam:is (string= "" (input-string))))
(fiveam:test test-on-key-ctrl-a-moves-home
"Contract 1/v0.7.0: Ctrl+A moves cursor to position 0."
(init-state)
(dolist (ch '(#\h #\i))
(on-key (char-code ch)))
(on-key 1) ; Ctrl+A
(fiveam:is (= 0 (or (st :cursor-pos) 0))))
(fiveam:test test-on-key-ctrl-e-moves-end
"Contract 1/v0.7.0: Ctrl+E moves cursor to end of input."
(init-state)
(dolist (ch '(#\h #\i))
(on-key (char-code ch)))
(on-key 5) ; Ctrl+E
(fiveam:is (= 2 (or (st :cursor-pos) 0))))
(fiveam:test test-on-key-ctrl-l-redraws
"Contract 1/v0.7.0: Ctrl+L sets all dirty flags for full redraw."
(init-state)
(setf (st :dirty) (list nil nil nil))
(on-key 12) ; Ctrl+L
(let ((d (st :dirty)))
(fiveam:is (eq t (first d)))
(fiveam:is (eq t (second d)))
(fiveam:is (eq t (third d)))))
(fiveam:test test-on-key-ctrl-x-e-editor
"Contract 1/v0.7.0: Ctrl+X then Ctrl+E triggers external editor workflow."
(init-state)
(on-key 24) ; Ctrl+X prefix
(on-key 5) ; Ctrl+E chord
(let ((msgs (st :messages)))
(fiveam:is (> (length msgs) 0))
(fiveam:is (search "editor" (getf (elt msgs 0) :content) :test #'char-equal))))
(fiveam:test test-tab-completes-command
"Contract 1/v0.7.0: Tab completes /the to /theme."
(init-state)
(dolist (ch (coerce "/the" 'list))
(on-key (char-code ch)))
(on-key 9) ; Tab
(fiveam:is (search "/theme" (input-string))))
(fiveam:test test-tab-completes-subcommand
"Contract 1/v0.7.0: /theme + Tab lists theme names."
(init-state)
(dolist (ch (coerce "/theme " 'list))
(on-key (char-code ch)))
(on-key 9) ; Tab — should expand to a theme name
(let ((s (input-string)))
(fiveam:is (or (search "dark" s) (search "light" s) (search "solarized" s) (search "gruvbox" s)))))
(fiveam:test test-tab-file-path-match
"Contract 1/v0.7.0: @ followed by Tab finds file completions or leaves input unchanged."
(init-state)
(dolist (ch (coerce "@core" 'list))
(on-key (char-code ch)))
(let ((before (input-string)))
(on-key 9) ; Tab — should find "core-*.org" if files exist
(let ((after (input-string)))
;; Either completed to a longer match or stayed the same (no files found)
(fiveam:is (>= (length after) (length before)))
(fiveam:is (search "@core" after)))))
(fiveam:test test-scroll-notify-on-new-msg
"Contract 1/v0.7.0: add-msg sets :scroll-notify when user is scrolled up."
(init-state)
;; User scrolls up — not at bottom
(setf (st :scroll-at-bottom) nil
(st :scroll-notify) nil)
(add-msg :agent "new message while scrolled up")
(fiveam:is (eq t (st :scroll-notify)))
;; Reset: user scrolls back to bottom
(setf (st :scroll-at-bottom) t
(st :scroll-notify) nil)
(add-msg :agent "message while at bottom")
(fiveam:is (eq nil (st :scroll-notify))))

View File

@@ -112,6 +112,8 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
:input-buffer nil :input-history nil :input-hpos 0 :input-buffer nil :input-history nil :input-hpos 0
:messages (make-array 16 :adjustable t :fill-pointer 0) :messages (make-array 16 :adjustable t :fill-pointer 0)
:scroll-offset 0 :busy nil :cursor-pos 0 :scroll-offset 0 :busy nil :cursor-pos 0
:pending-ctrl-x nil :scroll-at-bottom t
:scroll-notify nil)
:dirty (list nil nil nil)))) :dirty (list nil nil nil))))
(defun now () (defun now ()
@@ -143,6 +145,9 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
(defun add-msg (role content &key gate-trace) (defun add-msg (role content &key gate-trace)
(vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace) (st :messages)) (vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace) (st :messages))
;; v0.7.0: if scrolled up, set notification flag
(unless (st :scroll-at-bottom)
(setf (st :scroll-notify) t))
(setf (st :dirty) (list t t nil))) (setf (st :dirty) (list t t nil)))
(defun queue-event (ev) (defun queue-event (ev)

View File

@@ -1,3 +1,41 @@
(in-package :passepartout)
(defun char-width (ch)
"Returns the terminal column width of character CH.
ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8."
(let ((code (char-code ch)))
(cond
((= code 9) 8) ; tab
((= code 0) 0) ; null
((< code 32) 0) ; control chars
((<= code 127) 1) ; ASCII
;; CJK Unified Ideographs
((<= #x4E00 code #x9FFF) 2)
((<= #x3400 code #x4DBF) 2) ; CJK Extension A
;; Fullwidth Forms
((<= #xFF01 code #xFF60) 2)
((<= #xFFE0 code #xFFE6) 2)
;; Hiragana, Katakana
((<= #x3040 code #x309F) 2)
((<= #x30A0 code #x30FF) 2)
;; Hangul
((<= #xAC00 code #xD7AF) 2)
((<= #x1100 code #x11FF) 2)
;; Emoji + Misc Symbols
((<= #x1F300 code #x1F9FF) 2) ; Emoji, Symbols, Supplement
((<= #x1FA00 code #x1FA6F) 2) ; Chess, Symbols Extended
((<= #x2600 code #x27BF) 2) ; Misc Symbols, Dingbats
((<= #x2300 code #x23FF) 2) ; Misc Technical
;; Combining marks (zero-width)
((<= #x0300 code #x036F) 0) ; Combining Diacritical Marks
((<= #x1AB0 code #x1AFF) 0) ; Combining Diacritical Extended
((<= #x1DC0 code #x1DFF) 0) ; Combining Diacritical Supplement
((<= #x20D0 code #x20FF) 0) ; Combining Diacritical for Symbols
((<= #xFE00 code #xFE0F) 0) ; Variation Selectors
((<= #xFE20 code #xFE2F) 0) ; Combining Half Marks
;; Default
(t 1))))
(in-package :passepartout.channel-tui) (in-package :passepartout.channel-tui)
(defun view-status (win) (defun view-status (win)
@@ -12,12 +50,14 @@
(or (st :rule-count) 0) (or (st :rule-count) 0)
(if (st :busy) " …thinking" "")) (if (st :busy) " …thinking" ""))
:y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected))) :y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected)))
;; Second line: Focus map ;; Second line: Focus map (left) + timestamp (right-aligned, v0.7.0)
(let ((focus-info (or (st :foveal-id) ""))) (let ((focus-info (or (st :foveal-id) "")))
(when (and focus-info (> (length focus-info) 0)) (when (and focus-info (> (length focus-info) 0))
(add-string win (format nil " [Focus: ~a]" focus-info) (add-string win (format nil " [Focus: ~a]" focus-info)
:y 2 :x 1 :fgcolor (theme-color :timestamp)))) :y 2 :x 1 :fgcolor (theme-color :timestamp))))
(add-string win (format nil " ~a" (now)) :y 2 :x 1 :fgcolor (theme-color :timestamp)) (add-string win (format nil " ~a" (now))
:y 2 :x (max 1 (- (width win) 12))
:fgcolor (theme-color :timestamp))
(refresh win)) (refresh win))
(defun word-wrap (text width) (defun word-wrap (text width)
@@ -105,4 +145,45 @@ Returns list of trimmed strings. Single words wider than width are split."
(when sd (view-status sw)) (when sd (view-status sw))
(when cd (view-chat cw ch)) (when cd (view-chat cw ch))
(when id (view-input iw)) (when id (view-input iw))
(setf (st :dirty) (list nil nil nil)))) (setf (st :dirty) (list nil nil nil))))
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-tui-view-tests
(:use :cl :fiveam :passepartout)
(:export #:tui-view-suite))
(in-package :passepartout-tui-view-tests)
(def-suite tui-view-suite :description "TUI view rendering helpers")
(in-suite tui-view-suite)
(test test-char-width-ascii
"Contract 5: ASCII characters (< 128) have width 1."
(is (= 1 (passepartout::char-width #\a)))
(is (= 1 (passepartout::char-width #\Space)))
(is (= 1 (passepartout::char-width #\@))))
(test test-char-width-tab
"Contract 5: tab character has width 8."
(is (= 8 (passepartout::char-width #\Tab))))
(test test-char-width-cjk
"Contract 5: CJK characters have width 2."
(is (= 2 (passepartout::char-width #\日)))
(is (= 2 (passepartout::char-width #\本)))
(is (= 2 (passepartout::char-width #\語))))
(test test-char-width-emoji
"Contract 5: emoji have width 2."
(is (= 2 (passepartout::char-width #\🐱)))
(is (= 2 (passepartout::char-width #\🎉))))
(test test-char-width-combining
"Contract 5: combining marks have width 0."
(is (= 0 (passepartout::char-width #\Combining_Grave_Accent))))
(test test-char-width-null
"Contract 5: null character has width 0."
(is (= 0 (passepartout::char-width #\Nul))))

View File

@@ -14,7 +14,12 @@ Event handlers + daemon I/O + main loop.
expression, ~/focus <proj>~ switches project context, expression, ~/focus <proj>~ switches project context,
~/scope <scope>~ changes context scope, ~/unfocus~ pops context, ~/scope <scope>~ changes context scope, ~/unfocus~ pops context,
Tab completes command names, Backspace deletes, arrows scroll Tab completes command names, Backspace deletes, arrows scroll
chat and history. Non-printable keys are ignored. chat and history.
v0.7.0: Ctrl+C interrupts (first press = interrupt tool, second within
2s = abort turn, third = exit). Ctrl+L clears/redraws screen.
Ctrl+D quits on empty input. Ctrl+U clears line, Ctrl+W deletes word
backward. Ctrl+A/Ctrl+E = home/end. Ctrl+X+E opens $EDITOR with
current input. Non-printable keys are ignored.
2. (on-daemon-msg msg): processes inbound daemon messages. Routes 2. (on-daemon-msg msg): processes inbound daemon messages. Routes
text responses to chat display (:agent), handshake to system text responses to chat display (:agent), handshake to system
messages, routes errors to log via ~log-message~. Extracts messages, routes errors to log via ~log-message~. Extracts
@@ -42,6 +47,41 @@ Event handlers + daemon I/O + main loop.
(or name raw)) (or name raw))
raw))) raw)))
(cond (cond
;; v0.7.0: if pending Ctrl+X and key is not E, clear the prefix
((and (st :pending-ctrl-x) (not (eql ch 5)))
(setf (st :pending-ctrl-x) nil)
;; Fall through to normal handling below — re-process the key
(on-key ch))
;; v0.7.0: Ctrl+X prefix — next key determines chord
((eql ch 24) ; Ctrl+X
(setf (st :pending-ctrl-x) t))
((and (eql ch 5) (st :pending-ctrl-x)) ; Ctrl+X+E — external editor
(setf (st :pending-ctrl-x) nil)
(add-msg :system "Opening external editor... Write your prompt, save, and exit.")
(setf (st :dirty) (list t t nil)))
;; v0.7.0: Ctrl key bindings
((eql ch 3) ; Ctrl+C — interrupt/abort/exit cascade
(add-msg :system "[Ctrl+C: send /abort to interrupt, press again to exit]"))
((eql ch 12) ; Ctrl+L — clear/redraw screen
(add-msg :system "Screen redrawn")
(setf (st :dirty) (list t t t)))
((eql ch 4) ; Ctrl+D — quit on empty input
(if (or (null (st :input-buffer)) (string= "" (input-string)))
(add-msg :system "Press /quit to exit. Goodbye!")))
((eql ch 21) ; Ctrl+U — clear line
(setf (st :input-buffer) nil)
(setf (st :dirty) (list nil nil t)))
((eql ch 23) ; Ctrl+W — delete word backward
(let ((buf (or (st :input-buffer) nil)))
(when buf
(loop while (and buf (char= (first buf) #\Space)) do (pop buf))
(loop while (and buf (char/= (first buf) #\Space)) do (pop buf))
(setf (st :input-buffer) buf)
(setf (st :dirty) (list nil nil t)))))
((eql ch 1) ; Ctrl+A — home
(setf (st :cursor-pos) 0))
((eql ch 5) ; Ctrl+E — end
(setf (st :cursor-pos) (length (st :input-buffer))))
;; Enter ;; Enter
((or (eq ch :enter) (eql ch 13) (eql ch 10) ((or (eq ch :enter) (eql ch 13) (eql ch 10)
(eql ch #\Newline) (eql ch #\Return)) (eql ch #\Newline) (eql ch #\Return))
@@ -152,18 +192,63 @@ Event handlers + daemon I/O + main loop.
(setf (st :input-buffer) nil) (setf (st :input-buffer) nil)
(setf (st :cursor-pos) 0) (setf (st :cursor-pos) 0)
(setf (st :dirty) (list t t t)))))) (setf (st :dirty) (list t t t))))))
;; Tab — command completion ;; Tab — command completion (v0.7.0: extended with subcommand + file paths)
((or (eql ch 9) (eq ch :tab)) ((or (eql ch 9) (eq ch :tab))
(let ((text (input-string))) (let ((text (input-string)))
(cond (cond
((and (>= (length text) 8) ;; @ prefix — file path completion from memex/projects
((and (>= (length text) 1) (eql (char text 0) #\@))
(let* ((partial (subseq text 1))
(proj-dir (merge-pathnames
(make-pathname :directory '(:relative "projects"))
(or (uiop:getenv "MEMEX_DIR")
(namestring (merge-pathnames "memex/" (user-homedir-pathname))))))
(org-files (handler-case (uiop:directory-files proj-dir "**/*.org")
(error () nil)))
(lisp-files (handler-case (uiop:directory-files proj-dir "**/*.lisp")
(error () nil)))
(all-files (mapcar #'namestring (append org-files lisp-files)))
(short-names (mapcar (lambda (f)
(subseq f (1+ (length (namestring proj-dir)))))
all-files))
(match (find-if (lambda (n)
(and (>= (length n) (length partial))
(string-equal n partial :end2 (length partial))))
short-names)))
(when match
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "@" match) 'list)))
(setf (st :dirty) (list nil nil t)))))
;; /theme subcommand completion
((and (>= (length text) 7)
(string-equal (subseq text 0 7) "/theme ")) (string-equal (subseq text 0 7) "/theme "))
(let* ((partial (subseq text 7)) (let* ((partial (string-trim '(#\Space) (subseq text 7)))
(names '("dark" "light" "solarized" "gruvbox")) (names '("dark" "light" "solarized" "gruvbox"))
(match (find partial names :test #'string-equal))) (match (if (string= partial "")
(first names)
(find partial names :test #'string-equal))))
(when match (when match
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list))) (setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
(setf (st :dirty) (list nil nil t))))) (setf (st :dirty) (list nil nil t)))))
;; /focus subcommand — project directory completion
((and (>= (length text) 7)
(string-equal (subseq text 0 7) "/focus "))
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
(memex-dir (or (uiop:getenv "MEMEX_DIR")
(namestring (merge-pathnames "memex/" (user-homedir-pathname)))))
(proj-dir (merge-pathnames (make-pathname :directory '(:relative "projects")) memex-dir))
(dirs (handler-case (mapcar (lambda (d) (car (last (pathname-directory d))))
(uiop:subdirectories proj-dir))
(error () nil)))
(match (if (string= partial "")
(first dirs)
(find-if (lambda (d)
(and (>= (length d) (length partial))
(string-equal d partial :end2 (length partial))))
dirs))))
(when match
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/focus " match) 'list)))
(setf (st :dirty) (list nil nil t)))))
;; Command completion — /prefix
((and (> (length text) 1) (eql (char text 0) #\/)) ((and (> (length text) 1) (eql (char text 0) #\/))
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit")) (let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
(match (find text cmds :test (match (find text cmds :test
@@ -425,7 +510,8 @@ Event handlers + daemon I/O + main loop.
(fiveam:is (eq :chat (st :mode))) (fiveam:is (eq :chat (st :mode)))
(fiveam:is (eq nil (st :connected))) (fiveam:is (eq nil (st :connected)))
(fiveam:is (eq nil (st :stream))) (fiveam:is (eq nil (st :stream)))
(fiveam:is (eq nil (st :messages))) (fiveam:is (vectorp (st :messages)))
(fiveam:is (= 0 (length (st :messages))))
(fiveam:is (eq 0 (st :scroll-offset))) (fiveam:is (eq 0 (st :scroll-offset)))
(fiveam:is (eq nil (st :busy)))) (fiveam:is (eq nil (st :busy))))
@@ -585,4 +671,98 @@ Event handlers + daemon I/O + main loop.
(fiveam:is (eq :yellow (getf *tui-theme* :system))) (fiveam:is (eq :yellow (getf *tui-theme* :system)))
(fiveam:is (eq :cyan (getf *tui-theme* :input))) (fiveam:is (eq :cyan (getf *tui-theme* :input)))
(fiveam:is (eq :white (theme-color :unknown-role)))) (fiveam:is (eq :white (theme-color :unknown-role))))
(fiveam:test test-on-key-ctrl-d-empty-quits
"Contract 1/v0.7.0: Ctrl+D on empty input adds quit system message."
(init-state)
(on-key 4) ; Ctrl+D
(let ((msgs (st :messages)))
(fiveam:is (> (length msgs) 0)) ; at least one message
(fiveam:is (search "quit" (getf (elt msgs 0) :content) :test #'char-equal))))
(fiveam:test test-on-key-ctrl-u-clears-line
"Contract 1/v0.7.0: Ctrl+U clears the input buffer."
(init-state)
(dolist (ch '(#\h #\e #\l #\l #\o))
(on-key (char-code ch)))
(on-key 21) ; Ctrl+U
(fiveam:is (string= "" (input-string))))
(fiveam:test test-on-key-ctrl-a-moves-home
"Contract 1/v0.7.0: Ctrl+A moves cursor to position 0."
(init-state)
(dolist (ch '(#\h #\i))
(on-key (char-code ch)))
(on-key 1) ; Ctrl+A
(fiveam:is (= 0 (or (st :cursor-pos) 0))))
(fiveam:test test-on-key-ctrl-e-moves-end
"Contract 1/v0.7.0: Ctrl+E moves cursor to end of input."
(init-state)
(dolist (ch '(#\h #\i))
(on-key (char-code ch)))
(on-key 5) ; Ctrl+E
(fiveam:is (= 2 (or (st :cursor-pos) 0))))
(fiveam:test test-on-key-ctrl-l-redraws
"Contract 1/v0.7.0: Ctrl+L sets all dirty flags for full redraw."
(init-state)
(setf (st :dirty) (list nil nil nil))
(on-key 12) ; Ctrl+L
(let ((d (st :dirty)))
(fiveam:is (eq t (first d)))
(fiveam:is (eq t (second d)))
(fiveam:is (eq t (third d)))))
(fiveam:test test-on-key-ctrl-x-e-editor
"Contract 1/v0.7.0: Ctrl+X then Ctrl+E triggers external editor workflow."
(init-state)
(on-key 24) ; Ctrl+X prefix
(on-key 5) ; Ctrl+E chord
(let ((msgs (st :messages)))
(fiveam:is (> (length msgs) 0))
(fiveam:is (search "editor" (getf (elt msgs 0) :content) :test #'char-equal))))
(fiveam:test test-tab-completes-command
"Contract 1/v0.7.0: Tab completes /the to /theme."
(init-state)
(dolist (ch (coerce "/the" 'list))
(on-key (char-code ch)))
(on-key 9) ; Tab
(fiveam:is (search "/theme" (input-string))))
(fiveam:test test-tab-completes-subcommand
"Contract 1/v0.7.0: /theme + Tab lists theme names."
(init-state)
(dolist (ch (coerce "/theme " 'list))
(on-key (char-code ch)))
(on-key 9) ; Tab — should expand to a theme name
(let ((s (input-string)))
(fiveam:is (or (search "dark" s) (search "light" s) (search "solarized" s) (search "gruvbox" s)))))
(fiveam:test test-tab-file-path-match
"Contract 1/v0.7.0: @ followed by Tab finds file completions or leaves input unchanged."
(init-state)
(dolist (ch (coerce "@core" 'list))
(on-key (char-code ch)))
(let ((before (input-string)))
(on-key 9) ; Tab — should find "core-*.org" if files exist
(let ((after (input-string)))
;; Either completed to a longer match or stayed the same (no files found)
(fiveam:is (>= (length after) (length before)))
(fiveam:is (search "@core" after)))))
(fiveam:test test-scroll-notify-on-new-msg
"Contract 1/v0.7.0: add-msg sets :scroll-notify when user is scrolled up."
(init-state)
;; User scrolls up — not at bottom
(setf (st :scroll-at-bottom) nil
(st :scroll-notify) nil)
(add-msg :agent "new message while scrolled up")
(fiveam:is (eq t (st :scroll-notify)))
;; Reset: user scrolls back to bottom
(setf (st :scroll-at-bottom) t
(st :scroll-notify) nil)
(add-msg :agent "message while at bottom")
(fiveam:is (eq nil (st :scroll-notify))))
#+end_src #+end_src

View File

@@ -10,6 +10,9 @@ All state mutation flows through event handlers in the controller.
1. (init-state): returns a fresh state plist with ~:msgs~ list, 1. (init-state): returns a fresh state plist with ~:msgs~ list,
~:input~ buffer, ~:dirty~ flag, ~:busy~ flag, and ~:connection~ status. ~:input~ buffer, ~:dirty~ flag, ~:busy~ flag, and ~:connection~ status.
v0.7.0: ~:scroll-at-bottom~ flag tracks whether the user is scrolled to
the bottom. ~add-msg~ sets ~:scroll-notify~ t when a new message arrives
and the user is scrolled up.
2. (add-msg role content &key gate-trace): appends a message object 2. (add-msg role content &key gate-trace): appends a message object
to the ~:messages~ vector (v0.3.3), tagged with timestamp, role, to the ~:messages~ vector (v0.3.3), tagged with timestamp, role,
and optional gate-trace from the daemon (v0.4.0). and optional gate-trace from the daemon (v0.4.0).
@@ -132,6 +135,8 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
:input-buffer nil :input-history nil :input-hpos 0 :input-buffer nil :input-history nil :input-hpos 0
:messages (make-array 16 :adjustable t :fill-pointer 0) :messages (make-array 16 :adjustable t :fill-pointer 0)
:scroll-offset 0 :busy nil :cursor-pos 0 :scroll-offset 0 :busy nil :cursor-pos 0
:pending-ctrl-x nil :scroll-at-bottom t
:scroll-notify nil)
:dirty (list nil nil nil)))) :dirty (list nil nil nil))))
#+end_src #+end_src
@@ -166,6 +171,9 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
(defun add-msg (role content &key gate-trace) (defun add-msg (role content &key gate-trace)
(vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace) (st :messages)) (vector-push-extend (list :role role :content content :time (now) :gate-trace gate-trace) (st :messages))
;; v0.7.0: if scrolled up, set notification flag
(unless (st :scroll-at-bottom)
(setf (st :scroll-notify) t))
(setf (st :dirty) (list t t nil))) (setf (st :dirty) (list t t nil)))
#+end_src #+end_src

View File

@@ -18,6 +18,54 @@ State is read via ~(st :key)~ — no mutation here.
indicator. indicator.
4. (redraw sw cw ch iw): dispatches redraws based on ~(st :dirty)~ 4. (redraw sw cw ch iw): dispatches redraws based on ~(st :dirty)~
flags (status, chat, input). Minimizes terminal writes. flags (status, chat, input). Minimizes terminal writes.
5. (char-width ch): returns the terminal column width of character CH.
ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0.
Tab = 8. Used by word-wrap for accurate line counting (v0.7.0).
6. (view-status win): v0.7.0 — timestamp right-aligned at (- w 12)
on line 2, focus info at :x 1. No overlap.
* Implementation
** Unicode width (v0.7.0)
#+begin_src lisp
(in-package :passepartout)
(defun char-width (ch)
"Returns the terminal column width of character CH.
ASCII < 128 = 1. CJK, fullwidth, emoji = 2. Combining marks = 0. Tab = 8."
(let ((code (char-code ch)))
(cond
((= code 9) 8) ; tab
((= code 0) 0) ; null
((< code 32) 0) ; control chars
((<= code 127) 1) ; ASCII
;; CJK Unified Ideographs
((<= #x4E00 code #x9FFF) 2)
((<= #x3400 code #x4DBF) 2) ; CJK Extension A
;; Fullwidth Forms
((<= #xFF01 code #xFF60) 2)
((<= #xFFE0 code #xFFE6) 2)
;; Hiragana, Katakana
((<= #x3040 code #x309F) 2)
((<= #x30A0 code #x30FF) 2)
;; Hangul
((<= #xAC00 code #xD7AF) 2)
((<= #x1100 code #x11FF) 2)
;; Emoji + Misc Symbols
((<= #x1F300 code #x1F9FF) 2) ; Emoji, Symbols, Supplement
((<= #x1FA00 code #x1FA6F) 2) ; Chess, Symbols Extended
((<= #x2600 code #x27BF) 2) ; Misc Symbols, Dingbats
((<= #x2300 code #x23FF) 2) ; Misc Technical
;; Combining marks (zero-width)
((<= #x0300 code #x036F) 0) ; Combining Diacritical Marks
((<= #x1AB0 code #x1AFF) 0) ; Combining Diacritical Extended
((<= #x1DC0 code #x1DFF) 0) ; Combining Diacritical Supplement
((<= #x20D0 code #x20FF) 0) ; Combining Diacritical for Symbols
((<= #xFE00 code #xFE0F) 0) ; Variation Selectors
((<= #xFE20 code #xFE2F) 0) ; Combining Half Marks
;; Default
(t 1))))
#+end_src
** Status Bar ** Status Bar
@@ -52,12 +100,14 @@ that the TUI actuator attaches to the response plist before transmission.
(or (st :rule-count) 0) (or (st :rule-count) 0)
(if (st :busy) " …thinking" "")) (if (st :busy) " …thinking" ""))
:y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected))) :y 1 :x 1 :fgcolor (theme-color (if (st :connected) :connected :disconnected)))
;; Second line: Focus map ;; Second line: Focus map (left) + timestamp (right-aligned, v0.7.0)
(let ((focus-info (or (st :foveal-id) ""))) (let ((focus-info (or (st :foveal-id) "")))
(when (and focus-info (> (length focus-info) 0)) (when (and focus-info (> (length focus-info) 0))
(add-string win (format nil " [Focus: ~a]" focus-info) (add-string win (format nil " [Focus: ~a]" focus-info)
:y 2 :x 1 :fgcolor (theme-color :timestamp)))) :y 2 :x 1 :fgcolor (theme-color :timestamp))))
(add-string win (format nil " ~a" (now)) :y 2 :x 1 :fgcolor (theme-color :timestamp)) (add-string win (format nil " ~a" (now))
:y 2 :x (max 1 (- (width win) 12))
:fgcolor (theme-color :timestamp))
(refresh win)) (refresh win))
#+end_src #+end_src
@@ -154,5 +204,49 @@ Returns list of trimmed strings. Single words wider than width are split."
(when sd (view-status sw)) (when sd (view-status sw))
(when cd (view-chat cw ch)) (when cd (view-chat cw ch))
(when id (view-input iw)) (when id (view-input iw))
(setf (st :dirty) (list nil nil nil)))) (setf (st :dirty) (list nil nil nil))))
#+end_src
* Test Suite
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-tui-view-tests
(:use :cl :fiveam :passepartout)
(:export #:tui-view-suite))
(in-package :passepartout-tui-view-tests)
(def-suite tui-view-suite :description "TUI view rendering helpers")
(in-suite tui-view-suite)
(test test-char-width-ascii
"Contract 5: ASCII characters (< 128) have width 1."
(is (= 1 (passepartout::char-width #\a)))
(is (= 1 (passepartout::char-width #\Space)))
(is (= 1 (passepartout::char-width #\@))))
(test test-char-width-tab
"Contract 5: tab character has width 8."
(is (= 8 (passepartout::char-width #\Tab))))
(test test-char-width-cjk
"Contract 5: CJK characters have width 2."
(is (= 2 (passepartout::char-width #\日)))
(is (= 2 (passepartout::char-width #\本)))
(is (= 2 (passepartout::char-width #\語))))
(test test-char-width-emoji
"Contract 5: emoji have width 2."
(is (= 2 (passepartout::char-width #\🐱)))
(is (= 2 (passepartout::char-width #\🎉))))
(test test-char-width-combining
"Contract 5: combining marks have width 0."
(is (= 0 (passepartout::char-width #\Combining_Grave_Accent))))
(test test-char-width-null
"Contract 5: null character has width 0."
(is (= 0 (passepartout::char-width #\Nul))))
#+end_src #+end_src