Compare commits
3 Commits
72f032fd67
...
ce715b599c
| Author | SHA1 | Date | |
|---|---|---|---|
| ce715b599c | |||
| 55e0c962f4 | |||
| 66df5b493a |
106
docs/ROADMAP.org
106
docs/ROADMAP.org
@@ -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.
|
||||
|
||||
*** TODO Readline/Ctrl key bindings
|
||||
*** DONE Readline/Ctrl key bindings
|
||||
:PROPERTIES:
|
||||
:ID: id-v060-readline
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
: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.
|
||||
|
||||
- ~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
|
||||
*** DONE Unicode width awareness
|
||||
:PROPERTIES:
|
||||
:ID: id-v060-unicode
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
:END:
|
||||
|
||||
~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):
|
||||
|
||||
- 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]
|
||||
:LOGBOOK:
|
||||
- State "DONE" from "TODO" [2026-05-08 Thu]
|
||||
: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.
|
||||
|
||||
- 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
|
||||
*** DONE Scroll indicator + new-message notification
|
||||
:PROPERTIES:
|
||||
:ID: id-v060-scroll-indicator
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
: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:
|
||||
|
||||
- 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)
|
||||
*** DONE Fix status bar line 2 overlap
|
||||
:PROPERTIES:
|
||||
:ID: id-v060-status-bar-fix
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
: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.
|
||||
|
||||
*** TODO TUI-based setup wizard — replace stdin/stdout onboarding
|
||||
*** DONE Deeper autocomplete (frecency + subcommand)
|
||||
:PROPERTIES:
|
||||
:ID: id-v070-setup-wizard
|
||||
:ID: id-v070-autocomplete
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
: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~.
|
||||
|
||||
- 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)
|
||||
*** TODO External editor integration (Ctrl+X+E) — done, pending test
|
||||
:PROPERTIES:
|
||||
:ID: id-v070-external-editor
|
||||
:CREATED: [2026-05-08 Fri]
|
||||
: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)
|
||||
:PROPERTIES:
|
||||
|
||||
@@ -11,6 +11,41 @@
|
||||
(or name raw))
|
||||
raw)))
|
||||
(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
|
||||
((or (eq ch :enter) (eql ch 13) (eql ch 10)
|
||||
(eql ch #\Newline) (eql ch #\Return))
|
||||
@@ -121,18 +156,63 @@
|
||||
(setf (st :input-buffer) nil)
|
||||
(setf (st :cursor-pos) 0)
|
||||
(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))
|
||||
(let ((text (input-string)))
|
||||
(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 "))
|
||||
(let* ((partial (subseq text 7))
|
||||
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
||||
(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
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
|
||||
(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) #\/))
|
||||
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
|
||||
(match (find text cmds :test
|
||||
@@ -381,7 +461,8 @@
|
||||
(fiveam:is (eq :chat (st :mode)))
|
||||
(fiveam:is (eq nil (st :connected)))
|
||||
(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 nil (st :busy))))
|
||||
|
||||
@@ -541,3 +622,97 @@
|
||||
(fiveam:is (eq :yellow (getf *tui-theme* :system)))
|
||||
(fiveam:is (eq :cyan (getf *tui-theme* :input)))
|
||||
(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))))
|
||||
|
||||
@@ -112,6 +112,8 @@ See *tui-theme-presets* for named presets (dark, light, solarized, gruvbox).")
|
||||
:input-buffer nil :input-history nil :input-hpos 0
|
||||
:messages (make-array 16 :adjustable t :fill-pointer 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))))
|
||||
|
||||
(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)
|
||||
(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)))
|
||||
|
||||
(defun queue-event (ev)
|
||||
|
||||
@@ -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)
|
||||
|
||||
(defun view-status (win)
|
||||
@@ -12,12 +50,14 @@
|
||||
(or (st :rule-count) 0)
|
||||
(if (st :busy) " …thinking" ""))
|
||||
: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) "")))
|
||||
(when (and focus-info (> (length focus-info) 0))
|
||||
(add-string win (format nil " [Focus: ~a]" focus-info)
|
||||
: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))
|
||||
|
||||
(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 cd (view-chat cw ch))
|
||||
(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))))
|
||||
|
||||
@@ -14,7 +14,12 @@ Event handlers + daemon I/O + main loop.
|
||||
expression, ~/focus <proj>~ switches project context,
|
||||
~/scope <scope>~ changes context scope, ~/unfocus~ pops context,
|
||||
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
|
||||
text responses to chat display (:agent), handshake to system
|
||||
messages, routes errors to log via ~log-message~. Extracts
|
||||
@@ -42,6 +47,41 @@ Event handlers + daemon I/O + main loop.
|
||||
(or name raw))
|
||||
raw)))
|
||||
(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
|
||||
((or (eq ch :enter) (eql ch 13) (eql ch 10)
|
||||
(eql ch #\Newline) (eql ch #\Return))
|
||||
@@ -152,18 +192,63 @@ Event handlers + daemon I/O + main loop.
|
||||
(setf (st :input-buffer) nil)
|
||||
(setf (st :cursor-pos) 0)
|
||||
(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))
|
||||
(let ((text (input-string)))
|
||||
(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 "))
|
||||
(let* ((partial (subseq text 7))
|
||||
(let* ((partial (string-trim '(#\Space) (subseq text 7)))
|
||||
(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
|
||||
(setf (st :input-buffer) (reverse (coerce (concatenate 'string "/theme " match) 'list)))
|
||||
(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) #\/))
|
||||
(let* ((cmds '("/eval" "/focus" "/scope" "/unfocus" "/help" "/theme" "/reconnect" "/quit"))
|
||||
(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 nil (st :connected)))
|
||||
(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 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 :cyan (getf *tui-theme* :input)))
|
||||
(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
|
||||
|
||||
@@ -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,
|
||||
~: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
|
||||
to the ~:messages~ vector (v0.3.3), tagged with timestamp, role,
|
||||
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
|
||||
:messages (make-array 16 :adjustable t :fill-pointer 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))))
|
||||
#+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)
|
||||
(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)))
|
||||
#+end_src
|
||||
|
||||
|
||||
@@ -18,6 +18,54 @@ State is read via ~(st :key)~ — no mutation here.
|
||||
indicator.
|
||||
4. (redraw sw cw ch iw): dispatches redraws based on ~(st :dirty)~
|
||||
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
|
||||
|
||||
@@ -52,12 +100,14 @@ that the TUI actuator attaches to the response plist before transmission.
|
||||
(or (st :rule-count) 0)
|
||||
(if (st :busy) " …thinking" ""))
|
||||
: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) "")))
|
||||
(when (and focus-info (> (length focus-info) 0))
|
||||
(add-string win (format nil " [Focus: ~a]" focus-info)
|
||||
: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))
|
||||
#+end_src
|
||||
|
||||
@@ -154,5 +204,49 @@ Returns list of trimmed strings. Single words wider than width are split."
|
||||
(when sd (view-status sw))
|
||||
(when cd (view-chat cw ch))
|
||||
(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
|
||||
|
||||
Reference in New Issue
Block a user